Files
DriverVault/API Server/panel/src/components/PluginsCard.vue
T
tajniak81andClaude Opus 5 a7cab50e06 Apprise: a gateway to hand a message to, not a hundred protocols to carry
Apprise is a Python library that speaks 100+ notification services behind one URL
grammar — mailto://, tgram://, ntfy://, discord://. None of that is portable to a
server that takes no dependencies, and none of it needs to be: caronc/apprise-api
wraps the library in HTTP and is meant to run as a container beside us. So the
connector carries no notification protocols of its own. It posts a body to an
endpoint the operator runs and lets Apprise fan it out, which is also why adding
a service later costs nothing here.

Targets are addressed one of two ways and configKey is the switch. Stateful means
the URLs live on the Apprise server under a key, narrowed by a tag expression, and
recipients are then edited there — no credential for any downstream service is
ever held in DriverVault. Stateless means the URLs travel with the request, from a
secret config field, which is simpler for one destination and worse for ten. A
call that names its own key or urls takes that destination alone rather than
merging with the configured one: honouring a caller's URLs while still falling
back to the configured key would deliver the message somewhere nobody asked for.

baseUrl is Required, which no other connector's address is. Toyota, Anker and
Greencell leave everything blank at the global layer because the superadmin → org
→ user cascade exists to fill it in, and a blank there means "let the user
choose". There is no cascade behind this one — a notification gateway is
infrastructure the operator runs, not an account a driver owns — so nothing
further down can supply the address, and a blank is simply a plugin that cannot
work. Better to fail at enable than at the first notification nobody sees.

Three limits are choices rather than gaps. /add and /del are not implemented: the
Apprise config belongs to the operator, we post to it, and a connector that can
delete a notification config has a wider blast radius than one that can only send
through it. privacy=1 is forced on /json/urls rather than offered as a parameter,
so a target listing reads mailto://user:****@host and downstream tokens stay on
the Apprise side of the wire. Attachments are remote URLs the Apprise server
fetches; multipart upload is the API's own path for files and not ours.

Health follows the rule Greencell set. A reachable server whose config holds
nothing to notify is degraded, not down: the half we address works and the missing
half is the operator's config. Two cases earn their own line — a config key set
against a server running with stateful mode disabled can never resolve, and /status
answers 417 rather than 500 when Apprise finds a problem with itself, so that is a
parsed answer and not a transport failure. A proxy that strips our Accept header
gets the same codes back as plain text, which is read rather than called
unreadable; an HTML error page from something that is not Apprise is not, and a
test pins the difference.

Notifications needed a category of their own, and that is the one change outside
the plugin: the constant, the tab order in PluginsCard.vue, and the label in all
three panel languages. The cost is now written down in the plugins README beside
the Descriptor example, since the previous five categories predate anyone having
to add a sixth.

The plugin's tests run against an apprise-api stand-in built from that project's
views.py — both notify paths, the override rules, 204-as-empty against
424-as-failure, and every health branch. builtin_test.go is the other half: the
blank-import list in builtin.go is a silent failure mode, since a connector left
out of it compiles, passes its own tests, and never appears in the panel. What is
not covered is a live instance; there is no Docker on this machine, so the wire
contract comes from reading upstream's source rather than from running it, and a
smoke test against a real deployment is still worth doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 22:43:07 +02:00

285 lines
11 KiB
Vue

