Compare commits

...

18 Commits

Author SHA1 Message Date
Owen Schwartz
1f74e1b320 Merge pull request #2776 from fosrl/dev
1.17.0-s.0
2026-04-03 11:39:35 -04:00
Owen
fee780cb81 Add siem to migration 2026-04-03 11:32:02 -04:00
Owen
5056cba85d Add siem to migration 2026-04-03 11:32:02 -04:00
miloschwartz
dab38ff82c use debug log on log stream start 2026-04-03 11:16:56 -04:00
Owen
d83fa63af5 Fix to null out the rewrite on the frontend too 2026-04-02 21:58:14 -04:00
Owen
d5837ab718 Fix to use the stored data 2026-04-02 21:57:59 -04:00
Owen
b7ccb92236 Merge branch 'main' into dev 2026-04-02 17:39:25 -04:00
Owen Schwartz
23a151dd45 Merge pull request #2771 from LaurenceJJones/feature/systemd-install-instructions
enhance: Systemd newt instructions
2026-04-02 12:13:44 -04:00
Laurence
122079ddb2 split unix to linux and macos, show method everything other than windows, change nixos all to flake so makes sense under method 2026-04-02 17:05:50 +01:00
Owen Schwartz
1d0b0ae6ec Merge pull request #2770 from fosrl/revert-2766-feature/systemd-install-instructions
Revert "enhance: Systemd newt unit instructions"
2026-04-02 11:43:15 -04:00
Owen Schwartz
a55dd769cf Revert "enhance: Systemd newt unit instructions" 2026-04-02 11:43:01 -04:00
Owen Schwartz
1dc3409135 Merge pull request #2766 from LaurenceJJones/feature/systemd-install-instructions
enhance: Systemd newt unit instructions
2026-04-02 11:40:50 -04:00
Laurence
1bb89fce26 enhance: Systemd newt unit
Add a systemd unit option directly from dashboard to prevent copy and paste mistakes
2026-04-02 12:21:53 +01:00
Owen
363c13c387 Impvove communication 2026-04-01 09:53:49 -07:00
Owen
08e4afaef0 Update hp log message 2026-03-31 17:06:56 -07:00
Owen
69aa6e2d1d Prevent increase in writes on reconnect 2026-03-31 17:00:06 -07:00
Owen
547865e0da Mark targets unhealthy when site is down
Fix #2675
Fix #2700
Fix #1742
2026-03-31 16:24:53 -07:00
Owen
3a9e79e6d5 Filter only newt sites on private resources 2026-03-31 16:17:17 -07:00
20 changed files with 295 additions and 53 deletions

View File

@@ -60,7 +60,7 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
| <img width=500 /> | Description |
|-----------------|--------------|
| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/understanding-nodes) and connect to our control plane. |
| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing - no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/understanding-nodes) and connect to our control plane. |
| **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. |
| **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. |

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "Provisioning key updated",
"provisioningKeysUpdatedDescription": "Your changes have been saved.",
"provisioningKeysBannerTitle": "Site Provisioning Keys",
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup no need to set up separate credentials for each site.",
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
"provisioningKeysBannerButtonText": "Learn More",
"pendingSitesBannerTitle": "Pending Sites",
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review. Approve each site before it becomes active and gains access to your resources.",
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"pendingSitesBannerButtonText": "Learn More",
"apiKeysSettings": "{apiKeyName} Settings",
"userTitle": "Manage All Users",
@@ -2348,7 +2348,7 @@
"description": "Enterprise features, 50 users, 50 sites, and priority support."
}
},
"personalUseOnly": "Personal use only (free license no checkout)",
"personalUseOnly": "Personal use only (free license - no checkout)",
"buttons": {
"continueToCheckout": "Continue to Checkout"
},
@@ -2609,6 +2609,9 @@
"machineClients": "Machine Clients",
"install": "Install",
"run": "Run",
"envFile": "Environment File",
"serviceFile": "Service File",
"enableAndStart": "Enable and Start",
"clientNameDescription": "The display name of the client that can be changed later.",
"clientAddress": "Client Address (Advanced)",
"setupFailedToFetchSubnet": "Failed to fetch default subnet",

