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:
355
frontend/src/features/settings/DeploymentSettings.tsx
Normal file
355
frontend/src/features/settings/DeploymentSettings.tsx
Normal file
@ -0,0 +1,355 @@
|
||||
/**
|
||||
* `Settings → Deployment` (ticket #68) — turn IdeA Desktop into a server other
|
||||
* devices can reach, without a command line.
|
||||
*
|
||||
* Pure presentation over {@link useDeployment}; no `invoke()`, and no address is
|
||||
* ever derived here — LAN candidates and the upstream URL come from the backend
|
||||
* preview (`DesktopServerGateway`).
|
||||
*
|
||||
* Two UX invariants this screen exists to protect:
|
||||
*
|
||||
* - **The exposure mode is a decision, not a setting.** It is rendered as radio
|
||||
* *cards* with plain-language consequences, never a technical select, because
|
||||
* choosing wrong (proxy elsewhere, mode "on this computer") fails as a silent
|
||||
* proxy timeout with no IdeA error to read.
|
||||
* - **The authorized-proxy field is not a listen address.** That confusion is
|
||||
* the whole reason this screen is worded the way it is; the help text under
|
||||
* the field says so explicitly.
|
||||
*
|
||||
* The pairing code is runtime-only: shown while running, never rendered into a
|
||||
* persisted field, never mixed with the upstream value.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { ServerExposureMode } from "@/domain";
|
||||
import { Button, Field, Input, Panel, cn } from "@/shared";
|
||||
import { useDeployment } from "./useDeployment";
|
||||
|
||||
interface ModeOption {
|
||||
mode: ServerExposureMode;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** The three exposure choices, in increasing order of reach. */
|
||||
const MODE_OPTIONS: ModeOption[] = [
|
||||
{
|
||||
mode: "localOnly",
|
||||
title: "This computer only",
|
||||
description:
|
||||
"For using IdeA on this desktop only. Remote devices cannot connect.",
|
||||
},
|
||||
{
|
||||
mode: "remoteProxyLocal",
|
||||
title: "Remote access, proxy on this computer",
|
||||
description:
|
||||
"Use this when your HTTPS proxy runs on the same machine as IdeA Desktop.",
|
||||
},
|
||||
{
|
||||
mode: "remoteProxyOtherMachine",
|
||||
title: "Remote access, proxy on another machine",
|
||||
description:
|
||||
"Use this when the HTTPS proxy runs on another machine. IdeA will only accept traffic from that proxy.",
|
||||
},
|
||||
];
|
||||
|
||||
const STATE_LABEL: Record<string, string> = {
|
||||
stopped: "Stopped",
|
||||
starting: "Starting…",
|
||||
running: "Running",
|
||||
stopping: "Stopping…",
|
||||
failed: "Failed",
|
||||
};
|
||||
|
||||
/** Copy-to-clipboard button; degrades to disabled where the API is absent. */
|
||||
function CopyButton({ value, label }: { value: string; label: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const supported =
|
||||
typeof navigator !== "undefined" && Boolean(navigator.clipboard);
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={label}
|
||||
disabled={!supported}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(value).then(
|
||||
() => {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
},
|
||||
() => setCopied(false),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A read-only value the user is meant to copy elsewhere (never editable). */
|
||||
function ReadOnlyValue({ value, copyLabel }: { value: string; copyLabel: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 truncate rounded-md border border-border bg-raised px-2 py-1.5 font-mono text-xs text-content">
|
||||
{value}
|
||||
</code>
|
||||
<CopyButton value={value} label={copyLabel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeploymentSettings() {
|
||||
const vm = useDeployment();
|
||||
|
||||
if (!vm.ready || !vm.settings) {
|
||||
return (
|
||||
<Panel aria-label="deployment settings" title="Deployment">
|
||||
<p className="text-sm text-muted">Loading…</p>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
const { settings, status } = vm;
|
||||
const running = status.state === "running";
|
||||
const remote = settings.mode !== "localOnly";
|
||||
const otherMachine = settings.mode === "remoteProxyOtherMachine";
|
||||
const transitioning = status.state === "starting" || status.state === "stopping";
|
||||
|
||||
return (
|
||||
<div aria-label="deployment settings" className="flex flex-col gap-4">
|
||||
{/* ── Status ────────────────────────────────────────────────────────── */}
|
||||
<Panel
|
||||
title="Server"
|
||||
actions={
|
||||
running || status.state === "stopping" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void vm.stop()}
|
||||
disabled={vm.busy || transitioning}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void vm.start()}
|
||||
disabled={vm.busy || transitioning}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-2 rounded-full",
|
||||
running && "bg-success",
|
||||
status.state === "failed" && "bg-danger",
|
||||
(status.state === "stopped" || transitioning) && "bg-faint",
|
||||
)}
|
||||
/>
|
||||
<span className="text-content">{STATE_LABEL[status.state]}</span>
|
||||
</p>
|
||||
|
||||
{status.localUrl && (
|
||||
<p className="text-xs text-muted">
|
||||
Local URL: <code className="font-mono text-content">{status.localUrl}</code>
|
||||
</p>
|
||||
)}
|
||||
{status.publicUrl && (
|
||||
<p className="text-xs text-muted">
|
||||
Public URL: <code className="font-mono text-content">{status.publicUrl}</code>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* A failure carries the backend's message: it is the correction. */}
|
||||
{status.state === "failed" && status.error && (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{status.error.message}
|
||||
</p>
|
||||
)}
|
||||
{vm.actionError && (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{vm.actionError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* ── Exposure ──────────────────────────────────────────────────────── */}
|
||||
<Panel title="Exposure">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="exposure mode"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{MODE_OPTIONS.map((option) => {
|
||||
const selected = settings.mode === option.mode;
|
||||
return (
|
||||
<label
|
||||
key={option.mode}
|
||||
className={cn(
|
||||
"flex cursor-pointer gap-3 rounded-lg border p-3 transition-colors",
|
||||
selected
|
||||
? "border-border-strong bg-raised"
|
||||
: "border-border hover:bg-raised",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="exposure-mode"
|
||||
className="mt-1"
|
||||
checked={selected}
|
||||
onChange={() => vm.setMode(option.mode)}
|
||||
/>
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{option.title}
|
||||
</span>
|
||||
<span className="text-xs text-muted">{option.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Field label="Port">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
value={String(settings.port)}
|
||||
onChange={(e) => vm.setPort(Number(e.target.value))}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{remote && (
|
||||
<Field label="Public origin" hint="Example: https://idea.example.com">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
placeholder="https://idea.example.com"
|
||||
value={settings.publicOrigin ?? ""}
|
||||
onChange={(e) => vm.setPublicOrigin(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{otherMachine && (
|
||||
<>
|
||||
{/* Addresses come from the backend probe — never invented here. */}
|
||||
<Field
|
||||
label="LAN address to bind"
|
||||
hint="The address on this machine that the proxy will connect to."
|
||||
>
|
||||
{({ id, describedBy }) =>
|
||||
vm.candidateLanAddresses.length > 0 ? (
|
||||
<select
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
className="rounded-md border border-border bg-surface px-2 py-1.5 text-sm text-content"
|
||||
value={settings.lanBindAddress ?? ""}
|
||||
onChange={(e) => vm.setLanBindAddress(e.target.value)}
|
||||
>
|
||||
<option value="">Select an address…</option>
|
||||
{vm.candidateLanAddresses.map((address) => (
|
||||
<option key={address} value={address}>
|
||||
{address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
placeholder="192.168.1.42"
|
||||
value={settings.lanBindAddress ?? ""}
|
||||
onChange={(e) => vm.setLanBindAddress(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Authorized proxy IP/CIDR"
|
||||
hint="This is not where IdeA listens. It is the machine allowed to contact IdeA."
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
placeholder="203.0.113.7 or 203.0.113.0/24"
|
||||
value={settings.trustedProxies.join(", ")}
|
||||
onChange={(e) => vm.setTrustedProxies(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
If your proxy is not on this computer, choose this mode.
|
||||
Otherwise the proxy may time out without an IdeA error.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Warnings inform; they never block a start. */}
|
||||
{vm.warnings.map((warning) => (
|
||||
<p key={warning.code} className="text-xs text-warning">
|
||||
{warning.message}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{vm.validationError && (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{vm.validationError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* ── Proxy setup: the upstream to paste, backend-provided ──────────── */}
|
||||
{remote && (
|
||||
<Panel title="Proxy setup">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-muted">
|
||||
Point your HTTPS reverse proxy at this upstream.
|
||||
</p>
|
||||
{vm.upstreamUrl ? (
|
||||
<ReadOnlyValue value={vm.upstreamUrl} copyLabel="copy upstream url" />
|
||||
) : (
|
||||
<p className="text-xs text-faint">
|
||||
Complete the settings above to get the upstream URL.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* ── Pairing: runtime-only secret, isolated from the upstream ──────── */}
|
||||
<Panel title="Pairing">
|
||||
{running && status.pairingCode ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<ReadOnlyValue value={status.pairingCode} copyLabel="copy pairing code" />
|
||||
<p className="text-xs text-muted">
|
||||
Temporary code. It disappears when the server stops. Do not save it
|
||||
in configuration files.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted">
|
||||
Start the server to generate a pairing code.
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user