agentfit 0.1.2 → 0.1.5
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/.github/workflows/release.yml +4 -0
- package/README.md +0 -2
- package/app/(dashboard)/ai-insights/page.tsx +271 -0
- package/app/(dashboard)/models/page.tsx +21 -0
- package/app/(dashboard)/page.tsx +2 -0
- package/app/(dashboard)/sessions/[id]/page.tsx +16 -2
- package/app/(dashboard)/settings/page.tsx +168 -0
- package/app/api/analyze/aggregate/route.ts +88 -0
- package/app/api/analyze/estimate/route.ts +62 -0
- package/app/api/analyze/route.ts +142 -0
- package/app/api/cc-versions/route.ts +84 -0
- package/app/api/config/route.ts +35 -0
- package/bin/agentfit.mjs +22 -13
- package/components/analyze-confirm-dialog.tsx +81 -0
- package/components/app-sidebar.tsx +14 -0
- package/components/data-provider.tsx +4 -2
- package/components/model-usage-chart.tsx +216 -0
- package/components/overview-cards.tsx +1 -1
- package/components/session-ai-analysis.tsx +318 -0
- package/components/sessions-table.tsx +169 -15
- package/components/version-lag-chart.tsx +284 -0
- package/electron/main.mjs +81 -59
- package/generated/prisma/browser.ts +5 -0
- package/generated/prisma/client.ts +5 -0
- package/generated/prisma/internal/class.ts +14 -4
- package/generated/prisma/internal/prismaNamespace.ts +95 -2
- package/generated/prisma/internal/prismaNamespaceBrowser.ts +19 -1
- package/generated/prisma/models/Session.ts +57 -1
- package/generated/prisma/models/SessionAnalysis.ts +1321 -0
- package/generated/prisma/models.ts +1 -0
- package/lib/config.ts +45 -0
- package/lib/db.ts +1 -1
- package/lib/openai.ts +253 -0
- package/lib/parse-codex.ts +2 -0
- package/lib/parse-logs.ts +21 -7
- package/lib/queries.ts +5 -1
- package/lib/sync.ts +17 -5
- package/package.json +2 -1
- package/prisma/migrations/20260404151230_add_session_analysis/migration.sql +18 -0
- package/prisma/migrations/20260405230736_add_cli_version/migration.sql +41 -0
- package/prisma/migrations/20260406205546_add_model_counts/migration.sql +42 -0
- package/prisma/schema.prisma +16 -0
- package/prisma/schema.sql +20 -0
- /package/prisma/migrations/{20260401144555_add_system_prompt_edits → 20260403214556_init}/migration.sql +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState } from 'react'
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
4
4
|
import Link from 'next/link'
|
|
5
5
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
6
7
|
import {
|
|
7
8
|
Table,
|
|
8
9
|
TableBody,
|
|
@@ -21,12 +22,124 @@ import {
|
|
|
21
22
|
import { Badge } from '@/components/ui/badge'
|
|
22
23
|
import { PaginationControls } from '@/components/pagination-controls'
|
|
23
24
|
import { usePagination } from '@/hooks/use-pagination'
|
|
24
|
-
import {
|
|
25
|
+
import { AnalyzeConfirmDialog } from '@/components/analyze-confirm-dialog'
|
|
26
|
+
import { Eye, Sparkles, Loader2 } from 'lucide-react'
|
|
25
27
|
import type { SessionSummary } from '@/lib/parse-logs'
|
|
26
28
|
import { formatCost, formatTokens, formatDuration, formatNumber } from '@/lib/format'
|
|
27
29
|
|
|
28
30
|
export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
|
|
29
31
|
const [projectFilter, setProjectFilter] = useState<string>('all')
|
|
32
|
+
const [analyzedIds, setAnalyzedIds] = useState<Set<string>>(new Set())
|
|
33
|
+
const [analyzingIds, setAnalyzingIds] = useState<Set<string>>(new Set())
|
|
34
|
+
const [hasApiKey, setHasApiKey] = useState(false)
|
|
35
|
+
const [bulkAnalyzing, setBulkAnalyzing] = useState(false)
|
|
36
|
+
const [confirmOpen, setConfirmOpen] = useState(false)
|
|
37
|
+
const [bulkEstimate, setBulkEstimate] = useState<{
|
|
38
|
+
sessionCount: number
|
|
39
|
+
messageCount: number
|
|
40
|
+
estimatedCostUSD: number
|
|
41
|
+
} | null>(null)
|
|
42
|
+
const [bulkSessionIds, setBulkSessionIds] = useState<string[]>([])
|
|
43
|
+
|
|
44
|
+
const loadAnalysisStatus = useCallback(async () => {
|
|
45
|
+
try {
|
|
46
|
+
const [analyzeRes, configRes] = await Promise.all([
|
|
47
|
+
fetch('/api/analyze?status=true'),
|
|
48
|
+
fetch('/api/config'),
|
|
49
|
+
])
|
|
50
|
+
const analyzeData = await analyzeRes.json()
|
|
51
|
+
const configData = await configRes.json()
|
|
52
|
+
if (analyzeData.analyses) {
|
|
53
|
+
setAnalyzedIds(new Set(analyzeData.analyses.map((a: { sessionId: string }) => a.sessionId)))
|
|
54
|
+
}
|
|
55
|
+
setHasApiKey(configData.hasOpenAIKey || false)
|
|
56
|
+
} catch {
|
|
57
|
+
// ignore
|
|
58
|
+
}
|
|
59
|
+
}, [])
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
loadAnalysisStatus()
|
|
63
|
+
}, [loadAnalysisStatus])
|
|
64
|
+
|
|
65
|
+
async function handleAnalyzeOne(sessionId: string) {
|
|
66
|
+
setAnalyzingIds((prev) => new Set(prev).add(sessionId))
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch('/api/analyze', {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({ sessionId }),
|
|
72
|
+
})
|
|
73
|
+
if (res.ok) {
|
|
74
|
+
setAnalyzedIds((prev) => new Set(prev).add(sessionId))
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// ignore
|
|
78
|
+
} finally {
|
|
79
|
+
setAnalyzingIds((prev) => {
|
|
80
|
+
const next = new Set(prev)
|
|
81
|
+
next.delete(sessionId)
|
|
82
|
+
return next
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function handleBulkClick() {
|
|
88
|
+
const unanalyzed = filtered
|
|
89
|
+
.filter((s) => !analyzedIds.has(s.sessionId))
|
|
90
|
+
.map((s) => s.sessionId)
|
|
91
|
+
|
|
92
|
+
if (unanalyzed.length === 0) return
|
|
93
|
+
|
|
94
|
+
setBulkSessionIds(unanalyzed)
|
|
95
|
+
setBulkEstimate(null)
|
|
96
|
+
setConfirmOpen(true)
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch('/api/analyze/estimate', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'Content-Type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({ sessionIds: unanalyzed }),
|
|
103
|
+
})
|
|
104
|
+
const data = await res.json()
|
|
105
|
+
setBulkEstimate({
|
|
106
|
+
sessionCount: unanalyzed.length,
|
|
107
|
+
messageCount: data.messageCount,
|
|
108
|
+
estimatedCostUSD: data.estimatedCostUSD,
|
|
109
|
+
})
|
|
110
|
+
} catch {
|
|
111
|
+
setConfirmOpen(false)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function handleBulkConfirm() {
|
|
116
|
+
setConfirmOpen(false)
|
|
117
|
+
setBulkAnalyzing(true)
|
|
118
|
+
|
|
119
|
+
for (const sessionId of bulkSessionIds) {
|
|
120
|
+
setAnalyzingIds((prev) => new Set(prev).add(sessionId))
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetch('/api/analyze', {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'Content-Type': 'application/json' },
|
|
125
|
+
body: JSON.stringify({ sessionId }),
|
|
126
|
+
})
|
|
127
|
+
if (res.ok) {
|
|
128
|
+
setAnalyzedIds((prev) => new Set(prev).add(sessionId))
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// continue with next
|
|
132
|
+
} finally {
|
|
133
|
+
setAnalyzingIds((prev) => {
|
|
134
|
+
const next = new Set(prev)
|
|
135
|
+
next.delete(sessionId)
|
|
136
|
+
return next
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
setBulkAnalyzing(false)
|
|
142
|
+
}
|
|
30
143
|
|
|
31
144
|
const projects = [...new Set(sessions.map((s) => s.project))].sort()
|
|
32
145
|
const filtered =
|
|
@@ -42,19 +155,33 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
|
|
|
42
155
|
<CardTitle>Sessions</CardTitle>
|
|
43
156
|
<CardDescription>{filtered.length} sessions recorded</CardDescription>
|
|
44
157
|
</div>
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
158
|
+
<div className="flex items-center gap-2">
|
|
159
|
+
{hasApiKey && (
|
|
160
|
+
<Button
|
|
161
|
+
variant="outline"
|
|
162
|
+
size="sm"
|
|
163
|
+
onClick={handleBulkClick}
|
|
164
|
+
disabled={bulkAnalyzing || filtered.every((s) => analyzedIds.has(s.sessionId))}
|
|
165
|
+
>
|
|
166
|
+
{bulkAnalyzing && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
|
|
167
|
+
<Sparkles className="mr-1 h-3 w-3" />
|
|
168
|
+
Analyze All
|
|
169
|
+
</Button>
|
|
170
|
+
)}
|
|
171
|
+
<Select value={projectFilter} onValueChange={(v) => setProjectFilter(v ?? 'all')}>
|
|
172
|
+
<SelectTrigger className="w-[200px]">
|
|
173
|
+
<SelectValue placeholder="Filter by project" />
|
|
174
|
+
</SelectTrigger>
|
|
175
|
+
<SelectContent>
|
|
176
|
+
<SelectItem value="all">All projects</SelectItem>
|
|
177
|
+
{projects.map((p) => (
|
|
178
|
+
<SelectItem key={p} value={p}>
|
|
179
|
+
{p}
|
|
180
|
+
</SelectItem>
|
|
181
|
+
))}
|
|
182
|
+
</SelectContent>
|
|
183
|
+
</Select>
|
|
184
|
+
</div>
|
|
58
185
|
</div>
|
|
59
186
|
</CardHeader>
|
|
60
187
|
<CardContent>
|
|
@@ -69,6 +196,7 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
|
|
|
69
196
|
<TableHead className="text-right">Cost</TableHead>
|
|
70
197
|
<TableHead className="text-right">Duration</TableHead>
|
|
71
198
|
<TableHead className="text-right">Tools</TableHead>
|
|
199
|
+
<TableHead className="text-center">AI</TableHead>
|
|
72
200
|
<TableHead className="w-[60px]"></TableHead>
|
|
73
201
|
</TableRow>
|
|
74
202
|
</TableHeader>
|
|
@@ -89,6 +217,25 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
|
|
|
89
217
|
<TableCell className="text-right">{formatCost(s.costUSD)}</TableCell>
|
|
90
218
|
<TableCell className="text-right">{formatDuration(s.durationMinutes)}</TableCell>
|
|
91
219
|
<TableCell className="text-right">{formatNumber(s.toolCallsTotal)}</TableCell>
|
|
220
|
+
<TableCell className="text-center">
|
|
221
|
+
{analyzedIds.has(s.sessionId) ? (
|
|
222
|
+
<Badge variant="outline" className="text-[10px] text-green-600 border-green-600">
|
|
223
|
+
Done
|
|
224
|
+
</Badge>
|
|
225
|
+
) : analyzingIds.has(s.sessionId) ? (
|
|
226
|
+
<Loader2 className="h-3 w-3 animate-spin mx-auto text-muted-foreground" />
|
|
227
|
+
) : hasApiKey ? (
|
|
228
|
+
<button
|
|
229
|
+
onClick={() => handleAnalyzeOne(s.sessionId)}
|
|
230
|
+
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
|
231
|
+
title="Analyze with AI"
|
|
232
|
+
>
|
|
233
|
+
<Sparkles className="h-3 w-3" />
|
|
234
|
+
</button>
|
|
235
|
+
) : (
|
|
236
|
+
<span className="text-xs text-muted-foreground">—</span>
|
|
237
|
+
)}
|
|
238
|
+
</TableCell>
|
|
92
239
|
<TableCell>
|
|
93
240
|
<Link href={`/sessions/${s.sessionId}`} className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
|
|
94
241
|
<Eye className="h-4 w-4" />
|
|
@@ -99,6 +246,13 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
|
|
|
99
246
|
</TableBody>
|
|
100
247
|
</Table>
|
|
101
248
|
<PaginationControls pagination={pagination} noun="sessions" />
|
|
249
|
+
<AnalyzeConfirmDialog
|
|
250
|
+
open={confirmOpen}
|
|
251
|
+
onOpenChange={setConfirmOpen}
|
|
252
|
+
onConfirm={handleBulkConfirm}
|
|
253
|
+
loading={bulkAnalyzing}
|
|
254
|
+
estimate={bulkEstimate}
|
|
255
|
+
/>
|
|
102
256
|
</CardContent>
|
|
103
257
|
</Card>
|
|
104
258
|
)
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState, useEffect } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Bar,
|
|
6
|
+
Line,
|
|
7
|
+
ComposedChart,
|
|
8
|
+
CartesianGrid,
|
|
9
|
+
XAxis,
|
|
10
|
+
YAxis,
|
|
11
|
+
} from 'recharts'
|
|
12
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
13
|
+
import {
|
|
14
|
+
ChartContainer,
|
|
15
|
+
ChartTooltip,
|
|
16
|
+
type ChartConfig,
|
|
17
|
+
} from '@/components/ui/chart'
|
|
18
|
+
import type { SessionSummary } from '@/lib/parse-logs'
|
|
19
|
+
|
|
20
|
+
const chartConfig = {
|
|
21
|
+
releases: { label: 'New Releases', color: 'var(--chart-2)' },
|
|
22
|
+
behind: { label: 'Versions Behind', color: 'var(--chart-5)' },
|
|
23
|
+
} satisfies ChartConfig
|
|
24
|
+
|
|
25
|
+
function semverToNum(v: string): number {
|
|
26
|
+
const parts = v.split('.').map(Number)
|
|
27
|
+
return (parts[0] || 0) * 1_000_000 + (parts[1] || 0) * 1_000 + (parts[2] || 0)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Only match stable semver (no prerelease/beta suffixes)
|
|
31
|
+
function isStableVersion(v: string): boolean {
|
|
32
|
+
return /^\d+\.\d+\.\d+$/.test(v)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function VersionLagChart({ sessions }: { sessions: SessionSummary[] }) {
|
|
36
|
+
const [npmVersions, setNpmVersions] = useState<Record<string, string>>({})
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
fetch('/api/cc-versions')
|
|
40
|
+
.then((r) => r.json())
|
|
41
|
+
.then((data) => setNpmVersions(data))
|
|
42
|
+
.catch(() => {})
|
|
43
|
+
}, [])
|
|
44
|
+
|
|
45
|
+
const { data, currentLag, currentUserVersion, currentLatestVersion, totalReleases } = useMemo(() => {
|
|
46
|
+
const empty = { data: [], currentLag: 0, currentUserVersion: '', currentLatestVersion: '', totalReleases: 0 }
|
|
47
|
+
if (!Object.keys(npmVersions).length || !sessions.length) return empty
|
|
48
|
+
|
|
49
|
+
// Filter to stable versions only
|
|
50
|
+
const stableVersions = Object.entries(npmVersions)
|
|
51
|
+
.filter(([v]) => isStableVersion(v))
|
|
52
|
+
.map(([version, date]) => ({
|
|
53
|
+
version,
|
|
54
|
+
date: date.slice(0, 10),
|
|
55
|
+
num: semverToNum(version),
|
|
56
|
+
}))
|
|
57
|
+
.sort((a, b) => a.num - b.num)
|
|
58
|
+
|
|
59
|
+
// Determine session date range first
|
|
60
|
+
const sessionDates = sessions
|
|
61
|
+
.filter(s => s.cliVersion && s.cliVersion !== 'unknown')
|
|
62
|
+
.map(s => s.startTime.slice(0, 10))
|
|
63
|
+
.sort()
|
|
64
|
+
const rangeStart = sessionDates[0] || ''
|
|
65
|
+
const rangeEnd = sessionDates[sessionDates.length - 1] || ''
|
|
66
|
+
|
|
67
|
+
// Count releases and build per-day map within the session date range
|
|
68
|
+
let total = 0
|
|
69
|
+
const releasesPerDay = new Map<string, number>()
|
|
70
|
+
for (const v of stableVersions) {
|
|
71
|
+
if (v.date >= rangeStart && v.date <= rangeEnd) {
|
|
72
|
+
total++
|
|
73
|
+
releasesPerDay.set(v.date, (releasesPerDay.get(v.date) || 0) + 1)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Count how many stable versions are newer than userVersion as of date
|
|
78
|
+
function countBehind(userVersion: string, date: string): number {
|
|
79
|
+
const userNum = semverToNum(userVersion)
|
|
80
|
+
let count = 0
|
|
81
|
+
for (const v of stableVersions) {
|
|
82
|
+
if (v.date <= date && v.num > userNum) count++
|
|
83
|
+
}
|
|
84
|
+
return count
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function latestAtDate(date: string): string {
|
|
88
|
+
let best = ''
|
|
89
|
+
let bestNum = 0
|
|
90
|
+
for (const v of stableVersions) {
|
|
91
|
+
if (v.date <= date && v.num > bestNum) {
|
|
92
|
+
best = v.version
|
|
93
|
+
bestNum = v.num
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return best
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Group sessions by date
|
|
100
|
+
const byDate = new Map<string, string[]>()
|
|
101
|
+
for (const s of sessions) {
|
|
102
|
+
if (!s.cliVersion || s.cliVersion === 'unknown') continue
|
|
103
|
+
const date = s.startTime.slice(0, 10)
|
|
104
|
+
if (!byDate.has(date)) byDate.set(date, [])
|
|
105
|
+
byDate.get(date)!.push(s.cliVersion)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (byDate.size === 0) return empty
|
|
109
|
+
|
|
110
|
+
// Build date range: from first session date to last
|
|
111
|
+
const sortedDates = Array.from(byDate.keys()).sort()
|
|
112
|
+
const startDate = new Date(sortedDates[0])
|
|
113
|
+
const endDate = new Date(sortedDates[sortedDates.length - 1])
|
|
114
|
+
|
|
115
|
+
// Fill every day in the range
|
|
116
|
+
const points: {
|
|
117
|
+
date: string
|
|
118
|
+
releases: number
|
|
119
|
+
behind: number
|
|
120
|
+
userVersion: string
|
|
121
|
+
latestVersion: string
|
|
122
|
+
}[] = []
|
|
123
|
+
|
|
124
|
+
let lastUserVersion = ''
|
|
125
|
+
const d = new Date(startDate)
|
|
126
|
+
while (d <= endDate) {
|
|
127
|
+
const dateStr = d.toLocaleDateString('en-CA') // YYYY-MM-DD
|
|
128
|
+
const dayVersions = byDate.get(dateStr)
|
|
129
|
+
|
|
130
|
+
if (dayVersions) {
|
|
131
|
+
// Pick most common version that day
|
|
132
|
+
const counts = new Map<string, number>()
|
|
133
|
+
for (const v of dayVersions) {
|
|
134
|
+
counts.set(v, (counts.get(v) || 0) + 1)
|
|
135
|
+
}
|
|
136
|
+
lastUserVersion = Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0][0]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (lastUserVersion) {
|
|
140
|
+
const latest = latestAtDate(dateStr)
|
|
141
|
+
const behind = countBehind(lastUserVersion, dateStr)
|
|
142
|
+
|
|
143
|
+
points.push({
|
|
144
|
+
date: dateStr.slice(5),
|
|
145
|
+
releases: releasesPerDay.get(dateStr) || 0,
|
|
146
|
+
behind,
|
|
147
|
+
userVersion: lastUserVersion,
|
|
148
|
+
latestVersion: latest,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
d.setDate(d.getDate() + 1)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const last = points[points.length - 1]
|
|
156
|
+
return {
|
|
157
|
+
data: points,
|
|
158
|
+
currentLag: last?.behind ?? 0,
|
|
159
|
+
currentUserVersion: last?.userVersion ?? '',
|
|
160
|
+
currentLatestVersion: last?.latestVersion ?? '',
|
|
161
|
+
totalReleases: total,
|
|
162
|
+
}
|
|
163
|
+
}, [sessions, npmVersions])
|
|
164
|
+
|
|
165
|
+
if (data.length === 0) {
|
|
166
|
+
return (
|
|
167
|
+
<Card>
|
|
168
|
+
<CardHeader>
|
|
169
|
+
<CardTitle>Version Freshness</CardTitle>
|
|
170
|
+
<CardDescription>Your Claude Code version vs latest release over time</CardDescription>
|
|
171
|
+
</CardHeader>
|
|
172
|
+
<CardContent>
|
|
173
|
+
<div className="flex min-h-[250px] items-center justify-center text-sm text-muted-foreground">
|
|
174
|
+
No version data available. Re-sync to capture CLI versions.
|
|
175
|
+
</div>
|
|
176
|
+
</CardContent>
|
|
177
|
+
</Card>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<Card>
|
|
183
|
+
<CardHeader>
|
|
184
|
+
<CardTitle>Version Freshness</CardTitle>
|
|
185
|
+
<CardDescription className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
|
186
|
+
{currentLag === 0 ? (
|
|
187
|
+
<span className="text-green-600 dark:text-green-400">
|
|
188
|
+
Up to date (v{currentUserVersion})
|
|
189
|
+
</span>
|
|
190
|
+
) : (
|
|
191
|
+
<span>
|
|
192
|
+
v{currentUserVersion} —{' '}
|
|
193
|
+
<strong className="text-amber-600 dark:text-amber-400">
|
|
194
|
+
{currentLag} release{currentLag !== 1 ? 's' : ''} behind
|
|
195
|
+
</strong>{' '}
|
|
196
|
+
(latest: v{currentLatestVersion})
|
|
197
|
+
</span>
|
|
198
|
+
)}
|
|
199
|
+
<span className="text-xs text-muted-foreground">
|
|
200
|
+
{totalReleases} stable releases
|
|
201
|
+
</span>
|
|
202
|
+
</CardDescription>
|
|
203
|
+
</CardHeader>
|
|
204
|
+
<CardContent>
|
|
205
|
+
<ChartContainer config={chartConfig} className="min-h-[250px] w-full">
|
|
206
|
+
<ComposedChart data={data} accessibilityLayer>
|
|
207
|
+
<CartesianGrid vertical={false} />
|
|
208
|
+
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} />
|
|
209
|
+
<YAxis
|
|
210
|
+
yAxisId="left"
|
|
211
|
+
tickLine={false}
|
|
212
|
+
axisLine={false}
|
|
213
|
+
allowDecimals={false}
|
|
214
|
+
/>
|
|
215
|
+
<ChartTooltip
|
|
216
|
+
content={({ active, payload }) => {
|
|
217
|
+
if (!active || !payload?.length) return null
|
|
218
|
+
const d = payload[0].payload
|
|
219
|
+
return (
|
|
220
|
+
<div className="rounded-lg border bg-background px-3 py-2 text-sm shadow-md">
|
|
221
|
+
<div className="font-semibold">{d.date}</div>
|
|
222
|
+
<div className="mt-1 space-y-0.5 text-muted-foreground">
|
|
223
|
+
<div className="flex justify-between gap-4">
|
|
224
|
+
<span className="flex items-center gap-1.5">
|
|
225
|
+
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'var(--chart-2)' }} />
|
|
226
|
+
New releases
|
|
227
|
+
</span>
|
|
228
|
+
<span className="font-mono font-medium text-foreground">{d.releases}</span>
|
|
229
|
+
</div>
|
|
230
|
+
<div className="flex justify-between gap-4">
|
|
231
|
+
<span>Your version</span>
|
|
232
|
+
<span className="font-mono font-medium text-foreground">v{d.userVersion}</span>
|
|
233
|
+
</div>
|
|
234
|
+
<div className="flex justify-between gap-4">
|
|
235
|
+
<span>Latest</span>
|
|
236
|
+
<span className="font-mono font-medium text-foreground">v{d.latestVersion}</span>
|
|
237
|
+
</div>
|
|
238
|
+
<div className="flex justify-between gap-4 border-t pt-0.5">
|
|
239
|
+
<span className="flex items-center gap-1.5">
|
|
240
|
+
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'var(--chart-5)' }} />
|
|
241
|
+
Behind
|
|
242
|
+
</span>
|
|
243
|
+
<span className={`font-mono font-semibold ${d.behind === 0 ? 'text-green-600' : d.behind <= 3 ? 'text-amber-500' : 'text-red-500'}`}>
|
|
244
|
+
{d.behind === 0 ? 'up to date' : `${d.behind} release${d.behind !== 1 ? 's' : ''}`}
|
|
245
|
+
</span>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
)
|
|
250
|
+
}}
|
|
251
|
+
/>
|
|
252
|
+
<Bar
|
|
253
|
+
yAxisId="left"
|
|
254
|
+
dataKey="releases"
|
|
255
|
+
fill="var(--color-releases)"
|
|
256
|
+
fillOpacity={0.3}
|
|
257
|
+
radius={[2, 2, 0, 0]}
|
|
258
|
+
/>
|
|
259
|
+
<Line
|
|
260
|
+
yAxisId="left"
|
|
261
|
+
dataKey="behind"
|
|
262
|
+
type="monotone"
|
|
263
|
+
stroke="var(--color-behind)"
|
|
264
|
+
strokeWidth={2}
|
|
265
|
+
dot={false}
|
|
266
|
+
activeDot={{ r: 4, fill: 'var(--color-behind)' }}
|
|
267
|
+
/>
|
|
268
|
+
</ComposedChart>
|
|
269
|
+
</ChartContainer>
|
|
270
|
+
{/* Legend */}
|
|
271
|
+
<div className="mt-2 flex items-center justify-end gap-4 text-xs text-muted-foreground">
|
|
272
|
+
<span className="flex items-center gap-1.5">
|
|
273
|
+
<span className="inline-block h-2.5 w-2.5 rounded-sm opacity-40" style={{ backgroundColor: 'var(--chart-2)' }} />
|
|
274
|
+
Daily releases
|
|
275
|
+
</span>
|
|
276
|
+
<span className="flex items-center gap-1.5">
|
|
277
|
+
<span className="inline-block h-0.5 w-4 rounded" style={{ backgroundColor: 'var(--chart-5)' }} />
|
|
278
|
+
Versions behind
|
|
279
|
+
</span>
|
|
280
|
+
</div>
|
|
281
|
+
</CardContent>
|
|
282
|
+
</Card>
|
|
283
|
+
)
|
|
284
|
+
}
|