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>
192 lines
5.5 KiB
Vue
192 lines
5.5 KiB
Vue
<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>
|