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:
co-authored by
Claude Opus 5
parent
340a81b0d6
commit
e648634ce1
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
@@ -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."},
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user