@git-snitch/renderer 0.0.9 → 0.0.11
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/dist/charts.d.ts +13 -1
- package/dist/charts.d.ts.map +1 -1
- package/dist/charts.js +10 -0
- package/dist/template/report-template.html +23 -23
- package/package.json +3 -3
- package/src/ai-usage.tsx +119 -0
- package/src/charts-route.tsx +13 -0
- package/src/charts.tsx +27 -1
- package/src/overview.tsx +3 -0
- package/src/scan-routes.tsx +41 -12
- package/vite.config.ts +25 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@git-snitch/renderer",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"react-dom": "^19.2.5",
|
|
36
36
|
"recharts": "3.8.0",
|
|
37
37
|
"vite": "^8.0.8",
|
|
38
|
-
"@git-snitch/core": "0.0.
|
|
39
|
-
"@git-snitch/ui": "0.0.
|
|
38
|
+
"@git-snitch/core": "0.0.11",
|
|
39
|
+
"@git-snitch/ui": "0.0.11"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@testing-library/dom": "^10.4.1",
|
package/src/ai-usage.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@git-snitch/ui/components/card";
|
|
2
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@git-snitch/ui/components/table";
|
|
3
|
+
|
|
4
|
+
import type { AiUsageBreakdownItem, AiUsageSummary, ReportAiUsageProjectSummary } from "@git-snitch/core";
|
|
5
|
+
|
|
6
|
+
import { EmptyState } from "./empty-state.js";
|
|
7
|
+
|
|
8
|
+
type AiUsagePanelProps = {
|
|
9
|
+
readonly title?: string;
|
|
10
|
+
readonly description?: string;
|
|
11
|
+
readonly usage: AiUsageSummary | ReportAiUsageProjectSummary;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function formatNumber(value: number) {
|
|
15
|
+
return new Intl.NumberFormat("en").format(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatAiUsageCost(value: number) {
|
|
19
|
+
return new Intl.NumberFormat("en", { style: "currency", currency: "USD", maximumFractionDigits: 4 }).format(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatAiUsageTokens(value: number) {
|
|
23
|
+
return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasBreakdowns(usage: AiUsageSummary | ReportAiUsageProjectSummary): usage is ReportAiUsageProjectSummary {
|
|
27
|
+
return "breakdowns" in usage;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function usageHasTotals(usage: AiUsageSummary) {
|
|
31
|
+
return usage.records > 0 || usage.tokens.total > 0 || usage.cost > 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function SummaryMetric({ label, value, description }: { readonly label: string; readonly value: string; readonly description: string }) {
|
|
35
|
+
return (
|
|
36
|
+
<div className="rounded-xl border border-border/70 bg-background/70 p-4">
|
|
37
|
+
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">{label}</p>
|
|
38
|
+
<p className="mt-2 text-2xl font-semibold tracking-tight text-foreground">{value}</p>
|
|
39
|
+
<p className="mt-1 text-xs leading-5 text-muted-foreground">{description}</p>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function BreakdownTable({ title, rows, emptyDescription }: { readonly title: string; readonly rows: readonly AiUsageBreakdownItem[]; readonly emptyDescription: string }) {
|
|
45
|
+
const visibleRows = rows.slice(0, 8);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="rounded-xl border border-border/70 bg-background/70">
|
|
49
|
+
<div className="border-b border-border/70 px-4 py-3">
|
|
50
|
+
<h4 className="text-sm font-semibold text-foreground">{title}</h4>
|
|
51
|
+
</div>
|
|
52
|
+
{visibleRows.length === 0 ? (
|
|
53
|
+
<div className="p-4">
|
|
54
|
+
<EmptyState title={`${title} unavailable`} description={emptyDescription} />
|
|
55
|
+
</div>
|
|
56
|
+
) : (
|
|
57
|
+
<Table aria-label={title}>
|
|
58
|
+
<TableHeader>
|
|
59
|
+
<TableRow className="hover:bg-transparent">
|
|
60
|
+
<TableHead className="bg-muted/30 px-3 py-2 text-[0.7rem] uppercase tracking-[0.18em] text-muted-foreground">Name</TableHead>
|
|
61
|
+
<TableHead className="bg-muted/30 px-3 py-2 text-[0.7rem] uppercase tracking-[0.18em] text-muted-foreground">Messages</TableHead>
|
|
62
|
+
<TableHead className="bg-muted/30 px-3 py-2 text-[0.7rem] uppercase tracking-[0.18em] text-muted-foreground">Tokens</TableHead>
|
|
63
|
+
<TableHead className="bg-muted/30 px-3 py-2 text-[0.7rem] uppercase tracking-[0.18em] text-muted-foreground">Cost</TableHead>
|
|
64
|
+
</TableRow>
|
|
65
|
+
</TableHeader>
|
|
66
|
+
<TableBody>
|
|
67
|
+
{visibleRows.map((row) => (
|
|
68
|
+
<TableRow key={row.key}>
|
|
69
|
+
<TableCell className="px-3 py-3 text-sm font-medium text-foreground">{row.key}</TableCell>
|
|
70
|
+
<TableCell className="px-3 py-3 text-sm text-muted-foreground">{formatNumber(row.records)}</TableCell>
|
|
71
|
+
<TableCell className="px-3 py-3 text-sm text-muted-foreground">{formatNumber(row.tokens.total)}</TableCell>
|
|
72
|
+
<TableCell className="px-3 py-3 text-sm text-muted-foreground">{formatAiUsageCost(row.cost)}</TableCell>
|
|
73
|
+
</TableRow>
|
|
74
|
+
))}
|
|
75
|
+
</TableBody>
|
|
76
|
+
</Table>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function AiUsagePanel({
|
|
83
|
+
title = "AI usage",
|
|
84
|
+
description = "Local assistant usage matched to this report. Workspace paths are not rendered in the HTML report.",
|
|
85
|
+
usage,
|
|
86
|
+
}: AiUsagePanelProps) {
|
|
87
|
+
const hasTotals = usageHasTotals(usage);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<Card className="overflow-hidden shadow-none transition-transform duration-500 ease-out hover:-translate-y-0.5" aria-label={title}>
|
|
91
|
+
<CardHeader className="space-y-2">
|
|
92
|
+
<CardTitle className="text-base font-semibold tracking-tight text-foreground">{title}</CardTitle>
|
|
93
|
+
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
|
|
94
|
+
</CardHeader>
|
|
95
|
+
<CardContent className="grid gap-4">
|
|
96
|
+
<div className="grid grid-flow-dense gap-3 sm:grid-cols-3">
|
|
97
|
+
<SummaryMetric label="Total tokens" value={formatNumber(usage.tokens.total)} description="Input, output, cache, and reasoning tokens." />
|
|
98
|
+
<SummaryMetric label="Estimated cost" value={formatAiUsageCost(usage.cost)} description="USD estimate reported or derived from local logs." />
|
|
99
|
+
<SummaryMetric label="Messages" value={formatNumber(usage.records)} description="Matched assistant usage records." />
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{!hasTotals ? (
|
|
103
|
+
<EmptyState
|
|
104
|
+
title="No AI usage matched this report"
|
|
105
|
+
description="AI usage collection was enabled, but the matched local logs contain zero messages, tokens, and estimated cost for this report scope."
|
|
106
|
+
/>
|
|
107
|
+
) : null}
|
|
108
|
+
|
|
109
|
+
{hasBreakdowns(usage) ? (
|
|
110
|
+
<div className="grid grid-flow-dense gap-4 xl:grid-cols-3">
|
|
111
|
+
<BreakdownTable title="Client breakdown" rows={usage.breakdowns.byClient} emptyDescription="No client-level AI usage rows were available." />
|
|
112
|
+
<BreakdownTable title="Model breakdown" rows={usage.breakdowns.byModel} emptyDescription="No model-level AI usage rows were available." />
|
|
113
|
+
<BreakdownTable title="Recent days" rows={usage.breakdowns.byDay} emptyDescription="No dated AI usage rows were available." />
|
|
114
|
+
</div>
|
|
115
|
+
) : null}
|
|
116
|
+
</CardContent>
|
|
117
|
+
</Card>
|
|
118
|
+
);
|
|
119
|
+
}
|
package/src/charts-route.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import type { RepoReportData, ReportData } from "@git-snitch/core";
|
|
|
6
6
|
import {
|
|
7
7
|
ActivityHeatmap,
|
|
8
8
|
AdditionsVsDeletionsChart,
|
|
9
|
+
AiUsageBreakdownChart,
|
|
9
10
|
CodeOwnershipChart,
|
|
10
11
|
CommitActivityChart,
|
|
11
12
|
CommitSizeDistributionChart,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
WeeklyActivityChart,
|
|
18
19
|
deriveActivityHeatmapData,
|
|
19
20
|
deriveAdditionsVsDeletionsData,
|
|
21
|
+
deriveAiUsageBreakdownData,
|
|
20
22
|
deriveCodeOwnershipData,
|
|
21
23
|
deriveCommitActivityData,
|
|
22
24
|
deriveCommitSizeDistributionData,
|
|
@@ -112,6 +114,8 @@ export function ChartsRoute({ report }: ChartsRouteProps) {
|
|
|
112
114
|
const timeOfDay = deriveTimeOfDayData(report.commits);
|
|
113
115
|
const contributorShare = deriveContributorPieData(report.contributors);
|
|
114
116
|
const weeklyActivity = deriveWeeklyActivityData(report.commits);
|
|
117
|
+
const aiUsageByModel = deriveAiUsageBreakdownData(report.aiUsage?.breakdowns.byModel ?? []);
|
|
118
|
+
const aiUsageByHarness = deriveAiUsageBreakdownData(report.aiUsage?.breakdowns.byClient ?? []);
|
|
115
119
|
const showContributorShare = contributorShare.length > 1;
|
|
116
120
|
|
|
117
121
|
return (
|
|
@@ -153,6 +157,15 @@ export function ChartsRoute({ report }: ChartsRouteProps) {
|
|
|
153
157
|
<ContributionCalendar data={calendar} />
|
|
154
158
|
</div>
|
|
155
159
|
</SectionFrame>
|
|
160
|
+
|
|
161
|
+
{report.aiUsage !== undefined ? (
|
|
162
|
+
<SectionFrame title="AI usage" description="Model and harness usage from matched local assistant logs. These charts stay scoped to this repository report payload.">
|
|
163
|
+
<div className="grid grid-flow-dense gap-5 lg:grid-cols-2">
|
|
164
|
+
<AiUsageBreakdownChart title="AI usage by model" description="Matched assistant tokens and messages grouped by model." data={aiUsageByModel} />
|
|
165
|
+
<AiUsageBreakdownChart title="AI usage by harness" description="Matched assistant tokens and messages grouped by local harness or client." data={aiUsageByHarness} />
|
|
166
|
+
</div>
|
|
167
|
+
</SectionFrame>
|
|
168
|
+
) : null}
|
|
156
169
|
</div>
|
|
157
170
|
);
|
|
158
171
|
}
|
package/src/charts.tsx
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "recharts";
|
|
22
22
|
import type { ReactNode } from "react";
|
|
23
23
|
|
|
24
|
-
import type { CommitRecord, ContributorSummary, RepoReportData, ScanProjectReport, ScanReportData } from "@git-snitch/core";
|
|
24
|
+
import type { AiUsageBreakdownItem, CommitRecord, ContributorSummary, RepoReportData, ScanProjectReport, ScanReportData } from "@git-snitch/core";
|
|
25
25
|
|
|
26
26
|
import { EmptyState } from "./empty-state.js";
|
|
27
27
|
|
|
@@ -42,6 +42,7 @@ export type VelocityPoint = { readonly period: string; readonly commits: number;
|
|
|
42
42
|
export type CodeOwnershipPoint = { readonly owner: string; readonly additions: number; readonly deletions: number; readonly filesChanged: number };
|
|
43
43
|
export type ProjectComparisonPoint = { readonly project: string; readonly commits: number; readonly contributors: number; readonly filesChanged: number };
|
|
44
44
|
export type ActivityHeatmapCell = { readonly day: string; readonly hour: string; readonly commits: number };
|
|
45
|
+
export type AiUsageBreakdownPoint = { readonly name: string; readonly messages: number; readonly tokens: number; readonly cost: number };
|
|
45
46
|
|
|
46
47
|
type ChartPanelProps = {
|
|
47
48
|
readonly title: string;
|
|
@@ -213,6 +214,14 @@ export function deriveProjectsComparisonData(projects: readonly ScanProjectRepor
|
|
|
213
214
|
.sort((left, right) => right.commits - left.commits || left.project.localeCompare(right.project));
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
export function deriveAiUsageBreakdownData(rows: readonly AiUsageBreakdownItem[]): readonly AiUsageBreakdownPoint[] {
|
|
218
|
+
return rows
|
|
219
|
+
.filter((row) => row.records > 0 || row.tokens.total > 0 || row.cost > 0)
|
|
220
|
+
.map((row) => ({ name: row.key, messages: row.records, tokens: row.tokens.total, cost: row.cost }))
|
|
221
|
+
.sort((left, right) => right.tokens - left.tokens || right.messages - left.messages || left.name.localeCompare(right.name))
|
|
222
|
+
.slice(0, 8);
|
|
223
|
+
}
|
|
224
|
+
|
|
216
225
|
export function deriveActivityHeatmapData(commits: readonly CommitRecord[]): readonly ActivityHeatmapCell[] {
|
|
217
226
|
const counts = new Map<string, number>();
|
|
218
227
|
for (const commit of commits) {
|
|
@@ -454,6 +463,23 @@ export function ProjectsComparisonChart({ data }: { readonly data: readonly Proj
|
|
|
454
463
|
);
|
|
455
464
|
}
|
|
456
465
|
|
|
466
|
+
export function AiUsageBreakdownChart({ title, description, data }: { readonly title: string; readonly description: string; readonly data: readonly AiUsageBreakdownPoint[] }) {
|
|
467
|
+
return (
|
|
468
|
+
<ChartPanel title={title} description={description} isEmpty={!hasPositiveValue(data, (item) => [item.messages, item.tokens])} emptyTitle={`No ${title.toLowerCase()} to chart`} emptyDescription="AI usage charts need matched local assistant records with model or harness metadata.">
|
|
469
|
+
<ChartContainer config={{ tokens: { label: "Tokens", color: chartPalette[2] }, messages: { label: "Messages", color: chartPalette[4] } }} className="h-72 w-full">
|
|
470
|
+
<BarChart data={data} layout="vertical" margin={{ left: 8, right: 16, top: 8, bottom: 0 }}>
|
|
471
|
+
<CartesianGrid horizontal={false} />
|
|
472
|
+
<XAxis type="number" hide />
|
|
473
|
+
<YAxis dataKey="name" type="category" width={124} tickLine={false} axisLine={false} />
|
|
474
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
475
|
+
<Bar dataKey="tokens" fill="var(--color-tokens)" radius={[0, 3, 3, 0]} {...staticChartProps} />
|
|
476
|
+
<Bar dataKey="messages" fill="var(--color-messages)" radius={[0, 3, 3, 0]} {...staticChartProps} />
|
|
477
|
+
</BarChart>
|
|
478
|
+
</ChartContainer>
|
|
479
|
+
</ChartPanel>
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
457
483
|
export function ActivityHeatmap({ data }: { readonly data: readonly ActivityHeatmapCell[] }) {
|
|
458
484
|
return (
|
|
459
485
|
<ChartPanel title="Activity heatmap" description="UTC weekday and hour density." isEmpty={!hasPositiveValue(data, (item) => [item.commits])} emptyTitle="No activity heatmap to show" emptyDescription="The heatmap needs at least one dated commit.">
|
package/src/overview.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@git-snitch/ui/compone
|
|
|
2
2
|
|
|
3
3
|
import type { CommitRecord, GitHubRepoMeta, RepoReportData, ReportData } from "@git-snitch/core";
|
|
4
4
|
|
|
5
|
+
import { AiUsagePanel } from "./ai-usage.js";
|
|
5
6
|
import { CommitActivityChart, deriveCommitActivityData } from "./charts.js";
|
|
6
7
|
import { EmptyState } from "./empty-state.js";
|
|
7
8
|
import { StatsGrid } from "./layout.js";
|
|
@@ -232,6 +233,7 @@ export function RepoOverview({ report }: { readonly report: ReportData }) {
|
|
|
232
233
|
<div className="grid gap-6">
|
|
233
234
|
<StatsGrid stats={buildOverviewStats(report)} />
|
|
234
235
|
{githubMeta !== undefined ? <GitHubMetaBar meta={githubMeta} /> : null}
|
|
236
|
+
{report.aiUsage !== undefined ? <AiUsagePanel usage={report.aiUsage} /> : null}
|
|
235
237
|
<EmptyState
|
|
236
238
|
title="This repository has no commit activity yet"
|
|
237
239
|
description="git-snitch found a repository report, but there are no commits or contributors to summarize. Charts and streaks will appear after activity exists."
|
|
@@ -244,6 +246,7 @@ export function RepoOverview({ report }: { readonly report: ReportData }) {
|
|
|
244
246
|
<div className="grid gap-6">
|
|
245
247
|
<StatsGrid stats={buildOverviewStats(report)} />
|
|
246
248
|
{githubMeta !== undefined ? <GitHubMetaBar meta={githubMeta} /> : null}
|
|
249
|
+
{report.aiUsage !== undefined ? <AiUsagePanel usage={report.aiUsage} /> : null}
|
|
247
250
|
<section aria-label="Repository overview previews" className="grid grid-flow-dense gap-6 lg:grid-cols-2">
|
|
248
251
|
<StreakCard streak={deriveStreakSummary(report.commits)} />
|
|
249
252
|
<ChartPreview report={report} />
|
package/src/scan-routes.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { useMemo, useState } from "react";
|
|
|
3
3
|
import type { ContributorSummary, RepoReportData, ReportData, ScanProjectReport, ScanReportData } from "@git-snitch/core";
|
|
4
4
|
import { cn } from "@git-snitch/ui/lib/utils";
|
|
5
5
|
|
|
6
|
+
import { AiUsagePanel, formatAiUsageCost, formatAiUsageTokens } from "./ai-usage.js";
|
|
6
7
|
import { ChartsRoute } from "./charts-route.js";
|
|
7
8
|
import { EmptyState } from "./empty-state.js";
|
|
8
9
|
import { StatsGrid } from "./layout.js";
|
|
@@ -43,6 +44,9 @@ type ProjectComparisonRow = {
|
|
|
43
44
|
readonly contributors: number;
|
|
44
45
|
readonly churn: number;
|
|
45
46
|
readonly lastCommitAt: string | undefined;
|
|
47
|
+
readonly aiMessages: number | undefined;
|
|
48
|
+
readonly aiTokens: number | undefined;
|
|
49
|
+
readonly aiCost: number | undefined;
|
|
46
50
|
};
|
|
47
51
|
|
|
48
52
|
export type ScanProjectRouteEntry = {
|
|
@@ -180,7 +184,7 @@ export function deriveCrossProjectContributors(report: ScanReportData): readonly
|
|
|
180
184
|
.sort((left, right) => right.commitCount - left.commitCount || right.projectCount - left.projectCount || left.name.localeCompare(right.name));
|
|
181
185
|
}
|
|
182
186
|
|
|
183
|
-
const
|
|
187
|
+
const projectComparisonBaseColumns: ColumnDef<ProjectComparisonRow>[] = [
|
|
184
188
|
{
|
|
185
189
|
accessorKey: "label",
|
|
186
190
|
header: "Project",
|
|
@@ -208,6 +212,12 @@ const projectComparisonColumns: ColumnDef<ProjectComparisonRow>[] = [
|
|
|
208
212
|
{ accessorKey: "lastCommitAt", header: "Last commit", cell: ({ row }) => formatDate(row.original.lastCommitAt) },
|
|
209
213
|
];
|
|
210
214
|
|
|
215
|
+
const projectComparisonAiUsageColumns: ColumnDef<ProjectComparisonRow>[] = [
|
|
216
|
+
{ accessorKey: "aiMessages", header: "AI messages", cell: ({ row }) => row.original.aiMessages === undefined ? "—" : formatNumber(row.original.aiMessages) },
|
|
217
|
+
{ accessorKey: "aiTokens", header: "AI tokens", cell: ({ row }) => row.original.aiTokens === undefined ? "—" : formatAiUsageTokens(row.original.aiTokens) },
|
|
218
|
+
{ accessorKey: "aiCost", header: "AI cost", cell: ({ row }) => row.original.aiCost === undefined ? "—" : formatAiUsageCost(row.original.aiCost) },
|
|
219
|
+
];
|
|
220
|
+
|
|
211
221
|
const crossProjectContributorColumns: ColumnDef<ContributorAggregate>[] = [
|
|
212
222
|
{
|
|
213
223
|
accessorKey: "name",
|
|
@@ -245,19 +255,31 @@ function ScanIntro({ report }: { readonly report: ScanReportData }) {
|
|
|
245
255
|
|
|
246
256
|
function ProjectComparison({ report }: { readonly report: ScanReportData }) {
|
|
247
257
|
const entries = deriveScanProjectRouteEntries(report);
|
|
258
|
+
const hasProjectAiUsage = report.projects.some((project) => project.report.aiUsage !== undefined);
|
|
259
|
+
const columns = useMemo(
|
|
260
|
+
() => hasProjectAiUsage ? [...projectComparisonBaseColumns, ...projectComparisonAiUsageColumns] : projectComparisonBaseColumns,
|
|
261
|
+
[hasProjectAiUsage],
|
|
262
|
+
);
|
|
248
263
|
|
|
249
264
|
const rows: readonly ProjectComparisonRow[] = useMemo(
|
|
250
265
|
() =>
|
|
251
|
-
entries.map((entry) =>
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
266
|
+
entries.map((entry) => {
|
|
267
|
+
const usage = entry.project.report.aiUsage;
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
slug: entry.slug,
|
|
271
|
+
href: entry.href,
|
|
272
|
+
label: entry.label,
|
|
273
|
+
remoteUrl: entry.project.repository.remoteUrl,
|
|
274
|
+
commits: entry.project.report.commits.length,
|
|
275
|
+
contributors: entry.project.report.contributors.length,
|
|
276
|
+
churn: totalChurn(entry.project.report),
|
|
277
|
+
lastCommitAt: entry.project.repository.lastCommitAt,
|
|
278
|
+
aiMessages: usage?.records,
|
|
279
|
+
aiTokens: usage?.tokens.total,
|
|
280
|
+
aiCost: usage?.cost,
|
|
281
|
+
};
|
|
282
|
+
}),
|
|
261
283
|
[entries],
|
|
262
284
|
);
|
|
263
285
|
|
|
@@ -265,7 +287,7 @@ function ProjectComparison({ report }: { readonly report: ScanReportData }) {
|
|
|
265
287
|
<DataTable
|
|
266
288
|
ariaLabel="Project comparison"
|
|
267
289
|
data={rows}
|
|
268
|
-
columns={
|
|
290
|
+
columns={columns}
|
|
269
291
|
search={{ placeholder: "Search projects", toText: (row) => `${row.label}` }}
|
|
270
292
|
emptyState={{
|
|
271
293
|
title: "No repositories matched this scan",
|
|
@@ -305,6 +327,13 @@ export function ScanOverview({ report }: ScanRouteProps) {
|
|
|
305
327
|
<div className="grid gap-6">
|
|
306
328
|
<ScanIntro report={report} />
|
|
307
329
|
<StatsGrid stats={buildScanStats(report)} />
|
|
330
|
+
{report.analysis.aiUsage !== undefined ? (
|
|
331
|
+
<AiUsagePanel
|
|
332
|
+
title="Scan AI usage"
|
|
333
|
+
description="Total matched local assistant usage across repositories in this scan. Workspace paths are not rendered in the HTML report."
|
|
334
|
+
usage={report.analysis.aiUsage}
|
|
335
|
+
/>
|
|
336
|
+
) : null}
|
|
308
337
|
<ProjectComparison report={report} />
|
|
309
338
|
<CrossProjectContributors report={report} />
|
|
310
339
|
</div>
|
package/vite.config.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import tailwindcss from "@tailwindcss/vite";
|
|
2
2
|
import viteReact from "@vitejs/plugin-react";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { basename, dirname, resolve } from "node:path";
|
|
3
5
|
import { fileURLToPath } from "node:url";
|
|
4
6
|
import { defineConfig } from "vite";
|
|
5
7
|
|
|
@@ -7,9 +9,27 @@ import { createInlineHtmlPlugin } from "./src/inline-plugin";
|
|
|
7
9
|
|
|
8
10
|
const packageDirectory = fileURLToPath(new URL(".", import.meta.url));
|
|
9
11
|
const defaultTemplateModule = fileURLToPath(new URL("./src/custom-templates.ts", import.meta.url));
|
|
10
|
-
const packageNodeModules = fileURLToPath(new URL("./node_modules/", import.meta.url));
|
|
11
12
|
const customTemplateModule = process.env.GIT_SNITCH_TEMPLATE_MODULE;
|
|
12
13
|
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
function resolvePackageModule(specifier: string): string {
|
|
17
|
+
const entry = require.resolve(specifier);
|
|
18
|
+
return entry;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolvePackageRoot(pkgName: string): string {
|
|
22
|
+
const entry = require.resolve(pkgName);
|
|
23
|
+
let dir = dirname(entry);
|
|
24
|
+
while (basename(dir) !== pkgName && dirname(dir) !== dir) {
|
|
25
|
+
dir = dirname(dir);
|
|
26
|
+
}
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const reactRoot = resolvePackageRoot("react");
|
|
31
|
+
const reactDomRoot = resolvePackageRoot("react-dom");
|
|
32
|
+
|
|
13
33
|
export default defineConfig({
|
|
14
34
|
base: "./",
|
|
15
35
|
build: {
|
|
@@ -30,9 +50,10 @@ export default defineConfig({
|
|
|
30
50
|
resolve: {
|
|
31
51
|
alias: {
|
|
32
52
|
"@git-snitch/renderer": fileURLToPath(new URL("./src", import.meta.url)),
|
|
33
|
-
react:
|
|
34
|
-
"react/jsx-dev-runtime":
|
|
35
|
-
"react/jsx-runtime":
|
|
53
|
+
react: resolve(reactRoot),
|
|
54
|
+
"react/jsx-dev-runtime": resolvePackageModule("react/jsx-dev-runtime"),
|
|
55
|
+
"react/jsx-runtime": resolvePackageModule("react/jsx-runtime"),
|
|
56
|
+
"react-dom": resolve(reactDomRoot),
|
|
36
57
|
"virtual:git-snitch-custom-templates": customTemplateModule ?? defaultTemplateModule,
|
|
37
58
|
},
|
|
38
59
|
tsconfigPaths: true,
|