|
|
|
@@ -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 & 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 & control.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|