Files
PilotVault/API Server/panel/src/App.vue
T
tajniak81andClaude Opus 4.8 e3106d7b60 Show status latency at end of detail line in API panel
Reorder the Status card meta() detail so HTTP status and URL come
first and the latency (ms) reads last, e.g. "HTTP 200 · <url> · 12ms".
Rebuild the embedded panel assets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:36:17 +02:00

1315 lines
53 KiB
Vue

<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { theme, toggleTheme } from "./theme";
import EndpointTable from "./components/EndpointTable.vue";
// ---- Auth gate: this panel is restricted to superadmins ------------------
// The token is kept only for this browser (localStorage). Login proxies through
// the API Server to PocketBase; the caller's role is then confirmed via /api/me,
// and anyone who is not a superadmin is refused.
const TOKEN_KEY = "pv_panel_token";
const token = ref(localStorage.getItem(TOKEN_KEY) || "");
const me = ref(null); // { id, email, role } once verified as superadmin
const authed = ref(false);
const booting = ref(true);
const form = ref({ email: "", password: "" });
const loginErr = ref("");
const busy = ref(false);
// Console tabs.
const tab = ref("overview");
const TABS = [
{ id: "overview", label: "Overview" },
{ id: "users", label: "Users" },
{ id: "organizations", label: "Organizations" },
{ id: "pocketbase", label: "PocketBase" },
{ id: "plugins", label: "Plugins" },
];
// Verify a token resolves to a superadmin. Returns true when access is granted.
async function verify(tok) {
try {
const r = await fetch("/api/me", { headers: { Authorization: tok } });
if (!r.ok) return false;
const who = await r.json();
if (who.role !== "superadmin") return false;
me.value = { id: who.id, email: who.email, role: who.role };
return true;
} catch {
return false;
}
}
async function grant(tok) {
token.value = tok;
localStorage.setItem(TOKEN_KEY, tok);
authed.value = true;
startPolling();
loadPbConfig();
loadPlugins();
loadOrgs();
loadUsers();
}
async function doLogin() {
loginErr.value = "";
const email = form.value.email.trim().toLowerCase();
if (!email || !form.value.password) {
loginErr.value = "Enter your email and password.";
return;
}
busy.value = true;
try {
const r = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password: form.value.password }),
});
const body = await r.json().catch(() => ({}));
if (!r.ok || !body.token) {
loginErr.value = "Invalid email or password.";
return;
}
// Authenticated — now enforce the superadmin-only rule.
if (!(await verify(body.token))) {
loginErr.value = "Access to this panel is restricted to superadmins.";
return;
}
form.value.password = "";
await grant(body.token);
} catch {
loginErr.value = "Cannot reach the API server.";
} finally {
busy.value = false;
}
}
function logout() {
stopPolling();
localStorage.removeItem(TOKEN_KEY);
token.value = "";
me.value = null;
authed.value = false;
}
// ---- PocketBase connection settings (superadmin) -------------------------
const pb = ref(null); // { url, adminEmail, adminConfigured, probe }
const pbForm = ref({ url: "", adminEmail: "", adminPassword: "" });
const pbProbe = ref(null); // most recent test/save probe result
const pbMsg = ref("");
const pbErr = ref("");
const pbBusy = ref(false);
const pbTesting = ref(false);
function authHeaders(json) {
const h = { Authorization: token.value };
if (json) h["Content-Type"] = "application/json";
return h;
}
async function loadPbConfig() {
try {
const r = await fetch("/api/admin/pb-config", { headers: authHeaders() });
if (!r.ok) return;
const d = await r.json();
pb.value = d;
pbForm.value = { url: d.url || "", adminEmail: d.adminEmail || "", adminPassword: "" };
pbProbe.value = d.probe || null;
} catch {
/* leave settings unloaded; the card shows a retry */
}
}
async function testPbConfig() {
pbMsg.value = "";
pbErr.value = "";
pbTesting.value = true;
try {
const r = await fetch("/api/admin/pb-config/test", {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify(pbForm.value),
});
pbProbe.value = await r.json();
} catch {
pbErr.value = "Could not run the test.";
} finally {
pbTesting.value = false;
}
}
async function savePbConfig() {
pbMsg.value = "";
pbErr.value = "";
if (!pbForm.value.url.trim()) {
pbErr.value = "A PocketBase URL is required.";
return;
}
pbBusy.value = true;
try {
const r = await fetch("/api/admin/pb-config", {
method: "PUT",
headers: authHeaders(true),
body: JSON.stringify(pbForm.value),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
pbErr.value = d.error || "Could not save the connection.";
return;
}
pb.value = d.config;
pbProbe.value = d.config?.probe || null;
pbForm.value = { url: d.config.url, adminEmail: d.config.adminEmail, adminPassword: "" };
pbMsg.value = d.warning || "Connection saved.";
} catch {
pbErr.value = "Could not reach the API server.";
} finally {
pbBusy.value = false;
}
}
// ---- Plugins (external-service integrations, superadmin) ------------------
const plugins = ref([]); // [{ name, provider, version, kind, capabilities, authType, configFields, enabled, config, baseURL, health }]
const pluginsErr = ref("");
const pluginsMsg = ref("");
const editingPlugin = ref(""); // name whose config form is expanded
const editConfig = ref({}); // working copy of the expanded plugin's config
const busyPlugin = ref(""); // name of a plugin with an in-flight action
const newExt = ref({ name: "", baseURL: "", provider: "" });
const newExtErr = ref("");
const newExtBusy = ref(false);
// Plugins are grouped into category tabs (mirrors the Web App's Integrations tabs).
// A plugin's server-side `category` picks its tab; an unset category falls back to APIs — External.
const PLUGIN_CATEGORIES = [
{ id: "apis-external", label: "APIs — External" },
{ id: "drives-external", label: "Drives — External" },
{ id: "drives-local", label: "Drives — Local" },
];
const pluginTab = ref("apis-external");
function pluginCategory(p) {
return p.category || "apis-external";
}
function pluginsInCategory(id) {
return plugins.value.filter((p) => pluginCategory(p) === id);
}
const activePlugins = computed(() => pluginsInCategory(pluginTab.value));
const kindBadge = {
builtin: "bg-success-tint text-success",
external: "bg-warning-tint text-warning",
};
const healthBadge = {
ok: "bg-success-tint text-success",
degraded: "bg-warning-tint text-warning",
down: "bg-danger-tint text-danger",
};
async function loadPlugins() {
pluginsErr.value = "";
try {
const r = await fetch("/api/admin/plugins", { headers: authHeaders() });
if (!r.ok) {
pluginsErr.value = "Could not load plugins.";
return;
}
const d = await r.json();
plugins.value = d.plugins || [];
// Populate health (and live credit usage) for enabled plugins that don't have
// it yet — e.g. right after a server restart. Cached server-side afterwards,
// so this probes at most once per plugin per server run.
for (const p of plugins.value) {
if (p.enabled && !p.health) autoCheckHealth(p.name);
}
} catch {
pluginsErr.value = "Could not reach the API server.";
}
}
async function autoCheckHealth(name) {
try {
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(name)}/health`, {
method: "POST",
headers: authHeaders(),
});
const d = await r.json().catch(() => ({}));
if (r.ok && d.health) {
const t = pluginBy(name);
if (t) t.health = d.health;
}
} catch {
/* leave health unset */
}
}
function pluginBy(name) {
return plugins.value.find((p) => p.name === name);
}
async function togglePlugin(p) {
busyPlugin.value = p.name;
pluginsMsg.value = "";
try {
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}`, {
method: "PUT",
headers: authHeaders(true),
body: JSON.stringify({ enabled: !p.enabled }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
pluginsMsg.value = d.error || "Could not update the plugin.";
} else if (d.warning) {
pluginsMsg.value = `${p.name}: ${d.warning}`;
}
await loadPlugins();
} finally {
busyPlugin.value = "";
}
}
function startPluginEdit(p) {
editingPlugin.value = editingPlugin.value === p.name ? "" : p.name;
const cfg = { ...(p.config || {}) };
// Preselect each field's effective default when nothing is stored yet.
for (const f of p.configFields || []) {
if ((cfg[f.key] === undefined || cfg[f.key] === "") && f.default) cfg[f.key] = f.default;
}
editConfig.value = cfg;
}
async function savePluginConfig(p) {
busyPlugin.value = p.name;
pluginsMsg.value = "";
try {
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}`, {
method: "PUT",
headers: authHeaders(true),
body: JSON.stringify({ config: editConfig.value }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
pluginsMsg.value = d.error || "Could not save the configuration.";
} else {
pluginsMsg.value = d.warning ? `${p.name}: ${d.warning}` : "Configuration saved.";
editingPlugin.value = "";
}
await loadPlugins();
} finally {
busyPlugin.value = "";
}
}
async function checkPlugin(p) {
busyPlugin.value = p.name;
pluginsMsg.value = "";
try {
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}/health`, {
method: "POST",
headers: authHeaders(),
});
const d = await r.json().catch(() => ({}));
if (r.ok && d.health) {
const t = pluginBy(p.name);
if (t) t.health = d.health;
} else {
pluginsMsg.value = d.error || "Health check failed.";
}
} finally {
busyPlugin.value = "";
}
}
async function removePlugin(p) {
if (p.kind !== "external") return;
busyPlugin.value = p.name;
try {
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}`, {
method: "DELETE",
headers: authHeaders(),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
pluginsMsg.value = d.error || "Could not remove the plugin.";
}
await loadPlugins();
} finally {
busyPlugin.value = "";
}
}
async function registerExternal() {
newExtErr.value = "";
if (!newExt.value.name.trim() || !newExt.value.baseURL.trim()) {
newExtErr.value = "Name and base URL are required.";
return;
}
newExtBusy.value = true;
try {
const r = await fetch("/api/admin/plugins", {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify(newExt.value),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
newExtErr.value = d.error || "Could not register the plugin.";
return;
}
newExt.value = { name: "", baseURL: "", provider: "" };
pluginsMsg.value = "External plugin registered.";
await loadPlugins();
} catch {
newExtErr.value = "Could not reach the API server.";
} finally {
newExtBusy.value = false;
}
}
// ---- Users & Organizations (superadmin) ----------------------------------
// This panel is superadmin-gated, so the UI always operates with the full,
// cross-organization scope the API grants a superadmin.
const users = ref([]); // [{ id, email, role, verified, created, organization, organizationName }]
const orgs = ref([]); // [{ id, name, created }]
const umErr = ref(""); // load error banner (shared by both tabs)
const ROLES = [
{ value: "user", label: "User" },
{ value: "admin", label: "Admin" },
{ value: "superadmin", label: "Superadmin" },
];
const roleLabel = { user: "User", admin: "Admin", superadmin: "Superadmin" };
const roleBadge = {
user: "bg-sunken text-secondary",
admin: "bg-warning-tint text-warning",
superadmin: "bg-success-tint text-success",
};
// Member counts per organization, derived from the (complete, superadmin) user
// list — used to annotate orgs and to explain why a delete is blocked.
const orgMemberCount = computed(() => {
const m = {};
for (const u of users.value) {
if (u.organization) m[u.organization] = (m[u.organization] || 0) + 1;
}
return m;
});
// Turn either our own {error} envelope or a relayed PocketBase validation error
// ({message, data:{field:{message}}}) into a single readable line.
function apiError(d, fallback) {
if (!d) return fallback;
if (d.error) return d.error;
if (d.data && typeof d.data === "object") {
const parts = [];
for (const k in d.data) {
if (d.data[k] && d.data[k].message) parts.push(`${k}: ${d.data[k].message}`);
}
if (parts.length) return parts.join("; ");
}
return d.message || fallback;
}
function orgLabel(id) {
if (!id) return "— none —";
const o = orgs.value.find((x) => x.id === id);
return o ? o.name : id;
}
// -- Users --
const newUser = ref({ email: "", password: "", role: "user", organization: "" });
const newUserErr = ref("");
const newUserMsg = ref("");
const newUserBusy = ref(false);
const editingUser = ref(""); // id of the user whose inline editor is open
const editUser = ref({}); // working copy { email, role, organization, verified, password }
const busyUser = ref(""); // id with an in-flight action
const userMsg = ref("");
async function loadUsers() {
umErr.value = "";
try {
const r = await fetch("/api/users", { headers: authHeaders() });
const d = await r.json().catch(() => ({}));
if (!r.ok) {
umErr.value = apiError(d, "Could not load users.");
return;
}
users.value = d.users || [];
} catch {
umErr.value = "Could not reach the API server.";
}
}
async function createUser() {
newUserErr.value = "";
newUserMsg.value = "";
const email = newUser.value.email.trim().toLowerCase();
if (!email || !email.includes("@")) {
newUserErr.value = "A valid email is required.";
return;
}
if (newUser.value.password.length < 8) {
newUserErr.value = "Password must be at least 8 characters.";
return;
}
newUserBusy.value = true;
try {
const r = await fetch("/api/users", {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify({
email,
password: newUser.value.password,
role: newUser.value.role,
organization: newUser.value.organization,
}),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
newUserErr.value = apiError(d, "Could not create the user.");
return;
}
newUser.value = { email: "", password: "", role: "user", organization: "" };
newUserMsg.value = `Created ${d.user ? d.user.email : "user"}.`;
await loadUsers();
} catch {
newUserErr.value = "Could not reach the API server.";
} finally {
newUserBusy.value = false;
}
}
function startUserEdit(u) {
userMsg.value = "";
editingUser.value = editingUser.value === u.id ? "" : u.id;
editUser.value = {
email: u.email,
role: u.role,
organization: u.organization || "",
verified: !!u.verified,
password: "",
};
}
async function saveUser(u) {
userMsg.value = "";
busyUser.value = u.id;
try {
// Only send fields that actually changed; password only when set.
const patch = {};
const e = editUser.value;
if (e.email.trim().toLowerCase() !== u.email) patch.email = e.email.trim().toLowerCase();
if (e.role !== u.role) patch.role = e.role;
if ((e.organization || "") !== (u.organization || "")) patch.organization = e.organization || "";
if (e.verified !== !!u.verified) patch.verified = e.verified;
if (e.password) patch.password = e.password;
if (Object.keys(patch).length === 0) {
userMsg.value = "No changes to save.";
return;
}
const r = await fetch(`/api/users/${encodeURIComponent(u.id)}`, {
method: "PATCH",
headers: authHeaders(true),
body: JSON.stringify(patch),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
userMsg.value = apiError(d, "Could not update the user.");
return;
}
userMsg.value = `Updated ${u.email}.`;
editingUser.value = "";
await loadUsers();
} catch {
userMsg.value = "Could not reach the API server.";
} finally {
busyUser.value = "";
}
}
async function deleteUser(u) {
if (u.id === (me.value && me.value.id)) return; // guarded in the template too
if (!confirm(`Delete user ${u.email}? This cannot be undone.`)) return;
userMsg.value = "";
busyUser.value = u.id;
try {
const r = await fetch(`/api/users/${encodeURIComponent(u.id)}`, {
method: "DELETE",
headers: authHeaders(),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
userMsg.value = apiError(d, "Could not delete the user.");
return;
}
userMsg.value = `Deleted ${u.email}.`;
if (editingUser.value === u.id) editingUser.value = "";
await loadUsers();
} catch {
userMsg.value = "Could not reach the API server.";
} finally {
busyUser.value = "";
}
}
// -- Organizations --
const newOrgName = ref("");
const newOrgErr = ref("");
const newOrgMsg = ref("");
const newOrgBusy = ref(false);
const editingOrg = ref(""); // id of the org being renamed
const editOrgName = ref("");
const busyOrg = ref("");
const orgMsg = ref("");
async function loadOrgs() {
try {
const r = await fetch("/api/orgs", { headers: authHeaders() });
if (!r.ok) return;
const d = await r.json().catch(() => ({}));
orgs.value = d.organizations || [];
} catch {
/* leave orgs as-is; the users tab still works without names */
}
}
async function createOrg() {
newOrgErr.value = "";
newOrgMsg.value = "";
const name = newOrgName.value.trim();
if (!name) {
newOrgErr.value = "An organization name is required.";
return;
}
newOrgBusy.value = true;
try {
const r = await fetch("/api/orgs", {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify({ name }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
newOrgErr.value = apiError(d, "Could not create the organization.");
return;
}
newOrgName.value = "";
newOrgMsg.value = `Created ${d.organization ? d.organization.name : "organization"}.`;
await loadOrgs();
} catch {
newOrgErr.value = "Could not reach the API server.";
} finally {
newOrgBusy.value = false;
}
}
function startOrgEdit(o) {
orgMsg.value = "";
editingOrg.value = editingOrg.value === o.id ? "" : o.id;
editOrgName.value = o.name;
}
async function saveOrg(o) {
orgMsg.value = "";
const name = editOrgName.value.trim();
if (!name) {
orgMsg.value = "An organization name is required.";
return;
}
if (name === o.name) {
editingOrg.value = "";
return;
}
busyOrg.value = o.id;
try {
const r = await fetch(`/api/orgs/${encodeURIComponent(o.id)}`, {
method: "PATCH",
headers: authHeaders(true),
body: JSON.stringify({ name }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
orgMsg.value = apiError(d, "Could not rename the organization.");
return;
}
orgMsg.value = `Renamed to ${name}.`;
editingOrg.value = "";
await loadOrgs();
await loadUsers(); // refresh the resolved org names on user rows
} catch {
orgMsg.value = "Could not reach the API server.";
} finally {
busyOrg.value = "";
}
}
async function deleteOrg(o) {
const members = orgMemberCount.value[o.id] || 0;
if (members > 0) {
orgMsg.value = `${o.name} still has ${members} member${members === 1 ? "" : "s"}; reassign or remove them first.`;
return;
}
if (!confirm(`Delete organization ${o.name}?`)) return;
orgMsg.value = "";
busyOrg.value = o.id;
try {
const r = await fetch(`/api/orgs/${encodeURIComponent(o.id)}`, {
method: "DELETE",
headers: authHeaders(),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
orgMsg.value = apiError(d, "Could not delete the organization.");
return;
}
orgMsg.value = `Deleted ${o.name}.`;
if (editingOrg.value === o.id) editingOrg.value = "";
await loadOrgs();
} catch {
orgMsg.value = "Could not reach the API server.";
} finally {
busyOrg.value = "";
}
}
// Live health poll against this server's aggregate /api/status, which probes
// PocketBase and the Web App server-side (the browser only talks to the API).
const checkedAt = ref(null);
const devices = ref(null);
const svc = ref({
apiServer: { status: "checking", detail: "" },
pocketBase: { status: "checking", detail: "" },
webApp: { status: "checking", detail: "" },
});
let timer = null;
const rows = [
{ key: "apiServer", label: "API server" },
{ key: "pocketBase", label: "PocketBase" },
{ key: "webApp", label: "Web App" },
];
const badge = {
checking: { label: "checking", cls: "bg-sunken text-secondary" },
ok: { label: "operational", cls: "bg-success-tint text-success" },
down: { label: "unreachable", cls: "bg-danger-tint text-danger" },
unreachable: { label: "unreachable", cls: "bg-danger-tint text-danger" },
};
function meta(h) {
const bits = [];
if (h.httpStatus) bits.push("HTTP " + h.httpStatus);
if (h.url) bits.push(h.url);
if (typeof h.latencyMs === "number") bits.push(h.latencyMs + "ms");
return bits.join(" · ");
}
async function check() {
try {
const r = await fetch("/api/status");
if (!r.ok) throw new Error("status " + r.status);
const b = await r.json();
const api = b.apiServer || {};
devices.value = api.devices ?? 0;
svc.value = {
apiServer: { status: "ok", detail: "" },
pocketBase: { status: b.pocketBase?.status === "ok" ? "ok" : "down", detail: meta(b.pocketBase || {}) },
webApp: { status: b.webApp?.status === "ok" ? "ok" : "down", detail: meta(b.webApp || {}) },
};
} catch {
// The API server itself is unreachable → status of everything is unknown.
devices.value = null;
svc.value = {
apiServer: { status: "unreachable", detail: "" },
pocketBase: { status: "unreachable", detail: "" },
webApp: { status: "unreachable", detail: "" },
};
}
checkedAt.value = new Date();
}
function startPolling() {
check();
clearInterval(timer);
timer = setInterval(check, 10000);
}
function stopPolling() {
clearInterval(timer);
timer = null;
}
onMounted(async () => {
// Resume a previous superadmin session if the stored token still checks out.
if (token.value && (await verify(token.value))) {
authed.value = true;
startPolling();
loadPbConfig();
loadPlugins();
loadOrgs();
loadUsers();
} else if (token.value) {
localStorage.removeItem(TOKEN_KEY);
token.value = "";
}
booting.value = false;
});
onUnmounted(() => stopPolling());
// Client / dashboard API — used by the Web App and the API Web Panel.
const clientApi = [
{ method: "POST", path: "/api/auth/login", desc: "Exchange email + password for a session (via PocketBase)" },
{ method: "GET", path: "/api/auth/validate", desc: "Validate the current session token" },
{ method: "GET", path: "/api/me", desc: "Caller's id, email, role, and organization from their token" },
{ method: "GET", path: "/api/preferences", desc: "Read the caller's saved settings blob" },
{ method: "PUT", path: "/api/preferences", desc: "Persist the caller's settings onto their user record" },
{ method: "GET", path: "/api/devices", desc: "List connected devices and last-known state" },
{ method: "GET", path: "/api/devices/{id}/track", desc: "GPS track history for a device" },
{ method: "POST", path: "/api/devices/{id}/command", desc: "Send a command down to a device" },
{ method: "DELETE", path: "/api/devices/{id}", desc: "Forget a device's stored state" },
{ method: "GET", path: "/ws/ui", desc: "Live telemetry stream (WebSocket)" },
];
// Management API — user + organization management. Requires a manager
// (admin or superadmin) token; admins are scoped to their own organization,
// superadmins span all of them.
const managementApi = [
{ method: "GET", path: "/api/users", desc: "List users (admin: own org · superadmin: all)" },
{ method: "POST", path: "/api/users", desc: "Create a user {email, password, role, organization?}" },
{ method: "PATCH", path: "/api/users/{id}", desc: "Edit a user (role/org changes are scope-checked)" },
{ method: "DELETE", path: "/api/users/{id}", desc: "Delete a user (not self; admins in-org only)" },
{ method: "GET", path: "/api/orgs", desc: "List organizations (admin: own · superadmin: all)" },
{ method: "POST", path: "/api/orgs", desc: "Create an organization {name} (superadmin)" },
{ method: "PATCH", path: "/api/orgs/{id}", desc: "Rename an organization (superadmin)" },
{ method: "DELETE", path: "/api/orgs/{id}", desc: "Delete an empty organization (superadmin)" },
{ method: "GET", path: "/api/admin/pb-config", desc: "Read the PocketBase connection + live probe (superadmin)" },
{ method: "POST", path: "/api/admin/pb-config/test", desc: "Test a candidate connection without applying (superadmin)" },
{ method: "PUT", path: "/api/admin/pb-config", desc: "Update + persist the PocketBase connection (superadmin)" },
{ method: "GET", path: "/api/admin/plugins", desc: "List plugins + state + last health (superadmin)" },
{ method: "POST", path: "/api/admin/plugins", desc: "Register an external plugin {name, baseURL} (superadmin)" },
{ method: "PUT", path: "/api/admin/plugins/{name}", desc: "Enable/disable + configure a plugin (superadmin)" },
{ method: "DELETE", path: "/api/admin/plugins/{name}", desc: "Remove an external plugin (superadmin)" },
{ method: "POST", path: "/api/admin/plugins/{name}/health", desc: "Run a plugin health check (superadmin)" },
];
// Device API — used by the Fly App running on the drone/controller.
const deviceApi = [
{ method: "GET", path: "/ws/device?id={id}", desc: "Device telemetry uplink (WebSocket)" },
{ method: "POST", path: "/api/telemetry?id={id}", desc: "Push a single telemetry event over HTTP" },
{ method: "GET", path: "/healthz", desc: "Readiness probe" },
];
</script>
<template>
<div class="mx-auto flex max-w-5xl flex-col gap-6 px-6 pt-12 pb-16">
<!-- Header -->
<div class="flex items-center gap-3">
<svg width="32" height="32" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<g stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
<polyline points="8,30 19,17 30,30" stroke="var(--brand)" />
<polyline points="18,33 29,20 40,33" stroke="currentColor" />
</g>
</svg>
<div class="leading-tight text-primary">
<div class="font-display text-lg font-bold tracking-tight">PilotVault</div>
<span class="pv-eyebrow">API server</span>
</div>
<div class="flex-1"></div>
<span v-if="authed && me" class="pv-eyebrow hidden truncate sm:inline">{{ me.email }}</span>
<button v-if="authed" class="pv-btn-sec pv-btn-sm" @click="logout">Sign out</button>
<button
class="pv-btn-sec pv-btn-sm"
:title="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
@click="toggleTheme"
>
<!-- sun (shown in dark mode) -->
<svg v-if="theme === 'dark'" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
<!-- moon (shown in light mode) -->
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
Theme
</button>
</div>
<!-- Booting: resolving a stored session -->
<div v-if="booting" class="rounded-lg border border-subtle bg-card px-5 py-10 text-center shadow-sm">
<span class="pv-eyebrow">Checking session</span>
</div>
<!-- Login gate superadmin only -->
<div v-else-if="!authed" class="mx-auto w-full max-w-sm rounded-lg border border-subtle bg-card shadow-sm">
<div class="border-b border-subtle px-5 py-4">
<div class="text-base font-semibold text-primary">Sign in</div>
<span class="pv-eyebrow">Superadmin access only</span>
</div>
<form class="flex flex-col gap-3 px-5 py-5" @submit.prevent="doLogin">
<label class="flex flex-col gap-1">
<span class="pv-eyebrow">Email</span>
<input
v-model="form.email"
type="email"
autocomplete="username"
class="pv-input"
placeholder="superadmin@pilotvault.local"
/>
</label>
<label class="flex flex-col gap-1">
<span class="pv-eyebrow">Password</span>
<input
v-model="form.password"
type="password"
autocomplete="current-password"
class="pv-input"
placeholder="••••••••"
/>
</label>
<p v-if="loginErr" class="rounded-sm bg-danger-tint px-3 py-2 text-xs font-medium text-danger">
{{ loginErr }}
</p>
<button type="submit" class="pv-btn mt-1" :disabled="busy">
{{ busy ? "Signing in…" : "Sign in" }}
</button>
</form>
</div>
<!-- Console visible only to an authenticated superadmin -->
<template v-else>
<!-- Tabs -->
<div class="flex gap-1 self-start rounded-lg border border-subtle bg-card p-1 shadow-sm">
<button
v-for="t in TABS"
:key="t.id"
class="rounded-md px-3.5 py-1.5 text-sm font-semibold transition"
:class="tab === t.id ? 'bg-brand text-on-brand' : 'text-secondary hover:text-primary'"
@click="tab = t.id"
>
{{ t.label }}
</button>
</div>
<!-- Overview tab -->
<div v-show="tab === 'overview'" class="flex flex-col gap-6">
<!-- Status -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div class="text-base font-semibold text-primary">Status</div>
<span class="pv-eyebrow">{{ checkedAt ? "checked " + checkedAt.toLocaleTimeString() : "—" }}</span>
</div>
<div>
<div
v-for="row in rows"
:key="row.key"
class="flex items-center justify-between gap-3 border-t border-subtle px-5 py-3.5 first:border-t-0"
>
<div class="min-w-0">
<div class="text-sm font-semibold text-primary">{{ row.label }}</div>
<div v-if="svc[row.key].detail" class="truncate font-mono text-xs text-secondary">{{ svc[row.key].detail }}</div>
</div>
<span
class="inline-flex shrink-0 items-center gap-1.5 rounded-sm px-2.5 py-1 font-mono text-xs font-medium"
:class="badge[svc[row.key].status].cls"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ badge[svc[row.key].status].label }}
</span>
</div>
<!-- Devices metric -->
<div class="flex items-center justify-between gap-3 border-t border-subtle px-5 py-3.5">
<div class="text-sm font-semibold text-primary">Devices</div>
<span class="font-mono text-xs text-secondary">
{{ devices === null ? "—" : devices + " online" }}
</span>
</div>
</div>
</div>
<EndpointTable title="Client API" auth="PocketBase session" :endpoints="clientApi" />
<EndpointTable title="Management API" auth="Admin · superadmin" :endpoints="managementApi" />
<EndpointTable title="Device API" auth="Device uplink" :endpoints="deviceApi" />
</div>
<!-- Users tab -->
<div v-show="tab === 'users'" class="flex flex-col gap-6">
<!-- Create user -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="border-b border-subtle px-5 py-4">
<div class="text-base font-semibold text-primary">Add user</div>
<span class="pv-eyebrow">Create an account</span>
</div>
<div class="flex flex-col gap-3 px-5 py-5">
<div class="flex flex-col gap-3 sm:flex-row">
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Email</span>
<input v-model="newUser.email" type="email" class="pv-input" placeholder="pilot@pilotvault.local" autocomplete="off" spellcheck="false" />
</label>
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Password</span>
<input v-model="newUser.password" type="password" class="pv-input" placeholder="min. 8 characters" autocomplete="new-password" />
</label>
</div>
<div class="flex flex-col gap-3 sm:flex-row">
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Role</span>
<select v-model="newUser.role" class="pv-input">
<option v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</label>
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Organization</span>
<select v-model="newUser.organization" class="pv-input">
<option value=""> none </option>
<option v-for="o in orgs" :key="o.id" :value="o.id">{{ o.name }}</option>
</select>
</label>
</div>
<p v-if="newUserErr" class="rounded-sm bg-danger-tint px-3 py-2 text-xs font-medium text-danger">{{ newUserErr }}</p>
<p v-else-if="newUserMsg" class="rounded-sm bg-success-tint px-3 py-2 text-xs font-medium text-success">{{ newUserMsg }}</p>
<div>
<button class="pv-btn" :disabled="newUserBusy" @click="createUser">{{ newUserBusy ? "Creating…" : "Create user" }}</button>
</div>
</div>
</div>
<!-- User list -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-semibold text-primary">Users</div>
<span class="pv-eyebrow">{{ users.length }} total</span>
</div>
<button class="pv-btn-sec pv-btn-sm" @click="loadUsers">Refresh</button>
</div>
<div class="flex flex-col">
<p v-if="umErr" class="px-5 py-3 text-xs font-medium text-danger">{{ umErr }}</p>
<p v-if="userMsg" class="border-b border-subtle bg-sunken px-5 py-2.5 font-mono text-xs text-secondary">{{ userMsg }}</p>
<p v-if="!users.length && !umErr" class="px-5 py-6 text-sm text-secondary">No users yet.</p>
<div v-for="u in users" :key="u.id" class="border-t border-subtle px-5 py-4 first:border-t-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="truncate text-sm font-semibold text-primary">{{ u.email }}</span>
<span class="rounded-sm px-1.5 py-0.5 font-mono text-[10px] font-medium uppercase tracking-wider" :class="roleBadge[u.role]">{{ roleLabel[u.role] || u.role }}</span>
<span v-if="u.id === (me && me.id)" class="rounded-sm bg-brand/10 px-1.5 py-0.5 font-mono text-[10px] font-medium text-brand-text">you</span>
<span v-if="!u.verified" class="rounded-sm bg-warning-tint px-1.5 py-0.5 font-mono text-[10px] font-medium text-warning">unverified</span>
</div>
<div class="mt-0.5 font-mono text-xs text-secondary">
{{ u.organizationName || (u.organization ? u.organization : "no organization") }}
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<button class="pv-btn-sec pv-btn-sm" @click="startUserEdit(u)">{{ editingUser === u.id ? "Close" : "Edit" }}</button>
<button
v-if="u.id !== (me && me.id)"
class="pv-btn-sec pv-btn-sm !text-danger"
:disabled="busyUser === u.id"
@click="deleteUser(u)"
>
Delete
</button>
</div>
</div>
<!-- inline editor -->
<div v-if="editingUser === u.id" class="mt-3 flex flex-col gap-3 rounded-md border border-subtle bg-sunken px-4 py-4">
<div class="flex flex-col gap-3 sm:flex-row">
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Email</span>
<input v-model="editUser.email" type="email" class="pv-input" autocomplete="off" spellcheck="false" />
</label>
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">New password</span>
<input v-model="editUser.password" type="password" class="pv-input" placeholder="leave blank to keep current" autocomplete="new-password" />
</label>
</div>
<div class="flex flex-col gap-3 sm:flex-row">
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Role</span>
<select v-model="editUser.role" class="pv-input" :disabled="u.id === (me && me.id)">
<option v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
<span v-if="u.id === (me && me.id)" class="text-[11px] text-muted">You cannot change your own role.</span>
</label>
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Organization</span>
<select v-model="editUser.organization" class="pv-input">
<option value=""> none </option>
<option v-for="o in orgs" :key="o.id" :value="o.id">{{ o.name }}</option>
</select>
</label>
</div>
<label class="flex items-center gap-2">
<input v-model="editUser.verified" type="checkbox" class="h-4 w-4" />
<span class="text-sm text-primary">Verified</span>
</label>
<div class="flex items-center gap-2">
<button class="pv-btn pv-btn-sm" :disabled="busyUser === u.id" @click="saveUser(u)">Save changes</button>
<button class="pv-btn-sec pv-btn-sm" @click="editingUser = ''">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Organizations tab -->
<div v-show="tab === 'organizations'" class="flex flex-col gap-6">
<!-- Create org -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="border-b border-subtle px-5 py-4">
<div class="text-base font-semibold text-primary">Add organization</div>
<span class="pv-eyebrow">Create a tenant</span>
</div>
<div class="flex flex-col gap-3 px-5 py-5">
<div class="flex flex-col gap-2 sm:flex-row">
<input v-model="newOrgName" class="pv-input flex-1" placeholder="Acme Aerial Ltd." autocomplete="off" spellcheck="false" @keyup.enter="createOrg" />
<button class="pv-btn" :disabled="newOrgBusy" @click="createOrg">{{ newOrgBusy ? "Creating…" : "Create" }}</button>
</div>
<p v-if="newOrgErr" class="rounded-sm bg-danger-tint px-3 py-2 text-xs font-medium text-danger">{{ newOrgErr }}</p>
<p v-else-if="newOrgMsg" class="rounded-sm bg-success-tint px-3 py-2 text-xs font-medium text-success">{{ newOrgMsg }}</p>
</div>
</div>
<!-- Org list -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-semibold text-primary">Organizations</div>
<span class="pv-eyebrow">{{ orgs.length }} total</span>
</div>
<button class="pv-btn-sec pv-btn-sm" @click="loadOrgs">Refresh</button>
</div>
<div class="flex flex-col">
<p v-if="orgMsg" class="border-b border-subtle bg-sunken px-5 py-2.5 font-mono text-xs text-secondary">{{ orgMsg }}</p>
<p v-if="!orgs.length" class="px-5 py-6 text-sm text-secondary">No organizations yet.</p>
<div v-for="o in orgs" :key="o.id" class="border-t border-subtle px-5 py-4 first:border-t-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold text-primary">{{ o.name }}</div>
<div class="mt-0.5 font-mono text-xs text-secondary">
{{ (orgMemberCount[o.id] || 0) }} member{{ (orgMemberCount[o.id] || 0) === 1 ? "" : "s" }}
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<button class="pv-btn-sec pv-btn-sm" @click="startOrgEdit(o)">{{ editingOrg === o.id ? "Close" : "Rename" }}</button>
<button
class="pv-btn-sec pv-btn-sm !text-danger"
:disabled="busyOrg === o.id || (orgMemberCount[o.id] || 0) > 0"
:title="(orgMemberCount[o.id] || 0) > 0 ? 'Reassign or remove members first' : 'Delete organization'"
@click="deleteOrg(o)"
>
Delete
</button>
</div>
</div>
<!-- inline rename -->
<div v-if="editingOrg === o.id" class="mt-3 flex flex-col gap-2 rounded-md border border-subtle bg-sunken px-4 py-4 sm:flex-row sm:items-end">
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Name</span>
<input v-model="editOrgName" class="pv-input" autocomplete="off" spellcheck="false" @keyup.enter="saveOrg(o)" />
</label>
<div class="flex items-center gap-2">
<button class="pv-btn pv-btn-sm" :disabled="busyOrg === o.id" @click="saveOrg(o)">Save</button>
<button class="pv-btn-sec pv-btn-sm" @click="editingOrg = ''">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- PocketBase tab -->
<div v-show="tab === 'pocketbase'" class="flex flex-col gap-6">
<!-- PocketBase connection settings -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-semibold text-primary">PocketBase connection</div>
<span class="pv-eyebrow">Settings</span>
</div>
<span
v-if="pbProbe"
class="inline-flex shrink-0 items-center gap-1.5 rounded-sm px-2.5 py-1 font-mono text-xs font-medium"
:class="pbProbe.reachable ? (pbProbe.superuser ? 'bg-success-tint text-success' : 'bg-warning-tint text-warning') : 'bg-danger-tint text-danger'"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ pbProbe.reachable ? (pbProbe.superuser ? "connected" : "reachable") : "unreachable" }}
</span>
</div>
<div class="flex flex-col gap-3 px-5 py-5">
<label class="flex flex-col gap-1">
<span class="pv-eyebrow">PocketBase URL</span>
<input v-model="pbForm.url" class="pv-input" placeholder="http://10.2.1.10:8026" spellcheck="false" />
</label>
<label class="flex flex-col gap-1">
<span class="pv-eyebrow">Service account email</span>
<input v-model="pbForm.adminEmail" class="pv-input" placeholder="admin@pilotvault.local" autocomplete="off" spellcheck="false" />
</label>
<label class="flex flex-col gap-1">
<span class="pv-eyebrow">Service account password</span>
<input
v-model="pbForm.adminPassword"
type="password"
class="pv-input"
autocomplete="new-password"
:placeholder="pb && pb.adminConfigured ? 'leave blank to keep current' : 'set a password'"
/>
</label>
<div v-if="pbProbe" class="font-mono text-[11px] text-muted">
health: {{ pbProbe.reachable ? "ok" : "down" }}<span v-if="pbProbe.latencyMs"> · {{ pbProbe.latencyMs }}ms</span>
· superuser auth: {{ pbProbe.superuser ? "ok" : "—" }}<span v-if="pbProbe.detail"> · {{ pbProbe.detail }}</span>
</div>
<p v-if="pbErr" class="rounded-sm bg-danger-tint px-3 py-2 text-xs font-medium text-danger">{{ pbErr }}</p>
<p v-else-if="pbMsg" class="rounded-sm bg-success-tint px-3 py-2 text-xs font-medium text-success">{{ pbMsg }}</p>
<div class="flex flex-wrap items-center gap-2">
<button class="pv-btn" :disabled="pbBusy" @click="savePbConfig">{{ pbBusy ? "Saving…" : "Save connection" }}</button>
<button class="pv-btn-sec" :disabled="pbTesting" @click="testPbConfig">{{ pbTesting ? "Testing…" : "Test connection" }}</button>
</div>
<p class="text-[11px] text-muted">
The service account is used only for user &amp; organization management. Changing the URL
repoints the whole API Server at a new PocketBase and may sign you out.
</p>
</div>
</div>
</div>
<!-- Plugins tab -->
<div v-show="tab === 'plugins'" class="flex flex-col gap-6">
<!-- Plugins -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-semibold text-primary">Plugins</div>
<span class="pv-eyebrow">External integrations</span>
</div>
<button class="pv-btn-sec pv-btn-sm" @click="loadPlugins">Refresh</button>
</div>
<div class="flex flex-col">
<p v-if="pluginsErr" class="px-5 py-3 text-xs font-medium text-danger">{{ pluginsErr }}</p>
<p v-if="pluginsMsg" class="border-b border-subtle bg-sunken px-5 py-2.5 font-mono text-xs text-secondary">{{ pluginsMsg }}</p>
<p v-if="!plugins.length && !pluginsErr" class="px-5 py-6 text-sm text-secondary">No plugins registered yet.</p>
<!-- category tabs -->
<div v-if="plugins.length" class="flex gap-1 overflow-x-auto overflow-y-hidden border-b border-subtle px-3 pt-1">
<button
v-for="t in PLUGIN_CATEGORIES"
:key="t.id"
class="-mb-px whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition"
:class="pluginTab === t.id ? 'border-brand text-primary' : 'border-transparent text-secondary hover:text-primary'"
@click="pluginTab = t.id"
>
{{ t.label }}
</button>
</div>
<!-- per-tab empty state -->
<p v-if="plugins.length && !activePlugins.length" class="px-5 py-6 text-sm text-secondary">No plugins in this category.</p>
<!-- plugin rows -->
<div v-for="p in activePlugins" :key="p.name" class="border-t border-subtle px-5 py-4 first:border-t-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-primary">{{ p.name }}</span>
<span class="rounded-sm px-1.5 py-0.5 font-mono text-[10px] font-medium uppercase tracking-wider" :class="kindBadge[p.kind]">{{ p.kind }}</span>
<span
v-if="p.health"
class="inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 font-mono text-[10px] font-medium"
:class="healthBadge[p.health.status]"
:title="p.health.detail || ''"
>
<span class="h-1 w-1 rounded-full bg-current"></span>{{ p.health.status }}<span v-if="p.health.latencyMs"> · {{ p.health.latencyMs }}ms</span>
</span>
</div>
<div class="mt-0.5 font-mono text-xs text-secondary">{{ p.provider }} · v{{ p.version }} · {{ p.authType }}</div>
<div v-if="p.health && p.health.detail" class="mt-0.5 text-[11px] text-secondary">{{ p.health.detail }}</div>
<div v-if="p.capabilities && p.capabilities.length" class="mt-2 flex flex-col gap-1.5">
<div class="pv-eyebrow">Capabilities</div>
<div v-for="c in p.capabilities" :key="c.id" class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span class="rounded-sm bg-sunken px-1.5 py-0.5 font-mono text-[10px] text-secondary">{{ c.id }}</span>
<span v-if="c.method || c.endpoint" class="font-mono text-[10px] text-muted">{{ c.method }} {{ c.endpoint }}</span>
<span v-if="c.description" class="w-full text-[11px] text-secondary sm:w-auto">{{ c.description }}</span>
</div>
</div>
<div v-if="p.baseURL" class="mt-1 truncate font-mono text-[11px] text-muted">{{ p.baseURL }}</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
class="pv-btn-sec pv-btn-sm"
:disabled="busyPlugin === p.name"
:class="p.enabled ? '!border-transparent !bg-success-tint !text-success' : ''"
@click="togglePlugin(p)"
>
<span class="h-1.5 w-1.5 rounded-full" :class="p.enabled ? 'bg-success' : 'bg-muted'"></span>
{{ p.enabled ? "Enabled" : "Disabled" }}
</button>
<button v-if="p.configFields && p.configFields.length" class="pv-btn-sec pv-btn-sm" @click="startPluginEdit(p)">Configure</button>
<button class="pv-btn-sec pv-btn-sm" :disabled="busyPlugin === p.name" @click="checkPlugin(p)">Check</button>
<button v-if="p.kind === 'external'" class="pv-btn-sec pv-btn-sm !text-danger" :disabled="busyPlugin === p.name" @click="removePlugin(p)">Remove</button>
</div>
</div>
<!-- config form -->
<div v-if="editingPlugin === p.name" class="mt-3 flex flex-col gap-2 rounded-md border border-subtle bg-sunken px-4 py-4">
<label v-for="f in p.configFields" :key="f.key" class="flex flex-col gap-1">
<span class="pv-eyebrow">{{ f.label }}<span v-if="f.required" class="text-danger"> *</span></span>
<select v-if="f.type === 'select'" v-model="editConfig[f.key]" class="pv-input">
<option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<input
v-else
v-model="editConfig[f.key]"
:type="f.secret || f.type === 'password' ? 'password' : f.type === 'number' ? 'number' : 'text'"
class="pv-input"
autocomplete="off"
:placeholder="f.help || ''"
/>
<span v-if="f.help" class="text-[11px] text-muted">{{ f.help }}</span>
</label>
<div class="flex items-center gap-2">
<button class="pv-btn pv-btn-sm" :disabled="busyPlugin === p.name" @click="savePluginConfig(p)">Save configuration</button>
<button class="pv-btn-sec pv-btn-sm" @click="editingPlugin = ''">Cancel</button>
</div>
</div>
</div>
<!-- register external plugin -->
<div class="border-t border-subtle px-5 py-4">
<div class="pv-eyebrow mb-2">Register external plugin</div>
<div class="flex flex-col gap-2 sm:flex-row">
<input v-model="newExt.name" class="pv-input sm:w-40" placeholder="name" autocomplete="off" spellcheck="false" />
<input v-model="newExt.baseURL" class="pv-input flex-1" placeholder="https://plugin.example.com" autocomplete="off" spellcheck="false" />
<button class="pv-btn" :disabled="newExtBusy" @click="registerExternal">{{ newExtBusy ? "Adding…" : "Add" }}</button>
</div>
<p v-if="newExtErr" class="mt-2 text-xs font-medium text-danger">{{ newExtErr }}</p>
<p class="mt-2 text-[11px] text-muted">
A remote service that answers <span class="font-mono">GET /health</span> and
<span class="font-mono">GET /manifest</span> added at runtime, no rebuild.
</p>
</div>
</div>
</div>
</div>
</template>
<p class="text-center font-mono text-[11px] text-muted">
PilotVault live drone telemetry, command &amp; control.
</p>
</div>
</template>