This commit is contained in:
Jonas Bengtsson
2026-09-19 09:40:24 +02:00
parent ee4800993d
commit 19bccce083
13 changed files with 435 additions and 43 deletions
+301 -27
View File
@@ -1,4 +1,5 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { FormEvent, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type Listener, type Revision } from "../api";
interface ConfigPreview {
@@ -6,22 +7,80 @@ interface ConfigPreview {
warnings: string[];
}
interface BrokerSetting {
key: string;
value: string;
value_type: "string" | "int" | "bool";
}
const EMPTY_LISTENER: Omit<Listener, "id"> = {
name: "",
enabled: true,
port: 1883,
bind_address: null,
protocol: "mqtt",
allow_anonymous: false,
max_connections: -1,
tls_enabled: false,
tls_min_version: null,
require_certificate: false,
use_identity_as_username: false,
};
// Curated subset of spec section 31; anything else can be added later
// through the same settings endpoint (advanced editor is a later release).
const COMMON_SETTINGS: { key: string; label: string; placeholder: string; hint: string }[] = [
{ key: "max_packet_size", label: "max_packet_size", placeholder: "2000000", hint: "bytes; 2.1 default is 2 MB" },
{ key: "autosave_interval", label: "autosave_interval", placeholder: "900", hint: "seconds between persistence saves" },
{ key: "sys_interval", label: "sys_interval", placeholder: "10", hint: "$SYS update interval (s)" },
{ key: "message_size_limit", label: "message_size_limit", placeholder: "0", hint: "max payload bytes; 0 = unlimited" },
{ key: "max_queued_messages", label: "max_queued_messages", placeholder: "1000", hint: "per-client queue for offline clients" },
{ key: "global_max_clients", label: "global_max_clients", placeholder: "-1", hint: "total sessions; -1 = unlimited" },
];
export function BrokerConfigPage() {
const queryClient = useQueryClient();
const [editingId, setEditingId] = useState<string | null>(null);
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["listeners"] });
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["config-preview"] });
};
const listeners = useQuery({
queryKey: ["listeners"],
queryFn: () => api.get<Listener[]>("/broker/listeners"),
});
const settings = useQuery({
queryKey: ["settings"],
queryFn: () => api.get<BrokerSetting[]>("/broker/settings"),
});
const preview = useQuery({
queryKey: ["config-preview"],
queryFn: () => api.get<ConfigPreview>("/broker/config/preview"),
});
const revisions = useQuery({
queryKey: ["revisions"],
queryFn: () => api.get<Revision[]>("/broker/revisions"),
});
const addListener = useMutation({
mutationFn: (l: Omit<Listener, "id">) => api.post<Listener>("/broker/listeners", l),
onSuccess: refresh,
});
const updateListener = useMutation({
mutationFn: ({ id, ...l }: Listener) => api.put<Listener>(`/broker/listeners/${id}`, l),
onSuccess: refresh,
});
const removeListener = useMutation({
mutationFn: (id: string) => api.delete(`/broker/listeners/${id}`),
onSuccess: refresh,
});
const putSetting = useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) =>
api.put(`/broker/settings/${key}`, { key, value, value_type: "string" }),
onSuccess: refresh,
});
const deploy = useMutation({
mutationFn: (comment: string | null) =>
api.post<{ status: string; revision: number; broker_healthy: boolean }>(
@@ -29,8 +88,8 @@ export function BrokerConfigPage() {
{ comment },
),
onSuccess: () => {
preview.refetch();
revisions.refetch();
refresh();
queryClient.invalidateQueries({ queryKey: ["revisions"] });
},
});
@@ -38,28 +97,52 @@ export function BrokerConfigPage() {
<div>
<h2>Broker configuration</h2>
<p>
The configuration is generated from the platform model, validated with
<code> mosquitto --test-config</code>, deployed atomically, and every
The configuration is generated from the model below, validated with{" "}
<code>mosquitto --test-config</code>, deployed atomically, and every
deployment creates a revision.
</p>
{(listeners.error || preview.error || settings.error) && (
<div className="banner error">
{((listeners.error ?? preview.error ?? settings.error) as Error).message}
</div>
)}
<h3>Listeners</h3>
{listeners.isLoading ? (
<p>Loading…</p>
) : (
<table>
<thead>
<tr>
<th>Name</th>
<th>Port</th>
<th>Bind</th>
<th>Protocol</th>
<th>Anonymous</th>
<th>Enabled</th>
</tr>
</thead>
<tbody>
{(listeners.data ?? []).map((l) => (
<h3 style={{ marginTop: "1.5rem" }}>Add listener</h3>
<ListenerForm onSubmit={(l) => addListener.mutate(l)} pending={addListener.isPending} />
{addListener.error && (
<div className="banner error">{(addListener.error as Error).message}</div>
)}
<h3 style={{ marginTop: "2rem" }}>Listeners</h3>
<table>
<thead>
<tr>
<th>Name</th>
<th>Port</th>
<th>Bind</th>
<th>Protocol</th>
<th>Anonymous</th>
<th>Enabled</th>
<th></th>
</tr>
</thead>
<tbody>
{(listeners.data ?? []).map((l) =>
editingId === l.id ? (
<tr key={l.id}>
<td colSpan={7}>
<ListenerForm
initial={l}
pending={updateListener.isPending}
onCancel={() => setEditingId(null)}
onSubmit={(patch) => {
updateListener.mutate({ ...l, ...patch });
setEditingId(null);
}}
/>
</td>
</tr>
) : (
<tr key={l.id}>
<td>{l.name}</td>
<td className="mono">{l.port}</td>
@@ -67,12 +150,59 @@ export function BrokerConfigPage() {
<td>{l.protocol}</td>
<td>{l.allow_anonymous ? "yes" : "no"}</td>
<td>{l.enabled ? "yes" : "no"}</td>
<td>
<button onClick={() => setEditingId(l.id)}>Edit</button>{" "}
<button onClick={() => updateListener.mutate({ ...l, enabled: !l.enabled })}>
{l.enabled ? "Disable" : "Enable"}
</button>{" "}
<button
className="danger"
onClick={() => {
if (confirm(`Delete listener ${l.name}?`)) removeListener.mutate(l.id);
}}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
),
)}
</tbody>
</table>
{(listeners.data ?? []).length === 0 && !listeners.isLoading && (
<p style={{ color: "#52606d" }}>
No listeners yet. Add one above — after a deploy, the broker is only
reachable through the listeners defined here.
</p>
)}
<h3 style={{ marginTop: "2rem" }}>Global settings</h3>
<table>
<thead>
<tr>
<th>Setting</th>
<th>Value</th>
<th></th>
</tr>
</thead>
<tbody>
{COMMON_SETTINGS.map((s) => {
const existing = (settings.data ?? []).find((row) => row.key === s.key);
return (
<SettingsRow
key={s.key}
label={s.label}
hint={s.hint}
placeholder={s.placeholder}
initial={existing?.value}
pending={putSetting.isPending}
onSave={(value) => putSetting.mutate({ key: s.key, value })}
/>
);
})}
</tbody>
</table>
<h3 style={{ marginTop: "2rem" }}>Generated configuration</h3>
{preview.data?.warnings.map((w) => (
<div key={w} className="banner warn">
@@ -126,4 +256,148 @@ export function BrokerConfigPage() {
</table>
</div>
);
}
function ListenerForm({
initial,
onSubmit,
onCancel,
pending,
}: {
initial?: Listener;
onSubmit: (l: Omit<Listener, "id">) => void;
onCancel?: () => void;
pending: boolean;
}) {
const [draft, setDraft] = useState<Omit<Listener, "id">>(initial ?? EMPTY_LISTENER);
useEffect(() => {
setDraft(initial ?? EMPTY_LISTENER);
}, [initial]);
const set = (patch: Partial<Listener>) => setDraft((d) => ({ ...d, ...patch }));
const valid = draft.name.trim() !== "" && draft.port > 0 && draft.port <= 65535;
const submit = (e: FormEvent) => {
e.preventDefault();
if (valid) {
onSubmit(draft);
if (!initial) setDraft(EMPTY_LISTENER);
}
};
return (
<form className="stack" onSubmit={submit}>
{initial && (
<div className="banner warn">
Port or bind changes require a broker restart — a config reload
cannot modify existing listener definitions.
</div>
)}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "flex-end" }}>
<div>
<label>Name</label>
<input
value={draft.name}
onChange={(e) => set({ name: e.target.value })}
placeholder="Production MQTT"
/>
</div>
<div>
<label>Port</label>
<input
type="number"
value={draft.port}
onChange={(e) => set({ port: Number(e.target.value) })}
/>
</div>
<div>
<label>Protocol</label>
<select
value={draft.protocol}
onChange={(e) => set({ protocol: e.target.value as "mqtt" | "websockets" })}
>
<option value="mqtt">mqtt</option>
<option value="websockets">websockets</option>
</select>
</div>
<div>
<label>Bind address</label>
<input
value={draft.bind_address ?? ""}
onChange={(e) => set({ bind_address: e.target.value || null })}
placeholder="all interfaces"
/>
</div>
<div>
<label>Allow anonymous</label>
<select
value={draft.allow_anonymous ? "true" : "false"}
onChange={(e) => set({ allow_anonymous: e.target.value === "true" })}
>
<option value="false">false</option>
<option value="true">true</option>
</select>
</div>
<div>
<label>Max connections</label>
<input
type="number"
value={draft.max_connections}
onChange={(e) => set({ max_connections: Number(e.target.value) })}
/>
</div>
</div>
<p />
<button className="primary" disabled={!valid || pending}>
{pending ? (initial ? "Saving…" : "Adding…") : initial ? "Save changes" : "Add listener"}
</button>{" "}
{initial && onCancel && (
<button type="button" onClick={onCancel}>
Cancel
</button>
)}
</form>
);
}
function SettingsRow({
label,
hint,
placeholder,
initial,
pending,
onSave,
}: {
label: string;
hint: string;
placeholder: string;
initial?: string;
pending: boolean;
onSave: (value: string) => void;
}) {
const [value, setValue] = useState(initial ?? "");
useEffect(() => {
setValue(initial ?? "");
}, [initial]);
return (
<tr>
<td>
<span className="mono">{label}</span>
<div style={{ color: "#52606d", fontSize: "0.75rem" }}>{hint}</div>
</td>
<td>
<input
value={value}
placeholder={placeholder}
onChange={(e) => setValue(e.target.value)}
style={{ maxWidth: "220px" }}
/>
</td>
<td>
<button disabled={pending || value === (initial ?? "")} onClick={() => onSave(value)}>
Save
</button>
</td>
</tr>
);
}
+22
View File
@@ -1,19 +1,41 @@
import { FormEvent, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../auth";
// Only same-app targets are allowed: blocks open redirects such as
// "//evil.host" or "https://evil.host" via the ?next= query parameter.
function safeTarget(candidate: string | null): string {
return candidate && candidate.startsWith("/") && !candidate.startsWith("//")
? candidate
: "/clients";
}
export function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Where to go after sign-in. <Protected> passes the guarded route via
// router state (in-app navigation); the 401 handler in api.ts passes the
// current page as ?next= (full-page navigation). Direct /login visits
// default to /clients.
const from = safeTarget(
(location.state as { from?: string } | null)?.from ?? searchParams.get("next"),
);
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
await login(username, password);
// replace: Back must not return to the login screen after sign-in.
navigate(from, { replace: true });
} catch {
setError("Invalid username or password.");
} finally {