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,