mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-09 11:22:25 +00:00
Merge branch 'dev' into feat/update-popup
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import ResourcesTable, {
|
||||
ResourceRow,
|
||||
InternalResourceRow
|
||||
ResourceRow,
|
||||
InternalResourceRow
|
||||
} from "../../../../components/ResourcesTable";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ListResourcesResponse } from "@server/routers/resource";
|
||||
@@ -17,123 +17,130 @@ import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { toUnicode } from "punycode";
|
||||
|
||||
type ResourcesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<{ view?: string }>;
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<{ view?: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const t = await getTranslations();
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const t = await getTranslations();
|
||||
|
||||
const env = pullEnv();
|
||||
const env = pullEnv();
|
||||
|
||||
// Default to 'proxy' view, or use the query param if provided
|
||||
let defaultView: "proxy" | "internal" = "proxy";
|
||||
if (env.flags.enableClients) {
|
||||
defaultView = searchParams.view === "internal" ? "internal" : "proxy";
|
||||
}
|
||||
// Default to 'proxy' view, or use the query param if provided
|
||||
let defaultView: "proxy" | "internal" = "proxy";
|
||||
if (env.flags.enableClients) {
|
||||
defaultView = searchParams.view === "internal" ? "internal" : "proxy";
|
||||
}
|
||||
|
||||
let resources: ListResourcesResponse["resources"] = [];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
||||
`/org/${params.orgId}/resources`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
resources = res.data.data.resources;
|
||||
} catch (e) { }
|
||||
|
||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
||||
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
|
||||
siteResources = res.data.data.siteResources;
|
||||
} catch (e) { }
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
const getOrg = cache(async () =>
|
||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||
`/org/${params.orgId}`,
|
||||
await authCookieHeader()
|
||||
)
|
||||
);
|
||||
const res = await getOrg();
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
|
||||
if (!org) {
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
|
||||
const resourceRows: ResourceRow[] = resources.map((resource) => {
|
||||
return {
|
||||
id: resource.resourceId,
|
||||
name: resource.name,
|
||||
orgId: params.orgId,
|
||||
nice: resource.niceId,
|
||||
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
|
||||
protocol: resource.protocol,
|
||||
proxyPort: resource.proxyPort,
|
||||
http: resource.http,
|
||||
authState: !resource.http
|
||||
? "none"
|
||||
: resource.sso ||
|
||||
resource.pincodeId !== null ||
|
||||
resource.passwordId !== null ||
|
||||
resource.whitelist ||
|
||||
resource.headerAuthId
|
||||
? "protected"
|
||||
: "not_protected",
|
||||
enabled: resource.enabled,
|
||||
domainId: resource.domainId || undefined,
|
||||
ssl: resource.ssl
|
||||
};
|
||||
});
|
||||
|
||||
const internalResourceRows: InternalResourceRow[] = siteResources.map(
|
||||
(siteResource) => {
|
||||
return {
|
||||
id: siteResource.siteResourceId,
|
||||
name: siteResource.name,
|
||||
orgId: params.orgId,
|
||||
siteName: siteResource.siteName,
|
||||
protocol: siteResource.protocol,
|
||||
proxyPort: siteResource.proxyPort,
|
||||
siteId: siteResource.siteId,
|
||||
destinationIp: siteResource.destinationIp,
|
||||
destinationPort: siteResource.destinationPort,
|
||||
siteNiceId: siteResource.siteNiceId
|
||||
};
|
||||
}
|
||||
let resources: ListResourcesResponse["resources"] = [];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
||||
`/org/${params.orgId}/resources`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
resources = res.data.data.resources;
|
||||
} catch (e) { }
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("resourceTitle")}
|
||||
description={t("resourceDescription")}
|
||||
/>
|
||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
||||
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
|
||||
siteResources = res.data.data.siteResources;
|
||||
} catch (e) { }
|
||||
|
||||
<OrgProvider org={org}>
|
||||
<ResourcesTable
|
||||
resources={resourceRows}
|
||||
internalResources={internalResourceRows}
|
||||
orgId={params.orgId}
|
||||
defaultView={
|
||||
env.flags.enableClients ? defaultView : "proxy"
|
||||
}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
}}
|
||||
/>
|
||||
</OrgProvider>
|
||||
</>
|
||||
let org = null;
|
||||
try {
|
||||
const getOrg = cache(async () =>
|
||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||
`/org/${params.orgId}`,
|
||||
await authCookieHeader()
|
||||
)
|
||||
);
|
||||
}
|
||||
const res = await getOrg();
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
|
||||
if (!org) {
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
|
||||
const resourceRows: ResourceRow[] = resources.map((resource) => {
|
||||
return {
|
||||
id: resource.resourceId,
|
||||
name: resource.name,
|
||||
orgId: params.orgId,
|
||||
nice: resource.niceId,
|
||||
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
|
||||
protocol: resource.protocol,
|
||||
proxyPort: resource.proxyPort,
|
||||
http: resource.http,
|
||||
authState: !resource.http
|
||||
? "none"
|
||||
: resource.sso ||
|
||||
resource.pincodeId !== null ||
|
||||
resource.passwordId !== null ||
|
||||
resource.whitelist ||
|
||||
resource.headerAuthId
|
||||
? "protected"
|
||||
: "not_protected",
|
||||
enabled: resource.enabled,
|
||||
domainId: resource.domainId || undefined,
|
||||
ssl: resource.ssl,
|
||||
targets: resource.targets?.map(target => ({
|
||||
targetId: target.targetId,
|
||||
ip: target.ip,
|
||||
port: target.port,
|
||||
enabled: target.enabled,
|
||||
healthStatus: target.healthStatus
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
const internalResourceRows: InternalResourceRow[] = siteResources.map(
|
||||
(siteResource) => {
|
||||
return {
|
||||
id: siteResource.siteResourceId,
|
||||
name: siteResource.name,
|
||||
orgId: params.orgId,
|
||||
siteName: siteResource.siteName,
|
||||
protocol: siteResource.protocol,
|
||||
proxyPort: siteResource.proxyPort,
|
||||
siteId: siteResource.siteId,
|
||||
destinationIp: siteResource.destinationIp,
|
||||
destinationPort: siteResource.destinationPort,
|
||||
siteNiceId: siteResource.siteNiceId
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("resourceTitle")}
|
||||
description={t("resourceDescription")}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
<ResourcesTable
|
||||
resources={resourceRows}
|
||||
internalResources={internalResourceRows}
|
||||
orgId={params.orgId}
|
||||
defaultView={
|
||||
env.flags.enableClients ? defaultView : "proxy"
|
||||
}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
}}
|
||||
/>
|
||||
</OrgProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,13 +9,15 @@ import {
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel
|
||||
getFilteredRowModel,
|
||||
VisibilityState
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
@@ -25,7 +27,16 @@ import {
|
||||
ArrowUpRight,
|
||||
ShieldOff,
|
||||
ShieldCheck,
|
||||
RefreshCw
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Plus,
|
||||
Search,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -44,7 +55,6 @@ import { useTranslations } from "next-intl";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
@@ -65,6 +75,15 @@ 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';
|
||||
};
|
||||
|
||||
export type ResourceRow = {
|
||||
id: number;
|
||||
nice: string | null;
|
||||
@@ -78,8 +97,54 @@ export type ResourceRow = {
|
||||
enabled: boolean;
|
||||
domainId?: string;
|
||||
ssl: boolean;
|
||||
targetHost?: string;
|
||||
targetPort?: number;
|
||||
targets?: TargetHealth[];
|
||||
};
|
||||
|
||||
|
||||
function getOverallHealthStatus(targets?: TargetHealth[]): 'online' | 'degraded' | 'offline' | 'unknown' {
|
||||
if (!targets || targets.length === 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const monitoredTargets = targets.filter(t => t.enabled && t.healthStatus && t.healthStatus !== 'unknown');
|
||||
|
||||
if (monitoredTargets.length === 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const healthyCount = monitoredTargets.filter(t => t.healthStatus === 'healthy').length;
|
||||
const unhealthyCount = monitoredTargets.filter(t => t.healthStatus === 'unhealthy').length;
|
||||
|
||||
if (healthyCount === monitoredTargets.length) {
|
||||
return 'online';
|
||||
} else if (unhealthyCount === monitoredTargets.length) {
|
||||
return 'offline';
|
||||
} else {
|
||||
return 'degraded';
|
||||
}
|
||||
}
|
||||
|
||||
function StatusIcon({ status, className = "" }: {
|
||||
status: 'online' | 'degraded' | 'offline' | 'unknown';
|
||||
className?: string;
|
||||
}) {
|
||||
const iconClass = `h-4 w-4 ${className}`;
|
||||
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return <CheckCircle2 className={`${iconClass} text-green-500`} />;
|
||||
case 'degraded':
|
||||
return <CheckCircle2 className={`${iconClass} text-yellow-500`} />;
|
||||
case 'offline':
|
||||
return <XCircle className={`${iconClass} text-destructive`} />;
|
||||
case 'unknown':
|
||||
return <Clock className={`${iconClass} text-gray-400`} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export type InternalResourceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -143,6 +208,7 @@ const setStoredPageSize = (pageSize: number, tableId?: string): void => {
|
||||
};
|
||||
|
||||
|
||||
|
||||
export default function ResourcesTable({
|
||||
resources,
|
||||
internalResources,
|
||||
@@ -158,6 +224,7 @@ export default function ResourcesTable({
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
|
||||
const [proxyPageSize, setProxyPageSize] = useState<number>(() =>
|
||||
getStoredPageSize('proxy-resources', 20)
|
||||
);
|
||||
@@ -179,6 +246,10 @@ export default function ResourcesTable({
|
||||
const [proxySorting, setProxySorting] = useState<SortingState>(
|
||||
defaultSort ? [defaultSort] : []
|
||||
);
|
||||
|
||||
const [proxyColumnVisibility, setProxyColumnVisibility] = useState<VisibilityState>({});
|
||||
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
const [proxyColumnFilters, setProxyColumnFilters] =
|
||||
useState<ColumnFiltersState>([]);
|
||||
const [proxyGlobalFilter, setProxyGlobalFilter] = useState<any>([]);
|
||||
@@ -349,6 +420,76 @@ export default function ResourcesTable({
|
||||
});
|
||||
}
|
||||
|
||||
function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) {
|
||||
const overallStatus = getOverallHealthStatus(targets);
|
||||
|
||||
if (!targets || targets.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon status="unknown" />
|
||||
<span className="text-sm text-muted-foreground">No targets</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');
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="flex items-center gap-2 h-8">
|
||||
<StatusIcon status={overallStatus} />
|
||||
<span className="text-sm">
|
||||
{overallStatus === 'online' && 'Healthy'}
|
||||
{overallStatus === 'degraded' && 'Degraded'}
|
||||
{overallStatus === 'offline' && 'Offline'}
|
||||
{overallStatus === 'unknown' && 'Unknown'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[280px]">
|
||||
{monitoredTargets.length > 0 && (
|
||||
<>
|
||||
{monitoredTargets.map((target) => (
|
||||
<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'}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{`${target.ip}:${target.port}`}
|
||||
</div>
|
||||
<span className={`capitalize ${target.healthStatus === 'healthy' ? 'text-green-500' : 'text-destructive'
|
||||
}`}>
|
||||
{target.healthStatus}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{unknownTargets.length > 0 && (
|
||||
<>
|
||||
{unknownTargets.map((target) => (
|
||||
<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" />
|
||||
{`${target.ip}:${target.port}`}
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
{!target.enabled ? 'Disabled' : 'Not monitored'}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const proxyColumns: ColumnDef<ResourceRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
@@ -390,6 +531,33 @@ export default function ResourcesTable({
|
||||
return <span>{resourceRow.http ? (resourceRow.ssl ? "HTTPS" : "HTTP") : resourceRow.protocol.toUpperCase()}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
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 resourceRow = row.original;
|
||||
return <TargetStatusCell targets={resourceRow.targets} />;
|
||||
},
|
||||
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 };
|
||||
return statusOrder[statusA] - statusOrder[statusB];
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "domain",
|
||||
header: t("access"),
|
||||
@@ -647,6 +815,7 @@ export default function ResourcesTable({
|
||||
onColumnFiltersChange: setProxyColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setProxyGlobalFilter,
|
||||
onColumnVisibilityChange: setProxyColumnVisibility,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: proxyPageSize,
|
||||
@@ -656,7 +825,8 @@ export default function ResourcesTable({
|
||||
state: {
|
||||
sorting: proxySorting,
|
||||
columnFilters: proxyColumnFilters,
|
||||
globalFilter: proxyGlobalFilter
|
||||
globalFilter: proxyGlobalFilter,
|
||||
columnVisibility: proxyColumnVisibility
|
||||
}
|
||||
});
|
||||
|
||||
@@ -670,6 +840,7 @@ export default function ResourcesTable({
|
||||
onColumnFiltersChange: setInternalColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setInternalGlobalFilter,
|
||||
onColumnVisibilityChange: setInternalColumnVisibility,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: internalPageSize,
|
||||
@@ -679,7 +850,8 @@ export default function ResourcesTable({
|
||||
state: {
|
||||
sorting: internalSorting,
|
||||
columnFilters: internalColumnFilters,
|
||||
globalFilter: internalGlobalFilter
|
||||
globalFilter: internalGlobalFilter,
|
||||
columnVisibility: internalColumnVisibility
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user