From 2261c6d38bd093c13da91d9b988319fafbdee3f0 Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Sat, 29 Aug 2026 13:00:38 +0700 Subject: [PATCH] feat: integrate some shadcn component --- .prettierrc | 3 +- src/client/components/.gitkeep | 1 - .../components/dashboard/DashboardFooter.tsx | 8 + .../components/dashboard/DashboardHeader.tsx | 37 ++ .../dashboard/DashboardOverview.tsx | 49 ++ .../dashboard/DeleteMonitorDialog.tsx | 63 +++ .../dashboard/MonitorFormDialog.tsx | 198 ++++++++ .../components/dashboard/MonitorListPanel.tsx | 209 +++++++++ src/client/pages/.gitkeep | 1 - src/client/pages/DashboardPage.tsx | 423 +----------------- src/client/pages/LoginPage.tsx | 3 +- src/client/pages/MonitorDetailPage.tsx | 13 +- src/client/pages/SettingsPage.tsx | 19 +- src/client/pages/StatusPage.tsx | 8 +- src/client/styles.css | 76 +--- src/components/ui/alert-dialog.tsx | 84 ++++ src/components/ui/badge.tsx | 30 ++ src/components/ui/input.tsx | 29 ++ src/components/ui/select.tsx | 152 +++++++ src/components/ui/switch.tsx | 27 ++ 20 files changed, 940 insertions(+), 493 deletions(-) delete mode 100644 src/client/components/.gitkeep create mode 100644 src/client/components/dashboard/DashboardFooter.tsx create mode 100644 src/client/components/dashboard/DashboardHeader.tsx create mode 100644 src/client/components/dashboard/DashboardOverview.tsx create mode 100644 src/client/components/dashboard/DeleteMonitorDialog.tsx create mode 100644 src/client/components/dashboard/MonitorFormDialog.tsx create mode 100644 src/client/components/dashboard/MonitorListPanel.tsx delete mode 100644 src/client/pages/.gitkeep create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/switch.tsx diff --git a/.prettierrc b/.prettierrc index 5c7b5d3..8546789 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,5 +2,6 @@ "printWidth": 140, "singleQuote": true, "semi": true, - "useTabs": true + "useTabs": true, + "endOfLine": "auto" } diff --git a/src/client/components/.gitkeep b/src/client/components/.gitkeep deleted file mode 100644 index 33d0c25..0000000 --- a/src/client/components/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Reserved for shared components. diff --git a/src/client/components/dashboard/DashboardFooter.tsx b/src/client/components/dashboard/DashboardFooter.tsx new file mode 100644 index 0000000..8585bf0 --- /dev/null +++ b/src/client/components/dashboard/DashboardFooter.tsx @@ -0,0 +1,8 @@ +export function DashboardFooter() { + return ( + + ); +} diff --git a/src/client/components/dashboard/DashboardHeader.tsx b/src/client/components/dashboard/DashboardHeader.tsx new file mode 100644 index 0000000..9fd1310 --- /dev/null +++ b/src/client/components/dashboard/DashboardHeader.tsx @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..c690395 --- /dev/null +++ b/src/client/components/dashboard/DashboardOverview.tsx @@ -0,0 +1,49 @@ +import { ArrowRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useMonitorsQuery } from '../../queries/monitors'; + +type DashboardOverviewProps = { + onAddMonitor: () => void; +}; + +export function DashboardOverview({ onAddMonitor }: DashboardOverviewProps) { + const monitorsQuery = useMonitorsQuery(); + const monitors = monitorsQuery.data?.monitors ?? []; + const up = monitors.filter((monitor) => monitor.lastOk === true).length; + const down = monitors.filter((monitor) => monitor.lastOk === false).length; + + return ( + <> +
+
+

Infrastructure

+

Monitors

+

Track endpoint availability from Cloudflare's edge every five minutes.

+
+ {monitorsQuery.isSuccess && monitors.length > 0 ? ( + + ) : null} +
+ +
+
+

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/DeleteMonitorDialog.tsx b/src/client/components/dashboard/DeleteMonitorDialog.tsx new file mode 100644 index 0000000..12a0c72 --- /dev/null +++ b/src/client/components/dashboard/DeleteMonitorDialog.tsx @@ -0,0 +1,63 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import type { Monitor } from '../../api/monitors'; + +type DeleteMonitorDialogProps = { + monitor: Monitor | null; + isPending: boolean; + onCancel: () => void; + onConfirm: () => void; +}; + +export function DeleteMonitorDialog({ monitor, isPending, onCancel, onConfirm }: DeleteMonitorDialogProps) { + return ( + { + if (!open && !isPending) onCancel(); + }} + > + { + if (isPending) event.preventDefault(); + }} + > + +

Confirm

+ Delete {monitor?.name}? + This permanently deletes the monitor and its check history. +
+ + + + + + + + +
+
+ ); +} diff --git a/src/client/components/dashboard/MonitorFormDialog.tsx b/src/client/components/dashboard/MonitorFormDialog.tsx new file mode 100644 index 0000000..d1f4d6f --- /dev/null +++ b/src/client/components/dashboard/MonitorFormDialog.tsx @@ -0,0 +1,198 @@ +import { type FormEvent, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import type { Monitor, MonitorInput, MonitorMethod } from '../../api/monitors'; +import { useCreateMonitorMutation, useUpdateMonitorMutation } from '../../queries/monitors'; + +export const DEFAULT_MONITOR_INPUT: MonitorInput = { + name: '', + url: 'https://', + method: 'GET', + expectedStatus: 200, + intervalSeconds: 300, + timeoutMs: 10_000, + enabled: true, +}; + +const INTERVAL_OPTIONS = [ + { value: '300', label: '5 minutes' }, + { value: '900', label: '15 minutes' }, + { value: '1800', label: '30 minutes' }, + { value: '3600', label: '1 hour' }, + { value: '86400', label: '24 hours' }, +]; + +type MonitorFormDialogProps = { + editing: Monitor | null; + onClose: () => void; +}; + +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +function monitorInput(monitor: Monitor | null): MonitorInput { + if (!monitor) return DEFAULT_MONITOR_INPUT; + return { + name: monitor.name, + url: monitor.url, + method: monitor.method, + expectedStatus: monitor.expectedStatus, + intervalSeconds: monitor.intervalSeconds, + timeoutMs: monitor.timeoutMs, + enabled: monitor.enabled, + alertsEnabled: monitor.alertsEnabled, + }; +} + +export function MonitorFormDialog({ editing, onClose }: MonitorFormDialogProps) { + const createMutation = useCreateMonitorMutation(); + const updateMutation = useUpdateMonitorMutation(); + const [form, setForm] = useState(() => monitorInput(editing)); + const formMutation = editing ? updateMutation : createMutation; + + function closeForm() { + if (formMutation.isPending) return; + onClose(); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (editing) { + updateMutation.mutate({ id: editing.id, input: form }, { onSuccess: onClose }); + } else { + createMutation.mutate(form, { onSuccess: onClose }); + } + } + + return ( + { + if (!nextOpen) closeForm(); + }} + > + { + if (formMutation.isPending) event.preventDefault(); + }} + onInteractOutside={(event) => { + if (formMutation.isPending) event.preventDefault(); + }} + > + +

Configuration

+ {editing ? `Edit ${editing.name}` : 'Add a monitor'} + Checks run on the configured schedule, with a minimum interval of five minutes. +
+
+ + +
+ Method + +
+ +
+ Interval + +
+ +
+ setForm({ ...form, enabled })} /> + +
+
+ setForm({ ...form, alertsEnabled })} + /> + +
+
+ + +
+ {formMutation.isError && ( +

+ {errorMessage(formMutation.error, 'Unable to save monitor')} +

+ )} +
+
+
+ ); +} diff --git a/src/client/components/dashboard/MonitorListPanel.tsx b/src/client/components/dashboard/MonitorListPanel.tsx new file mode 100644 index 0000000..4ae1192 --- /dev/null +++ b/src/client/components/dashboard/MonitorListPanel.tsx @@ -0,0 +1,209 @@ +import { useState } from 'react'; +import { 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 type { Monitor } from '../../api/monitors'; +import { navigate } from '../../lib/router'; +import { useDeleteMonitorMutation, useMonitorsQuery, useRunCheckMutation, useUpdateMonitorMutation } from '../../queries/monitors'; +import { SiteIcon } from '../SiteIcon'; +import { DeleteMonitorDialog } from './DeleteMonitorDialog'; + +type MonitorListPanelProps = { + formOpen: boolean; + onAddMonitor: () => void; + onEdit: (monitor: Monitor) => void; +}; + +function formatCheckedAt(value: string | null) { + if (!value) return 'Not checked yet'; + return new Intl.DateTimeFormat('en', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); +} + +function monitorStatus(monitor: Monitor): { label: string; className: BadgeVariant } { + if (monitor.lastOk === true) return { label: 'Up', className: 'online' }; + if (monitor.lastOk === false) return { label: 'Down', className: 'offline' }; + return { label: 'Not checked', className: 'checking' }; +} + +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +export function MonitorListPanel({ formOpen, onAddMonitor, onEdit }: MonitorListPanelProps) { + const monitorsQuery = useMonitorsQuery(); + const updateMutation = useUpdateMonitorMutation(); + const deleteMutation = useDeleteMonitorMutation(); + const checkMutation = useRunCheckMutation(); + const [pendingDelete, setPendingDelete] = useState(null); + const monitors = monitorsQuery.data?.monitors ?? []; + + function confirmDelete() { + if (!pendingDelete) return; + deleteMutation.mutate(pendingDelete.id, { + onSuccess: () => setPendingDelete(null), + }); + } + + return ( +
+ setPendingDelete(null)} + onConfirm={confirmDelete} + /> +
+
+

Configured sites

+

Latest result for each monitored endpoint.

+
+ +
+ + {monitorsQuery.isPending ? ( +
+ {[0, 1, 2].map((item) => ( +
+ + + +
+ ))} +
+ ) : 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 && ( + + )} +
+ ) : ( +
+
+ Monitor + Latest result + Actions +
+ {monitors.map((monitor) => { + const status = monitorStatus(monitor); + const checking = checkMutation.isPending && checkMutation.variables === monitor.id; + const deleting = deleteMutation.isPending && deleteMutation.variables === monitor.id; + const toggling = updateMutation.isPending && updateMutation.variables?.id === monitor.id; + return ( +
+
+ + + +
+ + {monitor.url} + + {monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m + +
+
+
+ {checking ? 'Checking' : status.label} + + {monitor.lastStatusCode === null ? '—' : `HTTP ${monitor.lastStatusCode}`} ·{' '} + {monitor.lastLatencyMs === null ? '—' : `${monitor.lastLatencyMs} ms`} + + {monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)} +
+
+ + + + + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/client/pages/.gitkeep b/src/client/pages/.gitkeep deleted file mode 100644 index 515a594..0000000 --- a/src/client/pages/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Reserved for application pages. diff --git a/src/client/pages/DashboardPage.tsx b/src/client/pages/DashboardPage.tsx index 7e55c42..2e4e300 100644 --- a/src/client/pages/DashboardPage.tsx +++ b/src/client/pages/DashboardPage.tsx @@ -1,438 +1,43 @@ -import { type FormEvent, useState } from 'react'; -import { ArrowRight, Database, History, Pencil, Power, PowerOff, RefreshCw, Trash2, Zap } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import type { Monitor, MonitorInput, MonitorMethod } from '../api/monitors'; -import { SiteIcon } from '../components/SiteIcon'; -import { useLogoutMutation } from '../queries/auth'; -import { navigate } from '../lib/router'; -import { - useCreateMonitorMutation, - useDeleteMonitorMutation, - useMonitorsQuery, - useRunCheckMutation, - useUpdateMonitorMutation, -} from '../queries/monitors'; - -const DEFAULT_INPUT: MonitorInput = { - name: '', - url: 'https://', - method: 'GET', - expectedStatus: 200, - intervalSeconds: 300, - timeoutMs: 10_000, - enabled: true, -}; - -function formatCheckedAt(value: string | null) { - if (!value) return 'Not checked yet'; - return new Intl.DateTimeFormat('en', { - dateStyle: 'medium', - timeStyle: 'short', - }).format(new Date(value)); -} - -function monitorStatus(monitor: Monitor) { - if (monitor.lastOk === true) return { label: 'Up', className: 'online' }; - if (monitor.lastOk === false) return { label: 'Down', className: 'offline' }; - return { label: 'Not checked', className: 'checking' }; -} - -function errorMessage(error: unknown, fallback: string) { - return error instanceof Error ? error.message : fallback; -} +import { useState } from 'react'; +import type { Monitor } from '../api/monitors'; +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'; export function DashboardPage() { - const monitorsQuery = useMonitorsQuery(); - const createMutation = useCreateMonitorMutation(); - const updateMutation = useUpdateMonitorMutation(); - const deleteMutation = useDeleteMonitorMutation(); - const checkMutation = useRunCheckMutation(); - const logoutMutation = useLogoutMutation(); - const [formOpen, setFormOpen] = useState(false); const [editing, setEditing] = useState(null); - const [form, setForm] = useState(DEFAULT_INPUT); - - const monitors = monitorsQuery.data?.monitors ?? []; - const up = monitors.filter((monitor) => monitor.lastOk === true).length; - const down = monitors.filter((monitor) => monitor.lastOk === false).length; - const formMutation = editing ? updateMutation : createMutation; + const [formOpen, setFormOpen] = useState(false); function openCreateForm() { setEditing(null); - setForm(DEFAULT_INPUT); setFormOpen(true); } function openEditForm(monitor: Monitor) { setEditing(monitor); - setForm({ - name: monitor.name, - url: monitor.url, - method: monitor.method, - expectedStatus: monitor.expectedStatus, - intervalSeconds: monitor.intervalSeconds, - timeoutMs: monitor.timeoutMs, - enabled: monitor.enabled, - alertsEnabled: monitor.alertsEnabled, - }); setFormOpen(true); } function closeForm() { - if (formMutation.isPending) return; setFormOpen(false); setEditing(null); } - function handleSubmit(event: FormEvent) { - event.preventDefault(); - const onSuccess = () => closeForm(); - if (editing) { - updateMutation.mutate({ id: editing.id, input: form }, { onSuccess }); - } else { - createMutation.mutate(form, { onSuccess }); - } - } - - function handleDelete(monitor: Monitor) { - if (window.confirm(`Delete ${monitor.name} and its check history?`)) { - deleteMutation.mutate(monitor.id); - } - } - return (
-
-
- - - upwatch - -
- Production monitors - - View status page - - - -
-
-
+
-
-
-

Infrastructure

-

Monitors

-

Track endpoint availability from Cloudflare's edge every five minutes.

-
- {monitorsQuery.isSuccess && monitors.length > 0 ? ( - - ) : null} -
+ -
-
-

Total monitors

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

Currently up

- {up} - Latest checks succeeded -
-
-

Currently down

- {down} - Needs attention -
-
+ {formOpen ? : null} - { - if (!open) closeForm(); - }} - > - { - if (formMutation.isPending) event.preventDefault(); - }} - onInteractOutside={(event) => { - if (formMutation.isPending) event.preventDefault(); - }} - > - -

Configuration

- {editing ? `Edit ${editing.name}` : 'Add a monitor'} - Checks run on the configured schedule, with a minimum interval of five minutes. -
-
- - - - - - - - -
- - -
- {formMutation.isError && ( -

- {errorMessage(formMutation.error, 'Unable to save monitor')} -

- )} -
-
-
- -
-
-
-

Configured sites

-

Latest result for each monitored endpoint.

-
- -
- - {monitorsQuery.isPending ? ( -
- {[0, 1, 2].map((item) => ( -
- - - -
- ))} -
- ) : 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 && ( - - )} -
- ) : ( -
-
- Monitor - Latest result - Actions -
- {monitors.map((monitor) => { - const status = monitorStatus(monitor); - const checking = checkMutation.isPending && checkMutation.variables === monitor.id; - const deleting = deleteMutation.isPending && deleteMutation.variables === monitor.id; - const toggling = updateMutation.isPending && updateMutation.variables?.id === monitor.id; - return ( -
-
- - - -
- - {monitor.url} - - {monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m - -
-
-
- - - {checking ? 'Checking' : status.label} - - - {monitor.lastStatusCode === null ? '—' : `HTTP ${monitor.lastStatusCode}`} ·{' '} - {monitor.lastLatencyMs === null ? '—' : `${monitor.lastLatencyMs} ms`} - - {monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)} -
-
- - - - - -
-
- ); - })} -
- )} -
+
-
- Cloudflare Workers + D1 - Automatic refresh every minute -
+ +
); } diff --git a/src/client/pages/LoginPage.tsx b/src/client/pages/LoginPage.tsx index 3a3f3b2..e5e4550 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 { Input } from '@/components/ui/input'; import { navigate } from '../lib/router'; import { useLoginMutation, useSessionQuery } from '../queries/auth'; @@ -37,7 +38,7 @@ export function LoginPage() {
-
- - - {status.label} - + {status.label} {!monitor.alertsEnabled && ( Alerts muted @@ -187,10 +185,7 @@ export function MonitorDetailPage({ id }: { id: number }) { {checks.slice(0, 20).map((check) => ( - - - {check.ok ? 'Up' : 'Down'} - + {check.ok ? 'Up' : 'Down'} {check.statusCode ? `HTTP ${check.statusCode}` : (check.error ?? 'Failed')} diff --git a/src/client/pages/SettingsPage.tsx b/src/client/pages/SettingsPage.tsx index c64f320..fef6961 100644 --- a/src/client/pages/SettingsPage.tsx +++ b/src/client/pages/SettingsPage.tsx @@ -1,6 +1,8 @@ import { type FormEvent, useState } from 'react'; import { ArrowLeft, BellRing, Send, Zap } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; import type { NotificationSettings } from '../api/settings'; import { navigate } from '../lib/router'; import { useLogoutMutation } from '../queries/auth'; @@ -25,7 +27,7 @@ function SettingsForm({ settings }: { settings: NotificationSettings }) { -