View File

@@ -127,7 +127,7 @@ export class LogStreamingManager {
start(): void {
if (this.isRunning) return;
this.isRunning = true;
logger.info("LogStreamingManager: started");
logger.debug("LogStreamingManager: started");
this.schedulePoll(POLL_INTERVAL_MS);
}

View File

@@ -8,6 +8,7 @@ import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
import { convertTargetsIfNessicary } from "../client/targets";
import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
export const handleGetConfigMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context;
@@ -55,7 +56,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
logger.warn(
`handleGetConfigMessage: Site ${existingSite.siteId} last hole punch is too old, skipping`
`Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
);
return;
}

View File

@@ -1,4 +1,4 @@
import { db, newts, sites } from "@server/db";
import { db, newts, sites, targetHealthCheck, targets } from "@server/db";
import {
hasActiveConnections,
getClientConfigVersion
@@ -78,6 +78,32 @@ export const startNewtOfflineChecker = (): void => {
.update(sites)
.set({ online: false })
.where(eq(sites.siteId, staleSite.siteId));
const healthChecksOnSite = await db
.select()
.from(targetHealthCheck)
.innerJoin(
targets,
eq(targets.targetId, targetHealthCheck.targetId)
)
.innerJoin(sites, eq(sites.siteId, targets.siteId))
.where(eq(sites.siteId, staleSite.siteId));
for (const healthCheck of healthChecksOnSite) {
logger.info(
`Marking health check ${healthCheck.targetHealthCheck.targetHealthCheckId} offline due to site ${staleSite.siteId} being marked offline`
);
await db
.update(targetHealthCheck)
.set({ hcHealth: "unknown" })
.where(
eq(
targetHealthCheck.targetHealthCheckId,
healthCheck.targetHealthCheck
.targetHealthCheckId
)
);
}
}
// this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites
@@ -102,7 +128,8 @@ export const startNewtOfflineChecker = (): void => {
// loop over each one. If its offline and there is a new update then mark it online. If its online and there is no update then mark it offline
for (const site of allWireguardSites) {
const lastBandwidthUpdate = new Date(site.lastBandwidthUpdate!).getTime() / 1000;
const lastBandwidthUpdate =
new Date(site.lastBandwidthUpdate!).getTime() / 1000;
if (
lastBandwidthUpdate < wireguardOfflineThreshold &&
site.online

View File

@@ -20,6 +20,7 @@ import { handleFingerprintInsertion } from "./fingerprintingUtils";
import { Alias } from "@server/lib/ip";
import { build } from "@server/build";
import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
logger.info("Handling register olm message!");
@@ -274,7 +275,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
// TODO: I still think there is a better way to do this rather than locking it out here but ???
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
logger.warn(
"Client last hole punch is too old and we have sites to send; skipping this register"
`Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
);
return;
}

View File

