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 [tags, setTags] = useState([...machine.tags]);
const [tag, setTag] = useState("tag:");
const tagOptions = useMemo(
() => (existingTags ?? []).filter((existingTag) => !tags.includes(existingTag)),
[existingTags, tags],
);
const tagIsInvalid = useMemo(
() => tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag),
[tag, tags],
@@ -128,6 +132,21 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
<Plus className="p-1" size={30} />
</Button>
</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">
Not seeing the tags you expect? Tags need to be defined in your access control policy
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,
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.",
},
{ 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 cn from "~/utils/cn";
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 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 lookup = await agents?.lookup([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup);
const [lookup, policyResult] = await Promise.allSettled([
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 supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync();
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
return {
agent: agentSync
@@ -64,10 +69,10 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
nodeKey: agents?.agentNodeKey(),
}
: undefined,
existingTags: sortNodeTags(nodes),
existingTags: sortAssignableTags(nodes, policy),
magic,
node: enhancedNode,
stats: lookup?.[enhancedNode.nodeKey],
stats: stats?.[enhancedNode.nodeKey],
supportsNodeOwnerChange: supportsNodeOwnerChange,
tags,
users,
+9 -3
View File
@@ -10,7 +10,7 @@ import Tooltip from "~/components/tooltip";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
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 { 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 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 supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync();
@@ -60,6 +65,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
: undefined,
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
existingTags: sortAssignableTags(nodes, policy),
magic,
nodes,
populatedNodes,
@@ -423,7 +429,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
) : (
filteredAndSortedNodes.map((node) => (
<MachineRow
existingTags={sortNodeTags(loaderData.nodes)}
existingTags={loaderData.existingTags}
isAgent={
loaderData.agent !== undefined
? node.nodeKey === loaderData.agent.nodeKey
+91
View File
@@ -48,3 +48,94 @@ export function mapNodes(
export function sortNodeTags(nodes: Machine[]): string[] {
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 { isNoExpiry } from "~/utils/node-info";
import { extractTagOwnerTags, isNoExpiry, sortAssignableTags } from "~/utils/node-info";
describe("isNoExpiry", () => {
test("returns true for null", () => {
@@ -27,3 +27,51 @@ describe("isNoExpiry", () => {
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"]);
});
});