mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-20 15:55:31 +00:00
Merge branch 'dev' of github.com:fosrl/pangolin into dev
This commit is contained in:
5
.cursor/rules/Localization.mdc
Normal file
5
.cursor/rules/Localization.mdc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Always localize strings and use the `t` function to convert keys to strings. Add the keys to the en-us.json file. Never edit the other language files, as en-us.json is the single source of truth.
|
||||||
6
.cursor/rules/Nomenclature.mdc
Normal file
6
.cursor/rules/Nomenclature.mdc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Proxy resources = public resources
|
||||||
|
Private resources = client resources
|
||||||
@@ -93,6 +93,8 @@
|
|||||||
"siteConfirmCopy": "I have copied the config",
|
"siteConfirmCopy": "I have copied the config",
|
||||||
"searchSitesProgress": "Search sites...",
|
"searchSitesProgress": "Search sites...",
|
||||||
"siteAdd": "Add Site",
|
"siteAdd": "Add Site",
|
||||||
|
"sitesTableViewPublicResources": "View Public Resources",
|
||||||
|
"sitesTableViewPrivateResources": "View Private Resources",
|
||||||
"siteInstallNewt": "Install Site",
|
"siteInstallNewt": "Install Site",
|
||||||
"siteInstallNewtDescription": "Install the site connector for your system",
|
"siteInstallNewtDescription": "Install the site connector for your system",
|
||||||
"WgConfiguration": "WireGuard Configuration",
|
"WgConfiguration": "WireGuard Configuration",
|
||||||
|
|||||||
@@ -113,6 +113,16 @@ const listResourcesSchema = z.object({
|
|||||||
enum: ["no_targets", "healthy", "degraded", "offline", "unknown"],
|
enum: ["no_targets", "healthy", "degraded", "offline", "unknown"],
|
||||||
description:
|
description:
|
||||||
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets."
|
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets."
|
||||||
|
}),
|
||||||
|
siteId: z.coerce
|
||||||
|
.number<string>()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
description:
|
||||||
|
"When set, only resources that have at least one target on this site are returned"
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,6 +151,12 @@ export type ResourceWithTargets = {
|
|||||||
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
|
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
|
||||||
siteName: string | null;
|
siteName: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
sites: Array<{
|
||||||
|
siteId: number;
|
||||||
|
siteName: string;
|
||||||
|
siteNiceId: string;
|
||||||
|
online: boolean;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function queryResourcesBase() {
|
function queryResourcesBase() {
|
||||||
@@ -240,7 +256,8 @@ export async function listResources(
|
|||||||
query,
|
query,
|
||||||
healthStatus,
|
healthStatus,
|
||||||
sort_by,
|
sort_by,
|
||||||
order
|
order,
|
||||||
|
siteId
|
||||||
} = parsedQuery.data;
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
||||||
@@ -361,6 +378,18 @@ export async function listResources(
|
|||||||
if (typeof healthStatus !== "undefined") {
|
if (typeof healthStatus !== "undefined") {
|
||||||
conditions.push(eq(resources.health, healthStatus));
|
conditions.push(eq(resources.health, healthStatus));
|
||||||
}
|
}
|
||||||
|
if (siteId != null) {
|
||||||
|
const resourcesWithSite = db
|
||||||
|
.select({ resourceId: targets.resourceId })
|
||||||
|
.from(targets)
|
||||||
|
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
||||||
|
.where(
|
||||||
|
and(eq(sites.orgId, orgId), eq(sites.siteId, siteId))
|
||||||
|
);
|
||||||
|
conditions.push(
|
||||||
|
inArray(resources.resourceId, resourcesWithSite)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseQuery = queryResourcesBase().where(and(...conditions));
|
const baseQuery = queryResourcesBase().where(and(...conditions));
|
||||||
@@ -390,12 +419,15 @@ export async function listResources(
|
|||||||
.select({
|
.select({
|
||||||
targetId: targets.targetId,
|
targetId: targets.targetId,
|
||||||
resourceId: targets.resourceId,
|
resourceId: targets.resourceId,
|
||||||
|
siteId: targets.siteId,
|
||||||
ip: targets.ip,
|
ip: targets.ip,
|
||||||
port: targets.port,
|
port: targets.port,
|
||||||
enabled: targets.enabled,
|
enabled: targets.enabled,
|
||||||
healthStatus: targetHealthCheck.hcHealth,
|
healthStatus: targetHealthCheck.hcHealth,
|
||||||
hcEnabled: targetHealthCheck.hcEnabled,
|
hcEnabled: targetHealthCheck.hcEnabled,
|
||||||
siteName: sites.name
|
siteName: sites.name,
|
||||||
|
siteNiceId: sites.niceId,
|
||||||
|
siteOnline: sites.online
|
||||||
})
|
})
|
||||||
.from(targets)
|
.from(targets)
|
||||||
.where(inArray(targets.resourceId, resourceIdList))
|
.where(inArray(targets.resourceId, resourceIdList))
|
||||||
@@ -427,7 +459,8 @@ export async function listResources(
|
|||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
domainId: row.domainId,
|
domainId: row.domainId,
|
||||||
headerAuthId: row.headerAuthId,
|
headerAuthId: row.headerAuthId,
|
||||||
targets: []
|
targets: [],
|
||||||
|
sites: []
|
||||||
};
|
};
|
||||||
map.set(row.resourceId, entry);
|
map.set(row.resourceId, entry);
|
||||||
}
|
}
|
||||||
@@ -437,6 +470,33 @@ export async function listResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const entry of map.values()) {
|
||||||
|
const raw = allResourceTargets.filter(
|
||||||
|
(t) => t.resourceId === entry.resourceId
|
||||||
|
);
|
||||||
|
const siteById = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
siteId: number;
|
||||||
|
siteName: string;
|
||||||
|
siteNiceId: string;
|
||||||
|
online: boolean;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
for (const t of raw) {
|
||||||
|
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
siteById.set(t.siteId, {
|
||||||
|
siteId: t.siteId,
|
||||||
|
siteName: t.siteName ?? "",
|
||||||
|
siteNiceId: t.siteNiceId ?? "",
|
||||||
|
online: Boolean(t.siteOnline)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
entry.sites = Array.from(siteById.values());
|
||||||
|
}
|
||||||
|
|
||||||
const resourcesList: ResourceWithTargets[] = Array.from(map.values());
|
const resourcesList: ResourceWithTargets[] = Array.from(map.values());
|
||||||
|
|
||||||
return response<ListResourcesResponse>(res, {
|
return response<ListResourcesResponse>(res, {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import logger from "@server/logger";
|
|||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -68,6 +68,16 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
|||||||
enum: ["asc", "desc"],
|
enum: ["asc", "desc"],
|
||||||
default: "asc",
|
default: "asc",
|
||||||
description: "Sort order"
|
description: "Sort order"
|
||||||
|
}),
|
||||||
|
siteId: z.coerce
|
||||||
|
.number<string>()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
description:
|
||||||
|
"When set, only site resources associated with this site (via network) are returned"
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,10 +209,31 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
const { page, pageSize, query, mode, sort_by, order } =
|
const { page, pageSize, query, mode, sort_by, order, siteId } =
|
||||||
parsedQuery.data;
|
parsedQuery.data;
|
||||||
|
|
||||||
const conditions = [and(eq(siteResources.orgId, orgId))];
|
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||||
|
|
||||||
|
if (siteId != null) {
|
||||||
|
const resourcesForSite = db
|
||||||
|
.select({ id: siteResources.siteResourceId })
|
||||||
|
.from(siteResources)
|
||||||
|
.innerJoin(
|
||||||
|
siteNetworks,
|
||||||
|
eq(siteResources.networkId, siteNetworks.networkId)
|
||||||
|
)
|
||||||
|
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.orgId, orgId),
|
||||||
|
eq(sites.orgId, orgId),
|
||||||
|
eq(sites.siteId, siteId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
conditions.push(
|
||||||
|
inArray(siteResources.siteResourceId, resourcesForSite)
|
||||||
|
);
|
||||||
|
}
|
||||||
if (query) {
|
if (query) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
or(
|
or(
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
|||||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||||
|
import { GetSiteResponse } from "@server/routers/site/getSite";
|
||||||
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||||
|
import type ResponseT from "@server/types/Response";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
@@ -22,6 +24,13 @@ export interface ClientResourcesPageProps {
|
|||||||
searchParams: Promise<Record<string, string>>;
|
searchParams: Promise<Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(s: string | undefined): number | undefined {
|
||||||
|
if (!s) return undefined;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isInteger(n) || n <= 0) return undefined;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ClientResourcesPage(
|
export default async function ClientResourcesPage(
|
||||||
props: ClientResourcesPageProps
|
props: ClientResourcesPageProps
|
||||||
) {
|
) {
|
||||||
@@ -47,6 +56,32 @@ export default async function ClientResourcesPage(
|
|||||||
pagination = responseData.pagination;
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined);
|
||||||
|
|
||||||
|
let initialFilterSite: {
|
||||||
|
siteId: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
} | null = null;
|
||||||
|
if (siteIdParam) {
|
||||||
|
try {
|
||||||
|
const siteRes = await internal.get(
|
||||||
|
`/site/${siteIdParam}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
const s = (siteRes.data as ResponseT<GetSiteResponse>).data;
|
||||||
|
if (s && s.orgId === params.orgId) {
|
||||||
|
initialFilterSite = {
|
||||||
|
siteId: s.siteId,
|
||||||
|
name: s.name,
|
||||||
|
type: s.type
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// leave null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let org = null;
|
let org = null;
|
||||||
try {
|
try {
|
||||||
const res = await getCachedOrg(params.orgId);
|
const res = await getCachedOrg(params.orgId);
|
||||||
@@ -114,6 +149,7 @@ export default async function ClientResourcesPage(
|
|||||||
pageIndex: pagination.page - 1,
|
pageIndex: pagination.page - 1,
|
||||||
pageSize: pagination.pageSize
|
pageSize: pagination.pageSize
|
||||||
}}
|
}}
|
||||||
|
initialFilterSite={initialFilterSite}
|
||||||
/>
|
/>
|
||||||
</OrgProvider>
|
</OrgProvider>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
|||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import type { GetOrgResponse } from "@server/routers/org";
|
import type { GetOrgResponse } from "@server/routers/org";
|
||||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||||
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
import { GetSiteResponse } from "@server/routers/site/getSite";
|
||||||
|
import type ResponseT from "@server/types/Response";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
@@ -24,6 +25,13 @@ export interface ProxyResourcesPageProps {
|
|||||||
searchParams: Promise<Record<string, string>>;
|
searchParams: Promise<Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(s: string | undefined): number | undefined {
|
||||||
|
if (!s) return undefined;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isInteger(n) || n <= 0) return undefined;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ProxyResourcesPage(
|
export default async function ProxyResourcesPage(
|
||||||
props: ProxyResourcesPageProps
|
props: ProxyResourcesPageProps
|
||||||
) {
|
) {
|
||||||
@@ -47,13 +55,31 @@ export default async function ProxyResourcesPage(
|
|||||||
pagination = responseData.pagination;
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined);
|
||||||
try {
|
|
||||||
const res = await internal.get<
|
let initialFilterSite: {
|
||||||
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
siteId: number;
|
||||||
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
|
name: string;
|
||||||
siteResources = res.data.data.siteResources;
|
type: string;
|
||||||
} catch (e) {}
|
} | null = null;
|
||||||
|
if (siteIdParam) {
|
||||||
|
try {
|
||||||
|
const siteRes = await internal.get(
|
||||||
|
`/site/${siteIdParam}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
const s = (siteRes.data as ResponseT<GetSiteResponse>).data;
|
||||||
|
if (s && s.orgId === params.orgId) {
|
||||||
|
initialFilterSite = {
|
||||||
|
siteId: s.siteId,
|
||||||
|
name: s.name,
|
||||||
|
type: s.type
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// leave null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let org = null;
|
let org = null;
|
||||||
try {
|
try {
|
||||||
@@ -102,7 +128,8 @@ export default async function ProxyResourcesPage(
|
|||||||
enabled: target.enabled,
|
enabled: target.enabled,
|
||||||
healthStatus: target.healthStatus,
|
healthStatus: target.healthStatus,
|
||||||
siteName: target.siteName
|
siteName: target.siteName
|
||||||
}))
|
})),
|
||||||
|
sites: resource.sites ?? []
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
@@ -123,6 +150,7 @@ export default async function ProxyResourcesPage(
|
|||||||
pageIndex: pagination.page - 1,
|
pageIndex: pagination.page - 1,
|
||||||
pageSize: pagination.pageSize
|
pageSize: pagination.pageSize
|
||||||
}}
|
}}
|
||||||
|
initialFilterSite={initialFilterSite}
|
||||||
/>
|
/>
|
||||||
</OrgProvider>
|
</OrgProvider>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
|||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import { DataTable } from "@app/components/ui/data-table";
|
import { DataTable } from "@app/components/ui/data-table";
|
||||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -12,6 +13,11 @@ import {
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
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";
|
||||||
@@ -23,12 +29,14 @@ import {
|
|||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
|
Funnel,
|
||||||
MoreHorizontal
|
MoreHorizontal
|
||||||
} 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";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState, useTransition } from "react";
|
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
||||||
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
|
||||||
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
||||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
||||||
@@ -40,13 +48,13 @@ import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
|||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||||
|
import {
|
||||||
|
ResourceSitesStatusCell,
|
||||||
|
type ResourceSiteRow
|
||||||
|
} from "@app/components/ResourceSitesStatusCell";
|
||||||
|
|
||||||
export type InternalResourceSiteRow = {
|
export type InternalResourceSiteRow = ResourceSiteRow;
|
||||||
siteId: number;
|
|
||||||
siteName: string;
|
|
||||||
siteNiceId: string;
|
|
||||||
online: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InternalResourceRow = {
|
export type InternalResourceRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -111,121 +119,20 @@ function isSafeUrlForLink(href: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type AggregateSitesStatus = "allOnline" | "partial" | "allOffline";
|
|
||||||
|
|
||||||
function aggregateSitesStatus(
|
|
||||||
resourceSites: InternalResourceSiteRow[]
|
|
||||||
): AggregateSitesStatus {
|
|
||||||
if (resourceSites.length === 0) {
|
|
||||||
return "allOffline";
|
|
||||||
}
|
|
||||||
const onlineCount = resourceSites.filter((rs) => rs.online).length;
|
|
||||||
if (onlineCount === resourceSites.length) return "allOnline";
|
|
||||||
if (onlineCount > 0) return "partial";
|
|
||||||
return "allOffline";
|
|
||||||
}
|
|
||||||
|
|
||||||
function aggregateStatusDotClass(status: AggregateSitesStatus): string {
|
|
||||||
switch (status) {
|
|
||||||
case "allOnline":
|
|
||||||
return "bg-green-500";
|
|
||||||
case "partial":
|
|
||||||
return "bg-yellow-500";
|
|
||||||
case "allOffline":
|
|
||||||
default:
|
|
||||||
return "bg-neutral-500";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClientResourceSitesStatusCell({
|
|
||||||
orgId,
|
|
||||||
resourceSites
|
|
||||||
}: {
|
|
||||||
orgId: string;
|
|
||||||
resourceSites: InternalResourceSiteRow[];
|
|
||||||
}) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
if (resourceSites.length === 0) {
|
|
||||||
return <span>-</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const aggregate = aggregateSitesStatus(resourceSites);
|
|
||||||
const countLabel = t("multiSitesSelectorSitesCount", {
|
|
||||||
count: resourceSites.length
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="flex h-8 items-center gap-2 px-0 font-normal"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-2 w-2 shrink-0 rounded-full",
|
|
||||||
aggregateStatusDotClass(aggregate)
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="text-sm tabular-nums">{countLabel}</span>
|
|
||||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="min-w-56">
|
|
||||||
{resourceSites.map((site) => {
|
|
||||||
const isOnline = site.online;
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem key={site.siteId} asChild>
|
|
||||||
<Link
|
|
||||||
href={`/${orgId}/settings/sites/${site.siteNiceId}`}
|
|
||||||
className="flex cursor-pointer items-center justify-between gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-2 w-2 shrink-0 rounded-full",
|
|
||||||
isOnline
|
|
||||||
? "bg-green-500"
|
|
||||||
: "bg-neutral-500"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="truncate">
|
|
||||||
{site.siteName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 capitalize",
|
|
||||||
isOnline
|
|
||||||
? "text-green-600"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isOnline ? t("online") : t("offline")}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ClientResourcesTableProps = {
|
type ClientResourcesTableProps = {
|
||||||
internalResources: InternalResourceRow[];
|
internalResources: InternalResourceRow[];
|
||||||
orgId: string;
|
orgId: string;
|
||||||
pagination: PaginationState;
|
pagination: PaginationState;
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
|
initialFilterSite?: Selectedsite | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ClientResourcesTable({
|
export default function ClientResourcesTable({
|
||||||
internalResources,
|
internalResources,
|
||||||
orgId,
|
orgId,
|
||||||
pagination,
|
pagination,
|
||||||
rowCount
|
rowCount,
|
||||||
|
initialFilterSite = null
|
||||||
}: ClientResourcesTableProps) {
|
}: ClientResourcesTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {
|
const {
|
||||||
@@ -247,9 +154,26 @@ export default function ClientResourcesTable({
|
|||||||
const [editingResource, setEditingResource] =
|
const [editingResource, setEditingResource] =
|
||||||
useState<InternalResourceRow | null>();
|
useState<InternalResourceRow | null>();
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
||||||
|
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const siteIdQ = searchParams.get("siteId");
|
||||||
|
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
|
||||||
|
const selectedSite: Selectedsite | null = useMemo(() => {
|
||||||
|
if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
|
||||||
|
return initialFilterSite;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
siteId: siteIdNum,
|
||||||
|
name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
|
||||||
|
type: "newt"
|
||||||
|
};
|
||||||
|
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
try {
|
try {
|
||||||
@@ -294,9 +218,7 @@ export default function ClientResourcesTable({
|
|||||||
|
|
||||||
if (siteNames.length === 1) {
|
if (siteNames.length === 1) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}>
|
||||||
href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}
|
|
||||||
>
|
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
{siteNames[0]}
|
{siteNames[0]}
|
||||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||||
@@ -321,10 +243,7 @@ export default function ClientResourcesTable({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
{siteNames.map((siteName, idx) => (
|
{siteNames.map((siteName, idx) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem key={siteNiceIds[idx]} asChild>
|
||||||
key={siteNiceIds[idx]}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${orgId}/settings/sites/${siteNiceIds[idx]}`}
|
href={`/${orgId}/settings/sites/${siteNiceIds[idx]}`}
|
||||||
className="flex items-center gap-2 cursor-pointer"
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
@@ -391,11 +310,59 @@ export default function ClientResourcesTable({
|
|||||||
id: "sites",
|
id: "sites",
|
||||||
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
|
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
|
||||||
friendlyName: t("sites"),
|
friendlyName: t("sites"),
|
||||||
header: () => <span className="p-3">{t("sites")}</span>,
|
header: () => (
|
||||||
|
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
"justify-between text-sm h-8 px-2 w-full p-3",
|
||||||
|
!selectedSite && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{t("sites")}
|
||||||
|
<Funnel className="size-4 flex-none" />
|
||||||
|
{selectedSite && (
|
||||||
|
<Badge
|
||||||
|
className="truncate max-w-[10rem]"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{selectedSite.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className={dataTableFilterPopoverContentClassName}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<div className="border-b p-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-full justify-start font-normal"
|
||||||
|
onClick={clearSiteFilter}
|
||||||
|
>
|
||||||
|
{t("standaloneHcFilterAnySite")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<SitesSelector
|
||||||
|
orgId={orgId}
|
||||||
|
selectedSite={selectedSite}
|
||||||
|
onSelectSite={onPickSite}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return (
|
return (
|
||||||
<ClientResourceSitesStatusCell
|
<ResourceSitesStatusCell
|
||||||
orgId={resourceRow.orgId}
|
orgId={resourceRow.orgId}
|
||||||
resourceSites={resourceRow.sites}
|
resourceSites={resourceRow.sites}
|
||||||
/>
|
/>
|
||||||
@@ -576,6 +543,16 @@ export default function ClientResourcesTable({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearSiteFilter = () => {
|
||||||
|
handleFilterChange("siteId", undefined);
|
||||||
|
setSiteFilterOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPickSite = (site: Selectedsite) => {
|
||||||
|
handleFilterChange("siteId", String(site.siteId));
|
||||||
|
setSiteFilterOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
function toggleSort(column: string) {
|
function toggleSort(column: string) {
|
||||||
const newSearch = getNextSortOrder(column, searchParams);
|
const newSearch = getNextSortOrder(column, searchParams);
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@app/components/ui/command";
|
} from "@app/components/ui/command";
|
||||||
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
|
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
|
|
||||||
interface FilterOption {
|
interface FilterOption {
|
||||||
@@ -74,7 +75,10 @@ export function ColumnFilter({
|
|||||||
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
|
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0 w-50" align="start">
|
<PopoverContent
|
||||||
|
className={dataTableFilterPopoverContentClassName}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={searchPlaceholder} />
|
<CommandInput placeholder={searchPlaceholder} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@app/components/ui/command";
|
} from "@app/components/ui/command";
|
||||||
import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react";
|
import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
|
|
||||||
interface FilterOption {
|
interface FilterOption {
|
||||||
@@ -75,7 +76,10 @@ export function ColumnFilterButton({
|
|||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0 w-50" align="start">
|
<PopoverContent
|
||||||
|
className={dataTableFilterPopoverContentClassName}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={searchPlaceholder} />
|
<CommandInput placeholder={searchPlaceholder} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "@app/components/ui/command";
|
} from "@app/components/ui/command";
|
||||||
import { CheckIcon, Funnel } from "lucide-react";
|
import { CheckIcon, Funnel } from "lucide-react";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
|
|
||||||
type FilterOption = {
|
type FilterOption = {
|
||||||
@@ -101,7 +102,10 @@ export function ColumnMultiFilterButton({
|
|||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0 w-50" align="start">
|
<PopoverContent
|
||||||
|
className={dataTableFilterPopoverContentClassName}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={searchPlaceholder} />
|
<CommandInput placeholder={searchPlaceholder} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
|||||||
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 { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||||
|
|
||||||
type StandaloneHealthChecksTableProps = {
|
type StandaloneHealthChecksTableProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -376,7 +377,7 @@ export default function HealthChecksTable({
|
|||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-[min(20rem,var(--radix-popover-trigger-width))] p-0"
|
className={dataTableFilterPopoverContentClassName}
|
||||||
align="start"
|
align="start"
|
||||||
>
|
>
|
||||||
<div className="border-b p-1">
|
<div className="border-b p-1">
|
||||||
@@ -445,7 +446,7 @@ export default function HealthChecksTable({
|
|||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-[min(20rem,var(--radix-popover-trigger-width))] p-0"
|
className={dataTableFilterPopoverContentClassName}
|
||||||
align="start"
|
align="start"
|
||||||
>
|
>
|
||||||
<div className="border-b p-1">
|
<div className="border-b p-1">
|
||||||
|
|||||||
@@ -4,18 +4,29 @@ import { cn } from "@app/lib/cn";
|
|||||||
|
|
||||||
export function InfoSections({
|
export function InfoSections({
|
||||||
children,
|
children,
|
||||||
cols
|
cols,
|
||||||
|
columnSizing = "content"
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
cols?: number;
|
cols?: number;
|
||||||
|
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
|
||||||
|
columnSizing?: "fill" | "content";
|
||||||
}) {
|
}) {
|
||||||
|
const n = cols || 1;
|
||||||
|
const track =
|
||||||
|
columnSizing === "fill" ? "minmax(0, 1fr)" : "minmax(0, max-content)";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`grid grid-cols-2 md:grid-cols-(--columns) md:gap-4 gap-2 md:items-start`}
|
className={cn(
|
||||||
|
"grid grid-cols-2 md:grid-cols-(--columns) md:gap-16 gap-2 md:items-start",
|
||||||
|
columnSizing === "content" &&
|
||||||
|
"md:justify-items-start md:justify-start"
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
|
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
|
||||||
// value of a CSS variable at runtime and tailwind will just reuse that value
|
// value of a CSS variable at runtime and tailwind will just reuse that value
|
||||||
"--columns": `repeat(${cols || 1}, minmax(0, 1fr))`
|
"--columns": `repeat(${n}, ${track})`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
|
import {
|
||||||
|
ResourceSitesStatusCell,
|
||||||
|
type ResourceSiteRow
|
||||||
|
} from "@app/components/ResourceSitesStatusCell";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
import {
|
import {
|
||||||
@@ -11,9 +16,17 @@ import {
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
import { Switch } from "@app/components/ui/switch";
|
import { Switch } from "@app/components/ui/switch";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
|
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
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";
|
||||||
@@ -29,6 +42,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
Clock,
|
Clock,
|
||||||
|
Funnel,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
@@ -39,6 +53,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useOptimistic,
|
useOptimistic,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -84,6 +99,7 @@ export type ResourceRow = {
|
|||||||
targetPort?: number;
|
targetPort?: number;
|
||||||
targets?: TargetHealth[];
|
targets?: TargetHealth[];
|
||||||
health?: "online" | "degraded" | "unhealthy" | "unknown";
|
health?: "online" | "degraded" | "unhealthy" | "unknown";
|
||||||
|
sites: ResourceSiteRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatusIcon({
|
function StatusIcon({
|
||||||
@@ -114,13 +130,15 @@ type ProxyResourcesTableProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
pagination: PaginationState;
|
pagination: PaginationState;
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
|
initialFilterSite?: Selectedsite | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProxyResourcesTable({
|
export default function ProxyResourcesTable({
|
||||||
resources,
|
resources,
|
||||||
orgId,
|
orgId,
|
||||||
pagination,
|
pagination,
|
||||||
rowCount
|
rowCount,
|
||||||
|
initialFilterSite = null
|
||||||
}: ProxyResourcesTableProps) {
|
}: ProxyResourcesTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {
|
const {
|
||||||
@@ -140,13 +158,30 @@ export default function ProxyResourcesTable({
|
|||||||
|
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||||
|
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
||||||
|
|
||||||
|
const siteIdQ = searchParams.get("siteId");
|
||||||
|
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
|
||||||
|
const selectedSite: Selectedsite | null = useMemo(() => {
|
||||||
|
if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
|
||||||
|
return initialFilterSite;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
siteId: siteIdNum,
|
||||||
|
name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
|
||||||
|
type: "newt"
|
||||||
|
};
|
||||||
|
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, [router]);
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
@@ -351,6 +386,67 @@ export default function ProxyResourcesTable({
|
|||||||
return <span>{row.original.nice || "-"}</span>;
|
return <span>{row.original.nice || "-"}</span>;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "sites",
|
||||||
|
accessorFn: (row) =>
|
||||||
|
row.sites.map((s) => s.siteName).join(", "),
|
||||||
|
friendlyName: t("sites"),
|
||||||
|
header: () => (
|
||||||
|
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
"justify-between text-sm h-8 px-2 w-full p-3",
|
||||||
|
!selectedSite && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{t("sites")}
|
||||||
|
<Funnel className="size-4 flex-none" />
|
||||||
|
{selectedSite && (
|
||||||
|
<Badge
|
||||||
|
className="truncate max-w-[10rem]"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{selectedSite.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className={dataTableFilterPopoverContentClassName}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<div className="border-b p-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-full justify-start font-normal"
|
||||||
|
onClick={clearSiteFilter}
|
||||||
|
>
|
||||||
|
{t("standaloneHcFilterAnySite")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<SitesSelector
|
||||||
|
orgId={orgId}
|
||||||
|
selectedSite={selectedSite}
|
||||||
|
onSelectSite={onPickSite}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<ResourceSitesStatusCell
|
||||||
|
orgId={row.original.orgId}
|
||||||
|
resourceSites={row.original.sites}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "protocol",
|
accessorKey: "protocol",
|
||||||
friendlyName: t("protocol"),
|
friendlyName: t("protocol"),
|
||||||
@@ -596,6 +692,16 @@ export default function ProxyResourcesTable({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearSiteFilter = () => {
|
||||||
|
handleFilterChange("siteId", undefined);
|
||||||
|
setSiteFilterOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPickSite = (site: Selectedsite) => {
|
||||||
|
handleFilterChange("siteId", String(site.siteId));
|
||||||
|
setSiteFilterOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
function toggleSort(column: string) {
|
function toggleSort(column: string) {
|
||||||
const newSearch = getNextSortOrder(column, searchParams);
|
const newSearch = getNextSortOrder(column, searchParams);
|
||||||
|
|
||||||
|
|||||||
123
src/components/ResourceSitesStatusCell.tsx
Normal file
123
src/components/ResourceSitesStatusCell.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@app/components/ui/dropdown-menu";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export type ResourceSiteRow = {
|
||||||
|
siteId: number;
|
||||||
|
siteName: string;
|
||||||
|
siteNiceId: string;
|
||||||
|
online: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AggregateSitesStatus = "allOnline" | "partial" | "allOffline";
|
||||||
|
|
||||||
|
function aggregateSitesStatus(
|
||||||
|
resourceSites: ResourceSiteRow[]
|
||||||
|
): AggregateSitesStatus {
|
||||||
|
if (resourceSites.length === 0) {
|
||||||
|
return "allOffline";
|
||||||
|
}
|
||||||
|
const onlineCount = resourceSites.filter((rs) => rs.online).length;
|
||||||
|
if (onlineCount === resourceSites.length) return "allOnline";
|
||||||
|
if (onlineCount > 0) return "partial";
|
||||||
|
return "allOffline";
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateStatusDotClass(status: AggregateSitesStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "allOnline":
|
||||||
|
return "bg-green-500";
|
||||||
|
case "partial":
|
||||||
|
return "bg-yellow-500";
|
||||||
|
case "allOffline":
|
||||||
|
default:
|
||||||
|
return "bg-neutral-500";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceSitesStatusCell({
|
||||||
|
orgId,
|
||||||
|
resourceSites
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
resourceSites: ResourceSiteRow[];
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
if (resourceSites.length === 0) {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregate = aggregateSitesStatus(resourceSites);
|
||||||
|
const countLabel = t("multiSitesSelectorSitesCount", {
|
||||||
|
count: resourceSites.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex h-8 items-center gap-2 px-0 font-normal"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 w-2 shrink-0 rounded-full",
|
||||||
|
aggregateStatusDotClass(aggregate)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm tabular-nums">{countLabel}</span>
|
||||||
|
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="min-w-56">
|
||||||
|
{resourceSites.map((site) => {
|
||||||
|
const isOnline = site.online;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem key={site.siteId} asChild>
|
||||||
|
<Link
|
||||||
|
href={`/${orgId}/settings/sites/${site.siteNiceId}`}
|
||||||
|
className="flex cursor-pointer items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 w-2 shrink-0 rounded-full",
|
||||||
|
isOnline
|
||||||
|
? "bg-green-500"
|
||||||
|
: "bg-neutral-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">
|
||||||
|
{site.siteName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 capitalize",
|
||||||
|
isOnline
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isOnline ? t("online") : t("offline")}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -239,9 +239,7 @@ export default function SitesTable({
|
|||||||
if (originalRow.type == "local") {
|
if (originalRow.type == "local") {
|
||||||
return <span>-</span>;
|
return <span>-</span>;
|
||||||
}
|
}
|
||||||
return (
|
return <UptimeMiniBar siteId={originalRow.id} days={30} />;
|
||||||
<UptimeMiniBar siteId={originalRow.id} days={30} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -437,6 +435,22 @@ export default function SitesTable({
|
|||||||
{t("viewSettings")}
|
{t("viewSettings")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="block w-full"
|
||||||
|
href={`/${siteRow.orgId}/settings/resources/proxy?siteId=${siteRow.id}`}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
{t("sitesTableViewPublicResources")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="block w-full"
|
||||||
|
href={`/${siteRow.orgId}/settings/resources/client?siteId=${siteRow.id}`}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
{t("sitesTableViewPrivateResources")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedSite(siteRow);
|
setSelectedSite(siteRow);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
|
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
|
||||||
|
import { dataTableFilterDropdownContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -345,7 +346,9 @@ export function ControlledDataTable<TData, TValue>({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="start"
|
align="start"
|
||||||
className="w-48"
|
className={
|
||||||
|
dataTableFilterDropdownContentClassName
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
{filter.label}
|
{filter.label}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ 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";
|
||||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||||
|
import { dataTableFilterDropdownContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||||
import { ChevronDown, Plus, Search, RefreshCw, Columns, Filter } from "lucide-react";
|
import { ChevronDown, Plus, Search, RefreshCw, Columns, Filter } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -603,7 +604,9 @@ export function DataTable<TData, TValue>({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="start"
|
align="start"
|
||||||
className="w-48"
|
className={
|
||||||
|
dataTableFilterDropdownContentClassName
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
{filter.label}
|
{filter.label}
|
||||||
|
|||||||
5
src/lib/dataTableFilterPopover.ts
Normal file
5
src/lib/dataTableFilterPopover.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const dataTableFilterPopoverContentClassName =
|
||||||
|
"w-[min(16rem,calc(100vw-2rem))] p-0";
|
||||||
|
|
||||||
|
export const dataTableFilterDropdownContentClassName =
|
||||||
|
"w-[min(16rem,calc(100vw-2rem))]";
|
||||||
Reference in New Issue
Block a user