dashboard: add system status view, settings page, status API

Redesign dashboard as an ops status page with CPU/memory/disk gauges,
service health checks, and per-container resource bars. Add /api/status
endpoint that probes backend services and reads system metrics. Add
/settings page with theme toggle and API token management. Settings
accessible from user pill dropdown in navbar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Anton Kaminsky23d ago27902ea02f0eparent ff50d03
5 files changed+805-406
web/app/api/status/route.ts
@@ -0,0 +1,255 @@
1import { NextResponse } from "next/server";
2
3interface ServiceCheck {
4 name: string;
5 status: "operational" | "degraded" | "down";
6 latency: number | null;
7 detail?: string;
8}
9
10interface ContainerStats {
11 name: string;
12 status: string;
13 cpu_percent: number;
14 mem_usage_mb: number;
15 mem_limit_mb: number;
16 mem_percent: number;
17 net_rx_mb: number;
18 net_tx_mb: number;
19 uptime: string;
20}
21
22interface SystemMetrics {
23 cpu_percent: number;
24 mem_total_mb: number;
25 mem_used_mb: number;
26 mem_percent: number;
27 disk_total_gb: number;
28 disk_used_gb: number;
29 disk_percent: number;
30 load_avg: number[];
31 uptime: string;
32}
33
34async function checkService(
35 name: string,
36 url: string,
37 timeout = 5000
38): Promise<ServiceCheck> {
39 const start = Date.now();
40 try {
41 const controller = new AbortController();
42 const timer = setTimeout(() => controller.abort(), timeout);
43 const res = await fetch(url, { signal: controller.signal, cache: "no-store" });
44 clearTimeout(timer);
45 const latency = Date.now() - start;
46 if (res.ok || res.status === 401) {
47 return { name, status: "operational", latency };
48 }
49 return { name, status: "degraded", latency, detail: `HTTP ${res.status}` };
50 } catch (err: unknown) {
51 return {
52 name,
53 status: "down",
54 latency: null,
55 detail: err instanceof Error ? err.message : "Connection failed",
56 };
57 }
58}
59
60async function getDockerStats(): Promise<ContainerStats[]> {
61 try {
62 // Docker socket — works when running inside Docker on the host
63 const controller = new AbortController();
64 const timer = setTimeout(() => controller.abort(), 5000);
65 const res = await fetch("http://localhost:2375/containers/json?all=false", {
66 signal: controller.signal,
67 cache: "no-store",
68 });
69 clearTimeout(timer);
70 if (!res.ok) return [];
71
72 const containers: any[] = await res.json();
73 const stats: ContainerStats[] = [];
74
75 for (const c of containers) {
76 const name = (c.Names?.[0] ?? "").replace(/^\//, "").replace(/^grove-/, "").replace(/-1$/, "");
77 const upSince = c.Created ? new Date(c.Created * 1000) : null;
78 const uptimeMs = upSince ? Date.now() - upSince.getTime() : 0;
79
80 try {
81 const sRes = await fetch(`http://localhost:2375/containers/${c.Id}/stats?stream=false`, {
82 cache: "no-store",
83 });
84 if (sRes.ok) {
85 const s = await sRes.json();
86 const cpuDelta = s.cpu_stats.cpu_usage.total_usage - s.precpu_stats.cpu_usage.total_usage;
87 const sysDelta = s.cpu_stats.system_cpu_usage - s.precpu_stats.system_cpu_usage;
88 const cpuCount = s.cpu_stats.online_cpus || 1;
89 const cpuPercent = sysDelta > 0 ? (cpuDelta / sysDelta) * cpuCount * 100 : 0;
90
91 const memUsage = s.memory_stats.usage - (s.memory_stats.stats?.cache || 0);
92 const memLimit = s.memory_stats.limit;
93
94 const netRx = Object.values(s.networks || {}).reduce((a: number, n: any) => a + (n.rx_bytes || 0), 0);
95 const netTx = Object.values(s.networks || {}).reduce((a: number, n: any) => a + (n.tx_bytes || 0), 0);
96
97 stats.push({
98 name,
99 status: c.State,
100 cpu_percent: Math.round(cpuPercent * 100) / 100,
101 mem_usage_mb: Math.round(memUsage / 1024 / 1024),
102 mem_limit_mb: Math.round(memLimit / 1024 / 1024),
103 mem_percent: Math.round((memUsage / memLimit) * 10000) / 100,
104 net_rx_mb: Math.round(netRx / 1024 / 1024 * 100) / 100,
105 net_tx_mb: Math.round(netTx / 1024 / 1024 * 100) / 100,
106 uptime: formatUptime(uptimeMs),
107 });
108 continue;
109 }
110 } catch {}
111
112 stats.push({
113 name,
114 status: c.State,
115 cpu_percent: 0,
116 mem_usage_mb: 0,
117 mem_limit_mb: 0,
118 mem_percent: 0,
119 net_rx_mb: 0,
120 net_tx_mb: 0,
121 uptime: formatUptime(uptimeMs),
122 });
123 }
124
125 return stats;
126 } catch {
127 return [];
128 }
129}
130
131async function getSystemMetrics(): Promise<SystemMetrics | null> {
132 try {
133 // Use /proc on Linux hosts
134 const [memInfo, loadAvg, uptime, diskStat] = await Promise.allSettled([
135 fetchFile("/proc/meminfo"),
136 fetchFile("/proc/loadavg"),
137 fetchFile("/proc/uptime"),
138 fetchFile("/proc/diskstats"),
139 ]);
140
141 let cpuPercent = 0;
142 let memTotal = 0;
143 let memUsed = 0;
144 let memPercent = 0;
145 let loads: number[] = [];
146 let uptimeStr = "";
147
148 if (memInfo.status === "fulfilled" && memInfo.value) {
149 const lines = memInfo.value.split("\n");
150 const get = (key: string) => {
151 const line = lines.find((l: string) => l.startsWith(key));
152 return line ? parseInt(line.split(/\s+/)[1]) : 0;
153 };
154 memTotal = Math.round(get("MemTotal:") / 1024);
155 const memFree = get("MemFree:");
156 const buffers = get("Buffers:");
157 const cached = get("Cached:");
158 const available = get("MemAvailable:");
159 memUsed = Math.round((get("MemTotal:") - available) / 1024);
160 memPercent = Math.round((memUsed / memTotal) * 100);
161 }
162
163 if (loadAvg.status === "fulfilled" && loadAvg.value) {
164 const parts = loadAvg.value.trim().split(/\s+/);
165 loads = parts.slice(0, 3).map(Number);
166 // rough CPU% from 1-min load avg
167 const { cpus } = await import("os");
168 const numCpus = cpus().length;
169 cpuPercent = Math.min(100, Math.round((loads[0] / numCpus) * 100));
170 }
171
172 if (uptime.status === "fulfilled" && uptime.value) {
173 const secs = parseFloat(uptime.value.split(" ")[0]);
174 uptimeStr = formatUptime(secs * 1000);
175 }
176
177 // Disk usage via statfs-like approach (use os module)
178 let diskTotal = 0;
179 let diskUsed = 0;
180 let diskPercent = 0;
181 try {
182 const { execSync } = await import("child_process");
183 const df = execSync("df -BG / 2>/dev/null || df -g / 2>/dev/null", { encoding: "utf-8" });
184 const line = df.trim().split("\n")[1];
185 if (line) {
186 const parts = line.split(/\s+/);
187 diskTotal = parseInt(parts[1]);
188 diskUsed = parseInt(parts[2]);
189 diskPercent = Math.round((diskUsed / diskTotal) * 100);
190 }
191 } catch {}
192
193 return {
194 cpu_percent: cpuPercent,
195 mem_total_mb: memTotal,
196 mem_used_mb: memUsed,
197 mem_percent: memPercent,
198 disk_total_gb: diskTotal,
199 disk_used_gb: diskUsed,
200 disk_percent: diskPercent,
201 load_avg: loads,
202 uptime: uptimeStr,
203 };
204 } catch {
205 return null;
206 }
207}
208
209async function fetchFile(path: string): Promise<string | null> {
210 try {
211 const { readFileSync } = await import("fs");
212 return readFileSync(path, "utf-8");
213 } catch {
214 return null;
215 }
216}
217
218function formatUptime(ms: number): string {
219 const secs = Math.floor(ms / 1000);
220 const days = Math.floor(secs / 86400);
221 const hours = Math.floor((secs % 86400) / 3600);
222 const mins = Math.floor((secs % 3600) / 60);
223 if (days > 0) return `${days}d ${hours}h`;
224 if (hours > 0) return `${hours}h ${mins}m`;
225 return `${mins}m`;
226}
227
228export async function GET() {
229 const [checks, containers, system] = await Promise.all([
230 Promise.all([
231 checkService("Web", "http://grove-web:3000/"),
232 checkService("API", "http://grove-api:4000/api/health"),
233 checkService("Hub API", "http://hub-api:4000/api/auth/me"),
234 checkService("EdenAPI", "http://mononoke-slapi:8443/health_check"),
235 checkService("Git", "http://mononoke-git:8080/health_check"),
236 checkService("Bridge", "http://grove-bridge:8443/health_check"),
237 ]),
238 getDockerStats(),
239 getSystemMetrics(),
240 ]);
241
242 const overall = checks.every((c) => c.status === "operational")
243 ? "operational"
244 : checks.some((c) => c.status === "down")
245 ? "major_outage"
246 : "degraded";
247
248 return NextResponse.json({
249 status: overall,
250 services: checks,
251 containers,
252 system,
253 checked_at: new Date().toISOString(),
254 });
255}
0256
web/app/dashboard/page.tsx
@@ -1,40 +1,59 @@
11"use client";
22
3import { useEffect, useState } from "react";
3import { useEffect, useState, useCallback } from "react";
44import { useRouter } from "next/navigation";
5import Link from "next/link";
65import { useAuth } from "@/lib/auth";
7import { useTheme } from "@/lib/theme";
8import {
9 instances as instancesApi,
10 repos as reposApi,
11 orgs as orgsApi,
12 ApiError,
13 type Instance,
14 type Repo,
15 type Org,
16} from "@/lib/api";
17import { timeAgo, timeAgoFromDate } from "@/lib/utils";
186import { Skeleton } from "@/app/components/skeleton";
19import { Badge } from "@/app/components/ui/badge";
7
8/* ── Types ── */
9
10interface ServiceCheck {
11 name: string;
12 status: "operational" | "degraded" | "down";
13 latency: number | null;
14 detail?: string;
15}
16
17interface ContainerStats {
18 name: string;
19 status: string;
20 cpu_percent: number;
21 mem_usage_mb: number;
22 mem_limit_mb: number;
23 mem_percent: number;
24 net_rx_mb: number;
25 net_tx_mb: number;
26 uptime: string;
27}
28
29interface SystemMetrics {
30 cpu_percent: number;
31 mem_total_mb: number;
32 mem_used_mb: number;
33 mem_percent: number;
34 disk_total_gb: number;
35 disk_used_gb: number;
36 disk_percent: number;
37 load_avg: number[];
38 uptime: string;
39}
40
41interface StatusResponse {
42 status: string;
43 services: ServiceCheck[];
44 containers: ContainerStats[];
45 system: SystemMetrics | null;
46 checked_at: string;
47}
48
49/* ── Page ── */
2050
2151export default function DashboardPage() {
22 const { user, loading: authLoading, logout } = useAuth();
23 const { theme, toggle: toggleTheme } = useTheme();
52 const { user, loading: authLoading } = useAuth();
2453 const router = useRouter();
25 const [instanceList, setInstanceList] = useState<Instance[]>([]);
26 const [repoList, setRepoList] = useState<Repo[]>([]);
27 const [orgList, setOrgList] = useState<Org[]>([]);
28 const [loaded, setLoaded] = useState(false);
29 const [error, setError] = useState("");
30 const [lastCommitTimes, setLastCommitTimes] = useState<Record<number, number>>({});
3154
32 // New org form
33 const [showNewOrg, setShowNewOrg] = useState(false);
34 const [orgName, setOrgName] = useState("");
35 const [orgDisplayName, setOrgDisplayName] = useState("");
36 const [orgCreating, setOrgCreating] = useState(false);
37 const [orgError, setOrgError] = useState("");
55 const [data, setData] = useState<StatusResponse | null>(null);
56 const [loaded, setLoaded] = useState(false);
3857
3958 useEffect(() => {
4059 document.title = "Dashboard";
@@ -46,408 +65,277 @@
4665 }
4766 }, [authLoading, user, router]);
4867
68 const refresh = useCallback(async () => {
69 if (!user) return;
70 try {
71 const res = await fetch("/api/status", { cache: "no-store" });
72 const json: StatusResponse = await res.json();
73 setData(json);
74 } catch {}
75 setLoaded(true);
76 }, [user]);
77
4978 useEffect(() => {
5079 if (!user) return;
51 setError("");
52 Promise.allSettled([
53 instancesApi.list(),
54 reposApi.list(),
55 orgsApi.list(),
56 ])
57 .then(([instResult, repoResult, orgResult]) => {
58 const failures = [instResult, repoResult, orgResult].filter(
59 (result): result is PromiseRejectedResult => result.status === "rejected"
60 );
80 refresh();
81 const iv = setInterval(refresh, 30000);
82 return () => clearInterval(iv);
83 }, [user, refresh]);
6184
62 const authFailure = failures.find(
63 (result) =>
64 result.reason instanceof ApiError && result.reason.status === 401
65 );
66 if (authFailure) {
67 logout();
68 router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`);
69 return;
70 }
85 if (authLoading || !user) return null;
7186
72 setInstanceList(
73 instResult.status === "fulfilled" ? instResult.value.instances : []
74 );
75 setRepoList(
76 repoResult.status === "fulfilled" ? repoResult.value.repos : []
77 );
78 setOrgList(
79 orgResult.status === "fulfilled" ? orgResult.value.orgs : []
80 );
87 const sys = data?.system;
88 const containers = data?.containers ?? [];
89 const services = data?.services ?? [];
8190
82 if (failures.length > 0) {
83 const messages = failures
84 .map((result) =>
85 result.reason instanceof Error
86 ? result.reason.message
87 : "Unknown API error"
88 )
89 .join(" · ");
90 setError(`Some dashboard data failed to load: ${messages}`);
91 }
92 })
93 .finally(() => setLoaded(true));
94 }, [user, logout, router]);
91 return (
92 <div style={{ maxWidth: 1100, margin: "0 auto", padding: "1.25rem 1rem 2rem" }}>
93 {/* ── Header ── */}
94 <div className="flex items-center justify-between" style={{ marginBottom: "1rem" }}>
95 <span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Dashboard</span>
96 <div className="flex items-center gap-3">
97 {data?.checked_at && (
98 <span className="text-xs" style={{ color: "var(--text-faint)" }}>
99 {new Date(data.checked_at).toLocaleTimeString()}
100 </span>
101 )}
102 <button onClick={refresh} className="btn-reset text-xs" style={{ color: "var(--text-muted)" }}>
103 Refresh
104 </button>
105 </div>
106 </div>
95107
96 // Fetch last commit time for each repo
97 useEffect(() => {
98 if (repoList.length === 0) return;
99 Promise.all(
100 repoList.map(async (repo) => {
101 try {
102 const data = await reposApi.commits(repo.owner_name, repo.name, "main", { limit: 1 });
103 const latest = data.commits[0];
104 if (latest) return { id: repo.id, timestamp: latest.timestamp };
105 } catch {}
106 return null;
107 })
108 ).then((results) => {
109 const times: Record<number, number> = {};
110 for (const r of results) {
111 if (r) times[r.id] = r.timestamp;
112 }
113 setLastCommitTimes(times);
114 });
115 }, [repoList]);
108 {/* ── System gauges ── */}
109 <div
110 style={{
111 display: "grid",
112 gridTemplateColumns: "repeat(4, 1fr)",
113 gap: "0.625rem",
114 marginBottom: "0.875rem",
115 }}
116 >
117 <GaugeCard
118 label="CPU"
119 value={sys?.cpu_percent ?? null}
120 detail={sys?.load_avg ? `Load ${sys.load_avg.map((l) => l.toFixed(1)).join(" ")}` : undefined}
121 loaded={loaded}
122 />
123 <GaugeCard
124 label="Memory"
125 value={sys?.mem_percent ?? null}
126 detail={sys ? `${sys.mem_used_mb} / ${sys.mem_total_mb} MB` : undefined}
127 loaded={loaded}
128 />
129 <GaugeCard
130 label="Disk"
131 value={sys?.disk_percent ?? null}
132 detail={sys ? `${sys.disk_used_gb} / ${sys.disk_total_gb} GB` : undefined}
133 loaded={loaded}
134 />
135 <GaugeCard
136 label="Uptime"
137 value={null}
138 displayValue={sys?.uptime || null}
139 loaded={loaded}
140 />
141 </div>
116142
117 async function handleCreateOrg(e: React.FormEvent) {
118 e.preventDefault();
119 setOrgError("");
120 setOrgCreating(true);
121 try {
122 const { org } = await orgsApi.create({
123 name: orgName,
124 display_name: orgDisplayName || undefined,
125 });
126 setOrgList((prev) => [org, ...prev]);
127 setOrgName("");
128 setOrgDisplayName("");
129 setShowNewOrg(false);
130 } catch (err: unknown) {
131 setOrgError(
132 err instanceof Error ? err.message : "Failed to create org"
133 );
134 } finally {
135 setOrgCreating(false);
136 }
137 }
143 {/* ── Services + Containers ── */}
144 <div
145 style={{
146 display: "grid",
147 gridTemplateColumns: "1fr 1fr",
148 gap: "0.625rem",
149 }}
150 >
151 <Panel title="Services" count={services.length}>
152 {!loaded ? (
153 <SkeletonRows n={6} />
154 ) : services.length === 0 ? (
155 <EmptyRow>No service data</EmptyRow>
156 ) : (
157 <div className="flex flex-col">
158 {services.map((svc) => (
159 <div
160 key={svc.name}
161 className="flex items-center justify-between py-1.5 px-2.5"
162 style={{ borderBottom: "1px solid var(--divide)" }}
163 >
164 <div className="flex items-center gap-2">
165 <StatusDot status={svc.status} />
166 <span className="text-sm" style={{ color: "var(--text-primary)" }}>{svc.name}</span>
167 </div>
168 <span className="text-xs font-mono" style={{ color: "var(--text-faint)" }}>
169 {svc.latency !== null ? `${svc.latency}ms` : svc.detail ?? "—"}
170 </span>
171 </div>
172 ))}
173 </div>
174 )}
175 </Panel>
138176
139 if (authLoading || !user) return null;
177 <Panel title="Containers" count={containers.length}>
178 {!loaded ? (
179 <SkeletonRows n={6} />
180 ) : containers.length === 0 ? (
181 <EmptyRow>No container data</EmptyRow>
182 ) : (
183 <div className="flex flex-col">
184 {containers.map((c) => (
185 <div
186 key={c.name}
187 className="flex items-center justify-between py-1.5 px-2.5"
188 style={{ borderBottom: "1px solid var(--divide)" }}
189 >
190 <div className="flex items-center gap-2 min-w-0">
191 <StatusDot status={c.status === "running" ? "operational" : "down"} />
192 <span className="text-sm truncate" style={{ color: "var(--text-primary)" }}>{c.name}</span>
193 <span className="text-xs shrink-0" style={{ color: "var(--text-faint)" }}>{c.uptime}</span>
194 </div>
195 <div className="flex items-center gap-3 shrink-0">
196 <MiniBar value={c.cpu_percent} max={100} label={`${c.cpu_percent.toFixed(1)}%`} color="var(--accent)" />
197 <MiniBar value={c.mem_usage_mb} max={c.mem_limit_mb || 1} label={`${c.mem_usage_mb}M`} color="var(--status-merged-text)" />
198 </div>
199 </div>
200 ))}
201 </div>
202 )}
203 </Panel>
204 </div>
205 </div>
206 );
207}
208
209/* ── Gauge card ── */
140210
141 const activeInstances = instanceList.filter((i) => i.status === "active");
142 const hasInstance = activeInstances.length > 0;
211function GaugeCard({
212 label,
213 value,
214 displayValue,
215 detail,
216 loaded,
217}: {
218 label: string;
219 value: number | null;
220 displayValue?: string | null;
221 detail?: string;
222 loaded: boolean;
223}) {
224 const pct = value !== null ? Math.min(100, Math.max(0, value)) : null;
225 const color =
226 pct === null
227 ? "var(--text-faint)"
228 : pct > 85
229 ? "var(--status-closed-text)"
230 : pct > 60
231 ? "var(--status-merged-text)"
232 : "var(--status-open-text)";
143233
144234 return (
145 <div className="max-w-3xl mx-auto px-4 py-6 flex flex-col gap-8">
146 {/* Greeting */}
147 <div className="flex items-center gap-3">
235 <div
236 style={{
237 backgroundColor: "var(--bg-card)",
238 border: "1px solid var(--border-subtle)",
239 padding: "0.75rem 0.875rem",
240 position: "relative",
241 overflow: "hidden",
242 }}
243 >
244 {pct !== null && (
148245 <div
149 className="shrink-0 flex items-center justify-center text-sm"
150246 style={{
151 width: 36,
152 height: 36,
153 borderRadius: "50%",
154 backgroundColor: "var(--bg-inset)",
155 border: "1px solid var(--border-subtle)",
156 color: "var(--text-muted)",
247 position: "absolute",
248 left: 0,
249 bottom: 0,
250 width: `${pct}%`,
251 height: 3,
252 backgroundColor: color,
253 transition: "width 0.6s ease, background-color 0.3s",
157254 }}
158 >
159 {(user.display_name || user.username).charAt(0).toUpperCase()}
160 </div>
161 <div>
162 <div className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
163 {user.display_name || user.username}
255 />
256 )}
257 <div className="text-xs font-mono" style={{ color: "var(--text-faint)", marginBottom: 3 }}>
258 {label}
259 </div>
260 {!loaded ? (
261 <Skeleton width="3rem" height="1.25rem" />
262 ) : (
263 <>
264 <div
265 className="font-mono"
266 style={{ fontSize: "1.375rem", fontWeight: 500, color: displayValue ? "var(--text-primary)" : color, lineHeight: 1.1 }}
267 >
268 {displayValue ?? (pct !== null ? `${pct}%` : "—")}
164269 </div>
165 {loaded ? (
166 <div className="text-xs" style={{ color: "var(--text-faint)" }}>
167 {repoList.length} {repoList.length === 1 ? "repository" : "repositories"}
168 {orgList.length > 0 && (
169 <> · {orgList.length} {orgList.length === 1 ? "org" : "orgs"}</>
170 )}
171 {activeInstances.length > 0 && (
172 <> · {activeInstances.length} active {activeInstances.length === 1 ? "instance" : "instances"}</>
173 )}
174 </div>
175 ) : (
176 <Skeleton width="160px" height="0.75rem" />
270 {detail && (
271 <div className="text-xs" style={{ color: "var(--text-faint)", marginTop: 2 }}>{detail}</div>
177272 )}
178 </div>
179 </div>
180
181 {error && (
182 <div
183 className="text-sm px-3 py-2"
184 style={{ color: "var(--status-closed-text)", backgroundColor: "var(--bg-inset)", border: "1px solid var(--border-subtle)" }}
185 >
186 {error}
187 </div>
273 </>
188274 )}
275 </div>
276 );
277}
189278
190 {/* Repositories */}
191 <div>
192 <div className="flex items-center justify-between pb-2" style={{ borderBottom: "1px solid var(--divide)" }}>
193 <div className="flex items-center gap-2">
194 <span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Repositories</span>
195 {loaded && (
196 <span className="text-xs" style={{ color: "var(--text-faint)" }}>{repoList.length}</span>
197 )}
198 </div>
199 <Link
200 href="/new"
201 className="text-xs hover:underline"
202 style={{ color: "var(--accent)", textDecoration: "none" }}
203 >
204 New repository
205 </Link>
206 </div>
279/* ── Mini bar ── */
207280
208 {!loaded ? (
209 <div className="py-3 space-y-1">
210 <Skeleton width="100%" height="2.5rem" />
211 <Skeleton width="100%" height="2.5rem" />
212 </div>
213 ) : repoList.length === 0 ? (
214 <div className="py-8 text-center">
215 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
216 {hasInstance
217 ? "No repositories yet. Create your first repository to start hosting code."
218 : "Deploy an instance first, then create repositories."}
219 </p>
220 <Link
221 href={hasInstance ? "/new" : "/deploy"}
222 className="text-xs mt-2 inline-block hover:underline"
223 style={{ color: "var(--accent)" }}
224 >
225 {hasInstance ? "New repository" : "Deploy instance"}
226 </Link>
227 </div>
228 ) : (
229 <div>
230 {repoList.map((repo) => (
231 <Link
232 key={repo.id}
233 href={`/${repo.owner_name}/${repo.name}`}
234 className="flex items-center justify-between py-2.5 px-1 hover-row"
235 style={{ borderBottom: "1px solid var(--divide)", textDecoration: "none", color: "inherit" }}
236 >
237 <div className="flex items-center gap-1.5 text-sm min-w-0">
238 <span style={{ color: "var(--text-muted)" }}>{repo.owner_name}</span>
239 <span style={{ color: "var(--text-faint)" }}>/</span>
240 <span style={{ color: "var(--text-primary)" }}>{repo.name}</span>
241 {repo.description && (
242 <span className="text-xs truncate ml-2" style={{ color: "var(--text-faint)" }}>
243 {repo.description}
244 </span>
245 )}
246 </div>
247 <span className="text-xs shrink-0 ml-3" style={{ color: "var(--text-faint)" }}>
248 {lastCommitTimes[repo.id]
249 ? timeAgo(lastCommitTimes[repo.id])
250 : timeAgoFromDate(repo.created_at)}
251 </span>
252 </Link>
253 ))}
254 </div>
255 )}
281function MiniBar({ value, max, label, color }: { value: number; max: number; label: string; color: string }) {
282 const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
283 return (
284 <div className="flex items-center gap-1.5" style={{ minWidth: 70 }}>
285 <div style={{ width: 36, height: 6, backgroundColor: "var(--bg-inset)", borderRadius: 3, overflow: "hidden" }}>
286 <div style={{ width: `${pct}%`, height: "100%", backgroundColor: color, borderRadius: 3, transition: "width 0.4s ease" }} />
256287 </div>
288 <span className="text-xs font-mono" style={{ color: "var(--text-faint)" }}>{label}</span>
289 </div>
290 );
291}
257292
258 {/* Organizations */}
259 <div>
260 <div className="flex items-center justify-between pb-2" style={{ borderBottom: "1px solid var(--divide)" }}>
261 <div className="flex items-center gap-2">
262 <span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Organizations</span>
263 {loaded && (
264 <span className="text-xs" style={{ color: "var(--text-faint)" }}>{orgList.length}</span>
265 )}
266 </div>
267 <button
268 onClick={() => setShowNewOrg((v) => !v)}
269 className="btn-reset text-xs hover:underline"
270 style={{ color: "var(--text-muted)", font: "inherit", fontSize: "0.75rem" }}
271 >
272 {showNewOrg ? "Cancel" : "New org"}
273 </button>
274 </div>
275
276 {showNewOrg && (
277 <form
278 onSubmit={handleCreateOrg}
279 className="py-3 flex flex-col gap-2"
280 style={{ borderBottom: "1px solid var(--divide)" }}
281 >
282 <div className="text-xs" style={{ color: "var(--text-muted)" }}>Create a new organization</div>
283 <input
284 value={orgName}
285 onChange={(e) => setOrgName(e.target.value)}
286 placeholder="org-name"
287 required
288 className="text-sm px-2.5 py-1.5"
289 style={{
290 backgroundColor: "var(--bg-input)",
291 border: "1px solid var(--border-subtle)",
292 color: "var(--text-primary)",
293 font: "inherit",
294 fontSize: "0.875rem",
295 }}
296 />
297 <input
298 value={orgDisplayName}
299 onChange={(e) => setOrgDisplayName(e.target.value)}
300 placeholder="Display name (optional)"
301 className="text-sm px-2.5 py-1.5"
302 style={{
303 backgroundColor: "var(--bg-input)",
304 border: "1px solid var(--border-subtle)",
305 color: "var(--text-primary)",
306 font: "inherit",
307 fontSize: "0.875rem",
308 }}
309 />
310 {orgError && (
311 <div className="text-xs" style={{ color: "var(--status-closed-text)" }}>{orgError}</div>
312 )}
313 <button
314 type="submit"
315 disabled={orgCreating}
316 className="text-xs self-start px-3 py-1.5"
317 style={{
318 backgroundColor: "var(--accent)",
319 color: "var(--accent-text)",
320 border: "none",
321 cursor: orgCreating ? "wait" : "pointer",
322 font: "inherit",
323 fontSize: "0.75rem",
324 opacity: orgCreating ? 0.6 : 1,
325 }}
326 >
327 {orgCreating ? "Creating..." : "Create"}
328 </button>
329 </form>
330 )}
293/* ── Panel ── */
331294
332 {!loaded ? (
333 <div className="py-3">
334 <Skeleton width="100%" height="2.5rem" />
335 </div>
336 ) : orgList.length === 0 ? (
337 <div className="py-8 text-center">
338 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
339 No organizations yet.
340 </p>
341 <button
342 onClick={() => setShowNewOrg(true)}
343 className="btn-reset text-xs mt-2 hover:underline"
344 style={{ color: "var(--accent)", font: "inherit", fontSize: "0.75rem" }}
345 >
346 Create organization
347 </button>
348 </div>
349 ) : (
350 <div>
351 {orgList.map((org) => (
352 <Link
353 key={org.id}
354 href={`/${org.name}`}
355 className="flex items-center py-2.5 px-1 hover-row text-sm"
356 style={{ borderBottom: "1px solid var(--divide)", textDecoration: "none", color: "var(--text-primary)" }}
357 >
358 {org.display_name || org.name}
359 </Link>
360 ))}
361 </div>
295function Panel({ title, count, children }: { title: string; count?: number; children: React.ReactNode }) {
296 return (
297 <div
298 style={{
299 backgroundColor: "var(--bg-card)",
300 border: "1px solid var(--border-subtle)",
301 display: "flex",
302 flexDirection: "column",
303 }}
304 >
305 <div className="flex items-center gap-2 px-2.5 py-1.5" style={{ borderBottom: "1px solid var(--divide)" }}>
306 <span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>{title}</span>
307 {count !== undefined && (
308 <span className="text-xs" style={{ color: "var(--text-faint)" }}>{count}</span>
362309 )}
363310 </div>
311 <div style={{ flex: 1 }}>{children}</div>
312 </div>
313 );
314}
364315
365 {/* Instances */}
366 <div>
367 <div className="flex items-center justify-between pb-2" style={{ borderBottom: "1px solid var(--divide)" }}>
368 <div className="flex items-center gap-2">
369 <span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Instances</span>
370 {loaded && (
371 <span className="text-xs" style={{ color: "var(--text-faint)" }}>{instanceList.length}</span>
372 )}
373 </div>
374 <Link
375 href="/deploy"
376 className="text-xs hover:underline"
377 style={{ color: "var(--text-muted)", textDecoration: "none" }}
378 >
379 Deploy new
380 </Link>
381 </div>
382316
383 {!loaded ? (
384 <div className="py-3 space-y-1">
385 <Skeleton width="100%" height="2.5rem" />
386 <Skeleton width="100%" height="2.5rem" />
387 </div>
388 ) : instanceList.length === 0 ? (
389 <div className="py-8 text-center">
390 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
391 No instances yet. Deploy a Grove instance on any VPS to start hosting repositories.
392 </p>
393 <Link
394 href="/deploy"
395 className="text-xs mt-2 inline-block hover:underline"
396 style={{ color: "var(--accent)" }}
397 >
398 Deploy your first instance
399 </Link>
400 </div>
401 ) : (
402 <div>
403 {instanceList.map((inst) => (
404 <div
405 key={inst.id}
406 className="flex items-center justify-between py-2.5 px-1"
407 style={{ borderBottom: "1px solid var(--divide)" }}
408 >
409 <div>
410 <div className="text-sm" style={{ color: "var(--text-primary)" }}>{inst.name}</div>
411 <div className="text-xs mt-0.5" style={{ color: "var(--text-faint)" }}>{inst.region}</div>
412 </div>
413 <Badge
414 variant={
415 inst.status === "active"
416 ? "passed"
417 : inst.status === "creating"
418 ? "pending"
419 : "failed"
420 }
421 >
422 {inst.status}
423 </Badge>
424 </div>
425 ))}
426 </div>
427 )}
428 </div>
317/* ── Helpers ── */
429318
430 {/* Settings */}
431 <div>
432 <div className="pb-2" style={{ borderBottom: "1px solid var(--divide)" }}>
433 <span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>Settings</span>
434 </div>
435 <div className="flex items-center justify-between py-2.5 px-1" style={{ borderBottom: "1px solid var(--divide)" }}>
436 <div>
437 <div className="text-sm" style={{ color: "var(--text-primary)" }}>Appearance</div>
438 <div className="text-xs mt-0.5" style={{ color: "var(--text-faint)" }}>
439 Currently using {theme} mode
440 </div>
441 </div>
442 <button
443 onClick={toggleTheme}
444 className="btn-reset text-xs hover:underline"
445 style={{ color: "var(--text-muted)", font: "inherit", fontSize: "0.75rem" }}
446 >
447 Switch to {theme === "light" ? "dark" : "light"}
448 </button>
449 </div>
450 </div>
319function StatusDot({ status }: { status: "operational" | "degraded" | "down" }) {
320 const color =
321 status === "operational" ? "var(--status-open-text)" : status === "degraded" ? "var(--status-merged-text)" : "var(--status-closed-text)";
322 return (
323 <span style={{ width: 7, height: 7, borderRadius: "50%", backgroundColor: color, display: "inline-block", flexShrink: 0 }} />
324 );
325}
326
327function SkeletonRows({ n }: { n: number }) {
328 return (
329 <div className="p-2 space-y-1">
330 {Array.from({ length: n }, (_, i) => (
331 <Skeleton key={i} width="100%" height="1.75rem" />
332 ))}
451333 </div>
452334 );
453335}
336
337function EmptyRow({ children }: { children: React.ReactNode }) {
338 return (
339 <div className="py-4 text-center text-xs" style={{ color: "var(--text-faint)" }}>{children}</div>
340 );
341}
454342
web/app/nav.tsx
@@ -138,9 +138,14 @@
138138 }
139139 appSwitcherItems={appSwitcherItems}
140140 menuItems={
141 <DropdownItem onClick={() => { window.location.href = "/dashboard"; }}>
142 Dashboard
143 </DropdownItem>
141 <>
142 <DropdownItem onClick={() => { window.location.href = "/dashboard"; }}>
143 Dashboard
144 </DropdownItem>
145 <DropdownItem onClick={() => { window.location.href = "/settings"; }}>
146 Settings
147 </DropdownItem>
148 </>
144149 }
145150 belowBar={
146151 repoTabs ? (
147152
web/app/settings/page.tsx
@@ -0,0 +1,246 @@
1"use client";
2
3import { useEffect, useState } from "react";
4import { useRouter } from "next/navigation";
5import { useAuth } from "@/lib/auth";
6import { useTheme } from "@/lib/theme";
7import { auth as authApi, type ApiToken } from "@/lib/api";
8import { Skeleton } from "@/app/components/skeleton";
9
10export default function SettingsPage() {
11 const { user, loading: authLoading } = useAuth();
12 const { theme, toggle: toggleTheme } = useTheme();
13 const router = useRouter();
14
15 const [tokens, setTokens] = useState<ApiToken[]>([]);
16 const [tokensLoaded, setTokensLoaded] = useState(false);
17
18 // New token form
19 const [showNewToken, setShowNewToken] = useState(false);
20 const [tokenName, setTokenName] = useState("");
21 const [tokenExpiry, setTokenExpiry] = useState<"30d" | "90d" | "1y">("90d");
22 const [createdToken, setCreatedToken] = useState<string | null>(null);
23 const [tokenCreating, setTokenCreating] = useState(false);
24 const [tokenError, setTokenError] = useState("");
25
26 useEffect(() => {
27 document.title = "Settings";
28 }, []);
29
30 useEffect(() => {
31 if (!authLoading && !user) {
32 router.push(`/login?redirect=${encodeURIComponent("/settings")}`);
33 }
34 }, [authLoading, user, router]);
35
36 useEffect(() => {
37 if (!user) return;
38 authApi.listTokens().then(({ tokens }) => {
39 setTokens(tokens);
40 setTokensLoaded(true);
41 }).catch(() => setTokensLoaded(true));
42 }, [user]);
43
44 async function handleCreateToken(e: React.FormEvent) {
45 e.preventDefault();
46 setTokenError("");
47 setTokenCreating(true);
48 try {
49 const { token, api_token } = await authApi.createToken({
50 name: tokenName,
51 expires_in: tokenExpiry,
52 });
53 setCreatedToken(token);
54 setTokens((prev) => [api_token, ...prev]);
55 setTokenName("");
56 setShowNewToken(false);
57 } catch (err: unknown) {
58 setTokenError(err instanceof Error ? err.message : "Failed to create token");
59 } finally {
60 setTokenCreating(false);
61 }
62 }
63
64 async function handleDeleteToken(id: number) {
65 try {
66 await authApi.deleteToken(id);
67 setTokens((prev) => prev.filter((t) => t.id !== id));
68 } catch {}
69 }
70
71 if (authLoading || !user) return null;
72
73 return (
74 <div style={{ maxWidth: 640, margin: "0 auto", padding: "1.5rem 1rem 3rem" }}>
75 <h1 className="text-lg font-medium" style={{ color: "var(--text-primary)", margin: "0 0 1.5rem" }}>
76 Settings
77 </h1>
78
79 {/* Appearance */}
80 <Section title="Appearance">
81 <div className="flex items-center justify-between py-2.5 px-1" style={{ borderBottom: "1px solid var(--divide)" }}>
82 <div>
83 <div className="text-sm" style={{ color: "var(--text-primary)" }}>Theme</div>
84 <div className="text-xs mt-0.5" style={{ color: "var(--text-faint)" }}>{theme} mode</div>
85 </div>
86 <button
87 onClick={toggleTheme}
88 className="btn-reset text-xs"
89 style={{ color: "var(--accent)", font: "inherit", fontSize: "0.75rem" }}
90 >
91 Switch to {theme === "light" ? "dark" : "light"}
92 </button>
93 </div>
94 </Section>
95
96 {/* API Tokens */}
97 <Section
98 title="API Tokens"
99 action={
100 <button
101 onClick={() => { setShowNewToken((v) => !v); setCreatedToken(null); }}
102 className="btn-reset text-xs"
103 style={{ color: "var(--text-muted)", font: "inherit", fontSize: "0.75rem" }}
104 >
105 {showNewToken ? "Cancel" : "New token"}
106 </button>
107 }
108 >
109 {createdToken && (
110 <div
111 className="px-3 py-2 text-xs font-mono"
112 style={{
113 backgroundColor: "var(--status-open-bg)",
114 border: "1px solid var(--status-open-border)",
115 color: "var(--status-open-text)",
116 wordBreak: "break-all",
117 borderBottom: "1px solid var(--divide)",
118 }}
119 >
120 <div className="text-xs" style={{ fontFamily: "inherit", marginBottom: 2 }}>
121 Copy this token now — it won't be shown again:
122 </div>
123 {createdToken}
124 </div>
125 )}
126
127 {showNewToken && (
128 <form onSubmit={handleCreateToken} className="px-3 py-2.5 flex flex-col gap-2" style={{ borderBottom: "1px solid var(--divide)" }}>
129 <input
130 value={tokenName}
131 onChange={(e) => setTokenName(e.target.value)}
132 placeholder="Token name (e.g. laptop)"
133 required
134 className="text-sm px-2.5 py-1.5"
135 style={{
136 backgroundColor: "var(--bg-input)",
137 border: "1px solid var(--border-subtle)",
138 color: "var(--text-primary)",
139 font: "inherit",
140 fontSize: "0.8125rem",
141 }}
142 />
143 <div className="flex items-center gap-2">
144 {(["30d", "90d", "1y"] as const).map((exp) => (
145 <button
146 key={exp}
147 type="button"
148 onClick={() => setTokenExpiry(exp)}
149 className="text-xs px-2 py-1"
150 style={{
151 backgroundColor: tokenExpiry === exp ? "var(--accent)" : "var(--bg-inset)",
152 color: tokenExpiry === exp ? "var(--accent-text)" : "var(--text-muted)",
153 border: `1px solid ${tokenExpiry === exp ? "var(--accent)" : "var(--border-subtle)"}`,
154 cursor: "pointer",
155 font: "inherit",
156 fontSize: "0.6875rem",
157 }}
158 >
159 {exp === "30d" ? "30 days" : exp === "90d" ? "90 days" : "1 year"}
160 </button>
161 ))}
162 </div>
163 {tokenError && <div className="text-xs" style={{ color: "var(--status-closed-text)" }}>{tokenError}</div>}
164 <button
165 type="submit"
166 disabled={tokenCreating}
167 className="text-xs self-start px-3 py-1.5"
168 style={{
169 backgroundColor: "var(--accent)",
170 color: "var(--accent-text)",
171 border: "none",
172 cursor: tokenCreating ? "wait" : "pointer",
173 font: "inherit",
174 fontSize: "0.6875rem",
175 opacity: tokenCreating ? 0.6 : 1,
176 }}
177 >
178 {tokenCreating ? "Creating..." : "Create token"}
179 </button>
180 </form>
181 )}
182
183 {!tokensLoaded ? (
184 <div className="p-3 space-y-1">
185 <Skeleton width="100%" height="2rem" />
186 <Skeleton width="100%" height="2rem" />
187 </div>
188 ) : tokens.length === 0 ? (
189 <div className="py-4 text-center text-xs" style={{ color: "var(--text-faint)" }}>
190 No API tokens.
191 </div>
192 ) : (
193 tokens.map((t) => (
194 <div
195 key={t.id}
196 className="flex items-center justify-between px-3 py-2"
197 style={{ borderBottom: "1px solid var(--divide)" }}
198 >
199 <div>
200 <div className="text-sm" style={{ color: "var(--text-primary)" }}>
201 {t.name}
202 <span className="font-mono text-xs ml-1.5" style={{ color: "var(--text-faint)" }}>#{t.id}</span>
203 </div>
204 <div className="text-xs" style={{ color: "var(--text-faint)" }}>
205 Expires {new Date(t.expires_at).toLocaleDateString()}
206 {t.last_used_at && <> · last used {new Date(t.last_used_at).toLocaleDateString()}</>}
207 </div>
208 </div>
209 <button
210 onClick={() => handleDeleteToken(t.id)}
211 className="btn-reset text-xs"
212 style={{ color: "var(--status-closed-text)", font: "inherit", fontSize: "0.6875rem" }}
213 >
214 Revoke
215 </button>
216 </div>
217 ))
218 )}
219 </Section>
220 </div>
221 );
222}
223
224function Section({
225 title,
226 action,
227 children,
228}: {
229 title: string;
230 action?: React.ReactNode;
231 children: React.ReactNode;
232}) {
233 return (
234 <div style={{ marginBottom: "1.5rem" }}>
235 <div
236 className="flex items-center justify-between pb-2"
237 style={{ borderBottom: "1px solid var(--divide)" }}
238 >
239 <span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>{title}</span>
240 {action}
241 </div>
242 {children}
243 </div>
244 );
245}
246
0247
web/app/status/page.tsx
@@ -0,0 +1,5 @@
1import { redirect } from "next/navigation";
2
3export default function StatusPage() {
4 redirect("/dashboard");
5}
06