The plugin list, grouped by what a plugin actually is

Category has been in the plugin contract since it was written — apis-external,
drives-external, drives-local — and every builtin declared the same one, so it
grouped nothing. Two of the three talk to a wallbox and one talks to a car
manufacturer, and those are different questions an operator arrives with: the
Toyota card is where a driver's account gets linked, the Anker and Greencell
cards are where a charger's broker and credentials live. So vehicles and
chargers join the constants and the three builtins say which they are.

The panel groups on that field rather than on a list of names, which is what
keeps an external plugin from needing panel code. Tab order mirrors the
constants; a category with nothing in it gets no tab, and a single group hides
the bar entirely, so an install with one connector looks exactly as it did.
A category the panel does not recognise — or an empty one — falls to the
external-APIs tab rather than vanishing, because a plugin nobody can see is a
plugin nobody can disable. The selected tab falls back to the first group when
its own goes away, which is what removing the last external plugin does.

Registration still asks only for name, base URL and provider, so a plugin
registered at runtime lands under Other APIs until its manifest names a
category. That path already works and is the honest default: the panel is
guessing about a service it has never spoken to, and the service can say.

The header lockup is the other half. It was a copy of the Web App's mark rather
than the same mark, and copies drift — a 32px icon against 28, a 24px wordmark
against 21.6, "Driver" at text-strong instead of white, "Vault" a step lighter
than brand-400. The Web App's Logo.vue moves in verbatim, props included. The
one thing it cannot inherit is which variant to render: the Web App's rail is
always dark, while this panel flips with its own theme toggle, so on-dark is
bound to the theme and the hand-rolled bar fills that existed to survive that
flip are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-08-29 23:30:56 +02:00
co-authored by Claude Opus 5
parent 340a81b0d6
commit e648634ce1
15 changed files with 120 additions and 45 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" />
<title>DriverVault · API Server</title>
<script type="module" crossorigin src="/assets/index-DYiq470x.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CGuUVjJH.css">
<script type="module" crossorigin src="/assets/index-BjBOfjar.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nQWOOvsA.css">
</head>
<body>
<div id="app"></div>
@@ -171,7 +171,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
Provider: "Anker Solix (V1 Smart EV Charger)",
Version: "0.2.0",
Kind: plugins.KindBuiltin,
Category: plugins.CategoryAPIsExternal,
Category: plugins.CategoryChargers,
AuthType: plugins.AuthBasic,
Capabilities: []plugins.Capability{
{ID: "chargers", Method: "POST", Endpoint: epStandaloneChargers, Description: "List EV chargers bound to the account."},
@@ -169,7 +169,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
Provider: "Greencell (HabuDen EV charger)",
Version: "1.0.0",
Kind: plugins.KindBuiltin,
Category: plugins.CategoryAPIsExternal,
Category: plugins.CategoryChargers,
AuthType: plugins.AuthBasic,
Capabilities: []plugins.Capability{
{ID: "chargers", Method: "SUB", Endpoint: discoveryTopic,
@@ -124,7 +124,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
Provider: "Toyota Connected Europe",
Version: "0.2.0",
Kind: plugins.KindBuiltin,
Category: plugins.CategoryAPIsExternal,
Category: plugins.CategoryVehicles,
AuthType: plugins.AuthOAuth2,
Capabilities: []plugins.Capability{
{ID: "vehicles", Method: "GET", Endpoint: epVehicleGUID, Description: "List vehicles registered to the account."},
+3 -1
View File
@@ -90,8 +90,10 @@ func (c *Capability) UnmarshalJSON(b []byte) error {
}
// Category groups a plugin under a tab in the admin panel. A plugin with an
// empty category is treated as CategoryAPIsExternal by the panel.
// empty or unrecognised category is treated as CategoryAPIsExternal by the panel.
const (
CategoryVehicles = "vehicles" // car manufacturers' connected-car services
CategoryChargers = "chargers" // EV chargers and charging hardware
CategoryAPIsExternal = "apis-external" // remote HTTP APIs (external plugins)
CategoryDrivesExternal = "drives-external" // remote file stores (FTP/SFTP)
CategoryDrivesLocal = "drives-local" // drives on the host machine
+3 -19
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { theme, toggleTheme } from "./theme";
import { t, lang, setLang, TRANSLATED_LANGUAGES } from "./i18n";
import { me, token, restore, logout, isManager, isSuperadmin } from "./api";
import Logo from "./components/Logo.vue";
import LoginView from "./components/LoginView.vue";
import StatusCard from "./components/StatusCard.vue";
import PocketBaseCard from "./components/PocketBaseCard.vue";
@@ -67,13 +68,6 @@ function signOut() {
section.value = "overview";
}
// Logo bar fills — flip for legibility on the dark shell.
const barFills = computed(() =>
theme.value === "dark"
? ["#60a5fa", "#93c5fd", "#ffffff"]
: ["var(--brand-700)", "var(--brand-500)", "var(--brand-400)"],
);
// The DriverVault REST surface, grouped by resource. Mirrors the routes
// registered in internal/api/server.go.
const publicApi = [
@@ -270,18 +264,8 @@ const superadminApi = [
<div class="mx-auto flex max-w-4xl flex-col gap-6 px-6 pt-12 pb-16">
<!-- Header -->
<div class="flex items-center gap-3">
<span class="inline-flex items-center gap-2.5 select-none">
<svg class="h-8 w-8 shrink-0" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<g transform="translate(7 0) skewX(-13)">
<rect x="9" y="16" width="6" height="16" rx="3" :fill="barFills[0]" />
<rect x="19" y="12" width="6" height="24" rx="3" :fill="barFills[1]" />
<rect x="29" y="8" width="6" height="32" rx="3" :fill="barFills[2]" />
</g>
</svg>
<span class="text-2xl leading-none font-extrabold tracking-[-0.03em] italic">
<span class="text-strong">Driver</span><span class="text-brandtext">Vault</span>
</span>
</span>
<!-- The shared DriverVault lockup, on-dark on the dark shell. -->
<Logo :on-dark="theme === 'dark'" class="h-7" />
<span class="eyebrow mt-1.5">{{ t("app.apiServer") }}</span>
<div class="flex-1"></div>
+30
View File
@@ -0,0 +1,30 @@
<script setup>
// DriverVault lockup: the tri-bar "Fast Forward" mark + Archivo italic-800 wordmark.
// Kept identical to the Web App's Logo.vue — the same mark has to render the same
// way in both consoles. Colours come from the design tokens so it reads on both
// light and dark shells; `onDark` forces the light-on-dark variant (white bars/
// "Driver", brand-400 "Vault"), which this panel selects from the theme toggle.
defineProps({
iconOnly: { type: Boolean, default: false },
onDark: { type: Boolean, default: false },
});
</script>
<template>
<span class="inline-flex items-center gap-2.5 select-none">
<svg class="h-full w-auto shrink-0" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<g transform="translate(7 0) skewX(-13)">
<rect x="9" y="16" width="6" height="16" rx="3" :fill="onDark ? '#60A5FA' : 'var(--brand-700)'" />
<rect x="19" y="12" width="6" height="24" rx="3" :fill="onDark ? '#93C5FD' : 'var(--brand-500)'" />
<rect x="29" y="8" width="6" height="32" rx="3" :fill="onDark ? '#ffffff' : 'var(--brand-400)'" />
</g>
</svg>
<span
v-if="!iconOnly"
class="text-[1.35em] font-extrabold italic leading-none tracking-[-0.03em]"
>
<span :class="onDark ? 'text-white' : 'text-strong'">Driver</span><span :class="onDark ? 'text-brand-400' : 'text-brandtext'">Vault</span>
</span>
<span class="sr-only">DriverVault</span>
</span>
</template>
@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted, reactive } from "vue";
import { ref, computed, onMounted, reactive } from "vue";
import { request } from "../api";
import { t } from "../i18n";
@@ -14,6 +14,30 @@ 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", "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 {
@@ -153,7 +177,21 @@ const healthClass = (s) =>
{{ t("plugins.empty") }}
</p>
<div v-for="p in plugins" :key="p.name" class="border-t border-subtle first:border-t-0">
<!-- 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)">
+7
View File
@@ -134,6 +134,13 @@
"plugins": {
"title": "Plugins",
"subtitle": "Integrationer med tredjepartstjenester",
"categories": {
"vehicles": "Bilproducenter",
"chargers": "EV-ladere",
"apis-external": "Øvrige API'er",
"drives-external": "Eksterne drev",
"drives-local": "Lokale drev"
},
"registerExternal": "Registrér ekstern",
"cancelRegister": "Annuller",
"name": "Navn",
+7
View File
@@ -134,6 +134,13 @@
"plugins": {
"title": "Plugins",
"subtitle": "Third-party service integrations",
"categories": {
"vehicles": "Car manufacturers",
"chargers": "EV chargers",
"apis-external": "Other APIs",
"drives-external": "Remote drives",
"drives-local": "Local drives"
},
"registerExternal": "Register external",
"cancelRegister": "Cancel",
"name": "Name",
+7
View File
@@ -134,6 +134,13 @@
"plugins": {
"title": "Wtyczki",
"subtitle": "Integracje z usługami zewnętrznymi",
"categories": {
"vehicles": "Producenci samochodów",
"chargers": "Ładowarki EV",
"apis-external": "Pozostałe API",
"drives-external": "Dyski zdalne",
"drives-local": "Dyski lokalne"
},
"registerExternal": "Zarejestruj zewnętrzną",
"cancelRegister": "Anuluj",
"name": "Nazwa",