🚧 WIP
This commit is contained in:
+27
-1
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user