+
## Download Clients
diff --git a/cli/commands/clearExitNodes.ts b/cli/commands/clearExitNodes.ts
index 2a2832039..c7512a18c 100644
--- a/cli/commands/clearExitNodes.ts
+++ b/cli/commands/clearExitNodes.ts
@@ -23,7 +23,7 @@ export const clearExitNodes: CommandModule<
// Delete all exit nodes
const deletedCount = await db
.delete(exitNodes)
- .where(eq(exitNodes.exitNodeId, exitNodes.exitNodeId)) .returning();; // delete all
+ .where(eq(exitNodes.exitNodeId, exitNodes.exitNodeId)).returning();; // delete all
console.log(`Deleted ${deletedCount.length} exit node(s) from the database`);
diff --git a/config/ha-reference/README.md b/config/ha-reference/README.md
new file mode 100644
index 000000000..9aac0df59
--- /dev/null
+++ b/config/ha-reference/README.md
@@ -0,0 +1,3 @@
+## Example Docker Reference HA Deployment
+
+This directory contains a basic reference config for a highly available deployment of Pangolin with two nodes. For more information [refer to the docs](https://docs.pangolin.net/self-host/clustering/understanding-clustering).
\ No newline at end of file
diff --git a/config/ha-reference/database/docker-compose.yml b/config/ha-reference/database/docker-compose.yml
new file mode 100644
index 000000000..81b85ddc3
--- /dev/null
+++ b/config/ha-reference/database/docker-compose.yml
@@ -0,0 +1,23 @@
+services:
+ postgres:
+ image: postgres:17
+ container_name: postgres
+ environment:
+ POSTGRES_DB: postgres # Default database name
+ POSTGRES_USER: postgres # Default user
+ POSTGRES_PASSWORD: password # Default password (change for production!)
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ ports:
+ - "5432:5432"
+ restart: always
+
+ redis:
+ image: redis:latest
+ container_name: redis
+ ports:
+ - "6379:6379"
+ restart: always
+
+volumes:
+ postgres_data:
diff --git a/config/ha-reference/node1/config/certificates/.gitkeep b/config/ha-reference/node1/config/certificates/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/config/ha-reference/node1/config/config.yml b/config/ha-reference/node1/config/config.yml
new file mode 100644
index 000000000..5317262cb
--- /dev/null
+++ b/config/ha-reference/node1/config/config.yml
@@ -0,0 +1,38 @@
+# To see all available options, please visit the docs:
+# https://docs.pangolin.net/
+
+gerbil:
+ start_port: 51820
+ base_endpoint: "- {t("inviteExpiresIn", { - days: expiresInDays - })} -
-+ {t("inviteExpiresIn", { + days: expiresInDays + })} +
+ {inviteLink &&+ {t("promoteServerAdminQuestion", { + selectedUser: getUserDisplayName({ + email: promoting.email, + name: promoting.name, + username: promoting.username + }) + })} +
+ +{t("promoteServerAdminMessage")}
++ {t("demoteServerAdminQuestion", { + selectedUser: getUserDisplayName({ + email: demoting.email, + name: demoting.name, + username: demoting.username + }) + })} +
+ ++ {t("demoteServerAdminMessage", { + selectedUser: getUserDisplayName({ + email: demoting.email, + name: demoting.name, + username: demoting.username + }) + })} +
+- {t("approvalsEmptyStateDescription")} -
-+ {t("approvalsEmptyStateDescription")} +
-- {t( - "approvalsEmptyStateStep1Description" - )} -
-- {t( - "approvalsEmptyStateStep2Description" - )} -
-+ {t("approvalsEmptyStateHowToTitle")} +
++ {t("approvalsEmptyStatePreviewDescription")} +
+-
+ );
+ }
+ },
+ {
+ accessorKey: "subnet",
+ friendlyName: t("subnet"),
+ header: () => {t("subnet")},
+ cell: ({ row }) => {row.original.subnet || "-"}
+ },
+ {
+ accessorKey: "utilitySubnet",
+ friendlyName: t("utilitySubnet"),
+ header: () => {t("utilitySubnet")},
+ cell: ({ row }) => (
+ {row.original.utilitySubnet || "-"}
+ )
+ },
+ {
+ accessorKey: "userCount",
+ friendlyName: t("users"),
+ header: () => {t("users")},
+ cell: ({ row }) => {row.original.userCount}
+ },
+ {
+ accessorKey: "siteCount",
+ friendlyName: t("sites"),
+ header: () => {t("sites")},
+ cell: ({ row }) => {row.original.siteCount}
+ },
+ {
+ accessorKey: "resourceCount",
+ friendlyName: t("resources"),
+ header: () => {t("resources")},
+ cell: ({ row }) => {row.original.resourceCount}
+ },
+
+ {
+ id: "actions",
+ enableHiding: false,
+ header: () => ,
+ cell: ({ row }) => {
+ const orgRow = row.original;
+ return (
+ {t("orgQuestionRemove")}
+{t("orgMessageRemove")}
+- {t("inviteQuestionRegenerate", { - email: invitation?.email || "" - })} -
-{invitation?.email}
{t("inviteRegenerateMessage")}
-+ {t("inviteExpiresIn", { + days: expiresInDays + })} +
+ {inviteLink &&{t("commands")}
diff --git a/src/components/resource-launcher/LauncherResourcePanel.tsx b/src/components/resource-launcher/LauncherResourcePanel.tsx
index e35d9dce6..68944153f 100644
--- a/src/components/resource-launcher/LauncherResourcePanel.tsx
+++ b/src/components/resource-launcher/LauncherResourcePanel.tsx
@@ -1,6 +1,8 @@
"use client";
import { AiClientConfigSection } from "@app/components/ai-client-config/AiClientConfigSection";
+import { AiConfigBlocks } from "@app/components/ai-client-config/AiConfigBlocks";
+import CopyTextBox from "@app/components/CopyTextBox";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
@@ -28,7 +30,23 @@ import {
} from "@app/components/SidePanel";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger
+} from "@app/components/ui/dropdown-menu";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow
+} from "@app/components/ui/table";
import { useMyVirtualApiKeySecret } from "@app/hooks/useMyVirtualApiKeySecret";
+import type { AiConfigBlock } from "@app/lib/aiClientConfig";
+import { cn } from "@app/lib/cn";
import {
derivePublicAuthState,
formatPublicResourceType
@@ -36,7 +54,10 @@ import {
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
import { launcherQueries } from "@app/lib/queries";
-import type { LauncherResource } from "@server/routers/launcher/types";
+import type {
+ LauncherResource,
+ LauncherSiteInfo
+} from "@server/routers/launcher/types";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
@@ -44,15 +65,18 @@ import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
+ ChevronsUpDown,
Clock,
ExternalLink,
Loader2,
+ MoreHorizontal,
ShieldCheck,
ShieldOff,
XCircle
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
+import { useState } from "react";
type LauncherResourcePanelProps = {
open: boolean;
@@ -60,6 +84,7 @@ type LauncherResourcePanelProps = {
resource: LauncherResource | null;
orgId: string;
isAdmin: boolean;
+ onFilterBySite: (site: LauncherSiteInfo) => void;
};
type LauncherResourceDetailResult =
@@ -153,6 +178,249 @@ function HealthStatusDisplay({
const PUBLIC_AUTH_METHODS_MODES = ["http", "ssh", "rdp", "vnc"];
const PUBLIC_AUTH_BADGE_MODES = [...PUBLIC_AUTH_METHODS_MODES, "inference"];
+type SitesSortKey = "name" | "status";
+type SitesSortOrder = "asc" | "desc";
+
+function siteStatusSortValue(site: LauncherSiteInfo): number {
+ if (site.type !== "newt" && site.type !== "wireguard") {
+ return -1;
+ }
+ if (typeof site.online !== "boolean") {
+ return -1;
+ }
+ return site.online ? 1 : 0;
+}
+
+function SiteStatusCell({ site }: { site: LauncherSiteInfo }) {
+ const t = useTranslations();
+
+ if (site.type !== "newt" && site.type !== "wireguard") {
+ return -;
+ }
+
+ if (typeof site.online !== "boolean") {
+ return -;
+ }
+
+ return (
+
+
+ {site.online ? t("online") : t("offline")}
+
+ );
+}
+
+function LauncherResourceSitesSection({
+ orgId,
+ sites,
+ isAdmin,
+ onFilterBySite
+}: {
+ orgId: string;
+ sites: LauncherSiteInfo[];
+ isAdmin: boolean;
+ onFilterBySite: (site: LauncherSiteInfo) => void;
+}) {
+ const t = useTranslations();
+ const [sortKey, setSortKey] = useState
{message ?? t("noResults")}
+
+