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
+116
View File
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import type { Component } from "vue";
import {
Boxes,
ListChecks,
LogOut,
MonitorSmartphone,
Puzzle,
} from "@lucide/vue";
import {
TOKEN_INVALID_EVENT,
clearStoredToken,
getStoredToken,
} from "./api";
import TokenScreen from "./views/TokenScreen.vue";
import TasksView from "./views/TasksView.vue";
import DevicesView from "./views/DevicesView.vue";
import PluginsView from "./views/PluginsView.vue";
type ViewId = "tasks" | "devices" | "plugins";
const navItems: { id: ViewId; label: string; icon: Component }[] = [
{ id: "tasks", label: "Tasks", icon: ListChecks },
{ id: "devices", label: "Devices", icon: MonitorSmartphone },
{ id: "plugins", label: "Plugins", icon: Puzzle },
];
const activeView = ref<ViewId>("tasks");
const tokenRejectedMessage = ref("");
const hasToken = ref(false);
function refreshTokenState() {
hasToken.value = getStoredToken() !== null;
}
function onTokenInvalid() {
hasToken.value = false;
tokenRejectedMessage.value =
"the cloud api rejected the stored token (401/403). paste a new token to continue.";
}
function onStorage(event: StorageEvent) {
if (event.key === null) {
// Tab-wide sessionStorage clear (some browsers fire this on logout).
refreshTokenState();
}
}
function signOut() {
clearStoredToken();
hasToken.value = false;
tokenRejectedMessage.value = "";
}
onMounted(() => {
refreshTokenState();
window.addEventListener(TOKEN_INVALID_EVENT, onTokenInvalid as EventListener);
window.addEventListener("storage", onStorage as EventListener);
});
onUnmounted(() => {
window.removeEventListener(TOKEN_INVALID_EVENT, onTokenInvalid as EventListener);
window.removeEventListener("storage", onStorage as EventListener);
});
const activeComponent = computed(() => {
switch (activeView.value) {
case "tasks":
return TasksView;
case "devices":
return DevicesView;
case "plugins":
return PluginsView;
}
return TasksView;
});
function onTokenSubmitted() {
tokenRejectedMessage.value = "";
refreshTokenState();
}
</script>
<template>
<TokenScreen
v-if="!hasToken"
:rejection-message="tokenRejectedMessage"
@submitted="onTokenSubmitted"
/>
<div v-else class="app-shell">
<nav class="app-nav">
<h1>
<Boxes :size="14" />
Cloud Console
</h1>
<button
v-for="item in navItems"
:key="item.id"
:class="{ active: activeView === item.id }"
@click="activeView = item.id"
>
<component :is="item.icon" :size="14" />
{{ item.label }}
</button>
<div class="spacer" />
<button @click="signOut">
<LogOut :size="14" />
Clear token
</button>
</nav>
<main class="app-main">
<component :is="activeComponent" />
</main>
</div>
</template>