From a2dbd7c7a92b56cdf8591e589cf08a5288f36711 Mon Sep 17 00:00:00 2001 From: Benoit Sigoure Date: Wed, 29 Jul 2026 18:40:13 +0000 Subject: [PATCH 1/5] webapp: add per-scaleset job queue view Jobs recorded by scale set listeners now carry the garm scale set ID (new WorkflowJob.ScaleSetFkID column, auto-migrated; exposed as scale_set_id on the Job API model). The update path in CreateOrUpdateJob backfills the field for jobs recorded before the upgrade as soon as their next scale set message arrives. The new /ui/queue page groups queued and running jobs per scale set (ordered by request time, with queue position and waiting duration) and matches webhook jobs to pools by labels. It live-updates via the existing 'job' websocket events. --- database/sql/jobs.go | 14 + database/sql/models.go | 5 + params/params.go | 3 + webapp/src/lib/api/generated-client.ts | 11 + webapp/src/lib/api/generated/api.ts | 6 + webapp/src/lib/components/Navigation.svelte | 5 + webapp/src/routes/queue/+page.svelte | 317 ++++++++++++++++++ .../src/routes/queue/page.integration.test.ts | 219 ++++++++++++ webapp/swagger.yaml | 113 +++++++ workers/scaleset/scaleset_helper.go | 1 + 10 files changed, 694 insertions(+) create mode 100644 webapp/src/routes/queue/+page.svelte create mode 100644 webapp/src/routes/queue/page.integration.test.ts diff --git a/database/sql/jobs.go b/database/sql/jobs.go index ac1709427..5bbe50320 100644 --- a/database/sql/jobs.go +++ b/database/sql/jobs.go @@ -72,6 +72,10 @@ func sqlWorkflowJobToParamsJob(job WorkflowJob) (params.Job, error) { jobParam.RunnerName = job.Instance.Name } + if job.ScaleSetFkID != nil { + jobParam.ScaleSetID = *job.ScaleSetFkID + } + return jobParam, nil } @@ -104,6 +108,11 @@ func (s *sqlDatabase) paramsJobToWorkflowJob(ctx context.Context, conn *gorm.DB, LockedBy: job.LockedBy, } + if job.ScaleSetID != 0 { + scaleSetID := job.ScaleSetID + workflofJob.ScaleSetFkID = &scaleSetID + } + if job.RunnerName != "" { instance, err := s.getInstance(s.ctx, conn, job.RunnerName) if err != nil { @@ -360,6 +369,11 @@ func (s *sqlDatabase) CreateOrUpdateJob(ctx context.Context, job params.Job) (pa if job.ForgeInstanceID != nil { workflowJob.ForgeInstanceID = job.ForgeInstanceID } + + if job.ScaleSetID != 0 { + scaleSetID := job.ScaleSetID + workflowJob.ScaleSetFkID = &scaleSetID + } if err := tx.Save(&workflowJob).Error; err != nil { return fmt.Errorf("error saving job: %w", err) } diff --git a/database/sql/models.go b/database/sql/models.go index 01463581b..98a2a4290 100644 --- a/database/sql/models.go +++ b/database/sql/models.go @@ -474,6 +474,11 @@ type WorkflowJob struct { // ScaleSetJobID is the job ID for a scaleset job. ScaleSetJobID string `gorm:"index:scaleset_job_id_idx"` + // ScaleSetFkID is the ID of the scale set that this job was assigned to, + // if the job came in through a scale set listener. + ScaleSetFkID *uint `gorm:"index"` + ScaleSet ScaleSet `gorm:"foreignKey:ScaleSetFkID"` + // RunID is the ID of the workflow run. A run may have multiple jobs. RunID int64 // Action is the specific activity that triggered the event. diff --git a/params/params.go b/params/params.go index 276d36cd3..409c4eda8 100644 --- a/params/params.go +++ b/params/params.go @@ -1474,6 +1474,9 @@ type Job struct { WorkflowJobID int64 `json:"workflow_job_id,omitempty"` // ScaleSetJobID is the job ID when generated for a scale set. ScaleSetJobID string `json:"scaleset_job_id,omitempty"` + // ScaleSetID is the garm ID of the scale set this job was assigned to, + // if it came in through a scale set listener. + ScaleSetID uint `json:"scale_set_id,omitempty"` // RunID is the ID of the workflow run. A run may have multiple jobs. RunID int64 `json:"run_id,omitempty"` // Action is the specific activity that triggered the event. diff --git a/webapp/src/lib/api/generated-client.ts b/webapp/src/lib/api/generated-client.ts index 3c050843e..b6e5dfef1 100644 --- a/webapp/src/lib/api/generated-client.ts +++ b/webapp/src/lib/api/generated-client.ts @@ -21,7 +21,9 @@ import { ProxiesApi, ObjectsApi, ToolsApi, + JobsApi, type GARMAgentRelease, + type Job, type Repository, type Organization, type Enterprise, @@ -152,6 +154,7 @@ export class GeneratedGarmApiClient { private proxiesApi: ProxiesApi; private objectsApi: ObjectsApi; private toolsApi: ToolsApi; + private jobsApi: JobsApi; constructor(baseUrl: string = '') { this.baseUrl = baseUrl || window.location.origin; @@ -188,6 +191,7 @@ export class GeneratedGarmApiClient { this.proxiesApi = new ProxiesApi(this.config); this.objectsApi = new ObjectsApi(this.config); this.toolsApi = new ToolsApi(this.config); + this.jobsApi = new JobsApi(this.config); } // Set authentication token @@ -222,6 +226,7 @@ export class GeneratedGarmApiClient { this.providersApi = new ProvidersApi(this.config); this.firstRunApi = new FirstRunApi(this.config); this.hooksApi = new HooksApi(this.config); + this.jobsApi = new JobsApi(this.config); } // Authentication @@ -644,6 +649,12 @@ export class GeneratedGarmApiClient { await this.scaleSetsApi.deleteScaleSet(id.toString()); } + // Jobs + async listJobs(): Promise { + const response = await this.jobsApi.listJobs(); + return response.data || []; + } + // Instances async listInstances(): Promise { const response = await this.instancesApi.listInstances(); diff --git a/webapp/src/lib/api/generated/api.ts b/webapp/src/lib/api/generated/api.ts index 375b5fe2e..e4ecd9cdd 100644 --- a/webapp/src/lib/api/generated/api.ts +++ b/webapp/src/lib/api/generated/api.ts @@ -2349,6 +2349,12 @@ export interface Job { * @memberof Job */ 'runner_name'?: string; + /** + * ScaleSetID is the garm ID of the scale set this job was assigned to, if it came in through a scale set listener. + * @type {number} + * @memberof Job + */ + 'scale_set_id'?: number; /** * ScaleSetJobID is the job ID when generated for a scale set. * @type {string} diff --git a/webapp/src/lib/components/Navigation.svelte b/webapp/src/lib/components/Navigation.svelte index 61cd5496c..ac27400b6 100644 --- a/webapp/src/lib/components/Navigation.svelte +++ b/webapp/src/lib/components/Navigation.svelte @@ -77,6 +77,11 @@ label: 'Scale Sets', icon: 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4' // Database layers icon }, + { + href: resolve('/queue'), + label: 'Job Queue', + icon: 'M4 6h16M4 10h16M4 14h10M4 18h6' // Queue/list icon + }, { href: resolve('/instances'), label: 'Runners', diff --git a/webapp/src/routes/queue/+page.svelte b/webapp/src/routes/queue/+page.svelte new file mode 100644 index 000000000..7ef96980b --- /dev/null +++ b/webapp/src/routes/queue/+page.svelte @@ -0,0 +1,317 @@ + + + + Job Queue - GARM + + +
+ + + {#if error} +
+
+
+

Error

+
{error}
+ +
+
+
+ {/if} + +
+ + +
+ + {#if loading} +
+
+
+ {:else if queueGroups.length === 0} +
+

No queued or running jobs.

+
+ {:else} + {#each queueGroups as group (group.key)} +
+
+
+

+ {group.title} +

+

{group.subtitle}

+
+
+ + {group.queued.length} queued + + + {group.inProgress.length} running{group.maxRunners ? ` / ${group.maxRunners} max` : ''} + +
+
+ + {#if group.queued.length === 0} +
+ Queue is empty. +
+ {:else} +
+ + + + + + + + + + + + {#each group.queued as job, idx (job.id)} + + + + + + + + {/each} + +
#JobRepositoryLabelsWaiting
{idx + 1} + {#if job.workflow_run_url} + {job.name || '(unnamed job)'} + {:else} + {job.name || '(unnamed job)'} + {/if} + + {job.repository_owner}/{job.repository_name} + +
+ {#each job.labels || [] as label} + {label} + {/each} +
+
+ {waitingFor(job)} +
+
+ {/if} +
+ {/each} + {/if} +
diff --git a/webapp/src/routes/queue/page.integration.test.ts b/webapp/src/routes/queue/page.integration.test.ts new file mode 100644 index 000000000..c8704d4a1 --- /dev/null +++ b/webapp/src/routes/queue/page.integration.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/svelte'; + +// Unmock real components that setup.ts might mock +vi.unmock('$lib/components/PageHeader.svelte'); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +vi.mock('$app/stores', () => ({})); + +vi.mock('$lib/api/client.js', () => ({ + garmApi: { + listJobs: vi.fn() + } +})); + +vi.mock('$lib/stores/eager-cache.js', () => ({ + eagerCache: { + subscribe: vi.fn() + }, + eagerCacheManager: { + getScaleSets: vi.fn(), + getPools: vi.fn(), + retryResource: vi.fn() + } +})); + +vi.mock('$lib/stores/toast.js', () => ({ + toastStore: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + add: vi.fn() + } +})); + +vi.mock('$lib/stores/websocket.js', () => ({ + websocketStore: { + subscribeToEntity: vi.fn(() => vi.fn()) + } +})); + +import QueuePage from './+page.svelte'; +import { garmApi } from '$lib/api/client.js'; + +const mockScaleSets = [ + { + id: 5, + name: 'cloudstack-ubuntu24-micro', + org_id: 'org-uuid', + org_name: 'nexthop', + max_runners: 100, + min_idle_runners: 32, + enabled: true + }, + { + id: 7, + name: 'cloudstack-ubuntu24-large', + org_id: 'org-uuid', + org_name: 'nexthop', + max_runners: 10, + enabled: true + } +]; + +const mockPools = [ + { + id: 'aaaabbbb-cccc-dddd-eeee-ffff00001111', + image: 'ubuntu:22.04', + flavor: 'default', + org_id: 'org-uuid', + org_name: 'nexthop', + max_runners: 4, + tags: [{ id: 't1', name: 'self-hosted' }, { id: 't2', name: 'pool-label' }] + } +]; + +const mockJobs = [ + // Two queued scale set jobs (ordering by created_at: job 2 queued first) + { + id: 1, + scaleset_job_id: 'ss-job-1', + scale_set_id: 5, + name: 'build', + status: 'queued', + repository_owner: 'nexthop', + repository_name: 'repo-a', + labels: ['cloudstack-ubuntu24-micro'], + created_at: '2026-07-29T00:10:00Z' + }, + { + id: 2, + scaleset_job_id: 'ss-job-2', + scale_set_id: 5, + name: 'test', + status: 'queued', + repository_owner: 'nexthop', + repository_name: 'repo-b', + labels: ['cloudstack-ubuntu24-micro'], + created_at: '2026-07-29T00:05:00Z' + }, + // A running scale set job + { + id: 3, + scaleset_job_id: 'ss-job-3', + scale_set_id: 5, + name: 'lint', + status: 'in_progress', + repository_owner: 'nexthop', + repository_name: 'repo-a', + labels: ['cloudstack-ubuntu24-micro'], + created_at: '2026-07-29T00:01:00Z' + }, + // A queued webhook job that label-matches the pool + { + id: 4, + workflow_job_id: 1234, + name: 'pool-job', + status: 'queued', + repository_owner: 'nexthop', + repository_name: 'repo-c', + labels: ['self-hosted', 'pool-label'], + created_at: '2026-07-29T00:02:00Z' + }, + // A completed job that must not show up + { + id: 5, + scaleset_job_id: 'ss-job-5', + scale_set_id: 7, + name: 'done-job', + status: 'completed', + repository_owner: 'nexthop', + repository_name: 'repo-a', + labels: ['cloudstack-ubuntu24-large'], + created_at: '2026-07-29T00:00:00Z' + }, + // A queued job matching nothing + { + id: 6, + workflow_job_id: 5678, + name: 'orphan-job', + status: 'queued', + repository_owner: 'nexthop', + repository_name: 'repo-d', + labels: ['no-such-label'], + created_at: '2026-07-29T00:03:00Z' + } +]; + +describe('Queue Page - Integration Tests', () => { + beforeEach(async () => { + vi.clearAllMocks(); + + const cacheModule = await import('$lib/stores/eager-cache.js'); + vi.mocked(cacheModule.eagerCacheManager.getScaleSets).mockResolvedValue(mockScaleSets); + vi.mocked(cacheModule.eagerCacheManager.getPools).mockResolvedValue(mockPools); + vi.mocked(garmApi.listJobs).mockResolvedValue(mockJobs as any); + }); + + it('renders page title', async () => { + render(QueuePage); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Job Queue' })).toBeInTheDocument(); + }); + }); + + it('groups queued jobs per scale set with queue ordering and counts', async () => { + render(QueuePage); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'cloudstack-ubuntu24-micro' })).toBeInTheDocument(); + }); + + // Scale set group shows queued and running counts (incl. max runners) + expect(screen.getByText('2 queued')).toBeInTheDocument(); + expect(screen.getByText('1 running / 100 max')).toBeInTheDocument(); + + // Queue order: 'test' (older) before 'build' + const rows = screen.getAllByRole('row'); + const rowText = rows.map((r) => r.textContent || ''); + const testIdx = rowText.findIndex((t) => t.includes('test')); + const buildIdx = rowText.findIndex((t) => t.includes('build')); + expect(testIdx).toBeGreaterThan(0); + expect(buildIdx).toBeGreaterThan(testIdx); + + // Completed jobs are not shown + expect(screen.queryByText('done-job')).not.toBeInTheDocument(); + }); + + it('matches webhook jobs to pools by labels', async () => { + render(QueuePage); + + await waitFor(() => { + expect(screen.getByText('pool-job')).toBeInTheDocument(); + }); + expect(screen.getByText(/Pool aaaabbbb/)).toBeInTheDocument(); + }); + + it('shows unattributed jobs in their own group', async () => { + render(QueuePage); + + await waitFor(() => { + expect(screen.getByText('Unattributed jobs')).toBeInTheDocument(); + }); + expect(screen.getByText('orphan-job')).toBeInTheDocument(); + }); + + it('shows empty state when there are no active jobs', async () => { + vi.mocked(garmApi.listJobs).mockResolvedValue([]); + render(QueuePage); + + await waitFor(() => { + expect(screen.getByText('No queued or running jobs.')).toBeInTheDocument(); + }); + }); +}); diff --git a/webapp/swagger.yaml b/webapp/swagger.yaml index 5e1746ddb..3679f4c7e 100644 --- a/webapp/swagger.yaml +++ b/webapp/swagger.yaml @@ -1547,6 +1547,13 @@ definitions: runner_name: type: string x-go-name: RunnerName + scale_set_id: + description: |- + ScaleSetID is the garm ID of the scale set this job was assigned to, + if it came in through a scale set listener. + format: uint64 + type: integer + x-go-name: ScaleSetID scaleset_job_id: description: ScaleSetJobID is the job ID when generated for a scale set. type: string @@ -2647,6 +2654,52 @@ paths: summary: Logs in a user and returns a JWT token. tags: - login + /auth/oidc/callback: + get: + operationId: OIDCCallback + responses: + "200": + description: JWTResponse + schema: + $ref: '#/definitions/JWTResponse' + "400": + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + "401": + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: Handles the OIDC callback from the identity provider. + tags: + - oidc + /auth/oidc/login: + get: + operationId: OIDCLogin + responses: + "302": + description: Redirect to OIDC provider + "400": + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + "501": + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: Initiates OIDC login flow by redirecting to the identity provider. + tags: + - oidc + /auth/oidc/status: + get: + description: This endpoint is public and does not require authentication. + operationId: OIDCStatus + responses: + "200": + $ref: '#/responses/OIDCStatusResponse' + summary: Returns the OIDC configuration status (enabled/disabled). + tags: + - oidc /controller: put: operationId: UpdateController @@ -4103,6 +4156,53 @@ paths: summary: Update organization with the parameters given. tags: - organizations + /organizations/{orgID}/github-scalesets: + get: + description: |- + This lists scalesets directly from GitHub, not from GARM's database. + Useful for finding orphaned scalesets. + operationId: ListOrgGithubScaleSets + parameters: + - description: Organization ID. + in: path + name: orgID + required: true + type: string + responses: + "200": + $ref: '#/responses/RunnerScaleSetsResponse' + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: List all scalesets from GitHub for an organization. + tags: + - organizations + /organizations/{orgID}/github-scalesets/{scalesetID}: + delete: + description: This is useful for cleaning up orphaned scalesets. + operationId: DeleteOrgGithubScaleSet + parameters: + - description: Organization ID. + in: path + name: orgID + required: true + type: string + - description: GitHub scaleset ID (not GARM ID). + in: path + name: scalesetID + required: true + type: integer + responses: + "204": + $ref: '#/responses/empty' + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: Delete a scaleset directly from GitHub. + tags: + - organizations /organizations/{orgID}/instances: get: operationId: ListOrgInstances @@ -5320,6 +5420,19 @@ paths: summary: List the garm-agent releases available at the controller's releases URL, tags: - tools + /users: + get: + operationId: ListUsers + responses: + "200": + $ref: '#/responses/Users' + default: + description: APIErrorResponse + schema: + $ref: '#/definitions/APIErrorResponse' + summary: List all users. + tags: + - users produces: - application/json security: diff --git a/workers/scaleset/scaleset_helper.go b/workers/scaleset/scaleset_helper.go index 6754cdaa1..4163aa887 100644 --- a/workers/scaleset/scaleset_helper.go +++ b/workers/scaleset/scaleset_helper.go @@ -75,6 +75,7 @@ func (w *Worker) recordOrUpdateJob(job params.ScaleSetJobMessage) error { baseURL := strings.TrimRight(w.entity.Credentials.BaseURL, "/") jobParams := job.ToJob() jobParams.RunnerGroupName = w.scaleSet.GitHubRunnerGroup + jobParams.ScaleSetID = w.scaleSet.ID switch entity.EntityType { case params.ForgeEntityTypeEnterprise: From 90e5a4c3e0a36c3662d788bcedce74aee2a8e817 Mon Sep 17 00:00:00 2001 From: Benoit Sigoure Date: Wed, 29 Jul 2026 18:51:57 +0000 Subject: [PATCH 2/5] webapp: link queued jobs to their GitHub run/job page Webhook jobs carry the numeric GitHub job ID and deep-link to .../actions/runs//job/; scale set messages only carry a GUID, so those link to the workflow run page. Falls back to constructing the run URL from run_id when workflow_run_url is unset. Co-Authored-By: Claude Fable 5 --- webapp/src/routes/queue/+page.svelte | 19 ++++++- .../src/routes/queue/page.integration.test.ts | 52 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/webapp/src/routes/queue/+page.svelte b/webapp/src/routes/queue/+page.svelte index 7ef96980b..77d53beaf 100644 --- a/webapp/src/routes/queue/+page.svelte +++ b/webapp/src/routes/queue/+page.svelte @@ -122,6 +122,21 @@ return groups; } + // Link to the job on GitHub. Webhook jobs carry the numeric GitHub job ID, + // allowing a direct /job/ link; scale set messages only carry a GUID, + // so those link to the workflow run page. + function jobUrl(job: Job): string { + let runUrl = job.workflow_run_url; + if (!runUrl && job.run_id && job.repository_owner && job.repository_name) { + runUrl = `https://github.com/${job.repository_owner}/${job.repository_name}/actions/runs/${job.run_id}`; + } + if (!runUrl) return ''; + if (job.workflow_job_id) { + return `${runUrl}/job/${job.workflow_job_id}`; + } + return runUrl; + } + function waitingFor(job: Job): string { if (!job.created_at) return '-'; const seconds = Math.max(0, Math.floor((currentTime - new Date(job.created_at).getTime()) / 1000)); @@ -281,9 +296,9 @@ {idx + 1} - {#if job.workflow_run_url} + {#if jobUrl(job)} {job.name || '(unnamed job)'} { }); }); }); + +describe('Queue Page - job links', () => { + beforeEach(async () => { + vi.clearAllMocks(); + const cacheModule = await import('$lib/stores/eager-cache.js'); + vi.mocked(cacheModule.eagerCacheManager.getScaleSets).mockResolvedValue(mockScaleSets); + vi.mocked(cacheModule.eagerCacheManager.getPools).mockResolvedValue(mockPools); + }); + + it('deep-links webhook jobs to the GitHub job page and scale set jobs to the run page', async () => { + vi.mocked(garmApi.listJobs).mockResolvedValue([ + { + id: 10, + workflow_job_id: 90673415740, + run_id: 30480594764, + name: 'pool-job', + status: 'queued', + repository_owner: 'nexthop-ai', + repository_name: 'nhsystem-ops', + labels: ['self-hosted', 'pool-label'], + created_at: '2026-07-29T00:02:00Z' + }, + { + id: 11, + scaleset_job_id: 'guid-1', + scale_set_id: 5, + run_id: 1234, + workflow_run_url: 'https://github.com/nexthop-ai/repo-a/actions/runs/1234', + name: 'ss-job', + status: 'queued', + repository_owner: 'nexthop-ai', + repository_name: 'repo-a', + labels: [], + created_at: '2026-07-29T00:02:00Z' + } + ] as any); + + render(QueuePage); + + await waitFor(() => { + expect(screen.getByText('pool-job')).toBeInTheDocument(); + }); + expect(screen.getByText('pool-job').closest('a')).toHaveAttribute( + 'href', + 'https://github.com/nexthop-ai/nhsystem-ops/actions/runs/30480594764/job/90673415740' + ); + expect(screen.getByText('ss-job').closest('a')).toHaveAttribute( + 'href', + 'https://github.com/nexthop-ai/repo-a/actions/runs/1234' + ); + }); +}); From a884b210acd038782f6cdf43fa0c065082b5b36d Mon Sep 17 00:00:00 2001 From: Benoit Sigoure Date: Wed, 29 Jul 2026 20:22:30 +0000 Subject: [PATCH 3/5] webapp: queue page runner counts from instances, link groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "running" figure previously counted in_progress jobs attributed to the scale set, which undercounts (jobs started before scale_set_id tracking never get backfilled until completion). Count runner instances instead, matching the garm_scaleset_runner_count metric, broken down into busy/idle. Also surface GitHub's TotalAssignedJobs (persisted as desired_runner_count) when it exceeds the jobs GARM knows about: while a scale set is saturated, GitHub queues jobs on its side and only delivers individual job messages on assignment, so GARM cannot list them โ€” the badge makes that hidden backlog visible. Group titles now link to the scale set / pool detail page. --- webapp/src/routes/queue/+page.svelte | 86 ++++++++++++++++--- .../src/routes/queue/page.integration.test.ts | 23 ++++- 2 files changed, 96 insertions(+), 13 deletions(-) diff --git a/webapp/src/routes/queue/+page.svelte b/webapp/src/routes/queue/+page.svelte index 77d53beaf..6418cbd90 100644 --- a/webapp/src/routes/queue/+page.svelte +++ b/webapp/src/routes/queue/+page.svelte @@ -1,7 +1,8 @@