mirror of
https://github.com/tale/headplane.git
synced 2026-08-27 15:37:04 +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 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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user