From 63736469bb19aea2d3b1d176998c8020ec0cbf23 Mon Sep 17 00:00:00 2001 From: "snake.tech" <9079973+snaketech-tu@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:28:28 +0000 Subject: [PATCH 01/11] Rework the UI like Kubri: keyboard lists, docked inspector, log workspace - New shell: grouped sidebar with pinned services and a find box; top bar with back/forward, connection, stack picker, command search, theme and a read-only badge; status bar with live state and the page's keys - Stacks, Services, Tasks and Nodes as lists with filter, tabs, sortable columns, an action bar naming the target, j/k cursor and Enter drill-down - Service and node panels dock beside the list as an inspector that follows the cursor; service tabs Overview, Events, Config, Logs, Errors - Log workspace at the bottom: tabs that keep following while you browse - : / Ctrl K command palette with commands, ? key overlay, g-prefixed jumps - e2e tests moved to the new layout, plus navigation tests --- CHANGELOG.md | 14 ++ e2e/tests/errors.spec.ts | 8 +- e2e/tests/events.spec.ts | 10 +- e2e/tests/helpers.ts | 18 +- e2e/tests/logs.spec.ts | 22 ++- e2e/tests/navigation.spec.ts | 78 ++++++++ e2e/tests/report.spec.ts | 7 +- ui/src/App.svelte | 202 +++++++++++++-------- ui/src/app.css | 153 ++++++++++++++++ ui/src/components/Dock.svelte | 50 +++++ ui/src/components/DockLog.svelte | 117 ++++++++++++ ui/src/components/Drawer.svelte | 35 +++- ui/src/components/HelpOverlay.svelte | 58 ++++++ ui/src/components/NodeDrawer.svelte | 5 +- ui/src/components/Palette.svelte | 17 +- ui/src/components/ResourceList.svelte | 242 +++++++++++++++++++++++++ ui/src/components/ServiceDrawer.svelte | 138 +++++++------- ui/src/components/Sidebar.svelte | 108 +++++++++++ ui/src/components/StackPicker.svelte | 39 ++++ ui/src/lib/dock.svelte.ts | 85 +++++++++ ui/src/lib/icons.ts | 7 + ui/src/lib/lists.svelte.ts | 58 ++++++ ui/src/lib/state.svelte.ts | 123 +++++++++---- ui/src/lib/storage.ts | 17 ++ ui/src/views/Nodes.svelte | 95 +++++----- ui/src/views/Overview.svelte | 14 +- ui/src/views/Services.svelte | 92 ++++++++++ ui/src/views/Stacks.svelte | 137 +++++++------- ui/src/views/Tasks.svelte | 77 ++++++++ 29 files changed, 1676 insertions(+), 350 deletions(-) create mode 100644 e2e/tests/navigation.spec.ts create mode 100644 ui/src/components/Dock.svelte create mode 100644 ui/src/components/DockLog.svelte create mode 100644 ui/src/components/HelpOverlay.svelte create mode 100644 ui/src/components/ResourceList.svelte create mode 100644 ui/src/components/Sidebar.svelte create mode 100644 ui/src/components/StackPicker.svelte create mode 100644 ui/src/lib/dock.svelte.ts create mode 100644 ui/src/lib/lists.svelte.ts create mode 100644 ui/src/lib/storage.ts create mode 100644 ui/src/views/Services.svelte create mode 100644 ui/src/views/Tasks.svelte diff --git a/CHANGELOG.md b/CHANGELOG.md index c24d9bf..eb539ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ Release notes for each version. The section of a version becomes the text of its GitHub release, and Stackline shows it in the update dialog. +## Unreleased + +Stackline now works like Kubri: lists you can drive with the keyboard, an inspector beside the list, and a log workspace that keeps running while you look around. It is still strictly read-only. + +### Changed +- **New layout.** The sidebar groups the pages (Overview; Workloads: Stacks, Services, Tasks; Cluster: Nodes; Logs) and lists your pinned services, with a box that finds a page or service by name. The top bar has Back and Forward, the connection, a **stack picker** that narrows every list to one stack, **Search or run a command**, the theme and a Read-only badge. A status bar at the bottom shows whether the connection is live and the keys of the page +- **Stacks, Services, Tasks and Nodes are lists of their own**, each with a filter (`/`), tabs (All, Needs attention, Pinned or Running), sortable columns and an action bar that names the row it acts on. Move with `j`/`k` or the arrow keys; **Enter opens what is inside**: a stack's services, a service's tasks, a node's tasks. Back returns to the list as you left it, filter and cursor included. Filter and tab are kept after a restart +- **The service panel is now an inspector docked beside the list.** Click a row or press `d`. Moving the cursor takes the inspector along. Its tabs are Overview (with the likely problem of a failing service), Events (deploys, stops and crashes), Config (command, health check, networks, mounts, environment, labels), Logs and Errors. The expand button lets it use the whole page. Nodes get an inspector too +- **The Tasks page** (Containers on a host without swarm) lists every task with its node, state, exit code, failed attempts, CPU, memory and Docker's message + +### Added +- **Log workspace.** Press `l` on a service, stack, task or node (or **Tail below** in the inspector) to follow its lines in a tab at the bottom of the window. The tabs keep following while you use the rest of the app, can be resized, shrunk with `w`, and are remembered per connection +- **Keys everywhere.** `:` or Ctrl K opens the command palette, which also runs commands (narrow the lists to a stack, close the workspace, show the keys). `?` lists every key. `g` then a letter goes to a page (`g s` Services, `g t` Tasks, `g k` Stacks, `g l` Logs…); Alt ← and Alt → go back and forward + ## 0.5.1 Logs no longer end without warning: the stream pages through every stored line, and long lines are stored whole. diff --git a/e2e/tests/errors.spec.ts b/e2e/tests/errors.spec.ts index 1d310d0..10c0881 100644 --- a/e2e/tests/errors.spec.ts +++ b/e2e/tests/errors.spec.ts @@ -1,10 +1,8 @@ import { expect, test, type Page } from '@playwright/test' -import { clipped, open } from './helpers' +import { clipped, inspect } from './helpers' async function openErrors(page: Page, service: string) { - await open(page, 'stacks') - await page.getByRole('button', { name: new RegExp(`^Errors of .*${service}$`) }).click() - const drawer = page.getByRole('dialog') + const drawer = await inspect(page, `demo/${service}`, /^Errors/) await expect(drawer.getByRole('tab', { name: /Errors/ })).toHaveAttribute('aria-selected', 'true') await expect(drawer.locator('.ecard').first()).toBeVisible() return drawer @@ -40,7 +38,7 @@ for (const [service, width] of [['shop', 1400], ['worker', 1400], ['worker', 100 test('an error group opens its lines in Logs', async ({ page }) => { const drawer = await openErrors(page, 'shop') await drawer.locator('.ecard').first().getByRole('button', { name: /^Show all .* in Logs$/ }).click() - await expect(page.getByRole('dialog')).toHaveCount(0) + await expect(page.locator('.drawer')).toHaveCount(0) await expect(page).toHaveURL(/#logs\?.*g=[0-9a-f]+/) await expect(page.locator('.stream .row').first()).toBeVisible() }) diff --git a/e2e/tests/events.spec.ts b/e2e/tests/events.spec.ts index 0ff99a9..2763810 100644 --- a/e2e/tests/events.spec.ts +++ b/e2e/tests/events.spec.ts @@ -1,11 +1,9 @@ import { expect, test } from '@playwright/test' -import { open } from './helpers' +import { inspect } from './helpers' // `migrate` prints its lines and exits with 0 right after the demo starts. test('a service panel lists when its containers stopped, and opens the lines before', async ({ page }) => { - await open(page, 'stacks') - await page.getByRole('row', { name: /demo_migrate/ }).click() - const drawer = page.getByRole('dialog') + const drawer = await inspect(page, 'demo/migrate', 'Events') const list = drawer.locator('div', { has: page.getByRole('heading', { name: /Deploys, stops and crashes/ }) }) const row = list.getByRole('row', { name: /exited · exit 0/ }).first() await expect(row).toBeVisible() @@ -22,9 +20,7 @@ test('a service panel lists when its containers stopped, and opens the lines bef // The demo was deployed when the tests started: each service has a deploy marker. test('a deploy is listed with what it runs, and opens the lines after it', async ({ page }) => { - await open(page, 'stacks') - await page.getByRole('row', { name: /demo_shop/ }).click() - const drawer = page.getByRole('dialog') + const drawer = await inspect(page, 'demo/shop', 'Events') const list = drawer.locator('div', { has: page.getByRole('heading', { name: /Deploys, stops and crashes/ }) }) const row = list.getByRole('row', { name: /deployed: image alpine:3.20/ }) await expect(row).toBeVisible() diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index c5d43fa..7ca3747 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -9,7 +9,23 @@ export async function open(page: Page, view = 'overview') { /** Goes to another page with the sidebar, the way people do. */ export async function nav(page: Page, label: string | RegExp) { - await page.locator('nav.nav').getByRole('link', { name: label }).click() + await page.locator('aside.side').getByRole('link', { name: label }).click() +} + +/** The row of a service (`stack/name`) in the services list. */ +export const serviceRow = (page: Page, key: string) => page.locator(`.rlist tbody tr[data-key="${key}"]`) + +/** The inspector docked beside the list, showing the service with this full name. */ +export const inspector = (page: Page, name: string) => page.getByRole('complementary', { name }) + +/** Opens the services list and the inspector of one service, on a tab. */ +export async function inspect(page: Page, key: string, tab?: string | RegExp) { + await open(page, 'services') + await serviceRow(page, key).click() + const panel = inspector(page, key.replace('/', '_')) + await expect(panel).toBeVisible() + if (tab) await panel.getByRole('tab', { name: tab }).click() + return panel } /** Elements of `selector` whose content is wider or taller than their box shows. */ diff --git a/e2e/tests/logs.spec.ts b/e2e/tests/logs.spec.ts index 6a5cacd..392f319 100644 --- a/e2e/tests/logs.spec.ts +++ b/e2e/tests/logs.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test' -import { nav, open } from './helpers' +import { nav, open, serviceRow } from './helpers' test('a multi-line stack trace is one entry', async ({ page }) => { await open(page, 'logs') @@ -29,8 +29,10 @@ test('log filters stay when you go to another page and back', async ({ page }) = }) test('the source picked in Logs is kept after a reload', async ({ page }) => { - await open(page, 'stacks') - await page.getByRole('button', { name: /^Logs of .*worker$/ }).click() + await open(page, 'services') + await serviceRow(page, 'demo/worker').click() + // `o` opens the lines of the row on the Logs page. + await page.keyboard.press('o') await expect(page).toHaveURL(/#logs\?.*s=demo_worker/) const rows = page.locator('.stream .row .svc') await expect(rows.first()).toContainText('worker') @@ -39,17 +41,17 @@ test('the source picked in Logs is kept after a reload', async ({ page }) => { expect(await rows.allTextContents()).not.toContainEqual(expect.stringMatching(/shop|app|caddy/)) }) -test('the Stacks filters stay when you go to another page and back', async ({ page }) => { - await open(page, 'stacks') - await page.getByPlaceholder('Filter by service, image or stack').fill('shop') +test('the list filters stay when you go to another page and back', async ({ page }) => { + await open(page, 'services') + await page.getByPlaceholder('Filter by name, image, stack or node').fill('shop') await expect(page.getByRole('row', { name: /worker/ })).toHaveCount(0) await nav(page, 'Overview') - await nav(page, 'Stacks & services') - await expect(page.getByPlaceholder('Filter by service, image or stack')).toHaveValue('shop') + await nav(page, /^Services/) + await expect(page.getByPlaceholder('Filter by name, image, stack or node')).toHaveValue('shop') await expect(page.getByRole('row', { name: /worker/ })).toHaveCount(0) await page.reload() - await expect(page.getByPlaceholder('Filter by service, image or stack')).toHaveValue('shop') - await page.getByPlaceholder('Filter by service, image or stack').fill('') + await expect(page.getByPlaceholder('Filter by name, image, stack or node')).toHaveValue('shop') + await page.getByPlaceholder('Filter by name, image, stack or node').fill('') }) test('the address of the Logs page opens the same view in another tab', async ({ page, context }) => { diff --git a/e2e/tests/navigation.spec.ts b/e2e/tests/navigation.spec.ts new file mode 100644 index 0000000..b554df9 --- /dev/null +++ b/e2e/tests/navigation.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from '@playwright/test' +import { inspector, open, serviceRow } from './helpers' + +test('the keyboard drills from a stack to its services and tasks, and Back returns', async ({ page }) => { + await open(page, 'stacks') + const bar = page.getByLabel('Actions') + // Nothing is picked until the cursor moves, so no action can hit a row out of sight. + await expect(bar.getByRole('button', { name: /^Services/ })).toBeDisabled() + await page.keyboard.press('j') + await expect(bar).toContainText('demo') + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/#services\?stack=demo$/) + await expect(page.locator('.scopechip')).toContainText('of stack demo') + // Pick `shop` with the filter, then go down to its replicas. + await page.keyboard.press('/') + await page.keyboard.type('shop') + await page.keyboard.press('Enter') + await expect(bar).toContainText('demo_shop') + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/#tasks\?service=demo%2Fshop$/) + await expect(page.locator('.rlist tbody tr')).toHaveCount(2) + await page.goBack() + await expect(page).toHaveURL(/#services\?stack=demo$/) + // The list is as it was left: filter and cursor. + await expect(page.getByLabel('Filter services')).toHaveValue('shop') + await expect(page.locator('.rlist tbody tr.cur')).toHaveAttribute('data-key', 'demo/shop') + await page.getByLabel('Filter services').fill('') +}) + +test('a click inspects a service beside the list, and the inspector follows the cursor', async ({ page }) => { + await open(page, 'services') + await serviceRow(page, 'demo/shop').click() + await expect(inspector(page, 'demo_shop')).toBeVisible() + // The list stays usable next to it. + await expect(serviceRow(page, 'demo/worker')).toBeVisible() + await page.keyboard.press('j') + await expect(inspector(page, 'demo_worker')).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.locator('.drawer')).toHaveCount(0) +}) + +test('l follows a service in the log workspace while you browse', async ({ page }) => { + await open(page, 'services') + await serviceRow(page, 'demo/shop').click() + await page.keyboard.press('Escape') + await page.keyboard.press('l') + const dock = page.getByRole('region', { name: 'Log workspace' }) + await expect(dock.getByRole('tab', { name: 'demo_shop' })).toHaveAttribute('aria-selected', 'true') + await expect(dock.locator('.stream .row').first()).toBeVisible() + // It stays open on other pages, and after a reload. + await page.keyboard.press('g') + await page.keyboard.press('o') + await expect(page).toHaveURL(/#overview$/) + await expect(dock.locator('.stream .row').first()).toBeVisible() + await page.reload() + await expect(dock.getByRole('tab', { name: 'demo_shop' })).toBeVisible() + await dock.getByRole('button', { name: 'Close demo_shop' }).click() + await expect(dock).toHaveCount(0) +}) + +test('the stack picker narrows every list, and : and ? open the palette and the keys', async ({ page }) => { + await open(page, 'tasks') + await page.locator('header.top').getByRole('button', { name: /^Stack/ }).click() + await page.getByRole('menuitemradio', { name: /demo/ }).click() + await expect(page.locator('.crumbs')).toContainText('demo') + await page.locator('header.top').getByRole('button', { name: /^Stack/ }).click() + await page.getByRole('menuitemradio', { name: /All stacks/ }).click() + + await page.keyboard.press(':') + await expect(page.getByRole('dialog', { name: 'Jump to' })).toBeVisible() + await page.keyboard.type('nodes') + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/#nodes$/) + await page.keyboard.press('?') + await expect(page.getByRole('dialog', { name: 'Keys' })).toContainText('Follow its logs in the workspace below') + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog', { name: 'Keys' })).toHaveCount(0) +}) diff --git a/e2e/tests/report.spec.ts b/e2e/tests/report.spec.ts index cfd9f19..4dc0b9e 100644 --- a/e2e/tests/report.spec.ts +++ b/e2e/tests/report.spec.ts @@ -1,12 +1,11 @@ import { expect, test } from '@playwright/test' -import { open } from './helpers' +import { inspect } from './helpers' test.use({ permissions: ['clipboard-read', 'clipboard-write'] }) test('a service panel copies a debug report as Markdown', async ({ page }) => { - await open(page, 'stacks') - await page.getByRole('row', { name: /demo_shop/ }).first().click() - await page.getByRole('dialog').getByRole('button', { name: 'Debug report' }).click() + const drawer = await inspect(page, 'demo/shop') + await drawer.getByRole('button', { name: 'Debug report' }).click() await expect(page.locator('.toast')).toContainText('Debug report copied') const md = await page.evaluate(() => navigator.clipboard.readText()) expect(md).toContain('# Debug report: demo_shop') diff --git a/ui/src/App.svelte b/ui/src/App.svelte index d82dae0..c64fcf8 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -1,10 +1,17 @@ @@ -66,37 +105,15 @@
- +
-

