Étend ServerExposureSettings avec autoStart (défaut false, mock aligné sur le backend). useDeployment expose setAutoStart, qui persiste immédiatement le draft sans jamais appeler start() ni toucher le comportement de stop(), et ne met à jour l'état local qu'après confirmation de la sauvegarde (même piste de validation que start/save). DeploymentSettings ajoute la case à cocher sous le panneau Serveur, et des actions « Modifier le port »/« Réessayer » quand le statut échoue avec le code PORT_IN_USE stabilisé côté backend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
/**
|
|
* `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.
|
|
*
|
|
* This screen no longer shows a pairing code (#77). A code is not a property of
|
|
* a running server — it exists only when someone asks for one — so it lives in
|
|
* the Appareils surface next to the devices it authorises. What is left here is
|
|
* a signpost: starting the server from this screen and finding no way to pair is
|
|
* a dead end.
|
|
*/
|
|
|
|
import { useRef, 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: "Cet ordinateur uniquement",
|
|
description:
|
|
"Pour utiliser IdeA uniquement sur cet ordinateur. Les appareils distants ne peuvent pas se connecter.",
|
|
},
|
|
{
|
|
mode: "remoteProxyLocal",
|
|
title: "Accès distant, proxy sur cet ordinateur",
|
|
description:
|
|
"À utiliser quand le proxy HTTPS tourne sur la même machine qu'IdeA Desktop.",
|
|
},
|
|
{
|
|
mode: "remoteProxyOtherMachine",
|
|
title: "Accès distant, proxy sur une autre machine",
|
|
description:
|
|
"À utiliser quand le proxy HTTPS tourne sur une autre machine. IdeA n'acceptera que le trafic provenant de ce proxy.",
|
|
},
|
|
];
|
|
|
|
const STATE_LABEL: Record<string, string> = {
|
|
stopped: "Arrêté",
|
|
starting: "Démarrage…",
|
|
running: "En cours d'exécution",
|
|
stopping: "Arrêt…",
|
|
failed: "Échec",
|
|
};
|
|
|
|
/** 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 ? "Copié" : "Copier"}
|
|
</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();
|
|
// "Modifier le port" (#89) jumps into Accès réseau — imperative focus is the
|
|
// simplest correct answer for a cross-panel affordance on one scrolled page.
|
|
const portInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
if (!vm.ready || !vm.settings) {
|
|
return (
|
|
<Panel aria-label="deployment settings" title="Déploiement">
|
|
<p className="text-sm text-muted">Chargement…</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="Serveur"
|
|
actions={
|
|
running || status.state === "stopping" ? (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void vm.stop()}
|
|
disabled={vm.busy || transitioning}
|
|
>
|
|
Arrêter
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void vm.start()}
|
|
disabled={vm.busy || transitioning}
|
|
>
|
|
Démarrer
|
|
</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">
|
|
URL locale : <code className="font-mono text-content">{status.localUrl}</code>
|
|
</p>
|
|
)}
|
|
{status.publicUrl && (
|
|
<p className="text-xs text-muted">
|
|
URL publique : <code className="font-mono text-content">{status.publicUrl}</code>
|
|
</p>
|
|
)}
|
|
|
|
{/* A failure carries the backend's message: it is the correction.
|
|
A port conflict (#89 — most often surfaced by auto-start at
|
|
launch, but shown the same way for a manual start) gets a
|
|
dedicated, actionable message instead of the generic one. */}
|
|
{status.state === "failed" && status.error && (
|
|
status.error.code === "PORT_IN_USE" ? (
|
|
<div className="flex flex-col gap-2">
|
|
<p role="alert" className="text-sm text-danger">
|
|
Le serveur n'a pas démarré automatiquement. {status.error.message}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
portInputRef.current?.scrollIntoView?.({
|
|
behavior: "smooth",
|
|
block: "center",
|
|
});
|
|
portInputRef.current?.focus();
|
|
}}
|
|
>
|
|
Modifier le port
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void vm.start()}
|
|
disabled={vm.busy}
|
|
>
|
|
Réessayer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<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>
|
|
)}
|
|
|
|
{/* Auto-start at launch (#89) — a persistence-only toggle, never a
|
|
start trigger; it never runs the server right now. */}
|
|
<label className="flex cursor-pointer items-start gap-2 pt-2">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-0.5"
|
|
aria-label="Démarrer le serveur au lancement d'IdeA"
|
|
checked={settings.autoStart}
|
|
disabled={vm.autoStartBusy}
|
|
onChange={(e) => void vm.setAutoStart(e.target.checked)}
|
|
/>
|
|
<span className="flex flex-col gap-0.5">
|
|
<span className="text-sm text-content">
|
|
Démarrer le serveur au lancement d'IdeA
|
|
</span>
|
|
<span className="text-xs text-muted">
|
|
IdeA utilisera les réglages réseau enregistrés ci-dessous au
|
|
prochain démarrage de l'application desktop.
|
|
</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</Panel>
|
|
|
|
{/* ── Exposure (ticket #78 — "Accès réseau" per UX) ────────────────── */}
|
|
<Panel title="Accès réseau">
|
|
<div className="flex flex-col gap-4">
|
|
<div
|
|
role="radiogroup"
|
|
aria-label="mode d'accès réseau"
|
|
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
|
|
ref={portInputRef}
|
|
id={id}
|
|
type="number"
|
|
value={String(settings.port)}
|
|
onChange={(e) => vm.setPort(Number(e.target.value))}
|
|
/>
|
|
)}
|
|
</Field>
|
|
|
|
{remote && (
|
|
<Field label="Origine publique" hint="Exemple : 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="Adresse LAN d'écoute"
|
|
hint="L'adresse de cette machine à laquelle le proxy se connectera."
|
|
>
|
|
{({ 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="">Sélectionner une adresse…</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="IP/CIDR du proxy autorisé"
|
|
hint="Ce n'est pas l'adresse d'écoute d'IdeA. C'est la machine autorisée à contacter 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">
|
|
Si votre proxy n'est pas sur cet ordinateur, choisissez ce mode.
|
|
Sinon, le proxy peut expirer sans erreur IdeA.
|
|
</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="Configuration du proxy">
|
|
<div className="flex flex-col gap-2">
|
|
<p className="text-xs text-muted">
|
|
Faites pointer votre proxy inverse HTTPS vers cet upstream.
|
|
</p>
|
|
{vm.upstreamUrl ? (
|
|
<ReadOnlyValue value={vm.upstreamUrl} copyLabel="copier l'URL upstream" />
|
|
) : (
|
|
<p className="text-xs text-faint">
|
|
Complétez les paramètres ci-dessus pour obtenir l'URL upstream.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
)}
|
|
|
|
{/* ── Pairing moved to its own surface (#77) — leave a signpost ─────── */}
|
|
<Panel title="Appairage">
|
|
<p className="text-sm text-muted">
|
|
L'appairage est géré dans{" "}
|
|
<span className="text-content">Paramètres → Appareils</span>, où
|
|
vous pouvez générer un code et révoquer des appareils.
|
|
</p>
|
|
</Panel>
|
|
</div>
|
|
);
|
|
}
|