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>
254 lines
8.2 KiB
TypeScript
254 lines
8.2 KiB
TypeScript
/**
|
|
* `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,
|
|
};
|
|
}
|