feat: view and edit server tags
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user