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:
@@ -158,6 +158,17 @@ def create_app(
|
|||||||
|
|
||||||
app = FastAPI(title="Device Cloud API", lifespan=lifespan)
|
app = FastAPI(title="Device Cloud API", lifespan=lifespan)
|
||||||
|
|
||||||
|
if control_config.cors_allowed_origins:
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=list(control_config.cors_allowed_origins),
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def correlation_logging(request: Request, call_next):
|
async def correlation_logging(request: Request, call_next):
|
||||||
correlation_id = normalize_correlation_id(
|
correlation_id = normalize_correlation_id(
|
||||||
|
|||||||
@@ -608,3 +608,80 @@ def test_production_app_rejects_missing_credentials() -> None:
|
|||||||
database_url="postgresql://db/cloud",
|
database_url="postgresql://db/cloud",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_headers_are_absent_when_allow_list_is_empty() -> None:
|
||||||
|
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.options(
|
||||||
|
"/health/live",
|
||||||
|
headers={
|
||||||
|
"Origin": "http://console.example",
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code >= 400
|
||||||
|
assert "access-control-allow-origin" not in {
|
||||||
|
key.lower() for key in response.headers
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_headers_reflect_configured_origin_only() -> None:
|
||||||
|
app = create_app(
|
||||||
|
config=CloudControlConfig(
|
||||||
|
database_url="sqlite:///:memory:",
|
||||||
|
cors_allowed_origins=("http://console.example",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
allowed = client.options(
|
||||||
|
"/health/live",
|
||||||
|
headers={
|
||||||
|
"Origin": "http://console.example",
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
blocked = client.options(
|
||||||
|
"/health/live",
|
||||||
|
headers={
|
||||||
|
"Origin": "http://attacker.example",
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert allowed.status_code in {200, 204}
|
||||||
|
assert allowed.headers["access-control-allow-origin"] == "http://console.example"
|
||||||
|
# An origin that is not on the allow-list must not be echoed back.
|
||||||
|
assert (
|
||||||
|
blocked.headers.get("access-control-allow-origin") != "http://attacker.example"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_control_config_parses_cors_allow_list() -> None:
|
||||||
|
from cloud.control_config import load_control_config
|
||||||
|
|
||||||
|
config = load_control_config(
|
||||||
|
env={
|
||||||
|
"CLOUD_ENVIRONMENT": "local",
|
||||||
|
"CLOUD_DATABASE_URL": "sqlite:///:memory:",
|
||||||
|
"CLOUD_CONSOLE_CORS_ORIGINS": (
|
||||||
|
"http://console.example, https://console.example"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert config.cors_allowed_origins == (
|
||||||
|
"http://console.example",
|
||||||
|
"https://console.example",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_control_config_defaults_to_empty_cors_allow_list() -> None:
|
||||||
|
from cloud.control_config import load_control_config
|
||||||
|
|
||||||
|
config = load_control_config(
|
||||||
|
env={
|
||||||
|
"CLOUD_ENVIRONMENT": "local",
|
||||||
|
"CLOUD_DATABASE_URL": "sqlite:///:memory:",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert config.cors_allowed_origins == ()
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_CLOUD_API_BASE_URL=http://127.0.0.1:8001
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Cloud Console
|
||||||
|
|
||||||
|
Independent Vue 3 + Vite single-page app for the Cloud Control Plane
|
||||||
|
(`apps/cloud-api`). Operators authenticate by pasting a pre-issued scoped
|
||||||
|
bearer token; the console stores it in `sessionStorage`, attaches
|
||||||
|
`Authorization: Bearer <token>` to every request, and clears it whenever the
|
||||||
|
Cloud API responds `401` or `403`.
|
||||||
|
|
||||||
|
The app talks only to the platform SDK surface (`/v1/...`) and consumes the
|
||||||
|
two listing endpoints added by the `cloud-console` change (`GET /v1/tasks`,
|
||||||
|
`GET /v1/tasks/{task_id}/attempts`) alongside the existing
|
||||||
|
`/v1/devices`, `/v1/hosts`, `/v1/plugins`, and `POST /v1/plugins` routes.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+ (matching the existing `console/` SPA project)
|
||||||
|
- A running Cloud API (`apps/cloud-api`) reachable from your browser
|
||||||
|
- A bearer token issued via `CLOUD_PUBLIC_CREDENTIALS_JSON` whose scopes cover
|
||||||
|
what you intend to do from the console. Recommended least-privilege set:
|
||||||
|
- `tasks:read` — task list and attempt history views
|
||||||
|
- `pool:read` — device and host views
|
||||||
|
- `plugins:read` — plugin list
|
||||||
|
- Add `tasks:submit`/`plugins:admin` only if you need the write actions from
|
||||||
|
the same tab.
|
||||||
|
|
||||||
|
## Configure the backend CORS allow-list
|
||||||
|
|
||||||
|
The Cloud API has no CORS middleware by default. Before a browser can call it
|
||||||
|
cross-origin, set `CLOUD_CONSOLE_CORS_ORIGINS` to a comma-separated allow-list
|
||||||
|
that includes the exact origin your dev server prints (scheme + host + port —
|
||||||
|
no trailing slash):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: allow the default Vite dev origin
|
||||||
|
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart `apps/cloud-api` after changing this env. Tokens are still required —
|
||||||
|
the allow-list only says which browser origins may send them.
|
||||||
|
|
||||||
|
## Run the dev server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cloud-console
|
||||||
|
cp .env.example .env.local
|
||||||
|
# Edit .env.local if your Cloud API is not at http://127.0.0.1:8001
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite prints a local URL (default `http://127.0.0.1:5173`). Open it, paste a
|
||||||
|
bearer token, and the task/device/host/plugin dashboards become available.
|
||||||
|
|
||||||
|
`.env.local` overrides the default base URL via `VITE_CLOUD_API_BASE_URL`
|
||||||
|
(defaults to `http://127.0.0.1:8001`).
|
||||||
|
|
||||||
|
## Build for production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # type-checks with vue-tsc, then emits dist/
|
||||||
|
npm run preview # serves the built bundle locally
|
||||||
|
```
|
||||||
|
|
||||||
|
`dist/` is a static bundle — host it behind any static file server or CDN and
|
||||||
|
point it at a deployed Cloud API via `VITE_CLOUD_API_BASE_URL` set at build
|
||||||
|
time.
|
||||||
|
|
||||||
|
## Token handling
|
||||||
|
|
||||||
|
- The token is held in `sessionStorage` only. Closing the tab discards it.
|
||||||
|
- Every API request attaches `Authorization: Bearer <token>` and targets only
|
||||||
|
the configured `VITE_CLOUD_API_BASE_URL`.
|
||||||
|
- A `401`/`403` response clears the stored token and returns the operator to
|
||||||
|
the token-entry screen with the API's error detail.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
cloud-console/
|
||||||
|
├── src/
|
||||||
|
│ ├── api.ts # API client wrapper (token storage, fetch, errors)
|
||||||
|
│ ├── types.ts # TS interfaces mirroring the REST models
|
||||||
|
│ ├── App.vue # Shell: token gate, nav, view router
|
||||||
|
│ ├── main.ts # Vue bootstrap
|
||||||
|
│ ├── style.css # Dark theme styles
|
||||||
|
│ └── views/
|
||||||
|
│ ├── TokenScreen.vue
|
||||||
|
│ ├── TasksView.vue # list + detail with attempt history
|
||||||
|
│ ├── DevicesView.vue # device pool + host registry
|
||||||
|
│ └── PluginsView.vue # registry list + registration form
|
||||||
|
├── index.html
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json / tsconfig.node.json
|
||||||
|
└── vite.config.ts
|
||||||
|
```
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Cloud Console</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1211
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "cloud-console",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@lucide/vue": "^1.23.0",
|
||||||
|
"vue": "^3.5.39"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.1.3",
|
||||||
|
"vue-tsc": "^3.3.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_CLOUD_API_BASE_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
createApp(App).mount("#app");
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f172a;
|
||||||
|
--bg-elev: #1e293b;
|
||||||
|
--bg-elev-2: #273449;
|
||||||
|
--border: #334155;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--text-dim: #64748b;
|
||||||
|
--accent: #38bdf8;
|
||||||
|
--accent-hover: #7dd3fc;
|
||||||
|
--danger: #f87171;
|
||||||
|
--danger-bg: #7f1d1d;
|
||||||
|
--success: #4ade80;
|
||||||
|
--warning: #fbbf24;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0b1220;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav {
|
||||||
|
width: 220px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 16px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav h1 {
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav button {
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav button:hover:not(:disabled) {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav button.active {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav .spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 24px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.row-selectable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.row-selectable:hover {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.row-selected {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.queued,
|
||||||
|
.status-badge.assigned,
|
||||||
|
.status-badge.dispatched {
|
||||||
|
background: rgba(56, 189, 248, 0.18);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.done {
|
||||||
|
background: rgba(74, 222, 128, 0.18);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.failed {
|
||||||
|
background: rgba(248, 113, 113, 0.18);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.unreachable {
|
||||||
|
background: rgba(248, 113, 113, 0.18);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.idle,
|
||||||
|
.status-badge.wired {
|
||||||
|
background: rgba(74, 222, 128, 0.18);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.busy {
|
||||||
|
background: rgba(251, 191, 36, 0.18);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.error {
|
||||||
|
background: rgba(127, 29, 29, 0.4);
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.success {
|
||||||
|
background: rgba(34, 197, 94, 0.18);
|
||||||
|
border-color: var(--success);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-screen {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 80px auto;
|
||||||
|
padding: 32px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-screen h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-screen p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-screen label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-screen textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 88px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-screen .actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid .field-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-result {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
display: inline-block;
|
||||||
|
animation: spin 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
export type TaskStatus =
|
||||||
|
| "queued"
|
||||||
|
| "assigned"
|
||||||
|
| "dispatched"
|
||||||
|
| "done"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export interface TaskListItem {
|
||||||
|
id: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
goal: string | null;
|
||||||
|
workflow_definition_id: string | null;
|
||||||
|
assigned_device_id: string | null;
|
||||||
|
assigned_host_id: string | null;
|
||||||
|
attempt_count: number;
|
||||||
|
failure_reason: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskListResponse {
|
||||||
|
items: TaskListItem[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskAttempt {
|
||||||
|
task_id: string;
|
||||||
|
attempt: number;
|
||||||
|
lease_id: string;
|
||||||
|
host_id: string;
|
||||||
|
device_id: string;
|
||||||
|
status: string;
|
||||||
|
lease_expires_at: string;
|
||||||
|
created_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
failure_reason: string | null;
|
||||||
|
terminal_result: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceRecord {
|
||||||
|
device_id: string;
|
||||||
|
host_id: string;
|
||||||
|
driver_type: string;
|
||||||
|
status: string;
|
||||||
|
capability_tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostRecord {
|
||||||
|
host_id: string;
|
||||||
|
address: string | null;
|
||||||
|
last_seen_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginEntryPointKind = "driver" | "tool" | "skill";
|
||||||
|
|
||||||
|
export interface PluginRecord {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
entry_point_kind: string;
|
||||||
|
target: string;
|
||||||
|
wired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginRegistrationPayload {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
entry_point_kind: PluginEntryPointKind;
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { LoaderCircle, RefreshCw } from "@lucide/vue";
|
||||||
|
import { CloudApiError, listDevices, listHosts } from "../api";
|
||||||
|
import type { DeviceRecord, HostRecord } from "../types";
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const devices = ref<DeviceRecord[]>([]);
|
||||||
|
const hosts = ref<HostRecord[]>([]);
|
||||||
|
const staleAfterSeconds = ref(120);
|
||||||
|
|
||||||
|
const staleHostIds = computed(() => {
|
||||||
|
const cutoff = Date.now() - staleAfterSeconds.value * 1000;
|
||||||
|
return new Set(
|
||||||
|
hosts.value
|
||||||
|
.filter((host) => Date.parse(host.last_seen_at) < cutoff)
|
||||||
|
.map((host) => host.host_id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function hostIsStale(hostId: string): boolean {
|
||||||
|
return staleHostIds.value.has(hostId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = "";
|
||||||
|
try {
|
||||||
|
[devices.value, hosts.value] = await Promise.all([listDevices(), listHosts()]);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof CloudApiError) {
|
||||||
|
errorMessage.value = err.message;
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
errorMessage.value = err.message;
|
||||||
|
} else {
|
||||||
|
errorMessage.value = "failed to load device pool";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string): string {
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
if (Number.isNaN(parsed)) return value;
|
||||||
|
const date = new Date(parsed);
|
||||||
|
const secondsAgo = Math.round((Date.now() - parsed) / 1000);
|
||||||
|
const relative =
|
||||||
|
secondsAgo < 60
|
||||||
|
? `${secondsAgo}s ago`
|
||||||
|
: `${Math.round(secondsAgo / 60)}m ago`;
|
||||||
|
return `${date.toLocaleString()} (${relative})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refresh);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<h2>Device pool & host registry</h2>
|
||||||
|
<label>
|
||||||
|
Stale after (s)
|
||||||
|
<input
|
||||||
|
v-model.number="staleAfterSeconds"
|
||||||
|
type="number"
|
||||||
|
min="5"
|
||||||
|
step="5"
|
||||||
|
style="width: 80px"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button :disabled="loading" @click="refresh">
|
||||||
|
<RefreshCw :size="14" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<span v-if="loading" class="muted">
|
||||||
|
<LoaderCircle :size="14" class="loader" /> loading…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Hosts</h3>
|
||||||
|
<table v-if="hosts.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Host ID</th>
|
||||||
|
<th>Address</th>
|
||||||
|
<th>Last seen</th>
|
||||||
|
<th>State</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="host in hosts" :key="host.host_id">
|
||||||
|
<td>
|
||||||
|
<code>{{ host.host_id }}</code>
|
||||||
|
</td>
|
||||||
|
<td>{{ host.address || "—" }}</td>
|
||||||
|
<td class="dim">{{ formatTime(host.last_seen_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
hostIsStale(host.host_id)
|
||||||
|
? 'status-badge unreachable'
|
||||||
|
: 'status-badge idle'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ hostIsStale(host.host_id) ? "stale" : "healthy" }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-else class="muted">No hosts registered.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Devices</h3>
|
||||||
|
<table v-if="devices.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Device ID</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Driver</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Capabilities</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="device in devices"
|
||||||
|
:key="`${device.host_id}/${device.device_id}`"
|
||||||
|
>
|
||||||
|
<td><code>{{ device.device_id }}</code></td>
|
||||||
|
<td>
|
||||||
|
<code>{{ device.host_id }}</code>
|
||||||
|
<span v-if="hostIsStale(device.host_id)" class="status-badge unreachable">
|
||||||
|
host stale
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ device.driver_type }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
hostIsStale(device.host_id)
|
||||||
|
? 'status-badge unreachable'
|
||||||
|
: `status-badge ${device.status}`
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ hostIsStale(device.host_id) ? "unreachable" : device.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="device.capability_tags.length">
|
||||||
|
{{ device.capability_tags.join(", ") }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="dim">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-else class="muted">No devices pooled.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
import { LoaderCircle, Plus, RefreshCw } from "@lucide/vue";
|
||||||
|
import { CloudApiError, listPlugins, registerPlugin } from "../api";
|
||||||
|
import type {
|
||||||
|
PluginEntryPointKind,
|
||||||
|
PluginRecord,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const plugins = ref<PluginRecord[]>([]);
|
||||||
|
|
||||||
|
const showForm = ref(false);
|
||||||
|
const formError = ref("");
|
||||||
|
const formSuccess = ref("");
|
||||||
|
const submitting = ref(false);
|
||||||
|
|
||||||
|
const ENTRY_POINT_KINDS: PluginEntryPointKind[] = ["driver", "tool", "skill"];
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: "",
|
||||||
|
version: "",
|
||||||
|
entry_point_kind: "tool" as PluginEntryPointKind,
|
||||||
|
target: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.name = "";
|
||||||
|
form.version = "";
|
||||||
|
form.entry_point_kind = "tool";
|
||||||
|
form.target = "";
|
||||||
|
formError.value = "";
|
||||||
|
formSuccess.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = "";
|
||||||
|
try {
|
||||||
|
plugins.value = await listPlugins();
|
||||||
|
} catch (err) {
|
||||||
|
describeError(err, "failed to load plugin registry");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeError(err: unknown, fallback: string) {
|
||||||
|
if (err instanceof CloudApiError) {
|
||||||
|
errorMessage.value = err.message;
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
errorMessage.value = err.message;
|
||||||
|
} else {
|
||||||
|
errorMessage.value = fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
formError.value = "";
|
||||||
|
formSuccess.value = "";
|
||||||
|
if (!form.name.trim() || !form.version.trim() || !form.target.trim()) {
|
||||||
|
formError.value = "name, version, and target are required";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const created = await registerPlugin({
|
||||||
|
name: form.name.trim(),
|
||||||
|
version: form.version.trim(),
|
||||||
|
entry_point_kind: form.entry_point_kind,
|
||||||
|
target: form.target.trim(),
|
||||||
|
});
|
||||||
|
formSuccess.value = `registered ${created.name}@${created.version}`;
|
||||||
|
resetForm();
|
||||||
|
showForm.value = false;
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof CloudApiError) {
|
||||||
|
formError.value = err.message;
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
formError.value = err.message;
|
||||||
|
} else {
|
||||||
|
formError.value = "registration failed";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refresh);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<h2>Plugin registry</h2>
|
||||||
|
<button :disabled="loading" @click="refresh">
|
||||||
|
<RefreshCw :size="14" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button class="primary" @click="showForm = !showForm">
|
||||||
|
<Plus :size="14" />
|
||||||
|
{{ showForm ? "Close form" : "Register plugin" }}
|
||||||
|
</button>
|
||||||
|
<span v-if="loading" class="muted">
|
||||||
|
<LoaderCircle :size="14" class="loader" /> loading…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
|
||||||
|
<div v-if="formSuccess" class="notice success">{{ formSuccess }}</div>
|
||||||
|
|
||||||
|
<div class="panel" v-if="showForm">
|
||||||
|
<h3>Register a plugin</h3>
|
||||||
|
<p class="dim">
|
||||||
|
The cloud api requires the <code>plugins:admin</code> scope for this
|
||||||
|
call. Without it the api will respond with <code>403</code>, which the
|
||||||
|
form surfaces below.
|
||||||
|
</p>
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="plugin-name">Name</label>
|
||||||
|
<input id="plugin-name" v-model="form.name" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="plugin-version">Version</label>
|
||||||
|
<input id="plugin-version" v-model="form.version" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="plugin-kind">Entry point kind</label>
|
||||||
|
<select id="plugin-kind" v-model="form.entry_point_kind">
|
||||||
|
<option v-for="kind in ENTRY_POINT_KINDS" :key="kind" :value="kind">
|
||||||
|
{{ kind }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="plugin-target">Target</label>
|
||||||
|
<input
|
||||||
|
id="plugin-target"
|
||||||
|
v-model="form.target"
|
||||||
|
placeholder="module.path:AttributeName"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="formError" class="notice error" style="margin-top: 12px">
|
||||||
|
{{ formError }}
|
||||||
|
</div>
|
||||||
|
<div class="toolbar" style="margin-top: 12px">
|
||||||
|
<button type="submit" class="primary" :disabled="submitting">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="resetForm">Clear</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<table v-if="plugins.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Version</th>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Wired</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="plugin in plugins" :key="plugin.name">
|
||||||
|
<td><code>{{ plugin.name }}</code></td>
|
||||||
|
<td>{{ plugin.version }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge queued">{{ plugin.entry_point_kind }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="dim">{{ plugin.target }}</td>
|
||||||
|
<td>
|
||||||
|
<span :class="plugin.wired ? 'status-badge wired' : 'status-badge failed'">
|
||||||
|
{{ plugin.wired ? "wired" : "not wired" }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-else class="muted">No plugins registered.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, watch } from "vue";
|
||||||
|
import { LoaderCircle, RefreshCw } from "@lucide/vue";
|
||||||
|
import {
|
||||||
|
CloudApiError,
|
||||||
|
getTaskAttempts,
|
||||||
|
listTasks,
|
||||||
|
} from "../api";
|
||||||
|
import type {
|
||||||
|
TaskAttempt,
|
||||||
|
TaskListItem,
|
||||||
|
TaskListResponse,
|
||||||
|
TaskStatus,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const STATUSES: TaskStatus[] = [
|
||||||
|
"queued",
|
||||||
|
"assigned",
|
||||||
|
"dispatched",
|
||||||
|
"done",
|
||||||
|
"failed",
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusFilter = ref<TaskStatus | "">("");
|
||||||
|
const pageSize = ref(50);
|
||||||
|
const offset = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const result = ref<TaskListResponse | null>(null);
|
||||||
|
const selectedTask = ref<TaskListItem | null>(null);
|
||||||
|
const attempts = ref<TaskAttempt[]>([]);
|
||||||
|
const attemptsLoading = ref(false);
|
||||||
|
const attemptsError = ref("");
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true;
|
||||||
|
errorMessage.value = "";
|
||||||
|
try {
|
||||||
|
result.value = await listTasks({
|
||||||
|
status: statusFilter.value === "" ? undefined : statusFilter.value,
|
||||||
|
limit: pageSize.value,
|
||||||
|
offset: offset.value,
|
||||||
|
});
|
||||||
|
if (selectedTask.value) {
|
||||||
|
const stillPresent = result.value.items.find(
|
||||||
|
(item) => item.id === selectedTask.value?.id,
|
||||||
|
);
|
||||||
|
if (!stillPresent) {
|
||||||
|
selectedTask.value = null;
|
||||||
|
attempts.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err, "failed to load tasks");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectTask(task: TaskListItem) {
|
||||||
|
selectedTask.value = task;
|
||||||
|
attempts.value = [];
|
||||||
|
attemptsError.value = "";
|
||||||
|
attemptsLoading.value = true;
|
||||||
|
try {
|
||||||
|
attempts.value = await getTaskAttempts(task.id);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof CloudApiError && err.status === 404) {
|
||||||
|
// Task was deleted between list and detail load.
|
||||||
|
selectedTask.value = null;
|
||||||
|
await refresh();
|
||||||
|
} else {
|
||||||
|
handleError(err, "failed to load task attempts");
|
||||||
|
attemptsError.value = errorMessage.value;
|
||||||
|
errorMessage.value = "";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
attemptsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(err: unknown, fallback: string) {
|
||||||
|
if (err instanceof CloudApiError) {
|
||||||
|
errorMessage.value = err.message;
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
errorMessage.value = err.message;
|
||||||
|
} else {
|
||||||
|
errorMessage.value = fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPrevPage() {
|
||||||
|
offset.value = Math.max(0, offset.value - pageSize.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNextPage() {
|
||||||
|
if (!result.value) return;
|
||||||
|
if (offset.value + pageSize.value >= result.value.total) return;
|
||||||
|
offset.value = offset.value + pageSize.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedTask.value = null;
|
||||||
|
attempts.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(statusFilter, () => {
|
||||||
|
offset.value = 0;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
watch(pageSize, () => {
|
||||||
|
offset.value = 0;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
watch(offset, refresh);
|
||||||
|
|
||||||
|
onMounted(refresh);
|
||||||
|
|
||||||
|
function formatTime(value: string | null): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attemptOutcomeClass(status: string): string {
|
||||||
|
if (status === "done") return "status-badge done";
|
||||||
|
if (status === "failed" || status === "expired") return "status-badge failed";
|
||||||
|
return "status-badge queued";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTerminalResult(attempt: TaskAttempt): string {
|
||||||
|
if (!attempt.terminal_result) return "—";
|
||||||
|
try {
|
||||||
|
return JSON.stringify(attempt.terminal_result, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(attempt.terminal_result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<h2>Tasks</h2>
|
||||||
|
<label>
|
||||||
|
Status
|
||||||
|
<select v-model="statusFilter">
|
||||||
|
<option value="">any</option>
|
||||||
|
<option v-for="s in STATUSES" :key="s" :value="s">{{ s }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Page size
|
||||||
|
<select v-model.number="pageSize">
|
||||||
|
<option :value="10">10</option>
|
||||||
|
<option :value="25">25</option>
|
||||||
|
<option :value="50">50</option>
|
||||||
|
<option :value="100">100</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button :disabled="loading" @click="refresh">
|
||||||
|
<RefreshCw :size="14" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<span v-if="loading" class="muted">
|
||||||
|
<LoaderCircle :size="14" class="loader" /> loading…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
|
||||||
|
|
||||||
|
<div class="panel" v-if="!selectedTask">
|
||||||
|
<table v-if="result && result.items.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Goal / Workflow</th>
|
||||||
|
<th>Assignment</th>
|
||||||
|
<th>Attempts</th>
|
||||||
|
<th>Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="task in result.items"
|
||||||
|
:key="task.id"
|
||||||
|
class="row-selectable"
|
||||||
|
@click="selectTask(task)"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<code>{{ task.id.slice(0, 8) }}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge" :class="task.status">{{ task.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div v-if="task.goal">{{ task.goal }}</div>
|
||||||
|
<div v-else-if="task.workflow_definition_id" class="muted">
|
||||||
|
wf: {{ task.workflow_definition_id }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="dim">—</div>
|
||||||
|
<div v-if="task.failure_reason" class="dim">
|
||||||
|
{{ task.failure_reason }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div v-if="task.assigned_device_id">
|
||||||
|
{{ task.assigned_device_id }}
|
||||||
|
<span class="dim">on {{ task.assigned_host_id }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="dim">unassigned</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ task.attempt_count }}</td>
|
||||||
|
<td class="dim">{{ formatTime(task.created_at) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-else-if="result" class="muted">No tasks match the current filter.</div>
|
||||||
|
|
||||||
|
<div class="pagination" v-if="result">
|
||||||
|
<span>
|
||||||
|
showing
|
||||||
|
{{ result.offset + 1 }}–{{
|
||||||
|
Math.min(result.offset + result.items.length, result.total)
|
||||||
|
}}
|
||||||
|
of {{ result.total }}
|
||||||
|
</span>
|
||||||
|
<button :disabled="offset === 0" @click="goPrevPage">Prev</button>
|
||||||
|
<button
|
||||||
|
:disabled="offset + pageSize >= result.total"
|
||||||
|
@click="goNextPage"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" v-else>
|
||||||
|
<div class="toolbar">
|
||||||
|
<h2>
|
||||||
|
Task <code>{{ selectedTask.id.slice(0, 8) }}</code>
|
||||||
|
</h2>
|
||||||
|
<button @click="clearSelection">Back to list</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted">
|
||||||
|
Status: <span class="status-badge" :class="selectedTask.status">{{ selectedTask.status }}</span>
|
||||||
|
· Attempts: {{ selectedTask.attempt_count }}
|
||||||
|
</p>
|
||||||
|
<p v-if="selectedTask.goal">
|
||||||
|
<strong>Goal:</strong> {{ selectedTask.goal }}
|
||||||
|
</p>
|
||||||
|
<p v-if="selectedTask.workflow_definition_id">
|
||||||
|
<strong>Workflow:</strong>
|
||||||
|
<code>{{ selectedTask.workflow_definition_id }}</code>
|
||||||
|
</p>
|
||||||
|
<p v-if="selectedTask.failure_reason">
|
||||||
|
<strong class="text-danger">Failure reason:</strong>
|
||||||
|
{{ selectedTask.failure_reason }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Attempt history</h3>
|
||||||
|
<div v-if="attemptsLoading" class="muted">loading attempts…</div>
|
||||||
|
<div v-else-if="attemptsError" class="notice error">{{ attemptsError }}</div>
|
||||||
|
<table v-else-if="attempts.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Host / Device</th>
|
||||||
|
<th>Lease expires</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Completed</th>
|
||||||
|
<th>Failure</th>
|
||||||
|
<th>Terminal result</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="attempt in attempts" :key="attempt.attempt">
|
||||||
|
<td>{{ attempt.attempt }}</td>
|
||||||
|
<td>
|
||||||
|
<span :class="attemptOutcomeClass(attempt.status)">{{ attempt.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ attempt.host_id }}
|
||||||
|
<span class="dim">/ {{ attempt.device_id }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="dim">{{ formatTime(attempt.lease_expires_at) }}</td>
|
||||||
|
<td class="dim">{{ formatTime(attempt.created_at) }}</td>
|
||||||
|
<td class="dim">{{ formatTime(attempt.completed_at) }}</td>
|
||||||
|
<td v-if="attempt.failure_reason">{{ attempt.failure_reason }}</td>
|
||||||
|
<td v-else class="dim">—</td>
|
||||||
|
<td>
|
||||||
|
<pre class="attempt-result">{{ formatTerminalResult(attempt) }}</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-else class="muted">No attempts recorded for this task yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { API_BASE_URL, storeToken } from "../api";
|
||||||
|
|
||||||
|
defineProps<{ rejectionMessage?: string }>();
|
||||||
|
const emit = defineEmits<{ (e: "submitted"): void }>();
|
||||||
|
|
||||||
|
const token = ref("");
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const trimmed = token.value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
error.value = "paste a bearer token issued by the cloud control plane";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
storeToken(trimmed);
|
||||||
|
error.value = "";
|
||||||
|
emit("submitted");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="token-screen">
|
||||||
|
<h1>Cloud Console</h1>
|
||||||
|
<p>
|
||||||
|
Paste an operator bearer token scoped to the Cloud Control Plane at
|
||||||
|
<code>{{ API_BASE_URL }}</code>. The token is held in
|
||||||
|
<code>sessionStorage</code> only — close this tab to discard it.
|
||||||
|
</p>
|
||||||
|
<div v-if="rejectionMessage" class="notice error" style="margin-bottom: 16px">
|
||||||
|
{{ rejectionMessage }}
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<label for="token">Bearer token</label>
|
||||||
|
<textarea
|
||||||
|
id="token"
|
||||||
|
v-model="token"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="paste a token scoped at least to tasks:read, pool:read, plugins:read"
|
||||||
|
></textarea>
|
||||||
|
<div v-if="error" class="notice error" style="margin-top: 12px">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" type="submit">Connect</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
});
|
||||||
@@ -183,6 +183,74 @@ finally:
|
|||||||
PY
|
PY
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Cloud Console (Web UI)
|
||||||
|
|
||||||
|
The repository ships an independent Vue 3 + Vite SPA at `cloud-console/` that
|
||||||
|
renders the task queue/history, device pool, host registry, and plugin
|
||||||
|
registry, and exposes the existing plugin-registration action. It authenticates
|
||||||
|
the same way `CloudClient` does: by attaching a pre-issued bearer token to
|
||||||
|
every request. There is no login or session system.
|
||||||
|
|
||||||
|
### Provision an operator bearer token
|
||||||
|
|
||||||
|
Add a `CLOUD_PUBLIC_CREDENTIALS_JSON` entry whose scopes cover what the
|
||||||
|
console operators need to do. The least-privilege set for read-only dashboards
|
||||||
|
is `tasks:read`, `pool:read`, and `plugins:read`. Add `tasks:submit` only if
|
||||||
|
operators should submit ad-hoc tasks from the same tab, and `plugins:admin`
|
||||||
|
only if operators should register plugins:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"principal_id": "console-operator",
|
||||||
|
"token": "replace-with-a-long-random-opaque-token",
|
||||||
|
"scopes": ["tasks:read", "pool:read", "plugins:read", "plugins:admin"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Rotate the token the same way as any other credential entry: deploy the
|
||||||
|
updated Cloud API credential set and instruct operators to paste the new token
|
||||||
|
into the console. The console keeps the token only in browser `sessionStorage`
|
||||||
|
for that tab; closing the tab discards it.
|
||||||
|
|
||||||
|
### Configure the CORS allow-list
|
||||||
|
|
||||||
|
The Cloud API has no CORS middleware by default. Before a browser can call it
|
||||||
|
cross-origin, set `CLOUD_CONSOLE_CORS_ORIGINS` to a comma-separated allow-list
|
||||||
|
that includes the exact origin (scheme + host + port, no trailing slash) the
|
||||||
|
operator's browser will load the console from:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Allow a local Vite dev server
|
||||||
|
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
|
||||||
|
# Or a deployed origin
|
||||||
|
export CLOUD_CONSOLE_CORS_ORIGINS="https://console.example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the Cloud API after changing this env. The middleware is added only
|
||||||
|
when the allow-list is non-empty — existing deployments see no behavior change
|
||||||
|
until an operator opts in. Blanket `allow_origins=["*"]` is intentionally not
|
||||||
|
supported because every console request carries a bearer token.
|
||||||
|
|
||||||
|
### Run the console
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cloud-console
|
||||||
|
cp .env.example .env.local
|
||||||
|
# Edit .env.local if your Cloud API is not at http://127.0.0.1:8001
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite prints a local URL (default `http://127.0.0.1:5173`). That exact origin
|
||||||
|
must be in `CLOUD_CONSOLE_CORS_ORIGINS` on the Cloud API. Open the dev URL,
|
||||||
|
paste the operator token, and the dashboards become available.
|
||||||
|
|
||||||
|
For a production build, run `npm run build` and serve the resulting `dist/`
|
||||||
|
behind any static file server or CDN, with `VITE_CLOUD_API_BASE_URL` baked in
|
||||||
|
at build time. The deployed origin must be in `CLOUD_CONSOLE_CORS_ORIGINS`.
|
||||||
|
|
||||||
## Runtime AI Planner
|
## Runtime AI Planner
|
||||||
|
|
||||||
The Host Agent reuses the local Runtime planner. AI planning is disabled by
|
The Host Agent reuses the local Runtime planner. AI planning is disabled by
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
## 1. Repository: bounded task listing
|
## 1. Repository: bounded task listing
|
||||||
|
|
||||||
- [ ] 1.1 Add `list_tasks(*, status, limit, offset)` and `count_tasks(status)` to the `CloudRepository` Protocol in `repository.py`
|
- [x] 1.1 Add `list_tasks(*, status, limit, offset)` and `count_tasks(status)` to the `CloudRepository` Protocol in `repository.py`
|
||||||
- [ ] 1.2 Implement both methods in `sql_repository.py` using the existing SQLAlchemy query builder (no dialect-specific SQL), ordered most-recent-first
|
- [x] 1.2 Implement both methods in `sql_repository.py` using the existing SQLAlchemy query builder (no dialect-specific SQL), ordered most-recent-first
|
||||||
- [ ] 1.3 Add unit/integration tests covering status filtering, pagination bounds, and empty results against both SQLite and PostgreSQL
|
- [x] 1.3 Add unit/integration tests covering status filtering, pagination bounds, and empty results against both SQLite and PostgreSQL
|
||||||
|
|
||||||
## 2. Platform SDK API: task listing & attempt history
|
## 2. Platform SDK API: task listing & attempt history
|
||||||
|
|
||||||
- [ ] 2.1 Add response models (task summary list item, task attempt) to `cloud/sdk/models.py`
|
- [x] 2.1 Add response models (task summary list item, task attempt) to `cloud/sdk/models.py`
|
||||||
- [ ] 2.2 Implement `GET /v1/tasks` in `cloud/sdk/api.py`: `tasks:read` scope, optional `status` query param, `limit` (default 50, max 100) / `offset` query params, calling the new repository methods
|
- [x] 2.2 Implement `GET /v1/tasks` in `cloud/sdk/api.py`: `tasks:read` scope, optional `status` query param, `limit` (default 50, max 100) / `offset` query params, calling the new repository methods
|
||||||
- [ ] 2.3 Implement `GET /v1/tasks/{task_id}/attempts` in `cloud/sdk/api.py`: `tasks:read` scope, 404 on unknown task id, calling `list_task_attempts`
|
- [x] 2.3 Implement `GET /v1/tasks/{task_id}/attempts` in `cloud/sdk/api.py`: `tasks:read` scope, 404 on unknown task id, calling `list_task_attempts`
|
||||||
- [ ] 2.4 Add tests for both endpoints: filtered/unfiltered listing, page-size-exceeds-max rejection, attempts for known/unknown task id, and scope enforcement (401/403)
|
- [x] 2.4 Add tests for both endpoints: filtered/unfiltered listing, page-size-exceeds-max rejection, attempts for known/unknown task id, and scope enforcement (401/403)
|
||||||
|
|
||||||
## 3. Python SDK client parity
|
## 3. Python SDK client parity
|
||||||
|
|
||||||
- [ ] 3.1 Add `list_tasks(...)` and `get_task_attempts(task_id)` methods to `CloudClient` in `cloud/sdk/client.py`
|
- [x] 3.1 Add `list_tasks(...)` and `get_task_attempts(task_id)` methods to `CloudClient` in `cloud/sdk/client.py`
|
||||||
- [ ] 3.2 Add client tests asserting parity with direct HTTP calls to the two new endpoints
|
- [x] 3.2 Add client tests asserting parity with direct HTTP calls to the two new endpoints
|
||||||
|
|
||||||
## 4. Cloud API CORS configuration
|
## 4. Cloud API CORS configuration
|
||||||
|
|
||||||
- [ ] 4.1 Add a `cors_allowed_origins` field (env `CLOUD_CONSOLE_CORS_ORIGINS`, comma-separated, default empty) to `CloudControlConfig`/`load_control_config()`
|
- [x] 4.1 Add a `cors_allowed_origins` field (env `CLOUD_CONSOLE_CORS_ORIGINS`, comma-separated, default empty) to `CloudControlConfig`/`load_control_config()`
|
||||||
- [ ] 4.2 Wire `CORSMiddleware` into `apps/cloud-api/cloud_api/app.py`'s `create_app()`, added only when the allow-list is non-empty
|
- [x] 4.2 Wire `CORSMiddleware` into `apps/cloud-api/cloud_api/app.py`'s `create_app()`, added only when the allow-list is non-empty
|
||||||
- [ ] 4.3 Add a config/app test confirming CORS headers are absent by default and present only for a configured origin
|
- [x] 4.3 Add a config/app test confirming CORS headers are absent by default and present only for a configured origin
|
||||||
|
|
||||||
## 5. Cloud console frontend (independent SPA)
|
## 5. Cloud console frontend (independent SPA)
|
||||||
|
|
||||||
- [ ] 5.1 Scaffold an independent Vue 3 + Vite SPA project at `cloud-console/` (own `package.json`/build tooling, sibling to `console/`)
|
- [x] 5.1 Scaffold an independent Vue 3 + Vite SPA project at `cloud-console/` (own `package.json`/build tooling, sibling to `console/`)
|
||||||
- [ ] 5.2 Implement the token-entry screen and an API client wrapper that stores the bearer token in `sessionStorage` and attaches it to every request, clearing it and returning to the entry screen on `401`/`403`
|
- [x] 5.2 Implement the token-entry screen and an API client wrapper that stores the bearer token in `sessionStorage` and attaches it to every request, clearing it and returning to the entry screen on `401`/`403`
|
||||||
- [ ] 5.3 Implement the task view: filterable/paginated list against `GET /v1/tasks`, and a detail view with attempt history against `GET /v1/tasks/{id}/attempts`
|
- [x] 5.3 Implement the task view: filterable/paginated list against `GET /v1/tasks`, and a detail view with attempt history against `GET /v1/tasks/{id}/attempts`
|
||||||
- [ ] 5.4 Implement the device pool and host registry views against `GET /v1/devices` and `GET /v1/hosts`
|
- [x] 5.4 Implement the device pool and host registry views against `GET /v1/devices` and `GET /v1/hosts`
|
||||||
- [ ] 5.5 Implement the plugin registry view (list) and registration form against `GET /v1/plugins` and `POST /v1/plugins`, surfacing validation/conflict/authorization errors from the API
|
- [x] 5.5 Implement the plugin registry view (list) and registration form against `GET /v1/plugins` and `POST /v1/plugins`, surfacing validation/conflict/authorization errors from the API
|
||||||
- [ ] 5.6 Document how to run the frontend dev server against a Cloud API base URL (env config) and the CORS origin it needs configured
|
- [x] 5.6 Document how to run the frontend dev server against a Cloud API base URL (env config) and the CORS origin it needs configured
|
||||||
|
|
||||||
## 6. Documentation
|
## 6. Documentation
|
||||||
|
|
||||||
- [ ] 6.1 Add a section to `docs/CLOUD_DEPLOYMENT.md` covering: running the console, provisioning an operator bearer token (least-privilege scopes), and configuring `CLOUD_CONSOLE_CORS_ORIGINS`
|
- [x] 6.1 Add a section to `docs/CLOUD_DEPLOYMENT.md` covering: running the console, provisioning an operator bearer token (least-privilege scopes), and configuring `CLOUD_CONSOLE_CORS_ORIGINS`
|
||||||
|
|
||||||
## 7. Verification
|
## 7. Verification
|
||||||
|
|
||||||
- [ ] 7.1 Run the full backend test suite (`uv run --all-packages pytest -m "not integration"`) and confirm no regressions
|
- [x] 7.1 Run the full backend test suite (`uv run --all-packages pytest -m "not integration"`) and confirm no regressions
|
||||||
- [ ] 7.2 Run the PostgreSQL-backed repository/integration tests for the new listing methods
|
- [ ] 7.2 Run the PostgreSQL-backed repository/integration tests for the new listing methods
|
||||||
- [ ] 7.3 Manually verify end-to-end: submit a task via the existing SDK, confirm it appears in the console's task list, transitions status, and its attempt history renders; confirm device/host/plugin views render against a running Host Agent
|
- [ ] 7.3 Manually verify end-to-end: submit a task via the existing SDK, confirm it appears in the console's task list, transitions status, and its attempt history renders; confirm device/host/plugin views render against a running Host Agent
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class CloudControlConfig:
|
|||||||
allow_insecure_anonymous: bool = False
|
allow_insecure_anonymous: bool = False
|
||||||
credentials: tuple[BearerCredential, ...] = ()
|
credentials: tuple[BearerCredential, ...] = ()
|
||||||
enrollment_credentials: tuple[EnrollmentCredential, ...] = ()
|
enrollment_credentials: tuple[EnrollmentCredential, ...] = ()
|
||||||
|
cors_allowed_origins: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
def load_control_config(
|
def load_control_config(
|
||||||
@@ -90,6 +91,9 @@ def load_control_config(
|
|||||||
enrollment_credentials=_parse_enrollment_credentials(
|
enrollment_credentials=_parse_enrollment_credentials(
|
||||||
values.get("CLOUD_ENROLLMENT_TOKENS_JSON")
|
values.get("CLOUD_ENROLLMENT_TOKENS_JSON")
|
||||||
),
|
),
|
||||||
|
cors_allowed_origins=_parse_cors_origins(
|
||||||
|
values.get("CLOUD_CONSOLE_CORS_ORIGINS")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
validate_control_config(config)
|
validate_control_config(config)
|
||||||
return config
|
return config
|
||||||
@@ -219,3 +223,9 @@ def _parse_bool(value: str | None, *, default: bool) -> bool:
|
|||||||
if normalized in {"0", "false", "no", "off", "disabled", ""}:
|
if normalized in {"0", "false", "no", "off", "disabled", ""}:
|
||||||
return False
|
return False
|
||||||
raise CloudConfigurationError("boolean configuration value is invalid")
|
raise CloudConfigurationError("boolean configuration value is invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cors_origins(raw_value: str | None) -> tuple[str, ...]:
|
||||||
|
if raw_value is None or not raw_value.strip():
|
||||||
|
return ()
|
||||||
|
return tuple(origin.strip() for origin in raw_value.split(",") if origin.strip())
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from cloud.plugins import PluginManifest
|
from cloud.plugins import PluginManifest
|
||||||
from cloud.pool import HostRegistration, PooledDevice
|
from cloud.pool import HostRegistration, PooledDevice
|
||||||
from cloud.scheduler import ScheduledTask
|
from cloud.scheduler import ScheduledTask, ScheduledTaskStatus
|
||||||
|
|
||||||
|
|
||||||
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
|
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
|
||||||
@@ -154,6 +154,16 @@ class CloudRepository(Protocol):
|
|||||||
|
|
||||||
def list_queued_tasks(self) -> list[ScheduledTask]: ...
|
def list_queued_tasks(self) -> list[ScheduledTask]: ...
|
||||||
|
|
||||||
|
def list_tasks(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
status: ScheduledTaskStatus | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[ScheduledTask]: ...
|
||||||
|
|
||||||
|
def count_tasks(self, status: ScheduledTaskStatus | None = None) -> int: ...
|
||||||
|
|
||||||
def get_task(self, task_id: str) -> ScheduledTask | None: ...
|
def get_task(self, task_id: str) -> ScheduledTask | None: ...
|
||||||
|
|
||||||
def update_task(
|
def update_task(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ authentication can be added later without changing route signatures.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
from cloud.auth import (
|
from cloud.auth import (
|
||||||
PLUGINS_ADMIN_SCOPE,
|
PLUGINS_ADMIN_SCOPE,
|
||||||
@@ -28,11 +28,14 @@ from cloud.sdk.models import (
|
|||||||
HostResponse,
|
HostResponse,
|
||||||
PluginRegistrationRequest,
|
PluginRegistrationRequest,
|
||||||
PluginResponse,
|
PluginResponse,
|
||||||
|
TaskAttemptResponse,
|
||||||
|
TaskListItem,
|
||||||
|
TaskListResponse,
|
||||||
TaskStatusResponse,
|
TaskStatusResponse,
|
||||||
TaskSubmissionRequest,
|
TaskSubmissionRequest,
|
||||||
TaskSubmissionResponse,
|
TaskSubmissionResponse,
|
||||||
)
|
)
|
||||||
from fastapi import APIRouter, HTTPException, Request, status
|
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from cloud.plugins import PluginRegistry
|
from cloud.plugins import PluginRegistry
|
||||||
@@ -112,6 +115,76 @@ def create_cloud_router(
|
|||||||
failure_reason=task.failure_reason,
|
failure_reason=task.failure_reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get("/tasks", response_model=TaskListResponse)
|
||||||
|
def list_tasks(
|
||||||
|
request: Request,
|
||||||
|
status_filter: Literal[
|
||||||
|
"queued", "assigned", "dispatched", "done", "failed"
|
||||||
|
]
|
||||||
|
| None = Query(default=None, alias="status"),
|
||||||
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
) -> TaskListResponse:
|
||||||
|
_authorize(request, TASKS_READ_SCOPE)
|
||||||
|
tasks = scheduler.store.list_tasks(
|
||||||
|
status=status_filter,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
total = scheduler.store.count_tasks(status=status_filter)
|
||||||
|
return TaskListResponse(
|
||||||
|
items=[
|
||||||
|
TaskListItem(
|
||||||
|
id=task.id,
|
||||||
|
status=task.status,
|
||||||
|
goal=task.goal,
|
||||||
|
workflow_definition_id=task.workflow_definition_id,
|
||||||
|
assigned_device_id=task.assigned_device_id,
|
||||||
|
assigned_host_id=task.assigned_host_id,
|
||||||
|
attempt_count=task.attempt_count,
|
||||||
|
failure_reason=task.failure_reason,
|
||||||
|
created_at=task.created_at,
|
||||||
|
)
|
||||||
|
for task in tasks
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tasks/{task_id}/attempts",
|
||||||
|
response_model=list[TaskAttemptResponse],
|
||||||
|
)
|
||||||
|
def list_task_attempts(
|
||||||
|
task_id: str,
|
||||||
|
request: Request,
|
||||||
|
) -> list[TaskAttemptResponse]:
|
||||||
|
_authorize(request, TASKS_READ_SCOPE)
|
||||||
|
task = scheduler.store.get_task(task_id)
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"task {task_id!r} not found",
|
||||||
|
)
|
||||||
|
attempts = scheduler.store.list_task_attempts(task_id)
|
||||||
|
return [
|
||||||
|
TaskAttemptResponse(
|
||||||
|
task_id=attempt.task_id,
|
||||||
|
attempt=attempt.attempt,
|
||||||
|
lease_id=attempt.lease_id,
|
||||||
|
host_id=attempt.host_id,
|
||||||
|
device_id=attempt.device_id,
|
||||||
|
status=attempt.status,
|
||||||
|
lease_expires_at=attempt.lease_expires_at,
|
||||||
|
created_at=attempt.created_at,
|
||||||
|
completed_at=attempt.completed_at,
|
||||||
|
failure_reason=attempt.failure_reason,
|
||||||
|
terminal_result=attempt.terminal_result,
|
||||||
|
)
|
||||||
|
for attempt in attempts
|
||||||
|
]
|
||||||
|
|
||||||
@router.get("/devices", response_model=list[DeviceResponse])
|
@router.get("/devices", response_model=list[DeviceResponse])
|
||||||
def list_devices(request: Request) -> list[DeviceResponse]:
|
def list_devices(request: Request) -> list[DeviceResponse]:
|
||||||
_authorize(request, POOL_READ_SCOPE)
|
_authorize(request, POOL_READ_SCOPE)
|
||||||
|
|||||||
@@ -89,6 +89,23 @@ class CloudClient:
|
|||||||
resp = self._request("GET", f"/tasks/{task_id}")
|
resp = self._request("GET", f"/tasks/{task_id}")
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
def list_tasks(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
||||||
|
if status is not None:
|
||||||
|
params["status"] = status
|
||||||
|
resp = self._request("GET", "/tasks", params=params)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def get_task_attempts(self, task_id: str) -> list[dict[str, Any]]:
|
||||||
|
resp = self._request("GET", f"/tasks/{task_id}/attempts")
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
# ----------------------------------------------------------------- devices
|
# ----------------------------------------------------------------- devices
|
||||||
|
|
||||||
def list_devices(self) -> list[dict[str, Any]]:
|
def list_devices(self) -> list[dict[str, Any]]:
|
||||||
@@ -133,11 +150,13 @@ class CloudClient:
|
|||||||
path: str,
|
path: str,
|
||||||
*,
|
*,
|
||||||
json: dict[str, Any] | None = None,
|
json: dict[str, Any] | None = None,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
) -> httpx.Response:
|
) -> httpx.Response:
|
||||||
response = self._http.request(
|
response = self._http.request(
|
||||||
method,
|
method,
|
||||||
self._url(path),
|
self._url(path),
|
||||||
json=json,
|
json=json,
|
||||||
|
params=params,
|
||||||
headers=self._headers,
|
headers=self._headers,
|
||||||
auth=self._auth,
|
auth=self._auth,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -35,6 +35,39 @@ class TaskStatusResponse(BaseModel):
|
|||||||
failure_reason: str | None = None
|
failure_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskListItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
status: str
|
||||||
|
goal: str | None = None
|
||||||
|
workflow_definition_id: str | None = None
|
||||||
|
assigned_device_id: str | None = None
|
||||||
|
assigned_host_id: str | None = None
|
||||||
|
attempt_count: int = 0
|
||||||
|
failure_reason: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TaskListResponse(BaseModel):
|
||||||
|
items: list[TaskListItem]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class TaskAttemptResponse(BaseModel):
|
||||||
|
task_id: str
|
||||||
|
attempt: int
|
||||||
|
lease_id: str
|
||||||
|
host_id: str
|
||||||
|
device_id: str
|
||||||
|
status: str
|
||||||
|
lease_expires_at: datetime
|
||||||
|
created_at: datetime
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
failure_reason: str | None = None
|
||||||
|
terminal_result: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class DeviceResponse(BaseModel):
|
class DeviceResponse(BaseModel):
|
||||||
device_id: str
|
device_id: str
|
||||||
host_id: str
|
host_id: str
|
||||||
|
|||||||
@@ -368,6 +368,36 @@ class SQLAlchemyCloudRepository:
|
|||||||
).all()
|
).all()
|
||||||
return [_task_from_row(row) for row in rows]
|
return [_task_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
def list_tasks(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[Any]:
|
||||||
|
with self._sessions() as session:
|
||||||
|
statement = select(ScheduledTaskRow)
|
||||||
|
if status is not None:
|
||||||
|
statement = statement.where(ScheduledTaskRow.status == status)
|
||||||
|
statement = (
|
||||||
|
statement.order_by(
|
||||||
|
ScheduledTaskRow.created_at.desc(),
|
||||||
|
ScheduledTaskRow.id.desc(),
|
||||||
|
)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
rows = session.scalars(statement).all()
|
||||||
|
return [_task_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
def count_tasks(self, status: str | None = None) -> int:
|
||||||
|
with self._sessions() as session:
|
||||||
|
statement = select(func.count()).select_from(ScheduledTaskRow)
|
||||||
|
if status is not None:
|
||||||
|
statement = statement.where(ScheduledTaskRow.status == status)
|
||||||
|
count = session.scalar(statement)
|
||||||
|
return int(count or 0)
|
||||||
|
|
||||||
def get_task(self, task_id: str) -> Any | None:
|
def get_task(self, task_id: str) -> Any | None:
|
||||||
with self._sessions() as session:
|
with self._sessions() as session:
|
||||||
row = session.get(ScheduledTaskRow, task_id)
|
row = session.get(ScheduledTaskRow, task_id)
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ def test_client_applies_bearer_token_to_every_public_method(tmp_path) -> None:
|
|||||||
|
|
||||||
task_id = client.submit_task(goal="authenticated")["task_id"]
|
task_id = client.submit_task(goal="authenticated")["task_id"]
|
||||||
assert client.get_task_status(task_id)["status"] == "queued"
|
assert client.get_task_status(task_id)["status"] == "queued"
|
||||||
|
assert client.list_tasks()["total"] == 1
|
||||||
|
assert client.get_task_attempts(task_id) == []
|
||||||
assert client.list_devices() == []
|
assert client.list_devices() == []
|
||||||
assert client.list_hosts() == []
|
assert client.list_hosts() == []
|
||||||
assert client.list_plugins() == []
|
assert client.list_plugins() == []
|
||||||
@@ -164,6 +166,74 @@ def test_client_applies_bearer_token_to_every_public_method(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_list_tasks_and_get_attempts_match_direct_http(tmp_path) -> None:
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from core.models import Device
|
||||||
|
|
||||||
|
client, pool = _client_and_pool(tmp_path)
|
||||||
|
pool.sync_host_devices(
|
||||||
|
"host-a",
|
||||||
|
[Device(id="dev-a", driver_type="wda", status="idle")], # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
first_id = client.submit_task(goal="first")["task_id"]
|
||||||
|
second_id = client.submit_task(goal="second")["task_id"]
|
||||||
|
|
||||||
|
# Drive one task through an attempt so get_task_attempts has data.
|
||||||
|
scheduler = TaskScheduler(pool, CloudStore(tmp_path / "cloud.sqlite3"), _config())
|
||||||
|
scheduler.assign()
|
||||||
|
task = scheduler.store.get_task(first_id)
|
||||||
|
if task is not None and task.status == "assigned":
|
||||||
|
scheduler.store.record_task_result(
|
||||||
|
task_id=first_id,
|
||||||
|
attempt=task.attempt_count,
|
||||||
|
lease_id=task.lease_id or "",
|
||||||
|
host_id=task.assigned_host_id or "",
|
||||||
|
status="failed",
|
||||||
|
failure_reason="boom",
|
||||||
|
terminal_result={"exit_code": 1},
|
||||||
|
completed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
# The TestClient is the HTTP boundary — issuing direct calls through it
|
||||||
|
# exercises the same FastAPI routes the client does, which is the parity
|
||||||
|
# contract platform-sdk already relies on.
|
||||||
|
http_client = client._http # type: ignore[attr-defined]
|
||||||
|
direct_list = http_client.get(
|
||||||
|
client._url("/tasks"), # type: ignore[attr-defined]
|
||||||
|
params={"limit": 50, "offset": 0},
|
||||||
|
headers=client._headers, # type: ignore[attr-defined]
|
||||||
|
).json()
|
||||||
|
direct_attempts = http_client.get(
|
||||||
|
f"{client._url('/tasks')}/{first_id}/attempts", # type: ignore[attr-defined]
|
||||||
|
headers=client._headers, # type: ignore[attr-defined]
|
||||||
|
).json()
|
||||||
|
|
||||||
|
via_client = client.list_tasks()
|
||||||
|
assert via_client == direct_list
|
||||||
|
assert via_client["total"] == 2
|
||||||
|
# Most-recent-first: second (the newer) before first.
|
||||||
|
assert [item["id"] for item in via_client["items"]] == [second_id, first_id]
|
||||||
|
|
||||||
|
failed_only = client.list_tasks(status="failed")
|
||||||
|
assert failed_only["total"] == 1
|
||||||
|
assert failed_only["items"][0]["id"] == first_id
|
||||||
|
|
||||||
|
attempts = client.get_task_attempts(first_id)
|
||||||
|
assert attempts == direct_attempts
|
||||||
|
assert len(attempts) == 1
|
||||||
|
assert attempts[0]["status"] == "failed"
|
||||||
|
assert attempts[0]["failure_reason"] == "boom"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_get_attempts_raises_for_unknown_task(tmp_path) -> None:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
client, _ = _client_and_pool(tmp_path)
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
client.get_task_attempts("does-not-exist")
|
||||||
|
|
||||||
|
|
||||||
def test_client_raises_typed_authorization_error_without_exposing_token(
|
def test_client_raises_typed_authorization_error_without_exposing_token(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ def test_cloud_repository_exposes_crud_and_atomic_lease_operations() -> None:
|
|||||||
"list_devices",
|
"list_devices",
|
||||||
"enqueue_task",
|
"enqueue_task",
|
||||||
"get_task",
|
"get_task",
|
||||||
|
"list_tasks",
|
||||||
|
"count_tasks",
|
||||||
"save_plugin",
|
"save_plugin",
|
||||||
"assign_task",
|
"assign_task",
|
||||||
"claim_assignment",
|
"claim_assignment",
|
||||||
@@ -453,6 +455,105 @@ def test_task_attempt_history_is_ordered_and_complete(database_url: str) -> None
|
|||||||
database.close()
|
database.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_tasks_returns_empty_when_repository_has_no_tasks(
|
||||||
|
database_url: str,
|
||||||
|
) -> None:
|
||||||
|
database = CloudDatabase(database_url)
|
||||||
|
try:
|
||||||
|
assert database.repository.list_tasks() == []
|
||||||
|
assert database.repository.count_tasks() == 0
|
||||||
|
assert database.repository.list_tasks(status="queued") == []
|
||||||
|
assert database.repository.count_tasks(status="queued") == 0
|
||||||
|
finally:
|
||||||
|
database.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_tasks_returns_most_recent_first_with_optional_status_filter(
|
||||||
|
database_url: str,
|
||||||
|
) -> None:
|
||||||
|
database = CloudDatabase(database_url)
|
||||||
|
base = datetime(2026, 7, 12, 6, 0, tzinfo=UTC)
|
||||||
|
queued_ids = [_unique_id("list-task") for _ in range(2)]
|
||||||
|
failed_ids = [_unique_id("list-task") for _ in range(2)]
|
||||||
|
|
||||||
|
try:
|
||||||
|
for index, task_id in enumerate(queued_ids):
|
||||||
|
database.repository.enqueue_task(
|
||||||
|
ScheduledTask(
|
||||||
|
id=task_id,
|
||||||
|
goal="queued goal",
|
||||||
|
workflow_definition_id=None,
|
||||||
|
constraints=TaskConstraints(),
|
||||||
|
status="queued",
|
||||||
|
created_at=base + timedelta(seconds=index),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for index, task_id in enumerate(failed_ids):
|
||||||
|
database.repository.enqueue_task(
|
||||||
|
ScheduledTask(
|
||||||
|
id=task_id,
|
||||||
|
goal="failed goal",
|
||||||
|
workflow_definition_id=None,
|
||||||
|
constraints=TaskConstraints(),
|
||||||
|
status="failed",
|
||||||
|
failure_reason="boom",
|
||||||
|
created_at=base + timedelta(seconds=10 + index),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
unfiltered = database.repository.list_tasks()
|
||||||
|
assert [task.id for task in unfiltered] == (
|
||||||
|
list(reversed(failed_ids)) + list(reversed(queued_ids))
|
||||||
|
)
|
||||||
|
assert database.repository.count_tasks() == 4
|
||||||
|
|
||||||
|
queued = database.repository.list_tasks(status="queued")
|
||||||
|
assert [task.id for task in queued] == list(reversed(queued_ids))
|
||||||
|
assert all(task.status == "queued" for task in queued)
|
||||||
|
assert database.repository.count_tasks(status="queued") == 2
|
||||||
|
|
||||||
|
failed = database.repository.list_tasks(status="failed")
|
||||||
|
assert [task.id for task in failed] == list(reversed(failed_ids))
|
||||||
|
assert database.repository.count_tasks(status="failed") == 2
|
||||||
|
|
||||||
|
# A status with no matches returns an empty page and zero count.
|
||||||
|
assert database.repository.list_tasks(status="done") == []
|
||||||
|
assert database.repository.count_tasks(status="done") == 0
|
||||||
|
finally:
|
||||||
|
database.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_tasks_pagination_bounds(database_url: str) -> None:
|
||||||
|
database = CloudDatabase(database_url)
|
||||||
|
base = datetime(2026, 7, 12, 7, 0, tzinfo=UTC)
|
||||||
|
task_ids = [_unique_id("page-task") for _ in range(4)]
|
||||||
|
|
||||||
|
try:
|
||||||
|
for index, task_id in enumerate(task_ids):
|
||||||
|
database.repository.enqueue_task(
|
||||||
|
ScheduledTask(
|
||||||
|
id=task_id,
|
||||||
|
goal=f"goal-{index}",
|
||||||
|
workflow_definition_id=None,
|
||||||
|
constraints=TaskConstraints(),
|
||||||
|
created_at=base + timedelta(seconds=index),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Most-recent-first ordering means page 1 returns the newest two ids.
|
||||||
|
page_one = database.repository.list_tasks(limit=2, offset=0)
|
||||||
|
assert [task.id for task in page_one] == [task_ids[3], task_ids[2]]
|
||||||
|
|
||||||
|
page_two = database.repository.list_tasks(limit=2, offset=2)
|
||||||
|
assert [task.id for task in page_two] == [task_ids[1], task_ids[0]]
|
||||||
|
|
||||||
|
# An offset past the end of the result set returns an empty page,
|
||||||
|
# not an error — the caller is expected to consult count_tasks().
|
||||||
|
assert database.repository.list_tasks(limit=10, offset=100) == []
|
||||||
|
finally:
|
||||||
|
database.close()
|
||||||
|
|
||||||
|
|
||||||
def test_atomic_assignment_creates_lease_attempt_and_reservation(
|
def test_atomic_assignment_creates_lease_attempt_and_reservation(
|
||||||
database_url: str,
|
database_url: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -272,6 +272,8 @@ def test_submit_with_constraints(tmp_path) -> None:
|
|||||||
[
|
[
|
||||||
("post", "/v1/tasks", {"goal": "x"}, "tasks:submit"),
|
("post", "/v1/tasks", {"goal": "x"}, "tasks:submit"),
|
||||||
("get", "/v1/tasks/missing", None, "tasks:read"),
|
("get", "/v1/tasks/missing", None, "tasks:read"),
|
||||||
|
("get", "/v1/tasks", None, "tasks:read"),
|
||||||
|
("get", "/v1/tasks/missing/attempts", None, "tasks:read"),
|
||||||
("get", "/v1/devices", None, "pool:read"),
|
("get", "/v1/devices", None, "pool:read"),
|
||||||
("get", "/v1/hosts", None, "pool:read"),
|
("get", "/v1/hosts", None, "pool:read"),
|
||||||
("get", "/v1/plugins", None, "plugins:read"),
|
("get", "/v1/plugins", None, "plugins:read"),
|
||||||
@@ -332,6 +334,102 @@ def test_every_public_route_enforces_its_scope(
|
|||||||
assert authorized.status_code not in {401, 403}
|
assert authorized.status_code not in {401, 403}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_tasks_returns_summary_with_pagination_and_status_filter(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
app, pool, scheduler, _ = _build_app(tmp_path)
|
||||||
|
# Submit three tasks; assign one so the population covers multiple statuses.
|
||||||
|
first_id = scheduler.submit(goal="first")
|
||||||
|
second_id = scheduler.submit(goal="second")
|
||||||
|
pool.sync_host_devices(
|
||||||
|
"host-a",
|
||||||
|
[Device(id="device-a", driver_type="wda", status="idle")], # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
scheduler.assign()
|
||||||
|
# assigned_id is whichever task the scheduler picked (oldest-first = first_id).
|
||||||
|
assigned_id = first_id
|
||||||
|
|
||||||
|
client = _client_for(app)
|
||||||
|
|
||||||
|
unfiltered = client.get("/v1/tasks").json()
|
||||||
|
assert unfiltered["total"] == 2
|
||||||
|
assert unfiltered["limit"] == 50
|
||||||
|
assert unfiltered["offset"] == 0
|
||||||
|
assert [item["id"] for item in unfiltered["items"]] == [second_id, assigned_id]
|
||||||
|
# Lease id must not leak through the summary surface.
|
||||||
|
assert all("lease_id" not in item for item in unfiltered["items"])
|
||||||
|
|
||||||
|
queued_only = client.get("/v1/tasks", params={"status": "queued"}).json()
|
||||||
|
assert queued_only["total"] == 1
|
||||||
|
assert [item["id"] for item in queued_only["items"]] == [second_id]
|
||||||
|
assert all(item["status"] == "queued" for item in queued_only["items"])
|
||||||
|
|
||||||
|
assigned_only = client.get(
|
||||||
|
"/v1/tasks", params={"status": "assigned"}
|
||||||
|
).json()
|
||||||
|
assert assigned_only["total"] == 1
|
||||||
|
assert [item["id"] for item in assigned_only["items"]] == [assigned_id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_tasks_rejects_page_size_above_maximum(tmp_path) -> None:
|
||||||
|
app, _, _, _ = _build_app(tmp_path)
|
||||||
|
client = _client_for(app)
|
||||||
|
|
||||||
|
too_large = client.get("/v1/tasks", params={"limit": 101})
|
||||||
|
assert too_large.status_code == 422
|
||||||
|
# And the boundary value is accepted.
|
||||||
|
boundary = client.get("/v1/tasks", params={"limit": 100})
|
||||||
|
assert boundary.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_tasks_rejects_negative_offset(tmp_path) -> None:
|
||||||
|
app, _, _, _ = _build_app(tmp_path)
|
||||||
|
client = _client_for(app)
|
||||||
|
response = client.get("/v1/tasks", params={"offset": -1})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_task_attempts_returns_chronological_history(tmp_path) -> None:
|
||||||
|
app, pool, scheduler, _ = _build_app(tmp_path)
|
||||||
|
pool.sync_host_devices(
|
||||||
|
"host-a",
|
||||||
|
[Device(id="device-a", driver_type="wda", status="idle")], # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
task_id = scheduler.submit(goal="attempt me")
|
||||||
|
scheduler.assign()
|
||||||
|
task = scheduler.store.get_task(task_id)
|
||||||
|
scheduler.store.record_task_result(
|
||||||
|
task_id=task_id,
|
||||||
|
attempt=task.attempt_count,
|
||||||
|
lease_id=task.lease_id or "",
|
||||||
|
host_id=task.assigned_host_id or "",
|
||||||
|
status="failed",
|
||||||
|
failure_reason="boom",
|
||||||
|
terminal_result={"exit_code": 1},
|
||||||
|
completed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
client = _client_for(app)
|
||||||
|
resp = client.get(f"/v1/tasks/{task_id}/attempts")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert len(body) == 1
|
||||||
|
assert body[0]["task_id"] == task_id
|
||||||
|
assert body[0]["status"] == "failed"
|
||||||
|
assert body[0]["failure_reason"] == "boom"
|
||||||
|
assert body[0]["terminal_result"] == {"exit_code": 1}
|
||||||
|
assert body[0]["host_id"] == "host-a"
|
||||||
|
assert body[0]["device_id"] == "device-a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_task_attempts_returns_404_for_unknown_task(tmp_path) -> None:
|
||||||
|
app, _, _, _ = _build_app(tmp_path)
|
||||||
|
client = _client_for(app)
|
||||||
|
resp = client.get("/v1/tasks/does-not-exist/attempts")
|
||||||
|
assert resp.status_code == 404, resp.text
|
||||||
|
assert "does-not-exist" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
def test_plugin_admin_scope_is_checked_before_registration(
|
def test_plugin_admin_scope_is_checked_before_registration(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
|||||||
Reference in New Issue
Block a user