From 58ab4d91c2d6dde81f1ed8e374c342a9e88ab5f2 Mon Sep 17 00:00:00 2001 From: Duke <40759437+dukesteen@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:41:16 +0200 Subject: [PATCH 1/3] Show per-worker state on dashboard servers page Closes #186 --- .../src/components/ServerCard.vue | 54 +++++++++++++++++++ .../DashboardClient/src/styles.css | 46 ++++++++++++++++ .../DashboardClient/src/views/ServersView.vue | 23 +------- .../DashboardClient/tests/components.test.ts | 43 ++++++++++++++- 4 files changed, 144 insertions(+), 22 deletions(-) create mode 100644 src/Immediate.Jobs.Dashboard/DashboardClient/src/components/ServerCard.vue diff --git a/src/Immediate.Jobs.Dashboard/DashboardClient/src/components/ServerCard.vue b/src/Immediate.Jobs.Dashboard/DashboardClient/src/components/ServerCard.vue new file mode 100644 index 00000000..897c60ea --- /dev/null +++ b/src/Immediate.Jobs.Dashboard/DashboardClient/src/components/ServerCard.vue @@ -0,0 +1,54 @@ + + + diff --git a/src/Immediate.Jobs.Dashboard/DashboardClient/src/styles.css b/src/Immediate.Jobs.Dashboard/DashboardClient/src/styles.css index 054924ec..5429923c 100644 --- a/src/Immediate.Jobs.Dashboard/DashboardClient/src/styles.css +++ b/src/Immediate.Jobs.Dashboard/DashboardClient/src/styles.css @@ -1452,6 +1452,52 @@ tbody tr:hover { color: var(--text-subtle); } +.server-workers { + display: grid; + gap: 4px; + margin: 12px 0 0; + padding: 0; + list-style: none; +} + +.server-workers li { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + border-radius: 7px; + background: var(--panel-muted); + padding: 6px 10px; + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.server-workers li:not(.busy) { + grid-template-columns: auto minmax(0, 1fr); +} + +.server-worker-id { + color: var(--text-subtle); + font-weight: 650; +} + +.server-worker-job { + min-width: 0; + overflow: hidden; + color: var(--accent); + text-overflow: ellipsis; + white-space: nowrap; +} + +.server-worker-job:hover { + text-decoration: underline; +} + +.server-worker-meta { + color: var(--text-subtle); + white-space: nowrap; +} + .spin { animation: spin 850ms linear infinite; } diff --git a/src/Immediate.Jobs.Dashboard/DashboardClient/src/views/ServersView.vue b/src/Immediate.Jobs.Dashboard/DashboardClient/src/views/ServersView.vue index b0e720d4..db0ed748 100644 --- a/src/Immediate.Jobs.Dashboard/DashboardClient/src/views/ServersView.vue +++ b/src/Immediate.Jobs.Dashboard/DashboardClient/src/views/ServersView.vue @@ -1,10 +1,9 @@ diff --git a/src/Immediate.Jobs.Dashboard/DashboardClient/src/styles.css b/src/Immediate.Jobs.Dashboard/DashboardClient/src/styles.css index 5429923c..f90552ba 100644 --- a/src/Immediate.Jobs.Dashboard/DashboardClient/src/styles.css +++ b/src/Immediate.Jobs.Dashboard/DashboardClient/src/styles.css @@ -1452,10 +1452,38 @@ tbody tr:hover { color: var(--text-subtle); } +.server-slots { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(12px, 1fr)); + gap: 3px; + margin: 12px 0 0; + padding: 0; + list-style: none; +} + +.server-slot { + display: block; + aspect-ratio: 1; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--panel-muted); +} + +.server-slot.busy { + border-color: var(--accent); + background: var(--accent); +} + +.server-slot.busy:hover { + opacity: 0.75; +} + .server-workers { display: grid; gap: 4px; - margin: 12px 0 0; + max-height: 232px; + margin: 10px 0 0; + overflow-y: auto; padding: 0; list-style: none; } @@ -1472,10 +1500,6 @@ tbody tr:hover { font-variant-numeric: tabular-nums; } -.server-workers li:not(.busy) { - grid-template-columns: auto minmax(0, 1fr); -} - .server-worker-id { color: var(--text-subtle); font-weight: 650; @@ -1498,6 +1522,12 @@ tbody tr:hover { white-space: nowrap; } +.server-workers-idle { + margin: 10px 0 0; + color: var(--text-subtle); + font-size: 11px; +} + .spin { animation: spin 850ms linear infinite; } diff --git a/src/Immediate.Jobs.Dashboard/DashboardClient/tests/components.test.ts b/src/Immediate.Jobs.Dashboard/DashboardClient/tests/components.test.ts index c2a2bed9..28ae62fc 100644 --- a/src/Immediate.Jobs.Dashboard/DashboardClient/tests/components.test.ts +++ b/src/Immediate.Jobs.Dashboard/DashboardClient/tests/components.test.ts @@ -322,7 +322,7 @@ describe('dashboard components', () => { expect(wrapper.emitted('select')?.[0]).toEqual([completedJob]); }); - it('renders each server worker with its current job or idle state', () => { + it('renders a slot per worker and lists only busy workers', () => { const idleLoop = { isRunning: false, lastAttemptedAt: null, @@ -333,31 +333,47 @@ describe('dashboard components', () => { itemsSucceeded: 0, itemsFailed: 0, }; + const workers = Array.from({ length: 32 }, (_, workerId) => ({ workerId, jobHandle: null, attempt: null, startedAt: null })) + .reverse(); const server: JobServerSnapshot = { workerId: 'node-1', lastHeartbeat: '2026-07-21T12:00:00Z', - activeWorkers: 1, - maxWorkers: 2, + activeWorkers: 2, + maxWorkers: 32, serverTimeout: '00:02:00', - workers: [ - { workerId: 1, jobHandle: null, attempt: null, startedAt: null }, - { workerId: 0, jobHandle: 'redis:jobs:opaque', attempt: 2, startedAt: '2026-07-21T12:00:05Z' }, - ], + workers: workers.map(worker => worker.workerId === 3 + ? { ...worker, jobHandle: 'redis:jobs:opaque', attempt: 2, startedAt: '2026-07-21T12:00:05Z' } + : worker.workerId === 17 + ? { ...worker, jobHandle: 'job-17', attempt: 1, startedAt: '2026-07-21T12:00:07Z' } + : worker), acquisition: idleLoop, leaseRenewal: idleLoop, }; const router = createRouter({ history: createMemoryHistory(), routes }); const wrapper = mount(ServerCard, { props: { server }, global: { plugins: [router] } }); - const rows = wrapper.findAll('.server-workers li'); - - expect(rows).toHaveLength(2); - expect(rows[0].text()).toContain('#0'); - expect(rows[0].text()).toContain('Attempt 2'); - expect(rows[0].get('a').attributes('href')).toBe('/invocations/redis:jobs:opaque'); - expect(rows[1].text()).toContain('#1'); - expect(rows[1].text()).toContain('Idle'); - expect(rows[1].find('a').exists()).toBe(false); + const slots = wrapper.findAll('.server-slots .server-slot'); + const busyRows = wrapper.findAll('.server-workers li'); + + expect(slots).toHaveLength(32); + expect(slots[0].attributes('aria-label')).toBe('Worker #0: idle'); + expect(slots[3].classes()).toContain('busy'); + expect(slots[3].attributes('href')).toBe('/invocations/redis:jobs:opaque'); + expect(wrapper.findAll('.server-slot.busy')).toHaveLength(2); + + expect(busyRows).toHaveLength(2); + expect(busyRows[0].text()).toContain('#3'); + expect(busyRows[0].text()).toContain('Attempt 2'); + expect(busyRows[0].get('a').attributes('href')).toBe('/invocations/redis:jobs:opaque'); + expect(busyRows[1].text()).toContain('#17'); + expect(wrapper.text()).not.toContain('All workers idle'); + + const idleWrapper = mount(ServerCard, { + props: { server: { ...server, activeWorkers: 0, workers } }, + global: { plugins: [router] }, + }); + expect(idleWrapper.find('.server-workers').exists()).toBe(false); + expect(idleWrapper.text()).toContain('All workers idle'); }); it('renders segmented batch progress and lifecycle actions', () => { From 85366d83943d7e524fe4580b7ddae0c608451e06 Mon Sep 17 00:00:00 2001 From: Duke <40759437+dukesteen@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:03:52 +0200 Subject: [PATCH 3/3] Use 32 workers in the Aspire sample --- samples/Aspire/Api/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/Aspire/Api/Program.cs b/samples/Aspire/Api/Program.cs index da4c3eee..8f759f44 100644 --- a/samples/Aspire/Api/Program.cs +++ b/samples/Aspire/Api/Program.cs @@ -43,7 +43,7 @@ ) .ConfigureWorkers(o => { - o.WorkerCount = 4; + o.WorkerCount = 32; o.PollingInterval = TimeSpan.FromSeconds(5); }) .AddHealthCheck()