Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = void>(
path: string,
method: "POST" | "PATCH" | "PUT",
body?: unknown,
): Promise<T> {
const url = `${API_URL}/v1/dashboard${path}`;
const headers: Record<string, string> = { 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;
Comment on lines +378 to +399

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apiMutate doesn’t implement the same 401 refresh-and-retry flow that apiFetch uses via onAuthExpired. This means dashboard mutations (deprecate route, update track, update locations) will fail once the access token expires even though reads silently recover. Consider factoring the auth-refresh retry logic into a shared helper and using it for both apiFetch and apiMutate (including updating authToken and retrying once on 401).

Copilot uses AI. Check for mistakes.
}

/** Deprecate (or un-deprecate) a single route by ID. */
export function deprecateRoute(routeId: string, undeprecate = false): Promise<void> {
return apiMutate<void>(`/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<void> {
return apiMutate<void>(`/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<UpdateTrackLocationsResponse> {
return apiMutate<UpdateTrackLocationsResponse>(`/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<DashboardLocation[]> {
const params: Record<string, string> = {};
if (provider) params.provider = provider;
return apiFetch<DashboardLocation[]>("/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();
});
}
1 change: 1 addition & 0 deletions src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export default function Dashboard() {
summary={vpsData.summary}
isLoading={vpsData.isLoading}
error={vpsData.error}
onRefresh={vpsData.refresh}
/>
) : activeTab === "arms" ? (
<BanditArmsOverview countries={globalStats.countries} dataCenters={dataCenters} isLive={isLive} />
Expand Down
Loading