Merge branch 'alerting-rules' into trial

This commit is contained in:
Owen
2026-04-21 14:57:25 -07:00
220 changed files with 4948 additions and 1900 deletions

View File

@@ -12,6 +12,11 @@ import type { ListRolesResponse } from "@server/routers/role";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Approvals"
};
export interface ApprovalFeedPageProps {
params: Promise<{ orgId: string }>;

View File

@@ -7,6 +7,11 @@ import { getTranslations } from "next-intl/server";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import { build } from "@server/build";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Billing"
};
type BillingSettingsProps = {
children: React.ReactNode;

View File

@@ -97,7 +97,8 @@ export default function GeneralPage() {
emailPath: z.string().nullable().optional(),
namePath: z.string().nullable().optional(),
scopes: z.string().min(1, { message: t("idpScopeRequired") }),
autoProvision: z.boolean().default(false)
autoProvision: z.boolean().default(false),
orgMapping: z.string().optional()
});
// Google form schema (simplified)
@@ -109,7 +110,8 @@ export default function GeneralPage() {
.min(1, { message: t("idpClientSecretRequired") }),
roleMapping: z.string().nullable().optional(),
roleId: z.number().nullable().optional(),
autoProvision: z.boolean().default(false)
autoProvision: z.boolean().default(false),
orgMapping: z.string().optional()
});
// Azure form schema (simplified with tenant ID)
@@ -122,7 +124,8 @@ export default function GeneralPage() {
tenantId: z.string().min(1, { message: t("idpTenantIdRequired") }),
roleMapping: z.string().nullable().optional(),
roleId: z.number().nullable().optional(),
autoProvision: z.boolean().default(false)
autoProvision: z.boolean().default(false),
orgMapping: z.string().optional()
});
type OidcFormValues = z.infer<typeof OidcFormSchema>;
@@ -160,7 +163,8 @@ export default function GeneralPage() {
autoProvision: true,
roleMapping: null,
roleId: null,
tenantId: ""
tenantId: "",
orgMapping: ""
}
});
@@ -227,7 +231,8 @@ export default function GeneralPage() {
clientSecret: data.idpOidcConfig.clientSecret,
autoProvision: data.idp.autoProvision,
roleMapping: roleMapping || null,
roleId: null
roleId: null,
orgMapping: data.idpOrg?.orgMapping ?? ""
};
// Add variant-specific fields
@@ -344,12 +349,14 @@ export default function GeneralPage() {
}
// Build payload based on variant
const orgMappingTrimmed = data.orgMapping?.trim() ?? "";
let payload: any = {
name: data.name,
clientId: data.clientId,
clientSecret: data.clientSecret,
autoProvision: data.autoProvision,
roleMapping: roleMappingExpression
roleMapping: roleMappingExpression,
orgMapping: orgMappingTrimmed === "" ? null : orgMappingTrimmed
};
// Add variant-specific fields
@@ -532,6 +539,10 @@ export default function GeneralPage() {
}
rawExpression={rawRoleExpression}
onRawExpressionChange={setRawRoleExpression}
orgMappingField={{
control: form.control,
name: "orgMapping"
}}
/>
</form>
</Form>

View File

@@ -6,6 +6,11 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Identity Provider"
};
interface SettingsLayoutProps {
children: React.ReactNode;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Identity Provider"
};
export default async function IdpPage(props: {
params: Promise<{ orgId: string; idpId: string }>;
}) {

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Create Identity Provider"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -91,7 +91,8 @@ export default function Page() {
tenantId: z.string().optional(),
autoProvision: z.boolean().default(false),
roleMapping: z.string().nullable().optional(),
roleId: z.number().nullable().optional()
roleId: z.number().nullable().optional(),
orgMapping: z.string().optional()
});
type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>;
@@ -112,7 +113,8 @@ export default function Page() {
tenantId: "",
autoProvision: false,
roleMapping: null,
roleId: null
roleId: null,
orgMapping: ""
}
});
@@ -177,7 +179,7 @@ export default function Page() {
return;
}
const payload = {
const payload: Record<string, unknown> = {
name: data.name,
clientId: data.clientId,
clientSecret: data.clientSecret,
@@ -191,6 +193,10 @@ export default function Page() {
scopes: data.scopes,
variant: data.type
};
const trimmedOrgMapping = data.orgMapping?.trim();
if (trimmedOrgMapping) {
payload.orgMapping = trimmedOrgMapping;
}
// Use the appropriate endpoint based on provider type
const endpoint = "oidc";
@@ -336,6 +342,10 @@ export default function Page() {
}
rawExpression={rawRoleExpression}
onRawExpressionChange={setRawRoleExpression}
orgMappingField={{
control: form.control,
name: "orgMapping"
}}
/>
</form>
</Form>

View File

@@ -7,6 +7,11 @@ import { getTranslations } from "next-intl/server";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { IdpGlobalModeBanner } from "@app/components/IdpGlobalModeBanner";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Identity Providers"
};
type OrgIdpPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -3,6 +3,11 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types";
import { AxiosResponse } from "axios";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Enterprise Licenses"
};
type Props = {
params: Promise<{ orgId: string }>;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Remote Exit Node"
};
export default async function RemoteExitNodePage(props: {
params: Promise<{ orgId: string; remoteExitNodeId: string }>;
}) {

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Create Remote Exit Node"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -7,6 +7,11 @@ import ExitNodesTable, {
} from "@app/components/ExitNodesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Remote Exit Nodes"
};
type RemoteExitNodesPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -11,6 +11,11 @@ import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Invitations"
};
type InvitationsPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Access"
};
type AccessPageProps = {
params: Promise<{ orgId: string }>;
};

View File

