From bd495dd0981e6268ffb82d70b81eabbb1a959e34 Mon Sep 17 00:00:00 2001 From: thorsten Date: Mon, 31 Aug 2026 17:34:21 +0200 Subject: [PATCH] Take out the controls that ask twice or answer nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Level, time, event id and provider each had two controls: one in the toolbar that narrowed the query sent to Windows, one under the column header that narrowed what was already loaded. Same names, different reach, and nothing on screen said which was which. The columns keep them — a tick list with counts beats typing exact provider names, and an expression beats a comma-separated list of ids. The toolbar keeps only what has to be decided before reading: which channel, and the button. What is loaded now says how far back it reaches, beside the count, because a column filter can only narrow what was read: without that, an empty result for last Tuesday looks like a quiet one. The menu loses Save, Print, Refresh, Choose columns, Clear all filters and Documentation, which existed to be looked at. An entry that answers a click with "not built yet" is worse than no entry. Exit, Settings and About stay, and do what they say. --- src/lib/components/app-menu.svelte | 26 +++-------- src/lib/events.ts | 74 ------------------------------ src/lib/i18n/de.ts | 27 +---------- src/lib/i18n/en.ts | 27 +---------- src/lib/stores/events.svelte.ts | 55 ++++++++++++---------- src/lib/stores/events.test.ts | 60 ++++++++++-------------- src/lib/views/events-view.svelte | 67 +++++---------------------- 7 files changed, 72 insertions(+), 264 deletions(-) diff --git a/src/lib/components/app-menu.svelte b/src/lib/components/app-menu.svelte index e705116..1ae6cc5 100644 --- a/src/lib/components/app-menu.svelte +++ b/src/lib/components/app-menu.svelte @@ -1,7 +1,6 @@ @@ -118,10 +108,6 @@ {t.menu.items[item.id]} {#if item.shortcut} {item.shortcut} - {:else} - - {t.menu.sketch} - {/if} {/each} diff --git a/src/lib/events.ts b/src/lib/events.ts index 1951c6c..8f6a9a7 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -8,85 +8,11 @@ export const DEFAULT_CHANNELS = ['System', 'Application']; /** The four everyone reaches for, pinned above whatever else the machine publishes. */ export const PINNED_CHANNELS = ['System', 'Application', 'Security', 'Setup']; -export type Range = 'hour' | 'day' | 'week' | 'custom'; - -export const RANGES: Range[] = ['hour', 'day', 'week', 'custom']; - -/** Every level a Windows event can carry, with 0 folded into Information by the host. */ -export const LEVELS = [1, 2, 3, 4, 5] as const; - -export type LevelKey = 'critical' | 'error' | 'warning' | 'information' | 'verbose'; - -const SPANS: Record, number> = { - hour: 60 * 60 * 1000, - day: 24 * 60 * 60 * 1000, - week: 7 * 24 * 60 * 60 * 1000 -}; - -/** The i18n key for a level, so the label lives with the other translations rather than here. */ -export function levelKey(level: number): LevelKey { - switch (level) { - case 1: - return 'critical'; - case 2: - return 'error'; - case 3: - return 'warning'; - case 5: - return 'verbose'; - default: - return 'information'; - } -} - /** A record id is unique per channel, not per machine, so the channel has to be part of the key. */ export function keyOf(event: EventRecord): string { return `${event.channel}:${event.recordId}`; } -/** The window the range names, as the host wants it: RFC 3339 in UTC, or null for open-ended. */ -export function boundsOf( - range: Range, - from: string, - to: string, - now: number = Date.now() -): { from: string | null; to: string | null } { - if (range === 'custom') { - return { from: localToUtc(from), to: localToUtc(to) }; - } - return { from: new Date(now - SPANS[range]).toISOString(), to: null }; -} - -/** `datetime-local` gives back wall-clock text with no zone; the host only speaks UTC. */ -function localToUtc(value: string): string | null { - if (!value) return null; - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? null : new Date(parsed).toISOString(); -} - -/** `41, 6008 41` — separators are whatever the reader typed, and duplicates cost nothing. */ -export function numbersIn(text: string): number[] { - return [ - ...new Set( - text - .split(/[^0-9]+/) - .filter(Boolean) - .map(Number) - ) - ]; -} - -export function listIn(text: string): string[] { - return [ - ...new Set( - text - .split(',') - .map((part) => part.trim()) - .filter(Boolean) - ) - ]; -} - /* -------------------------------------------------------------------------------------------- */ /* Column filters */ /* -------------------------------------------------------------------------------------------- */ diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index d92b581..ce5c4dd 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -16,22 +16,14 @@ export const de: Translations = { }, menu: { label: 'Menüleiste', - sketch: 'Entwurf', - notBuilt: (action: string) => `${action} gibt es noch nicht.`, titles: { file: 'Datei', view: 'Ansicht', help: 'Hilfe' }, items: { - saveAs: 'Ereignisse speichern unter…', - print: 'Drucken…', exit: 'Beenden', - refresh: 'Aktualisieren', - columns: 'Spalten auswählen…', - clearFilters: 'Alle Filter zurücksetzen', settings: 'Einstellungen', - documentation: 'Dokumentation', about: 'Über OpenEventViewer' } }, @@ -44,27 +36,10 @@ export const de: Translations = { subtitle: 'Was Windows aufgezeichnet hat, neueste zuerst.', channel: 'Kanal', allChannels: 'System und Anwendung', - level: 'Stufe', - levels: { - critical: 'Kritisch', - error: 'Fehler', - warning: 'Warnung', - information: 'Information', - verbose: 'Ausführlich' - }, - range: 'Zeitraum', - ranges: { - hour: 'Letzte Stunde', - day: 'Letzte 24 Stunden', - week: 'Letzte 7 Tage', - custom: 'Eigener Zeitraum' - }, from: 'Von', to: 'Bis', - eventIds: 'Ereignis-IDs', - providers: 'Quellen', - providersHint: 'Exakte Namen, mit Komma getrennt', load: 'Laden', + span: (from: string, to: string) => `${from} bis ${to}`, keyword: 'Alle Spalten durchsuchen…', columnFilter: 'Spaltenfilter', clearColumnFilters: 'Spaltenfilter zurücksetzen', diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 0066c11..6478f0c 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -14,22 +14,14 @@ export const en = { }, menu: { label: 'Menu bar', - sketch: 'sketch', - notBuilt: (action: string) => `${action} is not built yet.`, titles: { file: 'File', view: 'View', help: 'Help' }, items: { - saveAs: 'Save events as…', - print: 'Print…', exit: 'Exit', - refresh: 'Refresh', - columns: 'Choose columns…', - clearFilters: 'Clear all filters', settings: 'Settings', - documentation: 'Documentation', about: 'About OpenEventViewer' } }, @@ -42,27 +34,10 @@ export const en = { subtitle: 'What Windows recorded, newest first.', channel: 'Channel', allChannels: 'System and Application', - level: 'Level', - levels: { - critical: 'Critical', - error: 'Error', - warning: 'Warning', - information: 'Information', - verbose: 'Verbose' - }, - range: 'Time', - ranges: { - hour: 'Last hour', - day: 'Last 24 hours', - week: 'Last 7 days', - custom: 'Custom range' - }, from: 'From', to: 'To', - eventIds: 'Event IDs', - providers: 'Providers', - providersHint: 'Exact names, comma separated', load: 'Load', + span: (from: string, to: string) => `${from} to ${to}`, keyword: 'Search every column…', columnFilter: 'column filter', clearColumnFilters: 'Clear column filters', diff --git a/src/lib/stores/events.svelte.ts b/src/lib/stores/events.svelte.ts index 15b73ae..c64a6c0 100644 --- a/src/lib/stores/events.svelte.ts +++ b/src/lib/stores/events.svelte.ts @@ -1,19 +1,15 @@ -import { SvelteSet } from 'svelte/reactivity'; import { call } from '$lib/bridge/client'; import type { EventFilter, EventRecord } from '$lib/bridge/contract'; -import { - ALL_CHANNELS, - DEFAULT_CHANNELS, - boundsOf, - keyOf, - listIn, - numbersIn, - type Range -} from '$lib/events'; +import { ALL_CHANNELS, DEFAULT_CHANNELS, keyOf } from '$lib/events'; import { settings } from '$lib/stores/settings.svelte'; /** - * What the Events page is looking at. + * What the Events page has loaded. + * + * The split is deliberate: this decides *which log and how much of it* reaches the machine, and the + * table's own column filters decide what is shown of it. Level, provider and time were once asked + * twice — once here as an XPath predicate and once in the table — and two controls with the same + * name and different reach is a worse trade than one query that reads the newest of everything. * * A singleton rather than per-view state: routing unmounts the page, and a trip to Settings and * back should not throw away a query that took three seconds to answer. @@ -21,12 +17,6 @@ import { settings } from '$lib/stores/settings.svelte'; class EventsStore { channels = $state([]); channel = $state(ALL_CHANNELS); - levels = new SvelteSet([1, 2, 3]); - range = $state('day'); - from = $state(''); - to = $state(''); - eventIdText = $state(''); - providerText = $state(''); events = $state([]); truncated = $state(false); @@ -44,8 +34,24 @@ class EventsStore { return this.events.find((event) => keyOf(event) === this.selectedId) ?? null; } - toggleLevel(level: number): void { - if (!this.levels.delete(level)) this.levels.add(level); + /** + * How far back what is loaded actually reaches. + * + * Shown beside the count, because a column filter can only narrow what was loaded: without this + * an empty result for last Tuesday looks the same as a quiet Tuesday. + */ + get oldest(): string | null { + return this.events.reduce( + (held, event) => (held === null || event.timeCreated < held ? event.timeCreated : held), + null + ); + } + + get newest(): string | null { + return this.events.reduce( + (held, event) => (held === null || event.timeCreated > held ? event.timeCreated : held), + null + ); } select(event: EventRecord | null): void { @@ -63,14 +69,13 @@ class EventsStore { } toFilter(): EventFilter { - const span = boundsOf(this.range, this.from, this.to); return { channels: this.channel === ALL_CHANNELS ? DEFAULT_CHANNELS : [this.channel], - levels: [...this.levels].sort((left, right) => left - right), - from: span.from, - to: span.to, - eventIds: numbersIn(this.eventIdText), - providers: listIn(this.providerText), + levels: [], + from: null, + to: null, + eventIds: [], + providers: [], max: settings.eventsMaxRows }; } diff --git a/src/lib/stores/events.test.ts b/src/lib/stores/events.test.ts index 91bea7b..fe0e30c 100644 --- a/src/lib/stores/events.test.ts +++ b/src/lib/stores/events.test.ts @@ -1,25 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { boundsOf, keyOf, levelKey, listIn, numbersIn } from '$lib/events'; +import { keyOf } from '$lib/events'; import { events } from './events.svelte'; describe('events store', () => { - it('turns the toolbar into the filter the host is given', () => { + /// The query decides which log and how much of it; everything about *which rows* is a column + /// filter. Asking the same question in both places is what made the toolbar look duplicated. + it('asks the host for a channel and a row count, and nothing else', () => { events.channel = 'System'; - events.levels.clear(); - events.levels.add(2); - events.levels.add(1); - events.range = 'hour'; - events.eventIdText = '41, 6008 41'; - events.providerText = ' EventLog , Microsoft-Windows-Kernel-Power ,'; const filter = events.toFilter(); expect(filter.channels).toEqual(['System']); - expect(filter.levels).toEqual([1, 2]); - expect(filter.eventIds).toEqual([41, 6008]); - expect(filter.providers).toEqual(['EventLog', 'Microsoft-Windows-Kernel-Power']); - expect(filter.to).toBeNull(); - expect(filter.from).not.toBeNull(); + expect(filter.max).toBeGreaterThan(0); + expect(filter).toMatchObject({ levels: [], eventIds: [], providers: [], from: null, to: null }); }); /// Reading everything is what the page opens on, and "everything" without administrator rights @@ -30,27 +23,27 @@ describe('events store', () => { expect(events.toFilter().channels).toEqual(['System', 'Application']); }); - it('measures a named range back from now and leaves a custom one open at both ends', () => { - const now = Date.parse('2026-08-22T12:00:00.000Z'); - - expect(boundsOf('day', '', '', now)).toEqual({ - from: '2026-08-21T12:00:00.000Z', - to: null - }); - expect(boundsOf('custom', '', '', now)).toEqual({ from: null, to: null }); - }); + /// A column filter can only narrow what was loaded. Without the span on screen, an empty result + /// for last Tuesday looks exactly like a quiet Tuesday. + it('reports how far back what it holds actually reaches', async () => { + events.channel = '__all__'; + await events.load(); - it('reads a wall-clock custom range back as UTC', () => { - const window = boundsOf('custom', '2026-08-20T08:30', '2026-08-20T09:30'); + expect(events.events.length).toBeGreaterThan(0); + expect(events.oldest).not.toBeNull(); + expect(events.newest).not.toBeNull(); + expect(events.oldest! <= events.newest!).toBe(true); - expect(window.from).toBe(new Date('2026-08-20T08:30').toISOString()); - expect(window.to).toBe(new Date('2026-08-20T09:30').toISOString()); + const times = events.events.map((event) => event.timeCreated); + expect(events.oldest).toBe([...times].sort()[0]); + expect(events.newest).toBe([...times].sort().at(-1)); }); - it('accepts ids typed with whatever separator came to hand', () => { - expect(numbersIn('41,6008 41; 137')).toEqual([41, 6008, 137]); - expect(numbersIn('')).toEqual([]); - expect(listIn('a, ,b,a')).toEqual(['a', 'b']); + it('has no span to report before anything is loaded', () => { + events.events = []; + + expect(events.oldest).toBeNull(); + expect(events.newest).toBeNull(); }); /// A record id is unique within its channel, not across the machine — two channels can both @@ -58,11 +51,4 @@ describe('events store', () => { it('keys a row by channel and record id together', () => { expect(keyOf({ channel: 'System', recordId: 7 } as never)).toBe('System:7'); }); - - it('names every level, and treats log-always as information', () => { - expect(levelKey(0)).toBe('information'); - expect(levelKey(1)).toBe('critical'); - expect(levelKey(4)).toBe('information'); - expect(levelKey(5)).toBe('verbose'); - }); }); diff --git a/src/lib/views/events-view.svelte b/src/lib/views/events-view.svelte index 5293bf6..992fd40 100644 --- a/src/lib/views/events-view.svelte +++ b/src/lib/views/events-view.svelte @@ -9,7 +9,7 @@ import { i18n } from '$lib/i18n/index.svelte'; import { cn } from '$lib/utils'; import type { EventRecord } from '$lib/bridge/contract'; - import { ALL_CHANNELS, LEVELS, PINNED_CHANNELS, RANGES, keyOf, levelKey } from '$lib/events'; + import { ALL_CHANNELS, PINNED_CHANNELS, keyOf } from '$lib/events'; import { call } from '$lib/bridge/client'; import { events } from '$lib/stores/events.svelte'; import { createEventsTable } from '$lib/stores/events-table.svelte'; @@ -27,9 +27,10 @@ .map((channel) => ({ value: channel, label: channel })) ]); - const rangeOptions = $derived( - RANGES.map((range) => ({ value: range, label: t.events.ranges[range] })) - ); + function shown(value: string): string { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? value : new Date(parsed).toLocaleString(); + } function firstLine(message: string): string { return message.split('\n')[0]?.trim() ?? ''; @@ -47,14 +48,10 @@ } let channel = $state(events.channel); - let range = $state(events.range); $effect(() => { events.channel = channel; }); - $effect(() => { - events.range = range; - }); $effect(() => { void events.loadChannels(); @@ -63,6 +60,8 @@
+
-
- {t.events.level} -
- {#each LEVELS as level (level)} - - {/each} -
-
- - - - {/if} - - - - -