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:
@@ -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>
|
||||
Reference in New Issue
Block a user