feat: add AI config for detect error automatically

This commit is contained in:
2026-08-29 18:24:06 +07:00
parent 2261c6d38b
commit 6998f83201
39 changed files with 3092 additions and 529 deletions
+17 -11
View File
@@ -1,3 +1,4 @@
import { TooltipProvider } from '@/components/ui/tooltip';
import { RequireAuth } from './components/RequireAuth';
import { usePathname } from './lib/router';
import { DashboardPage } from './pages/DashboardPage';
@@ -8,17 +9,22 @@ import { StatusPage } from './pages/StatusPage';
function App() {
const pathname = usePathname();
if (pathname === '/') return <StatusPage />;
if (pathname === '/login') return <LoginPage />;
const monitorMatch = pathname.match(/^\/monitors\/(\d+)\/?$/);
const page = monitorMatch ? (
<MonitorDetailPage id={Number(monitorMatch[1])} />
) : pathname === '/settings' ? (
<SettingsPage />
) : (
<DashboardPage />
);
return <RequireAuth>{page}</RequireAuth>;
let content;
if (pathname === '/') content = <StatusPage />;
else if (pathname === '/login') content = <LoginPage />;
else {
const monitorMatch = pathname.match(/^\/monitors\/(\d+)\/?$/);
const page = monitorMatch ? (
<MonitorDetailPage id={Number(monitorMatch[1])} />
) : pathname === '/settings' ? (
<SettingsPage />
) : (
<DashboardPage />
);
content = <RequireAuth>{page}</RequireAuth>;
}
return <TooltipProvider>{content}</TooltipProvider>;
}
export default App;
+1
View File
@@ -52,6 +52,7 @@ export type Incident = {
resolvedAt: string | null;
startStatusCode: number | null;
startError: string | null;
aiMessage: string | null;
durationMs: number | null;
createdAt: string;
updatedAt: string;
+33
View File
@@ -10,6 +10,24 @@ export type NotificationSettings = {
export type NotificationSettingsInput = Pick<NotificationSettings, 'webhookUrl' | 'webhookEnabled'>;
export type AiSettings = {
id: number;
enabled: boolean;
baseUrl: string | null;
model: string | null;
apiKeySet: boolean;
apiKeyPreview: string | null;
createdAt: string | null;
updatedAt: string | null;
};
export type AiSettingsInput = {
enabled: boolean;
baseUrl: string | null;
model: string | null;
apiKey?: string | null;
};
export function getNotificationSettings(signal?: AbortSignal) {
return getJson<{ settings: NotificationSettings }>('/api/settings/notifications', {
signal,
@@ -24,3 +42,18 @@ export function updateNotificationSettings(input: NotificationSettingsInput) {
export function testNotificationWebhook() {
return postJson<{ ok: true }>('/api/settings/notifications/test');
}
export function getAiSettings(signal?: AbortSignal) {
return getJson<{ settings: AiSettings }>('/api/settings/ai', {
signal,
credentials: 'same-origin',
});
}
export function updateAiSettings(input: AiSettingsInput) {
return putJson<{ settings: AiSettings }>('/api/settings/ai', input);
}
export function testAiSettings() {
return postJson<{ ok: true; message: string }>('/api/settings/ai/test');
}
+1
View File
@@ -7,6 +7,7 @@ export type PublicService = {
id: number;
name: string;
status: PublicServiceStatus;
message: string | null;
lastCheckedAt: string | null;
uptime90d: number | null;
history: Array<{
+124
View File
@@ -0,0 +1,124 @@
import { Activity, LogOut, Menu, Settings, Zap, type LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { navigate, usePathname } from '../lib/router';
import { useLogoutMutation } from '../queries/auth';
type NavigationItem = {
label: string;
icon: LucideIcon;
href?: string;
onClick: () => void;
disabled?: boolean;
};
export function AppHeader({ context }: { context?: string }) {
const pathname = usePathname();
const logoutMutation = useLogoutMutation();
const items: NavigationItem[] = [
{
label: 'View status page',
icon: Activity,
href: '/',
onClick: () => navigate('/'),
},
{
label: 'Settings',
icon: Settings,
href: '/settings',
onClick: () => navigate('/settings'),
},
{
label: logoutMutation.isPending ? 'Signing out…' : 'Sign out',
icon: LogOut,
onClick: () => logoutMutation.mutate(),
disabled: logoutMutation.isPending,
},
];
return (
<header className="dashboard-header">
<div className="dashboard-header-inner">
<Button
variant="unstyled"
className="brand brand-button"
type="button"
onClick={() => navigate('/dashboard')}
aria-label="Upwatch dashboard"
>
<Zap className="brand-mark" fill="currentColor" aria-hidden="true" />
<span>upwatch</span>
</Button>
<nav className="nav-actions" aria-label="Primary navigation">
{context && <span className="header-context">{context}</span>}
{items.map((item) => {
const ItemIcon = item.icon;
const isCurrent = item.href === pathname;
return (
<Tooltip key={item.label}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="app-nav-icon"
type="button"
aria-label={item.label}
aria-current={isCurrent ? 'page' : undefined}
disabled={item.disabled}
onClick={item.onClick}
>
<ItemIcon aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{item.label}</TooltipContent>
</Tooltip>
);
})}
</nav>
<div className="app-nav-mobile-trigger">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" type="button" aria-label="Open menu">
<Menu aria-hidden="true" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="app-nav-sheet">
<SheetHeader className="app-nav-sheet-header">
<SheetTitle className="app-nav-sheet-title">
<Zap fill="currentColor" aria-hidden="true" />
<span>upwatch</span>
</SheetTitle>
{context && <SheetDescription>{context}</SheetDescription>}
</SheetHeader>
<nav className="app-nav-mobile" aria-label="Primary navigation">
{items.map((item) => {
const ItemIcon = item.icon;
const isCurrent = item.href === pathname;
return (
<SheetClose asChild key={item.label}>
<button
className="app-nav-mobile-item"
type="button"
aria-current={isCurrent ? 'page' : undefined}
disabled={item.disabled}
onClick={item.onClick}
>
<ItemIcon aria-hidden="true" />
<span>{item.label}</span>
</button>
</SheetClose>
);
})}
</nav>
</SheetContent>
</Sheet>
</div>
</div>
</header>
);
}
@@ -1,5 +1,7 @@
import { useState } from 'react';
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, type TooltipContentProps } from 'recharts';
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
import { Empty, EmptyDescription, EmptyTitle } from '@/components/ui/empty';
import type { Check } from '../../api/monitors';
type LatencyDatum = {
@@ -8,18 +10,9 @@ type LatencyDatum = {
ok: boolean;
};
function LatencyTooltip({ active, payload }: TooltipContentProps) {
if (!active || !payload.length) return null;
const point = payload[0].payload as LatencyDatum;
return (
<div className="chart-tooltip">
<time>{new Date(point.t).toLocaleString()}</time>
<strong>{point.latency} ms</strong>
<span className={point.ok ? 'is-up' : 'is-down'}>{point.ok ? 'Up' : 'Down'}</span>
</div>
);
}
const latencyConfig = {
latency: { label: 'Latency', color: 'var(--primary-deep)' },
} satisfies ChartConfig;
export function LatencySparkline({ checks }: { checks: Check[] }) {
const [hasAnimated, setHasAnimated] = useState(false);
@@ -30,39 +23,54 @@ export function LatencySparkline({ checks }: { checks: Check[] }) {
}));
const values = data.map((point) => point.latency);
if (data.length < 2) return <div className="chart-empty">More checks are needed to draw latency.</div>;
if (data.length < 2)
return (
<Empty className="min-h-37.5 p-6">
<EmptyTitle className="text-[13px]">Not enough latency data</EmptyTitle>
<EmptyDescription>More checks are needed to draw latency.</EmptyDescription>
</Empty>
);
const minimum = Math.min(...values);
const maximum = Math.max(...values);
return (
<div className="sparkline-wrap" role="img" aria-label={`Latency from ${minimum} to ${maximum} milliseconds`}>
<ResponsiveContainer width="100%" height="100%">
<ChartContainer config={latencyConfig} className="h-full w-full">
<AreaChart data={data} margin={{ top: 10, right: 4, bottom: 4, left: 4 }}>
<defs>
<linearGradient id="latency-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#3ecf8e" stopOpacity="0.22" />
<stop offset="1" stopColor="#3ecf8e" stopOpacity="0" />
<stop offset="0" stopColor="var(--color-latency)" stopOpacity="0.22" />
<stop offset="1" stopColor="var(--color-latency)" stopOpacity="0" />
</linearGradient>
</defs>
<CartesianGrid vertical={false} stroke="#eeeeee" strokeDasharray="3 5" />
<CartesianGrid vertical={false} strokeDasharray="3 5" />
<XAxis dataKey="t" hide />
<YAxis hide domain={['dataMin', 'dataMax']} />
<Tooltip content={LatencyTooltip} cursor={{ stroke: '#9adfc1', strokeDasharray: '3 4' }} isAnimationActive={false} />
<ChartTooltip
content={
<ChartTooltipContent
className="min-w-36 gap-1.5 rounded-[6px] border-(--hairline) bg-white/97 px-3 py-2.5 shadow-[0_8px_24px_rgb(24_74_52/0.1),0_2px_6px_rgb(0_0_0/0.04)] backdrop-blur-sm"
labelFormatter={(_, payload) => new Date((payload[0].payload as LatencyDatum).t).toLocaleString()}
/>
}
cursor={{ stroke: 'var(--primary)', strokeDasharray: '3 4' }}
isAnimationActive={false}
/>
<Area
type="monotone"
dataKey="latency"
stroke="#24b47e"
stroke="var(--color-latency)"
strokeWidth={2.5}
fill="url(#latency-fill)"
activeDot={{ r: 4, fill: '#24b47e', stroke: '#fff', strokeWidth: 2 }}
activeDot={{ r: 4, fill: 'var(--color-latency)', stroke: 'var(--background)', strokeWidth: 2 }}
isAnimationActive={!hasAnimated}
animationDuration={700}
animationEasing="ease-out"
onAnimationEnd={() => setHasAnimated(true)}
/>
</AreaChart>
</ResponsiveContainer>
</ChartContainer>
<div className="chart-scale">
<span>{maximum} ms</span>
<span>{minimum} ms</span>
+32 -17
View File
@@ -1,5 +1,7 @@
import { useState } from 'react';
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis, type TooltipContentProps } from 'recharts';
import { Bar, BarChart, Cell, XAxis, YAxis } from 'recharts';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart';
import { Empty, EmptyDescription, EmptyTitle } from '@/components/ui/empty';
import type { Check } from '../../api/monitors';
type UptimeDatum = {
@@ -7,19 +9,14 @@ type UptimeDatum = {
v: number;
ok: boolean;
id: number;
status: 'up' | 'down';
fill: string;
};
function UptimeTooltip({ active, payload }: TooltipContentProps) {
if (!active || !payload.length) return null;
const point = payload[0].payload as UptimeDatum;
return (
<div className="chart-tooltip chart-tooltip-compact">
<time>{new Date(point.t).toLocaleString()}</time>
<span className={point.ok ? 'is-up' : 'is-down'}>{point.ok ? 'Up' : 'Down'}</span>
</div>
);
}
const uptimeConfig = {
up: { label: 'Up', color: 'var(--primary)' },
down: { label: 'Down', color: 'var(--chart-down)' },
} satisfies ChartConfig;
export function UptimeBar({ checks }: { checks: Check[] }) {
const [hasAnimated, setHasAnimated] = useState(false);
@@ -28,19 +25,37 @@ export function UptimeBar({ checks }: { checks: Check[] }) {
v: 1,
ok: check.ok,
id: check.id,
status: check.ok ? 'up' : 'down',
fill: check.ok ? 'var(--color-up)' : 'var(--color-down)',
}));
if (data.length === 0) return <div className="chart-empty">No availability checks recorded yet.</div>;
if (data.length === 0)
return (
<Empty className="min-h-37.5 p-6">
<EmptyTitle className="text-[13px]">No availability data yet</EmptyTitle>
<EmptyDescription>No availability checks recorded yet.</EmptyDescription>
</Empty>
);
const successfulChecks = data.filter((check) => check.ok).length;
return (
<div>
<div className="uptime-bar" role="img" aria-label={`${successfulChecks} of ${data.length} recent checks succeeded`}>
<ResponsiveContainer width="100%" height="100%">
<ChartContainer config={uptimeConfig} className="h-full w-full">
<BarChart data={data} barCategoryGap={2} margin={{ top: 10, right: 0, bottom: 0, left: 0 }}>
<XAxis dataKey="id" hide />
<YAxis hide domain={[0, 1]} />
<Tooltip content={UptimeTooltip} cursor={{ fill: 'rgb(23 23 23 / 0.04)' }} isAnimationActive={false} />
<ChartTooltip
content={
<ChartTooltipContent
className="min-w-36 gap-1.5 rounded-[6px] border-(--hairline) bg-white/97 px-3 py-2.5 shadow-[0_8px_24px_rgb(24_74_52/0.1),0_2px_6px_rgb(0_0_0/0.04)] backdrop-blur-sm"
hideIndicator={false}
nameKey="status"
labelFormatter={(_, payload) => new Date((payload[0].payload as UptimeDatum).t).toLocaleString()}
/>
}
isAnimationActive={false}
/>
<Bar
dataKey="v"
radius={[2, 2, 0, 0]}
@@ -50,11 +65,11 @@ export function UptimeBar({ checks }: { checks: Check[] }) {
onAnimationEnd={() => setHasAnimated(true)}
>
{data.map((point) => (
<Cell key={point.id} fill={point.ok ? '#3ecf8e' : '#d95c5c'} />
<Cell key={point.id} fill={point.ok ? 'var(--color-up)' : 'var(--color-down)'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartContainer>
</div>
<div className="uptime-legend">
<span>Oldest</span>
@@ -1,37 +0,0 @@
import { Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { navigate } from '../../lib/router';
import { useLogoutMutation } from '../../queries/auth';
export function DashboardHeader() {
const logoutMutation = useLogoutMutation();
return (
<header className="dashboard-header">
<div className="dashboard-header-inner">
<a className="brand" href="/dashboard" aria-label="Upwatch dashboard">
<Zap className="brand-mark" fill="currentColor" />
<span>upwatch</span>
</a>
<div className="nav-actions">
<span className="header-context">Production monitors</span>
<a className="nav-auth" href="/">
View status page
</a>
<Button variant="unstyled" className="nav-auth" type="button" onClick={() => navigate('/settings')}>
Settings
</Button>
<Button
variant="unstyled"
className="nav-auth"
type="button"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
>
{logoutMutation.isPending ? 'Signing out…' : 'Sign out'}
</Button>
</div>
</div>
</header>
);
}
@@ -1,5 +1,6 @@
import { ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { useMonitorsQuery } from '../../queries/monitors';
type DashboardOverviewProps = {
@@ -28,21 +29,21 @@ export function DashboardOverview({ onAddMonitor }: DashboardOverviewProps) {
</section>
<section className="metric-grid" aria-label="Monitor summary">
<div className="metric-card">
<Card className="metric-card">
<p>Total monitors</p>
<strong>{monitors.length}</strong>
<span>{monitors.filter((monitor) => monitor.enabled).length} enabled</span>
</div>
<div className="metric-card">
</Card>
<Card className="metric-card">
<p>Currently up</p>
<strong>{up}</strong>
<span>Latest checks succeeded</span>
</div>
<div className="metric-card">
</Card>
<Card className="metric-card">
<p>Currently down</p>
<strong>{down}</strong>
<span>Needs attention</span>
</div>
</Card>
</section>
</>
);
@@ -17,7 +17,7 @@ export const DEFAULT_MONITOR_INPUT: MonitorInput = {
enabled: true,
};
const INTERVAL_OPTIONS = [
export const INTERVAL_OPTIONS = [
{ value: '300', label: '5 minutes' },
{ value: '900', label: '15 minutes' },
{ value: '1800', label: '30 minutes' },
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { ArrowRight, Database, History, Pencil, Power, PowerOff, RefreshCw, Trash2 } from 'lucide-react';
import { AlertTriangle, ArrowRight, Database, History, Pencil, Power, PowerOff, RefreshCw, Trash2 } from 'lucide-react';
import { Badge, type BadgeVariant } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from '@/components/ui/empty';
import type { Monitor } from '../../api/monitors';
import { navigate } from '../../lib/router';
import { useDeleteMonitorMutation, useMonitorsQuery, useRunCheckMutation, useUpdateMonitorMutation } from '../../queries/monitors';
@@ -83,26 +84,33 @@ export function MonitorListPanel({ formOpen, onAddMonitor, onEdit }: MonitorList
))}
</div>
) : monitorsQuery.isError ? (
<div className="panel-state error-state">
<strong>Monitors could not be loaded</strong>
<p>{errorMessage(monitorsQuery.error, 'Unknown request error')}</p>
<Button variant="unstyled" className="secondary-button" type="button" onClick={() => void monitorsQuery.refetch()}>
Try again
</Button>
</div>
) : monitors.length === 0 ? (
<div className="panel-state empty-state">
<span className="empty-icon">
<Database />
</span>
<strong>No monitors yet</strong>
<p>Add the first endpoint to start collecting availability checks.</p>
{!formOpen && (
<Button variant="unstyled" className="primary-button" type="button" onClick={onAddMonitor}>
Add first site <ArrowRight />
<Empty variant="error">
<EmptyMedia variant="icon">
<AlertTriangle />
</EmptyMedia>
<EmptyTitle>Monitors could not be loaded</EmptyTitle>
<EmptyDescription>{errorMessage(monitorsQuery.error, 'Unknown request error')}</EmptyDescription>
<EmptyContent>
<Button variant="unstyled" className="secondary-button" type="button" onClick={() => void monitorsQuery.refetch()}>
Try again
</Button>
</EmptyContent>
</Empty>
) : monitors.length === 0 ? (
<Empty>
<EmptyMedia variant="icon">
<Database />
</EmptyMedia>
<EmptyTitle>No monitors yet</EmptyTitle>
<EmptyDescription>Add the first endpoint to start collecting availability checks.</EmptyDescription>
{!formOpen && (
<EmptyContent>
<Button variant="unstyled" className="primary-button" type="button" onClick={onAddMonitor}>
Add first site <ArrowRight />
</Button>
</EmptyContent>
)}
</div>
</Empty>
) : (
<div className="monitor-list">
<div className="services-title">
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import type { Monitor } from '../api/monitors';
import { AppHeader } from '../components/AppHeader';
import { DashboardFooter } from '../components/dashboard/DashboardFooter';
import { DashboardHeader } from '../components/dashboard/DashboardHeader';
import { DashboardOverview } from '../components/dashboard/DashboardOverview';
import { MonitorFormDialog } from '../components/dashboard/MonitorFormDialog';
import { MonitorListPanel } from '../components/dashboard/MonitorListPanel';
@@ -27,7 +27,7 @@ export function DashboardPage() {
return (
<div className="dashboard-shell">
<DashboardHeader />
<AppHeader context="Production monitors" />
<main className="dashboard-main">
<DashboardOverview onAddMonitor={openCreateForm} />
+32 -29
View File
@@ -1,6 +1,7 @@
import { type FormEvent, useEffect, useState } from 'react';
import { Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { navigate } from '../lib/router';
import { useLoginMutation, useSessionQuery } from '../queries/auth';
@@ -28,38 +29,40 @@ export function LoginPage() {
<span>upwatch</span>
</div>
<section className="auth-card" aria-labelledby="login-title">
<div className="auth-heading">
<p>Admin access</p>
<h1 id="login-title">Sign in to Upwatch</h1>
<span>Enter the admin password to manage your monitors.</span>
</div>
<form className="auth-form" onSubmit={handleSubmit}>
<div className="auth-field">
<label htmlFor="password">Password</label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
/>
<Card asChild>
<section className="auth-card" aria-labelledby="login-title">
<div className="auth-heading">
<p>Admin access</p>
<h1 id="login-title">Sign in to Upwatch</h1>
<span>Enter the admin password to manage your monitors.</span>
</div>
{loginMutation.isError && (
<p className="auth-error" role="alert">
{errorMessage}
</p>
)}
<form className="auth-form" onSubmit={handleSubmit}>
<div className="auth-field">
<label htmlFor="password">Password</label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
/>
</div>
<Button variant="unstyled" className="auth-submit" type="submit" disabled={loginMutation.isPending}>
{loginMutation.isPending ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</section>
{loginMutation.isError && (
<p className="auth-error" role="alert">
{errorMessage}
</p>
)}
<Button variant="unstyled" className="auth-submit" type="submit" disabled={loginMutation.isPending}>
{loginMutation.isPending ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</section>
</Card>
<p className="auth-footnote">Protected by an encrypted, seven-day session.</p>
</main>
+150 -63
View File
@@ -1,16 +1,24 @@
import { ArrowLeft, BellOff, CheckCircle2, Clock3, ExternalLink, RefreshCw, Zap } from 'lucide-react';
import { useState } from 'react';
import { ArrowLeft, BellOff, CheckCircle2, Clock3, ExternalLink, Pencil, Power, PowerOff, RefreshCw, Trash2 } from 'lucide-react';
import { Badge, type BadgeVariant } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Empty, EmptyContent, EmptyDescription, EmptyTitle } from '@/components/ui/empty';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { AppHeader } from '../components/AppHeader';
import { LatencySparkline } from '../components/charts/LatencySparkline';
import { UptimeBar } from '../components/charts/UptimeBar';
import { DeleteMonitorDialog } from '../components/dashboard/DeleteMonitorDialog';
import { INTERVAL_OPTIONS, MonitorFormDialog } from '../components/dashboard/MonitorFormDialog';
import { navigate } from '../lib/router';
import { useLogoutMutation } from '../queries/auth';
import {
useDeleteMonitorMutation,
useMonitorChecksQuery,
useMonitorIncidentsQuery,
useMonitorQuery,
useMonitorStatsQuery,
useRunCheckMutation,
useUpdateMonitorMutation,
} from '../queries/monitors';
function formatDate(value: string) {
@@ -31,13 +39,20 @@ function statusDetails(lastOk: boolean | null): { label: string; className: Badg
return { label: 'Awaiting first check', className: 'checking' };
}
function formatInterval(seconds: number) {
return INTERVAL_OPTIONS.find((option) => Number(option.value) === seconds)?.label ?? `${Math.round(seconds / 60)} min`;
}
export function MonitorDetailPage({ id }: { id: number }) {
const [isEditing, setIsEditing] = useState(false);
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
const monitorQuery = useMonitorQuery(id);
const checksQuery = useMonitorChecksQuery(id);
const statsQuery = useMonitorStatsQuery(id);
const incidentsQuery = useMonitorIncidentsQuery(id);
const checkMutation = useRunCheckMutation();
const logoutMutation = useLogoutMutation();
const updateMutation = useUpdateMonitorMutation();
const deleteMutation = useDeleteMonitorMutation();
const monitor = monitorQuery.data?.monitor;
const checks = checksQuery.data?.checks ?? [];
const incidents = incidentsQuery.data?.incidents ?? [];
@@ -53,33 +68,29 @@ export function MonitorDetailPage({ id }: { id: number }) {
);
if (monitorQuery.isError || !monitor)
return (
<div className="detail-error">
<strong>Monitor not found</strong>
<p>The requested monitor could not be loaded.</p>
<Button variant="unstyled" className="secondary-button" onClick={() => navigate('/dashboard')}>
Return to dashboard
</Button>
<div className="grid min-h-dvh place-content-center">
<Empty>
<EmptyTitle>Monitor not found</EmptyTitle>
<EmptyDescription>The requested monitor could not be loaded.</EmptyDescription>
<EmptyContent>
<Button variant="unstyled" className="secondary-button" onClick={() => navigate('/dashboard')}>
Return to dashboard
</Button>
</EmptyContent>
</Empty>
</div>
);
return (
<div className="dashboard-shell">
<header className="dashboard-header">
<div className="dashboard-header-inner">
<Button variant="unstyled" className="brand brand-button" type="button" onClick={() => navigate('/dashboard')}>
<Zap className="brand-mark" fill="currentColor" />
<span>upwatch</span>
</Button>
<div className="nav-actions">
<Button variant="unstyled" className="nav-auth" onClick={() => navigate('/settings')}>
Settings
</Button>
<Button variant="unstyled" className="nav-auth" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}>
Sign out
</Button>
</div>
</div>
</header>
<AppHeader />
{isEditing && <MonitorFormDialog key={monitor.id} editing={monitor} onClose={() => setIsEditing(false)} />}
<DeleteMonitorDialog
monitor={isConfirmingDelete ? monitor : null}
isPending={deleteMutation.isPending}
onCancel={() => setIsConfirmingDelete(false)}
onConfirm={() => deleteMutation.mutate(monitor.id, { onSuccess: () => navigate('/dashboard') })}
/>
<main className="dashboard-main detail-main">
<Button variant="unstyled" className="back-link" type="button" onClick={() => navigate('/dashboard')}>
<ArrowLeft /> All monitors
@@ -119,20 +130,24 @@ export function MonitorDetailPage({ id }: { id: number }) {
{(['24h', '7d', '30d', '90d'] as const).map((key) => {
const window = statsQuery.data?.windows[key];
return (
<article className="sla-card" key={key}>
<p>{key} uptime</p>
<strong>{window?.uptimePct == null ? '—' : `${window.uptimePct.toFixed(3)}%`}</strong>
<span>
{window?.totalChecks ?? 0} checks · {window?.avgLatencyMs ?? '—'} ms avg
</span>
</article>
<Card asChild key={key}>
<article className="sla-card">
<p>{key} uptime</p>
<strong>{window?.uptimePct == null ? '—' : `${window.uptimePct.toFixed(3)}%`}</strong>
<span>
{window?.totalChecks ?? 0} checks · {window?.avgLatencyMs ?? '—'} ms avg
</span>
</article>
</Card>
);
})}
<article className={`sla-card incident-summary ${openIncident ? 'has-incident' : ''}`}>
<p>Current incident</p>
<strong>{openIncident ? formatDuration(null, openIncident.startedAt) : 'None'}</strong>
<span>{openIncident ? `Open since ${formatDate(openIncident.startedAt)}` : 'Everything is operational'}</span>
</article>
<Card asChild>
<article className={`sla-card incident-summary ${openIncident ? 'has-incident' : ''}`}>
<p>Current incident</p>
<strong>{openIncident ? formatDuration(null, openIncident.startedAt) : 'None'}</strong>
<span>{openIncident ? `Open since ${formatDate(openIncident.startedAt)}` : 'Everything is operational'}</span>
</article>
</Card>
</section>
<div className="detail-grid">
@@ -172,34 +187,41 @@ export function MonitorDetailPage({ id }: { id: number }) {
{/* Keyboard focus makes this horizontally scrollable region accessible without a pointer. */}
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<section className="data-table-wrap recent-checks-scroll" aria-label="Recent checks" tabIndex={0}>
<table className="data-table">
<thead>
<tr>
<th>Status</th>
<th>Response</th>
<th>Latency</th>
<th>Checked</th>
</tr>
</thead>
<tbody>
<Table className="min-w-150">
<TableHeader>
<TableRow>
<TableHead>Status</TableHead>
<TableHead>Response</TableHead>
<TableHead>Latency</TableHead>
<TableHead>Checked</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{checks.slice(0, 20).map((check) => (
<tr key={check.id}>
<td>
<TableRow key={check.id}>
<TableCell>
<Badge variant={check.ok ? 'online' : 'offline'}>{check.ok ? 'Up' : 'Down'}</Badge>
</td>
<td>
<code>{check.statusCode ? `HTTP ${check.statusCode}` : (check.error ?? 'Failed')}</code>
</td>
<td>{check.latencyMs} ms</td>
<td>{formatDate(check.checkedAt)}</td>
</tr>
</TableCell>
<TableCell>
<code className="max-w-65 truncate font-mono text-[11px]/[1.4] font-normal">
{check.statusCode ? `HTTP ${check.statusCode}` : (check.error ?? 'Failed')}
</code>
</TableCell>
<TableCell>{check.latencyMs} ms</TableCell>
<TableCell>{formatDate(check.checkedAt)}</TableCell>
</TableRow>
))}
</tbody>
</table>
{checks.length === 0 && <div className="table-empty">No checks recorded.</div>}
</TableBody>
</Table>
{checks.length === 0 && (
<Empty className="min-h-37.5 p-6">
<EmptyTitle className="text-[13px]">No checks yet</EmptyTitle>
<EmptyDescription>No checks recorded.</EmptyDescription>
</Empty>
)}
</section>
</section>
<section className="data-panel">
<section className="data-panel incidents-panel">
<div className="data-panel-heading">
<div>
<p className="overline">Downtime</p>
@@ -207,26 +229,91 @@ export function MonitorDetailPage({ id }: { id: number }) {
</div>
<span>{incidents.length} recorded</span>
</div>
<div className="incident-list">
{/* Keyboard focus makes this scrollable region accessible without a pointer. */}
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<div className="incident-list" aria-label="Incident history" tabIndex={0}>
{incidents.map((incident) => (
<article className={incident.resolvedAt ? 'resolved' : 'open'} key={incident.id}>
<span>{incident.resolvedAt ? <CheckCircle2 /> : <Clock3 />}</span>
<div>
<strong>{incident.resolvedAt ? 'Resolved incident' : 'Incident in progress'}</strong>
<p>
{incident.startError ??
{incident.aiMessage ??
incident.startError ??
(incident.startStatusCode ? `HTTP ${incident.startStatusCode}` : 'Endpoint became unavailable')}
</p>
{incident.aiMessage && incident.startError && <small className="incident-raw">{incident.startError}</small>}
<small>
{formatDate(incident.startedAt)} · {formatDuration(incident.durationMs, incident.startedAt)}
</small>
</div>
</article>
))}
{incidents.length === 0 && <div className="table-empty">No downtime incidents recorded.</div>}
{incidents.length === 0 && (
<Empty className="min-h-37.5 p-6">
<EmptyTitle className="text-[13px]">No incidents yet</EmptyTitle>
<EmptyDescription>No downtime incidents recorded.</EmptyDescription>
</Empty>
)}
</div>
</section>
</div>
<section className="data-panel configuration-panel">
<div className="data-panel-heading">
<div>
<p className="overline">Setup</p>
<h2>Configuration</h2>
</div>
<Button variant="unstyled" className="secondary-button" type="button" onClick={() => setIsEditing(true)}>
<Pencil /> Edit
</Button>
</div>
<dl className="config-list">
<div className="config-row">
<dt>Method</dt>
<dd>{monitor.method}</dd>
</div>
<div className="config-row">
<dt>Expected status</dt>
<dd>{monitor.expectedStatus}</dd>
</div>
<div className="config-row">
<dt>Check interval</dt>
<dd>{formatInterval(monitor.intervalSeconds)}</dd>
</div>
<div className="config-row">
<dt>Timeout</dt>
<dd>{monitor.timeoutMs.toLocaleString()} ms</dd>
</div>
<div className="config-row">
<dt>Scheduled checks</dt>
<dd className="config-row-actions">
<span>{monitor.enabled ? 'Running' : 'Paused'}</span>
<Button
variant="unstyled"
className="row-action"
type="button"
disabled={updateMutation.isPending}
onClick={() => updateMutation.mutate({ id: monitor.id, input: { enabled: !monitor.enabled } })}
>
{monitor.enabled ? <PowerOff /> : <Power />}
{monitor.enabled ? 'Pause' : 'Resume'}
</Button>
</dd>
</div>
<div className="config-row">
<dt>Incident alerts</dt>
<dd>{monitor.alertsEnabled ? 'Enabled' : 'Muted'}</dd>
</div>
</dl>
<div className="config-danger">
<p>This permanently deletes the monitor and its check history.</p>
<Button variant="unstyled" className="danger-button" type="button" onClick={() => setIsConfirmingDelete(true)}>
<Trash2 /> Delete monitor
</Button>
</div>
</section>
</main>
</div>
);
+133 -37
View File
@@ -1,14 +1,19 @@
import { type FormEvent, useState } from 'react';
import { ArrowLeft, BellRing, Send, Zap } from 'lucide-react';
import { ArrowLeft, BellRing, Send, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Empty, EmptyTitle } from '@/components/ui/empty';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import type { NotificationSettings } from '../api/settings';
import type { AiSettings, NotificationSettings } from '../api/settings';
import { AppHeader } from '../components/AppHeader';
import { navigate } from '../lib/router';
import { useLogoutMutation } from '../queries/auth';
import {
useAiSettingsQuery,
useNotificationSettingsQuery,
useTestAiSettingsMutation,
useTestNotificationWebhookMutation,
useUpdateAiSettingsMutation,
useUpdateNotificationSettingsMutation,
} from '../queries/settings';
@@ -65,52 +70,143 @@ function SettingsForm({ settings }: { settings: NotificationSettings }) {
);
}
function AiSettingsForm({ settings }: { settings: AiSettings }) {
const [baseUrl, setBaseUrl] = useState(settings.baseUrl ?? 'https://api.openai.com/v1');
const [apiKey, setApiKey] = useState('');
const [model, setModel] = useState(settings.model ?? 'gpt-4o-mini');
const [enabled, setEnabled] = useState(settings.enabled);
const updateMutation = useUpdateAiSettingsMutation();
const testMutation = useTestAiSettingsMutation();
function submit(event: FormEvent) {
event.preventDefault();
updateMutation.mutate({
enabled,
baseUrl: baseUrl.trim() || null,
model: model.trim() || null,
apiKey: apiKey.trim() || null,
});
}
return (
<form className="settings-form" onSubmit={submit}>
<label className="field" htmlFor="ai-base-url">
<span>Base URL</span>
<Input
id="ai-base-url"
type="url"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="https://api.openai.com/v1"
/>
</label>
<label className="field" htmlFor="ai-api-key">
<span>API key</span>
<Input
id="ai-api-key"
type="password"
autoComplete="off"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder={settings.apiKeyPreview ?? 'Enter API key'}
/>
{settings.apiKeySet && <small className="field-helper">Leave blank to keep the current key.</small>}
</label>
<label className="field" htmlFor="ai-model">
<span>Model</span>
<Input id="ai-model" type="text" value={model} onChange={(event) => setModel(event.target.value)} placeholder="gpt-4o-mini" />
</label>
<div className="settings-toggle">
<Switch id="ai-enabled" checked={enabled} onCheckedChange={setEnabled} />
<label htmlFor="ai-enabled">
<strong>Enable AI incident messages</strong>
<small>Generate one sanitized public update when an incident opens.</small>
</label>
</div>
<div className="settings-actions">
<Button
variant="unstyled"
className="secondary-button"
type="button"
onClick={() => testMutation.mutate()}
disabled={!settings.apiKeySet || !settings.baseUrl || !settings.model || testMutation.isPending}
>
<Sparkles /> {testMutation.isPending ? 'Generating…' : 'Test generation'}
</Button>
<Button variant="unstyled" className="primary-button" type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving…' : 'Save settings'}
</Button>
</div>
{updateMutation.isSuccess && <p className="settings-success">AI settings saved.</p>}
{testMutation.isSuccess && <p className="settings-success">{testMutation.data.message}</p>}
{(updateMutation.isError || testMutation.isError) && (
<p className="form-error">{(updateMutation.error ?? testMutation.error)?.message ?? 'Request failed'}</p>
)}
</form>
);
}
export function SettingsPage() {
const settingsQuery = useNotificationSettingsQuery();
const logoutMutation = useLogoutMutation();
const aiSettingsQuery = useAiSettingsQuery();
return (
<div className="dashboard-shell">
<header className="dashboard-header">
<div className="dashboard-header-inner">
<Button variant="unstyled" className="brand brand-button" onClick={() => navigate('/dashboard')}>
<Zap className="brand-mark" fill="currentColor" />
<span>upwatch</span>
</Button>
<div className="nav-actions">
<span className="header-context">Settings</span>
<Button variant="unstyled" className="nav-auth" onClick={() => logoutMutation.mutate()}>
Sign out
</Button>
</div>
</div>
</header>
<AppHeader context="Settings" />
<main className="settings-main">
<Button variant="unstyled" className="back-link" type="button" onClick={() => navigate('/dashboard')}>
<ArrowLeft /> Dashboard
</Button>
<section className="settings-heading">
<p className="overline">Integrations</p>
<h1>Notifications</h1>
<p>Route monitor transitions to Slack, Discord, or any service that accepts JSON webhooks.</p>
<h1>Notifications &amp; AI</h1>
<p>Configure incident alerts and visitor-friendly status updates from one place.</p>
</section>
<section className="settings-card">
<div className="settings-card-intro">
<span>
<BellRing />
</span>
<div>
<h2>Incident webhook</h2>
<p>Upwatch sends a compact JSON payload for down and recovery events. Delivery failures never interrupt monitoring.</p>
<Card asChild>
<section className="settings-card">
<div className="settings-card-intro">
<span>
<BellRing />
</span>
<div>
<h2>Incident webhook</h2>
<p>Upwatch sends a compact JSON payload for down and recovery events. Delivery failures never interrupt monitoring.</p>
</div>
</div>
</div>
{settingsQuery.isPending ? (
<div className="table-empty">Loading settings</div>
) : settingsQuery.isError ? (
<p className="form-error">Unable to load notification settings.</p>
) : (
<SettingsForm key={settingsQuery.data.settings.updatedAt ?? 'new'} settings={settingsQuery.data.settings} />
)}
</section>
{settingsQuery.isPending ? (
<div className="table-empty">Loading settings</div>
) : settingsQuery.isError ? (
<Empty variant="error" className="m-6">
<EmptyTitle>Unable to load notification settings</EmptyTitle>
</Empty>
) : (
<SettingsForm key={settingsQuery.data.settings.updatedAt ?? 'new'} settings={settingsQuery.data.settings} />
)}
</section>
</Card>
<Card asChild>
<section className="settings-card">
<div className="settings-card-intro">
<span>
<Sparkles />
</span>
<div>
<h2>AI incident messages</h2>
<p>
Turn technical check failures into short, sanitized updates for visitors. Generation runs only when an incident opens.
</p>
</div>
</div>
{aiSettingsQuery.isPending ? (
<div className="table-empty">Loading settings</div>
) : aiSettingsQuery.isError ? (
<Empty variant="error" className="m-6">
<EmptyTitle>Unable to load AI settings</EmptyTitle>
</Empty>
) : (
<AiSettingsForm key={aiSettingsQuery.data.settings.updatedAt ?? 'new'} settings={aiSettingsQuery.data.settings} />
)}
</section>
</Card>
<section className="payload-preview">
<p className="overline">Payload preview</p>
<pre>{`{
+58 -15
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Activity, CircleCheck, Database, RefreshCw, TriangleAlert, Zap } from 'lucide-react';
import { Badge, type BadgeVariant } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from '@/components/ui/empty';
import type { PublicOverallStatus, PublicServiceStatus } from '../api/status';
import { SiteIcon } from '../components/SiteIcon';
import { StatusHistoryBar } from '../components/StatusHistoryBar';
@@ -53,6 +54,7 @@ export function StatusPage() {
const sessionQuery = useSessionQuery();
const [now, setNow] = useState(Date.now);
const status = statusQuery.data;
const activeIncidents = status?.services.filter((service) => service.message) ?? [];
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 5_000);
@@ -99,16 +101,18 @@ export function StatusPage() {
</div>
</div>
) : statusQuery.isError || !status ? (
<section className="status-panel-state status-error-state">
<span>
<Empty variant="error" className="mt-[42px] min-h-[280px] place-content-center p-12">
<EmptyMedia variant="icon">
<TriangleAlert />
</span>
<strong>Status could not be loaded</strong>
<p>{errorMessage(statusQuery.error)}</p>
<Button variant="unstyled" className="secondary-button" type="button" onClick={() => void statusQuery.refetch()}>
Try again
</Button>
</section>
</EmptyMedia>
<EmptyTitle>Status could not be loaded</EmptyTitle>
<EmptyDescription>{errorMessage(statusQuery.error)}</EmptyDescription>
<EmptyContent>
<Button variant="unstyled" className="secondary-button" type="button" onClick={() => void statusQuery.refetch()}>
Try again
</Button>
</EmptyContent>
</Empty>
) : (
<>
<section className={`status-banner ${status.overall}`} aria-live="polite">
@@ -122,6 +126,45 @@ export function StatusPage() {
<time dateTime={new Date(status.updatedAt).toISOString()}>{relativeUpdate(status.updatedAt, now)}</time>
</section>
{activeIncidents.length > 0 && (
<section className="active-incidents" aria-labelledby="active-incidents-title" aria-live="polite">
<header className="active-incidents-header">
<div className="active-incidents-heading">
<span className="active-incidents-icon">
<TriangleAlert aria-hidden="true" />
</span>
<div>
<p>Active incident</p>
<h2 id="active-incidents-title">Were working to restore service</h2>
</div>
</div>
<span className="active-incidents-count">
{activeIncidents.length} {activeIncidents.length === 1 ? 'service' : 'services'} affected
</span>
</header>
<div className="active-incident-list">
{activeIncidents.map((service) => (
<article className="active-incident-row" key={service.id}>
<div className="active-incident-service">
<span className="active-incident-service-icon">
<SiteIcon monitorId={service.id} favicon="public" />
</span>
<div>
<strong>{service.name}</strong>
<span>Service disruption</span>
</div>
</div>
<p>{service.message}</p>
<span className="active-incident-state">
<i /> Investigating
</span>
</article>
))}
</div>
</section>
)}
<section className="public-services-panel" aria-labelledby="public-services-title">
<div className="public-services-heading">
<div>
@@ -141,13 +184,13 @@ export function StatusPage() {
</div>
{status.services.length === 0 ? (
<div className="status-panel-state">
<span>
<Empty className="min-h-[280px] place-content-center p-12">
<EmptyMedia variant="icon">
<Database />
</span>
<strong>No public services yet</strong>
<p>Service health will appear here after monitoring is enabled.</p>
</div>
</EmptyMedia>
<EmptyTitle>No public services yet</EmptyTitle>
<EmptyDescription>Service health will appear here after monitoring is enabled.</EmptyDescription>
</Empty>
) : (
<div className="public-service-list">
{status.services.map((service) => {
+23
View File
@@ -1,8 +1,12 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import {
getAiSettings,
getNotificationSettings,
testAiSettings,
testNotificationWebhook,
updateAiSettings,
updateNotificationSettings,
type AiSettingsInput,
type NotificationSettingsInput,
} from '../api/settings';
import { queryClient } from '../lib/query-client';
@@ -10,6 +14,7 @@ import { queryClient } from '../lib/query-client';
export const settingsKeys = {
all: ['settings'] as const,
notifications: () => [...settingsKeys.all, 'notifications'] as const,
ai: () => [...settingsKeys.all, 'ai'] as const,
};
export function useNotificationSettingsQuery() {
@@ -29,3 +34,21 @@ export function useUpdateNotificationSettingsMutation() {
export function useTestNotificationWebhookMutation() {
return useMutation({ mutationFn: testNotificationWebhook });
}
export function useAiSettingsQuery() {
return useQuery({
queryKey: settingsKeys.ai(),
queryFn: ({ signal }) => getAiSettings(signal),
});
}
export function useUpdateAiSettingsMutation() {
return useMutation({
mutationFn: (input: AiSettingsInput) => updateAiSettings(input),
onSuccess: (data) => queryClient.setQueryData(settingsKeys.ai(), data),
});
}
export function useTestAiSettingsMutation() {
return useMutation({ mutationFn: testAiSettings });
}
+367 -224
View File
@@ -14,6 +14,7 @@
--primary: #3ecf8e;
--shadcn-primary: oklch(0.205 0 0);
--primary-deep: #24b47e;
--chart-down: #d95c5c;
--ink: #171717;
--muted: #707070;
--shadcn-muted: oklch(0.97 0 0);
@@ -57,6 +58,7 @@
box-sizing: border-box;
}
html {
font-size: 19px;
scroll-behavior: smooth;
}
body {
@@ -144,34 +146,99 @@ button {
color: var(--primary-deep);
}
.nav-actions {
display: flex;
display: none;
align-items: center;
gap: 20px;
gap: 8px;
}
.app-nav-mobile-trigger {
display: block;
}
.header-context {
margin-right: 12px;
padding-right: 20px;
border-right: 1px solid #e5e5e5;
font-size: 13px;
color: var(--muted);
}
.nav-auth {
padding: 0;
border: 0;
font-size: 13px;
font-weight: 500;
color: #525252;
background: transparent;
cursor: pointer;
transition: color 160ms ease;
.app-nav-icon {
color: #5f5f5f;
}
.nav-auth:hover:not(:disabled) {
.app-nav-icon:hover:not(:disabled),
.app-nav-icon[aria-current='page'] {
background: rgb(62 207 142 / 0.1);
color: var(--primary-deep);
}
.nav-auth:disabled {
.app-nav-sheet {
width: min(88vw, 340px);
border-color: var(--hairline);
background: #fff;
}
.app-nav-sheet-header {
gap: 5px;
padding: 24px 20px 18px;
border-bottom: 1px solid #ededed;
}
.app-nav-sheet-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 19px;
font-weight: 600;
letter-spacing: -0.5px;
}
.app-nav-sheet-title svg {
width: 24px;
height: 24px;
color: var(--primary-deep);
}
.app-nav-mobile {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 12px;
}
.app-nav-mobile-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-height: 46px;
padding: 10px 12px;
border: 0;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
text-align: left;
color: #4f4f4f;
background: transparent;
cursor: pointer;
transition:
background-color 160ms ease,
color 160ms ease;
}
.app-nav-mobile-item svg {
width: 18px;
height: 18px;
}
.app-nav-mobile-item:hover:not(:disabled),
.app-nav-mobile-item[aria-current='page'] {
color: var(--primary-deep);
background: rgb(62 207 142 / 0.1);
}
.app-nav-mobile-item:disabled {
cursor: wait;
opacity: 0.55;
}
@media (min-width: 768px) {
.nav-actions {
display: flex;
}
.app-nav-mobile-trigger {
display: none;
}
}
.dashboard-main {
width: min(1280px, calc(100% - 48px));
margin: 0 auto;
@@ -201,7 +268,8 @@ button {
}
.dashboard-intro > div > p:last-child {
margin: 12px 0 0;
font-size: 16px;
font-size: 19px;
line-height: 1.55;
color: var(--muted);
}
.primary-button,
@@ -264,7 +332,9 @@ button {
cursor: wait;
opacity: 0.6;
}
.primary-button svg {
.primary-button svg,
.secondary-button svg,
.danger-button svg {
width: 16px;
height: 16px;
}
@@ -639,44 +709,6 @@ button {
background: #fff2f2;
}
.panel-state {
display: grid;
justify-items: center;
padding: 72px 24px;
text-align: center;
}
.panel-state strong {
font-size: 16px;
font-weight: 500;
}
.panel-state p {
max-width: 420px;
margin: 8px 0 20px;
font-size: 13px;
line-height: 1.55;
color: var(--muted);
}
.empty-icon {
display: grid;
place-items: center;
width: 48px;
height: 48px;
margin-bottom: 18px;
border: 1px solid #dcdcdc;
border-radius: 8px;
color: #666;
background: #fafafa;
}
.empty-icon svg {
width: 22px;
height: 22px;
}
.error-state {
background: #fffafa;
}
.error-state strong {
color: #8b3434;
}
.monitor-skeleton > div {
display: grid;
grid-template-columns: 40px 1fr 180px;
@@ -786,7 +818,7 @@ button {
.auth-heading > span {
display: block;
margin-top: 12px;
font-size: 14px;
font-size: 19px;
line-height: 1.55;
color: #626262;
}
@@ -1038,6 +1070,56 @@ button {
monospace;
color: var(--faint);
}
.configuration-panel {
margin-top: 16px;
}
.config-list {
margin: 0;
}
.config-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 14px 22px;
border-bottom: 1px solid #f0f0f0;
}
.config-row:last-child {
border-bottom: 0;
}
.config-row dt {
font-size: 12px;
color: var(--muted);
}
.config-row dd {
margin: 0;
font:
400 13px/1.4 'IBM Plex Mono',
monospace;
color: var(--ink);
}
.config-row .config-row-actions {
display: flex;
align-items: center;
gap: 10px;
}
.config-row-actions .row-action {
font-family: 'DM Sans', 'Helvetica Neue', sans-serif;
}
.config-danger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 22px;
border-top: 1px solid #ededed;
background: #fdfafa;
}
.config-danger p {
margin: 0;
font-size: 12px;
color: var(--muted);
}
.chart-panel {
min-height: 330px;
}
@@ -1047,16 +1129,6 @@ button {
padding: 26px 24px 24px;
background: linear-gradient(180deg, #fdfefe, #fff);
}
.sparkline-wrap .recharts-wrapper:focus,
.sparkline-wrap .recharts-wrapper:focus-visible,
.sparkline-wrap .recharts-surface:focus,
.sparkline-wrap .recharts-surface:focus-visible,
.uptime-bar .recharts-wrapper:focus,
.uptime-bar .recharts-wrapper:focus-visible,
.uptime-bar .recharts-surface:focus,
.uptime-bar .recharts-surface:focus-visible {
outline: none;
}
.chart-scale {
position: absolute;
inset: 19px 22px 20px auto;
@@ -1069,57 +1141,6 @@ button {
monospace;
color: #aaa;
}
.chart-tooltip {
display: grid;
min-width: 144px;
gap: 5px;
padding: 10px 12px;
border: 1px solid var(--hairline);
border-radius: 6px;
color: var(--ink);
background: rgb(255 255 255 / 0.97);
box-shadow:
0 8px 24px rgb(24 74 52 / 0.1),
0 2px 6px rgb(0 0 0 / 0.04);
backdrop-filter: blur(8px);
}
.chart-tooltip time {
font-size: 10px;
line-height: 1.35;
color: var(--muted);
}
.chart-tooltip strong {
font:
500 15px/1.3 'IBM Plex Mono',
monospace;
letter-spacing: -0.3px;
}
.chart-tooltip > span {
display: inline-flex;
align-items: center;
gap: 6px;
width: max-content;
font-size: 10px;
font-weight: 500;
color: #16885b;
}
.chart-tooltip > span::before {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary-deep);
content: '';
}
.chart-tooltip > span.is-down {
color: #ae3d3d;
}
.chart-tooltip > span.is-down::before {
background: #d95c5c;
}
.chart-tooltip-compact {
min-width: 134px;
}
.chart-empty,
.table-empty {
display: grid;
min-height: 150px;
@@ -1157,7 +1178,7 @@ button {
}
.uptime-legend i.legend-down {
margin-left: 5px;
background: #d95c5c;
background: var(--chart-down);
}
.data-table-wrap {
overflow-x: auto;
@@ -1167,7 +1188,13 @@ button {
min-height: 0;
flex-direction: column;
}
.recent-checks-scroll {
.incidents-panel {
display: flex;
min-height: 0;
flex-direction: column;
}
.recent-checks-scroll,
.incident-list {
max-height: clamp(320px, 52vh, 556px);
overflow: auto;
overscroll-behavior: contain;
@@ -1175,69 +1202,41 @@ button {
scrollbar-gutter: stable;
scrollbar-width: thin;
}
.recent-checks-scroll:focus-visible {
.recent-checks-scroll:focus-visible,
.incident-list:focus-visible {
outline: 2px solid rgb(36 180 126 / 0.45);
outline-offset: -2px;
}
.recent-checks-scroll::-webkit-scrollbar {
.recent-checks-scroll::-webkit-scrollbar,
.incident-list::-webkit-scrollbar {
width: 9px;
height: 9px;
}
.recent-checks-scroll::-webkit-scrollbar-track {
.recent-checks-scroll::-webkit-scrollbar-track,
.incident-list::-webkit-scrollbar-track {
background: #f4f6f5;
}
.recent-checks-scroll::-webkit-scrollbar-thumb {
.recent-checks-scroll::-webkit-scrollbar-thumb,
.incident-list::-webkit-scrollbar-thumb {
border: 2px solid #f4f6f5;
border-radius: 999px;
background: #b8c7c0;
}
.recent-checks-scroll::-webkit-scrollbar-thumb:hover {
.recent-checks-scroll::-webkit-scrollbar-thumb:hover,
.incident-list::-webkit-scrollbar-thumb:hover {
background: #91a69d;
}
.recent-checks-scroll .data-table {
min-width: 600px;
}
.recent-checks-scroll .data-table th {
position: sticky;
z-index: 1;
top: 0;
box-shadow: 0 1px #e5e5e5;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.data-table th {
padding: 11px 16px;
font-weight: 400;
text-align: left;
color: #888;
background: #fafafa;
}
.data-table td {
padding: 14px 16px;
border-top: 1px solid #ededed;
color: #555;
white-space: nowrap;
}
.data-table code {
max-width: 260px;
overflow: hidden;
font:
400 11px/1.4 'IBM Plex Mono',
monospace;
text-overflow: ellipsis;
}
.incident-list {
max-height: 556px;
overflow: auto;
min-height: 0;
}
.incident-list article {
display: flex;
gap: 13px;
padding: 18px 20px;
}
.incident-list article > div {
min-width: 0;
}
.incident-list article + article {
border-top: 1px solid #ededed;
}
@@ -1265,6 +1264,7 @@ button {
}
.incident-list p {
margin: 4px 0 6px;
overflow-wrap: anywhere;
font-size: 12px;
line-height: 1.45;
color: var(--muted);
@@ -1275,18 +1275,11 @@ button {
monospace;
color: #aaa;
}
.detail-error {
display: grid;
min-height: 100dvh;
place-content: center;
justify-items: center;
padding: 24px;
text-align: center;
.incident-list .incident-raw {
display: block;
margin: -1px 0 5px;
overflow-wrap: anywhere;
}
.detail-error p {
color: var(--muted);
}
.settings-main {
width: min(760px, calc(100% - 48px));
margin: 0 auto;
@@ -1303,6 +1296,8 @@ button {
}
.settings-heading > p:last-child {
margin: 12px 0 0;
font-size: 19px;
line-height: 1.55;
color: var(--muted);
}
.settings-card {
@@ -1349,6 +1344,7 @@ button {
display: grid;
gap: 24px;
padding: 26px;
font-size: 15px;
}
.settings-toggle {
display: flex;
@@ -1370,6 +1366,12 @@ button {
margin-top: 4px;
color: var(--muted);
}
.field-helper {
display: block;
margin-top: 7px;
font-size: 11px;
color: var(--muted);
}
.settings-actions {
display: flex;
justify-content: flex-end;
@@ -1458,7 +1460,7 @@ button {
.status-intro > p:last-child {
max-width: 560px;
margin: 18px 0 0;
font-size: 16px;
font-size: 19px;
line-height: 1.6;
color: var(--muted);
}
@@ -1528,6 +1530,158 @@ button {
opacity: 0.66;
white-space: nowrap;
}
.active-incidents {
margin-top: 18px;
overflow: hidden;
border: 1px solid #e9c5c5;
border-radius: 10px;
background: #fff;
box-shadow: 0 14px 40px rgb(117 38 38 / 0.06);
animation: enter 500ms 45ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.active-incidents-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 92px;
padding: 18px 22px;
border-bottom: 1px solid #f0dede;
background: linear-gradient(110deg, #fff4f4 0%, #fffafa 72%, #fff 100%);
}
.active-incidents-heading {
display: flex;
align-items: center;
gap: 14px;
}
.active-incidents-icon {
display: grid;
width: 40px;
height: 40px;
place-items: center;
flex: 0 0 auto;
border: 1px solid rgb(174 61 61 / 0.2);
border-radius: 8px;
color: #a93e3e;
background: rgb(255 255 255 / 0.82);
box-shadow: 0 4px 14px rgb(130 34 34 / 0.06);
}
.active-incidents-icon svg {
width: 18px;
height: 18px;
}
.active-incidents-heading p {
margin: 0 0 3px;
font:
500 9px/1.3 'IBM Plex Mono',
monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #a24a4a;
}
.active-incidents-heading h2 {
margin: 0;
font-size: 17px;
font-weight: 600;
letter-spacing: -0.25px;
color: #5d2929;
}
.active-incidents-count {
flex: 0 0 auto;
padding: 5px 8px;
border: 1px solid #e8caca;
border-radius: 5px;
font:
500 9px/1.3 'IBM Plex Mono',
monospace;
color: #8e3f3f;
background: rgb(255 255 255 / 0.72);
}
.active-incident-row {
display: grid;
grid-template-columns: minmax(190px, 0.55fr) minmax(320px, 1.25fr) auto;
align-items: center;
gap: 24px;
min-height: 104px;
padding: 20px 22px;
}
.active-incident-row + .active-incident-row {
border-top: 1px solid #f1e5e5;
}
.active-incident-service {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.active-incident-service-icon {
display: grid;
width: 36px;
height: 36px;
place-items: center;
flex: 0 0 auto;
border: 1px solid #e5d6d6;
border-radius: 7px;
color: #765454;
background: #fcf8f8;
}
.active-incident-service-icon svg {
width: 17px;
height: 17px;
}
.active-incident-service-icon img {
width: 20px;
height: 20px;
border-radius: 4px;
object-fit: contain;
}
.active-incident-service > div {
min-width: 0;
}
.active-incident-service > div strong,
.active-incident-service > div span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.active-incident-service > div strong {
font-size: 14px;
font-weight: 600;
color: #292323;
}
.active-incident-service > div span {
margin-top: 3px;
font-size: 10px;
color: #9a7777;
}
.active-incident-row > p {
max-width: 66ch;
margin: 0;
font-size: 12px;
line-height: 1.6;
text-wrap: pretty;
color: #6f5a5a;
}
.active-incident-state {
display: inline-flex;
align-items: center;
gap: 7px;
justify-self: end;
font:
500 9px/1.3 'IBM Plex Mono',
monospace;
color: #8e4b4b;
white-space: nowrap;
}
.active-incident-state i {
width: 6px;
height: 6px;
border-radius: 50%;
background: #d95c5c;
box-shadow: 0 0 0 4px rgb(217 92 92 / 0.1);
animation: blink 1.8s ease-in-out infinite;
}
.public-services-panel {
margin-top: 18px;
overflow: hidden;
@@ -1657,51 +1811,6 @@ button {
height: 1px;
background: #ededed;
}
.status-panel-state {
display: grid;
justify-items: center;
min-height: 280px;
place-content: center;
padding: 48px 24px;
text-align: center;
}
.status-panel-state > span {
display: grid;
width: 44px;
height: 44px;
margin-bottom: 16px;
place-items: center;
border: 1px solid #dcdcdc;
border-radius: 50%;
color: #666;
background: #fafafa;
}
.status-panel-state svg {
width: 19px;
height: 19px;
}
.status-panel-state strong {
font-size: 15px;
font-weight: 500;
}
.status-panel-state p {
max-width: 400px;
margin: 8px 0 18px;
font-size: 13px;
line-height: 1.5;
color: var(--muted);
}
.status-error-state {
margin-top: 42px;
border: 1px solid #ebd1d1;
border-radius: 10px;
background: #fffafa;
}
.status-error-state > span {
border-color: #ebcaca;
color: #a34242;
background: #fff3f3;
}
.status-loading {
margin-top: 42px;
}
@@ -1866,9 +1975,6 @@ button {
.status-footer {
width: min(100% - 32px, 960px);
}
.header-context {
display: none;
}
.dashboard-main {
padding: 40px 0 56px;
}
@@ -1926,6 +2032,13 @@ button {
.detail-actions {
flex-wrap: wrap;
}
.config-row {
flex-wrap: wrap;
}
.config-danger {
align-items: flex-start;
flex-direction: column;
}
.detail-title h1 {
font-size: 34px;
}
@@ -1948,6 +2061,14 @@ button {
grid-column: 2;
justify-self: start;
}
.active-incident-row {
grid-template-columns: minmax(180px, 0.55fr) minmax(0, 1fr);
gap: 18px;
}
.active-incident-state {
grid-column: 2;
justify-self: start;
}
.public-service-row {
grid-template-columns: 1fr auto;
gap: 20px;
@@ -1983,7 +2104,7 @@ button {
padding-top: 32px;
}
.dashboard-intro > div > p:last-child {
font-size: 14px;
font-size: 19px;
line-height: 1.5;
}
.panel-heading {
@@ -2016,9 +2137,6 @@ button {
.row-action-icon {
width: 40px;
}
.nav-actions {
gap: 12px;
}
.detail-title {
align-items: flex-start;
}
@@ -2049,7 +2167,7 @@ button {
letter-spacing: -1.8px;
}
.status-intro > p:last-child {
font-size: 14px;
font-size: 19px;
}
.status-banner {
grid-template-columns: 1fr;
@@ -2063,6 +2181,28 @@ button {
.status-banner time {
grid-column: auto;
}
.active-incidents-header {
align-items: flex-start;
flex-direction: column;
gap: 14px;
padding: 18px 16px;
}
.active-incidents-count {
margin-left: 54px;
}
.active-incident-row {
grid-template-columns: 1fr;
gap: 14px;
padding: 18px 16px;
}
.active-incident-row > p {
padding-left: 48px;
}
.active-incident-state {
grid-column: auto;
justify-self: start;
margin-left: 48px;
}
.public-services-heading {
padding: 17px 16px;
}
@@ -2104,6 +2244,8 @@ button {
}
@theme inline {
--font-sans: 'DM Sans', 'Helvetica Neue', sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -2117,6 +2259,7 @@ button {
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-chart-down: var(--chart-down);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
+37
View File
@@ -0,0 +1,37 @@
import * as React from 'react';
import { Slot } from 'radix-ui';
import { cn } from '@/lib/utils';
// Customized from radix-nova: layout and typography defaults are intentionally left to consumer styles.
function Card({ className, asChild = false, ...props }: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'div';
return <Comp data-slot="card" className={cn('flex flex-col rounded-[8px] border border-[#e3e3e3] bg-white', className)} {...props} />;
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-header" className={cn(className)} {...props} />;
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-title" className={cn(className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-description" className={cn(className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-action" className={cn(className)} {...props} />;
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn(className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-footer" className={cn(className)} {...props} />;
}
export { Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter };
+294
View File
@@ -0,0 +1,294 @@
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import type { TooltipValueType } from 'recharts';
import { cn } from '@/lib/utils';
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
type TooltipNameType = number | string;
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> })
>;
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />');
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
initialDimension?: {
width: number;
height: number;
};
}) {
const uniqueId = React.useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension}>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, itemConfig]) => itemConfig.theme ?? itemConfig.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('')}
}
`,
)
.join(''),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
} & Omit<RechartsPrimitive.DefaultTooltipContentProps<TooltipValueType, TooltipNameType>, 'accessibilityLayer'>) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value = !labelKey && typeof label === 'string' ? (config[label]?.label ?? label) : itemConfig?.label;
if (labelFormatter) {
return <div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
className={cn(
'grid min-w-32 items-start gap-1.5 rounded-lg border border-oklch(0.922 0 0) border-oklch(0.922 0 0)/50 bg-oklch(1 0 0) px-2.5 py-1.5 text-xs shadow-xl dark:border-oklch(1 0 0 / 10%) dark:border-oklch(1 0 0 / 10%)/50 dark:bg-oklch(0.145 0 0)',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== 'none')
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color ?? item.payload?.fill ?? item.color;
return (
<div
key={index}
className={cn(
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-oklch(0.556 0 0) dark:[&>svg]:text-oklch(0.708 0 0)',
indicator === 'dot' && 'items-center',
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn('shrink-0 rounded-xs border-(--color-border) bg-(--color-bg)', {
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
})}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div className={cn('flex flex-1 justify-between leading-none', nestLabel ? 'items-end' : 'items-center')}>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-oklch(0.556 0 0) dark:text-oklch(0.708 0 0)">{itemConfig?.label ?? item.name}</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-oklch(0.145 0 0) tabular-nums dark:text-oklch(0.985 0 0)">
{typeof item.value === 'number' ? item.value.toLocaleString() : String(item.value)}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = 'bottom',
nameKey,
}: React.ComponentProps<'div'> & {
hideIcon?: boolean;
nameKey?: string;
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div className={cn('flex items-center justify-center gap-4', verticalAlign === 'top' ? 'pb-3' : 'pt-3', className)}>
{payload
.filter((item) => item.type !== 'none')
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={index}
className={cn(
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-oklch(0.556 0 0) dark:[&>svg]:text-oklch(0.708 0 0)',
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-xs"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null ? payload.payload : undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (payloadPayload && key in payloadPayload && typeof payloadPayload[key as keyof typeof payloadPayload] === 'string') {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
+92
View File
@@ -0,0 +1,92 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const emptyVariants = cva('grid justify-items-center bg-transparent p-[56px_24px] text-center', {
variants: {
variant: {
default: '',
error: 'rounded-[8px] border border-[#ebd1d1] bg-[#fffafa]',
},
},
defaultVariants: {
variant: 'default',
},
});
const emptyMediaVariants = cva('', {
variants: {
variant: {
default: '',
icon: 'mb-4.5 grid size-12 place-items-center rounded-[8px] border border-[#dcdcdc] bg-[#fafafa] text-[#666] [&_svg]:size-5.5',
},
},
defaultVariants: {
variant: 'default',
},
});
type EmptyVariant = NonNullable<VariantProps<typeof emptyVariants>['variant']>;
const EmptyVariantContext = React.createContext<EmptyVariant>('default');
function Empty({ className, variant = 'default', ...props }: React.ComponentProps<'div'> & VariantProps<typeof emptyVariants>) {
const resolvedVariant = variant ?? 'default';
return (
<EmptyVariantContext.Provider value={resolvedVariant}>
<div
data-slot="empty"
data-variant={resolvedVariant}
className={cn(emptyVariants({ variant: resolvedVariant }), className)}
{...props}
/>
</EmptyVariantContext.Provider>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="empty-header" className={cn('grid justify-items-center', className)} {...props} />;
}
function EmptyMedia({ className, variant, ...props }: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
const emptyVariant = React.useContext(EmptyVariantContext);
return (
<div
data-slot="empty-media"
data-variant={variant}
className={cn(
emptyMediaVariants({ variant }),
emptyVariant === 'error' && variant === 'icon' && 'border-[#ebcaca] bg-[#fff3f3] text-[#a34242]',
className,
)}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
const variant = React.useContext(EmptyVariantContext);
return (
<div
data-slot="empty-title"
className={cn('text-[16px] font-medium text-[#171717]', variant === 'error' && 'text-[#8b3434]', className)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p data-slot="empty-description" className={cn('mt-2 max-w-105 text-[13px] leading-[1.55] text-(--muted)', className)} {...props} />
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="empty-content" className={cn('mt-5 flex flex-col items-center', className)} {...props} />;
}
export { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle };
+103
View File
@@ -0,0 +1,103 @@
'use client';
import * as React from 'react';
import { Dialog as SheetPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { XIcon } from 'lucide-react';
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
'fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = 'right',
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left';
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
'fixed z-50 flex flex-col gap-4 bg-oklch(1 0 0) bg-clip-padding text-sm text-oklch(0.145 0 0) shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10 dark:bg-oklch(0.205 0 0) dark:text-oklch(0.985 0 0)',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm">
<XIcon />
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="sheet-header" className={cn('flex flex-col gap-0.5 p-4', className)} {...props} />;
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="sheet-footer" className={cn('mt-auto flex flex-col gap-2 p-4', className)} {...props} />;
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('text-base font-medium text-oklch(0.145 0 0) dark:text-oklch(0.985 0 0)', className)}
{...props}
/>
);
}
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-sm text-oklch(0.556 0 0) dark:text-oklch(0.708 0 0)', className)}
{...props}
/>
);
}
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };
+49
View File
@@ -0,0 +1,49 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return <table data-slot="table" className={cn('w-full border-collapse text-[12px]', className)} {...props} />;
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return <thead data-slot="table-header" className={cn(className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return <tbody data-slot="table-body" className={cn(className)} {...props} />;
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return <tfoot data-slot="table-footer" className={cn(className)} {...props} />;
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return <tr data-slot="table-row" className={cn(className)} {...props} />;
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn('sticky top-0 z-1 bg-[#fafafa] px-4 py-2.75 text-left font-normal text-[#888] shadow-[0_1px_#e5e5e5]', className)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn('whitespace-nowrap border-t border-[#ededed] px-4 py-3.5 text-[#555]', className)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
return <caption data-slot="table-caption" className={cn('mt-4 text-[12px] text-[#888]', className)} {...props} />;
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
+37
View File
@@ -0,0 +1,37 @@
import * as React from 'react';
import { Tooltip as TooltipPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({ className, sideOffset = 8, children, ...props }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center rounded-md border border-neutral-800 bg-neutral-950 px-2.5 py-1.5 text-xs leading-none font-medium whitespace-nowrap text-white shadow-lg shadow-black/15 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 dark:border-neutral-200 dark:bg-white dark:text-neutral-950',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2 fill-neutral-950 dark:fill-white" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
+52
View File
@@ -0,0 +1,52 @@
export type CompletionSettings = {
baseUrl: string;
apiKey: string;
model: string;
};
type CompletionBody = {
choices?: Array<{ message?: { content?: unknown } }>;
};
export async function requestCompletion(settings: CompletionSettings, system: string, user: string): Promise<string | null> {
try {
const response = await fetch(`${settings.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${settings.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: settings.model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
max_tokens: 160,
temperature: 0.2,
}),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
await response.body?.cancel();
console.warn(JSON.stringify({ message: 'AI completion returned an error', status: response.status }));
return null;
}
const body = (await response.json()) as CompletionBody;
const content = body.choices?.[0]?.message?.content;
if (typeof content !== 'string') {
console.warn(JSON.stringify({ message: 'AI completion returned malformed content' }));
return null;
}
return content;
} catch (error) {
console.warn(
JSON.stringify({
message: 'AI completion failed',
error: error instanceof Error ? error.message : String(error),
}),
);
return null;
}
}
+18
View File
@@ -0,0 +1,18 @@
/** Appended to every visitor-facing incident message so the update always closes with reassurance. */
export const INCIDENT_REASSURANCE =
'The problem was detected automatically, our team has been alerted, and we are working to restore normal service as soon as possible.';
/** Plain-language, non-technical description of the impact, used when no AI message is available. */
export function describeFailure(statusCode: number | null): string {
if (statusCode === null) return 'This service is currently unreachable and may not load for visitors.';
if (statusCode >= 500 && statusCode <= 599) return 'This service is having problems and some requests may fail or load incorrectly.';
if (statusCode === 429) return 'This service is under heavy load and is temporarily turning away some requests.';
if (statusCode >= 400 && statusCode <= 499) return 'This service is not responding correctly and some features may not work right now.';
if (statusCode >= 300 && statusCode <= 399) return 'This service is not loading as expected for visitors.';
return 'This service is not responding as expected and may be unavailable.';
}
/** The deterministic public message shown when AI copy is disabled or generation fails. */
export function deterministicIncidentMessage(statusCode: number | null): string {
return `${describeFailure(statusCode)} ${INCIDENT_REASSURANCE}`;
}
+176
View File
@@ -0,0 +1,176 @@
import { and, desc, eq, gte, sql } from 'drizzle-orm';
import type { CheckResult, Monitor } from '../checks/run-check';
import type { Database } from '../db/client';
import { checks, incidents } from '../db/schema';
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
const RECENT_WINDOW_MS = 24 * HOUR_MS;
const RECENT_LIMIT = 30;
const FLAP_WINDOW = 10;
/**
* A one-line, plain description of what actually failed. This is internal context
* for the model only - it may name HTTP codes and transport errors here because
* the prompt is responsible for translating them into visitor-facing language.
*/
function classifyFailure(monitor: Monitor, result: CheckResult): string {
if (result.statusCode === null) {
const error = (result.error ?? '').toLowerCase();
if (/tim(?:e|ed)\s?out|timeout|deadline|aborted/.test(error)) {
return 'Request timed out - no response came back before the timeout limit';
}
if (/getaddrinfo|enotfound|dns|name not resolved|could not resolve|eai_again/.test(error)) {
return 'DNS lookup failed - the hostname could not be resolved to an address';
}
if (/certificate|cert(?:\s|_)|self[- ]signed|tls|ssl|handshake|err_cert/.test(error)) {
return 'TLS/SSL failure - the certificate is invalid, expired, or untrusted';
}
if (/econnrefused|connection refused|refused to connect/.test(error)) {
return 'Connection refused - nothing is accepting connections at that address';
}
if (/econnreset|connection reset|socket hang up|premature close/.test(error)) {
return 'Connection dropped before any response was returned';
}
if (/ehostunreach|enetunreach|network is unreachable|no route to host/.test(error)) {
return 'Network unreachable - the host could not be contacted at all';
}
return 'No HTTP response was received from the endpoint';
}
const code = result.statusCode;
if (code >= 500) return `Server error - the endpoint answered with HTTP ${code}`;
if (code === 429) return 'The endpoint is rate limiting - HTTP 429 Too Many Requests';
if (code === 401 || code === 403) return `The endpoint rejected the health check as unauthorized - HTTP ${code}`;
if (code === 404) return 'The health-check path returned HTTP 404 Not Found';
if (code >= 400) return `Client error - the endpoint answered with HTTP ${code}`;
if (code >= 300) return `Unexpected redirect - the endpoint answered with HTTP ${code}`;
return `The endpoint answered with HTTP ${code}, but HTTP ${monitor.expectedStatus} was expected`;
}
function humanizeInterval(seconds: number): string {
if (seconds >= 3600 && seconds % 3600 === 0) return `${seconds / 3600} h`;
if (seconds >= 60 && seconds % 60 === 0) return `${seconds / 60} min`;
return `${seconds} s`;
}
function humanizeDuration(ms: number): string {
const minutes = Math.round(ms / 60_000);
if (minutes < 1) return 'under a minute';
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest ? `${hours} h ${rest} min` : `${hours} h`;
}
function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
}
function currentResponseLine(result: CheckResult): string {
return result.statusCode === null
? `Current probe: no response after ${result.latencyMs} ms`
: `Current probe: HTTP ${result.statusCode} after ${result.latencyMs} ms`;
}
/** Representative context used by the "Test generation" button in settings. */
export const SAMPLE_INCIDENT_CONTEXT = [
'Service name: Upwatch test',
'What is checked: GET request, healthy when it returns HTTP 200',
'Check frequency: every 5 min',
'Detected problem: Server error - the endpoint answered with HTTP 503',
'Current probe: HTTP 503 after 250 ms',
'Failed checks in a row: 3 (down for about 15 min)',
'Recent pattern: 3 of the last 10 checks failed',
'Typical response time when healthy: about 180 ms',
'Last healthy check: about 15 min ago',
'Availability: 96% over the last 24 h (48 checks)',
'Recurrence: 2 other incidents for this service in the last 30 days',
].join('\n');
/**
* Builds a compact, plain-text briefing about an incident for the language model.
* Never includes the monitor URL, hostname, or any IP - only the service name the
* operator chose and derived signal about behaviour over time.
*/
export async function buildIncidentContext(db: Database, monitor: Monitor, result: CheckResult): Promise<string> {
const now = Date.now();
const lines: string[] = [
`Service name: ${monitor.name}`,
`What is checked: ${monitor.method} request, healthy when it returns HTTP ${monitor.expectedStatus}`,
`Check frequency: every ${humanizeInterval(monitor.intervalSeconds)}`,
`Detected problem: ${classifyFailure(monitor, result)}`,
currentResponseLine(result),
];
try {
const recent = await db
.select({ ok: checks.ok, latencyMs: checks.latencyMs, checkedAt: checks.checkedAt })
.from(checks)
.where(and(eq(checks.monitorId, monitor.id), gte(checks.checkedAt, new Date(now - RECENT_WINDOW_MS))))
.orderBy(desc(checks.checkedAt))
.limit(RECENT_LIMIT);
if (recent.length > 0) {
let consecutive = 0;
for (const row of recent) {
if (row.ok) break;
consecutive += 1;
}
if (consecutive > 0) {
const outageStart = recent[consecutive - 1].checkedAt.getTime();
const cappedByWindow = consecutive === recent.length && recent.length === RECENT_LIMIT;
const outage = cappedByWindow ? '24 h or more' : humanizeDuration(now - outageStart);
lines.push(`Failed checks in a row: ${consecutive} (down for ${outage})`);
}
const flapWindow = recent.slice(0, FLAP_WINDOW);
const failed = flapWindow.filter((row) => !row.ok).length;
if (consecutive === 0 || failed < flapWindow.length) {
lines.push(`Recent pattern: ${failed} of the last ${flapWindow.length} checks failed (intermittent)`);
} else {
lines.push(`Recent pattern: every one of the last ${flapWindow.length} checks failed (hard down)`);
}
const healthyLatencies = recent.filter((row) => row.ok && typeof row.latencyMs === 'number').map((row) => row.latencyMs as number);
if (healthyLatencies.length > 0) {
lines.push(`Typical response time when healthy: about ${median(healthyLatencies)} ms`);
}
const lastHealthy = recent.find((row) => row.ok);
lines.push(
lastHealthy
? `Last healthy check: about ${humanizeDuration(now - lastHealthy.checkedAt.getTime())} ago`
: 'Last healthy check: none in the last 24 h',
);
const up = recent.filter((row) => row.ok).length;
lines.push(`Availability: ${Math.round((up / recent.length) * 100)}% over the last 24 h (${recent.length} checks)`);
}
const [priorIncidents] = await db
.select({ count: sql<number>`count(*)` })
.from(incidents)
.where(and(eq(incidents.monitorId, monitor.id), gte(incidents.startedAt, new Date(now - 30 * DAY_MS))));
// The incident that triggered this run is already persisted, so discount it.
const priorCount = Math.max(0, Number(priorIncidents?.count ?? 0) - 1);
lines.push(
priorCount > 0
? `Recurrence: ${priorCount} other incident${priorCount === 1 ? '' : 's'} for this service in the last 30 days`
: 'Recurrence: first incident for this service in the last 30 days',
);
} catch (error) {
console.warn(
JSON.stringify({
message: 'incident context enrichment failed',
error: error instanceof Error ? error.message : String(error),
monitorId: monitor.id,
}),
);
}
return lines.join('\n');
}
+71
View File
@@ -0,0 +1,71 @@
import { and, eq, isNull } from 'drizzle-orm';
import type { CheckResult, Monitor } from '../checks/run-check';
import { getDb } from '../db/client';
import { aiSettings, incidents } from '../db/schema';
import { requestCompletion } from './client';
import { buildIncidentContext } from './incident-context';
export const INCIDENT_MESSAGE_SYSTEM_PROMPT = [
"You write short public status updates for a website's visitors.",
'Your reader is a non-technical customer, not an engineer, and should not need any background to understand you.',
'',
'Write exactly two plain-English sentences:',
'1. What visitors may notice right now, described purely by its effect on them (pages not loading, sign-in failing, checkout not going through, slow responses). Never state the technical cause.',
'2. A calm reassurance that the problem has been detected automatically, the team has been alerted, and work to restore the service is already underway and will be completed as soon as possible.',
'',
'Rules:',
'- Never include URLs, hostnames, domain names, IP addresses, port numbers, file paths, HTTP status codes, error codes, or stack traces.',
'- Never name the technical fault (timeout, DNS, TLS, certificate, server, database, rate limit, etc.).',
'- No blame, no alarm, no speculation, and do not promise a specific fix time.',
'- Keep the whole update under 240 characters. Output only the two sentences and nothing else.',
].join('\n');
export function sanitizeIncidentMessage(value: string): string | null {
const message = value
.replace(/\r/g, '')
.trim()
.replace(/^(?:message|update|status)\s*:\s*/i, '')
.replace(/^(["'])([\s\S]*)\1$/, '$2')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 280);
if (!message) return null;
// Reject anything that leaked a technical detail past the prompt.
if (/https?:\/\//i.test(message)) return null;
if (/\b(?:\d{1,3}\.){3}\d{1,3}\b/.test(message)) return null;
if (/\bHTTP[\s/]?\d{3}\b/i.test(message) || /\b[45]\d{2}\s+(?:error|status|response)\b/i.test(message)) return null;
return message;
}
export async function generateIncidentMessage(env: Env, input: { monitor: Monitor; result: CheckResult }): Promise<string | null> {
if (!input.monitor.alertsEnabled) return null;
try {
const db = getDb(env);
const [settings] = await db.select().from(aiSettings).where(eq(aiSettings.id, 1)).limit(1);
if (!settings?.enabled || !settings.baseUrl || !settings.apiKey || !settings.model) return null;
const content = await requestCompletion(
{ baseUrl: settings.baseUrl, apiKey: settings.apiKey, model: settings.model },
INCIDENT_MESSAGE_SYSTEM_PROMPT,
await buildIncidentContext(db, input.monitor, input.result),
);
if (!content) return null;
const message = sanitizeIncidentMessage(content);
if (!message) return null;
await db
.update(incidents)
.set({ aiMessage: message, updatedAt: new Date() })
.where(and(eq(incidents.monitorId, input.monitor.id), isNull(incidents.resolvedAt)));
return message;
} catch (error) {
console.warn(
JSON.stringify({
message: 'incident message generation failed',
error: error instanceof Error ? error.message : String(error),
monitorId: input.monitor.id,
}),
);
return null;
}
}
+19 -12
View File
@@ -1,4 +1,5 @@
import { and, eq, sql } from 'drizzle-orm';
import { generateIncidentMessage } from '../ai/incident-message';
import { getDb } from '../db/client';
import { monitors } from '../db/schema';
import { sendIncidentAlert } from '../notifications/webhook';
@@ -6,6 +7,7 @@ import { buildResultStatements } from './persist-result';
import { runCheck } from './run-check';
const MAX_MONITORS_PER_RUN = 40;
const MAX_AI_MESSAGES_PER_RUN = 10;
const CONCURRENCY = 10;
export type DueCheckSummary = {
@@ -59,18 +61,23 @@ export async function runDueChecks(env: Env, ctx?: Pick<ExecutionContext, 'waitU
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
const notifications = persisted.flatMap((item) =>
item.transition === null
? []
: [
sendIncidentAlert(env, {
monitor: item.monitor,
kind: item.transition,
result: item.result,
at: item.checkedAt,
}),
],
);
let aiMessagesQueued = 0;
const notifications = persisted.flatMap((item) => {
if (item.transition === null) return [];
const work: Promise<unknown>[] = [
sendIncidentAlert(env, {
monitor: item.monitor,
kind: item.transition,
result: item.result,
at: item.checkedAt,
}),
];
if (item.transition === 'opened' && item.monitor.alertsEnabled && aiMessagesQueued < MAX_AI_MESSAGES_PER_RUN) {
aiMessagesQueued += 1;
work.push(generateIncidentMessage(env, { monitor: item.monitor, result: item.result }));
}
return work;
});
if (notifications.length > 0) {
const notificationWork = Promise.all(notifications).then(() => undefined);
if (ctx) ctx.waitUntil(notificationWork);
+11
View File
@@ -15,6 +15,16 @@ export const notificationSettings = sqliteTable('notification_settings', {
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const aiSettings = sqliteTable('ai_settings', {
id: integer('id').primaryKey(),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(false),
baseUrl: text('base_url'),
apiKey: text('api_key'),
model: text('model'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sessions = sqliteTable(
'sessions',
{
@@ -86,6 +96,7 @@ export const incidents = sqliteTable(
resolvedAt: integer('resolved_at', { mode: 'timestamp_ms' }),
startStatusCode: integer('start_status_code'),
startError: text('start_error'),
aiMessage: text('ai_message'),
durationMs: integer('duration_ms'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
+29
View File
@@ -0,0 +1,29 @@
export function isPrivateHostname(rawHostname: string): boolean {
const hostname = rawHostname
.toLowerCase()
.replace(/^\[|\]$/g, '')
.replace(/\.$/, '');
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
const ipv4 = hostname.split('.').map(Number);
if (ipv4.length === 4 && ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
const [first, second] = ipv4;
return (
first === 0 ||
first === 10 ||
first === 127 ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
);
}
if (hostname === '::' || hostname === '::1') return true;
if (/^f[cd][0-9a-f]{2}(?::|$)/i.test(hostname) || /^fe[89ab][0-9a-f](?::|$)/i.test(hostname)) return true;
const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
return mappedIpv4 ? isPrivateHostname(mappedIpv4[1]) : false;
}
export function isSafeRemoteUrl(url: URL) {
return (url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password && !isPrivateHostname(url.hostname);
}
+5 -30
View File
@@ -1,10 +1,12 @@
import { and, desc, eq, gte, isNull, or, sql } from 'drizzle-orm';
import { Hono } from 'hono';
import { generateIncidentMessage } from '../ai/incident-message';
import { buildResultStatements } from '../checks/persist-result';
import { runCheck } from '../checks/run-check';
import { getDb } from '../db/client';
import { checks, incidents, monitors } from '../db/schema';
import { requireAuth, type AuthVariables } from '../lib/require-auth';
import { isSafeRemoteUrl } from '../lib/safe-url';
import { sendIncidentAlert } from '../notifications/webhook';
type MonitorMethod = 'GET' | 'HEAD' | 'POST';
@@ -39,36 +41,6 @@ type EdgeCache = {
put(request: RequestInfo | URL, response: Response): Promise<void>;
};
function isPrivateHostname(rawHostname: string): boolean {
const hostname = rawHostname
.toLowerCase()
.replace(/^\[|\]$/g, '')
.replace(/\.$/, '');
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
const ipv4 = hostname.split('.').map(Number);
if (ipv4.length === 4 && ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
const [first, second] = ipv4;
return (
first === 0 ||
first === 10 ||
first === 127 ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
);
}
if (hostname === '::' || hostname === '::1') return true;
if (/^f[cd][0-9a-f]{2}(?::|$)/i.test(hostname) || /^fe[89ab][0-9a-f](?::|$)/i.test(hostname)) return true;
const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
return mappedIpv4 ? isPrivateHostname(mappedIpv4[1]) : false;
}
function isSafeRemoteUrl(url: URL) {
return (url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password && !isPrivateHostname(url.hostname);
}
async function readBodyLimited(response: Response, maximum: number, truncate: boolean) {
if (!response.body) return null;
const reader = response.body.getReader();
@@ -558,6 +530,9 @@ monitorRoutes.post('/:id/check', async (context) => {
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
if (transition) {
await sendIncidentAlert(context.env, { monitor, kind: transition, result, at: checkedAt });
if (transition === 'opened') {
await generateIncidentMessage(context.env, { monitor, result });
}
}
const [updated] = await db.select().from(monitors).where(eq(monitors.id, monitor.id)).limit(1);
+108 -1
View File
@@ -1,8 +1,12 @@
import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { requestCompletion } from '../ai/client';
import { SAMPLE_INCIDENT_CONTEXT } from '../ai/incident-context';
import { INCIDENT_MESSAGE_SYSTEM_PROMPT, sanitizeIncidentMessage } from '../ai/incident-message';
import { getDb } from '../db/client';
import { notificationSettings } from '../db/schema';
import { aiSettings, notificationSettings } from '../db/schema';
import { requireAuth, type AuthVariables } from '../lib/require-auth';
import { isSafeRemoteUrl } from '../lib/safe-url';
import { sendTestWebhook } from '../notifications/webhook';
type NotificationInput = {
@@ -10,6 +14,13 @@ type NotificationInput = {
webhookEnabled: boolean;
};
type AiInput = {
enabled: boolean;
baseUrl: string | null;
model: string | null;
apiKey?: string;
};
function parseNotificationInput(value: unknown): NotificationInput | string {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return 'Invalid request body';
@@ -34,6 +45,53 @@ function parseNotificationInput(value: unknown): NotificationInput | string {
return { webhookUrl, webhookEnabled: body.webhookEnabled };
}
function parseAiInput(value: unknown): AiInput | string {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return 'Invalid request body';
}
const body = value as Record<string, unknown>;
if (typeof body.enabled !== 'boolean') return 'enabled must be a boolean';
const rawBaseUrl = typeof body.baseUrl === 'string' ? body.baseUrl.trim() : body.baseUrl;
if (rawBaseUrl !== null && typeof rawBaseUrl !== 'string') return 'baseUrl must be a URL or null';
let baseUrl = rawBaseUrl || null;
if (baseUrl) {
try {
const url = new URL(baseUrl);
if (url.protocol !== 'https:' || !isSafeRemoteUrl(url)) throw new Error('unsafe URL');
baseUrl = url.toString().replace(/\/+$/, '');
} catch {
return 'Enter a valid public https base URL';
}
}
const rawModel = typeof body.model === 'string' ? body.model.trim() : body.model;
if (rawModel !== null && typeof rawModel !== 'string') return 'model must be a string or null';
const model = rawModel || null;
if (model && model.length > 100) return 'model must be between 1 and 100 characters';
let apiKey: string | undefined;
if (body.apiKey !== undefined && body.apiKey !== null) {
if (typeof body.apiKey !== 'string') return 'apiKey must be a string, null, or omitted';
apiKey = body.apiKey.trim();
}
return { enabled: body.enabled, baseUrl, model, apiKey };
}
function publicAiSettings(settings: typeof aiSettings.$inferSelect | undefined) {
return {
id: 1,
enabled: settings?.enabled ?? false,
baseUrl: settings?.baseUrl ?? null,
model: settings?.model ?? null,
apiKeySet: Boolean(settings?.apiKey),
apiKeyPreview: settings?.apiKey ? `••••••${settings.apiKey.slice(-4)}` : null,
createdAt: settings?.createdAt ?? null,
updatedAt: settings?.updatedAt ?? null,
};
}
const settingsRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
settingsRoutes.use('*', requireAuth);
@@ -75,4 +133,53 @@ settingsRoutes.post('/notifications/test', async (context) => {
return context.json({ ok: true });
});
settingsRoutes.get('/ai', async (context) => {
const [settings] = await getDb(context.env).select().from(aiSettings).where(eq(aiSettings.id, 1)).limit(1);
return context.json({ settings: publicAiSettings(settings) });
});
settingsRoutes.put('/ai', async (context) => {
let body: unknown;
try {
body = await context.req.json();
} catch {
return context.json({ message: 'Invalid request body' }, 400);
}
const input = parseAiInput(body);
if (typeof input === 'string') return context.json({ message: input }, 400);
const db = getDb(context.env);
const [existing] = await db.select().from(aiSettings).where(eq(aiSettings.id, 1)).limit(1);
const apiKey = input.apiKey === undefined ? (existing?.apiKey ?? null) : input.apiKey || null;
if (input.enabled && !input.baseUrl) return context.json({ message: 'A base URL is required when AI messages are enabled' }, 400);
if (input.enabled && !input.model) return context.json({ message: 'A model is required when AI messages are enabled' }, 400);
if (input.enabled && !apiKey) return context.json({ message: 'An API key is required when AI messages are enabled' }, 400);
const now = new Date();
const [settings] = await db
.insert(aiSettings)
.values({ id: 1, enabled: input.enabled, baseUrl: input.baseUrl, apiKey, model: input.model, createdAt: now, updatedAt: now })
.onConflictDoUpdate({
target: aiSettings.id,
set: { enabled: input.enabled, baseUrl: input.baseUrl, apiKey, model: input.model, updatedAt: now },
})
.returning();
return context.json({ settings: publicAiSettings(settings) });
});
settingsRoutes.post('/ai/test', async (context) => {
const [settings] = await getDb(context.env).select().from(aiSettings).where(eq(aiSettings.id, 1)).limit(1);
if (!settings?.baseUrl || !settings.apiKey || !settings.model) {
return context.json({ message: 'Save a base URL, API key, and model first' }, 400);
}
const content = await requestCompletion(
{ baseUrl: settings.baseUrl, apiKey: settings.apiKey, model: settings.model },
INCIDENT_MESSAGE_SYSTEM_PROMPT,
SAMPLE_INCIDENT_CONTEXT,
);
const message = content ? sanitizeIncidentMessage(content) : null;
if (!message) return context.json({ message: 'AI message generation failed' }, 502);
return context.json({ ok: true, message });
});
export default settingsRoutes;
+23 -3
View File
@@ -1,7 +1,8 @@
import { and, eq, gte, inArray, lt, sql } from 'drizzle-orm';
import { and, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm';
import { Hono } from 'hono';
import { deterministicIncidentMessage } from '../ai/fallback-message';
import { getDb } from '../db/client';
import { checks, monitorDailyStats, monitors } from '../db/schema';
import { checks, incidents, monitorDailyStats, monitors } from '../db/schema';
import { resolveFavicon } from './monitors';
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -22,6 +23,12 @@ type DailyAggregate = {
upChecks: number;
};
type OpenIncident = {
monitorId: number;
aiMessage: string | null;
startStatusCode: number | null;
};
type EdgeCache = {
match(request: RequestInfo | URL): Promise<Response | undefined>;
put(request: RequestInfo | URL, response: Response): Promise<void>;
@@ -76,9 +83,10 @@ statusRoutes.get('/', async (context) => {
const monitorIds = monitorRows.map((monitor) => monitor.id);
let historicalRows: DailyAggregate[] = [];
let todayRows: DailyAggregate[] = [];
let openIncidentRows: OpenIncident[] = [];
if (monitorIds.length > 0) {
[historicalRows, todayRows] = await Promise.all([
[historicalRows, todayRows, openIncidentRows] = await Promise.all([
db
.select({
monitorId: monitorDailyStats.monitorId,
@@ -105,6 +113,14 @@ statusRoutes.get('/', async (context) => {
.from(checks)
.where(and(inArray(checks.monitorId, monitorIds), gte(checks.checkedAt, new Date(today))))
.groupBy(checks.monitorId),
db
.select({
monitorId: incidents.monitorId,
aiMessage: incidents.aiMessage,
startStatusCode: incidents.startStatusCode,
})
.from(incidents)
.where(and(inArray(incidents.monitorId, monitorIds), isNull(incidents.resolvedAt))),
]);
}
@@ -114,6 +130,7 @@ statusRoutes.get('/', async (context) => {
if (buckets) buckets.push(row);
else bucketsByMonitor.set(row.monitorId, [row]);
}
const openIncidentsByMonitor = new Map(openIncidentRows.map((incident) => [incident.monitorId, incident]));
const services = monitorRows.map((monitor) => {
const buckets = bucketsByMonitor.get(monitor.id) ?? [];
@@ -128,10 +145,13 @@ statusRoutes.get('/', async (context) => {
};
});
const openIncident = openIncidentsByMonitor.get(monitor.id);
return {
id: monitor.id,
name: monitor.name,
status: serviceStatus(monitor.lastOk),
message:
monitor.lastOk === false ? (openIncident?.aiMessage ?? deterministicIncidentMessage(openIncident?.startStatusCode ?? null)) : null,
lastCheckedAt: monitor.lastCheckedAt?.toISOString() ?? null,
uptime90d: roundUptime(upChecks, totalChecks),
history,