Initial curated EventLens source from 0d80d3b38dfb817285481665b9e71e4b8a0656a0

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:30:24 +02:00
commit 663ea8c829
85 files changed
+12395

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from "@playwright/test";
test("workspace navigation and primary controls expose accessible state", async ({ page }) => {
await page.goto("/#analysis");
for (const workspace of ["Analysis", "Timeline", "Hunting", "Reports", "Settings"]) {
await expect(page.getByRole("button", { name: workspace, exact: false }).first()).toBeEnabled();
}
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
await page.getByRole("button", { name: "Timeline", exact: false }).click();
await expect(page.getByRole("button", { name: "Timeline", exact: false }).first()).toHaveAttribute("aria-current", "page");
await expect(page.getByRole("textbox", { name: "Search normalized evidence" })).toBeVisible();
await expect(page.getByLabel("Filter by category")).toBeVisible();
await expect(page.getByLabel("Filter by level")).toBeVisible();
await expect(page.getByLabel("Filter by provider")).toBeVisible();
await expect(page.getByRole("button", { name: "Previous evidence" })).toHaveAttribute("aria-label", "Previous evidence");
await expect(page.getByRole("listitem").first()).toHaveAttribute("aria-setsize", /[0-9]+/);
await page.getByRole("textbox", { name: "Search normalized evidence" }).focus();
await page.keyboard.press("Tab");
await expect(page.locator(":focus")).toBeVisible();
await page.getByRole("button", { name: "Hunting", exact: false }).click();
await expect(page.getByRole("textbox", { name: "Search hunting evidence" })).toBeVisible();
await page.getByRole("button", { name: "Reports", exact: false }).click();
await expect(page.getByRole("button", { name: "Export JSON", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Settings", exact: false }).click();
await expect(page.getByRole("group", { name: "Timestamp display" })).toBeVisible();
await page.setViewportSize({ width: 700, height: 900 });
await page.getByRole("button", { name: "Timeline", exact: false }).click();
await expect(page.getByLabel("Evidence time window")).toBeVisible();
await expect(page.getByLabel("Hide informational noise")).toBeVisible();
await expect(page.getByLabel("Focus incident")).toBeVisible();
});
+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from "@playwright/test";
const validUpload = { name: "sample.evtx", mimeType: "application/octet-stream", buffer: Buffer.from("synthetic") };
test("malformed successful analysis keeps the previous investigation", async ({ page }) => {
await page.goto("/#analysis");
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
await expect(page.getByRole("region", { name: "Current investigation context" })).toContainText("bundled-demo");
await page.route("**/api/analyze", (route) => route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ source: "bad.evtx", events: [], incidents: [{ id: "broken" }] }),
}));
await page.locator('input[type="file"]').setInputFiles(validUpload);
await expect(page.getByRole("alert")).toContainText("Analysis returned an invalid response.");
await expect(page.getByRole("region", { name: "Current investigation context" })).toContainText("bundled-demo");
await page.getByRole("button", { name: "Timeline", exact: false }).click();
await expect(page.getByRole("heading", { name: "Inspector", exact: true })).toBeVisible();
});
test("structured parser errors have safe, useful wording", async ({ page }) => {
await page.route("**/api/analyze", (route) => route.fulfill({
status: 422,
contentType: "application/json",
body: JSON.stringify({ detail: { code: "no_readable_events", message: "No readable Windows events were found in this file." } }),
}));
await page.goto("/#analysis");
await page.locator('input[type="file"]').setInputFiles(validUpload);
await expect(page.getByRole("alert")).toContainText("No readable Windows events were found in this file.");
await expect(page.getByRole("alert")).not.toContainText("HTTP 422");
});
test("rejecting an invalid file cancels an older pending analysis", async ({ page }) => {
await page.goto("/#analysis");
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
await expect(page.getByRole("region", { name: "Current investigation context" })).toContainText("bundled-demo");
let routeStarted!: () => void;
let releaseRoute!: () => void;
const started = new Promise<void>((resolve) => { routeStarted = resolve; });
const released = new Promise<void>((resolve) => { releaseRoute = resolve; });
await page.route("**/api/analyze", async (route) => {
routeStarted();
await released;
try {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ source: "late.evtx", events: [], incidents: [] }) });
} catch {
// The route can already be cancelled by the browser request abort.
}
});
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(validUpload);
await started;
await fileInput.setInputFiles({ name: "invalid.txt", mimeType: "text/plain", buffer: Buffer.from("invalid") });
await expect(page.getByRole("alert")).toContainText("Choose a Windows .evtx file.");
releaseRoute();
await page.waitForTimeout(300);
await expect(page.getByRole("region", { name: "Current investigation context" })).toContainText("bundled-demo");
await expect(page.getByRole("alert")).toContainText("Choose a Windows .evtx file.");
});
+28
View File
@@ -0,0 +1,28 @@
import { expect, test } from "@playwright/test";
const assets = [
["favicon.svg", /image\/svg\+xml/i],
["favicon.ico", /image\/(x-icon|vnd\.microsoft\.icon)/i],
["apple-touch-icon.png", /image\/png/i],
["eventlens-icon-192.png", /image\/png/i],
["eventlens-icon-512.png", /image\/png/i],
["site.webmanifest", /(manifest|json)/i],
] as const;
test("canonical EventLens branding and icon package are locally served", async ({ page, request }) => {
await page.goto("/#analysis");
await expect(page).toHaveTitle("EventLens — Windows Incident Reconstruction");
await expect(page.locator('meta[name="application-name"]')).toHaveAttribute("content", "EventLens");
await expect(page.locator('link[rel="manifest"]')).toHaveAttribute("href", "/site.webmanifest");
await expect(page.locator(".event-logo svg")).toBeVisible();
for (const [asset, contentType] of assets) {
const response = await request.get(`/${asset}`);
expect(response.status(), `${asset} should be served`).toBe(200);
expect(response.headers()["content-type"] ?? "", `${asset} MIME type`).toMatch(contentType);
}
for (const workspace of ["Analysis", "Timeline", "Hunting", "Reports", "Settings"]) {
await expect(page.getByRole("button", { name: workspace, exact: false }).first().locator("svg")).toHaveCount(1);
}
});
+40
View File
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test";
test("demo investigation workflow crosses Analysis, Timeline and Reports", async ({ page }) => {
await page.goto("/#analysis");
await expect(page.getByRole("heading", { name: "No event log loaded", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
await expect(page.getByRole("region", { name: "Current investigation context" }).getByText("bundled-demo", { exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Incident windows", exact: true })).toBeVisible();
await expect(page.getByText("Analysis status", { exact: true })).toBeVisible();
await expect(page.getByText("Deterministic demo fixture", { exact: true })).toBeVisible();
await expect(page.getByText("Current source", { exact: true })).toHaveCount(0);
await expect(page.getByText("How to continue", { exact: true })).toBeVisible();
await page.getByText("Service failure recorded", { exact: true }).click();
await expect(page).toHaveURL(/#timeline$/);
await expect(page.getByRole("heading", { name: "Event timeline", exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Example Sync Service terminated unexpectedly", exact: true })).toBeVisible();
await expect(page.getByText("Focus active", { exact: true })).toBeVisible();
const search = page.getByRole("textbox", { name: "Search normalized evidence" });
await search.fill("DNS name resolution");
await expect(page.locator(".event-row")).toHaveCount(0);
await search.fill("");
await page.getByRole("button", { name: "Stop focus", exact: true }).click();
await page.getByLabel("Filter by category").selectOption("network");
await expect(page.locator(".event-row")).toHaveCount(1);
await page.getByLabel("Filter by category").selectOption("");
await page.getByRole("button", { name: /Next evidence/ }).click();
await expect(page.getByText(/of [0-9]+/).first()).toBeVisible();
await page.getByRole("button", { name: "Reports", exact: false }).click();
await expect(page.getByRole("heading", { name: "Export report", exact: true })).toBeVisible();
await expect(page.getByText("Redaction preview", { exact: true })).toBeVisible();
const jsonDownload = page.waitForEvent("download");
await page.getByRole("button", { name: "Export JSON", exact: true }).click();
expect((await jsonDownload).suggestedFilename()).toMatch(/eventlens\.json$/);
const markdownDownload = page.waitForEvent("download");
await page.getByRole("button", { name: "Export Markdown", exact: true }).click();
expect((await markdownDownload).suggestedFilename()).toMatch(/eventlens\.md$/);
});
+25
View File
@@ -0,0 +1,25 @@
import { expect, test } from "@playwright/test";
const partialAnalysis = {
source: "partial.evtx",
events: [{ id: "1", timestamp: "2026-09-09T10:00:00Z", timestamp_source: "event", timestamp_valid: true, provider: "Synthetic", event_id: 1000, level: "error", channel: "Application", computer: "TEST-PC", category: "crash", summary: "Synthetic crash", fields: {} }],
incidents: [{ id: "incident-1", rule_id: "windows.generic.attention", start: "2026-09-09T10:00:00Z", end: "2026-09-09T10:00:00Z", title: "Synthetic crash", category: "crash", severity: "warning", reasons: ["Parser retained only a partial result."], evidence_event_ids: ["1"], original_count: 1, compressed_count: 1 }],
parse: { parser: "evtx-rs", status: "partial", parsed_events: 1, records_seen: 4, skipped_records: 2, issue_count: 1, truncated: true, truncation_reason: "event_limit_reached", duration_ms: 8, issues: [{ code: "event_limit_reached", record_id: 4, message: "later records were not analyzed", skipped_record: true }] },
};
test("partial result is unmistakable and invalid extension is rejected", async ({ page }) => {
await page.route("**/api/analyze", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(partialAnalysis) }));
await page.goto("/#analysis");
await expect(page.getByRole("heading", { name: "Analysis", exact: true })).toBeVisible();
await page.locator('input[type="file"]').setInputFiles({ name: "partial.evtx", mimeType: "application/octet-stream", buffer: Buffer.from("synthetic") });
await expect(page.getByRole("alert")).toContainText("Partial analysis");
await expect(page.getByRole("alert")).toContainText("incident reconstruction may be incomplete");
await expect(page.getByText("event_limit_reached", { exact: true })).toBeVisible();
await expect(page.getByText("Analysis status", { exact: true })).toBeVisible();
await expect(page.getByText("2 skipped · 1 parser issue", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Timeline", exact: false }).click();
await expect(page.getByRole("alert")).toContainText("incident reconstruction may be incomplete");
await page.locator('input[type="file"]').setInputFiles({ name: "not-an-evtx.txt", mimeType: "text/plain", buffer: Buffer.from("nope") });
await expect(page.getByRole("alert").filter({ hasText: "Choose a Windows .evtx file." })).toBeVisible();
});
+29
View File
@@ -0,0 +1,29 @@
import { expect, test } from "@playwright/test";
test("capture deterministic v1.0.6 portfolio views", async ({ page }) => {
await page.goto("/#analysis");
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
await expect(page.getByRole("heading", { name: "Incident windows", exact: true })).toBeVisible();
await page.screenshot({ path: "../docs/assets/eventlens-demo-overview.png", fullPage: true });
await page.getByText("Service failure recorded", { exact: true }).click();
await expect(page.getByRole("heading", { name: "Event timeline", exact: true })).toBeVisible();
await page.screenshot({ path: "../docs/assets/eventlens-timeline-evidence.png", fullPage: true });
await page.getByRole("button", { name: "Stop focus", exact: true }).click();
await page.getByRole("textbox", { name: "Search normalized evidence" }).fill("DNS name resolution");
await expect(page.locator(".event-row")).toHaveCount(1);
await page.screenshot({ path: "../docs/assets/eventlens-filter-search.png", fullPage: true });
await page.getByRole("button", { name: "Hunting", exact: false }).click();
await expect(page.getByRole("heading", { name: "Hunting", exact: true })).toBeVisible();
await page.screenshot({ path: "../docs/assets/eventlens-hunting.png", fullPage: true });
await page.getByRole("button", { name: "Reports", exact: false }).click();
await expect(page.getByRole("heading", { name: "Export report", exact: true })).toBeVisible();
await page.screenshot({ path: "../docs/assets/eventlens-redaction-export.png", fullPage: true });
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(page.getByRole("heading", { name: "Local data & privacy", exact: true })).toBeVisible();
await page.screenshot({ path: "../docs/assets/eventlens-settings.png", fullPage: true });
});
+19
View File
@@ -0,0 +1,19 @@
import { existsSync } from "node:fs";
import { expect, test } from "@playwright/test";
const fixture = process.env.EVENTLENS_EVTX_FIXTURE;
test("pinned real EVTX fixture survives the browser upload path", async ({ page }) => {
test.skip(!fixture || !existsSync(fixture), "CI supplies the hash-verified public EVTX fixture");
await page.goto("/#analysis");
await page.locator('input[type="file"]').setInputFiles(fixture!);
await expect(page.locator(".metric-card").first()).toContainText("7", { timeout: 30_000 });
await expect(page.getByText("events parsed", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Timeline", exact: false }).click();
await page.getByLabel("Hide informational noise").uncheck();
await expect(page.locator(".event-row").first()).toBeVisible();
await expect(page.getByRole("button", { name: /4625/ })).toBeVisible();
await page.getByRole("button", { name: "Reports", exact: false }).click();
await expect(page.getByRole("heading", { name: "Export report", exact: true })).toBeVisible();
await page.getByRole("button", { name: /Export JSON/ }).click();
});
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from "@playwright/test";
test("core workspace actions remain usable across supported viewport widths", async ({ page }) => {
await page.goto("/#analysis");
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
for (const viewport of [{ width: 360, height: 800 }, { width: 390, height: 844 }, { width: 768, height: 900 }, { width: 1024, height: 768 }, { width: 1366, height: 768 }, { width: 1440, height: 900 }, { width: 1920, height: 1080 }]) {
await page.setViewportSize(viewport);
await expect(page.getByRole("button", { name: "Open .evtx", exact: true }).first()).toBeVisible();
for (const workspace of ["Analysis", "Timeline", "Hunting", "Reports", "Settings"]) {
const button = page.getByRole("button", { name: workspace, exact: false }).first();
await button.click();
await expect(page.getByRole("heading", { name: workspace, exact: true })).toBeVisible();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1);
expect(overflow, `horizontal overflow in ${workspace} at ${viewport.width}px`).toBeTruthy();
}
}
});
@@ -0,0 +1,32 @@
import { expect, test } from "@playwright/test";
test("settings keeps session state and reports a persistence failure", async ({ page }) => {
await page.addInitScript(() => {
const originalSetItem = Storage.prototype.setItem;
Storage.prototype.setItem = function setItem(key: string, value: string) {
if (key === "eventlens.preferences.v1") {
throw new DOMException("Storage is unavailable", "QuotaExceededError");
}
return originalSetItem.call(this, key, value);
};
});
await page.goto("/#settings");
const compact = page.getByRole("radio", { name: /Compact/i });
await compact.press("Space");
await expect(compact).toBeChecked();
await expect(page.locator(".settings-save-feedback")).toHaveText("Preferences changed for this session only");
await expect(page.getByText("Saved automatically in this browser", { exact: true })).toBeHidden();
await expect(page.getByText("Preferences are available for this session only because browser storage is unavailable.")).toBeVisible();
await expect(page.getByText("Not saved to browser storage")).toBeVisible();
await expect(page.getByText("Interface preferences for this session")).toBeVisible();
await page.waitForTimeout(2_600);
await expect(page.getByRole("status")).toHaveText("Preferences changed for this session only");
await expect(page.getByText("Saved automatically in this browser", { exact: true })).toBeHidden();
await page.reload();
await expect(page.getByRole("radio", { name: /Comfortable/i })).toBeChecked();
});
+59
View File
@@ -0,0 +1,59 @@
import { expect, test } from "@playwright/test";
test("Settings communicates browser-local saving and resets accessible preference controls", async ({ page }) => {
await page.goto("/#settings");
await expect(page.getByText("Saved automatically in this browser", { exact: true })).toBeVisible();
await expect(page.getByRole("group", { name: "Timestamp display" })).toBeVisible();
await expect(page.getByRole("group", { name: "Interface density" })).toBeVisible();
await expect(page.getByRole("switch", { name: "Export redaction by default" })).toBeChecked();
await page.locator("label.preference-option").filter({ hasText: "Local browser time" }).click();
await expect(page.getByRole("status").filter({ hasText: "Preferences saved" })).toBeVisible();
await page.getByRole("switch", { name: "Export redaction by default" }).press("Space");
await page.getByLabel("Default noise mode").selectOption("all");
await page.getByLabel("Default workspace").selectOption("hunting");
await page.getByRole("radio", { name: /Compact/ }).press("Space");
await page.reload();
await expect(page.getByRole("radio", { name: /Local browser time/ })).toBeChecked();
await expect(page.getByRole("switch", { name: "Export redaction by default" })).not.toBeChecked();
await expect(page.getByLabel("Default noise mode")).toHaveValue("all");
await expect(page.getByLabel("Default workspace")).toHaveValue("hunting");
await expect(page.getByRole("radio", { name: /Compact/ })).toBeChecked();
const storedKeys = await page.evaluate(() => Object.keys(localStorage));
expect(storedKeys).toEqual(["eventlens.preferences.v1"]);
await page.getByText("Technical details", { exact: true }).press("Enter");
await expect(page.getByText("eventlens.preferences.v1", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Reset all preferences" }).press("Enter");
await expect(page.getByRole("status").filter({ hasText: "Preferences reset to defaults" })).toBeVisible();
await expect(page.getByRole("radio", { name: /^UTC/ })).toBeChecked();
await expect(page.getByRole("switch", { name: "Export redaction by default" })).toBeChecked();
await expect(page.getByLabel("Default noise mode")).toHaveValue("attention");
await expect(page.getByLabel("Default workspace")).toHaveValue("analysis");
});
test("a browser-local default workspace applies only without a deep-link override", async ({ page }) => {
await page.goto("/#settings");
await page.getByLabel("Default workspace").selectOption("hunting");
await page.goto("/");
await expect(page).toHaveURL(/#hunting$/);
await expect(page.getByRole("heading", { name: "Hunting", exact: true })).toBeVisible();
await page.goto("/#timeline");
await expect(page).toHaveURL(/#timeline$/);
await expect(page.getByRole("heading", { name: "Timeline", exact: true })).toBeVisible();
});
test("the active Settings tab remains visible in the compact workspace navigation", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/#settings");
const settingsTab = page.getByRole("button", { name: "Settings", exact: true });
await expect(settingsTab).toHaveAttribute("aria-current", "page");
const box = await settingsTab.boundingBox();
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
});
+22
View File
@@ -0,0 +1,22 @@
import { expect, test } from "@playwright/test";
function largeAnalysis(count: number) {
return {
source: "synthetic-large.evtx",
events: Array.from({ length: count }, (_, index) => ({ id: String(index), timestamp: "2026-09-09T10:00:00Z", timestamp_source: "event", timestamp_valid: true, provider: "Synthetic Provider", event_id: index, level: "warning", channel: "Application", computer: "TEST-PC", category: "other", summary: `Synthetic event ${index}`, fields: { message: `Synthetic event ${index}` } })),
incidents: [],
};
}
test("large timelines virtualize DOM rows and keep search usable", async ({ page }) => {
const analysis = largeAnalysis(10_000);
await page.route("**/api/demo", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(analysis) }));
await page.goto("/#analysis");
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
await expect(page.getByText("10,000", { exact: false }).first()).toBeVisible();
expect(await page.locator(".event-row").count()).toBeLessThan(80);
await page.getByRole("button", { name: "Timeline", exact: false }).click();
await page.getByRole("textbox", { name: "Search normalized evidence" }).fill("Synthetic event 9999");
await expect(page.locator(".event-row")).toHaveCount(1);
await expect(page.locator(".event-row").first()).toContainText("9999");
});
+77
View File
@@ -0,0 +1,77 @@
import { expect, test } from "@playwright/test";
test("all workspaces are actionable and preserve the loaded analysis", async ({ page }) => {
await page.goto("/#analysis");
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
for (const workspace of ["Analysis", "Timeline", "Hunting", "Reports", "Settings"]) {
const button = page.getByRole("button", { name: workspace, exact: false }).first();
await expect(button).toBeEnabled();
await button.click();
await expect(button).toHaveAttribute("aria-current", "page");
}
await expect(page.getByRole("region", { name: "Local preferences context" })).toBeVisible();
await expect(page.getByRole("region", { name: "Current investigation context" })).toHaveCount(0);
await page.getByRole("button", { name: "Timeline", exact: false }).click();
await expect(page.getByRole("heading", { name: "Event timeline", exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Inspector", exact: true })).toBeVisible();
await page.goBack();
await expect(page.getByRole("heading", { name: "Settings", exact: true })).toBeVisible();
await page.goForward();
await expect(page.getByRole("heading", { name: "Event timeline", exact: true })).toBeVisible();
});
test("context and global actions keep an ordered, workspace-appropriate layout", async ({ page }) => {
await page.goto("/#analysis");
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
const geometry = await page.evaluate(() => {
const rect = (selector: string) => document.querySelector(selector)?.getBoundingClientRect();
const topbar = rect(".event-topbar");
const context = rect(".investigation-context");
const actions = rect(".global-actions");
return { topbar, context, actions };
});
expect(geometry.topbar?.bottom).toBeLessThanOrEqual((geometry.context?.top ?? 0) + 1);
expect(geometry.context?.bottom).toBeLessThanOrEqual((geometry.actions?.top ?? 0) + 1);
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(page.getByRole("region", { name: "Local preferences context" })).toBeVisible();
await expect(page.getByRole("region", { name: "Current investigation context" })).toHaveCount(0);
await expect(page.getByRole("region", { name: "Analysis actions" })).toBeVisible();
await expect(page.getByText("Source", { exact: true })).toHaveCount(0);
});
test("Hunting combines facets and opens a result in the inspector", async ({ page }) => {
await page.goto("/#analysis");
await page.getByRole("button", { name: "Load demo", exact: true }).first().click();
await page.getByRole("button", { name: "Hunting", exact: false }).click();
await page.getByRole("textbox", { name: "Search hunting evidence" }).fill("DNS name resolution");
await expect(page.getByRole("status")).toHaveText("1 of 6 matching events");
await expect(page.getByRole("button", { name: /DNS name resolution timed out/ })).toBeVisible();
await page.getByRole("button", { name: /DNS name resolution timed out/ }).click();
await expect(page.getByRole("heading", { name: "Inspector", exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "DNS name resolution timed out", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Open in Timeline", exact: false }).click();
await expect(page).toHaveURL(/#timeline$/);
await expect(page.getByRole("heading", { name: "Event timeline", exact: true })).toBeVisible();
});
test("Settings persist preferences without persisting event data", async ({ page }) => {
await page.goto("/#settings");
await page.getByRole("radio", { name: /Local browser time/ }).press("Space");
await page.getByLabel("Default noise mode").selectOption("all");
await page.getByLabel("Default workspace").selectOption("hunting");
await page.getByRole("radio", { name: /Compact/ }).press("Space");
await page.reload();
await expect(page.getByLabel("Local browser time", { exact: false })).toBeChecked();
await expect(page.getByLabel("Default noise mode")).toHaveValue("all");
await expect(page.getByLabel("Default workspace")).toHaveValue("hunting");
await expect(page.getByLabel("Compact", { exact: false })).toBeChecked();
const stored = await page.evaluate(() => JSON.stringify(localStorage));
expect(stored).toContain("eventlens.preferences.v1");
expect(stored).not.toContain("bundled-demo");
expect(stored).not.toContain("Example Sync Service");
});
+43
View File
@@ -0,0 +1,43 @@
import eslint from "@eslint/js";
import globals from "globals";
import jsxA11y from "eslint-plugin-jsx-a11y";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist/**", "node_modules/**"] },
eslint.configs.recommended,
...tseslint.configs.recommended,
react.configs.flat.recommended,
react.configs.flat["jsx-runtime"],
jsxA11y.flatConfigs.recommended,
{
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
plugins: { "react-hooks": reactHooks },
settings: { react: { version: "detect" } },
rules: {
"jsx-a11y/label-has-associated-control": ["error", { depth: 4 }],
"no-alert": "error",
"no-eval": "error",
"no-undef": "off",
"no-unused-vars": "off",
"react/no-danger": "error",
"react/prop-types": "off",
"react-hooks/exhaustive-deps": "warn",
"react-hooks/rules-of-hooks": "error",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-unused-vars": ["error", {
argsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
ignoreRestSiblings: true,
varsIgnorePattern: "^_",
}],
},
},
);
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Local-first Windows EVTX incident reconstruction with deterministic correlation, evidence timelines and privacy-aware exports." />
<meta name="application-name" content="EventLens" />
<meta name="theme-color" content="#07131f" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<title>EventLens — Windows Incident Reconstruction</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5652
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "eventlens-web",
"version": "1.0.6",
"private": true,
"scripts": {
"dev": "vite",
"lint": "eslint src",
"build": "tsc --noEmit && vite build",
"test": "vitest run --passWithNoTests",
"test:e2e": "playwright test"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@playwright/test": "^1.63.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.39.5",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"typescript": "^5.8.0",
"typescript-eslint": "^8.70.1",
"vite": "^6.0.0",
"vitest": "^5.0.0"
}
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? "line" : "list",
use: {
baseURL: "http://127.0.0.1:4173",
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
webServer: [
{
command: "python -m uv run --directory backend --locked --extra dev python -m uvicorn app.main:app --host 127.0.0.1 --port 8000",
cwd: "..",
url: "http://127.0.0.1:8000/health",
reuseExistingServer: false,
timeout: 30_000,
},
{
command: "npm run dev -- --host 127.0.0.1 --port 4173",
cwd: ".",
url: "http://127.0.0.1:4173",
reuseExistingServer: false,
timeout: 30_000,
},
],
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<title id="title">EventLens evidence lens</title>
<rect width="64" height="64" rx="15" fill="#07131f"/>
<circle cx="27" cy="27" r="16" fill="none" stroke="#65d9d0" stroke-width="5"/>
<path d="M16 29h22" fill="none" stroke="#dff8f5" stroke-width="2.7" stroke-linecap="round"/>
<circle cx="20" cy="29" r="2.4" fill="#65d9d0"/>
<circle cx="27" cy="29" r="2.4" fill="#65d9d0"/>
<circle cx="34" cy="29" r="2.4" fill="#65d9d0"/>
<path d="m39 39 12 12" fill="none" stroke="#f4b860" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 636 B

+23
View File
@@ -0,0 +1,23 @@
{
"name": "EventLens — Windows Incident Reconstruction",
"short_name": "EventLens",
"description": "Local-first Windows EVTX incident reconstruction with deterministic correlation and evidence timelines.",
"start_url": "/#analysis",
"display": "standalone",
"background_color": "#07131f",
"theme_color": "#07131f",
"icons": [
{
"src": "/eventlens-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/eventlens-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
+252
View File
@@ -0,0 +1,252 @@
import { type ChangeEvent, type DragEvent, useEffect, useMemo, useRef, useState } from "react";
import { analysisToMarkdown, prepareExport } from "../export";
import { filterEvents, uniqueProviders, type EventFilters } from "../filter";
import type { Analysis, EventItem, Incident } from "../types";
import { downloadText, exportStem } from "./format";
import { DEFAULT_SETTINGS, EMPTY_FILTERS, EMPTY_HUNTING, ROW_HEIGHTS, SETTINGS_KEY, readSettings, type FilterState, type HuntingState, type InspectorTab, type UiSettings, type Workspace, workspaceFromHash } from "./model";
import { isAnalysis } from "./validation";
import { AppTopbar, GlobalActions, InvestigationContext, Statusbar } from "../components/shell";
import { ErrorBanner, ParserIssueNotice, PartialAnalysisWarning } from "../components/states";
import { AnalysisView } from "../workspaces/AnalysisView";
import { HuntingView } from "../workspaces/HuntingView";
import { ReportsView } from "../workspaces/ReportsView";
import { SettingsView } from "../workspaces/SettingsView";
import { TimelineView } from "../workspaces/TimelineView";
const API = (import.meta.env.VITE_API_BASE_URL ?? (import.meta.env.DEV ? "http://127.0.0.1:8000" : "")).replace(/\/$/, "");
const APP_VERSION = import.meta.env.VITE_EVENTLENS_VERSION ?? "development";
function analysisErrorMessage(status: number, body: unknown): string {
const detail = typeof body === "object" && body !== null && "detail" in body ? body.detail : null;
const code = typeof detail === "object" && detail !== null && "code" in detail ? detail.code : null;
if (code === "no_readable_events") return "No readable Windows events were found in this file.";
if (code === "parser_rejected_input") return "The EVTX parser rejected the input. Check the file and retry.";
if (code === "parse_capacity_exceeded") return "EventLens is busy analyzing another upload; retry shortly.";
if (status === 400) return "Upload a Windows .evtx file.";
if (status === 413) return "EVTX file exceeds the 50 MiB limit.";
if (status >= 500) return "The local EventLens service is unavailable. Check the service and retry.";
return `Analysis failed (HTTP ${status}).`;
}
export function App() {
const [settings, setSettings] = useState<UiSettings>(readSettings);
const [activeWorkspace, setActiveWorkspace] = useState<Workspace>(() => workspaceFromHash() ?? readSettings().defaultWorkspace);
const [data, setData] = useState<Analysis | null>(null);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
const [activeIncidentId, setActiveIncidentId] = useState("");
const [focusIncident, setFocusIncident] = useState(false);
const [filters, setFilters] = useState<FilterState>(() => EMPTY_FILTERS(readSettings().defaultNoise === "attention"));
const [hunting, setHunting] = useState<HuntingState>(EMPTY_HUNTING);
const [redactExports, setRedactExports] = useState(() => readSettings().defaultRedaction);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [inspectorTab, setInspectorTab] = useState<InspectorTab>("details");
const [settingsNotice, setSettingsNotice] = useState("");
const [settingsPersistenceFailed, setSettingsPersistenceFailed] = useState(false);
const [settingsStorageConfirmed, setSettingsStorageConfirmed] = useState(false);
const fileInput = useRef<HTMLInputElement>(null);
const requestRef = useRef<AbortController | null>(null);
useEffect(() => {
try {
window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
setSettingsPersistenceFailed(false);
setSettingsStorageConfirmed(true);
setSettingsNotice((notice) => {
if (notice === "Saving preferences…") return "Preferences saved";
if (notice === "Resetting preferences…") return "Preferences reset to defaults";
return notice;
});
} catch {
setSettingsPersistenceFailed(true);
setSettingsStorageConfirmed(false);
setSettingsNotice("Preferences changed for this session only");
}
}, [settings]);
useEffect(() => {
if (!settingsNotice) return;
const timeout = window.setTimeout(() => setSettingsNotice(""), 2_500);
return () => window.clearTimeout(timeout);
}, [settingsNotice]);
useEffect(() => {
const onHashChange = () => setActiveWorkspace(workspaceFromHash() ?? settings.defaultWorkspace);
window.addEventListener("hashchange", onHashChange);
if (!workspaceFromHash()) window.history.replaceState(null, "", `#${activeWorkspace}`);
return () => window.removeEventListener("hashchange", onHashChange);
}, [activeWorkspace, settings.defaultWorkspace]);
const navigate = (workspace: Workspace) => {
if (workspace === activeWorkspace && window.location.hash === `#${workspace}`) return;
window.location.hash = workspace;
setActiveWorkspace(workspace);
};
const activeIncident = useMemo(() => data?.incidents.find((incident) => incident.id === activeIncidentId) ?? null, [data, activeIncidentId]);
const selected = useMemo(() => data?.events.find((event) => event.id === selectedEventId) ?? null, [data, selectedEventId]);
const evidenceIds = useMemo(() => focusIncident && activeIncident ? new Set(activeIncident.evidence_event_ids) : null, [focusIncident, activeIncident]);
const providers = useMemo(() => uniqueProviders(data?.events ?? []), [data]);
const visibleEvents = useMemo(() => {
const eventFilters: EventFilters = {
search: filters.search,
category: filters.category,
level: filters.level,
provider: filters.provider,
noiseMode: filters.attentionOnly ? "attention" : "all",
evidenceIds,
windowMinutes: filters.windowMinutes,
anchorTimestamp: selected?.timestamp ?? activeIncident?.start ?? null,
};
return filterEvents(data?.events ?? [], eventFilters);
}, [data, filters, evidenceIds, selected, activeIncident]);
const incidentEventIds = useMemo(() => new Set(data?.incidents.flatMap((incident) => incident.evidence_event_ids) ?? []), [data]);
const huntingResults = useMemo(() => {
const filtered = filterEvents(data?.events ?? [], { search: hunting.search, category: hunting.category, level: hunting.level, provider: hunting.provider, noiseMode: "all", windowMinutes: hunting.windowMinutes, anchorTimestamp: selected?.timestamp ?? activeIncident?.start ?? null });
return filtered.filter((event) => (!hunting.eventId || String(event.event_id ?? "") === hunting.eventId) && (!hunting.computer || (event.computer ?? "").toLowerCase().includes(hunting.computer.toLowerCase())) && (!hunting.incidentOnly || incidentEventIds.has(event.id)));
}, [data, hunting, selected, activeIncident, incidentEventIds]);
const redactionPreview = useMemo(() => data && redactExports ? prepareExport(data, true).redaction : null, [data, redactExports]);
const rowHeight = ROW_HEIGHTS[settings.density];
const contextVisible = activeWorkspace === "timeline" ? visibleEvents.length : activeWorkspace === "hunting" ? huntingResults.length : undefined;
function updateSettings(patch: Partial<UiSettings>) {
setSettingsStorageConfirmed(false);
setSettings((current) => ({ ...current, ...patch }));
setSettingsNotice("Saving preferences…");
if (patch.defaultNoise) setFilters((current) => ({ ...current, attentionOnly: patch.defaultNoise === "attention" }));
if (typeof patch.defaultRedaction === "boolean") setRedactExports(patch.defaultRedaction);
}
function resetSettings() {
setSettingsStorageConfirmed(false);
setSettings({ ...DEFAULT_SETTINGS });
setFilters(EMPTY_FILTERS(true));
setRedactExports(true);
setSettingsNotice("Resetting preferences…");
}
function resetFilters() {
setFilters(EMPTY_FILTERS(settings.defaultNoise === "attention"));
setFocusIncident(false);
}
function resetHunting() { setHunting(EMPTY_HUNTING()); }
function resetView(next: Analysis) {
setData(next);
setSelectedEventId(next.events[0]?.id ?? null);
setActiveIncidentId(next.incidents[0]?.id ?? "");
setFocusIncident(false);
setFilters(EMPTY_FILTERS(settings.defaultNoise === "attention"));
resetHunting();
setInspectorTab("details");
}
async function loadDemo() {
requestRef.current?.abort();
const controller = new AbortController();
requestRef.current = controller;
setLoading(true); setError("");
try {
const response = await fetch(`${API}/api/demo`, { cache: "no-store", signal: controller.signal });
const body: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error("Demo request failed");
if (!isAnalysis(body)) throw new Error("Demo returned an invalid response");
if (requestRef.current !== controller || controller.signal.aborted) return;
resetView(body);
} catch (reason) {
if (requestRef.current === controller && !controller.signal.aborted) setError(reason instanceof Error && reason.message === "Demo returned an invalid response" ? reason.message : "The local EventLens service is unavailable. Check the service and retry.");
} finally { if (requestRef.current === controller) { requestRef.current = null; setLoading(false); } }
}
async function analyzeFile(file: File) {
requestRef.current?.abort();
requestRef.current = null;
if (!file.name.toLowerCase().endsWith(".evtx")) { setLoading(false); setError("Choose a Windows .evtx file."); return; }
const controller = new AbortController();
requestRef.current = controller;
setLoading(true); setError("");
const form = new FormData(); form.append("file", file);
try {
const response = await fetch(`${API}/api/analyze`, { method: "POST", body: form, cache: "no-store", signal: controller.signal });
const body: unknown = await response.json().catch(() => null);
if (requestRef.current !== controller || controller.signal.aborted) return;
if (!response.ok) { setError(analysisErrorMessage(response.status, body)); return; }
if (!isAnalysis(body)) throw new Error("Analysis returned an invalid response.");
resetView(body);
} catch (reason) {
if (requestRef.current === controller && !controller.signal.aborted) setError(reason instanceof Error && reason.message === "Analysis returned an invalid response." ? reason.message : "The local EventLens service is unavailable. Check the service and retry.");
} finally { if (requestRef.current === controller) { requestRef.current = null; setLoading(false); } }
}
async function upload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) await analyzeFile(file);
event.target.value = "";
}
async function drop(event: DragEvent<HTMLElement>) {
event.preventDefault(); setDragging(false);
const file = event.dataTransfer.files?.[0];
if (file) await analyzeFile(file);
}
function selectIncident(id: string, openTimeline = false) {
const incident = data?.incidents.find((item) => item.id === id);
if (!incident) { setActiveIncidentId(""); return; }
setActiveIncidentId(id);
setSelectedEventId(incident.evidence_event_ids[0] ?? null);
setInspectorTab("details");
if (openTimeline) { setFocusIncident(true); navigate("timeline"); }
}
function selectEvent(event: EventItem) {
setSelectedEventId(event.id);
const incident = data?.incidents.find((item) => item.evidence_event_ids.includes(event.id));
if (incident) setActiveIncidentId(incident.id);
}
function moveSelection(delta: number, events: EventItem[]) {
if (!events.length) return;
const index = selected ? events.findIndex((event) => event.id === selected.id) : -1;
const next = index < 0 ? (delta > 0 ? 0 : events.length - 1) : Math.min(events.length - 1, Math.max(0, index + delta));
selectEvent(events[next]);
}
function exportJson() {
if (!data) return;
const prepared = prepareExport(data, redactExports);
downloadText(`${exportStem(data.source)}-eventlens.json`, JSON.stringify(prepared.analysis, null, 2), "application/json");
}
function exportMarkdown() {
if (!data) return;
downloadText(`${exportStem(data.source)}-eventlens.md`, analysisToMarkdown(data, redactExports), "text/markdown");
}
useEffect(() => {
const scopedEvents = activeWorkspace === "hunting" ? huntingResults : visibleEvents;
if (!selectedEventId || !scopedEvents.some((event) => event.id === selectedEventId)) setSelectedEventId(scopedEvents[0]?.id ?? null);
}, [activeWorkspace, huntingResults, visibleEvents, selectedEventId]);
return <main className={`event-app-shell density-${settings.density}`} onDragOver={(event) => { event.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={drop}>
<AppTopbar activeWorkspace={activeWorkspace} onNavigate={navigate} version={APP_VERSION} />
<section className="event-main" aria-label={`${activeWorkspace} workspace`}>
<InvestigationContext data={data} activeIncident={activeIncident} visibleCount={contextVisible} workspace={activeWorkspace} />
<GlobalActions loading={loading} dragging={dragging} onOpen={() => fileInput.current?.click()} onDemo={() => void loadDemo()} workspace={activeWorkspace} />
<input className="hidden-input" aria-label="Choose EVTX file" ref={fileInput} type="file" accept=".evtx" onChange={upload} />
{loading ? <div className="loading-banner" role="status" aria-live="polite"><span className="loading-spinner" aria-hidden="true" />Analyzing event log… The application shell remains available.</div> : null}
{error ? <ErrorBanner message={error} onDemo={() => void loadDemo()} /> : null}
{data?.parse?.status === "partial" ? <PartialAnalysisWarning parse={data.parse} /> : null}
{data?.parse && data.parse.status !== "partial" && data.parse.issues.length ? <ParserIssueNotice parse={data.parse} /> : null}
<div className="workspace-content">
{activeWorkspace === "analysis" ? <AnalysisView data={data} timestampDisplay={settings.timestampDisplay} activeIncidentId={activeIncidentId} onOpen={(incident: Incident) => selectIncident(incident.id, true)} onOpenFile={() => fileInput.current?.click()} onDemo={() => void loadDemo()} /> : null}
{activeWorkspace === "timeline" ? <TimelineView data={data} filters={filters} providers={providers} visibleEvents={visibleEvents} selected={selected} activeIncident={activeIncident} activeIncidentId={activeIncidentId} focusIncident={focusIncident} timestampDisplay={settings.timestampDisplay} rowHeight={rowHeight} inspectorTab={inspectorTab} onFilterChange={(patch) => setFilters((current) => ({ ...current, ...patch }))} onResetFilters={resetFilters} onFocusChange={setFocusIncident} onSelectIncident={selectIncident} onSelectEvent={selectEvent} onMove={(delta) => moveSelection(delta, visibleEvents)} onTab={setInspectorTab} onOpenFile={() => fileInput.current?.click()} onDemo={() => void loadDemo()} /> : null}
{activeWorkspace === "hunting" ? <HuntingView data={data} state={hunting} providers={providers} results={huntingResults} selected={selected} activeIncident={activeIncident} timestampDisplay={settings.timestampDisplay} inspectorTab={inspectorTab} incidentEventIds={incidentEventIds} onChange={(patch) => setHunting((current) => ({ ...current, ...patch }))} onReset={resetHunting} onSelectEvent={selectEvent} onMove={(delta) => moveSelection(delta, huntingResults)} onTab={setInspectorTab} onOpenTimeline={() => navigate("timeline")} onOpenFile={() => fileInput.current?.click()} onDemo={() => void loadDemo()} /> : null}
{activeWorkspace === "reports" ? <ReportsView data={data} redactExports={redactExports} redactionPreview={redactionPreview} onRedactionChange={setRedactExports} onExportJson={exportJson} onExportMarkdown={exportMarkdown} timestampDisplay={settings.timestampDisplay} onOpenFile={() => fileInput.current?.click()} onDemo={() => void loadDemo()} /> : null}
{activeWorkspace === "settings" ? <SettingsView settings={settings} saveNotice={settingsPersistenceFailed ? "Preferences changed for this session only" : settingsNotice} storageFailed={settingsPersistenceFailed} storageConfirmed={settingsStorageConfirmed} onChange={updateSettings} onReset={resetSettings} /> : null}
</div>
<Statusbar data={data} />
</section>
</main>;
}
+68
View File
@@ -0,0 +1,68 @@
import type { Analysis } from "../types";
import type { TimestampDisplay } from "./model";
export function sourceLabel(source: string): string {
return source.split(/[\\/]/).pop() || source;
}
export function safeDate(timestamp: string | null): Date | null {
if (!timestamp) return null;
const date = new Date(timestamp);
return Number.isNaN(date.getTime()) ? null : date;
}
export function formatClock(timestamp: string | null, display: TimestampDisplay): string {
const date = safeDate(timestamp);
if (!date) return "Unknown time";
return display === "utc"
? `${date.toISOString().slice(11, 16)} UTC`
: date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export function formatDate(timestamp: string | null, display: TimestampDisplay): string {
const date = safeDate(timestamp);
if (!date) return "Unknown date";
return display === "utc"
? date.toISOString().slice(0, 10)
: date.toLocaleDateString([], { year: "numeric", month: "2-digit", day: "2-digit" });
}
export function formatDateTime(timestamp: string | null, display: TimestampDisplay): string {
const date = safeDate(timestamp);
if (!date) return "Unknown — timestamp is not reliable";
return display === "utc"
? `${date.toISOString().replace("T", " ").replace("Z", "")} (UTC)`
: date.toLocaleString([], { dateStyle: "medium", timeStyle: "medium" });
}
export function formatRange(start: string | null, end: string | null, display: TimestampDisplay): string {
if (!start) return "Unknown time";
return `${formatDate(start, display)} · ${formatClock(start, display)} – ${end ? formatClock(end, display) : "unknown"}`;
}
export function levelLabel(level: string): string {
return level === "error" || level === "critical" ? "High" : level === "warning" ? "Medium" : level === "information" ? "Info" : level;
}
export function statusLabel(data: Analysis | null): string {
if (!data) return "No analysis loaded";
if (!data.parse) return "Complete";
return data.parse.status === "complete" ? "Complete" : data.parse.status === "partial" ? "Partial" : "Failed";
}
export function downloadText(filename: string, content: string, type: string): void {
const blob = new Blob([content], { type });
const href = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = href;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(href), 0);
}
export function exportStem(source: string): string {
const base = sourceLabel(source).replace(/\.evtx$/i, "");
return base.replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 80) || "eventlens";
}
+84
View File
@@ -0,0 +1,84 @@
export const CATEGORIES = ["system", "service", "network", "authentication", "application", "crash", "other"];
export const LEVELS = ["critical", "error", "warning", "information", "verbose"];
export const WINDOWS = [0, 1, 5, 15, 60];
export const WORKSPACES = ["analysis", "timeline", "hunting", "reports", "settings"] as const;
export const ROW_HEIGHTS = { comfortable: 58, compact: 48 } as const;
export const OVERSCAN = 8;
export const SETTINGS_KEY = "eventlens.preferences.v1";
export type Workspace = (typeof WORKSPACES)[number];
export type TimestampDisplay = "utc" | "local";
export type Density = "comfortable" | "compact";
export type UiSettings = {
timestampDisplay: TimestampDisplay;
defaultNoise: "attention" | "all";
defaultRedaction: boolean;
density: Density;
defaultWorkspace: Workspace;
};
export type FilterState = {
search: string;
category: string;
level: string;
provider: string;
windowMinutes: number;
attentionOnly: boolean;
};
export type HuntingState = FilterState & {
eventId: string;
computer: string;
incidentOnly: boolean;
};
export type InspectorTab = "details" | "raw" | "related" | "insights";
export const DEFAULT_SETTINGS: UiSettings = {
timestampDisplay: "utc",
defaultNoise: "attention",
defaultRedaction: true,
density: "comfortable",
defaultWorkspace: "analysis",
};
export const EMPTY_FILTERS = (attentionOnly: boolean): FilterState => ({
search: "",
category: "",
level: "",
provider: "",
windowMinutes: 0,
attentionOnly,
});
export const EMPTY_HUNTING = (): HuntingState => ({
...EMPTY_FILTERS(false),
eventId: "",
computer: "",
incidentOnly: false,
});
export function readSettings(): UiSettings {
if (typeof window === "undefined") return { ...DEFAULT_SETTINGS };
try {
const parsed = JSON.parse(window.localStorage.getItem(SETTINGS_KEY) ?? "null") as Partial<UiSettings> | null;
if (!parsed || typeof parsed !== "object") return { ...DEFAULT_SETTINGS };
return {
timestampDisplay: parsed.timestampDisplay === "local" ? "local" : "utc",
defaultNoise: parsed.defaultNoise === "all" ? "all" : "attention",
defaultRedaction: typeof parsed.defaultRedaction === "boolean" ? parsed.defaultRedaction : true,
density: parsed.density === "compact" ? "compact" : "comfortable",
defaultWorkspace: WORKSPACES.includes(parsed.defaultWorkspace as Workspace)
? parsed.defaultWorkspace as Workspace
: "analysis",
};
} catch {
return { ...DEFAULT_SETTINGS };
}
}
export function workspaceFromHash(hash = typeof window === "undefined" ? "" : window.location.hash): Workspace | null {
const value = hash.replace(/^#/, "");
return WORKSPACES.includes(value as Workspace) ? value as Workspace : null;
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { isAnalysis } from "./validation";
const valid = {
source: "demo.evtx",
events: [{
id: "event-1",
timestamp: "2026-09-22T08:00:00Z",
timestamp_source: "event",
timestamp_valid: true,
provider: "Provider",
event_id: 1000,
level: "Error",
category: "crash",
summary: "Example event",
fields: { process: "example.exe" },
}],
incidents: [{
id: "incident-1",
rule_id: "crash.sequence",
start: "2026-09-22T08:00:00Z",
end: "2026-09-22T08:00:00Z",
title: "Example incident",
category: "crash",
severity: "high",
reasons: ["Observed crash evidence."],
evidence_event_ids: ["event-1"],
original_count: 1,
compressed_count: 1,
}],
parse: {
parser: "evtx",
status: "complete",
parsed_events: 1,
records_seen: 1,
skipped_records: 0,
issue_count: 0,
truncated: false,
truncation_reason: null,
duration_ms: 5,
issues: [],
},
};
describe("isAnalysis", () => {
it("accepts the normalized analysis contract", () => {
expect(isAnalysis(valid)).toBe(true);
});
it("rejects a successful-looking object without event arrays", () => {
expect(isAnalysis({ source: "demo.evtx", incidents: [] })).toBe(false);
});
it("rejects malformed nested evidence instead of trusting a 2xx payload", () => {
expect(isAnalysis({ ...valid, events: [{ ...valid.events[0], fields: { process: 42 } }] })).toBe(false);
expect(isAnalysis({ ...valid, parse: { ...valid.parse, status: "unknown" } })).toBe(false);
});
it("rejects missing evidence references and duplicate event IDs", () => {
expect(isAnalysis({ ...valid, incidents: [{ ...valid.incidents[0], evidence_event_ids: ["missing"] }] })).toBe(false);
expect(isAnalysis({ ...valid, events: [...valid.events, valid.events[0]], parse: { ...valid.parse, parsed_events: 2 } })).toBe(false);
});
it("rejects invalid timestamps and contradictory timestamp metadata", () => {
expect(isAnalysis({ ...valid, events: [{ ...valid.events[0], timestamp: "not-a-date" }] })).toBe(false);
expect(isAnalysis({ ...valid, events: [{ ...valid.events[0], timestamp: null, timestamp_valid: true }] })).toBe(false);
expect(isAnalysis({ ...valid, events: [{ ...valid.events[0], timestamp_source: "invalid", timestamp_valid: true }] })).toBe(false);
expect(isAnalysis({ ...valid, incidents: [{ ...valid.incidents[0], start: "not-a-date" }] })).toBe(false);
});
it("rejects failed or inconsistent successful parse responses", () => {
expect(isAnalysis({ ...valid, parse: { ...valid.parse, status: "failed" } })).toBe(false);
expect(isAnalysis({ ...valid, parse: { ...valid.parse, parsed_events: 2 } })).toBe(false);
expect(isAnalysis({ ...valid, parse: { ...valid.parse, issue_count: 1 } })).toBe(false);
expect(isAnalysis({ ...valid, incidents: [{ ...valid.incidents[0], original_count: 1.5 }] })).toBe(false);
});
});
+82
View File
@@ -0,0 +1,82 @@
import type { Analysis, EventItem, Incident, ParseIssue, ParseStats } from "../types";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isTimestamp(value: unknown): value is string | null {
return value === null || (typeof value === "string" && /(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value)));
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function isNonnegativeInteger(value: unknown): value is number {
return isFiniteNumber(value) && Number.isInteger(value) && value >= 0;
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function isEventItem(value: unknown): value is EventItem {
if (!isRecord(value)) return false;
if (typeof value.id !== "string" || !isTimestamp(value.timestamp)) return false;
if (typeof value.provider !== "string" || !(value.event_id === null || (isNonnegativeInteger(value.event_id) && value.event_id <= 4_294_967_295))) return false;
if (typeof value.level !== "string" || typeof value.category !== "string" || typeof value.summary !== "string") return false;
if (value.channel !== undefined && typeof value.channel !== "string") return false;
if (value.computer !== undefined && typeof value.computer !== "string") return false;
if (value.fields !== undefined) {
if (!isRecord(value.fields) || !Object.values(value.fields).every((item) => typeof item === "string")) return false;
}
if (typeof value.timestamp_valid !== "boolean" || !["event", "record", "missing", "invalid"].includes(String(value.timestamp_source))) return false;
if (value.timestamp === null && value.timestamp_valid) return false;
if (["event", "record"].includes(String(value.timestamp_source)) !== value.timestamp_valid) return false;
return true;
}
function isIncident(value: unknown): value is Incident {
if (!isRecord(value)) return false;
return typeof value.id === "string"
&& typeof value.rule_id === "string"
&& isTimestamp(value.start)
&& isTimestamp(value.end)
&& typeof value.title === "string"
&& typeof value.category === "string"
&& typeof value.severity === "string"
&& isStringArray(value.reasons)
&& isStringArray(value.evidence_event_ids)
&& isNonnegativeInteger(value.original_count)
&& value.original_count > 0
&& isNonnegativeInteger(value.compressed_count)
&& value.compressed_count > 0;
}
function isParseIssue(value: unknown): value is ParseIssue {
if (!isRecord(value) || typeof value.code !== "string" || typeof value.message !== "string") return false;
if (value.record_id !== undefined && value.record_id !== null && !isNonnegativeInteger(value.record_id)) return false;
if (value.skipped_record !== undefined && typeof value.skipped_record !== "boolean") return false;
return true;
}
function isParseStats(value: unknown): value is ParseStats {
if (!isRecord(value)) return false;
if (typeof value.parser !== "string" || !["complete", "partial"].includes(String(value.status))) return false;
for (const key of ["parsed_events", "records_seen", "skipped_records", "issue_count", "duration_ms"] as const) {
if (!isNonnegativeInteger(value[key])) return false;
}
if (typeof value.truncated !== "boolean") return false;
if (value.truncation_reason !== undefined && value.truncation_reason !== null && typeof value.truncation_reason !== "string") return false;
return Array.isArray(value.issues) && value.issues.every(isParseIssue) && value.issue_count === value.issues.length;
}
export function isAnalysis(value: unknown): value is Analysis {
if (!isRecord(value) || typeof value.source !== "string") return false;
if (!Array.isArray(value.events) || !value.events.every(isEventItem)) return false;
if (!Array.isArray(value.incidents) || !value.incidents.every(isIncident)) return false;
const eventIds = new Set(value.events.map((event: EventItem) => event.id));
if (eventIds.size !== value.events.length) return false;
if (!value.incidents.every((incident: Incident) => incident.evidence_event_ids.every((id) => eventIds.has(id)))) return false;
return value.parse === undefined || value.parse === null || (isParseStats(value.parse) && value.parse.parsed_events === value.events.length);
}
+61
View File
@@ -0,0 +1,61 @@
import type { SVGProps } from "react";
export type IconName =
| "analysis"
| "timeline"
| "hunting"
| "reports"
| "settings"
| "file"
| "upload"
| "demo"
| "search"
| "clock"
| "warning"
| "arrow-left"
| "arrow-right"
| "reset"
| "info"
| "export"
| "check"
| "privacy";
type IconProps = SVGProps<SVGSVGElement> & { name: IconName; title?: string };
const ICON_PROPS = { fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
export function Icon({ name, title, ...props }: IconProps) {
const content = {
analysis: <><path {...ICON_PROPS} d="M4 5h16v14H4z" /><path {...ICON_PROPS} d="M7 15h2m3-4h2m3-3h0" /><circle cx="8" cy="15" r="1" fill="currentColor" /><circle cx="13" cy="11" r="1" fill="currentColor" /><circle cx="18" cy="8" r="1" fill="currentColor" /></>,
timeline: <><path {...ICON_PROPS} d="M5 5v14M12 5v14M19 5v14" /><circle cx="5" cy="9" r="2" fill="currentColor" /><circle cx="12" cy="15" r="2" fill="currentColor" /><circle cx="19" cy="7" r="2" fill="currentColor" /></>,
hunting: <><circle {...ICON_PROPS} cx="10.5" cy="10.5" r="5.5" /><path {...ICON_PROPS} d="m15 15 5 5M4 4l2 2m12 12 2 2" /></>,
reports: <><path {...ICON_PROPS} d="M6 3h8l4 4v14H6z" /><path {...ICON_PROPS} d="M14 3v5h5M9 12h6m-6 4h6" /></>,
settings: <><path {...ICON_PROPS} d="M5 7h14M5 12h14M5 17h14" /><circle {...ICON_PROPS} cx="9" cy="7" r="2" /><circle {...ICON_PROPS} cx="15" cy="12" r="2" /><circle {...ICON_PROPS} cx="11" cy="17" r="2" /></>,
file: <><path {...ICON_PROPS} d="M6 3h8l4 4v14H6z" /><path {...ICON_PROPS} d="M14 3v5h5" /></>,
upload: <><path {...ICON_PROPS} d="M12 16V4m0 0L8 8m4-4 4 4M5 20h14" /></>,
demo: <><path {...ICON_PROPS} d="M12 3 20 8v8l-8 5-8-5V8z" /><path {...ICON_PROPS} d="m10 9 5 3-5 3z" fill="currentColor" stroke="none" /></>,
search: <><circle {...ICON_PROPS} cx="10.5" cy="10.5" r="6" /><path {...ICON_PROPS} d="m15 15 5 5" /></>,
clock: <><circle {...ICON_PROPS} cx="12" cy="12" r="8" /><path {...ICON_PROPS} d="M12 7v5l3 2" /></>,
warning: <><path {...ICON_PROPS} d="M12 3 22 20H2L12 3Z" /><path {...ICON_PROPS} d="M12 8v5m0 3v.2" /></>,
"arrow-left": <path {...ICON_PROPS} d="m14 5-7 7 7 7" />,
"arrow-right": <path {...ICON_PROPS} d="m10 5 7 7-7 7" />,
reset: <><path {...ICON_PROPS} d="M4 12a8 8 0 1 0 2-5.3" /><path {...ICON_PROPS} d="M4 4v4h4" /></>,
info: <><circle {...ICON_PROPS} cx="12" cy="12" r="8" /><path {...ICON_PROPS} d="M12 10v5m0-8v.2" /></>,
export: <><path {...ICON_PROPS} d="M12 3v12m0 0 4-4m-4 4-4-4M5 20h14" /></>,
check: <path {...ICON_PROPS} d="m5 12 4.2 4.2L19.5 6" />,
privacy: <><path {...ICON_PROPS} d="M12 3 20 6v5c0 4.8-3.2 8.2-8 10-4.8-1.8-8-5.2-8-10V6l8-3Z" /><path {...ICON_PROPS} d="m8.5 12 2.2 2.2 4.8-4.8" /></>,
}[name];
return <svg viewBox="0 0 24 24" aria-hidden={title ? undefined : true} role={title ? "img" : undefined} {...props}>{title ? <title>{title}</title> : null}{content}</svg>;
}
/** The canonical evidence-lens mark, shared by the browser package and topbar. */
export function EventLensMark(props: SVGProps<SVGSVGElement>) {
return <svg viewBox="0 0 64 64" aria-hidden="true" {...props}>
<circle cx="27" cy="27" r="16" fill="none" stroke="#65d9d0" strokeWidth="5" />
<path d="M16 29h22" fill="none" stroke="#dff8f5" strokeWidth="2.7" strokeLinecap="round" />
<circle cx="20" cy="29" r="2.4" fill="#65d9d0" />
<circle cx="27" cy="29" r="2.4" fill="#65d9d0" />
<circle cx="34" cy="29" r="2.4" fill="#65d9d0" />
<path d="m39 39 12 12" fill="none" stroke="#f4b860" strokeWidth="6" strokeLinecap="round" />
</svg>;
}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useRef } from "react";
import type { Analysis, Incident } from "../types";
import { sourceLabel, statusLabel } from "../app/format";
import { WORKSPACES, type Workspace } from "../app/model";
import { EventLensMark, Icon } from "./icons";
const WORKSPACE_LABELS: Record<Workspace, string> = { analysis: "Analysis", timeline: "Timeline", hunting: "Hunting", reports: "Reports", settings: "Settings" };
export function WorkspaceNav({ activeWorkspace, onNavigate }: { activeWorkspace: Workspace; onNavigate: (workspace: Workspace) => void }) {
const activeButton = useRef<HTMLButtonElement>(null);
useEffect(() => {
activeButton.current?.scrollIntoView({ block: "nearest", inline: "center" });
}, [activeWorkspace]);
return <nav className="workspace-nav" aria-label="EventLens workspaces">
{WORKSPACES.map((workspace) => <button key={workspace} ref={activeWorkspace === workspace ? activeButton : undefined} type="button" className={activeWorkspace === workspace ? "active" : ""} aria-current={activeWorkspace === workspace ? "page" : undefined} onClick={() => onNavigate(workspace)}><Icon name={workspace} /><span>{WORKSPACE_LABELS[workspace]}</span></button>)}
</nav>;
}
export function AppTopbar({ activeWorkspace, onNavigate, version }: { activeWorkspace: Workspace; onNavigate: (workspace: Workspace) => void; version: string }) {
return <header className="event-topbar"><div className="event-brand"><span className="event-logo"><EventLensMark /></span><div><strong>EventLens</strong><small>Windows incident reconstruction</small></div></div><WorkspaceNav activeWorkspace={activeWorkspace} onNavigate={onNavigate} /><div className="instance-status"><span aria-hidden="true" /><div><b>Local Instance</b><small>v{version}</small></div></div></header>;
}
export function InvestigationContext({ data, activeIncident, visibleCount, workspace }: { data: Analysis | null; activeIncident: Incident | null; visibleCount?: number; workspace: Workspace }) {
if (workspace === "settings") return <section className="investigation-context preferences-context" aria-label="Local preferences context"><div className="investigation-source"><Icon name="settings" /><span><b>Local preferences</b><small>Interface preferences for this session</small></span></div><div className="context-facts"><span>Event text, files and evidence are never stored here</span></div></section>;
if (!data) return null;
return <section className="investigation-context" aria-label="Current investigation context"><div className="investigation-source"><Icon name="file" /><span><b title={data.source}>{sourceLabel(data.source)}</b><small>Current forensic session</small></span></div><div className="context-facts"><span className={`status-badge status-${data.parse?.status ?? "demo"}`}>{statusLabel(data)}</span><span>{data.events.length.toLocaleString()} events</span><span>{data.incidents.length} incident{data.incidents.length === 1 ? "" : "s"}</span>{typeof visibleCount === "number" ? <span>{visibleCount.toLocaleString()} visible</span> : null}</div>{activeIncident ? <span className="context-incident" title={activeIncident.title}>Incident: <b>{activeIncident.title}</b></span> : null}</section>;
}
export function GlobalActions({ loading, dragging, onOpen, onDemo, workspace }: { loading: boolean; dragging: boolean; onOpen: () => void; onDemo: () => void; workspace: Workspace }) {
const secondary = workspace === "reports" || workspace === "settings";
return <section className={`global-actions ${dragging ? "dragging" : ""} ${secondary ? "global-actions-secondary" : ""}`} aria-label="Analysis actions"><div className="action-buttons"><button type="button" className="primary-button" onClick={onOpen} disabled={loading}><Icon name="upload" />{loading ? "Analyzing…" : "Open .evtx"}</button><button type="button" className="secondary-button" onClick={onDemo} disabled={loading}><Icon name="demo" />Load demo</button><span className="drop-hint">Drop an .evtx file anywhere in the workspace</span></div><span className="context-muted">Local, in-memory analysis</span></section>;
}
export function Statusbar({ data }: { data: Analysis | null }) {
return <footer className="event-statusbar"><span><Icon name="file" /> {data ? sourceLabel(data.source) : "No source loaded"}</span><span><Icon name="info" /> {data ? `${data.events.length.toLocaleString()} events` : "Local only"}</span><span className={data?.parse?.status === "partial" ? "partial" : "complete"}><i className="status-dot" aria-hidden="true" /> {statusLabel(data)}</span></footer>;
}
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import type { Incident, ParseStats } from "../types";
import { formatRange } from "../app/format";
import type { TimestampDisplay } from "../app/model";
import { Icon } from "./icons";
export function WorkspaceHeader({ eyebrow, title, description, action }: { eyebrow: string; title: string; description: string; action?: ReactNode }) {
return <header className="workspace-header"><div><p className="eyebrow">{eyebrow}</p><h1>{title}</h1><p className="workspace-description">{description}</p></div>{action ? <div className="workspace-header-action">{action}</div> : null}</header>;
}
export function EmptyState({ onOpen, onDemo, title = "No event log loaded", description = "Open a local EVTX file or load the deterministic demo to begin an evidence-based investigation." }: { onOpen: () => void; onDemo: () => void; title?: string; description?: string }) {
return <section className="empty-state" aria-live="polite"><div className="empty-state-mark"><Icon name="file" /></div><div><h2>{title}</h2><p>{description}</p><ol className="workflow-steps" aria-label="EventLens investigation workflow"><li>EVTX</li><li>Normalize</li><li>Correlate</li><li>Inspect evidence</li><li>Review/export</li></ol><p className="empty-privacy">Analysis stays local, uploads are not intentionally persisted, parsing is bounded, and exports have a redaction preview.</p><div className="empty-actions"><button type="button" className="primary-button" onClick={onOpen}><Icon name="upload" />Open .evtx</button><button type="button" className="secondary-button" onClick={onDemo}><Icon name="demo" />Load demo</button></div></div></section>;
}
export function PartialAnalysisWarning({ parse }: { parse?: ParseStats | null }) {
if (!parse || parse.status !== "partial") return null;
return <section className="partial-warning" role="alert" aria-live="assertive"><div className="notice-title"><Icon name="warning" /><div><strong>Partial analysis — incident reconstruction may be incomplete</strong><span>{parse.parsed_events.toLocaleString()} events retained from {parse.records_seen.toLocaleString()} records seen; {parse.skipped_records.toLocaleString()} observed record(s) were not retained.</span></div></div><p>Review parser issues before treating the timeline or exports as a complete investigation.</p><ul>{parse.issues.slice(0, 5).map((issue, index) => <li key={`${issue.code}-${issue.record_id ?? "none"}-${index}`}><code>{issue.code}</code> — {issue.message}</li>)}</ul></section>;
}
export function ParserIssueNotice({ parse }: { parse?: ParseStats | null }) {
if (!parse || !parse.issues.length) return null;
return <section className="parser-issues" role="status"><strong>Parser notes</strong><span>{parse.issues.length} non-fatal parser issue(s) were retained with the evidence. Review data-quality notes before exporting.</span><ul>{parse.issues.slice(0, 4).map((issue, index) => <li key={`${issue.code}-${issue.record_id ?? "none"}-${index}`}><code>{issue.code}</code> — {issue.message}</li>)}</ul></section>;
}
export function ErrorBanner({ message, onDemo }: { message: string; onDemo: () => void }) {
return <section className="error-banner" role="alert"><div><strong>Analysis could not be completed</strong><span>{message}</span></div><button type="button" className="text-button" onClick={onDemo}>Load demo instead</button></section>;
}
export function MetricCard({ icon, value, label, tone = "blue" }: { icon: ReactNode; value: string | number; label: string; tone?: "blue" | "amber" | "green" }) {
return <div className={`metric-card tone-${tone}`}><span className="metric-icon">{icon}</span><strong>{value}</strong><span>{label}</span></div>;
}
export function IncidentCard({ incident, timestampDisplay, selected, onOpen }: { incident: Incident; timestampDisplay: TimestampDisplay; selected: boolean; onOpen: (incident: Incident) => void }) {
const severity = incident.severity === "critical" || incident.severity === "error" ? "high" : incident.severity === "warning" ? "medium" : "info";
return <button type="button" className={`incident-card severity-${severity} ${selected ? "selected" : ""}`} onClick={() => onOpen(incident)}><div className="incident-card-heading"><span className="incident-icon"><Icon name="warning" /></span><strong>{incident.title}</strong><span className="severity-pill">{severity === "high" ? "High" : severity === "medium" ? "Medium" : "Info"}</span></div><p>{incident.reasons[0] ?? "Evidence was grouped by a deterministic EventLens rule."}</p><footer><span>{formatRange(incident.start, incident.end, timestampDisplay)}</span><b>{incident.original_count} event{incident.original_count === 1 ? "" : "s"}</b></footer><span className="incident-card-action">Inspect evidence <Icon name="arrow-right" /></span></button>;
}
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from "react";
import type { Analysis, EventItem, Incident } from "../types";
import { formatClock, formatDate, formatDateTime, levelLabel } from "../app/format";
import { CATEGORIES, LEVELS, OVERSCAN, WINDOWS, type FilterState, type InspectorTab, type TimestampDisplay } from "../app/model";
import { Icon } from "./icons";
export function filterCount(filters: FilterState): number {
return [filters.search, filters.category, filters.level, filters.provider, filters.windowMinutes > 0 ? filters.windowMinutes : "", filters.attentionOnly ? "attention" : ""].filter(Boolean).length;
}
export function FilterToolbar({ filters, providers, visibleCount, totalCount, anchorTimestamp, activeIncident, focusIncident, onFocusChange, onChange, onReset }: { filters: FilterState; providers: string[]; visibleCount: number; totalCount: number; anchorTimestamp: string | null; activeIncident: Incident | null; focusIncident: boolean; onFocusChange: (value: boolean) => void; onChange: (patch: Partial<FilterState>) => void; onReset: () => void }) {
const active = filterCount(filters);
return <section className="filter-toolbar" aria-label="Timeline filters"><div className="filter-primary"><label className="search-field"><Icon name="search" /><span className="sr-only">Search normalized evidence</span><input aria-label="Search normalized evidence" value={filters.search} onChange={(event) => onChange({ search: event.target.value })} placeholder="Search evidence, paths, IDs, fields…" /></label><span className="result-count" role="status"><strong>{visibleCount.toLocaleString()}</strong> of {totalCount.toLocaleString()} visible</span></div><div className="filter-secondary"><label><span>Category</span><select aria-label="Filter by category" value={filters.category} onChange={(event) => onChange({ category: event.target.value })}><option value="">All categories</option>{CATEGORIES.map((value) => <option key={value}>{value}</option>)}</select></label><label><span>Level</span><select aria-label="Filter by level" value={filters.level} onChange={(event) => onChange({ level: event.target.value })}><option value="">All levels</option>{LEVELS.map((value) => <option key={value}>{value}</option>)}</select></label><label className="provider-filter"><span>Provider</span><select aria-label="Filter by provider" value={filters.provider} onChange={(event) => onChange({ provider: event.target.value })}><option value="">All providers</option>{providers.map((value) => <option key={value}>{value}</option>)}</select></label><label><span>Time window</span><select aria-label="Evidence time window" value={filters.windowMinutes} onChange={(event) => onChange({ windowMinutes: Number(event.target.value) })} disabled={!anchorTimestamp}><option value={0}>All time</option>{WINDOWS.filter(Boolean).map((value) => <option value={value} key={value}>± {value} min</option>)}</select></label></div><div className="filter-actions"><label className="toggle-control"><input type="checkbox" checked={filters.attentionOnly} onChange={(event) => onChange({ attentionOnly: event.target.checked })} /><span className="toggle-track" /><span>Hide informational noise</span></label><label className="toggle-control"><input type="checkbox" checked={focusIncident} onChange={(event) => onFocusChange(event.target.checked)} disabled={!activeIncident} /><span className="toggle-track" /><span>Focus incident</span></label><button type="button" className="reset-button" onClick={onReset} disabled={!active}><Icon name="reset" />Reset filters{active ? ` (${active})` : ""}</button></div>{active || (focusIncident && activeIncident) ? <div className="filter-chips" aria-label="Active filters">{filters.search ? <span>Search: {filters.search}</span> : null}{filters.category ? <span>Category: {filters.category}</span> : null}{filters.level ? <span>Level: {filters.level}</span> : null}{filters.provider ? <span>Provider: {filters.provider}</span> : null}{filters.windowMinutes ? <span>± {filters.windowMinutes} min</span> : null}{filters.attentionOnly ? <span>Informational hidden</span> : null}{focusIncident && activeIncident ? <span>Incident focus: {activeIncident.title}</span> : null}</div> : null}</section>;
}
export function IncidentContext({ incidents, activeIncidentId, focusIncident, onSelect, onFocus }: { incidents: Incident[]; activeIncidentId: string; focusIncident: boolean; onSelect: (id: string) => void; onFocus: (value: boolean) => void }) {
if (!incidents.length) return null;
const active = incidents.find((incident) => incident.id === activeIncidentId) ?? null;
return <section className="incident-context"><label><span>Selected incident</span><select aria-label="Select incident" value={activeIncidentId} onChange={(event) => onSelect(event.target.value)}><option value="">No incident selected</option>{incidents.map((incident) => <option key={incident.id} value={incident.id}>{incident.title}</option>)}</select></label>{active ? <div className="incident-context-detail"><span className={`incident-focus-chip ${focusIncident ? "active" : ""}`}>{focusIncident ? "Focus active" : "Incident selected"}</span><span>{active.evidence_event_ids.length} linked evidence IDs</span><button type="button" className="text-button" onClick={() => onFocus(!focusIncident)}>{focusIncident ? "Stop focus" : "Focus evidence"}</button></div> : null}</section>;
}
function EventRow({ event, selected, position, total, rowHeight, timestampDisplay, onSelect }: { event: EventItem; selected: boolean; position: number; total: number; rowHeight: number; timestampDisplay: TimestampDisplay; onSelect: (event: EventItem) => void }) {
return <div role="listitem" aria-posinset={position} aria-setsize={total} style={{ height: rowHeight }}><button type="button" className={`event-row ${selected ? "active" : ""}`} style={{ height: rowHeight }} aria-label={`${event.provider}, event ${event.event_id ?? "unknown"}, ${event.summary}`} onClick={() => onSelect(event)}><span className={`event-rail rail-${event.level}`} aria-hidden="true"><span /></span><time>{formatClock(event.timestamp, timestampDisplay)}</time><b className="event-id">{event.event_id ?? "—"}</b><span className="event-copy"><strong>{event.provider}</strong><span>{event.summary}</span></span><span className="event-host">{event.computer || "—"}</span><span className={`event-level level-${event.level}`}>{levelLabel(event.level)}</span></button></div>;
}
export function VirtualizedEventList({ events, selectedId, rowHeight, timestampDisplay, onSelect }: { events: EventItem[]; selectedId?: string | null; rowHeight: number; timestampDisplay: TimestampDisplay; onSelect: (event: EventItem) => void }) {
const [scrollTop, setScrollTop] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const [viewportHeight, setViewportHeight] = useState(520);
useEffect(() => {
const node = listRef.current;
if (!node) return;
const update = () => setViewportHeight(node.clientHeight || 520);
update();
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, []);
if (!events.length) return <div className="event-list empty-list"><p role="status">No events match the current filters. Reset a filter or show informational noise to continue.</p></div>;
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN);
const visibleCount = Math.ceil(viewportHeight / rowHeight) + OVERSCAN * 2;
const last = Math.min(events.length, first + visibleCount);
return <div className="event-list" ref={listRef} onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)} role="list" aria-label="Normalized event timeline"><div className="event-list-inner" style={{ height: events.length * rowHeight }}><div className="event-list-window" style={{ transform: `translateY(${first * rowHeight}px)` }}>{events.slice(first, last).map((event, index) => <EventRow key={event.id} event={event} selected={selectedId === event.id} position={first + index + 1} total={events.length} rowHeight={rowHeight} timestampDisplay={timestampDisplay} onSelect={onSelect} />)}</div></div></div>;
}
export type InspectorProps = {
data: Analysis;
event: EventItem | null;
visibleEvents: EventItem[];
activeIncident: Incident | null;
timestampDisplay: TimestampDisplay;
tab: InspectorTab;
onTab: (tab: InspectorTab) => void;
onSelectEvent: (event: EventItem) => void;
onMove: (delta: number) => void;
onOpenTimeline?: () => void;
};
export function EvidenceInspector({ data, event, visibleEvents, activeIncident, timestampDisplay, tab, onTab, onSelectEvent, onMove, onOpenTimeline }: InspectorProps) {
const selectedIndex = event ? visibleEvents.findIndex((item) => item.id === event.id) : -1;
const relatedIncident = event ? data.incidents.find((incident) => incident.evidence_event_ids.includes(event.id)) ?? activeIncident : activeIncident;
const relatedEvents = relatedIncident ? data.events.filter((item) => relatedIncident.evidence_event_ids.includes(item.id)) : [];
const copy = async (value: string) => { if (navigator.clipboard) await navigator.clipboard.writeText(value); };
return <aside className="inspector-panel"><header className="panel-header"><div><p className="eyebrow">Evidence</p><h2>Inspector</h2></div><div className="inspector-navigation"><button type="button" aria-label="Previous evidence" onClick={() => onMove(-1)} disabled={!visibleEvents.length || selectedIndex <= 0}><Icon name="arrow-left" /></button><span>{selectedIndex >= 0 ? selectedIndex + 1 : 0} of {visibleEvents.length}</span><button type="button" aria-label="Next evidence" onClick={() => onMove(1)} disabled={!visibleEvents.length || selectedIndex === visibleEvents.length - 1}><Icon name="arrow-right" /></button></div></header>{event ? <><div className="inspector-title"><div><h3>{event.summary}</h3><p>Event ID {event.event_id ?? "—"} · {event.provider}</p></div><span className={`event-level level-${event.level}`}>{levelLabel(event.level)}</span></div>{onOpenTimeline ? <div className="inspector-actions"><button type="button" className="text-button" onClick={onOpenTimeline}>Open in Timeline <Icon name="arrow-right" /></button></div> : null}<div className="inspector-tabs" role="tablist" aria-label="Evidence views"><button type="button" role="tab" aria-selected={tab === "details"} className={tab === "details" ? "active" : ""} onClick={() => onTab("details")}>Details</button><button type="button" role="tab" aria-selected={tab === "raw"} className={tab === "raw" ? "active" : ""} onClick={() => onTab("raw")}>Normalized JSON</button><button type="button" role="tab" aria-selected={tab === "related"} className={tab === "related" ? "active" : ""} onClick={() => onTab("related")}>Related ({relatedEvents.length})</button><button type="button" role="tab" aria-selected={tab === "insights"} className={tab === "insights" ? "active" : ""} onClick={() => onTab("insights")}>Insights</button></div>{tab === "details" ? <div className="inspector-content"><div className="copy-row"><button type="button" className="quiet-button" onClick={() => void copy(event.id)}>Copy event ID</button><button type="button" className="quiet-button" onClick={() => void copy(JSON.stringify(event, null, 2))}>Copy JSON</button></div><dl className="fact-grid"><div><dt>Timestamp</dt><dd>{formatDateTime(event.timestamp, timestampDisplay)}</dd></div><div><dt>Timestamp source</dt><dd>{event.timestamp_source ?? (event.timestamp ? "event" : "missing")} · {event.timestamp_valid === false ? "unreliable" : "valid"}</dd></div><div><dt>Provider</dt><dd>{event.provider}</dd></div><div><dt>Event ID</dt><dd>{event.event_id ?? "—"}</dd></div><div><dt>Level</dt><dd><span className={`level-dot ${event.level}`} />{event.level}</dd></div><div><dt>Category</dt><dd>{event.category}</dd></div><div><dt>Channel</dt><dd>{event.channel || "—"}</dd></div><div><dt>Computer</dt><dd>{event.computer || "—"}</dd></div></dl><section className="normalized-fields"><header><b>Normalized fields</b><span>{Object.keys(event.fields ?? {}).length}</span></header>{Object.keys(event.fields ?? {}).length ? <div>{Object.entries(event.fields ?? {}).map(([key, value]) => <p key={key}><code>{key.replace(/^EventData\./, "").replace(/^UserData\./, "")}</code><span>{value}</span></p>)}</div> : <p className="muted-copy">No additional normalized fields.</p>}</section></div> : null}{tab === "raw" ? <pre className="inspector-raw">{JSON.stringify(event, null, 2)}</pre> : null}{tab === "related" ? <div className="inspector-content related-events">{relatedIncident ? <><p className="muted-copy">{relatedIncident.title} · {relatedIncident.rule_id}</p>{relatedEvents.map((related) => <button type="button" className={related.id === event.id ? "related-event active" : "related-event"} key={related.id} onClick={() => onSelectEvent(related)}><span>{formatClock(related.timestamp, timestampDisplay)}</span><strong>{related.event_id ?? "—"}</strong><span>{related.summary}</span></button>)}</> : <p className="muted-copy">Select an incident to inspect related evidence.</p>}</div> : null}{tab === "insights" ? <div className="inspector-content insight-copy"><p><strong>Evidence relationship</strong></p><p>{relatedIncident ? relatedIncident.reasons[0] ?? "This event is linked by a deterministic rule." : "This event is not currently linked to a selected incident."}</p><p>EventLens describes evidence-supported temporal or semantic relationships. It does not claim causality.</p></div> : null}</> : <div className="inspector-placeholder"><p>No visible event is selected.</p><span>Clear a filter or show informational noise to continue.</span></div>}</aside>;
}
export function TimelinePanel({ events, selectedId, rowHeight, timestampDisplay, onSelect }: { events: EventItem[]; selectedId: string | null; rowHeight: number; timestampDisplay: TimestampDisplay; onSelect: (event: EventItem) => void }) {
return <article className="timeline-panel panel"><header className="panel-header"><div><p className="eyebrow">Chronological evidence</p><h2>Event timeline</h2></div><span className="panel-count">{events.length.toLocaleString()} visible</span></header><div className="date-band">{formatDate(events[0]?.timestamp ?? null, timestampDisplay)}</div><VirtualizedEventList events={events} selectedId={selectedId} rowHeight={rowHeight} timestampDisplay={timestampDisplay} onSelect={onSelect} /></article>;
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { analysisToMarkdown, prepareExport } from "./export";
import type { Analysis } from "./types";
const analysis: Analysis = { source:"demo.evtx", incidents:[], events:[{id:"1",timestamp:"2026-09-09T10:00:00Z",provider:"Security",event_id:4625,level:"warning",category:"authentication",summary:"Login for admin@example.com",computer:"PC-01",fields:{"EventData.TargetUserName":"admin"}}] };
describe("export", () => {
it("redacts by default", () => {
const prepared = prepareExport(analysis, true);
expect(prepared.analysis.events[0].computer).toBe("[REDACTED_HOST]");
expect(prepared.analysis.source).toBe("[REDACTED_SOURCE].evtx");
expect(prepared.redaction?.total).toBeGreaterThan(0);
});
it("produces an incident/evidence markdown report", () => {
const markdown = analysisToMarkdown(analysis, true);
expect(markdown).toContain("# EventLens incident report — [REDACTED_SOURCE].evtx");
expect(markdown).toContain("[REDACTED_EMAIL]");
expect(markdown).toContain("## Evidence events");
});
it("escapes markdown control syntax from untrusted event text", () => {
const malicious: Analysis = {
source: "report#](https://example.invalid).evtx",
incidents: [{ id: "i", rule_id: "rule|x", start: null, end: null, title: "# heading", category: "other", severity: "warning", reasons: ["![x](https://example.invalid/a) <img src=x> ``` | table |"], evidence_event_ids: ["1"], original_count: 1, compressed_count: 1 }],
events: [{ id: "1", timestamp: null, provider: "Provider", event_id: 1, level: "warning", category: "other", summary: "# heading [click](https://example.invalid) <script>alert(1)</script>", fields: { payload: "```\n| remote |" } }],
};
const markdown = analysisToMarkdown(malicious, false);
expect(markdown).not.toContain("\n![x](https://example.invalid/a)");
expect(markdown).not.toContain("\n<img");
expect(markdown).not.toContain("\n<script>");
expect(markdown).toContain("unknown timestamp");
expect(markdown).toContain("https://example.invalid");
});
it("keeps partial completeness metadata aligned across JSON and Markdown", () => {
const partial: Analysis = {
...analysis,
parse: {
parser: "evtx-rs",
status: "partial",
parsed_events: 1,
records_seen: 4,
skipped_records: 2,
issue_count: 1,
truncated: true,
truncation_reason: "event_limit_reached",
duration_ms: 12,
issues: [{ code: "event_limit_reached", record_id: 4, message: "later records were not analyzed", skipped_record: true }],
},
};
const json = prepareExport(partial, false).analysis;
const markdown = analysisToMarkdown(partial, false);
expect(json.parse?.status).toBe("partial");
expect(json.parse?.skipped_records).toBe(2);
expect(markdown).toContain("PARTIAL — incident reconstruction may be incomplete");
expect(markdown).toContain("event_limit_reached");
expect(markdown).toContain("skipped 2");
});
});
+108
View File
@@ -0,0 +1,108 @@
import { redactAnalysis, type RedactionSummary } from "./redact";
import type { Analysis, Incident, ParseIssue } from "./types";
export type PreparedExport = { analysis: Analysis; redaction: RedactionSummary | null };
const REDACTION_TOKEN_RE = /^\[REDACTED_[A-Z]+\](?:\.evtx)?$/;
function markdownText(value: string): string {
const compact = value.replace(/[\r\n]+/g, " ");
return compact.split(/(\[REDACTED_[A-Z]+\])/g).map((part) => {
if (REDACTION_TOKEN_RE.test(part)) return part;
return part
.replaceAll("\\", "\\\\")
.replaceAll("`", "\\`")
.replaceAll("*", "\\*")
.replaceAll("_", "\\_")
.replaceAll("[", "\\[")
.replaceAll("]", "\\]")
.replaceAll("(", "\\(")
.replaceAll(")", "\\)")
.replaceAll("!", "\\!")
.replaceAll("#", "\\#")
.replaceAll(">", "\\>")
.replaceAll("<", "\\<")
.replaceAll("|", "\\|");
}).join("");
}
function markdownCode(value: string): string {
const normalized = value.replace(/[\r\n]+/g, " ↵ ");
const runs = normalized.match(/`+/g) ?? [];
const delimiter = "`".repeat(Math.max(1, ...runs.map((run) => run.length + 1)));
return `${delimiter} ${normalized} ${delimiter}`;
}
function displayTime(value: string | null): string {
return value ? value : "unknown timestamp (not used for temporal correlation)";
}
function issueLine(issue: ParseIssue): string {
const record = issue.record_id == null ? "—" : String(issue.record_id);
return `- ${markdownCode(issue.code)} · record ${markdownCode(record)} · ${markdownCode(issue.message)}`;
}
function incidentWindow(incident: Incident): string {
return `${displayTime(incident.start)} → ${displayTime(incident.end)}`;
}
export function prepareExport(source: Analysis, redact: boolean): PreparedExport {
if (!redact) return { analysis: source, redaction: null };
const result = redactAnalysis(source);
return { analysis: result.analysis, redaction: result.summary };
}
export function analysisToMarkdown(source: Analysis, redact = true): string {
const prepared = prepareExport(source, redact);
const analysis = prepared.analysis;
const lines = [
`# EventLens incident report — ${markdownText(analysis.source)}`,
"",
`- Events: ${analysis.events.length}`,
`- Incidents: ${analysis.incidents.length}`,
`- Redaction: ${redact ? `enabled (${prepared.redaction?.total ?? 0} replacement(s))` : "disabled"}`,
analysis.parse
? `- Parser status: ${analysis.parse.status}; records seen ${analysis.parse.records_seen}; parsed ${analysis.parse.parsed_events}; skipped ${analysis.parse.skipped_records}; issues ${analysis.parse.issue_count}; truncated ${analysis.parse.truncated ? "yes" : "no"}`
: "- Source: deterministic demo fixture",
analysis.parse
? `- Analysis completeness: ${analysis.parse.status === "partial" ? "PARTIAL — incident reconstruction may be incomplete" : analysis.parse.status.toUpperCase()}`
: "- Analysis completeness: DEMO fixture",
analysis.parse?.truncation_reason ? `- Truncation reason: ${markdownCode(analysis.parse.truncation_reason)}` : "",
"",
];
if (analysis.parse?.issues.length) {
lines.push("## Parser issues", "", ...analysis.parse.issues.map(issueLine), "");
}
lines.push("## Incidents", "");
for (const incident of analysis.incidents) {
lines.push(
`### ${markdownText(incident.title)}`,
`- Rule: ${markdownCode(incident.rule_id)}`,
`- Severity: ${markdownText(incident.severity)}`,
`- Window: ${markdownCode(incidentWindow(incident))}`,
`- Evidence: ${markdownCode(incident.evidence_event_ids.join(", "))}`,
...incident.reasons.map((reason) => `- Reason: ${markdownCode(reason)}`),
"",
);
}
lines.push("## Evidence events", "");
for (const event of analysis.events) {
lines.push(
`### ${markdownCode(displayTime(event.timestamp))} — ${markdownText(event.summary)}`,
`- Record: ${markdownCode(event.id)}`,
`- Provider: ${markdownCode(event.provider)}`,
`- Event ID: ${event.event_id ?? "—"}`,
`- Level: ${markdownText(event.level)}`,
`- Category: ${markdownText(event.category)}`,
`- Computer: ${markdownCode(event.computer || "—")}`,
`- Timestamp source: ${markdownCode(event.timestamp_source ?? (event.timestamp ? "event" : "missing"))}`,
`- Timestamp valid: ${event.timestamp_valid === false ? "no" : "yes"}`,
);
for (const [key, value] of Object.entries(event.fields ?? {})) {
lines.push(`- ${markdownCode(key)}: ${markdownCode(value)}`);
}
lines.push("");
}
lines.push("_EventLens redaction is best effort. Review this report before sharing._", "");
return lines.join("\n");
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { filterEvents } from "./filter";
import type { EventItem } from "./types";
function makeEvents(count: number): EventItem[] {
return Array.from({ length: count }, (_, index) => ({
id: String(index),
timestamp: "2026-09-09T10:00:00Z",
provider: index % 2 ? "Application Provider" : "Security Provider",
event_id: index,
level: "warning",
category: "other",
summary: `Synthetic event ${index}`,
fields: { message: index === count - 1 ? "needle-value" : "ordinary event" },
}));
}
describe("large result performance", () => {
it("searches 100,000 normalized events within a deterministic budget", () => {
const events = makeEvents(100_000);
const started = performance.now();
const result = filterEvents(events, { search: "needle-value", category: "", level: "", provider: "", noiseMode: "all" });
const elapsed = performance.now() - started;
expect(result.map((event) => event.id)).toEqual(["99999"]);
expect(elapsed).toBeLessThan(3_000);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { filterEvents, uniqueProviders } from "./filter";
import type { EventItem } from "./types";
const events: EventItem[] = [
{ id: "1", timestamp: "2026-09-09T10:00:00Z", provider: "DNS Client", event_id: 1014, level: "warning", category: "network", summary: "DNS timeout", computer: "PC-01", fields: { Query: "api.example.test" } },
{ id: "2", timestamp: "2026-09-09T10:00:10Z", provider: "Kernel General", event_id: 12, level: "information", category: "system", summary: "System started", computer: "PC-01" },
{ id: "3", timestamp: "2026-09-09T10:10:20Z", provider: "Application Error", event_id: 1000, level: "error", category: "crash", summary: "Example.exe crashed", computer: "PC-01" },
];
const base = { search: "", category: "", level: "", provider: "", noiseMode: "all" as const };
describe("event filters", () => {
it("combines category and attention filtering", () => {
expect(filterEvents(events, { ...base, category: "system", noiseMode: "attention" })).toEqual([]);
expect(filterEvents(events, { ...base, noiseMode: "attention" }).map((event) => event.id)).toEqual(["1", "3"]);
});
it("searches normalized evidence fields", () => {
expect(filterEvents(events, { ...base, search: "api.example.test" }).map((event) => event.id)).toEqual(["1"]);
});
it("searches stable evidence IDs", () => {
expect(filterEvents(events, { ...base, search: "2" }).map((event) => event.id)).toEqual(["2"]);
});
it("can focus an incident evidence set", () => {
expect(filterEvents(events, { ...base, evidenceIds: new Set(["3"]) }).map((event) => event.id)).toEqual(["3"]);
});
it("keeps informational incident evidence when focus overrides noise suppression", () => {
expect(filterEvents(events, { ...base, noiseMode: "attention", evidenceIds: new Set(["2"]) }).map((event) => event.id)).toEqual(["2"]);
});
it("applies a symmetric time window around selected evidence", () => {
expect(filterEvents(events, { ...base, anchorTimestamp: "2026-09-09T10:00:10Z", windowMinutes: 1 }).map((event) => event.id)).toEqual(["1", "2"]);
});
it("returns stable sorted provider options", () => {
expect(uniqueProviders(events)).toEqual(["Application Error", "DNS Client", "Kernel General"]);
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { EventItem } from "./types";
export type NoiseMode = "all" | "attention";
export type EventFilters = {
search: string;
category: string;
level: string;
provider: string;
noiseMode: NoiseMode;
evidenceIds?: Set<string> | null;
windowMinutes?: number | null;
anchorTimestamp?: string | null;
};
const ATTENTION_LEVELS = new Set(["critical", "error", "warning"]);
const searchDocuments = new WeakMap<EventItem, string>();
function searchDocument(event: EventItem): string {
const cached = searchDocuments.get(event);
if (cached) return cached;
const fieldText = Object.entries(event.fields ?? {})
.map(([key, value]) => `${key} ${value}`)
.join(" ");
const document = [event.id, event.summary, event.provider, event.event_id ?? "", event.category, event.channel ?? "", event.computer ?? "", fieldText]
.join(" ")
.toLowerCase();
searchDocuments.set(event, document);
return document;
}
export function filterEvents(events: EventItem[], filters: EventFilters): EventItem[] {
const search = filters.search.trim().toLowerCase();
const parsedAnchor = filters.anchorTimestamp ? Date.parse(filters.anchorTimestamp) : Number.NaN;
const anchor = Number.isFinite(parsedAnchor) ? parsedAnchor : null;
const windowMs = filters.windowMinutes && filters.windowMinutes > 0 ? filters.windowMinutes * 60_000 : null;
return events.filter((event) => {
if (filters.evidenceIds && !filters.evidenceIds.has(event.id)) return false;
if (filters.category && event.category !== filters.category) return false;
if (filters.level && event.level.toLowerCase() !== filters.level.toLowerCase()) return false;
if (filters.provider && event.provider !== filters.provider) return false;
if (filters.noiseMode === "attention" && !filters.evidenceIds && !ATTENTION_LEVELS.has(event.level.toLowerCase())) return false;
if (anchor !== null && windowMs !== null) {
if (!event.timestamp) return false;
const eventTime = Date.parse(event.timestamp);
if (!Number.isFinite(eventTime) || Math.abs(eventTime - anchor) > windowMs) return false;
}
if (!search) return true;
return searchDocument(event).includes(search);
});
}
export function uniqueProviders(events: EventItem[]): string[] {
return [...new Set(events.map((event) => event.provider).filter(Boolean))].sort((a, b) => a.localeCompare(b));
}
+9
View File
@@ -0,0 +1,9 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app/App";
import "./styles.css";
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("App root element was not found");
createRoot(rootElement).render(<React.StrictMode><App /></React.StrictMode>);
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { redactAnalysis } from "./redact";
import type { Analysis } from "./types";
const source: Analysis = {
source: "Security.evtx",
incidents: [{ id:"i1", rule_id:"windows.auth.failed-logon", start:"2026-09-09T10:00:00Z", end:"2026-09-09T10:00:01Z", title:"Failed login", category:"authentication", severity:"warning", reasons:["Connection from 192.168.1.15"], evidence_event_ids:["1"], original_count:1, compressed_count:1 }],
events: [{ id:"1", timestamp:"2026-09-09T10:00:00Z", provider:"Security", event_id:4625, level:"warning", category:"authentication", summary:"C:\\Users\\jens\\tool.exe contacted admin@example.com at 192.168.1.15", computer:"DEMO-PC", fields:{"EventData.TargetUserName":"jens","EventData.IpAddress":"10.0.0.5","EventData.ProcessName":"C:\\Windows\\System32\\cmd.exe"} }],
};
describe("redaction", () => {
it("redacts sensitive event values without mutating the source", () => {
const result = redactAnalysis(source);
const event = result.analysis.events[0];
expect(event.computer).toBe("[REDACTED_HOST]");
expect(event.fields?.["EventData.TargetUserName"]).toBe("[REDACTED_IDENTITY]");
expect(event.fields?.["EventData.IpAddress"]).toBe("[REDACTED_IP]");
expect(event.fields?.["EventData.ProcessName"]).toBe("[REDACTED_PATH]");
expect(event.summary).toContain("[REDACTED_EMAIL]");
expect(event.summary).toContain("[REDACTED_IP]");
expect(source.events[0].computer).toBe("DEMO-PC");
expect(result.summary.total).toBeGreaterThanOrEqual(6);
});
it("propagates identities and redacts Windows/UNC paths and IPv6 in free-form text", () => {
const sensitive: Analysis = {
source: "private.evtx",
incidents: [{
id: "incident-1",
rule_id: "windows.auth.failed-logon",
start: "2026-09-09T10:00:00Z",
end: "2026-09-09T10:00:01Z",
title: "Failed sign-in for jens",
category: "authentication",
severity: "warning",
reasons: ["jens used C:\\Program Files\\SecretTool\\agent.exe from 2001:db8::42"],
evidence_event_ids: ["1"],
original_count: 1,
compressed_count: 1,
}],
events: [{
id: "1",
timestamp: "2026-09-09T10:00:00Z",
provider: "Security",
event_id: 4625,
level: "warning",
category: "authentication",
summary: "Failed sign-in for DOMAIN\\jens at C:\\Program Files\\SecretTool\\agent.exe from 2001:db8::42, fe80::1 and ::ffff:192.0.2.1; SID S-1-5-21-123-456-789-1001; machine BUILD01$; UNC \\\\server\\Share With Space\\agent.exe",
computer: "DEMO-PC",
fields: {
"EventData.TargetUserName": "jens",
"EventData.SubjectUserName": "DOMAIN\\jens",
"EventData.ProcessName": "C:\\Program Files\\SecretTool\\agent.exe",
"EventData.UncPath": "\\\\server\\Share With Space\\agent.exe",
"EventData.MachineAccount": "DOMAIN\\BUILD01$",
"EventData.Sid": "S-1-5-21-123-456-789-1001",
},
}],
};
const result = redactAnalysis(sensitive);
const serialized = JSON.stringify(result.analysis);
expect(serialized).not.toContain("jens");
expect(serialized).not.toContain("Program Files\\SecretTool");
expect(serialized).not.toContain("Share With Space");
expect(serialized).not.toContain("2001:db8::42");
expect(serialized).not.toContain("fe80::1");
expect(serialized).not.toContain("192.0.2.1");
expect(serialized).not.toContain("BUILD01$");
expect(serialized).not.toContain("S-1-5-21");
expect(result.analysis.incidents[0].title).toContain("[REDACTED_IDENTITY]");
expect(result.summary.paths).toBeGreaterThan(0);
expect(result.summary.ips).toBeGreaterThanOrEqual(3);
});
it("propagates source-field values into free-form redaction", () => {
const sourceField: Analysis = {
source: "private.evtx",
incidents: [],
events: [{
id: "1",
timestamp: null,
provider: "Security",
event_id: 1,
level: "information",
category: "other",
summary: "Observed source Collector-A",
computer: "",
fields: { "EventData.SourceName": "Collector-A" },
}],
};
const result = redactAnalysis(sourceField);
expect(result.analysis.events[0].summary).toContain("[REDACTED_SOURCE]");
expect(result.summary.sources).toBeGreaterThan(1);
});
});
+177
View File
@@ -0,0 +1,177 @@
import type { Analysis, EventItem } from "./types";
export type RedactionSummary = {
hosts: number;
identities: number;
emails: number;
ips: number;
paths: number;
sources: number;
total: number;
};
export type RedactionResult = { analysis: Analysis; summary: RedactionSummary };
type RedactionCategory = keyof Omit<RedactionSummary, "total">;
type KnownValue = { value: string; replacement: string; category: RedactionCategory };
const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
const DOMAIN_IDENTITY_RE = /(?<![\w:])[A-Za-z0-9][A-Za-z0-9._-]{1,}\\[A-Za-z0-9._-]{1,}\$?(?![\w])/g;
const SID_RE = /\bS-\d(?:-\d+){2,}\b/gi;
const MACHINE_ACCOUNT_RE = /\b[A-Za-z0-9][A-Za-z0-9_.-]{1,}\$(?![\w])/g;
const IPV4_RE = /(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])/g;
const IPV4_MAPPED_IPV6_RE = /(?<![0-9A-Fa-f:])(?:[0-9A-Fa-f]{0,4}:){2,8}(?:\d{1,3}\.){3}\d{1,3}(?![\w:])/gi;
const IPV6_RE = /(?<![0-9A-Fa-f:])(?:[0-9A-Fa-f]{0,4}:){2,8}[0-9A-Fa-f]{0,4}(?![0-9A-Fa-f:])/g;
const QUOTED_PATH_RE = /(["'])(?=((?:[A-Za-z]:\\|\\\\)))([\s\S]*?)\1/g;
const UNQUOTED_PATH_RE = /(?<![\w])(?:[A-Za-z]:\\|\\\\)(?:[^\\/:*?"<>|\r\n]+\\)+[^\\/:*?"<>|\r\n]*?\.[A-Za-z0-9]{1,8}(?=$|[\s,;:!?)]|["'])/g;
const IDENTITY_KEY_RE = /(user(name)?|account|upn|principal|subject.*user|target.*user|machineaccount|sid|email)/i;
const EMAIL_KEY_RE = /(email|mail)/i;
const HOST_KEY_RE = /(computer|host(name)?|workstation|machine)/i;
const PATH_KEY_RE = /(path|filename|image|executable|process(name)?|command(line)?)/i;
const IP_KEY_RE = /(^|[._])(ip|ipaddress|sourceaddress|destinationaddress|remoteaddress)$/i;
const SOURCE_KEY_RE = /(^|[._])(source|sourcename|sourcefile|logfile)$/i;
const REPLACEMENT_RE = /^\[REDACTED_[A-Z]+\]$/;
function increment(summary: RedactionSummary, category: RedactionCategory): void {
summary[category] += 1;
summary.total += 1;
}
function replacementFor(category: RedactionCategory): string {
return `[REDACTED_${category === "ips" ? "IP" : category === "emails" ? "EMAIL" : category === "hosts" ? "HOST" : category === "paths" ? "PATH" : category === "sources" ? "SOURCE" : "IDENTITY"}]`;
}
function fieldCategory(key: string): RedactionCategory | null {
if (EMAIL_KEY_RE.test(key)) return "emails";
if (IDENTITY_KEY_RE.test(key)) return "identities";
if (HOST_KEY_RE.test(key)) return "hosts";
if (PATH_KEY_RE.test(key)) return "paths";
if (IP_KEY_RE.test(key)) return "ips";
if (SOURCE_KEY_RE.test(key)) return "sources";
return null;
}
function addKnown(known: Map<string, KnownValue>, value: string, category: RedactionCategory): void {
const candidate = value.trim();
if (candidate.length < 2 || REPLACEMENT_RE.test(candidate)) return;
const key = `${category}\u0000${candidate.toLocaleLowerCase()}`;
if (!known.has(key)) known.set(key, { value: candidate, category, replacement: replacementFor(category) });
}
function collectKnownValues(source: Analysis): Map<string, KnownValue> {
const known = new Map<string, KnownValue>();
for (const event of source.events) {
addKnown(known, event.computer ?? "", "hosts");
for (const [key, value] of Object.entries(event.fields ?? {})) {
const category = fieldCategory(key);
if (category) addKnown(known, value, category);
}
}
return known;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function replaceKnownValues(value: string, known: Map<string, KnownValue>, summary: RedactionSummary): string {
let next = value;
const values = [...known.values()].sort((a, b) => b.value.length - a.value.length);
for (const item of values) {
const pattern = new RegExp(`(?<![A-Za-z0-9_])${escapeRegExp(item.value)}(?![A-Za-z0-9_])`, "gi");
next = next.replace(pattern, () => {
increment(summary, item.category);
return item.replacement;
});
}
return next;
}
function isValidIpv4(value: string): boolean {
const octets = value.split(".").map(Number);
return octets.length === 4 && octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255);
}
function isValidIpv6(value: string): boolean {
const withoutZone = value.split("%")[0];
const pieces = withoutZone.split(":");
const hasCompression = withoutZone.includes("::");
if (withoutZone.includes(":::")) return false;
const expandedCount = pieces.reduce((count, piece) => {
if (!piece) return count;
if (piece.includes(".")) return count + 2;
return count + 1;
}, 0);
if (hasCompression ? expandedCount >= 8 : expandedCount !== 8) return false;
return pieces.filter(Boolean).every((piece) => piece.includes(".") ? isValidIpv4(piece) : /^[0-9A-Fa-f]{1,4}$/.test(piece));
}
function replacePattern(
value: string,
pattern: RegExp,
replacement: string,
category: RedactionCategory,
summary: RedactionSummary,
validate?: (match: string) => boolean,
): string {
return value.replace(pattern, (match) => {
if (validate && !validate(match)) return match;
increment(summary, category);
return replacement;
});
}
function redactPaths(value: string, summary: RedactionSummary): string {
const next = value.replace(QUOTED_PATH_RE, (match, _quote: string, _root: string, path: string) => {
if (!path) return match;
increment(summary, "paths");
return "[REDACTED_PATH]";
});
return next.replace(UNQUOTED_PATH_RE, () => {
increment(summary, "paths");
return "[REDACTED_PATH]";
});
}
function redactText(value: string, known: Map<string, KnownValue>, summary: RedactionSummary): string {
let next = redactPaths(value, summary);
next = replacePattern(next, EMAIL_RE, "[REDACTED_EMAIL]", "emails", summary);
next = replacePattern(next, DOMAIN_IDENTITY_RE, "[REDACTED_IDENTITY]", "identities", summary);
next = replacePattern(next, SID_RE, "[REDACTED_IDENTITY]", "identities", summary);
next = replacePattern(next, MACHINE_ACCOUNT_RE, "[REDACTED_IDENTITY]", "identities", summary);
next = replacePattern(next, IPV4_MAPPED_IPV6_RE, "[REDACTED_IP]", "ips", summary, isValidIpv6);
next = replacePattern(next, IPV4_RE, "[REDACTED_IP]", "ips", summary, isValidIpv4);
next = replacePattern(next, IPV6_RE, "[REDACTED_IP]", "ips", summary, isValidIpv6);
return replaceKnownValues(next, known, summary);
}
function redactField(key: string, value: string, known: Map<string, KnownValue>, summary: RedactionSummary): string {
const category = fieldCategory(key);
if (category && value) {
increment(summary, category);
return replacementFor(category);
}
return redactText(value, known, summary);
}
function redactEvent(event: EventItem, known: Map<string, KnownValue>, summary: RedactionSummary): EventItem {
const fields = Object.fromEntries(
Object.entries(event.fields ?? {}).map(([key, value]) => [key, redactField(key, value, known, summary)]),
);
let computer = event.computer ?? "";
if (computer) {
increment(summary, "hosts");
computer = "[REDACTED_HOST]";
}
return { ...event, computer, summary: redactText(event.summary, known, summary), fields };
}
export function redactAnalysis(source: Analysis): RedactionResult {
const summary: RedactionSummary = { hosts: 0, identities: 0, emails: 0, ips: 0, paths: 0, sources: source.source ? 1 : 0, total: source.source ? 1 : 0 };
const known = collectKnownValues(source);
const events = source.events.map((event) => redactEvent(event, known, summary));
const incidents = source.incidents.map((incident) => ({
...incident,
title: redactText(incident.title, known, summary),
reasons: incident.reasons.map((reason) => redactText(reason, known, summary)),
}));
return { analysis: { ...source, source: "[REDACTED_SOURCE].evtx", events, incidents }, summary };
}
+507
View File
@@ -0,0 +1,507 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #07131f;
color: #e7eef7;
--bg: #07131f;
--bg-raised: #0a1b2a;
--bg-panel: #0b2233;
--bg-soft: #0e2a3e;
--line: #21445b;
--line-soft: #18364b;
--text: #e7eef7;
--muted: #9db1c2;
--subtle: #728ba0;
--blue: #32b6ff;
--blue-strong: #168dff;
--amber: #ffc342;
--red: #ff6870;
--green: #4bd985;
--focus: #66caff;
--shadow-panel: 0 14px 34px rgba(0, 0, 0, .08);
--transition-fast: .15s ease;
--radius: 8px;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: radial-gradient(circle at 50% -14%, #102f47 0, var(--bg) 42%, #06101a 100%); font-size: 14px; }
button, input, select { font: inherit; }
button { color: inherit; }
button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
button:disabled { cursor: not-allowed; opacity: .5; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.hidden-input { display: none; }
.event-app-shell { min-height: 100vh; }
.event-topbar { min-height: 70px; padding: 0 28px; display: flex; align-items: center; gap: 28px; border-bottom: 1px solid rgba(61, 105, 136, .42); background: rgba(5, 17, 28, .94); position: sticky; top: 0; z-index: 10; backdrop-filter: blur(14px); }
.event-brand { display: flex; align-items: center; gap: 10px; min-width: 260px; }
.event-logo { width: 42px; height: 42px; display: grid; place-items: center; color: var(--blue); }
.event-logo svg { width: 100%; height: 100%; }
.event-brand strong { display: block; font-size: 22px; letter-spacing: -.03em; line-height: 1.05; }
.event-brand small { display: block; margin-top: 4px; color: var(--muted); font-size: 12px; }
.workspace-nav { display: flex; align-self: stretch; align-items: stretch; gap: 4px; overflow-x: auto; scrollbar-width: none; }
.workspace-nav::-webkit-scrollbar { display: none; }
.workspace-nav button { min-width: 88px; padding: 0 14px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--muted); cursor: pointer; white-space: nowrap; transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast); }
.workspace-nav button svg { width: 17px; height: 17px; }
.workspace-nav button:hover { color: var(--text); background: rgba(33, 78, 108, .24); }
.workspace-nav button.active { color: var(--blue); border-bottom-color: var(--blue); background: rgba(20, 91, 132, .13); }
.instance-status { margin-left: auto; display: flex; align-items: flex-start; gap: 8px; white-space: nowrap; }
.instance-status > span { width: 9px; height: 9px; margin-top: 4px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(75, 217, 133, .1); }
.instance-status div { display: flex; flex-direction: column; gap: 3px; }
.instance-status b { font-size: 12px; }
.instance-status small { color: var(--subtle); font-size: 11px; text-align: right; }
.event-main { width: min(100%, 1760px); margin: 0 auto; padding: 18px 28px 0; }
.global-actions { min-height: 58px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 8px 0 16px; border-bottom: 1px solid rgba(39, 77, 101, .48); transition: border-color .15s ease; }
.global-actions.dragging { border-color: var(--blue); }
.global-actions-secondary { min-height: 50px; padding-bottom: 12px; }
.global-actions-secondary .primary-button { border-color: #315773; background: #0d263a; box-shadow: none; color: #dce8f2; }
.global-actions-secondary .primary-button:hover { border-color: #4c7898; background: #12334b; }
.global-actions-secondary .drop-hint { color: #668096; }
.action-buttons { display: flex; align-items: center; gap: 9px; min-width: 0; }
.primary-button, .secondary-button { min-height: 38px; padding: 0 15px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; border-radius: 6px; font-weight: 700; cursor: pointer; transition: background var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast); }
.primary-button svg, .secondary-button svg, .reset-button svg, .text-button svg { width: 16px; height: 16px; }
.primary-button:hover, .secondary-button:hover, .quiet-button:hover, .reset-button:hover { transform: translateY(-1px); }
.primary-button { border: 1px solid #35aaff; background: linear-gradient(180deg, #1b9cff, #137de1); color: #fff; box-shadow: 0 4px 14px rgba(16, 126, 220, .18); }
.primary-button:hover { background: linear-gradient(180deg, #31adff, #1689ec); }
.secondary-button { border: 1px solid #315773; background: #0d263a; color: #dce8f2; }
.secondary-button:hover { border-color: #4c7898; background: #12334b; }
.drop-hint { margin-left: 4px; color: var(--subtle); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.context-muted { color: var(--subtle); }
.investigation-context { min-height: 46px; margin-top: 4px; padding: 8px 12px; display: flex; align-items: center; gap: 14px; border: 1px solid var(--line-soft); border-radius: 0 0 var(--radius) var(--radius); background: rgba(7, 25, 38, .66); color: var(--muted); font-size: 12px; }
.investigation-source { min-width: 0; display: flex; align-items: center; gap: 8px; }
.investigation-source > svg { width: 18px; height: 18px; flex: 0 0 auto; color: var(--blue); }
.investigation-source span { min-width: 0; display: grid; gap: 2px; }
.investigation-source b { max-width: 300px; overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
.investigation-source small { color: var(--subtle); font-size: 10px; }
.context-facts { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.context-facts > span:not(.status-badge) { color: var(--subtle); white-space: nowrap; }
.context-incident { min-width: 0; margin-left: auto; overflow: hidden; color: var(--subtle); text-overflow: ellipsis; white-space: nowrap; }
.context-incident b { color: var(--text); }
.preferences-context { justify-content: space-between; }
.preferences-context .context-facts { margin-left: auto; }
.status-badge { display: inline-flex; align-items: center; min-height: 23px; padding: 3px 9px; border: 1px solid currentColor; border-radius: 999px; font-size: 11px; font-weight: 700; }
.status-complete { color: var(--green); background: rgba(48, 129, 86, .18); }
.status-partial { color: var(--amber); background: rgba(157, 102, 14, .2); }
.status-failed { color: var(--red); background: rgba(145, 37, 52, .2); }
.status-demo { color: var(--blue); background: rgba(35, 120, 170, .18); }
.loading-banner, .error-banner { margin: 14px 0 0; padding: 11px 14px; border-radius: var(--radius); display: flex; align-items: center; gap: 10px; }
.loading-banner { border: 1px solid #2b6c8d; background: #0c2e40; color: #c9f0ff; }
.loading-spinner { width: 15px; height: 15px; border: 2px solid rgba(166, 226, 255, .32); border-top-color: #a6e2ff; border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.error-banner { justify-content: space-between; border: 1px solid #75404f; background: #2a1822; color: #ffd9de; }
.error-banner div { display: grid; gap: 3px; }
.error-banner span { overflow-wrap: anywhere; }
.text-button { border: 0; padding: 4px 0; background: transparent; color: var(--blue); cursor: pointer; font-weight: 700; }
.text-button:hover { color: #9bdcff; text-decoration: underline; }
.partial-warning, .parser-issues { margin-top: 14px; padding: 13px 16px; border-radius: var(--radius); }
.partial-warning { border: 1px solid #d08d24; background: linear-gradient(90deg, rgba(77, 43, 9, .94), rgba(51, 33, 12, .76)); color: #ffe5b5; }
.notice-title { display: flex; align-items: flex-start; gap: 10px; }
.notice-title svg { width: 23px; min-width: 23px; color: var(--amber); }
.notice-title div { display: grid; gap: 4px; }
.notice-title strong { font-size: 14px; }
.notice-title span, .partial-warning p, .partial-warning li, .parser-issues, .parser-issues li { font-size: 12px; line-height: 1.45; }
.partial-warning p { margin: 8px 0 4px; }
.partial-warning ul, .parser-issues ul { margin: 4px 0 0; padding-left: 24px; }
.partial-warning code, .parser-issues code { color: #ffd26c; }
.parser-issues { display: grid; gap: 4px; border: 1px solid #2e7185; background: #0b2c3b; color: #c2effb; }
.parser-issues code { color: #91e4ff; }
.workspace-content { padding-top: 22px; }
.workspace-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
.eyebrow { margin: 0 0 6px; color: var(--blue); font-size: 11px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
.workspace-header h1 { margin: 0; color: var(--text); font-size: clamp(25px, 3vw, 34px); line-height: 1.05; letter-spacing: -.035em; }
.workspace-description { max-width: 740px; margin: 8px 0 0; color: var(--muted); line-height: 1.5; }
.panel { border: 1px solid rgba(44, 85, 111, .72); border-radius: var(--radius); background: linear-gradient(180deg, rgba(10, 31, 47, .97), rgba(7, 24, 37, .97)); box-shadow: var(--shadow-panel); }
.panel-header { min-height: 55px; display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 16px; border-bottom: 1px solid var(--line-soft); }
.panel-header h2 { margin: 0; font-size: 16px; letter-spacing: -.01em; }
.panel-count, .section-count { color: var(--subtle); font-size: 12px; }
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; }
.section-heading h2 { margin: 0; font-size: 20px; letter-spacing: -.02em; }
.empty-state { min-height: 360px; padding: 52px 30px; border: 1px dashed #2d5972; border-radius: var(--radius); display: flex; align-items: center; justify-content: center; gap: 22px; background: linear-gradient(140deg, rgba(11, 37, 56, .78), rgba(7, 22, 34, .5)); }
.empty-state-mark { width: 60px; height: 60px; display: grid; place-items: center; border: 1px solid #2d7196; border-radius: 50%; color: var(--blue); background: rgba(25, 117, 163, .15); }
.empty-state-mark svg { width: 29px; }
.empty-state h2 { margin: 0; font-size: 22px; }
.empty-state p { max-width: 520px; margin: 8px 0 18px; color: var(--muted); line-height: 1.55; }
.empty-actions { display: flex; gap: 9px; }
.workflow-steps { margin: 0 0 12px; padding: 0; display: flex; flex-wrap: wrap; gap: 5px 0; list-style: none; color: #b9dce9; font-size: 11px; font-weight: 700; }
.workflow-steps li { display: inline-flex; align-items: center; }
.workflow-steps li + li::before { content: "→"; margin: 0 7px; color: var(--amber); }
.empty-privacy { margin-bottom: 16px !important; color: var(--subtle) !important; font-size: 12px; }
.analysis-summary { display: grid; grid-template-columns: minmax(205px, .82fr) minmax(0, 2.18fr); gap: 15px; }
.analysis-status-card { min-height: 126px; padding: 19px; display: flex; flex-direction: column; justify-content: center; border: 1px solid var(--line); border-left: 3px solid var(--green); border-radius: var(--radius); background: linear-gradient(180deg, #0d2a40, #091e2f); }
.analysis-status-card .eyebrow { margin-bottom: 8px; }
.analysis-status-card strong { color: var(--green); font-size: clamp(21px, 2.2vw, 29px); line-height: 1; text-transform: uppercase; }
.analysis-status-card p { margin: 8px 0 0; color: var(--muted); font-size: 12px; line-height: 1.4; }
.analysis-status-card.status-partial { border-left-color: var(--amber); }
.analysis-status-card.status-partial strong { color: var(--amber); }
.analysis-status-card.status-failed { border-left-color: var(--red); }
.analysis-status-card.status-failed strong { color: var(--red); }
.metric-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.metric-card { min-height: 126px; padding: 19px; display: grid; grid-template-columns: 40px 1fr; grid-template-rows: auto auto; align-content: center; gap: 2px 12px; border: 1px solid var(--line); border-radius: var(--radius); background: linear-gradient(180deg, #0d2a40, #091e2f); }
.metric-card .metric-icon { grid-row: 1 / 3; width: 35px; align-self: center; color: var(--blue); }
.metric-card .metric-icon svg { width: 100%; height: auto; }
.metric-card strong { font-size: clamp(22px, 2.4vw, 31px); color: var(--blue); line-height: 1.05; overflow-wrap: anywhere; }
.metric-card > span:last-child { color: var(--muted); font-size: 12px; }
.metric-card.tone-amber .metric-icon, .metric-card.tone-amber strong { color: var(--amber); }
.metric-card.tone-green .metric-icon, .metric-card.tone-green strong { color: var(--green); }
.analysis-section { margin-top: 28px; }
.incident-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-top: 13px; }
.incident-card { position: relative; min-height: 173px; padding: 17px; display: flex; flex-direction: column; align-items: stretch; text-align: left; border: 1px solid #8b671c; border-left: 3px solid var(--amber); border-radius: var(--radius); background: linear-gradient(155deg, #0d283a, #081b2a); cursor: pointer; transition: border-color .15s ease, background .15s ease, transform .15s ease; }
.incident-card:hover { transform: translateY(-2px); background: linear-gradient(155deg, #113149, #0b2133); }
.incident-card.selected { box-shadow: 0 0 0 2px rgba(50, 182, 255, .62); border-color: var(--blue); }
.incident-card.severity-high { border-color: #9b3d4b; border-left-color: var(--red); }
.incident-card-heading { min-width: 0; display: grid; grid-template-columns: 23px 1fr auto; align-items: start; gap: 8px; }
.incident-icon { color: var(--amber); }
.incident-icon svg { width: 21px; }
.severity-high .incident-icon { color: var(--red); }
.incident-card-heading strong { min-width: 0; font-size: 14px; line-height: 1.3; }
.severity-pill { align-self: start; padding: 3px 8px; border: 1px solid currentColor; border-radius: 999px; color: var(--amber); background: rgba(95, 66, 7, .35); font-size: 10px; font-weight: 700; }
.severity-high .severity-pill { color: var(--red); background: rgba(100, 23, 35, .35); }
.incident-card p { min-height: 37px; margin: 13px 0 11px; color: var(--muted); font-size: 12px; line-height: 1.5; }
.incident-card footer { display: flex; justify-content: space-between; gap: 10px; margin-top: auto; color: var(--subtle); font-size: 11px; }
.incident-card footer span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.incident-card-action { margin-top: 13px; color: var(--blue); font-size: 12px; font-weight: 700; display: inline-flex; align-items: center; gap: 5px; }
.incident-card-action svg { width: 14px; height: 14px; }
.analysis-note, .hunting-hint { margin-top: 20px; padding: 14px 16px; display: flex; align-items: flex-start; gap: 11px; border-left: 3px solid #2d83af; background: rgba(10, 43, 64, .55); color: var(--muted); }
.analysis-note > svg, .hunting-hint > svg { width: 19px; height: 19px; flex: 0 0 auto; color: var(--blue); }
.analysis-note strong { color: var(--text); }
.analysis-note p { margin: 4px 0 0; line-height: 1.45; }
.incident-context { margin-bottom: 13px; padding: 12px 14px; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: rgba(9, 30, 45, .7); }
.incident-context label, .filter-secondary label, .hunting-facets label { display: grid; gap: 5px; min-width: 0; color: var(--subtle); font-size: 11px; }
.incident-context select { min-width: 280px; }
.incident-context-detail { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: 12px; }
.incident-focus-chip { padding: 5px 9px; border: 1px solid #3d5d71; border-radius: 999px; color: var(--muted); }
.incident-focus-chip.active { border-color: var(--blue); color: var(--blue); background: rgba(27, 135, 194, .15); }
.filter-toolbar { margin-bottom: 14px; padding: 14px; border: 1px solid var(--line); border-radius: var(--radius); background: rgba(8, 27, 41, .72); }
.filter-primary { display: flex; align-items: center; gap: 14px; }
.search-field { height: 42px; min-width: 0; display: flex; align-items: center; gap: 9px; padding: 0 12px; border: 1px solid #2c536b; border-radius: 6px; background: #071b2a; }
.search-field:focus-within { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(50, 182, 255, .12); }
.search-field svg { width: 18px; flex: 0 0 auto; color: var(--muted); }
.search-field input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--text); }
.search-field input::placeholder { color: #7992a7; }
.filter-primary .search-field { flex: 1; }
.result-count { color: var(--subtle); font-size: 12px; white-space: nowrap; }
.result-count strong { color: var(--text); }
.filter-secondary { display: grid; grid-template-columns: minmax(145px, .9fr) minmax(125px, .8fr) minmax(200px, 1.4fr) minmax(140px, .9fr); gap: 10px; margin-top: 12px; }
.filter-secondary select, .hunting-facets select, .hunting-facets input, .incident-context select { width: 100%; height: 36px; min-width: 0; padding: 0 10px; border: 1px solid #2b5068; border-radius: 5px; outline: 0; background: #0a2234; color: var(--text); }
.filter-secondary select:focus, .hunting-facets select:focus, .hunting-facets input:focus, .incident-context select:focus { border-color: var(--blue); }
.filter-actions { margin-top: 12px; display: flex; align-items: center; flex-wrap: wrap; gap: 15px; }
.toggle-control { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; cursor: pointer; }
.toggle-control input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.toggle-track { pointer-events: none; }
.toggle-control input:focus-visible + .toggle-track { outline: 2px solid #66caff; outline-offset: 3px; }
.toggle-track { position: relative; width: 32px; height: 18px; flex: 0 0 auto; border-radius: 20px; background: #4a6172; transition: background .15s ease; }
.toggle-track::after { content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .15s ease; }
.toggle-control input:checked + .toggle-track { background: var(--blue-strong); }
.toggle-control input:checked + .toggle-track::after { transform: translateX(14px); }
.reset-button, .quiet-button { min-height: 34px; padding: 0 10px; border: 1px solid #315773; border-radius: 5px; background: #0c2639; color: var(--muted); cursor: pointer; font-size: 12px; }
.reset-button { margin-left: auto; color: var(--text); }
.filter-chips { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 12px; }
.filter-chips span { padding: 4px 8px; border: 1px solid #306887; border-radius: 999px; color: #a8daf3; background: rgba(23, 112, 156, .15); font-size: 11px; }
.timeline-layout { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(360px, .95fr); gap: 14px; min-height: 590px; }
.timeline-panel, .inspector-panel { min-width: 0; overflow: hidden; }
.date-band { min-height: 34px; padding: 9px 16px; border-bottom: 1px solid var(--line-soft); color: var(--muted); font-size: 12px; font-weight: 700; }
.event-list { height: 533px; overflow: auto; contain: strict; }
.event-list-inner { position: relative; min-height: 100%; }
.event-list-window { position: absolute; inset: 0 0 auto; }
.event-row { width: 100%; min-height: 48px; padding: 0 13px 0 0; display: grid; grid-template-columns: 30px 86px 58px minmax(180px, 1fr) minmax(82px, 115px) 68px; align-items: center; text-align: left; border: 0; border-bottom: 1px solid var(--line-soft); background: transparent; cursor: pointer; overflow: hidden; }
.event-row:hover { background: rgba(26, 77, 106, .3); }
.event-row.active { background: linear-gradient(90deg, rgba(22, 107, 157, .72), rgba(12, 48, 74, .72)); box-shadow: inset 3px 0 var(--blue); }
.event-row time, .event-id, .event-host, .event-level { font-size: 11px; }
.event-row time { color: #b8c8d5; white-space: nowrap; }
.event-id { color: var(--text); }
.event-copy { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.event-copy strong, .event-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.event-copy strong { color: #d6e3ed; font-size: 12px; font-weight: 650; }
.event-copy span { color: var(--muted); font-size: 12px; }
.event-host { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--subtle); }
.event-level { justify-self: end; padding: 4px 7px; border: 1px solid currentColor; border-radius: 4px; text-transform: capitalize; }
.level-error, .level-critical { color: var(--red); background: rgba(113, 26, 39, .35); }
.level-warning { color: var(--amber); background: rgba(102, 74, 7, .33); }
.level-information { color: #68caff; background: rgba(28, 96, 137, .35); }
.event-rail { position: relative; height: 100%; }
.event-rail::before { content: ""; position: absolute; left: 17px; top: 0; bottom: 0; width: 2px; background: #2e8cbd; }
.event-rail > span { position: absolute; left: 12px; top: calc(50% - 5px); width: 11px; height: 11px; border: 2px solid #07131f; border-radius: 50%; background: #42b6f2; }
.event-rail.rail-error::before, .event-rail.rail-critical::before { background: var(--red); }
.event-rail.rail-error > span, .event-rail.rail-critical > span { background: var(--red); }
.event-rail.rail-warning::before { background: #d7a821; }
.event-rail.rail-warning > span { background: var(--amber); }
.empty-list { display: grid; place-items: center; min-height: 260px; color: var(--muted); }
.empty-list p { padding: 24px; text-align: center; line-height: 1.5; }
.inspector-panel { display: flex; flex-direction: column; }
.inspector-navigation { display: flex; align-items: center; gap: 8px; }
.inspector-navigation button { width: 32px; height: 30px; border: 1px solid #315773; border-radius: 5px; background: #0d2a3f; cursor: pointer; font-size: 20px; line-height: 1; }
.inspector-navigation button svg { width: 16px; height: 16px; }
.inspector-navigation span { min-width: 64px; color: var(--muted); font-size: 11px; text-align: center; }
.inspector-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px; }
.inspector-title > div { min-width: 0; }
.inspector-title h3 { margin: 0 0 5px; overflow-wrap: anywhere; font-size: 16px; line-height: 1.3; }
.inspector-title p { margin: 0; color: var(--muted); font-size: 12px; }
.inspector-actions { padding: 0 16px 10px; }
.inspector-tabs { min-height: 42px; padding: 0 10px; display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); }
.inspector-tabs button { border: 0; border-bottom: 3px solid transparent; background: transparent; color: var(--muted); cursor: pointer; font-size: 11px; }
.inspector-tabs button:hover { color: var(--text); }
.inspector-tabs button.active { border-bottom-color: var(--blue); color: var(--blue); }
.inspector-tabs button[aria-selected="true"] { font-weight: 700; }
.inspector-content { padding: 14px 16px; overflow: auto; }
.copy-row { display: flex; justify-content: flex-end; gap: 7px; margin-bottom: 11px; }
.quiet-button { min-height: 29px; padding: 0 8px; font-size: 11px; }
.fact-grid { margin: 0; display: grid; grid-template-columns: minmax(106px, .9fr) minmax(0, 1.5fr); }
.fact-grid > div { display: contents; }
.fact-grid dt, .fact-grid dd { min-width: 0; padding: 6px 0; border-bottom: 1px solid rgba(35, 69, 91, .55); font-size: 12px; line-height: 1.4; }
.fact-grid dt { color: var(--subtle); }
.fact-grid dd { margin: 0; overflow-wrap: anywhere; color: var(--text); }
.level-dot { display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--red); }
.level-dot.warning { background: var(--amber); }
.level-dot.information { background: #5fc4f9; }
.normalized-fields { margin-top: 16px; border: 1px solid #2c526a; border-radius: 6px; overflow: hidden; background: #071a28; }
.normalized-fields > header { min-height: 38px; padding: 0 11px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line-soft); color: var(--text); font-size: 12px; }
.normalized-fields > header span { color: var(--subtle); }
.normalized-fields > div { padding: 9px 11px; }
.normalized-fields p { margin: 0; padding: 5px 0; display: grid; grid-template-columns: minmax(100px, .8fr) minmax(0, 1.2fr); gap: 10px; border-bottom: 1px solid rgba(35, 69, 91, .35); font-size: 11px; }
.normalized-fields p:last-child { border-bottom: 0; }
.normalized-fields code { color: #a9c4d7; overflow-wrap: anywhere; }
.normalized-fields p span { color: var(--text); overflow-wrap: anywhere; }
.muted-copy { color: var(--muted); font-size: 12px; line-height: 1.5; }
.normalized-fields > .muted-copy { margin: 12px; }
.inspector-raw { flex: 1; min-height: 320px; margin: 14px; padding: 13px; overflow: auto; border: 1px solid #2a4d64; border-radius: 6px; background: #04131e; color: #c7e2f1; font: 11px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.inspector-placeholder { padding: 28px 18px; color: var(--muted); line-height: 1.5; }
.inspector-placeholder p { margin: 0 0 5px; color: var(--text); font-weight: 700; }
.related-events { display: grid; gap: 7px; }
.related-event { min-width: 0; padding: 9px 10px; display: grid; grid-template-columns: 75px 45px minmax(0, 1fr); gap: 8px; border: 1px solid var(--line-soft); border-radius: 5px; background: transparent; color: var(--muted); text-align: left; cursor: pointer; font-size: 11px; }
.related-event:hover, .related-event.active { border-color: var(--blue); background: rgba(24, 107, 150, .22); }
.related-event strong { color: var(--text); }
.related-event span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.insight-copy { color: var(--muted); font-size: 12px; line-height: 1.55; }
.insight-copy p { margin: 0 0 12px; }
.insight-copy strong { color: var(--text); }
.hunting-query { padding: 16px; margin-bottom: 14px; }
.hunting-query-main { display: flex; align-items: center; gap: 14px; }
.search-field.large { height: 48px; flex: 1; }
.search-field.large input { font-size: 15px; }
.hunting-facets { display: grid; grid-template-columns: minmax(100px, .6fr) minmax(150px, 1.15fr) minmax(130px, .9fr) minmax(120px, .8fr) minmax(150px, 1fr) minmax(130px, .8fr); gap: 10px; margin-top: 14px; align-items: end; }
.hunting-facets input { height: 36px; }
.hunting-facets .facet-toggle { min-height: 36px; align-self: end; }
.hunting-facets .facet-toggle { grid-column: span 2; }
.hunting-facets .reset-button { grid-column: 6; margin: 0; height: 36px; justify-self: end; white-space: nowrap; }
.hunting-layout { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(360px, 1fr); gap: 14px; min-height: 590px; }
.hunting-results { min-width: 0; overflow: hidden; }
.hunting-result-list { max-height: 590px; overflow: auto; }
.hunting-result { width: 100%; min-height: 62px; padding: 9px 13px; display: grid; grid-template-columns: 96px 48px minmax(0, 1fr) minmax(85px, 110px) 12px; gap: 10px; align-items: center; border: 0; border-bottom: 1px solid var(--line-soft); background: transparent; text-align: left; cursor: pointer; }
.hunting-result:hover, .hunting-result.active { background: rgba(24, 107, 150, .25); }
.hunting-result .result-time, .result-meta { color: var(--subtle); font-size: 11px; line-height: 1.4; }
.hunting-result > strong { color: var(--text); font-size: 12px; }
.result-main { min-width: 0; display: grid; gap: 3px; }
.result-main b, .result-main span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result-main b { color: #d6e3ed; font-size: 12px; }
.result-main span { color: var(--muted); font-size: 11px; }
.result-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.level-text { font-size: 10px; font-weight: 700; }
.membership-dot { width: 9px; height: 9px; border: 1px solid #587080; border-radius: 50%; }
.membership-dot.known { border-color: var(--blue); background: var(--blue); box-shadow: 0 0 0 3px rgba(50, 182, 255, .12); }
.quiet-empty { min-height: 180px; padding: 30px 22px; color: var(--muted); }
.quiet-empty h3 { margin: 0; color: var(--text); font-size: 16px; }
.quiet-empty p { margin: 8px 0 0; line-height: 1.5; }
.hunting-hint { margin-bottom: 0; font-size: 12px; line-height: 1.5; }
.report-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.export-readiness { margin: 0 0 14px; padding: 12px 14px; display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); border-radius: var(--radius); background: rgba(10, 34, 49, .7); }
.export-readiness svg { width: 22px; height: 22px; flex: 0 0 auto; }
.export-readiness div { display: grid; gap: 3px; }
.export-readiness strong { color: var(--text); font-size: 13px; }
.export-readiness span { color: var(--muted); font-size: 12px; line-height: 1.4; }
.export-readiness.ready { border-color: rgba(75, 217, 133, .55); }
.export-readiness.ready svg { color: var(--green); }
.export-readiness.caution, .export-readiness.partial { border-color: rgba(255, 195, 66, .62); }
.export-readiness.caution svg, .export-readiness.partial svg { color: var(--amber); }
.report-card { min-width: 0; padding: 18px; }
.report-card .section-heading { align-items: center; }
.report-card p { color: var(--muted); line-height: 1.5; }
.report-facts { margin: 16px 0 0; display: grid; grid-template-columns: minmax(100px, .8fr) minmax(0, 1.2fr); }
.report-facts > div { display: contents; }
.report-facts dt, .report-facts dd { padding: 8px 0; border-bottom: 1px solid var(--line-soft); font-size: 12px; }
.report-facts dt { color: var(--subtle); }
.report-facts dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--text); }
.completeness-summary { margin-top: 17px; padding: 13px; display: grid; gap: 4px; border: 1px solid #2a536a; border-radius: 6px; background: rgba(12, 50, 72, .45); }
.completeness-summary strong { color: var(--text); }
.completeness-summary span { color: var(--muted); font-size: 12px; }
.warning-copy { color: var(--amber) !important; font-size: 12px; }
.issue-list { margin: 15px 0 0; padding: 0; list-style: none; display: grid; gap: 8px; }
.issue-list li { display: grid; gap: 3px; padding: 8px 10px; border-left: 2px solid var(--amber); background: rgba(77, 51, 8, .23); font-size: 12px; }
.issue-list code { color: #ffd36b; }
.issue-list span { color: var(--muted); }
.privacy-card, .export-card { min-height: 215px; }
.privacy-card > p, .export-card > p { font-size: 13px; }
.redaction-stats { margin-top: 18px; display: flex; flex-wrap: wrap; gap: 8px; }
.redaction-stats span { min-width: 75px; padding: 9px 10px; display: grid; gap: 3px; border: 1px solid #2d5872; border-radius: 6px; background: rgba(10, 42, 62, .62); color: var(--subtle); font-size: 10px; text-transform: uppercase; letter-spacing: .05em; }
.redaction-stats strong { color: var(--blue); font-size: 18px; letter-spacing: 0; }
.raw-export-warning { margin-top: 19px; padding: 11px; display: flex; align-items: center; gap: 9px; border: 1px solid #875d1c; border-radius: 6px; color: var(--amber); font-size: 12px; }
.raw-export-warning svg { width: 20px; flex: 0 0 auto; }
.export-actions { display: flex; gap: 9px; margin-top: 22px; }
.settings-header-status { display: grid; justify-items: end; gap: 4px; min-height: 30px; }
.settings-saved-automatically { display: inline-flex; align-items: center; gap: 6px; color: var(--green); font-size: 12px; font-weight: 700; white-space: nowrap; }
.settings-saved-automatically svg { width: 16px; height: 16px; }
.settings-save-feedback { min-height: 15px; color: var(--subtle); font-size: 11px; }
.settings-layout { max-width: 1260px; display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(280px, .85fr); gap: 16px; align-items: start; }
.settings-preferences { display: grid; gap: 16px; }
.settings-section { padding: 0 20px; overflow: hidden; }
.settings-section > header, .settings-privacy-panel > header { display: flex; align-items: flex-start; gap: 11px; padding: 18px 0 16px; border-bottom: 1px solid var(--line-soft); }
.settings-section > header > svg, .settings-privacy-panel > header > svg { width: 20px; height: 20px; flex: 0 0 auto; color: var(--blue); }
.settings-section h2, .settings-privacy-panel h2 { margin: 0; font-size: 16px; letter-spacing: -.01em; }
.settings-section header p { margin: 5px 0 0; color: var(--muted); font-size: 12px; line-height: 1.45; }
.settings-section fieldset { margin: 0; padding: 18px 0; border: 0; }
.settings-section fieldset + fieldset { border-top: 1px solid var(--line-soft); }
.settings-section legend { padding: 0; color: var(--text); font-size: 13px; font-weight: 750; }
.preference-option-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 11px; }
.preference-option { min-height: 80px; padding: 13px; display: grid; grid-template-columns: 17px minmax(0, 1fr); align-items: start; gap: 10px; border: 1px solid #294f67; border-radius: 7px; background: rgba(8, 29, 43, .62); cursor: pointer; transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast); }
.preference-option:hover { border-color: #437895; background: rgba(15, 54, 76, .68); }
.preference-option.selected { border-color: var(--blue); background: linear-gradient(145deg, rgba(21, 93, 130, .42), rgba(10, 41, 59, .66)); box-shadow: inset 0 0 0 1px rgba(50, 182, 255, .18); }
.preference-option:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 3px; }
.preference-option-indicator { width: 16px; height: 16px; margin-top: 1px; border: 1px solid #668398; border-radius: 50%; background: #071b2a; }
.preference-option.selected .preference-option-indicator { border: 4px solid var(--blue-strong); background: #d7f4ff; }
.preference-option > span:last-child { display: grid; gap: 4px; }
.preference-option strong { font-size: 13px; }
.preference-option small { color: var(--muted); font-size: 12px; line-height: 1.35; }
.preference-rows { padding: 4px 0; }
.preference-row { min-height: 76px; padding: 13px 0; display: grid; grid-template-columns: minmax(0, 1fr) 196px; align-items: center; gap: 18px; border-bottom: 1px solid var(--line-soft); }
.preference-row:last-child { border-bottom: 0; }
.preference-row > span:first-child { display: grid; gap: 4px; min-width: 0; }
.preference-row strong { font-size: 13px; }
.preference-row small { color: var(--muted); font-size: 12px; line-height: 1.4; }
.preference-row select { width: 100%; height: 36px; min-width: 0; padding: 0 10px; border: 1px solid #2b5068; border-radius: 5px; outline: 0; background: #0a2234; color: var(--text); }
.preference-row select:focus { border-color: var(--blue); }
.preference-switch { justify-self: end; display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
.preference-switch:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 3px; border-radius: 999px; }
.preference-switch-track { position: relative; width: 42px; height: 23px; border-radius: 999px; background: #4a6172; transition: background var(--transition-fast); }
.preference-switch-track > span { position: absolute; top: 3px; left: 3px; width: 17px; height: 17px; border-radius: 50%; background: #f2f8fb; transition: transform var(--transition-fast); }
.preference-switch input:checked + .preference-switch-track { background: var(--blue-strong); }
.preference-switch input:checked + .preference-switch-track > span { transform: translateX(19px); }
.preference-switch b { min-width: 24px; color: var(--blue); font-size: 11px; text-align: right; text-transform: uppercase; }
.settings-privacy-panel { padding: 0 20px 20px; }
.settings-privacy-panel > header { padding-bottom: 14px; }
.settings-privacy-panel .eyebrow { margin-bottom: 4px; font-size: 10px; }
.privacy-lead { margin: 16px 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
.privacy-facts { display: grid; gap: 12px; }
.privacy-facts section { display: grid; gap: 5px; padding: 11px 12px; border-left: 2px solid #2e779c; background: rgba(11, 43, 62, .42); }
.privacy-facts strong { color: var(--text); font-size: 12px; }
.privacy-facts span, .privacy-facts li { color: var(--muted); font-size: 12px; line-height: 1.45; }
.privacy-facts ul { margin: 0; padding-left: 17px; }
.settings-technical-details { margin-top: 16px; color: var(--muted); font-size: 12px; }
.settings-technical-details summary { color: #b7d9e8; cursor: pointer; font-weight: 700; }
.settings-technical-details p { margin: 10px 0 5px; line-height: 1.45; }
.settings-technical-details code { color: #a9dfff; overflow-wrap: anywhere; }
.settings-reset { width: 100%; margin-top: 18px; }
.event-statusbar { min-height: 38px; margin: 26px -28px 0; padding: 0 28px; display: flex; align-items: center; gap: 24px; border-top: 1px solid rgba(38, 71, 91, .65); color: var(--subtle); font-size: 11px; }
.event-statusbar > span { display: inline-flex; align-items: center; gap: 5px; min-width: 0; }
.event-statusbar svg { width: 14px; height: 14px; }
.event-statusbar > span:last-child { margin-left: auto; }
.event-statusbar .complete { color: var(--green); }
.event-statusbar .partial { color: var(--amber); }
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 18%, transparent); }
.density-compact .event-row { font-size: 12px; }
.density-compact .event-list { height: 560px; }
.density-compact .metric-card { min-height: 110px; }
@media (max-width: 1250px) {
.event-topbar { gap: 16px; padding-inline: 20px; }
.event-brand { min-width: 220px; }
.workspace-nav button { min-width: 74px; padding-inline: 9px; }
.event-main { padding-inline: 20px; }
.event-statusbar { margin-inline: -20px; padding-inline: 20px; }
.timeline-layout, .hunting-layout { grid-template-columns: minmax(0, 1.4fr) minmax(330px, 1fr); }
.hunting-facets { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
.hunting-facets .facet-toggle { grid-column: span 2; }
.hunting-facets .reset-button { grid-column: auto; justify-self: start; }
}
@media (max-width: 980px) {
.event-topbar { flex-wrap: wrap; min-height: 110px; padding-top: 10px; padding-bottom: 0; }
.event-brand { flex: 1; }
.instance-status { margin-left: 0; }
.workspace-nav { order: 3; width: 100%; min-height: 41px; }
.workspace-nav button { min-width: 100px; }
.global-actions { align-items: flex-start; flex-direction: column; gap: 10px; }
.investigation-context { flex-wrap: wrap; }
.context-incident { width: 100%; margin-left: 0; }
.analysis-summary { grid-template-columns: 1fr; }
.timeline-layout, .hunting-layout { grid-template-columns: 1fr; }
.inspector-panel { min-height: 520px; }
.hunting-layout .inspector-panel { min-height: 520px; }
.report-grid { grid-template-columns: 1fr; }
.settings-layout { grid-template-columns: 1fr; }
.settings-privacy-panel { max-width: none; }
}
@media (max-width: 700px) {
body { font-size: 13px; }
.event-topbar { min-height: 105px; padding-inline: 12px; }
.event-brand { min-width: 0; }
.event-logo { width: 36px; height: 36px; }
.event-brand strong { font-size: 19px; }
.event-brand small { font-size: 10px; }
.instance-status b { font-size: 11px; }
.workspace-nav button { min-width: 86px; padding-inline: 8px; font-size: 12px; }
.event-main { padding: 14px 12px 0; }
.drop-hint { display: none; }
.investigation-context { margin-top: 0; padding: 9px 10px; gap: 9px; }
.investigation-source { width: 100%; }
.investigation-source b { max-width: calc(100vw - 78px); }
.context-facts { gap: 8px; }
.context-incident { max-width: 100%; }
.workspace-header { align-items: flex-start; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.workspace-header h1 { font-size: 27px; }
.workspace-description { font-size: 13px; }
.empty-state { min-height: 300px; padding: 35px 20px; align-items: flex-start; flex-direction: column; }
.workflow-steps { font-size: 10px; }
.workflow-steps li + li::before { margin-inline: 5px; }
.metric-grid { grid-template-columns: 1fr; }
.metric-card { min-height: 90px; }
.incident-grid { grid-template-columns: 1fr; }
.incident-context { align-items: stretch; flex-direction: column; }
.incident-context select { min-width: 0; width: 100%; }
.incident-context-detail { justify-content: space-between; flex-wrap: wrap; }
.filter-primary { align-items: stretch; flex-direction: column; gap: 9px; }
.filter-secondary { grid-template-columns: 1fr 1fr; }
.filter-secondary .provider-filter { grid-column: 1 / -1; }
.filter-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.filter-actions .reset-button { margin-left: 0; grid-column: 1 / -1; justify-self: start; }
.filter-chips { max-height: 70px; overflow: auto; }
.event-list { height: 480px; }
.event-row { grid-template-columns: 25px 67px 43px minmax(110px, 1fr) 60px; padding-right: 9px; }
.event-row .event-host { display: none; }
.event-row time, .event-copy strong, .event-copy span { font-size: 11px; }
.event-level { padding: 3px 5px; font-size: 9px; }
.inspector-panel { min-height: 490px; }
.inspector-tabs { padding-inline: 3px; }
.inspector-tabs button { font-size: 10px; }
.hunting-query-main { align-items: stretch; flex-direction: column; }
.hunting-facets { grid-template-columns: 1fr 1fr; }
.hunting-facets .facet-toggle { grid-column: 1 / -1; }
.hunting-facets .reset-button { grid-column: 1 / -1; }
.hunting-result { grid-template-columns: 72px 38px minmax(0, 1fr) 10px; gap: 7px; }
.hunting-result .result-meta { display: none; }
.settings-header-status { justify-items: start; }
.settings-section, .settings-privacy-panel { padding-inline: 14px; }
.preference-option-grid { grid-template-columns: 1fr; }
.preference-row { grid-template-columns: 1fr; gap: 10px; }
.preference-row select { max-width: none; }
.preference-switch { justify-self: start; }
.preferences-context .context-facts { width: 100%; margin-left: 0; }
.export-actions { flex-direction: column; }
.event-statusbar { margin-inline: -12px; padding-inline: 12px; gap: 10px; font-size: 10px; }
}
@media (min-width: 1600px) {
.event-main { padding-inline: 40px; }
.event-statusbar { margin-inline: -40px; padding-inline: 40px; }
}
+55
View File
@@ -0,0 +1,55 @@
export type EventItem = {
id: string;
timestamp: string | null;
timestamp_source?: "event" | "record" | "missing" | "invalid";
timestamp_valid?: boolean;
provider: string;
event_id: number | null;
level: string;
channel?: string;
computer?: string;
category: string;
summary: string;
fields?: Record<string, string>;
};
export type Incident = {
id: string;
rule_id: string;
start: string | null;
end: string | null;
title: string;
category: string;
severity: string;
reasons: string[];
evidence_event_ids: string[];
original_count: number;
compressed_count: number;
};
export type ParseStats = {
parser: string;
status: "complete" | "partial" | "failed";
parsed_events: number;
records_seen: number;
skipped_records: number;
issue_count: number;
truncated: boolean;
truncation_reason?: string | null;
duration_ms: number;
issues: ParseIssue[];
};
export type ParseIssue = {
code: string;
record_id?: number | null;
message: string;
skipped_record?: boolean;
};
export type Analysis = {
source: string;
events: EventItem[];
incidents: Incident[];
parse?: ParseStats | null;
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+12
View File
@@ -0,0 +1,12 @@
import type { Analysis, Incident } from "../types";
import { statusLabel } from "../app/format";
import type { TimestampDisplay } from "../app/model";
import { Icon } from "../components/icons";
import { EmptyState, IncidentCard, MetricCard, WorkspaceHeader } from "../components/states";
export function AnalysisView({ data, timestampDisplay, activeIncidentId, onOpen, onOpenFile, onDemo }: { data: Analysis | null; timestampDisplay: TimestampDisplay; activeIncidentId: string; onOpen: (incident: Incident) => void; onOpenFile: () => void; onDemo: () => void }) {
if (!data) return <><WorkspaceHeader eyebrow="Workspace 01" title="Analysis" description="A compact overview of the current event-log reconstruction." /><EmptyState onOpen={onOpenFile} onDemo={onDemo} /></>;
const statusDetail = data.parse ? `${data.parse.skipped_records} skipped · ${data.parse.issue_count} parser issue${data.parse.issue_count === 1 ? "" : "s"}` : "Deterministic demo fixture";
const sourceMode = data.parse ? "EVTX file" : "Demo fixture";
return <><WorkspaceHeader eyebrow="Workspace 01 · Overview" title="Analysis" description="Understand completeness and incident windows before opening the evidence timeline." /><section className="analysis-summary" aria-label="Analysis summary"><div className={`analysis-status-card status-${data.parse?.status ?? "complete"}`}><span className="eyebrow">Analysis status</span><strong>{statusLabel(data)}</strong><p>{statusDetail}</p></div><div className="metric-grid"><MetricCard icon={<Icon name="file" />} value={data.events.length.toLocaleString()} label="events parsed" /><MetricCard icon={<Icon name="warning" />} value={data.incidents.length} label="incident windows" tone={data.incidents.length ? "amber" : "green"} /><MetricCard icon={<Icon name="clock" />} value={sourceMode} label="source mode" /></div></section><section className="analysis-section"><div className="section-heading"><div><p className="eyebrow">Deterministic reconstruction</p><h2>Incident windows</h2></div><span className="section-count">{data.incidents.length} found</span></div>{data.incidents.length ? <div className="incident-grid">{data.incidents.map((incident) => <IncidentCard key={incident.id} incident={incident} timestampDisplay={timestampDisplay} selected={activeIncidentId === incident.id} onOpen={onOpen} />)}</div> : <div className="quiet-empty"><h3>No incident windows found</h3><p>Warning and error evidence remains available in Timeline and Hunting for manual review.</p></div>}</section>{!data.parse ? <section className="analysis-note analysis-note-demo"><Icon name="info" /><div><strong>How to continue</strong><p>Select an incident to open its linked evidence in Timeline, or use Hunting to search the complete normalized event set.</p></div></section> : null}</>;
}
+12
View File
@@ -0,0 +1,12 @@
import type { Analysis, EventItem, Incident } from "../types";
import { CATEGORIES, LEVELS, WINDOWS, type HuntingState, type InspectorTab, type TimestampDisplay } from "../app/model";
import { formatClock, formatDate, levelLabel } from "../app/format";
import { Icon } from "../components/icons";
import { EmptyState, WorkspaceHeader } from "../components/states";
import { EvidenceInspector } from "../components/timeline";
export function HuntingView({ data, state, providers, results, selected, activeIncident, timestampDisplay, inspectorTab, incidentEventIds, onChange, onReset, onSelectEvent, onMove, onTab, onOpenTimeline, onOpenFile, onDemo }: { data: Analysis | null; state: HuntingState; providers: string[]; results: EventItem[]; selected: EventItem | null; activeIncident: Incident | null; timestampDisplay: TimestampDisplay; inspectorTab: InspectorTab; incidentEventIds: Set<string>; onChange: (patch: Partial<HuntingState>) => void; onReset: () => void; onSelectEvent: (event: EventItem) => void; onMove: (delta: number) => void; onTab: (tab: InspectorTab) => void; onOpenTimeline: () => void; onOpenFile: () => void; onDemo: () => void }) {
if (!data) return <><WorkspaceHeader eyebrow="Workspace 03 · Exploration" title="Hunting" description="Search the loaded normalized event set with compact investigative facets." /><EmptyState onOpen={onOpenFile} onDemo={onDemo} /></>;
const active = Boolean(state.search || state.eventId || state.provider || state.category || state.level || state.computer || state.windowMinutes || state.incidentOnly);
return <><WorkspaceHeader eyebrow="Workspace 03 · Exploration" title="Hunting" description="Combine event, provider, host and severity facets to find evidence without changing the source analysis." /><section className="hunting-query panel"><div className="hunting-query-main"><label className="search-field large"><Icon name="search" /><span className="sr-only">Search hunting evidence</span><input aria-label="Search hunting evidence" value={state.search} onChange={(event) => onChange({ search: event.target.value })} placeholder="Search summaries, fields, paths, IDs or providers…" /></label><span className="result-count" role="status"><strong>{results.length.toLocaleString()}</strong> of {data.events.length.toLocaleString()} matching events</span></div><div className="hunting-facets"><label><span>Event ID</span><input aria-label="Hunting event ID" inputMode="numeric" value={state.eventId} onChange={(event) => onChange({ eventId: event.target.value.replace(/[^0-9]/g, "") })} placeholder="4625" /></label><label><span>Provider</span><select aria-label="Hunting provider" value={state.provider} onChange={(event) => onChange({ provider: event.target.value })}><option value="">All providers</option>{providers.map((provider) => <option key={provider}>{provider}</option>)}</select></label><label><span>Category</span><select aria-label="Hunting category" value={state.category} onChange={(event) => onChange({ category: event.target.value })}><option value="">All categories</option>{CATEGORIES.map((category) => <option key={category}>{category}</option>)}</select></label><label><span>Level</span><select aria-label="Hunting level" value={state.level} onChange={(event) => onChange({ level: event.target.value })}><option value="">All levels</option>{LEVELS.map((level) => <option key={level}>{level}</option>)}</select></label><label><span>Computer / host</span><input aria-label="Hunting computer" value={state.computer} onChange={(event) => onChange({ computer: event.target.value })} placeholder="DEMO-PC" /></label><label><span>Time window</span><select aria-label="Hunting time window" value={state.windowMinutes} onChange={(event) => onChange({ windowMinutes: Number(event.target.value) })} disabled={!selected?.timestamp}><option value={0}>All time</option>{WINDOWS.filter(Boolean).map((value) => <option value={value} key={value}>± {value} min</option>)}</select></label><label className="toggle-control facet-toggle"><input type="checkbox" checked={state.incidentOnly} onChange={(event) => onChange({ incidentOnly: event.target.checked })} /><span className="toggle-track" /><span>Known incident evidence only</span></label><button type="button" className="reset-button" onClick={onReset} disabled={!active}><Icon name="reset" />Reset hunting</button></div></section><section className="hunting-layout"><article className="hunting-results panel"><header className="panel-header"><div><p className="eyebrow">Normalized event set</p><h2>Results</h2></div><span className="panel-count">{results.length.toLocaleString()} found</span></header>{results.length ? <div className="hunting-result-list" role="list">{results.map((event) => <div role="listitem" key={event.id}><button type="button" className={`hunting-result ${selected?.id === event.id ? "active" : ""}`} onClick={() => onSelectEvent(event)}><span className="result-time">{formatDate(event.timestamp, timestampDisplay)}<br />{formatClock(event.timestamp, timestampDisplay)}</span><strong>{event.event_id ?? "—"}</strong><span className="result-main"><b>{event.provider}</b><span>{event.summary}</span></span><span className="result-meta">{event.computer || "—"}<br /><small className={`level-text level-${event.level}`}>{levelLabel(event.level)}</small></span><span className={`membership-dot ${incidentEventIds.has(event.id) ? "known" : ""}`} title={incidentEventIds.has(event.id) ? "Known incident evidence" : "Unlinked event"} aria-label={incidentEventIds.has(event.id) ? "Known incident evidence" : "Unlinked event"} /></button></div>)}</div> : <div className="quiet-empty"><h3>No matching events</h3><p>Try clearing a facet or search the complete normalized dataset.</p>{active ? <button type="button" className="text-button" onClick={onReset}>Reset hunting filters</button> : null}</div>}</article><EvidenceInspector data={data} event={selected} visibleEvents={results} activeIncident={activeIncident} timestampDisplay={timestampDisplay} tab={inspectorTab} onTab={onTab} onSelectEvent={onSelectEvent} onMove={onMove} onOpenTimeline={onOpenTimeline} /></section><p className="hunting-hint"><Icon name="info" /> Results are local normalized evidence. A known-incident marker means the event is linked by an existing deterministic rule; it does not establish causality.</p></>;
}
+13
View File
@@ -0,0 +1,13 @@
import { prepareExport } from "../export";
import type { Analysis } from "../types";
import { sourceLabel, statusLabel } from "../app/format";
import type { TimestampDisplay } from "../app/model";
import { Icon } from "../components/icons";
import { EmptyState, WorkspaceHeader } from "../components/states";
export function ReportsView({ data, redactExports, redactionPreview, onRedactionChange, onExportJson, onExportMarkdown, timestampDisplay, onOpenFile, onDemo }: { data: Analysis | null; redactExports: boolean; redactionPreview: ReturnType<typeof prepareExport>["redaction"]; onRedactionChange: (value: boolean) => void; onExportJson: () => void; onExportMarkdown: () => void; timestampDisplay: TimestampDisplay; onOpenFile: () => void; onDemo: () => void }) {
if (!data) return <><WorkspaceHeader eyebrow="Workspace 04 · Output" title="Reports" description="Review completeness and privacy choices before exporting an investigation." /><EmptyState onOpen={onOpenFile} onDemo={onDemo} /></>;
const parse = data.parse;
const readiness = parse?.status === "partial" ? "Review completeness before sharing" : redactExports ? "Ready for review/export" : "Privacy review recommended";
return <><WorkspaceHeader eyebrow="Workspace 04 · Output" title="Reports" description="Review the exact analysis metadata, parser issues and redaction behavior before sharing." /><section className={`export-readiness ${parse?.status === "partial" ? "partial" : redactExports ? "ready" : "caution"}`}><Icon name={parse?.status === "partial" || !redactExports ? "warning" : "export"} /><div><strong>{readiness}</strong><span>{parse?.status === "partial" ? "The parser retained a partial result; incident reconstruction may be incomplete." : redactExports ? "Redaction substitutions are enabled. Review the generated output before sharing." : "Raw normalized evidence will be exported unless redaction is enabled."}</span></div></section><div className="report-grid"><section className="report-card panel"><div className="section-heading"><div><p className="eyebrow">Investigation summary</p><h2>Analysis metadata</h2></div></div><dl className="report-facts"><div><dt>Source</dt><dd title={data.source}>{sourceLabel(data.source)}</dd></div><div><dt>Version</dt><dd>EventLens v{import.meta.env.VITE_EVENTLENS_VERSION ?? "development"}</dd></div><div><dt>Events</dt><dd>{data.events.length.toLocaleString()}</dd></div><div><dt>Incidents</dt><dd>{data.incidents.length}</dd></div><div><dt>Timestamp display</dt><dd>{timestampDisplay === "utc" ? "UTC" : "Local browser time"}</dd></div></dl></section><section className="report-card panel"><div className="section-heading"><div><p className="eyebrow">Completeness</p><h2>Parser status</h2></div><span className={`status-badge status-${parse?.status ?? "demo"}`}>{statusLabel(data)}</span></div><div className="completeness-summary"><strong>{parse ? `${parse.parsed_events.toLocaleString()} retained` : "Demo fixture loaded"}</strong><span>{parse ? `${parse.records_seen.toLocaleString()} records seen · ${parse.skipped_records.toLocaleString()} skipped · ${parse.issue_count.toLocaleString()} issue(s)` : "The bundled demo is deterministic and bypasses raw EVTX parsing."}</span></div>{parse?.truncation_reason ? <p className="warning-copy">Truncation reason: <code>{parse.truncation_reason}</code></p> : null}{parse?.issues.length ? <ul className="issue-list">{parse.issues.map((issue, index) => <li key={`${issue.code}-${index}`}><code>{issue.code}</code><span>{issue.message}</span></li>)}</ul> : <p className="muted-copy">No parser issues were reported.</p>}</section><section className="report-card panel privacy-card"><div className="section-heading"><div><p className="eyebrow">Privacy before sharing</p><h2>Redaction preview</h2></div><label className="toggle-control"><input type="checkbox" checked={redactExports} onChange={(event) => onRedactionChange(event.target.checked)} /><span className="toggle-track" /><span>Redact export</span></label></div><p>Redaction is best effort. It propagates known structured values into summaries and applies conservative patterns; review the exported file before sharing.</p>{redactExports ? <div className="redaction-stats">{([["replacements", redactionPreview?.total ?? 0], ["hosts", redactionPreview?.hosts ?? 0], ["identities", redactionPreview?.identities ?? 0], ["emails", redactionPreview?.emails ?? 0], ["IPs", redactionPreview?.ips ?? 0], ["paths", redactionPreview?.paths ?? 0], ["sources", redactionPreview?.sources ?? 0]] as const).map(([label, value]) => <span key={label}><strong>{value}</strong>{label}</span>)}</div> : <div className="raw-export-warning"><Icon name="warning" /><span>Raw normalized evidence will be exported. Review carefully before sharing.</span></div>}</section><section className="report-card panel export-card"><div className="section-heading"><div><p className="eyebrow">Portable output</p><h2>Export report</h2></div></div><p>JSON preserves the analysis contract. Markdown is formatted for human review. Both include completeness metadata, parser issues and evidence IDs.</p><div className="export-actions"><button type="button" className="primary-button" onClick={onExportMarkdown}><Icon name="export" />Export Markdown</button><button type="button" className="secondary-button" onClick={onExportJson}><Icon name="export" />Export JSON</button></div></section></div></>;
}
+157
View File
@@ -0,0 +1,157 @@
import { WORKSPACES, type UiSettings } from "../app/model";
import { Icon } from "../components/icons";
import { WorkspaceHeader } from "../components/states";
type SettingsViewProps = {
settings: UiSettings;
saveNotice: string;
storageFailed: boolean;
storageConfirmed: boolean;
onChange: (patch: Partial<UiSettings>) => void;
onReset: () => void;
};
type OptionCardProps = {
checked: boolean;
description: string;
name: string;
onChange: () => void;
title: string;
};
function OptionCard({ checked, description, name, onChange, title }: OptionCardProps) {
const inputId = `${name}-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
return (
<label className={`preference-option ${checked ? "selected" : ""}`} htmlFor={inputId}>
<input className="sr-only" id={inputId} type="radio" name={name} checked={checked} onChange={onChange} />
<span className="preference-option-indicator" aria-hidden="true" />
<span>
<strong>{title}</strong>
<small>{description}</small>
</span>
</label>
);
}
export function SettingsView({ settings, saveNotice, storageFailed, storageConfirmed, onChange, onReset }: SettingsViewProps) {
const headerAction = (
<div className="settings-header-status">
{storageConfirmed && !storageFailed ? <span className="settings-saved-automatically"><Icon name="check" />Saved automatically in this browser</span> : null}
<span className="settings-save-feedback" role="status" aria-live="polite">{saveNotice}</span>
</div>
);
return (
<>
<WorkspaceHeader
eyebrow="Workspace 05 · Local preferences"
title="Settings"
description="Browser-local display defaults for this device. Investigation evidence is never stored here."
action={headerAction}
/>
<div className="settings-layout">
<div className="settings-preferences">
<section className="settings-section panel" aria-labelledby="display-time-heading">
<header>
<Icon name="clock" />
<div>
<h2 id="display-time-heading">Display & time</h2>
<p>Choose a stable forensic reference or a local reading context.</p>
</div>
</header>
<fieldset>
<legend>Timestamp display</legend>
<div className="preference-option-grid">
<OptionCard name="timestamp-display" title="UTC" description="Stable forensic reference time" checked={settings.timestampDisplay === "utc"} onChange={() => onChange({ timestampDisplay: "utc" })} />
<OptionCard name="timestamp-display" title="Local browser time" description="Useful for local investigation context" checked={settings.timestampDisplay === "local"} onChange={() => onChange({ timestampDisplay: "local" })} />
</div>
</fieldset>
<fieldset>
<legend>Interface density</legend>
<div className="preference-option-grid">
<OptionCard name="density" title="Comfortable" description="More breathing room for review" checked={settings.density === "comfortable"} onChange={() => onChange({ density: "comfortable" })} />
<OptionCard name="density" title="Compact" description="More evidence visible at once" checked={settings.density === "compact"} onChange={() => onChange({ density: "compact" })} />
</div>
</fieldset>
</section>
<section className="settings-section panel" aria-labelledby="investigation-defaults-heading">
<header>
<Icon name="analysis" />
<div>
<h2 id="investigation-defaults-heading">Investigation defaults</h2>
<p>New investigations use these defaults; matching display/export controls update immediately.</p>
</div>
</header>
<div className="preference-rows">
<label className="preference-row">
<span>
<strong>Informational noise</strong>
<small>Hide routine informational events by default and update the current Timeline filter.</small>
</span>
<select aria-label="Default noise mode" value={settings.defaultNoise} onChange={(event) => onChange({ defaultNoise: event.target.value as UiSettings["defaultNoise"] })}>
<option value="attention">Hide by default</option>
<option value="all">Show by default</option>
</select>
</label>
<label className="preference-row">
<span>
<strong>Default workspace</strong>
<small>Workspace opened when EventLens starts without a deep link.</small>
</span>
<select aria-label="Default workspace" value={settings.defaultWorkspace} onChange={(event) => onChange({ defaultWorkspace: event.target.value as UiSettings["defaultWorkspace"] })}>
{WORKSPACES.map((workspace) => <option key={workspace} value={workspace}>{workspace[0].toUpperCase() + workspace.slice(1)}</option>)}
</select>
</label>
<label className="preference-row preference-switch-row">
<span>
<strong>Export redaction</strong>
<small>Recommended · Redaction remains best effort and should be reviewed.</small>
</span>
<span className="preference-switch">
<input className="sr-only" type="checkbox" role="switch" aria-label="Export redaction by default" checked={settings.defaultRedaction} onChange={(event) => onChange({ defaultRedaction: event.target.checked })} />
<span className="preference-switch-track" aria-hidden="true"><span /></span>
<b aria-hidden="true">{settings.defaultRedaction ? "On" : "Off"}</b>
</span>
</label>
</div>
</section>
</div>
<aside className="settings-privacy-panel panel" aria-labelledby="local-privacy-heading">
<header>
<Icon name="privacy" />
<div>
<p className="eyebrow">{storageFailed ? "Session only" : "Browser-local only"}</p>
<h2 id="local-privacy-heading">Local data & privacy</h2>
</div>
</header>
<p className="privacy-lead">{storageFailed ? "Preferences are available for this session only because browser storage is unavailable." : "Only your interface preferences remain in this browser."}</p>
<div className="privacy-facts">
<section>
<strong>{storageFailed ? "Not saved to browser storage" : "Stored locally"}</strong>
<span>{storageFailed ? "UI preferences for this session only" : "UI preferences only"}</span>
</section>
<section>
<strong>Never stored there</strong>
<ul>
<li>Event text</li>
<li>Analysis results</li>
<li>Uploaded EVTX files</li>
<li>Incident evidence</li>
</ul>
</section>
</div>
<details className="settings-technical-details">
<summary>Technical details</summary>
<p>{storageFailed ? "When browser storage is available, preferences use this validated record:" : "The browser uses one validated preference record:"}</p>
<code>eventlens.preferences.v1</code>
</details>
<button type="button" className="secondary-button settings-reset" onClick={onReset}>
<Icon name="reset" />Reset all preferences
</button>
</aside>
</div>
</>
);
}
+9
View File
@@ -0,0 +1,9 @@
import type { Analysis, EventItem, Incident } from "../types";
import type { FilterState, InspectorTab, TimestampDisplay } from "../app/model";
import { EmptyState, WorkspaceHeader } from "../components/states";
import { EvidenceInspector, FilterToolbar, IncidentContext, TimelinePanel } from "../components/timeline";
export function TimelineView({ data, filters, providers, visibleEvents, selected, activeIncident, activeIncidentId, focusIncident, timestampDisplay, rowHeight, inspectorTab, onFilterChange, onResetFilters, onFocusChange, onSelectIncident, onSelectEvent, onMove, onTab, onOpenFile, onDemo }: { data: Analysis | null; filters: FilterState; providers: string[]; visibleEvents: EventItem[]; selected: EventItem | null; activeIncident: Incident | null; activeIncidentId: string; focusIncident: boolean; timestampDisplay: TimestampDisplay; rowHeight: number; inspectorTab: InspectorTab; onFilterChange: (patch: Partial<FilterState>) => void; onResetFilters: () => void; onFocusChange: (value: boolean) => void; onSelectIncident: (id: string) => void; onSelectEvent: (event: EventItem) => void; onMove: (delta: number) => void; onTab: (tab: InspectorTab) => void; onOpenFile: () => void; onDemo: () => void }) {
if (!data) return <><WorkspaceHeader eyebrow="Workspace 02 · Investigation" title="Timeline" description="Search and inspect normalized evidence in chronological context." /><EmptyState onOpen={onOpenFile} onDemo={onDemo} /></>;
return <><WorkspaceHeader eyebrow="Workspace 02 · Investigation" title="Timeline" description="The primary forensic workspace for event sequence, incident evidence and normalized details." /><IncidentContext incidents={data.incidents} activeIncidentId={activeIncidentId} focusIncident={focusIncident} onSelect={onSelectIncident} onFocus={onFocusChange} /><FilterToolbar filters={filters} providers={providers} visibleCount={visibleEvents.length} totalCount={data.events.length} anchorTimestamp={selected?.timestamp ?? activeIncident?.start ?? null} activeIncident={activeIncident} focusIncident={focusIncident} onFocusChange={onFocusChange} onChange={onFilterChange} onReset={onResetFilters} /><section className="timeline-layout"><TimelinePanel events={visibleEvents} selectedId={selected?.id ?? null} rowHeight={rowHeight} timestampDisplay={timestampDisplay} onSelect={onSelectEvent} /><EvidenceInspector data={data} event={selected} visibleEvents={visibleEvents} activeIncident={activeIncident} timestampDisplay={timestampDisplay} tab={inspectorTab} onTab={onTab} onSelectEvent={onSelectEvent} onMove={onMove} /></section></>;
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const version = readFileSync(resolve(fileURLToPath(new URL(".", import.meta.url)), "../VERSION"), "utf-8").trim();
export default defineConfig({
define: {
"import.meta.env.VITE_EVENTLENS_VERSION": JSON.stringify(version),
},
});
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
exclude: ["e2e/**", "node_modules/**", "dist/**"],
},
});