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>
306 lines
8.7 KiB
Vue
306 lines
8.7 KiB
Vue
<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>
|