🚧 WIP
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api, type Listener, type Revision } from "../api";
|
||||
|
||||
interface ConfigPreview {
|
||||
config_text: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function BrokerConfigPage() {
|
||||
const listeners = useQuery({
|
||||
queryKey: ["listeners"],
|
||||
queryFn: () => api.get<Listener[]>("/broker/listeners"),
|
||||
});
|
||||
|
||||
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 deploy = useMutation({
|
||||
mutationFn: (comment: string | null) =>
|
||||
api.post<{ status: string; revision: number; broker_healthy: boolean }>(
|
||||
"/broker/config/deploy",
|
||||
{ comment },
|
||||
),
|
||||
onSuccess: () => {
|
||||
preview.refetch();
|
||||
revisions.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<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
|
||||
deployment creates a revision.
|
||||
</p>
|
||||
|
||||
<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) => (
|
||||
<tr key={l.id}>
|
||||
<td>{l.name}</td>
|
||||
<td className="mono">{l.port}</td>
|
||||
<td className="mono">{l.bind_address ?? "0.0.0.0"}</td>
|
||||
<td>{l.protocol}</td>
|
||||
<td>{l.allow_anonymous ? "yes" : "no"}</td>
|
||||
<td>{l.enabled ? "yes" : "no"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<h3 style={{ marginTop: "2rem" }}>Generated configuration</h3>
|
||||
{preview.data?.warnings.map((w) => (
|
||||
<div key={w} className="banner warn">
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
<pre className="config mono">{preview.data?.config_text ?? "Generating…"}</pre>
|
||||
|
||||
<button
|
||||
className="primary"
|
||||
disabled={deploy.isPending}
|
||||
onClick={() => {
|
||||
const comment = prompt("Deployment comment (optional)");
|
||||
deploy.mutate(comment);
|
||||
}}
|
||||
>
|
||||
{deploy.isPending ? "Deploying…" : "Validate & deploy configuration"}
|
||||
</button>
|
||||
{deploy.data && (
|
||||
<div className="banner ok">
|
||||
Deployed revision {deploy.data.revision}. Broker health:{" "}
|
||||
{deploy.data.broker_healthy ? "OK" : "unknown — check logs"}
|
||||
</div>
|
||||
)}
|
||||
{deploy.error && (
|
||||
<div className="banner error">{(deploy.error as Error).message}</div>
|
||||
)}
|
||||
|
||||
<h3 style={{ marginTop: "2rem" }}>Revisions</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Deployed</th>
|
||||
<th>By</th>
|
||||
<th>Comment</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(revisions.data ?? []).map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="mono">{r.revision_number}</td>
|
||||
<td>{r.deployed ? new Date(r.deployed_at!).toLocaleString() : "—"}</td>
|
||||
<td>{r.created_by ?? "—"}</td>
|
||||
<td>{r.comment ?? "—"}</td>
|
||||
<td>{new Date(r.created_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type MqttClient, type PasswordReset } from "../api";
|
||||
|
||||
export function ClientsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [oncePassword, setOncePassword] = useState<PasswordReset | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const clients = useQuery({
|
||||
queryKey: ["clients"],
|
||||
queryFn: () => api.get<MqttClient[]>("/mqtt/clients"),
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (username: string) =>
|
||||
api.post<PasswordReset>("/mqtt/clients", { username }),
|
||||
onSuccess: (result) => {
|
||||
setOncePassword(result);
|
||||
setNewUsername("");
|
||||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const disable = useMutation({
|
||||
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/disable`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
|
||||
});
|
||||
|
||||
const enable = useMutation({
|
||||
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/enable`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (username: string) => api.delete(`/mqtt/clients/${username}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (newUsername) create.mutate(newUsername);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Clients</h2>
|
||||
<p>
|
||||
MQTT identities managed by the broker's dynamic security plugin. Passwords
|
||||
are shown exactly once on creation or reset.
|
||||
</p>
|
||||
|
||||
{oncePassword && (
|
||||
<div className="banner warn">
|
||||
<strong>Password for {oncePassword.username} — copy it now, it is shown
|
||||
only once:</strong>
|
||||
<div className="password-once mono">{oncePassword.password}</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="banner error">{error}</div>}
|
||||
|
||||
<form className="stack" onSubmit={submit}>
|
||||
<label htmlFor="new-client">New client username</label>
|
||||
<input
|
||||
id="new-client"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
placeholder="e.g. ps042"
|
||||
/>
|
||||
<p />
|
||||
<button className="primary" disabled={!newUsername || create.isPending}>
|
||||
{create.isPending ? "Creating…" : "Create client (random password)"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{clients.isLoading ? (
|
||||
<p>Loading clients…</p>
|
||||
) : (
|
||||
<table style={{ marginTop: "1.5rem" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Client ID</th>
|
||||
<th>Enabled</th>
|
||||
<th>Groups</th>
|
||||
<th>Roles</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(clients.data ?? []).map((c) => (
|
||||
<tr key={c.username}>
|
||||
<td className="mono">{c.username}</td>
|
||||
<td className="mono">{c.clientid ?? "—"}</td>
|
||||
<td>{c.enabled ? "yes" : "no"}</td>
|
||||
<td>{c.groups.map((g) => g.groupname).join(", ") || "—"}</td>
|
||||
<td>{c.roles.map((r) => r.rolename).join(", ") || "—"}</td>
|
||||
<td>
|
||||
{c.enabled ? (
|
||||
<button onClick={() => disable.mutate(c.username)}>Disable</button>
|
||||
) : (
|
||||
<button onClick={() => enable.mutate(c.username)}>Enable</button>
|
||||
)}{" "}
|
||||
<button
|
||||
className="danger"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete client ${c.username}?`))
|
||||
remove.mutate(c.username);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type Group } from "../api";
|
||||
|
||||
export function GroupsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const groups = useQuery({
|
||||
queryKey: ["groups"],
|
||||
queryFn: () => api.get<Group[]>("/mqtt/groups"),
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (groupname: string) => api.post("/mqtt/groups", { groupname }),
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
queryClient.invalidateQueries({ queryKey: ["groups"] });
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (groupname: string) => api.delete(`/mqtt/groups/${groupname}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["groups"] }),
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name) create.mutate(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Groups</h2>
|
||||
<p>Assign roles to collections of clients (dynamic security groups).</p>
|
||||
{error && <div className="banner error">{error}</div>}
|
||||
|
||||
<form className="stack" onSubmit={submit}>
|
||||
<label htmlFor="group-name">New group name</label>
|
||||
<input
|
||||
id="group-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. sensors"
|
||||
/>
|
||||
<p />
|
||||
<button className="primary" disabled={!name || create.isPending}>
|
||||
Create group
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<table style={{ marginTop: "1.5rem" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Group</th>
|
||||
<th>Roles</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(groups.data ?? []).map((g) => (
|
||||
<tr key={g.groupname}>
|
||||
<td className="mono">{g.groupname}</td>
|
||||
<td>{(g.roles ?? []).map((r) => r.rolename).join(", ") || "—"}</td>
|
||||
<td>
|
||||
<button
|
||||
className="danger"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete group ${g.groupname}?`)) remove.mutate(g.groupname);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(username, password);
|
||||
} catch {
|
||||
setError("Invalid username or password.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="login-card" onSubmit={(e) => void submit(e)}>
|
||||
<h2>MQTT Management</h2>
|
||||
{error && <div className="banner error">{error}</div>}
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<p />
|
||||
<button className="primary" disabled={busy || !username || !password}>
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type Role, type AclRule } from "../api";
|
||||
|
||||
const ACL_TYPES = [
|
||||
"publishClientSend",
|
||||
"publishClientReceive",
|
||||
"subscribeLiteral",
|
||||
"subscribePattern",
|
||||
"unsubscribeLiteral",
|
||||
"unsubscribePattern",
|
||||
] as const;
|
||||
|
||||
export function RolesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const roles = useQuery({
|
||||
queryKey: ["roles"],
|
||||
queryFn: () => api.get<Role[]>("/mqtt/roles"),
|
||||
});
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: ["role", selected],
|
||||
queryFn: () => api.get<Role>(`/mqtt/roles/${selected}`),
|
||||
enabled: selected !== null,
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (rolename: string) => api.post("/mqtt/roles", { rolename, acls: [] }),
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
queryClient.invalidateQueries({ queryKey: ["roles"] });
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (role: Role) => api.put(`/mqtt/roles/${role.rolename}`, role),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["role", selected] });
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name) create.mutate(name);
|
||||
};
|
||||
|
||||
const role = detail.data;
|
||||
|
||||
const updateAcl = (index: number, patch: Partial<AclRule>) => {
|
||||
if (!role) return;
|
||||
const acls = role.acls.map((a, i) => (i === index ? { ...a, ...patch } : a));
|
||||
save.mutate({ ...role, acls });
|
||||
};
|
||||
|
||||
const addAcl = () => {
|
||||
if (!role) return;
|
||||
save.mutate({
|
||||
...role,
|
||||
acls: [
|
||||
...role.acls,
|
||||
{ acltype: "publishClientSend", topic: "", allow: true, priority: 0 },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const removeAcl = (index: number) => {
|
||||
if (!role) return;
|
||||
save.mutate({ ...role, acls: role.acls.filter((_, i) => i !== index) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Roles & ACLs</h2>
|
||||
<p>
|
||||
ACL types map to dynamic security: publishClientSend (client publishes),
|
||||
publishClientReceive (client receives), subscribe/unsubscribe in literal
|
||||
and pattern variants. <code>%u</code> and <code>%c</code> substitute the
|
||||
username and client id.
|
||||
</p>
|
||||
{error && <div className="banner error">{error}</div>}
|
||||
|
||||
<form className="stack" onSubmit={submit}>
|
||||
<label htmlFor="role-name">New role name</label>
|
||||
<input
|
||||
id="role-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. site-a-sensors"
|
||||
/>
|
||||
<p />
|
||||
<button className="primary" disabled={!name || create.isPending}>
|
||||
Create role
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<table style={{ marginTop: "1.5rem" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Role</th>
|
||||
<th>ACLs</th>
|
||||
<th>Open</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(roles.data ?? []).map((r) => (
|
||||
<tr key={r.rolename}>
|
||||
<td className="mono">{r.rolename}</td>
|
||||
<td>{(r.acls ?? []).length}</td>
|
||||
<td>
|
||||
<button onClick={() => setSelected(r.rolename)}>Edit ACLs</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{role && (
|
||||
<section style={{ marginTop: "2rem" }}>
|
||||
<h3>
|
||||
Role: <span className="mono">{role.rolename}</span>
|
||||
</h3>
|
||||
<button onClick={addAcl} disabled={save.isPending}>
|
||||
+ Add ACL rule
|
||||
</button>
|
||||
<table style={{ marginTop: "1rem" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Topic filter</th>
|
||||
<th>Effect</th>
|
||||
<th>Priority</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{role.acls.map((acl, index) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<select
|
||||
value={acl.acltype}
|
||||
onChange={(e) =>
|
||||
updateAcl(index, { acltype: e.target.value as (typeof ACL_TYPES)[number] })
|
||||
}
|
||||
>
|
||||
{ACL_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="mono"
|
||||
value={acl.topic}
|
||||
placeholder="site/a/%u/telemetry/#"
|
||||
onChange={(e) => updateAcl(index, { topic: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
value={acl.allow ? "allow" : "deny"}
|
||||
onChange={(e) => updateAcl(index, { allow: e.target.value === "allow" })}
|
||||
>
|
||||
<option value="allow">allow</option>
|
||||
<option value="deny">deny</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100000}
|
||||
value={acl.priority}
|
||||
onChange={(e) =>
|
||||
updateAcl(index, { priority: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button className="danger" onClick={() => removeAcl(index)}>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{save.isPending && <p>Saving…</p>}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user