diff --git a/migrations/0004_smiling_jack_flag.sql b/migrations/0004_smiling_jack_flag.sql new file mode 100644 index 0000000..e0901b9 --- /dev/null +++ b/migrations/0004_smiling_jack_flag.sql @@ -0,0 +1,11 @@ +CREATE TABLE `ai_settings` ( + `id` integer PRIMARY KEY NOT NULL, + `enabled` integer DEFAULT false NOT NULL, + `base_url` text, + `api_key` text, + `model` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +ALTER TABLE `incidents` ADD `ai_message` text; \ No newline at end of file diff --git a/migrations/meta/0004_snapshot.json b/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..e2d2815 --- /dev/null +++ b/migrations/meta/0004_snapshot.json @@ -0,0 +1,659 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5c9f981a-cccd-4832-806c-4b156078d34d", + "prevId": "c137c06e-d876-42ae-a0a9-39115b3f0ebf", + "tables": { + "admin_credentials": { + "name": "admin_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_settings": { + "name": "ai_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "checks": { + "name": "checks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ok": { + "name": "ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "checks_monitor_id_checked_at_idx": { + "name": "checks_monitor_id_checked_at_idx", + "columns": [ + "monitor_id", + "checked_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "checks_monitor_id_monitors_id_fk": { + "name": "checks_monitor_id_monitors_id_fk", + "tableFrom": "checks", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "incidents": { + "name": "incidents", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_status_code": { + "name": "start_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_error": { + "name": "start_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ai_message": { + "name": "ai_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "incidents_monitor_id_started_at_idx": { + "name": "incidents_monitor_id_started_at_idx", + "columns": [ + "monitor_id", + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "incidents_monitor_id_monitors_id_fk": { + "name": "incidents_monitor_id_monitors_id_fk", + "tableFrom": "incidents", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "login_attempts": { + "name": "login_attempts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "login_attempts_ip_attempted_at_idx": { + "name": "login_attempts_ip_attempted_at_idx", + "columns": [ + "ip_address", + "attempted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_daily_stats": { + "name": "monitor_daily_stats", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_checks": { + "name": "total_checks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "up_checks": { + "name": "up_checks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avg_latency_ms": { + "name": "avg_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "min_latency_ms": { + "name": "min_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_latency_ms": { + "name": "max_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "monitor_daily_stats_monitor_id_day_uidx": { + "name": "monitor_daily_stats_monitor_id_day_uidx", + "columns": [ + "monitor_id", + "day" + ], + "isUnique": true + } + }, + "foreignKeys": { + "monitor_daily_stats_monitor_id_monitors_id_fk": { + "name": "monitor_daily_stats_monitor_id_monitors_id_fk", + "tableFrom": "monitor_daily_stats", + "tableTo": "monitors", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitors": { + "name": "monitors", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'GET'" + }, + "expected_status": { + "name": "expected_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 200 + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "timeout_ms": { + "name": "timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10000 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "alerts_enabled": { + "name": "alerts_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_ok": { + "name": "last_ok", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_status_code": { + "name": "last_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_latency_ms": { + "name": "last_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "monitors_enabled_last_checked_at_idx": { + "name": "monitors_enabled_last_checked_at_idx", + "columns": [ + "enabled", + "last_checked_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_settings": { + "name": "notification_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 34b18ec..c3e75f1 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1787887541718, "tag": "0003_colorful_kingpin", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1787997596170, + "tag": "0004_smiling_jack_flag", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/client/App.tsx b/src/client/App.tsx index fbfe00b..a4a1ea0 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -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 ; - if (pathname === '/login') return ; - const monitorMatch = pathname.match(/^\/monitors\/(\d+)\/?$/); - const page = monitorMatch ? ( - - ) : pathname === '/settings' ? ( - - ) : ( - - ); - return {page}; + let content; + if (pathname === '/') content = ; + else if (pathname === '/login') content = ; + else { + const monitorMatch = pathname.match(/^\/monitors\/(\d+)\/?$/); + const page = monitorMatch ? ( + + ) : pathname === '/settings' ? ( + + ) : ( + + ); + content = {page}; + } + + return {content}; } export default App; diff --git a/src/client/api/monitors.ts b/src/client/api/monitors.ts index ff2c527..2d6ca8c 100644 --- a/src/client/api/monitors.ts +++ b/src/client/api/monitors.ts @@ -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; diff --git a/src/client/api/settings.ts b/src/client/api/settings.ts index 3662ffc..52778ce 100644 --- a/src/client/api/settings.ts +++ b/src/client/api/settings.ts @@ -10,6 +10,24 @@ export type NotificationSettings = { export type NotificationSettingsInput = Pick; +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'); +} diff --git a/src/client/api/status.ts b/src/client/api/status.ts index fdf9ca0..cd2a84d 100644 --- a/src/client/api/status.ts +++ b/src/client/api/status.ts @@ -7,6 +7,7 @@ export type PublicService = { id: number; name: string; status: PublicServiceStatus; + message: string | null; lastCheckedAt: string | null; uptime90d: number | null; history: Array<{ diff --git a/src/client/components/AppHeader.tsx b/src/client/components/AppHeader.tsx new file mode 100644 index 0000000..1cddec7 --- /dev/null +++ b/src/client/components/AppHeader.tsx @@ -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 ( +
+
+ + + + +
+ + + + + + + + + {context && {context}} + + + + +
+
+
+ ); +} diff --git a/src/client/components/charts/LatencySparkline.tsx b/src/client/components/charts/LatencySparkline.tsx index 303850e..80205fa 100644 --- a/src/client/components/charts/LatencySparkline.tsx +++ b/src/client/components/charts/LatencySparkline.tsx @@ -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 ( -
- - {point.latency} ms - {point.ok ? 'Up' : 'Down'} -
- ); -} +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
More checks are needed to draw latency.
; + if (data.length < 2) + return ( + + Not enough latency data + More checks are needed to draw latency. + + ); const minimum = Math.min(...values); const maximum = Math.max(...values); return (
- + - - + + - + - + new Date((payload[0].payload as LatencyDatum).t).toLocaleString()} + /> + } + cursor={{ stroke: 'var(--primary)', strokeDasharray: '3 4' }} + isAnimationActive={false} + /> setHasAnimated(true)} /> - +
{maximum} ms {minimum} ms diff --git a/src/client/components/charts/UptimeBar.tsx b/src/client/components/charts/UptimeBar.tsx index 39501a2..d18eee0 100644 --- a/src/client/components/charts/UptimeBar.tsx +++ b/src/client/components/charts/UptimeBar.tsx @@ -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 ( -
- - {point.ok ? 'Up' : 'Down'} -
- ); -} +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
No availability checks recorded yet.
; + if (data.length === 0) + return ( + + No availability data yet + No availability checks recorded yet. + + ); const successfulChecks = data.filter((check) => check.ok).length; return (
- + - + new Date((payload[0].payload as UptimeDatum).t).toLocaleString()} + /> + } + isAnimationActive={false} + /> setHasAnimated(true)} > {data.map((point) => ( - + ))} - +
Oldest diff --git a/src/client/components/dashboard/DashboardHeader.tsx b/src/client/components/dashboard/DashboardHeader.tsx deleted file mode 100644 index 9fd1310..0000000 --- a/src/client/components/dashboard/DashboardHeader.tsx +++ /dev/null @@ -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 ( -
-
- - - upwatch - -
- Production monitors - - View status page - - - -
-
-
- ); -} diff --git a/src/client/components/dashboard/DashboardOverview.tsx b/src/client/components/dashboard/DashboardOverview.tsx index c690395..cfa5e33 100644 --- a/src/client/components/dashboard/DashboardOverview.tsx +++ b/src/client/components/dashboard/DashboardOverview.tsx @@ -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) {
-
+

Total monitors

{monitors.length} {monitors.filter((monitor) => monitor.enabled).length} enabled -
-
+ +

Currently up

{up} Latest checks succeeded -
-
+ +

Currently down

{down} Needs attention -
+
); diff --git a/src/client/components/dashboard/MonitorFormDialog.tsx b/src/client/components/dashboard/MonitorFormDialog.tsx index d1f4d6f..5d1fbb5 100644 --- a/src/client/components/dashboard/MonitorFormDialog.tsx +++ b/src/client/components/dashboard/MonitorFormDialog.tsx @@ -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' }, diff --git a/src/client/components/dashboard/MonitorListPanel.tsx b/src/client/components/dashboard/MonitorListPanel.tsx index 4ae1192..2268a2c 100644 --- a/src/client/components/dashboard/MonitorListPanel.tsx +++ b/src/client/components/dashboard/MonitorListPanel.tsx @@ -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 ))}
) : monitorsQuery.isError ? ( -
- Monitors could not be loaded -

{errorMessage(monitorsQuery.error, 'Unknown request error')}

- -
- ) : monitors.length === 0 ? ( -
- - - - No monitors yet -

Add the first endpoint to start collecting availability checks.

- {!formOpen && ( - + + + ) : monitors.length === 0 ? ( + + + + + No monitors yet + Add the first endpoint to start collecting availability checks. + {!formOpen && ( + + + )} -
+ ) : (
diff --git a/src/client/pages/DashboardPage.tsx b/src/client/pages/DashboardPage.tsx index 2e4e300..1b0ff6b 100644 --- a/src/client/pages/DashboardPage.tsx +++ b/src/client/pages/DashboardPage.tsx @@ -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 (
- +
diff --git a/src/client/pages/LoginPage.tsx b/src/client/pages/LoginPage.tsx index e5e4550..e25ad25 100644 --- a/src/client/pages/LoginPage.tsx +++ b/src/client/pages/LoginPage.tsx @@ -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() { upwatch
-
-
-

Admin access

-

Sign in to Upwatch

- Enter the admin password to manage your monitors. -
- -
-
- - setPassword(event.target.value)} - minLength={8} - required - /> + +
+
+

Admin access

+

Sign in to Upwatch

+ Enter the admin password to manage your monitors.
- {loginMutation.isError && ( -

- {errorMessage} -

- )} + +
+ + setPassword(event.target.value)} + minLength={8} + required + /> +
- - -
+ {loginMutation.isError && ( +

+ {errorMessage} +

+ )} + + + +
+

Protected by an encrypted, seven-day session.

diff --git a/src/client/pages/MonitorDetailPage.tsx b/src/client/pages/MonitorDetailPage.tsx index 6e14aa7..fff50b1 100644 --- a/src/client/pages/MonitorDetailPage.tsx +++ b/src/client/pages/MonitorDetailPage.tsx @@ -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 ( -
- Monitor not found -

The requested monitor could not be loaded.

- +
+ + Monitor not found + The requested monitor could not be loaded. + + + +
); return (
-
-
- -
- - -
-
-
+ + {isEditing && setIsEditing(false)} />} + setIsConfirmingDelete(false)} + onConfirm={() => deleteMutation.mutate(monitor.id, { onSuccess: () => navigate('/dashboard') })} + />
+
+
+
+
Method
+
{monitor.method}
+
+
+
Expected status
+
{monitor.expectedStatus}
+
+
+
Check interval
+
{formatInterval(monitor.intervalSeconds)}
+
+
+
Timeout
+
{monitor.timeoutMs.toLocaleString()} ms
+
+
+
Scheduled checks
+
+ {monitor.enabled ? 'Running' : 'Paused'} + +
+
+
+
Incident alerts
+
{monitor.alertsEnabled ? 'Enabled' : 'Muted'}
+
+
+
+

This permanently deletes the monitor and its check history.

+ +
+
); diff --git a/src/client/pages/SettingsPage.tsx b/src/client/pages/SettingsPage.tsx index fef6961..cef8ec2 100644 --- a/src/client/pages/SettingsPage.tsx +++ b/src/client/pages/SettingsPage.tsx @@ -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 ( +
+ + + +
+ + +
+
+ + +
+ {updateMutation.isSuccess &&

AI settings saved.

} + {testMutation.isSuccess &&

{testMutation.data.message}

} + {(updateMutation.isError || testMutation.isError) && ( +

{(updateMutation.error ?? testMutation.error)?.message ?? 'Request failed'}

+ )} +
+ ); +} + export function SettingsPage() { const settingsQuery = useNotificationSettingsQuery(); - const logoutMutation = useLogoutMutation(); + const aiSettingsQuery = useAiSettingsQuery(); return (
-
-
- -
- Settings - -
-
-
+

Integrations

-

Notifications

-

Route monitor transitions to Slack, Discord, or any service that accepts JSON webhooks.

+

Notifications & AI

+

Configure incident alerts and visitor-friendly status updates from one place.

-
-
- - - -
-

Incident webhook

-

Upwatch sends a compact JSON payload for down and recovery events. Delivery failures never interrupt monitoring.

+ +
+
+ + + +
+

Incident webhook

+

Upwatch sends a compact JSON payload for down and recovery events. Delivery failures never interrupt monitoring.

+
-
- {settingsQuery.isPending ? ( -
Loading settings…
- ) : settingsQuery.isError ? ( -

Unable to load notification settings.

- ) : ( - - )} -
+ {settingsQuery.isPending ? ( +
Loading settings…
+ ) : settingsQuery.isError ? ( + + Unable to load notification settings + + ) : ( + + )} + + + +
+
+ + + +
+

AI incident messages

+

+ Turn technical check failures into short, sanitized updates for visitors. Generation runs only when an incident opens. +

+
+
+ {aiSettingsQuery.isPending ? ( +
Loading settings…
+ ) : aiSettingsQuery.isError ? ( + + Unable to load AI settings + + ) : ( + + )} +
+

Payload preview

{`{
diff --git a/src/client/pages/StatusPage.tsx b/src/client/pages/StatusPage.tsx
index 3fb8e8d..b24fb94 100644
--- a/src/client/pages/StatusPage.tsx
+++ b/src/client/pages/StatusPage.tsx
@@ -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() {
 						
) : statusQuery.isError || !status ? ( -
- + + - - Status could not be loaded -

{errorMessage(statusQuery.error)}

- -
+ + Status could not be loaded + {errorMessage(statusQuery.error)} + + + + ) : ( <>
@@ -122,6 +126,45 @@ export function StatusPage() {
+ {activeIncidents.length > 0 && ( +
+
+
+ + +
+

Active incident

+

We’re working to restore service

+
+
+ + {activeIncidents.length} {activeIncidents.length === 1 ? 'service' : 'services'} affected + +
+ +
+ {activeIncidents.map((service) => ( +
+
+ + + +
+ {service.name} + Service disruption +
+
+

{service.message}

+ + Investigating + +
+ ))} +
+
+ )} +
@@ -141,13 +184,13 @@ export function StatusPage() {
{status.services.length === 0 ? ( -
- + + - - No public services yet -

Service health will appear here after monitoring is enabled.

-
+ + No public services yet + Service health will appear here after monitoring is enabled. + ) : (
{status.services.map((service) => { diff --git a/src/client/queries/settings.ts b/src/client/queries/settings.ts index 45537b8..40954a7 100644 --- a/src/client/queries/settings.ts +++ b/src/client/queries/settings.ts @@ -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 }); +} diff --git a/src/client/styles.css b/src/client/styles.css index a9eef8e..bf55526 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -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); diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..9c52a75 --- /dev/null +++ b/src/components/ui/card.tsx @@ -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 ; +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +export { Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter }; diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx new file mode 100644 index 0000000..f03fedc --- /dev/null +++ b/src/components/ui/chart.tsx @@ -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 }) +>; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error('useChart must be used within a '); + } + + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<'div'> & { + config: ChartConfig; + children: React.ComponentProps['children']; + initialDimension?: { + width: number; + height: number; + }; +}) { + const uniqueId = React.useId(); + const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`; + + return ( + +
+ + {children} +
+
+ ); +} + +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 ( +