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

@@ -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>

View File

@@ -33,7 +33,7 @@ import { Button } from "@app/components/ui/button";
import { useEffect, useMemo, useRef, useState } from "react";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus, Search, RefreshCw, Columns, Filter } from "lucide-react";
import { ChevronDown, Plus, Search, RefreshCw, Columns, Filter } from "lucide-react";
import {
Card,
CardContent,
@@ -46,6 +46,7 @@ import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
@@ -165,12 +166,20 @@ export type DataTablePaginationState = PaginationState & {
export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void;
/** When set (non-empty), replaces the single add button with a dropdown; `onAdd` is not used. */
export type DataTableAddAction = {
label: string;
onSelect: () => void;
};
type DataTableProps<TData, TValue> = {
columns: ExtendedColumnDef<TData, TValue>[];
data: TData[];
title?: string;
addButtonText?: string;
onAdd?: () => void;
/** Prefer over `onAdd` when non-empty. */
addActions?: DataTableAddAction[];
addButtonDisabled?: boolean;
onRefresh?: () => void;
isRefreshing?: boolean;
@@ -205,6 +214,7 @@ export function DataTable<TData, TValue>({
title,
addButtonText,
onAdd,
addActions,
addButtonDisabled = false,
onRefresh,
isRefreshing,
@@ -637,13 +647,45 @@ export function DataTable<TData, TValue>({
</Button>
</div>
)}
{onAdd && addButtonText && (
{addActions && addActions.length > 0 && addButtonText ? (
<div>
<Button onClick={onAdd} disabled={addButtonDisabled}>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={addButtonDisabled}
>
<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}
disabled={addButtonDisabled}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
</div>
)
)}
</div>
</CardHeader>