page.tsx164 lines · main
1import { RocketIcon } from '@/components/ui/rocket';
2
3import { apiJson } from '@/lib/api';
4import { toValidDate } from '@/lib/utils';
5
6import { EmptyState } from '../_components/empty-state';
7import { Section } from '../_components/section';
8import { StatCard } from '../_components/stat-card';
9
10interface DeployEntry {
11 id: string;
12 service: string;
13 buildSha: string;
14 buildAt: string | null;
15 env: string;
16 bootedAt: string | Date;
17}
18
19export const dynamic = 'force-dynamic';
20export const metadata = { title: 'admin · deploys' };
21
22function formatTs(t: string | Date): string {
23 const d = toValidDate(t);
24 return d ? d.toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '—';
25}
26
27function relativeTime(t: string | Date): string {
28 const d = typeof t === 'string' ? new Date(t) : t;
29 const then = d.getTime();
30 if (!Number.isFinite(then)) return '';
31 const sec = Math.max(0, Math.round((Date.now() - then) / 1000));
32 if (sec < 60) return `${sec}s ago`;
33 if (sec < 3600) return `${Math.round(sec / 60)}m ago`;
34 if (sec < 86400) return `${Math.round(sec / 3600)}h ago`;
35 return `${Math.round(sec / 86400)}d ago`;
36}
37
38export default async function DeploysAdminPage() {
39 const { deploys } = await apiJson<{ deploys: DeployEntry[] }>(
40 '/v1/admin/deploys?limit=100',
41 ).catch(() => ({ deploys: [] as DeployEntry[] }));
42
43 // Honest stats — all derived from the rows actually fetched (last 100).
44 const todayUtc = new Date().toISOString().slice(0, 10);
45 const deploysToday = deploys.filter((d) => {
46 const booted = toValidDate(d.bootedAt);
47 return booted !== null && booted.toISOString().slice(0, 10) === todayUtc;
48 }).length;
49 const services = new Set(deploys.map((d) => d.service)).size;
50
51 return (
52 <div className="flex flex-col gap-10">
53 <header className="flex flex-col gap-2">
54 <div className="flex items-center gap-2">
55 <span className="text-[var(--color-primary)]">
56 <RocketIcon size={20} />
57 </span>
58 <h1 className="font-mono text-xl tracking-tight">deploys</h1>
59 </div>
60 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
61 one row per api boot, newest first. the build sha comes from{' '}
62 <code>BRIVEN_BUILD_SHA</code> (passed by <code>scripts/deploy-kvm4.sh</code>) or — when
63 absent — from <code>.git/HEAD</code> inside the image. local boots with sha{' '}
64 <code>&quot;dev&quot;</code> are intentionally skipped.
65 </p>
66 </header>
67
68 {deploys.length > 0 ? (
69 <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
70 <StatCard
71 label="deploys recorded"
72 value={deploys.length}
73 icon={<RocketIcon size={14} />}
74 hint="most recent 100 boots"
75 />
76 <StatCard
77 label="deploys today"
78 value={deploysToday}
79 tone={deploysToday > 0 ? 'primary' : 'default'}
80 icon={<RocketIcon size={14} />}
81 hint="utc · within the 100 shown"
82 />
83 <StatCard
84 label="services"
85 value={services}
86 icon={<RocketIcon size={14} />}
87 hint="distinct services in this window"
88 />
89 </div>
90 ) : null}
91
92 <Section
93 title="deploy history"
94 icon={<RocketIcon size={16} />}
95 right={
96 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
97 newest first · last 100
98 </span>
99 }
100 >
101 {deploys.length === 0 ? (
102 <EmptyState
103 icon={<RocketIcon size={28} />}
104 title="no deploys recorded yet"
105 message="the table is populated automatically the next time the api boots with a real build sha."
106 />
107 ) : (
108 <div className="overflow-x-auto rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
109 <table className="w-full font-mono text-xs">
110 <thead className="text-left text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
111 <tr>
112 <th className="px-6 py-4 font-medium">booted</th>
113 <th className="px-6 py-4 font-medium">service</th>
114 <th className="px-6 py-4 font-medium">sha</th>
115 <th className="px-6 py-4 font-medium">built</th>
116 <th className="px-6 py-4 font-medium">env</th>
117 </tr>
118 </thead>
119 <tbody>
120 {deploys.map((d, idx) => {
121 const isCurrent = idx === 0;
122 return (
123 <tr
124 key={d.id}
125 className="border-t border-[var(--color-border-subtle)] align-top"
126 >
127 <td className="whitespace-nowrap px-6 py-4 text-[var(--color-text-subtle)]">
128 <div>{formatTs(d.bootedAt)}</div>
129 <div className="mt-0.5 text-[var(--color-text-muted)]">
130 {relativeTime(d.bootedAt)}
131 </div>
132 </td>
133 <td className="px-6 py-4">
134 <span className="inline-flex rounded-full border border-[var(--color-border-subtle)] px-2 py-0.5 text-[var(--color-text)]">
135 {d.service}
136 </span>
137 {isCurrent ? (
138 <span className="ml-2 inline-flex rounded-full bg-[var(--color-primary-subtle)] px-2 py-0.5 text-[var(--color-primary)]">
139 live
140 </span>
141 ) : null}
142 </td>
143 <td className="px-6 py-4">
144 <code className="text-[var(--color-text)]">{d.buildSha.slice(0, 12)}</code>
145 </td>
146 <td className="px-6 py-4 text-[var(--color-text-muted)]">
147 {d.buildAt ? formatTs(d.buildAt) : '—'}
148 </td>
149 <td className="px-6 py-4">
150 <span className="rounded-full border border-[var(--color-border-subtle)] px-2 py-0.5 text-[9px] uppercase tracking-wider text-[var(--color-text-subtle)]">
151 {d.env}
152 </span>
153 </td>
154 </tr>
155 );
156 })}
157 </tbody>
158 </table>
159 </div>
160 )}
161 </Section>
162 </div>
163 );
164}