Merge branch 'alerting-rules' into dev

This commit is contained in:
Owen
2026-04-21 15:05:12 -07:00
230 changed files with 16351 additions and 3320 deletions

View File

@@ -0,0 +1,95 @@
"use client";
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { apiResponseToFormValues } from "@app/lib/alertRuleForm";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import type { AxiosResponse } from "axios";
import type { GetAlertRuleResponse } from "@server/private/routers/alertRule";
import type { AlertRuleFormValues } from "@app/lib/alertRuleForm";
export default function EditAlertRulePage() {
const t = useTranslations();
const params = useParams();
const router = useRouter();
const orgId = params.orgId as string;
const ruleIdParam = params.ruleId as string;
const alertRuleId = parseInt(ruleIdParam, 10);
const api = createApiClient(useEnvContext());
const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.alertingRules);
const [formValues, setFormValues] = useState<AlertRuleFormValues | null | undefined>(undefined);
useEffect(() => {
if (isNaN(alertRuleId)) {
router.replace(`/${orgId}/settings/alerting`);
return;
}
api.get<AxiosResponse<GetAlertRuleResponse>>(
`/org/${orgId}/alert-rule/${alertRuleId}`
)
.then((res) => {
const rule = res.data.data;
setFormValues(apiResponseToFormValues(rule));
})
.catch((e) => {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
setFormValues(null);
});
}, [orgId, alertRuleId]);
useEffect(() => {
if (formValues === null) {
router.replace(`/${orgId}/settings/alerting`);
}
}, [formValues, orgId, router]);
if (formValues === undefined) {
return (
<>
<HeaderTitle
title={t("alertingEditRule")}
description={t("alertingRuleCredenzaDescription")}
/>
<div className="min-h-[12rem] flex items-center justify-center text-muted-foreground text-sm">
{t("loading")}
</div>
</>
);
}
if (formValues === null) {
return null;
}
return (
<>
<HeaderTitle
title={t("alertingEditRule")}
description={t("alertingRuleCredenzaDescription")}
/>
<AlertRuleGraphEditor
key={alertRuleId}
orgId={orgId}
alertRuleId={alertRuleId}
initialValues={formValues}
isNew={false}
disabled={!isPaid}
/>
</>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { defaultFormValues } from "@app/lib/alertRuleForm";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
export default function NewAlertRulePage() {
const params = useParams();
const orgId = params.orgId as string;
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.alertingRules);
return (
<>
<HeaderTitle
title={t("alertingCreateRule")}
description={t("alertingRuleCredenzaDescription")}
/>
<AlertRuleGraphEditor
orgId={orgId}
initialValues={defaultFormValues()}
isNew
disabled={!isPaid}
/>
</>
);
}

View File

@@ -0,0 +1,34 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import AlertingRulesTable from "@app/components/AlertingRulesTable";
import HealthChecksTable from "@app/components/HealthChecksTable";
import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs";
import { getTranslations } from "next-intl/server";
type AlertingPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function AlertingPage(props: AlertingPageProps) {
const params = await props.params;
const t = await getTranslations();
const tabs: TabItem[] = [
{ title: t("alertingTabRules"), href: "" },
{ title: t("alertingTabHealthChecks"), href: "" }
];
return (
<>
<SettingsSectionTitle
title={t("alertingTitle")}
description={t("alertingDescription")}
/>
<HorizontalTabs items={tabs} clientSide>
<AlertingRulesTable orgId={params.orgId} />
<HealthChecksTable orgId={params.orgId} />
</HorizontalTabs>
</>
);
}

View File

