Commit 37bafeba authored by BangNSK's avatar BangNSK

feat: add webhook management interface

parent a5729e5c
...@@ -11,6 +11,7 @@ import { Users } from './pages/Users'; ...@@ -11,6 +11,7 @@ import { Users } from './pages/Users';
import { Logs } from './pages/Logs'; import { Logs } from './pages/Logs';
import { Profile } from './pages/Profile'; import { Profile } from './pages/Profile';
import { ApiKeys } from './pages/ApiKeys'; import { ApiKeys } from './pages/ApiKeys';
import { Webhooks } from './pages/Webhooks';
import { ForgotPassword } from './pages/ForgotPassword'; import { ForgotPassword } from './pages/ForgotPassword';
import { ResetPassword } from './pages/ResetPassword'; import { ResetPassword } from './pages/ResetPassword';
import { VerifyEmail } from './pages/VerifyEmail'; import { VerifyEmail } from './pages/VerifyEmail';
...@@ -61,6 +62,7 @@ export default function App() { ...@@ -61,6 +62,7 @@ export default function App() {
{/* Profile */} {/* Profile */}
<Route path="profile" element={<Profile />} /> <Route path="profile" element={<Profile />} />
<Route path="api-keys" element={<ApiKeys />} /> <Route path="api-keys" element={<ApiKeys />} />
<Route path="webhooks" element={<Webhooks />} />
{/* Admin Only Routes */} {/* Admin Only Routes */}
<Route <Route
......
...@@ -10,6 +10,7 @@ const TITLES: Record<string, string> = { ...@@ -10,6 +10,7 @@ const TITLES: Record<string, string> = {
'/verify-email': 'Xác minh email | Data Crawler', '/verify-email': 'Xác minh email | Data Crawler',
'/profile': 'Hồ sơ cá nhân | Data Crawler', '/profile': 'Hồ sơ cá nhân | Data Crawler',
'/api-keys': 'Quản lý API Key | Data Crawler', '/api-keys': 'Quản lý API Key | Data Crawler',
'/webhooks': 'Quản lý Webhook | Data Crawler',
'/users': 'Quản lý người dùng | Data Crawler', '/users': 'Quản lý người dùng | Data Crawler',
'/logs': 'Nhật ký hệ thống | Data Crawler', '/logs': 'Nhật ký hệ thống | Data Crawler',
}; };
......
import React from 'react'; import React from 'react';
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom'; import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom';
import { useAuth } from '../context/auth'; import { useAuth } from '../context/auth';
import { Database, Users, History, User, LogOut, Compass, KeyRound } from 'lucide-react'; import { Database, Users, History, User, LogOut, Compass, KeyRound, MoreHorizontal, Webhook } from 'lucide-react';
export const DashboardLayout: React.FC = () => { export const DashboardLayout: React.FC = () => {
const { user, logout, isAdmin } = useAuth(); const { user, logout, isAdmin } = useAuth();
...@@ -26,6 +26,12 @@ export const DashboardLayout: React.FC = () => { ...@@ -26,6 +26,12 @@ export const DashboardLayout: React.FC = () => {
icon: <KeyRound className="h-5 w-5" />, icon: <KeyRound className="h-5 w-5" />,
allowed: true, allowed: true,
}, },
{
name: 'Quản lý Webhook',
path: '/webhooks',
icon: <Webhook className="h-5 w-5" />,
allowed: true,
},
{ {
name: 'Quản lý Users', name: 'Quản lý Users',
path: '/users', path: '/users',
...@@ -51,6 +57,10 @@ export const DashboardLayout: React.FC = () => { ...@@ -51,6 +57,10 @@ export const DashboardLayout: React.FC = () => {
? location.pathname === '/' || location.pathname.startsWith('/jobs/') ? location.pathname === '/' || location.pathname.startsWith('/jobs/')
: location.pathname === path; : location.pathname === path;
const visibleNavItems = navItems.filter((item) => item.allowed);
const mobilePrimaryItems = visibleNavItems.length > 5 ? visibleNavItems.slice(0, 4) : visibleNavItems;
const mobileOverflowItems = visibleNavItems.length > 5 ? visibleNavItems.slice(4) : [];
return ( return (
<div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-800"> <div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-800">
{/* Sidebar */} {/* Sidebar */}
...@@ -68,8 +78,7 @@ export const DashboardLayout: React.FC = () => { ...@@ -68,8 +78,7 @@ export const DashboardLayout: React.FC = () => {
{/* Navigation Links */} {/* Navigation Links */}
<nav className="flex-1 px-2 space-y-1 bg-white"> <nav className="flex-1 px-2 space-y-1 bg-white">
{navItems {visibleNavItems
.filter((item) => item.allowed)
.map((item) => { .map((item) => {
const isActive = isPathActive(item.path); const isActive = isPathActive(item.path);
return ( return (
...@@ -142,9 +151,7 @@ export const DashboardLayout: React.FC = () => { ...@@ -142,9 +151,7 @@ export const DashboardLayout: React.FC = () => {
</header> </header>
<nav className="fixed inset-x-0 bottom-0 z-40 grid grid-flow-col auto-cols-fr border-t border-slate-200 bg-white/95 px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-lg backdrop-blur md:hidden" aria-label="Điều hướng chính"> <nav className="fixed inset-x-0 bottom-0 z-40 grid grid-flow-col auto-cols-fr border-t border-slate-200 bg-white/95 px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 shadow-lg backdrop-blur md:hidden" aria-label="Điều hướng chính">
{navItems {mobilePrimaryItems.map((item) => {
.filter((item) => item.allowed)
.map((item) => {
const isActive = isPathActive(item.path); const isActive = isPathActive(item.path);
return ( return (
<Link <Link
...@@ -159,6 +166,35 @@ export const DashboardLayout: React.FC = () => { ...@@ -159,6 +166,35 @@ export const DashboardLayout: React.FC = () => {
</Link> </Link>
); );
})} })}
{mobileOverflowItems.length > 0 && (
<details className="group relative">
<summary className={`flex min-w-0 cursor-pointer list-none flex-col items-center gap-1 rounded-lg px-1 py-1.5 text-[10px] font-medium ${
mobileOverflowItems.some((item) => isPathActive(item.path))
? 'bg-indigo-50 text-indigo-700'
: 'text-slate-500'
}`}>
<MoreHorizontal className="h-5 w-5" />
<span>Thêm</span>
</summary>
<div className="fixed bottom-20 right-2 z-50 w-56 overflow-hidden rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl">
{mobileOverflowItems.map((item) => (
<Link
key={item.name}
to={item.path}
onClick={(event) => event.currentTarget.closest('details')?.removeAttribute('open')}
className={`flex min-h-11 items-center rounded-lg px-3 text-sm font-medium ${
isPathActive(item.path)
? 'bg-indigo-50 text-indigo-700'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
<span className="mr-3">{item.icon}</span>
{item.name}
</Link>
))}
</div>
</details>
)}
</nav> </nav>
{/* Page Content */} {/* Page Content */}
......
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import {
Activity,
AlertCircle,
CheckCircle2,
ChevronDown,
Clock3,
Eye,
EyeOff,
KeyRound,
Loader2,
Plus,
RefreshCw,
RotateCcw,
Send,
ShieldCheck,
Trash2,
Webhook,
X,
XCircle,
} from 'lucide-react';
import {
webhooksApi,
type WebhookConfig,
type WebhookDelivery,
type WebhookDeliveryStatus,
type WebhookEvent,
} from '../services/api';
import { getApiErrorMessages } from '../utils/apiError';
const EVENT_OPTIONS: Array<{ value: WebhookEvent; label: string; description: string }> = [
{
value: 'job.completed',
label: 'Job hoàn thành',
description: 'Gửi callback khi dữ liệu đã được crawl thành công.',
},
{
value: 'job.failed',
label: 'Job thất bại',
description: 'Gửi callback khi quá trình crawl kết thúc với lỗi.',
},
];
const STATUS_OPTIONS: Array<{ value: WebhookDeliveryStatus | ''; label: string }> = [
{ value: '', label: 'Tất cả trạng thái' },
{ value: 'PENDING', label: 'Đang chờ' },
{ value: 'SUCCESS', label: 'Thành công' },
{ value: 'FAILED', label: 'Thất bại' },
];
const dateFormatter = new Intl.DateTimeFormat('vi-VN', {
dateStyle: 'medium',
timeStyle: 'short',
});
function formatDate(value: string | null) {
return value ? dateFormatter.format(new Date(value)) : '—';
}
function formatJson(value: unknown) {
if (typeof value === 'string') {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
return JSON.stringify(value, null, 2);
}
function eventLabel(event: WebhookEvent) {
return EVENT_OPTIONS.find((item) => item.value === event)?.label ?? event;
}
function DeliveryStatusBadge({ status }: { status: WebhookDeliveryStatus }) {
const styles = {
SUCCESS: {
label: 'Thành công',
icon: CheckCircle2,
className: 'border-emerald-200 bg-emerald-50 text-emerald-700',
},
FAILED: {
label: 'Thất bại',
icon: XCircle,
className: 'border-rose-200 bg-rose-50 text-rose-700',
},
PENDING: {
label: 'Đang chờ',
icon: Clock3,
className: 'border-amber-200 bg-amber-50 text-amber-700',
},
}[status];
const Icon = styles.icon;
return (
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold ${styles.className}`}>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{styles.label}
</span>
);
}
function DeliveryDetails({ delivery }: { delivery: WebhookDelivery }) {
return (
<details className="group">
<summary className="inline-flex min-h-11 cursor-pointer list-none items-center gap-1.5 rounded-lg px-2 text-sm font-semibold text-indigo-600 outline-none transition hover:bg-indigo-50 focus-visible:ring-2 focus-visible:ring-indigo-500">
Chi tiết
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" aria-hidden="true" />
</summary>
<div className="mt-2 grid gap-3 xl:grid-cols-2">
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-slate-500">Payload</p>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-slate-950 p-3 text-xs leading-5 text-slate-100">
{formatJson(delivery.payload)}
</pre>
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-slate-500">Phản hồi</p>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-slate-100 p-3 text-xs leading-5 text-slate-700">
{delivery.errorMessage || formatJson(delivery.responseBody) || 'Chưa có phản hồi.'}
</pre>
</div>
</div>
</details>
);
}
export function Webhooks() {
const [configs, setConfigs] = useState<WebhookConfig[]>([]);
const [deliveries, setDeliveries] = useState<WebhookDelivery[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [pageError, setPageError] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [url, setUrl] = useState('');
const [secret, setSecret] = useState('');
const [showSecret, setShowSecret] = useState(false);
const [events, setEvents] = useState<WebhookEvent[]>(['job.completed', 'job.failed']);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState('');
const [deleteTarget, setDeleteTarget] = useState<WebhookConfig | null>(null);
const [deleting, setDeleting] = useState(false);
const [statusInput, setStatusInput] = useState<WebhookDeliveryStatus | ''>('');
const [jobIdInput, setJobIdInput] = useState('');
const [statusFilter, setStatusFilter] = useState<WebhookDeliveryStatus | ''>('');
const [jobIdFilter, setJobIdFilter] = useState('');
const [deliveriesLoading, setDeliveriesLoading] = useState(false);
const loadData = useCallback(async (silent = false) => {
if (silent) setRefreshing(true);
else setLoading(true);
try {
const [nextConfigs, nextDeliveries] = await Promise.all([
webhooksApi.listConfigs(),
webhooksApi.listDeliveries({
status: statusFilter || undefined,
jobId: jobIdFilter || undefined,
}),
]);
setConfigs(nextConfigs);
setDeliveries(nextDeliveries);
setPageError('');
} catch (error: unknown) {
setPageError(getApiErrorMessages(error, 'Không thể tải dữ liệu webhook.').join('\n'));
} finally {
setLoading(false);
setRefreshing(false);
}
}, [jobIdFilter, statusFilter]);
useEffect(() => {
void loadData();
}, [loadData]);
useEffect(() => {
if (!showCreate && !deleteTarget) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (deleteTarget && !deleting) setDeleteTarget(null);
if (showCreate && !creating) setShowCreate(false);
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [creating, deleteTarget, deleting, showCreate]);
const resetCreateForm = () => {
setUrl('');
setSecret('');
setShowSecret(false);
setEvents(['job.completed', 'job.failed']);
setCreateError('');
};
const closeCreate = () => {
if (creating) return;
resetCreateForm();
setShowCreate(false);
};
const generateSecret = () => {
const bytes = crypto.getRandomValues(new Uint8Array(24));
setSecret(Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''));
setShowSecret(true);
setCreateError('');
};
const toggleEvent = (event: WebhookEvent) => {
setEvents((current) => current.includes(event)
? current.filter((item) => item !== event)
: [...current, event]);
};
const handleCreate = async (event: FormEvent) => {
event.preventDefault();
const normalizedUrl = url.trim();
try {
const parsedUrl = new URL(normalizedUrl);
if (!['http:', 'https:'].includes(parsedUrl.protocol)) throw new Error();
} catch {
setCreateError('Vui lòng nhập URL hợp lệ bắt đầu bằng http:// hoặc https://.');
return;
}
if (secret.length < 16 || secret.length > 128) {
setCreateError('Signing secret phải có từ 16 đến 128 ký tự.');
return;
}
if (events.length === 0) {
setCreateError('Vui lòng chọn ít nhất một sự kiện.');
return;
}
setCreating(true);
setCreateError('');
try {
const created = await webhooksApi.createConfig({ url: normalizedUrl, secret, events });
setConfigs((current) => [created, ...current]);
resetCreateForm();
setShowCreate(false);
} catch (error: unknown) {
setCreateError(getApiErrorMessages(error, 'Không thể tạo webhook.').join('\n'));
} finally {
setCreating(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
setPageError('');
try {
await webhooksApi.deleteConfig(deleteTarget.id);
setConfigs((current) => current.filter((config) => config.id !== deleteTarget.id));
setDeliveries((current) => current.filter((delivery) => delivery.webhookConfigId !== deleteTarget.id));
setDeleteTarget(null);
} catch (error: unknown) {
setPageError(getApiErrorMessages(error, 'Không thể xóa webhook.').join('\n'));
} finally {
setDeleting(false);
}
};
const applyDeliveryFilters = async (event: FormEvent) => {
event.preventDefault();
const normalizedJobId = jobIdInput.trim();
setStatusFilter(statusInput);
setJobIdFilter(normalizedJobId);
if (statusInput === statusFilter && normalizedJobId === jobIdFilter) {
setDeliveriesLoading(true);
try {
setDeliveries(await webhooksApi.listDeliveries({
status: statusInput || undefined,
jobId: normalizedJobId || undefined,
}));
setPageError('');
} catch (error: unknown) {
setPageError(getApiErrorMessages(error, 'Không thể tải lịch sử gửi webhook.').join('\n'));
} finally {
setDeliveriesLoading(false);
}
}
};
const clearDeliveryFilters = () => {
setStatusInput('');
setJobIdInput('');
setStatusFilter('');
setJobIdFilter('');
};
const successfulDeliveries = deliveries.filter((item) => item.status === 'SUCCESS').length;
return (
<div className="mx-auto max-w-7xl space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="mb-2 inline-flex items-center gap-2 rounded-full border border-indigo-100 bg-indigo-50 px-3 py-1 text-xs font-semibold text-indigo-700">
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
Callback có chữ ký HMAC
</div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">Quản lý Webhook</h1>
<p className="mt-1 max-w-2xl text-sm leading-6 text-slate-500">
Nhận thông báo tự động khi job hoàn thành hoặc thất bại và theo dõi từng lần gửi từ hệ thống.
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => void loadData(true)}
disabled={refreshing}
aria-label="Làm mới dữ liệu webhook"
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 shadow-sm transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} aria-hidden="true" />
</button>
<button
type="button"
onClick={() => setShowCreate(true)}
className="inline-flex min-h-11 cursor-pointer items-center justify-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
>
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
Thêm endpoint
</button>
</div>
</div>
{pageError && (
<div className="whitespace-pre-line rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700" role="alert">
{pageError}
</div>
)}
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-slate-500">Endpoint đang hoạt động</p>
<Webhook className="h-5 w-5 text-indigo-500" aria-hidden="true" />
</div>
<p className="mt-2 text-2xl font-bold text-slate-900">{configs.filter((item) => item.isActive).length}</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-slate-500">Delivery hiển thị</p>
<Send className="h-5 w-5 text-sky-500" aria-hidden="true" />
</div>
<p className="mt-2 text-2xl font-bold text-slate-900">{deliveries.length}</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-slate-500">Gửi thành công</p>
<Activity className="h-5 w-5 text-emerald-500" aria-hidden="true" />
</div>
<p className="mt-2 text-2xl font-bold text-slate-900">{successfulDeliveries}</p>
</div>
</div>
<section aria-labelledby="webhook-endpoints-title" className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex flex-col gap-1 border-b border-slate-100 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 id="webhook-endpoints-title" className="font-semibold text-slate-900">Endpoint nhận callback</h2>
<p className="mt-0.5 text-sm text-slate-500">Secret được mã hóa và không hiển thị lại sau khi tạo.</p>
</div>
<span className="mt-2 text-xs font-medium text-slate-400 sm:mt-0">{configs.length} endpoint</span>
</div>
{loading ? (
<div className="flex min-h-40 items-center justify-center text-slate-500" role="status">
<Loader2 className="mr-2 h-5 w-5 animate-spin" aria-hidden="true" />
Đang tải endpoint...
</div>
) : configs.length === 0 ? (
<div className="px-5 py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-indigo-50 text-indigo-600">
<Webhook className="h-6 w-6" aria-hidden="true" />
</div>
<h3 className="mt-4 font-semibold text-slate-900">Chưa có webhook</h3>
<p className="mx-auto mt-1 max-w-md text-sm text-slate-500">Thêm endpoint đầu tiên để nhận sự kiện từ Data Crawler.</p>
<button
type="button"
onClick={() => setShowCreate(true)}
className="mt-4 inline-flex min-h-11 cursor-pointer items-center rounded-lg bg-indigo-600 px-4 text-sm font-semibold text-white transition hover:bg-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
>
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
Thêm endpoint
</button>
</div>
) : (
<div className="divide-y divide-slate-100">
{configs.map((config) => (
<article key={config.id} className="flex flex-col gap-4 px-5 py-4 transition hover:bg-slate-50/70 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="break-all font-mono text-sm font-semibold text-slate-800">{config.url}</p>
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-semibold ${config.isActive ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-slate-100 text-slate-600'}`}>
<span className={`h-1.5 w-1.5 rounded-full ${config.isActive ? 'bg-emerald-500' : 'bg-slate-400'}`} aria-hidden="true" />
{config.isActive ? 'Đang hoạt động' : 'Đã tắt'}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{config.events.map((event) => (
<span key={event} className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-600">{event}</span>
))}
<span className="text-xs text-slate-400">Tạo {formatDate(config.createdAt)}</span>
</div>
</div>
<button
type="button"
onClick={() => setDeleteTarget(config)}
aria-label={`Xóa webhook ${config.url}`}
className="inline-flex min-h-11 cursor-pointer items-center justify-center self-start rounded-lg border border-rose-200 bg-white px-3 text-sm font-semibold text-rose-600 transition hover:bg-rose-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 lg:self-auto"
>
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
Xóa
</button>
</article>
))}
</div>
)}
</section>
<section aria-labelledby="delivery-history-title" className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 px-5 py-4">
<h2 id="delivery-history-title" className="font-semibold text-slate-900">Lịch sử gửi webhook</h2>
<p className="mt-0.5 text-sm text-slate-500">Kiểm tra trạng thái, số lần thử, payload và phản hồi của endpoint.</p>
</div>
<form onSubmit={applyDeliveryFilters} className="grid gap-3 border-b border-slate-100 bg-slate-50/70 px-5 py-4 md:grid-cols-[minmax(0,1fr)_13rem_auto] md:items-end">
<div>
<label htmlFor="webhook-job-id" className="mb-1.5 block text-xs font-semibold text-slate-600">Job ID</label>
<input
id="webhook-job-id"
value={jobIdInput}
onChange={(event) => setJobIdInput(event.target.value)}
placeholder="Nhập UUID của job"
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 font-mono text-sm text-slate-700 outline-none transition placeholder:font-sans placeholder:text-slate-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100"
/>
</div>
<div>
<label htmlFor="webhook-status" className="mb-1.5 block text-xs font-semibold text-slate-600">Trạng thái</label>
<select
id="webhook-status"
value={statusInput}
onChange={(event) => setStatusInput(event.target.value as WebhookDeliveryStatus | '')}
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100"
>
{STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={deliveriesLoading}
className="inline-flex min-h-11 cursor-pointer items-center justify-center rounded-lg bg-slate-800 px-4 text-sm font-semibold text-white transition hover:bg-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{deliveriesLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : null}
Lọc
</button>
{(statusFilter || jobIdFilter || statusInput || jobIdInput) && (
<button
type="button"
onClick={clearDeliveryFilters}
aria-label="Xóa bộ lọc delivery"
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
</form>
{loading || deliveriesLoading ? (
<div className="flex min-h-48 items-center justify-center text-slate-500" role="status">
<Loader2 className="mr-2 h-5 w-5 animate-spin" aria-hidden="true" />
Đang tải lịch sử gửi...
</div>
) : deliveries.length === 0 ? (
<div className="px-5 py-12 text-center text-slate-500">
<Send className="mx-auto h-10 w-10 text-slate-300" aria-hidden="true" />
<p className="mt-3 font-medium text-slate-700">Chưa có delivery phù hợp</p>
<p className="mt-1 text-sm">Delivery sẽ xuất hiện sau khi một job phát sinh sự kiện đã đăng ký.</p>
</div>
) : (
<>
<div className="divide-y divide-slate-100 md:hidden">
{deliveries.map((delivery) => (
<article key={delivery.id} className="space-y-3 px-5 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-mono text-xs text-slate-500">{delivery.id}</p>
<p className="mt-1 text-sm font-semibold text-slate-800">{eventLabel(delivery.event)}</p>
</div>
<DeliveryStatusBadge status={delivery.status} />
</div>
<dl className="grid grid-cols-2 gap-3 text-sm">
<div><dt className="text-xs text-slate-400">HTTP</dt><dd className="mt-0.5 font-medium text-slate-700">{delivery.statusCode ?? '—'}</dd></div>
<div><dt className="text-xs text-slate-400">Lần thử</dt><dd className="mt-0.5 font-medium text-slate-700">{delivery.attempt}/3</dd></div>
<div className="col-span-2"><dt className="text-xs text-slate-400">Endpoint</dt><dd className="mt-0.5 break-all font-mono text-xs text-slate-700">{delivery.webhookConfig.url}</dd></div>
<div className="col-span-2"><dt className="text-xs text-slate-400">Thời gian</dt><dd className="mt-0.5 text-slate-700">{formatDate(delivery.createdAt)}</dd></div>
</dl>
<DeliveryDetails delivery={delivery} />
</article>
))}
</div>
<div className="hidden md:block">
<table className="min-w-full divide-y divide-slate-100 text-left text-sm">
<thead className="bg-slate-50 text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th className="px-5 py-3">Sự kiện / Endpoint</th>
<th className="px-5 py-3">Trạng thái</th>
<th className="px-5 py-3">HTTP</th>
<th className="px-5 py-3">Lần thử</th>
<th className="px-5 py-3">Thời gian</th>
<th className="px-5 py-3"><span className="sr-only">Chi tiết</span></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{deliveries.map((delivery) => (
<tr key={delivery.id} className="align-top transition hover:bg-slate-50/70">
<td className="max-w-xs px-5 py-4">
<p className="font-semibold text-slate-800">{eventLabel(delivery.event)}</p>
<p className="mt-1 truncate font-mono text-xs text-slate-500" title={delivery.webhookConfig.url}>{delivery.webhookConfig.url}</p>
<p className="mt-1 font-mono text-[11px] text-slate-400">Job: {delivery.crawlJobId}</p>
</td>
<td className="px-5 py-4"><DeliveryStatusBadge status={delivery.status} /></td>
<td className="px-5 py-4 font-mono text-slate-600">{delivery.statusCode ?? '—'}</td>
<td className="px-5 py-4 text-slate-600">{delivery.attempt}/3</td>
<td className="whitespace-nowrap px-5 py-4 text-xs text-slate-500">{formatDate(delivery.createdAt)}</td>
<td className="px-5 py-2"><DeliveryDetails delivery={delivery} /></td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</section>
{showCreate && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/50 p-4 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) closeCreate();
}}
>
<div className="max-h-[calc(100vh-2rem)] w-full max-w-xl overflow-y-auto rounded-2xl bg-white shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="create-webhook-title">
<div className="flex items-start justify-between border-b border-slate-100 px-6 py-5">
<div>
<h2 id="create-webhook-title" className="text-lg font-bold text-slate-900">Thêm webhook endpoint</h2>
<p className="mt-1 text-sm text-slate-500">Data Crawler sẽ ký payload bằng HMAC SHA-256.</p>
</div>
<button type="button" onClick={closeCreate} disabled={creating} aria-label="Đóng" className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed">
<X className="h-5 w-5" aria-hidden="true" />
</button>
</div>
<form onSubmit={handleCreate} className="space-y-5 px-6 py-5">
{createError && <div className="whitespace-pre-line rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700" role="alert">{createError}</div>}
<div>
<label htmlFor="webhook-url" className="mb-1.5 block text-sm font-semibold text-slate-700">Endpoint URL</label>
<input
id="webhook-url"
type="url"
inputMode="url"
autoFocus
required
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://example.com/webhooks/crawler"
className="h-11 w-full rounded-lg border border-slate-300 px-3 font-mono text-sm text-slate-700 outline-none transition placeholder:font-sans placeholder:text-slate-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100"
/>
<p className="mt-1.5 text-xs text-slate-500">Endpoint cần phản hồi mã HTTP 2xx trong vòng 10 giây.</p>
</div>
<fieldset>
<legend className="mb-2 text-sm font-semibold text-slate-700">Sự kiện nhận thông báo</legend>
<div className="grid gap-2 sm:grid-cols-2">
{EVENT_OPTIONS.map((option) => {
const checked = events.includes(option.value);
return (
<label key={option.value} className={`flex min-h-20 cursor-pointer gap-3 rounded-xl border p-3 transition ${checked ? 'border-indigo-300 bg-indigo-50' : 'border-slate-200 hover:bg-slate-50'}`}>
<input type="checkbox" checked={checked} onChange={() => toggleEvent(option.value)} className="mt-1 h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
<span>
<span className="block text-sm font-semibold text-slate-800">{option.label}</span>
<span className="mt-0.5 block text-xs leading-5 text-slate-500">{option.description}</span>
</span>
</label>
);
})}
</div>
</fieldset>
<div>
<div className="mb-1.5 flex items-center justify-between gap-3">
<label htmlFor="webhook-secret" className="text-sm font-semibold text-slate-700">Signing secret</label>
<button type="button" onClick={generateSecret} className="cursor-pointer text-xs font-semibold text-indigo-600 hover:text-indigo-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500">Tạo secret an toàn</button>
</div>
<div className="relative">
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" aria-hidden="true" />
<input
id="webhook-secret"
type={showSecret ? 'text' : 'password'}
required
minLength={16}
maxLength={128}
value={secret}
onChange={(event) => setSecret(event.target.value)}
placeholder="Tối thiểu 16 ký tự"
className="h-11 w-full rounded-lg border border-slate-300 py-2 pl-10 pr-12 font-mono text-sm text-slate-700 outline-none transition placeholder:font-sans placeholder:text-slate-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100"
/>
<button type="button" onClick={() => setShowSecret((current) => !current)} aria-label={showSecret ? 'Ẩn signing secret' : 'Hiện signing secret'} className="absolute right-1 top-1/2 inline-flex h-9 w-9 -translate-y-1/2 cursor-pointer items-center justify-center rounded-md text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500">
{showSecret ? <EyeOff className="h-4 w-4" aria-hidden="true" /> : <Eye className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<div className="mt-2 flex gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs leading-5 text-amber-800">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
Lưu secret này trong hệ thống nhận webhook để xác minh header X-Webhook-Signature.
</div>
</div>
<div className="flex flex-col-reverse gap-2 border-t border-slate-100 pt-5 sm:flex-row sm:justify-end">
<button type="button" onClick={closeCreate} disabled={creating} className="min-h-11 cursor-pointer rounded-lg border border-slate-200 px-4 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-60">Hủy</button>
<button type="submit" disabled={creating} className="inline-flex min-h-11 cursor-pointer items-center justify-center rounded-lg bg-indigo-600 px-4 text-sm font-semibold text-white transition hover:bg-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60">
{creating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : <Plus className="mr-2 h-4 w-4" aria-hidden="true" />}
{creating ? 'Đang tạo...' : 'Tạo webhook'}
</button>
</div>
</form>
</div>
</div>
)}
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/50 p-4 backdrop-blur-sm">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl" role="alertdialog" aria-modal="true" aria-labelledby="delete-webhook-title">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-rose-50 text-rose-600">
<Trash2 className="h-5 w-5" aria-hidden="true" />
</div>
<h2 id="delete-webhook-title" className="mt-4 text-lg font-bold text-slate-900">Xóa webhook này?</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">Endpoint <strong className="break-all font-mono text-slate-900">{deleteTarget.url}</strong> sẽ ngừng nhận sự kiện. Lịch sử delivery liên quan cũng sẽ bị xóa.</p>
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" onClick={() => setDeleteTarget(null)} disabled={deleting} className="min-h-11 cursor-pointer rounded-lg border border-slate-200 px-4 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-60">Hủy</button>
<button type="button" onClick={() => void handleDelete()} disabled={deleting} className="inline-flex min-h-11 cursor-pointer items-center justify-center rounded-lg bg-rose-600 px-4 text-sm font-semibold text-white transition hover:bg-rose-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60">
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : <Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />}
{deleting ? 'Đang xóa...' : 'Xóa webhook'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
...@@ -16,6 +16,38 @@ export interface CreatedApiKey extends ApiKeyRecord { ...@@ -16,6 +16,38 @@ export interface CreatedApiKey extends ApiKeyRecord {
rawKey: string; rawKey: string;
} }
export type WebhookEvent = 'job.completed' | 'job.failed';
export type WebhookDeliveryStatus = 'PENDING' | 'SUCCESS' | 'FAILED';
export interface WebhookConfig {
id: string;
userId: string;
url: string;
isActive: boolean;
events: WebhookEvent[];
createdAt: string;
updatedAt: string;
}
export interface WebhookDelivery {
id: string;
webhookConfigId: string;
crawlJobId: string;
event: WebhookEvent;
payload: unknown;
status: WebhookDeliveryStatus;
statusCode: number | null;
attempt: number;
responseBody: string | null;
errorMessage: string | null;
deliveredAt: string | null;
createdAt: string;
updatedAt: string;
webhookConfig: {
url: string;
};
}
interface ApiResponse<T> { interface ApiResponse<T> {
success: boolean; success: boolean;
data: T; data: T;
...@@ -133,6 +165,36 @@ export const apiKeysApi = { ...@@ -133,6 +165,36 @@ export const apiKeysApi = {
}, },
}; };
export const webhooksApi = {
async listConfigs(): Promise<WebhookConfig[]> {
const response = await api.get<ApiResponse<WebhookConfig[]>>('/webhooks/configs');
return response.data.data;
},
async createConfig(input: {
url: string;
secret: string;
events: WebhookEvent[];
}): Promise<WebhookConfig> {
const response = await api.post<ApiResponse<WebhookConfig>>('/webhooks/configs', input);
return response.data.data;
},
async deleteConfig(id: string): Promise<void> {
await api.delete(`/webhooks/configs/${id}`);
},
async listDeliveries(filters: {
jobId?: string;
status?: WebhookDeliveryStatus;
} = {}): Promise<WebhookDelivery[]> {
const response = await api.get<ApiResponse<WebhookDelivery[]>>('/webhooks/deliveries', {
params: filters,
});
return response.data.data;
},
};
function notifyAuthExpired() { function notifyAuthExpired() {
localStorage.removeItem('accessToken'); localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken'); localStorage.removeItem('refreshToken');
......
...@@ -59,3 +59,20 @@ test('profile rejects no-op submissions instead of reporting success', () => { ...@@ -59,3 +59,20 @@ test('profile rejects no-op submissions instead of reporting success', () => {
assert.match(profile, /Bạn chưa thay đổi thông tin nào/); assert.match(profile, /Bạn chưa thay đổi thông tin nào/);
assert.match(profile, /profileChanged/); assert.match(profile, /profileChanged/);
}); });
test('webhook management is routed and integrates the supported backend endpoints', () => {
const app = read('App.tsx');
const layout = read('layouts/DashboardLayout.tsx');
const pageTitle = read('components/PageTitle.tsx');
const api = read('services/api.ts');
const webhooks = read('pages/Webhooks.tsx');
assert.match(app, /path="webhooks"/);
assert.match(layout, /Quản lý Webhook/);
assert.match(pageTitle, /Quản lý Webhook \| Data Crawler/);
assert.match(api, /\/webhooks\/configs/);
assert.match(api, /\/webhooks\/deliveries/);
assert.match(webhooks, /job\.completed/);
assert.match(webhooks, /job\.failed/);
assert.match(webhooks, /X-Webhook-Signature/);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment