diff --git a/frontend/src/components/ProjectCard.tsx b/frontend/src/components/ProjectCard.tsx new file mode 100644 index 0000000..41ac996 --- /dev/null +++ b/frontend/src/components/ProjectCard.tsx @@ -0,0 +1,156 @@ +import { useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { Clock, History, Play, Settings } from 'lucide-react'; +import { Sparkline } from './Sparkline'; +import { StatusBadge } from './StatusBadge'; +import { getApiKey, getGroupOverview } from '../lib/api'; +import { describeCron } from '../lib/cronUtils'; +import { queryKeys } from '../lib/queryKeys'; +import type { Group, GroupHealthSummary, GroupStatus, Run } from '../lib/types'; + +interface ProjectCardProps { + group: Group; + latestRun: Run | undefined; + publicStatus: GroupStatus | undefined; + health?: GroupHealthSummary; + onRunNow: () => void; + isTriggering: boolean; +} + +export function ProjectCard({ + group, + latestRun, + publicStatus, + health, + onRunNow, + isTriggering, +}: ProjectCardProps) { + const { data: overview } = useQuery({ + queryKey: queryKeys.groups.overview(group.name), + queryFn: () => getGroupOverview(group.name), + staleTime: 5 * 60 * 1000, + enabled: !!getApiKey(), + }); + + const host = (() => { + try { return new URL(group.urls[0]).host; } catch { return group.urls[0] ?? ''; } + })(); + + const sparkData = (overview?.series ?? []).slice(-20).map((s) => s.avgLoadTimeMs); + const avgLoad = overview?.stats.avgLoadTimeMs ?? null; + const totalRuns = overview?.stats.totalRuns ?? null; + const uptime = publicStatus?.uptimePct ?? null; + const hasIssues = health && Object.values(health.tabs).some(Boolean); + + const isWarn = latestRun?.status === 'partial_failure' || latestRun?.status === 'failed'; + const sparkColor = isWarn ? 'var(--pc-warn)' : 'var(--pc-accent)'; + const uptimeColor = + uptime === null ? 'var(--foreground)' + : uptime >= 99 ? 'var(--pc-ok)' + : uptime >= 95 ? 'var(--pc-warn)' + : 'var(--pc-bad)'; + + return ( +
+ {/* Title row — links to project detail */} + +
+
+
+ {group.name} +
+
+ {host} +
+
+ {latestRun && } +
+ + + {/* Sparkline */} +
+ {sparkData.length >= 2 + ? + :
+ } +
+ + {/* Schedule + URL count */} +
+ + + {describeCron(group.schedule)} + + {group.urls.length} URLs +
+ + {/* Footer metrics */} +
+ + Uptime {uptime !== null ? `${uptime.toFixed(1)}%` : '—'} + + + Avg {avgLoad !== null ? `${Math.round(avgLoad)}ms` : '—'} + + + {hasIssues && ( + + Needs work + + )} + {totalRuns !== null && {totalRuns} runs} + +
+ + {/* Action buttons */} +
+ + + + History + + + + +
+
+ ); +} diff --git a/frontend/src/routes/groups.tsx b/frontend/src/routes/groups.tsx index 8387580..7eba9f6 100644 --- a/frontend/src/routes/groups.tsx +++ b/frontend/src/routes/groups.tsx @@ -1,13 +1,12 @@ import { queryOptions, useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'; -import { ChevronRight, Clock, History, LayoutGrid, List, Play, Search, Settings } from 'lucide-react'; +import { ChevronRight, LayoutGrid, List, Search, Settings } from 'lucide-react'; import { useState } from 'react'; -import { Sparkline } from '../components/Sparkline'; +import { ProjectCard } from '../components/ProjectCard'; import { StatusBadge } from '../components/StatusBadge'; import { getApiKey, getConfig, - getGroupOverview, getGroupsHealth, getLatestRuns, getPublicStatus, @@ -15,7 +14,7 @@ import { } from '../lib/api'; import { describeCron } from '../lib/cronUtils'; import { queryKeys } from '../lib/queryKeys'; -import type { Group, GroupHealthSummary, GroupStatus, Run, RunStatus } from '../lib/types'; +import type { GroupHealthSummary, GroupStatus, Run, RunStatus } from '../lib/types'; const configQueryOptions = queryOptions({ queryKey: queryKeys.config.all(), queryFn: getConfig }); const latestRunsQueryOptions = queryOptions({ @@ -54,248 +53,6 @@ function runStatusToFilter(status: RunStatus | undefined): 'ok' | 'running' | 'p return 'ok'; } -// ── Project card (grid view) ────────────────────────────────────────────────── - -function ProjectCard({ - group, - latestRun, - publicStatus, - health, - onRunNow, - isTriggering, -}: { - group: Group; - latestRun: Run | undefined; - publicStatus: GroupStatus | undefined; - health: GroupHealthSummary | undefined; - onRunNow: () => void; - isTriggering: boolean; -}) { - const { data: overview } = useQuery({ - queryKey: queryKeys.groups.overview(group.name), - queryFn: () => getGroupOverview(group.name), - staleTime: 5 * 60 * 1000, - enabled: !!getApiKey(), - }); - - const host = hostFromUrls(group.urls); - const sparkData = (overview?.series ?? []).slice(-20).map((s) => s.avgLoadTimeMs); - const avgLoad = overview?.stats.avgLoadTimeMs ?? null; - const totalRuns = overview?.stats.totalRuns ?? null; - const uptime = publicStatus?.uptimePct ?? null; - const hasIssues = health && Object.values(health.tabs).some(Boolean); - - const isWarn = latestRun?.status === 'partial_failure' || latestRun?.status === 'failed'; - const sparkColor = isWarn ? 'var(--pc-warn)' : 'var(--pc-accent)'; - const uptimeColor = - uptime === null - ? 'var(--foreground)' - : uptime >= 99 - ? 'var(--pc-ok)' - : uptime >= 95 - ? 'var(--pc-warn)' - : 'var(--pc-bad)'; - - return ( -
- {/* Title row */} - -
-
-
- {group.name} -
-
- {host} -
-
- {latestRun && } -
- - - {/* Sparkline */} -
- {sparkData.length >= 2 ? ( - - ) : ( -
- )} -
- - {/* Schedule + URL count */} -
- - - {describeCron(group.schedule)} - - - {group.urls.length} URLs - -
- - {/* Footer */} -
- - Uptime{' '} - - {uptime !== null ? `${uptime.toFixed(1)}%` : '—'} - - - - Avg{' '} - - {avgLoad !== null ? `${Math.round(avgLoad)}ms` : '—'} - - - - {hasIssues && ( - - Needs work - - )} - {totalRuns !== null && {totalRuns} runs} - -
- - {/* Action buttons */} -
- - - - History - - - - -
-
- ); -} - // ── Projects page ───────────────────────────────────────────────────────────── function ProjectsPage() { diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index bd3370e..fb27a55 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -1,6 +1,6 @@ -import { queryOptions, useQuery, useQueryClient } from '@tanstack/react-query'; -import { createFileRoute, Link } from '@tanstack/react-router'; -import { ChevronRight, Clock, RefreshCw } from 'lucide-react'; +import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'; +import { ChevronRight, RefreshCw } from 'lucide-react'; import { Bar, BarChart, @@ -17,15 +17,14 @@ import { import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; -import { Sparkline } from '../components/Sparkline'; +import { ProjectCard } from '../components/ProjectCard'; import { StatusBadge } from '../components/StatusBadge'; import { UptimeSegBars } from '../components/UptimeSegBars'; -import { getApiKey, getConfig, getGroupOverview, getLatestRuns, getPublicStatus, getStats } from '../lib/api'; +import { getApiKey, getConfig, getGroupOverview, getLatestRuns, getPublicStatus, getStats, triggerAsync } from '../lib/api'; import { CHART_TOOLTIP_STYLE, getColor, STATUS_COLORS, STATUS_LABELS } from '../lib/chartStyles'; -import { describeCron } from '../lib/cronUtils'; import { formatChartDate } from '../lib/formatChartDate'; import { queryKeys } from '../lib/queryKeys'; -import type { Group, GroupStatus, Run, Stats } from '../lib/types'; +import type { GroupStatus, Run, Stats } from '../lib/types'; const configQueryOptions = queryOptions({ queryKey: queryKeys.config.all(), queryFn: getConfig }); const latestRunsQueryOptions = queryOptions({ @@ -40,13 +39,21 @@ const publicStatusQueryOptions = queryOptions({ }); export const Route = createFileRoute('/')({ - loader: ({ context: { queryClient } }) => { + loader: async ({ context: { queryClient } }) => { if (!getApiKey()) return; - return Promise.all([ + const [config] = await Promise.all([ queryClient.ensureQueryData(configQueryOptions), queryClient.ensureQueryData(latestRunsQueryOptions), queryClient.ensureQueryData(statsQueryOptions), ]); + // Warm group overview cache so sparklines render immediately — don't block + for (const group of config?.groups ?? []) { + queryClient.prefetchQuery({ + queryKey: queryKeys.groups.overview(group.name), + queryFn: () => getGroupOverview(group.name), + staleTime: 5 * 60 * 1000, + }); + } }, pendingComponent: DashboardSkeleton, pendingMs: 200, @@ -129,12 +136,21 @@ function DashboardSkeleton() { function DashboardPage() { const queryClient = useQueryClient(); + const navigate = useNavigate(); const { data: config } = useQuery(configQueryOptions); const { data: latestRuns } = useQuery(latestRunsQueryOptions); const { data: stats } = useQuery(statsQueryOptions); const { data: publicStatus } = useQuery(publicStatusQueryOptions); + const trigger = useMutation({ + mutationFn: triggerAsync, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: queryKeys.runs.all() }); + navigate({ to: '/history/$runId', params: { runId: String(data.runId) } }); + }, + }); + const syncAll = () => queryClient.invalidateQueries(); const latestByGroup = new Map((latestRuns ?? []).map((r) => [r.group_name, r])); @@ -242,6 +258,8 @@ function DashboardPage() { group={group} latestRun={latestByGroup.get(group.name)} publicStatus={statusByGroup.get(group.name)} + onRunNow={() => trigger.mutate(group.name)} + isTriggering={trigger.isPending && trigger.variables === group.name} /> ))}
@@ -507,161 +525,6 @@ function DashboardPage() { // ── Helper components ───────────────────────────────────────────────────────── -function ProjectCard({ - group, - latestRun, - publicStatus, -}: { - group: Group; - latestRun: Run | undefined; - publicStatus: GroupStatus | undefined; -}) { - const { data: overview } = useQuery({ - queryKey: queryKeys.groups.overview(group.name), - queryFn: () => getGroupOverview(group.name), - staleTime: 5 * 60 * 1000, - enabled: !!getApiKey(), - }); - - const host = (() => { - try { - return new URL(group.urls[0]).host; - } catch { - return group.urls[0] ?? ''; - } - })(); - const sparkData = (overview?.series ?? []).slice(-20).map((s) => s.avgLoadTimeMs); - const avgLoad = overview?.stats.avgLoadTimeMs ?? null; - const totalRuns = overview?.stats.totalRuns ?? null; - const uptime = publicStatus?.uptimePct ?? null; - - const isWarn = latestRun?.status === 'partial_failure' || latestRun?.status === 'failed'; - const sparkColor = isWarn ? 'var(--pc-warn)' : 'var(--pc-accent)'; - const uptimeColor = - uptime === null - ? 'var(--foreground)' - : uptime >= 99 - ? 'var(--pc-ok)' - : uptime >= 95 - ? 'var(--pc-warn)' - : 'var(--pc-bad)'; - - return ( - -
- {/* Title row */} -
-
-
- {group.name} -
-
- {host} -
-
- {latestRun && } -
- - {/* Sparkline */} -
- {sparkData.length >= 2 ? ( - - ) : ( -
- )} -
- - {/* Schedule + URL count */} -
- - - {describeCron(group.schedule)} - - - {group.urls.length} URLs - -
- - {/* Footer */} -
- - Uptime{' '} - - {uptime !== null ? `${uptime.toFixed(1)}%` : '—'} - - - - Avg{' '} - - {avgLoad !== null ? `${Math.round(avgLoad)}ms` : '—'} - - - {totalRuns !== null && {totalRuns} runs} -
-
- - ); -} - function KpiTile({ label, value,