@@ -77,7 +77,8 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
const [targetCheck] = await db
.select({
targetId: targets.targetId,
siteId: targets.siteId
siteId: targets.siteId,
hcStatus: targetHealthCheck.hcHealth
})
.from(targets)
.innerJoin(
@@ -85,6 +86,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
eq(targets.resourceId, resources.resourceId)
)
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.innerJoin(targetHealthCheck, eq(targets.targetId, targetHealthCheck.targetId))
.where(
and(
eq(targets.targetId, targetIdNum),
@@ -101,6 +103,14 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
continue;
}
// check if the status has changed
if (targetCheck.hcStatus === healthStatus.status) {
logger.debug(
`Health status for target ${targetId} is already ${healthStatus.status}, skipping update`
);
continue;
}
// Update the target's health status in the database
await db
.update(targetHealthCheck)

View File

@@ -104,6 +104,42 @@ export default async function migration() {
CONSTRAINT "userOrgRoles_userId_orgId_roleId_unique" UNIQUE("userId","orgId","roleId")
);
`);
await db.execute(sql`
CREATE TABLE "eventStreamingCursors" (
"cursorId" serial PRIMARY KEY NOT NULL,
"destinationId" integer NOT NULL,
"logType" varchar(50) NOT NULL,
"lastSentId" bigint DEFAULT 0 NOT NULL,
"lastSentAt" bigint
);
`);
await db.execute(sql`
CREATE TABLE "eventStreamingDestinations" (
"destinationId" serial PRIMARY KEY NOT NULL,
"orgId" varchar(255) NOT NULL,
"sendConnectionLogs" boolean DEFAULT false NOT NULL,
"sendRequestLogs" boolean DEFAULT false NOT NULL,
"sendActionLogs" boolean DEFAULT false NOT NULL,
"sendAccessLogs" boolean DEFAULT false NOT NULL,
"type" varchar(50) NOT NULL,
"config" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"createdAt" bigint NOT NULL,
"updatedAt" bigint NOT NULL
);
`);
await db.execute(
sql`ALTER TABLE "eventStreamingCursors" ADD CONSTRAINT "eventStreamingCursors_destinationId_eventStreamingDestinations_destinationId_fk" FOREIGN KEY ("destinationId") REFERENCES "public"."eventStreamingDestinations"("destinationId") ON DELETE cascade ON UPDATE no action;`
);
await db.execute(
sql`ALTER TABLE "eventStreamingDestinations" ADD CONSTRAINT "eventStreamingDestinations_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;`
);
await db.execute(
sql`CREATE UNIQUE INDEX "idx_eventStreamingCursors_dest_type" ON "eventStreamingCursors" USING btree ("destinationId","logType");`
);
await db.execute(
sql`ALTER TABLE "userOrgs" DROP CONSTRAINT "userOrgs_roleId_roles_roleId_fk";`
);
@@ -177,8 +213,12 @@ export default async function migration() {
sql`CREATE INDEX "idx_accessAuditLog_siteResourceId" ON "connectionAuditLog" USING btree ("siteResourceId");`
);
await db.execute(sql`ALTER TABLE "userInvites" DROP COLUMN "roleId";`);
await db.execute(sql`ALTER TABLE "siteProvisioningKeys" ADD COLUMN "approveNewSites" boolean DEFAULT true NOT NULL;`);
await db.execute(sql`ALTER TABLE "sites" ADD COLUMN "status" varchar DEFAULT 'approved';`);
await db.execute(
sql`ALTER TABLE "siteProvisioningKeys" ADD COLUMN "approveNewSites" boolean DEFAULT true NOT NULL;`
);
await db.execute(
sql`ALTER TABLE "sites" ADD COLUMN "status" varchar DEFAULT 'approved';`
);
await db.execute(sql`COMMIT`);
console.log("Migrated database");

View File