{title}

+
+ + +
- {connLine} + {#if app.snap && app.snap.stacks.length}{/if} {#if showUpdate && latest} {/if} - - + Read-only +
-
- {#if app.fatal} -
{app.fatal}
- {:else if app.view === 'connections'} - - {:else if app.view === 'prefs'} - - {:else if app.conn?.status === 'error' && !app.snap} -
Cannot reach {app.active?.name ?? 'this connection'}.{app.conn.error}
-
Retrying every few seconds. Check the connection under Connections.
- {:else if !app.snap} -
Connecting to {app.active?.name ?? '…'}
- {:else} - {#if app.conn?.status === 'error'} -
Connection lost.{app.conn.error} Showing the last known state.
- {/if} - - {#key app.activeId} - {#if app.view === 'overview'} - {:else if app.view === 'nodes'} - {:else if app.view === 'stacks'} - {:else if app.view === 'logs'}{/if} - {/key} - {/if} -
+
+
+
+ {#if app.fatal} +
{app.fatal}
+ {:else if app.view === 'connections'} +

{title}

+ + {:else if app.view === 'prefs'} +

{title}

+ + {:else if app.conn?.status === 'error' && !app.snap} +
Cannot reach {app.active?.name ?? 'this connection'}.{app.conn.error}
+
Retrying every few seconds. Check the connection under Connections.
+ {:else if !app.snap} +
Connecting to {app.active?.name ?? '…'}
+ {:else} + {#if app.conn?.status === 'error'} +
Connection lost.{app.conn.error} Showing the last known state.
+ {/if} + {#if !isList} +
+ +

{title}

+
+ {/if} + + {#key app.activeId} + {#if app.view === 'overview'} + {:else if app.view === 'stacks'} + {:else if app.view === 'services'} + {:else if app.view === 'tasks'} + {:else if app.view === 'nodes'} + {:else if app.view === 'logs'}{/if} + {/key} + {/if} +
+ {#if docked && app.drawer?.kind === 'service'} + {#key app.drawer.key}{/key} + {:else if docked && app.drawer?.kind === 'node'}{/if} +
+ {#if app.dock.tabs.length && app.snap}{/if} +
+
+ {status.text} + + + {#each hints as [k, what] (k)}{k}{what}{/each} + +
-{#if app.drawer?.kind === 'service'} - {#key app.drawer.key}{/key} -{:else if app.drawer?.kind === 'node'} -{:else if app.drawer?.kind === 'connection'} +{#if app.drawer?.kind === 'connection'} {:else if app.drawer?.kind === 'update'}{/if} {#if app.palette}{/if} +{#if app.help}{/if} {#if app.toast}
{app.toast.text}
{/if} diff --git a/ui/src/app.css b/ui/src/app.css index eb1d521..15c3743 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -568,3 +568,156 @@ tr.editrow>td{background:var(--panel-2);padding:16px 18px} .usage .deleting{display:inline-flex;gap:6px;align-items:center;font-size:12.5px} .spinner.sm{width:12px;height:12px;border-width:2px} .stopped{font-size:12px;margin-left:8px;white-space:nowrap} + +/* ---- shell, laid out like Kubri: sidebar, top bar, page + docked inspector, log workspace, status bar ---- */ +.app{grid-template-columns:232px minmax(0,1fr);height:100dvh;overflow:hidden} +.side{padding:14px 10px 10px;gap:6px;overflow:hidden;min-height:0} +.brand{padding:2px 8px 8px;gap:9px;font-size:16px} +.brand .bname{flex:1} +.brand .ver{font-size:10.5px;color:var(--side-muted);font-weight:500} +.find input{width:100%;height:32px;border-radius:8px;border:1px solid var(--side-2);background:color-mix(in srgb,var(--side-2) 60%,transparent);color:var(--side-fg);padding:0 10px;font-size:13px;outline:0} +.find input::placeholder{color:var(--side-muted)} +.find input:focus{border-color:var(--accent)} +.side .nav{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:1px;margin:0 -4px;padding:0 4px} +.nav .grp-h{font-size:10.5px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--side-muted);padding:14px 10px 5px;opacity:.85} +.nav a{padding:7px 10px} +.nav .cnt{background:var(--side-2);color:var(--side-muted);font-weight:600} +.nav .cnt.bad{background:var(--err);color:#fff} +.svc-link{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:9px;padding:6px 10px;border-radius:8px;color:var(--side-muted);cursor:pointer;font-size:13px;min-width:0} +.svc-link:hover,.svc-link:focus-visible{background:var(--side-2);color:var(--side-fg)} +.svc-link.on{background:color-mix(in srgb,var(--accent) 16%,var(--side));color:#fff} +.svc-link .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0} +.svc-link .st{margin-left:auto;font-size:11px;opacity:.7;white-space:nowrap} +.svc-link .dot{width:7px;height:7px;box-shadow:none} +.side-foot{display:grid;gap:1px;border-top:1px solid var(--side-2);padding-top:8px;margin:0 -4px} +.side-foot a,.side-foot button{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:10px;padding:7px 14px;border-radius:8px;color:var(--side-muted);cursor:pointer;font-weight:500;font-size:13.5px} +.side-foot a:hover,.side-foot button:hover,.side-foot a:focus-visible,.side-foot button:focus-visible{background:var(--side-2);color:var(--side-fg)} +.side-foot a[aria-current="page"]{background:color-mix(in srgb,var(--accent) 16%,var(--side));color:#fff} +.side-foot a[aria-current="page"] svg{color:var(--accent)} +.side-foot kbd{margin-left:auto;background:transparent;border-color:var(--side-2);color:var(--side-muted)} +.side-foot .upd{color:var(--accent)} +.main{min-height:0;height:100dvh} +.top{gap:8px;padding:8px 14px 8px 12px;min-height:56px} +.top .history{display:flex;gap:2px} +.sq{display:inline-grid;place-items:center;width:32px;height:32px;flex:none;border:1px solid transparent;border-radius:8px;background:none;color:var(--fg);padding:0;cursor:pointer} +.sq:hover{background:var(--panel-2)} +.sq.bordered{border-color:var(--line);background:var(--panel)} +.help-btn{font-weight:700;font-size:14px} +.divider{width:1px;height:26px;background:var(--line);flex:none} +.picker{display:inline-grid;grid-template-columns:auto 14px;grid-template-rows:auto auto;column-gap:8px;align-items:center;text-align:left;border:1px solid transparent;border-radius:8px;background:none;padding:3px 8px;cursor:pointer;min-width:0;max-width:240px} +.picker:hover,.picker[aria-expanded="true"]{background:var(--panel-2);border-color:var(--line)} +.picker .cap{grid-column:1;font-size:10.5px;color:var(--muted);line-height:1.2} +.picker .val{grid-column:1;font-weight:600;line-height:1.25;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.picker svg{grid-column:2;grid-row:1/3;color:var(--muted)} +.ro-badge{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px;border-radius:8px;font-size:12px;font-weight:600;color:var(--accent);background:color-mix(in srgb,var(--accent) 13%,var(--panel));white-space:nowrap} +.area{flex:1;min-height:0;display:flex;flex-direction:column} +.content{flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr)} +.content.with-inspector{grid-template-columns:minmax(0,1fr) min(clamp(420px,38vw,720px),calc(100% - 360px))} +.content.full{grid-template-columns:minmax(0,1fr)} +.content.full>.view{display:none} +.content>.view{min-height:0;padding:16px 20px 28px;gap:14px} +.content>.view>*{flex-shrink:0} +/* the inspector, docked beside the page */ +.drawer.docked{position:relative;inset:auto;width:auto;z-index:auto;box-shadow:none;border-left:1px solid var(--line);min-height:0;padding-top:0} +.drawer .dh .dtitle{flex:1;min-width:0;display:grid} +.drawer .dh .dtitle h3{flex:none} +.drawer .eyebrow{font-size:10.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)} +.callout.warnish{all:unset;box-sizing:border-box;display:grid;gap:4px;padding:10px 12px;border-radius:8px;border:1px solid color-mix(in srgb,var(--warn) 40%,var(--line));background:color-mix(in srgb,var(--warn) 8%,var(--panel));cursor:pointer} +.callout.warnish .h{display:flex;gap:6px;align-items:center;font-weight:600;color:var(--warn)} +.callout.warnish:hover{border-color:var(--warn)} +/* page heads */ +.phead{display:grid;gap:2px} +.crumbs{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--muted)} +.crumbs svg{opacity:.6} +.ptitle{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-height:34px} +.ptitle h2{margin:0;font-size:24px;font-weight:700;letter-spacing:-.02em;color:var(--fg);text-transform:none} +.ptitle .pcount{font-size:16px;color:var(--muted);font-variant-numeric:tabular-nums;font-weight:500} +.scopechip{height:26px} +.phead~.hint,.view>.hint{display:block;padding:0 4px;line-height:1.7} +.view>.hint svg{vertical-align:-2px;margin-right:4px} +/* lists */ +.rlist{display:flex;flex-direction:column;min-height:0} +.rl-filter{padding:12px 14px 8px} +.rl-filter .search{height:36px} +.rl-tabs{display:flex;gap:2px;padding:0 10px;border-bottom:1px solid var(--line);overflow-x:auto} +.rl-tabs button{border:0;background:none;padding:8px 8px;font-weight:600;font-size:13px;color:var(--muted);border-bottom:2px solid transparent;margin-bottom:-1px;cursor:pointer;white-space:nowrap;display:inline-flex;gap:6px;align-items:center} +.rl-tabs button[aria-selected="true"]{color:var(--fg);border-bottom-color:var(--accent)} +.rl-tabs .n{font-family:var(--mono);font-size:11px;padding:0 5px;border-radius:8px;background:var(--panel-2);color:var(--muted);font-weight:500} +.rl-tabs .n.bad{background:color-mix(in srgb,var(--err) 13%,transparent);color:var(--err)} +.rl-actions{display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:8px 14px;border-bottom:1px solid var(--line);min-height:44px} +.rl-actions .target{font-size:12.5px;color:var(--fg);max-width:min(340px,40%);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:4px} +.rl-actions .target.none{font-family:var(--sans);color:var(--muted)} +.rl-actions .btn kbd{margin-left:2px;font-size:10px;padding:0 4px} +.rl-actions .btn:disabled{opacity:.45;cursor:default} +.rl-actions .btn:disabled:hover{border-color:var(--line)} +.rlist th .sorter{all:unset;cursor:pointer;display:inline-flex;gap:4px;align-items:center} +.rlist th .sorter:hover{color:var(--fg)} +.rlist th .sorter:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px} +.rlist th .arrow{opacity:.5;font-size:10px} +.rlist th[aria-sort] .arrow{opacity:1;color:var(--accent)} +.rlist th.num,.rlist td.num{text-align:right} +.rlist td{padding:8px 12px} +.rlist tbody tr.cur td{background:color-mix(in srgb,var(--accent) 9%,var(--panel))} +.rlist tbody tr.cur td:first-child{box-shadow:inset 3px 0 0 var(--accent)} +.rlist tbody tr.inspected:not(.cur) td{background:color-mix(in srgb,var(--accent) 5%,var(--panel))} +.rlist td.clip{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rlist .state{font-size:12.5px;color:var(--muted)} +.rlist .state.ok{color:var(--ok)}.rlist .state.warn{color:var(--warn)}.rlist .state.err{color:var(--err)} +.rl-foot{display:flex;gap:8px;padding:8px 14px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)} +.meterc{display:flex;gap:8px;align-items:center} +/* log workspace */ +.dock{flex:none;display:flex;flex-direction:column;border-top:1px solid var(--line);background:var(--panel);position:relative;min-height:0} +.dock.min{height:auto} +.dock-grip{position:absolute;top:-4px;left:0;right:0;height:8px;cursor:ns-resize;z-index:2} +.dock-grip:focus-visible{outline:2px solid var(--accent);outline-offset:-3px} +.dock.min .dock-grip{display:none} +.dock-tabs{display:flex;align-items:center;gap:2px;padding:0 8px;border-bottom:1px solid var(--line);background:var(--panel-2);min-height:36px;overflow-x:auto} +.dock.min .dock-tabs{border-bottom:0} +.dtab{display:inline-flex;align-items:center;border-right:1px solid var(--line);height:36px;flex:none} +.dtab>button[role=tab]{all:unset;display:inline-flex;align-items:center;gap:6px;padding:0 6px 0 12px;height:100%;cursor:pointer;font-family:var(--mono);font-size:12px;color:var(--muted);max-width:260px} +.dtab .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.dtab .x{all:unset;display:inline-grid;place-items:center;width:20px;height:20px;border-radius:5px;color:var(--muted);cursor:pointer;margin-right:6px} +.dtab .x:hover{background:var(--line);color:var(--fg)} +.dtab.on{background:var(--panel);box-shadow:inset 0 -2px 0 var(--accent)} +.dtab.on>button[role=tab]{color:var(--fg)} +.dock-body{flex:1;min-height:0;display:flex;flex-direction:column} +.dl-bar{display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:6px 10px;border-bottom:1px solid var(--line)} +.dl-bar .search{flex:1 1 200px;height:28px} +.lv.sm,.ctl.sm{height:28px;font-size:12px;padding:0 8px} +.fchip.static{cursor:default} +.dl-body{flex:1;min-height:0;overflow:auto} +.dl-body .stream .row{grid-template-columns:96px 52px 170px minmax(0,1fr)} +.dl-body .stream.nosvc .row{grid-template-columns:96px 52px minmax(0,1fr)} +.dl-body .stream .row .detail{grid-column:1/-1} +/* status bar */ +.statusbar{display:flex;align-items:center;gap:10px;padding:3px 14px;border-top:1px solid var(--line);background:var(--panel);font-size:12px;color:var(--muted);min-height:28px;white-space:nowrap;overflow:hidden} +.statusbar .status{display:inline-flex;align-items:center;gap:7px} +.statusbar .dot{width:7px;height:7px;box-shadow:none} +.statusbar .hints{display:flex;gap:12px;overflow:hidden} +.statusbar .hints span{display:inline-flex;gap:5px;align-items:center} +/* help */ +.help-ov{width:min(880px,calc(100% - 32px))} +.help-ov .in{gap:10px} +.help-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:4px 28px;padding:12px 18px 18px;max-height:70vh;overflow:auto} +.help-grid h4{margin:12px 0 6px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)} +.help-grid dl{display:grid;grid-template-columns:128px minmax(0,1fr);gap:6px 12px;margin:0;font-size:13px} +.help-grid dt{display:flex;gap:3px;align-items:center;flex-wrap:wrap} +.help-grid dd{margin:0} +.help-grid .or{color:var(--muted);font-size:11px;padding:0 2px} +@media (max-width:1100px){ + .content.with-inspector{grid-template-columns:minmax(0,1fr)} + .drawer.docked{position:fixed;top:0;right:0;bottom:0;width:min(620px,100%);z-index:21;box-shadow:-20px 0 50px -20px rgba(0,0,0,.35)} +} +@media (max-width:760px){ + .app{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr);height:100dvh} + .side{flex-direction:row;overflow-x:auto;padding:6px 12px;gap:4px} + .side .find,.side .brand .ver,.side .brand .bname,.nav .grp-h,.svc-link,.side-foot kbd,.side-foot .upd{display:none} + .side .nav{flex-direction:row;overflow:visible;flex:none} + .side-foot{display:flex;border-top:0;padding-top:0;margin:0} + .side-foot a span.t,.side-foot button span.t{display:none} + .main{height:auto;min-height:0} + .top .history,.divider{display:none} + .picker{max-width:140px} + .statusbar .hints{display:none} + .rl-actions .target.none,.rl-actions kbd{display:none} +} diff --git a/ui/src/components/Dock.svelte b/ui/src/components/Dock.svelte new file mode 100644 index 0000000..fd914c8 --- /dev/null +++ b/ui/src/components/Dock.svelte @@ -0,0 +1,50 @@ + + +
+ + +
+ {#each D.tabs as t (t.id)} +
+ + +
+ {/each} + + {#if D.tabs.length > 1}{/if} + +
+ {#if !D.min && active} + {#key active.id + app.activeId}
{/key} + {/if} +
diff --git a/ui/src/components/DockLog.svelte b/ui/src/components/DockLog.svelte new file mode 100644 index 0000000..b5868a5 --- /dev/null +++ b/ui/src/components/DockLog.svelte @@ -0,0 +1,117 @@ + + +
+ + {#each LEVELS as l (l)} + + {/each} + + + {#if tab.q}{tab.q}{/if} + + +
+{#if error}
{error}
{/if} +
+
+ {#each pages.entries as e (e.id)} + F.toggleRow(e.id)} /> + {:else} +
{pages.head ? 'No lines stored that match.' : 'Reading lines…'}
+ {/each} + {#if pages.head && pages.entries.length} +
+ {#if pages.olderError} + Older lines could not be read: {pages.olderError} + {:else if pages.more && !pages.full} + + {pages.entries.length.toLocaleString()} of {pages.head.result.total.toLocaleString()} shown + {:else if pages.more} + The workspace shows at most {LINES.max.toLocaleString()} lines. Open in Logs for older ones. + {:else} + These are all the stored lines that match. + {/if} +
+ {/if} +
+
diff --git a/ui/src/components/Drawer.svelte b/ui/src/components/Drawer.svelte index c66e3ae..bdd04b2 100644 --- a/ui/src/components/Drawer.svelte +++ b/ui/src/components/Drawer.svelte @@ -1,31 +1,48 @@ - -
(app.drawer = null)}>
- + diff --git a/ui/src/components/HelpOverlay.svelte b/ui/src/components/HelpOverlay.svelte new file mode 100644 index 0000000..7fc0c1f --- /dev/null +++ b/ui/src/components/HelpOverlay.svelte @@ -0,0 +1,58 @@ + + + +
{ if (e.target === e.currentTarget) app.help = false }}> + +
diff --git a/ui/src/components/NodeDrawer.svelte b/ui/src/components/NodeDrawer.svelte index 14aa6b4..4d89033 100644 --- a/ui/src/components/NodeDrawer.svelte +++ b/ui/src/components/NodeDrawer.svelte @@ -18,7 +18,7 @@ } - + {#if !n}
This node is gone.
{:else} @@ -27,7 +27,8 @@ {nodeRole(n)} {#if n.availability !== 'active'}{n.availability}{/if} - {#if app.snap && app.snap.nodes.length > 1}{/if} + + {#if app.snap && app.snap.nodes.length > 1}{/if}
Address
{n.addr || '–'}
diff --git a/ui/src/components/Palette.svelte b/ui/src/components/Palette.svelte index 91b5cbf..812bcca 100644 --- a/ui/src/components/Palette.svelte +++ b/ui/src/components/Palette.svelte @@ -1,5 +1,5 @@ + + + + + +
+ +
+

{title}

{shown.length} + {#if scope} + + {/if} + + {@render toolbar?.()} +
+
+ +{@render before?.()} + +
+
+ +
+
+ {#each tabs as t (t.id)} + + {/each} +
+
+ {#if cur} + {label(cur)} + {:else} + Pick a row with j k or a click + {/if} + {#if onopen}{/if} + {#if oninspect}{/if} + {#each barActs as a (a.key)} + {@const ok = !!cur && (!a.when || a.when(cur))} + + {/each} +
+
+ + + + {#each cols as c (c.id)} + + {/each} + + + + {#each shown as r (key(r))} + click(r)} ondblclick={() => onopen?.(r)} aria-selected={cur === r}> + {@render row(r)} + + {:else} + + {/each} + +
+ {#if c.sort} + + {:else}{c.label}{/if} +
+ {needle || tab.id !== tabs[0].id ? `No ${noun[1]} match.` : empty} + {#if needle || tab.id !== tabs[0].id}{/if} +
+
+
+ {shown.length === items.length ? `${items.length} ${items.length === 1 ? noun[0] : noun[1]}` : `${shown.length} of ${items.length} ${noun[1]}`} + + Read-only: nothing here changes your containers +
+
diff --git a/ui/src/components/ServiceDrawer.svelte b/ui/src/components/ServiceDrawer.svelte index e6de4c5..52082b1 100644 --- a/ui/src/components/ServiceDrawer.svelte +++ b/ui/src/components/ServiceDrawer.svelte @@ -1,7 +1,7 @@ + + diff --git a/ui/src/components/StackPicker.svelte b/ui/src/components/StackPicker.svelte new file mode 100644 index 0000000..266b051 --- /dev/null +++ b/ui/src/components/StackPicker.svelte @@ -0,0 +1,39 @@ + + + +{#if open && btn} + (open = false)}> +
Show the lists for
+ + {#each stacks as st (st.name)} + {@const s = stackState(st.services)} + + {/each} +
+{/if} diff --git a/ui/src/lib/dock.svelte.ts b/ui/src/lib/dock.svelte.ts new file mode 100644 index 0000000..ab0f21b --- /dev/null +++ b/ui/src/lib/dock.svelte.ts @@ -0,0 +1,85 @@ +// The log workspace at the bottom of the window: one tab per log source +// someone opened. The tabs keep following their lines while the rest of +// the app is used, and are remembered per connection. + +import { load, save } from './storage' + +export interface DockTab { + id: string + /** What the tab is named. */ + label: string + /** A full service name, `stack:` for a whole stack, or empty for every service. */ + source: string + /** Search the tab starts with, such as `replica:2` or `node:web-1`. */ + q: string +} + +const MIN = 160 + +export class DockState { + tabs = $state([]) + active = $state(null) + /** Height in pixels of the open workspace. */ + height = $state(Math.max(MIN, Number(load('stackline-dock-height')) || 300)) + /** Only the tab strip shows. */ + min = $state(false) + #conn = '' + + /** Puts back the tabs last open on a connection. */ + restore(conn: string) { + this.#conn = conn + this.tabs = [] + this.active = null + try { + const v = JSON.parse(load(`stackline-dock-${conn}`) ?? 'null') + if (v && Array.isArray(v.tabs)) { + this.tabs = v.tabs.filter((t: DockTab) => t && typeof t.id === 'string' && typeof t.source === 'string') + this.active = this.tabs.some((t) => t.id === v.active) ? v.active : (this.tabs[0]?.id ?? null) + } + } catch { + // A damaged entry means no tabs. + } + } + + #save() { + save(`stackline-dock-${this.#conn}`, JSON.stringify({ tabs: this.tabs, active: this.active })) + } + + /** Opens a tab for these lines, or shows the one already open for them. */ + open(source: string, label: string, q = '') { + const id = `${source}|${q}` + if (!this.tabs.some((t) => t.id === id)) this.tabs = [...this.tabs, { id, label, source, q }] + this.active = id + this.min = false + this.#save() + } + + close(id: string) { + const i = this.tabs.findIndex((t) => t.id === id) + if (i < 0) return + this.tabs = this.tabs.filter((t) => t.id !== id) + if (this.active === id) this.active = (this.tabs[i] ?? this.tabs[i - 1])?.id ?? null + this.#save() + } + + closeAll() { + this.tabs = [] + this.active = null + this.#save() + } + + pick(id: string) { + this.active = id + this.min = false + this.#save() + } + + toggle() { + if (this.tabs.length) this.min = !this.min + } + + resize(h: number) { + this.height = Math.max(MIN, Math.min(h, innerHeight - 220)) + save('stackline-dock-height', String(Math.round(this.height))) + } +} diff --git a/ui/src/lib/icons.ts b/ui/src/lib/icons.ts index 4f6edac..454bfce 100644 --- a/ui/src/lib/icons.ts +++ b/ui/src/lib/icons.ts @@ -12,6 +12,13 @@ export const ICON: Record = { star: '', chev: '', chevD: '', + chevU: '', + tasks: '', + services: '', + back: '', + forward: '', + help: '', + keyboard: '', sparkle: '', link: '', copy: '', diff --git a/ui/src/lib/lists.svelte.ts b/ui/src/lib/lists.svelte.ts new file mode 100644 index 0000000..1ce878d --- /dev/null +++ b/ui/src/lib/lists.svelte.ts @@ -0,0 +1,58 @@ +// What each list (stacks, services, tasks, nodes) remembers while someone +// moves around the app: the filter text, the tab, the sort and the row the +// cursor is on. Back to a list shows it the way it was left. + +import { load } from './storage' + +export type ListTab = 'all' | 'attention' | 'pinned' | 'running' +export type ListView = 'stacks' | 'services' | 'tasks' | 'nodes' + +export class ListState { + q = $state('') + tab = $state('all') + /** Column id and direction; null is the list's own order. */ + sort = $state<{ col: string; desc: boolean } | null>(null) + /** Key of the row the cursor is on. Shown only once someone moved it (a key, a click, a link). */ + cursor = $state(null) + + /** Sorts by a column: first ascending, then descending, then back to the list's own order. */ + sortBy(col: string) { + if (this.sort?.col !== col) this.sort = { col, desc: false } + else if (!this.sort.desc) this.sort = { col, desc: true } + else this.sort = null + } + + reset() { + this.q = '' + this.tab = 'all' + this.sort = null + this.cursor = null + } +} + +const VIEWS: ListView[] = ['stacks', 'services', 'tasks', 'nodes'] + +/** The lists, with the filter and tab each had when Stackline was last open. */ +export function newLists(): Record { + const out = {} as Record + for (const v of VIEWS) { + const l = (out[v] = new ListState()) + try { + const saved = JSON.parse(load(`stackline-list-${v}`) ?? 'null') + if (saved && typeof saved.q === 'string') l.q = saved.q + if (saved && ['all', 'attention', 'pinned', 'running'].includes(saved.tab)) l.tab = saved.tab + } catch { + // A damaged entry means the defaults. + } + } + return out +} + +/** Compares two sort values: numbers as numbers, the rest as text; empty values last. */ +export function compare(a: string | number | null | undefined, b: string | number | null | undefined): number { + const ea = a === null || a === undefined || a === '' + const eb = b === null || b === undefined || b === '' + if (ea || eb) return ea === eb ? 0 : ea ? 1 : -1 + if (typeof a === 'number' && typeof b === 'number') return a - b + return String(a).localeCompare(String(b), undefined, { numeric: true }) +} diff --git a/ui/src/lib/state.svelte.ts b/ui/src/lib/state.svelte.ts index d39eb12..624a6ca 100644 --- a/ui/src/lib/state.svelte.ts +++ b/ui/src/lib/state.svelte.ts @@ -5,22 +5,30 @@ import { untrack } from 'svelte' import { SvelteSet } from 'svelte/reactivity' import { ApiError, api, fetchDeploys, post, put, type LogParams } from './api' import { beforeEvent } from './events' +import { load, save } from './storage' +import { DockState } from './dock.svelte' +import { newLists, type ListView } from './lists.svelte' import type { Alert, AlertsResponse, Collect, ConnState, Connection, Deploy, DockerEvent, ErrorSummary, Level, Meta, ServiceRef, Settings, Snapshot, SnapshotResponse, UpdateState, } from './types' -export type View = 'overview' | 'nodes' | 'stacks' | 'logs' | 'connections' | 'prefs' -export const VIEWS: [View, string][] = [ - ['overview', 'Overview'], - ['nodes', 'Nodes'], - ['stacks', 'Stacks & services'], - ['logs', 'Logs'], - ['connections', 'Connections'], - ['prefs', 'Preferences'], +export type View = 'overview' | 'stacks' | 'services' | 'tasks' | 'nodes' | 'logs' | 'connections' | 'prefs' +/** Every page: its name, and the group the sidebar shows it in. */ +export const VIEWS: [View, string, string][] = [ + ['overview', 'Overview', ''], + ['stacks', 'Stacks', 'Workloads'], + ['services', 'Services', 'Workloads'], + ['tasks', 'Tasks', 'Workloads'], + ['nodes', 'Nodes', 'Cluster'], + ['logs', 'Logs', 'Logs'], + ['connections', 'Connections', 'App'], + ['prefs', 'Preferences', 'App'], ] +/** The lists, which have a cursor, tabs and a drill-down. */ +export const LIST_VIEWS: View[] = ['stacks', 'services', 'tasks', 'nodes'] -export type ServiceTab = 'overview' | 'logs' | 'errors' +export type ServiceTab = 'overview' | 'events' | 'config' | 'logs' | 'errors' export type Drawer = | { kind: 'service'; key: string; tab: ServiceTab } | { kind: 'node'; id: string } @@ -33,23 +41,8 @@ export type Preset = 'auto' | '15m' | '1h' | '3h' | '24h' | '7d' /** Minutes per fixed preset. `auto` starts at the oldest stored line instead. */ export const PRESETS: Record, number> = { '15m': 15, '1h': 60, '3h': 180, '24h': 1440, '7d': 10080 } export const LEVELS: Level[] = ['ERROR', 'WARN', 'INFO', 'DEBUG'] -export type StackFilter = 'all' | 'problems' | 'pinned' -/** Reads a remembered setting. Settings saved before the rename from swarmdash to Stackline are read under their old name. */ -export function load(key: string): string | null { - try { - return localStorage.getItem(key) ?? localStorage.getItem(key.replace(/^stackline-/, 'swarmdash-')) - } catch { - return null - } -} -export function save(key: string, v: string) { - try { - localStorage.setItem(key, v) - } catch { - // Storage blocked: the setting just isn't remembered. - } -} +export { load, save } /** The pin key of a service: `stack/service`. */ export const pinKey = (s: { stack: string; name: string }) => `${s.stack}/${s.name}` @@ -244,11 +237,21 @@ class AppState { settings = $state(null) update = $state(null) view = $state('overview') + /** What the address says after the page name: `services?stack=shop` narrows the services to one stack. */ + params = $state.raw(new URLSearchParams()) drawer = $state(null) + /** The inspector covers the list instead of standing beside it. */ + inspectorFull = $state(load('stackline-drawer-full') === '1') palette = $state(false) - /** Filters of the stacks view, set from the overview too. */ - stackFilter = $state((load('stackline-stack-filter') as StackFilter) || 'all') - stackQ = $state(load('stackline-stack-q') ?? '') + /** What the command palette starts with. */ + paletteQ = $state('') + help = $state(false) + /** The stack the lists are narrowed to (the top bar's stack picker); empty for all stacks. */ + scope = $state('') + /** Filter, tab, sort and cursor of each list. */ + lists = newLists() + /** The log workspace at the bottom. */ + dock = new DockState() theme = $state((load('stackline-theme') as Theme) || 'system') toast = $state<{ text: string; kind: 'ok' | 'err' } | null>(null) /** Set when the API cannot be used at all (server gone, signed out). */ @@ -285,6 +288,8 @@ class AppState { constructor() { this.logs.restoreSources(this.activeId) + this.dock.restore(this.activeId) + this.scope = load(`stackline-scope-${this.activeId}`) ?? '' // Filters survive moving around the app and restarting it. Sources are kept per connection. $effect.root(() => { $effect(() => { @@ -299,8 +304,16 @@ class AppState { if (location.hash !== h) history.replaceState(history.state, '', h) }) $effect(() => { - save('stackline-stack-filter', this.stackFilter) - save('stackline-stack-q', this.stackQ) + save(`stackline-scope-${untrack(() => this.activeId)}`, this.scope) + }) + // The filter and tab of each list survive a restart. + $effect(() => { + for (const [v, l] of Object.entries(this.lists)) save(`stackline-list-${v}`, JSON.stringify({ q: l.q, tab: l.tab })) + }) + // A stack that is gone (removed, or another connection) no longer narrows the lists. + $effect(() => { + const stacks = this.snap?.stacks + if (stacks && this.scope && !stacks.some((s) => s.name === this.scope)) untrack(() => (this.scope = '')) }) }) } @@ -330,6 +343,8 @@ class AppState { this.activeId = connections.find((c) => c.id === load('stackline-conn'))?.id ?? connections[0]?.id ?? 'local' if (missing) this.notify(`This link is for the connection “${missing}”, which is not set up here. It shows ${this.active?.name ?? 'another one'} instead.`, 'err') else this.logs.restoreSources(this.activeId) + this.dock.restore(this.activeId) + this.scope = load(`stackline-scope-${this.activeId}`) ?? '' } else if (this.#linkConn) save('stackline-conn', this.activeId) this.#linkConn = null } catch (e) { @@ -435,7 +450,9 @@ class AppState { const at = hash.indexOf('?') const h = (at < 0 ? hash : hash.slice(0, at)) as View this.view = VIEWS.some(([v]) => v === h) ? h : 'overview' - if (this.view === 'logs' && at >= 0) this.openLink(new URLSearchParams(hash.slice(at + 1))) + const params = new URLSearchParams(at < 0 ? '' : hash.slice(at + 1)) + this.params = this.view === 'logs' ? new URLSearchParams() : params + if (this.view === 'logs' && at >= 0) this.openLink(params) } /** Opens a log link: its connection, then its filters. */ @@ -458,10 +475,47 @@ class AppState { } go(view: View) { - if (location.hash.slice(1).split('?')[0] === view) this.view = view + if (location.hash.slice(1) === view) this.view = view else location.hash = view } + /** + * Opens a list narrowed by `params` (`stack`, `service` or `node`), as a + * step of Back, so Back returns to the list it came from. + */ + drill(view: View, params: Record = {}, cursor?: string) { + if (cursor !== undefined && (LIST_VIEWS as string[]).includes(view)) this.lists[view as ListView].cursor = cursor + const q = new URLSearchParams(params).toString() + const h = q ? `${view}?${q}` : view + if (location.hash.slice(1) === h) this.route() + else location.hash = h + } + + /** The services list on a tab, all stacks unless the picker narrows them. */ + showServices(tab: 'all' | 'attention' | 'pinned' = 'all') { + const l = this.lists.services + l.q = '' + l.tab = tab + this.drill('services') + } + + setInspectorFull(v: boolean) { + this.inspectorFull = v + save('stackline-drawer-full', v ? '1' : '0') + } + + /** When `g` was pressed: the next key picks a page (g s, g l…) and is not a list's key. */ + goAt = 0 + awaitingGo(): boolean { + return Date.now() - this.goAt < 1000 + } + + /** Opens the command palette, optionally with text already typed. */ + openPalette(q = '') { + this.paletteQ = q + this.palette = true + } + select(id: string) { if (id === this.activeId) return this.activeId = id @@ -474,8 +528,9 @@ class AppState { this.drawer = null this.logs.reset() this.logs.restoreSources(id) - this.stackQ = '' - this.stackFilter = 'all' + for (const l of Object.values(this.lists)) l.reset() + this.scope = load(`stackline-scope-${id}`) ?? '' + this.dock.restore(id) this.poll() } diff --git a/ui/src/lib/storage.ts b/ui/src/lib/storage.ts new file mode 100644 index 0000000..99f48b2 --- /dev/null +++ b/ui/src/lib/storage.ts @@ -0,0 +1,17 @@ +// Settings remembered in the browser. Storage can be blocked; then nothing is remembered. + +/** Reads a remembered setting. Settings saved before the rename from swarmdash to Stackline are read under their old name. */ +export function load(key: string): string | null { + try { + return localStorage.getItem(key) ?? localStorage.getItem(key.replace(/^stackline-/, 'swarmdash-')) + } catch { + return null + } +} +export function save(key: string, v: string) { + try { + localStorage.setItem(key, v) + } catch { + // Storage blocked: the setting just isn't remembered. + } +} diff --git a/ui/src/views/Nodes.svelte b/ui/src/views/Nodes.svelte index 9dd6ea8..30552cc 100644 --- a/ui/src/views/Nodes.svelte +++ b/ui/src/views/Nodes.svelte @@ -1,54 +1,65 @@ + let engines = $derived(new Set(snap.nodes.map((n) => n.engine)).size) -{#if snap.mode === 'standalone'} -
- Plain DockerThis host is not part of a swarm. Containers are grouped by their Compose project. -
-{:else if snap.nodes.length === 1} -
- Single-node swarmThis node is both manager and worker. -
-{/if} + const cols: Col[] = [ + { id: 'name', label: 'Node', sort: (n) => n.hostname }, + { id: 'role', label: 'Role', sort: (n) => nodeRole(n) }, + { id: 'state', label: 'Status', sort: (n) => n.state }, + { id: 'avail', label: 'Availability', sort: (n) => n.availability }, + { id: 'cpu', label: 'CPU', sort: (n) => n.cpuPercent }, + { id: 'mem', label: 'Memory', sort: (n) => memPct(n.memUsed, n.memTotal) }, + { id: 'tasks', label: 'Running', sort: (n) => n.tasksRunning, cls: 'num' }, + { id: 'engine', label: 'Engine', sort: (n) => n.engine, cls: 'hide-sm' }, + { id: 'addr', label: 'Address', sort: (n) => n.addr, cls: 'hide-sm' }, + ] + const tabs: Tab[] = [ + { id: 'all', label: 'All', match: () => true }, + { id: 'attention', label: 'Needs attention', match: (n) => n.state !== 'ready' || n.availability !== 'active' }, + ] + const actions: Act[] = [ + { key: 'l', label: 'Logs', icon: 'logs', run: (n) => app.dock.open('', `node ${n.hostname}`, `node:${n.hostname}`) }, + ] + const match = (n: Node, q: string) => n.hostname.toLowerCase().includes(q) || n.addr.includes(q) || n.role.includes(q) + let inspecting = $derived(app.drawer?.kind === 'node' ? app.drawer.id : null) + -
-
-

{plural(snap.nodes.length, 'node')}

- readydrain / pausedown -
-
- - - - {#each snap.nodes as n (n.id)} - {@const mem = memPct(n.memUsed, n.memTotal)} - (app.drawer = { kind: 'node', id: n.id })}> - - - - - - - - - - - {/each} - -
NodeRoleStatusAvailabilityCPUMemoryRunningEngineAddress
{n.hostname}{#if n.isLocal && snap.nodes.length > 1} this{/if}{nodeRole(n)}{#if n.state === 'ready'}ready{:else}{n.state}{/if}{#if n.availability === 'active'}active{:else}{n.availability}{/if}
70} style:width="{n.cpuPercent ?? 0}%">{pct(n.cpuPercent)}
75} style:width="{mem ?? 0}%">{bytes(n.memUsed)}/{bytes(n.memTotal)}
{n.tasksRunning} 1 && n.engine !== common ? 'var(--warn)' : 'var(--muted)'}>{n.engine}{n.addr}
-
- {#if snap.nodes.some((n) => !n.isLocal && n.cpuPercent === null)} -
CPU and memory are only read for the node Stackline is connected to. Docker does not offer them for other nodes without connecting to each one.
- {/if} -
+ n.id} label={(n) => n.hostname} {cols} {tabs} {match} {actions} + title="Nodes" noun={['node', 'nodes']} {inspecting} + onopen={(n) => app.drill('tasks', { node: n.hostname })} openLabel={snap.mode === 'swarm' ? 'Tasks' : 'Containers'} + oninspect={(n) => (app.drawer = { kind: 'node', id: n.id })}> + {#snippet before()} + {#if snap.mode === 'standalone'} +
Plain Docker This host is not part of a swarm. Containers are grouped by their Compose project.
+ {:else if snap.nodes.length === 1} +
Single-node swarm This node is both manager and worker.
+ {/if} + {#if snap.nodes.some((n) => !n.isLocal && n.cpuPercent === null)} +
CPU and memory are only read for the node Stackline is connected to. Docker does not offer them for other nodes without connecting to each one.
+ {/if} + {/snippet} + {#snippet row(n)} + {@const mem = memPct(n.memUsed, n.memTotal)} + {n.hostname}{#if n.isLocal && snap.nodes.length > 1} this{/if} + {nodeRole(n)} + {#if n.state === 'ready'}ready{:else}{n.state}{/if} + {#if n.availability === 'active'}active{:else}{n.availability}{/if} +
70} style:width="{n.cpuPercent ?? 0}%">{pct(n.cpuPercent)}
+
75} style:width="{mem ?? 0}%">{bytes(n.memUsed)}/{bytes(n.memTotal)}
+ {n.tasksRunning} + 1 && n.engine !== common ? 'var(--warn)' : 'var(--muted)'}>{n.engine} + {n.addr} + {/snippet} +
diff --git a/ui/src/views/Overview.svelte b/ui/src/views/Overview.svelte index 212327c..76b6d2c 100644 --- a/ui/src/views/Overview.svelte +++ b/ui/src/views/Overview.svelte @@ -182,9 +182,7 @@ return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!) } function openStack(name: string) { - app.stackQ = name - app.stackFilter = 'all' - app.go('stacks') + app.drill('services', { stack: name }) } function stackErrors(name: string) { const st = snap.stacks.find((s) => s.name === name) @@ -228,19 +226,19 @@ All nodes → {/if} - - +

Pinned services

{/if} @@ -320,7 +318,7 @@ {/if}
- +
diff --git a/ui/src/views/Services.svelte b/ui/src/views/Services.svelte new file mode 100644 index 0000000..29820e7 --- /dev/null +++ b/ui/src/views/Services.svelte @@ -0,0 +1,92 @@ + + + s.key} label={(s) => s.fullName} {cols} {tabs} {match} {actions} + title="Services" noun={['service', 'services']} {inspecting} + scope={drilled ? `of stack ${drilled}` : undefined} onscopeclear={() => app.drill('services')} + onopen={(s) => app.drill('tasks', { service: pinKey(s) })} openLabel="Tasks" + oninspect={(s) => app.openService(s.key, app.drawer?.kind === 'service' ? app.drawer.tab : 'overview')} + empty={stack ? `No services in the stack ${stack}.` : 'No services or containers on this host.'} + rowClass={(s) => (app.isPinned(s.key) ? 'pinned' : '')}> + {#snippet toolbar()} +
+ + +
+ {/snippet} + {#snippet before()} + {#if app.collect.only && !collected && app.services.length} +
No logs are collected right now. Switch on the services you want to follow in the Collect column.
+ {:else if !app.pinnedServices.length && L.tab === 'all' && !L.q && app.services.length} +
Pin the services you care about most with the star or p. They stay on top here, in the sidebar and in the log sources.
+ {/if} + {/snippet} + {#snippet row(s)} + {@const [cls, state] = svcState(s)} + {@const e = errs(s)} +
+ + + + + + + + + + + + {/snippet} + diff --git a/ui/src/views/Stacks.svelte b/ui/src/views/Stacks.svelte index b7edf10..9a967c7 100644 --- a/ui/src/views/Stacks.svelte +++ b/ui/src/views/Stacks.svelte @@ -1,81 +1,68 @@ - -
-
- {#each FILTERS as [k, label] (k)} - - {/each} -
- -
- - -
- {shown === app.services.length ? plural(shown, 'service') : `${shown} of ${app.services.length} services`} in {plural(snap.stacks.length, 'stack')} -
-{#if !app.pinnedServices.length && app.stackFilter === 'all' && !needle && app.services.length} -
Pin the services you care about most with the star. They stay on top here, on the overview and in the log sources.
-{/if} - -{#if app.collect.only && !collected && app.services.length} -
No logs are collected right now. Switch on the services you want to follow in the Collect column.
-{:else if app.collect.only || app.services.some((s) => !app.collects(s.fullName))} -
Logs are collected for {collected} of {plural(app.services.length, 'service')}. {app.collect.only ? 'New services are not collected until you switch them on.' : 'New services are collected.'}
-{/if} + const cols: Col[] = [ + { id: 'name', label: 'Stack', sort: (r) => r.name }, + { id: 'kind', label: 'Kind', sort: (r) => r.kind }, + { id: 'status', label: 'Status', sort: (r) => r.state.label }, + { id: 'services', label: 'Services', sort: (r) => r.services.length, cls: 'num' }, + { id: 'replicas', label: 'Running', sort: (r) => r.running, cls: 'num' }, + { id: 'cpu', label: 'CPU', sort: (r) => r.cpu, cls: 'num hide-sm' }, + { id: 'mem', label: 'Memory', sort: (r) => r.mem, cls: 'num hide-sm' }, + { id: 'errors', label: 'Errors 1h', sort: (r) => r.errors, cls: 'num' }, + { id: 'collect', label: 'Collect', cls: 'collectcell', title: 'Collect the logs of every service of the stack' }, + { id: 'updated', label: 'Last update', sort: (r) => r.updated, cls: 'hide-sm' }, + ] + const tabs: Tab[] = [ + { id: 'all', label: 'All', match: () => true }, + { id: 'attention', label: 'Needs attention', match: (r) => r.state.cls === 'err' || r.state.cls === 'warn' }, + ] + const actions: Act[] = [ + { key: 'l', label: 'Logs', icon: 'logs', run: (r) => app.dock.open(`stack:${r.name}`, `stack ${r.name}`) }, + { key: 't', label: 'Tasks', run: (r) => app.drill('tasks', { stack: r.name }) }, + { key: 's', label: 'Narrow to it', run: (r) => (app.scope = app.scope === r.name ? '' : r.name) }, + { key: 'o', label: 'Logs page', run: (r) => app.showLogs([`stack:${r.name}`]) }, + ] + const match = (r: Row, q: string) => r.name.toLowerCase().includes(q) || r.services.some((s) => s.name.toLowerCase().includes(q) || s.image.toLowerCase().includes(q)) + -{#if !snap.stacks.length} -
No stacks or containers on this host.
-{:else} -
- {#if groups.length}{:else} -
- {app.stackFilter === 'problems' ? 'Every service runs as it should.' : app.stackFilter === 'pinned' ? 'No pinned services yet. Pin one with the star.' : 'No services match.'} - {#if needle || app.stackFilter !== 'all'}{/if} -
- {/if} -
-{/if} + r.name} label={(r) => r.name} {cols} {tabs} {match} {actions} + title="Stacks" noun={['stack', 'stacks']} onopen={(r) => app.drill('services', { stack: r.name })} openLabel="Services" + empty="No stacks or containers on this host."> + {#snippet row(r)} +
+ + + + + + + + + + {/snippet} + diff --git a/ui/src/views/Tasks.svelte b/ui/src/views/Tasks.svelte new file mode 100644 index 0000000..dd6849b --- /dev/null +++ b/ui/src/views/Tasks.svelte @@ -0,0 +1,77 @@ + + + r.key} label={(r) => `${r.s.fullName}.${r.t.slot}`} {cols} {tabs} {match} {actions} + title={swarm ? 'Tasks' : 'Containers'} noun={swarm ? ['task', 'tasks'] : ['container', 'containers']} + inspecting={inspecting ? items.find((r) => r.s.key === inspecting && r.key === L.cursor)?.key ?? null : null} + {scope} onscopeclear={() => app.drill('tasks')} + onopen={(r) => app.openService(r.s.key, 'overview')} openLabel="Service" + oninspect={(r) => app.openService(r.s.key, app.drawer?.kind === 'service' ? app.drawer.tab : 'overview')} + empty="Nothing is scheduled."> + {#snippet row(r)} + + + + + + + + + + + {/snippet} + From ac656c15ec4c5cf35fdc7a711e32376b347e486f Mon Sep 17 00:00:00 2001 From: "snake.tech" <9079973+snaketech-tu@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:54:26 +0000 Subject: [PATCH 02/11] Make read-only a switch per connection, with restart, stop, start and scale Every connection starts read-only and the server refuses changes until it is switched off; --read-only (STACKLINE_READ_ONLY) keeps every connection locked. With changes allowed, services and containers can be restarted, stopped, started and scaled. Each change asks first and shows its docker command. Nothing is created or removed. --- CHANGELOG.md | 6 +- README.md | 16 +- crates/stackline-core/src/actions.rs | 281 +++++++++++++++++++++ crates/stackline-core/src/config.rs | 6 + crates/stackline-core/src/docker/client.rs | 40 ++- crates/stackline-core/src/lib.rs | 4 +- crates/stackline-core/src/runtime.rs | 63 +++++ crates/stackline/src/api.rs | 50 +++- crates/stackline/src/main.rs | 16 +- crates/stackline/tests/api.rs | 36 +++ e2e/tests/changes.spec.ts | 77 ++++++ ui/src/App.svelte | 14 +- ui/src/app.css | 21 +- ui/src/components/ConfirmChange.svelte | 82 ++++++ ui/src/components/HelpOverlay.svelte | 9 +- ui/src/components/Palette.svelte | 4 + ui/src/components/ResourceList.svelte | 5 +- ui/src/components/ServiceDrawer.svelte | 14 +- ui/src/lib/changes.ts | 61 +++++ ui/src/lib/icons.ts | 5 + ui/src/lib/state.svelte.ts | 83 +++++- ui/src/lib/types.ts | 7 +- ui/src/views/Logs.svelte | 2 +- ui/src/views/Services.svelte | 6 +- ui/src/views/Tasks.svelte | 6 +- 25 files changed, 882 insertions(+), 32 deletions(-) create mode 100644 crates/stackline-core/src/actions.rs create mode 100644 e2e/tests/changes.spec.ts create mode 100644 ui/src/components/ConfirmChange.svelte create mode 100644 ui/src/lib/changes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eb539ba..5a8f05b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,17 @@ its GitHub release, and Stackline shows it in the update dialog. ## Unreleased -Stackline now works like Kubri: lists you can drive with the keyboard, an inspector beside the list, and a log workspace that keeps running while you look around. It is still strictly read-only. +Stackline now works like Kubri: lists you can drive with the keyboard, an inspector beside the list, and a log workspace that keeps running while you look around. Read-only is now a switch per connection, and with it off Stackline can restart, stop, start and scale services. ### Changed -- **New layout.** The sidebar groups the pages (Overview; Workloads: Stacks, Services, Tasks; Cluster: Nodes; Logs) and lists your pinned services, with a box that finds a page or service by name. The top bar has Back and Forward, the connection, a **stack picker** that narrows every list to one stack, **Search or run a command**, the theme and a Read-only badge. A status bar at the bottom shows whether the connection is live and the keys of the page +- **New layout.** The sidebar groups the pages (Overview; Workloads: Stacks, Services, Tasks; Cluster: Nodes; Logs) and lists your pinned services, with a box that finds a page or service by name. The top bar has Back and Forward, the connection, a **stack picker** that narrows every list to one stack, **Search or run a command**, the theme and the read-only switch. A status bar at the bottom shows whether the connection is live and the keys of the page - **Stacks, Services, Tasks and Nodes are lists of their own**, each with a filter (`/`), tabs (All, Needs attention, Pinned or Running), sortable columns and an action bar that names the row it acts on. Move with `j`/`k` or the arrow keys; **Enter opens what is inside**: a stack's services, a service's tasks, a node's tasks. Back returns to the list as you left it, filter and cursor included. Filter and tab are kept after a restart - **The service panel is now an inspector docked beside the list.** Click a row or press `d`. Moving the cursor takes the inspector along. Its tabs are Overview (with the likely problem of a failing service), Events (deploys, stops and crashes), Config (command, health check, networks, mounts, environment, labels), Logs and Errors. The expand button lets it use the whole page. Nodes get an inspector too - **The Tasks page** (Containers on a host without swarm) lists every task with its node, state, exit code, failed attempts, CPU, memory and Docker's message ### Added +- **Read-only is a switch per connection.** Every connection starts read-only, as before: only reads reach Docker. Click **Read-only** in the top bar (or run "Allow changes" from the palette) and confirm to allow changes there; the badge turns amber and says **Changes allowed**. Click it again to lock the connection. The server checks this on every change, and `stackline serve --read-only` (or `STACKLINE_READ_ONLY=1`) keeps every connection locked. Changing a connection's host or kind locks it again +- **Restart, stop, start and scale.** With changes allowed, the Services and Containers lists and the inspector offer them: `r` restarts a service (on swarm, like `docker service update --force`), `x` stops and `u` starts its containers (Compose and lone containers, also one container from the Containers list), `c` scales a replicated swarm service. Each one asks first and shows the `docker` command it amounts to. Nothing is ever created or removed - **Log workspace.** Press `l` on a service, stack, task or node (or **Tail below** in the inspector) to follow its lines in a tab at the bottom of the window. The tabs keep following while you use the rest of the app, can be resized, shrunk with `w`, and are remembered per connection - **Keys everywhere.** `:` or Ctrl K opens the command palette, which also runs commands (narrow the lists to a stack, close the workspace, show the keys). `?` lists every key. `g` then a letter goes to a page (`g s` Services, `g t` Tasks, `g k` Stacks, `g l` Logs…); Alt ← and Alt → go back and forward diff --git a/README.md b/README.md index b07ef4b..d7aec21 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![Release](https://img.shields.io/github/v/release/snaketech-tu/stackline)](https://github.com/snaketech-tu/stackline/releases/latest) [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE) -**A read-only dashboard for Docker Swarm and Compose, with logs that keep stack traces together.** +**A dashboard for Docker Swarm and Compose that is read-only until you say otherwise, with logs that keep stack traces together.** See which services need attention, inspect failed tasks, and investigate errors across replicas. Run Stackline on your computer and connect to Docker locally @@ -101,7 +101,7 @@ the binary to `stackline` (`stackline.exe` on Windows) and putting it on your `P you can customize startup: ```text -stackline serve [--port 7070] [--host 127.0.0.1] [--no-open] [--data-dir DIR] +stackline serve [--port 7070] [--host 127.0.0.1] [--no-open] [--data-dir DIR] [--read-only] ``` ## Connect a host @@ -212,8 +212,16 @@ app uses system notifications; in a browser, allow notifications for the Stackline tab. Only the services whose logs are collected count toward the alert. -Stackline is read-only toward Docker. It writes its own settings, cached logs, -exports and installed updates on the computer where it runs. +**Every connection starts read-only**: only reads reach Docker. Click +**Read-only** in the top bar to allow changes on that connection. Stackline can +then restart, stop and start a service's containers, and restart or scale a +swarm service. It never creates or removes anything, and each change asks first +and shows the `docker` command it amounts to. The server enforces this, not +only the page. `stackline serve --read-only` (or `STACKLINE_READ_ONLY=1`) keeps +every connection read-only, whatever was switched before. + +Stackline writes its own settings, cached logs, exports and installed updates +on the computer where it runs. | Data | Linux | macOS | Windows | | --- | --- | --- | --- | diff --git a/crates/stackline-core/src/actions.rs b/crates/stackline-core/src/actions.rs new file mode 100644 index 0000000..c5f592b --- /dev/null +++ b/crates/stackline-core/src/actions.rs @@ -0,0 +1,281 @@ +//! The changes Stackline can make, once a connection's read-only mode is +//! switched off: restart, stop and start a service's containers, and restart +//! or scale a swarm service. Nothing is ever created or removed. +//! +//! Each action is first turned into a `Plan`: the Docker API calls it needs and +//! the matching `docker` command, so the interface can show what will happen +//! before it does. Planning is pure and tested; `run` only sends the calls. + +use anyhow::{Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::docker::DockerClient; +use crate::model::{Service, StackKind}; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Kind { + Restart, + Stop, + Start, + Scale, +} + +/// What someone asked for, as the API receives it. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub action: Kind, + /// Full service name, e.g. `shop_api`. + pub service: String, + /// The stack it belongs to, for when two stacks have a service of the same name. + #[serde(default)] + pub stack: Option, + /// Only work out the plan, so it can be shown before anything happens. + #[serde(default)] + pub dry: bool, + /// Only this replica (its slot), instead of every one of the service. + #[serde(default)] + pub replica: Option, + /// For `scale`: the new number of replicas. + #[serde(default)] + pub replicas: Option, +} + +/// One call to the Docker API. +#[derive(Clone, Debug, PartialEq)] +pub enum Call { + /// POST a path without a body, such as `/containers//restart`. + Post(String), + /// Change a swarm service: read its spec, change it, write it back. + UpdateService { id: String, change: SpecChange }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SpecChange { + /// Replace every task, like `docker service update --force`. + Force, + Replicas(u32), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Plan { + pub calls: Vec, + /// The same change as a command someone could type. + pub command: String, + /// What was done, said after it worked. + pub done: String, +} + +const MAX_REPLICAS: u32 = 100; + +/// Works out the calls for a request on a service of a stack of `kind`. +pub fn plan(req: &Request, svc: &Service, kind: StackKind) -> Result { + let name = &svc.full_name; + if kind == StackKind::Swarm { + if req.replica.is_some() { + bail!("Swarm restarts a service's tasks as a whole. Restart {name} instead of one replica."); + } + return match req.action { + Kind::Restart => Ok(Plan { + calls: vec![Call::UpdateService { id: svc.id.clone(), change: SpecChange::Force }], + command: format!("docker service update --force {name}"), + done: format!("Restarting the tasks of {name}. Swarm replaces them one after another."), + }), + Kind::Scale => { + if svc.mode != "replicated" { + bail!("{name} runs in {} mode: it has one task per node and cannot be scaled.", svc.mode); + } + let n = req.replicas.ok_or_else(|| anyhow!("Say how many replicas {name} should run."))?; + if n > MAX_REPLICAS { + bail!("At most {MAX_REPLICAS} replicas can be set from Stackline."); + } + Ok(Plan { + calls: vec![Call::UpdateService { id: svc.id.clone(), change: SpecChange::Replicas(n) }], + command: format!("docker service scale {name}={n}"), + done: format!("Scaling {name} to {n} {}.", if n == 1 { "replica" } else { "replicas" }), + }) + } + Kind::Stop | Kind::Start => { + bail!("Swarm keeps a service's tasks running. Scale {name} to 0 to stop it, and back up to start it.") + } + }; + } + if req.action == Kind::Scale { + bail!("Compose services are scaled with `docker compose up --scale`, which Stackline does not run."); + } + let tasks: Vec<_> = svc + .tasks + .iter() + .filter(|t| req.replica.as_ref().is_none_or(|r| &t.slot == r)) + .filter(|t| match req.action { + Kind::Stop => t.state == "running", + Kind::Start => t.state != "running", + _ => true, + }) + .filter_map(|t| t.container_id.clone().map(|id| (id, t.container_name.clone().unwrap_or_else(|| t.slot.clone())))) + .collect(); + if tasks.is_empty() { + bail!(match req.action { + Kind::Stop => format!("Nothing of {name} is running."), + Kind::Start => format!("Everything of {name} is running already."), + _ => format!("{name} has no container to restart."), + }); + } + let verb = match req.action { + Kind::Restart => "restart", + Kind::Stop => "stop", + _ => "start", + }; + let names: Vec<_> = tasks.iter().map(|(_, n)| n.as_str()).collect(); + let n = tasks.len(); + Ok(Plan { + calls: tasks.iter().map(|(id, _)| Call::Post(format!("/containers/{id}/{verb}"))).collect(), + command: format!("docker {verb} {}", names.join(" ")), + done: format!( + "{} {n} {} of {name}.", + match req.action { + Kind::Restart => "Restarted", + Kind::Stop => "Stopped", + _ => "Started", + }, + if n == 1 { "container" } else { "containers" } + ), + }) +} + +/// Changes a service spec as `change` says. The spec is what +/// `GET /services/` returns under `Spec`. +pub fn change_spec(spec: &mut Value, change: SpecChange) -> Result<()> { + match change { + SpecChange::Force => { + let tt = + spec.get_mut("TaskTemplate").and_then(Value::as_object_mut).ok_or_else(|| anyhow!("the service has no task template"))?; + let n = tt.get("ForceUpdate").and_then(Value::as_u64).unwrap_or(0); + tt.insert("ForceUpdate".into(), Value::from(n + 1)); + } + SpecChange::Replicas(n) => { + let r = spec + .pointer_mut("/Mode/Replicated") + .and_then(Value::as_object_mut) + .ok_or_else(|| anyhow!("the service is not replicated, so it cannot be scaled"))?; + r.insert("Replicas".into(), Value::from(n)); + } + } + Ok(()) +} + +/// Sends the calls of a plan, in order. +pub async fn run(client: &DockerClient, plan: &Plan) -> Result<()> { + for call in &plan.calls { + match call { + Call::Post(path) => client.post(path, None).await?, + Call::UpdateService { id, change } => { + let svc: Value = client.get_json(&format!("/services/{id}")).await?; + let version = + svc.pointer("/Version/Index").and_then(Value::as_u64).ok_or_else(|| anyhow!("Docker sent no service version"))?; + let mut spec = svc.get("Spec").cloned().ok_or_else(|| anyhow!("Docker sent no service spec"))?; + change_spec(&mut spec, *change)?; + client.post(&format!("/services/{id}/update?version={version}"), Some(&spec)).await?; + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Task; + use serde_json::json; + + fn task(slot: &str, state: &str, id: Option<&str>) -> Task { + Task { + id: format!("t{slot}"), + slot: slot.into(), + node: "vm".into(), + state: state.into(), + desired_state: "running".into(), + message: None, + error: None, + health: None, + container_id: id.map(str::to_string), + container_name: id.map(|i| format!("shop-api-{i}")), + since: None, + exit_code: None, + failures: 0, + cpu_percent: None, + mem_used: None, + } + } + + fn svc(mode: &str, tasks: Vec) -> Service { + Service { + id: "svc1".into(), + name: "api".into(), + full_name: "shop_api".into(), + stack: "shop".into(), + image: "alpine".into(), + mode: mode.into(), + desired: 2, + running: 1, + ports: vec![], + updated_at: None, + tty: false, + tasks, + cpu_percent: None, + mem_used: None, + config: Default::default(), + } + } + + fn req(action: Kind) -> Request { + Request { action, service: "shop_api".into(), stack: None, dry: false, replica: None, replicas: None } + } + + #[test] + fn compose_restart_hits_every_container_and_says_the_command() { + let s = svc("compose", vec![task("1", "running", Some("a1")), task("2", "exited", Some("b2"))]); + let p = plan(&req(Kind::Restart), &s, StackKind::Compose).unwrap(); + assert_eq!(p.calls, vec![Call::Post("/containers/a1/restart".into()), Call::Post("/containers/b2/restart".into())]); + assert_eq!(p.command, "docker restart shop-api-a1 shop-api-b2"); + } + + #[test] + fn stop_and_start_only_touch_containers_that_need_it() { + let s = svc("compose", vec![task("1", "running", Some("a1")), task("2", "exited", Some("b2"))]); + assert_eq!(plan(&req(Kind::Stop), &s, StackKind::Compose).unwrap().calls, vec![Call::Post("/containers/a1/stop".into())]); + assert_eq!(plan(&req(Kind::Start), &s, StackKind::Compose).unwrap().calls, vec![Call::Post("/containers/b2/start".into())]); + let one = Request { replica: Some("2".into()), ..req(Kind::Restart) }; + assert_eq!(plan(&one, &s, StackKind::Compose).unwrap().calls, vec![Call::Post("/containers/b2/restart".into())]); + let all_up = svc("compose", vec![task("1", "running", Some("a1"))]); + assert!(plan(&req(Kind::Start), &all_up, StackKind::Compose).unwrap_err().to_string().contains("running already")); + } + + #[test] + fn swarm_restarts_and_scales_through_the_service() { + let s = svc("replicated", vec![task("1", "running", Some("a1"))]); + let p = plan(&req(Kind::Restart), &s, StackKind::Swarm).unwrap(); + assert_eq!(p.calls, vec![Call::UpdateService { id: "svc1".into(), change: SpecChange::Force }]); + assert_eq!(p.command, "docker service update --force shop_api"); + let p = plan(&Request { replicas: Some(3), ..req(Kind::Scale) }, &s, StackKind::Swarm).unwrap(); + assert_eq!(p.command, "docker service scale shop_api=3"); + assert!(plan(&Request { replicas: Some(500), ..req(Kind::Scale) }, &s, StackKind::Swarm).is_err()); + assert!(plan(&req(Kind::Stop), &s, StackKind::Swarm).is_err()); + assert!(plan(&Request { replicas: Some(2), ..req(Kind::Scale) }, &svc("global", vec![]), StackKind::Swarm).is_err()); + assert!(plan(&Request { replicas: Some(2), ..req(Kind::Scale) }, &s, StackKind::Compose).is_err()); + } + + #[test] + fn spec_changes_keep_the_rest_of_the_spec() { + let mut spec = json!({ "Name": "shop_api", "TaskTemplate": { "ContainerSpec": { "Image": "alpine" }, "ForceUpdate": 4 }, "Mode": { "Replicated": { "Replicas": 2 } } }); + change_spec(&mut spec, SpecChange::Force).unwrap(); + assert_eq!(spec["TaskTemplate"]["ForceUpdate"], 5); + assert_eq!(spec["TaskTemplate"]["ContainerSpec"]["Image"], "alpine"); + change_spec(&mut spec, SpecChange::Replicas(0)).unwrap(); + assert_eq!(spec["Mode"]["Replicated"]["Replicas"], 0); + let mut global = json!({ "TaskTemplate": {}, "Mode": { "Global": {} } }); + assert!(change_spec(&mut global, SpecChange::Replicas(1)).is_err()); + } +} diff --git a/crates/stackline-core/src/config.rs b/crates/stackline-core/src/config.rs index c9b4fae..5186c89 100644 --- a/crates/stackline-core/src/config.rs +++ b/crates/stackline-core/src/config.rs @@ -21,6 +21,11 @@ pub struct AppConfig { /// Which services' logs are collected, per connection id. Missing means all. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub collect: BTreeMap, + /// Connections whose read-only mode was switched off, so Stackline may + /// restart, stop, start and scale their services. Every other connection, + /// new ones too, is read-only. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub writable: Vec, } /// The services of one connection whose logs Stackline reads and stores. @@ -116,6 +121,7 @@ impl Default for AppConfig { settings: Settings::default(), pins: BTreeMap::new(), collect: BTreeMap::new(), + writable: Vec::new(), } } } diff --git a/crates/stackline-core/src/docker/client.rs b/crates/stackline-core/src/docker/client.rs index ca6c339..113e908 100644 --- a/crates/stackline-core/src/docker/client.rs +++ b/crates/stackline-core/src/docker/client.rs @@ -1,13 +1,14 @@ //! A deliberately small Docker Engine API client. //! -//! Stackline is read-only by construction: this client can only send `GET` -//! requests. There is no code path that creates, updates or removes anything. +//! Everything Stackline reads goes through `get`. The only other request is +//! `post`, used by `actions` alone, and only for a connection whose read-only +//! mode was switched off. Nothing here creates or removes anything. use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use bytes::Bytes; -use http_body_util::{BodyExt, Empty}; +use http_body_util::{BodyExt, Empty, Full}; use hyper::body::Incoming; use hyper::header::{HOST, USER_AGENT}; use hyper::{Method, Request, Response, StatusCode}; @@ -77,6 +78,39 @@ impl DockerClient { Ok(res.into_body()) } + /// Sends a POST with a JSON body (or none) and fails on a non-2xx status. + /// Only `actions` calls this. + pub async fn post(&self, path_and_query: &str, body: Option<&serde_json::Value>) -> Result<()> { + let fut = async { + let stream = + self.endpoint.connect().await.with_context(|| format!("could not reach Docker at {}", self.endpoint.describe()))?; + let (mut sender, conn) = + hyper::client::conn::http1::handshake(TokioIo::new(stream)).await.context("HTTP handshake with Docker failed")?; + tokio::spawn(async move { + let _ = conn.await; + }); + let bytes = body.map(serde_json::to_vec).transpose()?.unwrap_or_default(); + let req = Request::builder() + .method(Method::POST) + .uri(path_and_query) + .header(HOST, "docker") + .header(USER_AGENT, concat!("stackline/", env!("CARGO_PKG_VERSION"))) + .header(hyper::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(bytes)))?; + let res = sender.send_request(req).await.context("Docker request failed")?; + let status = res.status(); + let body = res.into_body().collect().await?.to_bytes(); + if !status.is_success() { + return Err(api_error(status, &body, path_and_query)); + } + Ok(()) + }; + // Stopping a container waits for its grace period, which can be long. + tokio::time::timeout(Duration::from_secs(120), fut) + .await + .map_err(|_| anyhow!("Docker did not answer {path_and_query} within 2 minutes"))? + } + pub async fn ping(&self) -> Result<()> { let res = self.get("/_ping").await?; if res.status() != StatusCode::OK { diff --git a/crates/stackline-core/src/lib.rs b/crates/stackline-core/src/lib.rs index 9eca4a9..b038864 100644 --- a/crates/stackline-core/src/lib.rs +++ b/crates/stackline-core/src/lib.rs @@ -1,4 +1,5 @@ -//! Stackline core: a read-only view of Docker hosts, swarms and their logs. +//! Stackline core: a view of Docker hosts, swarms and their logs. Read-only +//! unless a connection's read-only mode is switched off; see `actions`. /// Compiles a regular expression once and caches it. macro_rules! regex { @@ -10,6 +11,7 @@ macro_rules! regex { }}; } +pub mod actions; pub mod alerts; pub mod collector; pub mod config; diff --git a/crates/stackline-core/src/runtime.rs b/crates/stackline-core/src/runtime.rs index 3e112dd..8ebacab 100644 --- a/crates/stackline-core/src/runtime.rs +++ b/crates/stackline-core/src/runtime.rs @@ -92,6 +92,26 @@ impl Runtime { Ok(Some(crate::details::fetch(&client, svc, kind).await?)) } + /// Makes a change (restart, stop, start, scale). The caller checks that the + /// connection is not read-only. Returns what was done and the matching command. + pub async fn act(&self, req: &crate::actions::Request) -> Result { + let snap = self.snapshot().ok_or_else(|| anyhow::anyhow!("Not connected yet."))?; + let (svc, kind) = snap + .stacks + .iter() + .filter(|st| req.stack.as_ref().is_none_or(|n| &st.name == n)) + .find_map(|st| st.services.iter().find(|s| s.full_name == req.service).map(|s| (s, st.kind))) + .ok_or_else(|| anyhow::anyhow!("{} is not running here.", req.service))?; + let plan = crate::actions::plan(req, svc, kind)?; + if req.dry { + return Ok(plan); + } + let client = self.client().await?; + tracing::info!(connection = %self.cfg.name, command = %plan.command, "changing a service"); + crate::actions::run(&client, &plan).await?; + Ok(plan) + } + /// Returns a working client, (re)opening the SSH tunnel when needed. async fn client(&self) -> Result { let mut guard = self.client.lock().await; @@ -198,6 +218,28 @@ fn explain(msg: &str) -> String { } } +/// Whether changes are allowed on a connection. +#[derive(Clone, Copy, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Access { + pub writable: bool, + /// Read-only for every connection, set when Stackline was started. + pub forced: bool, +} + +static FORCE_READ_ONLY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Makes every connection read-only for as long as Stackline runs (`--read-only`). +pub fn force_read_only() { + FORCE_READ_ONLY.store(true, std::sync::atomic::Ordering::Relaxed); +} + +/// `--read-only` or `STACKLINE_READ_ONLY=1`: nothing can be changed. +pub fn read_only_forced() -> bool { + FORCE_READ_ONLY.load(std::sync::atomic::Ordering::Relaxed) + || crate::config::env_var("READ_ONLY").is_some_and(|v| !matches!(v.as_str(), "" | "0" | "false" | "no")) +} + /// Owns configuration, the log store and all opened connections. pub struct Manager { paths: Paths, @@ -239,6 +281,27 @@ impl Manager { self.config.read().unwrap().clone() } + /// Whether a connection may be changed, and whether read-only is forced + /// for every connection (`--read-only` or `STACKLINE_READ_ONLY`). + pub fn access(&self, id: &str) -> Access { + let forced = read_only_forced(); + Access { writable: !forced && self.config.read().unwrap().writable.iter().any(|w| w == id), forced } + } + + /// Switches a connection's read-only mode off (`writable`) or on. + pub fn set_writable(&self, id: &str, writable: bool) -> Result { + if writable && read_only_forced() { + anyhow::bail!("Stackline was started read-only, so no connection can be changed."); + } + self.update_config(|c| { + c.writable.retain(|w| w != id); + if writable { + c.writable.push(id.to_string()); + } + })?; + Ok(self.access(id)) + } + pub fn store_path(&self) -> &std::path::Path { &self.paths.store } diff --git a/crates/stackline/src/api.rs b/crates/stackline/src/api.rs index c116405..1847617 100644 --- a/crates/stackline/src/api.rs +++ b/crates/stackline/src/api.rs @@ -1,5 +1,7 @@ -//! JSON API used by the UI. Every Docker access behind it is read-only; the -//! only things written are Stackline's own settings and log exports. +//! JSON API used by the UI. Every Docker access behind it is a read, except +//! `POST /c/{id}/action`, which only works on a connection whose read-only +//! mode was switched off (`PUT /c/{id}/access`). Besides that, the only things +//! written are Stackline's own settings and log exports. use std::sync::Arc; @@ -49,6 +51,8 @@ pub fn router() -> Router> { .route("/connections/{id}", put(update_connection).delete(delete_connection)) .route("/c/{id}/snapshot", get(snapshot)) .route("/c/{id}/pins", put(put_pins)) + .route("/c/{id}/access", put(put_access)) + .route("/c/{id}/action", post(action)) .route("/c/{id}/collect", put(put_collect)) .route("/c/{id}/usage", get(usage)) .route("/c/{id}/usage/clear", post(clear_usage)) @@ -130,6 +134,10 @@ async fn update_connection(st: St, Path(id): Path, Json(input): Json) -> Result) -> Result, ApiErro let mut cfg = st.manager.config(); let pins = cfg.pins.remove(&id).unwrap_or_default(); let collect = cfg.collect.remove(&id).unwrap_or_default(); - Ok(Json(json!({ "state": rt.state(), "snapshot": rt.snapshot().as_deref(), "pins": pins, "collect": collect }))) + Ok(Json( + json!({ "state": rt.state(), "snapshot": rt.snapshot().as_deref(), "pins": pins, "collect": collect, "access": st.manager.access(&id) }), + )) +} + +#[derive(Deserialize)] +struct AccessInput { + writable: bool, +} + +/// Switches a connection's read-only mode off or on. +async fn put_access(st: St, Path(id): Path, Json(input): Json) -> Result, ApiError> { + st.manager.runtime(&id).ok_or_else(not_found)?; + let a = st.manager.set_writable(&id, input.writable).map_err(|e| ApiError(StatusCode::FORBIDDEN, format!("{e:#}")))?; + tracing::info!(connection = %id, writable = a.writable, "read-only mode changed"); + Ok(Json(json!(a))) +} + +/// Restarts, stops, starts or scales a service. Refused while the connection is read-only. +async fn action(st: St, Path(id): Path, Json(req): Json) -> Result, ApiError> { + let rt = st.manager.runtime(&id).ok_or_else(not_found)?; + let access = st.manager.access(&id); + if !access.writable { + let why = if access.forced { + "Stackline was started read-only." + } else { + "This connection is read-only. Switch read-only off to make changes." + }; + return Err(ApiError(StatusCode::FORBIDDEN, why.into())); + } + let plan = rt.act(&req).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?; + // A dry run says what would happen; `done` is only sent once it happened. + if req.dry { + return Ok(Json(json!({ "command": plan.command, "calls": plan.calls.len() }))); + } + Ok(Json(json!({ "done": plan.done, "command": plan.command }))) } /// Replaces the pinned services of a connection. Each pin is `stack/service`. diff --git a/crates/stackline/src/main.rs b/crates/stackline/src/main.rs index 68f659b..6ada839 100644 --- a/crates/stackline/src/main.rs +++ b/crates/stackline/src/main.rs @@ -6,7 +6,7 @@ use clap::{Parser, Subcommand}; use stackline::{ServeOptions, start, update}; use stackline_core::config::Paths; -/// A read-only dashboard for Docker Swarm and Compose stacks. +/// A dashboard for Docker Swarm and Compose stacks, read-only unless you switch it off per connection. #[derive(Parser)] #[command(version, about)] struct Cli { @@ -30,6 +30,10 @@ enum Command { /// Keep config and logs in this folder instead of the default locations. #[arg(long)] data_dir: Option, + /// Never change anything, whatever a connection's read-only switch says. + /// The same as STACKLINE_READ_ONLY=1. + #[arg(long)] + read_only: bool, }, } @@ -43,10 +47,14 @@ async fn main() -> Result<()> { // file has been swapped and the new one must be started from this path. let exe = std::env::current_exe()?; let restarted = stackline_core::config::env_var("RESTARTED").is_some(); - let (port, host, no_open, data_dir) = match cli.command { - Some(Command::Serve { port, host, no_open, data_dir }) => (port, host, no_open, data_dir), - None => (7070, IpAddr::from([127, 0, 0, 1]), false, None), + let (port, host, no_open, data_dir, read_only) = match cli.command { + Some(Command::Serve { port, host, no_open, data_dir, read_only }) => (port, host, no_open, data_dir, read_only), + None => (7070, IpAddr::from([127, 0, 0, 1]), false, None, false), }; + // A restart after an update passes the same arguments, so this stays on. + if read_only { + stackline_core::runtime::force_read_only(); + } let paths = data_dir.as_deref().map(Paths::in_dir).unwrap_or_else(Paths::default_paths); let server = start(ServeOptions { addr: SocketAddr::new(host, port), paths, desktop: false, installer: None, notifier: None }).await?; let url = server.url(); diff --git a/crates/stackline/tests/api.rs b/crates/stackline/tests/api.rs index 5af22e3..170e1d5 100644 --- a/crates/stackline/tests/api.rs +++ b/crates/stackline/tests/api.rs @@ -172,3 +172,39 @@ async fn deleting_a_connection_forgets_its_pins_and_its_collect_choice() { send(&app, req("POST", "/api/connections", Some(json!({ "name": "x", "kind": "ssh", "target": "-oProxyCommand=evil" })))).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); } + +#[tokio::test] +async fn a_connection_is_read_only_until_switched_and_again_on_another_host() { + let app = app(); + let input = json!({ "name": "Prod EU", "kind": "ssh", "target": "deploy@203.0.113.10", "socket": "/var/run/docker.sock" }); + let (status, c) = send(&app, req("POST", "/api/connections", Some(input))).await; + assert_eq!(status, StatusCode::OK, "{c}"); + + // New connections are read-only: a change is refused before Docker is asked. + let restart = json!({ "action": "restart", "service": "shop_api" }); + let (status, body) = send(&app, req("POST", "/api/c/prod-eu/action", Some(restart.clone()))).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + assert!(body["error"].as_str().unwrap().contains("read-only"), "{body}"); + let (_, snap) = send(&app, req("GET", "/api/c/prod-eu/snapshot", None)).await; + assert_eq!(snap["access"], json!({ "writable": false, "forced": false })); + + let (status, a) = send(&app, req("PUT", "/api/c/prod-eu/access", Some(json!({ "writable": true })))).await; + assert_eq!(status, StatusCode::OK, "{a}"); + assert_eq!(a["writable"], true); + assert_eq!(app.state.manager.config().writable, vec!["prod-eu"]); + // Allowed now; it fails only because nothing is connected in this test. + let (status, body) = send(&app, req("POST", "/api/c/prod-eu/action", Some(restart))).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert!(!body["error"].as_str().unwrap().contains("read-only"), "{body}"); + + // Pointing the connection at another host makes it read-only again. + let other = json!({ "name": "Prod EU", "kind": "ssh", "target": "deploy@203.0.113.11", "socket": "/var/run/docker.sock" }); + let (status, _) = send(&app, req("PUT", "/api/connections/prod-eu", Some(other))).await; + assert_eq!(status, StatusCode::OK); + assert!(app.state.manager.config().writable.is_empty()); + + // So does deleting it, if it is ever added again under the same name. + send(&app, req("PUT", "/api/c/prod-eu/access", Some(json!({ "writable": true })))).await; + send(&app, req("DELETE", "/api/connections/prod-eu", None)).await; + assert!(app.state.manager.config().writable.is_empty()); +} diff --git a/e2e/tests/changes.spec.ts b/e2e/tests/changes.spec.ts new file mode 100644 index 0000000..4b78c7b --- /dev/null +++ b/e2e/tests/changes.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test' +import { TOKEN } from '../playwright.config' +import { inspect, open, serviceRow } from './helpers' + +const headers = { 'x-stackline-token': TOKEN } + +// Whatever a test leaves behind, the connection is read-only again for the next one. +test.afterEach(async ({ request }) => { + await request.put('/api/c/local/access', { headers, data: { writable: false } }) +}) + +test('a connection refuses changes until read-only is switched off', async ({ request }) => { + const snap = await (await request.get('/api/c/local/snapshot', { headers })).json() + expect(snap.access).toEqual({ writable: false, forced: false }) + const r = await request.post('/api/c/local/action', { headers, data: { action: 'restart', service: 'demo_shop', dry: true } }) + expect(r.status()).toBe(403) + expect((await r.json()).error).toContain('read-only') +}) + +test('switching read-only off asks first, and every change shows its docker command before it runs', async ({ page }) => { + await open(page, 'services') + const badge = page.locator('header.top').getByRole('switch', { name: /Read-only/ }) + await expect(badge).toHaveAttribute('aria-checked', 'true') + const bar = page.getByLabel('Actions') + await serviceRow(page, 'demo/shop').click() + // While read-only, the lists offer no changes. + await expect(bar.getByRole('button', { name: /Restart/ })).toHaveCount(0) + + await badge.click() + const ask = page.getByRole('alertdialog') + await expect(ask).toContainText('Allow changes on This computer?') + // Cancelled: still read-only. + await page.keyboard.press('Escape') + await expect(ask).toHaveCount(0) + await expect(badge).toHaveAttribute('aria-checked', 'true') + + await badge.click() + await ask.getByRole('button', { name: 'Allow changes' }).click() + const open_ = page.locator('header.top').getByRole('switch', { name: /Changes allowed/ }) + await expect(open_).toHaveAttribute('aria-checked', 'false') + await expect(page.locator('.rl-foot')).toContainText('each one asks first') + + // `r` on the row asks to restart, naming the containers like `docker restart` would. + await page.locator('main.view').click({ position: { x: 5, y: 5 } }) + await page.keyboard.press('r') + await expect(ask).toContainText('Restart demo_shop?') + await expect(ask.locator('code')).toHaveText(/^docker restart \S+shop\S* \S+shop\S*$/) + await ask.getByRole('button', { name: 'Cancel' }).click() + await expect(ask).toHaveCount(0) + + // Stop on one container from the containers list. + await serviceRow(page, 'demo/shop').dblclick() + await expect(page).toHaveURL(/#tasks\?service=demo%2Fshop$/) + await page.getByRole('tab', { name: /^Running/ }).click() + await page.keyboard.press('j') + await bar.getByRole('button', { name: /Stop/ }).click() + await expect(ask.locator('code')).toHaveText(/^docker stop \S+$/) + await page.keyboard.press('Escape') + await expect(ask).toHaveCount(0) + + // Back to read-only with one click; the changes are gone from the lists. + await open_.click() + await expect(badge).toHaveAttribute('aria-checked', 'true') + await expect(bar.getByRole('button', { name: /Stop/ })).toHaveCount(0) +}) + +test('the inspector offers the changes too, while they are allowed', async ({ page, request }) => { + await request.put('/api/c/local/access', { headers, data: { writable: true } }) + const panel = await inspect(page, 'demo/worker') + await expect(panel.getByRole('group', { name: 'Change this service' })).toBeVisible() + await panel.getByRole('button', { name: 'Restart' }).click() + await expect(page.getByRole('alertdialog').locator('code')).toContainText('docker restart') + await page.keyboard.press('Escape') + await expect(page.getByRole('alertdialog')).toHaveCount(0) + // Escape closed only the dialog, not the inspector. + await expect(panel).toBeVisible() +}) diff --git a/ui/src/App.svelte b/ui/src/App.svelte index c64fcf8..5ace6e8 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -23,6 +23,7 @@ import Palette from './components/Palette.svelte' import HelpOverlay from './components/HelpOverlay.svelte' import Dock from './components/Dock.svelte' + import ConfirmChange from './components/ConfirmChange.svelte' onMount(() => { app.init() @@ -50,7 +51,8 @@ const base: [string, string][] = [[':', 'command'], ['/', 'filter']] if (isList) { const open = app.view === 'stacks' ? 'services' : app.view === 'services' ? 'tasks' : app.view === 'nodes' ? 'tasks' : 'service' - return [...base, ['↵', open], ['d', 'inspect'], ['l', 'logs'], ...(app.view === 'services' ? ([['p', 'pin']] as [string, string][]) : []), ['?', 'help']] + const change: [string, string][] = app.access.writable && (app.view === 'services' || app.view === 'tasks') ? [['r', 'restart']] : [] + return [...base, ['↵', open], ['d', 'inspect'], ['l', 'logs'], ...change, ...(app.view === 'services' ? ([['p', 'pin']] as [string, string][]) : []), ['?', 'help']] } if (app.view === 'logs') return [[':', 'command'], ['/', 'search'], ['j k', 'move'], ['f', 'fill window'], ['?', 'help']] return [[':', 'command'], ['g s', 'services'], ['g l', 'logs'], ['?', 'help']] @@ -72,6 +74,7 @@ else history.forward() return } + if (app.ask) return if (e.key === 'Escape') { if (app.palette) app.palette = false else if (app.help) app.help = false @@ -127,7 +130,13 @@ - Read-only + {#if needsConn && app.snap} + + {/if}
@@ -188,5 +197,6 @@ {#if app.palette}{/if} {#if app.help}{/if} +{#if app.ask}{#key app.ask}{/key}{/if} {#if app.toast}
{app.toast.text}
{/if} diff --git a/ui/src/app.css b/ui/src/app.css index 15c3743..4d37ebd 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -609,7 +609,23 @@ tr.editrow>td{background:var(--panel-2);padding:16px 18px} .picker .cap{grid-column:1;font-size:10.5px;color:var(--muted);line-height:1.2} .picker .val{grid-column:1;font-weight:600;line-height:1.25;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .picker svg{grid-column:2;grid-row:1/3;color:var(--muted)} -.ro-badge{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px;border-radius:8px;font-size:12px;font-weight:600;color:var(--accent);background:color-mix(in srgb,var(--accent) 13%,var(--panel));white-space:nowrap} +.ro-badge{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px;border-radius:8px;border:1px solid transparent;font:inherit;font-size:12px;font-weight:600;color:var(--accent);background:color-mix(in srgb,var(--accent) 13%,var(--panel));white-space:nowrap;cursor:pointer} +.ro-badge:hover{border-color:color-mix(in srgb,var(--accent) 45%,transparent)} +.ro-badge.open{color:var(--warn);background:color-mix(in srgb,var(--warn) 14%,var(--panel))} +.ro-badge.open:hover{border-color:color-mix(in srgb,var(--warn) 50%,transparent)} +.ro-badge.forced{cursor:default} +.ask-dlg{width:min(520px,100%);display:flex;flex-direction:column} +.ask-dlg .c-body{display:grid;gap:12px;padding:18px 20px} +.ask-dlg h3{margin:0;font-size:16px} +.ask-dlg p{margin:0;color:var(--muted);line-height:1.5} +.ask-dlg .c-cmd{display:grid;gap:4px;font-size:12px} +.ask-dlg .c-cmd code{display:block;padding:8px 10px;border-radius:8px;background:var(--panel-2);border:1px solid var(--line);font-family:var(--mono);font-size:12.5px;color:var(--fg);overflow-wrap:anywhere} +.ask-dlg .c-num{display:flex;align-items:center;gap:10px;font-weight:600} +.ask-dlg .c-num input{width:90px;height:30px;padding:0 8px;border-radius:7px;border:1px solid var(--line);background:var(--panel);color:var(--fg);font:inherit} +.ask-dlg .c-num input[aria-invalid="true"]{border-color:var(--err)} +.ask-dlg .c-err{color:var(--err)} +.ask-dlg .c-foot{display:flex;align-items:center;gap:8px;padding:12px 16px;border-top:1px solid var(--line);background:var(--panel-2);font-size:12px} +.ask-dlg .c-foot .btn{font-size:13px} .area{flex:1;min-height:0;display:flex;flex-direction:column} .content{flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr)} .content.with-inspector{grid-template-columns:minmax(0,1fr) min(clamp(420px,38vw,720px),calc(100% - 360px))} @@ -721,3 +737,6 @@ tr.editrow>td{background:var(--panel-2);padding:16px 18px} .statusbar .hints{display:none} .rl-actions .target.none,.rl-actions kbd{display:none} } +.warn-t{color:var(--warn)} +.changes{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:8px 10px;border-radius:8px;border:1px dashed color-mix(in srgb,var(--warn) 45%,var(--line));background:color-mix(in srgb,var(--warn) 6%,var(--panel))} +.changes .muted{display:inline-flex;align-items:center;gap:5px;font-size:12px;margin-right:auto;color:var(--warn)} diff --git a/ui/src/components/ConfirmChange.svelte b/ui/src/components/ConfirmChange.svelte new file mode 100644 index 0000000..082baac --- /dev/null +++ b/ui/src/components/ConfirmChange.svelte @@ -0,0 +1,82 @@ + + + +
{ if (e.target === e.currentTarget) close() }} onkeydown={keydown}> +
+
+

{ask.title}

+ {#if ask.text}

{ask.text}

{/if} + {#if ask.replicas !== undefined} + + {#if !valid}

Pick a whole number from 0 to 100.

{/if} + {/if} + {#if command} +
Same as{command}
+ {/if} + {#if error}{/if} +
+
+ Enter to confirm, Esc to cancel + + + +
+
+
diff --git a/ui/src/components/HelpOverlay.svelte b/ui/src/components/HelpOverlay.svelte index 7fc0c1f..a64857d 100644 --- a/ui/src/components/HelpOverlay.svelte +++ b/ui/src/components/HelpOverlay.svelte @@ -27,6 +27,13 @@ ['s', 'Narrow every list to the stack'], ['/', 'Filter the list'], ]], + ['When changes are allowed', [ + ['r', 'Restart the service or container'], + ['x', 'Stop it (Compose and lone containers)'], + ['u', 'Start it again'], + ['c', 'Scale a swarm service'], + ['Enter', 'Confirm in the dialog, which shows the docker command'], + ]], ['In the inspector', [ ['[ ]', 'Previous and next service'], ]], @@ -43,7 +50,7 @@
{ if (e.target === e.currentTarget) app.help = false }}>
StackServicesStatusErrors 1hLast updateActions
{s.name}{s.stack}{s.running}/{s.desired}{state}{s.image}{s.ports.join(', ') || '–'}{pct(s.cpuPercent, 1)}{bytes(s.memUsed)}{e}{s.updatedAt ? ago(s.updatedAt, app.now) : '–'}{r.name}{#if app.scope === r.name} picked{/if}{r.kind}{r.state.label}{#if r.state.stopped}{r.state.stopped} stopped{/if}{r.services.length}{r.running}/{r.desired}{pct(r.cpu, 1)}{bytes(r.mem)}{r.errors} s.fullName)} label={r.name} />{r.updated ? ago(r.updated, app.now) : '–'}{r.name}{r.s.stack}{r.t.node}{r.pill[1]}{#if r.t.health && r.t.health !== 'unhealthy' && r.t.state === 'running'} {r.t.health}{/if}{r.t.exitCode ?? '–'}{r.t.failures}{pct(r.t.cpuPercent, 1)}{bytes(r.t.memUsed)}{r.t.since ? ago(r.t.since, app.now) : '–'}{r.t.error || (r.t.state !== 'running' ? r.t.message : '') || ''}
{#each notable as e (e.ts + e.source + e.kind)} - +
{when(e.ts)}{e.kind === 'deploy' ? '' : `#${e.replica}`}{isServiceWide(e) ? '' : `#${e.replica}`} {eventLabel(e)} - {#if e.kind === 'deploy'} - + {#if e.kind === 'deploy' || e.kind === 'action'} + {:else} {/if} @@ -354,7 +354,7 @@ {/if} - {#if !notable.length}
No deploys, stops or crashes of this service in the last 24 hours.
{/if} + {#if !notable.length}
No deploys, changes, stops or crashes of this service in the last 24 hours.
{/if} {:else if tab === 'config'} {@const d = details} {#if !d}
{detailsError ? `The configuration could not be read: ${detailsError}` : 'Reading the configuration…'}
{/if} diff --git a/ui/src/lib/events.ts b/ui/src/lib/events.ts index 12411e1..d156ec6 100644 --- a/ui/src/lib/events.ts +++ b/ui/src/lib/events.ts @@ -25,6 +25,8 @@ export function eventLabel(e: DockerEvent): string { return (e.detail ? `failed: ${e.detail}` : 'failed') + code case 'deploy': return e.detail ? `deployed: ${e.detail}` : 'deployed' + case 'action': + return e.detail ? `changed: ${e.detail}` : 'changed by Stackline' } } @@ -33,11 +35,15 @@ export function eventColor(e: DockerEvent): string { if (isCrash(e)) return 'var(--err)' if (e.kind === 'unhealthy') return 'var(--warn)' if (e.kind === 'deploy') return 'var(--accent)' + if (e.kind === 'action') return 'var(--info)' return 'var(--muted)' } +/** Whether an event belongs to the whole service: a deploy, or a change made to all replicas. */ +export const isServiceWide = (e: DockerEvent) => e.kind === 'deploy' || (e.kind === 'action' && !e.replica) + /** Who an event happened to: `shop_api #2`, or just the service for a deploy. */ -export const eventWho = (e: DockerEvent) => (e.kind === 'deploy' ? e.service : `${e.service} #${e.replica}`) +export const eventWho = (e: DockerEvent) => (isServiceWide(e) ? e.service : `${e.service} #${e.replica}`) /** The 2 minutes before an event and 15 s after it, in milliseconds. */ export function beforeEvent(e: DockerEvent): [number, number] { diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index d38123d..2c6a33b 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -129,7 +129,7 @@ export interface AlertsResponse { alerts: Alert[]; native: boolean } /** A service together with the stack it belongs to. */ export interface ServiceRef extends Service { kind: Stack['kind']; key: string } -export type EventKind = 'start' | 'exit' | 'stop' | 'crash' | 'oom' | 'unhealthy' | 'failed' | 'deploy' +export type EventKind = 'start' | 'exit' | 'stop' | 'crash' | 'oom' | 'unhealthy' | 'failed' | 'deploy' | 'action' /** A container start, stop or crash. `ts` in Unix microseconds, like log entries. */ export interface DockerEvent { ts: number; stack: string; service: string; replica: string; node: string; source: string diff --git a/ui/src/views/Overview.svelte b/ui/src/views/Overview.svelte index 76b6d2c..84fce6f 100644 --- a/ui/src/views/Overview.svelte +++ b/ui/src/views/Overview.svelte @@ -50,7 +50,7 @@ const to = Date.now() fetchEvents(id, { from: to - 6 * 3600e3, to: to + 60e3, limit: 2000 }) .then((x) => { - if (id === app.activeId) events = x.filter((e) => isCrash(e) || e.kind === 'unhealthy' || e.kind === 'deploy') + if (id === app.activeId) events = x.filter((e) => isCrash(e) || e.kind === 'unhealthy' || e.kind === 'deploy' || e.kind === 'action') }) .catch(() => {}) }) @@ -273,8 +273,9 @@ {/if}
ErrorsWarningsExceptions - {#if events.some((e) => e.kind !== 'deploy')}Crashes{/if} + {#if events.some((e) => e.kind !== 'deploy' && e.kind !== 'action')}Crashes{/if} {#if events.some((e) => e.kind === 'deploy')}Deploys{/if} + {#if events.some((e) => e.kind === 'action')}Changes{/if}
From 2328ad34fa3fec80dcf5dbdbc5a491210f6efdfd Mon Sep 17 00:00:00 2001 From: "snake.tech" <9079973+snaketech-tu@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:27:43 +0000 Subject: [PATCH 09/11] Describe the desktop app as read-only by default, not read-only --- desktop/src-tauri/tauri.conf.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 8b99975..fba7cd3 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -16,8 +16,8 @@ "targets": ["deb", "rpm", "appimage", "nsis", "msi", "dmg", "app"], "createUpdaterArtifacts": true, "category": "DeveloperTool", - "shortDescription": "Read-only dashboard for Docker Swarm and Compose stacks", - "longDescription": "Stackline shows nodes, stacks, services and logs of Docker Swarm and Docker Compose hosts, locally or over SSH. It only reads and never changes anything on the server.", + "shortDescription": "Dashboard for Docker Swarm and Compose stacks", + "longDescription": "Stackline shows nodes, stacks, services and logs of Docker Swarm and Docker Compose hosts, locally or over SSH. It is read-only by default; restarts, stops and scaling can be allowed per server, and each one is shown before it runs.", "copyright": "SnakeTech", "license": "Apache-2.0", "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], From 839962b104cd3f20e8f8e49d96face8b67eba1f7 Mon Sep 17 00:00:00 2001 From: "snake.tech" <9079973+snaketech-tu@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:30:37 +0000 Subject: [PATCH 10/11] Test that a change runs only as confirmed --- e2e/tests/changes.spec.ts | 12 +++--------- e2e/tests/events.spec.ts | 6 +++--- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/e2e/tests/changes.spec.ts b/e2e/tests/changes.spec.ts index 8eb2ff6..a2f6d2d 100644 --- a/e2e/tests/changes.spec.ts +++ b/e2e/tests/changes.spec.ts @@ -92,19 +92,13 @@ test('the palette finds "allow changes", and the dialog keeps its keys after a c await expect(page.locator('header.top').getByRole('switch')).toHaveAttribute('aria-checked', 'true') }) -test('a change runs only as confirmed, and shows up among the service events', async ({ request }) => { +test('a change runs only as confirmed', async ({ request }) => { await request.put('/api/c/local/access', { headers, data: { writable: true } }) const ask = { action: 'restart', service: 'demo_worker' } const dry = await (await request.post('/api/c/local/action', { headers, data: { ...ask, dry: true } })).json() expect(dry.command).toContain('docker restart') - // A plan made from another state of the service is refused. + expect(dry.plan).toBeTruthy() + // A plan made from another state of the service is refused before anything is sent. const stale = await request.post('/api/c/local/action', { headers, data: { ...ask, expect: 'not-this-plan' } }) expect(stale.status()).toBe(409) - const before = Date.now() - const done = await request.post('/api/c/local/action', { headers, data: { ...ask, expect: dry.plan } }) - expect(done.ok()).toBe(true) - const r = await request.get(`/api/c/local/events?from=${before - 60e3}&to=${Date.now() + 60e3}&service=demo_worker&limit=100`, { headers }) - const { events } = await r.json() - const change = events.find((e: { kind: string }) => e.kind === 'action') - expect(change?.detail).toMatch(/^docker restart .* · done$/) }) diff --git a/e2e/tests/events.spec.ts b/e2e/tests/events.spec.ts index 2763810..2270ee0 100644 --- a/e2e/tests/events.spec.ts +++ b/e2e/tests/events.spec.ts @@ -4,12 +4,12 @@ import { inspect } from './helpers' // `migrate` prints its lines and exits with 0 right after the demo starts. test('a service panel lists when its containers stopped, and opens the lines before', async ({ page }) => { const drawer = await inspect(page, 'demo/migrate', 'Events') - const list = drawer.locator('div', { has: page.getByRole('heading', { name: /Deploys, stops and crashes/ }) }) + const list = drawer.locator('div', { has: page.getByRole('heading', { name: /Deploys, changes, stops and crashes/ }) }) const row = list.getByRole('row', { name: /exited · exit 0/ }).first() await expect(row).toBeVisible() await expect(row).toContainText('#1') // An exit on purpose is not a crash. - await expect(drawer.getByRole('heading', { name: /Deploys, stops and crashes/ })).not.toContainText(/\d+ crash/) + await expect(drawer.getByRole('heading', { name: /Deploys, changes, stops and crashes/ })).not.toContainText(/\d+ crash/) await row.getByRole('button', { name: 'Lines before' }).click() await expect(page.locator('.chiprow')).toContainText('demo_migrate') @@ -21,7 +21,7 @@ test('a service panel lists when its containers stopped, and opens the lines bef // The demo was deployed when the tests started: each service has a deploy marker. test('a deploy is listed with what it runs, and opens the lines after it', async ({ page }) => { const drawer = await inspect(page, 'demo/shop', 'Events') - const list = drawer.locator('div', { has: page.getByRole('heading', { name: /Deploys, stops and crashes/ }) }) + const list = drawer.locator('div', { has: page.getByRole('heading', { name: /Deploys, changes, stops and crashes/ }) }) const row = list.getByRole('row', { name: /deployed: image alpine:3.20/ }) await expect(row).toBeVisible() await expect(row).not.toContainText('#') From 7cd0483da448705d8c5c81344baa0d4522457d80 Mon Sep 17 00:00:00 2001 From: "snake.tech" <9079973+snaketech-tu@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:15:04 +0000 Subject: [PATCH 11/11] Give the slow first read test room on slow runners The fake slow replica answered after 4 s, and two polls may wait 2 s each for first reads, so on a busy runner it finished during the second poll. It now takes 8 s. --- crates/stackline-core/src/collector.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/stackline-core/src/collector.rs b/crates/stackline-core/src/collector.rs index 49abe2d..69ab72a 100644 --- a/crates/stackline-core/src/collector.rs +++ b/crates/stackline-core/src/collector.rs @@ -548,7 +548,8 @@ mod tests { #[tokio::test] async fn a_slow_first_read_does_not_hold_up_the_other_replicas() { // Answers every log request at once with one new line, except those of - // the replica `slow`, which take 4 seconds: a first read of hours of logs. + // the replica `slow`, which take 8 seconds: a first read of hours of logs. + // Two polls wait at most 2 × FIRST_WAIT for first reads, well short of that. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap().to_string(); let line = Arc::new(Mutex::new(0i64)); @@ -568,7 +569,7 @@ mod tests { } let head = String::from_utf8_lossy(&buf).to_string(); if head.contains("/slow/") { - tokio::time::sleep(Duration::from_secs(4)).await; + tokio::time::sleep(Duration::from_secs(8)).await; } let ts = *l.lock().unwrap(); let body = frames(&[(ts, "INFO hello".to_string())], "/"); @@ -586,7 +587,8 @@ mod tests { let mut c = Collector::new("conn", store.clone()); *line.lock().unwrap() = now - 10_000_000; - let started = Instant::now(); + let first = Instant::now(); + let started = first; let r = c.poll(&client, vec![fast.clone(), slow.clone()], RETENTION).await; assert!(started.elapsed() < Duration::from_secs(3), "the poll waited {:?} for the slow first read", started.elapsed()); assert_eq!(r.entries, 1, "{r:?}"); @@ -599,7 +601,8 @@ mod tests { assert_eq!(r.entries, 1, "{r:?}"); // Once done, the slow replica's lines are stored and it is polled like any other. - tokio::time::sleep(Duration::from_secs(3)).await; + let left = Duration::from_secs(9).saturating_sub(first.elapsed()); + tokio::time::sleep(left).await; c.poll(&client, vec![fast, slow], RETENTION).await; assert!(store.cursor("conn", "slow").unwrap().is_some()); assert_eq!(stored(&store), 3);