mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-15 05:29:28 +00:00
feat(webui): redesign the admin dashboard around admin tasks
A sidebar replaces the flat tabs with seven pages, and one filter bar sets the time range, comparison period and scope for all of them. - Home: spend against budget, forecast, request rate, error rate, open breakers, top users and models, recent events and warnings. - Spend: spend over time with comparison, budget burn-down, an attribution drill-down from group to user to model, pricing health and cost avoided by free models. - Consumers: a directory with last seen and limit bars, an access matrix, and a detail page per user. - Models: the catalog with billing prices, fleet latency and error rates, and provider health. - Reliability: error, timeout, failover, 429 and 402 trends, events, and latency per provider. - Config: the redacted config and a change helper that emits YAML or users.json snippets to paste into git. - MCP & Agents: callers per target. Page state lives in the URL hash, and old tab links map to the new pages. The replaced views are removed. admin_assets_gen.go is regenerated. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SnippetBlock from '@/components/SnippetBlock.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { groupGrantSnippet, isValidName, isValidUserName, userGrantJsonFragment, validatePersonalGrant } from '@/lib/snippets'
|
||||
|
||||
/**
|
||||
* GrantForm (ChangeHelper, redesign-plan.md section 3.4) builds a group's
|
||||
* access-list snippet (providers/models/mcpServers/agents, YAML) or a
|
||||
* user's personal grant (providers/models only — GroupConfig has no
|
||||
* per-user mcpServers/agents grant field, auth.go's own UserConfig shape)
|
||||
* as a bare JSON fragment for an existing users.json entry.
|
||||
*
|
||||
* Every list field is a comma-separated text Input rather than a proper
|
||||
* multi-select (no such shadcn-vue primitive is installed in this panel) —
|
||||
* parsed, trimmed, and de-duplicated below.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
groupNames: string[]
|
||||
userNames: string[]
|
||||
}>()
|
||||
|
||||
const targetKind = ref<'group' | 'user'>('group')
|
||||
const targetName = ref('')
|
||||
const providersRaw = ref('')
|
||||
const modelsRaw = ref('')
|
||||
const mcpServersRaw = ref('')
|
||||
const agentsRaw = ref('')
|
||||
|
||||
/** parseList splits a comma-separated field into trimmed, non-empty, de-duplicated entries — the one shared parser every list field below uses. */
|
||||
function parseList(raw: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const part of raw.split(',')) {
|
||||
const trimmed = part.trim()
|
||||
if (trimmed) seen.add(trimmed)
|
||||
}
|
||||
return Array.from(seen)
|
||||
}
|
||||
|
||||
const providers = computed(() => parseList(providersRaw.value))
|
||||
const models = computed(() => parseList(modelsRaw.value))
|
||||
const mcpServers = computed(() => parseList(mcpServersRaw.value))
|
||||
const agents = computed(() => parseList(agentsRaw.value))
|
||||
|
||||
const targetNames = computed(() => (targetKind.value === 'group' ? props.groupNames : props.userNames))
|
||||
// A group name follows NAME_PATTERN (auth.go's configNamePattern — a
|
||||
// route-path-segment safety rule); a user name only needs to be
|
||||
// non-empty (auth.go's buildEntry, no character restriction at all —
|
||||
// P3 item 27). isValidUserName, not isValidName, for targetKind === 'user'.
|
||||
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
|
||||
|
||||
// validatePersonalGrant is a USER-ONLY rule (lib/snippets.ts's own doc
|
||||
// comment) — a group's own `providers: []` legitimately means "every
|
||||
// provider" and Go applies it with no such check at group-construction
|
||||
// time (auth.go). Gated on targetKind so a group grant with an empty
|
||||
// providers list + a models list (a perfectly valid group config) no
|
||||
// longer shows a rejection that does not apply to it (P1 item 13).
|
||||
const grantError = computed(() => (targetKind.value === 'user' ? validatePersonalGrant(providers.value, models.value) : null))
|
||||
|
||||
const isEmpty = computed(
|
||||
() => providers.value.length === 0 && models.value.length === 0 && mcpServers.value.length === 0 && agents.value.length === 0,
|
||||
)
|
||||
|
||||
const snippet = computed<string | null>(() => {
|
||||
if (!nameValid.value || grantError.value || isEmpty.value) return null
|
||||
if (targetKind.value === 'group') {
|
||||
return groupGrantSnippet(targetName.value.trim(), {
|
||||
providers: providers.value,
|
||||
models: models.value,
|
||||
mcpServers: mcpServers.value,
|
||||
agents: agents.value,
|
||||
})
|
||||
}
|
||||
const fragment = userGrantJsonFragment({ providers: providers.value, models: models.value })
|
||||
return fragment || null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label id="grant-target-kind-label" class="text-xs font-medium text-muted-foreground">Target</label>
|
||||
<Select :model-value="targetKind" @update:model-value="(v) => (targetKind = v as 'group' | 'user')">
|
||||
<SelectTrigger aria-labelledby="grant-target-kind-label"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="group">Group</SelectItem>
|
||||
<SelectItem value="user">User (personal grant, existing users.json entry)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label id="grant-target-name-label" class="text-xs font-medium text-muted-foreground">{{ targetKind === 'group' ? 'Group name' : 'User name' }}</label>
|
||||
<Select :model-value="targetName" @update:model-value="(v) => (targetName = String(v))">
|
||||
<SelectTrigger aria-labelledby="grant-target-name-label"><SelectValue placeholder="Select…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="name in targetNames" :key="name" :value="name">{{ name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="grant-providers" class="text-xs font-medium text-muted-foreground">Providers (comma-separated, empty = all)</label>
|
||||
<Input id="grant-providers" v-model="providersRaw" placeholder="anthropic, openai" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="grant-models" class="text-xs font-medium text-muted-foreground">Models (comma-separated, empty = all)</label>
|
||||
<Input id="grant-models" v-model="modelsRaw" placeholder="anthropic/claude-sonnet-5" />
|
||||
</div>
|
||||
<template v-if="targetKind === 'group'">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="grant-mcp" class="text-xs font-medium text-muted-foreground">MCP servers (comma-separated, empty = all)</label>
|
||||
<Input id="grant-mcp" v-model="mcpServersRaw" placeholder="fetch, weather" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="grant-agents" class="text-xs font-medium text-muted-foreground">Agents (comma-separated, empty = all)</label>
|
||||
<Input id="grant-agents" v-model="agentsRaw" placeholder="agentkit" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Alert v-if="grantError" variant="destructive">
|
||||
<AlertDescription>{{ grantError }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SnippetBlock
|
||||
v-if="snippet"
|
||||
:label="targetKind === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName}’s users.json line`"
|
||||
:text="snippet"
|
||||
/>
|
||||
<p v-else class="text-sm text-muted-foreground">Pick a target and at least one list to build a snippet.</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import SnippetBlock from '@/components/SnippetBlock.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { groupLimitsSnippet, isValidName, isValidNonNegative, isValidNonNegativeInt, isValidUserName, userLimitsJsonFragment } from '@/lib/snippets'
|
||||
import type { LimitsConfig } from '@/types/api'
|
||||
|
||||
/**
|
||||
* LimitsForm (ChangeHelper, redesign-plan.md section 3.4) builds a
|
||||
* group's or a user's limits snippet — a group's is a full YAML block
|
||||
* (lib/snippets.ts's groupLimitsSnippet); a user's own limits live inside
|
||||
* their users.json line, so it is a bare JSON field fragment
|
||||
* (userLimitsJsonFragment) meant to be merged into that user's existing
|
||||
* entry, not a whole new line (UserForm.vue owns building a whole new
|
||||
* line).
|
||||
*/
|
||||
const props = defineProps<{
|
||||
groupNames: string[]
|
||||
userNames: string[]
|
||||
}>()
|
||||
|
||||
const targetKind = ref<'group' | 'user'>('group')
|
||||
const targetName = ref('')
|
||||
|
||||
/** INTEGER_FIELDS is every LimitsConfig field Go decodes as `int64` (llmgateway.go) — requestsPerMinute/requestsPerDay/tokensPerDay/tokensPerMonth reject a fractional value like "1.5" at json.Unmarshal outright (P2 item 12), unlike costPerDayUSD/costPerMonthUSD, which are `float64`. */
|
||||
const INTEGER_FIELDS = new Set<keyof LimitsConfig>(['requestsPerMinute', 'requestsPerDay', 'tokensPerDay', 'tokensPerMonth'])
|
||||
|
||||
/** fieldValid applies isValidNonNegativeInt to the four int64 fields and isValidNonNegative (fractions allowed) to the two float64 cost fields — the ONE gate both invalidFields and limits below share, so they can never disagree on which fields are int-only. */
|
||||
function fieldValid(key: keyof LimitsConfig, value: number): boolean {
|
||||
return INTEGER_FIELDS.has(key) ? isValidNonNegativeInt(value) : isValidNonNegative(value)
|
||||
}
|
||||
|
||||
const FIELDS: { key: keyof LimitsConfig; label: string }[] = [
|
||||
{ key: 'requestsPerMinute', label: 'Requests/minute' },
|
||||
{ key: 'requestsPerDay', label: 'Requests/day' },
|
||||
{ key: 'tokensPerDay', label: 'Tokens/day (in+out)' },
|
||||
{ key: 'tokensPerMonth', label: 'Tokens/month (in+out)' },
|
||||
{ key: 'costPerDayUSD', label: 'Cost/day (USD)' },
|
||||
{ key: 'costPerMonthUSD', label: 'Cost/month (USD)' },
|
||||
]
|
||||
|
||||
const raw = reactive<Record<keyof LimitsConfig, string>>({
|
||||
requestsPerMinute: '',
|
||||
requestsPerDay: '',
|
||||
tokensPerDay: '',
|
||||
tokensPerMonth: '',
|
||||
costPerDayUSD: '',
|
||||
costPerMonthUSD: '',
|
||||
})
|
||||
|
||||
const targetNames = computed(() => (targetKind.value === 'group' ? props.groupNames : props.userNames))
|
||||
|
||||
/** invalidFields lists every field whose entered text is non-empty but fails its own domain check (fieldValid above) — surfaced individually rather than one generic "invalid input" message, so the operator can see exactly which field to fix. Split by INTEGER_FIELDS so the error text is accurate for both: a fraction is invalid for requestsPerMinute etc. but perfectly valid for costPerDayUSD/costPerMonthUSD. */
|
||||
const invalidFields = computed(() =>
|
||||
FIELDS.filter(({ key }) => raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
|
||||
)
|
||||
const invalidIntegerFields = computed(() =>
|
||||
FIELDS.filter(({ key }) => INTEGER_FIELDS.has(key) && raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
|
||||
)
|
||||
const invalidDecimalFields = computed(() =>
|
||||
FIELDS.filter(({ key }) => !INTEGER_FIELDS.has(key) && raw[key].trim() !== '' && !fieldValid(key, Number(raw[key]))).map((f) => f.label),
|
||||
)
|
||||
|
||||
const limits = computed<LimitsConfig>(() => {
|
||||
const out: LimitsConfig = {}
|
||||
for (const { key } of FIELDS) {
|
||||
const value = Number(raw[key])
|
||||
if (raw[key].trim() !== '' && fieldValid(key, value) && value > 0) out[key] = value
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
// A group name follows NAME_PATTERN (auth.go's configNamePattern — a
|
||||
// route-path-segment safety rule); a user name only needs to be
|
||||
// non-empty (auth.go's buildEntry, no character restriction at all —
|
||||
// P3 item 27). isValidUserName, not isValidName, for targetKind === 'user'.
|
||||
const nameValid = computed(() => (targetKind.value === 'group' ? isValidName(targetName.value.trim()) : isValidUserName(targetName.value)))
|
||||
|
||||
const snippet = computed<string | null>(() => {
|
||||
if (!nameValid.value || invalidFields.value.length > 0) return null
|
||||
if (Object.keys(limits.value).length === 0) return null
|
||||
return targetKind.value === 'group'
|
||||
? groupLimitsSnippet(targetName.value.trim(), limits.value)
|
||||
: userLimitsJsonFragment(limits.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label id="limits-target-kind-label" class="text-xs font-medium text-muted-foreground">Target</label>
|
||||
<Select :model-value="targetKind" @update:model-value="(v) => (targetKind = v as 'group' | 'user')">
|
||||
<SelectTrigger aria-labelledby="limits-target-kind-label"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="group">Group</SelectItem>
|
||||
<SelectItem value="user">User (existing users.json entry)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label id="limits-target-name-label" class="text-xs font-medium text-muted-foreground">{{ targetKind === 'group' ? 'Group name' : 'User name' }}</label>
|
||||
<Select :model-value="targetName" @update:model-value="(v) => (targetName = String(v))">
|
||||
<SelectTrigger aria-labelledby="limits-target-name-label"><SelectValue placeholder="Select…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="name in targetNames" :key="name" :value="name">{{ name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div v-for="field in FIELDS" :key="field.key" class="flex flex-col gap-1">
|
||||
<label :for="`limits-${field.key}`" class="text-xs font-medium text-muted-foreground">{{ field.label }}</label>
|
||||
<Input
|
||||
:id="`limits-${field.key}`"
|
||||
v-model="raw[field.key]"
|
||||
type="number"
|
||||
min="0"
|
||||
:step="INTEGER_FIELDS.has(field.key) ? '1' : 'any'"
|
||||
placeholder="unset"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert v-if="invalidFields.length" variant="destructive">
|
||||
<AlertDescription>
|
||||
<p v-if="invalidIntegerFields.length">Enter a non-negative whole number for: {{ invalidIntegerFields.join(', ') }}.</p>
|
||||
<p v-if="invalidDecimalFields.length">Enter a non-negative number for: {{ invalidDecimalFields.join(', ') }}.</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SnippetBlock
|
||||
v-if="snippet"
|
||||
:label="targetKind === 'group' ? 'Paste into middleware.yaml' : `Merge into ${targetName}’s users.json line`"
|
||||
:text="snippet"
|
||||
/>
|
||||
<p v-else class="text-sm text-muted-foreground">Pick a target and at least one limit to build a snippet.</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SnippetBlock from '@/components/SnippetBlock.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { isValidNonNegativeInt, modelMetaSnippet } from '@/lib/snippets'
|
||||
|
||||
/**
|
||||
* ModelMetaForm (ChangeHelper, redesign-plan.md section 3.4) builds a
|
||||
* model's contextTokens/free modelMeta entry — independent fields (unlike
|
||||
* PricingForm.vue's paired input/output price), either or both may be
|
||||
* set: contextTokens alone documents a window the upstream cannot report;
|
||||
* free alone marks a self-hosted model billed as 0 regardless of any
|
||||
* discovered/override price (pricing.go: modelMetaFree short-circuits
|
||||
* before override/builtin/LiteLLM resolution).
|
||||
*/
|
||||
const modelId = ref('')
|
||||
const contextRaw = ref('')
|
||||
const free = ref(false)
|
||||
|
||||
const modelIdValid = computed(() => modelId.value.trim() !== '')
|
||||
const contextValue = computed(() => Number(contextRaw.value))
|
||||
const contextEntered = computed(() => contextRaw.value.trim() !== '')
|
||||
// ModelMetaConfig.ContextTokens is Go `int` (llmgateway.go) — a fraction
|
||||
// like "128000.5" fails json.Unmarshal outright, so isValidNonNegativeInt
|
||||
// (not isValidNonNegative) is the right domain check here (P2 item 12).
|
||||
const contextValid = computed(() => !contextEntered.value || (isValidNonNegativeInt(contextValue.value) && contextValue.value > 0))
|
||||
|
||||
const snippet = computed<string | null>(() => {
|
||||
if (!modelIdValid.value || !contextValid.value) return null
|
||||
if (!contextEntered.value && !free.value) return null
|
||||
return modelMetaSnippet(modelId.value.trim(), {
|
||||
contextTokens: contextEntered.value ? contextValue.value : undefined,
|
||||
free: free.value || undefined,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="modelmeta-model-id" class="text-xs font-medium text-muted-foreground">Model id (provider/model)</label>
|
||||
<Input id="modelmeta-model-id" v-model="modelId" placeholder="gx10/current" class="font-mono" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="modelmeta-context" class="text-xs font-medium text-muted-foreground">Context window (tokens)</label>
|
||||
<Input id="modelmeta-context" v-model="contextRaw" type="number" min="1" step="1" placeholder="131072" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 pb-1.5 text-sm">
|
||||
<input id="modelmeta-free" v-model="free" type="checkbox" class="size-4 rounded border-input" />
|
||||
Free (billed as $0 regardless of any resolved price)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Alert v-if="contextEntered && !contextValid" variant="destructive">
|
||||
<AlertDescription>Context window must be a positive whole number of tokens.</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SnippetBlock v-if="snippet" label="Paste into middleware.yaml" :text="snippet" />
|
||||
<p v-else class="text-sm text-muted-foreground">Enter a model id and set a context window and/or free to build a snippet.</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SnippetBlock from '@/components/SnippetBlock.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { isValidNonNegative, pricingSnippet } from '@/lib/snippets'
|
||||
|
||||
/**
|
||||
* PricingForm (ChangeHelper, redesign-plan.md section 3.4) builds a
|
||||
* model's per-token pricing override — inputPerM/outputPerM, the two
|
||||
* ModelPricing fields (llmgateway.go) that live in the top-level
|
||||
* Config.Pricing map (`spec.plugin.llmgateway.pricing`) and OVERRIDE the
|
||||
* built-in/LiteLLM-derived price for one model id (pricing.go's
|
||||
* billingPriceSource: `override` always wins). This is a DIFFERENT config
|
||||
* section from ModelMetaForm.vue's own modelMeta entry — ModelMeta's own
|
||||
* cost fields drive metadata exposure only, never billing (see
|
||||
* lib/snippets.ts's own ModelMeta doc comment). Both fields are required
|
||||
* together — a one-sided price makes no billing sense — unlike
|
||||
* ModelMetaForm.vue's independent contextTokens/free fields.
|
||||
*/
|
||||
const modelId = ref('')
|
||||
const inputRaw = ref('')
|
||||
const outputRaw = ref('')
|
||||
|
||||
const modelIdValid = computed(() => modelId.value.trim() !== '')
|
||||
const inputValue = computed(() => Number(inputRaw.value))
|
||||
const outputValue = computed(() => Number(outputRaw.value))
|
||||
const inputValid = computed(() => inputRaw.value.trim() !== '' && isValidNonNegative(inputValue.value))
|
||||
const outputValid = computed(() => outputRaw.value.trim() !== '' && isValidNonNegative(outputValue.value))
|
||||
|
||||
const errors = computed(() => {
|
||||
const out: string[] = []
|
||||
if (inputRaw.value.trim() !== '' && !inputValid.value) out.push('Input price/MTok must be a non-negative number.')
|
||||
if (outputRaw.value.trim() !== '' && !outputValid.value) out.push('Output price/MTok must be a non-negative number.')
|
||||
if ((inputValid.value && !outputValid.value) || (!inputValid.value && outputValid.value)) {
|
||||
out.push('Set both input and output price/MTok — a one-sided override has no billing meaning.')
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const snippet = computed<string | null>(() => {
|
||||
if (!modelIdValid.value || !inputValid.value || !outputValid.value) return null
|
||||
return pricingSnippet(modelId.value.trim(), { inputPerM: inputValue.value, outputPerM: outputValue.value })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="pricing-model-id" class="text-xs font-medium text-muted-foreground">Model id (provider/model)</label>
|
||||
<Input id="pricing-model-id" v-model="modelId" placeholder="openai/gpt-4" class="font-mono" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="pricing-input" class="text-xs font-medium text-muted-foreground">Input price / MTok (USD)</label>
|
||||
<Input id="pricing-input" v-model="inputRaw" type="number" min="0" step="any" placeholder="0.50" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="pricing-output" class="text-xs font-medium text-muted-foreground">Output price / MTok (USD)</label>
|
||||
<Input id="pricing-output" v-model="outputRaw" type="number" min="0" step="any" placeholder="1.50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert v-if="errors.length" variant="destructive">
|
||||
<AlertDescription>
|
||||
<p v-for="(error, index) in errors" :key="index">{{ error }}</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SnippetBlock v-if="snippet" label="Paste into middleware.yaml" :text="snippet" />
|
||||
<p v-else class="text-sm text-muted-foreground">Enter a model id and both prices to build a snippet.</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SnippetBlock from '@/components/SnippetBlock.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { API_KEY_PLACEHOLDER, generateApiKey, isValidUserName, userJsonLine, validatePersonalGrant } from '@/lib/snippets'
|
||||
|
||||
/**
|
||||
* UserForm (ChangeHelper, redesign-plan.md section 3.4) builds a whole new
|
||||
* users.json line — name, apiKey (placeholder by default, Q10 DECISION;
|
||||
* "Generate" produces a real client-side random key, never sent anywhere),
|
||||
* a primary group (production's own single-`group` convention) plus an
|
||||
* optional comma list of ADDITIONAL groups that switches the emitted field
|
||||
* to plural `groups` (lib/snippets.ts's userJsonLine), an optional
|
||||
* personal grant (validated the same way GrantForm.vue's user-target path
|
||||
* is), and an admin checkbox.
|
||||
*/
|
||||
const props = defineProps<{ groupNames: string[] }>()
|
||||
|
||||
const name = ref('')
|
||||
const apiKey = ref(API_KEY_PLACEHOLDER)
|
||||
const primaryGroup = ref('')
|
||||
const extraGroupsRaw = ref('')
|
||||
const providersRaw = ref('')
|
||||
const modelsRaw = ref('')
|
||||
const admin = ref(false)
|
||||
|
||||
function generate(): void {
|
||||
apiKey.value = generateApiKey()
|
||||
}
|
||||
|
||||
function parseList(raw: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const part of raw.split(',')) {
|
||||
const trimmed = part.trim()
|
||||
if (trimmed) seen.add(trimmed)
|
||||
}
|
||||
return Array.from(seen)
|
||||
}
|
||||
|
||||
const extraGroups = computed(() => parseList(extraGroupsRaw.value))
|
||||
const providers = computed(() => parseList(providersRaw.value))
|
||||
const models = computed(() => parseList(modelsRaw.value))
|
||||
|
||||
const nameValid = computed(() => isValidUserName(name.value))
|
||||
const groupValid = computed(() => primaryGroup.value !== '')
|
||||
const apiKeyValid = computed(() => apiKey.value.trim() !== '' && apiKey.value !== API_KEY_PLACEHOLDER)
|
||||
const grantError = computed(() => validatePersonalGrant(providers.value, models.value))
|
||||
|
||||
const snippet = computed<string | null>(() => {
|
||||
if (!nameValid.value || !groupValid.value || !apiKeyValid.value || grantError.value) return null
|
||||
const allGroups = extraGroups.value.length > 0 ? [primaryGroup.value, ...extraGroups.value] : null
|
||||
return userJsonLine({
|
||||
name: name.value.trim(),
|
||||
apiKey: apiKey.value.trim(),
|
||||
group: allGroups ? undefined : primaryGroup.value,
|
||||
groups: allGroups ?? undefined,
|
||||
providers: providers.value,
|
||||
models: models.value,
|
||||
admin: admin.value,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="user-name" class="text-xs font-medium text-muted-foreground">Name</label>
|
||||
<Input id="user-name" v-model="name" placeholder="alice" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label id="user-group-label" class="text-xs font-medium text-muted-foreground">Primary group</label>
|
||||
<Select :model-value="primaryGroup" @update:model-value="(v) => (primaryGroup = String(v))">
|
||||
<SelectTrigger aria-labelledby="user-group-label"><SelectValue placeholder="Select…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="g in props.groupNames" :key="g" :value="g">{{ g }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="user-api-key" class="text-xs font-medium text-muted-foreground">API key</label>
|
||||
<div class="flex gap-2">
|
||||
<Input id="user-api-key" v-model="apiKey" class="font-mono" />
|
||||
<Button type="button" variant="outline" size="sm" @click="generate">Generate</Button>
|
||||
</div>
|
||||
<p v-if="apiKey === API_KEY_PLACEHOLDER" class="text-xs text-muted-foreground">Still the placeholder — click Generate for a real key.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="user-extra-groups" class="text-xs font-medium text-muted-foreground">Additional groups (comma-separated, optional)</label>
|
||||
<Input id="user-extra-groups" v-model="extraGroupsRaw" placeholder="friends" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="user-providers" class="text-xs font-medium text-muted-foreground">Personal grant: providers (optional)</label>
|
||||
<Input id="user-providers" v-model="providersRaw" placeholder="gx10" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="user-models" class="text-xs font-medium text-muted-foreground">Personal grant: models (optional)</label>
|
||||
<Input id="user-models" v-model="modelsRaw" placeholder="gx10/current" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="admin" type="checkbox" class="size-4 rounded border-input" />
|
||||
Admin (full access to /admin/api/*)
|
||||
</label>
|
||||
|
||||
<Alert v-if="grantError" variant="destructive">
|
||||
<AlertDescription>{{ grantError }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SnippetBlock v-if="snippet" label="Add as a new line in users.json" :text="snippet" />
|
||||
<p v-else class="text-sm text-muted-foreground">Fill in a name, group, and a real (generated) API key to build a snippet.</p>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user