<script setup>
import { ref, computed, onMounted, reactive } from "vue";
import { request } from "../api";
import { t } from "../i18n";
// Superadmin-only. Each plugin advertises its own config fields (Descriptor.
// ConfigFields), so the form below is generated rather than hard-coded — that's
// what lets an external plugin be added without touching this panel.
const plugins = ref([]);
const error = ref("");
const busy = ref(false);
const open = ref(null); // name of the expanded plugin
const drafts = reactive({}); // name -> { key: value }
const rowNotice = reactive({}); // name -> string
const showRegister = ref(false);
const reg = ref({ name: "", baseURL: "", provider: "" });
const tab = ref(""); // selected category tab; empty falls back to the first one
// Tab order, mirroring the Category* constants in internal/plugins/plugin.go. A
// plugin whose category is empty or unknown to this panel lands under the
// external-APIs tab rather than disappearing.
const CATEGORIES = ["vehicles", "chargers", "notifications", "apis-external", "drives-external", "drives-local"];
const categoryOf = (p) => (CATEGORIES.includes(p.category) ? p.category : "apis-external");
// Only categories that actually have a plugin get a tab — a fresh install with
// two builtins shows two tabs, not five empty ones.
const groups = computed(() => {
const by = {};
for (const p of plugins.value) (by[categoryOf(p)] ||= []).push(p);
return CATEGORIES.filter((c) => by[c]).map((c) => ({
id: c,
label: t(`plugins.categories.${c}`),
plugins: by[c],
}));
});
// Falling back to the first group keeps the card populated when the selected
// tab's last plugin is removed (or a reload drops it).
const activeGroup = computed(() => groups.value.find((g) => g.id === tab.value) || groups.value[0]);
const shown = computed(() => activeGroup.value?.plugins || []);
async function load() {
try {
const out = await request("/api/admin/plugins");
plugins.value = out.plugins || [];
error.value = "";
} catch (e) {
error.value = e.message;
}
}
onMounted(load);
function expand(p) {
if (open.value === p.name) {
open.value = null;
return;
}
// Seed the draft from the (secret-masked) stored config plus field defaults.
const d = {};
for (const f of p.configFields || []) d[f.key] = p.config?.[f.key] ?? "";
drafts[p.name] = d;
open.value = p.name;
}
async function save(p, enabled) {
busy.value = true;
rowNotice[p.name] = "";
try {
const out = await request(`/api/admin/plugins/${encodeURIComponent(p.name)}`, {
method: "PUT",
body: { enabled, config: drafts[p.name] ?? {} },
});
// A save can succeed while Init fails (e.g. bad credentials) — the server
// returns the saved plugin plus a warning.
rowNotice[p.name] = out.warning || t("plugins.saved");
await load();
} catch (e) {
rowNotice[p.name] = e.message;
} finally {
busy.value = false;
}
}
async function health(p) {
busy.value = true;
rowNotice[p.name] = t("plugins.checking");
try {
const out = await request(`/api/admin/plugins/${encodeURIComponent(p.name)}/health`, {
method: "POST",
});
rowNotice[p.name] = `${out.health.status}${out.health.detail ? " — " + out.health.detail : ""}`;
await load();
} catch (e) {
rowNotice[p.name] = e.message;
} finally {
busy.value = false;
}
}
async function remove(p) {
if (!confirm(t("plugins.confirmRemove", { name: p.name }))) return;
busy.value = true;
try {
await request(`/api/admin/plugins/${encodeURIComponent(p.name)}`, { method: "DELETE" });
if (open.value === p.name) open.value = null;
await load();
} catch (e) {
rowNotice[p.name] = e.message;
} finally {
busy.value = false;
}
}
async function registerExternal() {
busy.value = true;
error.value = "";
try {
await request("/api/admin/plugins", { method: "POST", body: reg.value });
reg.value = { name: "", baseURL: "", provider: "" };
showRegister.value = false;
await load();
} catch (e) {
error.value = e.message;
} finally {
busy.value = false;
}
}
const healthClass = (s) =>
s === "ok"
? "bg-success-soft text-success"
: s === "degraded"
? "bg-warning-soft text-warning"
: "bg-danger-soft text-danger";
</script>
<template>
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("plugins.title") }}</div>
<p class="mt-0.5 text-xs text-muted">{{ t("plugins.subtitle") }}</p>
</div>
<button class="dh-btn-ghost" @click="showRegister = !showRegister">
{{ showRegister ? t("plugins.cancelRegister") : t("plugins.registerExternal") }}
</button>
</div>
<!-- Register an external (remote HTTP) plugin the no-rebuild path. -->
<div v-if="showRegister" class="border-b border-subtle bg-sunken px-5 py-4">
<div class="grid gap-3 sm:grid-cols-3">
<div>
<label class="dh-label">{{ t("plugins.name") }}</label>
<input v-model="reg.name" class="dh-input" placeholder="acme-parts" />
</div>
<div>
<label class="dh-label">{{ t("plugins.baseUrl") }}</label>
<input v-model="reg.baseURL" class="dh-input" placeholder="http://127.0.0.1:9100" />
</div>
<div>
<label class="dh-label">{{ t("plugins.provider") }}</label>
<input v-model="reg.provider" class="dh-input" placeholder="ACME Corp" />
</div>
</div>
<button
class="dh-btn mt-3"
:disabled="busy || !reg.name || !reg.baseURL"
@click="registerExternal"
>
{{ t("plugins.register") }}
</button>
</div>
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
<p v-if="!plugins.length" class="px-5 py-6 text-center text-sm text-muted">
{{ t("plugins.empty") }}
</p>
<!-- Category tabs: car manufacturers, EV chargers, everything else. -->
<nav v-if="groups.length > 1" class="flex flex-wrap gap-1.5 border-b border-subtle px-5 py-3">
<button
v-for="g in groups"
:key="g.id"
class="dh-btn-ghost"
:class="activeGroup?.id === g.id ? 'border-accent text-brandtext' : ''"
@click="tab = g.id"
>
{{ g.label }}
<span class="dh-pill bg-sunken text-muted">{{ g.plugins.length }}</span>
</button>
</nav>
<div v-for="p in shown" :key="p.name" class="border-t border-subtle first:border-t-0">
<!-- Summary row -->
<div class="flex items-center gap-3 px-5 py-3">
<button class="flex flex-1 items-center gap-3 text-left" @click="expand(p)">
<span class="font-semibold text-strong">{{ p.name }}</span>
<span class="dh-pill bg-sunken text-muted">{{ p.kind || t("plugins.builtin") }}</span>
<span v-if="p.provider" class="text-xs text-muted">{{ p.provider }}</span>
<span v-if="p.health" class="dh-pill" :class="healthClass(p.health.status)">
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ p.health.status }}
</span>
</button>
<span class="dh-pill" :class="p.enabled ? 'bg-success-soft text-success' : 'bg-sunken text-muted'">
{{ p.enabled ? t("plugins.enabled") : t("plugins.disabled") }}
</span>
<button class="dh-btn-ghost" :disabled="busy" @click="health(p)">{{ t("plugins.health") }}</button>
<button
class="dh-btn-ghost"
:disabled="busy"
@click="expand(p)"
>
{{ open === p.name ? t("plugins.close") : t("plugins.configure") }}
</button>
</div>
<!-- Expanded config: generated from the plugin's declared fields. -->
<div v-if="open === p.name" class="bg-sunken px-5 py-4">
<div v-if="p.baseURL" class="data mb-3 text-xs text-muted">{{ p.baseURL }}</div>
<div v-if="(p.configFields || []).length" class="grid gap-3 sm:grid-cols-2">
<div v-for="f in p.configFields" :key="f.key">
<label class="dh-label">
{{ f.label || f.key }}<span v-if="f.required" class="text-danger"> *</span>
</label>
<select v-if="f.type === 'select'" v-model="drafts[p.name][f.key]" class="dh-select">
<!-- A non-required select can be left unset (empty), so the global
layer abstains and lower layers (org / user) may choose. -->
<option v-if="!f.required" value="">{{ t("plugins.notSet") }}</option>
<option v-for="o in f.options || []" :key="o.value" :value="o.value">
{{ o.label || o.value }}
</option>
</select>
<input
v-else
v-model="drafts[p.name][f.key]"
class="dh-input"
:type="f.type === 'password' ? 'password' : f.type === 'number' ? 'number' : 'text'"
:placeholder="f.default || ''"
autocomplete="off"
/>
<p v-if="f.help" class="mt-1 text-xs text-muted">{{ f.help }}</p>
</div>
</div>
<p v-else class="text-xs text-muted">{{ t("plugins.noConfig") }}</p>
<div v-if="(p.capabilities || []).length" class="mt-4">
<div class="eyebrow mb-1.5">{{ t("plugins.capabilities") }}</div>
<ul class="data flex flex-col gap-1 text-xs text-muted">
<li v-for="c in p.capabilities" :key="c.id">
<span class="text-strong">{{ c.id }}</span>
<span v-if="c.method || c.endpoint"> — {{ c.method }} {{ c.endpoint }}</span>
<span v-if="c.description"> · {{ c.description }}</span>
</li>
</ul>
</div>
<p v-if="rowNotice[p.name]" class="data mt-3 text-xs text-body">{{ rowNotice[p.name] }}</p>
<div class="mt-4 flex items-center gap-2">
<button class="dh-btn" :disabled="busy" @click="save(p, true)">
{{ p.enabled ? t("plugins.save") : t("plugins.saveEnable") }}
</button>
<button v-if="p.enabled" class="dh-btn-ghost" :disabled="busy" @click="save(p, false)">
{{ t("plugins.disable") }}
</button>
<span class="flex-1"></span>
<button
v-if="p.kind === 'external'"
class="dh-btn-danger"
:disabled="busy"
@click="remove(p)"
>
{{ t("plugins.remove") }}
</button>
</div>
<p v-if="p.kind !== 'external'" class="eyebrow mt-2">
{{ t("plugins.builtinNote") }}
</p>
</div>
</div>
</div>
</template>