feat(ui): add support for showing existing tag options

This commit is contained in:
Aarnav Tale
2026-06-17 11:35:38 -04:00
parent c8b1a30f34
commit 57c8046f99
6 changed files with 190 additions and 9 deletions
+19
View File
@@ -24,6 +24,10 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
const submittingRef = useRef(false); const submittingRef = useRef(false);
const [tags, setTags] = useState([...machine.tags]); const [tags, setTags] = useState([...machine.tags]);
const [tag, setTag] = useState("tag:"); const [tag, setTag] = useState("tag:");
const tagOptions = useMemo(
() => (existingTags ?? []).filter((existingTag) => !tags.includes(existingTag)),
[existingTags, tags],
);
const tagIsInvalid = useMemo( const tagIsInvalid = useMemo(
() => tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag), () => tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag),
[tag, tags], [tag, tags],
@@ -128,6 +132,21 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
<Plus className="p-1" size={30} /> <Plus className="p-1" size={30} />
</Button> </Button>
</div> </div>
{tagOptions.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{tagOptions.map((option) => (
<Button
className="px-2 py-1 font-mono text-xs"
key={option}
onClick={() => setTags([...tags, option])}
type="button"
variant="ghost"
>
{option}
</Button>
))}
</div>
) : null}
<p className="mt-2 text-sm opacity-50"> <p className="mt-2 text-sm opacity-50">
Not seeing the tags you expect? Tags need to be defined in your access control policy Not seeing the tags you expect? Tags need to be defined in your access control policy
before they can be assigned to machines. before they can be assigned to machines.
+12
View File
@@ -122,6 +122,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
{ {
success: false as const, success: false as const,
error: error:
extractApiErrorMessage(error.data) ??
"One or more tags are not defined in your ACL policy. Please add them to your policy before assigning them to a machine.", "One or more tags are not defined in your ACL policy. Please add them to your policy before assigning them to a machine.",
}, },
{ status: 400 }, { status: 400 },
@@ -207,3 +208,14 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}); });
} }
} }
function extractApiErrorMessage(error: { data?: unknown; rawData: string }) {
if (error.data != null && typeof error.data === "object" && "message" in error.data) {
const message = (error.data as { message?: unknown }).message;
if (typeof message === "string" && message.length > 0) {
return message;
}
}
return error.rawData.length > 0 ? error.rawData : undefined;
}
+10 -5
View File
@@ -12,7 +12,7 @@ import Tooltip from "~/components/tooltip";
import { nodesResource, usersResource } from "~/server/headscale/live-store"; import { nodesResource, usersResource } from "~/server/headscale/live-store";
import cn from "~/utils/cn"; import cn from "~/utils/cn";
import { getOSInfo, getTSVersion } from "~/utils/host-info"; import { getOSInfo, getTSVersion } from "~/utils/host-info";
import { isNoExpiry, mapNodes, sortNodeTags } from "~/utils/node-info"; import { isNoExpiry, mapNodes, sortAssignableTags } from "~/utils/node-info";
import { getUserDisplayName } from "~/utils/user"; import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/machine"; import type { Route } from "./+types/machine";
@@ -50,11 +50,16 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
} }
const agents = context.agents.state === "enabled" ? context.agents.value : undefined; const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
const lookup = await agents?.lookup([node.nodeKey]); const [lookup, policyResult] = await Promise.allSettled([
const [enhancedNode] = mapNodes([node], lookup); agents?.lookup([node.nodeKey]),
api.policy.get(),
]);
const stats = lookup.status === "fulfilled" ? lookup.value : undefined;
const [enhancedNode] = mapNodes([node], stats);
const tags = [...node.tags].toSorted(); const tags = [...node.tags].toSorted();
const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable; const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync(); const agentSync = agents?.lastSync();
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
return { return {
agent: agentSync agent: agentSync
@@ -64,10 +69,10 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
nodeKey: agents?.agentNodeKey(), nodeKey: agents?.agentNodeKey(),
} }
: undefined, : undefined,
existingTags: sortNodeTags(nodes), existingTags: sortAssignableTags(nodes, policy),
magic, magic,
node: enhancedNode, node: enhancedNode,
stats: lookup?.[enhancedNode.nodeKey], stats: stats?.[enhancedNode.nodeKey],
supportsNodeOwnerChange: supportsNodeOwnerChange, supportsNodeOwnerChange: supportsNodeOwnerChange,
tags, tags,
users, users,
+9 -3
View File
@@ -10,7 +10,7 @@ import Tooltip from "~/components/tooltip";
import { nodesResource, usersResource } from "~/server/headscale/live-store"; import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn"; import cn from "~/utils/cn";
import { mapNodes, sortNodeTags, type PopulatedNode } from "~/utils/node-info"; import { mapNodes, sortAssignableTags, type PopulatedNode } from "~/utils/node-info";
import type { Route } from "./+types/overview"; import type { Route } from "./+types/overview";
import { MachineFilters } from "./components/machine-filters"; import { MachineFilters } from "./components/machine-filters";
@@ -46,7 +46,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
} }
const agents = context.agents.state === "enabled" ? context.agents.value : undefined; const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
const stats = await agents?.lookup(nodes.map((node) => node.nodeKey)); const [statsResult, policyResult] = await Promise.allSettled([
agents?.lookup(nodes.map((node) => node.nodeKey)),
api.policy.get(),
]);
const stats = statsResult.status === "fulfilled" ? statsResult.value : undefined;
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
const populatedNodes = mapNodes(nodes, stats); const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable; const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync(); const agentSync = agents?.lastSync();
@@ -60,6 +65,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
} }
: undefined, : undefined,
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined, headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
existingTags: sortAssignableTags(nodes, policy),
magic, magic,
nodes, nodes,
populatedNodes, populatedNodes,
@@ -423,7 +429,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
) : ( ) : (
filteredAndSortedNodes.map((node) => ( filteredAndSortedNodes.map((node) => (
<MachineRow <MachineRow
existingTags={sortNodeTags(loaderData.nodes)} existingTags={loaderData.existingTags}
isAgent={ isAgent={
loaderData.agent !== undefined loaderData.agent !== undefined
? node.nodeKey === loaderData.agent.nodeKey ? node.nodeKey === loaderData.agent.nodeKey
+91
View File
@@ -48,3 +48,94 @@ export function mapNodes(
export function sortNodeTags(nodes: Machine[]): string[] { export function sortNodeTags(nodes: Machine[]): string[] {
return Array.from(new Set(nodes.flatMap((node) => node.tags))).sort(); return Array.from(new Set(nodes.flatMap((node) => node.tags))).sort();
} }
export function sortAssignableTags(nodes: Machine[], policy?: string): string[] {
return Array.from(new Set([...sortNodeTags(nodes), ...extractTagOwnerTags(policy)])).sort();
}
export function extractTagOwnerTags(policy: string | undefined): string[] {
if (!policy) {
return [];
}
try {
const parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(policy)) as unknown;
if (parsed == null || typeof parsed !== "object" || !("tagOwners" in parsed)) {
return [];
}
const tagOwners = (parsed as { tagOwners?: unknown }).tagOwners;
if (tagOwners == null || typeof tagOwners !== "object" || Array.isArray(tagOwners)) {
return [];
}
return Object.keys(tagOwners)
.filter((tag) => tag.startsWith("tag:"))
.sort();
} catch {
return [];
}
}
function stripJsonCommentsAndTrailingCommas(input: string): string {
let output = "";
let inString = false;
let escaped = false;
let inLineComment = false;
let inBlockComment = false;
for (let i = 0; i < input.length; i++) {
const char = input[i];
const next = input[i + 1];
if (inLineComment) {
if (char === "\n" || char === "\r") {
inLineComment = false;
output += char;
}
continue;
}
if (inBlockComment) {
if (char === "*" && next === "/") {
inBlockComment = false;
i++;
}
continue;
}
if (inString) {
output += char;
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
output += char;
continue;
}
if (char === "/" && next === "/") {
inLineComment = true;
i++;
continue;
}
if (char === "/" && next === "*") {
inBlockComment = true;
i++;
continue;
}
output += char;
}
return output.replace(/,\s*([}\]])/g, "$1");
}
+49 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { isNoExpiry } from "~/utils/node-info"; import { extractTagOwnerTags, isNoExpiry, sortAssignableTags } from "~/utils/node-info";
describe("isNoExpiry", () => { describe("isNoExpiry", () => {
test("returns true for null", () => { test("returns true for null", () => {
@@ -27,3 +27,51 @@ describe("isNoExpiry", () => {
expect(isNoExpiry("2020-01-01T00:00:00Z")).toBe(false); expect(isNoExpiry("2020-01-01T00:00:00Z")).toBe(false);
}); });
}); });
describe("extractTagOwnerTags", () => {
test("reads tags from HuJSON tagOwners", () => {
expect(
extractTagOwnerTags(`{
// comment
"tagOwners": {
"tag:prod": ["group:admins"],
"tag:server": [],
},
}`),
).toEqual(["tag:prod", "tag:server"]);
});
test("ignores invalid policies", () => {
expect(extractTagOwnerTags("not-json")).toEqual([]);
});
});
describe("sortAssignableTags", () => {
test("unions assigned node tags with ACL tag owners", () => {
expect(
sortAssignableTags(
[
{
id: "1",
givenName: "node",
name: "node",
machineKey: "mkey:test",
nodeKey: "nodekey:test",
discoKey: "discokey:test",
ipAddresses: [],
tags: ["tag:used"],
lastSeen: "",
expiry: null,
online: true,
registerMethod: "REGISTER_METHOD_AUTH_KEY",
createdAt: "",
availableRoutes: [],
approvedRoutes: [],
subnetRoutes: [],
},
],
'{"tagOwners":{"tag:declared":[]}}',
),
).toEqual(["tag:declared", "tag:used"]);
});
});