Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "stackline-desktop",
"private": true,
"version": "0.6.0",
"version": "0.7.0",
"scripts": {
"tauri": "tauri"
},
Expand Down
71 changes: 70 additions & 1 deletion e2e/tests/logs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(&|$)/)
})
13 changes: 12 additions & 1 deletion e2e/tests/prefs.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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)
})
4 changes: 2 additions & 2 deletions ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "stackline-ui",
"private": true,
"version": "0.6.0",
"version": "0.7.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
12 changes: 11 additions & 1 deletion ui/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>()
$effect(() => {
void app.view
void app.activeId
if (viewEl) viewEl.scrollTop = 0
})
</script>

<svelte:window onkeydown={keydown} />
Expand Down Expand Up @@ -144,7 +154,7 @@
</header>
<div class="area">
<div class="content" class:with-inspector={docked} class:full={docked && app.inspectorFull}>
<main class="view" class:list={isList}>
<main class="view" class:list={isList} bind:this={viewEl}>
{#if app.meta?.configWarning && !configSeen}
<div class="warn-banner host-banner" role="alert">
<span><b>Your settings were reset.</b><br /><span class="muted" style="font-size:12.5px">{app.meta.configWarning}</span></span>
Expand Down
1 change: 1 addition & 0 deletions ui/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -741,3 +741,4 @@ tr.editrow>td{background:var(--panel-2);padding:16px 18px}
.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)}
.pop.wide{width:min(440px,calc(100vw - 24px));max-width:none;max-height:min(640px,calc(100vh - 80px));grid-template-columns:minmax(0,1fr)}
4 changes: 2 additions & 2 deletions ui/src/components/DownloadMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Download is one click for what the view shows; the arrow picks the format or the whole range.
import { app, load, save } from '../lib/state.svelte'
import { api, apiFetch, query } from '../lib/api'
import { hhmm } from '../lib/format'
import { rangeText } from '../lib/timeframe'
import Icon from './Icon.svelte'
import Popover from './Popover.svelte'

Expand Down Expand Up @@ -71,7 +71,7 @@
<div class="sepl"></div>
<div class="hd">Download now</div>
<button class="it" role="menuitem" onclick={() => download(false)}><span class="l2"><span>What the view shows</span><small>Current filters and time range</small></span></button>
<button class="it" role="menuitem" onclick={() => download(true)}><span class="l2"><span>Everything from {hhmm(from)} to {hhmm(to)}</span><small>All services, all levels</small></span></button>
<button class="it" role="menuitem" onclick={() => download(true)}><span class="l2"><span>Everything {L.moving ? L.rangeLabel() : `from ${rangeText(from, to)}`}</span><small>All services, all levels</small></span></button>
<div class="sepl"></div>
<div class="note">Exports from the local store, with stack traces kept whole. Downloads go to this computer, never to the server.</div>
</Popover>
Expand Down
3 changes: 3 additions & 0 deletions ui/src/components/HelpOverlay.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
['Enter', 'Expand a line'],
['c', 'Copy a line'],
['f', 'Fill the window with the stream'],
['t', 'Type or pick a time range'],
['[ ]', 'The range before or after this one'],
['-', 'Zoom out to twice the range'],
]],
]
</script>
Expand Down
8 changes: 7 additions & 1 deletion ui/src/components/Palette.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { app, VIEWS } from '../lib/state.svelte'
import { fetchLogs } from '../lib/api'
import { firstLine } from '../lib/format'
import { parseRange } from '../lib/timeframe'
import type { Group } from '../lib/types'
import Icon from './Icon.svelte'

Expand Down Expand Up @@ -76,13 +77,18 @@
const hash = pastedLink
return [{ kind: 'Link', label: 'Open this log link', text: hash, right: '', run: () => { app.openLink(new URLSearchParams(hash.slice(hash.indexOf('?') + 1))); app.go('logs') } } satisfies Item]
}
// A typed time range (`2h`, `yesterday 14:00-15:00`) shows the logs of that range.
const range = parseRange(q)
const rangeItem: Item[] = range
? [{ kind: 'Range', label: `Show logs of ${q.trim()}`, text: q, right: 'time range', run: () => { app.go('logs'); app.logs.applyText(q) } }]
: []
const words = q.toLowerCase().split(/\s+/).filter(Boolean)
if (!words.length) {
const top = all.filter((x) => x.kind === 'Service' && (x.right.includes('★') || /^0\/[1-9]/.test(x.right)))
return [...top, ...all.filter((x) => x.kind === 'Error').slice(0, 3), ...all.filter((x) => x.kind === 'Page')]
}
// What it is called counts as much as its keywords: "allow changes" finds "Allow changes on …".
return all.filter((x) => words.every((w) => `${x.label} ${x.text}`.toLowerCase().includes(w))).slice(0, 30)
return [...rangeItem, ...all.filter((x) => words.every((w) => `${x.label} ${x.text}`.toLowerCase().includes(w)))].slice(0, 30)
})
$effect(() => {
void q
Expand Down
6 changes: 3 additions & 3 deletions ui/src/components/Popover.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import type { Snippet } from 'svelte'
import { onMount } from 'svelte'

let { anchor, onclose, align = 'left', label, children }: {
anchor: HTMLElement; onclose: () => void; align?: 'left' | 'right'; label: string; children: Snippet
let { anchor, onclose, align = 'left', label, wide = false, children }: {
anchor: HTMLElement; onclose: () => void; align?: 'left' | 'right'; label: string; wide?: boolean; children: Snippet
} = $props()

let el: HTMLDivElement | undefined = $state()
Expand Down Expand Up @@ -47,7 +47,7 @@

<!-- The menu itself moves to the end of the page; this wrapper stays where Svelte expects it. -->
<div style="display:contents">
<div class="pop" role="menu" aria-label={label} bind:this={el} style:left="{pos.left}px" style:top="{pos.top}px">
<div class="pop" class:wide role={wide ? 'dialog' : 'menu'} aria-label={label} bind:this={el} style:left="{pos.left}px" style:top="{pos.top}px">
{@render children()}
</div>
</div>
Loading
Loading