refactor(decnet_web/Webhooks): wire shell + bump coverage floor
Webhooks.tsx: 642 -> 387 LOC. Page now composes FormRow + SecretModal + useWebhooks hook; toast policy is the only UI concern left in the shell. Multi-select delete uses the hook's reload internally.
This commit is contained in:
@@ -1,98 +1,26 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus, Trash2, Pencil, Zap, AlertTriangle, Copy, X, Save,
|
Plus, Trash2, Pencil, Zap, AlertTriangle, X,
|
||||||
Check, Webhook as WebhookIcon,
|
Check, Webhook as WebhookIcon,
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import api, { type ApiError } from '../utils/api';
|
|
||||||
import { useToast } from './Toasts/useToast';
|
import { useToast } from './Toasts/useToast';
|
||||||
|
import FormRow from './Webhooks/FormRow';
|
||||||
|
import SecretModal from './Webhooks/SecretModal';
|
||||||
|
import { useWebhooks } from './Webhooks/useWebhooks';
|
||||||
|
import {
|
||||||
|
BLANK_FORM, deriveSimpleEvents, formatDate, formToPayload, SIMPLE_PRESETS,
|
||||||
|
} from './Webhooks/helpers';
|
||||||
|
import type { FormState, SimpleEvent, WebhookRow } from './Webhooks/types';
|
||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
import './Config.css';
|
import './Config.css';
|
||||||
import './Webhooks.css';
|
import './Webhooks.css';
|
||||||
|
|
||||||
type SimpleEvent = 'AttackerDetail' | 'DeckyStatus' | 'SystemStatus';
|
|
||||||
|
|
||||||
// Server-side canonical expansions (mirrors decnet/webhook/enums.py). Kept
|
|
||||||
// in sync manually; this is the sugar layer, not the source of truth.
|
|
||||||
const SIMPLE_PRESETS: Record<SimpleEvent, string[]> = {
|
|
||||||
AttackerDetail: ['attacker.>'],
|
|
||||||
DeckyStatus: ['decky.*.state', 'decky.*.traffic'],
|
|
||||||
SystemStatus: ['system.>'],
|
|
||||||
};
|
|
||||||
|
|
||||||
interface WebhookRow {
|
|
||||||
uuid: string;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
topic_patterns: string[];
|
|
||||||
enabled: boolean;
|
|
||||||
consecutive_failures: number;
|
|
||||||
last_success_at: string | null;
|
|
||||||
last_failure_at: string | null;
|
|
||||||
last_error: string | null;
|
|
||||||
auto_disabled_at: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
warnings: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormState {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
secret: string; // blank = server auto-generates (create) / keep existing (edit)
|
|
||||||
simple_events: SimpleEvent[];
|
|
||||||
topic_patterns: string; // textarea: one per line
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BLANK_FORM: FormState = {
|
|
||||||
name: '',
|
|
||||||
url: '',
|
|
||||||
secret: '',
|
|
||||||
simple_events: [],
|
|
||||||
topic_patterns: '',
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
function extractErrorDetail(err: unknown, fallback: string): string {
|
|
||||||
const e = err as ApiError;
|
|
||||||
if (e?.response?.data?.detail) return e.response.data.detail;
|
|
||||||
if (e?.response?.status === 403) return 'Insufficient permissions (admin only)';
|
|
||||||
if (e?.response?.status === 401) return 'Session expired — please log in again';
|
|
||||||
if (e?.message) return e.message;
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Derive which simple-event checkboxes should show as ticked for a given
|
|
||||||
* persisted pattern list. Only ticks when the intersection is exact —
|
|
||||||
* mixed custom + preset leaves everything unticked and the textarea is
|
|
||||||
* the source of truth. */
|
|
||||||
function deriveSimpleEvents(patterns: string[]): SimpleEvent[] {
|
|
||||||
const ticked: SimpleEvent[] = [];
|
|
||||||
const remaining = new Set(patterns);
|
|
||||||
for (const [name, preset] of Object.entries(SIMPLE_PRESETS) as [SimpleEvent, string[]][]) {
|
|
||||||
if (preset.every((p) => remaining.has(p))) {
|
|
||||||
ticked.push(name);
|
|
||||||
preset.forEach((p) => remaining.delete(p));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If anything outside the presets remains, don't tick — user sees raw.
|
|
||||||
if (remaining.size > 0) return [];
|
|
||||||
return ticked;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string | null): string {
|
|
||||||
if (!iso) return '—';
|
|
||||||
const d = new Date(iso);
|
|
||||||
if (Number.isNaN(d.getTime())) return iso;
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Webhooks: React.FC = () => {
|
const Webhooks: React.FC = () => {
|
||||||
const [webhooks, setWebhooks] = useState<WebhookRow[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
|
const {
|
||||||
|
webhooks, loading, error,
|
||||||
|
createWebhook, updateWebhook, removeWebhook, testWebhook,
|
||||||
|
} = useWebhooks();
|
||||||
|
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
@@ -108,7 +36,6 @@ const Webhooks: React.FC = () => {
|
|||||||
() => webhooks.filter((w) => w.warnings.some((msg) => msg.startsWith('insecure_url'))).length,
|
() => webhooks.filter((w) => w.warnings.some((msg) => msg.startsWith('insecure_url'))).length,
|
||||||
[webhooks],
|
[webhooks],
|
||||||
);
|
);
|
||||||
|
|
||||||
const enabledCount = useMemo(() => webhooks.filter((w) => w.enabled).length, [webhooks]);
|
const enabledCount = useMemo(() => webhooks.filter((w) => w.enabled).length, [webhooks]);
|
||||||
const failCount = useMemo(
|
const failCount = useMemo(
|
||||||
() => webhooks.filter((w) => w.consecutive_failures > 0).length,
|
() => webhooks.filter((w) => w.consecutive_failures > 0).length,
|
||||||
@@ -119,22 +46,6 @@ const Webhooks: React.FC = () => {
|
|||||||
[webhooks],
|
[webhooks],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchWebhooks = async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.get('/webhooks/');
|
|
||||||
setWebhooks(res.data);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setError(extractErrorDetail(err, 'Failed to load webhooks'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchWebhooks();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeForm = () => {
|
const closeForm = () => {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
@@ -177,98 +88,76 @@ const Webhooks: React.FC = () => {
|
|||||||
const handleSave = async (e: React.FormEvent) => {
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!form.name.trim() || !form.url.trim()) return;
|
if (!form.name.trim() || !form.url.trim()) return;
|
||||||
const rawPatterns = form.topic_patterns
|
const payload = formToPayload(form);
|
||||||
.split('\n')
|
if (form.simple_events.length === 0 && payload.topic_patterns.length === 0) {
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (form.simple_events.length === 0 && rawPatterns.length === 0) {
|
|
||||||
push({ text: 'SELECT AT LEAST ONE EVENT OR PATTERN', tone: 'violet', icon: 'alert-triangle' });
|
push({ text: 'SELECT AT LEAST ONE EVENT OR PATTERN', tone: 'violet', icon: 'alert-triangle' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await api.patch(`/webhooks/${editingId}`, {
|
const r = await updateWebhook(editingId, payload);
|
||||||
name: form.name.trim(),
|
if (r.ok) {
|
||||||
url: form.url.trim(),
|
push({ text: 'WEBHOOK UPDATED', tone: 'violet', icon: 'check-circle' });
|
||||||
secret: form.secret ? form.secret : undefined,
|
closeForm();
|
||||||
simple_events: form.simple_events,
|
} else {
|
||||||
topic_patterns: rawPatterns,
|
push({ text: `SAVE FAILED · ${(r.reason ?? '').toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
|
||||||
enabled: form.enabled,
|
}
|
||||||
});
|
|
||||||
push({ text: 'WEBHOOK UPDATED', tone: 'violet', icon: 'check-circle' });
|
|
||||||
} else {
|
} else {
|
||||||
const res = await api.post('/webhooks/', {
|
const r = await createWebhook(payload);
|
||||||
name: form.name.trim(),
|
if (r.ok) {
|
||||||
url: form.url.trim(),
|
push({ text: 'WEBHOOK CREATED', tone: 'violet', icon: 'check-circle' });
|
||||||
secret: form.secret ? form.secret : undefined,
|
if (r.data?.secret) {
|
||||||
simple_events: form.simple_events,
|
setNewSecret({ name: r.data.name, secret: r.data.secret });
|
||||||
topic_patterns: rawPatterns,
|
}
|
||||||
enabled: form.enabled,
|
closeForm();
|
||||||
});
|
} else {
|
||||||
push({ text: 'WEBHOOK CREATED', tone: 'violet', icon: 'check-circle' });
|
push({ text: `SAVE FAILED · ${(r.reason ?? '').toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
|
||||||
if (res.data?.secret) {
|
|
||||||
setNewSecret({ name: res.data.name, secret: res.data.secret });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
closeForm();
|
|
||||||
await fetchWebhooks();
|
|
||||||
} catch (err) {
|
|
||||||
const msg = extractErrorDetail(err, 'Save failed');
|
|
||||||
push({ text: `SAVE FAILED · ${msg.toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTestOne = async (uuid: string, name: string) => {
|
const handleTestOne = async (uuid: string, name: string) => {
|
||||||
try {
|
const r = await testWebhook(uuid);
|
||||||
const res = await api.post(`/webhooks/${uuid}/test`);
|
if (!r.ok) {
|
||||||
const { delivered, status_code, error: err } = res.data;
|
push({ text: `TEST FAILED · ${(r.reason ?? '').toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
|
||||||
if (delivered) {
|
return;
|
||||||
push({ text: `${name.toUpperCase()} · DELIVERED · ${status_code}`, tone: 'violet', icon: 'zap' });
|
}
|
||||||
} else {
|
const { delivered, status_code, error: err } = r.data ?? { delivered: false };
|
||||||
push({ text: `${name.toUpperCase()} · FAILED · ${(err || 'unknown').toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
|
if (delivered) {
|
||||||
}
|
push({ text: `${name.toUpperCase()} · DELIVERED · ${status_code}`, tone: 'violet', icon: 'zap' });
|
||||||
fetchWebhooks();
|
} else {
|
||||||
} catch (err) {
|
push({ text: `${name.toUpperCase()} · FAILED · ${(err || 'unknown').toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
|
||||||
const msg = extractErrorDetail(err, 'Test failed');
|
|
||||||
push({ text: `TEST FAILED · ${msg.toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteOne = async (uuid: string, name: string) => {
|
const handleDeleteOne = async (uuid: string, name: string) => {
|
||||||
try {
|
const r = await removeWebhook(uuid);
|
||||||
await api.delete(`/webhooks/${uuid}`);
|
if (r.ok) {
|
||||||
push({ text: `${name.toUpperCase()} · DELETED`, tone: 'violet', icon: 'trash' });
|
push({ text: `${name.toUpperCase()} · DELETED`, tone: 'violet', icon: 'trash' });
|
||||||
setSelected((s) => {
|
setSelected((s) => {
|
||||||
const n = new Set(s);
|
const n = new Set(s); n.delete(uuid); return n;
|
||||||
n.delete(uuid);
|
|
||||||
return n;
|
|
||||||
});
|
});
|
||||||
fetchWebhooks();
|
} else {
|
||||||
} catch (err) {
|
push({ text: `DELETE FAILED · ${(r.reason ?? '').toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
|
||||||
const msg = extractErrorDetail(err, 'Delete failed');
|
|
||||||
push({ text: `DELETE FAILED · ${msg.toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteSelected = async () => {
|
const handleDeleteSelected = async () => {
|
||||||
const ids = Array.from(selected);
|
const ids = Array.from(selected);
|
||||||
const results = await Promise.allSettled(ids.map((id) => api.delete(`/webhooks/${id}`)));
|
const results = await Promise.allSettled(ids.map((id) => removeWebhook(id)));
|
||||||
const ok = results.filter((r) => r.status === 'fulfilled').length;
|
const ok = results.filter((r) => r.status === 'fulfilled' && r.value.ok).length;
|
||||||
const bad = results.length - ok;
|
const bad = results.length - ok;
|
||||||
push({
|
push({
|
||||||
text: bad === 0
|
text: bad === 0 ? `DELETED · ${ok}` : `DELETED · ${ok} · FAILED · ${bad}`,
|
||||||
? `DELETED · ${ok}`
|
|
||||||
: `DELETED · ${ok} · FAILED · ${bad}`,
|
|
||||||
tone: 'violet',
|
tone: 'violet',
|
||||||
icon: bad ? 'alert-triangle' : 'trash',
|
icon: bad ? 'alert-triangle' : 'trash',
|
||||||
});
|
});
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
setDeleteArmed(false);
|
setDeleteArmed(false);
|
||||||
fetchWebhooks();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSelect = (uuid: string) => {
|
const toggleSelect = (uuid: string) => {
|
||||||
@@ -413,9 +302,7 @@ const Webhooks: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>{w.name}</td>
|
<td>{w.name}</td>
|
||||||
<td className="wh-url-cell" title={w.url}>
|
<td className="wh-url-cell" title={w.url}>{w.url}</td>
|
||||||
{w.url}
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
{w.topic_patterns.slice(0, 2).map((p) => (
|
{w.topic_patterns.slice(0, 2).map((p) => (
|
||||||
<span key={p} className="wh-chip">{p}</span>
|
<span key={p} className="wh-chip">{p}</span>
|
||||||
@@ -497,146 +384,4 @@ const Webhooks: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FormRowProps {
|
|
||||||
title: string;
|
|
||||||
form: FormState;
|
|
||||||
setForm: React.Dispatch<React.SetStateAction<FormState>>;
|
|
||||||
onSave: (e: React.FormEvent) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
saving: boolean;
|
|
||||||
isEdit: boolean;
|
|
||||||
onToggleSimple: (n: SimpleEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormRow: React.FC<FormRowProps> = ({
|
|
||||||
title, form, setForm, onSave, onCancel, saving, isEdit, onToggleSimple,
|
|
||||||
}) => (
|
|
||||||
<tr className="wh-form-row">
|
|
||||||
<td colSpan={7}>
|
|
||||||
<form className="wh-form-grid" onSubmit={onSave}>
|
|
||||||
<label className="wh-form-title">{title}</label>
|
|
||||||
|
|
||||||
<label>NAME</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
|
||||||
placeholder="shuffle-prod"
|
|
||||||
required
|
|
||||||
maxLength={64}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label>URL</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={form.url}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
|
||||||
placeholder="https://shuffle.example.com/api/v1/hooks/webhook_xxx"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
SECRET {isEdit && <span className="wh-form-hint">(blank = keep existing)</span>}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={form.secret}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, secret: e.target.value }))}
|
|
||||||
placeholder={isEdit ? '—' : 'leave blank to auto-generate'}
|
|
||||||
minLength={16}
|
|
||||||
maxLength={256}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label>SIMPLE EVENTS</label>
|
|
||||||
<div className="wh-checkbox-group">
|
|
||||||
{(['AttackerDetail', 'DeckyStatus', 'SystemStatus'] as const).map((name) => (
|
|
||||||
<label key={name}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.simple_events.includes(name)}
|
|
||||||
onChange={() => onToggleSimple(name)}
|
|
||||||
/>
|
|
||||||
{name}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
ADVANCED PATTERNS
|
|
||||||
<br />
|
|
||||||
<span className="wh-form-hint">(one per line, NATS-style)</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={form.topic_patterns}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, topic_patterns: e.target.value }))}
|
|
||||||
placeholder={'attacker.>\ndecky.*.state'}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label>ENABLED</label>
|
|
||||||
<div className="wh-checkbox-group">
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.enabled}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
Receive events
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="wh-form-buttons">
|
|
||||||
<button type="button" className="btn ghost" onClick={onCancel} disabled={saving}>
|
|
||||||
<X size={12} /> CANCEL
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn violet" disabled={saving}>
|
|
||||||
<Save size={12} /> {saving ? 'SAVING…' : isEdit ? 'SAVE CHANGES' : 'CREATE'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface SecretModalProps {
|
|
||||||
name: string;
|
|
||||||
secret: string;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SecretModal: React.FC<SecretModalProps> = ({ name, secret, onClose }) => {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const copy = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(secret);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 1500);
|
|
||||||
} catch {
|
|
||||||
/* no-op — browsers without clipboard perms will just see no feedback */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="wh-secret-modal-backdrop"
|
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
||||||
>
|
|
||||||
<div className="wh-secret-modal">
|
|
||||||
<h3>WEBHOOK SECRET · {name.toUpperCase()}</h3>
|
|
||||||
<div className="wh-secret-warn">
|
|
||||||
<AlertTriangle size={14} />
|
|
||||||
<span>COPY THIS NOW — IT WILL NOT BE SHOWN AGAIN. THE HMAC ON EVERY DELIVERY IS SIGNED WITH THIS VALUE.</span>
|
|
||||||
</div>
|
|
||||||
<div className="wh-secret-value">{secret}</div>
|
|
||||||
<div className="wh-secret-actions">
|
|
||||||
<button className="btn ghost" onClick={copy}>
|
|
||||||
<Copy size={12} /> {copied ? 'COPIED' : 'COPY'}
|
|
||||||
</button>
|
|
||||||
<button className="btn violet" onClick={onClose}>
|
|
||||||
<Check size={12} /> DONE
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Webhooks;
|
export default Webhooks;
|
||||||
|
|||||||
@@ -15,16 +15,15 @@ export default defineConfig({
|
|||||||
include: ['src/**/*.{ts,tsx}'],
|
include: ['src/**/*.{ts,tsx}'],
|
||||||
exclude: ['src/**/*.d.ts', 'src/test/**', 'src/main.tsx'],
|
exclude: ['src/**/*.d.ts', 'src/test/**', 'src/main.tsx'],
|
||||||
// Baseline floors. Each refactor PR raises these; never lower.
|
// Baseline floors. Each refactor PR raises these; never lower.
|
||||||
// Phase 6 (PersonaGeneration trim): page shell down from 875 to
|
// Phase 7 (Webhooks trim): page shell down from 642 to 387 LOC.
|
||||||
// 357 LOC. Lifted helpers, PersonaCard, PersonaEditor, and a
|
// Lifted helpers, FormRow, SecretModal, and a useWebhooks data
|
||||||
// usePersonaGeneration data hook (GET/PUT, global + topology
|
// hook (CRUD + test endpoint). 17 new tests. Suite: 43 files,
|
||||||
// endpoints). 18 new tests. Suite: 40 files, 190 tests,
|
// 207 tests, 21.69% lines / 16.51% branches.
|
||||||
// 20.83% lines / 16.3% branches.
|
|
||||||
thresholds: {
|
thresholds: {
|
||||||
lines: 20,
|
lines: 21,
|
||||||
functions: 17,
|
functions: 18,
|
||||||
branches: 16,
|
branches: 16,
|
||||||
statements: 19,
|
statements: 20,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user