This commit is contained in:
Jonas Bengtsson
2026-09-19 18:08:02 +02:00
parent a16a01b1e7
commit 680ad7a624
14 changed files with 483 additions and 14 deletions
+27 -1
View File
@@ -1,11 +1,14 @@
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "./auth";
import { api, type SystemStatus } from "./api";
import { LoginPage } from "./pages/Login";
import { ClientsPage } from "./pages/Clients";
import { RolesPage } from "./pages/Roles";
import { GroupsPage } from "./pages/Groups";
import { BrokerConfigPage } from "./pages/BrokerConfig";
import { CertificatesPage } from "./pages/Certificates";
import { SystemPage } from "./pages/System";
// Navigation structure per spec section 57. Entries with a `to` route are
// implemented; entries without one are planned pages, rendered greyed out
@@ -59,17 +62,32 @@ const NAV_SECTIONS: {
{ label: "Roles", hint: "Management roles admin (Release 3)" },
{ label: "Audit Log", hint: "Audit viewer (Release 3)" },
{ label: "Backup", hint: "Backup/restore (Release 3)" },
{ label: "System", hint: "System settings (Release 3)" },
{ label: "System", to: "/system" },
],
},
];
function RestartIndicator() {
const { data } = useQuery({
queryKey: ["system-status"],
queryFn: () => api.get<SystemStatus>("/system/status"),
refetchInterval: 15000,
});
if (!data?.restart_required) return null;
return (
<NavLink to="/system" className="restart-needed">
⚠ Restart needed — listener changes are pending
</NavLink>
);
}
function Layout({ children }: { children: React.ReactNode }) {
const { session, logout } = useAuth();
return (
<div className="layout">
<aside className="sidebar">
<h1>MQTT Management</h1>
<RestartIndicator />
<nav>
{NAV_SECTIONS.map((section) => (
<div key={section.title} className="nav-section">
@@ -158,6 +176,14 @@ export function App() {
</Protected>
}
/>
<Route
path="/system"
element={
<Protected>
<SystemPage />
</Protected>
}
/>
<Route path="/" element={<Navigate to="/clients" replace />} />
</Routes>
);
+5
View File
@@ -243,4 +243,9 @@ export interface Revision {
deployed: boolean;
deployed_at: string | null;
created_at: string;
}
export interface SystemStatus {
broker_alive: boolean;
restart_required: boolean;
}
+20 -5
View File
@@ -84,13 +84,19 @@ export function BrokerConfigPage() {
});
const deploy = useMutation({
mutationFn: (comment: string | null) =>
api.post<{ status: string; revision: number; broker_healthy: boolean }>(
"/broker/config/deploy",
{ comment },
),
api.post<{
status: string;
revision: number;
broker_healthy: boolean;
restart_required: boolean;
}>("/broker/config/deploy", { comment }),
onSuccess: () => {
refresh();
queryClient.invalidateQueries({ queryKey: ["revisions"] });
// The glowing restart-needed indicator polls on its own cycle;
// invalidate its query so a listener-changing deploy shows it
// IMMEDIATELY instead of up to 15s later.
queryClient.invalidateQueries({ queryKey: ["system-status"] });
},
});
@@ -223,9 +229,18 @@ export function BrokerConfigPage() {
{deploy.isPending ? "Deploying…" : "Validate & deploy configuration"}
</button>
{deploy.data && (
<div className="banner ok">
<div className={`banner ${deploy.data.restart_required ? "warn" : "ok"}`}>
Deployed revision {deploy.data.revision}. Broker health:{" "}
{deploy.data.broker_healthy ? "OK" : "unknown — check logs"}
{deploy.data.restart_required && (
<>
{" "}
<strong>
⚠ Restart required — listener changes are pending. Go to
Administration → System to restart the broker.
</strong>
</>
)}
</div>
)}
{deploy.error && (
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type SystemStatus } from "../api";
export function SystemPage() {
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [banner, setBanner] = useState<string | null>(null);
const status = useQuery({
queryKey: ["system-status"],
queryFn: () => api.get<SystemStatus>("/system/status"),
// Poll while a restart is in flight so "broker down" resolves by itself.
refetchInterval: (query) =>
query.state.data && !query.state.data.broker_alive ? 3000 : 15000,
});
const restart = useMutation({
mutationFn: () => api.post<{ status: string }>("/system/broker/restart"),
onSuccess: () => {
setBanner("Restart signal sent — the broker stops and Docker brings it back.");
setError(null);
// Force an immediate re-poll; the alive flag flips within seconds.
queryClient.invalidateQueries({ queryKey: ["system-status"] });
},
onError: (e: Error) => setError(e.message),
});
const data = status.data;
const restarting = data !== undefined && !data.broker_alive;
// Clear the transient banner once the broker is back.
useEffect(() => {
if (data?.broker_alive) setBanner(null);
}, [data?.broker_alive]);
return (
<div>
<h2>System</h2>
<p>
Broker lifecycle operations through the control sidecar (spec section
29). The restart stops Mosquitto gracefully — persistent sessions and
queued messages are saved — and Docker's restart policy brings the
container back within seconds.
</p>
{error && <div className="banner error">{error}</div>}
{banner && !error && <div className="banner ok">{banner}</div>}
{status.error && (
<div className="banner error">
Failed to load system status: {(status.error as Error).message}
</div>
)}
{data?.restart_required && (
<div className="banner warn">
<strong>Restart needed:</strong> the deployed configuration contains
listener changes that a config reload cannot apply (spec section 28).
Restart the broker below to activate them.
</div>
)}
<table>
<thead>
<tr>
<th>Service</th>
<th>State</th>
</tr>
</thead>
<tbody>
<tr>
<td>Mosquitto broker</td>
<td>
{restarting ? (
<span style={{ color: "#8a5a00" }}>
restarting… (polling until it is back)
</span>
) : data?.broker_alive ? (
<span style={{ color: "#1e6b3a" }}>running</span>
) : (
<span style={{ color: "#a4342d" }}>unreachable</span>
)}
</td>
</tr>
<tr>
<td>Listener changes pending a restart</td>
<td>{data?.restart_required ? "yes" : "no"}</td>
</tr>
</tbody>
</table>
<p />
<button
className="primary"
disabled={restart.isPending || restarting}
onClick={() => {
if (
confirm(
"Restart the Mosquitto broker?\n\n" +
"All connected devices are disconnected (they can reconnect " +
"once it is back). Persistent sessions and queued messages are " +
"saved. The broker is unavailable for a few seconds.",
)
)
restart.mutate();
}}
>
{restart.isPending
? "Sending restart signal…"
: restarting
? "Broker is restarting…"
: "Restart broker"}
</button>
</div>
);
}
+27
View File
@@ -73,6 +73,33 @@ h3[id] {
scroll-margin-top: 1rem;
}
/* --- Restart-needed indicator (sidebar, below the header) ------------- */
.restart-needed {
display: block;
padding: 0.45rem 0.6rem;
margin: 0.2rem 0 0.8rem;
border-radius: 6px;
color: #ffd9a0;
background: #6b4d00;
border: 1px solid #b8860b;
text-decoration: none;
font-size: 0.8rem;
text-align: center;
animation: restart-glow 1.8s ease-in-out infinite;
}
@keyframes restart-glow {
0%,
100% {
box-shadow: 0 0 4px rgba(255, 200, 80, 0.25);
background: #6b4d00;
}
50% {
box-shadow: 0 0 14px rgba(255, 200, 80, 0.85);
background: #8a6404;
}
}
.sidebar nav a.active,
.sidebar nav a:hover {
background: #323f4b;