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
+4
View File
@@ -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() {
<Badge variant={statusVariant(server.status)}>{server.status}</Badge>
</div>
<p className="mt-1 font-mono text-sm text-text-secondary">{server.ip_address}</p>
<div className="mt-2">
<TagChips serverId={server.server_id} tags={server.tags} editable />
</div>
</div>
<div className="flex flex-wrap gap-2">
{/* Rendered disabled rather than hidden when the licence does not
+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>
);
}
+16 -2
View File
@@ -32,6 +32,7 @@ export interface Server {
updates_checked_at?: string;
console_protocols?: string[];
inventory?: Inventory;
tags?: Record<string, string>;
}
export type MonitorType = "http" | "tcp" | "icmp" | "tls";
@@ -248,6 +249,7 @@ export interface Workflow {
workflow_id: string;
name: string;
target_server_ids: string[];
target_tags?: Record<string, string>;
steps: WorkflowStepRef[];
}
@@ -539,8 +541,20 @@ export const api = {
return request<{ acknowledged: boolean }>(`/auth/providers/${id}/ack-notice`, { method: "POST" });
},
listServers(): Promise<Server[]> {
return request<Server[]>("/servers");
listServers(tags?: Record<string, string>): Promise<Server[]> {
const params = Object.entries(tags ?? {}).map(([k, v]) => `tag=${encodeURIComponent(`${k}:${v}`)}`);
return request<Server[]>(`/servers${params.length ? `?${params.join("&")}` : ""}`);
},
listKnownTags(): Promise<Record<string, string[]>> {
return request<Record<string, string[]>>("/servers/tags");
},
setServerTags(serverId: string, tags: Record<string, string>): Promise<{ tags: Record<string, string> }> {
return request<{ tags: Record<string, string> }>(`/servers/${serverId}/tags`, {
method: "PUT",
body: JSON.stringify({ tags }),
});
},
getServer(serverId: string): Promise<ServerWithKeys> {