diff --git a/web/app/(app)/servers/[id]/page.tsx b/web/app/(app)/servers/[id]/page.tsx
index 5a4d9a4..cb8105a 100644
--- a/web/app/(app)/servers/[id]/page.tsx
+++ b/web/app/(app)/servers/[id]/page.tsx
@@ -8,6 +8,7 @@ import { api, ServerStatus, GenerateKeyOptions, PackageUpdate, Inventory } from
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { useLicense } from "@/lib/useLicense";
+import { TagChips } from "@/components/servers/TagChips";
function statusVariant(status: ServerStatus) {
switch (status) {
@@ -397,6 +398,9 @@ export default function ServerDetailPage() {
{server.status}
{server.ip_address}
+
+
+
{/* Rendered disabled rather than hidden when the licence does not
diff --git a/web/components/servers/TagChips.tsx b/web/components/servers/TagChips.tsx
new file mode 100644
index 0000000..eb5009d
--- /dev/null
+++ b/web/components/servers/TagChips.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { api } from "@/lib/api";
+import { Button } from "@/components/ui";
+
+/*
+ * A tag is key:value, so the chip shows both halves with the key dimmed — the
+ * value is the part people scan for, the key is what disambiguates it.
+ */
+
+export function TagChips({ serverId, tags, editable = false }: { serverId: string; tags?: Record; editable?: boolean }) {
+ const queryClient = useQueryClient();
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState<[string, string][]>(Object.entries(tags ?? {}));
+ const [error, setError] = useState(null);
+
+ // The datalist id is scoped to the server: the fleet list renders one of
+ // these per row, and a fixed id would have every editor read the first
+ // one's options.
+ const keysListId = `tag-keys-${serverId}`;
+
+ const { data: known } = useQuery({
+ queryKey: ["server-tags"],
+ queryFn: () => api.listKnownTags(),
+ enabled: editing,
+ staleTime: 60_000,
+ });
+
+ const { mutate: save, isPending } = useMutation({
+ mutationFn: () => api.setServerTags(serverId, Object.fromEntries(draft.filter(([k, v]) => k && v))),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["servers"] });
+ queryClient.invalidateQueries({ queryKey: ["server-tags"] });
+ setEditing(false);
+ setError(null);
+ },
+ onError: (e: Error) => setError(e.message),
+ });
+
+ const entries = Object.entries(tags ?? {});
+
+ if (!editing) {
+ return (
+