feat(frontend): make Storybook a validated, fully covered component workbench

Storybook existed only as an undocumented local tool: 9 of 24 reusable components had stories, autodocs pages were bare prop tables, nothing built or tested the stories, and no contributor doc mentioned the workbench existed.

Every reusable component under src/components/ now has a co-located story with enriched autodocs (component descriptions plus per-prop argTypes, kept as string metadata since the repo bans line comments). Stories double as headless Chromium tests through the Storybook vitest addon, with axe accessibility checks enforced as errors and play-function interaction tests covering the modals, the RHF field bridge, the config block, and the select-all buttons. The preview now mirrors the panel's real theme DOM (body class, shared AntD theme config, seeded theme storage) so what stories render matches production.

CI and make verify gain a static Storybook build as a compile gate, and the frontend test job installs Chromium so story tests run on every PR. Contributor docs (frontend README, CONTRIBUTING, agent guides) document the workbench, the story conventions, and the Controls setup. Node engines move to 24 LTS and gen:api drops the type-stripping flags that Node 24 makes default.
This commit is contained in:
MHSanaei
2026-07-14 03:37:21 +02:00
parent 4e928a1ce0
commit 7078abc14a
36 changed files with 2315 additions and 222 deletions
@@ -6,6 +6,18 @@ const meta = {
title: 'UI/InfinityIcon',
component: InfinityIcon,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Inline SVG infinity glyph used to denote an unlimited value (e.g. unlimited traffic or no expiry). Inherits the current text color.',
},
},
},
argTypes: {
width: { description: 'Icon width in pixels or any CSS length.' },
height: { description: 'Icon height in pixels or any CSS length.' },
},
} satisfies Meta<typeof InfinityIcon>;
export default meta;
@@ -7,6 +7,21 @@ const meta = {
title: 'UI/InputAddon',
component: InputAddon,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Prefix/suffix addon styled to sit flush against an Ant Design input. Becomes a keyboard-accessible button (role, tabIndex, Enter/Space) when `onClick` is provided.',
},
},
},
argTypes: {
children: { description: 'Addon content (text or an icon).' },
onClick: { description: 'When set, the addon becomes an activatable button.' },
ariaLabel: { description: 'Accessible label; used only when `onClick` is set.' },
className: { description: 'Extra CSS class appended to the addon.' },
style: { description: 'Inline styles for the addon element.' },
},
} satisfies Meta<typeof InputAddon>;
export default meta;
@@ -26,7 +41,7 @@ export const BesideInput: Story = {
render: () => (
<Space.Compact>
<InputAddon>https://</InputAddon>
<Input defaultValue="panel.example.com" style={{ width: 220 }} />
<Input defaultValue="panel.example.com" aria-label="Panel host" style={{ width: 220 }} />
</Space.Compact>
),
};
@@ -7,7 +7,22 @@ const meta = {
title: 'UI/SettingListItem',
component: SettingListItem,
tags: ['autodocs'],
parameters: { layout: 'padded' },
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Two-column settings row: a title and description on the left, and a control (Switch, InputNumber, …) on the right. Associates the title with the control via `aria-labelledby` for accessibility.',
},
},
},
argTypes: {
title: { description: 'Setting name shown on the left.' },
description: { description: 'Secondary help text under the title.' },
control: { description: 'The control rendered on the right (Switch, InputNumber, …).' },
children: { description: 'Alternative to `control`; used when no explicit control is passed.' },
paddings: { description: 'Row density: `default` or the tighter `small`.' },
},
} satisfies Meta<typeof SettingListItem>;
export default meta;
@@ -0,0 +1,85 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { AllSetting } from '@/models/setting';
import { EmailNotifications } from './EmailNotifications';
const meta = {
title: 'UI/Notifications/EmailNotifications',
component: EmailNotifications,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Grid of grouped event checkboxes on the settings page that picks which panel events (outbound/node health, Xray crashes, CPU/RAM thresholds, login attempts) trigger an SMTP email, stored as a comma-separated list in smtpEnabledEvents.',
},
},
},
argTypes: {
allSetting: {
description:
'Panel settings snapshot; smtpEnabledEvents holds the selected event keys and smtpCpu/smtpMemory the alert threshold percentages.',
},
updateSetting: {
description: 'Receives a partial settings patch when an event is toggled or a threshold input changes.',
},
},
} satisfies Meta<typeof EmailNotifications>;
export default meta;
type Story = StoryObj<typeof meta>;
function StatefulDemo({ initial }: { initial: AllSetting }) {
const [settings, setSettings] = useState(initial);
return (
<EmailNotifications
allSetting={settings}
updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
/>
);
}
const placeholderArgs = {
allSetting: new AllSetting(),
updateSetting: () => undefined,
};
export const NothingSelected: Story = {
args: placeholderArgs,
render: () => <StatefulDemo initial={new AllSetting()} />,
};
export const SystemThresholdAlerts: Story = {
args: placeholderArgs,
render: () => (
<StatefulDemo
initial={new AllSetting({ smtpEnabledEvents: 'cpu.high,memory.high', smtpCpu: 85, smtpMemory: 90 })}
/>
),
};
export const InfrastructureOnly: Story = {
args: placeholderArgs,
render: () => (
<StatefulDemo initial={new AllSetting({ smtpEnabledEvents: 'outbound.down,node.down,node.up,xray.crash' })} />
),
};
export const AllEventsEnabled: Story = {
args: placeholderArgs,
render: () => (
<StatefulDemo
initial={
new AllSetting({
smtpEnabledEvents:
'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
smtpCpu: 80,
smtpMemory: 80,
})
}
/>
),
};
@@ -8,7 +8,21 @@ const meta = {
title: 'UI/Notifications/NotificationCard',
component: NotificationCard,
tags: ['autodocs'],
parameters: { layout: 'padded' },
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Small outlined card that groups a notification channel — an icon and title in the header, a control in the top-right `extra` slot (typically a toggle), and the channel settings as its body.',
},
},
},
argTypes: {
icon: { description: 'Leading icon shown before the title.' },
title: { description: 'Channel name shown in the header.' },
extra: { description: 'Top-right slot, typically an enable/disable Switch.' },
children: { description: 'Card body — the channel settings.' },
},
} satisfies Meta<typeof NotificationCard>;
export default meta;
@@ -19,7 +33,7 @@ export const Default: Story = {
args: {
icon: <BellOutlined />,
title: 'Telegram',
extra: <Switch defaultChecked />,
extra: <Switch defaultChecked aria-label="Enable Telegram notifications" />,
children: <span>Push a message to the configured chat when an event fires.</span>,
},
};
@@ -28,7 +42,7 @@ export const Disabled: Story = {
args: {
icon: <BellOutlined />,
title: 'Email',
extra: <Switch />,
extra: <Switch aria-label="Enable email notifications" />,
children: <span>Email delivery is turned off for this channel.</span>,
},
};
@@ -0,0 +1,77 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { InputNumber } from 'antd';
import { NotificationEvent } from './NotificationEvent';
const meta = {
title: 'UI/Notifications/NotificationEvent',
component: NotificationEvent,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Single toggleable notification event row used inside the Telegram and email notification groups on the settings page. Renders a checkbox with a translated label and, when checked, an optional indented extra control such as a threshold input.',
},
},
},
argTypes: {
label: { description: 'i18n key (or already-translated text) shown next to the checkbox.' },
checked: { description: 'Whether the event notification is enabled.' },
onToggle: { description: 'Called when the checkbox is clicked.' },
children: { description: 'Extra control rendered indented below the label while checked.' },
},
} satisfies Meta<typeof NotificationEvent>;
export default meta;
type Story = StoryObj<typeof meta>;
function CpuThresholdDemo() {
const [checked, setChecked] = useState(true);
const [threshold, setThreshold] = useState(80);
return (
<NotificationEvent
label="pages.settings.eventCPUHigh"
checked={checked}
onToggle={() => setChecked((prev) => !prev)}
>
<InputNumber
size="small"
min={0}
max={100}
value={threshold}
onChange={(v) => setThreshold(v ?? 0)}
aria-label="CPU usage threshold percent"
style={{ width: 80 }}
/>
</NotificationEvent>
);
}
export const Unchecked: Story = {
args: {
label: 'pages.settings.eventLoginAttempt',
checked: false,
onToggle: () => undefined,
},
};
export const Checked: Story = {
args: {
label: 'pages.settings.eventXrayCrash',
checked: true,
onToggle: () => undefined,
},
};
export const CpuThreshold: Story = {
args: {
label: 'pages.settings.eventCPUHigh',
checked: true,
onToggle: () => undefined,
},
render: () => <CpuThresholdDemo />,
};
@@ -0,0 +1,131 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { InputNumber } from 'antd';
import { CloudServerOutlined, DashboardOutlined } from '@ant-design/icons';
import { AllSetting } from '@/models/setting';
import { NotificationGroup } from './NotificationGroup';
import type { NotificationGroupConfig } from './types';
const systemGroup: NotificationGroupConfig = {
icon: <DashboardOutlined />,
title: 'eventGroupSystem',
events: [
{
key: 'cpu.high',
label: 'eventCPUHigh',
settingKey: 'tgCpu',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
),
},
{
key: 'memory.high',
label: 'eventMemoryHigh',
settingKey: 'tgMemory',
extra: ({ value, onChange, ariaLabel }) => (
<InputNumber size="small" min={0} max={100} value={value} onChange={onChange} aria-label={ariaLabel} style={{ width: 80 }} />
),
},
],
};
const outboundGroup: NotificationGroupConfig = {
icon: <CloudServerOutlined />,
title: 'eventGroupOutbound',
events: [
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
],
};
const meta = {
title: 'UI/Notifications/NotificationGroup',
component: NotificationGroup,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Card for one notification event group (outbound, Xray, node, system, security) with a per-group select-all checkbox, a selected-count tag, and optional per-event threshold inputs. Composed by the Telegram and email notification tabs on the settings page.',
},
},
},
argTypes: {
config: { description: 'Group definition: icon, `pages.settings` title key, and the event rows to render.' },
selected: { description: 'Enabled event keys; drives each checkbox and the header count.' },
onToggle: { description: 'Called with the event key when a single checkbox is clicked.' },
onToggleAll: { description: 'Called with every event key in the group when the master checkbox is clicked.' },
allSetting: { description: 'Panel settings snapshot; threshold values such as `tgCpu` are read from it.' },
updateSetting: { description: 'Called with a partial settings patch when a threshold input changes.' },
},
} satisfies Meta<typeof NotificationGroup>;
export default meta;
type Story = StoryObj<typeof meta>;
function Demo() {
const [selected, setSelected] = useState<string[]>(['cpu.high']);
const [settings, setSettings] = useState(new AllSetting({ tgCpu: 85, tgMemory: 90 }));
return (
<NotificationGroup
config={systemGroup}
selected={selected}
onToggle={(key) =>
setSelected((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]))
}
onToggleAll={(keys) =>
setSelected((prev) => (keys.every((k) => prev.includes(k)) ? prev.filter((k) => !keys.includes(k)) : [...new Set([...prev, ...keys])]))
}
allSetting={settings}
updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
/>
);
}
export const AllSelected: Story = {
args: {
config: systemGroup,
selected: ['cpu.high', 'memory.high'],
onToggle: () => undefined,
onToggleAll: () => undefined,
allSetting: new AllSetting({ tgCpu: 85, tgMemory: 90 }),
updateSetting: () => undefined,
},
};
export const PartiallySelected: Story = {
args: {
config: systemGroup,
selected: ['cpu.high'],
onToggle: () => undefined,
onToggleAll: () => undefined,
allSetting: new AllSetting(),
updateSetting: () => undefined,
},
};
export const NoneSelected: Story = {
args: {
config: outboundGroup,
selected: [],
onToggle: () => undefined,
onToggleAll: () => undefined,
allSetting: new AllSetting(),
updateSetting: () => undefined,
},
};
export const Interactive: Story = {
args: {
config: systemGroup,
selected: [],
onToggle: () => undefined,
onToggleAll: () => undefined,
allSetting: new AllSetting(),
updateSetting: () => undefined,
},
render: () => <Demo />,
};
@@ -0,0 +1,104 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Checkbox } from 'antd';
import { NotificationHeader } from './NotificationHeader';
const meta = {
title: 'UI/Notifications/NotificationHeader',
component: NotificationHeader,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Selection summary for a notification group header — a `count/total` tag plus a tri-state master checkbox that selects or clears every event in the group. Rendered in the `extra` slot of the Telegram/email notification cards on the settings page.',
},
},
},
argTypes: {
count: { description: 'Number of events currently selected in the group.' },
total: { description: 'Total number of events the group offers.' },
allSelected: { description: 'Checks the master checkbox when every event is selected.' },
indeterminate: { description: 'Shows the dash state when only some events are selected.' },
onToggleAll: { description: 'Called when the master checkbox is clicked to select or clear all events.' },
},
} satisfies Meta<typeof NotificationHeader>;
export default meta;
type Story = StoryObj<typeof meta>;
export const NoneSelected: Story = {
args: {
count: 0,
total: 6,
allSelected: false,
indeterminate: false,
onToggleAll: () => undefined,
},
};
export const PartialSelection: Story = {
args: {
count: 3,
total: 6,
allSelected: false,
indeterminate: true,
onToggleAll: () => undefined,
},
};
export const AllSelected: Story = {
args: {
count: 6,
total: 6,
allSelected: true,
indeterminate: false,
onToggleAll: () => undefined,
},
};
const events = ['Panel login', 'Xray crashed', 'CPU high', 'Client depleted'];
function GroupDemo() {
const [selected, setSelected] = useState<string[]>(['Panel login', 'CPU high']);
const count = selected.length;
const total = events.length;
function toggleAll() {
setSelected(count === total ? [] : [...events]);
}
function toggle(name: string) {
setSelected((prev) => (prev.includes(name) ? prev.filter((e) => e !== name) : [...prev, name]));
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 260 }}>
<NotificationHeader
count={count}
total={total}
allSelected={count === total}
indeterminate={count > 0 && count < total}
onToggleAll={toggleAll}
/>
{events.map((name) => (
<Checkbox key={name} checked={selected.includes(name)} onChange={() => toggle(name)}>
{name}
</Checkbox>
))}
</div>
);
}
const placeholderArgs = {
count: 0,
total: 0,
allSelected: false,
indeterminate: false,
onToggleAll: () => undefined,
};
export const Interactive: Story = {
args: placeholderArgs,
render: () => <GroupDemo />,
};
@@ -0,0 +1,146 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { InputNumber, Space } from 'antd';
import {
CloudServerOutlined,
DashboardOutlined,
DesktopOutlined,
SafetyOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { NotificationLayout } from './NotificationLayout';
import { NotificationCard } from './NotificationCard';
import { NotificationEvent } from './NotificationEvent';
import { NotificationHeader } from './NotificationHeader';
const noop = () => undefined;
function OutboundGroup() {
return (
<NotificationCard
icon={<CloudServerOutlined />}
title="Outbound"
extra={<NotificationHeader count={1} total={2} allSelected={false} indeterminate onToggleAll={noop} />}
>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
<NotificationEvent label="Outbound went down" checked onToggle={noop} />
<NotificationEvent label="Outbound recovered" checked={false} onToggle={noop} />
</Space>
</NotificationCard>
);
}
function XrayGroup() {
return (
<NotificationCard
icon={<ThunderboltOutlined />}
title="Xray"
extra={<NotificationHeader count={1} total={1} allSelected indeterminate={false} onToggleAll={noop} />}
>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
<NotificationEvent label="Xray crashed" checked onToggle={noop} />
</Space>
</NotificationCard>
);
}
function NodeGroup() {
return (
<NotificationCard
icon={<DesktopOutlined />}
title="Nodes"
extra={<NotificationHeader count={0} total={2} allSelected={false} indeterminate={false} onToggleAll={noop} />}
>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
<NotificationEvent label="Node went offline" checked={false} onToggle={noop} />
<NotificationEvent label="Node back online" checked={false} onToggle={noop} />
</Space>
</NotificationCard>
);
}
function SystemGroup() {
return (
<NotificationCard
icon={<DashboardOutlined />}
title="System"
extra={<NotificationHeader count={2} total={2} allSelected indeterminate={false} onToggleAll={noop} />}
>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
<NotificationEvent label="CPU usage above threshold (%)" checked onToggle={noop}>
<InputNumber size="small" min={0} max={100} defaultValue={80} aria-label="CPU usage threshold percent" style={{ width: 80 }} />
</NotificationEvent>
<NotificationEvent label="Memory usage above threshold (%)" checked onToggle={noop}>
<InputNumber size="small" min={0} max={100} defaultValue={90} aria-label="Memory usage threshold percent" style={{ width: 80 }} />
</NotificationEvent>
</Space>
</NotificationCard>
);
}
function SecurityGroup() {
return (
<NotificationCard
icon={<SafetyOutlined />}
title="Security"
extra={<NotificationHeader count={1} total={1} allSelected indeterminate={false} onToggleAll={noop} />}
>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
<NotificationEvent label="Panel login attempt" checked onToggle={noop} />
</Space>
</NotificationCard>
);
}
const meta = {
title: 'UI/Notifications/NotificationLayout',
component: NotificationLayout,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Responsive auto-fit grid (min 260px columns) that arranges notification event-group cards; the Telegram and email notification tabs on the settings page render their groups inside it.',
},
},
},
argTypes: {
children: { description: 'Grid items, typically one NotificationCard per event group.' },
},
} satisfies Meta<typeof NotificationLayout>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AllEventGroups: Story = {
args: {
children: (
<>
<OutboundGroup />
<XrayGroup />
<NodeGroup />
<SystemGroup />
<SecurityGroup />
</>
),
},
};
export const TwoGroups: Story = {
args: {
children: (
<>
<SystemGroup />
<SecurityGroup />
</>
),
},
};
export const SingleGroup: Story = {
args: {
children: <OutboundGroup />,
},
};
@@ -0,0 +1,82 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { AllSetting } from '@/models/setting';
import { TelegramNotifications } from './TelegramNotifications';
const meta = {
title: 'UI/Notifications/TelegramNotifications',
component: TelegramNotifications,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Grid of event-group cards (outbound, Xray, node, system, security) that pick which panel events the Telegram bot reports, with per-group select-all and CPU/RAM threshold inputs. Used on the settings page Telegram tab to edit `tgEnabledEvents`.',
},
},
},
argTypes: {
allSetting: { description: 'Panel settings snapshot; reads `tgEnabledEvents` plus the `tgCpu`/`tgMemory` thresholds.' },
updateSetting: { description: 'Called with a partial settings patch when an event toggle or threshold changes.' },
},
} satisfies Meta<typeof TelegramNotifications>;
export default meta;
type Story = StoryObj<typeof meta>;
function Demo({ initial }: { initial: AllSetting }) {
const [settings, setSettings] = useState(initial);
return (
<TelegramNotifications
allSetting={settings}
updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
/>
);
}
const placeholderArgs = {
allSetting: new AllSetting(),
updateSetting: () => undefined,
};
export const NothingSelected: Story = {
args: placeholderArgs,
render: () => <Demo initial={new AllSetting()} />,
};
export const TypicalMonitoring: Story = {
args: placeholderArgs,
render: () => (
<Demo
initial={
new AllSetting({
tgBotEnable: true,
tgBotChatId: '123456789',
tgEnabledEvents: 'xray.crash,node.down,cpu.high,memory.high,login.attempt',
tgCpu: 85,
tgMemory: 90,
})
}
/>
),
};
export const EverythingEnabled: Story = {
args: placeholderArgs,
render: () => (
<Demo
initial={
new AllSetting({
tgBotEnable: true,
tgEnabledEvents:
'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
tgCpu: 70,
tgMemory: 75,
})
}
/>
),
};