Compare commits

...

14 Commits

Author SHA1 Message Date
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
17 changed files with 200 additions and 44 deletions

View File

@@ -60,7 +60,7 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
| <img width=500 /> | Description | | <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: 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. | | **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", "provisioningKeysUpdated": "Provisioning key updated",
"provisioningKeysUpdatedDescription": "Your changes have been saved.", "provisioningKeysUpdatedDescription": "Your changes have been saved.",
"provisioningKeysBannerTitle": "Site Provisioning Keys", "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", "provisioningKeysBannerButtonText": "Learn More",
"pendingSitesBannerTitle": "Pending Sites", "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", "pendingSitesBannerButtonText": "Learn More",
"apiKeysSettings": "{apiKeyName} Settings", "apiKeysSettings": "{apiKeyName} Settings",
"userTitle": "Manage All Users", "userTitle": "Manage All Users",
@@ -2348,7 +2348,7 @@
"description": "Enterprise features, 50 users, 50 sites, and priority support." "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": { "buttons": {
"continueToCheckout": "Continue to Checkout" "continueToCheckout": "Continue to Checkout"
}, },
@@ -2609,6 +2609,9 @@
"machineClients": "Machine Clients", "machineClients": "Machine Clients",
"install": "Install", "install": "Install",
"run": "Run", "run": "Run",
"envFile": "Environment File",
"serviceFile": "Service File",
"enableAndStart": "Enable and Start",
"clientNameDescription": "The display name of the client that can be changed later.", "clientNameDescription": "The display name of the client that can be changed later.",
"clientAddress": "Client Address (Advanced)", "clientAddress": "Client Address (Advanced)",
"setupFailedToFetchSubnet": "Failed to fetch default subnet", "setupFailedToFetchSubnet": "Failed to fetch default subnet",

View File

@@ -8,6 +8,7 @@ import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { buildClientConfigurationForNewtClient } from "./buildConfiguration"; import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
import { convertTargetsIfNessicary } from "../client/targets"; import { convertTargetsIfNessicary } from "../client/targets";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
export const handleGetConfigMessage: MessageHandler = async (context) => { export const handleGetConfigMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;
@@ -55,7 +56,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) { if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
logger.warn( 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; return;
} }

View File

