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)
|
||||
|
||||
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")
|
||||
async def correlation_logging(request: Request, call_next):
|
||||
correlation_id = normalize_correlation_id(
|
||||
|
||||
@@ -608,3 +608,80 @@ def test_production_app_rejects_missing_credentials() -> None:
|
||||
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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
The Host Agent reuses the local Runtime planner. AI planning is disabled by
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
## 1. Repository: bounded task listing
|
||||
|
||||
- [ ] 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
|
||||
- [ ] 1.3 Add unit/integration tests covering status filtering, pagination bounds, and empty results against both SQLite and PostgreSQL
|
||||
- [x] 1.1 Add `list_tasks(*, status, limit, offset)` and `count_tasks(status)` to the `CloudRepository` Protocol in `repository.py`
|
||||
- [x] 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.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.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
|
||||
- [ ] 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.1 Add response models (task summary list item, task attempt) to `cloud/sdk/models.py`
|
||||
- [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
|
||||
- [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`
|
||||
- [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.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.1 Add `list_tasks(...)` and `get_task_attempts(task_id)` methods to `CloudClient` in `cloud/sdk/client.py`
|
||||
- [x] 3.2 Add client tests asserting parity with direct HTTP calls to the two new endpoints
|
||||
|
||||
## 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()`
|
||||
- [ ] 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.1 Add a `cors_allowed_origins` field (env `CLOUD_CONSOLE_CORS_ORIGINS`, comma-separated, default empty) to `CloudControlConfig`/`load_control_config()`
|
||||
- [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
|
||||
- [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.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`
|
||||
- [ ] 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`
|
||||
- [ ] 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.1 Scaffold an independent Vue 3 + Vite SPA project at `cloud-console/` (own `package.json`/build tooling, sibling to `console/`)
|
||||
- [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`
|
||||
- [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`
|
||||
- [x] 5.4 Implement the device pool and host registry views against `GET /v1/devices` and `GET /v1/hosts`
|
||||
- [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
|
||||
- [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.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.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.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
|
||||
credentials: tuple[BearerCredential, ...] = ()
|
||||
enrollment_credentials: tuple[EnrollmentCredential, ...] = ()
|
||||
cors_allowed_origins: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def load_control_config(
|
||||
@@ -90,6 +91,9 @@ def load_control_config(
|
||||
enrollment_credentials=_parse_enrollment_credentials(
|
||||
values.get("CLOUD_ENROLLMENT_TOKENS_JSON")
|
||||
),
|
||||
cors_allowed_origins=_parse_cors_origins(
|
||||
values.get("CLOUD_CONSOLE_CORS_ORIGINS")
|
||||
),
|
||||
)
|
||||
validate_control_config(config)
|
||||
return config
|
||||
@@ -219,3 +223,9 @@ def _parse_bool(value: str | None, *, default: bool) -> bool:
|
||||
if normalized in {"0", "false", "no", "off", "disabled", ""}:
|
||||
return False
|
||||
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:
|
||||
from cloud.plugins import PluginManifest
|
||||
from cloud.pool import HostRegistration, PooledDevice
|
||||
from cloud.scheduler import ScheduledTask
|
||||
from cloud.scheduler import ScheduledTask, ScheduledTaskStatus
|
||||
|
||||
|
||||
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
|
||||
@@ -154,6 +154,16 @@ class CloudRepository(Protocol):
|
||||
|
||||
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 update_task(
|
||||
|
||||
@@ -10,7 +10,7 @@ authentication can be added later without changing route signatures.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from cloud.auth import (
|
||||
PLUGINS_ADMIN_SCOPE,
|
||||
@@ -28,11 +28,14 @@ from cloud.sdk.models import (
|
||||
HostResponse,
|
||||
PluginRegistrationRequest,
|
||||
PluginResponse,
|
||||
TaskAttemptResponse,
|
||||
TaskListItem,
|
||||
TaskListResponse,
|
||||
TaskStatusResponse,
|
||||
TaskSubmissionRequest,
|
||||
TaskSubmissionResponse,
|
||||
)
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cloud.plugins import PluginRegistry
|
||||
@@ -112,6 +115,76 @@ def create_cloud_router(
|
||||
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])
|
||||
def list_devices(request: Request) -> list[DeviceResponse]:
|
||||
_authorize(request, POOL_READ_SCOPE)
|
||||
|
||||
@@ -89,6 +89,23 @@ class CloudClient:
|
||||
resp = self._request("GET", f"/tasks/{task_id}")
|
||||
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
|
||||
|
||||
def list_devices(self) -> list[dict[str, Any]]:
|
||||
@@ -133,11 +150,13 @@ class CloudClient:
|
||||
path: str,
|
||||
*,
|
||||
json: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> httpx.Response:
|
||||
response = self._http.request(
|
||||
method,
|
||||
self._url(path),
|
||||
json=json,
|
||||
params=params,
|
||||
headers=self._headers,
|
||||
auth=self._auth,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -35,6 +35,39 @@ class TaskStatusResponse(BaseModel):
|
||||
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):
|
||||
device_id: str
|
||||
host_id: str
|
||||
|
||||
@@ -368,6 +368,36 @@ class SQLAlchemyCloudRepository:
|
||||
).all()
|
||||
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:
|
||||
with self._sessions() as session:
|
||||
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"]
|
||||
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_hosts() == []
|
||||
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(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
|
||||
@@ -73,6 +73,8 @@ def test_cloud_repository_exposes_crud_and_atomic_lease_operations() -> None:
|
||||
"list_devices",
|
||||
"enqueue_task",
|
||||
"get_task",
|
||||
"list_tasks",
|
||||
"count_tasks",
|
||||
"save_plugin",
|
||||
"assign_task",
|
||||
"claim_assignment",
|
||||
@@ -453,6 +455,105 @@ def test_task_attempt_history_is_ordered_and_complete(database_url: str) -> None
|
||||
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(
|
||||
database_url: str,
|
||||
) -> None:
|
||||
|
||||
@@ -272,6 +272,8 @@ def test_submit_with_constraints(tmp_path) -> None:
|
||||
[
|
||||
("post", "/v1/tasks", {"goal": "x"}, "tasks:submit"),
|
||||
("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/hosts", None, "pool: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}
|
||||
|
||||
|
||||
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(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
|
||||
Reference in New Issue
Block a user