feat(frontend): panneau Settings Deployment et gateway serveur desktop (#68 F1+F2)
Donne à l'utilisateur la surface pour activer le serveur depuis l'app. - `features/settings/` : `SettingsView`, `DeploymentSettings`, `useDeployment`. - `adapters/desktopServer.ts` + port `DesktopServerGateway` et DTO associés ; adapters mock et http/unsupported alignés (le mode web n'expose pas le contrôle du serveur qui l'héberge). - `ProjectsView` : le `showSettings: boolean` devient une navigation interne `AI Profiles` / `Deployment`. Le libellé alternant « Close AI Profiles » disparaît — Settings existait déjà dans cette vue, la surface évolue au lieu d'ajouter un `PanelId`. CORRECTION D'UN BRIEF FAUX, remontée spontanément par DevFrontend et qui mérite de survivre : le cadrage décrivait le mode `remoteProxyOtherMachine` avec deux champs. `validate_settings` en exige un troisième, `lanBindAddress`, et rejette loopback comme unspecified. Construit selon la spec, chaque save et chaque start en mode 3 aurait échoué — le lot serait parti vert et cassé. Le panneau expose donc un select alimenté par `candidateLanAddresses` fourni par le backend : la règle « le frontend n'invente jamais une IP » tient. QA ré-exécutée par Git avant merge : `npm run typecheck` exit 0 · `npx vitest run` 87 files / 789 passed, 0 échec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
253
frontend/src/features/settings/useDeployment.ts
Normal file
253
frontend/src/features/settings/useDeployment.ts
Normal file
@ -0,0 +1,253 @@
|
||||
/**
|
||||
* `useDeployment` — view-model for `Settings → Deployment` (ticket #68).
|
||||
*
|
||||
* Owns the draft exposure config, the backend-derived preview, and the embedded
|
||||
* server lifecycle. All behaviour lives here so `DeploymentSettings` stays
|
||||
* presentation-only (no `invoke()`, no network reasoning in JSX).
|
||||
*
|
||||
* **The backend owns every network fact.** LAN candidates and the proxy upstream
|
||||
* URL are read from `previewExposure`; nothing here derives an address.
|
||||
*
|
||||
* Two backend behaviours shape this hook:
|
||||
*
|
||||
* 1. `preview_server_exposure_settings` *validates* before previewing, so an
|
||||
* incomplete draft is rejected rather than previewed. That makes it the
|
||||
* UI's validation authority (it returns the same message `start` would), but
|
||||
* it also means a `remoteProxyOtherMachine` draft cannot be previewed until
|
||||
* it already carries a LAN bind address — which is what the preview is for.
|
||||
* So the LAN candidate list is fetched with a separate always-valid
|
||||
* `localOnly` probe; the backend builds that list independently of the mode.
|
||||
* 2. Only `start` reports a runtime failure, so a rejected save/start surfaces
|
||||
* the backend message verbatim — it is the concrete correction to apply.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type {
|
||||
DiagnosticWarning,
|
||||
EmbeddedServerStatus,
|
||||
GatewayError,
|
||||
ServerExposureMode,
|
||||
ServerExposureSettings,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
/** Debounce before previewing a draft, so typing doesn't spam the backend. */
|
||||
const PREVIEW_DEBOUNCE_MS = 250;
|
||||
|
||||
function messageOf(e: unknown): string {
|
||||
return e && typeof e === "object" && "message" in e
|
||||
? String((e as GatewayError).message)
|
||||
: String(e);
|
||||
}
|
||||
|
||||
export interface DeploymentVm {
|
||||
/** False until the persisted settings have loaded. */
|
||||
ready: boolean;
|
||||
/** The draft config being edited (persisted config until the user edits). */
|
||||
settings: ServerExposureSettings | null;
|
||||
/** Current server status. */
|
||||
status: EmbeddedServerStatus;
|
||||
/** LAN addresses discovered by the backend (never derived client-side). */
|
||||
candidateLanAddresses: string[];
|
||||
/** Backend-built upstream URL for the current draft; absent when invalid. */
|
||||
upstreamUrl?: string;
|
||||
/** Non-fatal diagnostics for the draft — informational, never blocking. */
|
||||
warnings: DiagnosticWarning[];
|
||||
/** Why the draft is rejected, as told by the backend. Actionable, not decorative. */
|
||||
validationError: string | null;
|
||||
/** Last save/start/stop failure. */
|
||||
actionError: string | null;
|
||||
/** True while a start/stop is in flight. */
|
||||
busy: boolean;
|
||||
setMode: (mode: ServerExposureMode) => void;
|
||||
setPublicOrigin: (origin: string) => void;
|
||||
setLanBindAddress: (address: string) => void;
|
||||
setTrustedProxies: (raw: string) => void;
|
||||
setPort: (port: number) => void;
|
||||
/** Persists the draft, then starts the server so what runs is what is shown. */
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useDeployment(): DeploymentVm {
|
||||
const { desktopServer } = useGateways();
|
||||
const [settings, setSettings] = useState<ServerExposureSettings | null>(null);
|
||||
const [status, setStatus] = useState<EmbeddedServerStatus>({
|
||||
state: "stopped",
|
||||
});
|
||||
const [candidateLanAddresses, setCandidates] = useState<string[]>([]);
|
||||
const [upstreamUrl, setUpstreamUrl] = useState<string | undefined>();
|
||||
const [warnings, setWarnings] = useState<DiagnosticWarning[]>([]);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Guards against a stale in-flight preview overwriting a newer one.
|
||||
const previewSeq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const [persisted, current] = await Promise.all([
|
||||
desktopServer.getExposureSettings(),
|
||||
desktopServer.status(),
|
||||
]);
|
||||
if (!alive) return;
|
||||
setSettings(persisted);
|
||||
setStatus(current);
|
||||
} catch (e) {
|
||||
if (alive) setActionError(messageOf(e));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [desktopServer]);
|
||||
|
||||
// LAN candidates via an always-valid `localOnly` probe (see the header note).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const preview = await desktopServer.previewExposure({
|
||||
mode: "localOnly",
|
||||
port: 0,
|
||||
trustedProxies: [],
|
||||
});
|
||||
if (alive) setCandidates(preview.candidateLanAddresses);
|
||||
} catch {
|
||||
// No candidates ⇒ the LAN field falls back to free text; the backend
|
||||
// still rejects a bad address on save.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [desktopServer]);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
void desktopServer.onStatusChanged(setStatus).then((u) => {
|
||||
// The effect may have torn down while the subscription was resolving.
|
||||
if (cancelled) u();
|
||||
else unsubscribe = u;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [desktopServer]);
|
||||
|
||||
// Preview (and thereby validate) the draft, debounced.
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
const seq = ++previewSeq.current;
|
||||
const timer = setTimeout(() => {
|
||||
void desktopServer.previewExposure(settings).then(
|
||||
(preview) => {
|
||||
if (seq !== previewSeq.current) return;
|
||||
setUpstreamUrl(preview.upstreamUrl);
|
||||
setWarnings(preview.warnings);
|
||||
setValidationError(null);
|
||||
},
|
||||
(e) => {
|
||||
if (seq !== previewSeq.current) return;
|
||||
// The draft is incomplete/invalid: the backend message *is* the fix.
|
||||
setUpstreamUrl(undefined);
|
||||
setWarnings([]);
|
||||
setValidationError(messageOf(e));
|
||||
},
|
||||
);
|
||||
}, PREVIEW_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [desktopServer, settings]);
|
||||
|
||||
const patch = useCallback((change: Partial<ServerExposureSettings>) => {
|
||||
setActionError(null);
|
||||
setSettings((prev) => (prev ? { ...prev, ...change } : prev));
|
||||
}, []);
|
||||
|
||||
const setMode = useCallback(
|
||||
(mode: ServerExposureMode) => {
|
||||
// Dropping to a narrower mode clears the fields that mode does not use, so
|
||||
// a stale origin/proxy can never be persisted behind the user's back.
|
||||
if (mode === "localOnly") {
|
||||
patch({ mode, publicOrigin: undefined, lanBindAddress: undefined, trustedProxies: [] });
|
||||
} else if (mode === "remoteProxyLocal") {
|
||||
patch({ mode, lanBindAddress: undefined, trustedProxies: [] });
|
||||
} else {
|
||||
patch({ mode });
|
||||
}
|
||||
},
|
||||
[patch],
|
||||
);
|
||||
|
||||
const setPublicOrigin = useCallback(
|
||||
(origin: string) => patch({ publicOrigin: origin.trim() || undefined }),
|
||||
[patch],
|
||||
);
|
||||
const setLanBindAddress = useCallback(
|
||||
(address: string) => patch({ lanBindAddress: address || undefined }),
|
||||
[patch],
|
||||
);
|
||||
const setTrustedProxies = useCallback(
|
||||
(raw: string) =>
|
||||
patch({
|
||||
trustedProxies: raw
|
||||
.split(/[\s,]+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
}),
|
||||
[patch],
|
||||
);
|
||||
const setPort = useCallback((port: number) => patch({ port }), [patch]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!settings) return;
|
||||
setBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
// `start` runs the *persisted* config, so persist the draft first —
|
||||
// otherwise the server would run something other than what is on screen.
|
||||
await desktopServer.saveExposureSettings(settings);
|
||||
setStatus(await desktopServer.start());
|
||||
} catch (e) {
|
||||
setActionError(messageOf(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [desktopServer, settings]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
setStatus(await desktopServer.stop());
|
||||
} catch (e) {
|
||||
setActionError(messageOf(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [desktopServer]);
|
||||
|
||||
return {
|
||||
ready: settings !== null,
|
||||
settings,
|
||||
status,
|
||||
candidateLanAddresses,
|
||||
upstreamUrl,
|
||||
warnings,
|
||||
validationError,
|
||||
actionError,
|
||||
busy,
|
||||
setMode,
|
||||
setPublicOrigin,
|
||||
setLanBindAddress,
|
||||
setTrustedProxies,
|
||||
setPort,
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user