feat: 优化整体兜底个性化规则管理交互
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-06 22:16:09 +08:00
parent 0e3424c4a7
commit e4f93f7193
16 changed files with 1580 additions and 4 deletions
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
import { MonitorRuleManager } from './MonitorRuleManager';
import {
monitorApi,
names,
@@ -26,7 +27,7 @@ export function MonitorScopePicker({ scope, onChange }: { scope: Scope; onChange
return;
}
monitorApi
.options(kind, scope, keyword, page)
.options(kind, { tenantId: scope.tenantId, applicationId: scope.applicationId }, keyword, page)
.then((items) => {
if (live) {
setOptions(items);
@@ -109,6 +110,14 @@ export function MonitorScopePicker({ scope, onChange }: { scope: Scope; onChange
}
export function MonitorRulesModal({ type, onClose }: { type: MonitorType; onClose: () => void }) {
return type === 'overall' ? (
<MonitorRuleManager onClose={onClose} />
) : (
<CommonMonitorRulesModal type={type} onClose={onClose} />
);
}
function CommonMonitorRulesModal({ type, onClose }: { type: MonitorType; onClose: () => void }) {
const dirtyRef = useRef(false);
const [rules, setRules] = useState<Rule[]>([]),
[error, setError] = useState(''),
@@ -0,0 +1,176 @@
import { useEffect, useId, useRef, useState } from 'react';
import { Button, Input } from '@/components/ui';
import { ruleManagementApi, type RuleOption } from './monitorApi';
export function MonitorObjectSelect({
label,
kind,
value,
tenantId,
applicationId,
onChange,
disabled = false,
}: {
label: string;
kind: 'tenant' | 'application' | 'signature';
value?: RuleOption;
tenantId?: string;
applicationId?: string;
disabled?: boolean;
onChange: (value: RuleOption) => void;
}) {
const id = useId(),
root = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false),
[keyword, setKeyword] = useState(''),
[page, setPage] = useState(1);
const [items, setItems] = useState<RuleOption[]>([]),
[more, setMore] = useState(false),
[loading, setLoading] = useState(false);
const [error, setError] = useState(''),
[retry, setRetry] = useState(0);
useEffect(() => {
if (!open) return;
const controller = new AbortController();
const timer = setTimeout(() => {
setLoading(true);
setItems([]);
setError('');
void ruleManagementApi
.options({ kind, keyword, page, tenantId, applicationId }, controller.signal)
.then((result) => {
if (!controller.signal.aborted) {
setItems(result.items);
setMore(result.hasMore);
}
})
.catch((e: Error) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
}, 250);
return () => {
controller.abort();
clearTimeout(timer);
};
}, [open, kind, keyword, page, tenantId, applicationId, retry]);
useEffect(() => {
const outside = (e: PointerEvent) => {
if (!root.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('pointerdown', outside);
return () => document.removeEventListener('pointerdown', outside);
}, []);
return (
<div
className="monitor-rules__picker"
ref={root}
onKeyDown={(e) => {
if (e.key === 'Escape' && open) {
e.stopPropagation();
setOpen(false);
root.current?.querySelector<HTMLButtonElement>('[aria-haspopup]')?.focus();
}
if (['ArrowDown', 'ArrowUp'].includes(e.key) && open) {
e.preventDefault();
const options = [...(root.current?.querySelectorAll<HTMLButtonElement>('[role="option"]') ?? [])];
const index = options.indexOf(document.activeElement as HTMLButtonElement);
options[(index + (e.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length]?.focus();
}
}}
>
<span id={id}>{label}</span>
<button
type="button"
className="monitor-rules__select"
aria-labelledby={id}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={() => {
setLoading(true);
setOpen(!open);
setKeyword('');
setPage(1);
}}
>
{value?.name ?? `请选择${label}`}
<span aria-hidden></span>
</button>
{open && (
<div className="monitor-rules__dropdown">
<Input
autoFocus
aria-label={`搜索${label}`}
placeholder={`搜索${label}名称`}
value={keyword}
onChange={(e) => {
setLoading(true);
setKeyword(e.target.value);
setPage(1);
}}
/>
{loading ? (
<p role="status"></p>
) : error ? (
<div role="alert">
{error}
<Button type="button" size="sm" onClick={() => setRetry(retry + 1)}>
</Button>
</div>
) : (
<>
<div role="listbox" aria-label={`${label}搜索结果`} className="monitor-rules__options">
{items.map((item) => (
<button
key={item.id}
type="button"
role="option"
aria-selected={item.id === value?.id}
onClick={() => {
onChange(item);
setOpen(false);
}}
>
{item.name}
</button>
))}
</div>
{!items.length && <p>{label}</p>}
<div className="monitor-rules__actions">
<Button
type="button"
size="sm"
variant="ghost"
disabled={page === 1}
onClick={() => {
setLoading(true);
setPage(page - 1);
}}
>
</Button>
<span> {page} </span>
<Button
type="button"
size="sm"
variant="ghost"
disabled={!more}
onClick={() => {
setLoading(true);
setPage(page + 1);
}}
>
</Button>
</div>
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,313 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Modal, Select, Tag } from '@/components/ui';
import { MonitorObjectSelect } from './MonitorObjectSelect';
import { MonitorRuleForm } from './MonitorRuleForm';
import { draftErrors, ruleDraft } from './monitorRuleDraft';
import {
monitorApi,
ruleManagementApi,
ruleSource,
scopeTitle,
time,
type ManagedRule,
type RuleContext,
type RuleOption,
type Scope,
} from './monitorApi';
export function MonitorRuleEditor({
initial,
common = false,
onClose,
onBack,
onSaved,
}: {
initial?: ManagedRule;
common?: boolean;
onClose: () => void;
onBack: () => void;
onSaved: (rule: ManagedRule) => void;
}) {
const [scope, setScope] = useState<Scope>(initial?.scope ?? {});
const [kind, setKind] = useState(
initial?.scope.signatureId ? (initial.scope.applicationId ? 'combined' : 'signature') : 'application',
);
const [tenant, setTenant] = useState<RuleOption>(),
[application, setApplication] = useState<RuleOption>(),
[signature, setSignature] = useState<RuleOption>();
const [editing, setEditing] = useState(Boolean(initial));
const [context, setContext] = useState<RuleContext>(),
[draft, setDraft] = useState(ruleDraft());
const [error, setError] = useState(''),
[errors, setErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false),
[loading, setLoading] = useState(false),
[dirty, setDirty] = useState(false),
[retry, setRetry] = useState(0);
const saving = useRef(false);
const complete =
common ||
Boolean(
scope.tenantId &&
(kind === 'application'
? scope.applicationId
: kind === 'signature'
? scope.signatureId
: scope.applicationId && scope.signatureId),
);
const scopeKey = JSON.stringify(scope);
useEffect(() => {
setContext(undefined);
setError('');
setErrors({});
if (!complete) {
setLoading(false);
setDraft(ruleDraft());
return;
}
const controller = new AbortController();
setLoading(true);
void ruleManagementApi
.editor(JSON.parse(scopeKey) as Scope, controller.signal)
.then((result) => {
if (controller.signal.aborted) return;
setContext(result);
setDraft(
ruleDraft(
result.current && !result.current.config.deleted ? result.current.config : result.inherited[0]?.config,
),
);
setDirty(false);
})
.catch((e: Error) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [scopeKey, complete, retry]);
useEffect(() => {
const before = (event: BeforeUnloadEvent) => {
if (dirty) event.preventDefault();
};
window.addEventListener('beforeunload', before);
return () => window.removeEventListener('beforeunload', before);
}, [dirty]);
const leave = (action: () => void) => {
if (!saving.current && (!dirty || window.confirm('有未保存的规则,确认放弃修改?'))) action();
};
const changeScope = (next: Scope, action: () => void) =>
leave(() => {
setScope(next);
setDirty(false);
action();
});
const duplicate = !common && !editing && context?.current && !context.current.config.deleted;
const selected = initial ?? context?.current;
const invalidObject = Boolean(
selected &&
Object.entries(selected.scope).some(([key]) => {
const prefix = key.replace('Id', '');
return !selected.names?.[`${prefix}Name`] || selected.names?.[`${prefix}Status`] === 'deleted';
}),
);
async function save() {
if (!context || !complete || duplicate || invalidObject || saving.current) return;
const nextErrors = draftErrors(draft);
setErrors(nextErrors);
if (Object.keys(nextErrors).length) return;
saving.current = true;
setBusy(true);
setError('');
try {
const result = (await monitorApi.saveRule({
type: 'overall',
scope,
version: context.current?.version ?? 0,
config: {
enabled: draft.enabled,
minSamples: Number(draft.min),
thresholds: draft.thresholds.map((n) => (n.trim() ? Number(n) : null)),
consecutiveBad: Number(draft.bad),
consecutiveGood: Number(draft.good),
deleted: false,
},
})) as ManagedRule[];
setDirty(false);
onSaved(result[0]);
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败,请重试');
} finally {
saving.current = false;
setBusy(false);
}
}
return (
<Modal
open
size="xl"
title={common ? '整体兜底 · 阈值设置' : editing ? '编辑个性化规则' : '新增个性化规则'}
onClose={() => leave(onClose)}
footer={
<>
<Button variant="ghost" disabled={busy} onClick={() => leave(common ? onClose : onBack)}>
{common ? '关闭' : '返回列表'}
</Button>
<Button
disabled={busy || loading || !context || !complete || Boolean(duplicate) || invalidObject}
onClick={() => void save()}
>
{busy ? '保存中…' : '保存规则'}
</Button>
</>
}
>
<div className="monitor-rules">
{common && (
<div role="tablist" aria-label="规则类别" className="monitor-rules__tabs">
<button role="tab" aria-selected type="button">
</button>
<button role="tab" aria-selected={false} type="button" onClick={() => leave(onBack)}>
</button>
</div>
)}
<section className="monitor-rules__section">
<h3></h3>
{common ? (
<p></p>
) : editing ? (
<p>
{selected ? scopeTitle(selected) : '读取范围中…'} <Tag tone="info">{ruleSource(selected ?? null)}</Tag>
</p>
) : (
<>
<Select
label="覆盖类型"
value={kind}
options={[
{ value: 'application', label: '应用覆盖' },
{ value: 'signature', label: '签名覆盖' },
{ value: 'combined', label: '应用+签名覆盖' },
]}
onChange={(e) =>
changeScope({}, () => {
setKind(e.target.value);
setTenant(undefined);
setApplication(undefined);
setSignature(undefined);
})
}
/>
<div className="monitor-rules__grid">
{kind === 'signature' ? (
<MonitorObjectSelect
label="企业"
kind="tenant"
value={tenant}
onChange={(option) =>
changeScope({ tenantId: option.id }, () => {
setTenant(option);
setSignature(undefined);
})
}
/>
) : (
<MonitorObjectSelect
label="企业应用"
kind="application"
value={application}
onChange={(option) =>
changeScope({ tenantId: option.tenantId, applicationId: option.id }, () => {
setApplication(option);
setSignature(undefined);
})
}
/>
)}
{kind !== 'application' && (
<MonitorObjectSelect
label="签名"
kind="signature"
value={signature}
tenantId={scope.tenantId}
applicationId={scope.applicationId}
disabled={!scope.tenantId}
onChange={(option) => changeScope({ ...scope, signatureId: option.id }, () => setSignature(option))}
/>
)}
</div>
</>
)}
{!common && (
<p className="monitor-rules__hint">
</p>
)}
</section>
{duplicate && (
<div className="monitor-rules__notice" role="status">
<Button variant="ghost" onClick={() => setEditing(true)}>
</Button>
</div>
)}
{invalidObject && (
<p role="alert"></p>
)}
{loading && <p role="status"></p>}
{error && (
<div className="monitor-rules__error" role="alert">
{error}
<Button variant="ghost" onClick={() => leave(() => setRetry(retry + 1))}>
</Button>
</div>
)}
{context && (
<div className="monitor-rules__notice">
<p>
{context.matched[0]
? `${ruleSource(context.matched[0])} · ${scopeTitle(context.matched[0])} · v${context.matched[0].version} · ${context.matched[0].config.enabled ? '告警启用' : '告警停用'}`
: '尚未配置,不告警'}
</p>
{context.current && new Date(context.current.effectiveAt) > new Date(context.serverTime) && (
<p>
v{context.current.version} ·{' '}
{context.current.config.deleted ? '恢复继承' : context.current.config.enabled ? '告警启用' : '告警停用'}{' '}
· {time(context.current.effectiveAt)}
</p>
)}
{(!context.current || context.current.config.deleted) && (
<p>
{context.inherited[0]
? `${ruleSource(context.inherited[0])} · ${scopeTitle(context.inherited[0])}`
: '无可继承配置,阈值留空'}
</p>
)}
{context.matched.length > 1 && (
<p>{context.matched.map((r) => `${ruleSource(r)} v${r.version}`).join(' → ')}</p>
)}
</div>
)}
<MonitorRuleForm
draft={draft}
errors={errors}
disabled={!context || loading || busy || Boolean(duplicate) || invalidObject}
onChange={(next) => {
setDraft(next);
setDirty(true);
setErrors({});
}}
/>
<p className="monitor-rules__hint">10使</p>
</div>
</Modal>
);
}
@@ -0,0 +1,76 @@
import { Input } from '@/components/ui';
import type { RuleDraft } from './monitorRuleDraft';
export function MonitorRuleForm({
draft,
onChange,
errors,
disabled,
}: {
draft: RuleDraft;
onChange: (draft: RuleDraft) => void;
errors: Record<string, string>;
disabled: boolean;
}) {
return (
<fieldset className="monitor-rules__fields" disabled={disabled}>
<legend></legend>
<div className="monitor-rules__grid">
<Input
label="最低成熟样本量"
type="number"
min={1}
max={100000000}
value={draft.min}
error={errors.min}
onChange={(e) => onChange({ ...draft, min: e.target.value })}
/>
{['1分钟', '5分钟', '20分钟'].map((name, i) => (
<Input
key={name}
label={`${name}到达率下限(%`}
type="number"
min={0}
max={100}
step="0.01"
value={draft.thresholds[i]}
error={errors[`threshold${i}`]}
onChange={(e) =>
onChange({ ...draft, thresholds: draft.thresholds.map((n, j) => (j === i ? e.target.value : n)) })
}
/>
))}
</div>
<p className="monitor-rules__hint">0%</p>
<h3></h3>
<label className="monitor-rules__check">
<input
type="checkbox"
checked={draft.enabled}
onChange={(e) => onChange({ ...draft, enabled: e.target.checked })}
/>
</label>
<div className="monitor-rules__grid">
<Input
label="连续异常次数"
type="number"
min={1}
max={5}
value={draft.bad}
error={errors.bad}
onChange={(e) => onChange({ ...draft, bad: e.target.value })}
/>
<Input
label="连续恢复次数"
type="number"
min={1}
max={5}
value={draft.good}
error={errors.good}
onChange={(e) => onChange({ ...draft, good: e.target.value })}
/>
</div>
<p className="monitor-rules__hint"></p>
</fieldset>
);
}
@@ -0,0 +1,159 @@
.monitor-rules {
display: grid;
gap: 18px;
color: var(--color-text);
}
.monitor-rules .monitor-rules__tabs,
.monitor-rules .monitor-rules__actions,
.monitor-rules .monitor-rules__toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.monitor-rules .monitor-rules__tabs {
border-bottom: 1px solid #e5e7eb;
}
.monitor-rules .monitor-rules__tabs > button {
padding: 10px 16px;
border: 0;
background: transparent;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.monitor-rules .monitor-rules__tabs > button[aria-selected='true'] {
color: #2563eb;
border-bottom-color: #2563eb;
}
.monitor-rules .monitor-rules__toolbar > .ui-field {
flex: 1;
min-width: 180px;
}
.monitor-rules .monitor-rules__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.monitor-rules .monitor-rules__section,
.monitor-rules .monitor-rules__fields {
display: grid;
gap: 14px;
padding: 0;
margin: 0;
min-width: 0;
border: 0;
}
.monitor-rules .monitor-rules__fields > legend,
.monitor-rules .monitor-rules__section > h3,
.monitor-rules .monitor-rules__fields > h3 {
font-size: 16px;
font-weight: 600;
margin: 0;
padding-bottom: 8px;
}
.monitor-rules .monitor-rules__notice {
padding: 12px 14px;
border: 1px solid #dbeafe;
border-radius: 8px;
background: #eff6ff;
overflow-wrap: anywhere;
}
.monitor-rules .monitor-rules__hint {
color: #6b7280;
font-size: 13px;
margin: 0;
}
.monitor-rules .monitor-rules__error {
color: #dc2626;
}
.monitor-rules .monitor-rules__check {
display: flex;
gap: 8px;
align-items: center;
}
.monitor-rules .monitor-rules__picker {
position: relative;
display: grid;
gap: 8px;
min-width: 0;
}
.monitor-rules .monitor-rules__select {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
padding: 10px 12px;
min-height: 40px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
text-align: left;
overflow-wrap: anywhere;
cursor: pointer;
}
.monitor-rules .monitor-rules__select:disabled {
opacity: 0.5;
cursor: default;
}
.monitor-rules .monitor-rules__dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 5;
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
}
.monitor-rules .monitor-rules__options {
max-height: 220px;
overflow: auto;
display: grid;
}
.monitor-rules .monitor-rules__options > button {
border: 0;
padding: 10px;
background: #fff;
text-align: left;
overflow-wrap: anywhere;
cursor: pointer;
}
.monitor-rules .monitor-rules__options > button:hover,
.monitor-rules .monitor-rules__options > button:focus-visible,
.monitor-rules .monitor-rules__options > button[aria-selected='true'] {
background: #eff6ff;
color: #2563eb;
}
@media (width <= 640px) {
.monitor-rules .monitor-rules__grid {
grid-template-columns: minmax(0, 1fr);
}
.monitor-rules .monitor-rules__toolbar {
align-items: stretch;
flex-direction: column;
}
}
@@ -0,0 +1,105 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, it, vi } from 'vitest';
import { MonitorRuleManager } from './MonitorRuleManager';
import { MonitorRuleEditor } from './MonitorRuleEditor';
const api = vi.hoisted(() => ({
list: vi.fn(),
options: vi.fn(),
editor: vi.fn(),
restore: vi.fn(),
saveRule: vi.fn(),
}));
vi.mock('./monitorApi', async (original) => ({
...(await original<object>()),
ruleManagementApi: api,
monitorApi: { saveRule: api.saveRule },
}));
const config = { enabled: true, minSamples: 100, thresholds: [90, 95, 98], consecutiveBad: 1, consecutiveGood: 2 };
const rule = {
id: 'r1',
type: 'overall' as const,
scope: { tenantId: 't1', applicationId: 'a1' },
config,
version: 2,
effectiveAt: '2026-09-06T00:10:00Z',
names: { tenantName: '企业甲', applicationName: '应用甲', tenantStatus: 'active', applicationStatus: 'active' },
};
const empty = { current: null, matched: [], inherited: [], serverTime: '2026-09-06T00:00:00Z' };
beforeEach(() => {
vi.clearAllMocks();
api.editor.mockResolvedValue(empty);
api.list.mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 20, serverTime: empty.serverTime });
api.options.mockResolvedValue({
items: [{ id: 'a1', tenantId: 't1', name: '企业甲 · 应用甲' }],
hasMore: false,
page: 1,
});
});
it('keeps unconfigured common values blank and validates inline before writing', async () => {
const user = userEvent.setup();
render(<MonitorRuleManager onClose={vi.fn()} />);
await waitFor(() => expect(screen.getByRole('button', { name: '保存规则' })).toBeEnabled());
expect(screen.getByLabelText('1分钟到达率下限(%')).toHaveValue(null);
await user.click(screen.getByRole('button', { name: '保存规则' }));
expect(screen.getByText('请至少设置一项到达率下限')).toBeVisible();
expect(api.saveRule).not.toHaveBeenCalled();
});
it('shows named rules and pending status separately from current status', async () => {
api.list.mockResolvedValue({
items: [{ ...rule, config: { ...config, enabled: false }, active: { ...rule, version: 1 } }],
total: 1,
page: 1,
pageSize: 20,
serverTime: empty.serverTime,
});
const user = userEvent.setup();
render(<MonitorRuleManager onClose={vi.fn()} />);
await user.click(screen.getByRole('tab', { name: '个性化规则' }));
expect(await screen.findByText('企业甲 · 应用甲')).toBeVisible();
expect(screen.getByText('告警启用')).toBeVisible();
expect(screen.getByText('待生效 v2 · 停用')).toBeVisible();
});
it('detects an existing scope, keeps its name visible, and requires explicit editing', async () => {
api.editor.mockResolvedValue({ ...empty, current: rule });
const user = userEvent.setup();
render(<MonitorRuleEditor onClose={vi.fn()} onBack={vi.fn()} onSaved={vi.fn()} />);
await user.click(screen.getByRole('button', { name: '企业应用' }));
await user.click(await screen.findByRole('option', { name: '企业甲 · 应用甲' }));
expect(await screen.findByRole('button', { name: '编辑已有规则' })).toBeVisible();
expect(screen.getByRole('button', { name: '企业应用' })).toHaveTextContent('企业甲 · 应用甲');
expect(screen.getByRole('button', { name: '保存规则' })).toBeDisabled();
await user.click(screen.getByRole('button', { name: '编辑已有规则' }));
expect(screen.queryByRole('button', { name: '企业应用' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '保存规则' })).toBeEnabled();
});
it('preserves draft on conflict and does not leave without confirmation', async () => {
api.editor.mockResolvedValue({ ...empty, current: rule });
api.saveRule.mockRejectedValue(new Error('规则已被修改,请刷新后重试'));
const back = vi.fn(),
user = userEvent.setup();
render(<MonitorRuleEditor initial={rule} onClose={vi.fn()} onBack={back} onSaved={vi.fn()} />);
await waitFor(() => expect(screen.getByLabelText('最低成熟样本量')).toHaveValue(100));
await user.clear(screen.getByLabelText('最低成熟样本量'));
await user.type(screen.getByLabelText('最低成熟样本量'), '250');
await user.click(screen.getByRole('button', { name: '保存规则' }));
expect(await screen.findByRole('alert')).toHaveTextContent('规则已被修改');
expect(screen.getByLabelText('最低成熟样本量')).toHaveValue(250);
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
await user.click(screen.getByRole('button', { name: '返回列表' }));
expect(back).not.toHaveBeenCalled();
confirm.mockRestore();
});
it('restores using the saved rule and displays the no-inheritance outcome', async () => {
api.list.mockResolvedValue({ items: [rule], total: 1, page: 1, pageSize: 20, serverTime: empty.serverTime });
api.editor.mockResolvedValue({ ...empty, current: rule });
api.restore.mockResolvedValue([{ ...rule, version: 3 }]);
const user = userEvent.setup();
render(<MonitorRuleManager onClose={vi.fn()} />);
await user.click(screen.getByRole('tab', { name: '个性化规则' }));
await user.click(await screen.findByRole('button', { name: '恢复继承' }));
expect(await screen.findByText(/没有可用规则,恢复后不告警/)).toBeVisible();
await user.click(screen.getByRole('button', { name: '确认恢复继承' }));
expect(api.restore).toHaveBeenCalledWith(rule);
expect(api.saveRule).not.toHaveBeenCalled();
});
@@ -0,0 +1,316 @@
import { useEffect, useState } from 'react';
import { Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { MonitorRuleEditor } from './MonitorRuleEditor';
import { ruleManagementApi, ruleSource, scopeTitle, time, type ManagedRule, type RuleContext } from './monitorApi';
import './MonitorRuleManager.css';
function RestoreRule({
rule,
onBack,
onSaved,
}: {
rule: ManagedRule;
onBack: () => void;
onSaved: (rule: ManagedRule) => void;
}) {
const [context, setContext] = useState<RuleContext>(),
[error, setError] = useState(''),
[busy, setBusy] = useState(false),
[retry, setRetry] = useState(0);
useEffect(() => {
const abort = new AbortController();
setContext(undefined);
setError('');
void ruleManagementApi
.editor(rule.scope, abort.signal)
.then((value) => {
if (!abort.signal.aborted) setContext(value);
})
.catch((e: Error) => {
if (!abort.signal.aborted) setError(e.message);
});
return () => abort.abort();
}, [rule, retry]);
async function restore() {
if (!context?.current || busy) return;
setBusy(true);
setError('');
try {
const rows = await ruleManagementApi.restore(context.current);
onSaved(rows[0] as ManagedRule);
} catch (e) {
setError(e instanceof Error ? e.message : '恢复继承失败');
} finally {
setBusy(false);
}
}
return (
<Modal
open
title="恢复继承"
onClose={() => {
if (!busy) onBack();
}}
footer={
<>
<Button variant="ghost" disabled={busy} onClick={onBack}>
</Button>
<Button disabled={busy || !context} onClick={() => void restore()}>
{busy ? '处理中…' : '确认恢复继承'}
</Button>
</>
}
>
<div className="monitor-rules">
<p>
{scopeTitle(rule)}{ruleSource(rule)}
</p>
{!context && !error && <p role="status"></p>}
{context && (
<>
<p>
{context.inherited.length
? context.inherited
.map((r) => `${ruleSource(r)} · ${scopeTitle(r)}${r.config.enabled ? '启用' : '停用'}`)
.join(' → ')
: '没有可用规则,恢复后不告警'}
</p>
<p> </p>
</>
)}
{error && (
<div role="alert">
{error}
<Button variant="ghost" onClick={() => setRetry(retry + 1)}>
</Button>
</div>
)}
</div>
</Modal>
);
}
export function MonitorRuleManager({ onClose }: { onClose: () => void }) {
const [tab, setTab] = useState<'common' | 'custom'>('common');
const [editor, setEditor] = useState<ManagedRule | 'new' | null>(null),
[restore, setRestore] = useState<ManagedRule | null>(null);
const [keyword, setKeyword] = useState(''),
[kind, setKind] = useState(''),
[page, setPage] = useState(1),
[refresh, setRefresh] = useState(0);
const [items, setItems] = useState<ManagedRule[]>([]),
[total, setTotal] = useState(0),
[serverTime, setServerTime] = useState('');
const [loading, setLoading] = useState(false),
[error, setError] = useState(''),
[notice, setNotice] = useState('');
useEffect(() => {
if (tab !== 'custom' || editor || restore) return;
const abort = new AbortController();
const timer = setTimeout(() => {
setLoading(true);
setError('');
void ruleManagementApi
.list({ keyword, kind, page }, abort.signal)
.then((result) => {
if (abort.signal.aborted) return;
if (page > 1 && !result.items.length) {
setPage(Math.max(1, Math.ceil(result.total / 20)));
return;
}
setItems(result.items);
setTotal(result.total);
setServerTime(result.serverTime);
})
.catch((e: Error) => {
if (!abort.signal.aborted) setError(e.message);
})
.finally(() => {
if (!abort.signal.aborted) setLoading(false);
});
}, 250);
return () => {
abort.abort();
clearTimeout(timer);
};
}, [tab, editor, restore, keyword, kind, page, refresh]);
function saved(rule: ManagedRule) {
setNotice(`已保存 v${rule.version}${time(rule.effectiveAt)} 生效`);
setEditor(null);
setRestore(null);
setRefresh((n) => n + 1);
}
if (restore) return <RestoreRule rule={restore} onBack={() => setRestore(null)} onSaved={saved} />;
if (tab === 'common' || editor)
return (
<MonitorRuleEditor
key={tab === 'common' ? `common-${refresh}` : editor === 'new' ? 'new' : editor?.id}
common={tab === 'common'}
initial={editor && editor !== 'new' ? editor : undefined}
onClose={onClose}
onBack={() => {
setTab('custom');
setEditor(null);
}}
onSaved={(rule) => {
saved(rule);
setTab('custom');
}}
/>
);
const columns: TableColumn<ManagedRule>[] = [
{
key: 'scope',
title: '适用范围',
width: '260px',
render: (r) => (
<div className="ui-table__long-text">
<strong>{scopeTitle(r)}</strong>
<p>{ruleSource(r)}</p>
</div>
),
},
{
key: 'thresholds',
title: '阈值摘要',
width: '210px',
render: (r) => (
<div>
<p>{r.config.minSamples} </p>
<p>
{r.config.thresholds
.map((n, i) => `${['1分', '5分', '20分'][i]} ${n === null ? '未启用' : `${n}%`}`)
.join(' / ')}
</p>
</div>
),
},
{
key: 'status',
title: '状态与生效时间',
width: '220px',
render: (r) => {
const pending = new Date(r.effectiveAt) > new Date(serverTime),
active = pending ? r.active : r;
return (
<>
<Tag tone={active && !active.config.deleted && active.config.enabled ? 'success' : 'neutral'}>
{!active || active.config.deleted ? '未生效覆盖' : active.config.enabled ? '告警启用' : '告警停用'}
</Tag>
<p>
{pending
? `待生效 v${r.version} · ${r.config.deleted ? '恢复继承' : r.config.enabled ? '启用' : '停用'}`
: `v${r.version}`}
</p>
<p>{time(r.effectiveAt)}</p>
</>
);
},
},
{
key: 'actions',
title: '操作',
width: '165px',
render: (r) => (
<div className="monitor-rules__actions">
<Button size="sm" variant="ghost" onClick={() => setEditor(r)}>
</Button>
<Button size="sm" variant="ghost" disabled={Boolean(r.config.deleted)} onClick={() => setRestore(r)}>
</Button>
</div>
),
},
];
return (
<Modal
open
size="xl"
title="整体兜底 · 阈值设置"
onClose={onClose}
footer={
<Button variant="ghost" onClick={onClose}>
</Button>
}
>
<div className="monitor-rules">
<div role="tablist" aria-label="规则类别" className="monitor-rules__tabs">
<button role="tab" aria-selected={false} type="button" onClick={() => setTab('common')}>
</button>
<button role="tab" aria-selected type="button">
</button>
</div>
{notice && (
<p role="status" className="monitor-rules__notice">
{notice}
</p>
)}
<div className="monitor-rules__toolbar">
<Input
aria-label="搜索规则"
placeholder="搜索企业、应用或签名"
value={keyword}
onChange={(e) => {
setKeyword(e.target.value);
setPage(1);
}}
/>
<Select
aria-label="筛选覆盖类型"
value={kind}
options={[
{ value: '', label: '全部覆盖类型' },
{ value: 'application', label: '应用覆盖' },
{ value: 'signature', label: '签名覆盖' },
{ value: 'combined', label: '应用+签名覆盖' },
]}
onChange={(e) => {
setKind(e.target.value);
setPage(1);
}}
/>
<Button onClick={() => setEditor('new')}></Button>
</div>
<p className="monitor-rules__hint">
</p>
{error ? (
<div role="alert">
{error}
<Button variant="ghost" onClick={() => setRefresh(refresh + 1)}>
</Button>
</div>
) : loading ? (
<p role="status"></p>
) : (
<Table
columns={columns}
data={items}
rowKey="id"
pagination={false}
emptyText="暂无个性化规则,可新增覆盖;未覆盖范围沿用通用规则。"
/>
)}
<Pagination
page={page}
total={total}
totalPages={Math.max(1, Math.ceil(total / 20))}
previousDisabled={loading || page === 1}
nextDisabled={loading || page * 20 >= total}
onPrevious={() => setPage(page - 1)}
onNext={() => setPage(page + 1)}
onPageChange={setPage}
/>
</div>
</Modal>
);
}
@@ -26,6 +26,32 @@ export type Rule = {
version: number;
effectiveAt: string;
};
export type ManagedRule = Rule & { names: Record<string, string | null>; active?: Rule | null };
export type RuleOption = { id: string; name: string; tenantId: string };
export type RuleContext = {
current: ManagedRule | null;
matched: ManagedRule[];
inherited: ManagedRule[];
serverTime: string;
};
export const ruleManagementApi = {
list: (query: Record<string, string | number | undefined>, signal?: AbortSignal) =>
request<Page<ManagedRule> & { serverTime: string }>(withQuery('/admin/sending-monitor/rule-management', query), {
signal,
}),
options: (query: Record<string, string | number | undefined>, signal?: AbortSignal) =>
request<{ items: RuleOption[]; page: number; hasMore: boolean }>(
withQuery('/admin/sending-monitor/rule-options', query),
{ signal },
),
editor: (scope: Scope, signal?: AbortSignal) =>
request<RuleContext>(withQuery('/admin/sending-monitor/rule-editor', scope), { signal }),
restore: (rule: Rule) =>
request<Rule[]>(`/admin/sending-monitor/rules/${rule.id}/restore`, {
method: 'POST',
body: JSON.stringify({ version: rule.version }),
}),
};
export type Metric = {
seconds: number;
success: number;
@@ -136,3 +162,12 @@ export const monitorApi = {
request<Page<Alert>>(withQuery('/admin/sending-monitor/alerts', { page, state }), { signal }),
read: (id: string) => request(`/admin/sending-monitor/alerts/${id}/read`, { method: 'POST' }),
};
export const scopeTitle = (rule: ManagedRule) =>
[
rule.names?.tenantName ?? (rule.scope.tenantId ? '企业已删除' : ''),
rule.names?.applicationName ?? (rule.scope.applicationId ? '应用已删除' : ''),
rule.names?.signatureName ?? (rule.scope.signatureId ? '签名已删除' : ''),
]
.filter(Boolean)
.join(' · ') || '全局默认';
@@ -0,0 +1,28 @@
import type { Config } from './monitorApi';
export type RuleDraft = { min: string; thresholds: string[]; bad: string; good: string; enabled: boolean };
export const ruleDraft = (config?: Config): RuleDraft => ({
min: config ? String(config.minSamples) : '',
thresholds: config?.thresholds.map((n) => (n === null ? '' : String(n))) ?? ['', '', ''],
bad: String(config?.consecutiveBad ?? 1),
good: String(config?.consecutiveGood ?? 2),
enabled: config?.enabled ?? false,
});
export function draftErrors(draft: RuleDraft) {
const errors: Record<string, string> = {};
const min = Number(draft.min);
if (!Number.isSafeInteger(min) || min < 1 || min > 100000000) errors.min = '请输入1100000000之间的整数';
let previous = -1;
draft.thresholds.forEach((text, i) => {
if (!text.trim()) return;
const n = Number(text);
if (!Number.isFinite(n) || n < 0 || n > 100 || Math.abs(n * 100 - Math.round(n * 100)) > 1e-7)
errors[`threshold${i}`] = '请输入0100,最多两位小数';
else if (n < previous) errors[`threshold${i}`] = '较长时限的下限不能低于较短时限';
previous = n;
});
if (draft.thresholds.every((s) => !s.trim())) errors.threshold0 = '请至少设置一项到达率下限';
for (const key of ['bad', 'good'] as const)
if (!Number.isInteger(Number(draft[key])) || Number(draft[key]) < 1 || Number(draft[key]) > 5)
errors[key] = '请输入15之间的整数';
return errors;
}