mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-03 00:29:10 +00:00
Merge pull request #1960 from Fredkiss3/refactor/separate-tables
refactor: separate tables
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -49,4 +49,5 @@ postgres/
|
||||
dynamic/
|
||||
*.mmdb
|
||||
scratch/
|
||||
tsconfig.json
|
||||
tsconfig.json
|
||||
hydrateSaas.ts
|
||||
@@ -144,8 +144,10 @@
|
||||
"expires": "Expires",
|
||||
"never": "Never",
|
||||
"shareErrorSelectResource": "Please select a resource",
|
||||
"resourceTitle": "Manage Resources",
|
||||
"resourceDescription": "Access resources on sites publically or privately",
|
||||
"proxyResourceTitle": "Manage Proxy Resources",
|
||||
"proxyResourceDescription": "Create and manage secure, publicly accessible resources through a web browser",
|
||||
"clientResourceTitle": "Manage Client Resources",
|
||||
"clientResourceDescription": "Create and manage private resources that are accessible only through a connected client, with no public exposure",
|
||||
"resourcesSearch": "Search resources...",
|
||||
"resourceAdd": "Add Resource",
|
||||
"resourceErrorDelte": "Error deleting resource",
|
||||
@@ -1151,6 +1153,8 @@
|
||||
"sidebarHome": "Home",
|
||||
"sidebarSites": "Sites",
|
||||
"sidebarResources": "Resources",
|
||||
"sidebarProxyResources": "Proxy Resources",
|
||||
"sidebarClientResources": "Client Resources",
|
||||
"sidebarAccessControl": "Access Control",
|
||||
"sidebarUsers": "Users",
|
||||
"sidebarInvitations": "Invitations",
|
||||
@@ -1162,6 +1166,8 @@
|
||||
"sidebarIdentityProviders": "Identity Providers",
|
||||
"sidebarLicense": "License",
|
||||
"sidebarClients": "Clients",
|
||||
"sidebarUserDevices": "User devices",
|
||||
"sidebarMachineClients": "Machine Clients",
|
||||
"sidebarDomains": "Domains",
|
||||
"sidebarBluePrints": "Blueprints",
|
||||
"blueprints": "Blueprints",
|
||||
@@ -1875,8 +1881,10 @@
|
||||
"enterpriseEdition": "Enterprise Edition",
|
||||
"unlicensed": "Unlicensed",
|
||||
"beta": "Beta",
|
||||
"manageClients": "Manage Clients",
|
||||
"manageClientsDescription": "Clients are devices that can connect to your sites",
|
||||
"manageUserDevices": "User Devices",
|
||||
"manageUserDevicesDescription": "View and manage personal devices that users connect with to securely access your organization’s resources",
|
||||
"manageMachineClients": "Manage Machine Clients",
|
||||
"manageMachineClientsDescription": "Create and manage automated clients, such as servers or services, that securely connect to your network",
|
||||
"clientsTableUserClients": "User",
|
||||
"clientsTableMachineClients": "Machine",
|
||||
"licenseTableValidUntil": "Valid Until",
|
||||
@@ -2182,7 +2190,7 @@
|
||||
"generatedcredentials": "Generated Credentials",
|
||||
"copyandsavethesecredentials": "Copy and save these credentials",
|
||||
"copyandsavethesecredentialsdescription": "These credentials will not be shown again after you leave this page. Save them securely now.",
|
||||
"credentialsSaved" : "Credentials Saved",
|
||||
"credentialsSaved": "Credentials Saved",
|
||||
"credentialsSavedDescription": "Credentials have been regenerated and saved successfully.",
|
||||
"credentialsSaveError": "Credentials Save Error",
|
||||
"credentialsSaveErrorDescription": "An error occurred while regenerating and saving the credentials.",
|
||||
|
||||
@@ -11,6 +11,7 @@ const runMigrations = async () => {
|
||||
migrationsFolder: migrationsFolder
|
||||
});
|
||||
console.log("Migrations completed successfully.");
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("Error running migrations:", error);
|
||||
process.exit(1);
|
||||
|
||||
@@ -359,10 +359,8 @@ export async function getNextAvailableOrgSubnet(): Promise<string> {
|
||||
return subnet;
|
||||
}
|
||||
|
||||
export function generateRemoteSubnets(
|
||||
allSiteResources: SiteResource[]
|
||||
): string[] {
|
||||
let remoteSubnets = allSiteResources
|
||||
export function generateRemoteSubnets(allSiteResources: SiteResource[]): string[] {
|
||||
const remoteSubnets = allSiteResources
|
||||
.filter((sr) => {
|
||||
if (sr.mode === "cidr") return true;
|
||||
if (sr.mode === "host") {
|
||||
@@ -415,7 +413,7 @@ export function generateSubnetProxyTargets(
|
||||
subnet: string | null;
|
||||
}[]
|
||||
): SubnetProxyTarget[] {
|
||||
let targets: SubnetProxyTarget[] = [];
|
||||
const targets: SubnetProxyTarget[] = [];
|
||||
|
||||
if (clients.length === 0) {
|
||||
logger.debug(
|
||||
|
||||
@@ -394,9 +394,9 @@ async function handleMessagesForSiteClients(
|
||||
return;
|
||||
}
|
||||
|
||||
let newtJobs: Promise<any>[] = [];
|
||||
let olmJobs: Promise<any>[] = [];
|
||||
let exitNodeJobs: Promise<any>[] = [];
|
||||
const newtJobs: Promise<any>[] = [];
|
||||
const olmJobs: Promise<any>[] = [];
|
||||
const exitNodeJobs: Promise<any>[] = [];
|
||||
|
||||
// Combine all clients that need processing (those being added or removed)
|
||||
const clientsToProcess = new Map<
|
||||
@@ -632,8 +632,8 @@ async function handleSubnetProxyTargetUpdates(
|
||||
return;
|
||||
}
|
||||
|
||||
let proxyJobs = [];
|
||||
let olmJobs = [];
|
||||
const proxyJobs = [];
|
||||
const olmJobs = [];
|
||||
// Generate targets for added associations
|
||||
if (clientSiteResourcesToAdd.length > 0) {
|
||||
const addedClients = allClients.filter((client) =>
|
||||
|
||||
@@ -1043,7 +1043,7 @@ hybridRouter.get(
|
||||
);
|
||||
}
|
||||
|
||||
let rules = await db
|
||||
const rules = await db
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId));
|
||||
|
||||
@@ -246,7 +246,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||
.from(siteResources)
|
||||
.where(eq(siteResources.siteId, siteId));
|
||||
|
||||
let targetsToSend: SubnetProxyTarget[] = [];
|
||||
const targetsToSend: SubnetProxyTarget[] = [];
|
||||
|
||||
for (const resource of allSiteResources) {
|
||||
// Get clients associated with this specific resource
|
||||
|
||||
@@ -304,8 +304,7 @@ export async function updateSiteResource(
|
||||
});
|
||||
}
|
||||
|
||||
// Update olms for both destination and alias changes
|
||||
let olmJobs: Promise<void>[] = [];
|
||||
const olmJobs: Promise<void>[] = [];
|
||||
for (const client of mergedAllClients) {
|
||||
// we also need to update the remote subnets on the olms for each client that has access to this site
|
||||
olmJobs.push(
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { cache } from "react";
|
||||
import MemberResourcesPortal from "../../components/MemberResourcesPortal";
|
||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Layout } from "@app/components/Layout";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import MemberResourcesPortal from "@app/components/MemberResourcesPortal";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import EnvProvider from "@app/providers/EnvProvider";
|
||||
import { orgLangingNavItems } from "@app/app/navigation";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
|
||||
type OrgPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
||||
78
src/app/[orgId]/settings/clients/machine/page.tsx
Normal file
78
src/app/[orgId]/settings/clients/machine/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { ClientRow } from "@app/components/MachineClientsTable";
|
||||
import MachineClientsTable from "@app/components/MachineClientsTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ListClientsResponse } from "@server/routers/client";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type ClientsPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<{ view?: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ClientsPage(props: ClientsPageProps) {
|
||||
const t = await getTranslations();
|
||||
|
||||
const params = await props.params;
|
||||
|
||||
let machineClients: ListClientsResponse["clients"] = [];
|
||||
|
||||
try {
|
||||
const machineRes = await internal.get<
|
||||
AxiosResponse<ListClientsResponse>
|
||||
>(
|
||||
`/org/${params.orgId}/clients?filter=machine`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
machineClients = machineRes.data.data.clients;
|
||||
} catch (e) {}
|
||||
|
||||
function formatSize(mb: number): string {
|
||||
if (mb >= 1024 * 1024) {
|
||||
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
|
||||
} else if (mb >= 1024) {
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
} else {
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
}
|
||||
}
|
||||
|
||||
const mapClientToRow = (
|
||||
client: ListClientsResponse["clients"][0]
|
||||
): ClientRow => {
|
||||
return {
|
||||
name: client.name,
|
||||
id: client.clientId,
|
||||
subnet: client.subnet.split("/")[0],
|
||||
mbIn: formatSize(client.megabytesIn || 0),
|
||||
mbOut: formatSize(client.megabytesOut || 0),
|
||||
orgId: params.orgId,
|
||||
online: client.online,
|
||||
olmVersion: client.olmVersion || undefined,
|
||||
olmUpdateAvailable: client.olmUpdateAvailable || false,
|
||||
userId: client.userId,
|
||||
username: client.username,
|
||||
userEmail: client.userEmail
|
||||
};
|
||||
};
|
||||
|
||||
const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("manageMachineClients")}
|
||||
description={t("manageMachineClientsDescription")}
|
||||
/>
|
||||
|
||||
<MachineClientsTable
|
||||
machineClients={machineClientRows}
|
||||
orgId={params.orgId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,4 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ClientRow } from "../../../../components/ClientsTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { ListClientsResponse } from "@server/routers/client";
|
||||
import ClientsTable from "../../../../components/ClientsTable";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type ClientsPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -15,76 +8,6 @@ type ClientsPageProps = {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ClientsPage(props: ClientsPageProps) {
|
||||
const t = await getTranslations();
|
||||
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
// Default to 'user' view, or use the query param if provided
|
||||
let defaultView: "user" | "machine" = "user";
|
||||
defaultView = searchParams.view === "machine" ? "machine" : "user";
|
||||
|
||||
let userClients: ListClientsResponse["clients"] = [];
|
||||
let machineClients: ListClientsResponse["clients"] = [];
|
||||
|
||||
try {
|
||||
const [userRes, machineRes] = await Promise.all([
|
||||
internal.get<AxiosResponse<ListClientsResponse>>(
|
||||
`/org/${params.orgId}/clients?filter=user`,
|
||||
await authCookieHeader()
|
||||
),
|
||||
internal.get<AxiosResponse<ListClientsResponse>>(
|
||||
`/org/${params.orgId}/clients?filter=machine`,
|
||||
await authCookieHeader()
|
||||
)
|
||||
]);
|
||||
userClients = userRes.data.data.clients;
|
||||
machineClients = machineRes.data.data.clients;
|
||||
} catch (e) {}
|
||||
|
||||
function formatSize(mb: number): string {
|
||||
if (mb >= 1024 * 1024) {
|
||||
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
|
||||
} else if (mb >= 1024) {
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
} else {
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
}
|
||||
}
|
||||
|
||||
const mapClientToRow = (client: ListClientsResponse["clients"][0]): ClientRow => {
|
||||
return {
|
||||
name: client.name,
|
||||
id: client.clientId,
|
||||
subnet: client.subnet.split("/")[0],
|
||||
mbIn: formatSize(client.megabytesIn || 0),
|
||||
mbOut: formatSize(client.megabytesOut || 0),
|
||||
orgId: params.orgId,
|
||||
online: client.online,
|
||||
olmVersion: client.olmVersion || undefined,
|
||||
olmUpdateAvailable: client.olmUpdateAvailable || false,
|
||||
userId: client.userId,
|
||||
username: client.username,
|
||||
userEmail: client.userEmail
|
||||
};
|
||||
};
|
||||
|
||||
const userClientRows: ClientRow[] = userClients.map(mapClientToRow);
|
||||
const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("manageClients")}
|
||||
description={t("manageClientsDescription")}
|
||||
/>
|
||||
|
||||
<ClientsTable
|
||||
userClients={userClientRows}
|
||||
machineClients={machineClientRows}
|
||||
orgId={params.orgId}
|
||||
defaultView={defaultView}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
redirect(`/${params.orgId}/settings/clients/user`);
|
||||
}
|
||||
|
||||
75
src/app/[orgId]/settings/clients/user/page.tsx
Normal file
75
src/app/[orgId]/settings/clients/user/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { ListClientsResponse } from "@server/routers/client";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { ClientRow } from "@app/components/MachineClientsTable";
|
||||
import UserDevicesTable from "@app/components/UserDevicesTable";
|
||||
|
||||
type ClientsPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ClientsPage(props: ClientsPageProps) {
|
||||
const t = await getTranslations();
|
||||
|
||||
const params = await props.params;
|
||||
|
||||
let userClients: ListClientsResponse["clients"] = [];
|
||||
|
||||
try {
|
||||
const userRes = await internal.get<AxiosResponse<ListClientsResponse>>(
|
||||
`/org/${params.orgId}/clients?filter=user`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
userClients = userRes.data.data.clients;
|
||||
} catch (e) {}
|
||||
|
||||
function formatSize(mb: number): string {
|
||||
if (mb >= 1024 * 1024) {
|
||||
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
|
||||
} else if (mb >= 1024) {
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
} else {
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
}
|
||||
}
|
||||
|
||||
const mapClientToRow = (
|
||||
client: ListClientsResponse["clients"][0]
|
||||
): ClientRow => {
|
||||
return {
|
||||
name: client.name,
|
||||
id: client.clientId,
|
||||
subnet: client.subnet.split("/")[0],
|
||||
mbIn: formatSize(client.megabytesIn || 0),
|
||||
mbOut: formatSize(client.megabytesOut || 0),
|
||||
orgId: params.orgId,
|
||||
online: client.online,
|
||||
olmVersion: client.olmVersion || undefined,
|
||||
olmUpdateAvailable: client.olmUpdateAvailable || false,
|
||||
userId: client.userId,
|
||||
username: client.username,
|
||||
userEmail: client.userEmail
|
||||
};
|
||||
};
|
||||
|
||||
const userClientRows: ClientRow[] = userClients.map(mapClientToRow);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("manageUserDevices")}
|
||||
description={t("manageUserDevicesDescription")}
|
||||
/>
|
||||
|
||||
<UserDevicesTable
|
||||
userClients={userClientRows}
|
||||
orgId={params.orgId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
93
src/app/[orgId]/settings/resources/client/page.tsx
Normal file
93
src/app/[orgId]/settings/resources/client/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import ClientResourcesTable from "@app/components/ClientResourcesTable";
|
||||
import type { InternalResourceRow } from "@app/components/ClientResourcesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
|
||||
export interface ClientResourcesPageProps {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<{ view?: string }>;
|
||||
}
|
||||
|
||||
export default async function ClientResourcesPage(
|
||||
props: ClientResourcesPageProps
|
||||
) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
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 res = await getCachedOrg(params.orgId);
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
|
||||
if (!org) {
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
|
||||
const internalResourceRows: InternalResourceRow[] = siteResources.map(
|
||||
(siteResource) => {
|
||||
return {
|
||||
id: siteResource.siteResourceId,
|
||||
name: siteResource.name,
|
||||
orgId: params.orgId,
|
||||
siteName: siteResource.siteName,
|
||||
siteAddress: siteResource.siteAddress || null,
|
||||
mode: siteResource.mode || ("port" as any),
|
||||
// protocol: siteResource.protocol,
|
||||
// proxyPort: siteResource.proxyPort,
|
||||
siteId: siteResource.siteId,
|
||||
destination: siteResource.destination,
|
||||
// destinationPort: siteResource.destinationPort,
|
||||
alias: siteResource.alias || null,
|
||||
siteNiceId: siteResource.siteNiceId
|
||||
};
|
||||
}
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("clientResourceTitle")}
|
||||
description={t("clientResourceDescription")}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
<ClientResourcesTable
|
||||
internalResources={internalResourceRows}
|
||||
orgId={params.orgId}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
}}
|
||||
/>
|
||||
</OrgProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +1,10 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import ResourcesTable, {
|
||||
ResourceRow,
|
||||
InternalResourceRow
|
||||
} from "../../../../components/ResourcesTable";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ListResourcesResponse } from "@server/routers/resource";
|
||||
import { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { toUnicode } from "punycode";
|
||||
|
||||
type ResourcesPageProps = {
|
||||
export interface ResourcesPageProps {
|
||||
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 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";
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
siteAddress: siteResource.siteAddress || null,
|
||||
mode: siteResource.mode || ("port" as any),
|
||||
// protocol: siteResource.protocol,
|
||||
// proxyPort: siteResource.proxyPort,
|
||||
siteId: siteResource.siteId,
|
||||
destination: siteResource.destination,
|
||||
// destinationPort: siteResource.destinationPort,
|
||||
alias: siteResource.alias || null,
|
||||
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>
|
||||
</>
|
||||
);
|
||||
redirect(`/${params.orgId}/settings/resources/proxy`);
|
||||
}
|
||||
|
||||
112
src/app/[orgId]/settings/resources/proxy/page.tsx
Normal file
112
src/app/[orgId]/settings/resources/proxy/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { ResourceRow } from "@app/components/ProxyResourcesTable";
|
||||
import ProxyResourcesTable from "@app/components/ProxyResourcesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import type { GetOrgResponse } from "@server/routers/org";
|
||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { toUnicode } from "punycode";
|
||||
import { cache } from "react";
|
||||
|
||||
export interface ProxyResourcesPageProps {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<{ view?: string }>;
|
||||
}
|
||||
|
||||
export default async function ProxyResourcesPage(
|
||||
props: ProxyResourcesPageProps
|
||||
) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
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,
|
||||
targets: resource.targets?.map((target) => ({
|
||||
targetId: target.targetId,
|
||||
ip: target.ip,
|
||||
port: target.port,
|
||||
enabled: target.enabled,
|
||||
healthStatus: target.healthStatus
|
||||
}))
|
||||
};
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("proxyResourceTitle")}
|
||||
description={t("proxyResourceDescription")}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
<ProxyResourcesTable
|
||||
resources={resourceRows}
|
||||
orgId={params.orgId}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
}}
|
||||
/>
|
||||
</OrgProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { Toaster } from "@app/components/ui/toaster";
|
||||
import { build } from "@server/build";
|
||||
import { TopLoader } from "@app/components/Toploader";
|
||||
import Script from "next/script";
|
||||
import { ReactQueryProvider } from "@app/components/react-query-provider";
|
||||
import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
|
||||
@@ -95,16 +95,16 @@ export default async function RootLayout({
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
)}
|
||||
<ReactQueryProvider>
|
||||
<NextIntlClientProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ThemeDataProvider colors={loadBrandingColors()}>
|
||||
<EnvProvider env={pullEnv()}>
|
||||
<NextIntlClientProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ThemeDataProvider colors={loadBrandingColors()}>
|
||||
<EnvProvider env={env}>
|
||||
<TanstackQueryProvider>
|
||||
<LicenseStatusProvider
|
||||
licenseStatus={licenseStatus}
|
||||
>
|
||||
@@ -124,11 +124,11 @@ export default async function RootLayout({
|
||||
</SupportStatusProvider>
|
||||
</LicenseStatusProvider>
|
||||
<Toaster />
|
||||
</EnvProvider>
|
||||
</ThemeDataProvider>
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
</ReactQueryProvider>
|
||||
</TanstackQueryProvider>
|
||||
</EnvProvider>
|
||||
</ThemeDataProvider>
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
CreditCard,
|
||||
Logs,
|
||||
SquareMousePointer,
|
||||
ScanEye
|
||||
ScanEye,
|
||||
GlobeLock,
|
||||
Smartphone
|
||||
} from "lucide-react";
|
||||
|
||||
export type SidebarNavSection = {
|
||||
@@ -31,7 +33,7 @@ export const orgLangingNavItems: SidebarNavItem[] = [
|
||||
{
|
||||
title: "sidebarAccount",
|
||||
href: "/{orgId}",
|
||||
icon: <User className="h-4 w-4" />
|
||||
icon: <User className="size-4 flex-none" />
|
||||
}
|
||||
];
|
||||
|
||||
@@ -44,20 +46,50 @@ export const orgNavSections = (
|
||||
{
|
||||
title: "sidebarSites",
|
||||
href: "/{orgId}/settings/sites",
|
||||
icon: <Combine className="h-4 w-4" />
|
||||
icon: <Combine className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarResources",
|
||||
href: "/{orgId}/settings/resources",
|
||||
icon: <Waypoints className="h-4 w-4" />
|
||||
icon: <Waypoints className="size-4 flex-none" />,
|
||||
items: [
|
||||
{
|
||||
title: "sidebarProxyResources",
|
||||
href: "/{orgId}/settings/resources/proxy",
|
||||
icon: <Globe className="size-4 flex-none" />
|
||||
},
|
||||
...(enableClients
|
||||
? [
|
||||
{
|
||||
title: "sidebarClientResources",
|
||||
href: "/{orgId}/settings/resources/client",
|
||||
icon: (
|
||||
<GlobeLock className="size-4 flex-none" />
|
||||
)
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
},
|
||||
...(enableClients
|
||||
? [
|
||||
{
|
||||
title: "sidebarClients",
|
||||
href: "/{orgId}/settings/clients",
|
||||
icon: <MonitorUp className="h-4 w-4" />,
|
||||
isBeta: true
|
||||
icon: <MonitorUp className="size-4 flex-none" />,
|
||||
isBeta: true,
|
||||
items: [
|
||||
{
|
||||
href: "/{orgId}/settings/clients/user",
|
||||
title: "sidebarUserDevices",
|
||||
icon: (
|
||||
<Smartphone className="size-4 flex-none" />
|
||||
)
|
||||
},
|
||||
{
|
||||
href: "/{orgId}/settings/clients/machine",
|
||||
title: "sidebarMachineClients",
|
||||
icon: <Server className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
: []),
|
||||
@@ -66,7 +98,7 @@ export const orgNavSections = (
|
||||
{
|
||||
title: "sidebarRemoteExitNodes",
|
||||
href: "/{orgId}/settings/remote-exit-nodes",
|
||||
icon: <Server className="h-4 w-4" />,
|
||||
icon: <Server className="size-4 flex-none" />,
|
||||
showEE: true
|
||||
}
|
||||
]
|
||||
@@ -74,12 +106,12 @@ export const orgNavSections = (
|
||||
{
|
||||
title: "sidebarDomains",
|
||||
href: "/{orgId}/settings/domains",
|
||||
icon: <Globe className="h-4 w-4" />
|
||||
icon: <Globe className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarBluePrints",
|
||||
href: "/{orgId}/settings/blueprints",
|
||||
icon: <ReceiptText className="h-4 w-4" />
|
||||
icon: <ReceiptText className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -88,31 +120,31 @@ export const orgNavSections = (
|
||||
items: [
|
||||
{
|
||||
title: "sidebarUsers",
|
||||
icon: <User className="h-4 w-4" />,
|
||||
icon: <User className="size-4 flex-none" />,
|
||||
items: [
|
||||
{
|
||||
title: "sidebarUsers",
|
||||
href: "/{orgId}/settings/access/users",
|
||||
icon: <User className="h-4 w-4" />
|
||||
icon: <User className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarInvitations",
|
||||
href: "/{orgId}/settings/access/invitations",
|
||||
icon: <TicketCheck className="h-4 w-4" />
|
||||
icon: <TicketCheck className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "sidebarRoles",
|
||||
href: "/{orgId}/settings/access/roles",
|
||||
icon: <Users className="h-4 w-4" />
|
||||
icon: <Users className="size-4 flex-none" />
|
||||
},
|
||||
...(build == "saas"
|
||||
? [
|
||||
{
|
||||
title: "sidebarIdentityProviders",
|
||||
href: "/{orgId}/settings/idp",
|
||||
icon: <Fingerprint className="h-4 w-4" />,
|
||||
icon: <Fingerprint className="size-4 flex-none" />,
|
||||
showEE: true
|
||||
}
|
||||
]
|
||||
@@ -120,7 +152,7 @@ export const orgNavSections = (
|
||||
{
|
||||
title: "sidebarShareableLinks",
|
||||
href: "/{orgId}/settings/share-links",
|
||||
icon: <LinkIcon className="h-4 w-4" />
|
||||
icon: <LinkIcon className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -131,19 +163,19 @@ export const orgNavSections = (
|
||||
{
|
||||
title: "sidebarLogsRequest",
|
||||
href: "/{orgId}/settings/logs/request",
|
||||
icon: <SquareMousePointer className="h-4 w-4" />
|
||||
icon: <SquareMousePointer className="size-4 flex-none" />
|
||||
},
|
||||
...(build != "oss"
|
||||
? [
|
||||
{
|
||||
title: "sidebarLogsAccess",
|
||||
href: "/{orgId}/settings/logs/access",
|
||||
icon: <ScanEye className="h-4 w-4" />
|
||||
icon: <ScanEye className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarLogsAction",
|
||||
href: "/{orgId}/settings/logs/action",
|
||||
icon: <Logs className="h-4 w-4" />
|
||||
icon: <Logs className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
@@ -158,7 +190,7 @@ export const orgNavSections = (
|
||||
return [
|
||||
{
|
||||
title: "sidebarLogs",
|
||||
icon: <Logs className="h-4 w-4" />,
|
||||
icon: <Logs className="size-4 flex-none" />,
|
||||
items: logItems
|
||||
}
|
||||
];
|
||||
@@ -170,14 +202,14 @@ export const orgNavSections = (
|
||||
{
|
||||
title: "sidebarApiKeys",
|
||||
href: "/{orgId}/settings/api-keys",
|
||||
icon: <KeyRound className="h-4 w-4" />
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
},
|
||||
...(build == "saas"
|
||||
? [
|
||||
{
|
||||
title: "sidebarBilling",
|
||||
href: "/{orgId}/settings/billing",
|
||||
icon: <CreditCard className="h-4 w-4" />
|
||||
icon: <CreditCard className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: []),
|
||||
@@ -186,14 +218,14 @@ export const orgNavSections = (
|
||||
{
|
||||
title: "sidebarEnterpriseLicenses",
|
||||
href: "/{orgId}/settings/license",
|
||||
icon: <TicketCheck className="h-4 w-4" />
|
||||
icon: <TicketCheck className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: "sidebarSettings",
|
||||
href: "/{orgId}/settings/general",
|
||||
icon: <Settings className="h-4 w-4" />
|
||||
icon: <Settings className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -206,24 +238,24 @@ export const adminNavSections: SidebarNavSection[] = [
|
||||
{
|
||||
title: "sidebarAllUsers",
|
||||
href: "/admin/users",
|
||||
icon: <Users className="h-4 w-4" />
|
||||
icon: <Users className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarApiKeys",
|
||||
href: "/admin/api-keys",
|
||||
icon: <KeyRound className="h-4 w-4" />
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarIdentityProviders",
|
||||
href: "/admin/idp",
|
||||
icon: <Fingerprint className="h-4 w-4" />
|
||||
icon: <Fingerprint className="size-4 flex-none" />
|
||||
},
|
||||
...(build == "enterprise"
|
||||
? [
|
||||
{
|
||||
title: "sidebarLicense",
|
||||
href: "/admin/license",
|
||||
icon: <TicketCheck className="h-4 w-4" />
|
||||
icon: <TicketCheck className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
|
||||
634
src/components/ClientResourcesTable.tsx
Normal file
634
src/components/ClientResourcesTable.tsx
Normal file
@@ -0,0 +1,634 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
Columns,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
||||
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { orgQueries, siteQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export type TargetHealth = {
|
||||
targetId: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
healthStatus?: "healthy" | "unhealthy" | "unknown";
|
||||
};
|
||||
|
||||
export type ResourceRow = {
|
||||
id: number;
|
||||
nice: string | null;
|
||||
name: string;
|
||||
orgId: string;
|
||||
domain: string;
|
||||
authState: string;
|
||||
http: boolean;
|
||||
protocol: string;
|
||||
proxyPort: number | null;
|
||||
enabled: boolean;
|
||||
domainId?: string;
|
||||
ssl: boolean;
|
||||
targetHost?: string;
|
||||
targetPort?: number;
|
||||
targets?: TargetHealth[];
|
||||
};
|
||||
|
||||
export type InternalResourceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
orgId: string;
|
||||
siteName: string;
|
||||
siteAddress: string | null;
|
||||
// mode: "host" | "cidr" | "port";
|
||||
mode: "host" | "cidr";
|
||||
// protocol: string | null;
|
||||
// proxyPort: number | null;
|
||||
siteId: number;
|
||||
siteNiceId: string;
|
||||
destination: string;
|
||||
// destinationPort: number | null;
|
||||
alias: string | null;
|
||||
};
|
||||
|
||||
type ClientResourcesTableProps = {
|
||||
internalResources: InternalResourceRow[];
|
||||
orgId: string;
|
||||
defaultSort?: {
|
||||
id: string;
|
||||
desc: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export default function ClientResourcesTable({
|
||||
internalResources,
|
||||
orgId,
|
||||
defaultSort
|
||||
}: ClientResourcesTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [internalPageSize, setInternalPageSize] = useStoredPageSize(
|
||||
"internal-resources",
|
||||
20
|
||||
);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
||||
const [selectedInternalResource, setSelectedInternalResource] =
|
||||
useState<InternalResourceRow | null>();
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [editingResource, setEditingResource] =
|
||||
useState<InternalResourceRow | null>();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
|
||||
const { data: sites = [] } = useQuery(orgQueries.sites({ orgId, api }));
|
||||
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>(
|
||||
defaultSort ? [defaultSort] : []
|
||||
);
|
||||
const [internalColumnFilters, setInternalColumnFilters] =
|
||||
useState<ColumnFiltersState>([]);
|
||||
const [internalGlobalFilter, setInternalGlobalFilter] = useState<any>([]);
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
|
||||
const [internalColumnVisibility, setInternalColumnVisibility] =
|
||||
useStoredColumnVisibility("internal-resources", {});
|
||||
const refreshData = async () => {
|
||||
try {
|
||||
router.refresh();
|
||||
console.log("Data refreshed");
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteInternalResource = async (
|
||||
resourceId: number,
|
||||
siteId: number
|
||||
) => {
|
||||
try {
|
||||
await api
|
||||
.delete(`/org/${orgId}/site/${siteId}/resource/${resourceId}`)
|
||||
.then(() => {
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(t("resourceErrorDelete"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("v"))
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "siteName",
|
||||
friendlyName: t("siteName"),
|
||||
header: () => <span className="p-3">{t("siteName")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{resourceRow.siteName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mode",
|
||||
friendlyName: t("editInternalResourceDialogMode"),
|
||||
header: () => (
|
||||
<span className="p-3">
|
||||
{t("editInternalResourceDialogMode")}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const modeLabels: Record<"host" | "cidr" | "port", string> = {
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
port: t("editInternalResourceDialogModePort")
|
||||
};
|
||||
return <span>{modeLabels[resourceRow.mode]}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "destination",
|
||||
friendlyName: t("resourcesTableDestination"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("resourcesTableDestination")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
let displayText: string;
|
||||
let copyText: string;
|
||||
|
||||
// if (
|
||||
// resourceRow.mode === "port" &&
|
||||
// // resourceRow.protocol &&
|
||||
// // resourceRow.proxyPort &&
|
||||
// // resourceRow.destinationPort
|
||||
// ) {
|
||||
// // const protocol = resourceRow.protocol.toUpperCase();
|
||||
// // For port mode: site part uses alias or site address, destination part uses destination IP
|
||||
// // If site address has CIDR notation, extract just the IP address
|
||||
// let siteAddress = resourceRow.siteAddress;
|
||||
// if (siteAddress && siteAddress.includes("/")) {
|
||||
// siteAddress = siteAddress.split("/")[0];
|
||||
// }
|
||||
// const siteDisplay = resourceRow.alias || siteAddress;
|
||||
// // displayText = `${protocol} ${siteDisplay}:${resourceRow.proxyPort} -> ${resourceRow.destination}:${resourceRow.destinationPort}`;
|
||||
// // copyText = `${siteDisplay}:${resourceRow.proxyPort}`;
|
||||
// } else if (resourceRow.mode === "host") {
|
||||
if (resourceRow.mode === "host") {
|
||||
// For host mode: use alias if available, otherwise use destination
|
||||
const destinationDisplay =
|
||||
resourceRow.alias || resourceRow.destination;
|
||||
displayText = destinationDisplay;
|
||||
copyText = destinationDisplay;
|
||||
} else if (resourceRow.mode === "cidr") {
|
||||
displayText = resourceRow.destination;
|
||||
copyText = resourceRow.destination;
|
||||
} else {
|
||||
const destinationDisplay =
|
||||
resourceRow.alias || resourceRow.destination;
|
||||
displayText = destinationDisplay;
|
||||
copyText = destinationDisplay;
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={copyText}
|
||||
isLink={false}
|
||||
displayText={displayText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: ({ table }) => {
|
||||
const hasHideableColumns = table
|
||||
.getAllColumns()
|
||||
.some((column) => column.getCanHide());
|
||||
if (!hasHideableColumns) {
|
||||
return <span className="p-3"></span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1 p-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Columns className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t("columns") || "Columns"}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel>
|
||||
{t("toggleColumns") || "Toggle columns"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
const columnDef =
|
||||
column.columnDef as any;
|
||||
const friendlyName =
|
||||
columnDef.friendlyName;
|
||||
const displayName =
|
||||
friendlyName ||
|
||||
(typeof columnDef.header ===
|
||||
"string"
|
||||
? columnDef.header
|
||||
: column.id);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(
|
||||
!!value
|
||||
)
|
||||
}
|
||||
onSelect={(e) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
>
|
||||
{displayName}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedInternalResource(
|
||||
resourceRow
|
||||
);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setEditingResource(resourceRow);
|
||||
setIsEditDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const internalTable = useReactTable({
|
||||
data: internalResources,
|
||||
columns: internalColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setInternalSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setInternalColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setInternalGlobalFilter,
|
||||
onColumnVisibilityChange: setInternalColumnVisibility,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: internalPageSize,
|
||||
pageIndex: 0
|
||||
},
|
||||
columnVisibility: internalColumnVisibility
|
||||
},
|
||||
state: {
|
||||
sorting: internalSorting,
|
||||
columnFilters: internalColumnFilters,
|
||||
globalFilter: internalGlobalFilter,
|
||||
columnVisibility: internalColumnVisibility
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedInternalResource && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedInternalResource(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<p>{t("resourceQuestionRemove")}</p>
|
||||
<p>{t("resourceMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("resourceDeleteConfirm")}
|
||||
onConfirm={async () =>
|
||||
deleteInternalResource(
|
||||
selectedInternalResource!.id,
|
||||
selectedInternalResource!.siteId
|
||||
)
|
||||
}
|
||||
string={selectedInternalResource.name}
|
||||
title={t("resourceDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
|
||||
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={t("resourcesSearch")}
|
||||
value={internalGlobalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
internalTable.setGlobalFilter(
|
||||
String(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:justify-end">
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startTransition(refreshData)}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="hidden sm:inline">
|
||||
{t("refresh")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{" "}
|
||||
<Button
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourceAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto mt-9">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{internalTable
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers
|
||||
.filter((header) =>
|
||||
header.column.getIsVisible()
|
||||
)
|
||||
.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={`whitespace-nowrap ${
|
||||
header.column
|
||||
.id ===
|
||||
"actions"
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: header
|
||||
.column
|
||||
.id ===
|
||||
"name"
|
||||
? "md:sticky md:left-0 z-10 bg-card"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{internalTable.getRowModel().rows
|
||||
?.length ? (
|
||||
internalTable
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`whitespace-nowrap ${
|
||||
cell.column
|
||||
.id ===
|
||||
"actions"
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: cell
|
||||
.column
|
||||
.id ===
|
||||
"name"
|
||||
? "md:sticky md:left-0 z-10 bg-card"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={internalColumns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t(
|
||||
"resourcesTableNoInternalResourcesFound"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={internalTable}
|
||||
onPageSizeChange={setInternalPageSize}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{editingResource && (
|
||||
<EditInternalResourceDialog
|
||||
open={isEditDialogOpen}
|
||||
setOpen={setIsEditDialogOpen}
|
||||
resource={editingResource}
|
||||
orgId={orgId}
|
||||
onSuccess={() => {
|
||||
router.refresh();
|
||||
setEditingResource(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateInternalResourceDialog
|
||||
open={isCreateDialogOpen}
|
||||
setOpen={setIsCreateDialogOpen}
|
||||
orgId={orgId}
|
||||
sites={sites}
|
||||
onSuccess={() => {
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,985 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
VisibilityState
|
||||
} from "@tanstack/react-table";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
MoreHorizontal,
|
||||
X,
|
||||
RefreshCw,
|
||||
Columns,
|
||||
Search,
|
||||
Plus
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { InfoPopup } from "./ui/info-popup";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger
|
||||
} from "@app/components/ui/tabs";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
subnet: string;
|
||||
// siteIds: string;
|
||||
mbIn: string;
|
||||
mbOut: string;
|
||||
orgId: string;
|
||||
online: boolean;
|
||||
olmVersion?: string;
|
||||
olmUpdateAvailable: boolean;
|
||||
userId: string | null;
|
||||
username: string | null;
|
||||
userEmail: string | null;
|
||||
};
|
||||
|
||||
type ClientTableProps = {
|
||||
userClients: ClientRow[];
|
||||
machineClients: ClientRow[];
|
||||
orgId: string;
|
||||
defaultView?: "user" | "machine";
|
||||
};
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
PAGE_SIZE: "datatable-page-size",
|
||||
COLUMN_VISIBILITY: "datatable-column-visibility",
|
||||
getTablePageSize: (tableId?: string) =>
|
||||
tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE,
|
||||
getTableColumnVisibility: (tableId?: string) =>
|
||||
tableId
|
||||
? `datatable-${tableId}-column-visibility`
|
||||
: STORAGE_KEYS.COLUMN_VISIBILITY
|
||||
};
|
||||
|
||||
const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
|
||||
if (typeof window === "undefined") return defaultSize;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTablePageSize(tableId);
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
if (parsed > 0 && parsed <= 1000) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (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;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTablePageSize(tableId);
|
||||
localStorage.setItem(key, pageSize.toString());
|
||||
} catch (error) {
|
||||
console.warn("Failed to save page size to localStorage:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStoredColumnVisibility = (
|
||||
tableId?: string,
|
||||
defaultVisibility?: Record<string, boolean>
|
||||
): Record<string, boolean> => {
|
||||
if (typeof window === "undefined") return defaultVisibility || {};
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTableColumnVisibility(tableId);
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Validate that it's an object
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Failed to read column visibility from localStorage:",
|
||||
error
|
||||
);
|
||||
}
|
||||
return defaultVisibility || {};
|
||||
};
|
||||
|
||||
const setStoredColumnVisibility = (
|
||||
visibility: Record<string, boolean>,
|
||||
tableId?: string
|
||||
): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTableColumnVisibility(tableId);
|
||||
localStorage.setItem(key, JSON.stringify(visibility));
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Failed to save column visibility to localStorage:",
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default function ClientsTable({
|
||||
userClients,
|
||||
machineClients,
|
||||
orgId,
|
||||
defaultView = "user"
|
||||
}: ClientTableProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations();
|
||||
|
||||
const [userPageSize, setUserPageSize] = useState<number>(() =>
|
||||
getStoredPageSize("user-clients", 20)
|
||||
);
|
||||
const [machinePageSize, setMachinePageSize] = useState<number>(() =>
|
||||
getStoredPageSize("machine-clients", 20)
|
||||
);
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const [userSorting, setUserSorting] = useState<SortingState>([]);
|
||||
const [userColumnFilters, setUserColumnFilters] =
|
||||
useState<ColumnFiltersState>([]);
|
||||
const [userGlobalFilter, setUserGlobalFilter] = useState<any>([]);
|
||||
|
||||
const [machineSorting, setMachineSorting] = useState<SortingState>([]);
|
||||
const [machineColumnFilters, setMachineColumnFilters] =
|
||||
useState<ColumnFiltersState>([]);
|
||||
const [machineGlobalFilter, setMachineGlobalFilter] = useState<any>([]);
|
||||
|
||||
const defaultUserColumnVisibility = {
|
||||
client: false,
|
||||
subnet: false
|
||||
};
|
||||
const defaultMachineColumnVisibility = {
|
||||
client: false,
|
||||
subnet: false,
|
||||
userId: false
|
||||
};
|
||||
|
||||
const [userColumnVisibility, setUserColumnVisibility] =
|
||||
useState<VisibilityState>(() =>
|
||||
getStoredColumnVisibility(
|
||||
"user-clients",
|
||||
defaultUserColumnVisibility
|
||||
)
|
||||
);
|
||||
const [machineColumnVisibility, setMachineColumnVisibility] =
|
||||
useState<VisibilityState>(() =>
|
||||
getStoredColumnVisibility(
|
||||
"machine-clients",
|
||||
defaultMachineColumnVisibility
|
||||
)
|
||||
);
|
||||
|
||||
const currentView = searchParams.get("view") || defaultView;
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log("Data refreshed");
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (value === "machine") {
|
||||
params.set("view", "machine");
|
||||
} else {
|
||||
params.delete("view");
|
||||
}
|
||||
|
||||
const newUrl = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`;
|
||||
router.replace(newUrl, { scroll: false });
|
||||
};
|
||||
|
||||
const deleteClient = (clientId: number) => {
|
||||
api.delete(`/client/${clientId}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting client", e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting client",
|
||||
description: formatAxiosError(e, "Error deleting client")
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const getSearchInput = () => {
|
||||
if (currentView === "machine") {
|
||||
return (
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={t("resourcesSearch")}
|
||||
value={machineGlobalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
machineTable.setGlobalFilter(String(e.target.value))
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={t("resourcesSearch")}
|
||||
value={userGlobalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
userTable.setGlobalFilter(String(e.target.value))
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getActionButton = () => {
|
||||
// Only show create button on machine clients tab
|
||||
if (currentView === "machine") {
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.push(`/${orgId}/settings/clients/create`)
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("createClient")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Check if there are any rows without userIds in the current view's data
|
||||
const hasRowsWithoutUserId = useMemo(() => {
|
||||
const currentData = currentView === "machine" ? machineClients : userClients;
|
||||
return currentData?.some((client) => !client.userId) ?? false;
|
||||
}, [currentView, machineClients, userClients]);
|
||||
|
||||
const columns: ExtendedColumnDef<ClientRow>[] = useMemo(() => {
|
||||
const baseColumns: ExtendedColumnDef<ClientRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: "Name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "userId",
|
||||
friendlyName: "User",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
User
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return r.userId ? (
|
||||
<Link
|
||||
href={`/${r.orgId}/settings/access/users/${r.userId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{r.userEmail || r.username || r.userId}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
}
|
||||
},
|
||||
// {
|
||||
// accessorKey: "siteName",
|
||||
// header: ({ column }) => {
|
||||
// return (
|
||||
// <Button
|
||||
// variant="ghost"
|
||||
// onClick={() =>
|
||||
// column.toggleSorting(column.getIsSorted() === "asc")
|
||||
// }
|
||||
// >
|
||||
// Site
|
||||
// <ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
// </Button>
|
||||
// );
|
||||
// },
|
||||
// cell: ({ row }) => {
|
||||
// const r = row.original;
|
||||
// return (
|
||||
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
|
||||
// <Button variant="outline">
|
||||
// {r.siteName}
|
||||
// <ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
// </Button>
|
||||
// </Link>
|
||||
// );
|
||||
// }
|
||||
// },
|
||||
{
|
||||
accessorKey: "online",
|
||||
friendlyName: "Connectivity",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Connectivity
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
if (originalRow.online) {
|
||||
return (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>Connected</span>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>Disconnected</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mbIn",
|
||||
friendlyName: "Data In",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Data In
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mbOut",
|
||||
friendlyName: "Data Out",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Data Out
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "client",
|
||||
friendlyName: t("client"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("client")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Badge variant="secondary">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Olm</span>
|
||||
{originalRow.olmVersion && (
|
||||
<span className="text-xs text-gray-500">
|
||||
v{originalRow.olmVersion}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
{originalRow.olmUpdateAvailable && (
|
||||
<InfoPopup info={t("olmUpdateAvailableInfo")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "subnet",
|
||||
friendlyName: "Address",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Address
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
// Only include actions column if there are rows without userIds
|
||||
if (hasRowsWithoutUserId) {
|
||||
baseColumns.push({
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: ({ table }) => {
|
||||
const hasHideableColumns = table
|
||||
.getAllColumns()
|
||||
.some((column) => column.getCanHide());
|
||||
if (!hasHideableColumns) {
|
||||
return <span className="p-3"></span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1 p-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
|
||||
<Columns className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t("columns") || "Columns"}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel>
|
||||
{t("toggleColumns") || "Toggle columns"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
const columnDef = column.columnDef as any;
|
||||
const friendlyName = columnDef.friendlyName;
|
||||
const displayName = friendlyName ||
|
||||
(typeof columnDef.header === "string"
|
||||
? columnDef.header
|
||||
: column.id);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{displayName}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const clientRow = row.original;
|
||||
return !clientRow.userId ? (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* <Link */}
|
||||
{/* className="block w-full" */}
|
||||
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
|
||||
{/* > */}
|
||||
{/* <DropdownMenuItem> */}
|
||||
{/* View settings */}
|
||||
{/* </DropdownMenuItem> */}
|
||||
{/* </Link> */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedClient(clientRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
Edit
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
}, [hasRowsWithoutUserId, t]);
|
||||
|
||||
const userTable = useReactTable({
|
||||
data: userClients || [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setUserSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setUserColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setUserGlobalFilter,
|
||||
onColumnVisibilityChange: setUserColumnVisibility,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: userPageSize,
|
||||
pageIndex: 0
|
||||
},
|
||||
columnVisibility: userColumnVisibility
|
||||
},
|
||||
state: {
|
||||
sorting: userSorting,
|
||||
columnFilters: userColumnFilters,
|
||||
globalFilter: userGlobalFilter,
|
||||
columnVisibility: userColumnVisibility
|
||||
}
|
||||
});
|
||||
|
||||
const machineTable = useReactTable({
|
||||
data: machineClients || [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setMachineSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setMachineColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setMachineGlobalFilter,
|
||||
onColumnVisibilityChange: setMachineColumnVisibility,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: machinePageSize,
|
||||
pageIndex: 0
|
||||
},
|
||||
columnVisibility: machineColumnVisibility
|
||||
},
|
||||
state: {
|
||||
sorting: machineSorting,
|
||||
columnFilters: machineColumnFilters,
|
||||
globalFilter: machineGlobalFilter,
|
||||
columnVisibility: machineColumnVisibility
|
||||
}
|
||||
});
|
||||
|
||||
const handleUserPageSizeChange = (newPageSize: number) => {
|
||||
setUserPageSize(newPageSize);
|
||||
setStoredPageSize(newPageSize, "user-clients");
|
||||
};
|
||||
|
||||
const handleMachinePageSizeChange = (newPageSize: number) => {
|
||||
setMachinePageSize(newPageSize);
|
||||
setStoredPageSize(newPageSize, "machine-clients");
|
||||
};
|
||||
|
||||
// Persist column visibility changes to localStorage
|
||||
useEffect(() => {
|
||||
setStoredColumnVisibility(userColumnVisibility, "user-clients");
|
||||
}, [userColumnVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
setStoredColumnVisibility(machineColumnVisibility, "machine-clients");
|
||||
}, [machineColumnVisibility]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedClient && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedClient(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<p>{t("deleteClientQuestion")}</p>
|
||||
<p>{t("clientMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete Client"
|
||||
onConfirm={async () => deleteClient(selectedClient!.id)}
|
||||
string={selectedClient.name}
|
||||
title="Delete Client"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<Card>
|
||||
<Tabs
|
||||
defaultValue={defaultView}
|
||||
className="w-full"
|
||||
onValueChange={handleTabChange}
|
||||
>
|
||||
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
|
||||
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
|
||||
{getSearchInput()}
|
||||
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="user">
|
||||
{t("clientsTableUserClients")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="machine">
|
||||
{t("clientsTableMachineClients")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:justify-end">
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={refreshData}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="hidden sm:inline">
|
||||
{t("refresh")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div>{getActionButton()}</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TabsContent value="user">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{userTable
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers
|
||||
.filter((header) =>
|
||||
header.column.getIsVisible()
|
||||
)
|
||||
.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={`whitespace-nowrap ${
|
||||
header.column.id ===
|
||||
"actions"
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: header.column.id ===
|
||||
"name"
|
||||
? "md:sticky md:left-0 z-10 bg-card"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{userTable.getRowModel().rows
|
||||
?.length ? (
|
||||
userTable
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={
|
||||
cell.id
|
||||
}
|
||||
className={`whitespace-nowrap ${
|
||||
cell.column.id ===
|
||||
"actions"
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: cell.column.id ===
|
||||
"name"
|
||||
? "md:sticky md:left-0 z-10 bg-card"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{flexRender(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("noResults")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={userTable}
|
||||
onPageSizeChange={
|
||||
handleUserPageSizeChange
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="machine">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{machineTable
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers
|
||||
.filter((header) =>
|
||||
header.column.getIsVisible()
|
||||
)
|
||||
.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={`whitespace-nowrap ${
|
||||
header.column.id ===
|
||||
"actions"
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: header.column.id ===
|
||||
"name"
|
||||
? "md:sticky md:left-0 z-10 bg-card"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{machineTable.getRowModel().rows
|
||||
?.length ? (
|
||||
machineTable
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={
|
||||
cell.id
|
||||
}
|
||||
className={`whitespace-nowrap ${
|
||||
cell.column.id ===
|
||||
"actions"
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: cell.column.id ===
|
||||
"name"
|
||||
? "md:sticky md:left-0 z-10 bg-card"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{flexRender(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("noResults")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={machineTable}
|
||||
onPageSizeChange={
|
||||
handleMachinePageSizeChange
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -18,15 +19,6 @@ import {
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -36,29 +28,36 @@ import {
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import { ListUsersResponse } from "@server/routers/user";
|
||||
import { ListClientsResponse } from "@server/routers/client/listClients";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ListClientsResponse } from "@server/routers/client/listClients";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { ListUsersResponse } from "@server/routers/user";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
type Site = ListSitesResponse["sites"][0];
|
||||
|
||||
@@ -89,7 +88,9 @@ export default function CreateInternalResourceDialog({
|
||||
// mode: z.enum(["host", "cidr", "port"]),
|
||||
mode: z.enum(["host", "cidr"]),
|
||||
destination: z.string().min(1),
|
||||
siteId: z.int().positive(t("createInternalResourceDialogPleaseSelectSite")),
|
||||
siteId: z
|
||||
.int()
|
||||
.positive(t("createInternalResourceDialogPleaseSelectSite")),
|
||||
// protocol: z.enum(["tcp", "udp"]),
|
||||
// proxyPort: z.int()
|
||||
// .positive()
|
||||
@@ -101,25 +102,31 @@ export default function CreateInternalResourceDialog({
|
||||
// .max(65535, t("createInternalResourceDialogDestinationPortMax"))
|
||||
// .nullish(),
|
||||
alias: z.string().nullish(),
|
||||
roles: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
).optional(),
|
||||
users: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
).optional(),
|
||||
clients: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
).optional()
|
||||
})
|
||||
roles: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
users: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
clients: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
});
|
||||
// .refine(
|
||||
// (data) => {
|
||||
// if (data.mode === "port") {
|
||||
@@ -159,13 +166,47 @@ export default function CreateInternalResourceDialog({
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>([]);
|
||||
const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>([]);
|
||||
const [allClients, setAllClients] = useState<{ id: string; text: string }[]>([]);
|
||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<number | null>(null);
|
||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<number | null>(null);
|
||||
const [activeClientsTagIndex, setActiveClientsTagIndex] = useState<number | null>(null);
|
||||
const [hasMachineClients, setHasMachineClients] = useState(false);
|
||||
const { data: rolesResponse = [] } = useQuery(orgQueries.roles({ orgId }));
|
||||
const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId }));
|
||||
const { data: clientsResponse = [] } = useQuery(
|
||||
orgQueries.clients({
|
||||
orgId,
|
||||
filters: {
|
||||
filter: "machine"
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const allRoles = rolesResponse
|
||||
.map((role) => ({
|
||||
id: role.roleId.toString(),
|
||||
text: role.name
|
||||
}))
|
||||
.filter((role) => role.text !== "Admin");
|
||||
|
||||
const allUsers = usersResponse.map((user) => ({
|
||||
id: user.id.toString(),
|
||||
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
|
||||
}));
|
||||
|
||||
const allClients = clientsResponse
|
||||
.filter((client) => !client.userId)
|
||||
.map((client) => ({
|
||||
id: client.clientId.toString(),
|
||||
text: client.name
|
||||
}));
|
||||
|
||||
const hasMachineClients = allClients.length > 0;
|
||||
|
||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [activeClientsTagIndex, setActiveClientsTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const availableSites = sites.filter(
|
||||
(site) => site.type === "newt" && site.subnet
|
||||
@@ -208,50 +249,6 @@ export default function CreateInternalResourceDialog({
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRolesUsersAndClients = async () => {
|
||||
try {
|
||||
const [rolesResponse, usersResponse, clientsResponse] = await Promise.all([
|
||||
api.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`),
|
||||
api.get<AxiosResponse<ListUsersResponse>>(`/org/${orgId}/users`),
|
||||
api.get<AxiosResponse<ListClientsResponse>>(`/org/${orgId}/clients?filter=machine&limit=1000`)
|
||||
]);
|
||||
|
||||
setAllRoles(
|
||||
rolesResponse.data.data.roles
|
||||
.map((role) => ({
|
||||
id: role.roleId.toString(),
|
||||
text: role.name
|
||||
}))
|
||||
.filter((role) => role.text !== "Admin")
|
||||
);
|
||||
|
||||
setAllUsers(
|
||||
usersResponse.data.data.users.map((user) => ({
|
||||
id: user.id.toString(),
|
||||
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
|
||||
}))
|
||||
);
|
||||
|
||||
const machineClients = clientsResponse.data.data.clients
|
||||
.filter((client) => !client.userId)
|
||||
.map((client) => ({
|
||||
id: client.clientId.toString(),
|
||||
text: client.name
|
||||
}));
|
||||
|
||||
setAllClients(machineClients);
|
||||
setHasMachineClients(machineClients.length > 0);
|
||||
} catch (error) {
|
||||
console.error("Error fetching roles, users, and clients:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (open) {
|
||||
fetchRolesUsersAndClients();
|
||||
}
|
||||
}, [open, orgId]);
|
||||
|
||||
const handleSubmit = async (data: FormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
@@ -265,10 +262,19 @@ export default function CreateInternalResourceDialog({
|
||||
// destinationPort: data.mode === "port" ? data.destinationPort : undefined,
|
||||
destination: data.destination,
|
||||
enabled: true,
|
||||
alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : undefined,
|
||||
roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [],
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: undefined,
|
||||
roleIds: data.roles
|
||||
? data.roles.map((r) => parseInt(r.id))
|
||||
: [],
|
||||
userIds: data.users ? data.users.map((u) => u.id) : [],
|
||||
clientIds: data.clients ? data.clients.map((c) => parseInt(c.id)) : []
|
||||
clientIds: data.clients
|
||||
? data.clients.map((c) => parseInt(c.id))
|
||||
: []
|
||||
}
|
||||
);
|
||||
|
||||
@@ -295,7 +301,9 @@ export default function CreateInternalResourceDialog({
|
||||
|
||||
toast({
|
||||
title: t("createInternalResourceDialogSuccess"),
|
||||
description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"),
|
||||
description: t(
|
||||
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
|
||||
@@ -307,7 +315,9 @@ export default function CreateInternalResourceDialog({
|
||||
title: t("createInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("createInternalResourceDialogFailedToCreateInternalResource")
|
||||
t(
|
||||
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
@@ -321,13 +331,19 @@ export default function CreateInternalResourceDialog({
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-md">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("createInternalResourceDialogNoSitesAvailable")}</CredenzaTitle>
|
||||
<CredenzaTitle>
|
||||
{t("createInternalResourceDialogNoSitesAvailable")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("createInternalResourceDialogNoSitesAvailableDescription")}
|
||||
{t(
|
||||
"createInternalResourceDialogNoSitesAvailableDescription"
|
||||
)}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaFooter>
|
||||
<Button onClick={() => setOpen(false)}>{t("createInternalResourceDialogClose")}</Button>
|
||||
<Button onClick={() => setOpen(false)}>
|
||||
{t("createInternalResourceDialogClose")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
@@ -338,9 +354,13 @@ export default function CreateInternalResourceDialog({
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-2xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("createInternalResourceDialogCreateClientResource")}</CredenzaTitle>
|
||||
<CredenzaTitle>
|
||||
{t("createInternalResourceDialogCreateClientResource")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("createInternalResourceDialogCreateClientResourceDescription")}
|
||||
{t(
|
||||
"createInternalResourceDialogCreateClientResourceDescription"
|
||||
)}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -353,7 +373,9 @@ export default function CreateInternalResourceDialog({
|
||||
{/* Resource Properties Form */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("createInternalResourceDialogResourceProperties")}
|
||||
{t(
|
||||
"createInternalResourceDialogResourceProperties"
|
||||
)}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
@@ -361,7 +383,11 @@ export default function CreateInternalResourceDialog({
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("createInternalResourceDialogName")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"createInternalResourceDialogName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -375,7 +401,11 @@ export default function CreateInternalResourceDialog({
|
||||
name="siteId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>{t("createInternalResourceDialogSite")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"createInternalResourceDialogSite"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
@@ -384,43 +414,71 @@ export default function CreateInternalResourceDialog({
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground"
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? availableSites.find(
|
||||
(site) => site.siteId === field.value
|
||||
(
|
||||
site
|
||||
) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
)?.name
|
||||
: t("createInternalResourceDialogSelectSite")}
|
||||
: t(
|
||||
"createInternalResourceDialogSelectSite"
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("createInternalResourceDialogSearchSites")} />
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"createInternalResourceDialogSearchSites"
|
||||
)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("createInternalResourceDialogNoSitesFound")}</CommandEmpty>
|
||||
<CommandEmpty>
|
||||
{t(
|
||||
"createInternalResourceDialogNoSitesFound"
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableSites.map((site) => (
|
||||
<CommandItem
|
||||
key={site.siteId}
|
||||
value={site.name}
|
||||
onSelect={() => {
|
||||
field.onChange(site.siteId);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
field.value === site.siteId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{site.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
{availableSites.map(
|
||||
(
|
||||
site
|
||||
) => (
|
||||
<CommandItem
|
||||
key={
|
||||
site.siteId
|
||||
}
|
||||
value={
|
||||
site.name
|
||||
}
|
||||
onSelect={() => {
|
||||
field.onChange(
|
||||
site.siteId
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
field.value ===
|
||||
site.siteId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{
|
||||
site.name
|
||||
}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -431,14 +489,20 @@ export default function CreateInternalResourceDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("createInternalResourceDialogMode")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"createInternalResourceDialogMode"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -448,15 +512,23 @@ export default function CreateInternalResourceDialog({
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{/* <SelectItem value="port">{t("createInternalResourceDialogModePort")}</SelectItem> */}
|
||||
<SelectItem value="host">{t("createInternalResourceDialogModeHost")}</SelectItem>
|
||||
<SelectItem value="cidr">{t("createInternalResourceDialogModeCidr")}</SelectItem>
|
||||
<SelectItem value="host">
|
||||
{t(
|
||||
"createInternalResourceDialogModeHost"
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="cidr">
|
||||
{t(
|
||||
"createInternalResourceDialogModeCidr"
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/*
|
||||
{/*
|
||||
{mode === "port" && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -521,7 +593,9 @@ export default function CreateInternalResourceDialog({
|
||||
{/* Target Configuration Form */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("createInternalResourceDialogTargetConfiguration")}
|
||||
{t(
|
||||
"createInternalResourceDialogTargetConfiguration"
|
||||
)}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
@@ -530,14 +604,22 @@ export default function CreateInternalResourceDialog({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("createInternalResourceDialogDestination")}
|
||||
{t(
|
||||
"createInternalResourceDialogDestination"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{mode === "host" && t("createInternalResourceDialogDestinationHostDescription")}
|
||||
{mode === "cidr" && t("createInternalResourceDialogDestinationCidrDescription")}
|
||||
{mode === "host" &&
|
||||
t(
|
||||
"createInternalResourceDialogDestinationHostDescription"
|
||||
)}
|
||||
{mode === "cidr" &&
|
||||
t(
|
||||
"createInternalResourceDialogDestinationCidrDescription"
|
||||
)}
|
||||
{/* {mode === "port" && t("createInternalResourceDialogDestinationIPDescription")} */}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -584,12 +666,23 @@ export default function CreateInternalResourceDialog({
|
||||
name="alias"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("createInternalResourceDialogAlias")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"createInternalResourceDialogAlias"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} />
|
||||
<Input
|
||||
{...field}
|
||||
value={
|
||||
field.value ?? ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("createInternalResourceDialogAliasDescription")}
|
||||
{t(
|
||||
"createInternalResourceDialogAliasDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -609,31 +702,53 @@ export default function CreateInternalResourceDialog({
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("roles")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeRolesTagIndex}
|
||||
setActiveTagIndex={setActiveRolesTagIndex}
|
||||
placeholder={t("accessRoleSelect2")}
|
||||
activeTagIndex={
|
||||
activeRolesTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessRoleSelect2"
|
||||
)}
|
||||
size="sm"
|
||||
tags={form.getValues().roles || []}
|
||||
tags={
|
||||
form.getValues()
|
||||
.roles || []
|
||||
}
|
||||
setTags={(newRoles) => {
|
||||
form.setValue(
|
||||
"roles",
|
||||
newRoles as [Tag, ...Tag[]]
|
||||
newRoles as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allRoles}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allRoles
|
||||
}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={true}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("resourceRoleDescription")}
|
||||
{t(
|
||||
"resourceRoleDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -643,25 +758,45 @@ export default function CreateInternalResourceDialog({
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("users")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeUsersTagIndex}
|
||||
setActiveTagIndex={setActiveUsersTagIndex}
|
||||
placeholder={t("accessUserSelect")}
|
||||
tags={form.getValues().users || []}
|
||||
activeTagIndex={
|
||||
activeUsersTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessUserSelect"
|
||||
)}
|
||||
tags={
|
||||
form.getValues()
|
||||
.users || []
|
||||
}
|
||||
size="sm"
|
||||
setTags={(newUsers) => {
|
||||
form.setValue(
|
||||
"users",
|
||||
newUsers as [Tag, ...Tag[]]
|
||||
newUsers as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allUsers}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allUsers
|
||||
}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={true}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -675,31 +810,62 @@ export default function CreateInternalResourceDialog({
|
||||
name="clients"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("clients")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("clients")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeClientsTagIndex}
|
||||
setActiveTagIndex={setActiveClientsTagIndex}
|
||||
placeholder={t("accessClientSelect") || "Select machine clients"}
|
||||
activeTagIndex={
|
||||
activeClientsTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveClientsTagIndex
|
||||
}
|
||||
placeholder={
|
||||
t(
|
||||
"accessClientSelect"
|
||||
) ||
|
||||
"Select machine clients"
|
||||
}
|
||||
size="sm"
|
||||
tags={form.getValues().clients || []}
|
||||
setTags={(newClients) => {
|
||||
tags={
|
||||
form.getValues()
|
||||
.clients ||
|
||||
[]
|
||||
}
|
||||
setTags={(
|
||||
newClients
|
||||
) => {
|
||||
form.setValue(
|
||||
"clients",
|
||||
newClients as [Tag, ...Tag[]]
|
||||
newClients as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allClients}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={true}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allClients
|
||||
}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("resourceClientDescription") || "Machine clients that can access this resource"}
|
||||
{t(
|
||||
"resourceClientDescription"
|
||||
) ||
|
||||
"Machine clients that can access this resource"}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -45,6 +45,8 @@ import { ListClientsResponse } from "@server/routers/client/listClients";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||
|
||||
type InternalResourceData = {
|
||||
id: number;
|
||||
@@ -81,32 +83,41 @@ export default function EditInternalResourceDialog({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, t("editInternalResourceDialogNameRequired")).max(255, t("editInternalResourceDialogNameMaxLength")),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, t("editInternalResourceDialogNameRequired"))
|
||||
.max(255, t("editInternalResourceDialogNameMaxLength")),
|
||||
mode: z.enum(["host", "cidr", "port"]),
|
||||
// protocol: z.enum(["tcp", "udp"]).nullish(),
|
||||
// proxyPort: z.int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")).nullish(),
|
||||
destination: z.string().min(1),
|
||||
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
|
||||
alias: z.string().nullish(),
|
||||
roles: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
).optional(),
|
||||
users: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
).optional(),
|
||||
clients: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
).optional()
|
||||
})
|
||||
roles: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
users: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
clients: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
});
|
||||
// .refine(
|
||||
// (data) => {
|
||||
// if (data.mode === "port") {
|
||||
@@ -146,14 +157,53 @@ export default function EditInternalResourceDialog({
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>([]);
|
||||
const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>([]);
|
||||
const [allClients, setAllClients] = useState<{ id: string; text: string }[]>([]);
|
||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<number | null>(null);
|
||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<number | null>(null);
|
||||
const [activeClientsTagIndex, setActiveClientsTagIndex] = useState<number | null>(null);
|
||||
const { data: rolesResponse = [] } = useQuery(orgQueries.roles({ orgId }));
|
||||
|
||||
const allRoles = rolesResponse
|
||||
.map((role) => ({
|
||||
id: role.roleId.toString(),
|
||||
text: role.name
|
||||
}))
|
||||
.filter((role) => role.text !== "Admin");
|
||||
|
||||
const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId }));
|
||||
const { data: existingClients = [] } = useQuery(
|
||||
resourceQueries.resourceUsers({ resourceId: resource.id })
|
||||
);
|
||||
const { data: clientsResponse = [] } = useQuery(
|
||||
orgQueries.clients({
|
||||
orgId,
|
||||
filters: {
|
||||
filter: "machine"
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const allUsers = usersResponse.map((user) => ({
|
||||
id: user.id.toString(),
|
||||
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
|
||||
}));
|
||||
|
||||
const allClients = clientsResponse
|
||||
.filter((client) => !client.userId)
|
||||
.map((client) => ({
|
||||
id: client.clientId.toString(),
|
||||
text: client.name
|
||||
}));
|
||||
|
||||
const hasMachineClients =
|
||||
allClients.length > 0 || existingClients.length > 0;
|
||||
|
||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [activeClientsTagIndex, setActiveClientsTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [loadingRolesUsers, setLoadingRolesUsers] = useState(false);
|
||||
const [hasMachineClients, setHasMachineClients] = useState(false);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -173,140 +223,141 @@ export default function EditInternalResourceDialog({
|
||||
|
||||
const mode = form.watch("mode");
|
||||
|
||||
const fetchRolesAndUsers = async () => {
|
||||
setLoadingRolesUsers(true);
|
||||
try {
|
||||
const [
|
||||
rolesResponse,
|
||||
resourceRolesResponse,
|
||||
usersResponse,
|
||||
resourceUsersResponse,
|
||||
clientsResponse
|
||||
] = await Promise.all([
|
||||
api.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`),
|
||||
api.get<AxiosResponse<ListSiteResourceRolesResponse>>(
|
||||
`/site-resource/${resource.id}/roles`
|
||||
),
|
||||
api.get<AxiosResponse<ListUsersResponse>>(`/org/${orgId}/users`),
|
||||
api.get<AxiosResponse<ListSiteResourceUsersResponse>>(
|
||||
`/site-resource/${resource.id}/users`
|
||||
),
|
||||
api.get<AxiosResponse<ListClientsResponse>>(`/org/${orgId}/clients?filter=machine&limit=1000`)
|
||||
]);
|
||||
// const fetchRolesAndUsers = async () => {
|
||||
// setLoadingRolesUsers(true);
|
||||
// try {
|
||||
// const [
|
||||
// // rolesResponse,
|
||||
// resourceRolesResponse,
|
||||
// usersResponse,
|
||||
// resourceUsersResponse,
|
||||
// clientsResponse
|
||||
// ] = await Promise.all([
|
||||
// // api.get<AxiosResponse<ListRolesResponse>>(
|
||||
// // `/org/${orgId}/roles`
|
||||
// // ),
|
||||
// api.get<AxiosResponse<ListSiteResourceRolesResponse>>(
|
||||
// `/site-resource/${resource.id}/roles`
|
||||
// ),
|
||||
// api.get<AxiosResponse<ListUsersResponse>>(
|
||||
// `/org/${orgId}/users`
|
||||
// ),
|
||||
// api.get<AxiosResponse<ListSiteResourceUsersResponse>>(
|
||||
// `/site-resource/${resource.id}/users`
|
||||
// ),
|
||||
// api.get<AxiosResponse<ListClientsResponse>>(
|
||||
// `/org/${orgId}/clients?filter=machine&limit=1000`
|
||||
// )
|
||||
// ]);
|
||||
|
||||
let resourceClientsResponse: AxiosResponse<AxiosResponse<ListSiteResourceClientsResponse>>;
|
||||
try {
|
||||
resourceClientsResponse = await api.get<AxiosResponse<ListSiteResourceClientsResponse>>(
|
||||
`/site-resource/${resource.id}/clients`
|
||||
);
|
||||
} catch {
|
||||
resourceClientsResponse = {
|
||||
data: {
|
||||
data: {
|
||||
clients: []
|
||||
}
|
||||
},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {} as any,
|
||||
config: {} as any
|
||||
} as any;
|
||||
}
|
||||
// let resourceClientsResponse: AxiosResponse<
|
||||
// AxiosResponse<ListSiteResourceClientsResponse>
|
||||
// >;
|
||||
// try {
|
||||
// resourceClientsResponse = await api.get<
|
||||
// AxiosResponse<ListSiteResourceClientsResponse>
|
||||
// >(`/site-resource/${resource.id}/clients`);
|
||||
// } catch {
|
||||
// resourceClientsResponse = {
|
||||
// data: {
|
||||
// data: {
|
||||
// clients: []
|
||||
// }
|
||||
// },
|
||||
// status: 200,
|
||||
// statusText: "OK",
|
||||
// headers: {} as any,
|
||||
// config: {} as any
|
||||
// } as any;
|
||||
// }
|
||||
|
||||
setAllRoles(
|
||||
rolesResponse.data.data.roles
|
||||
.map((role) => ({
|
||||
id: role.roleId.toString(),
|
||||
text: role.name
|
||||
}))
|
||||
.filter((role) => role.text !== "Admin")
|
||||
);
|
||||
// // setAllRoles(
|
||||
// // rolesResponse.data.data.roles
|
||||
// // .map((role) => ({
|
||||
// // id: role.roleId.toString(),
|
||||
// // text: role.name
|
||||
// // }))
|
||||
// // .filter((role) => role.text !== "Admin")
|
||||
// // );
|
||||
|
||||
form.setValue(
|
||||
"roles",
|
||||
resourceRolesResponse.data.data.roles
|
||||
.map((i) => ({
|
||||
id: i.roleId.toString(),
|
||||
text: i.name
|
||||
}))
|
||||
.filter((role) => role.text !== "Admin")
|
||||
);
|
||||
// form.setValue(
|
||||
// "roles",
|
||||
// resourceRolesResponse.data.data.roles
|
||||
// .map((i) => ({
|
||||
// id: i.roleId.toString(),
|
||||
// text: i.name
|
||||
// }))
|
||||
// .filter((role) => role.text !== "Admin")
|
||||
// );
|
||||
|
||||
setAllUsers(
|
||||
usersResponse.data.data.users.map((user) => ({
|
||||
id: user.id.toString(),
|
||||
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
|
||||
}))
|
||||
);
|
||||
// form.setValue(
|
||||
// "users",
|
||||
// resourceUsersResponse.data.data.users.map((i) => ({
|
||||
// id: i.userId.toString(),
|
||||
// text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
|
||||
// }))
|
||||
// );
|
||||
|
||||
form.setValue(
|
||||
"users",
|
||||
resourceUsersResponse.data.data.users.map((i) => ({
|
||||
id: i.userId.toString(),
|
||||
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
|
||||
}))
|
||||
);
|
||||
// const machineClients = clientsResponse.data.data.clients
|
||||
// .filter((client) => !client.userId)
|
||||
// .map((client) => ({
|
||||
// id: client.clientId.toString(),
|
||||
// text: client.name
|
||||
// }));
|
||||
|
||||
const machineClients = clientsResponse.data.data.clients
|
||||
.filter((client) => !client.userId)
|
||||
.map((client) => ({
|
||||
id: client.clientId.toString(),
|
||||
text: client.name
|
||||
}));
|
||||
// setAllClients(machineClients);
|
||||
|
||||
setAllClients(machineClients);
|
||||
|
||||
const existingClients = resourceClientsResponse.data.data.clients.map((c: { clientId: number; name: string }) => ({
|
||||
id: c.clientId.toString(),
|
||||
text: c.name
|
||||
}));
|
||||
// const existingClients =
|
||||
// resourceClientsResponse.data.data.clients.map(
|
||||
// (c: { clientId: number; name: string }) => ({
|
||||
// id: c.clientId.toString(),
|
||||
// text: c.name
|
||||
// })
|
||||
// );
|
||||
|
||||
form.setValue("clients", existingClients);
|
||||
// form.setValue("clients", existingClients);
|
||||
|
||||
// Show clients tag input if there are machine clients OR existing client access
|
||||
setHasMachineClients(machineClients.length > 0 || existingClients.length > 0);
|
||||
} catch (error) {
|
||||
console.error("Error fetching roles, users, and clients:", error);
|
||||
} finally {
|
||||
setLoadingRolesUsers(false);
|
||||
}
|
||||
};
|
||||
// // Show clients tag input if there are machine clients OR existing client access
|
||||
// setHasMachineClients(
|
||||
// machineClients.length > 0 || existingClients.length > 0
|
||||
// );
|
||||
// } catch (error) {
|
||||
// console.error("Error fetching roles, users, and clients:", error);
|
||||
// } finally {
|
||||
// setLoadingRolesUsers(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({
|
||||
name: resource.name,
|
||||
mode: resource.mode || "host",
|
||||
// protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined,
|
||||
// proxyPort: resource.proxyPort ?? undefined,
|
||||
destination: resource.destination || "",
|
||||
// destinationPort: resource.destinationPort ?? undefined,
|
||||
alias: resource.alias ?? null,
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
});
|
||||
fetchRolesAndUsers();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, resource]);
|
||||
// useEffect(() => {
|
||||
// if (open) {
|
||||
// fetchRolesAndUsers();
|
||||
// }
|
||||
// }, [open, resource]);
|
||||
|
||||
const handleSubmit = async (data: FormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Update the site resource
|
||||
await api.post(`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`, {
|
||||
name: data.name,
|
||||
mode: data.mode,
|
||||
// protocol: data.mode === "port" ? data.protocol : null,
|
||||
// proxyPort: data.mode === "port" ? data.proxyPort : null,
|
||||
// destinationPort: data.mode === "port" ? data.destinationPort : null,
|
||||
destination: data.destination,
|
||||
alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : null,
|
||||
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
||||
userIds: (data.users || []).map((u) => u.id),
|
||||
clientIds: (data.clients || []).map((c) => parseInt(c.id))
|
||||
});
|
||||
await api.post(
|
||||
`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`,
|
||||
{
|
||||
name: data.name,
|
||||
mode: data.mode,
|
||||
// protocol: data.mode === "port" ? data.protocol : null,
|
||||
// proxyPort: data.mode === "port" ? data.proxyPort : null,
|
||||
// destinationPort: data.mode === "port" ? data.destinationPort : null,
|
||||
destination: data.destination,
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: null,
|
||||
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
||||
userIds: (data.users || []).map((u) => u.id),
|
||||
clientIds: (data.clients || []).map((c) => parseInt(c.id))
|
||||
}
|
||||
);
|
||||
|
||||
// Update roles, users, and clients
|
||||
// await Promise.all([
|
||||
@@ -323,7 +374,9 @@ export default function EditInternalResourceDialog({
|
||||
|
||||
toast({
|
||||
title: t("editInternalResourceDialogSuccess"),
|
||||
description: t("editInternalResourceDialogInternalResourceUpdatedSuccessfully"),
|
||||
description: t(
|
||||
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
|
||||
@@ -333,7 +386,12 @@ export default function EditInternalResourceDialog({
|
||||
console.error("Error updating internal resource:", error);
|
||||
toast({
|
||||
title: t("editInternalResourceDialogError"),
|
||||
description: formatAxiosError(error, t("editInternalResourceDialogFailedToUpdateInternalResource")),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"editInternalResourceDialogFailedToUpdateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
@@ -342,27 +400,64 @@ export default function EditInternalResourceDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// reset only on close
|
||||
form.reset({
|
||||
name: resource.name,
|
||||
mode: resource.mode || "host",
|
||||
// protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined,
|
||||
// proxyPort: resource.proxyPort ?? undefined,
|
||||
destination: resource.destination || "",
|
||||
// destinationPort: resource.destinationPort ?? undefined,
|
||||
alias: resource.alias ?? null,
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
});
|
||||
}
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<CredenzaContent className="max-w-2xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("editInternalResourceDialogEditClientResource")}</CredenzaTitle>
|
||||
<CredenzaTitle>
|
||||
{t("editInternalResourceDialogEditClientResource")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("editInternalResourceDialogUpdateResourceProperties", { resourceName: resource.name })}
|
||||
{t(
|
||||
"editInternalResourceDialogUpdateResourceProperties",
|
||||
{ resourceName: resource.name }
|
||||
)}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6" id="edit-internal-resource-form">
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-6"
|
||||
id="edit-internal-resource-form"
|
||||
>
|
||||
{/* Resource Properties Form */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogResourceProperties")}</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t(
|
||||
"editInternalResourceDialogResourceProperties"
|
||||
)}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogName")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"editInternalResourceDialogName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -376,9 +471,15 @@ export default function EditInternalResourceDialog({
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogMode")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"editInternalResourceDialogMode"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
@@ -388,8 +489,16 @@ export default function EditInternalResourceDialog({
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{/* <SelectItem value="port">{t("editInternalResourceDialogModePort")}</SelectItem> */}
|
||||
<SelectItem value="host">{t("editInternalResourceDialogModeHost")}</SelectItem>
|
||||
<SelectItem value="cidr">{t("editInternalResourceDialogModeCidr")}</SelectItem>
|
||||
<SelectItem value="host">
|
||||
{t(
|
||||
"editInternalResourceDialogModeHost"
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="cidr">
|
||||
{t(
|
||||
"editInternalResourceDialogModeCidr"
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
@@ -448,20 +557,34 @@ export default function EditInternalResourceDialog({
|
||||
|
||||
{/* Target Configuration Form */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogTargetConfiguration")}</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t(
|
||||
"editInternalResourceDialogTargetConfiguration"
|
||||
)}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogDestination")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"editInternalResourceDialogDestination"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{mode === "host" && t("editInternalResourceDialogDestinationHostDescription")}
|
||||
{mode === "cidr" && t("editInternalResourceDialogDestinationCidrDescription")}
|
||||
{mode === "host" &&
|
||||
t(
|
||||
"editInternalResourceDialogDestinationHostDescription"
|
||||
)}
|
||||
{mode === "cidr" &&
|
||||
t(
|
||||
"editInternalResourceDialogDestinationCidrDescription"
|
||||
)}
|
||||
{/* {mode === "port" && t("editInternalResourceDialogDestinationIPDescription")} */}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -499,12 +622,23 @@ export default function EditInternalResourceDialog({
|
||||
name="alias"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogAlias")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"editInternalResourceDialogAlias"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} />
|
||||
<Input
|
||||
{...field}
|
||||
value={
|
||||
field.value ?? ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("editInternalResourceDialogAliasDescription")}
|
||||
{t(
|
||||
"editInternalResourceDialogAliasDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -529,31 +663,57 @@ export default function EditInternalResourceDialog({
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("roles")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeRolesTagIndex}
|
||||
setActiveTagIndex={setActiveRolesTagIndex}
|
||||
placeholder={t("accessRoleSelect2")}
|
||||
activeTagIndex={
|
||||
activeRolesTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessRoleSelect2"
|
||||
)}
|
||||
size="sm"
|
||||
tags={form.getValues().roles || []}
|
||||
setTags={(newRoles) => {
|
||||
tags={
|
||||
form.getValues()
|
||||
.roles || []
|
||||
}
|
||||
setTags={(
|
||||
newRoles
|
||||
) => {
|
||||
form.setValue(
|
||||
"roles",
|
||||
newRoles as [Tag, ...Tag[]]
|
||||
newRoles as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allRoles}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={true}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allRoles
|
||||
}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("resourceRoleDescription")}
|
||||
{t(
|
||||
"resourceRoleDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -563,25 +723,49 @@ export default function EditInternalResourceDialog({
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("users")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeUsersTagIndex}
|
||||
setActiveTagIndex={setActiveUsersTagIndex}
|
||||
placeholder={t("accessUserSelect")}
|
||||
tags={form.getValues().users || []}
|
||||
activeTagIndex={
|
||||
activeUsersTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"accessUserSelect"
|
||||
)}
|
||||
tags={
|
||||
form.getValues()
|
||||
.users || []
|
||||
}
|
||||
size="sm"
|
||||
setTags={(newUsers) => {
|
||||
setTags={(
|
||||
newUsers
|
||||
) => {
|
||||
form.setValue(
|
||||
"users",
|
||||
newUsers as [Tag, ...Tag[]]
|
||||
newUsers as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allUsers}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={true}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allUsers
|
||||
}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -589,42 +773,73 @@ export default function EditInternalResourceDialog({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{hasMachineClients && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clients"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("clients")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeClientsTagIndex}
|
||||
setActiveTagIndex={setActiveClientsTagIndex}
|
||||
placeholder={t("accessClientSelect") || "Select machine clients"}
|
||||
size="sm"
|
||||
tags={form.getValues().clients || []}
|
||||
setTags={(newClients) => {
|
||||
form.setValue(
|
||||
"clients",
|
||||
newClients as [Tag, ...Tag[]]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={true}
|
||||
autocompleteOptions={allClients}
|
||||
allowDuplicates={false}
|
||||
restrictTagsToAutocompleteOptions={true}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("resourceClientDescription") || "Machine clients that can access this resource"}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{hasMachineClients && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clients"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>
|
||||
{t("clients")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeClientsTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveClientsTagIndex
|
||||
}
|
||||
placeholder={
|
||||
t(
|
||||
"accessClientSelect"
|
||||
) ||
|
||||
"Select machine clients"
|
||||
}
|
||||
size="sm"
|
||||
tags={
|
||||
form.getValues()
|
||||
.clients ||
|
||||
[]
|
||||
}
|
||||
setTags={(
|
||||
newClients
|
||||
) => {
|
||||
form.setValue(
|
||||
"clients",
|
||||
newClients as [
|
||||
Tag,
|
||||
...Tag[]
|
||||
]
|
||||
);
|
||||
}}
|
||||
enableAutocomplete={
|
||||
true
|
||||
}
|
||||
autocompleteOptions={
|
||||
allClients
|
||||
}
|
||||
allowDuplicates={
|
||||
false
|
||||
}
|
||||
restrictTagsToAutocompleteOptions={
|
||||
true
|
||||
}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceClientDescription"
|
||||
) ||
|
||||
"Machine clients that can access this resource"}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -645,7 +860,7 @@ export default function EditInternalResourceDialog({
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("editInternalResourceDialogSaveResource")}
|
||||
{t("editInternalResourceDialogSaveResource")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
700
src/components/MachineClientsTable.tsx
Normal file
700
src/components/MachineClientsTable.tsx
Normal file
@@ -0,0 +1,700 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
Columns,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { InfoPopup } from "./ui/info-popup";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
subnet: string;
|
||||
// siteIds: string;
|
||||
mbIn: string;
|
||||
mbOut: string;
|
||||
orgId: string;
|
||||
online: boolean;
|
||||
olmVersion?: string;
|
||||
olmUpdateAvailable: boolean;
|
||||
userId: string | null;
|
||||
username: string | null;
|
||||
userEmail: string | null;
|
||||
};
|
||||
|
||||
type ClientTableProps = {
|
||||
machineClients: ClientRow[];
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export default function MachineClientsTable({
|
||||
machineClients,
|
||||
orgId
|
||||
}: ClientTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const [machinePageSize, setMachinePageSize] = useStoredPageSize(
|
||||
"machine-clients",
|
||||
20
|
||||
);
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
|
||||
const [machineSorting, setMachineSorting] = useState<SortingState>([]);
|
||||
const [machineColumnFilters, setMachineColumnFilters] =
|
||||
useState<ColumnFiltersState>([]);
|
||||
const [machineGlobalFilter, setMachineGlobalFilter] = useState<any>([]);
|
||||
|
||||
const defaultMachineColumnVisibility = {
|
||||
client: false,
|
||||
subnet: false,
|
||||
userId: false
|
||||
};
|
||||
|
||||
const [machineColumnVisibility, setMachineColumnVisibility] =
|
||||
useStoredColumnVisibility(
|
||||
"machine-clients",
|
||||
defaultMachineColumnVisibility
|
||||
);
|
||||
|
||||
const refreshData = async () => {
|
||||
try {
|
||||
router.refresh();
|
||||
console.log("Data refreshed");
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteClient = (clientId: number) => {
|
||||
api.delete(`/client/${clientId}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting client", e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting client",
|
||||
description: formatAxiosError(e, "Error deleting client")
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Check if there are any rows without userIds in the current view's data
|
||||
const hasRowsWithoutUserId = useMemo(() => {
|
||||
return machineClients.some((client) => !client.userId) ?? false;
|
||||
}, [machineClients]);
|
||||
|
||||
const columns: ExtendedColumnDef<ClientRow>[] = useMemo(() => {
|
||||
const baseColumns: ExtendedColumnDef<ClientRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: "Name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
Name
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "userId",
|
||||
friendlyName: "User",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
User
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return r.userId ? (
|
||||
<Link
|
||||
href={`/${r.orgId}/settings/access/users/${r.userId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{r.userEmail || r.username || r.userId}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
}
|
||||
},
|
||||
// {
|
||||
// accessorKey: "siteName",
|
||||
// header: ({ column }) => {
|
||||
// return (
|
||||
// <Button
|
||||
// variant="ghost"
|
||||
// onClick={() =>
|
||||
// column.toggleSorting(column.getIsSorted() === "asc")
|
||||
// }
|
||||
// >
|
||||
// Site
|
||||
// <ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
// </Button>
|
||||
// );
|
||||
// },
|
||||
// cell: ({ row }) => {
|
||||
// const r = row.original;
|
||||
// return (
|
||||
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
|
||||
// <Button variant="outline">
|
||||
// {r.siteName}
|
||||
// <ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
// </Button>
|
||||
// </Link>
|
||||
// );
|
||||
// }
|
||||
// },
|
||||
{
|
||||
accessorKey: "online",
|
||||
friendlyName: "Connectivity",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
Connectivity
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
if (originalRow.online) {
|
||||
return (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>Connected</span>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>Disconnected</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mbIn",
|
||||
friendlyName: "Data In",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
Data In
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mbOut",
|
||||
friendlyName: "Data Out",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
Data Out
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "client",
|
||||
friendlyName: t("client"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("client")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Badge variant="secondary">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Olm</span>
|
||||
{originalRow.olmVersion && (
|
||||
<span className="text-xs text-gray-500">
|
||||
v{originalRow.olmVersion}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
{originalRow.olmUpdateAvailable && (
|
||||
<InfoPopup info={t("olmUpdateAvailableInfo")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "subnet",
|
||||
friendlyName: "Address",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
Address
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Only include actions column if there are rows without userIds
|
||||
if (hasRowsWithoutUserId) {
|
||||
baseColumns.push({
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: ({ table }) => {
|
||||
const hasHideableColumns = table
|
||||
.getAllColumns()
|
||||
.some((column) => column.getCanHide());
|
||||
if (!hasHideableColumns) {
|
||||
return <span className="p-3"></span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1 p-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Columns className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t("columns") || "Columns"}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-48"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
{t("toggleColumns") || "Toggle columns"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
const columnDef =
|
||||
column.columnDef as any;
|
||||
const friendlyName =
|
||||
columnDef.friendlyName;
|
||||
const displayName =
|
||||
friendlyName ||
|
||||
(typeof columnDef.header ===
|
||||
"string"
|
||||
? columnDef.header
|
||||
: column.id);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(
|
||||
!!value
|
||||
)
|
||||
}
|
||||
onSelect={(e) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
>
|
||||
{displayName}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const clientRow = row.original;
|
||||
return !clientRow.userId ? (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open menu
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* <Link */}
|
||||
{/* className="block w-full" */}
|
||||
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
|
||||
{/* > */}
|
||||
{/* <DropdownMenuItem> */}
|
||||
{/* View settings */}
|
||||
{/* </DropdownMenuItem> */}
|
||||
{/* </Link> */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedClient(clientRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
Delete
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
Edit
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
}, [hasRowsWithoutUserId, t]);
|
||||
|
||||
const machineTable = useReactTable({
|
||||
data: machineClients || [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setMachineSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setMachineColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setMachineGlobalFilter,
|
||||
onColumnVisibilityChange: setMachineColumnVisibility,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: machinePageSize,
|
||||
pageIndex: 0
|
||||
},
|
||||
columnVisibility: machineColumnVisibility
|
||||
},
|
||||
state: {
|
||||
sorting: machineSorting,
|
||||
columnFilters: machineColumnFilters,
|
||||
globalFilter: machineGlobalFilter,
|
||||
columnVisibility: machineColumnVisibility
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedClient && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedClient(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<p>{t("deleteClientQuestion")}</p>
|
||||
<p>{t("clientMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete Client"
|
||||
onConfirm={async () => deleteClient(selectedClient!.id)}
|
||||
string={selectedClient.name}
|
||||
title="Delete Client"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
|
||||
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={t("resourcesSearch")}
|
||||
value={machineGlobalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
machineTable.setGlobalFilter(
|
||||
String(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:justify-end">
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startTransition(refreshData)}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="hidden sm:inline">
|
||||
{t("refresh")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{" "}
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/${orgId}/settings/clients/create`
|
||||
)
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("createClient")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto mt-9">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{machineTable
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers
|
||||
.filter((header) =>
|
||||
header.column.getIsVisible()
|
||||
)
|
||||
.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={`whitespace-nowrap ${
|
||||
header.column
|
||||
.id ===
|
||||
"actions"
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: header
|
||||
.column
|
||||
.id ===
|
||||
"name"
|
||||
? "md:sticky md:left-0 z-10 bg-card"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{machineTable.getRowModel().rows?.length ? (
|
||||
machineTable
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`whitespace-nowrap ${
|
||||
cell.column
|
||||
.id ===
|
||||
"actions"
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: cell
|
||||
.column
|
||||
.id ===
|
||||
"name"
|
||||
? "md:sticky md:left-0 z-10 bg-card"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("noResults")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={machineTable}
|
||||
onPageSizeChange={setMachinePageSize}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
830
src/components/ProxyResourcesTable.tsx
Normal file
830
src/components/ProxyResourcesTable.tsx
Normal file
@@ -0,0 +1,830 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import {
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable
|
||||
} from "@tanstack/react-table";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Columns,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
export type TargetHealth = {
|
||||
targetId: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
healthStatus?: "healthy" | "unhealthy" | "unknown";
|
||||
};
|
||||
|
||||
export type ResourceRow = {
|
||||
id: number;
|
||||
nice: string | null;
|
||||
name: string;
|
||||
orgId: string;
|
||||
domain: string;
|
||||
authState: string;
|
||||
http: boolean;
|
||||
protocol: string;
|
||||
proxyPort: number | null;
|
||||
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-muted-foreground`} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type ProxyResourcesTableProps = {
|
||||
resources: ResourceRow[];
|
||||
orgId: string;
|
||||
defaultSort?: {
|
||||
id: string;
|
||||
desc: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export default function ProxyResourcesTable({
|
||||
resources,
|
||||
orgId,
|
||||
defaultSort
|
||||
}: ProxyResourcesTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [proxyPageSize, setProxyPageSize] = useStoredPageSize(
|
||||
"proxy-resources",
|
||||
20
|
||||
);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<ResourceRow | null>();
|
||||
|
||||
const [proxySorting, setProxySorting] = useState<SortingState>(
|
||||
defaultSort ? [defaultSort] : []
|
||||
);
|
||||
|
||||
const [proxyColumnFilters, setProxyColumnFilters] =
|
||||
useState<ColumnFiltersState>([]);
|
||||
const [proxyGlobalFilter, setProxyGlobalFilter] = useState<any>([]);
|
||||
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [proxyColumnVisibility, setProxyColumnVisibility] =
|
||||
useStoredColumnVisibility("proxy-resources", {});
|
||||
const refreshData = () => {
|
||||
try {
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteResource = (resourceId: number) => {
|
||||
api.delete(`/resource/${resourceId}`)
|
||||
.catch((e) => {
|
||||
console.error(t("resourceErrorDelte"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("resourceErrorDelte"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
async function toggleResourceEnabled(val: boolean, resourceId: number) {
|
||||
await api
|
||||
.post<AxiosResponse<UpdateResourceResponse>>(
|
||||
`resource/${resourceId}`,
|
||||
{
|
||||
enabled: val
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourcesErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("resourcesErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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">
|
||||
{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"
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 h-8 px-0 font-normal"
|
||||
>
|
||||
<StatusIcon status={overallStatus} />
|
||||
<span className="text-sm">
|
||||
{overallStatus === "online" &&
|
||||
t("resourcesTableHealthy")}
|
||||
{overallStatus === "degraded" &&
|
||||
t("resourcesTableDegraded")}
|
||||
{overallStatus === "offline" &&
|
||||
t("resourcesTableOffline")}
|
||||
{overallStatus === "unknown" &&
|
||||
t("resourcesTableUnknown")}
|
||||
</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
|
||||
? t("disabled")
|
||||
: t("resourcesTableNotMonitored")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const proxyColumns: ExtendedColumnDef<ResourceRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "nice",
|
||||
friendlyName: t("resource"),
|
||||
enableHiding: true,
|
||||
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",
|
||||
friendlyName: t("protocol"),
|
||||
header: () => <span className="p-3">{t("protocol")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<span>
|
||||
{resourceRow.http
|
||||
? resourceRow.ssl
|
||||
? "HTTPS"
|
||||
: "HTTP"
|
||||
: resourceRow.protocol.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
friendlyName: t("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",
|
||||
friendlyName: t("access"),
|
||||
header: () => <span className="p-3">{t("access")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{!resourceRow.http ? (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.proxyPort?.toString() || ""}
|
||||
isLink={false}
|
||||
/>
|
||||
) : !resourceRow.domainId ? (
|
||||
<InfoPopup
|
||||
info={t("domainNotFoundDescription")}
|
||||
text={t("domainNotFound")}
|
||||
/>
|
||||
) : (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.domain}
|
||||
isLink={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "authState",
|
||||
friendlyName: t("authentication"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("authentication")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div>
|
||||
{resourceRow.authState === "protected" ? (
|
||||
<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="flex items-center space-x-2">
|
||||
<ShieldOff className="w-4 h-4 text-yellow-500" />
|
||||
<span>{t("notProtected")}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
friendlyName: t("enabled"),
|
||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={
|
||||
row.original.http
|
||||
? !!row.original.domainId && row.original.enabled
|
||||
: row.original.enabled
|
||||
}
|
||||
disabled={
|
||||
row.original.http ? !row.original.domainId : false
|
||||
}
|
||||
onCheckedChange={(val) =>
|
||||
toggleResourceEnabled(val, row.original.id)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: ({ table }) => {
|
||||
const hasHideableColumns = table
|
||||
.getAllColumns()
|
||||
.some((column) => column.getCanHide());
|
||||
if (!hasHideableColumns) {
|
||||
return <span className="p-3"></span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1 p-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Columns className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t("columns") || "Columns"}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel>
|
||||
{t("toggleColumns") || "Toggle columns"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
const columnDef =
|
||||
column.columnDef as any;
|
||||
const friendlyName =
|
||||
columnDef.friendlyName;
|
||||
const displayName =
|
||||
friendlyName ||
|
||||
(typeof columnDef.header ===
|
||||
"string"
|
||||
? columnDef.header
|
||||
: column.id);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(
|
||||
!!value
|
||||
)
|
||||
}
|
||||
onSelect={(e) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
>
|
||||
{displayName}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.nice}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedResource(resourceRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.nice}`}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const proxyTable = useReactTable({
|
||||
data: resources,
|
||||
columns: proxyColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setProxySorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setProxyColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setProxyGlobalFilter,
|
||||
onColumnVisibilityChange: setProxyColumnVisibility,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: proxyPageSize,
|
||||
pageIndex: 0
|
||||
},
|
||||
columnVisibility: proxyColumnVisibility
|
||||
},
|
||||
state: {
|
||||
sorting: proxySorting,
|
||||
columnFilters: proxyColumnFilters,
|
||||
globalFilter: proxyGlobalFilter,
|
||||
columnVisibility: proxyColumnVisibility
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedResource && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedResource(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<p>{t("resourceQuestionRemove")}</p>
|
||||
<p>{t("resourceMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("resourceDeleteConfirm")}
|
||||
onConfirm={async () => deleteResource(selectedResource!.id)}
|
||||
string={selectedResource.name}
|
||||
title={t("resourceDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
|
||||
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={t("resourcesSearch")}
|
||||
value={proxyGlobalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
proxyTable.setGlobalFilter(
|
||||
String(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:justify-end">
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startTransition(refreshData)}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="hidden sm:inline">
|
||||
{t("refresh")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/${orgId}/settings/resources/create`
|
||||
)
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourceAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto mt-9">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{proxyTable
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers
|
||||
.filter((header) =>
|
||||
header.column.getIsVisible()
|
||||
)
|
||||
.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={`whitespace-nowrap ${
|
||||
header.column
|
||||
.id ===
|
||||
"actions"
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: header
|
||||
.column
|
||||
.id ===
|
||||
"name"
|
||||
? "md:sticky md:left-0 z-10 bg-card"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{proxyTable.getRowModel().rows?.length ? (
|
||||
proxyTable
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`whitespace-nowrap ${
|
||||
cell.column
|
||||
.id ===
|
||||
"actions"
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: cell
|
||||
.column
|
||||
.id ===
|
||||
"name"
|
||||
? "md:sticky md:left-0 z-10 bg-card"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={proxyColumns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t(
|
||||
"resourcesTableNoProxyResourcesFound"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={proxyTable}
|
||||
onPageSizeChange={setProxyPageSize}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,19 +3,29 @@ import * as React from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { durationToMs } from "@app/lib/durationToMs";
|
||||
|
||||
export type ReactQueryProviderProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ReactQueryProvider({ children }: ReactQueryProviderProps) {
|
||||
export function TanstackQueryProvider({ children }: ReactQueryProviderProps) {
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [queryClient] = React.useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2, // retry twice by default
|
||||
staleTime: 5 * 60 * 1_000 // 5 minutes
|
||||
staleTime: durationToMs(5, "minutes"),
|
||||
meta: {
|
||||
api
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
meta: { api }
|
||||
}
|
||||
}
|
||||
})
|
||||
679
src/components/UserDevicesTable.tsx
Normal file
679
src/components/UserDevicesTable.tsx
Normal file
@@ -0,0 +1,679 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
Columns,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
Search
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { InfoPopup } from "./ui/info-popup";
|
||||
|
||||
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
subnet: string;
|
||||
// siteIds: string;
|
||||
mbIn: string;
|
||||
mbOut: string;
|
||||
orgId: string;
|
||||
online: boolean;
|
||||
olmVersion?: string;
|
||||
olmUpdateAvailable: boolean;
|
||||
userId: string | null;
|
||||
username: string | null;
|
||||
userEmail: string | null;
|
||||
};
|
||||
|
||||
type ClientTableProps = {
|
||||
userClients: ClientRow[];
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [userPageSize, setUserPageSize] = useStoredPageSize(
|
||||
"user-clients",
|
||||
20
|
||||
);
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
|
||||
const [userSorting, setUserSorting] = useState<SortingState>([]);
|
||||
const [userColumnFilters, setUserColumnFilters] =
|
||||
useState<ColumnFiltersState>([]);
|
||||
const [userGlobalFilter, setUserGlobalFilter] = useState<any>([]);
|
||||
|
||||
const defaultUserColumnVisibility = {
|
||||
client: false,
|
||||
subnet: false
|
||||
};
|
||||
|
||||
const [userColumnVisibility, setUserColumnVisibility] =
|
||||
useStoredColumnVisibility("user-clients", defaultUserColumnVisibility);
|
||||
|
||||
const refreshData = async () => {
|
||||
try {
|
||||
router.refresh();
|
||||
console.log("Data refreshed");
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteClient = (clientId: number) => {
|
||||
api.delete(`/client/${clientId}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting client", e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting client",
|
||||
description: formatAxiosError(e, "Error deleting client")
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Check if there are any rows without userIds in the current view's data
|
||||
const hasRowsWithoutUserId = useMemo(() => {
|
||||
return userClients.some((client) => !client.userId);
|
||||
}, [userClients]);
|
||||
|
||||
const columns: ExtendedColumnDef<ClientRow>[] = useMemo(() => {
|
||||
const baseColumns: ExtendedColumnDef<ClientRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: "Name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
Name
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "userId",
|
||||
friendlyName: "User",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
User
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return r.userId ? (
|
||||
<Link
|
||||
href={`/${r.orgId}/settings/access/users/${r.userId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{r.userEmail || r.username || r.userId}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
}
|
||||
},
|
||||
// {
|
||||
// accessorKey: "siteName",
|
||||
// header: ({ column }) => {
|
||||
// return (
|
||||
// <Button
|
||||
// variant="ghost"
|
||||
// onClick={() =>
|
||||
// column.toggleSorting(column.getIsSorted() === "asc")
|
||||
// }
|
||||
// >
|
||||
// Site
|
||||
// <ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
// </Button>
|
||||
// );
|
||||
// },
|
||||
// cell: ({ row }) => {
|
||||
// const r = row.original;
|
||||
// return (
|
||||
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
|
||||
// <Button variant="outline">
|
||||
// {r.siteName}
|
||||
// <ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
// </Button>
|
||||
// </Link>
|
||||
// );
|
||||
// }
|
||||
// },
|
||||
{
|
||||
accessorKey: "online",
|
||||
friendlyName: "Connectivity",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
Connectivity
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
if (originalRow.online) {
|
||||
return (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>Connected</span>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>Disconnected</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mbIn",
|
||||
friendlyName: "Data In",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
Data In
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mbOut",
|
||||
friendlyName: "Data Out",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
Data Out
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "client",
|
||||
friendlyName: t("client"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("client")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Badge variant="secondary">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Olm</span>
|
||||
{originalRow.olmVersion && (
|
||||
<span className="text-xs text-gray-500">
|
||||
v{originalRow.olmVersion}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
{originalRow.olmUpdateAvailable && (
|
||||
<InfoPopup info={t("olmUpdateAvailableInfo")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "subnet",
|
||||
friendlyName: "Address",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
Address
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Only include actions column if there are rows without userIds
|
||||
if (hasRowsWithoutUserId) {
|
||||
baseColumns.push({
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: ({ table }) => {
|
||||
const hasHideableColumns = table
|
||||
.getAllColumns()
|
||||
.some((column) => column.getCanHide());
|
||||
if (!hasHideableColumns) {
|
||||
return <span className="p-3"></span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1 p-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Columns className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t("columns") || "Columns"}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-48"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
{t("toggleColumns") || "Toggle columns"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
const columnDef =
|
||||
column.columnDef as any;
|
||||
const friendlyName =
|
||||
columnDef.friendlyName;
|
||||
const displayName =
|
||||
friendlyName ||
|
||||
(typeof columnDef.header ===
|
||||
"string"
|
||||
? columnDef.header
|
||||
: column.id);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(
|
||||
!!value
|
||||
)
|
||||
}
|
||||
onSelect={(e) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
>
|
||||
{displayName}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const clientRow = row.original;
|
||||
return !clientRow.userId ? (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open menu
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* <Link */}
|
||||
{/* className="block w-full" */}
|
||||
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
|
||||
{/* > */}
|
||||
{/* <DropdownMenuItem> */}
|
||||
{/* View settings */}
|
||||
{/* </DropdownMenuItem> */}
|
||||
{/* </Link> */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedClient(clientRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
Delete
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
Edit
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
}, [hasRowsWithoutUserId, t]);
|
||||
|
||||
const userTable = useReactTable({
|
||||
data: userClients || [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setUserSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setUserColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setUserGlobalFilter,
|
||||
onColumnVisibilityChange: setUserColumnVisibility,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: userPageSize,
|
||||
pageIndex: 0
|
||||
},
|
||||
columnVisibility: userColumnVisibility
|
||||
},
|
||||
state: {
|
||||
sorting: userSorting,
|
||||
columnFilters: userColumnFilters,
|
||||
globalFilter: userGlobalFilter,
|
||||
columnVisibility: userColumnVisibility
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedClient && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedClient(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<p>{t("deleteClientQuestion")}</p>
|
||||
<p>{t("clientMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete Client"
|
||||
onConfirm={async () => deleteClient(selectedClient!.id)}
|
||||
string={selectedClient.name}
|
||||
title="Delete Client"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
|
||||
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={t("resourcesSearch")}
|
||||
value={userGlobalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
userTable.setGlobalFilter(
|
||||
String(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:justify-end">
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startTransition(refreshData)}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="hidden sm:inline">
|
||||
{t("refresh")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto mt-9">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{userTable
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers
|
||||
.filter((header) =>
|
||||
header.column.getIsVisible()
|
||||
)
|
||||
.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={`whitespace-nowrap ${
|
||||
header.column
|
||||
.id ===
|
||||
"actions"
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: header
|
||||
.column
|
||||
.id ===
|
||||
"name"
|
||||
? "md:sticky md:left-0 z-10 bg-card"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{userTable.getRowModel().rows?.length ? (
|
||||
userTable
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`whitespace-nowrap ${
|
||||
cell.column
|
||||
.id ===
|
||||
"actions"
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: cell
|
||||
.column
|
||||
.id ===
|
||||
"name"
|
||||
? "md:sticky md:left-0 z-10 bg-card"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("noResults")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={userTable}
|
||||
onPageSizeChange={setUserPageSize}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
Dispatch,
|
||||
SetStateAction
|
||||
type Dispatch,
|
||||
type SetStateAction
|
||||
} from "react";
|
||||
|
||||
type SetValue<T> = Dispatch<SetStateAction<T>>;
|
||||
|
||||
81
src/hooks/useStoredColumnVisibility.ts
Normal file
81
src/hooks/useStoredColumnVisibility.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { VisibilityState } from "@tanstack/react-table";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
COLUMN_VISIBILITY: "datatable-column-visibility",
|
||||
getTableColumnVisibility: (tableId: string) =>
|
||||
`datatable-${tableId}-column-visibility`
|
||||
};
|
||||
|
||||
const getStoredColumnVisibility = (
|
||||
tableId: string,
|
||||
defaultVisibility?: Record<string, boolean>
|
||||
): Record<string, boolean> => {
|
||||
if (typeof window === "undefined") return defaultVisibility || {};
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTableColumnVisibility(tableId);
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Validate that it's an object
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Failed to read column visibility from localStorage:",
|
||||
error
|
||||
);
|
||||
}
|
||||
return defaultVisibility || {};
|
||||
};
|
||||
|
||||
const setStoredColumnVisibility = (
|
||||
visibility: Record<string, boolean>,
|
||||
tableId: string
|
||||
): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTableColumnVisibility(tableId);
|
||||
localStorage.setItem(key, JSON.stringify(visibility));
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Failed to save column visibility to localStorage:",
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function useStoredColumnVisibility(
|
||||
tableId: string,
|
||||
defaultColumnVisibility?: Record<string, boolean>
|
||||
) {
|
||||
const [columnVisibility, setVisibility] = useState<VisibilityState>(() =>
|
||||
getStoredColumnVisibility(tableId, defaultColumnVisibility)
|
||||
);
|
||||
|
||||
const setColumnVisibility = useCallback(
|
||||
(
|
||||
updaterOrValue:
|
||||
| VisibilityState
|
||||
| ((old: VisibilityState) => VisibilityState)
|
||||
) => {
|
||||
if (typeof updaterOrValue === "function") {
|
||||
setVisibility((oldValue) => {
|
||||
const newValue = updaterOrValue(oldValue);
|
||||
setStoredColumnVisibility(newValue, tableId);
|
||||
return newValue;
|
||||
});
|
||||
} else {
|
||||
setVisibility(updaterOrValue);
|
||||
setStoredColumnVisibility(updaterOrValue, tableId);
|
||||
}
|
||||
},
|
||||
[tableId]
|
||||
);
|
||||
|
||||
return [columnVisibility, setColumnVisibility] as const;
|
||||
}
|
||||
60
src/hooks/useStoredPageSize.ts
Normal file
60
src/hooks/useStoredPageSize.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
PAGE_SIZE: "datatable-page-size",
|
||||
getTablePageSize: (tableId: string) => `datatable-${tableId}-page-size`
|
||||
};
|
||||
|
||||
const getStoredPageSize = (tableId: string, defaultSize = 20): number => {
|
||||
if (typeof window === "undefined") return defaultSize;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTablePageSize(tableId);
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
if (parsed > 0 && parsed <= 1000) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (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;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTablePageSize(tableId);
|
||||
localStorage.setItem(key, pageSize.toString());
|
||||
} catch (error) {
|
||||
console.warn("Failed to save page size to localStorage:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// export function useStore
|
||||
export function useStoredPageSize(tableId: string, defaultPageSize?: number) {
|
||||
const [pageSize, setSize] = useState(() =>
|
||||
getStoredPageSize(tableId, defaultPageSize)
|
||||
);
|
||||
|
||||
const setPageSize = useCallback(
|
||||
(updaterOrValue: number | ((old: number) => number)) => {
|
||||
if (typeof updaterOrValue === "function") {
|
||||
setSize((oldValue) => {
|
||||
const newValue = updaterOrValue(oldValue);
|
||||
setStoredPageSize(newValue, tableId);
|
||||
return newValue;
|
||||
});
|
||||
} else {
|
||||
setSize(updaterOrValue);
|
||||
setStoredPageSize(updaterOrValue, tableId);
|
||||
}
|
||||
},
|
||||
[tableId]
|
||||
);
|
||||
|
||||
return [pageSize, setPageSize] as const;
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
||||
import { durationToMs } from "./durationToMs";
|
||||
import { build } from "@server/build";
|
||||
import { remote } from "./api";
|
||||
import type { ListClientsResponse } from "@server/routers/client";
|
||||
import type { ListRolesResponse } from "@server/routers/role";
|
||||
import type { ListSitesResponse } from "@server/routers/site";
|
||||
import type {
|
||||
ListSiteResourceRolesResponse,
|
||||
ListSiteResourceUsersResponse
|
||||
} from "@server/routers/siteResource";
|
||||
import type { ListUsersResponse } from "@server/routers/user";
|
||||
import type ResponseT from "@server/types/Response";
|
||||
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import z from "zod";
|
||||
import { remote } from "./api";
|
||||
import { durationToMs } from "./durationToMs";
|
||||
|
||||
export type ProductUpdate = {
|
||||
link: string | null;
|
||||
@@ -65,3 +75,90 @@ export const productUpdatesQueries = {
|
||||
// because we don't need to listen for new versions there
|
||||
})
|
||||
};
|
||||
|
||||
export const clientFilterSchema = z.object({
|
||||
filter: z.enum(["machine", "user"]),
|
||||
limit: z.int().prefault(1000).optional()
|
||||
});
|
||||
|
||||
export const orgQueries = {
|
||||
clients: ({
|
||||
orgId,
|
||||
filters
|
||||
}: {
|
||||
orgId: string;
|
||||
filters: z.infer<typeof clientFilterSchema>;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "CLIENTS", filters] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams({
|
||||
...filters,
|
||||
limit: (filters.limit ?? 1000).toString()
|
||||
});
|
||||
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListClientsResponse>
|
||||
>(`/org/${orgId}/clients?${sp.toString()}`, { signal });
|
||||
|
||||
return res.data.data.clients;
|
||||
}
|
||||
}),
|
||||
users: ({ orgId }: { orgId: string }) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "USERS"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListUsersResponse>
|
||||
>(`/org/${orgId}/users`, { signal });
|
||||
|
||||
return res.data.data.users;
|
||||
}
|
||||
}),
|
||||
roles: ({ orgId }: { orgId: string }) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "ROLES"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListRolesResponse>
|
||||
>(`/org/${orgId}/roles`, { signal });
|
||||
|
||||
return res.data.data.roles;
|
||||
}
|
||||
}),
|
||||
|
||||
sites: ({ orgId }: { orgId: string }) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "SITES"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListSitesResponse>
|
||||
>(`/org/${orgId}/sites`, { signal });
|
||||
return res.data.data.sites;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
export const resourceQueries = {
|
||||
resourceUsers: ({ resourceId }: { resourceId: number }) =>
|
||||
queryOptions({
|
||||
queryKey: ["RESOURCES", resourceId, "USERS"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListSiteResourceUsersResponse>
|
||||
>(`/site-resource/${resourceId}/users`, { signal });
|
||||
return res.data.data.users;
|
||||
}
|
||||
}),
|
||||
resourceRoles: ({ resourceId }: { resourceId: number }) =>
|
||||
queryOptions({
|
||||
queryKey: ["RESOURCES", resourceId, "ROLES"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListSiteResourceRolesResponse>
|
||||
>(`/site-resource/${resourceId}/roles`, { signal });
|
||||
|
||||
return res.data.data.roles;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
13
src/types/tanstack-query.d.ts
vendored
Normal file
13
src/types/tanstack-query.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import "@tanstack/react-query";
|
||||
import type { AxiosInstance } from "axios";
|
||||
|
||||
interface Meta extends Record<string, unknown> {
|
||||
api: AxiosInstance;
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-query" {
|
||||
interface Register {
|
||||
queryMeta: Meta;
|
||||
mutationMeta: Meta;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user