Files
agentic-mobile-control/console/src/App.vue
T

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>