@@ -76,9 +76,15 @@ export default async function migration() {
`
).run();
db.prepare(`CREATE INDEX 'idx_accessAuditLog_startedAt' ON 'connectionAuditLog' ('startedAt');`).run();
db.prepare(`CREATE INDEX 'idx_accessAuditLog_org_startedAt' ON 'connectionAuditLog' ('orgId','startedAt');`).run();
db.prepare(`CREATE INDEX 'idx_accessAuditLog_siteResourceId' ON 'connectionAuditLog' ('siteResourceId');`).run();
db.prepare(
`CREATE INDEX 'idx_accessAuditLog_startedAt' ON 'connectionAuditLog' ('startedAt');`
).run();
db.prepare(
`CREATE INDEX 'idx_accessAuditLog_org_startedAt' ON 'connectionAuditLog' ('orgId','startedAt');`
).run();
db.prepare(
`CREATE INDEX 'idx_accessAuditLog_siteResourceId' ON 'connectionAuditLog' ('siteResourceId');`
).run();
db.prepare(
`
@@ -168,6 +174,42 @@ export default async function migration() {
);
`
).run();
db.prepare(
`
CREATE TABLE 'eventStreamingCursors' (
'cursorId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
'destinationId' integer NOT NULL,
'logType' text NOT NULL,
'lastSentId' integer DEFAULT 0 NOT NULL,
'lastSentAt' integer,
FOREIGN KEY ('destinationId') REFERENCES 'eventStreamingDestinations'('destinationId') ON UPDATE no action ON DELETE cascade
);
`
).run();
db.prepare(
`
CREATE UNIQUE INDEX 'idx_eventStreamingCursors_dest_type' ON 'eventStreamingCursors' ('destinationId','logType');--> statement-breakpoint
`
).run();
db.prepare(
`
CREATE TABLE 'eventStreamingDestinations' (
'destinationId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
'orgId' text NOT NULL,
'sendConnectionLogs' integer DEFAULT false NOT NULL,
'sendRequestLogs' integer DEFAULT false NOT NULL,
'sendActionLogs' integer DEFAULT false NOT NULL,
'sendAccessLogs' integer DEFAULT false NOT NULL,
'type' text NOT NULL,
'config' text NOT NULL,
'enabled' integer DEFAULT true NOT NULL,
'createdAt' integer NOT NULL,
'updatedAt' integer NOT NULL,
FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade
);
`
).run();
db.prepare(
`INSERT INTO '__new_userInvites'("inviteId", "orgId", "email", "expiresAt", "token") SELECT "inviteId", "orgId", "email", "expiresAt", "token" FROM 'userInvites';`
).run();
@@ -191,8 +233,12 @@ export default async function migration() {
`ALTER TABLE 'user' ADD 'marketingEmailConsent' integer DEFAULT false;`
).run();
db.prepare(`ALTER TABLE 'user' ADD 'locale' text;`).run();
db.prepare(`ALTER TABLE 'siteProvisioningKeys' ADD COLUMN 'approveNewSites' integer DEFAULT 1 NOT NULL;`).run();
db.prepare(`ALTER TABLE 'sites' ADD COLUMN 'status' text DEFAULT 'approved';`).run();
db.prepare(
`ALTER TABLE 'siteProvisioningKeys' ADD COLUMN 'approveNewSites' integer DEFAULT 1 NOT NULL;`
).run();
db.prepare(
`ALTER TABLE 'sites' ADD COLUMN 'status' text DEFAULT 'approved';`
).run();
})();
db.pragma("foreign_keys = ON");

View File

@@ -9,6 +9,8 @@ import DismissableBanner from "@app/components/DismissableBanner";
import Link from "next/link";
import { Button } from "@app/components/ui/button";
import { ArrowRight, Plug } from "lucide-react";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
type PendingSitesPageProps = {
params: Promise<{ orgId: string }>;
@@ -96,6 +98,10 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) {
</Button>
</Link>
</DismissableBanner>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.SiteProvisioningKeys]}
/>
<PendingSitesTable
sites={siteRows}
orgId={params.orgId}

View File

@@ -400,7 +400,11 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId, config)
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
}
trigger={
<Button
@@ -424,7 +428,11 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId, config)
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
}
trigger={
<Button

View File

@@ -776,7 +776,11 @@ export default function Page() {
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId, config)
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
}
trigger={
<Button
@@ -800,7 +804,11 @@ export default function Page() {
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId, config)
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
}
trigger={
<Button

View File

@@ -614,6 +614,7 @@ export function InternalResourceForm({
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
filterTypes={["newt"]}
onSelectSite={(site) => {
setSelectedSite(site);
field.onChange(site.siteId);

View File

@@ -15,6 +15,8 @@ import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { build } from "@server/build";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { type PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
@@ -63,6 +65,10 @@ export default function PendingSitesTable({
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const canUseSiteProvisioning =
isPaidUser(tierMatrix[TierFeature.SiteProvisioningKeys]) &&
build !== "oss";
const booleanSearchFilterSchema = z
.enum(["true", "false"])
@@ -450,6 +456,7 @@ export default function PendingSitesTable({
onSearch={handleSearchChange}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
refreshButtonDisabled={!canUseSiteProvisioning}
rowCount={rowCount}
columnVisibility={{
niceId: false,

View File

@@ -311,6 +311,7 @@ export default function SiteProvisioningKeysTable({
addButtonDisabled={!canUseSiteProvisioning}
onRefresh={refreshData}
isRefreshing={isRefreshing}
refreshButtonDisabled={!canUseSiteProvisioning}
addButtonText={t("provisioningKeysAdd")}
enableColumnVisibility={true}
stickyLeftColumn="name"

View File

@@ -10,14 +10,14 @@ import {
import { CheckboxWithLabel } from "./ui/checkbox";
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
import { useState } from "react";
import { FaCubes, FaDocker, FaWindows } from "react-icons/fa";
import { Terminal } from "lucide-react";
import { FaApple, FaCubes, FaDocker, FaLinux, FaWindows } from "react-icons/fa";
import { SiKubernetes, SiNixos } from "react-icons/si";
export type CommandItem = string | { title: string; command: string };
const PLATFORMS = [
"unix",
"linux",
"macos",
"docker",
"kubernetes",
"podman",
@@ -43,7 +43,7 @@ export function NewtSiteInstallCommands({
const t = useTranslations();
const [acceptClients, setAcceptClients] = useState(true);
const [platform, setPlatform] = useState<Platform>("unix");
const [platform, setPlatform] = useState<Platform>("linux");
const [architecture, setArchitecture] = useState(
() => getArchitectures(platform)[0]
);
@@ -54,8 +54,68 @@ export function NewtSiteInstallCommands({
: "";
const commandList: Record<Platform, Record<string, CommandItem[]>> = {
unix: {
All: [
linux: {
Run: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
},
{
title: t("run"),
command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
}
],
"Systemd Service": [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
},
{
title: t("envFile"),
command: `# Create the directory and environment file
sudo install -d -m 0755 /etc/newt
sudo tee /etc/newt/newt.env > /dev/null << 'EOF'
NEWT_ID=${id}
NEWT_SECRET=${secret}
PANGOLIN_ENDPOINT=${endpoint}${!acceptClients ? `
DISABLE_CLIENTS=true` : ""}
EOF
sudo chmod 600 /etc/newt/newt.env`
},
{
title: t("serviceFile"),
command: `sudo tee /etc/systemd/system/newt.service > /dev/null << 'EOF'
[Unit]
Description=Newt
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=root
Group=root
EnvironmentFile=/etc/newt/newt.env
ExecStart=/usr/local/bin/newt
Restart=always
RestartSec=2
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF`
},
{
title: t("enableAndStart"),
command: `sudo systemctl daemon-reload
sudo systemctl enable --now newt`
}
]
},
macos: {
Run: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
@@ -131,7 +191,7 @@ WantedBy=default.target`
]
},
nixos: {
All: [
Flake: [
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
]
}
@@ -172,9 +232,9 @@ WantedBy=default.target`
<OptionSelect<string>
label={
["docker", "podman"].includes(platform)
? t("method")
: t("architecture")
platform === "windows"
? t("architecture")
: t("method")
}
options={getArchitectures(platform).map((arch) => ({
value: arch,
@@ -261,8 +321,10 @@ function getPlatformIcon(platformName: Platform) {
switch (platformName) {
case "windows":
return <FaWindows className="h-4 w-4 mr-2" />;
case "unix":
return <Terminal className="h-4 w-4 mr-2" />;
case "linux":
return <FaLinux className="h-4 w-4 mr-2" />;
case "macos":
return <FaApple className="h-4 w-4 mr-2" />;
case "docker":
return <FaDocker className="h-4 w-4 mr-2" />;
case "kubernetes":
@@ -272,7 +334,7 @@ function getPlatformIcon(platformName: Platform) {
case "nixos":
return <SiNixos className="h-4 w-4 mr-2" />;
default:
return <Terminal className="h-4 w-4 mr-2" />;
return <FaLinux className="h-4 w-4 mr-2" />;
}
}
@@ -280,8 +342,10 @@ function getPlatformName(platformName: Platform) {
switch (platformName) {
case "windows":
return "Windows";
case "unix":
return "Unix & macOS";
case "linux":
return "Linux";
case "macos":
return "macOS";
case "docker":
return "Docker";
case "kubernetes":
@@ -291,14 +355,16 @@ function getPlatformName(platformName: Platform) {
case "nixos":
return "NixOS";
default:
return "Unix / macOS";
return "Linux";
}
}
function getArchitectures(platform: Platform) {
switch (platform) {
case "unix":
return ["All"];
case "linux":
return ["Run", "Systemd Service"];
case "macos":
return ["Run"];
case "windows":
return ["x64"];
case "docker":
@@ -308,8 +374,8 @@ function getArchitectures(platform: Platform) {
case "podman":
return ["Podman Quadlet", "Podman Run"];
case "nixos":
return ["All"];
return ["Flake"];
default:
return ["x64"];
return ["Run"];
}
}

View File

@@ -24,12 +24,14 @@ export type SitesSelectorProps = {
orgId: string;
selectedSite?: Selectedsite | null;
onSelectSite: (selected: Selectedsite) => void;
filterTypes?: string[];
};
export function SitesSelector({
orgId,
selectedSite,
onSelectSite
onSelectSite,
filterTypes
}: SitesSelectorProps) {
const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState("");
@@ -45,7 +47,9 @@ export function SitesSelector({
// always include the selected site in the list of sites shown
const sitesShown = useMemo(() => {
const allSites: Array<Selectedsite> = [...sites];
const allSites: Array<Selectedsite> = filterTypes
? sites.filter((s) => filterTypes.includes(s.type))
: [...sites];
if (
debouncedQuery.trim().length === 0 &&
selectedSite &&
@@ -54,7 +58,7 @@ export function SitesSelector({
allSites.unshift(selectedSite);
}
return allSites;
}, [debouncedQuery, sites, selectedSite]);
}, [debouncedQuery, sites, selectedSite, filterTypes]);
return (
<Command shouldFilter={false}>

View File

@@ -69,6 +69,7 @@ type ControlledDataTableProps<TData, TValue> = {
onAdd?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
refreshButtonDisabled?: boolean;
isNavigatingToAddPage?: boolean;
searchPlaceholder?: string;
filters?: DataTableFilter[];
@@ -91,6 +92,7 @@ export function ControlledDataTable<TData, TValue>({
onAdd,
onRefresh,
isRefreshing,
refreshButtonDisabled = false,
searchPlaceholder = "Search...",
filters,
filterDisplayMode = "label",
@@ -335,7 +337,7 @@ export function ControlledDataTable<TData, TValue>({
<Button
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
disabled={isRefreshing || refreshButtonDisabled}
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}

View File

@@ -174,6 +174,7 @@ type DataTableProps<TData, TValue> = {
addButtonDisabled?: boolean;
onRefresh?: () => void;
isRefreshing?: boolean;
refreshButtonDisabled?: boolean;
searchPlaceholder?: string;
searchColumn?: string;
defaultSort?: {
@@ -207,6 +208,7 @@ export function DataTable<TData, TValue>({
addButtonDisabled = false,
onRefresh,
isRefreshing,
refreshButtonDisabled = false,
searchPlaceholder = "Search...",
searchColumn = "name",
defaultSort,
@@ -624,7 +626,7 @@ export function DataTable<TData, TValue>({
<Button
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
disabled={isRefreshing || refreshButtonDisabled}
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}

View File

@@ -22,12 +22,21 @@ export async function getUserLocale(): Promise<Locale> {
const res = await internal.get("/user", await authCookieHeader());
const userLocale = res.data?.data?.locale;
if (userLocale && locales.includes(userLocale as Locale)) {
// Set the cookie so subsequent requests don't need the API call
(await cookies()).set(COOKIE_NAME, userLocale, {
maxAge: COOKIE_MAX_AGE,
path: "/",
sameSite: "lax"
});
// Try to cache in a cookie so subsequent requests skip the API
// call. cookies().set() is only permitted in Server Actions and
// Route Handlers — not during rendering — so we isolate it so
// that a write failure doesn't prevent the locale from being
// returned for the current request.
try {
(await cookies()).set(COOKIE_NAME, userLocale, {
maxAge: COOKIE_MAX_AGE,
path: "/",
sameSite: "lax"
});
} catch {
// Cannot set cookies in this context (e.g. during rendering);
// the correct locale is still returned below.
}
return userLocale as Locale;
}
} catch {