feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { GatewayKey } from '../../api';
|
||||
import {
|
||||
listGatewayKeys,
|
||||
createGatewayKey,
|
||||
revokeGatewayKey,
|
||||
rotateGatewayKey,
|
||||
patchGatewayKey,
|
||||
} from '../../api';
|
||||
import { GatewayKeyCreateDialog } from './GatewayKeyCreateDialog';
|
||||
import { GatewayKeyRawKeyDialog } from './GatewayKeyRawKeyDialog';
|
||||
import { GatewayKeyUsagePanel } from './GatewayKeyUsagePanel';
|
||||
|
||||
interface Props {
|
||||
showToast?: (msg: string, variant?: 'success' | 'error') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → LLM → Gateway Server → Virtual Keys section (Step 8).
|
||||
*
|
||||
* Renders the Gateway Keys list + create/rotate/revoke actions inline
|
||||
* within the Gateway Server form. Previously this lived under its own
|
||||
* sidebar entry (`gateway-keys`); the entry was removed in Step 8 because
|
||||
* key management is a Gateway Server operation, not a separate concern.
|
||||
*
|
||||
* List table + actions per row (Detail / Rotate / Revoke). Create
|
||||
* dialog issues a fresh sk-aao-* key; the raw value is then surfaced
|
||||
* in a one-time reveal dialog with copy + acknowledge gate.
|
||||
*
|
||||
* Filters: ?team= (text input) and ?activeOnly= (checkbox). Both
|
||||
* roundtrip through React Query for cache scoping.
|
||||
*
|
||||
* Note: this section talks to its own admin REST API (not the global
|
||||
* config save flow), so edits here are applied immediately and do not
|
||||
* participate in the surrounding form's draft/dirty/Save&Apply bar.
|
||||
*/
|
||||
export function GatewayKeysSection({ showToast }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [teamFilter, setTeamFilter] = useState('');
|
||||
const [activeOnly, setActiveOnly] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [createSubmitting, setCreateSubmitting] = useState(false);
|
||||
const [rawDialog, setRawDialog] = useState<{ rawKey: string; team: string; reason: 'created' | 'rotated' } | null>(null);
|
||||
const [usagePanelId, setUsagePanelId] = useState<string | null>(null);
|
||||
const [budgetDraft, setBudgetDraft] = useState<{ id: string; value: string } | null>(null);
|
||||
const [rpmDraft, setRpmDraft] = useState<{ id: string; value: string } | null>(null);
|
||||
|
||||
const queryKey = ['gateway-keys', { team: teamFilter || undefined, activeOnly }];
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => listGatewayKeys({ team: teamFilter || undefined, activeOnly }),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
function notify(msg: string, variant: 'success' | 'error' = 'success'): void {
|
||||
if (showToast) showToast(msg, variant);
|
||||
}
|
||||
|
||||
async function handleCreate(input: {
|
||||
team: string;
|
||||
allowedModels?: string[];
|
||||
tokensBudget?: number | null;
|
||||
rateLimitRpm?: number | null;
|
||||
}): Promise<void> {
|
||||
setCreateSubmitting(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const created = await createGatewayKey(input);
|
||||
setCreating(false);
|
||||
if (created.key) {
|
||||
setRawDialog({ rawKey: created.key, team: created.team, reason: 'created' });
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
|
||||
notify('Gateway key を発行しました');
|
||||
} catch (e) {
|
||||
setCreateError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setCreateSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRotate(row: GatewayKey): Promise<void> {
|
||||
if (!confirm(`team=${row.team} のキーをローテーションしますか?\n旧キーは無効になります。`)) return;
|
||||
try {
|
||||
const created = await rotateGatewayKey(row.id);
|
||||
if (created.key) {
|
||||
setRawDialog({ rawKey: created.key, team: created.team, reason: 'rotated' });
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
|
||||
notify('Rotate しました');
|
||||
} catch (e) {
|
||||
notify(e instanceof Error ? e.message : String(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(row: GatewayKey): Promise<void> {
|
||||
if (!confirm(`team=${row.team} のキー (${row.keyPrefix}…) を Revoke しますか?\nこの操作は取り消せません。`)) return;
|
||||
try {
|
||||
await revokeGatewayKey(row.id);
|
||||
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
|
||||
notify('Revoke しました');
|
||||
} catch (e) {
|
||||
notify(e instanceof Error ? e.message : String(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePatch(id: string, patch: { tokensBudget?: number | null; rateLimitRpm?: number | null }): Promise<void> {
|
||||
try {
|
||||
await patchGatewayKey(id, patch);
|
||||
await qc.invalidateQueries({ queryKey: ['gateway-keys'] });
|
||||
notify('更新しました');
|
||||
} catch (e) {
|
||||
notify(e instanceof Error ? e.message : String(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function commitBudget(id: string): void {
|
||||
if (!budgetDraft || budgetDraft.id !== id) return;
|
||||
const v = budgetDraft.value.trim();
|
||||
const parsed = v === '' ? null : Number(v);
|
||||
if (parsed !== null && (!Number.isFinite(parsed) || parsed <= 0)) {
|
||||
notify('tokens budget must be a positive integer or empty', 'error');
|
||||
setBudgetDraft(null);
|
||||
return;
|
||||
}
|
||||
handlePatch(id, { tokensBudget: parsed });
|
||||
setBudgetDraft(null);
|
||||
}
|
||||
|
||||
function commitRpm(id: string): void {
|
||||
if (!rpmDraft || rpmDraft.id !== id) return;
|
||||
const v = rpmDraft.value.trim();
|
||||
const parsed = v === '' ? null : Number(v);
|
||||
if (parsed !== null && (!Number.isFinite(parsed) || parsed <= 0)) {
|
||||
notify('rate limit must be a positive integer or empty', 'error');
|
||||
setRpmDraft(null);
|
||||
return;
|
||||
}
|
||||
handlePatch(id, { rateLimitRpm: parsed });
|
||||
setRpmDraft(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">Team filter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={teamFilter}
|
||||
onChange={(e) => setTeamFilter(e.target.value)}
|
||||
placeholder="alpha"
|
||||
className="px-2 py-1 text-sm border border-slate-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm mt-5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeOnly}
|
||||
onChange={(e) => setActiveOnly(e.target.checked)}
|
||||
/>
|
||||
Active only
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="ml-auto px-2 py-1 text-xs rounded border border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCreateError(null); setCreating(true); }}
|
||||
className="px-3 py-1.5 text-sm rounded bg-accent text-white hover:bg-accent-strong"
|
||||
>
|
||||
+ 新規発行
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border border-hairline rounded overflow-hidden">
|
||||
{isLoading && <div className="p-3 text-sm text-slate-500">Loading…</div>}
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600">
|
||||
取得エラー: {String((error as Error).message ?? error)}
|
||||
</div>
|
||||
)}
|
||||
{data && data.length === 0 && (
|
||||
<div className="p-6 text-center text-sm text-slate-400">
|
||||
キーが登録されていません。「+ 新規発行」から作成できます。
|
||||
</div>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="text-left p-2 font-medium">Prefix</th>
|
||||
<th className="text-left p-2 font-medium">Team</th>
|
||||
<th className="text-left p-2 font-medium">Models</th>
|
||||
<th className="text-right p-2 font-medium">Budget</th>
|
||||
<th className="text-right p-2 font-medium">Rpm</th>
|
||||
<th className="text-left p-2 font-medium">Source</th>
|
||||
<th className="text-left p-2 font-medium">Status</th>
|
||||
<th className="text-right p-2 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => {
|
||||
const isRevoked = row.revokedAt !== null;
|
||||
const isConfig = row.source === 'config-import';
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={`border-t border-hairline ${isRevoked ? 'bg-slate-50 text-slate-400' : ''}`}
|
||||
>
|
||||
<td className="p-2 font-mono text-xs">{row.keyPrefix}…</td>
|
||||
<td className="p-2">{row.team}</td>
|
||||
<td className="p-2 text-xs text-slate-500">
|
||||
{row.allowedModels === null
|
||||
? <span className="text-slate-400 italic">all</span>
|
||||
: row.allowedModels.join(', ')}
|
||||
</td>
|
||||
<td className="p-2 text-right font-mono text-xs">
|
||||
{budgetDraft?.id === row.id ? (
|
||||
<input
|
||||
type="number"
|
||||
autoFocus
|
||||
value={budgetDraft.value}
|
||||
onChange={(e) => setBudgetDraft({ id: row.id, value: e.target.value })}
|
||||
onBlur={() => commitBudget(row.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitBudget(row.id);
|
||||
if (e.key === 'Escape') setBudgetDraft(null);
|
||||
}}
|
||||
className="w-20 px-1 py-0.5 text-xs border border-slate-300 rounded text-right"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRevoked || isConfig}
|
||||
onClick={() => setBudgetDraft({ id: row.id, value: row.tokensBudget?.toString() ?? '' })}
|
||||
className="text-left disabled:cursor-not-allowed hover:underline"
|
||||
title={isConfig ? 'config-import keys は config.yaml で管理' : isRevoked ? 'revoked' : 'click to edit'}
|
||||
>
|
||||
{row.tokensBudget !== null ? row.tokensBudget.toLocaleString() : <span className="text-slate-400">∞</span>}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 text-right font-mono text-xs">
|
||||
{rpmDraft?.id === row.id ? (
|
||||
<input
|
||||
type="number"
|
||||
autoFocus
|
||||
value={rpmDraft.value}
|
||||
onChange={(e) => setRpmDraft({ id: row.id, value: e.target.value })}
|
||||
onBlur={() => commitRpm(row.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitRpm(row.id);
|
||||
if (e.key === 'Escape') setRpmDraft(null);
|
||||
}}
|
||||
className="w-16 px-1 py-0.5 text-xs border border-slate-300 rounded text-right"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRevoked || isConfig}
|
||||
onClick={() => setRpmDraft({ id: row.id, value: row.rateLimitRpm?.toString() ?? '' })}
|
||||
className="text-left disabled:cursor-not-allowed hover:underline"
|
||||
title={isConfig ? 'config-import keys は config.yaml で管理' : isRevoked ? 'revoked' : 'click to edit'}
|
||||
>
|
||||
{row.rateLimitRpm !== null ? row.rateLimitRpm.toString() : <span className="text-slate-400">∞</span>}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 text-xs">
|
||||
{isConfig ? (
|
||||
<span className="px-1.5 py-0.5 bg-slate-100 rounded text-slate-600">config</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 bg-accent-soft rounded text-accent">admin</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 text-xs">
|
||||
{isRevoked ? (
|
||||
<span className="px-1.5 py-0.5 bg-red-50 text-red-700 rounded">revoked</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 bg-green-50 text-green-700 rounded">active</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUsagePanelId(row.id)}
|
||||
className="px-2 py-0.5 text-xs rounded border border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
詳細
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRevoked}
|
||||
onClick={() => handleRotate(row)}
|
||||
className="px-2 py-0.5 text-xs rounded border border-slate-300 hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Rotate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRevoked}
|
||||
onClick={() => handleRevoke(row)}
|
||||
className="px-2 py-0.5 text-xs rounded border border-red-300 text-red-700 hover:bg-red-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-500">
|
||||
Tokens budget は月次 UTC でリセット。Rate limit (rpm) は 60 秒スライディングウィンドウ。
|
||||
config-import のキー(config.yaml から取り込まれたもの)は値の編集ができません。
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<GatewayKeyCreateDialog
|
||||
onCancel={() => setCreating(false)}
|
||||
onSubmit={handleCreate}
|
||||
submitting={createSubmitting}
|
||||
error={createError}
|
||||
/>
|
||||
)}
|
||||
{rawDialog && (
|
||||
<GatewayKeyRawKeyDialog
|
||||
rawKey={rawDialog.rawKey}
|
||||
team={rawDialog.team}
|
||||
reason={rawDialog.reason}
|
||||
onClose={() => setRawDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{usagePanelId && (
|
||||
<GatewayKeyUsagePanel
|
||||
keyId={usagePanelId}
|
||||
onClose={() => setUsagePanelId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user