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:
2026-07-16 23:43:37 +02:00
parent 49e1d5a157
commit 7efa634f23
16 changed files with 1458 additions and 31 deletions

View 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,
};
}