Merge branch 'dev' of github.com:fosrl/pangolin into dev

This commit is contained in:
Owen
2026-04-21 21:20:24 -07:00
17 changed files with 264 additions and 144 deletions

View File

@@ -3141,5 +3141,6 @@
"idpUnassociateMenu": "Unassociate", "idpUnassociateMenu": "Unassociate",
"idpDeleteAllOrgsMenu": "Delete", "idpDeleteAllOrgsMenu": "Delete",
"publicIpEndpoint": "Endpoint", "publicIpEndpoint": "Endpoint",
"lastTriggeredAt": "Last Trigger" "lastTriggeredAt": "Last Trigger",
"reject": "Reject"
} }

View File

@@ -121,7 +121,7 @@ export function createApiServer() {
const httpServer = apiServer.listen(externalPort, (err?: any) => { const httpServer = apiServer.listen(externalPort, (err?: any) => {
if (err) throw err; if (err) throw err;
logger.info( logger.info(
`API server is running on http://localhost:${externalPort}` `Dashboard API server is running on http://localhost:${externalPort}`
); );
}); });

View File

@@ -36,7 +36,7 @@ export function createInternalServer() {
internalServer.listen(internalPort, (err?: any) => { internalServer.listen(internalPort, (err?: any) => {
if (err) throw err; if (err) throw err;
logger.info( logger.info(
`Internal server is running on http://localhost:${internalPort}` `Internal API server is running on http://localhost:${internalPort}`
); );
}); });

View File

@@ -72,7 +72,7 @@ class TelemetryClient {
logger.debug("Successfully sent analytics data"); logger.debug("Successfully sent analytics data");
}); });
}, },
48 * 60 * 60 * 1000 336 * 60 * 60 * 1000
); );
this.collectAndSendAnalytics().catch((err) => { this.collectAndSendAnalytics().catch((err) => {

View File

@@ -29,7 +29,7 @@ export async function createNextServer() {
nextServer.listen(nextPort, (err?: any) => { nextServer.listen(nextPort, (err?: any) => {
if (err) throw err; if (err) throw err;
logger.info( logger.info(
`Next.js server is running on http://localhost:${nextPort}` `Dashboard Web UI server is running on http://localhost:${nextPort}`
); );
}); });

View File

@@ -89,7 +89,7 @@ async function pushCertUpdateToAffectedNewts(
return; return;
} }
logger.info( logger.debug(
`acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"` `acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"`
); );
@@ -187,7 +187,7 @@ async function pushCertUpdateToAffectedNewts(
newt.version newt.version
); );
logger.info( logger.debug(
`acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}` `acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}`
); );
} }
@@ -400,7 +400,7 @@ async function syncAcmeCerts(
}) })
.where(eq(certificates.domain, domain)); .where(eq(certificates.domain, domain));
logger.info( logger.debug(
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` `acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
); );
@@ -423,7 +423,7 @@ async function syncAcmeCerts(
wildcard wildcard
}); });
logger.info( logger.debug(
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` `acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
); );
@@ -461,7 +461,7 @@ export function initAcmeCertSync(): void {
const resolver = privateConfigData.acme?.resolver ?? "letsencrypt"; const resolver = privateConfigData.acme?.resolver ?? "letsencrypt";
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000; const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
logger.info( logger.debug(
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms` `acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
); );

View File

@@ -381,7 +381,7 @@ export function startPingAccumulator(): void {
// Don't prevent the process from exiting // Don't prevent the process from exiting
flushTimer.unref(); flushTimer.unref();
logger.info( logger.debug(
`Ping accumulator started (flush interval: ${FLUSH_INTERVAL_MS}ms)` `Ping accumulator started (flush interval: ${FLUSH_INTERVAL_MS}ms)`
); );
} }

View File

@@ -50,6 +50,7 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import OrgRolesTagField from "@app/components/OrgRolesTagField"; import OrgRolesTagField from "@app/components/OrgRolesTagField";
import CopyToClipboard from "@app/components/CopyToClipboard";
type UserType = "internal" | "oidc"; type UserType = "internal" | "oidc";
@@ -670,9 +671,8 @@ export default function Page() {
days: expiresInDays days: expiresInDays
})} })}
</p> </p>
<CopyTextBox <CopyToClipboard
text={inviteLink} text={inviteLink}
wrapText={false}
/> />
</div> </div>
</SettingsSectionBody> </SettingsSectionBody>

