diff --git a/CHANGELOG.md b/CHANGELOG.md index b59712f..8d90250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ 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. +## 0.7.0 + +### Added +- **Query any time range on the Logs page.** Next to the range buttons, the clock button (or `t`) opens a menu where you can type a range (`90m`, `yesterday`, `14:00-15:30`, `since 8:00`, `9.11. 8:00 - 12:00`, `14:05 ± 5m`, `now-6h to now-2h`), pick a quick one (5m to 3d, today, yesterday, this week), set exact From and To times, or reuse a recent range. Ctrl K understands the same text and opens the Logs page on that range +- **Step through time.** The arrows beside the range (or `[` and `]`) show the range of the same length before or after; **Zoom out** (or `-`) doubles it. Reaching now turns it back into a range that follows the clock +- **Ranges that run until now**, such as `today` or `since 14:00`, keep following new lines like the presets do, Live included, and their link keeps the start + +### Changed +- The Logs page names the range in words (`last 90 minutes`, `yesterday 22:00 – 02:00`) instead of bare clock times, and the download menu does the same + +### Fixed +- **Pages open at the top.** Every page scrolls in the same area, so a page opened where the last one was scrolled to: Preferences, opened from the Logs page, started at its bottom. Each page, and each connection you switch to, now starts at the top + ## 0.6.0 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. diff --git a/Cargo.lock b/Cargo.lock index e8cb154..b2625b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3971,7 +3971,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stackline" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "axum", @@ -3999,7 +3999,7 @@ dependencies = [ [[package]] name = "stackline-core" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "bytes", @@ -4020,7 +4020,7 @@ dependencies = [ [[package]] name = "stackline-desktop" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index afe74a1..618e629 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/stackline-core", "crates/stackline", "desktop/src-tauri"] default-members = ["crates/stackline-core", "crates/stackline"] [workspace.package] -version = "0.6.0" +version = "0.7.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/snaketech-tu/stackline" diff --git a/README.md b/README.md index c649a0c..42334a9 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,25 @@ stack:shop replica:2 node:worker-01 level:warn ``` +**Pick any time range.** Besides the buttons (Auto, 15m, 1h, 3h, 24h, 7d), the +clock button (or `t`) takes a typed range, quick picks, exact From and To +fields and your recent ranges. Ctrl K takes the same text. For example: + +```text +90m last 90 minutes +today / yesterday the whole day (today runs until now) +since 8:00 from 08:00 until now, following new lines +14:00-15:30 today, or yesterday when that is still ahead +yesterday 22:00 to 02:00 across midnight +9.11. 8:00 - 12:00 a day without a year is the last one that passed +2026-11-09 one whole day +14:05 ± 5m around a moment +now-6h to now-2h relative to now +``` + +`[` and `]` move to the range before or after (same length), `-` zooms out. +Copy link keeps the exact range. + Drag across the timeline to narrow the time range, select an error group to filter the stream, then expand a log entry to inspect its trace. Enable **Live** to follow new entries. Downloads use the current filters by default. diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 9ebef7f..d0500e1 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "stackline-desktop", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stackline-desktop", - "version": "0.6.0", + "version": "0.7.0", "devDependencies": { "@tauri-apps/cli": "^2" } diff --git a/desktop/package.json b/desktop/package.json index 2c83558..fbc27d1 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "stackline-desktop", "private": true, - "version": "0.6.0", + "version": "0.7.0", "scripts": { "tauri": "tauri" }, diff --git a/e2e/tests/logs.spec.ts b/e2e/tests/logs.spec.ts index 392f319..f2134a9 100644 --- a/e2e/tests/logs.spec.ts +++ b/e2e/tests/logs.spec.ts @@ -95,7 +95,7 @@ test('Copy link fixes the time range, and the link opens from Ctrl K', async ({ await expect(page.getByRole('heading', { name: 'Log stream' })).toBeVisible() await expect(page.getByPlaceholder(/^Search/)).toHaveValue('worker') await expect(page.getByRole('group', { name: 'Levels' }).getByRole('button', { name: /^Warn/ })).toHaveAttribute('aria-pressed', 'true') - await expect(page.getByRole('button', { name: 'Reset zoom' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Reset range' })).toBeVisible() }) // `migrate` prints a 40 KB line, 1500 short lines and "Applied 3 migrations", then exits. @@ -138,3 +138,72 @@ test('a line longer than 16 KB is one entry, whole, and the row says it goes on' await row.click() await expect(row.locator('pre.trace')).toHaveText('Schema dump ' + 'x'.repeat(40000)) }) + +test('a typed time range shows those lines, and the arrows step through time', async ({ page }) => { + await open(page, 'logs') + const rangeButton = page.getByRole('button', { name: /Custom/ }) + await rangeButton.click() + const typed = page.getByRole('textbox', { name: 'Type a time range' }) + await typed.fill('90 min') + await expect(page.getByRole('button', { name: 'Show last 90 minutes' })).toBeVisible() + await typed.press('Enter') + await expect(page).toHaveURL(/[#&?]t=90m(&|$)/) + await expect(page.getByRole('button', { name: /last 90 minutes/ }).first()).toBeVisible() + await expect(page.locator('.stream .row').first()).toBeVisible() + + // Earlier: the 90 minutes before, a fixed range in the address. + await page.getByRole('button', { name: 'Earlier' }).click() + await expect(page).toHaveURL(/[#&?]t=\d+-\d+(&|$)/) + const [a, z] = /t=(\d+)-(\d+)/.exec(page.url())!.slice(1).map(Number) + expect(z - a).toBe(90 * 60e3) + // Later again: back to a range that ends now. + await page.getByRole('button', { name: 'Later' }).click() + await expect(page).toHaveURL(/[#&?]t=90m(&|$)/) + + // A range that runs until now follows the clock and comes back from its link. + await page.keyboard.press('t') + await typed.fill('since 5h ago') + await typed.press('Enter') + await expect(page).toHaveURL(/[#&?]t=\d+-now(&|$)/) + await expect(page.locator('.stream .row').first()).toBeVisible() + await page.reload() + await expect(page.getByRole('button', { name: /^since / }).first()).toBeVisible() + + // Typed ranges are offered again. + await page.keyboard.press('t') + await expect(page.getByRole('dialog', { name: 'Time range' })).toContainText('since 5h ago') + await expect(page.getByRole('dialog', { name: 'Time range' })).toContainText('90 min') + await page.keyboard.press('Escape') +}) + +test('from and to fields pick an exact range, and nonsense is refused', async ({ page }) => { + await open(page, 'logs') + await page.getByRole('button', { name: /Custom/ }).click() + const dialog = page.getByRole('dialog', { name: 'Time range' }) + await dialog.getByRole('textbox', { name: 'Type a time range' }).fill('soon-ish') + await expect(dialog).toContainText('Not a range yet') + + const pad = (n: number) => String(n).padStart(2, '0') + const local = (t: number) => { + const d = new Date(t) + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` + } + const now = Date.now() + await dialog.getByLabel('From').fill(local(now - 2 * 3600e3)) + await dialog.getByLabel(/^To/).fill(local(now - 3 * 3600e3)) + await dialog.getByRole('button', { name: 'Show this range' }).click() + await expect(dialog).toContainText('The end must come after the start.') + await dialog.getByLabel(/^To/).fill(local(now - 60e3)) + await dialog.getByRole('button', { name: 'Show this range' }).click() + await expect(dialog).toHaveCount(0) + await expect(page).toHaveURL(/[#&?]t=\d+-\d+(&|$)/) + await expect(page.locator('.stream .row').first()).toBeVisible() +}) + +test('Ctrl K takes a time range', async ({ page }) => { + await open(page, 'overview') + await page.keyboard.press('Control+k') + await page.getByPlaceholder(/^Search or run a command/).fill('last 2 hours') + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/#logs\?.*t=2h(&|$)/) +}) diff --git a/e2e/tests/prefs.spec.ts b/e2e/tests/prefs.spec.ts index 5892bb0..3d16b09 100644 --- a/e2e/tests/prefs.spec.ts +++ b/e2e/tests/prefs.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from '@playwright/test' -import { open } from './helpers' +import { nav, open } from './helpers' const storeLine = (page: Page) => page.locator('section.panel', { has: page.getByRole('heading', { name: 'Local store' }) }).locator('.set .l').first() const entries = async (page: Page) => Number(((await storeLine(page).textContent()) ?? '').match(/([\d,.]+) entries/)?.[1].replace(/[,.]/g, '') ?? -1) @@ -35,3 +35,14 @@ test('after clearing the store, logs are collected again', async ({ page }) => { await open(page, 'logs') await expect(page.locator('.stream .row').first()).toBeVisible() }) + +test('Preferences opens at the top, not where the last page was scrolled to', async ({ page }) => { + await open(page, 'logs') + const view = page.locator('main.view') + // The log list makes the page long; scroll it well down. + await expect.poll(() => view.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeGreaterThan(300) + await view.evaluate((el) => (el.scrollTop = el.scrollHeight)) + await nav(page, 'Preferences') + await expect(page.getByRole('heading', { name: 'Appearance' })).toBeVisible() + expect(await view.evaluate((el) => el.scrollTop)).toBe(0) +}) diff --git a/ui/package-lock.json b/ui/package-lock.json index 273dd78..19bee7c 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "stackline-ui", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stackline-ui", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@fontsource/jetbrains-mono": "^5.3.0", "@fontsource/plus-jakarta-sans": "^5.3.0" diff --git a/ui/package.json b/ui/package.json index 951529d..598088e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "stackline-ui", "private": true, - "version": "0.6.0", + "version": "0.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/ui/src/App.svelte b/ui/src/App.svelte index c771d82..f61e7c8 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -106,6 +106,16 @@ } /** The note about a reset settings file was read. */ let configSeen = $state(false) + + // Every page scrolls in the same element, so a new page would open at the + // place the last one was scrolled to (Preferences after the Logs page opened + // at its bottom). Each page and each connection starts at the top. + let viewEl = $state() + $effect(() => { + void app.view + void app.activeId + if (viewEl) viewEl.scrollTop = 0 + }) @@ -144,7 +154,7 @@
-
+
{#if app.meta?.configWarning && !configSeen}