@@ -1,4 +1,4 @@
import { db, newts, sites } from "@server/db"; import { db, newts, sites, targetHealthCheck, targets } from "@server/db";
import { import {
hasActiveConnections, hasActiveConnections,
getClientConfigVersion getClientConfigVersion
@@ -78,6 +78,32 @@ export const startNewtOfflineChecker = (): void => {
.update(sites) .update(sites)
.set({ online: false }) .set({ online: false })
.where(eq(sites.siteId, staleSite.siteId)); .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 // 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 // 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) { for (const site of allWireguardSites) {
const lastBandwidthUpdate = new Date(site.lastBandwidthUpdate!).getTime() / 1000; const lastBandwidthUpdate =
new Date(site.lastBandwidthUpdate!).getTime() / 1000;
if ( if (
lastBandwidthUpdate < wireguardOfflineThreshold && lastBandwidthUpdate < wireguardOfflineThreshold &&
site.online site.online

View File

@@ -20,6 +20,7 @@ import { handleFingerprintInsertion } from "./fingerprintingUtils";
import { Alias } from "@server/lib/ip"; import { Alias } from "@server/lib/ip";
import { build } from "@server/build"; import { build } from "@server/build";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
export const handleOlmRegisterMessage: MessageHandler = async (context) => { export const handleOlmRegisterMessage: MessageHandler = async (context) => {
logger.info("Handling register olm message!"); 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 ??? // 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) { if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
logger.warn( 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; return;
} }

View File

@@ -77,7 +77,8 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
const [targetCheck] = await db const [targetCheck] = await db
.select({ .select({
targetId: targets.targetId, targetId: targets.targetId,
siteId: targets.siteId siteId: targets.siteId,
hcStatus: targetHealthCheck.hcHealth
}) })
.from(targets) .from(targets)
.innerJoin( .innerJoin(
@@ -85,6 +86,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
eq(targets.resourceId, resources.resourceId) eq(targets.resourceId, resources.resourceId)
) )
.innerJoin(sites, eq(targets.siteId, sites.siteId)) .innerJoin(sites, eq(targets.siteId, sites.siteId))
.innerJoin(targetHealthCheck, eq(targets.targetId, targetHealthCheck.targetId))
.where( .where(
and( and(
eq(targets.targetId, targetIdNum), eq(targets.targetId, targetIdNum),
@@ -101,6 +103,14 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
continue; 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 // Update the target's health status in the database
await db await db
.update(targetHealthCheck) .update(targetHealthCheck)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,14 +10,14 @@ import {
import { CheckboxWithLabel } from "./ui/checkbox"; import { CheckboxWithLabel } from "./ui/checkbox";
import { OptionSelect, type OptionSelectOption } from "./OptionSelect"; import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
import { useState } from "react"; import { useState } from "react";
import { FaCubes, FaDocker, FaWindows } from "react-icons/fa"; import { FaApple, FaCubes, FaDocker, FaLinux, FaWindows } from "react-icons/fa";
import { Terminal } from "lucide-react";
import { SiKubernetes, SiNixos } from "react-icons/si"; import { SiKubernetes, SiNixos } from "react-icons/si";
export type CommandItem = string | { title: string; command: string }; export type CommandItem = string | { title: string; command: string };
const PLATFORMS = [ const PLATFORMS = [
"unix", "linux",
"macos",
"docker", "docker",
"kubernetes", "kubernetes",
"podman", "podman",
@@ -43,7 +43,7 @@ export function NewtSiteInstallCommands({
const t = useTranslations(); const t = useTranslations();
const [acceptClients, setAcceptClients] = useState(true); const [acceptClients, setAcceptClients] = useState(true);
const [platform, setPlatform] = useState<Platform>("unix"); const [platform, setPlatform] = useState<Platform>("linux");
const [architecture, setArchitecture] = useState( const [architecture, setArchitecture] = useState(
() => getArchitectures(platform)[0] () => getArchitectures(platform)[0]
); );
@@ -54,8 +54,68 @@ export function NewtSiteInstallCommands({
: ""; : "";
const commandList: Record<Platform, Record<string, CommandItem[]>> = { const commandList: Record<Platform, Record<string, CommandItem[]>> = {
unix: { linux: {
All: [ 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"), title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash` command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
@@ -131,7 +191,7 @@ WantedBy=default.target`
] ]
}, },
nixos: { nixos: {
All: [ Flake: [
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}` `nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
] ]
} }
@@ -172,9 +232,9 @@ WantedBy=default.target`
<OptionSelect<string> <OptionSelect<string>
label={ label={
["docker", "podman"].includes(platform) platform === "windows"
? t("method") ? t("architecture")
: t("architecture") : t("method")
} }
options={getArchitectures(platform).map((arch) => ({ options={getArchitectures(platform).map((arch) => ({
value: arch, value: arch,
@@ -261,8 +321,10 @@ function getPlatformIcon(platformName: Platform) {
switch (platformName) { switch (platformName) {
case "windows": case "windows":
return <FaWindows className="h-4 w-4 mr-2" />; return <FaWindows className="h-4 w-4 mr-2" />;
case "unix": case "linux":
return <Terminal className="h-4 w-4 mr-2" />; return <FaLinux className="h-4 w-4 mr-2" />;
case "macos":
return <FaApple className="h-4 w-4 mr-2" />;
case "docker": case "docker":
return <FaDocker className="h-4 w-4 mr-2" />; return <FaDocker className="h-4 w-4 mr-2" />;
case "kubernetes": case "kubernetes":
@@ -272,7 +334,7 @@ function getPlatformIcon(platformName: Platform) {
case "nixos": case "nixos":
return <SiNixos className="h-4 w-4 mr-2" />; return <SiNixos className="h-4 w-4 mr-2" />;
default: 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) { switch (platformName) {
case "windows": case "windows":
return "Windows"; return "Windows";
case "unix": case "linux":
return "Unix & macOS"; return "Linux";
case "macos":
return "macOS";
case "docker": case "docker":
return "Docker"; return "Docker";
case "kubernetes": case "kubernetes":
@@ -291,14 +355,16 @@ function getPlatformName(platformName: Platform) {
case "nixos": case "nixos":
return "NixOS"; return "NixOS";
default: default:
return "Unix / macOS"; return "Linux";
} }
} }
function getArchitectures(platform: Platform) { function getArchitectures(platform: Platform) {
switch (platform) { switch (platform) {
case "unix": case "linux":
return ["All"]; return ["Run", "Systemd Service"];
case "macos":
return ["Run"];
case "windows": case "windows":
return ["x64"]; return ["x64"];
case "docker": case "docker":
@@ -308,8 +374,8 @@ function getArchitectures(platform: Platform) {
case "podman": case "podman":
return ["Podman Quadlet", "Podman Run"]; return ["Podman Quadlet", "Podman Run"];
case "nixos": case "nixos":
return ["All"]; return ["Flake"];
default: default:
return ["x64"]; return ["Run"];
} }
} }

View File

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

View File

@@ -69,6 +69,7 @@ type ControlledDataTableProps<TData, TValue> = {
onAdd?: () => void; onAdd?: () => void;
onRefresh?: () => void; onRefresh?: () => void;
isRefreshing?: boolean; isRefreshing?: boolean;
refreshButtonDisabled?: boolean;
isNavigatingToAddPage?: boolean; isNavigatingToAddPage?: boolean;
searchPlaceholder?: string; searchPlaceholder?: string;
filters?: DataTableFilter[]; filters?: DataTableFilter[];
@@ -91,6 +92,7 @@ export function ControlledDataTable<TData, TValue>({
onAdd, onAdd,
onRefresh, onRefresh,
isRefreshing, isRefreshing,
refreshButtonDisabled = false,
searchPlaceholder = "Search...", searchPlaceholder = "Search...",
filters, filters,
filterDisplayMode = "label", filterDisplayMode = "label",
@@ -335,7 +337,7 @@ export function ControlledDataTable<TData, TValue>({
<Button <Button
variant="outline" variant="outline"
onClick={onRefresh} onClick={onRefresh}
disabled={isRefreshing} disabled={isRefreshing || refreshButtonDisabled}
> >
<RefreshCw <RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} 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; addButtonDisabled?: boolean;
onRefresh?: () => void; onRefresh?: () => void;
isRefreshing?: boolean; isRefreshing?: boolean;
refreshButtonDisabled?: boolean;
searchPlaceholder?: string; searchPlaceholder?: string;
searchColumn?: string; searchColumn?: string;
defaultSort?: { defaultSort?: {
@@ -207,6 +208,7 @@ export function DataTable<TData, TValue>({
addButtonDisabled = false, addButtonDisabled = false,
onRefresh, onRefresh,
isRefreshing, isRefreshing,
refreshButtonDisabled = false,
searchPlaceholder = "Search...", searchPlaceholder = "Search...",
searchColumn = "name", searchColumn = "name",
defaultSort, defaultSort,
@@ -624,7 +626,7 @@ export function DataTable<TData, TValue>({
<Button <Button
variant="outline" variant="outline"
onClick={onRefresh} onClick={onRefresh}
disabled={isRefreshing} disabled={isRefreshing || refreshButtonDisabled}
> >
<RefreshCw <RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} 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 res = await internal.get("/user", await authCookieHeader());
const userLocale = res.data?.data?.locale; const userLocale = res.data?.data?.locale;
if (userLocale && locales.includes(userLocale as Locale)) { if (userLocale && locales.includes(userLocale as Locale)) {
// Set the cookie so subsequent requests don't need the API call // 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, { (await cookies()).set(COOKIE_NAME, userLocale, {
maxAge: COOKIE_MAX_AGE, maxAge: COOKIE_MAX_AGE,
path: "/", path: "/",
sameSite: "lax" sameSite: "lax"
}); });
} catch {
// Cannot set cookies in this context (e.g. during rendering);
// the correct locale is still returned below.
}
return userLocale as Locale; return userLocale as Locale;
} }
} catch { } catch {