feat: service logs and the audit filters move into the band

Service logs: the page's name is the breadcrumb's, so the `<h1>` is gone and the
search field, the timeframe and the two switches sit in the band beside it, with
the documentation tooltip after the name. ServiceLogsInner registers the band
itself, the way RunsPage does, since that route is its only caller, and the
`title` snippet the page used to hand it goes with the heading.

The search is the widest control there, because searching is what the page is
for, and it is the one that gives width back under pressure. The two switches
fold into a popover below a 1200px bar rather than at a phone's width, so the
field keeps its full width down to a much narrower window — folded rather than
dropped, because the page no longer carries a toolbar they could fall back to.

The timeframe control took no size of its own, so it defaulted to `md` beside a
`sm` field, and sat in a `w-48` box 20px narrower than its content, whose
overflow is visible and so spilled into its neighbours.

No `separator`: the two panes below start under the bar with borders of their
own, so at rest there is nothing for the band to divide.

Audit logs: the filters lose their labels. Each select says what it filters
while unset — "all usernames", "all resources" — and once set the value names
the field by itself. From and To become the TimeframeSelect the runs page uses,
which absorbs the standalone Refresh button, since that control's left half is
the refresh. `auditTimeframes` joins the runs and service-logs presets, with
"All time" first. The bounds stay the filters' own `after`/`before` rather than a
remembered preset label: the timeline's zoom writes them too, and a label could
then contradict the chart.

