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