feat: view and edit server tags

This commit is contained in:
2026-08-04 13:38:42 +01:00
parent d1b3cd2f74
commit fa1fd14ed1
3 changed files with 145 additions and 2 deletions
+125
View File
@@ -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<string, string>; editable?: boolean }) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<[string, string][]>(Object.entries(tags ?? {}));
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-wrap items-center gap-1.5">
{entries.length === 0 && <span className="text-xs text-text-tertiary">No tags</span>}
{entries.map(([k, v]) => (
<span key={k} className="rounded-sm border border-border bg-surface-2 px-2 py-0.5 font-mono text-[11px]">
<span className="text-text-tertiary">{k}:</span>
<span className="text-text-primary">{v}</span>
</span>
))}
{editable && (
<button
type="button"
onClick={() => {
setDraft(entries);
setEditing(true);
}}
className="rounded-sm px-1.5 py-0.5 text-[11px] text-text-secondary hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{entries.length === 0 ? "Add tags" : "Edit"}
</button>
)}
</div>
);
}
return (
<div className="rounded-lg border border-border bg-surface p-3">
<datalist id={keysListId}>{Object.keys(known ?? {}).map((k) => <option key={k} value={k} />)}</datalist>
<div className="flex flex-col gap-2">
{draft.map(([k, v], i) => (
<div key={i} className="flex items-center gap-2">
<input
list={keysListId}
value={k}
onChange={(e) => setDraft((d) => d.map((row, j): [string, string] => (j === i ? [e.target.value, row[1]] : row)))}
placeholder="env"
className="w-32 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-accent/50 focus:outline-none"
/>
<span className="font-mono text-text-tertiary">:</span>
<input
value={v}
onChange={(e) => setDraft((d) => d.map((row, j): [string, string] => (j === i ? [row[0], e.target.value] : row)))}
placeholder="prod"
className="w-40 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-accent/50 focus:outline-none"
/>
<button
type="button"
onClick={() => setDraft((d) => d.filter((_, j) => j !== i))}
className="text-xs text-text-tertiary hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
aria-label={`Remove ${k || "tag"}`}
>
Remove
</button>
</div>
))}
</div>
{draft.length < 20 && (
<button
type="button"
onClick={() => setDraft((d) => [...d, ["", ""] as [string, string]])}
className="mt-2 text-xs text-accent hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
Add tag
</button>
)}
{error && <p className="mt-2 text-xs text-danger">{error}</p>}
<div className="mt-3 flex gap-2">
<Button size="sm" variant="primary" loading={isPending} onClick={() => save()}>
Save tags
</Button>
<Button size="sm" variant="ghost" onClick={() => { setEditing(false); setError(null); }}>
Cancel
</Button>
</div>
</div>
);
}