Add tags to servers, with a tag filter on the Servers page
Servers can be tagged (prod, media, rack-1, ...) for grouping. Operators and admins edit tags inline on a server's detail page; the input suggests tags already used on other servers so the same word ends up spelled the same way everywhere. Tags show as chips that keep one colour per tag, on the server cards, in the Manage table (and its CSV export), and on the detail page, where each chip links to the Servers list filtered by that tag. The Servers page has a tag bar with counts; picking several tags narrows to servers that have all of them. The filter lives in the URL, so it survives a refresh and can be linked to. Tags are also matched by the global search. Tags are normalized on the server (trimmed, lowercased, spaces become "-", duplicates merged, sorted); letters in any language are allowed, plus digits and - _ . : /, at most 30 characters and 12 per server. Invalid input is rejected with a message naming the offending tag, and nothing is saved. Changes are audit-logged with the before and after lists. Stored as a JSON column on servers (migration 0010). Every server response now returns tags as an array, and the shared response shaping strips the token hash in one place instead of five. Verified with 27 backend checks (normalization edge cases including Swedish letters, roles, validation, search, audit, PATCH/detail/list shapes) and by driving the real Servers and detail pages against the real router in a browser (filtering, editing, invalid tag, viewer view). Real dev database mtime untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c11158e98
commit
9f1609c4ed
13 files changed
+1745
-12
No files matched your search
@@ -303,6 +303,7 @@ export interface ServerRecord {
|
||||
proxmoxGuestType: "qemu" | "lxc" | null;
|
||||
proxmoxVmid: number | null;
|
||||
hideProxmoxLink: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface ServerHardware {
|
||||
@@ -835,6 +836,8 @@ export const api = {
|
||||
request<{ server: ServerRecord }>(`/api/servers/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
detail: (id: number) => request<ServerDetail>(`/api/servers/${id}/detail`),
|
||||
remove: (id: number) => request<void>(`/api/servers/${id}`, { method: "DELETE" }),
|
||||
setTags: (id: number, tags: string[]) =>
|
||||
request<{ tags: string[] }>(`/api/servers/${id}/tags`, { method: "PUT", body: JSON.stringify({ tags }) }),
|
||||
addLink: (id: number, data: { label: string; url: string }) =>
|
||||
request<{ link: ServerLink }>(`/api/servers/${id}/links`, { method: "POST", body: JSON.stringify(data) }),
|
||||
updateLink: (id: number, linkId: number, data: { label?: string; url?: string }) =>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import { normalizeTagInput, tagColorClass } from "../utils/tags";
|
||||
import { readableError } from "../utils/errors";
|
||||
|
||||
export function TagBadges({ tags, linked = false }: { tags: string[]; linked?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{tags.map((tag) =>
|
||||
linked ? (
|
||||
<Link key={tag} to={`/servers?tag=${encodeURIComponent(tag)}`} className={`badge text-decoration-none ${tagColorClass(tag)}`} title={`All servers tagged ${tag}`}>
|
||||
{tag}
|
||||
</Link>
|
||||
) : (
|
||||
<span key={tag} className={`badge ${tagColorClass(tag)}`}>
|
||||
{tag}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tags on a server's detail page: the chips (each links to the filtered Servers list) and, for operators, an inline editor. */
|
||||
export default function ServerTags({
|
||||
serverId,
|
||||
tags,
|
||||
canEdit,
|
||||
onSaved,
|
||||
}: {
|
||||
serverId: number;
|
||||
tags: string[];
|
||||
canEdit: boolean;
|
||||
onSaved: (tags: string[]) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftTags, setDraftTags] = useState<string[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function startEditing() {
|
||||
setDraftTags(tags);
|
||||
setInput("");
|
||||
setError(null);
|
||||
setEditing(true);
|
||||
// Offer the tags already used elsewhere, so the same word ends up spelled the same way everywhere.
|
||||
api.servers
|
||||
.list()
|
||||
.then((res) => setSuggestions([...new Set(res.servers.flatMap((s) => s.tags))].sort()))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function addFromInput(): string[] {
|
||||
const additions = input
|
||||
.split(",")
|
||||
.map(normalizeTagInput)
|
||||
.filter(Boolean);
|
||||
const next = [...new Set([...draftTags, ...additions])];
|
||||
setDraftTags(next);
|
||||
setInput("");
|
||||
return next;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const finalTags = input.trim() ? addFromInput() : draftTags;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.servers.setTags(serverId, finalTags);
|
||||
onSaved(res.tags);
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
setError(readableError(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
if (tags.length === 0 && !canEdit) return null;
|
||||
return (
|
||||
<div className="d-flex flex-wrap align-items-center gap-1 mb-3">
|
||||
<TagBadges tags={tags} linked />
|
||||
{canEdit && (
|
||||
<button className="btn btn-link btn-sm p-0 ms-1" onClick={startEditing}>
|
||||
{tags.length === 0 ? "+ Add tags" : "Edit tags"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card mb-3">
|
||||
<div className="card-body">
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
<label className="form-label">Tags</label>
|
||||
<div className="d-flex flex-wrap align-items-center gap-1 mb-2">
|
||||
{draftTags.map((tag) => (
|
||||
<span key={tag} className={`badge ${tagColorClass(tag)}`}>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-sm ms-1"
|
||||
style={{ fontSize: "0.5rem" }}
|
||||
aria-label={`Remove ${tag}`}
|
||||
onClick={() => setDraftTags(draftTags.filter((t) => t !== tag))}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
{draftTags.length === 0 && <span className="text-secondary small">No tags yet.</span>}
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<input
|
||||
className="form-control"
|
||||
list={`tag-suggestions-${serverId}`}
|
||||
placeholder="Type a tag and press Enter — e.g. prod, media, rack-1"
|
||||
maxLength={40}
|
||||
value={input}
|
||||
autoFocus
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
if (input.trim()) addFromInput();
|
||||
} else if (e.key === "Backspace" && !input && draftTags.length > 0) {
|
||||
setDraftTags(draftTags.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id={`tag-suggestions-${serverId}`}>
|
||||
{suggestions
|
||||
.filter((s) => !draftTags.includes(s))
|
||||
.map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||
Save
|
||||
</button>
|
||||
<button className="btn" onClick={() => setEditing(false)} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-secondary small mt-2">Lowercase letters, numbers and - _ . : / — up to 12 tags per server.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type TaskRecord,
|
||||
} from "../api/client";
|
||||
import ServerPorts from "../components/ServerPorts";
|
||||
import ServerTags from "../components/ServerTags";
|
||||
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
|
||||
@@ -355,6 +356,13 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
|
||||
</div>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
<ServerTags
|
||||
serverId={serverId}
|
||||
tags={server.tags}
|
||||
canEdit={canEditTasks}
|
||||
onSaved={(tags) => setDetail((d) => (d ? { ...d, server: { ...d.server, tags } } : d))}
|
||||
/>
|
||||
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Admin Links</h3>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState, type CSSProperties } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { api, type CurrentUser, type ServerRecord } from "../api/client";
|
||||
import CopyButton from "../components/CopyButton";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
@@ -8,6 +8,8 @@ import SortableTh from "../components/SortableTh";
|
||||
import { usePagination } from "../hooks/usePagination";
|
||||
import Pagination from "../components/Pagination";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
import { TagBadges } from "../components/ServerTags";
|
||||
import { tagColorClass } from "../utils/tags";
|
||||
|
||||
function typeBadgeStyle(colors: Record<string, string>, type: string): CSSProperties {
|
||||
const color = colors[type];
|
||||
@@ -35,6 +37,9 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [managingServers, setManagingServers] = useState(false);
|
||||
const [integrationColors, setIntegrationColors] = useState<Record<string, string>>({});
|
||||
// The active tag filter lives in the URL, so a tag chip on a server's page can link straight to "everything with this tag".
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedTags = searchParams.getAll("tag");
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.integrationColors().then((res) => setIntegrationColors(res.integrationColors)).catch(() => {});
|
||||
@@ -101,12 +106,30 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
||||
const { pageItems: serverPageItems, page: serverPage, setPage: setServerPage, pageCount: serverPageCount, totalCount: serverTotalCount } =
|
||||
usePagination(sortedServers);
|
||||
|
||||
const tagCounts = new Map<string, number>();
|
||||
for (const s of servers) for (const t of s.tags) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1);
|
||||
// A tag from the URL that no server has (any more) stays listed, so it can still be switched off.
|
||||
for (const t of selectedTags) if (!tagCounts.has(t)) tagCounts.set(t, 0);
|
||||
const allTags = [...tagCounts.keys()].sort((a, b) => a.localeCompare(b, "sv"));
|
||||
const visibleServers = servers.filter((s) => selectedTags.every((t) => s.tags.includes(t)));
|
||||
|
||||
function toggleTag(tag: string) {
|
||||
const next = selectedTags.includes(tag) ? selectedTags.filter((t) => t !== tag) : [...selectedTags, tag];
|
||||
setSearchParams(next.length > 0 ? { tag: next } : {}, { replace: true });
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
if (!sortedServers) return;
|
||||
downloadCsv(
|
||||
"servers.csv",
|
||||
["Name", "Hostname", "Token prefix", "Last seen"],
|
||||
sortedServers.map((s) => [s.name, s.hostname ?? "", s.apiTokenPrefix, s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"]),
|
||||
["Name", "Hostname", "Tags", "Token prefix", "Last seen"],
|
||||
sortedServers.map((s) => [
|
||||
s.name,
|
||||
s.hostname ?? "",
|
||||
s.tags.join(" "),
|
||||
s.apiTokenPrefix,
|
||||
s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never",
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -243,6 +266,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
||||
direction={sortDir}
|
||||
onSort={requestSort}
|
||||
/>
|
||||
<th>Tags</th>
|
||||
<th>Token</th>
|
||||
<SortableTh<ServerRecord>
|
||||
label="Last seen"
|
||||
@@ -261,6 +285,11 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
||||
<Link to={`/servers/${s.id}`}>{s.name}</Link>
|
||||
</td>
|
||||
<td>{s.hostname ?? "—"}</td>
|
||||
<td>
|
||||
<div className="d-flex flex-wrap gap-1">
|
||||
<TagBadges tags={s.tags} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-secondary">{s.apiTokenPrefix}…</td>
|
||||
<td>{s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"}</td>
|
||||
<td>
|
||||
@@ -280,7 +309,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
||||
))}
|
||||
{sortedServers?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-secondary text-center">
|
||||
<td colSpan={6} className="text-secondary text-center">
|
||||
No servers registered yet.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -307,8 +336,38 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{allTags.length > 0 && (
|
||||
<div className="d-flex flex-wrap align-items-center gap-1 mb-3">
|
||||
<span className="text-secondary small me-1">Tags</span>
|
||||
{allTags.map((tag) => {
|
||||
const active = selectedTags.includes(tag);
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
className={`badge border-0 ${active ? "bg-primary text-white" : tagColorClass(tag)}`}
|
||||
aria-pressed={active}
|
||||
onClick={() => toggleTag(tag)}
|
||||
>
|
||||
{tag} <span className="opacity-75">· {tagCounts.get(tag)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{selectedTags.length > 0 && (
|
||||
<button type="button" className="btn btn-link btn-sm p-0 ms-1" onClick={() => setSearchParams({}, { replace: true })}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{visibleServers.length === 0 && (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">No server has {selectedTags.length > 1 ? "all of those tags" : "that tag"}.</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="row row-cards">
|
||||
{servers.map((s) => (
|
||||
{visibleServers.map((s) => (
|
||||
<div className="col-sm-6 col-lg-3" key={s.id}>
|
||||
<Link to={`/servers/${s.id}`} className="card card-sm text-reset text-decoration-none">
|
||||
<div className="card-body">
|
||||
@@ -327,6 +386,11 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
||||
<span className="badge bg-secondary-lt text-secondary">No data</span>
|
||||
)}
|
||||
</div>
|
||||
{s.tags.length > 0 && (
|
||||
<div className="d-flex flex-wrap gap-1 mt-2">
|
||||
<TagBadges tags={s.tags} />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-secondary small mt-2">
|
||||
{s.lastSeenAt ? `Last seen ${formatDateTime(new Date(s.lastSeenAt))}` : "Never reported"}
|
||||
</div>
|
||||
@@ -335,6 +399,7 @@ export default function Servers({ user }: { user: CurrentUser }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const PALETTE = ["blue", "azure", "indigo", "purple", "pink", "orange", "yellow", "lime", "green", "teal", "cyan", "red"];
|
||||
|
||||
/** The same tag always gets the same colour, so "prod" is recognisable at a glance across every server. */
|
||||
export function tagColorClass(tag: string): string {
|
||||
let hash = 0;
|
||||
for (const ch of tag) hash = (hash * 31 + ch.codePointAt(0)!) >>> 0;
|
||||
const color = PALETTE[hash % PALETTE.length];
|
||||
return `bg-${color}-lt text-${color}`;
|
||||
}
|
||||
|
||||
/** Mirrors the server's normalisation for what's shown while typing; the server stays the authority on what's valid. */
|
||||
export function normalizeTagInput(raw: string): string {
|
||||
return raw.trim().toLowerCase().replace(/\s+/g, "-");
|
||||
}
|
||||
Reference in new issue
Block a user