mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 13:48:31 +00:00
[#000] fix: improve the setting pages
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 & maintenance</h1>
|
||||
<p>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.
|
||||
<h1 className="m-0 text-display-sm sm:text-display font-medium tracking-display dark:text-gray-50">
|
||||
Notifications, AI & 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>
|
||||
</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>
|
||||
<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="settings-card maintenance-settings-card">
|
||||
<div className="settings-card-intro">
|
||||
<span>
|
||||
<Wrench />
|
||||
<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>Maintenance windows</h2>
|
||||
<p>Keep probing during planned work while suppressing alerts and excluding those checks from uptime.</p>
|
||||
<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>
|
||||
<MaintenanceWindowsPanel />
|
||||
<Panel />
|
||||
</section>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
+77
-1
@@ -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
@@ -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 };
|
||||
Reference in New Issue
Block a user