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} - - - - -