feat(cloud): add targeted task governance foundation
Tests / Test passed: 659

This commit is contained in:
2026-07-13 22:21:12 +08:00
parent a3ba94be04
commit 2cd314b183
41 changed files with 2099 additions and 15 deletions
+6 -1
View File
@@ -32,6 +32,11 @@ const canAdminPlugins = computed(
currentUser.value?.scopes.includes("*") ||
currentUser.value?.scopes.includes("plugins:admin"),
);
const canSubmitTasks = computed(
() =>
currentUser.value?.scopes.includes("*") ||
currentUser.value?.scopes.includes("tasks:submit"),
);
const isAuthenticated = computed(() => currentUser.value !== null);
const currentUserLabel = computed(() =>
currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "",
@@ -128,7 +133,7 @@ const activeComponent = computed(() => {
</nav>
<main class="app-main">
<PluginsView v-if="activeView === 'plugins'" :can-admin="canAdminPlugins" />
<component v-else :is="activeComponent" />
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
</main>
</div>
</template>
+8
View File
@@ -6,6 +6,7 @@ import type {
PluginRegistrationPayload,
TaskAttempt,
TaskListResponse,
TaskSubmissionPayload,
TaskStatus,
} from "./types";
@@ -135,6 +136,13 @@ export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
}
export function submitTask(payload: TaskSubmissionPayload): Promise<{ task_id: string }> {
return request<{ task_id: string }>("/v1/tasks", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function listDevices(): Promise<DeviceRecord[]> {
return request<DeviceRecord[]>("/v1/devices");
}
+13
View File
@@ -14,9 +14,22 @@ export interface TaskListItem {
assigned_host_id: string | null;
attempt_count: number;
failure_reason: string | null;
target_host_id: string | null;
target_device_id: string | null;
created_at: string;
}
export interface TaskSubmissionPayload {
goal?: string;
workflow_definition_id?: string;
constraints?: {
driver_type?: string;
capability_tags?: string[];
target_host_id?: string;
target_device_id?: string;
};
}
export interface TaskListResponse {
items: TaskListItem[];
total: number;
+105 -3
View File
@@ -1,18 +1,25 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { LoaderCircle, RefreshCw } from "@lucide/vue";
import {
CloudApiError,
getTaskAttempts,
listTasks,
listDevices,
listHosts,
submitTask,
} from "../api";
import type {
TaskAttempt,
TaskListItem,
TaskListResponse,
TaskStatus,
DeviceRecord,
HostRecord,
} from "../types";
const props = defineProps<{ canSubmit: boolean }>();
const STATUSES: TaskStatus[] = [
"queued",
"assigned",
@@ -31,16 +38,36 @@ const selectedTask = ref<TaskListItem | null>(null);
const attempts = ref<TaskAttempt[]>([]);
const attemptsLoading = ref(false);
const attemptsError = ref("");
const composerOpen = ref(false);
const submitGoal = ref("");
const submitWorkflow = ref("");
const submitHostId = ref("");
const submitDeviceId = ref("");
const submitDriverType = ref("");
const submitCapabilityTags = ref("");
const submitting = ref(false);
const hosts = ref<HostRecord[]>([]);
const devices = ref<DeviceRecord[]>([]);
const availableDevices = computed(() =>
devices.value.filter((device) => device.host_id === submitHostId.value),
);
async function refresh() {
loading.value = true;
errorMessage.value = "";
try {
result.value = await listTasks({
const [tasks, loadedHosts, loadedDevices] = await Promise.all([
listTasks({
status: statusFilter.value === "" ? undefined : statusFilter.value,
limit: pageSize.value,
offset: offset.value,
});
}),
listHosts(),
listDevices(),
]);
result.value = tasks;
hosts.value = loadedHosts;
devices.value = loadedDevices;
if (selectedTask.value) {
const stillPresent = result.value.items.find(
(item) => item.id === selectedTask.value?.id,
@@ -57,6 +84,47 @@ async function refresh() {
}
}
async function createTask() {
const goal = submitGoal.value.trim();
const workflow = submitWorkflow.value.trim();
if (!goal && !workflow) {
errorMessage.value = "provide a goal or workflow id";
return;
}
submitting.value = true;
errorMessage.value = "";
try {
const hostId = submitHostId.value || undefined;
const deviceId = submitDeviceId.value || undefined;
await submitTask({
goal: goal || undefined,
workflow_definition_id: workflow || undefined,
constraints: {
driver_type: submitDriverType.value || undefined,
capability_tags: submitCapabilityTags.value
.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
target_host_id: hostId,
target_device_id: deviceId,
},
});
composerOpen.value = false;
submitGoal.value = "";
submitWorkflow.value = "";
submitHostId.value = "";
submitDeviceId.value = "";
submitDriverType.value = "";
submitCapabilityTags.value = "";
offset.value = 0;
await refresh();
} catch (err) {
handleError(err, "failed to create task");
} finally {
submitting.value = false;
}
}
async function selectTask(task: TaskListItem) {
selectedTask.value = task;
attempts.value = [];
@@ -113,6 +181,11 @@ watch(pageSize, () => {
refresh();
});
watch(offset, refresh);
watch(submitHostId, () => {
if (!availableDevices.value.some((device) => device.device_id === submitDeviceId.value)) {
submitDeviceId.value = "";
}
});
onMounted(refresh);
@@ -145,6 +218,9 @@ function formatTerminalResult(attempt: TaskAttempt): string {
<div>
<div class="toolbar">
<h2>Tasks</h2>
<button v-if="props.canSubmit" @click="composerOpen = !composerOpen">
{{ composerOpen ? "Cancel create" : "Create task" }}
</button>
<label>
Status
<select v-model="statusFilter">
@@ -172,6 +248,29 @@ function formatTerminalResult(attempt: TaskAttempt): string {
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
<form v-if="composerOpen" class="panel" @submit.prevent="createTask">
<h3>Create task</h3>
<label>Goal <input v-model="submitGoal" placeholder="Describe the device task" /></label>
<label>Workflow ID <input v-model="submitWorkflow" placeholder="Optional workflow reference" /></label>
<label>
Target Host
<select v-model="submitHostId">
<option value="">Any eligible host</option>
<option v-for="host in hosts" :key="host.host_id" :value="host.host_id">{{ host.host_id }}</option>
</select>
</label>
<label>
Target Device
<select v-model="submitDeviceId" :disabled="!submitHostId">
<option value="">Any eligible device on selected host</option>
<option v-for="device in availableDevices" :key="device.device_id" :value="device.device_id">{{ device.device_id }}</option>
</select>
</label>
<label>Driver type <input v-model="submitDriverType" placeholder="Optional, e.g. wda" /></label>
<label>Capability tags <input v-model="submitCapabilityTags" placeholder="Optional, comma separated" /></label>
<div class="actions"><button type="submit" :disabled="submitting">{{ submitting ? "Creating…" : "Create" }}</button></div>
</form>
<div class="panel" v-if="!selectedTask">
<table v-if="result && result.items.length">
<thead>
@@ -212,6 +311,9 @@ function formatTerminalResult(attempt: TaskAttempt): string {
{{ task.assigned_device_id }}
<span class="dim">on {{ task.assigned_host_id }}</span>
</div>
<div v-else-if="task.target_host_id" class="dim">
target: {{ task.target_host_id }}<span v-if="task.target_device_id"> / {{ task.target_device_id }}</span>
</div>
<div v-else class="dim">unassigned</div>
</td>
<td>{{ task.attempt_count }}</td>