tokentrace 0.15.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/app/api/parser-debug/preview/route.ts +92 -0
- package/app/api/parser-overrides/route.ts +65 -0
- package/app/api/saved-reports/[id]/route.ts +20 -0
- package/app/api/saved-reports/route.ts +34 -0
- package/app/parser-debug/page.tsx +10 -0
- package/app/reports/page.tsx +19 -0
- package/components/parser-debug/parser-overrides-panel.tsx +205 -0
- package/components/period-filter.tsx +36 -38
- package/components/reports/saved-reports-panel.tsx +206 -0
- package/dist/runtime/agent.mjs +3880 -6
- package/dist/runtime/db-migrate.mjs +35 -0
- package/dist/runtime/db-seed.mjs +35 -0
- package/dist/runtime/digest.mjs +35 -0
- package/dist/runtime/doctor.mjs +35 -0
- package/dist/runtime/evidence.mjs +35 -0
- package/dist/runtime/insights.mjs +35 -0
- package/dist/runtime/mcp.mjs +30 -0
- package/dist/runtime/pricing-refresh.mjs +35 -0
- package/dist/runtime/repair.mjs +321 -87
- package/dist/runtime/report.mjs +487 -7
- package/dist/runtime/reset.mjs +35 -0
- package/dist/runtime/review.mjs +35 -0
- package/dist/runtime/scan.mjs +259 -29
- package/dist/runtime/status.mjs +35 -0
- package/package.json +1 -1
- package/scripts/agent.ts +70 -4
- package/scripts/repair.ts +26 -8
- package/scripts/report.ts +49 -0
- package/scripts/scan.ts +13 -0
- package/server.json +2 -2
- package/src/db/migrate-core.ts +35 -0
- package/src/ingestion/scan-adapters.ts +48 -2
- package/src/ingestion/scan.ts +16 -0
- package/src/lib/agent-actions.ts +138 -0
- package/src/lib/handoff.ts +129 -0
- package/src/lib/mcp/agent-guide.ts +5 -0
- package/src/lib/mcp/tools.ts +7 -0
- package/src/lib/mcp-server.ts +19 -0
- package/src/lib/parser-overrides-cli.ts +143 -0
- package/src/lib/parser-overrides.ts +110 -0
- package/src/lib/saved-report-runner.ts +171 -0
- package/src/lib/saved-reports-store.ts +189 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,55 @@ All notable changes to TokenTrace are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## [0.16.0] - 2026-05-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Parser overrides.** Force a specific parser for a file or exclude it from
|
|
12
|
+
future scans without editing source code. Override is honored by the next
|
|
13
|
+
scan via `selectAdapter`. New surfaces:
|
|
14
|
+
- Dashboard: `Parser overrides` panel on `/parser-debug` (list, add, clear).
|
|
15
|
+
- REST: `GET / POST / DELETE /api/parser-overrides`.
|
|
16
|
+
- REST: `POST /api/parser-debug/preview` returns predicted parse output for
|
|
17
|
+
an alternate parser without writing to the local database.
|
|
18
|
+
- CLI: `tokentrace repair set-parser <path> --parser <id> [--note "..."]`,
|
|
19
|
+
`--exclude` variant, and `clear-parser <path>` (all support `--json`).
|
|
20
|
+
- File paths are normalized to absolute form so scan-resolved paths match
|
|
21
|
+
user-supplied overrides, regardless of relative vs absolute input.
|
|
22
|
+
- **Saved reports.** Persisted local report templates that can be replayed
|
|
23
|
+
from the dashboard or the CLI. New surfaces:
|
|
24
|
+
- Dashboard: `/reports` page with create / list / delete.
|
|
25
|
+
- REST: `GET / POST /api/saved-reports`, `GET / DELETE /api/saved-reports/[id]`.
|
|
26
|
+
- CLI: `tokentrace report --saved "<name>" --format json|markdown|html`
|
|
27
|
+
and `tokentrace report --list-saved [--json]`.
|
|
28
|
+
- Standalone HTML report format escapes every user-supplied filter value
|
|
29
|
+
so opening or archiving the file is safe.
|
|
30
|
+
- Param keys are validated against an allow-list before persistence.
|
|
31
|
+
- **Agent handoff.** Structured envelope plus action log for multi-agent
|
|
32
|
+
workflows.
|
|
33
|
+
- CLI: `tokentrace agent --handoff [--json]` returns the
|
|
34
|
+
`tokentrace.handoff.v1` envelope (scan state, repair queue, confidence,
|
|
35
|
+
recent actions, suggested next actions). Pure read.
|
|
36
|
+
- CLI: `tokentrace agent --actions [--limit N] [--json]` reads the local
|
|
37
|
+
agent action log.
|
|
38
|
+
- MCP: new `get_handoff` tool returns the same envelope; added to the
|
|
39
|
+
recommended workflow in `get_agent_guide`.
|
|
40
|
+
- Action log is bounded to the last 500 entries (configurable via
|
|
41
|
+
`TOKENTRACE_AGENT_ACTION_LOG_MAX`), token-shaped strings are redacted
|
|
42
|
+
before write, and the writer is best-effort so logging never breaks the
|
|
43
|
+
CLI command it observes. `tokentrace scan` now writes a row on completion.
|
|
44
|
+
|
|
45
|
+
### Internal
|
|
46
|
+
|
|
47
|
+
- Three new local SQLite tables: `file_parser_overrides`, `saved_reports`,
|
|
48
|
+
`agent_actions`. All additive migrations.
|
|
49
|
+
|
|
50
|
+
## [0.15.2] - 2026-05-23
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- Period filter presets (Today / 7d / 30d / 60d / 90d / Month / All time) now navigate correctly when clicked on the production webpack build. Previously the preset `<Link>` elements were rendered inside the custom-date `<form>`, and under Next.js 16 + React 19 the form was eating Link clicks, leaving the URL and rendered data unchanged. The preset links are now siblings of the form rather than children.
|
|
55
|
+
|
|
7
56
|
## [0.15.1] - 2026-05-22
|
|
8
57
|
|
|
9
58
|
### Changed
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { NextResponse } from "next/server";
|
|
3
|
+
import { adapters } from "@/src/ingestion/adapters";
|
|
4
|
+
import type { FileCandidate, NormalizedInteraction } from "@/src/ingestion/types";
|
|
5
|
+
|
|
6
|
+
export const dynamic = "force-dynamic";
|
|
7
|
+
|
|
8
|
+
type PreviewBody = {
|
|
9
|
+
path?: unknown;
|
|
10
|
+
parserId?: unknown;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function POST(request: Request) {
|
|
14
|
+
let body: PreviewBody;
|
|
15
|
+
try {
|
|
16
|
+
body = (await request.json()) as PreviewBody;
|
|
17
|
+
} catch {
|
|
18
|
+
return NextResponse.json({ error: "Request body must be JSON." }, { status: 400 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const filePath = typeof body.path === "string" ? body.path.trim() : "";
|
|
22
|
+
const parserId = typeof body.parserId === "string" ? body.parserId.trim() : "";
|
|
23
|
+
|
|
24
|
+
if (!filePath) {
|
|
25
|
+
return NextResponse.json({ error: "path is required" }, { status: 400 });
|
|
26
|
+
}
|
|
27
|
+
if (!parserId) {
|
|
28
|
+
return NextResponse.json({ error: "parserId is required" }, { status: 400 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const adapter = adapters.find((candidate) => candidate.id === parserId);
|
|
32
|
+
if (!adapter) {
|
|
33
|
+
return NextResponse.json(
|
|
34
|
+
{ error: `parser ${parserId} is not registered` },
|
|
35
|
+
{ status: 404 }
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let stat;
|
|
40
|
+
try {
|
|
41
|
+
stat = await fs.stat(filePath);
|
|
42
|
+
} catch {
|
|
43
|
+
return NextResponse.json({ error: `file not found: ${filePath}` }, { status: 404 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const candidate: FileCandidate = {
|
|
47
|
+
path: filePath,
|
|
48
|
+
sizeBytes: stat.size,
|
|
49
|
+
modifiedTime: stat.mtime
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
let parseResult;
|
|
53
|
+
try {
|
|
54
|
+
parseResult = await adapter.parse(candidate, { storeRawMessageContent: false });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return NextResponse.json(
|
|
57
|
+
{
|
|
58
|
+
error: `parser ${parserId} threw while parsing: ${
|
|
59
|
+
error instanceof Error ? error.message : "Unknown error"
|
|
60
|
+
}`
|
|
61
|
+
},
|
|
62
|
+
{ status: 422 }
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const allInteractions: NormalizedInteraction[] = parseResult.sessions.flatMap(
|
|
67
|
+
(session) => session.interactions
|
|
68
|
+
);
|
|
69
|
+
const predictedTotalTokens = allInteractions.reduce(
|
|
70
|
+
(sum, interaction) => sum + (interaction.totalTokens ?? 0),
|
|
71
|
+
0
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return NextResponse.json({
|
|
75
|
+
parserId,
|
|
76
|
+
path: filePath,
|
|
77
|
+
sessions: parseResult.sessions.map((session) => ({
|
|
78
|
+
externalId: session.externalId,
|
|
79
|
+
provider: session.provider,
|
|
80
|
+
tool: session.tool,
|
|
81
|
+
interactionCount: session.interactions.length,
|
|
82
|
+
totalTokens: session.interactions.reduce(
|
|
83
|
+
(sum, interaction) => sum + (interaction.totalTokens ?? 0),
|
|
84
|
+
0
|
|
85
|
+
)
|
|
86
|
+
})),
|
|
87
|
+
predictedInteractions: allInteractions.length,
|
|
88
|
+
predictedTotalTokens,
|
|
89
|
+
warnings: parseResult.warnings,
|
|
90
|
+
errors: parseResult.errors
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
clearParserOverride,
|
|
4
|
+
listParserOverrides,
|
|
5
|
+
setParserOverride
|
|
6
|
+
} from "@/src/lib/parser-overrides";
|
|
7
|
+
|
|
8
|
+
export const dynamic = "force-dynamic";
|
|
9
|
+
|
|
10
|
+
type WriteBody = {
|
|
11
|
+
path?: unknown;
|
|
12
|
+
parserId?: unknown;
|
|
13
|
+
excluded?: unknown;
|
|
14
|
+
note?: unknown;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export async function GET() {
|
|
18
|
+
return NextResponse.json({ overrides: listParserOverrides() });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function POST(request: Request) {
|
|
22
|
+
let body: WriteBody;
|
|
23
|
+
try {
|
|
24
|
+
body = (await request.json()) as WriteBody;
|
|
25
|
+
} catch {
|
|
26
|
+
return NextResponse.json({ error: "Request body must be JSON." }, { status: 400 });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const path = typeof body.path === "string" ? body.path.trim() : "";
|
|
30
|
+
if (!path) return NextResponse.json({ error: "path is required" }, { status: 400 });
|
|
31
|
+
|
|
32
|
+
const excluded = body.excluded === true;
|
|
33
|
+
const parserId =
|
|
34
|
+
!excluded && typeof body.parserId === "string" && body.parserId.trim()
|
|
35
|
+
? body.parserId.trim()
|
|
36
|
+
: undefined;
|
|
37
|
+
const note = typeof body.note === "string" && body.note.trim() ? body.note.trim() : undefined;
|
|
38
|
+
|
|
39
|
+
if (!excluded && !parserId) {
|
|
40
|
+
return NextResponse.json({ error: "parserId or excluded must be provided" }, { status: 400 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const override = setParserOverride({ path, parserId, excluded, note });
|
|
45
|
+
return NextResponse.json({ override });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return NextResponse.json(
|
|
48
|
+
{ error: error instanceof Error ? error.message : "Failed to set parser override" },
|
|
49
|
+
{ status: 400 }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function DELETE(request: Request) {
|
|
55
|
+
let body: { path?: unknown };
|
|
56
|
+
try {
|
|
57
|
+
body = (await request.json()) as { path?: unknown };
|
|
58
|
+
} catch {
|
|
59
|
+
return NextResponse.json({ error: "Request body must be JSON." }, { status: 400 });
|
|
60
|
+
}
|
|
61
|
+
const path = typeof body.path === "string" ? body.path.trim() : "";
|
|
62
|
+
if (!path) return NextResponse.json({ error: "path is required" }, { status: 400 });
|
|
63
|
+
const removed = clearParserOverride(path);
|
|
64
|
+
return NextResponse.json({ removed, path });
|
|
65
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { deleteSavedReport, findSavedReportById } from "@/src/lib/saved-reports-store";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
|
|
6
|
+
type RouteContext = { params: Promise<{ id: string }> };
|
|
7
|
+
|
|
8
|
+
export async function GET(_request: Request, context: RouteContext) {
|
|
9
|
+
const { id } = await context.params;
|
|
10
|
+
const report = findSavedReportById(id);
|
|
11
|
+
if (!report) return NextResponse.json({ error: "saved report not found" }, { status: 404 });
|
|
12
|
+
return NextResponse.json({ report });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function DELETE(_request: Request, context: RouteContext) {
|
|
16
|
+
const { id } = await context.params;
|
|
17
|
+
const removed = deleteSavedReport(id);
|
|
18
|
+
if (!removed) return NextResponse.json({ error: "saved report not found" }, { status: 404 });
|
|
19
|
+
return NextResponse.json({ removed: true, id });
|
|
20
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { createSavedReport, listSavedReports } from "@/src/lib/saved-reports-store";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
|
|
6
|
+
export async function GET() {
|
|
7
|
+
return NextResponse.json({ reports: listSavedReports() });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function POST(request: Request) {
|
|
11
|
+
let body: Record<string, unknown>;
|
|
12
|
+
try {
|
|
13
|
+
body = (await request.json()) as Record<string, unknown>;
|
|
14
|
+
} catch {
|
|
15
|
+
return NextResponse.json({ error: "Request body must be JSON." }, { status: 400 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
19
|
+
const viewType = typeof body.viewType === "string" ? body.viewType : "";
|
|
20
|
+
const format = typeof body.format === "string" ? body.format : undefined;
|
|
21
|
+
const params =
|
|
22
|
+
body.params && typeof body.params === "object" && !Array.isArray(body.params)
|
|
23
|
+
? (body.params as Record<string, string | number | boolean>)
|
|
24
|
+
: undefined;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const report = createSavedReport({ name, viewType, format, params });
|
|
28
|
+
return NextResponse.json({ report }, { status: 201 });
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const message = error instanceof Error ? error.message : "Failed to create saved report";
|
|
31
|
+
const status = /already exists/i.test(message) ? 409 : 400;
|
|
32
|
+
return NextResponse.json({ error: message }, { status });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -4,7 +4,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|
|
4
4
|
import { MonoText, PageHeader } from "@/components/ui/typography";
|
|
5
5
|
import { EmptyState } from "@/components/empty-state";
|
|
6
6
|
import { ScanNowButton } from "@/components/scan-now-button";
|
|
7
|
+
import { ParserOverridesPanel } from "@/components/parser-debug/parser-overrides-panel";
|
|
7
8
|
import { getScanTrustData } from "@/src/lib/analytics";
|
|
9
|
+
import { adapters } from "@/src/ingestion/adapters";
|
|
10
|
+
import { listParserOverrides } from "@/src/lib/parser-overrides";
|
|
8
11
|
|
|
9
12
|
export const dynamic = "force-dynamic";
|
|
10
13
|
|
|
@@ -16,6 +19,11 @@ export default async function ParserDebugPage({
|
|
|
16
19
|
const params = await searchParams;
|
|
17
20
|
const selectedSource = params?.source;
|
|
18
21
|
const { scanFiles, health } = getScanTrustData();
|
|
22
|
+
const initialOverrides = listParserOverrides();
|
|
23
|
+
const parserOptions = adapters.map((adapter) => ({
|
|
24
|
+
id: adapter.id,
|
|
25
|
+
displayName: adapter.displayName
|
|
26
|
+
}));
|
|
19
27
|
const visibleScanFiles = selectedSource
|
|
20
28
|
? scanFiles.filter((file) => file.path === selectedSource)
|
|
21
29
|
: scanFiles.slice(0, 500);
|
|
@@ -30,6 +38,8 @@ export default async function ParserDebugPage({
|
|
|
30
38
|
description="Inspect adapter selection, parser confidence, extracted tokens, warnings, and failures."
|
|
31
39
|
/>
|
|
32
40
|
|
|
41
|
+
<ParserOverridesPanel initialOverrides={initialOverrides} parsers={parserOptions} />
|
|
42
|
+
|
|
33
43
|
<div className="rounded-md border bg-card p-3">
|
|
34
44
|
<div className="text-sm font-medium">Latest parser mix</div>
|
|
35
45
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { PageHeader } from "@/components/ui/typography";
|
|
2
|
+
import { SavedReportsPanel } from "@/components/reports/saved-reports-panel";
|
|
3
|
+
import { listSavedReports } from "@/src/lib/saved-reports-store";
|
|
4
|
+
|
|
5
|
+
export const dynamic = "force-dynamic";
|
|
6
|
+
|
|
7
|
+
export default async function ReportsPage() {
|
|
8
|
+
const reports = listSavedReports();
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="space-y-6">
|
|
12
|
+
<PageHeader
|
|
13
|
+
title="Reports"
|
|
14
|
+
description="Save reusable local report templates and replay them from the CLI."
|
|
15
|
+
/>
|
|
16
|
+
<SavedReportsPanel initial={reports} />
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useTransition } from "react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Input } from "@/components/ui/input";
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { Badge } from "@/components/ui/badge";
|
|
8
|
+
import { MonoText } from "@/components/ui/typography";
|
|
9
|
+
import type { ParserOverride } from "@/src/lib/parser-overrides";
|
|
10
|
+
|
|
11
|
+
type ParserOption = { id: string; displayName: string };
|
|
12
|
+
|
|
13
|
+
type Props = {
|
|
14
|
+
initialOverrides: ParserOverride[];
|
|
15
|
+
parsers: ParserOption[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function ParserOverridesPanel({ initialOverrides, parsers }: Props) {
|
|
19
|
+
const [overrides, setOverrides] = useState<ParserOverride[]>(initialOverrides);
|
|
20
|
+
const [adding, setAdding] = useState(false);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
const [busy, startTransition] = useTransition();
|
|
23
|
+
|
|
24
|
+
const [path, setPath] = useState("");
|
|
25
|
+
const [parserId, setParserId] = useState(parsers[0]?.id ?? "");
|
|
26
|
+
const [excluded, setExcluded] = useState(false);
|
|
27
|
+
const [note, setNote] = useState("");
|
|
28
|
+
|
|
29
|
+
function resetForm() {
|
|
30
|
+
setPath("");
|
|
31
|
+
setParserId(parsers[0]?.id ?? "");
|
|
32
|
+
setExcluded(false);
|
|
33
|
+
setNote("");
|
|
34
|
+
setError(null);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function submit() {
|
|
38
|
+
setError(null);
|
|
39
|
+
const trimmed = path.trim();
|
|
40
|
+
if (!trimmed) {
|
|
41
|
+
setError("Path is required.");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const payload: {
|
|
45
|
+
path: string;
|
|
46
|
+
parserId?: string;
|
|
47
|
+
excluded?: boolean;
|
|
48
|
+
note?: string;
|
|
49
|
+
} = { path: trimmed };
|
|
50
|
+
if (excluded) payload.excluded = true;
|
|
51
|
+
else payload.parserId = parserId;
|
|
52
|
+
if (note.trim()) payload.note = note.trim();
|
|
53
|
+
|
|
54
|
+
startTransition(async () => {
|
|
55
|
+
const response = await fetch("/api/parser-overrides", {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "content-type": "application/json" },
|
|
58
|
+
body: JSON.stringify(payload)
|
|
59
|
+
});
|
|
60
|
+
const body = await response.json().catch(() => ({}));
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
setError(body.error ?? "Failed to set parser override.");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
setOverrides((prev) => {
|
|
66
|
+
const without = prev.filter((row) => row.path !== body.override.path);
|
|
67
|
+
return [body.override, ...without];
|
|
68
|
+
});
|
|
69
|
+
resetForm();
|
|
70
|
+
setAdding(false);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function clear(rowPath: string) {
|
|
75
|
+
setError(null);
|
|
76
|
+
startTransition(async () => {
|
|
77
|
+
const response = await fetch("/api/parser-overrides", {
|
|
78
|
+
method: "DELETE",
|
|
79
|
+
headers: { "content-type": "application/json" },
|
|
80
|
+
body: JSON.stringify({ path: rowPath })
|
|
81
|
+
});
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
const body = await response.json().catch(() => ({}));
|
|
84
|
+
setError(body.error ?? "Failed to clear override.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
setOverrides((prev) => prev.filter((row) => row.path !== rowPath));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Card>
|
|
93
|
+
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
|
94
|
+
<div>
|
|
95
|
+
<CardTitle>Parser overrides</CardTitle>
|
|
96
|
+
<CardDescription>
|
|
97
|
+
Force a specific parser for a file or exclude it from future scans.
|
|
98
|
+
</CardDescription>
|
|
99
|
+
</div>
|
|
100
|
+
<Button
|
|
101
|
+
type="button"
|
|
102
|
+
size="sm"
|
|
103
|
+
variant={adding ? "outline" : "default"}
|
|
104
|
+
onClick={() => setAdding((value) => !value)}
|
|
105
|
+
>
|
|
106
|
+
{adding ? "Cancel" : "Add override"}
|
|
107
|
+
</Button>
|
|
108
|
+
</CardHeader>
|
|
109
|
+
<CardContent className="space-y-4">
|
|
110
|
+
{error ? (
|
|
111
|
+
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
|
|
112
|
+
{error}
|
|
113
|
+
</div>
|
|
114
|
+
) : null}
|
|
115
|
+
|
|
116
|
+
{adding ? (
|
|
117
|
+
<div className="space-y-3 rounded-md border bg-muted/30 p-3">
|
|
118
|
+
<label className="block space-y-1 text-sm">
|
|
119
|
+
<span className="font-medium">File path</span>
|
|
120
|
+
<Input
|
|
121
|
+
value={path}
|
|
122
|
+
onChange={(event) => setPath(event.target.value)}
|
|
123
|
+
placeholder="/absolute/path/to/file.jsonl"
|
|
124
|
+
spellCheck={false}
|
|
125
|
+
/>
|
|
126
|
+
</label>
|
|
127
|
+
<label className="flex items-center gap-2 text-sm">
|
|
128
|
+
<input
|
|
129
|
+
type="checkbox"
|
|
130
|
+
checked={excluded}
|
|
131
|
+
onChange={(event) => setExcluded(event.target.checked)}
|
|
132
|
+
/>
|
|
133
|
+
<span>Exclude this file from scans</span>
|
|
134
|
+
</label>
|
|
135
|
+
{!excluded ? (
|
|
136
|
+
<label className="block space-y-1 text-sm">
|
|
137
|
+
<span className="font-medium">Parser</span>
|
|
138
|
+
<select
|
|
139
|
+
value={parserId}
|
|
140
|
+
onChange={(event) => setParserId(event.target.value)}
|
|
141
|
+
className="h-9 w-full rounded-md border bg-card px-2 text-sm"
|
|
142
|
+
>
|
|
143
|
+
{parsers.map((parser) => (
|
|
144
|
+
<option key={parser.id} value={parser.id}>
|
|
145
|
+
{parser.displayName} ({parser.id})
|
|
146
|
+
</option>
|
|
147
|
+
))}
|
|
148
|
+
</select>
|
|
149
|
+
</label>
|
|
150
|
+
) : null}
|
|
151
|
+
<label className="block space-y-1 text-sm">
|
|
152
|
+
<span className="font-medium">Note (optional)</span>
|
|
153
|
+
<Input
|
|
154
|
+
value={note}
|
|
155
|
+
onChange={(event) => setNote(event.target.value)}
|
|
156
|
+
placeholder="Why this override exists"
|
|
157
|
+
/>
|
|
158
|
+
</label>
|
|
159
|
+
<div className="flex justify-end gap-2">
|
|
160
|
+
<Button type="button" variant="outline" size="sm" onClick={() => setAdding(false)} disabled={busy}>
|
|
161
|
+
Cancel
|
|
162
|
+
</Button>
|
|
163
|
+
<Button type="button" size="sm" onClick={submit} disabled={busy}>
|
|
164
|
+
{busy ? "Saving…" : "Save override"}
|
|
165
|
+
</Button>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
) : null}
|
|
169
|
+
|
|
170
|
+
{overrides.length === 0 ? (
|
|
171
|
+
<p className="text-sm text-muted-foreground">No parser overrides set.</p>
|
|
172
|
+
) : (
|
|
173
|
+
<ul className="divide-y rounded-md border">
|
|
174
|
+
{overrides.map((override) => (
|
|
175
|
+
<li key={override.path} className="flex flex-col gap-2 p-3 sm:flex-row sm:items-start sm:justify-between">
|
|
176
|
+
<div className="min-w-0">
|
|
177
|
+
<MonoText className="break-all text-sm">{override.path}</MonoText>
|
|
178
|
+
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
|
179
|
+
{override.excluded ? (
|
|
180
|
+
<Badge variant="warning">Excluded</Badge>
|
|
181
|
+
) : (
|
|
182
|
+
<Badge variant="outline">{override.parserId}</Badge>
|
|
183
|
+
)}
|
|
184
|
+
{override.note ? (
|
|
185
|
+
<span className="text-muted-foreground">{override.note}</span>
|
|
186
|
+
) : null}
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
<Button
|
|
190
|
+
type="button"
|
|
191
|
+
size="sm"
|
|
192
|
+
variant="outline"
|
|
193
|
+
onClick={() => clear(override.path)}
|
|
194
|
+
disabled={busy}
|
|
195
|
+
>
|
|
196
|
+
Clear
|
|
197
|
+
</Button>
|
|
198
|
+
</li>
|
|
199
|
+
))}
|
|
200
|
+
</ul>
|
|
201
|
+
)}
|
|
202
|
+
</CardContent>
|
|
203
|
+
</Card>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -72,47 +72,45 @@ export function PeriodFilter({
|
|
|
72
72
|
|
|
73
73
|
return (
|
|
74
74
|
<div className="min-w-0 max-w-full rounded-lg bg-card p-3 outline-solid outline-1 outline-border sm:p-4">
|
|
75
|
-
<
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
<span>Period</span>
|
|
84
|
-
<span className="hidden shrink-0 whitespace-nowrap rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground 2xl:inline-flex">
|
|
85
|
-
{statusLabel}
|
|
86
|
-
</span>
|
|
87
|
-
</div>
|
|
88
|
-
|
|
89
|
-
<div className="period-preset-scroll -mx-3 min-w-0 flex-1 overflow-x-auto px-3 sm:mx-0 sm:px-0 md:overflow-visible">
|
|
90
|
-
<div className="flex w-max items-center gap-1.5 pr-1 md:w-auto md:flex-wrap">
|
|
91
|
-
{dateRangeOptions.map((option) => (
|
|
92
|
-
<Button
|
|
93
|
-
key={option.key}
|
|
94
|
-
asChild
|
|
95
|
-
size="sm"
|
|
96
|
-
variant={range.key === option.key ? "default" : "outline"}
|
|
97
|
-
className="px-2.5"
|
|
98
|
-
>
|
|
99
|
-
<Link href={rangeHref(option.key, basePath, preserveParams)}>
|
|
100
|
-
{compactOptionLabel(option.key, option.label)}
|
|
101
|
-
</Link>
|
|
102
|
-
</Button>
|
|
103
|
-
))}
|
|
104
|
-
</div>
|
|
105
|
-
</div>
|
|
75
|
+
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-3 lg:flex-nowrap">
|
|
76
|
+
<div className="flex shrink-0 items-center gap-2 text-sm font-semibold">
|
|
77
|
+
<CalendarDays className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
78
|
+
<span>Period</span>
|
|
79
|
+
<span className="hidden shrink-0 whitespace-nowrap rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground 2xl:inline-flex">
|
|
80
|
+
{statusLabel}
|
|
81
|
+
</span>
|
|
82
|
+
</div>
|
|
106
83
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
84
|
+
<div className="period-preset-scroll -mx-3 min-w-0 flex-1 overflow-x-auto px-3 sm:mx-0 sm:px-0 md:overflow-visible">
|
|
85
|
+
<div className="flex w-max items-center gap-1.5 pr-1 md:w-auto md:flex-wrap">
|
|
86
|
+
{dateRangeOptions.map((option) => (
|
|
87
|
+
<Button
|
|
88
|
+
key={option.key}
|
|
89
|
+
asChild
|
|
90
|
+
size="sm"
|
|
91
|
+
variant={range.key === option.key ? "default" : "outline"}
|
|
92
|
+
className="px-2.5"
|
|
93
|
+
>
|
|
94
|
+
<Link href={rangeHref(option.key, basePath, preserveParams)}>
|
|
95
|
+
{compactOptionLabel(option.key, option.label)}
|
|
96
|
+
</Link>
|
|
97
|
+
</Button>
|
|
98
|
+
))}
|
|
113
99
|
</div>
|
|
114
100
|
</div>
|
|
115
|
-
|
|
101
|
+
|
|
102
|
+
<form className="period-custom-row ml-auto flex min-w-0 flex-wrap items-center gap-2 shrink-0 lg:flex-nowrap" action={basePath}>
|
|
103
|
+
<input type="hidden" name="range" value="custom" />
|
|
104
|
+
{Object.entries(preserveParams).map(([key, value]) =>
|
|
105
|
+
value ? <input key={key} type="hidden" name={key} value={value} /> : null
|
|
106
|
+
)}
|
|
107
|
+
<PeriodDateField label="From" name="from" defaultValue={range.fromInput} />
|
|
108
|
+
<PeriodDateField label="To" name="to" defaultValue={range.toInput} />
|
|
109
|
+
<Button size="sm" type="submit" variant={range.key === "custom" ? "default" : "outline"}>
|
|
110
|
+
Apply
|
|
111
|
+
</Button>
|
|
112
|
+
</form>
|
|
113
|
+
</div>
|
|
116
114
|
</div>
|
|
117
115
|
);
|
|
118
116
|
}
|