mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
feat(ui): add support for showing existing tag options
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user