522 lines
16 KiB
Vue
522 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
|
import type { Component } from "vue";
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ListChecks,
|
|
LoaderCircle,
|
|
MonitorSmartphone,
|
|
Plus,
|
|
RefreshCw,
|
|
Save,
|
|
Settings2,
|
|
Trash2,
|
|
} from "@lucide/vue";
|
|
import {
|
|
API_BASE_URL,
|
|
getConfig,
|
|
getTask,
|
|
getTimeline,
|
|
listDevices,
|
|
listTasks,
|
|
registerDevice,
|
|
unregisterDevice,
|
|
updateConfig,
|
|
} from "./api";
|
|
import type { Device, TaskRecord, TimelineRecord } from "./types";
|
|
|
|
type ViewId = "dashboard" | "tasks" | "config";
|
|
|
|
const navItems: { id: ViewId; label: string; icon: Component }[] = [
|
|
{ id: "dashboard", label: "Devices", icon: MonitorSmartphone },
|
|
{ id: "tasks", label: "Tasks", icon: ListChecks },
|
|
{ id: "config", label: "Config", icon: Settings2 },
|
|
];
|
|
|
|
const taskStatuses = ["created", "running", "completed", "failed", "cancelled"];
|
|
|
|
const activeView = ref<ViewId>("dashboard");
|
|
const loading = ref(false);
|
|
const refreshError = ref("");
|
|
const devices = ref<Device[]>([]);
|
|
const tasks = ref<TaskRecord[]>([]);
|
|
const selectedTask = ref<TaskRecord | null>(null);
|
|
const timeline = ref<TimelineRecord[]>([]);
|
|
const selectedStepIndex = ref(0);
|
|
const deviceFilter = ref("");
|
|
const statusFilter = ref("");
|
|
const deviceError = ref("");
|
|
const configError = ref("");
|
|
const configSaved = ref("");
|
|
const maxSteps = ref(20);
|
|
const deviceForm = reactive({
|
|
name: "",
|
|
driver_type: "wda",
|
|
server_url: "http://127.0.0.1:4723",
|
|
udid: "",
|
|
wda_local_port: "",
|
|
});
|
|
|
|
let refreshTimer: number | undefined;
|
|
|
|
const currentStep = computed<TimelineRecord | null>(() => {
|
|
if (!timeline.value.length) {
|
|
return null;
|
|
}
|
|
return timeline.value[selectedStepIndex.value] ?? timeline.value[0];
|
|
});
|
|
|
|
const runningTasks = computed(
|
|
() => tasks.value.filter((task) => task.status === "running").length,
|
|
);
|
|
|
|
const failedTasks = computed(
|
|
() => tasks.value.filter((task) => task.status === "failed").length,
|
|
);
|
|
|
|
onMounted(async () => {
|
|
await refreshAll();
|
|
refreshTimer = window.setInterval(() => {
|
|
void refreshStatus();
|
|
}, 10000);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
if (refreshTimer !== undefined) {
|
|
window.clearInterval(refreshTimer);
|
|
}
|
|
});
|
|
|
|
async function refreshAll(): Promise<void> {
|
|
loading.value = true;
|
|
refreshError.value = "";
|
|
try {
|
|
await Promise.all([refreshDevices(), refreshTasks(), refreshConfig()]);
|
|
} catch (error) {
|
|
refreshError.value = errorMessage(error);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function refreshStatus(): Promise<void> {
|
|
try {
|
|
await Promise.all([refreshDevices(), refreshTasks()]);
|
|
} catch (error) {
|
|
refreshError.value = errorMessage(error);
|
|
}
|
|
}
|
|
|
|
async function refreshDevices(): Promise<void> {
|
|
devices.value = await listDevices();
|
|
}
|
|
|
|
async function refreshTasks(): Promise<void> {
|
|
tasks.value = await listTasks({
|
|
deviceId: deviceFilter.value,
|
|
status: statusFilter.value,
|
|
});
|
|
if (selectedTask.value) {
|
|
await openTask(selectedTask.value.id, false);
|
|
}
|
|
}
|
|
|
|
async function refreshConfig(): Promise<void> {
|
|
const config = await getConfig();
|
|
maxSteps.value = config.max_steps;
|
|
}
|
|
|
|
async function applyTaskFilters(): Promise<void> {
|
|
await refreshTasks();
|
|
}
|
|
|
|
async function openTask(taskId: string, switchView = true): Promise<void> {
|
|
const [task, records] = await Promise.all([getTask(taskId), getTimeline(taskId)]);
|
|
selectedTask.value = task;
|
|
timeline.value = records;
|
|
selectedStepIndex.value = records.length ? Math.min(selectedStepIndex.value, records.length - 1) : 0;
|
|
if (switchView) {
|
|
activeView.value = "tasks";
|
|
}
|
|
}
|
|
|
|
async function submitDevice(): Promise<void> {
|
|
deviceError.value = "";
|
|
const connectionInfo: Record<string, unknown> = {};
|
|
if (deviceForm.server_url.trim()) {
|
|
connectionInfo.server_url = deviceForm.server_url.trim();
|
|
}
|
|
if (deviceForm.udid.trim()) {
|
|
connectionInfo.udid = deviceForm.udid.trim();
|
|
}
|
|
if (deviceForm.wda_local_port.trim()) {
|
|
const port = Number(deviceForm.wda_local_port);
|
|
if (!Number.isFinite(port)) {
|
|
deviceError.value = "wda_local_port must be a number";
|
|
return;
|
|
}
|
|
connectionInfo.wda_local_port = port;
|
|
}
|
|
|
|
try {
|
|
await registerDevice({
|
|
driver_type: deviceForm.driver_type,
|
|
name: deviceForm.name.trim() || null,
|
|
connection_info: connectionInfo,
|
|
});
|
|
deviceForm.name = "";
|
|
deviceForm.udid = "";
|
|
deviceForm.wda_local_port = "";
|
|
await refreshDevices();
|
|
} catch (error) {
|
|
deviceError.value = errorMessage(error);
|
|
}
|
|
}
|
|
|
|
async function removeDevice(device: Device): Promise<void> {
|
|
if (!window.confirm(`Remove ${displayDeviceName(device)}?`)) {
|
|
return;
|
|
}
|
|
deviceError.value = "";
|
|
try {
|
|
await unregisterDevice(device.id);
|
|
await refreshDevices();
|
|
} catch (error) {
|
|
deviceError.value = errorMessage(error);
|
|
}
|
|
}
|
|
|
|
async function saveConfig(): Promise<void> {
|
|
configError.value = "";
|
|
configSaved.value = "";
|
|
try {
|
|
const updated = await updateConfig({ max_steps: Number(maxSteps.value) });
|
|
maxSteps.value = updated.max_steps;
|
|
configSaved.value = "Saved";
|
|
} catch (error) {
|
|
configError.value = errorMessage(error);
|
|
}
|
|
}
|
|
|
|
function previousStep(): void {
|
|
selectedStepIndex.value = Math.max(0, selectedStepIndex.value - 1);
|
|
}
|
|
|
|
function nextStep(): void {
|
|
selectedStepIndex.value = Math.min(timeline.value.length - 1, selectedStepIndex.value + 1);
|
|
}
|
|
|
|
function displayDeviceName(device: Device): string {
|
|
return device.name || device.id;
|
|
}
|
|
|
|
function findDeviceName(deviceId: string): string {
|
|
return devices.value.find((device) => device.id === deviceId)?.name || deviceId;
|
|
}
|
|
|
|
function formatDate(value: string | null): string {
|
|
if (!value) {
|
|
return "-";
|
|
}
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
month: "short",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
function prettyJson(value: unknown): string {
|
|
return JSON.stringify(value ?? {}, null, 2);
|
|
}
|
|
|
|
function errorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : "Request failed";
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="app-shell">
|
|
<aside class="sidebar" aria-label="Console navigation">
|
|
<div class="brand">
|
|
<MonitorSmartphone :size="22" aria-hidden="true" />
|
|
<div>
|
|
<strong>Apex Console</strong>
|
|
<span>{{ API_BASE_URL }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<nav class="nav-list">
|
|
<button
|
|
v-for="item in navItems"
|
|
:key="item.id"
|
|
class="nav-button"
|
|
:class="{ active: activeView === item.id }"
|
|
type="button"
|
|
@click="activeView = item.id"
|
|
>
|
|
<component :is="item.icon" :size="18" aria-hidden="true" />
|
|
<span>{{ item.label }}</span>
|
|
</button>
|
|
</nav>
|
|
</aside>
|
|
|
|
<main class="workspace">
|
|
<header class="topbar">
|
|
<div>
|
|
<h1>{{ navItems.find((item) => item.id === activeView)?.label }}</h1>
|
|
<p>{{ devices.length }} devices / {{ tasks.length }} tasks</p>
|
|
</div>
|
|
<button class="icon-text-button" type="button" :disabled="loading" @click="refreshAll">
|
|
<LoaderCircle v-if="loading" class="spin" :size="17" aria-hidden="true" />
|
|
<RefreshCw v-else :size="17" aria-hidden="true" />
|
|
<span>Refresh</span>
|
|
</button>
|
|
</header>
|
|
|
|
<p v-if="refreshError" class="alert error">{{ refreshError }}</p>
|
|
|
|
<section v-if="activeView === 'dashboard'" class="view-grid">
|
|
<div class="metrics">
|
|
<div class="metric">
|
|
<span class="metric-label">Devices</span>
|
|
<strong>{{ devices.length }}</strong>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">Running</span>
|
|
<strong>{{ runningTasks }}</strong>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">Failed</span>
|
|
<strong>{{ failedTasks }}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<section class="panel">
|
|
<div class="section-title">
|
|
<h2>Device Status</h2>
|
|
</div>
|
|
<div v-if="!devices.length" class="empty-state">
|
|
<MonitorSmartphone :size="34" aria-hidden="true" />
|
|
<span>No devices registered. Add one from Config.</span>
|
|
</div>
|
|
<ul v-else class="device-list">
|
|
<li v-for="device in devices" :key="device.id" class="device-row">
|
|
<div>
|
|
<strong>{{ displayDeviceName(device) }}</strong>
|
|
<span>{{ device.id }}</span>
|
|
</div>
|
|
<div class="row-meta">
|
|
<span class="driver-label">{{ device.driver_type }}</span>
|
|
<span class="status-pill" :class="device.status">{{ device.status }}</span>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
</section>
|
|
|
|
<section v-if="activeView === 'tasks'" class="tasks-layout">
|
|
<section class="panel task-browser">
|
|
<div class="section-title">
|
|
<h2>Task List</h2>
|
|
</div>
|
|
<div class="filters">
|
|
<label>
|
|
Device
|
|
<select v-model="deviceFilter" @change="applyTaskFilters">
|
|
<option value="">All devices</option>
|
|
<option v-for="device in devices" :key="device.id" :value="device.id">
|
|
{{ displayDeviceName(device) }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Status
|
|
<select v-model="statusFilter" @change="applyTaskFilters">
|
|
<option value="">All statuses</option>
|
|
<option v-for="statusName in taskStatuses" :key="statusName" :value="statusName">
|
|
{{ statusName }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div v-if="!tasks.length" class="empty-state compact">
|
|
<ListChecks :size="30" aria-hidden="true" />
|
|
<span>No tasks match the current filters.</span>
|
|
</div>
|
|
<button
|
|
v-for="task in tasks"
|
|
v-else
|
|
:key="task.id"
|
|
class="task-row"
|
|
:class="{ selected: selectedTask?.id === task.id }"
|
|
type="button"
|
|
@click="openTask(task.id)"
|
|
>
|
|
<span class="task-goal">{{ task.goal }}</span>
|
|
<span class="task-meta">
|
|
{{ findDeviceName(task.device_id) }} / {{ formatDate(task.created_at) }}
|
|
</span>
|
|
<span class="status-pill" :class="task.status">{{ task.status }}</span>
|
|
</button>
|
|
</section>
|
|
|
|
<section class="panel timeline-panel">
|
|
<div class="section-title">
|
|
<h2>Task Detail</h2>
|
|
<span v-if="selectedTask" class="status-pill" :class="selectedTask.status">
|
|
{{ selectedTask.status }}
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="!selectedTask" class="empty-state">
|
|
<ListChecks :size="34" aria-hidden="true" />
|
|
<span>Select a task to inspect its timeline.</span>
|
|
</div>
|
|
|
|
<div v-else class="task-detail">
|
|
<dl class="detail-grid">
|
|
<div>
|
|
<dt>Goal</dt>
|
|
<dd>{{ selectedTask.goal }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Device</dt>
|
|
<dd>{{ findDeviceName(selectedTask.device_id) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Updated</dt>
|
|
<dd>{{ formatDate(selectedTask.updated_at) }}</dd>
|
|
</div>
|
|
<div v-if="selectedTask.failure_reason">
|
|
<dt>Failure</dt>
|
|
<dd>{{ selectedTask.failure_reason }}</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<div class="timeline-controls">
|
|
<button
|
|
class="icon-button"
|
|
type="button"
|
|
title="Previous step"
|
|
:disabled="selectedStepIndex === 0"
|
|
@click="previousStep"
|
|
>
|
|
<ChevronLeft :size="18" aria-hidden="true" />
|
|
</button>
|
|
<span>{{ timeline.length ? selectedStepIndex + 1 : 0 }} / {{ timeline.length }}</span>
|
|
<button
|
|
class="icon-button"
|
|
type="button"
|
|
title="Next step"
|
|
:disabled="selectedStepIndex >= timeline.length - 1"
|
|
@click="nextStep"
|
|
>
|
|
<ChevronRight :size="18" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="!currentStep" class="empty-state compact">
|
|
<span>No timeline records captured.</span>
|
|
</div>
|
|
<div v-else class="timeline-stage">
|
|
<div class="screenshot-frame">
|
|
<img
|
|
v-if="currentStep.image_base64"
|
|
:src="`data:image/png;base64,${currentStep.image_base64}`"
|
|
alt="Task step screenshot"
|
|
/>
|
|
<span v-else>No screenshot</span>
|
|
</div>
|
|
<div class="step-data">
|
|
<div>
|
|
<h3>Tool Call</h3>
|
|
<pre>{{ prettyJson(currentStep.tool_call) }}</pre>
|
|
</div>
|
|
<div>
|
|
<h3>Result</h3>
|
|
<pre>{{ prettyJson(currentStep.result) }}</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</section>
|
|
|
|
<section v-if="activeView === 'config'" class="config-layout">
|
|
<section class="panel">
|
|
<div class="section-title">
|
|
<h2>Device Configuration</h2>
|
|
</div>
|
|
<form class="form-grid" @submit.prevent="submitDevice">
|
|
<label>
|
|
Name
|
|
<input v-model="deviceForm.name" type="text" placeholder="Desk iPhone" />
|
|
</label>
|
|
<label>
|
|
Driver
|
|
<select v-model="deviceForm.driver_type">
|
|
<option value="wda">wda</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Server URL
|
|
<input v-model="deviceForm.server_url" type="url" />
|
|
</label>
|
|
<label>
|
|
UDID
|
|
<input v-model="deviceForm.udid" type="text" />
|
|
</label>
|
|
<label>
|
|
WDA local port
|
|
<input v-model="deviceForm.wda_local_port" type="number" min="1" />
|
|
</label>
|
|
<button class="icon-text-button submit-button" type="submit">
|
|
<Plus :size="17" aria-hidden="true" />
|
|
<span>Add Device</span>
|
|
</button>
|
|
</form>
|
|
<p v-if="deviceError" class="alert error">{{ deviceError }}</p>
|
|
|
|
<ul class="device-list managed">
|
|
<li v-for="device in devices" :key="device.id" class="device-row">
|
|
<div>
|
|
<strong>{{ displayDeviceName(device) }}</strong>
|
|
<span>{{ device.id }}</span>
|
|
</div>
|
|
<button
|
|
class="icon-button danger"
|
|
type="button"
|
|
title="Remove device"
|
|
@click="removeDevice(device)"
|
|
>
|
|
<Trash2 :size="17" aria-hidden="true" />
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="section-title">
|
|
<h2>Runtime Parameters</h2>
|
|
</div>
|
|
<form class="settings-form" @submit.prevent="saveConfig">
|
|
<label>
|
|
Max steps
|
|
<input v-model.number="maxSteps" type="number" min="1" />
|
|
</label>
|
|
<button class="icon-text-button" type="submit">
|
|
<Save :size="17" aria-hidden="true" />
|
|
<span>Save</span>
|
|
</button>
|
|
</form>
|
|
<p v-if="configError" class="alert error">{{ configError }}</p>
|
|
<p v-if="configSaved" class="alert success">{{ configSaved }}</p>
|
|
</section>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
</template>
|