mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 13:48:31 +00:00
feat: integrate some shadcn component
This commit is contained in:
+2
-1
@@ -2,5 +2,6 @@
|
|||||||
"printWidth": 140,
|
"printWidth": 140,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"useTabs": true
|
"useTabs": true,
|
||||||
|
"endOfLine": "auto"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# Reserved for shared components.
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export function DashboardFooter() {
|
||||||
|
return (
|
||||||
|
<footer className="dashboard-page-footer">
|
||||||
|
<span>Cloudflare Workers + D1</span>
|
||||||
|
<span>Automatic refresh every minute</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<section className="dashboard-intro">
|
||||||
|
<div>
|
||||||
|
<p className="overline">Infrastructure</p>
|
||||||
|
<h1>Monitors</h1>
|
||||||
|
<p>Track endpoint availability from Cloudflare's edge every five minutes.</p>
|
||||||
|
</div>
|
||||||
|
{monitorsQuery.isSuccess && monitors.length > 0 ? (
|
||||||
|
<Button variant="unstyled" className="primary-button" type="button" onClick={onAddMonitor}>
|
||||||
|
Add monitor <ArrowRight />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="metric-grid" aria-label="Monitor summary">
|
||||||
|
<div 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">
|
||||||
|
<p>Currently up</p>
|
||||||
|
<strong>{up}</strong>
|
||||||
|
<span>Latest checks succeeded</span>
|
||||||
|
</div>
|
||||||
|
<div className="metric-card">
|
||||||
|
<p>Currently down</p>
|
||||||
|
<strong>{down}</strong>
|
||||||
|
<span>Needs attention</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AlertDialog
|
||||||
|
open={monitor !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !isPending) onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent
|
||||||
|
onEscapeKeyDown={(event) => {
|
||||||
|
if (isPending) event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<p className="overline">Confirm</p>
|
||||||
|
<AlertDialogTitle>Delete {monitor?.name}?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>This permanently deletes the monitor and its check history.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="form-actions compact-actions">
|
||||||
|
<AlertDialogCancel>
|
||||||
|
<Button variant="unstyled" className="secondary-button" type="button" disabled={isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction>
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="danger-button"
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onConfirm();
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? 'Deleting…' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>(() => monitorInput(editing));
|
||||||
|
const formMutation = editing ? updateMutation : createMutation;
|
||||||
|
|
||||||
|
function closeForm() {
|
||||||
|
if (formMutation.isPending) return;
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (editing) {
|
||||||
|
updateMutation.mutate({ id: editing.id, input: form }, { onSuccess: onClose });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(form, { onSuccess: onClose });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen) closeForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
className="max-h-[85vh] w-[calc(100%-2rem)] overflow-y-auto sm:max-w-3xl"
|
||||||
|
onEscapeKeyDown={(event) => {
|
||||||
|
if (formMutation.isPending) event.preventDefault();
|
||||||
|
}}
|
||||||
|
onInteractOutside={(event) => {
|
||||||
|
if (formMutation.isPending) event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<p className="overline">Configuration</p>
|
||||||
|
<DialogTitle>{editing ? `Edit ${editing.name}` : 'Add a monitor'}</DialogTitle>
|
||||||
|
<DialogDescription>Checks run on the configured schedule, with a minimum interval of five minutes.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form className="monitor-form" onSubmit={handleSubmit}>
|
||||||
|
<label className="field field-name" htmlFor="monitor-name">
|
||||||
|
<span>Name</span>
|
||||||
|
<Input
|
||||||
|
id="monitor-name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||||
|
maxLength={100}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field field-url" htmlFor="monitor-url">
|
||||||
|
<span>URL</span>
|
||||||
|
<Input
|
||||||
|
id="monitor-url"
|
||||||
|
type="url"
|
||||||
|
value={form.url}
|
||||||
|
onChange={(event) => setForm({ ...form, url: event.target.value })}
|
||||||
|
placeholder="https://example.com/health"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="field">
|
||||||
|
<span id="monitor-method-label">Method</span>
|
||||||
|
<Select value={form.method} onValueChange={(value) => setForm({ ...form, method: value as MonitorMethod })}>
|
||||||
|
<SelectTrigger aria-labelledby="monitor-method-label">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(['GET', 'HEAD', 'POST'] as const).map((method) => (
|
||||||
|
<SelectItem key={method} value={method}>
|
||||||
|
{method}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<label className="field" htmlFor="monitor-expected-status">
|
||||||
|
<span>Expected status</span>
|
||||||
|
<Input
|
||||||
|
id="monitor-expected-status"
|
||||||
|
type="number"
|
||||||
|
min="100"
|
||||||
|
max="599"
|
||||||
|
value={form.expectedStatus}
|
||||||
|
onChange={(event) => setForm({ ...form, expectedStatus: event.target.valueAsNumber })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="field">
|
||||||
|
<span id="monitor-interval-label">Interval</span>
|
||||||
|
<Select value={String(form.intervalSeconds)} onValueChange={(value) => setForm({ ...form, intervalSeconds: Number(value) })}>
|
||||||
|
<SelectTrigger aria-labelledby="monitor-interval-label">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{INTERVAL_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<label className="field" htmlFor="monitor-timeout">
|
||||||
|
<span>Timeout (ms)</span>
|
||||||
|
<Input
|
||||||
|
id="monitor-timeout"
|
||||||
|
type="number"
|
||||||
|
min="1000"
|
||||||
|
max="30000"
|
||||||
|
step="1000"
|
||||||
|
value={form.timeoutMs}
|
||||||
|
onChange={(event) => setForm({ ...form, timeoutMs: event.target.valueAsNumber })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="toggle-field">
|
||||||
|
<Switch id="monitor-enabled" checked={form.enabled ?? true} onCheckedChange={(enabled) => setForm({ ...form, enabled })} />
|
||||||
|
<label htmlFor="monitor-enabled">Enable scheduled checks</label>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-field">
|
||||||
|
<Switch
|
||||||
|
id="monitor-alerts-enabled"
|
||||||
|
checked={form.alertsEnabled ?? true}
|
||||||
|
onCheckedChange={(alertsEnabled) => setForm({ ...form, alertsEnabled })}
|
||||||
|
/>
|
||||||
|
<label htmlFor="monitor-alerts-enabled">Enable incident alerts</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions compact-actions">
|
||||||
|
<Button variant="unstyled" className="secondary-button" type="button" onClick={closeForm}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="unstyled" className="primary-button" type="submit" disabled={formMutation.isPending}>
|
||||||
|
{formMutation.isPending ? 'Saving…' : editing ? 'Save changes' : 'Add monitor'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{formMutation.isError && (
|
||||||
|
<p className="form-error" role="alert">
|
||||||
|
{errorMessage(formMutation.error, 'Unable to save monitor')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Monitor | null>(null);
|
||||||
|
const monitors = monitorsQuery.data?.monitors ?? [];
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (!pendingDelete) return;
|
||||||
|
deleteMutation.mutate(pendingDelete.id, {
|
||||||
|
onSuccess: () => setPendingDelete(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="services-panel" aria-labelledby="monitor-list-title">
|
||||||
|
<DeleteMonitorDialog
|
||||||
|
monitor={pendingDelete}
|
||||||
|
isPending={deleteMutation.isPending}
|
||||||
|
onCancel={() => setPendingDelete(null)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
/>
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h2 id="monitor-list-title">Configured sites</h2>
|
||||||
|
<p>Latest result for each monitored endpoint.</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="icon-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void monitorsQuery.refetch()}
|
||||||
|
disabled={monitorsQuery.isFetching}
|
||||||
|
aria-label="Refresh monitors"
|
||||||
|
>
|
||||||
|
<RefreshCw className={monitorsQuery.isFetching ? 'is-spinning' : ''} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{monitorsQuery.isPending ? (
|
||||||
|
<div className="monitor-skeleton" aria-label="Loading monitors">
|
||||||
|
{[0, 1, 2].map((item) => (
|
||||||
|
<div key={item}>
|
||||||
|
<i />
|
||||||
|
<span />
|
||||||
|
<b />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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 />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="monitor-list">
|
||||||
|
<div className="services-title">
|
||||||
|
<span>Monitor</span>
|
||||||
|
<span>Latest result</span>
|
||||||
|
<span>Actions</span>
|
||||||
|
</div>
|
||||||
|
{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 (
|
||||||
|
<article className={`service-row ${monitor.enabled ? '' : 'is-disabled'}`} key={monitor.id}>
|
||||||
|
<div className="service-name">
|
||||||
|
<span className="service-icon">
|
||||||
|
<SiteIcon key={monitor.url} monitorId={monitor.id} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="monitor-name-link"
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(`/monitors/${monitor.id}`)}
|
||||||
|
>
|
||||||
|
{monitor.name}
|
||||||
|
</Button>
|
||||||
|
<small title={monitor.url}>{monitor.url}</small>
|
||||||
|
<span className="monitor-meta">
|
||||||
|
{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="monitor-result">
|
||||||
|
<Badge variant={checking ? 'checking' : status.className}>{checking ? 'Checking' : status.label}</Badge>
|
||||||
|
<code>
|
||||||
|
{monitor.lastStatusCode === null ? '—' : `HTTP ${monitor.lastStatusCode}`} ·{' '}
|
||||||
|
{monitor.lastLatencyMs === null ? '—' : `${monitor.lastLatencyMs} ms`}
|
||||||
|
</code>
|
||||||
|
<small title={monitor.lastError ?? undefined}>{monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)}</small>
|
||||||
|
</div>
|
||||||
|
<div className="row-actions" aria-label={`Actions for ${monitor.name}`}>
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="row-action row-action-labeled"
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(`/monitors/${monitor.id}`)}
|
||||||
|
>
|
||||||
|
<History aria-hidden="true" />
|
||||||
|
<span>History</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="row-action row-action-labeled row-action-accent"
|
||||||
|
type="button"
|
||||||
|
onClick={() => checkMutation.mutate(monitor.id)}
|
||||||
|
disabled={checking}
|
||||||
|
aria-busy={checking}
|
||||||
|
>
|
||||||
|
<RefreshCw className={checking ? 'is-spinning' : ''} aria-hidden="true" />
|
||||||
|
<span>{checking ? 'Checking' : 'Check now'}</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="row-action row-action-icon"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEdit(monitor)}
|
||||||
|
aria-label={`Edit ${monitor.name}`}
|
||||||
|
title="Edit monitor"
|
||||||
|
>
|
||||||
|
<Pencil aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="row-action row-action-icon"
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateMutation.mutate({ id: monitor.id, input: { enabled: !monitor.enabled } })}
|
||||||
|
disabled={toggling}
|
||||||
|
aria-label={`${monitor.enabled ? 'Disable' : 'Enable'} ${monitor.name}`}
|
||||||
|
title={`${monitor.enabled ? 'Disable' : 'Enable'} monitor`}
|
||||||
|
>
|
||||||
|
{monitor.enabled ? <PowerOff aria-hidden="true" /> : <Power aria-hidden="true" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="row-action row-action-icon danger-action"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPendingDelete(monitor)}
|
||||||
|
disabled={deleting}
|
||||||
|
aria-label={`Delete ${monitor.name}`}
|
||||||
|
title="Delete monitor"
|
||||||
|
aria-busy={deleting}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Reserved for application pages.
|
|
||||||
@@ -1,438 +1,43 @@
|
|||||||
import { type FormEvent, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ArrowRight, Database, History, Pencil, Power, PowerOff, RefreshCw, Trash2, Zap } from 'lucide-react';
|
import type { Monitor } from '../api/monitors';
|
||||||
import { Button } from '@/components/ui/button';
|
import { DashboardFooter } from '../components/dashboard/DashboardFooter';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { DashboardHeader } from '../components/dashboard/DashboardHeader';
|
||||||
import type { Monitor, MonitorInput, MonitorMethod } from '../api/monitors';
|
import { DashboardOverview } from '../components/dashboard/DashboardOverview';
|
||||||
import { SiteIcon } from '../components/SiteIcon';
|
import { MonitorFormDialog } from '../components/dashboard/MonitorFormDialog';
|
||||||
import { useLogoutMutation } from '../queries/auth';
|
import { MonitorListPanel } from '../components/dashboard/MonitorListPanel';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardPage() {
|
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<Monitor | null>(null);
|
const [editing, setEditing] = useState<Monitor | null>(null);
|
||||||
const [form, setForm] = useState<MonitorInput>(DEFAULT_INPUT);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
function openCreateForm() {
|
function openCreateForm() {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setForm(DEFAULT_INPUT);
|
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditForm(monitor: Monitor) {
|
function openEditForm(monitor: Monitor) {
|
||||||
setEditing(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);
|
setFormOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeForm() {
|
function closeForm() {
|
||||||
if (formMutation.isPending) return;
|
|
||||||
setFormOpen(false);
|
setFormOpen(false);
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
||||||
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 (
|
return (
|
||||||
<div className="dashboard-shell">
|
<div className="dashboard-shell">
|
||||||
<header className="dashboard-header">
|
<DashboardHeader />
|
||||||
<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>
|
|
||||||
|
|
||||||
<main className="dashboard-main">
|
<main className="dashboard-main">
|
||||||
<section className="dashboard-intro">
|
<DashboardOverview onAddMonitor={openCreateForm} />
|
||||||
<div>
|
|
||||||
<p className="overline">Infrastructure</p>
|
|
||||||
<h1>Monitors</h1>
|
|
||||||
<p>Track endpoint availability from Cloudflare's edge every five minutes.</p>
|
|
||||||
</div>
|
|
||||||
{monitorsQuery.isSuccess && monitors.length > 0 ? (
|
|
||||||
<Button variant="unstyled" className="primary-button" type="button" onClick={openCreateForm}>
|
|
||||||
Add monitor <ArrowRight />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="metric-grid" aria-label="Monitor summary">
|
{formOpen ? <MonitorFormDialog key={editing?.id ?? 'create'} editing={editing} onClose={closeForm} /> : null}
|
||||||
<div 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">
|
|
||||||
<p>Currently up</p>
|
|
||||||
<strong>{up}</strong>
|
|
||||||
<span>Latest checks succeeded</span>
|
|
||||||
</div>
|
|
||||||
<div className="metric-card">
|
|
||||||
<p>Currently down</p>
|
|
||||||
<strong>{down}</strong>
|
|
||||||
<span>Needs attention</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Dialog
|
<MonitorListPanel formOpen={formOpen} onAddMonitor={openCreateForm} onEdit={openEditForm} />
|
||||||
open={formOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) closeForm();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent
|
|
||||||
className="max-h-[85vh] w-[calc(100%-2rem)] overflow-y-auto sm:max-w-3xl"
|
|
||||||
onEscapeKeyDown={(event) => {
|
|
||||||
if (formMutation.isPending) event.preventDefault();
|
|
||||||
}}
|
|
||||||
onInteractOutside={(event) => {
|
|
||||||
if (formMutation.isPending) event.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<p className="overline">Configuration</p>
|
|
||||||
<DialogTitle>{editing ? `Edit ${editing.name}` : 'Add a monitor'}</DialogTitle>
|
|
||||||
<DialogDescription>Checks run on the configured schedule, with a minimum interval of five minutes.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form className="monitor-form" onSubmit={handleSubmit}>
|
|
||||||
<label className="field field-name">
|
|
||||||
<span>Name</span>
|
|
||||||
<input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} maxLength={100} required />
|
|
||||||
</label>
|
|
||||||
<label className="field field-url">
|
|
||||||
<span>URL</span>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={form.url}
|
|
||||||
onChange={(event) => setForm({ ...form, url: event.target.value })}
|
|
||||||
placeholder="https://example.com/health"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>Method</span>
|
|
||||||
<select value={form.method} onChange={(event) => setForm({ ...form, method: event.target.value as MonitorMethod })}>
|
|
||||||
<option>GET</option>
|
|
||||||
<option>HEAD</option>
|
|
||||||
<option>POST</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>Expected status</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="100"
|
|
||||||
max="599"
|
|
||||||
value={form.expectedStatus}
|
|
||||||
onChange={(event) => setForm({ ...form, expectedStatus: event.target.valueAsNumber })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>Interval</span>
|
|
||||||
<select
|
|
||||||
value={form.intervalSeconds}
|
|
||||||
onChange={(event) => setForm({ ...form, intervalSeconds: Number(event.target.value) })}
|
|
||||||
>
|
|
||||||
<option value="300">5 minutes</option>
|
|
||||||
<option value="900">15 minutes</option>
|
|
||||||
<option value="1800">30 minutes</option>
|
|
||||||
<option value="3600">1 hour</option>
|
|
||||||
<option value="86400">24 hours</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>Timeout (ms)</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1000"
|
|
||||||
max="30000"
|
|
||||||
step="1000"
|
|
||||||
value={form.timeoutMs}
|
|
||||||
onChange={(event) => setForm({ ...form, timeoutMs: event.target.valueAsNumber })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="toggle-field">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.enabled ?? true}
|
|
||||||
onChange={(event) => setForm({ ...form, enabled: event.target.checked })}
|
|
||||||
/>
|
|
||||||
<span>Enable scheduled checks</span>
|
|
||||||
</label>
|
|
||||||
<label className="toggle-field">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.alertsEnabled ?? true}
|
|
||||||
onChange={(event) => setForm({ ...form, alertsEnabled: event.target.checked })}
|
|
||||||
/>
|
|
||||||
<span>Enable incident alerts</span>
|
|
||||||
</label>
|
|
||||||
<div className="form-actions compact-actions">
|
|
||||||
<Button variant="unstyled" className="secondary-button" type="button" onClick={closeForm}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="unstyled" className="primary-button" type="submit" disabled={formMutation.isPending}>
|
|
||||||
{formMutation.isPending ? 'Saving…' : editing ? 'Save changes' : 'Add monitor'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{formMutation.isError && (
|
|
||||||
<p className="form-error" role="alert">
|
|
||||||
{errorMessage(formMutation.error, 'Unable to save monitor')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<section className="services-panel" aria-labelledby="monitor-list-title">
|
|
||||||
<div className="panel-heading">
|
|
||||||
<div>
|
|
||||||
<h2 id="monitor-list-title">Configured sites</h2>
|
|
||||||
<p>Latest result for each monitored endpoint.</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="unstyled"
|
|
||||||
className="icon-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void monitorsQuery.refetch()}
|
|
||||||
disabled={monitorsQuery.isFetching}
|
|
||||||
aria-label="Refresh monitors"
|
|
||||||
>
|
|
||||||
<RefreshCw className={monitorsQuery.isFetching ? 'is-spinning' : ''} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{monitorsQuery.isPending ? (
|
|
||||||
<div className="monitor-skeleton" aria-label="Loading monitors">
|
|
||||||
{[0, 1, 2].map((item) => (
|
|
||||||
<div key={item}>
|
|
||||||
<i />
|
|
||||||
<span />
|
|
||||||
<b />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</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={openCreateForm}>
|
|
||||||
Add first site <ArrowRight />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="monitor-list">
|
|
||||||
<div className="services-title">
|
|
||||||
<span>Monitor</span>
|
|
||||||
<span>Latest result</span>
|
|
||||||
<span>Actions</span>
|
|
||||||
</div>
|
|
||||||
{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 (
|
|
||||||
<article className={`service-row ${monitor.enabled ? '' : 'is-disabled'}`} key={monitor.id}>
|
|
||||||
<div className="service-name">
|
|
||||||
<span className="service-icon">
|
|
||||||
<SiteIcon key={monitor.url} monitorId={monitor.id} />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
variant="unstyled"
|
|
||||||
className="monitor-name-link"
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate(`/monitors/${monitor.id}`)}
|
|
||||||
>
|
|
||||||
{monitor.name}
|
|
||||||
</Button>
|
|
||||||
<small title={monitor.url}>{monitor.url}</small>
|
|
||||||
<span className="monitor-meta">
|
|
||||||
{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="monitor-result">
|
|
||||||
<span className={`row-status ${checking ? 'checking' : status.className}`}>
|
|
||||||
<i />
|
|
||||||
{checking ? 'Checking' : status.label}
|
|
||||||
</span>
|
|
||||||
<code>
|
|
||||||
{monitor.lastStatusCode === null ? '—' : `HTTP ${monitor.lastStatusCode}`} ·{' '}
|
|
||||||
{monitor.lastLatencyMs === null ? '—' : `${monitor.lastLatencyMs} ms`}
|
|
||||||
</code>
|
|
||||||
<small title={monitor.lastError ?? undefined}>{monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)}</small>
|
|
||||||
</div>
|
|
||||||
<div className="row-actions" aria-label={`Actions for ${monitor.name}`}>
|
|
||||||
<Button
|
|
||||||
variant="unstyled"
|
|
||||||
className="row-action row-action-labeled"
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate(`/monitors/${monitor.id}`)}
|
|
||||||
>
|
|
||||||
<History aria-hidden="true" />
|
|
||||||
<span>History</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="unstyled"
|
|
||||||
className="row-action row-action-labeled row-action-accent"
|
|
||||||
type="button"
|
|
||||||
onClick={() => checkMutation.mutate(monitor.id)}
|
|
||||||
disabled={checking}
|
|
||||||
aria-busy={checking}
|
|
||||||
>
|
|
||||||
<RefreshCw className={checking ? 'is-spinning' : ''} aria-hidden="true" />
|
|
||||||
<span>{checking ? 'Checking' : 'Check now'}</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="unstyled"
|
|
||||||
className="row-action row-action-icon"
|
|
||||||
type="button"
|
|
||||||
onClick={() => openEditForm(monitor)}
|
|
||||||
aria-label={`Edit ${monitor.name}`}
|
|
||||||
title="Edit monitor"
|
|
||||||
>
|
|
||||||
<Pencil aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="unstyled"
|
|
||||||
className="row-action row-action-icon"
|
|
||||||
type="button"
|
|
||||||
onClick={() => updateMutation.mutate({ id: monitor.id, input: { enabled: !monitor.enabled } })}
|
|
||||||
disabled={toggling}
|
|
||||||
aria-label={`${monitor.enabled ? 'Disable' : 'Enable'} ${monitor.name}`}
|
|
||||||
title={`${monitor.enabled ? 'Disable' : 'Enable'} monitor`}
|
|
||||||
>
|
|
||||||
{monitor.enabled ? <PowerOff aria-hidden="true" /> : <Power aria-hidden="true" />}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="unstyled"
|
|
||||||
className="row-action row-action-icon danger-action"
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDelete(monitor)}
|
|
||||||
disabled={deleting}
|
|
||||||
aria-label={`Delete ${monitor.name}`}
|
|
||||||
title="Delete monitor"
|
|
||||||
aria-busy={deleting}
|
|
||||||
>
|
|
||||||
<Trash2 aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
<footer className="dashboard-page-footer">
|
|
||||||
<span>Cloudflare Workers + D1</span>
|
<DashboardFooter />
|
||||||
<span>Automatic refresh every minute</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type FormEvent, useEffect, useState } from 'react';
|
import { type FormEvent, useEffect, useState } from 'react';
|
||||||
import { Zap } from 'lucide-react';
|
import { Zap } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { navigate } from '../lib/router';
|
import { navigate } from '../lib/router';
|
||||||
import { useLoginMutation, useSessionQuery } from '../queries/auth';
|
import { useLoginMutation, useSessionQuery } from '../queries/auth';
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ export function LoginPage() {
|
|||||||
<form className="auth-form" onSubmit={handleSubmit}>
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
<div className="auth-field">
|
<div className="auth-field">
|
||||||
<label htmlFor="password">Password</label>
|
<label htmlFor="password">Password</label>
|
||||||
<input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ArrowLeft, BellOff, CheckCircle2, Clock3, ExternalLink, RefreshCw, Zap } from 'lucide-react';
|
import { ArrowLeft, BellOff, CheckCircle2, Clock3, ExternalLink, RefreshCw, Zap } from 'lucide-react';
|
||||||
|
import { Badge, type BadgeVariant } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { LatencySparkline } from '../components/charts/LatencySparkline';
|
import { LatencySparkline } from '../components/charts/LatencySparkline';
|
||||||
import { UptimeBar } from '../components/charts/UptimeBar';
|
import { UptimeBar } from '../components/charts/UptimeBar';
|
||||||
@@ -24,7 +25,7 @@ function formatDuration(ms: number | null, startedAt: string) {
|
|||||||
return `${Math.round(duration / 86_400_000)} days`;
|
return `${Math.round(duration / 86_400_000)} days`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusDetails(lastOk: boolean | null) {
|
function statusDetails(lastOk: boolean | null): { label: string; className: BadgeVariant } {
|
||||||
if (lastOk === true) return { label: 'Operational', className: 'online' };
|
if (lastOk === true) return { label: 'Operational', className: 'online' };
|
||||||
if (lastOk === false) return { label: 'Down', className: 'offline' };
|
if (lastOk === false) return { label: 'Down', className: 'offline' };
|
||||||
return { label: 'Awaiting first check', className: 'checking' };
|
return { label: 'Awaiting first check', className: 'checking' };
|
||||||
@@ -96,10 +97,7 @@ export function MonitorDetailPage({ id }: { id: number }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-actions">
|
<div className="detail-actions">
|
||||||
<span className={`row-status ${status.className}`}>
|
<Badge variant={status.className}>{status.label}</Badge>
|
||||||
<i />
|
|
||||||
{status.label}
|
|
||||||
</span>
|
|
||||||
{!monitor.alertsEnabled && (
|
{!monitor.alertsEnabled && (
|
||||||
<span className="muted-alert">
|
<span className="muted-alert">
|
||||||
<BellOff /> Alerts muted
|
<BellOff /> Alerts muted
|
||||||
@@ -187,10 +185,7 @@ export function MonitorDetailPage({ id }: { id: number }) {
|
|||||||
{checks.slice(0, 20).map((check) => (
|
{checks.slice(0, 20).map((check) => (
|
||||||
<tr key={check.id}>
|
<tr key={check.id}>
|
||||||
<td>
|
<td>
|
||||||
<span className={`row-status ${check.ok ? 'online' : 'offline'}`}>
|
<Badge variant={check.ok ? 'online' : 'offline'}>{check.ok ? 'Up' : 'Down'}</Badge>
|
||||||
<i />
|
|
||||||
{check.ok ? 'Up' : 'Down'}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<code>{check.statusCode ? `HTTP ${check.statusCode}` : (check.error ?? 'Failed')}</code>
|
<code>{check.statusCode ? `HTTP ${check.statusCode}` : (check.error ?? 'Failed')}</code>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { type FormEvent, useState } from 'react';
|
import { type FormEvent, useState } from 'react';
|
||||||
import { ArrowLeft, BellRing, Send, Zap } from 'lucide-react';
|
import { ArrowLeft, BellRing, Send, Zap } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
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 type { NotificationSettings } from '../api/settings';
|
||||||
import { navigate } from '../lib/router';
|
import { navigate } from '../lib/router';
|
||||||
import { useLogoutMutation } from '../queries/auth';
|
import { useLogoutMutation } from '../queries/auth';
|
||||||
@@ -25,7 +27,7 @@ function SettingsForm({ settings }: { settings: NotificationSettings }) {
|
|||||||
<form className="settings-form" onSubmit={submit}>
|
<form className="settings-form" onSubmit={submit}>
|
||||||
<label className="field" htmlFor="webhook-url">
|
<label className="field" htmlFor="webhook-url">
|
||||||
<span>Webhook URL</span>
|
<span>Webhook URL</span>
|
||||||
<input
|
<Input
|
||||||
id="webhook-url"
|
id="webhook-url"
|
||||||
type="url"
|
type="url"
|
||||||
value={webhookUrl}
|
value={webhookUrl}
|
||||||
@@ -33,18 +35,13 @@ function SettingsForm({ settings }: { settings: NotificationSettings }) {
|
|||||||
placeholder="https://hooks.example.com/services/…"
|
placeholder="https://hooks.example.com/services/…"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-toggle" htmlFor="webhook-enabled" aria-label="Enable incident alerts">
|
<div className="settings-toggle">
|
||||||
<input
|
<Switch id="webhook-enabled" checked={webhookEnabled} onCheckedChange={setWebhookEnabled} />
|
||||||
id="webhook-enabled"
|
<label htmlFor="webhook-enabled">
|
||||||
type="checkbox"
|
|
||||||
checked={webhookEnabled}
|
|
||||||
onChange={(event) => setWebhookEnabled(event.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<strong>Enable incident alerts</strong>
|
<strong>Enable incident alerts</strong>
|
||||||
<small>Send a webhook when a monitor goes down and when it recovers.</small>
|
<small>Send a webhook when a monitor goes down and when it recovers.</small>
|
||||||
</span>
|
</label>
|
||||||
</label>
|
</div>
|
||||||
<div className="settings-actions">
|
<div className="settings-actions">
|
||||||
<Button
|
<Button
|
||||||
variant="unstyled"
|
variant="unstyled"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Activity, CircleCheck, Database, RefreshCw, TriangleAlert, Zap } from 'lucide-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 { Button } from '@/components/ui/button';
|
||||||
import type { PublicOverallStatus, PublicServiceStatus } from '../api/status';
|
import type { PublicOverallStatus, PublicServiceStatus } from '../api/status';
|
||||||
import { SiteIcon } from '../components/SiteIcon';
|
import { SiteIcon } from '../components/SiteIcon';
|
||||||
@@ -23,7 +24,7 @@ const OVERALL_COPY: Record<PublicOverallStatus, { title: string; detail: string
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERVICE_STATUS: Record<PublicServiceStatus, { label: string; className: string }> = {
|
const SERVICE_STATUS: Record<PublicServiceStatus, { label: string; className: BadgeVariant }> = {
|
||||||
up: { label: 'Operational', className: 'online' },
|
up: { label: 'Operational', className: 'online' },
|
||||||
down: { label: 'Down', className: 'offline' },
|
down: { label: 'Down', className: 'offline' },
|
||||||
unknown: { label: 'Awaiting data', className: 'checking' },
|
unknown: { label: 'Awaiting data', className: 'checking' },
|
||||||
@@ -159,10 +160,9 @@ export function StatusPage() {
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{service.name}</strong>
|
<strong>{service.name}</strong>
|
||||||
<span className={`row-status ${serviceStatus.className}`}>
|
<Badge className="mt-1.75" variant={serviceStatus.className}>
|
||||||
<i />
|
|
||||||
{serviceStatus.label}
|
{serviceStatus.label}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="public-service-uptime">
|
<div className="public-service-uptime">
|
||||||
|
|||||||
+20
-56
@@ -205,7 +205,8 @@ button {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.primary-button,
|
.primary-button,
|
||||||
.secondary-button {
|
.secondary-button,
|
||||||
|
.danger-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -243,12 +244,23 @@ button {
|
|||||||
border-color: #aaa;
|
border-color: #aaa;
|
||||||
background: var(--soft);
|
background: var(--soft);
|
||||||
}
|
}
|
||||||
|
.danger-button {
|
||||||
|
border: 1px solid #efcaca;
|
||||||
|
color: #9f2f2f;
|
||||||
|
background: #fff6f6;
|
||||||
|
}
|
||||||
|
.danger-button:hover:not(:disabled) {
|
||||||
|
border-color: #e0b4b4;
|
||||||
|
background: #fff0f0;
|
||||||
|
}
|
||||||
.primary-button:active:not(:disabled),
|
.primary-button:active:not(:disabled),
|
||||||
.secondary-button:active:not(:disabled) {
|
.secondary-button:active:not(:disabled),
|
||||||
|
.danger-button:active:not(:disabled) {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
.primary-button:disabled,
|
.primary-button:disabled,
|
||||||
.secondary-button:disabled {
|
.secondary-button:disabled,
|
||||||
|
.danger-button:disabled {
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
@@ -310,28 +322,6 @@ button {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #353535;
|
color: #353535;
|
||||||
}
|
}
|
||||||
.field input,
|
|
||||||
.field select {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 42px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid #cfcfcf;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--ink);
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.025);
|
|
||||||
}
|
|
||||||
.field input:hover,
|
|
||||||
.field select:hover {
|
|
||||||
border-color: #aaa;
|
|
||||||
}
|
|
||||||
.field input:focus,
|
|
||||||
.field select:focus {
|
|
||||||
border-color: var(--primary-deep);
|
|
||||||
outline: 0;
|
|
||||||
box-shadow: 0 0 0 3px rgb(36 180 126 / 0.14);
|
|
||||||
}
|
|
||||||
.toggle-field {
|
.toggle-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -340,12 +330,9 @@ button {
|
|||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #444;
|
color: #444;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.toggle-field input {
|
.toggle-field label {
|
||||||
width: 16px;
|
cursor: pointer;
|
||||||
height: 16px;
|
|
||||||
accent-color: var(--primary-deep);
|
|
||||||
}
|
}
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -817,25 +804,6 @@ button {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #353535;
|
color: #353535;
|
||||||
}
|
}
|
||||||
.auth-field input {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 42px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid #cfcfcf;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--ink);
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.025);
|
|
||||||
}
|
|
||||||
.auth-field input:hover {
|
|
||||||
border-color: #a9a9a9;
|
|
||||||
}
|
|
||||||
.auth-field input:focus {
|
|
||||||
border-color: var(--primary-deep);
|
|
||||||
outline: 0;
|
|
||||||
box-shadow: 0 0 0 3px rgb(36 180 126 / 0.14);
|
|
||||||
}
|
|
||||||
.auth-error {
|
.auth-error {
|
||||||
margin: -4px 0 0;
|
margin: -4px 0 0;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -1384,15 +1352,11 @@ button {
|
|||||||
}
|
}
|
||||||
.settings-toggle {
|
.settings-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.settings-toggle input {
|
.settings-toggle label {
|
||||||
width: 17px;
|
cursor: pointer;
|
||||||
height: 17px;
|
|
||||||
margin-top: 2px;
|
|
||||||
accent-color: var(--primary-deep);
|
|
||||||
}
|
}
|
||||||
.settings-toggle strong,
|
.settings-toggle strong,
|
||||||
.settings-toggle small {
|
.settings-toggle small {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 isolate 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 AlertDialogContent({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-white p-6 text-sm text-neutral-900 shadow-xl ring-1 ring-black/10 duration-100 outline-none sm:max-w-sm 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:bg-neutral-900 dark:text-neutral-50 dark:ring-white/10',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="alert-dialog-header" className={cn('flex flex-col gap-2', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="alert-dialog-footer" className={cn(className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title data-slot="alert-dialog-title" className={cn('text-base leading-none font-medium', className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn('text-sm text-neutral-500 dark:text-neutral-400', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction(props: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return <AlertDialogPrimitive.Action data-slot="alert-dialog-action" {...props} asChild />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel(props: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return <AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" {...props} asChild />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva('inline-flex items-center gap-1.75 text-[12px] font-medium [&>i]:size-1.75 [&>i]:shrink-0 [&>i]:rounded-full', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
online: 'text-[#16885b] [&>i]:bg-(--primary-deep) [&>i]:shadow-[0_0_0_3px_rgb(62_207_142/0.12)]',
|
||||||
|
offline: 'text-[#ae3d3d] [&>i]:bg-[#d05a5a]',
|
||||||
|
checking: 'text-[#8b7722] [&>i]:animate-[blink_1.1s_ease-in-out_infinite] [&>i]:bg-[#d7bd53]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'online',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>;
|
||||||
|
|
||||||
|
function Badge({ className, variant = 'online', children, ...props }: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return (
|
||||||
|
<span data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props}>
|
||||||
|
<i aria-hidden="true" />
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants, type BadgeVariant };
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches the monitor and authentication form controls so it sits flush next
|
||||||
|
* to a SelectTrigger: 42px tall, 6px radius, and an emerald focus ring.
|
||||||
|
*/
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-10.5 w-full rounded-[6px] border border-[#cfcfcf] bg-white px-3 py-2',
|
||||||
|
'text-[14px] text-(--ink) shadow-[inset_0_1px_2px_rgb(0_0_0/0.025)] transition-colors outline-none',
|
||||||
|
'placeholder:text-(--faint)',
|
||||||
|
'hover:border-[#aaa]',
|
||||||
|
'focus-visible:border-(--primary-deep) focus-visible:shadow-[0_0_0_3px_rgb(36_180_126/0.14)] focus-visible:outline-none!',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'aria-invalid:border-destructive aria-invalid:shadow-[0_0_0_3px_rgb(220_98_98/0.14)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Select as SelectPrimitive } from 'radix-ui';
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" className={cn('scroll-my-1', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the shared Input so both controls sit flush in the same form grid:
|
||||||
|
* 42px tall, 6px radius, and an emerald focus ring.
|
||||||
|
*/
|
||||||
|
function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
className={cn(
|
||||||
|
'group flex min-h-10.5 w-full cursor-pointer items-center justify-between gap-2 rounded-[6px] border border-[#cfcfcf] bg-white px-3 py-2 text-left',
|
||||||
|
'text-[14px] text-(--ink) shadow-[inset_0_1px_2px_rgb(0_0_0/0.025)] transition-colors outline-none data-placeholder:text-(--faint) [&_svg]:shrink-0',
|
||||||
|
'hover:border-[#aaa]',
|
||||||
|
'focus-visible:border-(--primary-deep) focus-visible:shadow-[0_0_0_3px_rgb(36_180_126/0.14)] focus-visible:outline-none!',
|
||||||
|
'data-[state=open]:border-(--primary-deep) data-[state=open]:shadow-[0_0_0_3px_rgb(36_180_126/0.14)]',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-(--faint) transition-transform group-data-[state=open]:rotate-180" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Level 2 elevation + hairline border, per DESIGN.md "Elevation & Depth". */
|
||||||
|
function SelectContent({ className, children, position = 'popper', ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto',
|
||||||
|
'rounded-[8px] border border-(--hairline) bg-white text-(--ink) shadow-[0_8px_24px_rgb(0_0_0/0.08)]',
|
||||||
|
'duration-100 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',
|
||||||
|
'data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport className={cn('p-1', position === 'popper' && 'w-full min-w-(--radix-select-trigger-width)')}>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn('px-2 py-1.5 text-[12px] font-medium tracking-wide text-(--muted) uppercase', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-1.5 pr-8 pl-2 text-[14px] text-(--ink) outline-hidden select-none',
|
||||||
|
'focus:bg-[#ededed] data-[state=checked]:font-medium',
|
||||||
|
'data-disabled:pointer-events-none data-disabled:opacity-50',
|
||||||
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center text-(--primary-deep)">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn('pointer-events-none -mx-1 my-1 h-px bg-(--hairline)', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1 text-(--faint)', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1 text-(--faint)', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Switch as SwitchPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-5.5 w-10 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-[#dfdfdf] p-px transition-colors outline-none',
|
||||||
|
'data-[state=checked]:border-[#35c586] data-[state=checked]:bg-(--primary)',
|
||||||
|
'focus-visible:shadow-[0_0_0_3px_rgb(36_180_126/0.14)] focus-visible:outline-none!',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block size-4.5 translate-x-0 rounded-full bg-white shadow-[0_1px_2px_rgb(0_0_0/0.15)] transition-transform data-[state=checked]:translate-x-4.5"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
Reference in New Issue
Block a user