🚧 WIP
This commit is contained in:
@@ -152,12 +152,15 @@ Hardening done beyond the plan:
|
||||
- [x] Unpublished-port warning: listeners on ports not mapped by
|
||||
compose.yaml warn in the preview/deploy (reachable only inside
|
||||
the Docker network; keep PUBLISHED_PORTS in sync with compose)
|
||||
- [x] Restart-required detection + restart from the UI: deploy compares
|
||||
listener lines against the previous deployed revision (reload
|
||||
cannot apply listener changes, §28) and sets a pending-restart
|
||||
flag; a glowing sidebar indicator links to Administration →
|
||||
System, where the broker restarts via the control sidecar
|
||||
(SIGTERM to PID 1, Docker policy revives it; audited as
|
||||
BROKER_RESTARTED, §29/§38)
|
||||
- [ ] Configuration diff view (revision N-1 → N)
|
||||
- [ ] Configuration rollback (creates a new revision, §62)
|
||||
- [ ] Reload-vs-restart classification (§28) — the deploy health check
|
||||
passes on reload, but a NEW listener still needs an explicit
|
||||
broker restart (docker compose restart mosquitto); surfacing
|
||||
that in the UI is pending
|
||||
- [ ] CA import (existing external CA)
|
||||
- [ ] Root-key export + removal workflow (§18)
|
||||
- [ ] CSR signing (client-side key generation)
|
||||
@@ -170,9 +173,11 @@ Hardening done beyond the plan:
|
||||
|
||||
- [x] Audit logging (backend records every privileged action: LOGIN,
|
||||
LOGOUT, CLIENT_*, GROUP_*, ROLE_*, ACL_CHANGED, LISTENER_*,
|
||||
BROKER_CONFIG_DEPLOYED, USER_*, …)
|
||||
BROKER_CONFIG_DEPLOYED, BROKER_RESTARTED, USER_*, …)
|
||||
- [x] Client metadata/tags (owner / purpose / tags in PostgreSQL; API
|
||||
accepts them on create)
|
||||
- [x] Administration → System page: broker status + restart (the
|
||||
§28 restart gap is closed end-to-end from the UI)
|
||||
- [ ] Audit browsing UI (events are recorded; no viewer page yet)
|
||||
- [ ] Forced password-change UI (backend flag + API exist; the frontend
|
||||
does not enforce `must_change_password` yet)
|
||||
|
||||
@@ -17,6 +17,13 @@ from app.models import BrokerSettings, Certificate, ListenerConfig
|
||||
|
||||
_BOOL = {True: "true", False: "false"}
|
||||
|
||||
# BrokerSettings keys that are INTERNAL platform state, not broker
|
||||
# options — they must never be emitted into the generated mosquitto.conf
|
||||
# (a leaked key makes `mosquitto --test-config` fail with "Unknown
|
||||
# configuration variable").
|
||||
RESTART_REQUIRED_KEY = "restart_required"
|
||||
RESERVED_SETTINGS_KEYS = frozenset({RESTART_REQUIRED_KEY})
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerationResult:
|
||||
@@ -31,6 +38,10 @@ def _global_section(db: Session, warnings: list[str]) -> list[str]:
|
||||
value = setting.value.strip()
|
||||
if not key or key.startswith("#"):
|
||||
continue
|
||||
if key in RESERVED_SETTINGS_KEYS:
|
||||
# Internal platform state, not a broker option — emitting it
|
||||
# would fail validation (see RESERVED_SETTINGS_KEYS above).
|
||||
continue
|
||||
lines.append(f"{key} {value}")
|
||||
return lines
|
||||
|
||||
|
||||
+2
-1
@@ -125,10 +125,11 @@ def health_ready():
|
||||
|
||||
|
||||
# Routers
|
||||
from app.routers import auth, broker, mqtt, pki, users # noqa: E402
|
||||
from app.routers import auth, broker, mqtt, pki, system, users # noqa: E402
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(mqtt.router)
|
||||
app.include_router(broker.router)
|
||||
app.include_router(pki.router)
|
||||
app.include_router(system.router)
|
||||
@@ -20,7 +20,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app.config import get_settings
|
||||
from app.configgen import generate_config
|
||||
from app.configgen import RESTART_REQUIRED_KEY, generate_config
|
||||
from app.db import get_db
|
||||
from app.deps import CurrentUser, require_roles
|
||||
from app.models import (
|
||||
@@ -65,6 +65,51 @@ def _next_revision_number(db: Session) -> int:
|
||||
return (highest or 0) + 1
|
||||
|
||||
|
||||
# BrokerSettings key holding the pending-restart flag (value "true"/"false").
|
||||
# Defined in configgen (RESERVED_SETTINGS_KEYS keeps it out of the
|
||||
# generated config); the bootstrap config the broker starts with must
|
||||
# match the generator's explicit listener form for the comparison.
|
||||
_BOOTSTRAP_LISTENERS = {"listener 1883 0.0.0.0"}
|
||||
|
||||
|
||||
def _listener_signature(config_text: str) -> set[str]:
|
||||
"""The listener DEFINITION lines of a generated config.
|
||||
|
||||
Mosquitto's reload cannot add, remove or redefine listeners (spec
|
||||
section 28): textual changes to `listener <port> <bind>` lines are
|
||||
treated as different listeners and only take effect on restart.
|
||||
Everything else (cert paths, TLS versions, max_connections, ...) is
|
||||
reload-safe.
|
||||
"""
|
||||
import re
|
||||
|
||||
return set(re.findall(r"^listener \d+.*$", config_text, re.MULTILINE))
|
||||
|
||||
|
||||
def _set_restart_required(db: Session, required: bool) -> None:
|
||||
setting = db.scalar(
|
||||
select(BrokerSettings).where(BrokerSettings.key == RESTART_REQUIRED_KEY)
|
||||
)
|
||||
if setting is None:
|
||||
db.add(
|
||||
BrokerSettings(
|
||||
key=RESTART_REQUIRED_KEY,
|
||||
value="true" if required else "false",
|
||||
value_type="bool",
|
||||
)
|
||||
)
|
||||
else:
|
||||
setting.value = "true" if required else "false"
|
||||
|
||||
|
||||
def restart_required(db: Session) -> bool:
|
||||
"""Whether the deployed config has listener changes pending a restart."""
|
||||
setting = db.scalar(
|
||||
select(BrokerSettings.value).where(BrokerSettings.key == RESTART_REQUIRED_KEY)
|
||||
)
|
||||
return setting == "true"
|
||||
|
||||
|
||||
def _call_control(
|
||||
path: str, *, method: str = "POST", json_body: dict | None = None
|
||||
) -> dict:
|
||||
@@ -306,6 +351,23 @@ def deploy_config(
|
||||
if response.get("status") != "deployed":
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, response)
|
||||
|
||||
# Spec section 28: a reload cannot apply listener definition changes.
|
||||
# Compare the listener lines against the previously DEPLOYED revision
|
||||
# (or the bootstrap config when none exists yet) and flag a pending
|
||||
# restart so the UI can surface it (glowing indicator -> System page).
|
||||
previous = db.scalar(
|
||||
select(ConfigRevision)
|
||||
.where(ConfigRevision.deployed.is_(True))
|
||||
.order_by(ConfigRevision.revision_number.desc())
|
||||
)
|
||||
previous_listeners = (
|
||||
_listener_signature(previous.config_text)
|
||||
if previous is not None
|
||||
else set(_BOOTSTRAP_LISTENERS)
|
||||
)
|
||||
needs_restart = _listener_signature(result.config_text) != previous_listeners
|
||||
_set_restart_required(db, needs_restart)
|
||||
|
||||
revision = ConfigRevision(
|
||||
revision_number=_next_revision_number(db),
|
||||
created_by=user.username,
|
||||
@@ -329,6 +391,7 @@ def deploy_config(
|
||||
"status": "deployed",
|
||||
"revision": revision.revision_number,
|
||||
"broker_healthy": response.get("broker_healthy"),
|
||||
"restart_required": needs_restart,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""System administration (spec sections 29, 41, 46).
|
||||
|
||||
Broker status and restart through the control sidecar. The restart
|
||||
primitive is SIGTERM to PID 1 in the broker's (shared) PID namespace —
|
||||
the broker shuts down gracefully (persistence saved) and Docker's
|
||||
`restart: unless-stopped` policy brings the container back. No Docker
|
||||
socket is involved (spec section 29).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app.db import get_db
|
||||
from app.deps import CurrentUser, require_roles
|
||||
from app.models import ManagementRole
|
||||
from app.routers.broker import _call_control, restart_required
|
||||
|
||||
router = APIRouter(prefix="/api/v1/system", tags=["system"])
|
||||
|
||||
BrokerAdmin = Annotated[CurrentUser, Depends(require_roles(ManagementRole.BROKER_ADMIN))]
|
||||
AnyReader = Annotated[
|
||||
CurrentUser,
|
||||
Depends(
|
||||
require_roles(
|
||||
ManagementRole.BROKER_ADMIN,
|
||||
ManagementRole.OPERATOR,
|
||||
ManagementRole.AUDITOR,
|
||||
)
|
||||
),
|
||||
]
|
||||
DbDep = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def system_status(db: DbDep, _user: AnyReader):
|
||||
"""Broker process state + pending-restart flag (spec section 46).
|
||||
|
||||
Used by the sidebar indicator (polls lightly) and the System page.
|
||||
The control call is allowed to fail — an unreachable sidecar is a
|
||||
status, not an error.
|
||||
"""
|
||||
try:
|
||||
control = _call_control("/status", method="GET")
|
||||
broker_alive = bool(control.get("broker_pid_alive"))
|
||||
except Exception: # noqa: BLE001 - status endpoint must not 500
|
||||
broker_alive = False
|
||||
return {
|
||||
"broker_alive": broker_alive,
|
||||
"restart_required": restart_required(db),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/broker/restart")
|
||||
def restart_broker(request: Request, db: DbDep, user: BrokerAdmin):
|
||||
"""Restart the broker via the control sidecar (spec section 29).
|
||||
|
||||
The sidecar acknowledges and then SIGTERMs PID 1; Docker restarts
|
||||
the container. The pending-restart flag is cleared here — the config
|
||||
on disk is what the broker will boot with, so listener changes take
|
||||
effect by definition once the restart completes.
|
||||
"""
|
||||
from app.routers.broker import _set_restart_required
|
||||
|
||||
response = _call_control("/restart")
|
||||
_set_restart_required(db, False)
|
||||
audit.record(
|
||||
db,
|
||||
"BROKER_RESTARTED",
|
||||
request=request,
|
||||
user=user.username,
|
||||
object_type="broker",
|
||||
object_id="mosquitto",
|
||||
result="initiated",
|
||||
)
|
||||
return {"status": "restarting", "broker_alive": False}
|
||||
@@ -224,6 +224,27 @@ def client(db_session, monkeypatch):
|
||||
# The PKI client-cert endpoint validates the dynsec client exists.
|
||||
monkeypatch.setattr(pki_router, "dynsec_client", fake)
|
||||
|
||||
# The control sidecar is not running in tests: fake its responses so
|
||||
# deploy/system endpoints can be exercised (broker_alive always true).
|
||||
from app.routers import broker as broker_router
|
||||
from app.routers import system as system_router
|
||||
|
||||
def _fake_call_control(path, *, method="POST", json_body=None):
|
||||
if path == "/deploy":
|
||||
return {
|
||||
"status": "deployed",
|
||||
"broker_healthy": True,
|
||||
"validation": {"valid": True},
|
||||
}
|
||||
if path == "/restart":
|
||||
return {"status": "restarting"}
|
||||
if path == "/status":
|
||||
return {"broker_pid_alive": True, "config_file": "/mosquitto/config/mosquitto.conf"}
|
||||
raise AssertionError(f"unexpected control path in tests: {path}")
|
||||
|
||||
monkeypatch.setattr(broker_router, "_call_control", _fake_call_control)
|
||||
monkeypatch.setattr(system_router, "_call_control", _fake_call_control)
|
||||
|
||||
with TestClient(main_module.app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@@ -130,6 +130,70 @@ def test_client_id_binding(admin_client):
|
||||
assert refused.status_code == 400
|
||||
|
||||
|
||||
# --- System: broker restart + restart-required flag (spec 28, 29) -------------
|
||||
|
||||
|
||||
def test_deploy_sets_restart_required_on_listener_change(admin_client):
|
||||
"""Adding a listener definition vs the bootstrap config must flag a
|
||||
pending restart (reload cannot add listeners, spec section 28)."""
|
||||
# No deployed revision yet -> compared against the bootstrap config
|
||||
# (listener 1883 0.0.0.0). A listener on 8883 differs from that.
|
||||
admin_client.post(
|
||||
"/api/v1/broker/listeners",
|
||||
json={"name": "New TLS", "port": 8883, "protocol": "mqtt"},
|
||||
)
|
||||
deploy = admin_client.post(
|
||||
"/api/v1/broker/config/deploy", json={"comment": "listener added"}
|
||||
)
|
||||
assert deploy.status_code == 200
|
||||
body = deploy.json()
|
||||
assert body["restart_required"] is True
|
||||
|
||||
# The status endpoint reflects the same flag (drives the glow).
|
||||
status = admin_client.get("/api/v1/system/status").json()
|
||||
assert status["restart_required"] is True
|
||||
assert status["broker_alive"] is True
|
||||
|
||||
|
||||
def test_deploy_without_listener_change_needs_no_restart(admin_client):
|
||||
"""Deploying a config whose listener lines match the previous deployed
|
||||
revision must NOT set the flag (everything else is reload-safe)."""
|
||||
admin_client.post(
|
||||
"/api/v1/broker/listeners",
|
||||
json={"name": "Baseline", "port": 1883, "protocol": "mqtt"},
|
||||
)
|
||||
first = admin_client.post(
|
||||
"/api/v1/broker/config/deploy", json={"comment": "baseline"}
|
||||
).json()
|
||||
# 1883 matches the bootstrap signature on the FIRST deploy; the flag
|
||||
# may or may not be set then. The SECOND deploy (identical listener
|
||||
# lines) must clear it.
|
||||
second = admin_client.post(
|
||||
"/api/v1/broker/config/deploy", json={"comment": "no change"}
|
||||
).json()
|
||||
assert second["restart_required"] is False
|
||||
|
||||
|
||||
def test_restart_clears_the_flag(admin_client):
|
||||
admin_client.post(
|
||||
"/api/v1/broker/config/deploy", json={"comment": "flag set"}
|
||||
)
|
||||
restarted = admin_client.post("/api/v1/system/broker/restart")
|
||||
assert restarted.status_code == 200
|
||||
assert restarted.json()["status"] == "restarting"
|
||||
|
||||
status = admin_client.get("/api/v1/system/status").json()
|
||||
assert status["restart_required"] is False
|
||||
|
||||
|
||||
def test_system_endpoints_rbac(auditor_client):
|
||||
"""Status is read-only (auditor may read); restart is privileged."""
|
||||
assert auditor_client.get("/api/v1/system/status").status_code == 200
|
||||
assert (
|
||||
auditor_client.post("/api/v1/system/broker/restart").status_code == 403
|
||||
)
|
||||
|
||||
|
||||
def test_role_acl_lifecycle(admin_client):
|
||||
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
|
||||
|
||||
|
||||
@@ -84,6 +84,20 @@ def test_published_port_listener_does_not_warn(session):
|
||||
assert not any("not published to the host" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_internal_settings_never_leak_into_config(session):
|
||||
"""Platform-internal BrokerSettings keys (restart_required) are state,
|
||||
not broker options: emitting them fails `--test-config` with 'Unknown
|
||||
configuration variable' (found live in the restart E2E)."""
|
||||
session.add(ListenerConfig(name="l", port=1883, protocol="mqtt"))
|
||||
session.add(BrokerSettings(key="restart_required", value="true", value_type="bool"))
|
||||
session.add(BrokerSettings(key="max_packet_size", value="2000000", value_type="int"))
|
||||
session.commit()
|
||||
|
||||
result = generate_config(session)
|
||||
assert "max_packet_size 2000000" in result.config_text
|
||||
assert "restart_required" not in result.config_text
|
||||
|
||||
|
||||
def test_tls_listener_without_pki_warns(session):
|
||||
session.add(
|
||||
ListenerConfig(
|
||||
|
||||
@@ -115,6 +115,21 @@ def _signal_reload() -> None:
|
||||
raise _Error(500, f"mosquitto_signal failed: {proc.stderr.strip()}")
|
||||
|
||||
|
||||
def _restart_broker() -> None:
|
||||
"""Gracefully stop the broker; Docker's restart policy revives it.
|
||||
|
||||
The broker is PID 1 in the shared PID namespace. SIGTERM makes it
|
||||
shut down cleanly (persistence saved); `restart: unless-stopped`
|
||||
on the mosquitto service then starts the container again. This
|
||||
sidecar is a separate container and survives, so it can report
|
||||
status while the broker is down (spec section 29: restart broker
|
||||
where explicitly supported — no Docker socket involved).
|
||||
"""
|
||||
# The HTTP response MUST be flushed before PID 1 dies, so the kill
|
||||
# happens after the handler returns — orchestrated by the route.
|
||||
os.kill(1, signal.SIGTERM)
|
||||
|
||||
|
||||
def _broker_pid_alive() -> bool:
|
||||
try:
|
||||
# Broker is PID 1 in the shared PID namespace.
|
||||
@@ -215,6 +230,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
if self.path == "/deploy":
|
||||
self._handle_deploy()
|
||||
elif self.path == "/restart":
|
||||
# Acknowledge BEFORE the broker dies: the response must
|
||||
# reach the client first, then the process is signaled
|
||||
# in a daemon thread so the handler completes cleanly.
|
||||
self._send(200, {"status": "restarting"})
|
||||
threading.Thread(target=_restart_broker, daemon=True).start()
|
||||
elif self.path == "/reload":
|
||||
_signal_reload()
|
||||
self._send(200, {"status": "reloading", "broker_healthy": _post_broker_health()})
|
||||
|
||||
+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>
|
||||
);
|
||||
|
||||
@@ -244,3 +244,8 @@ export interface Revision {
|
||||
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