[#000] fix: improve the setting pages

This commit is contained in:
2026-09-15 12:15:31 +07:00
parent a70717322c
commit d5f0d9b695
10 changed files with 473 additions and 1375 deletions
@@ -19,8 +19,8 @@ export function AiActivityPanel() {
const { events, summary } = query.data;
const tokenTotal = summary.promptTokens + summary.completionTokens;
return (
<div className="ai-activity-panel">
<div className="ai-activity-stats">
<div className="pt-5.5 px-panel-x pb-panel-x">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5 [&>div]:grid [&>div]:gap-0.75 [&>div]:p-3.5 [&>div]:rounded-card-sm [&>div]:border [&>div]:border-border-panel [&>div]:bg-surface-soft dark:[&>div]:border-white/8 dark:[&>div]:bg-night-subtle dark:[&>div]:shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] [&_strong]:text-subtitle dark:[&_strong]:text-gray-50 [&_span]:text-footnote [&_span]:text-muted-text dark:[&_span]:text-gray-400">
<div>
<strong>{summary.total ? Math.round((summary.ok / summary.total) * 100) : 0}%</strong>
<span>Success rate</span>
@@ -39,14 +39,20 @@ export function AiActivityPanel() {
</div>
</div>
{events.length === 0 ? (
<Empty className="channel-empty">
<Empty className="py-11 px-6">
<EmptyTitle>No AI activity yet</EmptyTitle>
<EmptyDescription>Attempts will appear here after AI generation or autopilot runs.</EmptyDescription>
</Empty>
) : (
<div className="ai-activity-history" aria-label="AI activity history">
<div
className="grid content-start gap-px max-h-activity-max mt-4.5 rounded-md border border-border-panel bg-border-panel overflow-auto overscroll-contain table-scrollbar dark:border-white/8 dark:bg-white/6"
aria-label="AI activity history"
>
{events.map((event) => (
<div className="ai-activity-row" key={event.id}>
<div
className="grid grid-cols-2 md:grid-cols-[100px_minmax(110px,0.8fr)_minmax(145px,1fr)_minmax(130px,1fr)_auto] items-center gap-3 p-2.5 sm:px-3 text-footnote bg-white dark:bg-night-panel dark:hover:bg-night-subtle [&>strong]:font-semibold [&>strong]:capitalize dark:[&>strong]:text-gray-50 [&>span]:text-footnote [&>span]:text-muted-text dark:[&>span]:text-gray-400 [&>small]:text-footnote [&>small]:text-muted-text dark:[&>small]:text-gray-400"
key={event.id}
>
<Badge variant={event.outcome === 'ok' ? 'online' : event.outcome.startsWith('skipped') ? 'pending' : 'offline'}>
{event.outcome.replaceAll('_', ' ')}
</Badge>
@@ -0,0 +1,165 @@
import { type FormEvent, useState } from 'react';
import { Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Empty, EmptyTitle } from '@/components/ui/empty';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import type { AiSettings } from '../../api/settings';
import { useAiSettingsQuery, useTestAiSettingsMutation, useUpdateAiSettingsMutation } from '../../queries/settings';
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 [autopilotEnabled, setAutopilotEnabled] = useState(settings.autopilotEnabled);
const [followupMinutes, setFollowupMinutes] = useState(settings.autopilotFollowupMinutes);
const [maxUpdates, setMaxUpdates] = useState(settings.autopilotMaxUpdates);
const [advanceStatus, setAdvanceStatus] = useState(settings.autopilotAdvanceStatus);
const [degradedIncidents, setDegradedIncidents] = useState(settings.autopilotDegradedIncidents);
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,
autopilotEnabled,
autopilotFollowupMinutes: followupMinutes,
autopilotMaxUpdates: maxUpdates,
autopilotAdvanceStatus: advanceStatus,
autopilotDegradedIncidents: degradedIncidents,
});
}
return (
<form className="grid gap-6 p-5 sm:p-panel-x text-item" onSubmit={submit}>
<label className="field" htmlFor="ai-base-url">
<span>Base URL</span>
<Input
id="ai-base-url"
type="url"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="https://api.openai.com/v1"
/>
</label>
<label className="field" htmlFor="ai-api-key">
<span>API key</span>
<Input
id="ai-api-key"
type="password"
autoComplete="off"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder={settings.apiKeyPreview ?? 'Enter API key'}
/>
{settings.apiKeySet && (
<small className="block mt-1.75 text-footnote text-muted-text dark:text-gray-400">Leave blank to keep the current key.</small>
)}
</label>
<label className="field" htmlFor="ai-model">
<span>Model</span>
<Input id="ai-model" type="text" value={model} onChange={(event) => setModel(event.target.value)} placeholder="gpt-4o-mini" />
</label>
<div className="flex items-center gap-2.75 [&>label]:cursor-pointer [&_strong]:block [&_strong]:text-caption [&_strong]:font-medium dark:[&_strong]:text-gray-50 [&_small]:block [&_small]:mt-1 [&_small]:text-muted-text dark:[&_small]:text-gray-400">
<Switch id="ai-enabled" checked={enabled} onCheckedChange={setEnabled} />
<label htmlFor="ai-enabled">
<strong>Enable AI incident messages</strong>
<small>Generate one sanitized public update when an incident opens.</small>
</label>
</div>
<fieldset className="grid gap-4 my-1 p-5 rounded-lg border border-border-panel bg-surface-fieldset dark:border-white/8 dark:bg-night-subtle [&>legend]:px-1.75 [&>legend]:text-caption [&>legend]:font-semibold dark:[&>legend]:text-gray-50">
<legend>Autopilot</legend>
<div className="flex items-center gap-2.75 [&>label]:cursor-pointer [&_strong]:block [&_strong]:text-caption [&_strong]:font-medium dark:[&_strong]:text-gray-50 [&_small]:block [&_small]:mt-1 [&_small]:text-muted-text dark:[&_small]:text-gray-400">
<Switch id="autopilot-enabled" checked={autopilotEnabled} onCheckedChange={setAutopilotEnabled} />
<label htmlFor="autopilot-enabled">
<strong>Enable incident autopilot</strong>
<small>Write sanitized opening, follow-up, and resolution updates without sending extra alerts.</small>
</label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label className="field" htmlFor="autopilot-cadence">
<span>Initial follow-up cadence (minutes)</span>
<Input
id="autopilot-cadence"
type="number"
min={5}
max={240}
value={followupMinutes}
onChange={(event) => setFollowupMinutes(Number(event.target.value))}
/>
</label>
<label className="field" htmlFor="autopilot-max-updates">
<span>Maximum automatic updates</span>
<Input
id="autopilot-max-updates"
type="number"
min={1}
max={20}
value={maxUpdates}
onChange={(event) => setMaxUpdates(Number(event.target.value))}
/>
</label>
</div>
<div className="flex items-center gap-2.75 [&>label]:cursor-pointer [&_strong]:block [&_strong]:text-caption [&_strong]:font-medium dark:[&_strong]:text-gray-50 [&_small]:block [&_small]:mt-1 [&_small]:text-muted-text dark:[&_small]:text-gray-400">
<Switch id="autopilot-advance-status" checked={advanceStatus} onCheckedChange={setAdvanceStatus} />
<label htmlFor="autopilot-advance-status">
<strong>Advance incident status</strong>
<small>Use objective check patterns to move between investigating, identified, and monitoring.</small>
</label>
</div>
<div className="flex items-center gap-2.75 [&>label]:cursor-pointer [&_strong]:block [&_strong]:text-caption [&_strong]:font-medium dark:[&_strong]:text-gray-50 [&_small]:block [&_small]:mt-1 [&_small]:text-muted-text dark:[&_small]:text-gray-400">
<Switch id="autopilot-degraded" checked={degradedIncidents} onCheckedChange={setDegradedIncidents} />
<label htmlFor="autopilot-degraded">
<strong>Open degraded incidents</strong>
<small>Publish performance degradation incidents. Keep disabled to avoid public noise.</small>
</label>
</div>
</fieldset>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end items-stretch sm:items-center gap-2.5 pt-5 border-t border-border-row dark:border-t-white/7 [&_svg]:size-3.5">
<Button
variant="unstyled"
className="secondary-button"
type="button"
onClick={() => testMutation.mutate()}
disabled={!settings.apiKeySet || !settings.baseUrl || !settings.model || testMutation.isPending}
>
<Sparkles /> {testMutation.isPending ? 'Generating…' : 'Test generation'}
</Button>
<Button variant="unstyled" className="primary-button" type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving…' : 'Save settings'}
</Button>
</div>
{updateMutation.isSuccess && (
<p className="-mt-2 px-3 py-2.5 rounded-md border border-success-banner-border bg-success-banner-bg text-xs text-success-banner-text dark:border-brand/30 dark:bg-brand/10 dark:text-brand-soft">
AI settings saved.
</p>
)}
{testMutation.isSuccess && (
<p className="-mt-2 px-3 py-2.5 rounded-md border border-success-banner-border bg-success-banner-bg text-xs text-success-banner-text dark:border-brand/30 dark:bg-brand/10 dark:text-brand-soft">
{testMutation.data.message}
</p>
)}
{(updateMutation.error || testMutation.error) && (
<p className="form-error">{(updateMutation.error ?? testMutation.error)?.message ?? 'Request failed'}</p>
)}
</form>
);
}
export function AiIncidentMessagesPanel() {
const aiSettingsQuery = useAiSettingsQuery();
if (aiSettingsQuery.isPending) return <div className="table-empty">Loading settings</div>;
if (aiSettingsQuery.isError) {
return (
<Empty variant="error" className="m-6">
<EmptyTitle>Unable to load AI settings</EmptyTitle>
</Empty>
);
}
return <AiSettingsForm key={aiSettingsQuery.data.settings.updatedAt ?? 'new'} settings={aiSettingsQuery.data.settings} />;
}
@@ -115,8 +115,8 @@ export function MaintenanceWindowDialog({ editing, onClose }: { editing: Mainten
<DialogTitle>{editing ? `Edit ${editing.name}` : 'Add maintenance window'}</DialogTitle>
<DialogDescription>Probes continue, but alerts and uptime calculations pause for selected services.</DialogDescription>
</DialogHeader>
<form className="maintenance-form" onSubmit={submit}>
<label className="field maintenance-name" htmlFor="maintenance-name">
<form className="grid grid-cols-2 gap-x-4 gap-y-5 mt-3" onSubmit={submit}>
<label className="field col-span-full" htmlFor="maintenance-name">
<span>Name</span>
<Input
id="maintenance-name"
@@ -147,7 +147,7 @@ export function MaintenanceWindowDialog({ editing, onClose }: { editing: Mainten
required
/>
</label>
<div className="field maintenance-timezone">
<div className="field col-span-full">
<span id="maintenance-timezone-label">Timezone</span>
<Select value={form.timezone} onValueChange={(timezone) => setForm({ ...form, timezone })}>
<SelectTrigger aria-labelledby="maintenance-timezone-label">
@@ -162,40 +162,47 @@ export function MaintenanceWindowDialog({ editing, onClose }: { editing: Mainten
</SelectContent>
</Select>
</div>
<fieldset className="maintenance-services">
<fieldset className="col-span-full min-w-0 m-0 p-0 border-0 [&>legend]:mb-2 [&>legend]:text-caption [&>legend]:font-medium [&>legend]:text-ink-label dark:[&>legend]:text-night-body">
<legend>Services</legend>
<DropdownMenuPrimitive.Root>
<DropdownMenuPrimitive.Trigger asChild>
<button
className="maintenance-service-select"
className="group flex w-full min-h-10.5 items-center justify-between gap-4 px-3 py-2 rounded-md border border-border-subtle bg-white text-sm text-left text-ink shadow-[inset_0_1px_2px_rgb(0_0_0/0.025)] cursor-pointer transition-[border-color,box-shadow] duration-120 hover:border-border-input-hover focus-visible:outline-none focus-visible:border-primary-deep focus-visible:shadow-[0_0_0_3px_rgb(36_180_126/0.14)] 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-55 dark:border-white/12 dark:bg-night-input dark:text-gray-50 dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)] dark:hover:border-white/22 dark:focus-visible:border-primary-deep dark:focus-visible:shadow-[0_0_0_3px_rgba(62,207,142,0.2)] dark:data-[state=open]:border-primary-deep dark:data-[state=open]:shadow-[0_0_0_3px_rgba(62,207,142,0.2)]"
type="button"
disabled={monitorsQuery.isPending || monitors.length === 0}
aria-label="Select services for this maintenance window"
>
<span className={selectedMonitors.length === 0 ? 'is-placeholder' : undefined}>
<span className={selectedMonitors.length === 0 ? 'text-faint' : undefined}>
{monitorsQuery.isPending
? 'Loading services…'
: monitors.length === 0
? 'No services available'
: selectedServicesLabel}
</span>
<span className="maintenance-service-select-meta">
<span className="flex items-center gap-2.25 text-faint [&>small]:font-mono [&>small]:text-2xs/[1.3] [&>small]:font-medium [&>small]:whitespace-nowrap [&>svg]:size-4 [&>svg]:transition-transform [&>svg]:duration-120 group-data-[state=open]:rotate-180">
{selectedMonitors.length > 0 && <small>{selectedMonitors.length} selected</small>}
<ChevronDown aria-hidden="true" />
</span>
</button>
</DropdownMenuPrimitive.Trigger>
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content className="maintenance-service-menu" sideOffset={5} align="start">
<DropdownMenuPrimitive.Content
className="z-70 w-(--radix-dropdown-menu-trigger-width) max-h-[min(256px,var(--radix-dropdown-menu-content-available-height))] overflow-y-auto p-1 rounded-lg border border-hairline bg-white shadow-[0_8px_24px_rgb(0_0_0/0.08)] dark:border-white/12 dark:bg-night-card dark:shadow-[0_16px_40px_rgba(0,0,0,0.6)]"
sideOffset={5}
align="start"
>
{monitors.map((monitor) => (
<DropdownMenuPrimitive.CheckboxItem
key={monitor.id}
className="maintenance-service-option"
className="relative flex items-center gap-2.5 min-h-9 px-2.25 py-1.75 rounded-md text-caption text-ink cursor-pointer outline-none select-none focus:bg-surface-highlight data-highlighted:bg-surface-highlight dark:text-night-body dark:focus:bg-white/6 dark:focus:text-white dark:data-highlighted:bg-white/6 dark:data-highlighted:text-white"
checked={form.monitorIds.includes(monitor.id)}
onCheckedChange={(checked) => toggleMonitor(monitor.id, checked === true)}
onSelect={(event) => event.preventDefault()}
>
<span className="maintenance-service-check" aria-hidden="true">
<span
className="grid shrink-0 size-4.25 place-items-center rounded border border-border-control bg-white text-white dark:border-white/20 dark:bg-night-icon in-data-[state=checked]:border-primary-deep in-data-[state=checked]:bg-primary-deep [&>svg]:size-3 [&>svg]:stroke-[2.5]"
aria-hidden="true"
>
<DropdownMenuPrimitive.ItemIndicator>
<Check />
</DropdownMenuPrimitive.ItemIndicator>
@@ -207,18 +214,22 @@ export function MaintenanceWindowDialog({ editing, onClose }: { editing: Mainten
</DropdownMenuPrimitive.Portal>
</DropdownMenuPrimitive.Root>
{monitors.length === 0 && !monitorsQuery.isPending && (
<small className="maintenance-services-empty">Add a monitor before assigning a maintenance window.</small>
<small className="block mt-1.75 text-footnote text-muted-text dark:text-gray-400">
Add a monitor before assigning a maintenance window.
</small>
)}
</fieldset>
<div className="settings-toggle maintenance-enabled">
<div className="col-span-full flex items-center gap-2.75 [&>label]:cursor-pointer [&_strong]:block [&_strong]:text-caption [&_strong]:font-medium dark:[&_strong]:text-gray-50 [&_small]:block [&_small]:mt-1 [&_small]:text-muted-text dark:[&_small]:text-gray-400">
<Switch id="maintenance-enabled" checked={form.enabled} onCheckedChange={(enabled) => setForm({ ...form, enabled })} />
<label htmlFor="maintenance-enabled">
<strong>Enable this window</strong>
<small>The schedule repeats every day in the selected timezone.</small>
</label>
</div>
<p className="maintenance-helper">Checks run every five minutes. Add a few minutes of padding before and after the backup.</p>
<div className="form-actions compact-actions maintenance-actions">
<p className="-mt-1.5 col-span-full text-xs leading-normal text-muted-text dark:text-gray-400">
Checks run every five minutes. Add a few minutes of padding before and after the backup.
</p>
<div className="form-actions compact-actions col-span-full">
<Button variant="unstyled" className="secondary-button" type="button" onClick={close}>
Cancel
</Button>
@@ -33,7 +33,7 @@ export function MaintenanceWindowsPanel() {
return (
<>
<div className="maintenance-panel-header">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-5 px-5 sm:px-panel-x py-5 border-b border-border-row dark:border-b-white/7 [&>p]:m-0 [&>p]:text-caption [&>p]:text-muted-text dark:[&>p]:text-gray-400 [&_svg]:size-3.75">
<p>Define daily quiet periods for backups or planned service work.</p>
<Button variant="unstyled" className="secondary-button" type="button" onClick={() => setDialog({ open: true, editing: null })}>
<Plus /> Add window
@@ -46,25 +46,28 @@ export function MaintenanceWindowsPanel() {
<EmptyTitle>Unable to load maintenance windows</EmptyTitle>
</Empty>
) : windowsQuery.data.windows.length === 0 ? (
<Empty className="maintenance-empty">
<Empty className="py-11 px-6">
<EmptyTitle>No maintenance windows</EmptyTitle>
<EmptyDescription>Add a recurring window to keep planned downtime out of alerts and uptime.</EmptyDescription>
</Empty>
) : (
<div className="maintenance-window-list">
{windowsQuery.data.windows.map((window) => (
<article className="maintenance-window-row" key={window.id}>
<div className="maintenance-window-main">
<div className="maintenance-window-title">
<article
className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 px-5 sm:px-panel-x py-5.25 border-t border-border-row first:border-t-0 dark:border-t-white/6"
key={window.id}
>
<div className="min-w-0 [&>p]:my-2 [&>p]:font-mono [&>p]:text-xs/relaxed [&>p]:font-medium [&>p]:text-ink-body dark:[&>p]:text-gray-300 [&>p>span]:text-faint dark:[&>p>span]:text-gray-400">
<div className="flex items-center gap-2.5 [&>strong]:text-sm [&>strong]:font-semibold dark:[&>strong]:text-gray-50">
<strong>{window.name}</strong>
{window.active && <Badge variant="maintenance">Active now</Badge>}
{!window.enabled && <span className="maintenance-disabled">Disabled</span>}
{!window.enabled && <span className="text-footnote text-faint dark:text-gray-400">Disabled</span>}
</div>
<p>
{minutesToTime(window.startMinute)}{minutesToTime(window.startMinute + window.durationMinutes)}{' '}
<span>Daily · {window.timezone}</span>
</p>
<div className="maintenance-window-services">
<div className="flex flex-wrap gap-1.5 [&>span]:px-1.75 [&>span]:py-1 [&>span]:rounded-badge [&>span]:border [&>span]:border-border-subtle [&>span]:bg-surface-soft [&>span]:text-footnote [&>span]:not-italic [&>span]:text-muted-text dark:[&>span]:border-white/8 dark:[&>span]:bg-night-icon dark:[&>span]:text-gray-400 [&>em]:text-footnote [&>em]:not-italic [&>em]:text-faint dark:[&>em]:text-gray-400">
{window.monitorIds.length ? (
window.monitorIds.map((id) => <span key={id}>{monitorNames.get(id) ?? `Service ${id}`}</span>)
) : (
@@ -72,7 +75,7 @@ export function MaintenanceWindowsPanel() {
)}
</div>
</div>
<div className="maintenance-window-actions">
<div className="flex shrink-0 self-end sm:self-auto -mt-12 sm:mt-0 gap-1.5 [&_svg]:size-3.75">
<Button
variant="unstyled"
className="icon-button"
@@ -84,7 +87,7 @@ export function MaintenanceWindowsPanel() {
</Button>
<Button
variant="unstyled"
className="icon-button danger-icon-button"
className="icon-button text-danger-icon dark:text-red-400 dark:hover:not(:disabled):border-red-500/35 dark:hover:not(:disabled):text-red-300 dark:hover:not(:disabled):bg-red-500/12"
type="button"
aria-label={`Delete ${window.name}`}
onClick={() => setDeleting(window)}
@@ -68,13 +68,16 @@ export function NotificationChannelDialog({ editing, onClose }: { editing: Notif
return (
<Dialog open onOpenChange={(open) => !open && !mutation.isPending && onClose()}>
<DialogContent className="channel-dialog">
<DialogHeader className="channel-dialog-header">
<DialogContent className="flex flex-col gap-0 w-[calc(100vw-20px)] sm:w-[min(var(--spacing-dialog-channel),calc(100vw-32px))] max-w-dialog-channel max-h-[calc(100dvh-20px)] sm:max-h-[min(90dvh,760px)] overflow-hidden p-0 rounded-card sm:rounded-xl shadow-[0_24px_70px_rgb(22_62_45/0.15),0_4px_16px_rgb(0_0_0/0.07)] dark:border dark:border-white/10 dark:bg-night-card dark:shadow-[0_24px_64px_-12px_rgba(0,0,0,0.85),0_0_0_1px_rgba(255,255,255,0.06)] *:data-[slot=dialog-close]:top-4 sm:*:data-[slot=dialog-close]:top-4.75 *:data-[slot=dialog-close]:right-3.5 sm:*:data-[slot=dialog-close]:right-5">
<DialogHeader className="relative shrink-0 gap-1.25 pt-5.25 pr-13.5 pb-4.5 pl-5 sm:pt-6 sm:pr-16 sm:pb-5 sm:pl-panel-x border-b border-border-dialog-header bg-linear-to-b from-white to-surface-dialog-header dark:border-b-white/8 dark:bg-night-subtle [&_.overline]:mb-1 **:data-[slot=dialog-title]:text-xl **:data-[slot=dialog-title]:leading-tight **:data-[slot=dialog-title]:tracking-dialog-title **:data-[slot=dialog-description]:text-caption **:data-[slot=dialog-description]:leading-normal">
<p className="overline">Alert destination</p>
<DialogTitle>{editing ? `Edit ${editing.name}` : 'Add notification channel'}</DialogTitle>
<DialogDescription>Send incident activity to a team tool or custom integration.</DialogDescription>
</DialogHeader>
<form className="channel-form" onSubmit={submit}>
<form
className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-4.25 min-h-0 m-0 pt-4.5 px-5 sm:pt-5 sm:px-panel-x pb-0 overflow-y-auto overscroll-contain dialog-scrollbar [&_.field]:gap-1.5 [&_.field]:min-w-0"
onSubmit={submit}
>
<label className="field" htmlFor="channel-name">
<span>Name</span>
<Input
@@ -126,7 +129,7 @@ export function NotificationChannelDialog({ editing, onClose }: { editing: Notif
</label>
</>
) : (
<label className="field channel-endpoint-field" htmlFor="channel-url">
<label className="field sm:col-span-2" htmlFor="channel-url">
<span>{form.type === 'webhook' ? 'Webhook URL' : `${form.type === 'slack' ? 'Slack' : 'Discord'} webhook URL`}</span>
<Input
id="channel-url"
@@ -138,35 +141,42 @@ export function NotificationChannelDialog({ editing, onClose }: { editing: Notif
/>
</label>
)}
<fieldset className="maintenance-services channel-services">
<fieldset className="sm:col-span-2 block min-w-0 m-0 p-0 border-0 pt-4.5 border-t border-border-section dark:border-t-white/8 [&>legend]:float-left [&>legend]:mb-1.75 [&>legend]:text-caption [&>legend]:font-medium [&>legend]:text-ink-label dark:[&>legend]:text-night-body">
<legend>Services</legend>
<DropdownMenuPrimitive.Root>
<DropdownMenuPrimitive.Trigger asChild>
<button className="maintenance-service-select" type="button">
<span className={selected.length === 0 ? 'is-placeholder' : undefined}>
<button
className="group clear-both flex w-full min-h-10.5 items-center justify-between gap-4 px-3 py-2 rounded-md border border-border-subtle bg-white text-sm text-left text-ink shadow-[inset_0_1px_2px_rgb(0_0_0/0.025)] cursor-pointer transition-[border-color,box-shadow] duration-120 hover:border-border-input-hover focus-visible:outline-none focus-visible:border-primary-deep focus-visible:shadow-[0_0_0_3px_rgb(36_180_126/0.14)] 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-55 dark:border-white/12 dark:bg-night-input dark:text-gray-50 dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)] dark:hover:border-white/22 dark:focus-visible:border-primary-deep dark:focus-visible:shadow-[0_0_0_3px_rgba(62,207,142,0.2)] dark:data-[state=open]:border-primary-deep dark:data-[state=open]:shadow-[0_0_0_3px_rgba(62,207,142,0.2)]"
type="button"
>
<span className={selected.length === 0 ? 'text-faint' : undefined}>
{selected.length === 0
? 'All services'
: selected.length === 1
? selected[0].name
: `${selected[0].name} +${selected.length - 1} more`}
</span>
<span className="maintenance-service-select-meta">
<span className="flex items-center gap-2.25 text-faint [&>small]:font-mono [&>small]:text-2xs/[1.3] [&>small]:font-medium [&>small]:whitespace-nowrap [&>svg]:size-4 [&>svg]:transition-transform [&>svg]:duration-120 group-data-[state=open]:rotate-180">
<small>{selected.length || 'Any'}</small>
<ChevronDown />
</span>
</button>
</DropdownMenuPrimitive.Trigger>
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content className="maintenance-service-menu" sideOffset={5} align="start">
<DropdownMenuPrimitive.Content
className="z-70 w-(--radix-dropdown-menu-trigger-width) max-h-[min(256px,var(--radix-dropdown-menu-content-available-height))] overflow-y-auto p-1 rounded-lg border border-hairline bg-white shadow-[0_8px_24px_rgb(0_0_0/0.08)] dark:border-white/12 dark:bg-night-card dark:shadow-[0_16px_40px_rgba(0,0,0,0.6)]"
sideOffset={5}
align="start"
>
{monitors.map((monitor) => (
<DropdownMenuPrimitive.CheckboxItem
key={monitor.id}
className="maintenance-service-option"
className="relative flex items-center gap-2.5 min-h-9 px-2.25 py-1.75 rounded-md text-caption text-ink cursor-pointer outline-none select-none focus:bg-surface-highlight data-highlighted:bg-surface-highlight dark:text-night-body dark:focus:bg-white/6 dark:focus:text-white dark:data-highlighted:bg-white/6 dark:data-highlighted:text-white"
checked={form.monitorIds.includes(monitor.id)}
onCheckedChange={(checked) => toggleMonitor(monitor.id, checked === true)}
onSelect={(event) => event.preventDefault()}
>
<span className="maintenance-service-check">
<span className="grid shrink-0 size-4.25 place-items-center rounded border border-border-control bg-white text-white dark:border-white/20 dark:bg-night-icon in-data-[state=checked]:border-primary-deep in-data-[state=checked]:bg-primary-deep [&>svg]:size-3 [&>svg]:stroke-[2.5]">
<DropdownMenuPrimitive.ItemIndicator>
<Check />
</DropdownMenuPrimitive.ItemIndicator>
@@ -177,16 +187,18 @@ export function NotificationChannelDialog({ editing, onClose }: { editing: Notif
</DropdownMenuPrimitive.Content>
</DropdownMenuPrimitive.Portal>
</DropdownMenuPrimitive.Root>
<small className="field-helper">Leave empty to notify for every service.</small>
<small className="block mt-1.5 text-footnote text-muted-text dark:text-gray-400">
Leave empty to notify for every service.
</small>
</fieldset>
<div className="settings-toggle channel-toggle-option">
<div className="flex items-start gap-2.75 min-w-0 p-3.5 rounded-lg border border-border-option bg-surface-option transition-colors duration-160 hover:border-border-option-hover hover:bg-surface-option-hover dark:border-white/8 dark:bg-night-subtle dark:hover:border-white/18 dark:hover:bg-night-option-hover **:[[role=switch]]:mt-px [&>label]:min-w-0 [&>label]:cursor-pointer [&_strong]:block [&_strong]:text-caption [&_strong]:font-medium dark:[&_strong]:text-gray-50 [&_small]:block [&_small]:mt-1 [&_small]:text-footnote [&_small]:leading-alert [&_small]:text-muted-text dark:[&_small]:text-gray-400">
<Switch id="channel-enabled" checked={form.enabled} onCheckedChange={(enabled) => setForm({ ...form, enabled })} />
<label htmlFor="channel-enabled">
<strong>Enable channel</strong>
<small>Allow automated downtime and recovery alerts.</small>
</label>
</div>
<div className="settings-toggle channel-toggle-option">
<div className="flex items-start gap-2.75 min-w-0 p-3.5 rounded-lg border border-border-option bg-surface-option transition-colors duration-160 hover:border-border-option-hover hover:bg-surface-option-hover dark:border-white/8 dark:bg-night-subtle dark:hover:border-white/18 dark:hover:bg-night-option-hover **:[[role=switch]]:mt-px [&>label]:min-w-0 [&>label]:cursor-pointer [&_strong]:block [&_strong]:text-caption [&_strong]:font-medium dark:[&_strong]:text-gray-50 [&_small]:block [&_small]:mt-1 [&_small]:text-footnote [&_small]:leading-alert [&_small]:text-muted-text dark:[&_small]:text-gray-400">
<Switch
id="channel-manual"
checked={form.notifyManual}
@@ -198,12 +210,12 @@ export function NotificationChannelDialog({ editing, onClose }: { editing: Notif
</label>
</div>
{form.type === 'webhook' && (
<div className="channel-payload-preview">
<div className="sm:col-span-2 p-3 rounded-md border border-border-card bg-surface-soft dark:border-white/8 dark:bg-night-subtle [&>span]:block [&>span]:mb-1.75 [&>span]:text-xs [&>span]:font-semibold dark:[&>span]:text-gray-50 [&>pre]:m-0 [&>pre]:overflow-x-auto [&>pre]:text-footnote [&>pre]:text-muted-text dark:[&>pre]:text-gray-400">
<span>Raw payload</span>
<pre>{`{ "event": "down", "monitor": { … }, "statusCode": 500, "error": "…", "at": "…" }`}</pre>
</div>
)}
<div className="form-actions compact-actions channel-dialog-footer">
<div className="form-actions compact-actions sm:col-span-2 sticky bottom-0 z-2 flex justify-end gap-2.25 w-auto -mx-5 sm:-mx-panel-x mt-0.75 px-5 py-3.5 sm:px-panel-x sm:py-3.75 border-t border-border-dialog-footer bg-surface-dialog-footer/97 backdrop-blur-md dark:border-t-white/8 dark:bg-night-card/97 [&_button]:flex-1 sm:[&_button]:flex-initial [&_.primary-button]:min-h-9.5 [&_.primary-button]:px-3.75 [&_.primary-button]:py-1.75 [&_.secondary-button]:min-h-9.5 [&_.secondary-button]:px-3.75 [&_.secondary-button]:py-1.75">
<Button variant="unstyled" className="secondary-button" type="button" onClick={onClose}>
Cancel
</Button>
@@ -212,7 +224,7 @@ export function NotificationChannelDialog({ editing, onClose }: { editing: Notif
</Button>
</div>
{mutation.isError && (
<p className="form-error" role="alert">
<p className="form-error sm:col-span-2" role="alert">
{mutation.error.message}
</p>
)}
@@ -25,13 +25,21 @@ import { NotificationChannelDialog } from './NotificationChannelDialog';
function DeliveryHistory({ channel }: { channel: NotificationChannel }) {
const deliveriesQuery = useNotificationDeliveriesQuery(channel.id);
if (deliveriesQuery.isPending) return <div className="channel-history-state">Loading delivery history</div>;
if (deliveriesQuery.isError) return <div className="channel-history-state form-error">Unable to load delivery history.</div>;
if (deliveriesQuery.data.deliveries.length === 0) return <div className="channel-history-state">No deliveries recorded yet.</div>;
if (deliveriesQuery.isPending)
return <div className="w-full mt-4 text-xs text-muted-text dark:text-gray-400">Loading delivery history</div>;
if (deliveriesQuery.isError) return <div className="w-full mt-4 text-xs form-error">Unable to load delivery history.</div>;
if (deliveriesQuery.data.deliveries.length === 0)
return <div className="w-full mt-4 text-xs text-muted-text dark:text-gray-400">No deliveries recorded yet.</div>;
return (
<div className="channel-history" aria-label={`${channel.name} delivery history`}>
<div
className="grid content-start w-full max-h-history-max gap-px mt-4.5 rounded-md border border-border-panel bg-border-panel overflow-auto overscroll-contain table-scrollbar dark:border-white/8 dark:bg-white/6"
aria-label={`${channel.name} delivery history`}
>
{deliveriesQuery.data.deliveries.map((delivery) => (
<div className="channel-history-row" key={delivery.id}>
<div
className="grid grid-cols-[78px_minmax(100px,0.8fr)_minmax(145px,1fr)_minmax(120px,1fr)_auto] items-center gap-3 p-2.5 sm:px-3 text-footnote bg-white min-w-table-min dark:bg-night-panel dark:hover:bg-night-subtle [&>strong]:font-semibold [&>strong]:capitalize dark:[&>strong]:text-gray-50 [&>span]:text-muted-text dark:[&>span]:text-gray-400 [&>small]:text-muted-text dark:[&>small]:text-gray-400"
key={delivery.id}
>
<Badge variant={delivery.ok ? 'online' : 'offline'}>{delivery.ok ? 'Delivered' : 'Failed'}</Badge>
<strong>{delivery.event.replaceAll('_', ' ')}</strong>
<span>{new Date(delivery.createdAt).toLocaleString()}</span>
@@ -57,7 +65,7 @@ export function NotificationChannelsPanel() {
return (
<>
<div className="channel-panel-header">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-5 px-5 sm:px-panel-x py-5 border-b border-border-row dark:border-b-white/7 [&>p]:m-0 [&>p]:text-caption [&>p]:text-muted-text dark:[&>p]:text-gray-400 [&_svg]:size-3.75">
<p>Route automatic and manual incident activity to the right team.</p>
<Button variant="unstyled" className="secondary-button" type="button" onClick={() => setDialog({ open: true, editing: null })}>
<Plus /> Add channel
@@ -70,22 +78,25 @@ export function NotificationChannelsPanel() {
<EmptyTitle>Unable to load notification channels</EmptyTitle>
</Empty>
) : channelsQuery.data.channels.length === 0 ? (
<Empty className="channel-empty">
<Empty className="py-11 px-6">
<EmptyTitle>No notification channels</EmptyTitle>
<EmptyDescription>Add Slack, Discord, Telegram, or a raw webhook destination.</EmptyDescription>
</Empty>
) : (
<div className="channel-list">
{channelsQuery.data.channels.map((channel) => (
<article className="channel-row" key={channel.id}>
<div className="channel-row-summary">
<div className="channel-main">
<div className="channel-title">
<article
className="flex flex-col px-5 sm:px-panel-x py-5.25 border-t border-border-row first:border-t-0 dark:border-t-white/6"
key={channel.id}
>
<div className="flex flex-col sm:flex-row w-full sm:items-center justify-between gap-6">
<div className="min-w-0">
<div className="flex items-center flex-wrap gap-2 [&>strong]:text-sm [&>strong]:font-semibold dark:[&>strong]:text-gray-50">
<strong>{channel.name}</strong>
<Badge variant="maintenance">{channel.type}</Badge>
{!channel.enabled && <span className="maintenance-disabled">Disabled</span>}
{!channel.enabled && <span className="text-footnote text-faint dark:text-gray-400">Disabled</span>}
</div>
<div className="channel-delivery">
<div className="flex items-center flex-wrap gap-2 my-2 text-footnote text-faint dark:text-gray-400">
{channel.lastDelivery ? (
<Badge variant={channel.lastDelivery.ok ? 'online' : 'offline'}>
{channel.lastDelivery.ok ? 'Delivered' : 'Failed'}
@@ -99,7 +110,7 @@ export function NotificationChannelsPanel() {
</span>
)}
</div>
<div className="channel-services">
<div className="flex items-center flex-wrap gap-2 [&>span]:px-1.75 [&>span]:py-1 [&>span]:rounded-badge [&>span]:border [&>span]:border-border-subtle [&>span]:bg-surface-soft [&>span]:text-footnote [&>span]:text-muted-text dark:[&>span]:border-white/8 dark:[&>span]:bg-night-icon dark:[&>span]:text-gray-400 [&>em]:px-1.75 [&>em]:py-1 [&>em]:rounded-badge [&>em]:border [&>em]:border-border-subtle [&>em]:bg-surface-soft [&>em]:text-footnote [&>em]:not-italic [&>em]:text-muted-text dark:[&>em]:border-white/8 dark:[&>em]:bg-night-icon dark:[&>em]:text-gray-400">
{channel.monitorIds.length === 0 ? (
<em>All services</em>
) : (
@@ -107,10 +118,10 @@ export function NotificationChannelsPanel() {
)}
</div>
</div>
<div className="channel-actions">
<div className="flex items-center flex-wrap gap-2 shrink-0 self-stretch sm:self-auto [&_svg]:size-3.75">
<Button
variant="unstyled"
className="secondary-button compact-button"
className="secondary-button min-h-8.5 px-2.5 py-1.75"
type="button"
disabled={testMutation.isPending}
onClick={() => testMutation.mutate(channel.id)}
@@ -119,7 +130,7 @@ export function NotificationChannelsPanel() {
</Button>
<Button
variant="unstyled"
className="secondary-button compact-button"
className="secondary-button min-h-8.5 px-2.5 py-1.75"
type="button"
aria-expanded={historyId === channel.id}
onClick={() => setHistoryId((current) => (current === channel.id ? null : channel.id))}
@@ -137,7 +148,7 @@ export function NotificationChannelsPanel() {
</Button>
<Button
variant="unstyled"
className="icon-button danger-icon-button"
className="icon-button text-danger-icon dark:text-red-400 dark:hover:not(:disabled):border-red-500/35 dark:hover:not(:disabled):text-red-300 dark:hover:not(:disabled):bg-red-500/12"
type="button"
aria-label={`Delete ${channel.name}`}
onClick={() => setDeleting(channel)}
@@ -151,8 +162,12 @@ export function NotificationChannelsPanel() {
))}
</div>
)}
{testMutation.isError && <p className="form-error channel-feedback">{testMutation.error.message}</p>}
{testMutation.isSuccess && <p className="settings-success channel-feedback">Test notification delivered.</p>}
{testMutation.isError && <p className="form-error mx-panel-x mb-5 dark:text-red-400">{testMutation.error.message}</p>}
{testMutation.isSuccess && (
<p className="-mt-2 px-3 py-2.5 rounded-md border border-success-banner-border bg-success-banner-bg text-xs text-success-banner-text dark:border-brand/30 dark:bg-brand/10 dark:text-brand-soft mx-panel-x mb-5">
Test notification delivered.
</p>
)}
{dialog.open && <NotificationChannelDialog editing={dialog.editing} onClose={() => setDialog({ open: false, editing: null })} />}
<AlertDialog open={deleting !== null} onOpenChange={(open) => !open && !deleteMutation.isPending && setDeleting(null)}>
<AlertDialogContent>
+75 -210
View File
@@ -1,234 +1,99 @@
import { type FormEvent, useState } from 'react';
import { Activity, ArrowLeft, BellRing, Sparkles, Wrench } 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 { AiSettings } from '../api/settings';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AppHeader } from '../components/AppHeader';
import { AiActivityPanel } from '../components/settings/AiActivityPanel';
import { AiIncidentMessagesPanel } from '../components/settings/AiIncidentMessagesPanel';
import { MaintenanceWindowsPanel } from '../components/settings/MaintenanceWindowsPanel';
import { NotificationChannelsPanel } from '../components/settings/NotificationChannelsPanel';
import { navigate } from '../lib/router';
import { useSeo } from '../lib/seo';
import { useAiSettingsQuery, useTestAiSettingsMutation, useUpdateAiSettingsMutation } from '../queries/settings';
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 [autopilotEnabled, setAutopilotEnabled] = useState(settings.autopilotEnabled);
const [followupMinutes, setFollowupMinutes] = useState(settings.autopilotFollowupMinutes);
const [maxUpdates, setMaxUpdates] = useState(settings.autopilotMaxUpdates);
const [advanceStatus, setAdvanceStatus] = useState(settings.autopilotAdvanceStatus);
const [degradedIncidents, setDegradedIncidents] = useState(settings.autopilotDegradedIncidents);
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,
autopilotEnabled,
autopilotFollowupMinutes: followupMinutes,
autopilotMaxUpdates: maxUpdates,
autopilotAdvanceStatus: advanceStatus,
autopilotDegradedIncidents: degradedIncidents,
});
}
return (
<form className="settings-form" onSubmit={submit}>
<label className="field" htmlFor="ai-base-url">
<span>Base URL</span>
<Input
id="ai-base-url"
type="url"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="https://api.openai.com/v1"
/>
</label>
<label className="field" htmlFor="ai-api-key">
<span>API key</span>
<Input
id="ai-api-key"
type="password"
autoComplete="off"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder={settings.apiKeyPreview ?? 'Enter API key'}
/>
{settings.apiKeySet && <small className="field-helper">Leave blank to keep the current key.</small>}
</label>
<label className="field" htmlFor="ai-model">
<span>Model</span>
<Input id="ai-model" type="text" value={model} onChange={(event) => setModel(event.target.value)} placeholder="gpt-4o-mini" />
</label>
<div className="settings-toggle">
<Switch id="ai-enabled" checked={enabled} onCheckedChange={setEnabled} />
<label htmlFor="ai-enabled">
<strong>Enable AI incident messages</strong>
<small>Generate one sanitized public update when an incident opens.</small>
</label>
</div>
<fieldset className="autopilot-settings">
<legend>Autopilot</legend>
<div className="settings-toggle">
<Switch id="autopilot-enabled" checked={autopilotEnabled} onCheckedChange={setAutopilotEnabled} />
<label htmlFor="autopilot-enabled">
<strong>Enable incident autopilot</strong>
<small>Write sanitized opening, follow-up, and resolution updates without sending extra alerts.</small>
</label>
</div>
<div className="autopilot-number-fields">
<label className="field" htmlFor="autopilot-cadence">
<span>Initial follow-up cadence (minutes)</span>
<Input
id="autopilot-cadence"
type="number"
min={5}
max={240}
value={followupMinutes}
onChange={(event) => setFollowupMinutes(Number(event.target.value))}
/>
</label>
<label className="field" htmlFor="autopilot-max-updates">
<span>Maximum automatic updates</span>
<Input
id="autopilot-max-updates"
type="number"
min={1}
max={20}
value={maxUpdates}
onChange={(event) => setMaxUpdates(Number(event.target.value))}
/>
</label>
</div>
<div className="settings-toggle">
<Switch id="autopilot-advance-status" checked={advanceStatus} onCheckedChange={setAdvanceStatus} />
<label htmlFor="autopilot-advance-status">
<strong>Advance incident status</strong>
<small>Use objective check patterns to move between investigating, identified, and monitoring.</small>
</label>
</div>
<div className="settings-toggle">
<Switch id="autopilot-degraded" checked={degradedIncidents} onCheckedChange={setDegradedIncidents} />
<label htmlFor="autopilot-degraded">
<strong>Open degraded incidents</strong>
<small>Publish performance degradation incidents. Keep disabled to avoid public noise.</small>
</label>
</div>
</fieldset>
<div className="settings-actions">
<Button
variant="unstyled"
className="secondary-button"
type="button"
onClick={() => testMutation.mutate()}
disabled={!settings.apiKeySet || !settings.baseUrl || !settings.model || testMutation.isPending}
>
<Sparkles /> {testMutation.isPending ? 'Generating…' : 'Test generation'}
</Button>
<Button variant="unstyled" className="primary-button" type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving…' : 'Save settings'}
</Button>
</div>
{updateMutation.isSuccess && <p className="settings-success">AI settings saved.</p>}
{testMutation.isSuccess && <p className="settings-success">{testMutation.data.message}</p>}
{(updateMutation.isError || testMutation.isError) && (
<p className="form-error">{(updateMutation.error ?? testMutation.error)?.message ?? 'Request failed'}</p>
)}
</form>
);
}
const SECTIONS = [
{
id: 'ai-activity',
label: 'AI activity',
icon: Activity,
description: 'Audit model calls, sanitizer rejections, latency, and token usage from the last seven days.',
Panel: AiActivityPanel,
},
{
id: 'notifications',
label: 'Notification channels',
icon: BellRing,
description: 'Route incidents to Slack, Discord, Telegram, or existing webhook integrations, with delivery history.',
Panel: NotificationChannelsPanel,
},
{
id: 'ai-messages',
label: 'AI incident messages',
icon: Sparkles,
description: 'Turn technical check failures into short, sanitized updates for visitors. Generation runs only when an incident opens.',
Panel: AiIncidentMessagesPanel,
},
{
id: 'maintenance',
label: 'Maintenance windows',
icon: Wrench,
description: 'Keep probing during planned work while suppressing alerts and excluding those checks from uptime.',
Panel: MaintenanceWindowsPanel,
},
] as const;
export function SettingsPage() {
const aiSettingsQuery = useAiSettingsQuery();
useSeo({ title: 'Settings — upwatch', noindex: true });
return (
<div className="dashboard-shell">
<AppHeader context="Settings" />
<main className="settings-main">
<main className="dashboard-main pt-8.5 pb-20">
<Button variant="unstyled" className="back-link" type="button" onClick={() => navigate('/dashboard')}>
<ArrowLeft /> Dashboard
</Button>
<section className="settings-heading">
<section className="mt-10.5">
<p className="overline">Integrations</p>
<h1>Notifications, AI &amp; maintenance</h1>
<p>Configure incident alerts, visitor-friendly updates, and planned downtime from one place.</p>
<h1 className="m-0 text-display-sm sm:text-display font-medium tracking-display dark:text-gray-50">
Notifications, AI &amp; maintenance
</h1>
<p className="mt-3 text-subtitle leading-subtitle text-muted-text dark:text-gray-400">
Configure incident alerts, visitor-friendly updates, and planned downtime from one place.
</p>
</section>
<Card asChild>
<section className="settings-card">
<div className="settings-card-intro">
<span>
<Activity />
</span>
<div>
<h2>AI activity</h2>
<p>Audit model calls, sanitizer rejections, latency, and token usage from the last seven days.</p>
</div>
</div>
<AiActivityPanel />
</section>
</Card>
<Card asChild>
<section className="settings-card">
<div className="settings-card-intro">
<span>
<BellRing />
</span>
<div>
<h2>Notification channels</h2>
<p>Route incidents to Slack, Discord, Telegram, or existing webhook integrations, with delivery history.</p>
</div>
</div>
<NotificationChannelsPanel />
</section>
</Card>
<Card asChild>
<section className="settings-card">
<div className="settings-card-intro">
<span>
<Sparkles />
</span>
<div>
<h2>AI incident messages</h2>
<p>
Turn technical check failures into short, sanitized updates for visitors. Generation runs only when an incident opens.
</p>
</div>
</div>
{aiSettingsQuery.isPending ? (
<div className="table-empty">Loading settings</div>
) : aiSettingsQuery.isError ? (
<Empty variant="error" className="m-6">
<EmptyTitle>Unable to load AI settings</EmptyTitle>
</Empty>
) : (
<AiSettingsForm key={aiSettingsQuery.data.settings.updatedAt ?? 'new'} settings={aiSettingsQuery.data.settings} />
)}
</section>
</Card>
<Card asChild>
<section className="settings-card maintenance-settings-card">
<div className="settings-card-intro">
<span>
<Wrench />
</span>
<div>
<h2>Maintenance windows</h2>
<p>Keep probing during planned work while suppressing alerts and excluding those checks from uptime.</p>
</div>
</div>
<MaintenanceWindowsPanel />
</section>
</Card>
<Tabs defaultValue={SECTIONS[0].id} className="mt-9.5">
<TabsList aria-label="Settings sections">
{SECTIONS.map((section) => {
const Icon = section.icon;
return (
<TabsTrigger key={section.id} value={section.id}>
<Icon aria-hidden="true" />
{section.label}
</TabsTrigger>
);
})}
</TabsList>
{SECTIONS.map((section) => {
const Icon = section.icon;
const Panel = section.Panel;
return (
<TabsContent key={section.id} value={section.id}>
<Card asChild>
<section className="overflow-hidden rounded-card border border-border-subtle bg-white shadow-[0_8px_24px_rgb(0_0_0/0.035)] dark:border-white/8 dark:bg-night-panel dark:shadow-[0_16px_40px_rgba(0,0,0,0.35)]">
<div className="flex gap-4 p-5 sm:p-panel-x border-b border-border-row dark:border-b-white/7">
<span className="grid shrink-0 size-10.5 place-items-center rounded-lg border border-badge-green-border bg-badge-green-bg text-accent-green-hover dark:border-brand/25 dark:bg-brand/12 dark:text-brand [&>svg]:size-4.75">
<Icon />
</span>
<div>
<h2 className="m-0 text-lg font-medium dark:text-gray-50">{section.label}</h2>
<p className="mt-1.5 text-caption leading-subtitle text-muted-text dark:text-gray-400">{section.description}</p>
</div>
</div>
<Panel />
</section>
</Card>
</TabsContent>
);
})}
</Tabs>
</main>
</div>
);
+77 -1
View File
@@ -3,7 +3,6 @@
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@import './styles/StatusPage.css';
@import './styles/SettingsPage.css';
@custom-variant dark (&:is(.dark *));
@@ -730,6 +729,52 @@
}
}
@utility table-scrollbar {
scrollbar-color: var(--color-slate-300) transparent;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--color-slate-300);
border-radius: 9999px;
}
}
.dark .table-scrollbar {
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
}
@utility dialog-scrollbar {
scrollbar-color: #bdc9c3 transparent;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: #bdc9c3;
border-radius: 9999px;
}
}
.dark .dialog-scrollbar {
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
}
@theme inline {
--font-sans: 'DM Sans', 'Helvetica Neue', sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
@@ -750,6 +795,7 @@
--color-ink: var(--ink);
--color-hairline: var(--hairline);
--color-faint: var(--faint);
--color-muted-text: var(--muted);
--color-soft: var(--soft);
--color-night: var(--night);
--color-primary-deep: var(--primary-deep);
@@ -778,6 +824,7 @@
--color-night-surface: #13161b;
--color-night-card-alt: #15171b;
--color-night-incident: #151114;
--color-night-option-hover: #181c24;
/* Dark mode text tones */
--color-night-metric: #f4f7f6;
@@ -801,6 +848,12 @@
--color-surface-sparkline: #fdfefe;
--color-surface-uptime: #fcfefd;
--color-surface-danger-subtle: #fdfafa;
--color-surface-option: #fbfcfb;
--color-surface-option-hover: #f8fbf9;
--color-surface-fieldset: #fafcfb;
--color-surface-dialog-header: #fdfefd;
--color-surface-dialog-footer: #fafbfa;
--color-surface-highlight: #f2f5f3;
/* Status orb colors */
--color-status-online-border: #c5f1df;
@@ -832,6 +885,13 @@
--color-border-accent-checked: #8dd9b9;
--color-border-hero: #e7e7e7;
--color-border-divider: #f0f0f0;
--color-border-panel: #e6e6e6;
--color-border-control: #cfcfcf;
--color-border-option: #e2e6e4;
--color-border-option-hover: #cbd6d1;
--color-border-dialog-header: #e9ecea;
--color-border-section: #edf0ee;
--color-border-dialog-footer: #e4e8e6;
/* Text & foreground tones */
--color-ink-muted-dark: #888888;
@@ -849,6 +909,14 @@
--color-danger-hover: #922f2f;
--color-danger-bg: #fff2f2;
--color-danger-border: #efd1d1;
--color-danger-icon: #a74545;
/* Settings badges & feedback */
--color-badge-green-border: #bcead6;
--color-badge-green-bg: #f0fbf6;
--color-success-banner-border: #bde9d5;
--color-success-banner-bg: #f2fbf7;
--color-success-banner-text: #16754f;
/* Incident tone colors */
--color-tone-blue: #2563a8;
@@ -879,6 +947,7 @@
--tracking-brand: -0.6px;
--tracking-overline: 0.08em;
--tracking-metric: -1px;
--tracking-dialog-title: -0.35px;
/* Line height (leading) */
--leading-title: 1.16;
@@ -904,11 +973,16 @@
--text-display: 40px;
/* Sizing and layout tokens */
--spacing-panel-x: 26px;
--spacing-metric-h: 142px;
--spacing-row-h: 112px;
--spacing-skeleton-h: 104px;
--spacing-dialog-max: 860px;
--spacing-dialog-btn: 148px;
--spacing-dialog-channel: 720px;
--spacing-history-max: 380px;
--spacing-activity-max: 420px;
--spacing-table-min: 650px;
--spacing-select-max: 260px;
--spacing-textarea-h: 154px;
--spacing-textarea-sm-h: 132px;
@@ -917,7 +991,9 @@
--breakpoint-compact: 440px;
/* Radius tokens */
--radius-badge: 5px;
--radius-card-sm: 7px;
--radius-card: 10px;
--radius-actions: 9px;
--radius-dialog: 14px;
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react';
import { Tabs as TabsPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return <TabsPrimitive.Root data-slot="tabs" className={cn('flex flex-col', className)} {...props} />;
}
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn('table-scrollbar flex w-full flex-nowrap items-center gap-1.5 overflow-x-auto pb-1', className)}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
'inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-1.75 text-caption font-medium whitespace-nowrap outline-none transition-colors select-none',
'text-icon-muted hover:bg-[rgb(62_207_142/0.1)] hover:text-primary-deep focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-deep/40 disabled:pointer-events-none disabled:opacity-50',
'data-[state=active]:bg-[rgb(62_207_142/0.1)] data-[state=active]:text-primary-deep',
'dark:text-[#9ca3af] dark:hover:bg-[rgba(62,207,142,0.12)] dark:hover:text-brand',
'dark:data-[state=active]:bg-[rgba(62,207,142,0.12)] dark:data-[state=active]:text-brand',
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content data-slot="tabs-content" className={cn('mt-5 outline-none focus-visible:outline-none', className)} {...props} />
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };