From 6b0ea1397fb673fcf6edb9045326602f63150e22 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 13 Apr 2026 18:17:13 -0600 Subject: [PATCH] feat: admin controls for deprecate, pool size, and locations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds operator-facing mutations to the dashboard so routine recovery (like today's Madrid cleanup) doesn't require dropping into the lc CLI. Infrastructure tab • Per-row "Deprecate" button on every non-deprecated VPS route, with a confirmation modal that spells out what happens next (destroy worker tears down the VM). • "stuck" dashed badge on rows stuck in provisioning/configuring/pending for >10 minutes — these are the rows the button is most useful on. Tracks tab • Pool-size inline editor with a two-step confirm dialog that shows whether a change will trigger provisioning or deprecation and by how much, since bumping the pool size triggers real cloud-provider VMs. • Disabled / testing toggles. • Locations editor: multi-select chips of current vps_locations with an "+ add" dropdown scoped to the track's providers. Removing a location defaults to also deprecating any existing vps_routes in that location (pre-checked), which is the right default per the 2026-04-13 Madrid incident where removing a location left orphaned routes behind. All mutations hit the OAuth-gated /v1/dashboard endpoints on the server (getlantern/lantern-cloud#2561). Errors show in a floating banner rather than console-only so operators actually see them. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/api/client.ts | 89 ++++++ src/components/Dashboard.tsx | 1 + src/components/TracksOverview.tsx | 471 ++++++++++++++++++++++++++++-- src/components/VPSOverview.tsx | 162 +++++++++- src/hooks/useVPSData.ts | 15 +- 5 files changed, 709 insertions(+), 29 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index a499252..f0cffc3 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -370,3 +370,92 @@ export function getStreamURL(): string { if (authToken) url.searchParams.set("token", authToken); return url.toString(); } + +// ── Admin mutations ── +// All endpoints below are OAuth-gated server-side; the actor's email is +// audit-logged on every call via the otel span the handler creates. + +async function apiMutate( + path: string, + method: "POST" | "PATCH" | "PUT", + body?: unknown, +): Promise { + const url = `${API_URL}/v1/dashboard${path}`; + const headers: Record = { Accept: "application/json" }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + if (authToken) headers.Authorization = `Bearer ${authToken}`; + const res = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`${method} ${path}: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`); + } + // Some handlers return no body (204/200 empty). Guard against that. + const ct = res.headers.get("content-type") ?? ""; + if (!ct.includes("application/json")) return undefined as T; + return (await res.json()) as T; +} + +/** Deprecate (or un-deprecate) a single route by ID. */ +export function deprecateRoute(routeId: string, undeprecate = false): Promise { + return apiMutate(`/routes/${encodeURIComponent(routeId)}/deprecate`, "POST", { undeprecate }); +} + +export interface UpdateTrackBody { + vpsPoolSize?: number; + disabled?: boolean; + testing?: boolean; +} + +/** PATCH a small set of safe fields on a track. Omitted fields are left alone. */ +export function updateTrack(trackId: number, body: UpdateTrackBody): Promise { + return apiMutate(`/tracks/${trackId}`, "PATCH", body); +} + +export interface UpdateTrackLocationsBody { + locationIds: number[]; + deprecateRemovedRoutes?: boolean; +} + +export interface UpdateTrackLocationsResponse { + added: number[]; + removed: number[]; + routesDeprecated: number; +} + +/** Replace the full vps_locations set for a track. */ +export function updateTrackLocations( + trackId: number, + body: UpdateTrackLocationsBody, +): Promise { + return apiMutate(`/tracks/${trackId}/locations`, "PUT", body); +} + +export interface DashboardLocation { + id: number; + name: string; + providerName: string; +} + +/** List all locations, optionally scoped by provider name (e.g. "LINODE"). */ +export function fetchLocations(provider?: string): Promise { + const params: Record = {}; + if (provider) params.provider = provider; + return apiFetch("/locations", params); +} + +/** Read the current vps_locations for a track (for pre-populating the editor). */ +export function fetchTrackLocations(trackId: number): Promise<{ locationId: number; name: string }[]> { + // Reuses the existing unauthed /track-locations/{id} endpoint which is + // behind the same API host. It returns [{locationId, name}]. + return fetch(`${API_URL}/track-locations/${trackId}`, { + headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined, + }) + .then((r) => { + if (!r.ok) throw new Error(`track-locations ${trackId}: ${r.status}`); + return r.json(); + }); +} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 9183e73..01c5d34 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -221,6 +221,7 @@ export default function Dashboard() { summary={vpsData.summary} isLoading={vpsData.isLoading} error={vpsData.error} + onRefresh={vpsData.refresh} /> ) : activeTab === "arms" ? ( diff --git a/src/components/TracksOverview.tsx b/src/components/TracksOverview.tsx index 9bd4b58..117ea57 100644 --- a/src/components/TracksOverview.tsx +++ b/src/components/TracksOverview.tsx @@ -1,6 +1,16 @@ import { useState, useEffect, useMemo, useCallback, useRef, memo, type CSSProperties } from "react"; import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts"; -import { fetchTracks, fetchSigNozMetrics, type DashboardTrackDetail, type TrackMetrics } from "../api/client"; +import { + fetchTracks, + fetchSigNozMetrics, + updateTrack, + updateTrackLocations, + fetchLocations, + fetchTrackLocations, + type DashboardTrackDetail, + type TrackMetrics, + type DashboardLocation, +} from "../api/client"; const TIER_COLORS: Record = { FREE: "#a0c8a0", @@ -263,6 +273,35 @@ const detailLabel: CSSProperties = { marginBottom: "0.15rem", }; +const editButtonStyle: CSSProperties = { + all: "unset", + fontFamily: "var(--font-mono)", + fontSize: "0.5rem", + color: "#00e5c8", + border: "1px solid #00e5c840", + padding: "0.1rem 0.35rem", + borderRadius: "3px", + cursor: "pointer", + textTransform: "uppercase", + letterSpacing: "0.03em", +}; + +function flagButtonStyle(on: boolean, accent: string): CSSProperties { + return { + all: "unset", + fontFamily: "var(--font-mono)", + fontSize: "0.52rem", + color: on ? accent : "#667080", + border: `1px solid ${on ? `${accent}60` : "#ffffff14"}`, + background: on ? `${accent}15` : "transparent", + padding: "0.15rem 0.45rem", + borderRadius: "3px", + cursor: "pointer", + textTransform: "uppercase", + letterSpacing: "0.03em", + }; +} + type SortField = "name" | "tier" | "protocol" | "vpsRunning" | "vpsPoolSize"; type FilterTier = "all" | "Free" | "Pro" | "New"; type FilterStatus = "all" | "withRoutes" | "empty"; @@ -311,27 +350,68 @@ function TracksOverview() { const [metricsTimeRange, setMetricsTimeRange] = useState<"1h" | "6h" | "24h" | "7d">("6h"); const metricsLoadingRef = useRef(false); + // ── Edit state ── + // Which track (if any) is being edited, and what dialog is active. + // The "poolConfirm" dialog shows a 2-step confirm when a pool-size bump + // would trigger provisioning. The "locations" dialog is the multi-select + // editor. `busy` disables every control while a mutation is in flight. + const [poolEdit, setPoolEdit] = useState<{ trackId: number; trackName: string; current: number; next: number } | null>(null); + const [savingTrackId, setSavingTrackId] = useState(null); + const [editError, setEditError] = useState(null); + const [locEditor, setLocEditor] = useState<{ trackId: number; trackName: string; providers: string[] } | null>(null); + + const savePoolSize = async () => { + if (!poolEdit) return; + setSavingTrackId(poolEdit.trackId); + setEditError(null); + try { + await updateTrack(poolEdit.trackId, { vpsPoolSize: poolEdit.next }); + setPoolEdit(null); + await reloadTracks(); + } catch (e) { + console.error("update pool size failed", e); + setEditError(e instanceof Error ? e.message : "Failed to update pool size"); + } finally { + setSavingTrackId(null); + } + }; + + const toggleFlag = async (track: DashboardTrackDetail, field: "disabled" | "testing", value: boolean) => { + setSavingTrackId(track.id); + setEditError(null); + try { + await updateTrack(track.id, { [field]: value }); + await reloadTracks(); + } catch (e) { + console.error(`update ${field} failed`, e); + setEditError(e instanceof Error ? e.message : `Failed to update ${field}`); + } finally { + setSavingTrackId(null); + } + }; + + const reloadTracks = useCallback(async () => { + try { + const data = await fetchTracks(); + setTracks(data.tracks || []); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load tracks"); + } finally { + setIsLoading(false); + } + }, []); + useEffect(() => { let cancelled = false; - const load = async () => { - try { - const data = await fetchTracks(); - if (!cancelled) { - setTracks(data.tracks || []); - setError(null); - setIsLoading(false); - } - } catch (e) { - if (!cancelled) { - setError(e instanceof Error ? e.message : "Failed to load tracks"); - setIsLoading(false); - } - } + const tick = async () => { + if (cancelled) return; + await reloadTracks(); }; - load(); - const interval = setInterval(load, 60_000); + tick(); + const interval = setInterval(tick, 60_000); return () => { cancelled = true; clearInterval(interval); }; - }, []); + }, [reloadTracks]); // Fetch SigNoz metrics grouped by track useEffect(() => { @@ -518,6 +598,36 @@ function TracksOverview() { } return ( + <> + {editError && ( +
+ {editError} +
+ )} + {poolEdit && setPoolEdit(null)} + onSave={savePoolSize} + onChange={(next) => setPoolEdit({ ...poolEdit, next })} + />} + {locEditor && setLocEditor(null)} + onSaved={async () => { setLocEditor(null); await reloadTracks(); }} + setBusyId={setSavingTrackId} + setErr={setEditError} + />}
{/* Summary Cards */}
@@ -810,12 +920,64 @@ function TracksOverview() { <>
Pool Size
-
{track.vpsPoolSize} per location
+
+ {track.vpsPoolSize} per location + +
Route Age
{track.routeAgeHours ? `${track.routeAgeHours}h` : "never expires"}
+
+
Locations
+
+ +
+
+
+
Flags
+
+ + +
+
)}
@@ -905,6 +1067,277 @@ function TracksOverview() { )}
+ + ); +} + +// ── Pool-size confirmation dialog ── +// Two-step intent: shows the current size, lets the user type the new size, +// and spells out the consequence ("increasing triggers immediate VPS +// provisioning by the pool worker on its next cycle"). Save is disabled until +// the value actually changes. +function PoolSizeConfirm({ + edit, + busy, + onCancel, + onSave, + onChange, +}: { + edit: { trackId: number; trackName: string; current: number; next: number }; + busy: boolean; + onCancel: () => void; + onSave: () => void; + onChange: (n: number) => void; +}) { + const diff = edit.next - edit.current; + const willTriggerProvisioning = diff > 0; + const willTriggerDeprecation = diff < 0; + return ( +
!busy && onCancel()} + style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 9000 }} + > +
e.stopPropagation()} + style={{ background: "var(--bg-card)", border: "1px solid #00e5c840", borderRadius: "6px", padding: "1.1rem 1.3rem", width: "min(480px, 92vw)", fontFamily: "var(--font-sans)", color: "#c0c8d4", boxShadow: "0 10px 40px rgba(0,0,0,0.6)" }} + > +
+ Edit pool size — {edit.trackName} +
+
+ Current pool size: {edit.current} VPS routes per location. +
+
+ New size: + onChange(Math.max(0, Math.min(50, parseInt(e.target.value) || 0)))} + disabled={busy} + style={{ all: "unset", fontFamily: "var(--font-mono)", fontSize: "0.9rem", color: "#00e5c8", background: "#00e5c808", border: "1px solid #00e5c830", padding: "0.25rem 0.5rem", borderRadius: "4px", width: "4rem", textAlign: "center" }} + /> + per location +
+
+ {willTriggerProvisioning ? ( + <>Heads up: increasing pool size triggers new VPS provisioning on the next pool-worker tick (~60s). This will incur cloud-provider costs. + ) : willTriggerDeprecation ? ( + <>Heads up: decreasing pool size will cause the pool worker to deprecate excess routes (up to {Math.abs(diff)} per location), triggering VM teardown. + ) : ( + <>No change from current value. + )} +
+
+ + +
+
+
+ ); +} + +// ── Locations editor ── +// Multi-select of locations filtered to the track's providers. Shows current +// set as removable chips and a dropdown of available-but-unselected locations. +// Removing a location prompts the operator to also deprecate any vps_routes +// in that location — critical because the track's pool worker would otherwise +// orphan them (see 2026-04-13 Madrid incident). +function LocationsEditor({ + trackId, + trackName, + providers, + busy, + onClose, + onSaved, + setBusyId, + setErr, +}: { + trackId: number; + trackName: string; + providers: string[]; + busy: boolean; + onClose: () => void; + onSaved: () => Promise; + setBusyId: (id: number | null) => void; + setErr: (msg: string | null) => void; +}) { + const [current, setCurrent] = useState<{ locationId: number; name: string }[] | null>(null); + const [available, setAvailable] = useState([]); + const [loading, setLoading] = useState(true); + const [deprecateRemoved, setDeprecateRemoved] = useState(true); + const [addPickerOpen, setAddPickerOpen] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + // We load locations for every provider the track supports and union + // them so a track on ["LINODE", "OCI"] sees both provider's locations. + const [cur, ...provLists] = await Promise.all([ + fetchTrackLocations(trackId), + ...providers.map((p) => fetchLocations(p)), + ]); + if (cancelled) return; + setCurrent(cur); + const merged = new Map(); + for (const list of provLists) { + for (const l of list) merged.set(l.id, l); + } + setAvailable(Array.from(merged.values()).sort((a, b) => a.name.localeCompare(b.name))); + } catch (e) { + console.error("load locations for editor failed", e); + if (!cancelled) setErr(e instanceof Error ? e.message : "Failed to load locations"); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [trackId, providers, setErr]); + + const selectedIds = new Set((current ?? []).map((c) => c.locationId)); + const pickableLocations = available.filter((l) => !selectedIds.has(l.id)); + + const remove = (id: number) => { + setCurrent((prev) => prev ? prev.filter((c) => c.locationId !== id) : prev); + }; + const add = (loc: DashboardLocation) => { + setCurrent((prev) => prev ? [...prev, { locationId: loc.id, name: loc.name }].sort((a, b) => a.name.localeCompare(b.name)) : [{ locationId: loc.id, name: loc.name }]); + setAddPickerOpen(false); + }; + + const save = async () => { + if (!current) return; + setBusyId(trackId); + setErr(null); + try { + const resp = await updateTrackLocations(trackId, { + locationIds: current.map((c) => c.locationId), + deprecateRemovedRoutes: deprecateRemoved, + }); + if (resp.routesDeprecated > 0) { + // Surface the side-effect so the operator sees it. + setErr(null); + } + await onSaved(); + } catch (e) { + console.error("save track locations failed", e); + setErr(e instanceof Error ? e.message : "Failed to save locations"); + } finally { + setBusyId(null); + } + }; + + return ( +
!busy && onClose()} + style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 9000 }} + > +
e.stopPropagation()} + style={{ background: "var(--bg-card)", border: "1px solid #00e5c840", borderRadius: "6px", padding: "1.1rem 1.3rem", width: "min(620px, 94vw)", maxHeight: "85vh", display: "flex", flexDirection: "column", fontFamily: "var(--font-sans)", color: "#c0c8d4", boxShadow: "0 10px 40px rgba(0,0,0,0.6)" }} + > +
+ Edit locations — {trackName} +
+
+ Providers: {providers.join(", ")} +
+ +
+ {loading ? ( +
Loading locations…
+ ) : ( + <> +
+ Current ({current?.length ?? 0}) +
+
+ {(current ?? []).map((c) => ( + + {c.name} + + + ))} + +
+ + {addPickerOpen && ( +
+ {pickableLocations.map((loc) => ( + + ))} + {pickableLocations.length === 0 && ( +
+ No more locations available for this track's providers. +
+ )} +
+ )} + + + + )} +
+ +
+ + +
+
+
); } diff --git a/src/components/VPSOverview.tsx b/src/components/VPSOverview.tsx index c675927..8372377 100644 --- a/src/components/VPSOverview.tsx +++ b/src/components/VPSOverview.tsx @@ -1,11 +1,25 @@ import { useState, useMemo, useEffect, memo, type CSSProperties } from "react"; -import type { DashboardVPSRoute, DashboardVPSSummary } from "../api/client"; +import { deprecateRoute, type DashboardVPSRoute, type DashboardVPSSummary } from "../api/client"; interface VPSOverviewProps { routes: DashboardVPSRoute[]; summary: DashboardVPSSummary | null; isLoading: boolean; error: string | null; + onRefresh?: () => void; +} + +// Routes in provisioning/configuring that have been around for >10 min are +// effectively stuck — the pool worker retries every ~60s so anything that +// hasn't reached "running" by then almost certainly won't without intervention. +const STUCK_THRESHOLD_MS = 10 * 60 * 1000; + +function isStuck(route: DashboardVPSRoute): boolean { + if (route.deprecated) return false; + if (route.status !== "provisioning" && route.status !== "configuring" && route.status !== "pending") { + return false; + } + return Date.now() - Date.parse(route.created) > STUCK_THRESHOLD_MS; } const STATUS_COLORS: Record = { @@ -133,11 +147,34 @@ const badgeBase: CSSProperties = { whiteSpace: "nowrap", }; -function VPSOverview({ routes, summary, isLoading, error }: VPSOverviewProps) { +function VPSOverview({ routes, summary, isLoading, error, onRefresh }: VPSOverviewProps) { const [collapsedRegions, setCollapsedRegions] = useState>(new Set()); const [copiedRouteId, setCopiedRouteId] = useState(null); const [, setTick] = useState(0); + // Deprecation state: one pending route id while a request is in-flight, plus + // a confirmation modal payload for the row the user has armed. + const [confirmDeprecate, setConfirmDeprecate] = useState(null); + const [deprecatingId, setDeprecatingId] = useState(null); + const [deprecateError, setDeprecateError] = useState(null); + + const runDeprecate = async (route: DashboardVPSRoute) => { + setDeprecatingId(route.id); + setDeprecateError(null); + try { + await deprecateRoute(route.id); + setConfirmDeprecate(null); + onRefresh?.(); + } catch (err) { + console.error("deprecate route failed", err); + setDeprecateError( + err instanceof Error ? err.message : "Failed to deprecate route", + ); + } finally { + setDeprecatingId(null); + } + }; + useEffect(() => { const id = setInterval(() => setTick((t) => t + 1), 60_000); return () => clearInterval(id); @@ -237,6 +274,7 @@ function VPSOverview({ routes, summary, isLoading, error }: VPSOverviewProps) { } return ( + <>
{/* Summary Cards */}
@@ -380,8 +418,8 @@ function VPSOverview({ routes, summary, isLoading, error }: VPSOverviewProps) { / {route.peakAssignmentCount} - {/* Status / Deprecated badge */} - + {/* Status / Deprecated badge + stuck indicator */} + {isDeprecated ? ( deprecated @@ -391,8 +429,46 @@ function VPSOverview({ routes, summary, isLoading, error }: VPSOverviewProps) { {route.status} )} + {isStuck(route) && ( + ${Math.floor((Date.now() - Date.parse(route.created)) / 60000)} minutes — likely stuck`} + style={{ ...badgeBase, background: "#e0606010", color: "#e06060", border: "1px dashed #e0606050" }} + > + stuck + + )} + {/* Deprecate button */} + {!isDeprecated && ( + + )} + {/* SSH command — copy to clipboard */}
+ {confirmDeprecate && ( +
!deprecatingId && setConfirmDeprecate(null)} + style={{ + position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", + display: "flex", alignItems: "center", justifyContent: "center", zIndex: 9000, + }} + > +
e.stopPropagation()} + style={{ + background: "var(--bg-card)", + border: "1px solid #e0606040", + borderRadius: "6px", + padding: "1.1rem 1.3rem", + width: "min(460px, 92vw)", + fontFamily: "var(--font-sans)", + color: "#c0c8d4", + boxShadow: "0 10px 40px rgba(0,0,0,0.6)", + }} + > +
+ Deprecate route? +
+
+ Route + {confirmDeprecate.id.substring(0, 8)} + on{" "} + {confirmDeprecate.trackName} in{" "} + {confirmDeprecate.regionName}{" "} + ({confirmDeprecate.vpsProvider} / {confirmDeprecate.vpsRegion}). + The destroy worker will tear down the VM on its next cycle. +
+ {deprecateError && ( +
+ {deprecateError} +
+ )} +
+ + +
+
+
+ )} + ); } diff --git a/src/hooks/useVPSData.ts b/src/hooks/useVPSData.ts index ab93016..31a9265 100644 --- a/src/hooks/useVPSData.ts +++ b/src/hooks/useVPSData.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { fetchInfrastructure, type DashboardVPSRoute, type DashboardVPSSummary } from "../api/client"; import { useAuth } from "./useAuth"; @@ -8,12 +8,15 @@ export function useVPSData(enabled: boolean) { const [summary, setSummary] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + + const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); useEffect(() => { if (!enabled || !isAuthenticated) return; let cancelled = false; - const refresh = async () => { + const load = async () => { setIsLoading(true); try { const data = await fetchInfrastructure(true); @@ -34,10 +37,10 @@ export function useVPSData(enabled: boolean) { } }; - refresh(); - const interval = setInterval(refresh, 60000); + load(); + const interval = setInterval(load, 60000); return () => { cancelled = true; clearInterval(interval); }; - }, [enabled, isAuthenticated]); + }, [enabled, isAuthenticated, refreshKey]); - return { routes, summary, isLoading, error }; + return { routes, summary, isLoading, error, refresh }; }