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:
bobbanandClaude Sonnet 5 committed 2026-09-26 03:06:29 +02:00
1 parent 4c11158e98
commit 9f1609c4ed
13 files changed
+1745 -12

No files matched your search

+1
View File
@@ -0,0 +1 @@
ALTER TABLE `servers` ADD `tags` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -71,6 +71,13 @@
"when": 1790382571470,
"tag": "0009_sharp_magus",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1790384497980,
"tag": "0010_magenta_alice",
"breakpoints": true
}
]
}
+1
View File
@@ -222,6 +222,7 @@ export const servers = sqliteTable("servers", {
disks: text("disks"), // JSON string: {mount, sizeBytes, usedBytes}[]
listeningPorts: text("listening_ports"), // JSON string: {protocol, port, address, process}[] — what the agent sees bound on the host
lastPortScan: text("last_port_scan"), // JSON string: summary of the most recent network scan from this app
tags: text("tags"), // JSON string: string[] — free-form labels for grouping and filtering, normalized by services/serverTags
// Optional link to a Proxmox VM/LXC — set by an admin, not the agent.
proxmoxIntegrationId: integer("proxmox_integration_id").references(() => integrations.id, {
+1 -1
View File
@@ -36,7 +36,7 @@ searchRouter.get("/", asyncHandler(async (req, res) => {
db
.select({ id: servers.id, name: servers.name, hostname: servers.hostname })
.from(servers)
.where(or(like(servers.name, term), like(servers.hostname, term), like(servers.description, term)))
.where(or(like(servers.name, term), like(servers.hostname, term), like(servers.description, term), like(servers.tags, term)))
.limit(RESULT_LIMIT),
db
.select({ id: secrets.id, name: secrets.name, type: secrets.type })
+51 -6
View File
@@ -10,8 +10,16 @@ import { asyncHandler } from "../utils/asyncHandler.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createProxmoxAdapter, type ProxmoxGuestType } from "../integrations/proxmox/adapter.js";
import { serverPortsRouter } from "./serverPorts.js";
import { InvalidTagError, normalizeTags, parseTags } from "../services/serverTags.js";
export const serversRouter = Router();
/** A server row as the API returns it: no token hash, and tags as an array rather than the stored JSON. */
function publicServer<T extends { apiTokenHash: string; tags: string | null }>(row: T) {
const { apiTokenHash: _hash, tags, ...rest } = row;
return { ...rest, tags: parseTags(tags) };
}
serversRouter.use(requireAuth);
serversRouter.use("/:id/ports", serverPortsRouter);
@@ -25,7 +33,7 @@ const createServerSchema = z.object({
serversRouter.get("/", asyncHandler(async (_req, res) => {
const rows = await db.query.servers.findMany({ orderBy: (s, { asc }) => [asc(s.name)] });
res.json({
servers: rows.map(({ apiTokenHash, ...rest }) => rest),
servers: rows.map(publicServer),
});
}));
@@ -58,7 +66,7 @@ serversRouter.post("/", requireRole("admin"), asyncHandler(async (req, res) => {
detail: { name: created.name },
});
const { apiTokenHash, ...serverOut } = created;
const serverOut = publicServer(created);
// The full token is only ever shown once, at creation time.
res.status(201).json({ server: serverOut, token });
}));
@@ -85,7 +93,7 @@ serversRouter.post("/:id/rotate-token", requireRole("admin"), asyncHandler(async
detail: { name: updated.name },
});
const { apiTokenHash, ...serverOut } = updated;
const serverOut = publicServer(updated);
res.json({ server: serverOut, token });
}));
@@ -145,7 +153,7 @@ serversRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req, res)
detail: { name: updated.name },
});
const { apiTokenHash, ...serverOut } = updated;
const serverOut = publicServer(updated);
res.json({ server: serverOut });
}));
@@ -232,7 +240,8 @@ serversRouter.get("/:id/detail", asyncHandler(async (req, res) => {
.orderBy(serverLinks.label);
const {
apiTokenHash,
apiTokenHash: _apiTokenHash,
tags: rawTags,
ipAddresses: _rawIpAddresses,
disks: _rawDisks,
cpuModel: _cpuModel,
@@ -245,7 +254,43 @@ serversRouter.get("/:id/detail", asyncHandler(async (req, res) => {
...serverOut
} = server;
res.json({ server: serverOut, ipAddresses, hardware, dnsMatches, links });
res.json({ server: { ...serverOut, tags: parseTags(rawTags) }, ipAddresses, hardware, dnsMatches, links });
}));
const tagsSchema = z.object({ tags: z.array(z.string().max(100)).max(50) });
// Tags are lightweight labels, edited by operators like port notes and admin links — not admin-only like renaming a server.
serversRouter.put("/:id/tags", requireRole("operator"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const parsed = tagsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", message: "Tags must be a list of text.", details: parsed.error.flatten() });
}
let tags: string[];
try {
tags = normalizeTags(parsed.data.tags);
} catch (err) {
if (err instanceof InvalidTagError) return res.status(400).json({ error: "invalid_tag", message: err.message });
throw err;
}
const [existing] = await db.select().from(servers).where(eq(servers.id, id)).limit(1);
if (!existing) return res.status(404).json({ error: "not_found" });
await db.update(servers).set({ tags: tags.length > 0 ? JSON.stringify(tags) : null }).where(eq(servers.id, id));
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "set_tags",
targetType: "server",
targetId: id,
detail: { name: existing.name, before: parseTags(existing.tags), after: tags },
});
res.json({ tags });
}));
const linkSchema = z.object({
+38
View File
@@ -0,0 +1,38 @@
export const MAX_TAGS_PER_SERVER = 12;
export const MAX_TAG_LENGTH = 30;
// Letters and digits (any language — Swedish åäö included) first, then a few separators people actually use in tags.
const TAG_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}._:/-]*$/u;
export class InvalidTagError extends Error {}
/**
* Turns what someone typed into the stored form: trimmed, lowercased, inner whitespace turned into "-", duplicates
* merged (so "Prod" and "prod " are one tag), sorted so the display is stable. Throws InvalidTagError with a
* message fit to show the user.
*/
export function normalizeTags(input: string[]): string[] {
const seen = new Set<string>();
for (const raw of input) {
const tag = raw.trim().toLowerCase().replace(/\s+/g, "-");
if (!tag) continue; // an empty box isn't an error, it's just nothing
if (tag.length > MAX_TAG_LENGTH) throw new InvalidTagError(`"${raw.trim()}" is too long — tags are at most ${MAX_TAG_LENGTH} characters.`);
if (!TAG_PATTERN.test(tag)) {
throw new InvalidTagError(`"${raw.trim()}" isn't a valid tag — use letters, numbers, and - _ . : /`);
}
seen.add(tag);
}
if (seen.size > MAX_TAGS_PER_SERVER) throw new InvalidTagError(`A server can have at most ${MAX_TAGS_PER_SERVER} tags.`);
return [...seen].sort((a, b) => a.localeCompare(b, "sv"));
}
/** Reads the stored JSON column, tolerating null and anything unexpected. */
export function parseTags(stored: string | null | undefined): string[] {
if (!stored) return [];
try {
const value = JSON.parse(stored);
return Array.isArray(value) ? value.filter((t): t is string => typeof t === "string") : [];
} catch {
return [];
}
}