diff --git a/app/routes/machines/dialogs/tags.tsx b/app/routes/machines/dialogs/tags.tsx
index 6affc80..396cd42 100644
--- a/app/routes/machines/dialogs/tags.tsx
+++ b/app/routes/machines/dialogs/tags.tsx
@@ -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
Not seeing the tags you expect? Tags need to be defined in your access control policy
before they can be assigned to machines.
diff --git a/app/routes/machines/machine-actions.ts b/app/routes/machines/machine-actions.ts
index bc228e0..624adc8 100644
--- a/app/routes/machines/machine-actions.ts
+++ b/app/routes/machines/machine-actions.ts
@@ -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;
+}
diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx
index fb169a0..fbbfaac 100644
--- a/app/routes/machines/machine.tsx
+++ b/app/routes/machines/machine.tsx
@@ -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,
diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx
index 72f5b96..70e72a0 100644
--- a/app/routes/machines/overview.tsx
+++ b/app/routes/machines/overview.tsx
@@ -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) => (