Merge branch 'dev' into feat/update-popup

This commit is contained in:
Milo Schwartz
2025-11-14 09:12:11 -08:00
committed by GitHub
71 changed files with 2562 additions and 935 deletions

View File

@@ -54,22 +54,7 @@ export default function BlueprintDetailsForm({
<div className="flex flex-col gap-6">
<Alert>
<AlertDescription>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
{t("appliedAt")}
</InfoSectionTitle>
<InfoSectionContent>
<time
className="text-muted-foreground"
dateTime={blueprint.createdAt.toString()}
>
{new Date(
blueprint.createdAt * 1000
).toLocaleString()}
</time>
</InfoSectionContent>
</InfoSection>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
{t("status")}
@@ -88,16 +73,6 @@ export default function BlueprintDetailsForm({
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("message")}
</InfoSectionTitle>
<InfoSectionContent>
<p className="text-muted-foreground">
{blueprint.message}
</p>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("source")}
@@ -106,35 +81,59 @@ export default function BlueprintDetailsForm({
{blueprint.source === "API" && (
<Badge
variant="secondary"
className="-mx-2"
className="inline-flex items-center gap-1 "
>
<span className="inline-flex items-center gap-1 ">
API
<Webhook className="size-4 flex-none" />
</span>
API
<Webhook className="w-3 h-3 flex-none" />
</Badge>
)}
{blueprint.source === "NEWT" && (
<Badge variant="secondary">
<span className="inline-flex items-center gap-1 ">
Newt CLI
<Terminal className="size-4 flex-none" />
</span>
<Badge
variant="secondary"
className="inline-flex items-center gap-1 "
>
<Terminal className="w-3 h-3 flex-none" />
Newt CLI
</Badge>
)}
{blueprint.source === "UI" && (
<Badge
variant="secondary"
className="-mx-1 py-1"
className="inline-flex items-center gap-1 "
>
<span className="inline-flex items-center gap-1 ">
Dashboard{" "}
<Globe className="size-4 flex-none" />
</span>
<Globe className="w-3 h-3 flex-none" />
Dashboard
</Badge>
)}{" "}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("appliedAt")}
</InfoSectionTitle>
<InfoSectionContent>
<time
className="text-muted-foreground"
dateTime={blueprint.createdAt.toString()}
>
{new Date(
blueprint.createdAt * 1000
).toLocaleString()}
</time>
</InfoSectionContent>
</InfoSection>
{blueprint.message && (
<InfoSection>
<InfoSectionTitle>
{t("message")}
</InfoSectionTitle>
<InfoSectionContent>
<p className="text-muted-foreground">
{blueprint.message}
</p>
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
</AlertDescription>
</Alert>
@@ -169,11 +168,6 @@ export default function BlueprintDetailsForm({
<FormLabel>
{t("parsedContents")}
</FormLabel>
<FormDescription>
{t(
"blueprintContentsDescription"
)}
</FormDescription>
<FormControl>
<div
className={cn(

View File

@@ -116,10 +116,13 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
}
case "UI": {
return (
<Badge variant="secondary">
<Badge
variant="secondary"
className="inline-flex items-center gap-1"
>
<span className="inline-flex items-center gap-1 ">
Dashboard{" "}
<Globe className="size-4 flex-none" />
<Globe className="w-3 h-3" />
Dashboard
</span>
</Badge>
);
@@ -163,18 +166,14 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
cell: ({ row }) => {
return (
<div className="flex justify-end">
<Button
variant="outline"
className="items-center"
asChild
<Link
href={`/${orgId}/settings/blueprints/${row.original.blueprintId}`}
>
<Link
href={`/${orgId}/settings/blueprints/${row.original.blueprintId}`}
>
View details{" "}
<ArrowRight className="size-4 flex-none" />
</Link>
</Button>
<Button variant="outline" className="items-center">
View Details
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}

View File

@@ -1,7 +1,6 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useClientContext } from "@app/hooks/useClientContext";
import {
InfoSection,
@@ -19,9 +18,7 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
return (
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">{t("clientInformation")}</AlertTitle>
<AlertDescription className="mt-4">
<AlertDescription>
<InfoSections cols={2}>
<>
<InfoSection>

View File

@@ -278,14 +278,14 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* <Link */}
{/* className="block w-full" */}
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
{/* > */}
{/* <DropdownMenuItem> */}
{/* View settings */}
{/* </DropdownMenuItem> */}
{/* </Link> */}
<Link
className="block w-full"
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
>
<DropdownMenuItem>
View settings
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedClient(clientRow);

View File

@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { Badge } from "@app/components/ui/badge";
import { DNSRecordsDataTable } from "./DNSRecordsDataTable";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { useEnvContext } from "@app/hooks/useEnvContext";
export type DNSRecordRow = {
id: string;
@@ -24,6 +25,30 @@ export default function DNSRecordsTable({
type
}: Props) {
const t = useTranslations();
const env = useEnvContext();
const statusColumn: ColumnDef<DNSRecordRow> = {
accessorKey: "verified",
header: ({ column }) => {
return <div>{t("status")}</div>;
},
cell: ({ row }) => {
const verified = row.original.verified;
return verified ? (
type === "wildcard" ? (
<Badge variant="outlinePrimary">
{t("manual", { fallback: "Manual" })}
</Badge>
) : (
<Badge variant="green">{t("verified")}</Badge>
)
) : (
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
);
}
};
const columns: ColumnDef<DNSRecordRow>[] = [
{
@@ -81,28 +106,7 @@ export default function DNSRecordsTable({
);
}
},
{
accessorKey: "verified",
header: ({ column }) => {
return <div>{t("status")}</div>;
},
cell: ({ row }) => {
const verified = row.original.verified;
return verified ? (
type === "wildcard" ? (
<Badge variant="outlinePrimary">
{t("manual", { fallback: "Manual" })}
</Badge>
) : (
<Badge variant="green">{t("verified")}</Badge>
)
) : (
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
);
}
}
...(env.env.flags.usePangolinDns ? [statusColumn] : [])
];
return (

View File

@@ -124,7 +124,6 @@ export function DNSRecordsDataTable<TData, TValue>({
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="bg-secondary dark:bg-transparent"
>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>

View File

@@ -9,6 +9,7 @@ import {
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { Badge } from "./ui/badge";
import { useEnvContext } from "@app/hooks/useEnvContext";
type DomainInfoCardProps = {
failed: boolean;
@@ -22,6 +23,7 @@ export default function DomainInfoCard({
type
}: DomainInfoCardProps) {
const t = useTranslations();
const env = useEnvContext();
const getTypeDisplay = (type: string) => {
switch (type) {
@@ -46,32 +48,34 @@ export default function DomainInfoCard({
<span>{getTypeDisplay(type ? type : "")}</span>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{failed ? (
<Badge variant="red">
{t("failed", { fallback: "Failed" })}
</Badge>
) : verified ? (
type === "wildcard" ? (
<Badge variant="outlinePrimary">
{t("manual", {
fallback: "Manual"
})}
{env.env.flags.usePangolinDns && (
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{failed ? (
<Badge variant="red">
{t("failed", { fallback: "Failed" })}
</Badge>
) : verified ? (
type === "wildcard" ? (
<Badge variant="outlinePrimary">
{t("manual", {
fallback: "Manual"
})}
</Badge>
) : (
<Badge variant="green">
{t("verified")}
</Badge>
)
) : (
<Badge variant="green">
{t("verified")}
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
)
) : (
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
)}
</InfoSectionContent>
</InfoSection>
)}
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
</AlertDescription>
</Alert>

View File

@@ -55,7 +55,8 @@ export default function DomainsTable({ domains, orgId }: Props) {
const [restartingDomains, setRestartingDomains] = useState<Set<string>>(
new Set()
);
const api = createApiClient(useEnvContext());
const env = useEnvContext();
const api = createApiClient(env);
const router = useRouter();
const t = useTranslations();
const { toast } = useToast();
@@ -134,6 +135,41 @@ export default function DomainsTable({ domains, orgId }: Props) {
}
};
const statusColumn: ColumnDef<DomainRow> = {
accessorKey: "verified",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const { verified, failed, type } = row.original;
if (verified) {
return type == "wildcard" ? (
<Badge variant="outlinePrimary">{t("manual")}</Badge>
) : (
<Badge variant="green">{t("verified")}</Badge>
);
} else if (failed) {
return (
<Badge variant="red">
{t("failed", { fallback: "Failed" })}
</Badge>
);
} else {
return <Badge variant="yellow">{t("pending")}</Badge>;
}
}
};
const columns: ColumnDef<DomainRow>[] = [
{
accessorKey: "baseDomain",
@@ -173,40 +209,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
);
}
},
{
accessorKey: "verified",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const { verified, failed, type } = row.original;
if (verified) {
return type == "wildcard" ? (
<Badge variant="outlinePrimary">{t("manual")}</Badge>
) : (
<Badge variant="green">{t("verified")}</Badge>
);
} else if (failed) {
return (
<Badge variant="red">
{t("failed", { fallback: "Failed" })}
</Badge>
);
} else {
return <Badge variant="yellow">{t("pending")}</Badge>;
}
}
},
...(env.env.flags.usePangolinDns ? [statusColumn] : []),
{
id: "actions",
cell: ({ row }) => {

View File

@@ -0,0 +1,52 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon } from "lucide-react";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext";
type ExitNodeInfoCardProps = {};
export default function ExitNodeInfoCard({}: ExitNodeInfoCardProps) {
const { remoteExitNode, updateRemoteExitNode } = useRemoteExitNodeContext();
const t = useTranslations();
return (
<Alert>
<AlertDescription className="mt-4">
<InfoSections cols={2}>
<>
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
<InfoSectionContent>
{remoteExitNode.online ? (
<div className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</div>
) : (
<div className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>{t("offline")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
</>
<InfoSection>
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
<InfoSectionContent>
{remoteExitNode.address}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
);
}

View File

@@ -50,5 +50,11 @@ export function InfoSectionContent({
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("break-words", className)}>{children}</div>;
return (
<div className={cn("min-w-0 overflow-hidden", className)}>
<div className="w-full truncate [&>div.flex]:min-w-0 [&>div.flex]:!whitespace-normal [&>div.flex>span]:truncate [&>div.flex>a]:truncate">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,216 @@
"use client";
import { useState } from "react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, AlertTriangle } from "lucide-react";
import { useTranslations } from "next-intl";
import { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import CopyTextBox from "@app/components/CopyTextBox";
import { QRCodeCanvas } from "qrcode.react";
type CredentialType = "site-wireguard" | "site-newt" | "client-olm" | "remote-exit-node";
interface RegenerateCredentialsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
type: CredentialType;
onConfirmRegenerate: () => Promise<void>;
dashboardUrl: string;
credentials?: {
// For WireGuard sites
wgConfig?: string;
Id?: string;
Secret?: string;
};
}
export default function RegenerateCredentialsModal({
open,
onOpenChange,
type,
onConfirmRegenerate,
dashboardUrl,
credentials
}: RegenerateCredentialsModalProps) {
const t = useTranslations();
const [stage, setStage] = useState<"confirm" | "show">("confirm");
const [loading, setLoading] = useState(false);
const handleConfirm = async () => {
try {
setLoading(true);
await onConfirmRegenerate();
setStage("show");
} catch (error) {
} finally {
setLoading(false);
}
};
const handleClose = () => {
setStage("confirm");
onOpenChange(false);
};
const getTitle = () => {
if (stage === "confirm") {
return t("regeneratecredentials");
}
switch (type) {
case "site-wireguard":
return t("WgConfiguration");
case "site-newt":
return t("siteNewtCredentials");
case "client-olm":
return t("clientOlmCredentials");
case "remote-exit-node":
return t("remoteExitNodeCreate.generate.title");
}
};
const getDescription = () => {
if (stage === "confirm") {
return t("regenerateCredentialsWarning");
}
switch (type) {
case "site-wireguard":
return t("WgConfigurationDescription");
case "site-newt":
return t("siteNewtCredentialsDescription");
case "client-olm":
return t("clientOlmCredentialsDescription");
case "remote-exit-node":
return t("remoteExitNodeCreate.generate.description");
}
};
return (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent className="max-h-[80vh] flex flex-col">
<CredenzaHeader>
<CredenzaTitle>{getTitle()}</CredenzaTitle>
<CredenzaDescription>{getDescription()}</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="overflow-y-auto px-4">
{stage === "confirm" ? (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("warning")}
</AlertTitle>
<AlertDescription>
{t("regenerateCredentialsConfirmation")}
</AlertDescription>
</Alert>
) : (
<>
{credentials?.wgConfig && (
<div className="space-y-4">
<div className="flex flex-col items-center gap-4">
<CopyTextBox text={credentials.wgConfig} />
<div className="relative w-fit border rounded-md">
<div className="bg-white p-6 rounded-md">
<QRCodeCanvas
value={credentials.wgConfig}
size={168}
className="mx-auto"
/>
</div>
</div>
</div>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("copyandsavethesecredentials")}
</AlertTitle>
<AlertDescription>
{t("copyandsavethesecredentialsdescription")}
</AlertDescription>
</Alert>
</div>
)}
{credentials?.Id && credentials.Secret && (
<div className="space-y-4">
<InfoSections cols={1}>
<InfoSection>
<InfoSectionTitle>
{t("endpoint")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={dashboardUrl} />
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("Id")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={credentials?.Id} />
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("SecretKey")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={credentials?.Secret} />
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("copyandsavethesecredentials")}
</AlertTitle>
<AlertDescription>
{t("copyandsavethesecredentialsdescription")}
</AlertDescription>
</Alert>
</div>
)}
</>
)}
</CredenzaBody>
<CredenzaFooter>
{stage === "confirm" ? (
<>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
onClick={handleConfirm}
loading={loading}
disabled={loading}
variant="destructive"
>
{t("confirm")}
</Button>
</>
) : (
<Button onClick={handleClose} className="w-full">
{t("close")}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
import { ShieldCheck, ShieldOff, Eye, EyeOff } from "lucide-react";
import { useResourceContext } from "@app/hooks/useResourceContext";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
@@ -17,21 +17,30 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
type ResourceInfoBoxType = {};
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const { resource, authInfo } = useResourceContext();
export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
const { resource, authInfo, updateResource } = useResourceContext();
const { env } = useEnvContext();
const t = useTranslations();
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
return (
<Alert>
<AlertDescription>
{/* 4 cols because of the certs */}
<InfoSections
cols={resource.http && env.flags.usePangolinDns ? 4 : 3}
cols={resource.http && env.flags.usePangolinDns ? 5 : 4}
>
<InfoSection>
<InfoSectionTitle>
{t("identifier")}
</InfoSectionTitle>
<InfoSectionContent>
{resource.niceId}
</InfoSectionContent>
</InfoSection>
{resource.http ? (
<>
<InfoSection>
@@ -40,17 +49,17 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionTitle>
<InfoSectionContent>
{authInfo.password ||
authInfo.pincode ||
authInfo.sso ||
authInfo.whitelist ||
authInfo.headerAuth ? (
<div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" />
authInfo.pincode ||
authInfo.sso ||
authInfo.whitelist ||
authInfo.headerAuth ? (
<div className="flex items-start space-x-2">
<ShieldCheck className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-500" />
<span>{t("protected")}</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" />
<ShieldOff className="w-4 h-4 flex-shrink-0" />
<span>{t("notProtected")}</span>
</div>
)}
@@ -91,9 +100,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
{t("protocol")}
</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.protocol.toUpperCase()}
</span>
{resource.protocol.toUpperCase()}
</InfoSectionContent>
</InfoSection>
<InfoSection>
@@ -155,11 +162,17 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<InfoSection>
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.enabled
? t("enabled")
: t("disabled")}
</span>
{resource.enabled ? (
<div className="flex items-center space-x-2">
<Eye className="w-4 h-4 flex-shrink-0 text-green-500" />
<span>{t("enabled")}</span>
</div>
) : (
<div className="flex items-center space-x-2">
<EyeOff className="w-4 h-4 flex-shrink-0 text-neutral-500" />
<span>{t("disabled")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>

View File

@@ -17,7 +17,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
DropdownMenuCheckboxItem
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import {
@@ -36,7 +36,7 @@ import {
Wifi,
WifiOff,
CheckCircle2,
XCircle,
XCircle
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -75,13 +75,12 @@ import EditInternalResourceDialog from "@app/components/EditInternalResourceDial
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import { Alert, AlertDescription } from "@app/components/ui/alert";
export type TargetHealth = {
targetId: number;
ip: string;
port: number;
enabled: boolean;
healthStatus?: 'healthy' | 'unhealthy' | 'unknown';
healthStatus?: "healthy" | "unhealthy" | "unknown";
};
export type ResourceRow = {
@@ -102,45 +101,55 @@ export type ResourceRow = {
targets?: TargetHealth[];
};
function getOverallHealthStatus(targets?: TargetHealth[]): 'online' | 'degraded' | 'offline' | 'unknown' {
function getOverallHealthStatus(
targets?: TargetHealth[]
): "online" | "degraded" | "offline" | "unknown" {
if (!targets || targets.length === 0) {
return 'unknown';
return "unknown";
}
const monitoredTargets = targets.filter(t => t.enabled && t.healthStatus && t.healthStatus !== 'unknown');
const monitoredTargets = targets.filter(
(t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown"
);
if (monitoredTargets.length === 0) {
return 'unknown';
return "unknown";
}
const healthyCount = monitoredTargets.filter(t => t.healthStatus === 'healthy').length;
const unhealthyCount = monitoredTargets.filter(t => t.healthStatus === 'unhealthy').length;
const healthyCount = monitoredTargets.filter(
(t) => t.healthStatus === "healthy"
).length;
const unhealthyCount = monitoredTargets.filter(
(t) => t.healthStatus === "unhealthy"
).length;
if (healthyCount === monitoredTargets.length) {
return 'online';
return "online";
} else if (unhealthyCount === monitoredTargets.length) {
return 'offline';
return "offline";
} else {
return 'degraded';
return "degraded";
}
}
function StatusIcon({ status, className = "" }: {
status: 'online' | 'degraded' | 'offline' | 'unknown';
function StatusIcon({
status,
className = ""
}: {
status: "online" | "degraded" | "offline" | "unknown";
className?: string;
}) {
const iconClass = `h-4 w-4 ${className}`;
switch (status) {
case 'online':
case "online":
return <CheckCircle2 className={`${iconClass} text-green-500`} />;
case 'degraded':
case "degraded":
return <CheckCircle2 className={`${iconClass} text-yellow-500`} />;
case 'offline':
case "offline":
return <XCircle className={`${iconClass} text-destructive`} />;
case 'unknown':
return <Clock className={`${iconClass} text-gray-400`} />;
case "unknown":
return <Clock className={`${iconClass} text-muted-foreground`} />;
default:
return null;
}
@@ -171,15 +180,14 @@ type ResourcesTableProps = {
};
};
const STORAGE_KEYS = {
PAGE_SIZE: 'datatable-page-size',
PAGE_SIZE: "datatable-page-size",
getTablePageSize: (tableId?: string) =>
tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE
};
const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
if (typeof window === 'undefined') return defaultSize;
if (typeof window === "undefined") return defaultSize;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
@@ -191,24 +199,22 @@ const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
}
}
} catch (error) {
console.warn('Failed to read page size from localStorage:', error);
console.warn("Failed to read page size from localStorage:", error);
}
return defaultSize;
};
const setStoredPageSize = (pageSize: number, tableId?: string): void => {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
localStorage.setItem(key, pageSize.toString());
} catch (error) {
console.warn('Failed to save page size to localStorage:', error);
console.warn("Failed to save page size to localStorage:", error);
}
};
export default function ResourcesTable({
resources,
internalResources,
@@ -224,12 +230,11 @@ export default function ResourcesTable({
const api = createApiClient({ env });
const [proxyPageSize, setProxyPageSize] = useState<number>(() =>
getStoredPageSize('proxy-resources', 20)
getStoredPageSize("proxy-resources", 20)
);
const [internalPageSize, setInternalPageSize] = useState<number>(() =>
getStoredPageSize('internal-resources', 20)
getStoredPageSize("internal-resources", 20)
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@@ -247,8 +252,10 @@ export default function ResourcesTable({
defaultSort ? [defaultSort] : []
);
const [proxyColumnVisibility, setProxyColumnVisibility] = useState<VisibilityState>({});
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>({});
const [proxyColumnVisibility, setProxyColumnVisibility] =
useState<VisibilityState>({});
const [internalColumnVisibility, setInternalColumnVisibility] =
useState<VisibilityState>({});
const [proxyColumnFilters, setProxyColumnFilters] =
useState<ColumnFiltersState>([]);
@@ -427,24 +434,34 @@ export default function ResourcesTable({
return (
<div className="flex items-center gap-2">
<StatusIcon status="unknown" />
<span className="text-sm text-muted-foreground">No targets</span>
<span className="text-sm">
{t("resourcesTableNoTargets")}
</span>
</div>
);
}
const monitoredTargets = targets.filter(t => t.enabled && t.healthStatus && t.healthStatus !== 'unknown');
const unknownTargets = targets.filter(t => !t.enabled || !t.healthStatus || t.healthStatus === 'unknown');
const monitoredTargets = targets.filter(
(t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown"
);
const unknownTargets = targets.filter(
(t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown"
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="flex items-center gap-2 h-8">
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2 h-8 px-0"
>
<StatusIcon status={overallStatus} />
<span className="text-sm">
{overallStatus === 'online' && 'Healthy'}
{overallStatus === 'degraded' && 'Degraded'}
{overallStatus === 'offline' && 'Offline'}
{overallStatus === 'unknown' && 'Unknown'}
{overallStatus === "online" && t("resourcesTableHealthy")}
{overallStatus === "degraded" && t("resourcesTableDegraded")}
{overallStatus === "offline" && t("resourcesTableOffline")}
{overallStatus === "unknown" && t("resourcesTableUnknown")}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
@@ -453,16 +470,29 @@ export default function ResourcesTable({
{monitoredTargets.length > 0 && (
<>
{monitoredTargets.map((target) => (
<DropdownMenuItem key={target.targetId} className="flex items-center justify-between gap-4">
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon
status={target.healthStatus === 'healthy' ? 'online' : 'offline'}
status={
target.healthStatus ===
"healthy"
? "online"
: "offline"
}
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
</div>
<span className={`capitalize ${target.healthStatus === 'healthy' ? 'text-green-500' : 'text-destructive'
}`}>
<span
className={`capitalize ${
target.healthStatus === "healthy"
? "text-green-500"
: "text-destructive"
}`}
>
{target.healthStatus}
</span>
</DropdownMenuItem>
@@ -472,13 +502,21 @@ export default function ResourcesTable({
{unknownTargets.length > 0 && (
<>
{unknownTargets.map((target) => (
<DropdownMenuItem key={target.targetId} className="flex items-center justify-between gap-4">
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon status="unknown" className="h-3 w-3" />
<StatusIcon
status="unknown"
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
</div>
<span className="text-muted-foreground">
{!target.enabled ? 'Disabled' : 'Not monitored'}
{!target.enabled
? t("disabled")
: t("resourcesTableNotMonitored")}
</span>
</DropdownMenuItem>
))}
@@ -489,7 +527,6 @@ export default function ResourcesTable({
);
}
const proxyColumns: ColumnDef<ResourceRow>[] = [
{
accessorKey: "name",
@@ -507,28 +544,20 @@ export default function ResourcesTable({
);
}
},
{
accessorKey: "nice",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("resource")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "protocol",
header: t("protocol"),
cell: ({ row }) => {
const resourceRow = row.original;
return <span>{resourceRow.http ? (resourceRow.ssl ? "HTTPS" : "HTTP") : resourceRow.protocol.toUpperCase()}</span>;
return (
<span>
{resourceRow.http
? resourceRow.ssl
? "HTTPS"
: "HTTP"
: resourceRow.protocol.toUpperCase()}
</span>
);
}
},
{
@@ -554,7 +583,12 @@ export default function ResourcesTable({
sortingFn: (rowA, rowB) => {
const statusA = getOverallHealthStatus(rowA.original.targets);
const statusB = getOverallHealthStatus(rowB.original.targets);
const statusOrder = { online: 3, degraded: 2, offline: 1, unknown: 0 };
const statusOrder = {
online: 3,
degraded: 2,
offline: 1,
unknown: 0
};
return statusOrder[statusA] - statusOrder[statusB];
}
},
@@ -605,13 +639,13 @@ export default function ResourcesTable({
return (
<div>
{resourceRow.authState === "protected" ? (
<span className="text-green-500 flex items-center space-x-2">
<ShieldCheck className="w-4 h-4" />
<span className="flex items-center space-x-2">
<ShieldCheck className="w-4 h-4 text-green-500" />
<span>{t("protected")}</span>
</span>
) : resourceRow.authState === "not_protected" ? (
<span className="text-yellow-500 flex items-center space-x-2">
<ShieldOff className="w-4 h-4" />
<span className="flex items-center space-x-2">
<ShieldOff className="w-4 h-4 text-yellow-500" />
<span>{t("notProtected")}</span>
</span>
) : (
@@ -857,12 +891,12 @@ export default function ResourcesTable({
const handleProxyPageSizeChange = (newPageSize: number) => {
setProxyPageSize(newPageSize);
setStoredPageSize(newPageSize, 'proxy-resources');
setStoredPageSize(newPageSize, "proxy-resources");
};
const handleInternalPageSizeChange = (newPageSize: number) => {
setInternalPageSize(newPageSize);
setStoredPageSize(newPageSize, 'internal-resources');
setStoredPageSize(newPageSize, "internal-resources");
};
return (
@@ -876,12 +910,8 @@ export default function ResourcesTable({
}}
dialog={
<div>
<p>
{t("resourceQuestionRemove")}
</p>
<p>
{t("resourceMessageRemove")}
</p>
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
@@ -900,12 +930,8 @@ export default function ResourcesTable({
}}
dialog={
<div>
<p>
{t("resourceQuestionRemove")}
</p>
<p>
{t("resourceMessageRemove")}
</p>
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
@@ -955,9 +981,7 @@ export default function ResourcesTable({
{t("refresh")}
</Button>
</div>
<div>
{getActionButton()}
</div>
<div>{getActionButton()}</div>
</div>
</CardHeader>
<CardContent>
@@ -976,12 +1000,12 @@ export default function ResourcesTable({
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
@@ -1039,7 +1063,9 @@ export default function ResourcesTable({
<div className="mt-4">
<DataTablePagination
table={proxyTable}
onPageSizeChange={handleProxyPageSizeChange}
onPageSizeChange={
handleProxyPageSizeChange
}
/>
</div>
</TabsContent>
@@ -1077,12 +1103,12 @@ export default function ResourcesTable({
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
@@ -1140,7 +1166,9 @@ export default function ResourcesTable({
<div className="mt-4">
<DataTablePagination
table={internalTable}
onPageSizeChange={handleInternalPageSizeChange}
onPageSizeChange={
handleInternalPageSizeChange
}
/>
</div>
</TabsContent>

View File

@@ -1,7 +1,6 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { useSiteContext } from "@app/hooks/useSiteContext";
import {
InfoSection,
@@ -12,9 +11,10 @@ import {
import { useTranslations } from "next-intl";
import { useEnvContext } from "@app/hooks/useEnvContext";
type SiteInfoCardProps = {};
export default function SiteInfoCard({}: SiteInfoCardProps) {
export default function SiteInfoCard({ }: SiteInfoCardProps) {
const { site, updateSite } = useSiteContext();
const t = useTranslations();
const { env } = useEnvContext();
@@ -31,10 +31,19 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
}
};
return (
<Alert>
<AlertDescription>
<InfoSections cols={env.flags.enableClients ? 3 : 2}>
<InfoSections cols={env.flags.enableClients ? 4 : 3}>
<InfoSection>
<InfoSectionTitle>
{t("identifier")}
</InfoSectionTitle>
<InfoSectionContent>
{site.niceId}
</InfoSectionContent>
</InfoSection>
{(site.type == "newt" || site.type == "wireguard") && (
<>
<InfoSection>

View File

@@ -164,30 +164,6 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}
}
},
{
accessorKey: "nice",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
className="hidden md:flex whitespace-nowrap"
>
{t("site")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return (
<div className="hidden md:block whitespace-nowrap">
{row.original.nice}
</div>
);
}
},
{
accessorKey: "mbIn",
header: ({ column }) => {