dashboard-v2 hotfix: relative asset base so /clawstor mount works
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 2s

Browser hit /clawstor/ then requested /v2/assets/index-XXX.css
(absolute path baked in by vite base '/v2/') which 404'd because
Tailscale Serve only mounted /clawstor and /clawstor/api. Switch
vite base to './' — assets resolve relative to whatever URL the
SPA loaded from. Works for local (:7700/v2/) + Tailscale
(/clawstor) with no config coupling.
This commit is contained in:
Omar Sobh
2026-07-14 16:28:21 -07:00
parent a2114b918d
commit 9f28ad5338
7 changed files with 159 additions and 120 deletions
+27 -11
View File
@@ -1,22 +1,38 @@
// Thin fetch wrappers around /api/v2/*. Kept minimal — no state
// library; each page owns its own useEffect + useState.
// The backend runs on the same origin the SPA loads from, so
// absolute URLs are unnecessary. Dev proxy handles the :5173 →
// :7700 hop.
// API base:
// * dev (vite proxy) → '/api'
// * prod local → '/api' (SPA at :7700/v2/, API at :7700/api/)
// * prod via Tailscale → '/clawstor/api' (SPA at /clawstor, API at /clawstor/api)
//
// Detect at load time by checking the current URL's pathname
// prefix. Cheap + no build-time coupling.
const API_BASE = (() => {
if (typeof window === 'undefined')
return '/api';
const p = window.location.pathname;
if (p.startsWith('/clawstor/') || p === '/clawstor')
return '/clawstor/api';
return '/api';
})();
async function get(path) {
const resp = await fetch(path, { headers: { Accept: 'application/json' } });
const full = `${API_BASE}${path}`;
const resp = await fetch(full, { headers: { Accept: 'application/json' } });
if (!resp.ok) {
throw new Error(`${path}${resp.status} ${resp.statusText}`);
throw new Error(`${full}${resp.status} ${resp.statusText}`);
}
return resp.json();
}
// All paths are relative to API_BASE. E.g. '/v2/fleet' becomes
// '/api/v2/fleet' locally or '/clawstor/api/v2/fleet' via Tailscale.
export const api = {
nodeStatus: (name = 'local') => get(`/api/v2/node/${name}/status`),
blobs: (limit = 200, offset = 0) => get(`/api/v2/storage/blobs?limit=${limit}&offset=${offset}`),
tags: (prefix = '') => get(`/api/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`),
refs: (limit = 200, offset = 0) => get(`/api/v2/storage/refs?limit=${limit}&offset=${offset}`),
snapshots: () => get(`/api/v2/storage/snapshots`),
refTracking: (repo = '') => get(`/api/v2/storage/ref-tracking?repo=${encodeURIComponent(repo)}`),
fleet: () => get('/v2/fleet'),
nodeStatus: (name) => get(`/v2/node/${name}/status`),
blobs: (limit = 200, offset = 0) => get(`/v2/storage/blobs?limit=${limit}&offset=${offset}`),
tags: (prefix = '') => get(`/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`),
refs: (limit = 200, offset = 0) => get(`/v2/storage/refs?limit=${limit}&offset=${offset}`),
snapshots: () => get('/v2/storage/snapshots'),
refTracking: (repo = '') => get(`/v2/storage/ref-tracking?repo=${encodeURIComponent(repo)}`),
};
/** Format bytes as MB / GB / TB as needed. */
export function fmtBytes(n) {
+36 -11
View File
@@ -3,6 +3,7 @@
export interface NodeStatusV2 {
node_name: string;
zone: string;
blob_store_root: string | null;
blob_count: number;
tag_count: number;
@@ -10,6 +11,15 @@ export interface NodeStatusV2 {
snapshot_count: number;
ref_tracking_count: number;
blob_store_bytes: number;
rustc_release: string | null;
online: boolean;
error: string | null;
}
export interface FleetSnapshot {
aggregator_name: string;
fetched_at_unix: number;
nodes: NodeStatusV2[];
}
export interface BlobSummary {
@@ -43,28 +53,43 @@ export interface RefTrackingItem {
last_seen_unix: number;
}
// The backend runs on the same origin the SPA loads from, so
// absolute URLs are unnecessary. Dev proxy handles the :5173 →
// :7700 hop.
// API base:
// * dev (vite proxy) → '/api'
// * prod local → '/api' (SPA at :7700/v2/, API at :7700/api/)
// * prod via Tailscale → '/clawstor/api' (SPA at /clawstor, API at /clawstor/api)
//
// Detect at load time by checking the current URL's pathname
// prefix. Cheap + no build-time coupling.
const API_BASE = (() => {
if (typeof window === 'undefined') return '/api';
const p = window.location.pathname;
if (p.startsWith('/clawstor/') || p === '/clawstor') return '/clawstor/api';
return '/api';
})();
async function get<T>(path: string): Promise<T> {
const resp = await fetch(path, { headers: { Accept: 'application/json' } });
const full = `${API_BASE}${path}`;
const resp = await fetch(full, { headers: { Accept: 'application/json' } });
if (!resp.ok) {
throw new Error(`${path}${resp.status} ${resp.statusText}`);
throw new Error(`${full}${resp.status} ${resp.statusText}`);
}
return resp.json();
}
// All paths are relative to API_BASE. E.g. '/v2/fleet' becomes
// '/api/v2/fleet' locally or '/clawstor/api/v2/fleet' via Tailscale.
export const api = {
nodeStatus: (name = 'local') => get<NodeStatusV2>(`/api/v2/node/${name}/status`),
fleet: () => get<FleetSnapshot>('/v2/fleet'),
nodeStatus: (name: string) => get<NodeStatusV2>(`/v2/node/${name}/status`),
blobs: (limit = 200, offset = 0) =>
get<BlobSummary[]>(`/api/v2/storage/blobs?limit=${limit}&offset=${offset}`),
get<BlobSummary[]>(`/v2/storage/blobs?limit=${limit}&offset=${offset}`),
tags: (prefix = '') =>
get<TagSummary[]>(`/api/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`),
get<TagSummary[]>(`/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`),
refs: (limit = 200, offset = 0) =>
get<RefSummary[]>(`/api/v2/storage/refs?limit=${limit}&offset=${offset}`),
snapshots: () => get<SnapshotSummary[]>(`/api/v2/storage/snapshots`),
get<RefSummary[]>(`/v2/storage/refs?limit=${limit}&offset=${offset}`),
snapshots: () => get<SnapshotSummary[]>('/v2/storage/snapshots'),
refTracking: (repo = '') =>
get<RefTrackingItem[]>(`/api/v2/storage/ref-tracking?repo=${encodeURIComponent(repo)}`),
get<RefTrackingItem[]>(`/v2/storage/ref-tracking?repo=${encodeURIComponent(repo)}`),
};
/** Format bytes as MB / GB / TB as needed. */