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
26 changes: 6 additions & 20 deletions src/lib/components/app-menu.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { toast } from 'svelte-sonner';
import { call } from '$lib/bridge/client';
import { i18n } from '$lib/i18n/index.svelte';
import { cn } from '$lib/utils';
Expand All @@ -12,8 +11,7 @@

interface Item {
id: ItemId;
/** Absent means the entry is a sketch and says so when it is pressed. */
run?: () => unknown;
run: () => unknown;
/** Only where the key really does it — an invented shortcut is worse than none. */
shortcut?: string;
}
Expand All @@ -23,35 +21,27 @@
groups: Item[][];
}

// Every entry here does what it says. An entry that only apologises when pressed is worse than
// no entry, so the sketches this bar started as are gone rather than greyed out.
const MENUS = $derived<Menu[]>([
{
id: 'file',
groups: [
[{ id: 'saveAs' }, { id: 'print' }],
[{ id: 'exit', run: () => call('app_exit', {}), shortcut: 'Alt+F4' }]
]
groups: [[{ id: 'exit', run: () => call('app_exit', {}), shortcut: 'Alt+F4' }]]
},
{
id: 'view',
groups: [
[{ id: 'refresh' }, { id: 'columns' }, { id: 'clearFilters' }],
[{ id: 'settings', run: () => goto(resolve('/settings')) }]
]
groups: [[{ id: 'settings', run: () => goto(resolve('/settings')) }]]
},
{
id: 'help',
groups: [[{ id: 'documentation' }], [{ id: 'about', run: () => goto(resolve('/info')) }]]
groups: [[{ id: 'about', run: () => goto(resolve('/info')) }]]
}
]);

let open = $state<string | null>(null);

function pick(item: Item) {
open = null;
if (!item.run) {
toast(t.menu.notBuilt(t.menu.items[item.id]));
return;
}
void item.run();
}
</script>
Expand Down Expand Up @@ -118,10 +108,6 @@
<span class="flex-1">{t.menu.items[item.id]}</span>
{#if item.shortcut}
<span class="text-muted-foreground">{item.shortcut}</span>
{:else}
<span class="text-[10px] tracking-wide text-muted-foreground/70 uppercase">
{t.menu.sketch}
</span>
{/if}
</button>
{/each}
Expand Down
74 changes: 0 additions & 74 deletions src/lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Exclude<Range, 'custom'>, 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 */
/* -------------------------------------------------------------------------------------------- */
Expand Down
27 changes: 1 addition & 26 deletions src/lib/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
},
Expand All @@ -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',
Expand Down
27 changes: 1 addition & 26 deletions src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
},
Expand All @@ -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',
Expand Down
55 changes: 30 additions & 25 deletions src/lib/stores/events.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,22 @@
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.
*/
class EventsStore {
channels = $state<string[]>([]);
channel = $state<string>(ALL_CHANNELS);
levels = new SvelteSet<number>([1, 2, 3]);
range = $state<Range>('day');
from = $state('');
to = $state('');
eventIdText = $state('');
providerText = $state('');

events = $state<EventRecord[]>([]);
truncated = $state(false);
Expand All @@ -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<string | null>(
(held, event) => (held === null || event.timeCreated < held ? event.timeCreated : held),
null
);
}

get newest(): string | null {
return this.events.reduce<string | null>(
(held, event) => (held === null || event.timeCreated > held ? event.timeCreated : held),
null
);
}

select(event: EventRecord | null): void {
Expand All @@ -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
};
}
Expand Down
Loading
Loading