That takes the row from 1326px to 898px, so it stays inline down to a ~1500px
viewport instead of ~2500. The fold measures the row against the bar less what
the breadcrumb takes, plus a gap the two must never close — the breadcrumb
yields width grudgingly and the row not at all, so sized to just meet they
overlap rather than compress, and mid-resize the tooltip after the page's name
lands on the timeframe button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-05 06:47:04 -07:00
co-authored by Claude Opus 5
parent 0f7b0f6a44
commit 64ada4e26d
6 changed files with 215 additions and 181 deletions
@@ -10,8 +10,10 @@
import Toggle from './Toggle.svelte'
import TextInput from './text_input/TextInput.svelte'
import Popover from './Popover.svelte'
// The clickable one, for the folded switches; the import above is the hover tooltip.
import MeltPopover from './meltComponents/Popover.svelte'
import { sendUserToast } from '$lib/toast'
import { onDestroy, tick, type Snippet } from 'svelte'
import { onDestroy, tick } from 'svelte'
import { fade } from 'svelte/transition'
import {
AlertTriangle,
@@ -22,7 +24,8 @@
Loader2,
ScrollText,
Search,
SearchX
SearchX,
SlidersHorizontal
} from 'lucide-svelte'
import { copyToClipboard, scroll_into_view_if_needed_polyfill, truncateRev } from '$lib/utils'
import LogSnippetViewer from './LogSnippetViewer.svelte'
@@ -33,15 +36,24 @@
import { goto } from '$lib/navigation'
import { page } from '$app/state'
import { watch } from 'runed'
import Tooltip from './Tooltip.svelte'
import PageHeaderContent from './PageHeaderContent.svelte'
import { pageHeader } from './pageHeaderRegistry.svelte'
interface Props {
searchTerm: string
tagLabel?: string
/** Rendered at the start of the toolbar row, so the page title shares it. */
title?: Snippet
}
let { searchTerm = $bindable(), tagLabel, title }: Props = $props()
let { searchTerm = $bindable(), tagLabel }: Props = $props()
/** Bar width below which the two switches fold into a popover. Measured: the breadcrumb takes
* ~360, the search field asks for 384 and the timeframe 223, so under this the field is what
* would start giving width back — and it is the control the page exists for. */
const COMPACT_SWITCHES_BAR = 1200
const compactSwitches = $derived(
pageHeader.barWidth > 0 && pageHeader.barWidth < COMPACT_SWITCHES_BAR
)
let queryParseErrors: string[] | undefined = $state(undefined)
@@ -692,88 +704,135 @@
</DrawerContent>
</Drawer>
<div class="flex flex-col grow min-h-0 min-w-0 gap-2 pb-2">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 pt-4 pb-2">
{@render title?.()}
<div class="relative grow min-w-64">
<Search
size={16}
class="absolute left-2 top-1/2 -translate-y-1/2 text-hint pointer-events-none z-10"
/>
<TextInput
bind:value={searchTerm}
size="md"
class="pl-8 pr-8"
inputProps={{
id: 'quickSearchInput',
placeholder: 'Search service logs',
autocomplete: 'off',
autofocus: true
}}
/>
{#if searchTerm !== '' && queryParseErrors && queryParseErrors.length > 0}
<div class="absolute right-2 top-1/2 -translate-y-1/2 z-10 flex">
<Popover notClickable placement="bottom-end">
<AlertTriangle size={16} class="text-yellow-500" />
{#snippet text()}
<div class="flex flex-col gap-1 text-xs">
<span>Some search terms were ignored because they could not be parsed:</span>
<ul class="list-disc pl-4">
{#each queryParseErrors ?? [] as msg}
<li>{msg}</li>
{/each}
</ul>
</div>
{/snippet}
</Popover>
</div>
{/if}
</div>
<TimeframeSelect
items={serviceLogsTimeframes}
bind:value={timeframe}
{loading}
wrapperClasses="w-64"
onClick={() => {
minTs = undefined
maxTs = undefined
allLogs = undefined
const ts = timeframe.computeMinMax()
getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined)
{#snippet serviceLogsHint()}
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/service_logs">
Logs written by the servers, workers and indexers of this instance, grouped by host.
</Tooltip>
{/snippet}
{#snippet serviceLogsActions()}
<!-- The widest control here, because searching is what the page is for. It is also the one that
gives width back when the bar is tight — that is what `actionsFlexible` on the registration
asks the bar to allow — down to a floor that still fits a query worth reading. -->
<div class="relative w-96 min-w-40 shrink">
<Search
size={16}
class="absolute left-2 top-1/2 -translate-y-1/2 text-hint pointer-events-none z-10"
/>
<TextInput
bind:value={searchTerm}
size="sm"
class="pl-8 pr-8"
inputProps={{
id: 'quickSearchInput',
placeholder: 'Search service logs',
autocomplete: 'off',
autofocus: true
}}
/>
<div class="flex items-center gap-4">
<Toggle
size="sm"
bind:checked={withError}
options={{
right: 'With errors only',
rightTooltip: 'Only list the log files that contain at least one error line'
}}
on:change={() => {
allLogs = undefined
getAllLogs(minTs, maxTs)
}}
/>
<Toggle
size="sm"
bind:checked={autoRefresh}
disabled={searchTerm != ''}
on:change={(e) => {
if (e.detail) {
getAllLogs(maxTs, undefined)
} else {
timeout && clearTimeout(timeout)
}
}}
options={{
right: 'Auto-refresh',
rightTooltip: 'Fetch new log files every 5 seconds. Paused while searching.'
}}
/>
</div>
{#if searchTerm !== '' && queryParseErrors && queryParseErrors.length > 0}
<div class="absolute right-2 top-1/2 -translate-y-1/2 z-10 flex">
<Popover notClickable placement="bottom-end">
<AlertTriangle size={16} class="text-yellow-500" />
{#snippet text()}
<div class="flex flex-col gap-1 text-xs">
<span>Some search terms were ignored because they could not be parsed:</span>
<ul class="list-disc pl-4">
{#each queryParseErrors ?? [] as msg}
<li>{msg}</li>
{/each}
</ul>
</div>
{/snippet}
</Popover>
</div>
{/if}
</div>
<!-- No width of its own: the preset labels run to "1000 last service logs", and a fixed box
narrower than that does not clip it — the control's overflow is visible, so it spills into
whatever sits next to it in the bar. -->
<TimeframeSelect
unifiedSize="sm"
items={serviceLogsTimeframes}
bind:value={timeframe}
{loading}
wrapperClasses="shrink-0"
onClick={() => {
minTs = undefined
maxTs = undefined
allLogs = undefined
const ts = timeframe.computeMinMax()
getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined)
}}
/>
<!-- The two labelled switches are the widest thing in this row, and the search field is the one
that pays for them: it is the only control that yields width. So they fold into a popover
well before the bar is tight, which keeps the field at its full width down to a much
narrower window. Folded, not dropped — the page below no longer carries a toolbar they
could fall back to, so hiding them would put them out of reach. -->
{#if compactSwitches}
<MeltPopover placement="bottom-end">
{#snippet trigger()}
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: SlidersHorizontal }}
title="Log listing options"
/>
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-3 p-3">
{@render logSwitches()}
</div>
{/snippet}
</MeltPopover>
{:else}
<div class="flex items-center gap-4 shrink-0">
{@render logSwitches()}
</div>
{/if}
{/snippet}
{#snippet logSwitches()}
<Toggle
size="sm"
bind:checked={withError}
options={{
right: 'With errors only',
rightTooltip: 'Only list the log files that contain at least one error line'
}}
on:change={() => {
allLogs = undefined
getAllLogs(minTs, maxTs)
}}
/>
<Toggle
size="sm"
bind:checked={autoRefresh}
disabled={searchTerm != ''}
on:change={(e) => {
if (e.detail) {
getAllLogs(maxTs, undefined)
} else {
timeout && clearTimeout(timeout)
}
}}
options={{
right: 'Auto-refresh',
rightTooltip: 'Fetch new log files every 5 seconds. Paused while searching.'
}}
/>
{/snippet}
<!-- The page's name is the breadcrumb's, so the band carries the hint beside it and the toolbar at
its far end; the page below starts straight into the logs.
No `separator`: the default draws the edge only while something is passing under the bar, and
the two panes below start lower than it with borders of their own, so at rest there is nothing
for the band to divide. -->
<PageHeaderContent afterName={serviceLogsHint} actions={serviceLogsActions} actionsFlexible />
<div class="flex flex-col grow min-h-0 min-w-0 gap-2 py-2">
{#if searchError}
<Alert type="warning" title="Service logs search unavailable" size="xs">
{searchError}
@@ -18,7 +18,10 @@
import type { ActionKind } from '$lib/common'
import Button from '$lib/components/common/button/Button.svelte'
import CalendarPicker from '$lib/components/common/calendarPicker/CalendarPicker.svelte'
import TimeframeSelect, {
auditTimeframes,
useSyncedTimeframe
} from '$lib/components/runs/TimeframeSelect.svelte'
import {
type AuditLog,
ResourceService,
@@ -29,7 +32,7 @@
} from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { ChevronDown, Download, Loader2, RefreshCcw } from 'lucide-svelte'
import { ChevronDown, Download } from 'lucide-svelte'
import { onDestroy, onMount, untrack } from 'svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
@@ -266,22 +269,34 @@
untrack(() => updateQueryParams())
})
// `leading-none`: the label's own line box is what decides whether a stacked field fits the
// header band. At the default leading the field is 52px against a 50px bar and the label is
// clipped off the top; at the font's own height it is 44px.
const fieldLabel = 'text-xs font-semibold text-emphasis shrink-0 leading-none'
// The bounds stay the filters' own `after`/`before`, because the timeline's zoom writes them
// too; picking a preset resolves it to a concrete pair here rather than keeping a label that
// the chart could then contradict.
const _timeframe = useSyncedTimeframe(
auditTimeframes,
() => ({ minTs: after, maxTs: before }),
(v) => {
const { minTs, maxTs } = v.timeframe
? (auditTimeframes.find((t) => t.label === v.timeframe)?.computeMinMax() ?? {
minTs: null,
maxTs: null
})
: { minTs: v.minTs ?? null, maxTs: v.maxTs ?? null }
after = minTs ?? undefined
before = maxTs ?? undefined
}
)
</script>
<!-- Label above input, 4px apart, fields 8px apart — the form layout in brand-guidelines.md. The
labels used to be positioned absolutely above the controls, which put them outside the box and
clipped them against the top of the window once these filters moved into the header band. -->
<!-- `inline` rather than a breakpoint: the caller measures whether the row fits the width the band
<!-- No labels above the controls: each one says what it filters while it is unset ("all usernames"),
and once it is set the value names the field by itself. That halves the row's height, which is
what lets it sit in the header band, and halves its width, which is what lets it stay there.
`inline` rather than a breakpoint: the caller measures whether the row fits the width the band
leaves it, which a media query cannot see — the sidebar, a side panel and the filters' own
values all move that width without moving the viewport's. -->
<div class={inline ? 'flex flex-row gap-2 items-end pr-2' : 'flex flex-col gap-2'}>
<div class={inline ? 'flex flex-row gap-2 items-center pr-2' : 'flex flex-col gap-2'}>
{#if $workspaceStore == 'admins'}
<div class="flex flex-col gap-1 min-w-0">
<span class={fieldLabel}>Scope</span>
<div class="flex min-w-0 shrink-0">
<ToggleButtonGroup
selected={scope ?? 'admins'}
on:selected={({ detail }) => {
@@ -311,45 +326,22 @@
</ToggleButtonGroup>
</div>
{/if}
<div class="flex flex-col gap-1 min-w-0">
<span class={fieldLabel}>From</span>
<div class="flex items-center relative min-w-0 bg-surface-input">
<input type="text" class="!h-7 !text-xs" value={after ?? ''} disabled />
<CalendarPicker
clearable
date={after}
placement="bottom-end"
label="From"
on:change={({ detail }) => {
after = new Date(detail).toISOString()
}}
on:clear={() => {
after = undefined
}}
/>
</div>
</div>
<div class="flex flex-col gap-1 min-w-0">
<span class={fieldLabel}>To</span>
<div class="flex items-center relative min-w-0 bg-surface-input">
<input type="text" class="!h-7 !text-xs" value={before ?? ''} disabled />
<CalendarPicker
clearable
bind:date={before}
label="To"
placement="bottom-end"
on:change={({ detail }) => {
before = new Date(detail).toISOString()
}}
on:clear={() => {
before = undefined
}}
/>
</div>
</div>
<!-- The same control the runs page uses: presets and a two-calendar range in one popover,
in place of the two date fields this row used to carry. -->
<TimeframeSelect
unifiedSize="sm"
items={auditTimeframes}
bind:value={_timeframe.val}
{loading}
wrapperClasses="shrink-0"
onClick={() => {
loadUsers()
resources.refresh()
onRefresh?.()
}}
/>
<div class="flex flex-col gap-1 min-w-0">
<span class={fieldLabel}>Username</span>
<div class="w-40 min-w-0 shrink">
<Select
class="w-full"
size="sm"
@@ -358,7 +350,7 @@
items={usernames
? [
...($userStore?.is_admin || $userStore?.is_super_admin
? [{ value: 'all', label: 'all' }]
? [{ value: 'all', label: 'all usernames' }]
: []),
...usernames.map((e) => ({
value: e,
@@ -369,41 +361,41 @@
: []}
/>
</div>
<div class="flex flex-col gap-1 min-w-0">
<span class={fieldLabel}>Resource</span>
<div class="w-40 min-w-0 shrink">
<Select
class="w-full"
size="sm"
onCreateItem={(r) => (resources.value?.push(r), (resource = r))}
createText="Press enter to use this value"
bind:value={resource}
items={safeSelectItems(['all', ...(resources.value ?? [])])}
items={[{ value: 'all', label: 'all resources' }, ...safeSelectItems(resources.value ?? [])]}
inputClass="dark:!bg-gray-700"
RightIcon={ChevronDown}
/>
</div>
<div class="flex flex-col gap-1 min-w-0">
<span class={fieldLabel}>Operation</span>
<div class="w-40 min-w-0 shrink">
<Select
class="w-full"
size="sm"
bind:value={operation}
items={['all', ...Object.values(operations)].map((r) => ({ value: r, label: r }))}
items={[
{ value: 'all', label: 'all operations' },
...Object.values(operations).map((r) => ({ value: r, label: r }))
]}
inputClass="dark:!bg-gray-700"
RightIcon={ChevronDown}
/>
</div>
<div class="flex flex-col gap-1 min-w-0">
<span class={fieldLabel}>Action</span>
<div class="w-36 min-w-0 shrink">
<Select
class="w-full"
size="sm"
bind:value={actionKind}
RightIcon={ChevronDown}
items={[
{ value: 'all', label: 'all' },
{ value: 'all', label: 'all actions' },
{ value: 'create', label: 'Create' },
{ value: 'update', label: 'Update' },
{ value: 'delete', label: 'Delete' },
@@ -412,8 +404,6 @@
/>
</div>
<!-- The buttons carry no label of their own, so they line up with the inputs beside them rather
than with the labels above those. -->
<div class="flex flex-row items-center gap-1">
<Button
variant="subtle"
@@ -439,24 +429,5 @@
startIcon={{ icon: Download }}
iconOnly
/>
<Button
variant="accent"
on:click={() => {
loadUsers()
resources.refresh()
onRefresh?.()
}}
unifiedSize="sm"
wrapperClasses="ml-auto"
>
<div class="flex flex-row gap-1 items-center">
{#if loading}
<Loader2 size={14} class="animate-spin" />
{:else}
<RefreshCcw size={14} />
{/if}
Refresh
</div>
</Button>
</div>
</div>
@@ -3,7 +3,8 @@
type Timeframe,
buildManualTimeframe,
serviceLogsTimeframes,
runsTimeframes
runsTimeframes,
auditTimeframes
} from './timeframes'
import { type Timeframe, buildManualTimeframe } from './timeframes'
@@ -39,6 +39,15 @@ export const serviceLogsTimeframes: Timeframe[] = [
{ label: 'Within last month', computeMinMax: () => computeMinMaxInc(30 * 24 * 60 * 60 * 1000) }
].map((item) => ({ ...item, type: 'dynamic' }))
export const auditTimeframes: Timeframe[] = [
{ label: 'All time', computeMinMax: () => ({ minTs: null, maxTs: null }) },
{ label: 'Within last 30 minutes', computeMinMax: () => computeMinMaxInc(30 * 60 * 1000) },
{ label: 'Within last 24 hours', computeMinMax: () => computeMinMaxInc(24 * 60 * 60 * 1000) },
{ label: 'Within last 7 days', computeMinMax: () => computeMinMaxInc(7 * 24 * 60 * 60 * 1000) },
{ label: 'Within last month', computeMinMax: () => computeMinMaxInc(30 * 24 * 60 * 60 * 1000) },
{ label: 'Within last year', computeMinMax: () => computeMinMaxInc(365 * 24 * 60 * 60 * 1000) }
].map((item) => ({ ...item, type: 'dynamic' }))
export const runsTimeframes: Timeframe[] = [
{ label: 'Latest runs', computeMinMax: () => ({ minTs: null, maxTs: null }) },
{ label: 'Within 30 seconds', computeMinMax: () => computeMinMaxInc(30 * 1000) },
@@ -73,13 +73,18 @@
// fits inside itself, which it always does, and the filters would never fold.
let filtersRowWidth = $state(0)
/** What the breadcrumb takes before the filters get any: the workspace disc and name, the
* page's name and the hint beside it. Measured at 361-398px across bar widths; the round
* number above that is what keeps a row that only just fits from overrunning the bar. */
* page's name and the hint beside it. Measured at 361-398px across bar widths. */
const BREADCRUMB_WIDTH = 400
/** Clear space held between the breadcrumb and the first filter. The breadcrumb yields width
* grudgingly and the filter row not at all, so when the two are sized to just meet they
* overlap instead of compressing — and mid-resize, before the breadcrumb has settled, the
* tooltip at the end of it lands on the timeframe button. Measured without this: the gap
* reached 4px across a 1600->1200 sweep. */
const BREADCRUMB_GAP = 48
let inlineFilters = $derived(
filtersRowWidth > 0 &&
pageHeader.barWidth > 0 &&
filtersRowWidth <= pageHeader.barWidth - BREADCRUMB_WIDTH
filtersRowWidth <= pageHeader.barWidth - BREADCRUMB_WIDTH - BREADCRUMB_GAP
)
// Function to fetch missing job execution audit logs
@@ -2,7 +2,6 @@
import { page } from '$app/state'
import { Alert } from '$lib/components/common'
import ServiceLogsInner from '$lib/components/ServiceLogsInner.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { devopsRole } from '$lib/stores'
// `query` is what the global search links with; `searchTerm` is what the page writes back.
@@ -11,26 +10,16 @@
)
</script>
{#snippet title()}
<div class="flex items-center gap-2">
<h1 class="text-2xl font-semibold text-emphasis whitespace-nowrap">Service logs</h1>
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/service_logs">
Logs written by the servers, workers and indexers of this instance, grouped by host.
</Tooltip>
</div>
{/snippet}
<!-- `h-full`, not `h-screen`: the page header band sits above this box, so a viewport floor here
would overhang the content box by the band's height. -->
<div class="flex flex-col w-full min-w-0 h-full max-h-full px-4">
{#if $devopsRole == false}
<div class="flex flex-col gap-2 pt-4">
{@render title()}
<Alert title="Service logs are only available to superadmins and devops users" type="warning">
Ask one of them to look at the logs, or to grant you the devops role.
</Alert>
</div>
{:else}
<ServiceLogsInner bind:searchTerm {title} />
<ServiceLogsInner bind:searchTerm />
{/if}
</div>