Initial commit: PilotVault multi-service project

Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App
(Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment
configs. Design assets and build artifacts are gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 4.8 committed 2026-07-13 11:43:33 +02:00
commit afc6952eda
172 files changed
+24591

No files matched your search

+807
View File
@@ -0,0 +1,807 @@
<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); // { 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: "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 = { 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();
}
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;
}
}
// 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 (typeof h.latencyMs === "number") bits.push(h.latencyMs + "ms");
if (h.httpStatus) bits.push("HTTP " + h.httpStatus);
if (h.url) bits.push(h.url);
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();
} 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>
<!-- 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>