@@ -8,6 +8,11 @@ import RolesTable, { type RoleRow } from "@app/components/RolesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Roles"
};
type RolesPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -8,6 +8,11 @@ import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { cache } from "react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "User"
};
interface UserLayoutProps {
children: React.ReactNode;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "User"
};
export default async function UserPage(props: {
params: Promise<{ orgId: string; userId: string }>;
}) {

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Create User"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -46,7 +46,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
import { ListIdpsResponse } from "@server/routers/idp";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import Image from "next/image";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import OrgRolesTagField from "@app/components/OrgRolesTagField";
@@ -152,31 +152,8 @@ export default function Page() {
const getIdpIcon = (variant: string | null) => {
if (!variant) return null;
switch (variant.toLowerCase()) {
case "google":
return (
<Image
src="/idp/google.png"
alt={t("idpGoogleAlt")}
width={24}
height={24}
className="rounded"
/>
);
case "azure":
return (
<Image
src="/idp/azure.png"
alt={t("idpAzureAlt")}
width={24}
height={24}
className="rounded"
/>
);
default:
return null;
}
const type = variant.toLowerCase();
return <IdpTypeIcon type={type} size={24} />;
};
const validFor = [
@@ -490,7 +467,7 @@ export default function Page() {
<div>
<SettingsContainer>
{!inviteLink ? (
{!inviteLink && userOptions.length > 1 ? (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -513,7 +490,7 @@ export default function Page() {
genericOidcForm.reset();
}
}}
cols={2}
cols={3}
/>
</SettingsSectionBody>
</SettingsSection>

View File

@@ -11,6 +11,11 @@ import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Users"
};
type UsersPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -49,7 +49,6 @@ export default function EditAlertRulePage() {
});
setFormValues(null);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [orgId, alertRuleId]);
useEffect(() => {

View File

@@ -7,6 +7,11 @@ import { GetApiKeyResponse } from "@server/routers/apiKeys";
import ApiKeyProvider from "@app/providers/ApiKeyProvider";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "API Key"
};
interface SettingsLayoutProps {
children: React.ReactNode;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "API Key"
};
export default async function ApiKeysPage(props: {
params: Promise<{ orgId: string; apiKeyId: string }>;
}) {

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Create API Key"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -2,11 +2,14 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import OrgApiKeysTable, {
OrgApiKeyRow
} from "@app/components/OrgApiKeysTable";
import OrgApiKeysTable, { OrgApiKeyRow } from "@app/components/OrgApiKeysTable";
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "API Keys"
};
type ApiKeyPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -17,7 +17,7 @@ type BluePrintsPageProps = {
};
export const metadata: Metadata = {
title: "Blueprint Detail"
title: "Edit Blueprint"
};
export default async function BluePrintDetailPage(props: BluePrintsPageProps) {

View File

@@ -12,7 +12,7 @@ export interface CreateBlueprintPageProps {
}
export const metadata: Metadata = {
title: "Create blueprint"
title: "Create Blueprint"
};
export default async function CreateBlueprintPage(

View File

@@ -8,6 +8,11 @@ import { GetClientResponse } from "@server/routers/client";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Machine Client"
};
type SettingsLayoutProps = {
children: React.ReactNode;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Machine Client"
};
export default async function ClientPage(props: {
params: Promise<{ orgId: string; niceId: number | string }>;
}) {

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Create Machine Client"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -8,6 +8,11 @@ import { ListClientsResponse } from "@server/routers/client";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import type { Pagination } from "@server/types/Pagination";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Machine Clients"
};
type ClientsPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Clients"
};
type ClientsPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;

View File

@@ -8,6 +8,11 @@ import { GetClientResponse } from "@server/routers/client";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "User Device"
};
type SettingsLayoutProps = {
children: React.ReactNode;

View File

@@ -1,10 +1,13 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "User Device"
};
export default async function ClientPage(props: {
params: Promise<{ orgId: string; niceId: number | string }>;
}) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/clients/user/${params.niceId}/general`
);
redirect(`/${params.orgId}/settings/clients/user/${params.niceId}/general`);
}

View File

@@ -7,6 +7,11 @@ import { type ListUserDevicesResponse } from "@server/routers/client";
import type { Pagination } from "@server/types/Pagination";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "User Devices"
};
type ClientsPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -1,16 +1,13 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import DomainInfoCard from "@app/components/DomainInfoCard";
import RestartDomainButton from "@app/components/RestartDomainButton";
import DomainPageClient from "@app/components/DomainPageClient";
import { GetDomainResponse } from "@server/routers/domain/getDomain";
import { pullEnv } from "@app/lib/pullEnv";
import { getTranslations } from "next-intl/server";
import RefreshButton from "@app/components/RefreshButton";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { GetDNSRecordsResponse } from "@server/routers/domain";
import DNSRecordsTable from "@app/components/DNSRecordTable";
import DomainCertForm from "@app/components/DomainCertForm";
import { build } from "@server/build";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Domain"
};
interface DomainSettingsPageProps {
params: Promise<{ domainId: string; orgId: string }>;
@@ -20,8 +17,6 @@ export default async function DomainSettingsPage({
params
}: DomainSettingsPageProps) {
const { domainId, orgId } = await params;
const t = await getTranslations();
const env = pullEnv();
let domain: GetDomainResponse | null = null;
try {
@@ -34,57 +29,27 @@ export default async function DomainSettingsPage({
return null;
}
let dnsRecords;
let dnsRecords: GetDNSRecordsResponse | null = null;
try {
const response = await internal.get(
`/org/${orgId}/domain/${domainId}/dns-records`,
await authCookieHeader()
);
dnsRecords = response.data.data;
} catch (error) {
} catch {
return null;
}
if (!domain) {
if (!domain || !dnsRecords) {
return null;
}
return (
<>
<div className="flex justify-between">
<SettingsSectionTitle
title={domain.baseDomain}
description={t("domainSettingDescription")}
/>
{env.flags.usePangolinDns && domain.failed ? (
<RestartDomainButton
orgId={orgId}
domainId={domain.domainId}
/>
) : (
<RefreshButton />
)}
</div>
<div className="space-y-6">
{build != "oss" && env.flags.usePangolinDns ? (
<DomainInfoCard
failed={domain.failed}
verified={domain.verified}
type={domain.type}
errorMessage={domain.errorMessage}
/>
) : null}
<DNSRecordsTable records={dnsRecords} type={domain.type} />
{domain.type == "wildcard" && !domain.configManaged && (
<DomainCertForm
orgId={orgId}
domainId={domain.domainId}
domain={domain}
/>
)}
</div>
</>
<DomainPageClient
initialDomain={domain}
initialDnsRecords={dnsRecords}
orgId={orgId}
domainId={domainId}
/>
);
}
}

View File

@@ -11,6 +11,11 @@ import OrgProvider from "@app/providers/OrgProvider";
import { ListDomainsResponse } from "@server/routers/domain";
import { toUnicode } from "punycode";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Domains"
};
type Props = {
params: Promise<{ orgId: string }>;

View File

@@ -11,6 +11,7 @@ import {
GetLoginPageResponse
} from "@server/routers/loginPage/types";
import { AxiosResponse } from "axios";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export interface AuthPageProps {

View File

@@ -11,6 +11,11 @@ import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
import { build } from "@server/build";
import { pullEnv } from "@app/lib/pullEnv";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Organization"
};
type GeneralSettingsProps = {
children: React.ReactNode;

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Access Logs"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Action Logs"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -2,6 +2,11 @@ import { LogAnalyticsData } from "@app/components/LogAnalyticsData";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import { Suspense } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Log Analytics"
};
export interface AnalyticsPageProps {
params: Promise<{ orgId: string }>;

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Connection Logs"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -1,3 +1,9 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Logs"
};
export default function GeneralPage() {
return null;
}

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Request Logs"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Streaming Logs"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Settings"
};
type OrgPageProps = {
params: Promise<{ orgId: string }>;
};

View File

@@ -12,6 +12,11 @@ import DismissableBanner from "@app/components/DismissableBanner";
import Link from "next/link";
import { Button } from "@app/components/ui/button";
import { ArrowRight, Plug } from "lucide-react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Provisioning Keys"
};
type ProvisioningKeysPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Provisioning"
};
type ProvisioningPageProps = {
params: Promise<{ orgId: string }>;
};
@@ -7,4 +12,4 @@ type ProvisioningPageProps = {
export default async function ProvisioningPage(props: ProvisioningPageProps) {
const params = await props.params;
redirect(`/${params.orgId}/settings/provisioning/keys`);
}
}

View File

@@ -11,6 +11,11 @@ import { Button } from "@app/components/ui/button";
import { ArrowRight, Plug } from "lucide-react";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Pending Sites"
};
type PendingSitesPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -10,8 +10,13 @@ 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 type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Private Resources"
};
export interface ClientResourcesPageProps {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Public Resources"
};
export interface ResourcesPageProps {
params: Promise<{ orgId: string }>;
}

View File

@@ -62,7 +62,7 @@ import { GetResourceResponse } from "@server/routers/resource/getResource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import UptimeBar from "@app/components/UptimeBar";
import UptimeAlertSection from "@app/components/UptimeAlertSection";
type MaintenanceSectionFormProps = {
resource: GetResourceResponse;
@@ -579,19 +579,13 @@ export default function GeneralForm() {
return (
<>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Uptime</SettingsSectionTitle>
<SettingsSectionDescription>
Site availability over the last 90 days.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{resource?.resourceId && (
<UptimeBar resourceId={resource.resourceId} days={90} />
)}
</SettingsSectionBody>
</SettingsSection>
{resource?.resourceId && resource?.orgId && (
<UptimeAlertSection
orgId={resource.orgId}
resourceId={resource.resourceId}
startingName={resource.name}
/>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>

View File

@@ -14,6 +14,11 @@ import OrgProvider from "@app/providers/OrgProvider";
import { cache } from "react";
import ResourceInfoBox from "@app/components/ResourceInfoBox";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Public Resource"
};
export const dynamic = "force-dynamic";

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Public Resource"
};
export default async function ResourcePage(props: {
params: Promise<{ niceId: string; orgId: string }>;
}) {

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Create Public Resource"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -776,9 +776,15 @@ export default function Page() {
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
updateTarget(
row.original.targetId,
config.path === null &&
config.pathMatchType === null
? {
...config,
rewritePath: null,
rewritePathType: null
}
: config
)
}
@@ -804,9 +810,15 @@ export default function Page() {
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
updateTarget(
row.original.targetId,
config.path === null &&
config.pathMatchType === null
? {
...config,
rewritePath: null,
rewritePathType: null
}
: config
)
}
@@ -1061,7 +1073,7 @@ export default function Page() {
: null
);
}}
cols={2}
cols={3}
/>
</>
)}
@@ -1118,28 +1130,30 @@ export default function Page() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<DomainPicker
orgId={orgId as string}
warnOnProvidedDomain={
remoteExitNodes.length >= 1
}
onDomainChange={(res) => {
if (!res) return;
<SettingsSectionForm>
<DomainPicker
orgId={orgId as string}
warnOnProvidedDomain={
remoteExitNodes.length >= 1
}
onDomainChange={(res) => {
if (!res) return;
httpForm.setValue(
"subdomain",
res.subdomain
);
httpForm.setValue(
"domainId",
res.domainId
);
console.log(
"Domain changed:",
res
);
}}
/>
httpForm.setValue(
"subdomain",
res.subdomain
);
httpForm.setValue(
"domainId",
res.domainId
);
console.log(
"Domain changed:",
res
);
}}
/>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
) : (
@@ -1155,98 +1169,101 @@ export default function Page() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...tcpUdpForm}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="tcp-udp-settings-form"
>
<Controller
control={tcpUdpForm.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("protocol")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
{...field}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"protocolSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
TCP
</SelectItem>
<SelectItem value="udp">
UDP
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<SettingsSectionForm>
<Form {...tcpUdpForm}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="tcp-udp-settings-form"
>
<Controller
control={
tcpUdpForm.control
}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"protocol"
)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
{...field}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"protocolSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
TCP
</SelectItem>
<SelectItem value="udp">
UDP
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={tcpUdpForm.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
) =>
field.onChange(
<FormField
control={
tcpUdpForm.control
}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourcePortNumberDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}

View File

@@ -13,6 +13,11 @@ import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { toUnicode } from "punycode";
import { cache } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Public Resources"
};
export interface ProxyResourcesPageProps {
params: Promise<{ orgId: string }>;

View File

@@ -7,10 +7,13 @@ import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import { ListAccessTokensResponse } from "@server/routers/accessToken";
import ShareLinksTable, {
ShareLinkRow
} from "@app/components/ShareLinksTable";
import ShareLinksTable, { ShareLinkRow } from "@app/components/ShareLinksTable";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Shareable Links"
};
type ShareLinksPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -1,6 +1,6 @@
"use client";
import UptimeBar from "@app/components/UptimeBar";
import UptimeAlertSection from "@app/components/UptimeAlertSection";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
@@ -113,19 +113,13 @@ export default function GeneralPage() {
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Uptime</SettingsSectionTitle>
<SettingsSectionDescription>
Site availability over the last 90 days.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{site?.siteId && (
<UptimeBar siteId={site.siteId} days={90} />
)}
</SettingsSectionBody>
</SettingsSection>
{site?.siteId && site?.orgId && (
<UptimeAlertSection
orgId={site.orgId}
siteId={site.siteId}
startingName={site.name}
/>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>

View File

@@ -8,7 +8,11 @@ import { HorizontalTabs } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SiteInfoCard from "@app/components/SiteInfoCard";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Site"
};
interface SettingsLayoutProps {
children: React.ReactNode;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Site"
};
export default async function SitePage(props: {
params: Promise<{ orgId: string; niceId: string }>;
}) {

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Create Site"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -5,8 +5,13 @@ import { AxiosResponse } from "axios";
import SitesTable, { SiteRow } from "@app/components/SitesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SitesBanner from "@app/components/SitesBanner";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Sites"
};
type SitesPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;

View File

@@ -20,7 +20,6 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -63,7 +62,7 @@ import {
SettingsSectionForm
} from "@app/components/Settings";
import { useTranslations } from "next-intl";
import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields";
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
import {
compileRoleMappingExpression,
createMappingBuilderRule,
@@ -499,9 +498,17 @@ export default function PoliciesPage() {
id="policy-default-mappings-form"
className="space-y-6"
>
<RoleMappingConfigFields
fieldIdPrefix="admin-idp-default-role"
showFreeformRoleNamesHint={true}
<AutoProvisionConfigWidget
showAutoProvisionSwitch={false}
autoProvision={true}
onAutoProvisionChange={() => {}}
orgMappingField={{
control: defaultMappingsForm.control,
name: "defaultOrgMapping",
labelKey: "defaultMappingsOrg"
}}
roleMappingFieldIdPrefix="admin-idp-default-role"
showFreeformRoleNamesHint
roleMappingMode={defaultRoleMappingMode}
onRoleMappingModeChange={
setDefaultRoleMappingMode
@@ -528,27 +535,6 @@ export default function PoliciesPage() {
setDefaultRawRoleExpression
}
/>
<FormField
control={defaultMappingsForm.control}
name="defaultOrgMapping"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("defaultMappingsOrg")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"defaultMappingsOrgDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<SettingsSectionFooter>
@@ -687,9 +673,15 @@ export default function PoliciesPage() {
)}
/>
<RoleMappingConfigFields
fieldIdPrefix="admin-idp-policy-role"
showFreeformRoleNamesHint={false}
<AutoProvisionConfigWidget
showAutoProvisionSwitch={false}
autoProvision={true}
onAutoProvisionChange={() => {}}
orgMappingField={{
control: form.control,
name: "orgMapping"
}}
roleMappingFieldIdPrefix="admin-idp-policy-role"
roleMappingMode={policyRoleMappingMode}
onRoleMappingModeChange={
setPolicyRoleMappingMode
@@ -716,27 +708,6 @@ export default function PoliciesPage() {
setPolicyRawRoleExpression
}
/>
<FormField
control={form.control}
name="orgMapping"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("orgMappingPathOptional")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"defaultMappingsOrgDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>

View File

@@ -24,7 +24,6 @@ import {
import HeaderTitle from "@app/components/SettingsSectionTitle";
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
@@ -34,7 +33,6 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
import { applyOidcIdpProviderType } from "@app/lib/idp/oidcIdpProviderDefaults";
import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { InfoIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -220,23 +218,6 @@ export default function Page() {
)}
/>
<div className="flex items-start mb-0">
<SwitchInput
id="auto-provision-toggle"
label={t(
"idpAutoProvisionUsers"
)}
defaultChecked={form.getValues(
"autoProvision"
)}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
/>
</div>
</form>
</Form>
</SettingsSectionForm>
@@ -244,6 +225,32 @@ export default function Page() {
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpAutoProvisionUsers")}
</SettingsSectionTitle>
<SettingsSectionDescription>
<IdpAutoProvisionUsersDescription />
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="space-y-2">
<SwitchInput
id="auto-provision-toggle"
label={t("idpAutoProvisionUsers")}
defaultChecked={form.getValues("autoProvision")}
onCheckedChange={(checked) => {
form.setValue("autoProvision", checked);
}}
/>
<p className="text-sm text-muted-foreground">
{t("idpAutoProvisionConfigureAfterCreate")}
</p>
</div>
</SettingsSectionBody>
</SettingsSection>
<fieldset
disabled={templatesLocked}
className="min-w-0 border-0 p-0 m-0 disabled:pointer-events-none disabled:opacity-60"

View File

@@ -352,20 +352,6 @@ export default function LicensePage() {
}
description={t("licenseBannerDescription")}
>
<Link
href={ENTERPRISE_PRICING_URL}
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="default"
size="sm"
className="gap-2"
>
{t("licenseBannerGetLicense")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
<Link
href={ENTERPRISE_DOCS_URL}
target="_blank"
@@ -380,6 +366,20 @@ export default function LicensePage() {
<ExternalLink className="w-4 h-4" />
</Button>
</Link>
<Link
href={ENTERPRISE_PRICING_URL}
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("licenseBannerGetLicense")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
)}

View File

@@ -6,7 +6,7 @@
:root {
--radius: 0.75rem;
--background: oklch(0.985 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
@@ -22,30 +22,30 @@
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.91 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--border: oklch(0.88 0.004 286.32);
--input: oklch(0.88 0.004 286.32);
--ring: oklch(0.705 0.213 47.604);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar: #fafafa;
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.705 0.213 47.604);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent: #eaeaea;
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.213 47.604);
}
.dark {
--background: oklch(0.19 0.006 285.885);
--background: #0d0d0f;
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card: #0d0d0f;
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover: #0d0d0f;
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.6717 0.1946 41.93);
--primary-foreground: oklch(0.98 0.016 73.684);
@@ -57,7 +57,7 @@
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.5382 0.1949 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 13%);
--border: oklch(1 0 0 / 15%);
--input: oklch(1 0 0 / 18%);
--ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.488 0.243 264.376);
@@ -65,11 +65,11 @@
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar: #040404;
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent: #131317;
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.646 0.222 41.116);
@@ -110,6 +110,15 @@
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
@@ -166,7 +175,9 @@ p {
}
@keyframes dot-pulse {
0%, 80%, 100% {
0%,
80%,
100% {
opacity: 0.3;
transform: scale(0.8);
}
@@ -189,7 +200,10 @@ p {
/* Only apply custom viewport height on mobile */
@media (max-width: 767px) {
.h-screen-safe {
height: var(--vh, 100vh); /* Use CSS variable set by ViewportHeightFix on mobile */
height: var(
--vh,
100vh
); /* Use CSS variable set by ViewportHeightFix on mobile */
}
}
}

View File

@@ -23,7 +23,7 @@ import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
import { TailwindIndicator } from "@app/components/TailwindIndicator";
import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
import StoreInternalRedirect from "@app/components/StoreInternalRedirect";
import { Inter } from "next/font/google";
import { Inter, Mona_Sans } from "next/font/google";
export const metadata: Metadata = {
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
@@ -36,7 +36,11 @@ const inter = Inter({
subsets: ["latin"]
});
const fontClassName = inter.className;
const monaSans = Mona_Sans({
subsets: ["latin"]
});
const fontClassName = monaSans.className;
export default async function RootLayout({
children

View File

@@ -213,9 +213,9 @@ export const orgNavSections = (
icon: <Building2 className="size-4 flex-none" />,
items: [
{
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" />
title: "sidebarAlerting",
href: "/{orgId}/settings/alerting",
icon: <BellRing className="size-4 flex-none" />
},
{
title: "sidebarProvisioning",
@@ -228,9 +228,9 @@ export const orgNavSections = (
icon: <ReceiptText className="size-4 flex-none" />
},
{
title: "sidebarAlerting",
href: "/{orgId}/settings/alerting",
icon: <BellRing className="size-4 flex-none" />
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" />
}
]
},

View File

@@ -13,6 +13,7 @@ import {
import { Switch } from "@app/components/ui/switch";
import { toast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { orgQueries } from "@app/lib/queries";
@@ -24,9 +25,14 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { PaginationState } from "@tanstack/react-table";
import type { DataTablePaginationState } from "@app/components/ui/data-table";
import { useDebouncedCallback } from "use-debounce";
type AlertingRulesTableProps = {
orgId: string;
siteId?: number;
resourceId?: number;
};
type AlertRuleRow = {
@@ -41,6 +47,7 @@ type AlertRuleRow = {
updatedAt: number;
siteIds: number[];
healthCheckIds: number[];
resourceIds: number[];
};
function ruleHref(orgId: string, ruleId: number) {
@@ -53,10 +60,14 @@ function sourceSummary(
) {
if (
rule.eventType === "site_online" ||
rule.eventType === "site_offline"
rule.eventType === "site_offline" ||
rule.eventType === "site_toggle"
) {
return t("alertingSummarySites", { count: rule.siteIds.length });
}
if (rule.eventType.startsWith("resource_")) {
return t("alertingSummaryResources", { count: rule.resourceIds.length });
}
return t("alertingSummaryHealthChecks", {
count: rule.healthCheckIds.length
});
@@ -71,16 +82,26 @@ function triggerLabel(
return t("alertingTriggerSiteOnline");
case "site_offline":
return t("alertingTriggerSiteOffline");
case "site_toggle":
return t("alertingTriggerSiteToggle");
case "health_check_healthy":
return t("alertingTriggerHcHealthy");
case "health_check_not_healthy":
case "health_check_unhealthy":
return t("alertingTriggerHcUnhealthy");
case "health_check_toggle":
return t("alertingTriggerHcToggle");
case "resource_healthy":
return t("alertingTriggerResourceHealthy");
case "resource_unhealthy":
return t("alertingTriggerResourceUnhealthy");
case "resource_toggle":
return t("alertingTriggerResourceToggle");
default:
return rule.eventType;
}
}
export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
export default function AlertingRulesTable({ orgId, siteId, resourceId }: AlertingRulesTableProps) {
const router = useRouter();
const t = useTranslations();
const api = createApiClient(useEnvContext());
@@ -88,19 +109,52 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.alertingRules);
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [deleteOpen, setDeleteOpen] = useState(false);
const [selected, setSelected] = useState<AlertRuleRow | null>(null);
const [togglingId, setTogglingId] = useState<number | null>(null);
const page = Math.max(1, Number(searchParams.get("page") ?? 1));
const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20));
const pageIndex = page - 1;
const query = searchParams.get("query") ?? undefined;
const {
data: rows = [],
data,
isLoading,
refetch,
isRefetching
} = useQuery(orgQueries.alertRules({ orgId }));
} = useQuery(orgQueries.alertRules({ orgId, limit: pageSize, offset: pageIndex * pageSize, query, siteId, resourceId }));
const rows = data?.alertRules ?? [];
const total = data?.pagination.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const paginationState: DataTablePaginationState = { pageIndex, pageSize, pageCount };
const handlePaginationChange = (newState: PaginationState) => {
searchParams.set("page", (newState.pageIndex + 1).toString());
searchParams.set("pageSize", newState.pageSize.toString());
filter({ searchParams });
};
const handleSearchChange = useDebouncedCallback((value: string) => {
if (value) {
searchParams.set("query", value);
} else {
searchParams.delete("query");
}
searchParams.delete("page");
filter({ searchParams });
}, 300);
const invalidate = () =>
queryClient.invalidateQueries(orgQueries.alertRules({ orgId }));
queryClient.invalidateQueries({ queryKey: ["ORG", orgId, "ALERT_RULES"] });
const setEnabled = async (rule: AlertRuleRow, enabled: boolean) => {
setTogglingId(rule.alertRuleId);
@@ -268,19 +322,22 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
<DataTable
columns={columns}
data={rows}
persistPageSize="Org-alerting-rules-table"
title={t("alertingRules")}
searchPlaceholder={t("alertingSearchRules")}
searchColumn="name"
onSearch={handleSearchChange}
searchQuery={query}
manualFiltering
onAdd={() => {
router.push(`/${orgId}/settings/alerting/create`);
}}
onRefresh={() => refetch()}
isRefreshing={isRefetching || isLoading}
isRefreshing={isRefetching || isLoading || isFiltering}
addButtonText={t("alertingAddRule")}
enableColumnVisibility
stickyLeftColumn="name"
stickyRightColumn="rowActions"
pagination={paginationState}
onPaginationChange={handlePaginationChange}
/>
</>
);

View File

@@ -1,19 +1,33 @@
"use client";
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
import { FormDescription } from "@app/components/ui/form";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields";
import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping";
import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields";
import { useTranslations } from "next-intl";
import type { Control } from "react-hook-form";
type Role = {
roleId: number;
name: string;
};
export type IdpOrgMappingFieldBinding = {
control: unknown;
name: string;
labelKey?: string;
};
type AutoProvisionConfigWidgetProps = {
autoProvision: boolean;
onAutoProvisionChange: (checked: boolean) => void;
@@ -28,6 +42,11 @@ type AutoProvisionConfigWidgetProps = {
onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void;
rawExpression: string;
onRawExpressionChange: (expression: string) => void;
orgMappingField: IdpOrgMappingFieldBinding;
showAutoProvisionSwitch?: boolean;
roleMappingFieldIdPrefix?: string;
showFreeformRoleNamesHint?: boolean;
autoProvisionSwitchId?: string;
};
export default function AutoProvisionConfigWidget({
@@ -43,41 +62,95 @@ export default function AutoProvisionConfigWidget({
mappingBuilderRules,
onMappingBuilderRulesChange,
rawExpression,
onRawExpressionChange
onRawExpressionChange,
orgMappingField,
showAutoProvisionSwitch = true,
roleMappingFieldIdPrefix = "org-idp-auto-provision",
showFreeformRoleNamesHint = false,
autoProvisionSwitchId = "auto-provision-toggle"
}: AutoProvisionConfigWidgetProps) {
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const showMappingTabs = showAutoProvisionSwitch === false || autoProvision;
const orgMappingLabelKey =
orgMappingField.labelKey ?? "orgMappingPathOptional";
return (
<div className="space-y-4">
<div className="mb-4">
<SwitchInput
id="auto-provision-toggle"
label={t("idpAutoProvisionUsers")}
defaultChecked={autoProvision}
onCheckedChange={onAutoProvisionChange}
disabled={!isPaidUser(tierMatrix.autoProvisioning)}
/>
</div>
{showAutoProvisionSwitch && (
<div className="mb-4">
<SwitchInput
id={autoProvisionSwitchId}
label={t("idpAutoProvisionUsers")}
defaultChecked={autoProvision}
onCheckedChange={onAutoProvisionChange}
disabled={!isPaidUser(tierMatrix.autoProvisioning)}
/>
</div>
)}
{autoProvision && (
<RoleMappingConfigFields
fieldIdPrefix="org-idp-auto-provision"
showFreeformRoleNamesHint={false}
roleMappingMode={roleMappingMode}
onRoleMappingModeChange={onRoleMappingModeChange}
roles={roles}
fixedRoleNames={fixedRoleNames}
onFixedRoleNamesChange={onFixedRoleNamesChange}
mappingBuilderClaimPath={mappingBuilderClaimPath}
onMappingBuilderClaimPathChange={
onMappingBuilderClaimPathChange
}
mappingBuilderRules={mappingBuilderRules}
onMappingBuilderRulesChange={onMappingBuilderRulesChange}
rawExpression={rawExpression}
onRawExpressionChange={onRawExpressionChange}
/>
{showMappingTabs && (
<HorizontalTabs
clientSide
defaultTab={0}
items={[
{ title: t("roleMapping"), href: "#" },
{ title: t("orgMapping"), href: "#" }
]}
>
<div className="space-y-4 mt-4 p-1">
<RoleMappingConfigFields
fieldIdPrefix={roleMappingFieldIdPrefix}
showFreeformRoleNamesHint={
showFreeformRoleNamesHint
}
roleMappingMode={roleMappingMode}
onRoleMappingModeChange={onRoleMappingModeChange}
roles={roles}
fixedRoleNames={fixedRoleNames}
onFixedRoleNamesChange={onFixedRoleNamesChange}
mappingBuilderClaimPath={mappingBuilderClaimPath}
onMappingBuilderClaimPathChange={
onMappingBuilderClaimPathChange
}
mappingBuilderRules={mappingBuilderRules}
onMappingBuilderRulesChange={
onMappingBuilderRulesChange
}
rawExpression={rawExpression}
onRawExpressionChange={onRawExpressionChange}
/>
</div>
<div className="space-y-4 mt-4 p-1">
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("defaultMappingsOrgDescription")}
</p>
<FormField
control={
orgMappingField.control as Control<any>
}
name={orgMappingField.name}
render={({ field }) => (
<FormItem>
<FormLabel>
{t(orgMappingLabelKey)}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder="e.g., ends_with(email, '@organization.com')"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</HorizontalTabs>
)}
</div>
);

View File

@@ -0,0 +1,93 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { domainQueries } from "@app/lib/queries";
import { GetDomainResponse } from "@server/routers/domain/getDomain";
import { GetDNSRecordsResponse } from "@server/routers/domain";
import DomainInfoCard from "@app/components/DomainInfoCard";
import DNSRecordsTable from "@app/components/DNSRecordTable";
import RestartDomainButton from "@app/components/RestartDomainButton";
import RefreshButton from "@app/components/RefreshButton";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import DomainCertForm from "@app/components/DomainCertForm";
import { build } from "@server/build";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
interface DomainPageClientProps {
initialDomain: GetDomainResponse;
initialDnsRecords: GetDNSRecordsResponse;
orgId: string;
domainId: string;
}
export default function DomainPageClient({
initialDomain,
initialDnsRecords,
orgId,
domainId
}: DomainPageClientProps) {
const t = useTranslations();
const { env } = useEnvContext();
const { data: domain, refetch: refetchDomain } = useQuery({
...domainQueries.getDomain({ orgId, domainId }),
initialData: initialDomain
});
const { data: dnsRecords, refetch: refetchDnsRecords } = useQuery({
...domainQueries.getDNSRecords({ orgId, domainId }),
initialData: initialDnsRecords
});
const refetchAll = () => {
refetchDomain();
refetchDnsRecords();
};
return (
<>
<div className="flex justify-between">
<SettingsSectionTitle
title={domain.baseDomain}
description={t("domainSettingDescription")}
/>
{env.flags.usePangolinDns && domain.failed ? (
<RestartDomainButton
orgId={orgId}
domainId={domain.domainId}
onSuccess={refetchAll}
/>
) : (
<RefreshButton onRefresh={refetchAll} />
)}
</div>
<div className="space-y-6">
{build !== "oss" && env.flags.usePangolinDns ? (
<DomainInfoCard
failed={domain.failed}
verified={domain.verified}
type={domain.type}
errorMessage={domain.errorMessage}
/>
) : null}
<DNSRecordsTable
records={dnsRecords.map((r) => ({
...r,
id: String(r.id)
}))}
type={domain.type}
/>
{domain.type === "wildcard" && !domain.configManaged && (
<DomainCertForm
orgId={orgId}
domainId={domain.domainId}
domain={domain}
/>
)}
</div>
</>
);
}

View File

@@ -10,13 +10,12 @@ import {
MoreHorizontal,
RefreshCw
} from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "@app/components/ui/badge";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import CreateDomainForm from "@app/components/CreateDomainForm";
import { useToast } from "@app/hooks/useToast";
@@ -34,6 +33,10 @@ import {
TooltipTrigger
} from "./ui/tooltip";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { orgQueries } from "@app/lib/queries";
import { toUnicode } from "punycode";
import { durationToMs } from "@app/lib/durationToMs";
export type DomainRow = {
domainId: string;
@@ -59,32 +62,32 @@ export default function DomainsTable({ domains, orgId }: Props) {
const [selectedDomain, setSelectedDomain] = useState<DomainRow | null>(
null
);
const [isRefreshing, setIsRefreshing] = useState(false);
const [restartingDomains, setRestartingDomains] = useState<Set<string>>(
new Set()
);
const env = useEnvContext();
const api = createApiClient(env);
const router = useRouter();
const t = useTranslations();
const { toast } = useToast();
const { org } = useOrgContext();
const queryClient = useQueryClient();
const refreshData = async () => {
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 { data: rawDomains, isRefetching, refetch } = useQuery({
...orgQueries.domains({ orgId }),
initialData: domains as any,
refetchInterval: durationToMs(10, "seconds")
});
const tableData = useMemo(
() =>
(rawDomains ?? []).map((d) => ({
...d,
baseDomain: toUnicode(d.baseDomain),
type: d.type ?? "",
errorMessage: d.errorMessage ?? null
} as DomainRow)),
[rawDomains]
);
const deleteDomain = async (domainId: string) => {
try {
@@ -94,7 +97,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
description: t("domainDeletedDescription")
});
setIsDeleteModalOpen(false);
refreshData();
refetch();
} catch (e) {
toast({
title: t("error"),
@@ -114,7 +117,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
fallback: "Domain verification restarted successfully"
})
});
refreshData();
refetch();
} catch (e) {
toast({
title: t("error"),
@@ -361,16 +364,16 @@ export default function DomainsTable({ domains, orgId }: Props) {
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
onCreated={(domain) => {
refreshData();
refetch();
}}
/>
<DomainsDataTable
columns={columns}
data={domains}
data={tableData}
onAdd={() => setIsCreateModalOpen(true)}
onRefresh={refreshData}
isRefreshing={isRefreshing}
onRefresh={refetch}
isRefreshing={isRefetching}
/>
</>
);

View File

@@ -41,6 +41,11 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { SitesSelector } from "@app/components/site-selector";
import type { Selectedsite } from "@app/components/site-selector";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { cn } from "@app/lib/cn";
export type HealthCheckConfig = {
hcEnabled: boolean;
@@ -84,6 +89,9 @@ export type HealthCheckRow = {
resourceId: number | null;
resourceName: string | null;
resourceNiceId: string | null;
siteId: number | null;
siteName: string | null;
siteNiceId: string | null;
};
export type HealthCheckCredenzaProps =
@@ -132,6 +140,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false);
const [selectedSite, setSelectedSite] = useState<Selectedsite | null>(null);
const healthCheckSchema = z
.object({
@@ -280,8 +289,14 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
hcStatus: initialValues.hcStatus ?? null,
hcHeaders: parsedHeaders
});
if (initialValues.siteId && initialValues.siteName) {
setSelectedSite({ siteId: initialValues.siteId, name: initialValues.siteName, type: "" });
} else {
setSelectedSite(null);
}
} else {
form.reset(DEFAULT_VALUES);
setSelectedSite(null);
}
}
}, [open]);
@@ -331,6 +346,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
try {
const payload = {
name: (values as any).name,
siteId: selectedSite?.siteId,
hcEnabled: values.hcEnabled,
hcMode: values.hcMode,
hcScheme: values.hcScheme,
@@ -439,6 +455,42 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
/>
)}
{/* Site picker (submit mode only) */}
{mode === "submit" && (
<div className="mt-4">
<FormItem>
<FormLabel>{t("site")}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!selectedSite && "text-muted-foreground"
)}
>
<span className="truncate">
{selectedSite ? selectedSite.name : t("siteSelect")}
</span>
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-full min-w-64">
<SitesSelector
orgId={orgId!}
selectedSite={selectedSite}
onSelectSite={(site) => {
setSelectedSite(site);
}}
/>
</PopoverContent>
</Popover>
</FormItem>
</div>
)}
<div className="mt-5">
<HorizontalTabs
clientSide

View File

@@ -24,6 +24,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import type { DataTablePaginationState } from "@app/components/ui/data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce";
import Link from "next/link";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
@@ -72,24 +76,66 @@ export default function HealthChecksTable({
const isPaid = isPaidUser(tierMatrix.standaloneHealthChecks);
const [credenzaOpen, setCredenzaOpen] = useState(false);
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [deleteOpen, setDeleteOpen] = useState(false);
const [selected, setSelected] = useState<HealthCheckRow | null>(null);
const [togglingId, setTogglingId] = useState<number | null>(null);
const page = Math.max(1, Number(searchParams.get("page") ?? 1));
const pageSize = Math.max(1, Number(searchParams.get("pageSize") ?? 20));
const pageIndex = page - 1;
const query = searchParams.get("query") ?? undefined;
const {
data: rows = [],
data,
isLoading,
refetch,
isRefetching
} = useQuery({
...orgQueries.standaloneHealthChecks({ orgId }),
...orgQueries.standaloneHealthChecks({
orgId,
limit: pageSize,
offset: pageIndex * pageSize,
query
}),
refetchInterval: 10_000
});
const rows = data?.healthChecks ?? [];
const total = data?.pagination.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const paginationState: DataTablePaginationState = {
pageIndex,
pageSize,
pageCount
};
const handlePaginationChange = (newState: PaginationState) => {
searchParams.set("page", (newState.pageIndex + 1).toString());
searchParams.set("pageSize", newState.pageSize.toString());
filter({ searchParams });
};
const handleSearchChange = useDebouncedCallback((value: string) => {
if (value) {
searchParams.set("query", value);
} else {
searchParams.delete("query");
}
searchParams.delete("page");
filter({ searchParams });
}, 300);
const invalidate = () =>
queryClient.invalidateQueries(
orgQueries.standaloneHealthChecks({ orgId })
);
queryClient.invalidateQueries({
queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"]
});
const handleToggleEnabled = async (
row: HealthCheckRow,
@@ -194,6 +240,27 @@ export default function HealthChecksTable({
);
}
},
{
id: "site",
friendlyName: "Site",
header: () => (
<span className="p-3">Site</span>
),
cell: ({ row }) => {
const r = row.original;
if (!r.siteId || !r.siteName || !r.siteNiceId) {
return <span className="text-neutral-400">-</span>;
}
return (
<Link href={`/${orgId}/settings/sites/${r.siteNiceId}/general`}>
<Button variant="outline" size="sm">
{r.siteName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);
}
},
{
id: "health",
friendlyName: t("standaloneHcColumnHealth"),
@@ -341,21 +408,24 @@ export default function HealthChecksTable({
<DataTable
columns={columns}
data={rows}
persistPageSize="Org-standalone-health-checks-table"
title={t("standaloneHcTableTitle")}
searchPlaceholder={t("standaloneHcSearchPlaceholder")}
searchColumn="name"
onSearch={handleSearchChange}
searchQuery={query}
manualFiltering
onAdd={() => {
setSelected(null);
setCredenzaOpen(true);
}}
addButtonDisabled={!isPaid}
onRefresh={() => refetch()}
isRefreshing={isRefetching || isLoading}
isRefreshing={isRefetching || isLoading || isFiltering}
addButtonText={t("standaloneHcAddButton")}
enableColumnVisibility
stickyLeftColumn="name"
stickyRightColumn="rowActions"
pagination={paginationState}
onPaginationChange={handlePaginationChange}
/>
</>
);

View File

@@ -8,23 +8,25 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
import type { Env } from "@app/lib/types/env";
export function isIdpGlobalModeBannerVisible(env: Env): boolean {
if (build === "saas") {
return false;
}
return env.app.identityProviderMode === undefined;
}
export function IdpGlobalModeBanner() {
const t = useTranslations();
const { env } = useEnvContext();
const { isPaidUser, hasEnterpriseLicense } = usePaidStatus();
const identityProviderModeUndefined =
env.app.identityProviderMode === undefined;
const paidUserForOrgOidc = isPaidUser(tierMatrix.orgOidc);
const enterpriseUnlicensed =
build === "enterprise" && !hasEnterpriseLicense;
if (build === "saas") {
return null;
}
if (!identityProviderModeUndefined) {
if (!isIdpGlobalModeBannerVisible(env)) {
return null;
}

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { Button } from "@app/components/ui/button";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useTranslations } from "next-intl";
import Image from "next/image";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
import {
generateOidcUrlProxy,
type GenerateOidcUrlResponse
@@ -135,24 +135,7 @@ export default function IdpLoginButtons({
disabled={loading}
loading={loading}
>
{effectiveType === "google" && (
<Image
src="/idp/google.png"
alt="Google"
width={16}
height={16}
className="rounded"
/>
)}
{effectiveType === "azure" && (
<Image
src="/idp/azure.png"
alt="Azure"
width={16}
height={16}
className="rounded"
/>
)}
<IdpTypeIcon type={effectiveType} size={16} />
<span>{idp.name}</span>
</Button>
);

View File

@@ -1,7 +1,7 @@
"use client";
import { Badge } from "@app/components/ui/badge";
import Image from "next/image";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
type IdpTypeBadgeProps = {
type: string;
@@ -29,34 +29,8 @@ export default function IdpTypeBadge({
variant="secondary"
className="inline-flex items-center space-x-1 w-fit"
>
{effectiveType === "google" && (
<>
<Image
src="/idp/google.png"
alt="Google"
width={16}
height={16}
className="rounded"
/>
<span>{effectiveName}</span>
</>
)}
{effectiveType === "azure" && (
<>
<Image
src="/idp/azure.png"
alt="Azure"
width={16}
height={16}
className="rounded"
/>
<span>{effectiveName}</span>
</>
)}
{effectiveType === "oidc" && <span>{effectiveName}</span>}
{!["google", "azure", "oidc"].includes(effectiveType) && (
<span>{effectiveName}</span>
)}
<IdpTypeIcon type={effectiveType} size={16} />
<span>{effectiveName}</span>
</Badge>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { cn } from "@app/lib/cn";
import Image from "next/image";
import { ReactNode } from "react";
type Props = {
type?: string | null;
variant?: string | null;
size?: number;
className?: string;
alt?: string;
fallback?: ReactNode;
};
export default function IdpTypeIcon({
type,
variant,
size = 16,
className,
alt,
fallback = null
}: Props) {
const effectiveType = (variant || type || "").toLowerCase();
let src: string | null = null;
let defaultAlt = "";
if (effectiveType === "google") {
src = "/idp/google.png";
defaultAlt = "Google";
} else if (effectiveType === "azure") {
src = "/idp/azure.png";
defaultAlt = "Azure";
} else if (effectiveType === "oidc") {
src = "/idp/openid.png";
defaultAlt = "OAuth2/OIDC";
}
if (!src) {
return <>{fallback}</>;
}
return (
<Image
src={src}
alt={alt ?? defaultAlt}
width={size}
height={size}
className={cn("shrink-0 rounded", className)}
/>
);
}

View File

@@ -1542,7 +1542,7 @@ export function InternalResourceForm({
</span>
)
)}
<span className="pl-1">
<span className="pl-1 font-normal">
{t(
"accessClientSelect"
)}

View File

@@ -49,7 +49,7 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
return (
<div className="absolute top-0 left-0 right-0 z-50 hidden md:block">
<div className="absolute inset-0 bg-background/83 backdrop-blur-sm" />
<div className="absolute inset-0 bg-background backdrop-blur-sm" />
<div className="relative z-10 px-6 py-2">
<div className="container mx-auto max-w-12xl">
<div className="h-16 flex items-center justify-between">

View File

@@ -136,7 +136,7 @@ export function LayoutSidebar({
return (
<div
className={cn(
"hidden md:flex border-r bg-card flex-col h-full shrink-0 relative",
"hidden md:flex border-r bg-sidebar flex-col h-full shrink-0 relative",
isSidebarCollapsed ? "w-16" : "w-64"
)}
>
@@ -165,7 +165,7 @@ export function LayoutSidebar({
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md",
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
@@ -202,7 +202,7 @@ export function LayoutSidebar({
/>
</div>
{/* Fade gradient at bottom to indicate scrollable content */}
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-sidebar to-transparent" />
</div>
{isSidebarCollapsed && (
@@ -217,7 +217,7 @@ export function LayoutSidebar({
setHasManualToggle(true);
setSidebarStateCookie(false);
}}
className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 transition-colors"
className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 transition-colors"
aria-label={t("sidebarExpand")}
>
<PanelRightOpen className="h-4 w-4" />
@@ -231,12 +231,17 @@ export function LayoutSidebar({
</div>
)}
<div className="pt-1 flex flex-col shrink-0 gap-2 w-full border-t border-border">
{canShowProductUpdates && (
<div
className={cn(
"pt-1 flex flex-col shrink-0 gap-2 w-full border-t border-border",
isSidebarCollapsed && "pb-2"
)}
>
{canShowProductUpdates ? (
<div className="px-4">
<ProductUpdates isCollapsed={isSidebarCollapsed} />
</div>
)}
) : <div className="mt-0.2"></div>}
{showTrial && (
<div className="px-4">

View File

@@ -53,7 +53,7 @@ export default function LocaleSwitcherSelect({
)}
aria-label={label}
>
<Languages className="h-4 w-4" />
<Languages className="text-muted-foreground h-4 w-4" />
<span className="text-left flex-1">
{selected?.label ?? label}
</span>

View File

@@ -27,7 +27,6 @@ import { LockIcon } from "lucide-react";
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
import { createApiClient } from "@app/lib/api";
import Link from "next/link";
import Image from "next/image";
import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator";
import { useTranslations } from "next-intl";
@@ -37,6 +36,7 @@ import {
} from "@app/actions/server";
import { redirect as redirectTo } from "next/navigation";
import { useEnvContext } from "@app/hooks/useEnvContext";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
// @ts-ignore
import { loadReoScript } from "reodotdev";
import { build } from "@server/build";
@@ -393,24 +393,7 @@ export default function LoginForm({
loginWithIdp(idp.idpId);
}}
>
{effectiveType === "google" && (
<Image
src="/idp/google.png"
alt="Google"
width={16}
height={16}
className="rounded"
/>
)}
{effectiveType === "azure" && (
<Image
src="/idp/azure.png"
alt="Azure"
width={16}
height={16}
className="rounded"
/>
)}
<IdpTypeIcon type={effectiveType} size={16} />
<span>{idp.name}</span>
</Button>
);

View File

@@ -1,19 +1,26 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import {
DataTable,
type DataTableAddAction
} from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onAdd?: () => void;
addActions?: DataTableAddAction[];
addButtonDisabled?: boolean;
}
export function IdpDataTable<TData, TValue>({
columns,
data,
onAdd
onAdd,
addActions,
addButtonDisabled
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
@@ -27,6 +34,8 @@ export function IdpDataTable<TData, TValue>({
searchColumn="name"
addButtonText={t("idpAdd")}
onAdd={onAdd}
addActions={addActions}
addButtonDisabled={addButtonDisabled}
enableColumnVisibility={true}
stickyRightColumn="actions"
/>

View File

@@ -4,13 +4,37 @@ import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { IdpDataTable } from "@app/components/OrgIdpDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useState } from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
ArrowRight,
ArrowUpDown,
KeyRound,
MoreHorizontal
} from "lucide-react";
import { useMemo, useState } 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 { useUserContext } from "@app/hooks/useUserContext";
import { useRouter } from "next/navigation";
import {
DropdownMenu,
@@ -21,6 +45,14 @@ import {
import Link from "next/link";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { useQuery } from "@tanstack/react-query";
import { useDebounce } from "use-debounce";
import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
import { cn } from "@app/lib/cn";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner";
export type IdpRow = {
idpId: number;
@@ -29,6 +61,15 @@ export type IdpRow = {
variant?: string;
};
type AdminIdpRow = ListUserAdminOrgIdpsResponse["idps"][number];
function IdpImportRowIcon({
type,
variant
}: Pick<AdminIdpRow, "type" | "variant">) {
return <IdpTypeIcon type={type} variant={variant} size={20} />;
}
type Props = {
idps: IdpRow[];
orgId: string;
@@ -37,10 +78,53 @@ type Props = {
export default function IdpTable({ idps, orgId }: Props) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedIdp, setSelectedIdp] = useState<IdpRow | null>(null);
const api = createApiClient(useEnvContext());
const [isUnassociateModalOpen, setIsUnassociateModalOpen] = useState(false);
const [selectedUnassociateIdp, setSelectedUnassociateIdp] =
useState<IdpRow | null>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importSearchQuery, setImportSearchQuery] = useState("");
const [importSubmitting, setImportSubmitting] = useState(false);
const [debouncedImportSearch] = useDebounce(importSearchQuery, 150);
const envContext = useEnvContext();
const api = createApiClient(envContext);
const { user } = useUserContext();
const { isPaidUser } = usePaidStatus();
const router = useRouter();
const t = useTranslations();
const canImportOrgOidcIdp = isPaidUser(tierMatrix.orgOidc);
const addIdpDisabled = isIdpGlobalModeBannerVisible(envContext.env);
const { data: adminIdpsRaw = [] } = useQuery({
queryKey: ["admin-org-idps", user.userId],
queryFn: async () => {
const res = await api.get<{
data: ListUserAdminOrgIdpsResponse;
}>(`/user/${user.userId}/admin-org-idps`);
return res.data.data.idps;
},
enabled: importDialogOpen && !!user?.userId
});
const importableIdps = useMemo(() => {
const localIds = new Set(idps.map((i) => i.idpId));
return adminIdpsRaw.filter(
(row) => row.orgId !== orgId && !localIds.has(row.idpId)
);
}, [adminIdpsRaw, orgId, idps]);
const shownImportIdps = useMemo(() => {
const q = debouncedImportSearch.trim().toLowerCase();
if (!q) {
return importableIdps;
}
return importableIdps.filter((row) => {
const hay = `${row.orgName} ${row.name}`.toLowerCase();
return hay.includes(q);
});
}, [importableIdps, debouncedImportSearch]);
const deleteIdp = async (idpId: number) => {
try {
await api.delete(`/org/${orgId}/idp/${idpId}`);
@@ -59,24 +143,50 @@ export default function IdpTable({ idps, orgId }: Props) {
}
};
const importIdp = async (row: AdminIdpRow) => {
setImportSubmitting(true);
try {
await api.post(`/org/${orgId}/idp/${row.idpId}/import`, {
sourceOrgId: row.orgId
});
toast({
title: t("success"),
description: t("idpImportedDescription")
});
setImportDialogOpen(false);
setImportSearchQuery("");
router.refresh();
router.push(`/${orgId}/settings/idp/${row.idpId}/general`);
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setImportSubmitting(false);
}
};
const unassociateIdp = async (idpId: number) => {
try {
await api.delete(`/org/${orgId}/idp/${idpId}/association`);
toast({
title: t("success"),
description: t("idpUnassociatedDescription")
});
setIsUnassociateModalOpen(false);
router.refresh();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
}
};
const columns: ExtendedColumnDef<IdpRow>[] = [
{
accessorKey: "idpId",
friendlyName: "ID",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "name",
friendlyName: t("name"),
@@ -142,6 +252,14 @@ export default function IdpTable({ idps, orgId }: Props) {
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedUnassociateIdp(siteRow);
setIsUnassociateModalOpen(true);
}}
>
{t("idpUnassociateMenu")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedIdp(siteRow);
@@ -149,7 +267,7 @@ export default function IdpTable({ idps, orgId }: Props) {
}}
>
<span className="text-red-500">
{t("delete")}
{t("idpDeleteAllOrgsMenu")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -179,8 +297,8 @@ export default function IdpTable({ idps, orgId }: Props) {
}}
dialog={
<div className="space-y-2">
<p>{t("idpQuestionRemove")}</p>
<p>{t("idpMessageRemove")}</p>
<p>{t("idpDeleteGlobalQuestion")}</p>
<p>{t("idpDeleteGlobalDescription")}</p>
</div>
}
buttonText={t("idpConfirmDelete")}
@@ -189,11 +307,127 @@ export default function IdpTable({ idps, orgId }: Props) {
title={t("idpDelete")}
/>
)}
{selectedUnassociateIdp && (
<ConfirmDeleteDialog
open={isUnassociateModalOpen}
setOpen={(val) => {
setIsUnassociateModalOpen(val);
setSelectedUnassociateIdp(null);
}}
dialog={
<div className="space-y-2">
<p>{t("idpUnassociateQuestion")}</p>
<p>{t("idpUnassociateDescription")}</p>
</div>
}
buttonText={t("idpUnassociateConfirm")}
onConfirm={async () =>
unassociateIdp(selectedUnassociateIdp.idpId)
}
string={selectedUnassociateIdp.name}
title={t("idpUnassociateTitle")}
warningText={t("idpUnassociateWarning")}
/>
)}
<Credenza
open={importDialogOpen}
onOpenChange={(open) => {
setImportDialogOpen(open);
if (!open) {
setImportSearchQuery("");
}
}}
>
<CredenzaContent className="sm:max-w-lg">
<CredenzaHeader>
<CredenzaTitle>
{t("idpImportDialogTitle")}
</CredenzaTitle>
<CredenzaDescription>
{t("idpImportDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody
className={cn(
importSubmitting && "pointer-events-none opacity-60"
)}
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t("idpImportSearchPlaceholder")}
value={importSearchQuery}
onValueChange={setImportSearchQuery}
/>
<CommandList>
<CommandEmpty>
{t("idpImportEmpty")}
</CommandEmpty>
<CommandGroup>
{shownImportIdps.map((row) => (
<CommandItem
key={`${row.idpId}:${row.orgId}`}
className="items-start gap-3 py-2.5"
value={`${row.idpId}:${row.orgId}:${row.orgName}:${row.name}`}
disabled={!canImportOrgOidcIdp}
onSelect={() => {
if (!canImportOrgOidcIdp) {
return;
}
void importIdp(row);
}}
>
<div className="mt-0.5 shrink-0">
<IdpImportRowIcon
type={row.type}
variant={row.variant}
/>
</div>
<div className="min-w-0 flex-1 text-left">
<div className="truncate font-medium leading-tight">
{row.orgName}
</div>
<div className="truncate text-sm leading-tight text-muted-foreground">
{row.name}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
disabled={importSubmitting}
>
{t("cancel")}
</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
<IdpDataTable
columns={columns}
data={idps}
onAdd={() => router.push(`/${orgId}/settings/idp/create`)}
addButtonDisabled={addIdpDisabled}
addActions={[
{
label: t("idpAddActionCreateNew"),
onSelect: () => {
router.push(`/${orgId}/settings/idp/create`);
}
},
{
label: t("idpAddActionImportFromOrg"),
onSelect: () => {
setImportDialogOpen(true);
}
}
]}
/>
</>
);

View File

@@ -76,8 +76,8 @@ export function OrgSelector({
className={cn(
"cursor-pointer transition-colors",
isCollapsed
? "w-full h-16 flex items-center justify-center hover:bg-muted"
: "w-full px-5 py-4 hover:bg-muted"
? "w-full h-16 flex items-center justify-center hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50"
: "w-full px-5 py-4 hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50"
)}
>
{isCollapsed ? (
@@ -172,7 +172,7 @@ export function OrgSelector({
<Button
variant="ghost"
size="sm"
className="w-full justify-start h-8 font-normal text-muted-foreground hover:text-foreground"
className="w-full justify-start h-8 font-normal text-muted-foreground"
onClick={() => {
setOpen(false);
router.push("/setup");

View File

@@ -378,6 +378,7 @@ export default function ProxyResourcesTable({
{
accessorKey: "protocol",
friendlyName: t("protocol"),
enableHiding: true,
header: () => <span className="p-3">{t("protocol")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
@@ -684,7 +685,7 @@ export default function ProxyResourcesTable({
isRefreshing={isRefreshing || isFiltering}
isNavigatingToAddPage={isNavigatingToAddPage}
enableColumnVisibility
columnVisibility={{ niceId: false }}
columnVisibility={{ niceId: false, protocol: false }}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>

View File

@@ -7,7 +7,11 @@ import { Button } from "@app/components/ui/button";
import { useTranslations } from "next-intl";
import { toast } from "@app/hooks/useToast";
export default function RefreshButton() {
interface RefreshButtonProps {
onRefresh?: () => void;
}
export default function RefreshButton({ onRefresh }: RefreshButtonProps = {}) {
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
const t = useTranslations();
@@ -16,7 +20,11 @@ export default function RefreshButton() {
setIsRefreshing(true);
try {
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
if (onRefresh) {
onRefresh();
} else {
router.refresh();
}
} catch {
toast({
title: t("error"),

View File

@@ -12,11 +12,13 @@ import { useTranslations } from "next-intl";
interface RestartDomainButtonProps {
orgId: string;
domainId: string;
onSuccess?: () => void;
}
export default function RestartDomainButton({
orgId,
domainId
domainId,
onSuccess
}: RestartDomainButtonProps) {
const router = useRouter();
const api = createApiClient(useEnvContext());
@@ -35,7 +37,11 @@ export default function RestartDomainButton({
});
// Wait a bit before refreshing to allow the restart to take effect
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
if (onSuccess) {
onSuccess();
} else {
router.refresh();
}
} catch (e) {
toast({
title: t("error"),

View File

@@ -79,10 +79,7 @@ export default function RoleMappingConfigFields({
);
useEffect(() => {
if (
!supportsMultipleRolesPerUser &&
mappingBuilderRules.length > 1
) {
if (!supportsMultipleRolesPerUser && mappingBuilderRules.length > 1) {
onMappingBuilderRulesChange([mappingBuilderRules[0]]);
}
}, [
@@ -95,11 +92,7 @@ export default function RoleMappingConfigFields({
if (!supportsMultipleRolesPerUser && fixedRoleNames.length > 1) {
onFixedRoleNamesChange([fixedRoleNames[0]]);
}
}, [
supportsMultipleRolesPerUser,
fixedRoleNames,
onFixedRoleNamesChange
]);
}, [supportsMultipleRolesPerUser, fixedRoleNames, onFixedRoleNamesChange]);
const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`;
const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`;
@@ -116,7 +109,6 @@ export default function RoleMappingConfigFields({
return (
<div className="space-y-4">
<div>
<FormLabel className="mb-2">{t("roleMapping")}</FormLabel>
<FormDescription className="mb-4">
{t("roleMappingDescription")}
</FormDescription>
@@ -272,7 +264,9 @@ export default function RoleMappingConfigFields({
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showRemoveButton={mappingBuilderShowsRemoveColumn}
showRemoveButton={
mappingBuilderShowsRemoveColumn
}
rule={rule}
onChange={(nextRule) => {
const nextRules = mappingBuilderRules.map(
@@ -390,12 +384,10 @@ function BuilderRuleRow({
text: name
}))}
setTags={(nextTags) => {
const prevRoleTags = rule.roleNames.map(
(name) => ({
id: name,
text: name
})
);
const prevRoleTags = rule.roleNames.map((name) => ({
id: name,
text: name
}));
const next =
typeof nextTags === "function"
? nextTags(prevRoleTags)

View File

@@ -121,8 +121,8 @@ function CollapsibleNavItem({
"flex items-center w-full rounded-md transition-colors",
"px-3 py-1.5",
isActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
? "bg-sidebar-accent font-medium"
: "text-muted-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 hover:text-foreground",
isDisabled && "cursor-not-allowed opacity-60"
)}
disabled={isDisabled}
@@ -256,8 +256,8 @@ function CollapsedNavItemWithPopover({
className={cn(
"flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full",
isActive || isChildActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
? "bg-sidebar-accent font-medium"
: "text-muted-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 hover:text-foreground",
isDisabled &&
"cursor-not-allowed opacity-60"
)}
@@ -308,8 +308,8 @@ function CollapsedNavItemWithPopover({
className={cn(
"flex items-center rounded-md transition-colors px-3 py-1.5 text-sm",
childIsActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground",
? "bg-sidebar-accent font-medium"
: "text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground",
childIsDisabled &&
"cursor-not-allowed opacity-60"
)}
@@ -450,8 +450,8 @@ export function SidebarNav({
"flex items-center rounded-md transition-colors relative",
isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5",
isActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
? "bg-sidebar-accent font-medium"
: "text-muted-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 hover:text-foreground",
isDisabled && "cursor-not-allowed opacity-60"
)}
onClick={(e) => {

View File

@@ -33,7 +33,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
return (
<Alert>
<AlertDescription>
<InfoSections cols={3}>
<InfoSections cols={site.endpoint ? 4 : 3}>
<InfoSection>
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionContent>{site.niceId}</InfoSectionContent>
@@ -68,6 +68,18 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
{getConnectionTypeString(site.type)}
</InfoSectionContent>
</InfoSection>
{site.endpoint && (
<InfoSection>
<InfoSectionTitle>
{t("publicIpEndpoint")}
</InfoSectionTitle>
<InfoSectionContent>
{site.endpoint.includes(":")
? site.endpoint.substring(0, site.endpoint.lastIndexOf(":"))
: site.endpoint}
</InfoSectionContent>
</InfoSection>
)}
</InfoSections>
</AlertDescription>
</Alert>

View File

@@ -0,0 +1,302 @@
"use client";
import { useState, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { BellPlus, BellRing } from "lucide-react";
import {
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody
} from "@app/components/Settings";
import UptimeBar from "@app/components/UptimeBar";
import { Button } from "@app/components/ui/button";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { orgQueries } from "@app/lib/queries";
interface UptimeAlertSectionProps {
orgId: string;
siteId?: number;
startingName?: string;
resourceId?: number;
days?: number;
}
export default function UptimeAlertSection({
orgId,
siteId,
startingName,
resourceId,
days = 90
}: UptimeAlertSectionProps) {
const api = createApiClient(useEnvContext());
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [name, setName] = useState(`${siteId ? "Site" : "Resource"} ${startingName} Alert`);
const [userTags, setUserTags] = useState<Tag[]>([]);
const [roleTags, setRoleTags] = useState<Tag[]>([]);
const [emailTags, setEmailTags] = useState<Tag[]>([]);
const [activeUserTagIndex, setActiveUserTagIndex] = useState<number | null>(
null
);
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
null
);
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
number | null
>(null);
const [loading, setLoading] = useState(false);
const { data: alertRules, isLoading: alertRulesLoading } = useQuery(
orgQueries.alertRulesForSource({ orgId, siteId, resourceId })
);
const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId }));
const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId }));
const allUsers = useMemo(
() =>
orgUsers.map((u) => ({
id: String(u.id),
text: getUserDisplayName({
email: u.email,
name: u.name,
username: u.username
})
})),
[orgUsers]
);
const allRoles = useMemo(
() =>
orgRoles
.map((r) => ({ id: String(r.roleId), text: r.name }))
.filter((r) => r.text !== "Admin"),
[orgRoles]
);
const hasRules = (alertRules?.length ?? 0) > 0;
async function handleSubmit() {
if (
userTags.length === 0 &&
roleTags.length === 0 &&
emailTags.length === 0
) {
toast({
variant: "destructive",
title: "No recipients",
description:
"Please add at least one user, role, or email to notify."
});
return;
}
setLoading(true);
try {
await api.put(`/org/${orgId}/alert-rule`, {
name,
eventType: siteId ? "site_toggle" : "resource_toggle",
enabled: true,
cooldownSeconds: 300,
siteIds: siteId ? [siteId] : [],
healthCheckIds: [],
resourceIds: resourceId ? [resourceId] : [],
userIds: userTags.map((tag) => tag.id),
roleIds: roleTags.map((tag) => Number(tag.id)),
emails: emailTags.map((tag) => tag.text),
webhookActions: []
});
toast({
title: "Alert created",
description:
"You will be notified when this changes status."
});
setOpen(false);
setName("Uptime Alert");
setUserTags([]);
setRoleTags([]);
setEmailTags([]);
queryClient.invalidateQueries({
queryKey: orgQueries.alertRulesForSource({
orgId,
siteId,
resourceId
}).queryKey
});
} catch (e) {
toast({
variant: "destructive",
title: "Failed to create alert",
description: formatAxiosError(e, "An error occurred.")
});
}
setLoading(false);
}
const alertButton = alertRulesLoading ? null : hasRules ? (
<Button variant="outline" asChild>
<Link href={`/${orgId}/settings/alerting?siteId=${siteId}&resourceId=${resourceId}`}>
<BellRing className="size-4 mr-2" />
View Alerts
</Link>
</Button>
) : (
<Button variant="outline" onClick={() => setOpen(true)}>
<BellPlus className="size-4 mr-2" />
Add Alert
</Button>
);
return (
<>
<SettingsSection>
<SettingsSectionHeader>
<div className="flex justify-between items-start">
<div>
<SettingsSectionTitle>Uptime</SettingsSectionTitle>
<SettingsSectionDescription>
Site availability over the last {days} days.
</SettingsSectionDescription>
</div>
{alertButton}
</div>
</SettingsSectionHeader>
<SettingsSectionBody>
<UptimeBar
siteId={siteId}
resourceId={resourceId}
days={days}
/>
</SettingsSectionBody>
</SettingsSection>
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Create Email Alert</CredenzaTitle>
<CredenzaDescription>
Get notified by email when this{" "}
{siteId ? "site" : "resource"} goes offline or
comes back online.
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="alert-name">Name</Label>
<Input
id="alert-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Alert name"
/>
</div>
<div className="space-y-2">
<Label>Notify Users</Label>
<TagInput
activeTagIndex={activeUserTagIndex}
setActiveTagIndex={setActiveUserTagIndex}
placeholder="Select users..."
size="sm"
tags={userTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(userTags)
: newTags;
setUserTags(next as Tag[]);
}}
enableAutocomplete
autocompleteOptions={allUsers}
restrictTagsToAutocompleteOptions
allowDuplicates={false}
sortTags
/>
</div>
<div className="space-y-2">
<Label>Notify Roles</Label>
<TagInput
activeTagIndex={activeRoleTagIndex}
setActiveTagIndex={setActiveRoleTagIndex}
placeholder="Select roles..."
size="sm"
tags={roleTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(roleTags)
: newTags;
setRoleTags(next as Tag[]);
}}
enableAutocomplete
autocompleteOptions={allRoles}
restrictTagsToAutocompleteOptions
allowDuplicates={false}
sortTags
/>
</div>
<div className="space-y-2">
<Label>Additional Emails</Label>
<TagInput
activeTagIndex={activeEmailTagIndex}
setActiveTagIndex={setActiveEmailTagIndex}
placeholder="Enter email addresses..."
size="sm"
tags={emailTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(emailTags)
: newTags;
setEmailTags(next as Tag[]);
}}
allowDuplicates={false}
sortTags
validateTag={(tag) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag)
}
delimiterList={[",", "Enter"]}
/>
</div>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Cancel</Button>
</CredenzaClose>
<Button
onClick={handleSubmit}
loading={loading}
disabled={loading}
>
Create Alert
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -27,7 +27,7 @@ import {
TableHeader,
TableRow
} from "@app/components/ui/table";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@app/components/ui/tabs";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { Loader2, RefreshCw } from "lucide-react";
import moment from "moment";
import { useUserContext } from "@app/hooks/useUserContext";
@@ -58,7 +58,6 @@ export default function ViewDevicesDialog({
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<"available" | "archived">("available");
const fetchDevices = async () => {
setLoading(true);
@@ -177,34 +176,21 @@ export default function ViewDevicesDialog({
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value as "available" | "archived")
}
className="w-full"
<HorizontalTabs
clientSide
defaultTab={0}
items={[
{
title: `${t("available") || "Available"} (${devices.filter((d) => !d.archived).length})`,
href: "#available"
},
{
title: `${t("archived") || "Archived"} (${devices.filter((d) => d.archived).length})`,
href: "#archived"
}
]}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="available">
{t("available") || "Available"} (
{
devices.filter(
(d) => !d.archived
).length
}
)
</TabsTrigger>
<TabsTrigger value="archived">
{t("archived") || "Archived"} (
{
devices.filter(
(d) => d.archived
).length
}
)
</TabsTrigger>
</TabsList>
<TabsContent value="available" className="mt-4">
<div>
{devices.filter((d) => !d.archived)
.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
@@ -271,8 +257,8 @@ export default function ViewDevicesDialog({
</Table>
</div>
)}
</TabsContent>
<TabsContent value="archived" className="mt-4">
</div>
<div>
{devices.filter((d) => d.archived)
.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
@@ -336,8 +322,8 @@ export default function ViewDevicesDialog({
</Table>
</div>
)}
</TabsContent>
</Tabs>
</div>
</HorizontalTabs>
)}
</CredenzaBody>
<CredenzaFooter>

View File

@@ -35,6 +35,7 @@ import {
RadioGroupItem
} from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
import { StrategySelect } from "@app/components/StrategySelect";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import {
@@ -43,54 +44,115 @@ import {
} from "@app/lib/alertRuleForm";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { ChevronsUpDown, Plus, Trash2 } from "lucide-react";
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
import { Bell, Globe, ChevronsUpDown, Plus, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { Control, UseFormReturn } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { useDebounce } from "use-debounce";
export function DropdownAddAction({
export function AddActionPanel({
onAdd
}: {
onAdd: (type: AlertRuleFormAction["type"]) => void;
}) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const EXTERNAL_INTEGRATIONS = [
{
id: "pagerduty",
name: "PagerDuty",
logo: "/third-party/pgd.png",
description: "Send alerts to PagerDuty for incident management",
descriptionKey: t("alertingExternalPagerDutyDescription")
},
{
id: "opsgenie",
name: "Opsgenie",
logo: "/third-party/opsgenie.png",
description: "Route alerts to Opsgenie for on-call management",
descriptionKey: t("alertingExternalOpsgenieDescription")
},
{
id: "servicenow",
name: "ServiceNow",
logo: "/third-party/servicenow.png",
description: "Create ServiceNow incidents from alert events",
descriptionKey: t("alertingExternalServiceNowDescription")
},
{
id: "incidentio",
name: "Incident.io",
logo: "/third-party/incidentio.png",
description: "Trigger Incident.io workflows from alert events",
descriptionKey: t("alertingExternalIncidentIoDescription")
}
] as const;
const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id);
const [selected, setSelected] = useState<string | null>(null);
const isPremiumSelected =
selected !== null && EXTERNAL_IDS.includes(selected as any);
const isBuiltInSelected = selected !== null && !isPremiumSelected;
const actionTypeOptions = [
{
id: "notify",
title: t("alertingActionNotify"),
description: t("alertingActionNotifyDescription"),
icon: <Bell className="h-5 w-5" />
},
{
id: "webhook",
title: t("alertingActionWebhook"),
description: t("alertingActionWebhookDescription"),
icon: <Globe className="h-5 w-5" />
},
...EXTERNAL_INTEGRATIONS.map((integration) => ({
id: integration.id,
title: integration.name,
description: integration.description,
icon: (
<img
src={integration.logo}
alt={integration.name}
className="h-5 w-5 object-contain"
/>
)
}))
];
const handleAdd = () => {
if (!isBuiltInSelected) return;
onAdd(selected as AlertRuleFormAction["type"]);
setSelected(null);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="sm">
<div className="space-y-3">
<StrategySelect
options={actionTypeOptions}
value={selected}
cols={2}
onChange={(v) => setSelected(v)}
/>
{isPremiumSelected && <ContactSalesBanner />}
{!isPremiumSelected && (
<Button
type="button"
size="sm"
disabled={!isBuiltInSelected}
onClick={handleAdd}
>
<Plus className="h-4 w-4 mr-1" />
{t("alertingAddAction")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-48" align="start">
<Command>
<CommandList>
<CommandGroup>
<CommandItem
onSelect={() => {
onAdd("notify");
setOpen(false);
}}
>
{t("alertingActionNotify")}
</CommandItem>
<CommandItem
onSelect={() => {
onAdd("webhook");
setOpen(false);
}}
>
{t("alertingActionWebhook")}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
);
}
@@ -275,6 +337,93 @@ function HealthCheckMultiSelect({
);
}
function ResourceMultiSelect({
orgId,
value,
onChange
}: {
orgId: string;
value: number[];
onChange: (v: number[]) => void;
}) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [debounced] = useDebounce(q, 150);
const { data: resources = [] } = useQuery(
orgQueries.resources({ orgId, query: debounced, perPage: 10 })
);
const shown = useMemo(() => {
return resources;
}, [resources]);
const toggle = (id: number) => {
if (value.includes(id)) {
onChange(value.filter((x) => x !== id));
} else {
onChange([...value, id]);
}
};
const summary =
value.length === 0
? t("alertingSelectResources")
: t("alertingResourcesSelected", { count: value.length });
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">{summary}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t("alertingSelectResources")}
value={q}
onValueChange={setQ}
/>
<CommandList>
<CommandEmpty>
{t("alertingResourcesEmpty")}
</CommandEmpty>
<CommandGroup>
{shown.map((r) => (
<CommandItem
key={r.resourceId}
value={`${r.resourceId}:${r.name}`}
onSelect={() => toggle(r.resourceId)}
className="cursor-pointer"
>
<Checkbox
checked={value.includes(r.resourceId)}
className="mr-2 pointer-events-none shrink-0"
aria-hidden
tabIndex={-1}
/>
<span className="truncate">{r.name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function ActionBlock({
orgId,
index,
@@ -294,6 +443,20 @@ export function ActionBlock({
}) {
const t = useTranslations();
const type = useWatch({ control, name: `actions.${index}.type` });
const typeHeader =
type === "notify" ? (
<div className="flex items-center gap-2 text-sm font-medium">
<Bell className="h-4 w-4 text-muted-foreground" />
{t("alertingActionNotify")}
</div>
) : (
<div className="flex items-center gap-2 text-sm font-medium">
<Globe className="h-4 w-4 text-muted-foreground" />
{t("alertingActionWebhook")}
</div>
);
return (
<div className="rounded-lg border p-4 space-y-3 relative">
{canRemove && (
@@ -307,55 +470,7 @@ export function ActionBlock({
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
<FormField
control={control}
name={`actions.${index}.type`}
render={({ field }) => (
<FormItem>
<FormLabel>{t("alertingActionType")}</FormLabel>
<Select
value={field.value}
onValueChange={(v) => {
const nt = v as AlertRuleFormAction["type"];
if (nt === "notify") {
onUpdate({
type: "notify",
userTags: [],
roleTags: [],
emailTags: []
});
} else {
onUpdate({
type: "webhook",
url: "",
method: "POST",
headers: [],
authType: "none",
bearerToken: "",
basicCredentials: "",
customHeaderName: "",
customHeaderValue: ""
});
}
}}
>
<FormControl>
<SelectTrigger className="max-w-xs">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="notify">
{t("alertingActionNotify")}
</SelectItem>
<SelectItem value="webhook">
{t("alertingActionWebhook")}
</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
{typeHeader}
{type === "notify" && (
<NotifyActionFields
orgId={orgId}
@@ -396,8 +511,8 @@ function NotifyActionFields({
number | null
>(null);
const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId }));
const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId }));
const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery(orgQueries.users({ orgId }));
const { data: orgRoles = [], isLoading: isLoadingRoles } = useQuery(orgQueries.roles({ orgId }));
const allUsers = useMemo(
() =>
@@ -420,6 +535,50 @@ function NotifyActionFields({
[orgRoles]
);
const hasResolvedTagsRef = useRef(false);
useEffect(() => {
if (isLoadingUsers || isLoadingRoles) return;
if (hasResolvedTagsRef.current) return;
const currentUserTags = form.getValues(
`actions.${index}.userTags`
) as Tag[];
const currentRoleTags = form.getValues(
`actions.${index}.roleTags`
) as Tag[];
const resolvedUserTags = currentUserTags.map((tag) => {
const match = allUsers.find((u) => u.id === tag.id);
return match ? { id: tag.id, text: match.text } : tag;
});
const resolvedRoleTags = currentRoleTags.map((tag) => {
const match = allRoles.find((r) => r.id === tag.id);
return match ? { id: tag.id, text: match.text } : tag;
});
const userTagsNeedUpdate = resolvedUserTags.some(
(t, i) => t.text !== currentUserTags[i]?.text
);
const roleTagsNeedUpdate = resolvedRoleTags.some(
(t, i) => t.text !== currentRoleTags[i]?.text
);
if (userTagsNeedUpdate) {
form.setValue(`actions.${index}.userTags`, resolvedUserTags, {
shouldDirty: false
});
}
if (roleTagsNeedUpdate) {
form.setValue(`actions.${index}.roleTags`, resolvedRoleTags, {
shouldDirty: false
});
}
hasResolvedTagsRef.current = true;
}, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]);
const userTags = (useWatch({ control, name: `actions.${index}.userTags` }) ?? []) as Tag[];
const roleTags = (useWatch({ control, name: `actions.${index}.roleTags` }) ?? []) as Tag[];
const emailTags = (useWatch({ control, name: `actions.${index}.emailTags` }) ?? []) as Tag[];
@@ -870,6 +1029,58 @@ export function AlertRuleSourceFields({
const t = useTranslations();
const { setValue, getValues } = useFormContext<AlertRuleFormValues>();
const sourceType = useWatch({ control, name: "sourceType" });
const allSites = useWatch({ control, name: "allSites" });
const allHealthChecks = useWatch({ control, name: "allHealthChecks" });
const allResources = useWatch({ control, name: "allResources" });
const siteStrategyOptions = useMemo(
() => [
{
id: "all" as const,
title: t("alertingAllSites"),
description: t("alertingAllSitesDescription")
},
{
id: "specific" as const,
title: t("alertingSpecificSites"),
description: t("alertingSpecificSitesDescription")
}
],
[t]
);
const healthCheckStrategyOptions = useMemo(
() => [
{
id: "all" as const,
title: t("alertingAllHealthChecks"),
description: t("alertingAllHealthChecksDescription")
},
{
id: "specific" as const,
title: t("alertingSpecificHealthChecks"),
description: t("alertingSpecificHealthChecksDescription")
}
],
[t]
);
const resourceStrategyOptions = useMemo(
() => [
{
id: "all" as const,
title: t("alertingAllResources"),
description: t("alertingAllResourcesDescription")
},
{
id: "specific" as const,
title: t("alertingSpecificResources"),
description: t("alertingSpecificResourcesDescription")
}
],
[t]
);
return (
<div className="space-y-4">
<FormField
@@ -888,19 +1099,33 @@ export function AlertRuleSourceFields({
if (next === "site") {
if (
curTrigger !== "site_online" &&
curTrigger !== "site_offline"
curTrigger !== "site_offline" &&
curTrigger !== "site_toggle"
) {
setValue("trigger", "site_offline", {
setValue("trigger", "site_toggle", {
shouldValidate: true
});
}
} else if (next === "resource") {
if (
curTrigger !== "resource_healthy" &&
curTrigger !== "resource_unhealthy" &&
curTrigger !== "resource_toggle"
) {
setValue(
"trigger",
"resource_toggle",
{ shouldValidate: true }
);
}
} else if (
curTrigger !== "health_check_healthy" &&
curTrigger !== "health_check_unhealthy"
curTrigger !== "health_check_unhealthy" &&
curTrigger !== "health_check_toggle"
) {
setValue(
"trigger",
"health_check_unhealthy",
"health_check_toggle",
{ shouldValidate: true }
);
}
@@ -918,6 +1143,9 @@ export function AlertRuleSourceFields({
<SelectItem value="health_check">
{t("alertingSourceHealthCheck")}
</SelectItem>
<SelectItem value="resource">
{t("alertingSourceResource")}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -925,39 +1153,131 @@ export function AlertRuleSourceFields({
)}
/>
{sourceType === "site" ? (
<FormField
control={control}
name="siteIds"
render={({ field }) => (
<FormItem>
<FormLabel>{t("alertingPickSites")}</FormLabel>
<SiteMultiSelect
orgId={orgId}
value={field.value}
onChange={field.onChange}
/>
<FormMessage />
</FormItem>
<>
<FormField
control={control}
name="allSites"
render={({ field }) => (
<FormItem>
<StrategySelect
options={siteStrategyOptions}
value={field.value ? "all" : "specific"}
onChange={(v) => {
field.onChange(v === "all");
if (v === "all") {
setValue("siteIds", []);
}
}}
cols={2}
/>
<FormMessage />
</FormItem>
)}
/>
{!allSites && (
<FormField
control={control}
name="siteIds"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("alertingPickSites")}
</FormLabel>
<SiteMultiSelect
orgId={orgId}
value={field.value}
onChange={field.onChange}
/>
<FormMessage />
</FormItem>
)}
/>
)}
/>
</>
) : sourceType === "resource" ? (
<>
<FormField
control={control}
name="allResources"
render={({ field }) => (
<FormItem>
<StrategySelect
options={resourceStrategyOptions}
value={field.value ? "all" : "specific"}
onChange={(v) => {
field.onChange(v === "all");
if (v === "all") {
setValue("resourceIds", []);
}
}}
cols={2}
/>
<FormMessage />
</FormItem>
)}
/>
{!allResources && (
<FormField
control={control}
name="resourceIds"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("alertingPickResources")}
</FormLabel>
<ResourceMultiSelect
orgId={orgId}
value={field.value}
onChange={field.onChange}
/>
<FormMessage />
</FormItem>
)}
/>
)}
</>
) : (
<FormField
control={control}
name="healthCheckIds"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("alertingPickHealthChecks")}
</FormLabel>
<HealthCheckMultiSelect
orgId={orgId}
value={field.value}
onChange={field.onChange}
/>
<FormMessage />
</FormItem>
<>
<FormField
control={control}
name="allHealthChecks"
render={({ field }) => (
<FormItem>
<StrategySelect
options={healthCheckStrategyOptions}
value={field.value ? "all" : "specific"}
onChange={(v) => {
field.onChange(v === "all");
if (v === "all") {
setValue("healthCheckIds", []);
}
}}
cols={2}
/>
<FormMessage />
</FormItem>
)}
/>
{!allHealthChecks && (
<FormField
control={control}
name="healthCheckIds"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("alertingPickHealthChecks")}
</FormLabel>
<HealthCheckMultiSelect
orgId={orgId}
value={field.value}
onChange={field.onChange}
/>
<FormMessage />
</FormItem>
)}
/>
)}
/>
</>
)}
</div>
);
@@ -990,6 +1310,9 @@ export function AlertRuleTriggerFields({
<SelectContent>
{sourceType === "site" ? (
<>
<SelectItem value="site_toggle">
{t("alertingTriggerSiteToggle")}
</SelectItem>
<SelectItem value="site_online">
{t("alertingTriggerSiteOnline")}
</SelectItem>
@@ -997,8 +1320,23 @@ export function AlertRuleTriggerFields({
{t("alertingTriggerSiteOffline")}
</SelectItem>
</>
) : sourceType === "resource" ? (
<>
<SelectItem value="resource_toggle">
{t("alertingTriggerResourceToggle")}
</SelectItem>
<SelectItem value="resource_healthy">
{t("alertingTriggerResourceHealthy")}
</SelectItem>
<SelectItem value="resource_unhealthy">
{t("alertingTriggerResourceUnhealthy")}
</SelectItem>
</>
) : (
<>
<SelectItem value="health_check_toggle">
{t("alertingTriggerHcToggle")}
</SelectItem>
<SelectItem value="health_check_healthy">
{t("alertingTriggerHcHealthy")}
</SelectItem>

View File

@@ -2,9 +2,9 @@
import {
ActionBlock,
AddActionPanel,
AlertRuleSourceFields,
AlertRuleTriggerFields,
DropdownAddAction
AlertRuleTriggerFields
} from "@app/components/alert-rule-editor/AlertRuleFields";
import { SettingsContainer } from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
@@ -82,6 +82,12 @@ function summarizeSource(v: AlertRuleFormValues, t: AlertRuleT) {
}
return t("alertingSummarySites", { count: v.siteIds.length });
}
if (v.sourceType === "resource") {
if (v.resourceIds.length === 0) {
return t("alertingNodeNotConfigured");
}
return t("alertingSummaryResources", { count: v.resourceIds.length });
}
if (v.healthCheckIds.length === 0) {
return t("alertingNodeNotConfigured");
}
@@ -94,10 +100,20 @@ function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) {
return t("alertingTriggerSiteOnline");
case "site_offline":
return t("alertingTriggerSiteOffline");
case "site_toggle":
return t("alertingTriggerSiteToggle");
case "health_check_healthy":
return t("alertingTriggerHcHealthy");
case "health_check_unhealthy":
return t("alertingTriggerHcUnhealthy");
case "health_check_toggle":
return t("alertingTriggerHcToggle");
case "resource_healthy":
return t("alertingTriggerResourceHealthy");
case "resource_unhealthy":
return t("alertingTriggerResourceUnhealthy");
case "resource_toggle":
return t("alertingTriggerResourceToggle");
default:
return v.trigger;
}
@@ -330,13 +346,21 @@ export default function AlertRuleGraphEditor({
useWatch({ control: form.control, name: "enabled" }) ?? true;
const wSourceType =
useWatch({ control: form.control, name: "sourceType" }) ?? "site";
const wAllSites =
useWatch({ control: form.control, name: "allSites" }) ?? true;
const wSiteIds =
useWatch({ control: form.control, name: "siteIds" }) ?? [];
const wAllHealthChecks =
useWatch({ control: form.control, name: "allHealthChecks" }) ?? true;
const wHealthCheckIds =
useWatch({ control: form.control, name: "healthCheckIds" }) ?? [];
const wAllResources =
useWatch({ control: form.control, name: "allResources" }) ?? true;
const wResourceIds =
useWatch({ control: form.control, name: "resourceIds" }) ?? [];
const wTrigger =
useWatch({ control: form.control, name: "trigger" }) ??
"site_offline";
"site_toggle";
const wActions =
useWatch({ control: form.control, name: "actions" }) ?? [];
@@ -345,8 +369,12 @@ export default function AlertRuleGraphEditor({
name: wName,
enabled: wEnabled,
sourceType: wSourceType,
allSites: wAllSites,
siteIds: wSiteIds,
allHealthChecks: wAllHealthChecks,
healthCheckIds: wHealthCheckIds,
allResources: wAllResources,
resourceIds: wResourceIds,
trigger: wTrigger,
actions: wActions
}),
@@ -354,8 +382,12 @@ export default function AlertRuleGraphEditor({
wName,
wEnabled,
wSourceType,
wAllSites,
wSiteIds,
wAllHealthChecks,
wHealthCheckIds,
wAllResources,
wResourceIds,
wTrigger,
wActions
]
@@ -673,47 +705,43 @@ export default function AlertRuleGraphEditor({
)}
{isActionsSidebar && (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-medium">
{t(
"alertingSectionActions"
)}
</span>
<DropdownAddAction
onAdd={(type) => {
const newIndex =
fields.length;
if (type === "notify") {
append({
type: "notify",
userTags: [],
roleTags: [],
emailTags: []
});
} else {
append({
type: "webhook",
url: "",
method: "POST",
headers: [
{
key: "",
value: ""
}
],
authType: "none",
bearerToken: "",
basicCredentials: "",
customHeaderName: "",
customHeaderValue: ""
});
}
setSelectedStep(
`action-${newIndex}`
);
}}
/>
</div>
<span className="text-sm font-medium">
{t("alertingSectionActions")}
</span>
<AddActionPanel
onAdd={(type) => {
const newIndex =
fields.length;
if (type === "notify") {
append({
type: "notify",
userTags: [],
roleTags: [],
emailTags: []
});
} else {
append({
type: "webhook",
url: "",
method: "POST",
headers: [
{
key: "",
value: ""
}
],
authType: "none",
bearerToken: "",
basicCredentials: "",
customHeaderName: "",
customHeaderValue: ""
});
}
setSelectedStep(
`action-${newIndex}`
);
}}
/>
{fields.map((f, index) => (
<ActionBlock
key={f.id}

View File

@@ -6,8 +6,8 @@ import {
} from "@app/components/StrategySelect";
import { useEnvContext } from "@app/hooks/useEnvContext";
import type { IdpOidcProviderType } from "@app/lib/idp/oidcIdpProviderDefaults";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { useEffect, useMemo } from "react";
type Props = {
@@ -32,7 +32,8 @@ export function OidcIdpProviderTypeSelect({ value, onTypeChange }: Props) {
{
id: "oidc",
title: "OAuth2/OIDC",
description: t("idpOidcDescription")
description: t("idpOidcDescription"),
icon: <IdpTypeIcon type="oidc" size={24} />
}
];
if (hideTemplates) {
@@ -44,29 +45,13 @@ export function OidcIdpProviderTypeSelect({ value, onTypeChange }: Props) {
id: "google",
title: t("idpGoogleTitle"),
description: t("idpGoogleDescription"),
icon: (
<Image
src="/idp/google.png"
alt={t("idpGoogleAlt")}
width={24}
height={24}
className="rounded"
/>
)
icon: <IdpTypeIcon type="google" size={24} />
},
{
id: "azure",
title: t("idpAzureTitle"),
description: t("idpAzureDescription"),
icon: (
<Image
src="/idp/azure.png"
alt={t("idpAzureAlt")}
width={24}
height={24}
className="rounded"
/>
)
icon: <IdpTypeIcon type="azure" size={24} />
}
];
}, [hideTemplates, t]);

View File

@@ -18,12 +18,14 @@ import {
TableRow
} from "@/components/ui/table";
import { DataTablePagination } from "@app/components/DataTablePagination";
import type { DataTableAddAction } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
@@ -31,7 +33,14 @@ import {
import { Input } from "@app/components/ui/input";
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
import { Columns, Filter, Plus, RefreshCw, Search } from "lucide-react";
import {
ChevronDown,
Columns,
Filter,
Plus,
RefreshCw,
Search
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
@@ -67,6 +76,8 @@ type ControlledDataTableProps<TData, TValue> = {
tableId: string;
addButtonText?: string;
onAdd?: () => void;
addActions?: DataTableAddAction[];
addButtonDisabled?: boolean;
onRefresh?: () => void;
isRefreshing?: boolean;
refreshButtonDisabled?: boolean;
@@ -90,6 +101,8 @@ export function ControlledDataTable<TData, TValue>({
rows,
addButtonText,
onAdd,
addActions,
addButtonDisabled = false,
onRefresh,
isRefreshing,
refreshButtonDisabled = false,
@@ -348,16 +361,49 @@ export function ControlledDataTable<TData, TValue>({
</Button>
</div>
)}
{onAdd && addButtonText && (
{addActions && addActions.length > 0 && addButtonText ? (
<div>
<Button
onClick={onAdd}
loading={isNavigatingToAddPage}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={
addButtonDisabled ||
isNavigatingToAddPage
}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{addActions.map((action, i) => (
<DropdownMenuItem
key={i}
onSelect={() =>
action.onSelect()
}
>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
onAdd &&
addButtonText && (
<div>
<Button
onClick={onAdd}
loading={isNavigatingToAddPage}
disabled={addButtonDisabled}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
</div>
)
)}
</div>
</CardHeader>

Some files were not shown because too many files have changed in this diff Show More