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>
285 lines
11 KiB
Vue
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>
|