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 ( +
+ {entries.length === 0 && No tags} + {entries.map(([k, v]) => ( + + {k}: + {v} + + ))} + {editable && ( + + )} +
+ ); + } + + return ( +
+ {Object.keys(known ?? {}).map((k) => + +
+ {draft.map(([k, v], i) => ( +
+ 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" + /> + : + 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" + /> + +
+ ))} +
+ + {draft.length < 20 && ( + + )} + + {error &&

{error}

} + +
+ + +
+
+ ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index 7323a27..6432647 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -32,6 +32,7 @@ export interface Server { updates_checked_at?: string; console_protocols?: string[]; inventory?: Inventory; + tags?: Record; } 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; steps: WorkflowStepRef[]; } @@ -539,8 +541,20 @@ export const api = { return request<{ acknowledged: boolean }>(`/auth/providers/${id}/ack-notice`, { method: "POST" }); }, - listServers(): Promise { - return request("/servers"); + listServers(tags?: Record): Promise { + const params = Object.entries(tags ?? {}).map(([k, v]) => `tag=${encodeURIComponent(`${k}:${v}`)}`); + return request(`/servers${params.length ? `?${params.join("&")}` : ""}`); + }, + + listKnownTags(): Promise> { + return request>("/servers/tags"); + }, + + setServerTags(serverId: string, tags: Record): Promise<{ tags: Record }> { + return request<{ tags: Record }>(`/servers/${serverId}/tags`, { + method: "PUT", + body: JSON.stringify({ tags }), + }); }, getServer(serverId: string): Promise {