diff --git a/messages/en-US.json b/messages/en-US.json index 89ef232c5..5bb1af511 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1,4 +1,8 @@ { + "contactSalesEnable": "Contact sales to enable this feature.", + "contactSalesBookDemo": "Book a demo", + "contactSalesOr": "or", + "contactSalesContactUs": "contact us", "setupCreate": "Create the organization, site, and resources", "headerAuthCompatibilityInfo": "Enable this to force a 401 Unauthorized response when an authentication token is missing. This is required for browsers or specific HTTP libraries that do not send credentials without a server challenge.", "headerAuthCompatibility": "Extended compatibility", @@ -3047,5 +3051,6 @@ "healthCheckTabStrategy": "Strategy", "healthCheckTabConnection": "Connection", "healthCheckTabAdvanced": "Advanced", - "healthCheckStrategyNotAvailable": "This strategy is not available. Please contact sales to enable this feature." + "healthCheckStrategyNotAvailable": "This strategy is not available. Please contact sales to enable this feature.", + "uptime30d": "Uptime (30d)" } diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts new file mode 100644 index 000000000..9ede25fe6 --- /dev/null +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -0,0 +1,91 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import logger from "@server/logger"; +import { processAlerts } from "../processAlerts"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Fire a `health_check_healthy` alert for the given health check. + * + * Call this after a previously-failing health check has recovered so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the health check. + * @param healthCheckId - Numeric primary key of the health check. + * @param healthCheckName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireHealthCheckHealthyAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string | null, + extra?: Record +): Promise { + try { + await processAlerts({ + eventType: "health_check_healthy", + orgId, + healthCheckId, + data: { + healthCheckId, + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } +} + +/** + * Fire a `health_check_not_healthy` alert for the given health check. + * + * Call this after a health check has been detected as failing so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the health check. + * @param healthCheckId - Numeric primary key of the health check. + * @param healthCheckName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireHealthCheckNotHealthyAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string | null, + extra?: Record +): Promise { + try { + await processAlerts({ + eventType: "health_check_not_healthy", + orgId, + healthCheckId, + data: { + healthCheckId, + ...(healthCheckName != null ? { healthCheckName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireHealthCheckNotHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 34bbe4f88..a17c88fb1 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -427,6 +427,13 @@ authenticated.get( resource.listResources ); +authenticated.get( + "/resource/:resourceId/status-history", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.getResource), + resource.getResourceStatusHistory +); + authenticated.get( "/org/:orgId/resources", verifyOrgAccess, diff --git a/server/routers/resource/getStatusHistory.ts b/server/routers/resource/getStatusHistory.ts new file mode 100644 index 000000000..9aa548624 --- /dev/null +++ b/server/routers/resource/getStatusHistory.ts @@ -0,0 +1,93 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, statusHistory } from "@server/db"; +import { and, eq, gte, asc } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { + computeBuckets, + statusHistoryQuerySchema, + StatusHistoryResponse +} from "@server/lib/statusHistory"; + +const resourceParamsSchema = z.object({ + resourceId: z.string().transform((v) => parseInt(v, 10)) +}); + +export async function getResourceStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = resourceParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const parsedQuery = statusHistoryQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "resource"; + const entityId = parsedParams.data.resourceId; + const { days } = parsedQuery.data; + + const nowSec = Math.floor(Date.now() / 1000); + const startSec = nowSec - days * 86400; + + const events = await db + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + eq(statusHistory.entityId, entityId), + gte(statusHistory.timestamp, startSec) + ) + ) + .orderBy(asc(statusHistory.timestamp)); + + const { buckets, totalDowntime } = computeBuckets(events, days); + const totalWindow = days * 86400; + const overallUptime = + totalWindow > 0 + ? Math.max( + 0, + ((totalWindow - totalDowntime) / totalWindow) * 100 + ) + : 100; + + return response(res, { + data: { + entityType, + entityId, + days: buckets, + overallUptimePercent: Math.round(overallUptime * 100) / 100, + totalDowntimeSeconds: totalDowntime + }, + success: true, + error: false, + message: "Status history retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 12e98a70d..6a259d7fe 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -32,3 +32,4 @@ export * from "./addUserToResource"; export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; +export * from "./getStatusHistory"; diff --git a/src/components/ContactSalesBanner.tsx b/src/components/ContactSalesBanner.tsx index fedd5e49a..e5cb87d83 100644 --- a/src/components/ContactSalesBanner.tsx +++ b/src/components/ContactSalesBanner.tsx @@ -2,32 +2,35 @@ import { KeyRound, ExternalLink } from "lucide-react"; import Link from "next/link"; +import { useTranslations } from "next-intl"; export function ContactSalesBanner() { + const t = useTranslations(); + return (
- Contact sales to enable this feature.{" "} + {t("contactSalesEnable")}{" "} - Book a demo + {t("contactSalesBookDemo")} - {" or "} + {" " + t("contactSalesOr") + " "} - contact us + {t("contactSalesContactUs")} . @@ -36,4 +39,4 @@ export function ContactSalesBanner() {
); -} +} \ No newline at end of file diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index a5d35b2e0..a09af2da1 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -229,7 +229,7 @@ export default function HealthChecksTable({ { id: "uptime", friendlyName: "Uptime", - header: () => Uptime (30d), + header: () => {t("uptime30d")}, cell: ({ row }) => { return ( diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index fbb544ddf..2990445b0 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -19,6 +19,7 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { UpdateResourceResponse } from "@server/routers/resource"; import type { PaginationState } from "@tanstack/react-table"; +import { useQuery } from "@tanstack/react-query"; import { AxiosResponse } from "axios"; import { ArrowDown01Icon, @@ -37,6 +38,7 @@ import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { + useEffect, useOptimistic, useRef, useState, @@ -47,6 +49,13 @@ import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { ControlledDataTable } from "./ui/controlled-data-table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; +import type { StatusHistoryResponse } from "@server/lib/statusHistory"; export type TargetHealth = { targetId: number; @@ -161,6 +170,13 @@ export default function ProxyResourcesTable({ const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); + useEffect(() => { + const interval = setInterval(() => { + router.refresh(); + }, 10_000); + return () => clearInterval(interval); + }, []); + const refreshData = () => { startTransition(() => { try { @@ -322,6 +338,82 @@ export default function ProxyResourcesTable({ ); } + function ResourceStatusHistory({ + resourceId, + api + }: { + resourceId: number; + api: ReturnType; + }) { + const { data: history, isLoading: loading } = useQuery({ + queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, 30], + queryFn: async ({ signal }) => { + const res = await api.get( + `/resource/${resourceId}/status-history`, + { + params: { days: 30 }, + signal + } + ); + return (res.data.data ?? res.data) as StatusHistoryResponse; + }, + staleTime: 5 * 60 * 1000, + meta: { api } + }); + + if (loading) { + return ( +
+ {Array.from({ length: 90 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (!history) return null; + + return ( +
+ +
+ {history.days.map((bucket, i) => { + const colorClass = + bucket.status === "good" + ? "bg-green-500" + : bucket.status === "degraded" + ? "bg-yellow-500" + : bucket.status === "bad" + ? "bg-red-500" + : "bg-muted"; + return ( + + +
+ + + + {bucket.date}:{" "} + {bucket.uptimePercent}% uptime + + + + ); + })} +
+ + + {history.overallUptimePercent.toFixed(1)}% uptime + +
+ ); + } + const proxyColumns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -422,6 +514,20 @@ export default function ProxyResourcesTable({ return statusOrder[statusA] - statusOrder[statusB]; } }, + { + id: "statusHistory", + friendlyName: t("statusHistory"), + header: () => {t("statusHistory")}, + cell: ({ row }) => { + const resourceRow = row.original; + return ( + + ); + } + }, { accessorKey: "domain", friendlyName: t("access"), diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 68fbc0cac..ffec95283 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -30,7 +30,7 @@ import { import { useTranslations } from "next-intl"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; +import { useState, useTransition, useEffect } from "react"; import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; @@ -85,6 +85,13 @@ export default function SitesTable({ const api = createApiClient(useEnvContext()); const t = useTranslations(); + useEffect(() => { + const interval = setInterval(() => { + router.refresh(); + }, 10_000); + return () => clearInterval(interval); + }, []); + const booleanSearchFilterSchema = z .enum(["true", "false"]) .optional() @@ -226,7 +233,7 @@ export default function SitesTable({ { id: "uptime", friendlyName: "Uptime", - header: () => Uptime (30d), + header: () => {t("uptime30d")}, cell: ({ row }) => { const originalRow = row.original; return ( diff --git a/src/components/UptimeMiniBar.tsx b/src/components/UptimeMiniBar.tsx index f668ac023..5685b9ca5 100644 --- a/src/components/UptimeMiniBar.tsx +++ b/src/components/UptimeMiniBar.tsx @@ -54,13 +54,15 @@ export default function UptimeMiniBar({ const siteQuery = useQuery({ ...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }), enabled: siteId != null, - meta: { api } + meta: { api }, + staleTime: 5 * 60 * 1000 }); const hcQuery = useQuery({ ...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }), enabled: healthCheckId != null && siteId == null, - meta: { api } + meta: { api }, + staleTime: 5 * 60 * 1000 }); const { data, isLoading } = siteId != null ? siteQuery : hcQuery;