More refreshing and status history displays

This commit is contained in:
Owen
2026-04-17 17:18:15 -07:00
parent 74165aa1cc
commit 8214700eaa
10 changed files with 326 additions and 11 deletions

View File

@@ -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", "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.", "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", "headerAuthCompatibility": "Extended compatibility",
@@ -3047,5 +3051,6 @@
"healthCheckTabStrategy": "Strategy", "healthCheckTabStrategy": "Strategy",
"healthCheckTabConnection": "Connection", "healthCheckTabConnection": "Connection",
"healthCheckTabAdvanced": "Advanced", "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)"
} }

View File

@@ -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<string, unknown>
): Promise<void> {
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<string, unknown>
): Promise<void> {
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
);
}
}

View File

@@ -427,6 +427,13 @@ authenticated.get(
resource.listResources resource.listResources
); );
authenticated.get(
"/resource/:resourceId/status-history",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.getResource),
resource.getResourceStatusHistory
);
authenticated.get( authenticated.get(
"/org/:orgId/resources", "/org/:orgId/resources",
verifyOrgAccess, verifyOrgAccess,

View File

@@ -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<any> {
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<StatusHistoryResponse>(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")
);
}
}

View File

@@ -32,3 +32,4 @@ export * from "./addUserToResource";
export * from "./removeUserFromResource"; export * from "./removeUserFromResource";
export * from "./listAllResourceNames"; export * from "./listAllResourceNames";
export * from "./removeEmailFromResourceWhitelist"; export * from "./removeEmailFromResourceWhitelist";
export * from "./getStatusHistory";

View File

@@ -2,32 +2,35 @@
import { KeyRound, ExternalLink } from "lucide-react"; import { KeyRound, ExternalLink } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl";
export function ContactSalesBanner() { export function ContactSalesBanner() {
const t = useTranslations();
return ( return (
<div className="rounded-md border border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden"> <div className="rounded-md border border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden">
<div className="py-3 px-4"> <div className="py-3 px-4">
<div className="flex items-center gap-2.5 text-sm text-muted-foreground"> <div className="flex items-center gap-2.5 text-sm text-muted-foreground">
<KeyRound className="size-4 shrink-0 text-black-500" /> <KeyRound className="size-4 shrink-0 text-black-500" />
<span> <span>
Contact sales to enable this feature.{" "} {t("contactSalesEnable")}{" "}
<Link <Link
href="https://click.fossorial.io/ep922" href="https://click.fossorial.io/ep922"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-black-600 underline" className="inline-flex items-center gap-1 font-medium text-black-600 underline"
> >
Book a demo {t("contactSalesBookDemo")}
<ExternalLink className="size-3.5 shrink-0" /> <ExternalLink className="size-3.5 shrink-0" />
</Link> </Link>
{" or "} {" " + t("contactSalesOr") + " "}
<Link <Link
href="https://pangolin.net/contact" href="https://pangolin.net/contact"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-black-600 underline" className="inline-flex items-center gap-1 font-medium text-black-600 underline"
> >
contact us {t("contactSalesContactUs")}
<ExternalLink className="size-3.5 shrink-0" /> <ExternalLink className="size-3.5 shrink-0" />
</Link> </Link>
. .

View File

@@ -229,7 +229,7 @@ export default function HealthChecksTable({
{ {
id: "uptime", id: "uptime",
friendlyName: "Uptime", friendlyName: "Uptime",
header: () => <span className="p-3">Uptime (30d)</span>, header: () => <span className="p-3">{t("uptime30d")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<UptimeMiniBar orgId={orgId} healthCheckId={row.original.targetHealthCheckId} days={30} /> <UptimeMiniBar orgId={orgId} healthCheckId={row.original.targetHealthCheckId} days={30} />

View File

@@ -19,6 +19,7 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { UpdateResourceResponse } from "@server/routers/resource"; import { UpdateResourceResponse } from "@server/routers/resource";
import type { PaginationState } from "@tanstack/react-table"; import type { PaginationState } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { import {
ArrowDown01Icon, ArrowDown01Icon,
@@ -37,6 +38,7 @@ import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
useEffect,
useOptimistic, useOptimistic,
useRef, useRef,
useState, useState,
@@ -47,6 +49,13 @@ import { useDebouncedCallback } from "use-debounce";
import z from "zod"; import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import { ControlledDataTable } from "./ui/controlled-data-table"; 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 = { export type TargetHealth = {
targetId: number; targetId: number;
@@ -161,6 +170,13 @@ export default function ProxyResourcesTable({
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition();
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 10_000);
return () => clearInterval(interval);
}, []);
const refreshData = () => { const refreshData = () => {
startTransition(() => { startTransition(() => {
try { try {
@@ -322,6 +338,82 @@ export default function ProxyResourcesTable({
); );
} }
function ResourceStatusHistory({
resourceId,
api
}: {
resourceId: number;
api: ReturnType<typeof createApiClient>;
}) {
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 (
<div className="flex items-center gap-0.5">
{Array.from({ length: 90 }).map((_, i) => (
<div
key={i}
className="w-1 h-6 rounded-sm bg-muted animate-pulse"
/>
))}
</div>
);
}
if (!history) return null;
return (
<div className="flex items-center gap-2">
<TooltipProvider>
<div className="flex items-center gap-0.5">
{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 (
<Tooltip key={i}>
<TooltipTrigger asChild>
<div
className={`w-1 h-6 rounded-sm ${colorClass} cursor-default`}
/>
</TooltipTrigger>
<TooltipContent>
<span>
{bucket.date}:{" "}
{bucket.uptimePercent}% uptime
</span>
</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
<span className="text-sm text-muted-foreground whitespace-nowrap">
{history.overallUptimePercent.toFixed(1)}% uptime
</span>
</div>
);
}
const proxyColumns: ExtendedColumnDef<ResourceRow>[] = [ const proxyColumns: ExtendedColumnDef<ResourceRow>[] = [
{ {
accessorKey: "name", accessorKey: "name",
@@ -422,6 +514,20 @@ export default function ProxyResourcesTable({
return statusOrder[statusA] - statusOrder[statusB]; return statusOrder[statusA] - statusOrder[statusB];
} }
}, },
{
id: "statusHistory",
friendlyName: t("statusHistory"),
header: () => <span className="p-3">{t("statusHistory")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<ResourceStatusHistory
resourceId={resourceRow.id}
api={api}
/>
);
}
},
{ {
accessorKey: "domain", accessorKey: "domain",
friendlyName: t("access"), friendlyName: t("access"),

View File

@@ -30,7 +30,7 @@ import {
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useState, useTransition } from "react"; import { useState, useTransition, useEffect } from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import z from "zod"; import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
@@ -85,6 +85,13 @@ export default function SitesTable({
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const t = useTranslations(); const t = useTranslations();
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 10_000);
return () => clearInterval(interval);
}, []);
const booleanSearchFilterSchema = z const booleanSearchFilterSchema = z
.enum(["true", "false"]) .enum(["true", "false"])
.optional() .optional()
@@ -226,7 +233,7 @@ export default function SitesTable({
{ {
id: "uptime", id: "uptime",
friendlyName: "Uptime", friendlyName: "Uptime",
header: () => <span className="p-3">Uptime (30d)</span>, header: () => <span className="p-3">{t("uptime30d")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const originalRow = row.original; const originalRow = row.original;
return ( return (

View File

@@ -54,13 +54,15 @@ export default function UptimeMiniBar({
const siteQuery = useQuery({ const siteQuery = useQuery({
...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }), ...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }),
enabled: siteId != null, enabled: siteId != null,
meta: { api } meta: { api },
staleTime: 5 * 60 * 1000
}); });
const hcQuery = useQuery({ const hcQuery = useQuery({
...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }), ...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }),
enabled: healthCheckId != null && siteId == null, enabled: healthCheckId != null && siteId == null,
meta: { api } meta: { api },
staleTime: 5 * 60 * 1000
}); });
const { data, isLoading } = siteId != null ? siteQuery : hcQuery; const { data, isLoading } = siteId != null ? siteQuery : hcQuery;