View File

@@ -101,7 +101,6 @@ export function LayoutMobileMenu({
"serverAdmin" "serverAdmin"
)} )}
</span> </span>
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
</Link> </Link>
</div> </div>
)} )}

View File

@@ -189,7 +189,6 @@ export function LayoutSidebar({
<span className="flex-1"> <span className="flex-1">
{t("serverAdmin")} {t("serverAdmin")}
</span> </span>
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
</> </>
)} )}
</Link> </Link>

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { import {
@@ -24,7 +25,8 @@ import {
ArrowUpRight, ArrowUpRight,
Check, Check,
ChevronsUpDownIcon, ChevronsUpDownIcon,
MoreHorizontal MoreHorizontal,
X
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
@@ -62,6 +64,9 @@ export default function PendingSitesTable({
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const [approvingIds, setApprovingIds] = useState<Set<number>>(new Set()); const [approvingIds, setApprovingIds] = useState<Set<number>>(new Set());
const [rejectingIds, setRejectingIds] = useState<Set<number>>(new Set());
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const t = useTranslations(); const t = useTranslations();
@@ -128,6 +133,33 @@ export default function PendingSitesTable({
} }
} }
async function rejectSite(siteId: number) {
setRejectingIds((prev) => new Set(prev).add(siteId));
try {
await api.delete(`/site/${siteId}`);
toast({
title: t("success"),
description: t("siteDeleted"),
variant: "default"
});
setIsDeleteModalOpen(false);
setSelectedSite(null);
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("siteErrorDelete"),
description: formatAxiosError(e, t("siteErrorDelete"))
});
} finally {
setRejectingIds((prev) => {
const next = new Set(prev);
next.delete(siteId);
return next;
});
}
}
const columns: ExtendedColumnDef<SiteRow>[] = [ const columns: ExtendedColumnDef<SiteRow>[] = [
{ {
accessorKey: "name", accessorKey: "name",
@@ -387,6 +419,7 @@ export default function PendingSitesTable({
cell: ({ row }) => { cell: ({ row }) => {
const siteRow = row.original; const siteRow = row.original;
const isApproving = approvingIds.has(siteRow.id); const isApproving = approvingIds.has(siteRow.id);
const isRejecting = rejectingIds.has(siteRow.id);
return ( return (
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<DropdownMenu> <DropdownMenu>
@@ -409,7 +442,18 @@ export default function PendingSitesTable({
</DropdownMenu> </DropdownMenu>
<Button <Button
variant="outline" variant="outline"
disabled={isApproving} disabled={isApproving || isRejecting}
onClick={() => {
setSelectedSite(siteRow);
setIsDeleteModalOpen(true);
}}
>
<X className="mr-2 w-4 h-4" />
{t("reject")}
</Button>
<Button
variant="outline"
disabled={isApproving || isRejecting}
onClick={() => approveSite(siteRow.id)} onClick={() => approveSite(siteRow.id)}
> >
<Check className="mr-2 w-4 h-4" /> <Check className="mr-2 w-4 h-4" />
@@ -446,28 +490,51 @@ export default function PendingSitesTable({
}, 300); }, 300);
return ( return (
<ControlledDataTable <>
columns={columns} {selectedSite && (
rows={sites} <ConfirmDeleteDialog
tableId="pending-sites-table" open={isDeleteModalOpen}
searchPlaceholder={t("searchSitesProgress")} setOpen={(val) => {
pagination={pagination} setIsDeleteModalOpen(val);
onPaginationChange={handlePaginationChange} if (!val) {
searchQuery={searchParams.get("query")?.toString()} setSelectedSite(null);
onSearch={handleSearchChange} }
onRefresh={refreshData} }}
isRefreshing={isRefreshing || isFiltering} dialog={
refreshButtonDisabled={!canUseSiteProvisioning} <div className="space-y-2">
rowCount={rowCount} <p>{t("siteQuestionRemove")}</p>
columnVisibility={{ <p>{t("siteMessageRemove")}</p>
niceId: false, </div>
nice: false, }
exitNode: false, buttonText={t("siteConfirmDelete")}
address: false onConfirm={async () => rejectSite(selectedSite.id)}
}} string={selectedSite.name}
enableColumnVisibility title={t("siteDelete")}
stickyLeftColumn="name" />
stickyRightColumn="actions" )}
/> <ControlledDataTable
columns={columns}
rows={sites}
tableId="pending-sites-table"
searchPlaceholder={t("searchSitesProgress")}
pagination={pagination}
onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
refreshButtonDisabled={!canUseSiteProvisioning}
rowCount={rowCount}
columnVisibility={{
niceId: false,
nice: false,
exitNode: false,
address: false
}}
enableColumnVisibility
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
); );
} }

View File

@@ -382,6 +382,7 @@ export default function UsersTable({
pagination={pagination} pagination={pagination}
rowCount={rowCount} rowCount={rowCount}
isNavigatingToAddPage={isNavigatingToAddPage} isNavigatingToAddPage={isNavigatingToAddPage}
addButtonText={t("accessUserCreate")}
searchQuery={searchParams.get("query")?.toString()} searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange} onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange} onPaginationChange={handlePaginationChange}

View File

@@ -40,6 +40,7 @@ import { useTranslations } from "next-intl";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { Badge } from "../ui/badge";
const FORM_ID = "alert-rule-form"; const FORM_ID = "alert-rule-form";
@@ -181,9 +182,9 @@ export default function AlertRuleGraphEditor({
> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{isNew && ( {isNew && (
<span className="text-xs rounded-md border bg-muted px-2 py-1 text-muted-foreground"> <Badge variant="secondary" >
{t("alertingDraftBadge")} {t("alertingDraftBadge")}
</span> </Badge>
)} )}
</div> </div>
<FormField <FormField

View File

@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0", "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs transition-colors outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0",
{ {
variants: { variants: {
variant: { variant: {
@@ -13,7 +13,7 @@ const badgeVariants = cva(
outlinePrimary: outlinePrimary:
"border-transparent bg-transparent border-primary text-primary", "border-transparent bg-transparent border-primary text-primary",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground", "bg-muted border text-secondary-foreground",
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground", "border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground", outline: "text-foreground",

View File

@@ -17,6 +17,7 @@ import {
TableHeader, TableHeader,
TableRow TableRow
} from "@/components/ui/table"; } from "@/components/ui/table";
import { DataTableEmptyState } from "@/components/ui/data-table-empty-state";
import { DataTablePagination } from "@app/components/DataTablePagination"; import { DataTablePagination } from "@app/components/DataTablePagination";
import type { DataTableAddAction } from "@app/components/ui/data-table"; import type { DataTableAddAction } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
@@ -249,6 +250,38 @@ export function ControlledDataTable<TData, TValue>({
return ""; return "";
}; };
const tableRows = table.getRowModel().rows;
const hasRows = tableRows.length > 0;
const hasAddAction = Boolean(
addButtonText && ((addActions && addActions.length > 0) || onAdd)
);
const showAddActionInEmptyState = !hasRows && hasAddAction;
const addAction = addActions && addActions.length > 0 && addButtonText ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={addButtonDisabled || isNavigatingToAddPage}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{addActions.map((action, i) => (
<DropdownMenuItem key={i} onSelect={() => action.onSelect()}>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : onAdd && addButtonText ? (
<Button onClick={onAdd} loading={isNavigatingToAddPage} disabled={addButtonDisabled}>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
) : null;
return ( return (
<div className="container mx-auto max-w-12xl"> <div className="container mx-auto max-w-12xl">
<Card> <Card>
@@ -367,51 +400,15 @@ export function ControlledDataTable<TData, TValue>({
</Button> </Button>
</div> </div>
)} )}
{addActions && {addAction && (
addActions.length > 0 && <>
addButtonText ? ( <div className="sm:hidden">{addAction}</div>
<div> {!showAddActionInEmptyState && (
<DropdownMenu> <div className="hidden sm:block">
<DropdownMenuTrigger asChild> {addAction}
<Button </div>
disabled={ )}
addButtonDisabled || </>
isNavigatingToAddPage
}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{addActions.map((action, i) => (
<DropdownMenuItem
key={i}
onSelect={() =>
action.onSelect()
}
>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
onAdd &&
addButtonText && (
<div>
<Button
onClick={onAdd}
loading={isNavigatingToAddPage}
disabled={addButtonDisabled}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
</div>
)
)} )}
</div> </div>
</CardHeader> </CardHeader>
@@ -606,14 +603,18 @@ export function ControlledDataTable<TData, TValue>({
</TableRow> </TableRow>
)) ))
) : ( ) : (
<TableRow> <DataTableEmptyState
<TableCell colSpan={columns.length}
colSpan={columns.length} action={
className="h-24 text-center" showAddActionInEmptyState
> ? (
No results found. <div className="hidden sm:block">
</TableCell> {addAction}
</TableRow> </div>
)
: undefined
}
/>
)} )}
</TableBody> </TableBody>
</Table> </Table>

View File

@@ -0,0 +1,46 @@
"use client";
import { TableCell, TableRow } from "@/components/ui/table";
import { useTranslations } from "next-intl";
import { type ReactNode } from "react";
const PLACEHOLDER_ROW_COUNT = 5;
type DataTableEmptyStateProps = {
colSpan: number;
action?: ReactNode;
};
export function DataTableEmptyState({
colSpan,
action
}: DataTableEmptyStateProps) {
const t = useTranslations();
return (
<TableRow className="hidden sm:table-row hover:bg-transparent data-[state=selected]:bg-transparent">
<TableCell colSpan={colSpan} className="p-0">
<div className="relative min-h-[11rem] w-full overflow-hidden">
<div
className="absolute inset-0 flex flex-col justify-start"
aria-hidden
>
{Array.from({ length: PLACEHOLDER_ROW_COUNT }).map(
(_, i) => (
<div
key={i}
className="h-10 shrink-0 border-b border-border/30"
/>
)
)}
</div>
<div className="relative flex min-h-[11rem] w-full flex-col items-center justify-center gap-4 px-4 py-8">
<p className="text-sm text-muted-foreground">
{t("noResults")}
</p>
{action}
</div>
</div>
</TableCell>
</TableRow>
);
}

View File

@@ -29,6 +29,7 @@ import {
TableHeader, TableHeader,
TableRow TableRow
} from "@/components/ui/table"; } from "@/components/ui/table";
import { DataTableEmptyState } from "@/components/ui/data-table-empty-state";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
@@ -515,6 +516,36 @@ export function DataTable<TData, TValue>({
return ""; return "";
}; };
const tableRows = table.getRowModel().rows;
const hasRows = tableRows.length > 0;
const hasAddAction = Boolean(
addButtonText && ((addActions && addActions.length > 0) || onAdd)
);
const showAddActionInEmptyState = !hasRows && hasAddAction;
const addAction = addActions && addActions.length > 0 && addButtonText ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button disabled={addButtonDisabled}>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{addActions.map((action, i) => (
<DropdownMenuItem key={i} onSelect={() => action.onSelect()}>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : onAdd && addButtonText ? (
<Button onClick={onAdd} disabled={addButtonDisabled}>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
) : null;
return ( return (
<div className="container mx-auto max-w-12xl"> <div className="container mx-auto max-w-12xl">
<Card> <Card>
@@ -651,45 +682,15 @@ export function DataTable<TData, TValue>({
</Button> </Button>
</div> </div>
)} )}
{addActions && addActions.length > 0 && addButtonText ? ( {addAction && (
<div> <>
<DropdownMenu> <div className="sm:hidden">{addAction}</div>
<DropdownMenuTrigger asChild> {!showAddActionInEmptyState && (
<Button <div className="hidden sm:block">
disabled={addButtonDisabled} {addAction}
> </div>
<Plus className="mr-2 h-4 w-4" /> )}
{addButtonText} </>
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{addActions.map((action, i) => (
<DropdownMenuItem
key={i}
onSelect={() =>
action.onSelect()
}
>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
onAdd &&
addButtonText && (
<div>
<Button
onClick={onAdd}
disabled={addButtonDisabled}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
</div>
)
)} )}
</div> </div>
</CardHeader> </CardHeader>
@@ -884,14 +885,18 @@ export function DataTable<TData, TValue>({
</TableRow> </TableRow>
)) ))
) : ( ) : (
<TableRow> <DataTableEmptyState
<TableCell colSpan={columns.length}
colSpan={columns.length} action={
className="h-24 text-center" showAddActionInEmptyState
> ? (
No results found. <div className="hidden sm:block">
</TableCell> {addAction}
</TableRow> </div>
)
: undefined
}
/>
)} )}
</TableBody> </TableBody>
</Table> </Table>