feat(cloud-console): task listing, attempt history, CORS, and console SPA

Implements the cloud-console OpenSpec change: adds GET /v1/tasks (filterable,
bounded pagination, tasks:read) and GET /v1/tasks/{id}/attempts (404 on unknown
task) to the platform SDK, with matching CloudClient methods and a closed-by-
default CLOUD_CONSOLE_CORS_ORIGINS allow-list wired through CloudControlConfig.
Ships an independent Vue 3 + Vite SPA at cloud-console/ that authenticates with
an operator-supplied bearer token held in sessionStorage, renders tasks with
attempt history, device pool, host registry, and the plugin registry with a
registration form.

Backend test suite: 438 passed (-m "not integration"); cloud-console typecheck
and production build both succeed. PostgreSQL-backed repository tests and
manual end-to-end verification remain pending external infrastructure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 14:00:23 +08:00
co-authored by Claude Opus 4.6
parent 62923b9285
commit 2169bb03d9
32 changed files with 3415 additions and 24 deletions
+143
View File
@@ -0,0 +1,143 @@
import type {
DeviceRecord,
HostRecord,
PluginRecord,
PluginRegistrationPayload,
TaskAttempt,
TaskListResponse,
TaskStatus,
} from "./types";
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
| string
| undefined;
export const API_BASE_URL = (
configuredBaseUrl || "http://127.0.0.1:8001"
).replace(/\/$/, "");
const TOKEN_STORAGE_KEY = "cloudConsole.bearerToken";
export const TOKEN_INVALID_EVENT = "cloud-console:token-invalid";
export class CloudApiError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "CloudApiError";
}
}
export function getStoredToken(): string | null {
try {
return sessionStorage.getItem(TOKEN_STORAGE_KEY);
} catch {
return null;
}
}
export function storeToken(token: string): void {
sessionStorage.setItem(TOKEN_STORAGE_KEY, token);
}
export function clearStoredToken(): void {
sessionStorage.removeItem(TOKEN_STORAGE_KEY);
}
interface RequestInitLike {
method?: string;
body?: string | null;
headers?: Record<string, string>;
}
async function request<T>(path: string, init: RequestInitLike = {}): Promise<T> {
const token = getStoredToken();
if (!token) {
throw new CloudApiError(401, "no bearer token stored");
}
const headers: Record<string, string> = {
Accept: "application/json",
Authorization: `Bearer ${token}`,
...init.headers,
};
if (init.body !== undefined && init.body !== null) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(`${API_BASE_URL}${path}`, {
method: init.method || "GET",
body: init.body ?? null,
headers,
});
if (response.status === 401 || response.status === 403) {
clearStoredToken();
window.dispatchEvent(new CustomEvent(TOKEN_INVALID_EVENT));
let detail = "token rejected by cloud api";
try {
const payload = (await response.json()) as { detail?: unknown };
if (typeof payload.detail === "string") {
detail = payload.detail;
}
} catch {
// fall back to the default detail
}
throw new CloudApiError(response.status, detail);
}
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const payload = (await response.json()) as { detail?: unknown };
if (typeof payload.detail === "string") {
message = payload.detail;
} else if (payload.detail) {
message = JSON.stringify(payload.detail);
}
} catch {
message = await response.text().catch(() => message);
}
throw new CloudApiError(response.status, message);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export function listTasks(options?: {
status?: TaskStatus;
limit?: number;
offset?: number;
}): Promise<TaskListResponse> {
const params = new URLSearchParams();
if (options?.status) params.set("status", options.status);
params.set("limit", String(options?.limit ?? 50));
params.set("offset", String(options?.offset ?? 0));
const query = params.toString();
return request<TaskListResponse>(`/v1/tasks${query ? `?${query}` : ""}`);
}
export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
return request<TaskAttempt[]>(
`/v1/tasks/${encodeURIComponent(taskId)}/attempts`,
);
}
export function listDevices(): Promise<DeviceRecord[]> {
return request<DeviceRecord[]>("/v1/devices");
}
export function listHosts(): Promise<HostRecord[]> {
return request<HostRecord[]>("/v1/hosts");
}
export function listPlugins(): Promise<PluginRecord[]> {
return request<PluginRecord[]>("/v1/plugins");
}
export function registerPlugin(
payload: PluginRegistrationPayload,
): Promise<PluginRecord> {
return request<PluginRecord>("/v1/plugins", {
method: "POST",
body: JSON.stringify(payload),
});
}