This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user