This commit is contained in:
@@ -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 = '请输入1~100000000之间的整数';
|
||||
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}`] = '请输入0~100,最多两位小数';
|
||||
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] = '请输入1~5之间的整数';
|
||||
return errors;
|
||||
}
|
||||
Reference in New Issue
Block a user