mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-05 20:13:58 +00:00
Merge branch 'alerting-rules' into trial
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
93
src/components/DomainPageClient.tsx
Normal file
93
src/components/DomainPageClient.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
53
src/components/IdpTypeIcon.tsx
Normal file
53
src/components/IdpTypeIcon.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1542,7 +1542,7 @@ export function InternalResourceForm({
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<span className="pl-1">
|
||||
<span className="pl-1 font-normal">
|
||||
{t(
|
||||
"accessClientSelect"
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
302
src/components/UptimeAlertSection.tsx
Normal file
302
src/components/UptimeAlertSection.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user