Point from the dashboard to Stalwart's own administration

A line under the cards says where the rest is: detailed metrics, the
delivery queue, logs and server settings are in Stalwart's own
administration. It links there when the operator sets STALWART_ADMIN_URL,
and stays plain text otherwise, because STALWART_URL is how this server
reaches Stalwart and is often an address no browser can open.

Several servers: a servers file entry may now be an object,
{"url": ..., "adminUrl": ...}, and a session routed to that server gets its
adminUrl. A routed domain without one gets no link rather than the default
server's, for the same reason routing never falls back. The URL is sent
only to a session that may administer.

The shipped example file stopped the server at startup: its "_comment"
key was read as a domain and refused as not a URL, while the test that
checks the example skipped it. Keys starting with an underscore are notes
now -- no mail domain starts with one -- and the example is also loaded
through the real parser in a test, so the two cannot disagree again.

Two new strings, in all nine catalogues.
This commit is contained in:
2026-09-15 08:16:52 -07:00
parent 0054b8a3ce
commit 4787e8bf12
21 changed files with 226 additions and 23 deletions
+17 -1
View File
@@ -1,12 +1,13 @@
import { useEffect, useState, type ReactNode } from "react";
import { Link } from "wouter";
import { ArrowDownToLine, ArrowUpFromLine, Globe, Hourglass, LayoutDashboard, MemoryStick, RefreshCw, Users } from "lucide-react";
import { ArrowDownToLine, ArrowUpFromLine, ExternalLink, Globe, Hourglass, LayoutDashboard, MemoryStick, RefreshCw, Users } from "lucide-react";
import { adminSections, dashboardCards, type DashboardCard } from "@/lib/adminAccess";
import { balancedColumns, countObjects, DASHBOARD_WINDOW_MS, isRefused, loadMetrics, summariseMetrics, type MessageStats } from "@/lib/adminDashboard";
import { formatDayMonthTime, resolvedLocale } from "@/lib/datetime";
import { formatSize } from "@/lib/format";
import { t } from "@/lib/i18n";
import { Empty } from "@/ui/misc";
import { useSession } from "@/store/session";
import { usePermissions } from "./usePermissions";
/** Loading, a number, refused by the server (the card goes), or failed (the card says so). */
@@ -33,6 +34,7 @@ async function settle<T>(work: Promise<T>): Promise<Loaded<T>> {
*/
export function AdminDashboard() {
const perms = usePermissions();
const adminUrl = useSession((s) => s.session?.ihasmail?.server?.adminUrl ?? null);
const cards = dashboardCards(perms);
const sections = adminSections(perms);
const [reload, setReload] = useState(0);
@@ -109,6 +111,20 @@ export function AdminDashboard() {
) : (
<Empty icon={<LayoutDashboard size={32} />} title={t("Nothing to show")} />
)}
{/* The line between the two interfaces, said where someone looking for
more numbers will be: this is a glance, and operating the server is
Stalwart's own administration. The link is the operator's to give. */}
<p className="hint admin-dashboard-note">
{t("Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.")}
{adminUrl && (
<>
{" "}
<a href={adminUrl} target="_blank" rel="noopener noreferrer">
{t("Open Stalwart admin")} <ExternalLink size={13} aria-hidden="true" />
</a>
</>
)}
</p>
</div>
);
}
@@ -111,3 +111,40 @@ describe("the Administration dashboard", () => {
expect(cards()[4]).toEqual(["Received", "—", "Could not be loaded"]);
});
});
describe("the pointer to Stalwart's own administration", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
const renderWith = async (adminUrl: string | null) => {
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions: HELPDESK, server: { edition: "enterprise", adminUrl } } } as unknown as JmapSession });
const { hook } = memoryLocation({ path: "/admin" });
await act(async () => {
root.render(<Router hook={hook}><AdminDashboard /></Router>);
});
await act(async () => {});
};
it("names it, and links it where the operator has said where it is", async () => {
await renderWith("https://admin.example.com");
const note = host.querySelector(".admin-dashboard-note")!;
expect(note.textContent).toContain("Stalwart's own administration");
const link = note.querySelector("a")!;
expect(link.getAttribute("href")).toBe("https://admin.example.com");
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
});
it("names it without a link where nobody has", async () => {
await renderWith(null);
expect(host.querySelector(".admin-dashboard-note")?.textContent).toContain("Stalwart's own administration");
expect(host.querySelector(".admin-dashboard-note a")).toBeNull();
});
});