mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-09 23:49:47 +02:00
Merge pull request #3263 from Fredkiss3/feat/command-bar
feat: Command Palette for searching accross the dashboard
This commit is contained in:
@@ -1519,6 +1519,29 @@
|
|||||||
"navbar": "Navigation Menu",
|
"navbar": "Navigation Menu",
|
||||||
"navbarDescription": "Main navigation menu for the application",
|
"navbarDescription": "Main navigation menu for the application",
|
||||||
"navbarDocsLink": "Documentation",
|
"navbarDocsLink": "Documentation",
|
||||||
|
"commandPaletteTitle": "Command palette",
|
||||||
|
"commandPaletteDescription": "Search for pages, organizations, resources, and actions",
|
||||||
|
"commandPaletteSearchPlaceholder": "Search pages, resources, actions...",
|
||||||
|
"commandPaletteNoResults": "No results found.",
|
||||||
|
"commandPaletteSearching": "Searching...",
|
||||||
|
"commandPaletteNavigation": "Navigation",
|
||||||
|
"commandPaletteOrganizations": "Organizations",
|
||||||
|
"commandPaletteSites": "Sites",
|
||||||
|
"commandPaletteResources": "Resources",
|
||||||
|
"commandPaletteUsers": "Users",
|
||||||
|
"commandPaletteClients": "Machine clients",
|
||||||
|
"commandPaletteActions": "Actions",
|
||||||
|
"commandPaletteCreateSite": "Create site",
|
||||||
|
"commandPaletteCreateProxyResource": "Create public resource",
|
||||||
|
"commandPaletteCreateUser": "Create user",
|
||||||
|
"commandPaletteCreateApiKey": "Create API key",
|
||||||
|
"commandPaletteCreateMachineClient": "Create machine client",
|
||||||
|
"commandPaletteCreateAlertRule": "Create alert rule",
|
||||||
|
"commandPaletteCreateIdentityProvider": "Create identity provider",
|
||||||
|
"commandPaletteToggleTheme": "Toggle theme",
|
||||||
|
"commandPaletteChooseOrganization": "Choose organization",
|
||||||
|
"commandPaletteShortcutMac": "⌘K",
|
||||||
|
"commandPaletteShortcutWindows": "Ctrl K",
|
||||||
"otpErrorEnable": "Unable to enable 2FA",
|
"otpErrorEnable": "Unable to enable 2FA",
|
||||||
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
|
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
|
||||||
"otpSetupCheckCode": "Please enter a 6-digit code",
|
"otpSetupCheckCode": "Please enter a 6-digit code",
|
||||||
@@ -1596,6 +1619,43 @@
|
|||||||
"sidebarManagement": "Management",
|
"sidebarManagement": "Management",
|
||||||
"sidebarBillingAndLicenses": "Billing & Licenses",
|
"sidebarBillingAndLicenses": "Billing & Licenses",
|
||||||
"sidebarLogsAnalytics": "Analytics",
|
"sidebarLogsAnalytics": "Analytics",
|
||||||
|
"commandSites": "Sites",
|
||||||
|
"commandActionModeInfo": "Type \">\" to open action mode",
|
||||||
|
"commandResources": "Resources",
|
||||||
|
"commandProxyResources": "Public Resources",
|
||||||
|
"commandClientResources": "Private Resources",
|
||||||
|
"commandClients": "Clients",
|
||||||
|
"commandUserDevices": "User Devices",
|
||||||
|
"commandMachineClients": "Machine Clients",
|
||||||
|
"commandDomains": "Domains",
|
||||||
|
"commandRemoteExitNodes": "Remote Nodes",
|
||||||
|
"commandTeam": "Team",
|
||||||
|
"commandUsers": "Users",
|
||||||
|
"commandRoles": "Roles",
|
||||||
|
"commandInvitations": "Invitations",
|
||||||
|
"commandPolicies": "Shared Policies",
|
||||||
|
"commandResourcePolicies": "Public Resources Policies",
|
||||||
|
"commandIdentityProviders": "Identity Providers",
|
||||||
|
"commandApprovals": "Approval Requests",
|
||||||
|
"commandShareableLinks": "Shareable Links",
|
||||||
|
"commandOrganization": "Organization",
|
||||||
|
"commandLogsAndAnalytics": "Logs & Analytics",
|
||||||
|
"commandLogsAnalytics": "Analytics",
|
||||||
|
"commandLogsRequest": "HTTP Request Logs",
|
||||||
|
"commandLogsAccess": "Authentication Logs",
|
||||||
|
"commandLogsAction": "Admin Action Logs",
|
||||||
|
"commandLogsConnection": "Network Logs",
|
||||||
|
"commandLogsStreaming": "Event Streaming",
|
||||||
|
"commandManagement": "Management",
|
||||||
|
"commandAlerting": "Alerting",
|
||||||
|
"commandProvisioning": "Provisioning",
|
||||||
|
"commandBluePrints": "Blueprints",
|
||||||
|
"commandApiKeys": "API Keys",
|
||||||
|
"commandBillingAndLicenses": "Billing & Licenses",
|
||||||
|
"commandBilling": "Billing",
|
||||||
|
"commandEnterpriseLicenses": "Licenses",
|
||||||
|
"commandSettings": "Settings",
|
||||||
|
"commandSearchResults": "Search Results",
|
||||||
"alertingTitle": "Alerting",
|
"alertingTitle": "Alerting",
|
||||||
"alertingDescription": "Define sources, triggers, and actions for notifications",
|
"alertingDescription": "Define sources, triggers, and actions for notifications",
|
||||||
"alertingRules": "Alert rules",
|
"alertingRules": "Alert rules",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import UserProvider from "@app/providers/UserProvider";
|
|||||||
import { Layout } from "@app/components/Layout";
|
import { Layout } from "@app/components/Layout";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import { orgNavSections } from "@app/app/navigation";
|
import { commandBarNavSections, orgNavSections } from "@app/app/navigation";
|
||||||
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -82,6 +82,9 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
navItems={orgNavSections(env, {
|
navItems={orgNavSections(env, {
|
||||||
isPrimaryOrg: primaryOrg
|
isPrimaryOrg: primaryOrg
|
||||||
})}
|
})}
|
||||||
|
commandNavItems={commandBarNavSections(env, {
|
||||||
|
isPrimaryOrg: primaryOrg
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
|
import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner";
|
||||||
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable";
|
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable";
|
||||||
import PrivateResourcesTable from "@app/components/PrivateResourcesTable";
|
import PrivateResourcesTable from "@app/components/PrivateResourcesTable";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner";
|
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
|
||||||
import { GetSiteResponse } from "@server/routers/site/getSite";
|
|
||||||
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||||
import type ResponseT from "@server/types/Response";
|
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -24,13 +21,6 @@ export interface ClientResourcesPageProps {
|
|||||||
searchParams: Promise<Record<string, string>>;
|
searchParams: Promise<Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePositiveInt(s: string | undefined): number | undefined {
|
|
||||||
if (!s) return undefined;
|
|
||||||
const n = Number(s);
|
|
||||||
if (!Number.isInteger(n) || n <= 0) return undefined;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ClientResourcesPage(
|
export default async function ClientResourcesPage(
|
||||||
props: ClientResourcesPageProps
|
props: ClientResourcesPageProps
|
||||||
) {
|
) {
|
||||||
@@ -39,7 +29,7 @@ export default async function ClientResourcesPage(
|
|||||||
const searchParams = new URLSearchParams(await props.searchParams);
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
|
|
||||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||||
let pagination: ListResourcesResponse["pagination"] = {
|
let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = {
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20
|
pageSize: 20
|
||||||
@@ -56,34 +46,6 @@ export default async function ClientResourcesPage(
|
|||||||
pagination = responseData.pagination;
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const siteIdParam = parsePositiveInt(
|
|
||||||
searchParams.get("siteId") ?? undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
let initialFilterSite: {
|
|
||||||
siteId: number;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
} | null = null;
|
|
||||||
if (siteIdParam) {
|
|
||||||
try {
|
|
||||||
const siteRes = await internal.get(
|
|
||||||
`/site/${siteIdParam}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
);
|
|
||||||
const s = (siteRes.data as ResponseT<GetSiteResponse>).data;
|
|
||||||
if (s && s.orgId === params.orgId) {
|
|
||||||
initialFilterSite = {
|
|
||||||
siteId: s.siteId,
|
|
||||||
name: s.name,
|
|
||||||
type: s.type
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// leave null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let org = null;
|
let org = null;
|
||||||
try {
|
try {
|
||||||
const res = await getCachedOrg(params.orgId);
|
const res = await getCachedOrg(params.orgId);
|
||||||
@@ -154,7 +116,6 @@ export default async function ClientResourcesPage(
|
|||||||
pageIndex: pagination.page - 1,
|
pageIndex: pagination.page - 1,
|
||||||
pageSize: pagination.pageSize
|
pageSize: pagination.pageSize
|
||||||
}}
|
}}
|
||||||
initialFilterSite={initialFilterSite}
|
|
||||||
/>
|
/>
|
||||||
</OrgProvider>
|
</OrgProvider>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -341,3 +341,235 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export type CommandBarNavSection = {
|
||||||
|
// Added from 'dev' branch
|
||||||
|
heading: string;
|
||||||
|
items: CommandBarNavItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandBarNavItem = {
|
||||||
|
href?: string;
|
||||||
|
title: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
showEE?: boolean;
|
||||||
|
isBeta?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commandBarNavSections = (
|
||||||
|
env?: Env,
|
||||||
|
options?: OrgNavSectionsOptions
|
||||||
|
): CommandBarNavSection[] => [
|
||||||
|
{
|
||||||
|
heading: "network",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "commandSites",
|
||||||
|
href: "/{orgId}/settings/sites",
|
||||||
|
icon: <Plug className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "commandProxyResources",
|
||||||
|
href: "/{orgId}/settings/resources/public",
|
||||||
|
icon: <Globe className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "commandClientResources",
|
||||||
|
href: "/{orgId}/settings/resources/private",
|
||||||
|
icon: <GlobeLock className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/{orgId}/settings/clients/user",
|
||||||
|
title: "commandUserDevices",
|
||||||
|
icon: <Laptop className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/{orgId}/settings/clients/machine",
|
||||||
|
title: "commandMachineClients",
|
||||||
|
icon: <Server className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
...(build === "saas"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "commandRemoteExitNodes",
|
||||||
|
href: "/{orgId}/settings/remote-exit-nodes",
|
||||||
|
icon: <Server className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "commandTeam",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "commandUsers",
|
||||||
|
href: "/{orgId}/settings/access/users",
|
||||||
|
icon: <User className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "commandRoles",
|
||||||
|
href: "/{orgId}/settings/access/roles",
|
||||||
|
icon: <Users className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "commandInvitations",
|
||||||
|
href: "/{orgId}/settings/access/invitations",
|
||||||
|
icon: <TicketCheck className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "accessControl",
|
||||||
|
items: [
|
||||||
|
...(!env?.flags.disableEnterpriseFeatures
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "commandResourcePolicies",
|
||||||
|
href: "/{orgId}/settings/policies/resources/public",
|
||||||
|
icon: <ShieldIcon className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
// PaidFeaturesAlert
|
||||||
|
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
|
||||||
|
build === "saas" ||
|
||||||
|
env?.app.identityProviderMode === "org" ||
|
||||||
|
(env?.app.identityProviderMode === undefined && build !== "oss")
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "commandIdentityProviders",
|
||||||
|
href: "/{orgId}/settings/idp",
|
||||||
|
icon: <Fingerprint className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(!env?.flags.disableEnterpriseFeatures
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "commandApprovals",
|
||||||
|
href: "/{orgId}/settings/access/approvals",
|
||||||
|
icon: <UserCog className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
title: "commandShareableLinks",
|
||||||
|
href: "/{orgId}/settings/share-links",
|
||||||
|
icon: <LinkIcon className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "commandLogsAndAnalytics",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "commandLogsAnalytics",
|
||||||
|
href: "/{orgId}/settings/logs/analytics",
|
||||||
|
icon: <ChartLine className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "commandLogsRequest",
|
||||||
|
href: "/{orgId}/settings/logs/request",
|
||||||
|
icon: <SquareMousePointer className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
...(!env?.flags.disableEnterpriseFeatures
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "commandLogsAccess",
|
||||||
|
href: "/{orgId}/settings/logs/access",
|
||||||
|
icon: <ScanEye className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "commandLogsAction",
|
||||||
|
href: "/{orgId}/settings/logs/action",
|
||||||
|
icon: <Logs className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "commandLogsConnection",
|
||||||
|
href: "/{orgId}/settings/logs/connection",
|
||||||
|
icon: <Cable className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "commandLogsStreaming",
|
||||||
|
href: "/{orgId}/settings/logs/streaming",
|
||||||
|
icon: <Unplug className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "commandManagement",
|
||||||
|
items: [
|
||||||
|
...(!env?.flags.disableEnterpriseFeatures
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "commandAlerting",
|
||||||
|
href: "/{orgId}/settings/alerting",
|
||||||
|
icon: <BellRing className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "commandProvisioning",
|
||||||
|
href: "/{orgId}/settings/provisioning",
|
||||||
|
icon: <Boxes className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
title: "commandBluePrints",
|
||||||
|
href: "/{orgId}/settings/blueprints",
|
||||||
|
icon: <ReceiptText className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "commandApiKeys",
|
||||||
|
href: "/{orgId}/settings/api-keys",
|
||||||
|
icon: <KeyRound className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
...(!env?.flags.disableEnterpriseFeatures
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "labels",
|
||||||
|
href: "/{orgId}/settings/labels",
|
||||||
|
icon: <TagIcon className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
heading: "commandOrganization",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "commandSettings",
|
||||||
|
href: "/{orgId}/settings/general",
|
||||||
|
icon: <Settings className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "commandDomains",
|
||||||
|
href: "/{orgId}/settings/domains",
|
||||||
|
icon: <Globe className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
...(build === "saas" && options?.isPrimaryOrg
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
heading: "commandBillingAndLicenses",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "commandBilling",
|
||||||
|
href: "/{orgId}/settings/billing",
|
||||||
|
icon: <CreditCard className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "commandEnterpriseLicenses",
|
||||||
|
href: "/{orgId}/settings/license",
|
||||||
|
icon: <TicketCheck className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
];
|
||||||
|
|||||||
+3
-1
@@ -21,9 +21,11 @@ export default async function Page(props: {
|
|||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
redirect: string | undefined;
|
redirect: string | undefined;
|
||||||
t: string | undefined;
|
t: string | undefined;
|
||||||
|
orgs?: string | undefined;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const params = await props.searchParams; // this is needed to prevent static optimization
|
const params = await props.searchParams; // this is needed to prevent static optimization
|
||||||
|
const showOrgPicker = params.orgs === "1";
|
||||||
|
|
||||||
const env = pullEnv();
|
const env = pullEnv();
|
||||||
|
|
||||||
@@ -106,7 +108,7 @@ export default async function Page(props: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetOrgId) {
|
if (targetOrgId && !showOrgPicker) {
|
||||||
const targetOrg = orgs.find((org) => org.orgId === targetOrgId);
|
const targetOrg = orgs.find((org) => org.orgId === targetOrgId);
|
||||||
return (
|
return (
|
||||||
<RedirectToOrg
|
<RedirectToOrg
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function CreateShareLinkForm({
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const { data: allResources = [] } = useQuery(
|
const { data: allResources = [] } = useQuery(
|
||||||
orgQueries.resources({ orgId: org?.org.orgId ?? "" })
|
orgQueries.proxyResources({ orgId: org?.org.orgId ?? "" })
|
||||||
);
|
);
|
||||||
|
|
||||||
const [selectedResource, setSelectedResource] =
|
const [selectedResource, setSelectedResource] =
|
||||||
|
|||||||
+61
-49
@@ -1,17 +1,22 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import type { SidebarNavSection } from "@app/app/navigation";
|
import type {
|
||||||
|
CommandBarNavSection,
|
||||||
|
SidebarNavSection
|
||||||
|
} from "@app/app/navigation";
|
||||||
import { LayoutSidebar } from "@app/components/LayoutSidebar";
|
import { LayoutSidebar } from "@app/components/LayoutSidebar";
|
||||||
import { LayoutHeader } from "@app/components/LayoutHeader";
|
import { LayoutHeader } from "@app/components/LayoutHeader";
|
||||||
import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu";
|
import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { CommandPaletteProvider } from "./command-palette/CommandPalette";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
orgs?: ListUserOrgsResponse["orgs"];
|
orgs?: ListUserOrgsResponse["orgs"];
|
||||||
navItems?: SidebarNavSection[];
|
navItems?: SidebarNavSection[];
|
||||||
|
commandNavItems?: CommandBarNavSection[];
|
||||||
showSidebar?: boolean;
|
showSidebar?: boolean;
|
||||||
showHeader?: boolean;
|
showHeader?: boolean;
|
||||||
showTopBar?: boolean;
|
showTopBar?: boolean;
|
||||||
@@ -25,6 +30,7 @@ export async function Layout({
|
|||||||
orgId,
|
orgId,
|
||||||
orgs,
|
orgs,
|
||||||
navItems = [],
|
navItems = [],
|
||||||
|
commandNavItems = [],
|
||||||
showSidebar = true,
|
showSidebar = true,
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
showTopBar = true,
|
showTopBar = true,
|
||||||
@@ -41,61 +47,67 @@ export async function Layout({
|
|||||||
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
|
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen-safe overflow-hidden">
|
<CommandPaletteProvider
|
||||||
{/* Desktop Sidebar */}
|
orgId={orgId}
|
||||||
{showSidebar && (
|
orgs={orgs}
|
||||||
<LayoutSidebar
|
navItems={commandNavItems}
|
||||||
orgId={orgId}
|
>
|
||||||
orgs={orgs}
|
<div className="flex h-screen-safe overflow-hidden">
|
||||||
navItems={navItems}
|
{/* Desktop Sidebar */}
|
||||||
defaultSidebarCollapsed={initialSidebarCollapsed}
|
{showSidebar && (
|
||||||
hasCookiePreference={hasCookiePreference}
|
<LayoutSidebar
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main content area */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex-1 flex flex-col h-full min-w-0 relative",
|
|
||||||
!showSidebar && "w-full"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Mobile header */}
|
|
||||||
{showHeader && (
|
|
||||||
<LayoutMobileMenu
|
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
orgs={orgs}
|
orgs={orgs}
|
||||||
navItems={navItems}
|
navItems={navItems}
|
||||||
showSidebar={showSidebar}
|
defaultSidebarCollapsed={initialSidebarCollapsed}
|
||||||
showTopBar={showTopBar}
|
hasCookiePreference={hasCookiePreference}
|
||||||
launcherMode={launcherMode}
|
|
||||||
showViewAsAdmin={showViewAsAdmin}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop header */}
|
{/* Main content area */}
|
||||||
{showHeader && (
|
<div
|
||||||
<LayoutHeader
|
className={cn(
|
||||||
showTopBar={showTopBar}
|
"flex-1 flex flex-col h-full min-w-0 relative",
|
||||||
launcherMode={launcherMode}
|
!showSidebar && "w-full"
|
||||||
orgId={orgId}
|
)}
|
||||||
orgs={orgs}
|
>
|
||||||
showViewAsAdmin={showViewAsAdmin}
|
{/* Mobile header */}
|
||||||
/>
|
{showHeader && (
|
||||||
)}
|
<LayoutMobileMenu
|
||||||
|
orgId={orgId}
|
||||||
|
orgs={orgs}
|
||||||
|
navItems={navItems}
|
||||||
|
showSidebar={showSidebar}
|
||||||
|
showTopBar={showTopBar}
|
||||||
|
launcherMode={launcherMode}
|
||||||
|
showViewAsAdmin={showViewAsAdmin}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Desktop header */}
|
||||||
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
{showHeader && (
|
||||||
<div
|
<LayoutHeader
|
||||||
className={cn(
|
showTopBar={showTopBar}
|
||||||
"container mx-auto max-w-12xl mb-12",
|
launcherMode={launcherMode}
|
||||||
showHeader && "md:pt-14" // Add top padding only on desktop to account for fixed header
|
orgId={orgId}
|
||||||
)}
|
orgs={orgs}
|
||||||
>
|
showViewAsAdmin={showViewAsAdmin}
|
||||||
{children}
|
/>
|
||||||
</div>
|
)}
|
||||||
</main>
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"container mx-auto max-w-12xl mb-12",
|
||||||
|
showHeader && "md:pt-14" // Add top padding only on desktop to account for fixed header
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CommandPaletteProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ListUserOrgsResponse } from "@server/routers/org";
|
|||||||
import { LauncherOrgSelector } from "@app/components/resource-launcher/LauncherOrgSelector";
|
import { LauncherOrgSelector } from "@app/components/resource-launcher/LauncherOrgSelector";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
|
||||||
|
|
||||||
type LayoutHeaderProps = {
|
type LayoutHeaderProps = {
|
||||||
showTopBar: boolean;
|
showTopBar: boolean;
|
||||||
@@ -104,6 +105,7 @@ export function LayoutHeader({
|
|||||||
|
|
||||||
{showTopBar && (
|
{showTopBar && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
<CommandPaletteTrigger />
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
<ProfileIcon />
|
<ProfileIcon />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { SidebarNav } from "@app/components/SidebarNav";
|
|
||||||
import { OrgSelector } from "@app/components/OrgSelector";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { Menu, Server, Settings, SquareMousePointer } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import ProfileIcon from "@app/components/ProfileIcon";
|
|
||||||
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
|
||||||
import type { SidebarNavSection } from "@app/app/navigation";
|
import type { SidebarNavSection } from "@app/app/navigation";
|
||||||
|
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
|
||||||
|
import { OrgSelector } from "@app/components/OrgSelector";
|
||||||
|
import ProfileIcon from "@app/components/ProfileIcon";
|
||||||
|
import { SidebarNav } from "@app/components/SidebarNav";
|
||||||
|
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetTrigger,
|
SheetDescription,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetDescription
|
SheetTrigger
|
||||||
} from "@app/components/ui/sheet";
|
} from "@app/components/ui/sheet";
|
||||||
import { Abel } from "next/font/google";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
|
import { Menu, Server, Settings, SquareMousePointer } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
interface LayoutMobileMenuProps {
|
interface LayoutMobileMenuProps {
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
@@ -207,6 +207,7 @@ export function LayoutMobileMenu({
|
|||||||
{showTopBar && (
|
{showTopBar && (
|
||||||
<div className="ml-auto flex items-center justify-end">
|
<div className="ml-auto flex items-center justify-end">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
<CommandPaletteTrigger variant="mobile" />
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
<ProfileIcon />
|
<ProfileIcon />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -119,15 +119,13 @@ type ClientResourcesTableProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
pagination: PaginationState;
|
pagination: PaginationState;
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
initialFilterSite?: Selectedsite | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PrivateResourcesTable({
|
export default function PrivateResourcesTable({
|
||||||
internalResources,
|
internalResources,
|
||||||
orgId,
|
orgId,
|
||||||
pagination,
|
pagination,
|
||||||
rowCount,
|
rowCount
|
||||||
initialFilterSite = null
|
|
||||||
}: ClientResourcesTableProps) {
|
}: ClientResourcesTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {
|
const {
|
||||||
@@ -145,7 +143,11 @@ export default function PrivateResourcesTable({
|
|||||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||||
|
|
||||||
const [selectedInternalResource, setSelectedInternalResource] =
|
const [selectedInternalResource, setSelectedInternalResource] =
|
||||||
|
useState<InternalResourceRow | null>(null);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [editingResource, setEditingResource] =
|
||||||
useState<InternalResourceRow | null>();
|
useState<InternalResourceRow | null>();
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
|
||||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
const [isRefreshing, startRefreshTransition] = useTransition();
|
||||||
|
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ function ResourceMultiSelect({
|
|||||||
const [debounced] = useDebounce(q, 150);
|
const [debounced] = useDebounce(q, 150);
|
||||||
|
|
||||||
const { data: resources = [] } = useQuery(
|
const { data: resources = [] } = useQuery(
|
||||||
orgQueries.resources({ orgId, query: debounced, perPage: 10 })
|
orgQueries.proxyResources({ orgId, query: debounced, perPage: 10 })
|
||||||
);
|
);
|
||||||
|
|
||||||
const shown = useMemo(() => {
|
const shown = useMemo(() => {
|
||||||
|
|||||||
@@ -0,0 +1,478 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CommandBarNavSection,
|
||||||
|
SidebarNavSection
|
||||||
|
} from "@app/app/navigation";
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator
|
||||||
|
} from "@app/components/ui/command";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
|
import {
|
||||||
|
ChevronRightIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
GlobeLockIcon,
|
||||||
|
LaptopIcon,
|
||||||
|
PlugIcon,
|
||||||
|
ServerIcon,
|
||||||
|
UserIcon
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState
|
||||||
|
} from "react";
|
||||||
|
import { useCommandPaletteActions } from "./useCommandPaletteActions";
|
||||||
|
import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation";
|
||||||
|
import { useCommandPaletteSearch } from "./useCommandPaletteSearch";
|
||||||
|
import { resources } from "@server/db";
|
||||||
|
|
||||||
|
type CommandPaletteProps = {
|
||||||
|
orgId?: string;
|
||||||
|
orgs?: ListUserOrgsResponse["orgs"];
|
||||||
|
navItems: CommandBarNavSection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan for command bar:
|
||||||
|
* - the nav items should be custom items instead of all of the ones in the sidebar
|
||||||
|
* - actions should be triggered by using `>` (like in Github)
|
||||||
|
* -> if search starts with `>`, the filter should exclude that char in the filter string
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const router = useRouter();
|
||||||
|
const { open, setOpen } = useCommandPalette();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const isActionMode = search.startsWith(">");
|
||||||
|
|
||||||
|
const navigationGroups = useCommandPaletteNavigation(navItems);
|
||||||
|
// const organizations = useCommandPaletteOrganizations(orgs);
|
||||||
|
const actions = useCommandPaletteActions(orgId, orgs);
|
||||||
|
const {
|
||||||
|
shouldSearch,
|
||||||
|
sites,
|
||||||
|
publicResources,
|
||||||
|
privateResources,
|
||||||
|
users,
|
||||||
|
machineClients,
|
||||||
|
userDevices,
|
||||||
|
isLoading,
|
||||||
|
hasResults: hasEntityResults
|
||||||
|
} = useCommandPaletteSearch({
|
||||||
|
orgId,
|
||||||
|
query: search,
|
||||||
|
enabled: !isActionMode && open
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(nextOpen: boolean) => {
|
||||||
|
setOpen(nextOpen);
|
||||||
|
if (!nextOpen) {
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
const runCommand = useCallback(
|
||||||
|
(command: () => void) => {
|
||||||
|
setOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
command();
|
||||||
|
},
|
||||||
|
[setOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
title={t("commandPaletteTitle")}
|
||||||
|
description={t("commandPaletteDescription")}
|
||||||
|
className="max-w-2xl **:data-[slot=command-input-wrapper]:h-15"
|
||||||
|
commandProps={{
|
||||||
|
loop: true,
|
||||||
|
filter(value, query) {
|
||||||
|
let search = query;
|
||||||
|
if (query.startsWith(">")) {
|
||||||
|
search = query.substring(1);
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
search,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
value
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search.trim().toLowerCase())
|
||||||
|
) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t("commandPaletteSearchPlaceholder")}
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
isLoading={!isActionMode && shouldSearch && isLoading}
|
||||||
|
/>
|
||||||
|
<CommandList className="max-h-118 min-h-0 h-(--cmdk-list-height) scroll-pb-4 scroll-pt-2 transition-[height] duration-250 ease-in-out">
|
||||||
|
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
|
||||||
|
|
||||||
|
<CommandGroup
|
||||||
|
heading={t("commandActionModeInfo")}
|
||||||
|
className="[&_[cmdk-group-heading]]:text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isActionMode &&
|
||||||
|
navigationGroups.map((group, groupIndex) => (
|
||||||
|
<React.Fragment key={group.heading}>
|
||||||
|
{groupIndex > 0 && <CommandSeparator />}
|
||||||
|
<CommandGroup
|
||||||
|
heading={group.heading}
|
||||||
|
className={cn(
|
||||||
|
"[&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-sm pb-2.5",
|
||||||
|
groupIndex > 0 &&
|
||||||
|
"[&_[cmdk-group-heading]]:pt-3"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<CommandItem
|
||||||
|
key={item.id}
|
||||||
|
value={`${item.title} ${group.heading}`}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() =>
|
||||||
|
router.push(item.href)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* {organizations.length > 1 && (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup
|
||||||
|
heading={t("commandPaletteOrganizations")}
|
||||||
|
>
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<CommandItem
|
||||||
|
key={org.id}
|
||||||
|
value={`${org.name} ${org.orgId}`}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() => router.push(org.href))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate">{org.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||||
|
{org.orgId}
|
||||||
|
</span>
|
||||||
|
{org.isPrimaryOrg && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="ml-auto shrink-0 text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
{t("primary")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{!isActionMode && shouldSearch && orgId && hasEntityResults && (
|
||||||
|
<CommandGroup
|
||||||
|
heading={t("commandSearchResults")}
|
||||||
|
className={cn(
|
||||||
|
"[&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-sm pb-2.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sites.map((site) => (
|
||||||
|
<CommandItem
|
||||||
|
key={site.id}
|
||||||
|
value={`${site.name} site`}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() => router.push(site.href))
|
||||||
|
}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<PlugIcon className="size-4 flex-none" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("commandSites")}
|
||||||
|
</span>
|
||||||
|
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
|
||||||
|
<span className="truncate">
|
||||||
|
{site.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
{publicResources.map((resource) => (
|
||||||
|
<CommandItem
|
||||||
|
key={resource.id}
|
||||||
|
value={`${resource.name} public resource`}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() => router.push(resource.href))
|
||||||
|
}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<GlobeIcon className="size-4 flex-none" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("commandProxyResources")}
|
||||||
|
</span>
|
||||||
|
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
|
||||||
|
<span className="truncate">
|
||||||
|
{resource.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
{privateResources.map((resource) => (
|
||||||
|
<CommandItem
|
||||||
|
key={resource.id}
|
||||||
|
value={`${resource.name} private resource`}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() => router.push(resource.href))
|
||||||
|
}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<GlobeLockIcon className="size-4 flex-none" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("commandClientResources")}
|
||||||
|
</span>
|
||||||
|
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
|
||||||
|
<span className="truncate">
|
||||||
|
{resource.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
{users.map((user) => (
|
||||||
|
<CommandItem
|
||||||
|
key={user.id}
|
||||||
|
value={`${user.name} ${user.email}`}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() => router.push(user.href))
|
||||||
|
}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<UserIcon className="size-4 flex-none" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("commandUsers")}
|
||||||
|
</span>
|
||||||
|
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
|
||||||
|
<div className="inline-flex min-w-0 items-center gap-1">
|
||||||
|
<span className="truncate">
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
{machineClients.map((client) => (
|
||||||
|
<CommandItem
|
||||||
|
key={client.id}
|
||||||
|
value={`${client.name} client`}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() => router.push(client.href))
|
||||||
|
}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<ServerIcon className="size-4 flex-none" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("commandMachineClients")}
|
||||||
|
</span>
|
||||||
|
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
|
||||||
|
<span className="truncate">
|
||||||
|
{client.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
{userDevices.map((device) => (
|
||||||
|
<CommandItem
|
||||||
|
key={device.id}
|
||||||
|
value={`${device.name} user device`}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() => router.push(device.href))
|
||||||
|
}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<LaptopIcon className="size-4 flex-none" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("commandUserDevices")}
|
||||||
|
</span>
|
||||||
|
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
|
||||||
|
<span className="truncate">
|
||||||
|
{device.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isActionMode && actions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<CommandGroup
|
||||||
|
heading={t("commandPaletteActions")}
|
||||||
|
className={cn(
|
||||||
|
"[&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-sm pb-2.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<CommandItem
|
||||||
|
key={action.id}
|
||||||
|
value={action.label}
|
||||||
|
onSelect={() =>
|
||||||
|
runCommand(() => {
|
||||||
|
if (action.onSelect) {
|
||||||
|
action.onSelect();
|
||||||
|
} else if (action.href) {
|
||||||
|
router.push(action.href);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
|
{action.icon}
|
||||||
|
<span>{action.label}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************/
|
||||||
|
/* COMMAND PALETTE CONTEXT */
|
||||||
|
/*******************************/
|
||||||
|
export type CommandPaletteContextValue = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
toggle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
export function useCommandPalette() {
|
||||||
|
const context = useContext(CommandPaletteContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useCommandPalette must be used within CommandPaletteProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
//*******************************/
|
||||||
|
/* COMMAND PALETTE PROVIDER */
|
||||||
|
/*******************************/
|
||||||
|
type CommandPaletteProviderProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
orgId?: string;
|
||||||
|
orgs?: ListUserOrgsResponse["orgs"];
|
||||||
|
navItems: SidebarNavSection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function isEditableTarget(target: EventTarget | null) {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
const tagName = target.tagName;
|
||||||
|
return (
|
||||||
|
tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPaletteProvider({
|
||||||
|
children,
|
||||||
|
orgId,
|
||||||
|
orgs,
|
||||||
|
navItems
|
||||||
|
}: CommandPaletteProviderProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setOpen((current) => !current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contextValue = useMemo<CommandPaletteContextValue>(
|
||||||
|
() => ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
toggle
|
||||||
|
}),
|
||||||
|
[open, toggle]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
event.key.toLowerCase() !== "k" ||
|
||||||
|
!(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open && isEditableTarget(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [open, toggle]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandPaletteContext value={contextValue}>
|
||||||
|
{children}
|
||||||
|
<CommandPalette orgId={orgId} orgs={orgs} navItems={navItems} />
|
||||||
|
</CommandPaletteContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { CommandShortcut } from "@app/components/ui/command";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCommandPalette } from "./CommandPalette";
|
||||||
|
|
||||||
|
type CommandPaletteTriggerProps = {
|
||||||
|
variant?: "header" | "mobile";
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useIsMac() {
|
||||||
|
const [isMac, setIsMac] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMac(/Mac|iPhone|iPod|iPad/.test(navigator.platform));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMac;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPaletteTrigger({
|
||||||
|
variant = "header",
|
||||||
|
className
|
||||||
|
}: CommandPaletteTriggerProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { setOpen } = useCommandPalette();
|
||||||
|
const isMac = useIsMac();
|
||||||
|
|
||||||
|
if (variant === "mobile") {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={className}
|
||||||
|
aria-label={t("commandPaletteTitle")}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Search className="size-5" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"hidden h-9 w-56 justify-start gap-2 px-3 text-muted-foreground md:flex lg:w-64",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-label={t("commandPaletteTitle")}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Search className="size-4 shrink-0 opacity-50" />
|
||||||
|
<span className="flex-1 truncate text-left text-sm font-normal">
|
||||||
|
{t("commandPaletteSearchPlaceholder")}
|
||||||
|
</span>
|
||||||
|
<CommandShortcut>
|
||||||
|
{isMac
|
||||||
|
? t("commandPaletteShortcutMac")
|
||||||
|
: t("commandPaletteShortcutWindows")}
|
||||||
|
</CommandShortcut>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import {
|
||||||
|
BellRing,
|
||||||
|
Building2,
|
||||||
|
Globe,
|
||||||
|
KeyRound,
|
||||||
|
MonitorUp,
|
||||||
|
Plus,
|
||||||
|
SunMoon,
|
||||||
|
UserPlus
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
|
|
||||||
|
export type CommandPaletteAction = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
href?: string;
|
||||||
|
onSelect?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCommandPaletteActions(
|
||||||
|
orgId?: string,
|
||||||
|
orgs?: ListUserOrgsResponse["orgs"]
|
||||||
|
): CommandPaletteAction[] {
|
||||||
|
const t = useTranslations();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const { user } = useUserContext();
|
||||||
|
const { setTheme, theme } = useTheme();
|
||||||
|
const isAdminPage = pathname?.startsWith("/admin");
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const actions: CommandPaletteAction[] = [];
|
||||||
|
|
||||||
|
function cycleTheme() {
|
||||||
|
const currentTheme = theme || "system";
|
||||||
|
if (currentTheme === "light") {
|
||||||
|
setTheme("dark");
|
||||||
|
} else if (currentTheme === "dark") {
|
||||||
|
setTheme("system");
|
||||||
|
} else {
|
||||||
|
setTheme("light");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdminPage) {
|
||||||
|
actions.push({
|
||||||
|
id: "create-admin-api-key",
|
||||||
|
label: t("commandPaletteCreateApiKey"),
|
||||||
|
icon: <KeyRound className="size-4" />,
|
||||||
|
href: "/admin/api-keys/create"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
build === "oss" ||
|
||||||
|
env?.app.identityProviderMode === "global" ||
|
||||||
|
env?.app.identityProviderMode === undefined
|
||||||
|
) {
|
||||||
|
actions.push({
|
||||||
|
id: "create-admin-idp",
|
||||||
|
label: t("commandPaletteCreateIdentityProvider"),
|
||||||
|
icon: <Plus className="size-4" />,
|
||||||
|
href: "/admin/idp/create"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (orgId) {
|
||||||
|
actions.push({
|
||||||
|
id: "create-site",
|
||||||
|
label: t("commandPaletteCreateSite"),
|
||||||
|
icon: <Plus className="size-4" />,
|
||||||
|
href: `/${orgId}/settings/sites/create`
|
||||||
|
});
|
||||||
|
actions.push({
|
||||||
|
id: "create-proxy-resource",
|
||||||
|
label: t("commandPaletteCreateProxyResource"),
|
||||||
|
icon: <Globe className="size-4" />,
|
||||||
|
href: `/${orgId}/settings/resources/proxy/create`
|
||||||
|
});
|
||||||
|
actions.push({
|
||||||
|
id: "create-machine-client",
|
||||||
|
label: t("commandPaletteCreateMachineClient"),
|
||||||
|
icon: <MonitorUp className="size-4" />,
|
||||||
|
href: `/${orgId}/settings/clients/machine/create`
|
||||||
|
});
|
||||||
|
actions.push({
|
||||||
|
id: "create-user",
|
||||||
|
label: t("commandPaletteCreateUser"),
|
||||||
|
icon: <UserPlus className="size-4" />,
|
||||||
|
href: `/${orgId}/settings/access/users/create`
|
||||||
|
});
|
||||||
|
actions.push({
|
||||||
|
id: "create-api-key",
|
||||||
|
label: t("commandPaletteCreateApiKey"),
|
||||||
|
icon: <KeyRound className="size-4" />,
|
||||||
|
href: `/${orgId}/settings/api-keys/create`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!env?.flags.disableEnterpriseFeatures) {
|
||||||
|
actions.push({
|
||||||
|
id: "create-alert-rule",
|
||||||
|
label: t("commandPaletteCreateAlertRule"),
|
||||||
|
icon: <BellRing className="size-4" />,
|
||||||
|
href: `/${orgId}/settings/alerting/create`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
|
||||||
|
build === "saas" ||
|
||||||
|
env?.app.identityProviderMode === "org" ||
|
||||||
|
(env?.app.identityProviderMode === undefined && build !== "oss")
|
||||||
|
) {
|
||||||
|
actions.push({
|
||||||
|
id: "create-idp",
|
||||||
|
label: t("commandPaletteCreateIdentityProvider"),
|
||||||
|
icon: <Plus className="size-4" />,
|
||||||
|
href: `/${orgId}/settings/idp/create`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.push({
|
||||||
|
id: "toggle-theme",
|
||||||
|
label: t("commandPaletteToggleTheme"),
|
||||||
|
icon: <SunMoon className="size-4" />,
|
||||||
|
onSelect: cycleTheme
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user.serverAdmin && !isAdminPage) {
|
||||||
|
actions.push({
|
||||||
|
id: "go-admin",
|
||||||
|
label: t("serverAdmin"),
|
||||||
|
icon: <Building2 className="size-4" />,
|
||||||
|
href: "/admin/users"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}, [isAdminPage, orgId, orgs, env, user.serverAdmin, theme, setTheme, t]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CommandBarNavSection } from "@app/app/navigation";
|
||||||
|
import { flattenNavSections } from "@app/lib/flattenNavItems";
|
||||||
|
import {
|
||||||
|
hydrateNavHref,
|
||||||
|
navHrefParamsFromRoute
|
||||||
|
} from "@app/lib/hydrateNavHref";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export type NavigationCommand = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
sectionHeading: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigationCommandGroup = {
|
||||||
|
heading: string;
|
||||||
|
items: NavigationCommand[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCommandPaletteNavigation(
|
||||||
|
navItems: CommandBarNavSection[]
|
||||||
|
): NavigationCommandGroup[] {
|
||||||
|
const params = useParams();
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const hrefParams = navHrefParamsFromRoute(params);
|
||||||
|
const flat = flattenNavSections(navItems);
|
||||||
|
const groups = new Map<string, NavigationCommand[]>();
|
||||||
|
|
||||||
|
for (const item of flat) {
|
||||||
|
const href = hydrateNavHref(item.href, hrefParams);
|
||||||
|
if (!href) continue;
|
||||||
|
|
||||||
|
const groupItems = groups.get(item.sectionHeading) ?? [];
|
||||||
|
groupItems.push({
|
||||||
|
id: `nav-${item.sectionHeading}-${item.title}-${href}`,
|
||||||
|
title: t(item.title),
|
||||||
|
href,
|
||||||
|
icon: item.icon,
|
||||||
|
sectionHeading: item.sectionHeading
|
||||||
|
});
|
||||||
|
groups.set(item.sectionHeading, groupItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(groups.entries()).map(([heading, items]) => ({
|
||||||
|
heading: t(heading),
|
||||||
|
items
|
||||||
|
}));
|
||||||
|
}, [navItems, params, t]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export type OrganizationCommand = {
|
||||||
|
id: string;
|
||||||
|
orgId: string;
|
||||||
|
name: string;
|
||||||
|
isPrimaryOrg?: boolean;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCommandPaletteOrganizations(
|
||||||
|
orgs: ListUserOrgsResponse["orgs"] | undefined
|
||||||
|
): OrganizationCommand[] {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!orgs?.length) return [];
|
||||||
|
|
||||||
|
const sortedOrgs = [...orgs].sort((a, b) => {
|
||||||
|
const aPrimary = Boolean(a.isPrimaryOrg);
|
||||||
|
const bPrimary = Boolean(b.isPrimaryOrg);
|
||||||
|
if (aPrimary && !bPrimary) return -1;
|
||||||
|
if (!aPrimary && bPrimary) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortedOrgs.map((org) => {
|
||||||
|
const newPath = pathname.includes("/settings/")
|
||||||
|
? pathname.replace(/^\/[^/]+/, `/${org.orgId}`)
|
||||||
|
: `/${org.orgId}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `org-${org.orgId}`,
|
||||||
|
orgId: org.orgId,
|
||||||
|
name: org.name,
|
||||||
|
isPrimaryOrg: org.isPrimaryOrg,
|
||||||
|
href: newPath
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [orgs, pathname]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { orgQueries } from "@app/lib/queries";
|
||||||
|
import { useQueries } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
|
const SEARCH_PER_PAGE = 5;
|
||||||
|
const MIN_QUERY_LENGTH = 2;
|
||||||
|
|
||||||
|
export type SiteSearchResult = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResourceSearchResult = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserSearchResult = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClientSearchResult = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCommandPaletteSearch({
|
||||||
|
orgId,
|
||||||
|
query,
|
||||||
|
enabled
|
||||||
|
}: {
|
||||||
|
orgId?: string;
|
||||||
|
query: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}) {
|
||||||
|
const [debouncedQuery] = useDebounce(query, 150);
|
||||||
|
const trimmedQuery = debouncedQuery.trim();
|
||||||
|
const shouldSearch =
|
||||||
|
enabled && !!orgId && trimmedQuery.length >= MIN_QUERY_LENGTH;
|
||||||
|
|
||||||
|
const [
|
||||||
|
sitesQuery,
|
||||||
|
proxyResourcesQuery,
|
||||||
|
privateResourcesQuery,
|
||||||
|
usersQuery,
|
||||||
|
clientsQuery,
|
||||||
|
userDevicesQuery
|
||||||
|
] = useQueries({
|
||||||
|
queries: [
|
||||||
|
{
|
||||||
|
...orgQueries.sites({
|
||||||
|
orgId: orgId ?? "",
|
||||||
|
query: trimmedQuery,
|
||||||
|
perPage: SEARCH_PER_PAGE
|
||||||
|
}),
|
||||||
|
enabled: shouldSearch
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...orgQueries.proxyResources({
|
||||||
|
orgId: orgId ?? "",
|
||||||
|
query: trimmedQuery,
|
||||||
|
perPage: SEARCH_PER_PAGE
|
||||||
|
}),
|
||||||
|
enabled: shouldSearch
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...orgQueries.privateResources({
|
||||||
|
orgId: orgId ?? "",
|
||||||
|
query: trimmedQuery,
|
||||||
|
perPage: SEARCH_PER_PAGE
|
||||||
|
}),
|
||||||
|
enabled: shouldSearch
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...orgQueries.users({
|
||||||
|
orgId: orgId ?? "",
|
||||||
|
query: trimmedQuery,
|
||||||
|
perPage: SEARCH_PER_PAGE
|
||||||
|
}),
|
||||||
|
enabled: shouldSearch
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...orgQueries.machineClients({
|
||||||
|
orgId: orgId ?? "",
|
||||||
|
query: trimmedQuery,
|
||||||
|
perPage: SEARCH_PER_PAGE
|
||||||
|
}),
|
||||||
|
enabled: shouldSearch
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...orgQueries.userDevices({
|
||||||
|
orgId: orgId ?? "",
|
||||||
|
query: trimmedQuery,
|
||||||
|
perPage: SEARCH_PER_PAGE
|
||||||
|
}),
|
||||||
|
enabled: shouldSearch
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const sites = useMemo((): SiteSearchResult[] => {
|
||||||
|
if (!orgId || !sitesQuery.data) return [];
|
||||||
|
return sitesQuery.data.map((site) => ({
|
||||||
|
id: `site-${site.siteId}`,
|
||||||
|
name: site.name,
|
||||||
|
href: `/${orgId}/settings/sites/${site.niceId}`
|
||||||
|
}));
|
||||||
|
}, [orgId, sitesQuery.data]);
|
||||||
|
|
||||||
|
const publicResources = useMemo((): ResourceSearchResult[] => {
|
||||||
|
if (!orgId || !proxyResourcesQuery.data) return [];
|
||||||
|
return proxyResourcesQuery.data.map((resource) => ({
|
||||||
|
id: `resource-${resource.resourceId}`,
|
||||||
|
name: resource.name,
|
||||||
|
href: `/${orgId}/settings/resources/proxy/${resource.niceId}`
|
||||||
|
}));
|
||||||
|
}, [orgId, proxyResourcesQuery.data]);
|
||||||
|
|
||||||
|
const privateResources = useMemo((): ResourceSearchResult[] => {
|
||||||
|
if (!orgId || !privateResourcesQuery.data) return [];
|
||||||
|
return privateResourcesQuery.data.map((resource) => ({
|
||||||
|
id: `site-resource-${resource.siteResourceId}`,
|
||||||
|
name: resource.name,
|
||||||
|
href: `/${orgId}/settings/resources/private?query=${resource.name}`
|
||||||
|
}));
|
||||||
|
}, [orgId, privateResourcesQuery.data]);
|
||||||
|
|
||||||
|
const users = useMemo((): UserSearchResult[] => {
|
||||||
|
if (!orgId || !usersQuery.data) return [];
|
||||||
|
return usersQuery.data.map((user) => ({
|
||||||
|
id: `user-${user.id}`,
|
||||||
|
name: user.name ?? user.email ?? user.username ?? "",
|
||||||
|
email: user.email ?? user.username ?? "",
|
||||||
|
href: `/${orgId}/settings/access/users/${user.id}`
|
||||||
|
}));
|
||||||
|
}, [orgId, usersQuery.data]);
|
||||||
|
|
||||||
|
const machineClients = useMemo((): ClientSearchResult[] => {
|
||||||
|
if (!orgId || !clientsQuery.data) return [];
|
||||||
|
return clientsQuery.data
|
||||||
|
.filter((client) => !client.userId)
|
||||||
|
.map((client) => ({
|
||||||
|
id: `client-${client.clientId}`,
|
||||||
|
name: client.name,
|
||||||
|
href: `/${orgId}/settings/clients/machine/${client.niceId}`
|
||||||
|
}));
|
||||||
|
}, [orgId, clientsQuery.data]);
|
||||||
|
|
||||||
|
const userDevices = useMemo((): ClientSearchResult[] => {
|
||||||
|
if (!orgId || !userDevicesQuery.data) return [];
|
||||||
|
return userDevicesQuery.data
|
||||||
|
.filter((client) => !client.userId)
|
||||||
|
.map((client) => ({
|
||||||
|
id: `client-${client.clientId}`,
|
||||||
|
name: client.name,
|
||||||
|
href: `/${orgId}/settings/clients/user/${client.niceId}`
|
||||||
|
}));
|
||||||
|
}, [orgId, userDevicesQuery.data]);
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
shouldSearch &&
|
||||||
|
(sitesQuery.isFetching ||
|
||||||
|
proxyResourcesQuery.isFetching ||
|
||||||
|
privateResourcesQuery.isFetching ||
|
||||||
|
usersQuery.isFetching ||
|
||||||
|
userDevicesQuery.isFetching ||
|
||||||
|
clientsQuery.isFetching);
|
||||||
|
|
||||||
|
const hasResults =
|
||||||
|
sites.length > 0 ||
|
||||||
|
publicResources.length > 0 ||
|
||||||
|
users.length > 0 ||
|
||||||
|
privateResources.length > 0 ||
|
||||||
|
userDevices.length > 0 ||
|
||||||
|
machineClients.length > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
debouncedQuery: trimmedQuery,
|
||||||
|
shouldSearch,
|
||||||
|
sites,
|
||||||
|
publicResources,
|
||||||
|
privateResources,
|
||||||
|
users,
|
||||||
|
machineClients,
|
||||||
|
userDevices,
|
||||||
|
isLoading,
|
||||||
|
hasResults
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ export function ResourceSelector({
|
|||||||
const [debouncedSearchQuery] = useDebounce(resourceSearchQuery, 150);
|
const [debouncedSearchQuery] = useDebounce(resourceSearchQuery, 150);
|
||||||
|
|
||||||
const { data: resources = [] } = useQuery(
|
const { data: resources = [] } = useQuery(
|
||||||
orgQueries.resources({
|
orgQueries.proxyResources({
|
||||||
orgId: orgId,
|
orgId: orgId,
|
||||||
query: debouncedSearchQuery,
|
query: debouncedSearchQuery,
|
||||||
perPage: 10
|
perPage: 10
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Command as CommandPrimitive } from "cmdk";
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
import { SearchIcon } from "lucide-react";
|
import { LoaderIcon, SearchIcon } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -41,6 +41,7 @@ function CommandDialog({
|
|||||||
description?: string;
|
description?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
|
commandProps?: React.ComponentProps<typeof Command>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
@@ -48,8 +49,16 @@ function CommandDialog({
|
|||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogContent className={cn("overflow-hidden p-0", className)}>
|
<DialogContent
|
||||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
className={cn(
|
||||||
|
"overflow-hidden p-0 place-items-start",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Command
|
||||||
|
{...props.commandProps}
|
||||||
|
className="transition duration-150 mt-0 [&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -59,14 +68,21 @@ function CommandDialog({
|
|||||||
|
|
||||||
function CommandInput({
|
function CommandInput({
|
||||||
className,
|
className,
|
||||||
|
isLoading,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
}: React.ComponentProps<typeof CommandPrimitive.Input> & {
|
||||||
|
isLoading?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="command-input-wrapper"
|
data-slot="command-input-wrapper"
|
||||||
className="flex h-9 items-center gap-2 border-b px-3"
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
>
|
>
|
||||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
{isLoading ? (
|
||||||
|
<LoaderIcon className="animate-spin size-4 shrink-0 opacity-50" />
|
||||||
|
) : (
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
)}
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
data-slot="command-input"
|
data-slot="command-input"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -158,10 +174,11 @@ function CommandShortcut({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span">) {
|
}: React.ComponentProps<"span">) {
|
||||||
return (
|
return (
|
||||||
<span
|
<kbd
|
||||||
data-slot="command-shortcut"
|
data-slot="command-shortcut"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
"px-1 py-0.5 rounded-sm border-border bg-muted border",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type {
|
||||||
|
SidebarNavItem,
|
||||||
|
SidebarNavSection
|
||||||
|
} from "@app/components/SidebarNav";
|
||||||
|
|
||||||
|
export type FlatNavItem = {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
sectionHeading: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function flattenItems(
|
||||||
|
items: SidebarNavItem[],
|
||||||
|
sectionHeading: string,
|
||||||
|
result: FlatNavItem[]
|
||||||
|
) {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.href) {
|
||||||
|
result.push({
|
||||||
|
title: item.title,
|
||||||
|
href: item.href,
|
||||||
|
icon: item.icon,
|
||||||
|
sectionHeading
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (item.items?.length) {
|
||||||
|
flattenItems(item.items, sectionHeading, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenNavSections(
|
||||||
|
sections: SidebarNavSection[]
|
||||||
|
): FlatNavItem[] {
|
||||||
|
const result: FlatNavItem[] = [];
|
||||||
|
for (const section of sections) {
|
||||||
|
flattenItems(section.items, section.heading, result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export type NavHrefParams = {
|
||||||
|
orgId?: string;
|
||||||
|
niceId?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
userId?: string;
|
||||||
|
apiKeyId?: string;
|
||||||
|
clientId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hydrateNavHref(
|
||||||
|
val: string | undefined,
|
||||||
|
params: NavHrefParams
|
||||||
|
): string | undefined {
|
||||||
|
if (!val) return undefined;
|
||||||
|
return val
|
||||||
|
.replace("{orgId}", params.orgId ?? "")
|
||||||
|
.replace("{niceId}", params.niceId ?? "")
|
||||||
|
.replace("{resourceId}", params.resourceId ?? "")
|
||||||
|
.replace("{userId}", params.userId ?? "")
|
||||||
|
.replace("{apiKeyId}", params.apiKeyId ?? "")
|
||||||
|
.replace("{clientId}", params.clientId ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navHrefParamsFromRoute(
|
||||||
|
params: Record<string, string | string[] | undefined>
|
||||||
|
): NavHrefParams {
|
||||||
|
return {
|
||||||
|
orgId: params.orgId as string | undefined,
|
||||||
|
niceId: params.niceId as string | undefined,
|
||||||
|
resourceId: params.resourceId as string | undefined,
|
||||||
|
userId: params.userId as string | undefined,
|
||||||
|
apiKeyId: params.apiKeyId as string | undefined,
|
||||||
|
clientId: params.clientId as string | undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
+78
-4
@@ -7,7 +7,10 @@ import type {
|
|||||||
QueryConnectionAuditLogResponse,
|
QueryConnectionAuditLogResponse,
|
||||||
QueryRequestAuditLogResponse
|
QueryRequestAuditLogResponse
|
||||||
} from "@server/routers/auditLogs/types";
|
} from "@server/routers/auditLogs/types";
|
||||||
import type { ListClientsResponse } from "@server/routers/client";
|
import type {
|
||||||
|
ListClientsResponse,
|
||||||
|
ListUserDevicesResponse
|
||||||
|
} from "@server/routers/client";
|
||||||
import type {
|
import type {
|
||||||
GetDNSRecordsResponse,
|
GetDNSRecordsResponse,
|
||||||
ListDomainsResponse
|
ListDomainsResponse
|
||||||
@@ -25,6 +28,7 @@ import type {
|
|||||||
import type { ListRolesResponse } from "@server/routers/role";
|
import type { ListRolesResponse } from "@server/routers/role";
|
||||||
import type { ListSitesResponse } from "@server/routers/site";
|
import type { ListSitesResponse } from "@server/routers/site";
|
||||||
import type {
|
import type {
|
||||||
|
ListAllSiteResourcesByOrgResponse,
|
||||||
ListSiteResourceClientsResponse,
|
ListSiteResourceClientsResponse,
|
||||||
ListSiteResourceRolesResponse,
|
ListSiteResourceRolesResponse,
|
||||||
ListSiteResourceUsersResponse
|
ListSiteResourceUsersResponse
|
||||||
@@ -38,7 +42,7 @@ import {
|
|||||||
queryOptions
|
queryOptions
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import z from "zod";
|
import z, { meta } from "zod";
|
||||||
import { remote } from "./api";
|
import { remote } from "./api";
|
||||||
import { durationToMs } from "./durationToMs";
|
import { durationToMs } from "./durationToMs";
|
||||||
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||||
@@ -185,6 +189,38 @@ export const orgQueries = {
|
|||||||
return res.data.data.clients;
|
return res.data.data.clients;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
userDevices: ({
|
||||||
|
orgId,
|
||||||
|
query,
|
||||||
|
perPage = 10_000
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
query?: string;
|
||||||
|
perPage?: number;
|
||||||
|
}) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: [
|
||||||
|
"ORG",
|
||||||
|
orgId,
|
||||||
|
"USER_DEVICES",
|
||||||
|
{ query, perPage }
|
||||||
|
] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const sp = new URLSearchParams({
|
||||||
|
pageSize: perPage.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query?.trim()) {
|
||||||
|
sp.set("query", query);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<ListUserDevicesResponse>
|
||||||
|
>(`/org/${orgId}/user-devices?${sp.toString()}`, { signal });
|
||||||
|
|
||||||
|
return res.data.data.devices;
|
||||||
|
}
|
||||||
|
}),
|
||||||
users: ({
|
users: ({
|
||||||
orgId,
|
orgId,
|
||||||
query,
|
query,
|
||||||
@@ -329,7 +365,7 @@ export const orgQueries = {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
resources: ({
|
proxyResources: ({
|
||||||
orgId,
|
orgId,
|
||||||
query,
|
query,
|
||||||
perPage = 10_000
|
perPage = 10_000
|
||||||
@@ -339,7 +375,12 @@ export const orgQueries = {
|
|||||||
perPage?: number;
|
perPage?: number;
|
||||||
}) =>
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["ORG", orgId, "RESOURCES", { query, perPage }] as const,
|
queryKey: [
|
||||||
|
"ORG",
|
||||||
|
orgId,
|
||||||
|
"PROXY_RESOURCES",
|
||||||
|
{ query, perPage }
|
||||||
|
] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
const sp = new URLSearchParams({
|
const sp = new URLSearchParams({
|
||||||
pageSize: perPage.toString()
|
pageSize: perPage.toString()
|
||||||
@@ -357,6 +398,39 @@ export const orgQueries = {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
privateResources: ({
|
||||||
|
orgId,
|
||||||
|
query,
|
||||||
|
perPage = 10_000
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
query?: string;
|
||||||
|
perPage?: number;
|
||||||
|
}) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: [
|
||||||
|
"ORG",
|
||||||
|
orgId,
|
||||||
|
"PRIVATE_RESOURCES",
|
||||||
|
{ query, perPage }
|
||||||
|
] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const sp = new URLSearchParams({
|
||||||
|
pageSize: perPage.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query?.trim()) {
|
||||||
|
sp.set("query", query);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
||||||
|
>(`/org/${orgId}/site-resources?${sp.toString()}`, { signal });
|
||||||
|
|
||||||
|
return res.data.data.siteResources;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
healthChecks: ({
|
healthChecks: ({
|
||||||
orgId,
|
orgId,
|
||||||
perPage = 10_000
|
perPage = 10_000
|
||||||
|
|||||||
Reference in New Issue
Block a user