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:
tajniak81
2026-07-13 11:43:33 +02:00
co-authored by Claude Opus 4.8
commit afc6952eda
172 changed files with 24591 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0F1E3D" />
<title>PilotVault · API Server</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1964
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "pilotvault-api-panel",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.0.0",
"vite": "^6.0.7"
}
}
+7
View File
@@ -0,0 +1,7 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="11" fill="#0F1E3D" />
<g stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none">
<polyline points="10,30 21,17 32,30" stroke="#3D7BF0" />
<polyline points="16,33 27,20 38,33" stroke="#F4F7FC" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 371 B

+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>
@@ -0,0 +1,40 @@
<script setup>
defineProps({
title: String,
auth: String,
endpoints: Array, // [{ method, path, desc }]
});
const methodClass = {
GET: "text-success",
POST: "text-brand-text",
PATCH: "text-warning",
DELETE: "text-danger",
};
</script>
<template>
<div class="overflow-hidden rounded-lg border border-subtle bg-card shadow-xs">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div class="text-base font-semibold text-primary">{{ title }}</div>
<span class="pv-eyebrow">{{ auth }}</span>
</div>
<table class="w-full text-left text-sm">
<thead>
<tr class="pv-eyebrow">
<th class="px-5 py-2.5 font-medium">Endpoint</th>
<th class="px-5 py-2.5 font-medium">Description</th>
</tr>
</thead>
<tbody>
<tr v-for="e in endpoints" :key="e.method + e.path" class="transition-colors hover:bg-sunken">
<td class="border-t border-subtle px-5 py-2.5 font-mono text-xs whitespace-nowrap">
<span class="font-semibold" :class="methodClass[e.method]">{{ e.method }}</span>
<span class="text-primary"> {{ e.path }}</span>
</td>
<td class="border-t border-subtle px-5 py-2.5 text-secondary">{{ e.desc }}</td>
</tr>
</tbody>
</table>
</div>
</template>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
import "./theme";
createApp(App).mount("#app");
+261
View File
@@ -0,0 +1,261 @@
/* PilotVault API panel — design tokens (Vault Navy + Signal Blue) mapped into
Tailwind v4. Light is default; data-theme="dark" flips the semantic layer. */
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap');
@import "tailwindcss";
/* ============================================================
RAW RAMPS + SEMANTIC ALIASES (light)
============================================================ */
:root,
[data-theme="light"] {
/* Brand ramps (do not theme-flip) */
--navy-950: #0B1730;
--navy-900: #0F1E3D; /* Vault Navy — core brand */
--navy-800: #1B2E52;
--navy-700: #26406E;
--blue-50: #EAF1FE;
--blue-100: #D6E3FD;
--blue-300: #8FB4F6;
--blue-400: #5B93F5;
--blue-500: #3D7BF0; /* Signal Blue — accent */
--blue-600: #2B62CC;
--blue-700: #1F4CA0;
--slate-0: #FFFFFF;
--slate-50: #F6F7F9;
--slate-100: #EEF0F3;
--slate-150: #E6E9EE;
--slate-200: #DCE0E7;
--slate-300: #C5CCD7;
--slate-400: #97A1B0;
--slate-500: #6B7688;
--slate-700: #333B4A;
--steel: #5A6B85;
--green-500: #1F8A5B; --green-100: #DCF1E7; --green-600: #177049;
--amber-500: #D9852B; --amber-100: #FBEBD5; --amber-600: #B86C1B;
--red-500: #D64545; --red-100: #FBE0E0; --red-600: #B83232;
/* Semantic aliases — LIGHT */
--bg-page: var(--slate-100);
--bg-sunken: var(--slate-50);
--surface-card: var(--slate-0);
--border-subtle: var(--slate-200);
--border-strong: var(--slate-300);
--border-focus: var(--blue-500);
--text-primary: var(--navy-900);
--text-secondary: var(--steel);
--text-muted: var(--slate-400);
--brand: var(--blue-500);
--brand-hover: var(--blue-600);
--brand-active: var(--blue-700);
--brand-contrast: #FFFFFF;
--text-brand: var(--blue-600);
--success: var(--green-600);
--success-tint: var(--green-100);
--warning: var(--amber-600);
--warning-tint: var(--amber-100);
--danger: var(--red-600);
--danger-tint: var(--red-100);
--ring-focus: 0 0 0 3px color-mix(in srgb, var(--blue-500) 45%, transparent);
--sh-xs: 0 1px 2px rgba(15, 30, 61, 0.06);
--sh-sm: 0 1px 2px rgba(15, 30, 61, 0.06), 0 1px 3px rgba(15, 30, 61, 0.04);
--dur-fast: 120ms;
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
color-scheme: light;
}
/* ============================================================
DARK THEME — only the semantic layer remaps.
============================================================ */
[data-theme="dark"] {
--bg-page: var(--navy-950);
--bg-sunken: #0B111C;
--surface-card: #10203F;
--border-subtle: color-mix(in srgb, #ffffff 8%, transparent);
--border-strong: color-mix(in srgb, #ffffff 18%, transparent);
--border-focus: var(--blue-400);
--text-primary: #F4F7FC;
--text-secondary: #8FA0BE;
--text-muted: #5E6E8C;
--brand: var(--blue-400);
--brand-hover: var(--blue-300);
--brand-active: var(--blue-100);
--brand-contrast: #0F1E3D;
--text-brand: var(--blue-300);
--success: #5FD3A0;
--success-tint: color-mix(in srgb, var(--green-500) 22%, transparent);
--warning: #F0B26A;
--warning-tint: color-mix(in srgb, var(--amber-500) 22%, transparent);
--danger: #F08A8A;
--danger-tint: color-mix(in srgb, var(--red-500) 22%, transparent);
--ring-focus: 0 0 0 3px color-mix(in srgb, var(--blue-400) 55%, transparent);
--sh-xs: 0 1px 2px rgba(0, 0, 0, 0.35);
--sh-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
color-scheme: dark;
}
/* ============================================================
TAILWIND THEME — utilities resolve to the semantic vars, so
everything flips automatically under data-theme="dark".
============================================================ */
@theme inline {
--color-*: initial;
--color-page: var(--bg-page);
--color-sunken: var(--bg-sunken);
--color-card: var(--surface-card);
--color-subtle: var(--border-subtle);
--color-strong: var(--border-strong);
--color-primary: var(--text-primary);
--color-secondary: var(--text-secondary);
--color-muted: var(--text-muted);
--color-on-brand: var(--brand-contrast);
--color-brand: var(--brand);
--color-brand-text: var(--text-brand);
--color-success: var(--success);
--color-success-tint: var(--success-tint);
--color-warning: var(--warning);
--color-warning-tint: var(--warning-tint);
--color-danger: var(--danger);
--color-danger-tint: var(--danger-tint);
--font-display: 'Space Grotesk', ui-sans-serif, system-ui, 'Segoe UI', sans-serif;
--font-sans: 'Space Grotesk', ui-sans-serif, system-ui, 'Segoe UI', sans-serif;
--font-mono: 'Space Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
--radius-*: initial;
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-full: 9999px;
--shadow-*: initial;
--shadow-xs: var(--sh-xs);
--shadow-sm: var(--sh-sm);
}
/* ============================================================
BASE
============================================================ */
html,
body,
#app {
height: 100%;
}
body {
font-family: var(--font-sans);
background: var(--bg-page);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
font-family: var(--font-display);
letter-spacing: -0.02em;
}
/* Secondary button */
@utility pv-btn-sec {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
padding: 0 16px;
border-radius: var(--radius-md);
border: 1px solid var(--border-strong);
background: var(--surface-card);
color: var(--text-primary);
font-family: var(--font-sans);
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: background-color var(--dur-fast) var(--ease-standard),
transform var(--dur-fast) var(--ease-standard);
}
.pv-btn-sec:hover:not(:disabled) { background: var(--bg-sunken); }
.pv-btn-sec:active:not(:disabled) { transform: translateY(1px); }
.pv-btn-sec:focus-visible { outline: none; box-shadow: var(--ring-focus); }
/* Small button size modifier */
@utility pv-btn-sm {
height: 32px;
padding: 0 12px;
font-size: 0.75rem;
border-radius: var(--radius-sm);
}
/* Primary button */
@utility pv-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
padding: 0 16px;
border-radius: var(--radius-md);
border: 1px solid transparent;
background: var(--brand);
color: var(--brand-contrast);
font-family: var(--font-sans);
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: background-color var(--dur-fast) var(--ease-standard),
transform var(--dur-fast) var(--ease-standard);
}
.pv-btn:hover:not(:disabled) { background: var(--brand-hover); }
.pv-btn:active:not(:disabled) { transform: translateY(1px); }
.pv-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.pv-btn:focus-visible { outline: none; box-shadow: var(--ring-focus); }
/* Text input */
@utility pv-input {
width: 100%;
height: 40px;
padding: 0 12px;
border-radius: var(--radius-md);
border: 1px solid var(--border-strong);
background: var(--surface-card);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 0.875rem;
}
.pv-input::placeholder { color: var(--text-muted); }
.pv-input:focus { outline: none; border-color: var(--border-focus); box-shadow: var(--ring-focus); }
/* Mono eyebrow — uppercase, tracked out */
@utility pv-eyebrow {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-muted);
}
+30
View File
@@ -0,0 +1,30 @@
import { ref } from "vue";
// Persisted light/dark theme, shared key with the PilotVault design-system kits.
const KEY = "pilotvault-theme";
function initial() {
try {
return localStorage.getItem(KEY) === "dark" ? "dark" : "light";
} catch {
return "light";
}
}
export const theme = ref(initial());
export function applyTheme(t) {
theme.value = t;
document.documentElement.setAttribute("data-theme", t);
try {
localStorage.setItem(KEY, t);
} catch {
/* private mode — theme just won't persist */
}
}
export function toggleTheme() {
applyTheme(theme.value === "dark" ? "light" : "dark");
}
applyTheme(theme.value);
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
// Builds into internal/api/dist, which the Go server embeds via go:embed
// and serves at the server root.
export default defineConfig({
plugins: [vue(), tailwindcss()],
build: {
outDir: "../internal/api/dist",
emptyOutDir: true,
},
server: {
port: 5174,
proxy: {
// Dev-mode proxy to a locally running API Server.
"/api": "http://localhost:8080",
},
},
});