Compare commits

...

3 Commits

Author SHA1 Message Date
Owen b2d5a1ffdf Remove weird extra status history component 2026-04-17 17:45:49 -07:00
Owen a5b8a44e78 Add the status to the resources and ajust location 2026-04-17 17:40:00 -07:00
Owen df8104fe56 Write the resource status as well 2026-04-17 17:25:51 -07:00
7 changed files with 128 additions and 104 deletions
@@ -1,4 +1,11 @@
import { db, targets, resources, sites, targetHealthCheck, statusHistory } from "@server/db";
import {
db,
targets,
resources,
sites,
targetHealthCheck,
statusHistory
} from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { Newt } from "@server/db";
import { eq, and } from "drizzle-orm";
@@ -88,6 +95,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
orgId: targetHealthCheck.orgId,
targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
resourceOrgId: resources.orgId,
resourceId: resources.resourceId,
name: targetHealthCheck.name,
hcStatus: targetHealthCheck.hcHealth
})
@@ -134,8 +142,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
| "healthy"
| "unhealthy"
})
.where(eq(targetHealthCheck.targetId, targetIdNum))
.execute();
.where(eq(targetHealthCheck.targetId, targetIdNum));
// Log the state change to status history
await db.insert(statusHistory).values({
@@ -143,8 +150,49 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
entityId: targetCheck.targetHealthCheckId,
orgId: targetCheck.orgId || targetCheck.resourceOrgId,
status: healthStatus.status,
timestamp: Math.floor(Date.now() / 1000),
}).execute();
timestamp: Math.floor(Date.now() / 1000)
});
if (targetCheck.resourceId) {
// Log the state change to status history for the resource as well
// so we can show the resource status along with the site
// if the status is healthy we should check if ALL of the targets on the resource are currently healthy and if not then dont mark the resource as healthy yet, we want to wait until all targets are healthy to mark the resource as healthy
let status = healthStatus.status;
if (healthStatus.status === "healthy") {
const otherTargets = await db
.select({ hcHealth: targetHealthCheck.hcHealth })
.from(targets)
.innerJoin(
targetHealthCheck,
eq(targets.targetId, targetHealthCheck.targetId)
)
.where(
and(
eq(targets.resourceId, targetCheck.resourceId),
eq(targets.targetId, targetIdNum) // only check the other targets, not the one we just updated
)
);
const allHealthy = otherTargets.every(
(t) => t.hcHealth === "healthy"
);
if (!allHealthy) {
logger.debug(
`Not marking resource ${targetCheck.resourceId} as healthy because not all targets are healthy`
);
status = "unhealthy";
}
}
await db.insert(statusHistory).values({
entityType: "resource",
entityId: targetCheck.resourceId,
orgId: targetCheck.orgId || targetCheck.resourceOrgId,
status: status,
timestamp: Math.floor(Date.now() / 1000)
});
}
// because we are checking above if there was a change we can fire the alert here because it changed
if (healthStatus.status === "unhealthy") {
@@ -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 UptimeBar from "@app/components/UptimeBar";
type MaintenanceSectionFormProps = {
resource: GetResourceResponse;
@@ -578,6 +579,19 @@ export default function GeneralForm() {
return (
<>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Uptime</SettingsSectionTitle>
<SettingsSectionDescription>
Site availability over the last 90 days.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{resource?.resourceId && (
<UptimeBar resourceId={resource.resourceId} days={90} />
)}
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -113,6 +113,19 @@ export default function GeneralPage() {
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Uptime</SettingsSectionTitle>
<SettingsSectionDescription>
Site availability over the last 90 days.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{site?.siteId && (
<UptimeBar siteId={site.siteId} days={90} />
)}
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -225,19 +238,6 @@ export default function GeneralPage() {
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Uptime</SettingsSectionTitle>
<SettingsSectionDescription>
Site availability over the last 90 days.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{site?.siteId && (
<UptimeBar siteId={site.siteId} days={90} />
)}
</SettingsSectionBody>
</SettingsSection>
</SettingsContainer>
);
}
+4 -82
View File
@@ -56,6 +56,7 @@ import {
TooltipTrigger
} from "@app/components/ui/tooltip";
import type { StatusHistoryResponse } from "@server/lib/statusHistory";
import UptimeMiniBar from "./UptimeMiniBar";
export type TargetHealth = {
targetId: number;
@@ -338,82 +339,6 @@ 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>[] = [
{
accessorKey: "name",
@@ -516,15 +441,12 @@ export default function ProxyResourcesTable({
},
{
id: "statusHistory",
friendlyName: t("statusHistory"),
header: () => <span className="p-3">{t("statusHistory")}</span>,
friendlyName: t("uptime30d"),
header: () => <span className="p-3">{t("uptime30d")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<ResourceStatusHistory
resourceId={resourceRow.id}
api={api}
/>
<UptimeMiniBar resourceId={resourceRow.id} days={30} />
);
}
},
+13 -2
View File
@@ -47,6 +47,7 @@ const barColorClass: Record<string, string> = {
type UptimeBarProps = {
orgId?: string;
siteId?: number;
resourceId?: number;
healthCheckId?: number;
days?: number;
title?: string;
@@ -56,6 +57,7 @@ type UptimeBarProps = {
export default function UptimeBar({
orgId,
siteId,
resourceId,
healthCheckId,
days = 90,
title,
@@ -71,11 +73,20 @@ export default function UptimeBar({
const hcQuery = useQuery({
...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }),
enabled: healthCheckId != null && siteId == null,
enabled: healthCheckId != null && siteId == null && resourceId == null,
meta: { api }
});
const { data, isLoading } = siteId != null ? siteQuery : hcQuery;
const resourceQuery = useQuery({
...orgQueries.resourceStatusHistory({ resourceId, days }),
enabled: resourceId != null && siteId == null && healthCheckId == null,
meta: { api }
});
const { data, isLoading } =
siteId != null ? siteQuery :
resourceId != null ? resourceQuery :
hcQuery;
if (isLoading) {
return (
+14 -2
View File
@@ -39,6 +39,7 @@ const barColorClass: Record<string, string> = {
type UptimeMiniBarProps = {
orgId?: string;
siteId?: number;
resourceId?: number;
healthCheckId?: number;
days?: number;
};
@@ -46,6 +47,7 @@ type UptimeMiniBarProps = {
export default function UptimeMiniBar({
orgId,
siteId,
resourceId,
healthCheckId,
days = 30
}: UptimeMiniBarProps) {
@@ -60,12 +62,22 @@ export default function UptimeMiniBar({
const hcQuery = useQuery({
...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }),
enabled: healthCheckId != null && siteId == null,
enabled: healthCheckId != null && siteId == null && resourceId == null,
meta: { api },
staleTime: 5 * 60 * 1000
});
const { data, isLoading } = siteId != null ? siteQuery : hcQuery;
const resourceQuery = useQuery({
...orgQueries.resourceStatusHistory({ resourceId, days }),
enabled: resourceId != null && siteId == null && healthCheckId == null,
meta: { api },
staleTime: 5 * 60 * 1000
});
const { data, isLoading } =
siteId != null ? siteQuery :
resourceId != null ? resourceQuery :
hcQuery;
if (isLoading) {
return (
+17
View File
@@ -323,6 +323,23 @@ export const orgQueries = {
}
}),
resourceStatusHistory: ({
resourceId,
days = 90
}: {
resourceId?: number;
days?: number;
}) =>
queryOptions({
queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, days] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<StatusHistoryResponse>
>(`/resource/${resourceId}/status-history?days=${days}`, { signal });
return res.data.data;
}
}),
healthCheckStatusHistory: ({
orgId,
healthCheckId,