@@ -471,11 +471,7 @@ export default function GeneralPage() {
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
}
>
<Button
variant="outline"
size="sm"
className="text-xs h-6"
>
<Button variant="outline" size="sm">
{row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>

View File

@@ -22,7 +22,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
function formatBytes(bytes: number | null): string {
if (bytes === null || bytes === undefined) return "";
if (bytes === null || bytes === undefined) return "-";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
@@ -33,7 +33,7 @@ function formatBytes(bytes: number | null): string {
function formatDuration(startedAt: number, endedAt: number | null): string {
if (endedAt === null || endedAt === undefined) return "Active";
const durationSec = endedAt - startedAt;
if (durationSec < 0) return "";
if (durationSec < 0) return "-";
if (durationSec < 60) return `${durationSec}s`;
if (durationSec < 3600) {
const m = Math.floor(durationSec / 60);
@@ -451,11 +451,7 @@ export default function ConnectionLogsPage() {
<Link
href={`/${row.original.orgId}/settings/resources/client/?query=${row.original.resourceNiceId}`}
>
<Button
variant="outline"
size="sm"
className="text-xs h-6"
>
<Button variant="outline" size="sm">
{row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
@@ -464,7 +460,7 @@ export default function ConnectionLogsPage() {
}
return (
<span className="whitespace-nowrap">
{row.original.resourceName ?? ""}
{row.original.resourceName ?? "-"}
</span>
);
}
@@ -497,11 +493,7 @@ export default function ConnectionLogsPage() {
<Link
href={`/${row.original.orgId}/settings/clients/${clientType}/${row.original.clientNiceId}`}
>
<Button
variant="outline"
size="sm"
className="text-xs h-6"
>
<Button variant="outline" size="sm">
<Laptop className="mr-1 h-3 w-3" />
{row.original.clientName}
<ArrowUpRight className="ml-2 h-3 w-3" />
@@ -511,7 +503,7 @@ export default function ConnectionLogsPage() {
}
return (
<span className="whitespace-nowrap">
{row.original.clientName ?? ""}
{row.original.clientName ?? "-"}
</span>
);
}
@@ -546,7 +538,7 @@ export default function ConnectionLogsPage() {
</span>
);
}
return <span></span>;
return <span>-</span>;
}
},
{
@@ -620,23 +612,23 @@ export default function ConnectionLogsPage() {
<div>
<strong>Session ID:</strong>{" "}
<span className="font-mono">
{row.sessionId ?? ""}
{row.sessionId ?? "-"}
</span>
</div>
<div>
<strong>Protocol:</strong>{" "}
{row.protocol?.toUpperCase() ?? ""}
{row.protocol?.toUpperCase() ?? "-"}
</div>
<div>
<strong>Source:</strong>{" "}
<span className="font-mono">
{row.sourceAddr ?? ""}
{row.sourceAddr ?? "-"}
</span>
</div>
<div>
<strong>Destination:</strong>{" "}
<span className="font-mono">
{row.destAddr ?? ""}
{row.destAddr ?? "-"}
</span>
</div>
</div>
@@ -646,7 +638,7 @@ export default function ConnectionLogsPage() {
</div>*/}
{/*<div>
<strong>Resource:</strong>{" "}
{row.resourceName ?? ""}
{row.resourceName ?? "-"}
{row.resourceNiceId && (
<span className="text-muted-foreground ml-1">
({row.resourceNiceId})
@@ -654,7 +646,7 @@ export default function ConnectionLogsPage() {
)}
</div>*/}
<div>
<strong>Site:</strong> {row.siteName ?? ""}
<strong>Site:</strong> {row.siteName ?? "-"}
{row.siteNiceId && (
<span className="text-muted-foreground ml-1">
({row.siteNiceId})
@@ -662,7 +654,7 @@ export default function ConnectionLogsPage() {
)}
</div>
<div>
<strong>Site ID:</strong> {row.siteId ?? ""}
<strong>Site ID:</strong> {row.siteId ?? "-"}
</div>
<div>
<strong>Started At:</strong>{" "}
@@ -670,14 +662,12 @@ export default function ConnectionLogsPage() {
? new Date(
row.startedAt * 1000
).toLocaleString()
: ""}
: "-"}
</div>
<div>
<strong>Ended At:</strong>{" "}
{row.endedAt
? new Date(
row.endedAt * 1000
).toLocaleString()
? new Date(row.endedAt * 1000).toLocaleString()
: "Active"}
</div>
<div>
@@ -686,7 +676,7 @@ export default function ConnectionLogsPage() {
</div>
{/*<div>
<strong>Resource ID:</strong>{" "}
{row.siteResourceId ?? ""}
{row.siteResourceId ?? "-"}
</div>*/}
</div>
<div className="space-y-2">

View File

@@ -360,6 +360,7 @@ export default function GeneralPage() {
// 105 - Valid Password
// 106 - Valid email
// 107 - Valid SSO
// 108 - Connected Client
// 201 - Resource Not Found
// 202 - Resource Blocked
@@ -377,6 +378,7 @@ export default function GeneralPage() {
105: t("validPassword"),
106: t("validEmail"),
107: t("validSSO"),
108: t("connectedClient"),
201: t("resourceNotFound"),
202: t("resourceBlocked"),
203: t("droppedByRule"),
@@ -510,14 +512,14 @@ export default function GeneralPage() {
cell: ({ row }) => {
return (
<Link
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
href={
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
? `/${row.original.orgId}/settings/resources/client?query=${row.original.resourceNiceId}`
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
}
onClick={(e) => e.stopPropagation()}
>
<Button
variant="outline"
size="sm"
className="text-xs h-6"
>
<Button variant="outline" size="sm">
{row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
@@ -634,6 +636,7 @@ export default function GeneralPage() {
{ value: "105", label: t("validPassword") },
{ value: "106", label: t("validEmail") },
{ value: "107", label: t("validSSO") },
{ value: "108", label: t("connectedClient") },
{ value: "201", label: t("resourceNotFound") },
{ value: "202", label: t("resourceBlocked") },
{ value: "203", label: t("droppedByRule") },

View File

@@ -38,6 +38,8 @@ import {
HttpDestinationCredenza,
parseHttpConfig
} from "@app/components/HttpDestinationCredenza";
import { S3DestinationCredenza } from "@app/components/S3DestinationCredenza";
import { DatadogDestinationCredenza } from "@app/components/DatadogDestinationCredenza";
import { useTranslations } from "next-intl";
// ── Re-export Destination so the rest of the file can use it ──────────────────
@@ -203,7 +205,6 @@ function DestinationTypePicker({
id: "s3",
title: t("streamingS3Title"),
description: t("streamingS3Description"),
disabled: true,
icon: (
<Image
src="/third-party/s3.png"
@@ -218,7 +219,6 @@ function DestinationTypePicker({
id: "datadog",
title: t("streamingDatadogTitle"),
description: t("streamingDatadogDescription"),
disabled: true,
icon: (
<Image
src="/third-party/dd.png"
@@ -255,7 +255,7 @@ function DestinationTypePicker({
<StrategySelect
options={destinationTypeOptions}
value={selected}
onChange={setSelected}
onChange={(type) => setSelected(type)}
cols={1}
/>
</div>
@@ -291,6 +291,7 @@ export default function StreamingDestinationsPage() {
const [typePickerOpen, setTypePickerOpen] = useState(false);
const [editingDestination, setEditingDestination] =
useState<Destination | null>(null);
const [pickedType, setPickedType] = useState<DestinationType>("http");
const [togglingIds, setTogglingIds] = useState<Set<number>>(new Set());
// Delete state
@@ -392,7 +393,8 @@ export default function StreamingDestinationsPage() {
setTypePickerOpen(true);
};
const handleTypePicked = (_type: DestinationType) => {
const handleTypePicked = (type: DestinationType) => {
setPickedType(type);
setTypePickerOpen(false);
setEditingDestination(null);
setModalOpen(true);
@@ -400,6 +402,7 @@ export default function StreamingDestinationsPage() {
const openEdit = (destination: Destination) => {
setEditingDestination(destination);
setPickedType((destination.type as DestinationType) ?? "http");
setModalOpen(true);
};
@@ -434,7 +437,7 @@ export default function StreamingDestinationsPage() {
disabled={!isEnterprise}
/>
))}
{/* Add card is always clickable paywall is enforced inside the picker */}
{/* Add card is always clickable - paywall is enforced inside the picker */}
<AddDestinationCard onClick={openCreate} />
</div>
)}
@@ -446,13 +449,33 @@ export default function StreamingDestinationsPage() {
isPaywalled={!isEnterprise}
/>
<HttpDestinationCredenza
open={modalOpen}
onOpenChange={setModalOpen}
editing={editingDestination}
orgId={orgId}
onSaved={loadDestinations}
/>
{pickedType === "http" && (
<HttpDestinationCredenza
open={modalOpen}
onOpenChange={setModalOpen}
editing={editingDestination}
orgId={orgId}
onSaved={loadDestinations}
/>
)}
{pickedType === "s3" && (
<S3DestinationCredenza
open={modalOpen}
onOpenChange={setModalOpen}
editing={editingDestination}
orgId={orgId}
onSaved={loadDestinations}
/>
)}
{pickedType === "datadog" && (
<DatadogDestinationCredenza
open={modalOpen}
onOpenChange={setModalOpen}
editing={editingDestination}
orgId={orgId}
onSaved={loadDestinations}
/>
)}
{deleteTarget && (
<ConfirmDeleteDialog

View File

@@ -65,23 +65,34 @@ export default async function ClientResourcesPage(
id: siteResource.siteResourceId,
name: siteResource.name,
orgId: params.orgId,
siteName: siteResource.siteName,
siteAddress: siteResource.siteAddress || null,
mode: siteResource.mode || ("port" as any),
sites: siteResource.siteIds.map((siteId, idx) => ({
siteId,
siteName: siteResource.siteNames[idx],
siteNiceId: siteResource.siteNiceIds[idx],
online: siteResource.siteOnlines[idx]
})),
mode: siteResource.mode,
scheme: siteResource.scheme,
ssl: siteResource.ssl,
siteNames: siteResource.siteNames,
siteAddresses: siteResource.siteAddresses || null,
// protocol: siteResource.protocol,
// proxyPort: siteResource.proxyPort,
siteId: siteResource.siteId,
siteIds: siteResource.siteIds,
destination: siteResource.destination,
// destinationPort: siteResource.destinationPort,
httpHttpsPort: siteResource.destinationPort ?? null,
alias: siteResource.alias || null,
aliasAddress: siteResource.aliasAddress || null,
siteNiceId: siteResource.siteNiceId,
siteNiceIds: siteResource.siteNiceIds,
niceId: siteResource.niceId,
tcpPortRangeString: siteResource.tcpPortRangeString || null,
udpPortRangeString: siteResource.udpPortRangeString || null,
disableIcmp: siteResource.disableIcmp || false,
authDaemonMode: siteResource.authDaemonMode ?? null,
authDaemonPort: siteResource.authDaemonPort ?? null
authDaemonPort: siteResource.authDaemonPort ?? null,
subdomain: siteResource.subdomain ?? null,
domainId: siteResource.domainId ?? null,
fullDomain: siteResource.fullDomain ?? null
};
}
);

View File

@@ -62,6 +62,7 @@ import { GetResourceResponse } from "@server/routers/resource/getResource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import UptimeAlertSection from "@app/components/UptimeAlertSection";
type MaintenanceSectionFormProps = {
resource: GetResourceResponse;
@@ -578,6 +579,13 @@ export default function GeneralForm() {
return (
<>
<SettingsContainer>
{resource?.resourceId && resource?.orgId && (
<UptimeAlertSection
orgId={resource.orgId}
resourceId={resource.resourceId}
startingName={resource.name}
/>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>

View File

@@ -1,6 +1,6 @@
"use client";
import HealthCheckDialog from "@/components/HealthCheckDialog";
import HealthCheckCredenza from "@/components/HealthCheckCredenza";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -168,6 +168,30 @@ function ProxyResourceTargetsForm({
const [targets, setTargets] = useState<LocalTarget[]>(initialTargets);
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
const { data: polledTargets } = useQuery({
...resourceQueries.resourceTargets({
resourceId: resource.resourceId
}),
refetchInterval: 10_000
});
useEffect(() => {
if (!polledTargets) return;
setTargets((prev) =>
prev.map((t) => {
const fresh = polledTargets.find(
(p) => p.targetId === t.targetId
);
if (!fresh) return t;
return {
...t,
hcHealth: fresh.hcHealth,
hcEnabled: t.updated ? t.hcEnabled : fresh.hcEnabled
};
})
);
}, [polledTargets]);
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
new Map()
);
@@ -317,19 +341,6 @@ function ProxyResourceTargetsForm({
header: () => <span className="p-3">{t("healthCheck")}</span>,
cell: ({ row }) => {
const status = row.original.hcHealth || "unknown";
const isEnabled = row.original.hcEnabled;
const getStatusColor = (status: string) => {
switch (status) {
case "healthy":
return "green";
case "unhealthy":
return "red";
case "unknown":
default:
return "secondary";
}
};
const getStatusText = (status: string) => {
switch (status) {
@@ -343,19 +354,7 @@ function ProxyResourceTargetsForm({
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "healthy":
return <CircleCheck className="w-3 h-3" />;
case "unhealthy":
return <CircleX className="w-3 h-3" />;
case "unknown":
default:
return null;
}
};
return (
return (
<div className="flex items-center justify-center w-full">
{row.original.siteType === "newt" ? (
<Button
@@ -366,12 +365,15 @@ function ProxyResourceTargetsForm({
}
>
<div
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : ""}`}
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : "text-neutral-500"}`}
>
<Settings className="h-4 w-4 text-foreground" />
<div
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
></div>
{getStatusText(status)}
</div>
</Button>
) : (
<span>-</span>
)}
@@ -640,10 +642,10 @@ function ProxyResourceTargetsForm({
hcInterval: null,
hcTimeout: null,
hcHeaders: null,
hcFollowRedirects: null,
hcScheme: null,
hcHostname: null,
hcPort: null,
hcFollowRedirects: null,
hcHealth: "unknown",
hcStatus: null,
hcMode: null,
@@ -965,10 +967,10 @@ function ProxyResourceTargetsForm({
</SettingsSection>
{selectedTargetForHealthCheck && (
<HealthCheckDialog
<HealthCheckCredenza
mode="autoSave"
open={healthCheckDialogOpen}
setOpen={setHealthCheckDialogOpen}
targetId={selectedTargetForHealthCheck.targetId}
targetAddress={`${selectedTargetForHealthCheck.ip}:${selectedTargetForHealthCheck.port}`}
targetMethod={
selectedTargetForHealthCheck.method || undefined
@@ -993,7 +995,7 @@ function ProxyResourceTargetsForm({
selectedTargetForHealthCheck.hcPort ||
selectedTargetForHealthCheck.port,
hcFollowRedirects:
selectedTargetForHealthCheck.hcFollowRedirects ||
selectedTargetForHealthCheck.hcFollowRedirects ??
true,
hcStatus:
selectedTargetForHealthCheck.hcStatus || undefined,

View File

@@ -2,7 +2,7 @@
import CopyTextBox from "@app/components/CopyTextBox";
import DomainPicker from "@app/components/DomainPicker";
import HealthCheckDialog from "@app/components/HealthCheckDialog";
import HealthCheckCredenza from "@app/components/HealthCheckCredenza";
import {
PathMatchDisplay,
PathMatchModal,
@@ -1477,12 +1477,10 @@ export default function Page() {
</Button>
</div>
{selectedTargetForHealthCheck && (
<HealthCheckDialog
<HealthCheckCredenza
mode="autoSave"
open={healthCheckDialogOpen}
setOpen={setHealthCheckDialogOpen}
targetId={
selectedTargetForHealthCheck.targetId
}
targetAddress={`${selectedTargetForHealthCheck.ip}:${selectedTargetForHealthCheck.port}`}
targetMethod={
selectedTargetForHealthCheck.method ||
@@ -1517,7 +1515,7 @@ export default function Page() {
selectedTargetForHealthCheck.hcPort ||
selectedTargetForHealthCheck.port,
hcFollowRedirects:
selectedTargetForHealthCheck.hcFollowRedirects ||
selectedTargetForHealthCheck.hcFollowRedirects ??
true,
hcStatus:
selectedTargetForHealthCheck.hcStatus ||

View File

@@ -1,5 +1,7 @@
"use client";
import UptimeAlertSection from "@app/components/UptimeAlertSection";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -111,6 +113,13 @@ export default function GeneralPage() {
return (
<SettingsContainer>
{site?.siteId && site?.orgId && (
<UptimeAlertSection
orgId={site.orgId}
siteId={site.siteId}
startingName={site.name}
/>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>

View File

@@ -425,7 +425,7 @@ export default function Page() {
setRemoteExitNodeOptions(exitNodeOptions);
if (exitNodeOptions.length === 0) {
// No remote exit nodes available remove local option and default to newt
// No remote exit nodes available - remove local option and default to newt
setTunnelTypes((prev: any) =>
prev.filter((item: any) => item.id !== "local")
);
@@ -434,7 +434,7 @@ export default function Page() {
}
} catch (error) {
console.error("Failed to fetch remote exit nodes:", error);
// If fetch fails, no remote exit nodes available remove local option and default to newt
// If fetch fails, no remote exit nodes available - remove local option and default to newt
setTunnelTypes((prev: any) =>
prev.filter((item: any) => item.id !== "local")
);

View File

@@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
import { Env } from "@app/lib/types/env";
import { build } from "@server/build";
import {
BellRing,
Boxes,
Building2,
Cable,
@@ -212,9 +213,9 @@ export const orgNavSections = (
icon: <Building2 className="size-4 flex-none" />,
items: [
{
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" />
title: "sidebarAlerting",
href: "/{orgId}/settings/alerting",
icon: <BellRing className="size-4 flex-none" />
},
{
title: "sidebarProvisioning",
@@ -225,6 +226,11 @@ export const orgNavSections = (
title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints",
icon: <ReceiptText className="size-4 flex-none" />
},
{
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" />
}
]
},

View File

@@ -0,0 +1,32 @@
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import {
Card,
CardContent,
CardHeader,
CardTitle
} from "@app/components/ui/card";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Private Placeholder"
};
export default async function MaintenanceScreen() {
const t = await getTranslations();
let title = t("privateMaintenanceScreenTitle");
let message = t("privateMaintenanceScreenMessage");
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">{message}</CardContent>
</Card>
</div>
);
}