+
+ );
+}
diff --git a/app/layouts/no-access.tsx b/app/layouts/no-access.tsx
deleted file mode 100644
index 4bee4d0..0000000
--- a/app/layouts/no-access.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-import { Icon } from "@iconify/react";
-import { Form } from "react-router";
-
-import Button from "~/components/Button";
-import Card from "~/components/Card";
-import Link from "~/components/link";
-import Options from "~/components/Options";
-import toast from "~/utils/toast";
-
-interface NoAccessProps {
- linkedUserName?: string;
- osValue?: string;
-}
-
-export default function NoAccess({ linkedUserName, osValue }: NoAccessProps) {
- return (
-
-
- {linkedUserName ? (
-
-
- ✓ Your account is linked to Headscale user {linkedUserName}.
-
-
- ) : undefined}
-
- Access your network via Tailscale
-
- You don't have dashboard access, but you can still connect to your Headscale network.
- Install Tailscale on your device to get started.
-
-
-
-
-
- Linux
-
- }
- >
-
-
- Click this button to copy the command.{" "}
-
- View script source
-
-
-
-
-
-
- Need dashboard access?
-
- Your account is signed in but doesn't have permission to manage the dashboard. Contact
- an administrator to request access.
-
-
-
-
-
- );
-}
diff --git a/app/layouts/shell.tsx b/app/layouts/shell.tsx
index 652d630..79a77b5 100644
--- a/app/layouts/shell.tsx
+++ b/app/layouts/shell.tsx
@@ -1,11 +1,11 @@
import { Outlet, redirect } from "react-router";
import Footer from "~/components/Footer";
-import Header from "~/components/Header";
+import Header from "~/layout/header";
import { Capabilities } from "~/server/web/roles";
import { getUserDisplayName } from "~/utils/user";
-import { Route } from "./+types/shell";
+import type { Route } from "./+types/shell";
import NoAccess from "./no-access";
// This loads the bare minimum for the application to function
@@ -31,13 +31,13 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const user =
principal.kind === "oidc"
? {
- subject: principal.user.subject,
- name: principal.profile.name,
email: principal.profile.email,
- username: principal.profile.username,
+ name: principal.profile.name,
picture: principal.profile.picture,
+ subject: principal.user.subject,
+ username: principal.profile.username,
}
- : { subject: "api_key", name: principal.displayName };
+ : { name: principal.displayName, subject: "api_key" };
let linkedUserName: string | undefined;
let osValue: string | undefined;
@@ -57,31 +57,31 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const userAgent = request.headers.get("user-agent");
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
switch (os?.[0]) {
- case "Windows":
+ case "Windows": {
osValue = "windows";
break;
- case "Mac OS X":
+ }
+ case "Mac OS X": {
osValue = "macos";
break;
+ }
case "iPhone":
- case "iPad":
+ case "iPad": {
osValue = "ios";
break;
- case "Android":
+ }
+ case "Android": {
osValue = "android";
break;
- default:
+ }
+ default: {
osValue = "linux";
break;
+ }
}
}
return {
- config: context.hs.c,
- url: context.config.headscale.public_url ?? context.config.headscale.url,
- configAvailable: context.hs.readable(),
- debug: context.config.debug,
- user,
access: {
ui: check,
dns: context.auth.can(principal, Capabilities.read_network),
@@ -90,11 +90,16 @@ export async function loader({ request, context }: Route.LoaderArgs) {
machines: context.auth.can(principal, Capabilities.read_machines),
settings: context.auth.can(principal, Capabilities.read_feature),
},
- onboarding: request.url.endsWith("/onboarding"),
- noAccess,
- linkedUserName,
- osValue,
+ config: context.hs.c,
+ configAvailable: context.hs.readable(),
+ debug: context.config.debug,
healthy: await api.isHealthy(),
+ linkedUserName,
+ noAccess,
+ onboarding: request.url.endsWith("/onboarding"),
+ osValue,
+ url: context.config.headscale.public_url ?? context.config.headscale.url,
+ user,
};
} catch {
return redirect("/login", {
@@ -109,7 +114,7 @@ export default function Shell({ loaderData }: Route.ComponentProps) {
if (loaderData.noAccess && !loaderData.onboarding) {
return (
<>
-
+
>
@@ -118,7 +123,7 @@ export default function Shell({ loaderData }: Route.ComponentProps) {
return (
<>
-
+
>
diff --git a/public/logo-dark-bg.svg b/app/logo/dark-bg.svg
similarity index 100%
rename from public/logo-dark-bg.svg
rename to app/logo/dark-bg.svg
diff --git a/public/logo-dark.svg b/app/logo/dark.svg
similarity index 100%
rename from public/logo-dark.svg
rename to app/logo/dark.svg
diff --git a/public/logo-light-bg.svg b/app/logo/light-bg.svg
similarity index 100%
rename from public/logo-light-bg.svg
rename to app/logo/light-bg.svg
diff --git a/public/logo-light.svg b/app/logo/light.svg
similarity index 100%
rename from public/logo-light.svg
rename to app/logo/light.svg
diff --git a/app/routes.ts b/app/routes.ts
index 8058b7c..0a9c1ce 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -2,7 +2,6 @@ import { index, layout, prefix, route } from "@react-router/dev/routes";
export default [
// Utility Routes
- index("routes/util/redirect.ts"),
route("/healthz", "routes/util/healthz.ts"),
// API Routes
@@ -15,27 +14,26 @@ export default [
route("/oidc/start", "routes/auth/oidc-start.ts"),
route("/ssh", "routes/ssh/console.tsx"),
- // All the main logged-in dashboard routes
- // Double nested to separate error propagations
- layout("layouts/shell.tsx", [
+ // All the main logged-in routes
+ layout("layout/app.tsx", [
+ index("routes/home.tsx"),
route("/onboarding", "routes/users/onboarding.tsx"),
route("/onboarding/skip", "routes/users/onboarding-skip.tsx"),
- layout("layouts/dashboard.tsx", [
- ...prefix("/machines", [
- index("routes/machines/overview.tsx"),
- route("/:id", "routes/machines/machine.tsx"),
- ]),
- route("/users", "routes/users/overview.tsx"),
- route("/acls", "routes/acls/overview.tsx"),
- route("/dns", "routes/dns/overview.tsx"),
+ ...prefix("/machines", [
+ index("routes/machines/overview.tsx"),
+ route("/:id", "routes/machines/machine.tsx"),
+ ]),
- ...prefix("/settings", [
- index("routes/settings/overview.tsx"),
- route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
- route("/restrictions", "routes/settings/restrictions/overview.tsx"),
- // route('/local-agent', 'routes/settings/local-agent.tsx'),
- ]),
+ route("/users", "routes/users/overview.tsx"),
+ route("/acls", "routes/acls/overview.tsx"),
+ route("/dns", "routes/dns/overview.tsx"),
+
+ ...prefix("/settings", [
+ index("routes/settings/overview.tsx"),
+ route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
+ route("/restrictions", "routes/settings/restrictions/overview.tsx"),
+ // route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),
];
diff --git a/app/routes/acls/overview.tsx b/app/routes/acls/overview.tsx
index d3c96db..0579dd5 100644
--- a/app/routes/acls/overview.tsx
+++ b/app/routes/acls/overview.tsx
@@ -1,220 +1,189 @@
-import {
- AlertCircle,
- Construction,
- Eye,
- FlaskConical,
- Pencil,
-} from 'lucide-react';
-import { useEffect, useState } from 'react';
-import { isRouteErrorResponse, useFetcher, useRevalidator } from 'react-router';
-import Button from '~/components/Button';
-import Card from '~/components/Card';
-import Code from '~/components/Code';
-import Link from '~/components/Link';
-import Notice from '~/components/Notice';
-import Tabs from '~/components/Tabs';
-import { isApiError } from '~/server/headscale/api/error-client';
-import toast from '~/utils/toast';
-import type { Route } from './+types/overview';
-import { aclAction } from './acl-action';
-import { aclLoader } from './acl-loader';
-import { Differ, Editor } from './components/cm.client';
+import { AlertCircle, Construction, Eye, FlaskConical, Pencil } from "lucide-react";
+import { useEffect, useState } from "react";
+import { isRouteErrorResponse, useFetcher, useRevalidator } from "react-router";
+
+import Button from "~/components/Button";
+import Card from "~/components/Card";
+import Code from "~/components/Code";
+import Link from "~/components/link";
+import Notice from "~/components/Notice";
+import Tabs from "~/components/Tabs";
+import { isApiError } from "~/server/headscale/api/error-client";
+import toast from "~/utils/toast";
+
+import type { Route } from "./+types/overview";
+import { aclAction } from "./acl-action";
+import { aclLoader } from "./acl-loader";
+import { Differ, Editor } from "./components/cm.client";
export const loader = aclLoader;
export const action = aclAction;
-export default function Page({
- loaderData: { access, writable, policy },
-}: Route.ComponentProps) {
- const [codePolicy, setCodePolicy] = useState(policy);
- const fetcher = useFetcher();
- const { revalidate } = useRevalidator();
- const disabled = !access || !writable; // Disable if no permission or not writable
+export default function Page({ loaderData: { access, writable, policy } }: Route.ComponentProps) {
+ const [codePolicy, setCodePolicy] = useState(policy);
+ const fetcher = useFetcher();
+ const { revalidate } = useRevalidator();
+ const disabled = !access || !writable; // Disable if no permission or not writable
- useEffect(() => {
- // Update the codePolicy when the loader data changes
- if (policy !== codePolicy) {
- setCodePolicy(policy);
- }
- }, [policy]);
+ useEffect(() => {
+ // Update the codePolicy when the loader data changes
+ if (policy !== codePolicy) {
+ setCodePolicy(policy);
+ }
+ }, [policy]);
- useEffect(() => {
- if (!fetcher.data) {
- // No data yet, return
- return;
- }
+ useEffect(() => {
+ if (!fetcher.data) {
+ // No data yet, return
+ return;
+ }
- if (fetcher.data.success === true) {
- toast('Updated policy');
- revalidate();
- }
- }, [fetcher.data]);
+ if (fetcher.data.success === true) {
+ toast("Updated policy");
+ revalidate();
+ }
+ }, [fetcher.data]);
- return (
-
- {!access ? (
-
- You do not have the necessary permissions to edit the Access Control
- List policy. Please contact your administrator to request access or to
- make changes to the ACL policy.
-
- ) : !writable ? (
-
- The ACL policy mode is most likely set to file in your
- Headscale configuration. This means that the ACL file cannot be edited
- through the web interface. In order to resolve this, you'll need to
- set policy.mode to database in your
- Headscale configuration.
-
- ) : undefined}
-
Access Control List (ACL)
-
- The ACL file is used to define the access control rules for your
- network. You can find more information about the ACL file in the{' '}
-
- Tailscale ACL guide
- {' '}
- and the{' '}
-
- Headscale docs
-
- .
-
- Previewing rules is not available yet. This feature is still in
- development and is pretty complicated to implement. Hopefully I
- will be able to get to it soon.
-
-
-
-
-
-
-
- );
+ return (
+
+ {!access ? (
+
+ You do not have the necessary permissions to edit the Access Control List policy. Please
+ contact your administrator to request access or to make changes to the ACL policy.
+
+ ) : !writable ? (
+
+ The ACL policy mode is most likely set to file in your Headscale
+ configuration. This means that the ACL file cannot be edited through the web interface. In
+ order to resolve this, you'll need to set policy.mode to{" "}
+ database in your Headscale configuration.
+
+ ) : undefined}
+
Access Control List (ACL)
+
+ The ACL file is used to define the access control rules for your network. You can find more
+ information about the ACL file in the{" "}
+
+ Tailscale ACL guide
+ {" "}
+ and the{" "}
+
+ Headscale docs
+
+ .
+
+ Previewing rules is not available yet. This feature is still in development and is
+ pretty complicated to implement. Hopefully I will be able to get to it soon.
+
+
+
+
+
+
+
+ );
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
- if (
- isRouteErrorResponse(error) &&
- isApiError(error.data) &&
- error.data.rawData.includes('reading policy from path') &&
- error.data.rawData.includes('no such file or directory')
- ) {
- return (
-
-
-
- ACL Policy Unavailable
-
-
-
- The ACL policy is currently unavailable because the policy file does
- not exist on the server. This usually indicates that Headscale is
- running in file mode for ACLs, and the specified policy
- file is missing.
-
-
-
-
- In order to resolve this issue, there are two possible actions you
- can take:
-
-
-
- Create the ACL policy file at the specified path in your Headscale
- configuration.
-
-
- Alternatively, you can switch Headscale to use{' '}
- database mode for ACLs by updating your Headscale
- configuration. This will allow Headplane to manage the ACL policy
- directly through the web interface.
-
-
-
-
- );
- }
+ if (
+ isRouteErrorResponse(error) &&
+ isApiError(error.data) &&
+ error.data.rawData.includes("reading policy from path") &&
+ error.data.rawData.includes("no such file or directory")
+ ) {
+ return (
+
+
+
+ ACL Policy Unavailable
+
+
+
+ The ACL policy is currently unavailable because the policy file does not exist on the
+ server. This usually indicates that Headscale is running in file mode for
+ ACLs, and the specified policy file is missing.
+
+
+
+
+ In order to resolve this issue, there are two possible actions you can take:
+
+
+
+ Create the ACL policy file at the specified path in your Headscale configuration.
+
+
+ Alternatively, you can switch Headscale to use database mode for ACLs by
+ updating your Headscale configuration. This will allow Headplane to manage the ACL
+ policy directly through the web interface.
+
+
+
+
+ );
+ }
- throw error;
+ throw error;
}
diff --git a/app/routes/auth/login/config-error.tsx b/app/routes/auth/login/config-error.tsx
index 65213ac..09f5ce8 100644
--- a/app/routes/auth/login/config-error.tsx
+++ b/app/routes/auth/login/config-error.tsx
@@ -3,7 +3,7 @@ import { AlertCircle, CloudOff } from "lucide-react";
import Card from "~/components/Card";
import Code from "~/components/Code";
import Link from "~/components/link";
-import { OidcConnectorError } from "~/server/web/oidc-connector";
+import type { OidcConnectorError } from "~/server/web/oidc-connector";
export function OidcDiscoveryFailedNotice() {
return (
@@ -34,11 +34,7 @@ export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[]
{code.node}
))}
{" "}
-
+
Learn more
@@ -54,7 +50,7 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
for (const error of errors) {
switch (error) {
- case "INVALID_API_KEY":
+ case "INVALID_API_KEY": {
messages.push({
key: error,
node: (
@@ -65,8 +61,9 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
+ }
- case "MISSING_AUTHORIZATION_ENDPOINT":
+ case "MISSING_AUTHORIZATION_ENDPOINT": {
messages.push({
key: error,
node: (
@@ -77,8 +74,9 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
+ }
- case "MISSING_TOKEN_ENDPOINT":
+ case "MISSING_TOKEN_ENDPOINT": {
messages.push({
key: error,
node: (
@@ -89,8 +87,9 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
+ }
- case "MISSING_USERINFO_ENDPOINT":
+ case "MISSING_USERINFO_ENDPOINT": {
messages.push({
key: error,
node: (
@@ -101,8 +100,9 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
+ }
- case "MISSING_REQUIRED_CLAIMS":
+ case "MISSING_REQUIRED_CLAIMS": {
messages.push({
key: error,
node: (
@@ -113,8 +113,9 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
+ }
- case "UNKNOWN_ERROR":
+ case "UNKNOWN_ERROR": {
messages.push({
key: error,
node: (
@@ -125,6 +126,7 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
+ }
}
}
diff --git a/app/routes/auth/login/page.tsx b/app/routes/auth/login/page.tsx
index 3df13c4..8e62346 100644
--- a/app/routes/auth/login/page.tsx
+++ b/app/routes/auth/login/page.tsx
@@ -27,7 +27,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const oidcConnector = await context.oidc?.connector.get();
// MARK: This works because the OIDC connector will always return false
- // for `isExclusive` if the OIDC config isn't usable.
+ // For `isExclusive` if the OIDC config isn't usable.
if (oidcConnector?.isExclusive && urlState !== "logout") {
return redirect("/oidc/start");
}
@@ -63,14 +63,14 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
useEffect(() => {
// State is a one time thing, we need to remove it after it has
- // been consumed to prevent logic loops.
+ // Been consumed to prevent logic loops.
if (urlState !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete("s");
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
- // which will trigger a full reload
+ // Which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
@@ -103,8 +103,8 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
Headplane is configured to use secure cookies, but this site is being served over an
insecure connection and login will not work correctly.{" "}
Learn more.
diff --git a/app/routes/dns/components/manage-ns.tsx b/app/routes/dns/components/manage-ns.tsx
index ce57fd2..3d7a69f 100644
--- a/app/routes/dns/components/manage-ns.tsx
+++ b/app/routes/dns/components/manage-ns.tsx
@@ -1,153 +1,129 @@
-import { Info } from 'lucide-react';
-import { Form, useSubmit } from 'react-router';
-import Button from '~/components/Button';
-import Link from '~/components/Link';
-import Switch from '~/components/Switch';
-import TableList from '~/components/TableList';
-import Tooltip from '~/components/Tooltip';
-import cn from '~/utils/cn';
-import AddNS from '../dialogs/add-ns';
+import { Info } from "lucide-react";
+import { Form, useSubmit } from "react-router";
+
+import Button from "~/components/Button";
+import Link from "~/components/link";
+import Switch from "~/components/Switch";
+import TableList from "~/components/TableList";
+import Tooltip from "~/components/Tooltip";
+import cn from "~/utils/cn";
+
+import AddNS from "../dialogs/add-ns";
interface Props {
- nameservers: Record;
- overrideLocalDns: boolean;
- isDisabled: boolean;
+ nameservers: Record;
+ overrideLocalDns: boolean;
+ isDisabled: boolean;
}
-export default function ManageNS({
- nameservers,
- isDisabled,
- overrideLocalDns,
-}: Props) {
- return (
-
-
Nameservers
-
- Set the nameservers used by devices on the Tailnet to resolve DNS
- queries.{' '}
-
- Learn more
-
-
-
-
-
- When enabled, use the DNS servers listed below to resolve
- names outside the tailnet. When disabled (default), devices
- will prefer their local DNS configuration.
-
- Learn More
-
-
-
-
+
+
+
+ When enabled, use the DNS servers listed below to resolve names outside the
+ tailnet. When disabled (default), devices will prefer their local DNS
+ configuration.
+
+ Learn More
+
+
+
+
Headscale supports adding custom DNS records to your Tailnet. As of now, only A{" "}
and AAAA records are supported.{" "}
-
+
Learn More
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
new file mode 100644
index 0000000..67c7649
--- /dev/null
+++ b/app/routes/home.tsx
@@ -0,0 +1,153 @@
+import { Check, Copy } from "lucide-react";
+import { Form, redirect } from "react-router";
+
+import androidSvg from "~/assets/android.svg";
+import iosSvg from "~/assets/ios.svg";
+import linuxSvg from "~/assets/linux.svg";
+import macosSvg from "~/assets/macos.svg";
+import windowsSvg from "~/assets/windows.svg";
+import Button from "~/components/Button";
+import Card from "~/components/Card";
+import Link from "~/components/link";
+import { Capabilities } from "~/server/web/roles";
+import cn from "~/utils/cn";
+import toast from "~/utils/toast";
+
+import type { Route } from "./+types/home";
+
+export async function loader({ request, context }: Route.LoaderArgs) {
+ const principal = await context.auth.require(request);
+
+ if (context.auth.can(principal, Capabilities.ui_access)) {
+ return redirect("/machines");
+ }
+
+ const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
+ const api = context.hsApi.getRuntimeClient(apiKey);
+
+ let linkedUserName: string | undefined;
+ if (principal.kind === "oidc" && principal.user.headscaleUserId) {
+ try {
+ const users = await api.getUsers();
+ const hsUser = users.find((u) => u.id === principal.user.headscaleUserId);
+ linkedUserName = hsUser?.name;
+ } catch {
+ // API unavailable, skip linked user resolution
+ }
+ }
+
+ return { linkedUserName };
+}
+
+const downloads = [
+ {
+ href: "https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe",
+ icon: windowsSvg,
+ name: "Windows",
+ note: "Windows 10+",
+ },
+ {
+ href: "https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg",
+ icon: macosSvg,
+ name: "macOS",
+ note: "macOS Big Sur+",
+ },
+ {
+ href: "https://apps.apple.com/us/app/tailscale/id1470499037",
+ icon: iosSvg,
+ name: "iOS",
+ note: "iOS 15+",
+ },
+ {
+ href: "https://play.google.com/store/apps/details?id=com.tailscale.ipn",
+ icon: androidSvg,
+ name: "Android",
+ note: "Android 8+",
+ },
+];
+
+export default function Home({ loaderData }: Route.ComponentProps) {
+ return (
+
+ {loaderData.linkedUserName && (
+
+
+
+ Your account is linked to Headscale user {loaderData.linkedUserName}.
+
+
+ )}
+
+ Access your network via Tailscale
+
+ You've successfully authenticated but don't have access to the dashboard. You can still
+ connect to your Headscale network by installing Tailscale.
+
+
+
+
+
+ Linux
+
+
+
+ curl -fsSL https://tailscale.com/install.sh | sh
+
+
+
+
+ Need access to the dashboard? Contact your administrator to request access.
+
+
+
+ );
+}
diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx
index d169288..e1ecb58 100644
--- a/app/routes/machines/components/machine-row.tsx
+++ b/app/routes/machines/components/machine-row.tsx
@@ -3,7 +3,7 @@ import { useMemo } from "react";
import Chip from "~/components/Chip";
import Link from "~/components/link";
-import Menu from "~/components/Menu";
+import { Menu, MenuContent, MenuItem, MenuTrigger } from "~/components/menu";
import StatusCircle from "~/components/StatusCircle";
import { ExitNodeTag } from "~/components/tags/ExitNode";
import { ExpiryTag } from "~/components/tags/Expiry";
@@ -13,7 +13,7 @@ import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
import type { User } from "~/types";
import cn from "~/utils/cn";
import * as hinfo from "~/utils/host-info";
-import { PopulatedNode } from "~/utils/node-info";
+import type { PopulatedNode } from "~/utils/node-info";
import { formatTimeDelta } from "~/utils/time";
import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
@@ -76,29 +76,28 @@ export default function MachineRow({
{node.ipAddresses[0]}
-
@@ -190,25 +189,31 @@ export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
return uiTags.map((tag) => {
switch (tag) {
case "exit-approved":
- case "exit-waiting":
+ case "exit-waiting": {
return ;
+ }
case "subnet-approved":
- case "subnet-waiting":
+ case "subnet-waiting": {
return ;
+ }
case "expired":
- case "no-expiry":
+ case "no-expiry": {
return ;
+ }
- case "tailscale-ssh":
+ case "tailscale-ssh": {
return ;
+ }
- case "headplane-agent":
+ case "headplane-agent": {
return ;
+ }
- default:
+ default: {
return null;
+ }
}
});
}
diff --git a/app/routes/machines/components/menu.tsx b/app/routes/machines/components/menu.tsx
index 26edf91..8b94e3a 100644
--- a/app/routes/machines/components/menu.tsx
+++ b/app/routes/machines/components/menu.tsx
@@ -2,7 +2,7 @@ import { Cog, Ellipsis, SquareTerminal } from "lucide-react";
import { useState } from "react";
import Button from "~/components/Button";
-import Menu from "~/components/Menu";
+import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import type { User } from "~/types";
import cn from "~/utils/cn";
import { PopulatedNode } from "~/utils/node-info";
@@ -140,39 +140,38 @@ export default function MachineMenu({
)
) : undefined}
-
- {isFullButton ? (
-
-
-