mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
feat(webui): redesign admin key gate
Add a circular key-icon badge and a small "LLM Gateway" wordmark, so the screen reads as this product's door and not a generic prompt. Restructure the dense security paragraph into a short description plus a three-item muted checklist. Each claim keeps its original strength: the key stays in this tab's session storage, the browser never writes it to disk, and requests go only to this page's own /admin/api/* routes. Add a show/hide toggle for the key input. It reuses the ghost icon-button composition SearchInput.vue already uses for its clear button. Autofocus the input on mount. Disable submit while the trimmed input is empty. Add a brief shake on the key input when auth.error is set. The animation respects prefers-reduced-motion and lives in a scoped <style> block local to this file — the first one in webui/src/components, added because no Tailwind-core or tw-animate-css keyframe reads as "rejected". Extend the header in App.vue with a GitHub repository link, per an operator scope addition mid-task: an icon-only ghost button that opens github.com/lukaszraczylo/traefik-llmgateway in a new tab. The link sits in App.vue's header, above the gate/dashboard switch, so it renders on every screen including the auth gate. Installs @fortawesome/free-brands-svg-icons, pinned like the other fontawesome packages, for the faGithub icon. auth.submit() semantics, dashboard.refresh() timing, and the backend stay unchanged.
This commit is contained in:
Generated
+13
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.3.1",
|
"@fortawesome/fontawesome-svg-core": "^7.3.1",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "^7.3.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.3.1",
|
"@fortawesome/free-solid-svg-icons": "^7.3.1",
|
||||||
"@fortawesome/vue-fontawesome": "^3.3.3",
|
"@fortawesome/vue-fontawesome": "^3.3.3",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
@@ -688,6 +689,18 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fortawesome/free-brands-svg-icons": {
|
||||||
|
"version": "7.3.1",
|
||||||
|
"resolved": "https://npm.fontawesome.com/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.3.1.tgz",
|
||||||
|
"integrity": "sha512-8eiNzycuFhy3mZsi5EVO8JpO3H8l7/kSCl4Gk4iUew9hJuzYFqAAD6kqgeEup3+YZ9hL+5cjjorGzrRLkYuURg==",
|
||||||
|
"license": "(CC-BY-4.0 AND MIT)",
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-common-types": "7.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fortawesome/free-solid-svg-icons": {
|
"node_modules/@fortawesome/free-solid-svg-icons": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://npm.fontawesome.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.3.1.tgz",
|
"resolved": "https://npm.fontawesome.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.3.1.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.3.1",
|
"@fortawesome/fontawesome-svg-core": "^7.3.1",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "^7.3.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.3.1",
|
"@fortawesome/free-solid-svg-icons": "^7.3.1",
|
||||||
"@fortawesome/vue-fontawesome": "^3.3.3",
|
"@fortawesome/vue-fontawesome": "^3.3.3",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
|||||||
+21
-1
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { faGithub } from '@fortawesome/free-brands-svg-icons'
|
||||||
import { faCircleExclamation } from '@fortawesome/free-solid-svg-icons'
|
import { faCircleExclamation } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
import AuthGate from '@/components/AuthGate.vue'
|
import AuthGate from '@/components/AuthGate.vue'
|
||||||
import OverviewView from '@/components/OverviewView.vue'
|
import OverviewView from '@/components/OverviewView.vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import UsageView from '@/components/UsageView.vue'
|
import UsageView from '@/components/UsageView.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -15,6 +17,10 @@ import { useDashboardStore } from '@/stores/dashboard'
|
|||||||
// lean; it fetches once, on first visit to the Charts tab.
|
// lean; it fetches once, on first visit to the Charts tab.
|
||||||
const ChartsView = defineAsyncComponent(() => import('@/components/ChartsView.vue'))
|
const ChartsView = defineAsyncComponent(() => import('@/components/ChartsView.vue'))
|
||||||
|
|
||||||
|
/** The module path (go.mod / .traefik.yml `import:`) — same repo the plugin
|
||||||
|
* ships from, so this is the one canonical URL rather than a guess. */
|
||||||
|
const GITHUB_REPO_URL = 'https://github.com/lukaszraczylo/traefik-llmgateway'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const dashboard = useDashboardStore()
|
const dashboard = useDashboardStore()
|
||||||
|
|
||||||
@@ -34,7 +40,21 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex w-full flex-col gap-6 px-4 py-8 sm:px-6 lg:px-8">
|
<div class="flex w-full flex-col gap-6 px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<header class="flex flex-col gap-1">
|
<header class="flex flex-col gap-1">
|
||||||
<h1 class="text-xl font-semibold tracking-tight">LLM Gateway — Admin</h1>
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<h1 class="text-xl font-semibold tracking-tight">LLM Gateway — Admin</h1>
|
||||||
|
<Button
|
||||||
|
as="a"
|
||||||
|
:href="GITHUB_REPO_URL"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="GitHub repository"
|
||||||
|
class="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon :icon="faGithub" class="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<p class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
<p class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<FontAwesomeIcon v-if="dashboard.error" :icon="faCircleExclamation" class="size-3.5 text-destructive" />
|
<FontAwesomeIcon v-if="dashboard.error" :icon="faCircleExclamation" class="size-3.5 text-destructive" />
|
||||||
<span :class="dashboard.error ? 'text-destructive' : undefined">{{ statusText }}</span>
|
<span :class="dashboard.error ? 'text-destructive' : undefined">{{ statusText }}</span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { faKey } from '@fortawesome/free-solid-svg-icons'
|
import { faCheck, faEye, faEyeSlash, faKey } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { ref } from 'vue'
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -12,6 +12,13 @@ import { useDashboardStore } from '@/stores/dashboard'
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const dashboard = useDashboardStore()
|
const dashboard = useDashboardStore()
|
||||||
const keyInput = ref('')
|
const keyInput = ref('')
|
||||||
|
const showKey = ref(false)
|
||||||
|
const keyInputEl = ref<InstanceType<typeof Input> | null>(null)
|
||||||
|
|
||||||
|
/** Disable submit until there's non-whitespace to send — matches the store's
|
||||||
|
* own `if (!key) return` guard in submit(), just surfaced to the button
|
||||||
|
* instead of silently no-op'ing on click. */
|
||||||
|
const canSubmit = computed(() => keyInput.value.trim() !== '')
|
||||||
|
|
||||||
function submit(): void {
|
function submit(): void {
|
||||||
const value = keyInput.value
|
const value = keyInput.value
|
||||||
@@ -23,37 +30,101 @@ function submit(): void {
|
|||||||
// for up to 5s after key entry.
|
// for up to 5s after key entry.
|
||||||
void dashboard.refresh()
|
void dashboard.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Autofocus the key field — this is the only control on the gate screen,
|
||||||
|
// so a keyboard/screen-reader user should land straight in it.
|
||||||
|
void nextTick(() => keyInputEl.value?.$el?.focus())
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mx-auto flex min-h-[70vh] max-w-md flex-col justify-center px-4">
|
<div class="mx-auto flex min-h-[70vh] max-w-md flex-col justify-center px-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader class="flex flex-col items-center gap-3 text-center">
|
||||||
<CardTitle class="flex items-center gap-2">
|
<div class="flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
<FontAwesomeIcon :icon="faKey" class="size-4 text-primary" />
|
<FontAwesomeIcon :icon="faKey" class="size-5" aria-hidden="true" />
|
||||||
Admin key required
|
</div>
|
||||||
</CardTitle>
|
<div class="flex flex-col items-center gap-1">
|
||||||
<CardDescription>
|
<span class="text-sm font-semibold text-foreground">LLM Gateway</span>
|
||||||
Enter an admin API key. The browser keeps it only in this tab's session storage: it is
|
<CardTitle>Admin key required</CardTitle>
|
||||||
never written to disk and is sent only to this page's own /admin/api/* requests.
|
</div>
|
||||||
</CardDescription>
|
<CardDescription>Enter an admin API key to continue.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="flex flex-col gap-4">
|
<CardContent class="flex flex-col gap-4">
|
||||||
<form class="flex flex-col gap-3" @submit.prevent="submit">
|
<form class="flex flex-col gap-3" @submit.prevent="submit">
|
||||||
<Input
|
<div class="relative">
|
||||||
v-model="keyInput"
|
<Input
|
||||||
type="password"
|
ref="keyInputEl"
|
||||||
autocomplete="off"
|
v-model="keyInput"
|
||||||
placeholder="API key"
|
:type="showKey ? 'text' : 'password'"
|
||||||
aria-label="Admin API key"
|
autocomplete="off"
|
||||||
/>
|
placeholder="API key"
|
||||||
<Button type="submit" class="w-full">Continue</Button>
|
aria-label="Admin API key"
|
||||||
|
class="h-10 pr-10 text-base"
|
||||||
|
:class="{ 'authgate-shake': auth.error }"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
class="absolute top-1/2 right-1.5 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
:aria-label="showKey ? 'Hide key' : 'Show key'"
|
||||||
|
@click="showKey = !showKey"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon :icon="showKey ? faEyeSlash : faEye" class="size-3.5" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" class="w-full" :disabled="!canSubmit">
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<Alert v-if="auth.error" variant="destructive">
|
<Alert v-if="auth.error" variant="destructive">
|
||||||
<AlertTitle>Authentication failed</AlertTitle>
|
<AlertTitle>Authentication failed</AlertTitle>
|
||||||
<AlertDescription>{{ auth.error }}</AlertDescription>
|
<AlertDescription>{{ auth.error }}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
<ul class="flex flex-col gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<li class="flex items-start gap-1.5">
|
||||||
|
<FontAwesomeIcon :icon="faCheck" class="mt-0.5 size-3 shrink-0" aria-hidden="true" />
|
||||||
|
<span>Kept only in this tab's session storage</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-1.5">
|
||||||
|
<FontAwesomeIcon :icon="faCheck" class="mt-0.5 size-3 shrink-0" aria-hidden="true" />
|
||||||
|
<span>Never written to disk</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-1.5">
|
||||||
|
<FontAwesomeIcon :icon="faCheck" class="mt-0.5 size-3 shrink-0" aria-hidden="true" />
|
||||||
|
<span>Sent only to this page's own /admin/api/* requests</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/*
|
||||||
|
* First scoped <style> block in webui/src/components — no other component
|
||||||
|
* needs one yet (grep confirms) but nothing in the Vite/@vitejs/plugin-vue
|
||||||
|
* pipeline forbids it, and the alternative (Tailwind-only) has no shake
|
||||||
|
* keyframe to compose from: Tailwind v4 core ships only pulse/bounce/spin/
|
||||||
|
* ping (node_modules/tailwindcss/theme.css), and tw-animate-css adds
|
||||||
|
* accordion/collapsible/enter/exit/caret-blink, none of which reads as
|
||||||
|
* "rejected". Contained to this file per the task's file-scope constraint.
|
||||||
|
*
|
||||||
|
* Applied only while auth.error is set (a failed submit), not on every
|
||||||
|
* render, so it communicates state rather than decorating the page.
|
||||||
|
*/
|
||||||
|
@keyframes authgate-shake {
|
||||||
|
10%, 90% { transform: translateX(-1px); }
|
||||||
|
20%, 80% { transform: translateX(2px); }
|
||||||
|
30%, 50%, 70% { transform: translateX(-4px); }
|
||||||
|
40%, 60% { transform: translateX(4px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.authgate-shake {
|
||||||
|
animation: authgate-shake 0.4s ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user