Initial commit: DSH Web Manager

This commit is contained in:
2026-10-13 09:34:47 +08:00
commit 85332bbc56
135 changed files with 19712 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
export class ApiError extends Error {
constructor(public status: number, message: string) { super(message); }
}
export interface User { id: string; username: string; admin: boolean; quota_bytes: number; created_at?: string }
export interface Session { user: User; csrf_token: string }
export interface Instance { id: string; name: string; status: string; image_id: string; version: string; created_at: string }
export interface Image { id: string; image_id?: string; version: string; tag?: string; status?: string; usage_count?: number; recipe_rev?: string; platform?: string; created_at?: string }
export interface Build { id: string; version: string; status: string; image_id?: string; logs?: string; error?: string; created_at: string }
export interface Entry { name: string; path?: string; is_dir: boolean; size: number; mod_time: string }
export interface Usage { used_bytes: number; quota_bytes: number }
export interface Backup { id: string; created_at: string; size: number }
export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string }
export function createApi(fetcher: typeof fetch = fetch) {
let csrf = '';
let refreshing: Promise<Session> | undefined;
const csrfCookie = () => typeof document === 'undefined' ? '' : document.cookie.split('; ').find(c => c.startsWith('dsh_csrf='))?.slice(9) || '';
async function request<T>(path: string, method = 'GET', data?: unknown, retried = false): Promise<T> {
const headers = new Headers();
if (method !== 'GET' && (csrf || csrfCookie())) headers.set('X-CSRF-Token', csrf || csrfCookie());
const multipart = data instanceof FormData;
if (data !== undefined && !multipart) headers.set('Content-Type', 'application/json');
let response: Response;
try { response = await fetcher('/api' + path, { method, credentials: 'same-origin', headers, body: data === undefined ? undefined : multipart ? data : JSON.stringify(data) }); }
catch { throw new ApiError(0, '无法连接服务器,请检查网络或后端服务。'); }
if (response.status === 401 && !retried && path !== '/login' && path !== '/refresh' && (csrf || csrfCookie())) {
try {
refreshing ??= request<Session>('/refresh', 'POST', undefined, true).finally(() => { refreshing = undefined; });
const session = await refreshing; csrf = session.csrf_token;
return request<T>(path, method, data, true);
} catch { /* The original response determines session expiration handling. */ }
}
const text = await response.text();
let payload: unknown;
try { payload = text ? JSON.parse(text) : undefined; } catch { payload = undefined; }
if (!response.ok) {
if (response.status === 401 && path !== '/login' && path !== '/me' && path !== '/refresh' && typeof window !== 'undefined') window.dispatchEvent(new Event('session-expired'));
throw new ApiError(response.status, (payload as {error?: string})?.error || (response.status === 401 ? '登录已失效,请重新登录。' : '请求失败(HTTP ' + response.status + ')'));
}
if (text && payload === undefined) throw new ApiError(response.status, '服务器返回了非 JSON 响应,请检查 API 代理配置。');
return payload as T;
}
return {
request,
async me() { const session = await request<Session>('/me'); csrf = session.csrf_token; return session; },
async login(username: string, password: string) { const session = await request<Session>('/login', 'POST', {username,password}); csrf = session.csrf_token; return session; },
async logout() { await request('/logout', 'POST'); csrf = ''; },
instances: () => request<Instance[]>('/instances'),
images: () => request<Image[]>('/images'),
builds: () => request<Build[]>('/builds'),
usage: (instance?: string) => request<Usage>('/ws/usage' + (instance ? '?instance=' + encodeURIComponent(instance) : '')),
tree: (path: string, instance?: string) => request<Entry[]>('/ws/tree?path=' + encodeURIComponent(path) + (instance ? '&instance=' + encodeURIComponent(instance) : '')),
download: (path: string, instance?: string) => '/api/ws/download?path=' + encodeURIComponent(path) + (instance ? '&instance=' + encodeURIComponent(instance) : ''),
mkdir: (path: string, instance?: string) => request('/ws/mkdir' + (instance ? '?instance=' + encodeURIComponent(instance) : ''), 'POST', {path}),
rename: (path: string, newPath: string, instance?: string) => request('/ws/rename' + (instance ? '?instance=' + encodeURIComponent(instance) : ''), 'POST', {path, new_path: newPath}),
delete: (path: string, instance?: string) => request('/ws/delete?path=' + encodeURIComponent(path) + (instance ? '&instance=' + encodeURIComponent(instance) : ''), 'DELETE'),
async upload(path: string, file: File, instance?: string) { const form = new FormData(); form.set('path', path); form.set('file', file); return request('/ws/upload' + (instance ? '?instance=' + encodeURIComponent(instance) : ''), 'POST', form); },
};
}
export const api = createApi();
export const instancePath = (id: string) => '/instances/' + encodeURIComponent(id);