maestro/ui/src/components/settings/GatewayServerForm.tsx
2026-06-03 04:30:10 +00:00

382 lines
16 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
import { getGatewayServerStatus, type GatewayServerStatus } from '../../api';
import { GatewayKeysSection } from './GatewayKeysSection';
/**
* Settings → LLM → Gateway Server.
*
* Sections (top → bottom):
* - Enable toggle + live status badge
* - Listen port
* - Backends list (config-driven, draft/Save&Apply)
* - Virtual Keys (key management — admin REST API, applied immediately)
* - Advanced timeouts (request / upstream / shutdown)
*
* Step 8 of the 2026-05-21 settings restructure folded the standalone
* Gateway Keys sidebar entry into this form as the "Virtual Keys"
* section, so key issuance / rotation / revocation lives next to the
* Gateway it configures. The keys section uses its own admin REST API
* and therefore bypasses the surrounding Save & Apply bar — that's why
* it's allowed to share this form even though it doesn't touch
* `config.gateway.*`.
*
* Status badge polls /api/admin/gateway/status every 3s so an enable
* flip is reflected near-instantly without a page reload.
*
* Field names are camelCase to match the in-memory AppConfig shape
* (src/config.ts:transformKeys converts YAML snake_case → camelCase on
* load, and toSnakeKeys reverses on save). The displayed labels keep the
* YAML names (max_slots, api_key, ...) so operators can map back to
* config.yaml.example without translation.
*/
interface GatewayBackend {
id?: string;
endpoint?: string;
model?: string;
maxSlots?: number;
apiKey?: string;
}
interface GatewayConfigShape {
enabled?: boolean;
listenPort?: number;
requestTimeoutSec?: number;
upstreamTimeoutSec?: number;
shutdownGracefulSec?: number;
backends?: GatewayBackend[];
virtualKeys?: unknown[];
}
/**
* Render value for a `<FieldInput type="number">`. Returns the number
* when it's a finite integer-typed value, otherwise `fallback`. Without
* this, `value={NaN ?? 1}` resolves to `NaN` (nullish-coalesce only
* traps null/undefined), and React renders the literal string "NaN"
* into the input — see https://gitea.example.com/.../issues for the
* Phase 3c regression that motivated this helper.
*/
function numberValue(n: unknown, fallback: number | ''): number | '' {
return typeof n === 'number' && Number.isFinite(n) ? n : fallback;
}
/**
* Parse the string emitted by a number `<FieldInput>` into either a
* finite number, or `undefined` for empty / unparseable input. Storing
* `undefined` (rather than NaN) keeps the next render's value clean.
*/
function parseNumberInput(v: string): number | undefined {
if (v === '') return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
}
function StatusBadge({ status }: { status: GatewayServerStatus | undefined }) {
if (!status) {
return <span className="text-2xs text-slate-400"></span>;
}
if (status.state === 'unavailable') {
return (
<span title={status.message} className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">
unavailable
</span>
);
}
if (status.state === 'running') {
return (
<span className="text-xs px-2 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200">
running (mounted at /v1, port {status.sharedPort})
</span>
);
}
if (status.state === 'misconfigured') {
return (
<span className="text-xs px-2 py-0.5 rounded bg-red-50 text-red-700 border border-red-200">
misconfigured ({status.errors.length} error{status.errors.length === 1 ? '' : 's'})
</span>
);
}
if (status.state === 'starting' || status.state === 'stopping') {
return (
<span className="text-xs px-2 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200">
{status.state}
</span>
);
}
return (
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">
disabled
</span>
);
}
/**
* Validate backend rows in-form so the operator sees red-bordered fields
* before they hit Save. Returns a per-row error map keyed by row index.
*/
function validateBackends(backends: GatewayBackend[]): Map<number, string[]> {
const errors = new Map<number, string[]>();
const seenIds = new Set<string>();
backends.forEach((b, i) => {
const rowErrs: string[] = [];
if (!b.id || b.id.trim() === '') rowErrs.push('id required');
else if (seenIds.has(b.id)) rowErrs.push('duplicate id');
if (b.id) seenIds.add(b.id);
if (!b.endpoint || b.endpoint.trim() === '') rowErrs.push('endpoint required');
else {
try {
const u = new URL(b.endpoint);
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
rowErrs.push('endpoint must be http(s)');
}
} catch {
rowErrs.push('endpoint invalid URL');
}
}
if (!b.model || b.model.trim() === '') rowErrs.push('model required');
if (
typeof b.maxSlots !== 'number'
|| !Number.isFinite(b.maxSlots)
|| b.maxSlots <= 0
|| !Number.isInteger(b.maxSlots)
) {
rowErrs.push('max_slots must be positive integer');
}
if (rowErrs.length > 0) errors.set(i, rowErrs);
});
return errors;
}
export function GatewayServerForm({ config, onChange }: SectionFormProps) {
const gw: GatewayConfigShape = config.gateway ?? {};
const backends: GatewayBackend[] = Array.isArray(gw.backends) ? gw.backends : [];
const statusQuery = useQuery({
queryKey: ['gateway-server-status'],
queryFn: getGatewayServerStatus,
refetchInterval: 3000,
staleTime: 1000,
});
const backendErrors = useMemo(() => validateBackends(backends), [backends]);
const setEnabled = (v: boolean) => onChange('gateway.enabled', v);
const setListenPort = (v: number | undefined) => onChange('gateway.listenPort', v);
const setRequestTimeout = (v: number | undefined) => onChange('gateway.requestTimeoutSec', v);
const setUpstreamTimeout = (v: number | undefined) => onChange('gateway.upstreamTimeoutSec', v);
const setShutdownGraceful = (v: number | undefined) => onChange('gateway.shutdownGracefulSec', v);
const updateBackend = (i: number, field: keyof GatewayBackend, value: unknown) => {
const next = backends.map((b, idx) => (idx === i ? { ...b, [field]: value } : b));
onChange('gateway.backends', next);
};
const addBackend = () => {
const next: GatewayBackend = {
id: `backend-${backends.length + 1}`,
endpoint: '',
model: '',
maxSlots: 1,
};
onChange('gateway.backends', [...backends, next]);
};
const removeBackend = (i: number) => {
onChange('gateway.backends', backends.filter((_, idx) => idx !== i));
};
return (
<div className="space-y-4">
<div>
<h2 className="text-base font-semibold text-slate-800 mb-1">Gateway Server</h2>
<p className="text-xs text-slate-500">
AAO LLM Gateway <code>/v1/chat/completions</code> worker UI <strong></strong> ( process ) AAO <code>provider.workers[].endpoint</code> URL GPU
</p>
<div className="flex items-center gap-3 mt-2 flex-wrap">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={gw.enabled === true}
onChange={e => setEnabled(e.target.checked)}
className="rounded"
/>
<span className="font-medium text-slate-700">Enable Gateway</span>
</label>
<StatusBadge status={statusQuery.data} />
</div>
{statusQuery.data?.errors && statusQuery.data.errors.length > 0 && (
<ul className="mt-2 text-xs text-red-700 bg-red-50 border border-red-200 rounded p-2 space-y-0.5">
{statusQuery.data.errors.map((e, i) => (
<li key={i}> {e}</li>
))}
</ul>
)}
</div>
<div className="border-t border-hairline pt-3">
<FieldLabel>Listen port</FieldLabel>
<div className="grid grid-cols-2 gap-3">
<div>
<FieldInput
type="number"
value={numberValue(gw.listenPort, 4000)}
onChange={v => setListenPort(parseNumberInput(v))}
/>
<HelpText>
<strong> process 使</strong>: worker UI (
{statusQuery.data?.sharedPort ?? '9876'}) <code>AAO_MODE=gateway</code> process
</HelpText>
</div>
<div className="text-xs text-slate-500 pt-1.5">
process deploy:{' '}
<code className="text-2xs">AAO_MODE=gateway scripts/gateway.sh start</code>
</div>
</div>
</div>
<div className="border-t border-hairline pt-3">
<div className="flex items-center justify-between mb-1.5">
<h3 className="text-sm font-medium text-slate-700">Backends</h3>
<button
onClick={addBackend}
className="px-2.5 h-7 text-xs text-accent border border-accent rounded-md hover:bg-accent-soft"
>
+ Add backend
</button>
</div>
<HelpText>
llama-server / Ollama / vLLM Gateway <code>request.model</code> <code>model</code> busy backend <br/>
<strong>api_key </strong>: <code>config.yaml</code> <code>${'${VAR}'}</code> env var literal env <code>config.yaml</code>
</HelpText>
{backends.length === 0 ? (
<div className="text-xs text-slate-400 border border-dashed border-slate-200 rounded p-4 mt-2 text-center">
backend 1
</div>
) : (
<div className="space-y-2 mt-2">
{backends.map((b, i) => {
const errs = backendErrors.get(i) ?? [];
return (
<div
key={i}
className={`border rounded-md p-3 space-y-2 relative ${errs.length > 0 ? 'border-red-200 bg-red-50/30' : 'border-slate-200'}`}
>
<button
onClick={() => removeBackend(i)}
className="absolute top-1.5 right-2 text-slate-400 hover:text-red-500 text-lg leading-none"
title="この backend を削除"
>
&times;
</button>
<div className="grid grid-cols-2 gap-2.5">
<div>
<FieldLabel>id</FieldLabel>
<FieldInput value={b.id ?? ''} onChange={v => updateBackend(i, 'id', v)} placeholder="gpu-rtx-a" />
</div>
<div>
<FieldLabel>model</FieldLabel>
<FieldInput value={b.model ?? ''} onChange={v => updateBackend(i, 'model', v)} placeholder="qwen3:8b" />
</div>
<div className="col-span-2">
<FieldLabel>endpoint</FieldLabel>
<FieldInput value={b.endpoint ?? ''} onChange={v => updateBackend(i, 'endpoint', v)} placeholder="http://gpu-host:8080/v1" />
</div>
<div>
<FieldLabel>max_slots</FieldLabel>
<FieldInput
type="number"
value={numberValue(b.maxSlots, 1)}
onChange={v => updateBackend(i, 'maxSlots', parseNumberInput(v))}
placeholder="1"
/>
</div>
<div>
<FieldLabel>api_key ()</FieldLabel>
<FieldInput
type="password"
value={b.apiKey ?? ''}
onChange={v => updateBackend(i, 'apiKey', v || undefined)}
placeholder="sk-... or ${ENV_VAR}"
/>
{/* G2: warn when the operator saves a literal
${VAR} reference. The config writer stores
fields verbatim — env substitution happens at
load time, so saving the form turns the
reference into a literal "${VAR}" string and
the env var indirection is lost. */}
{typeof b.apiKey === 'string' && b.apiKey.trimStart().startsWith('${') && (
<p className="text-2xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1 mt-1">
env var reference detected: 保存すると <code>{b.apiKey}</code> config.yaml env env config.yaml
</p>
)}
</div>
</div>
{errs.length > 0 && (
<ul className="text-2xs text-red-600 list-disc pl-4 space-y-0.5">
{errs.map((e, ei) => <li key={ei}>{e}</li>)}
</ul>
)}
</div>
);
})}
</div>
)}
</div>
<div className="border-t border-hairline pt-3">
<div className="mb-1.5">
<h3 className="text-sm font-medium text-slate-700">Virtual Keys</h3>
</div>
<HelpText>
Gateway <code>sk-aao-*</code> bearer key rotaterevoke <br/>
<strong></strong>: Gateway Server Save &amp; Apply admin API (Save )
</HelpText>
<div className="mt-2">
<GatewayKeysSection />
</div>
</div>
<details className="border-t border-hairline pt-3 group">
<summary className="text-sm font-medium text-slate-700 cursor-pointer">
Advanced
</summary>
<div className="grid grid-cols-3 gap-3 mt-2">
<div>
<FieldLabel>request_timeout_sec</FieldLabel>
<FieldInput
type="number"
value={numberValue(gw.requestTimeoutSec, 600)}
onChange={v => setRequestTimeout(parseNumberInput(v))}
/>
<HelpText>chat budget (streaming )</HelpText>
</div>
<div>
<FieldLabel>upstream_timeout_sec</FieldLabel>
<FieldInput
type="number"
value={numberValue(gw.upstreamTimeoutSec, 30)}
onChange={v => setUpstreamTimeout(parseNumberInput(v))}
/>
<HelpText>1 chunk idle </HelpText>
</div>
<div>
<FieldLabel>shutdown_graceful_sec</FieldLabel>
<FieldInput
type="number"
value={numberValue(gw.shutdownGracefulSec, 30)}
onChange={v => setShutdownGraceful(parseNumberInput(v))}
/>
<HelpText>SIGTERM drain </HelpText>
</div>
</div>
<div className="mt-3 text-xs text-slate-500">
<p>
<strong>Hot reload:</strong> Save process gateway (backend / virtual_key bounce in-flight graceful drain )
</p>
</div>
</details>
</div>
);
}