Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
frontend-data-crawler-be
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
BangNSK
frontend-data-crawler-be
Commits
37bafeba
Commit
37bafeba
authored
Aug 04, 2026
by
BangNSK
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add webhook management interface
parent
a5729e5c
Changes
6
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
785 additions
and
6 deletions
+785
-6
App.tsx
src/App.tsx
+2
-0
PageTitle.tsx
src/components/PageTitle.tsx
+1
-0
DashboardLayout.tsx
src/layouts/DashboardLayout.tsx
+42
-6
Webhooks.tsx
src/pages/Webhooks.tsx
+661
-0
api.ts
src/services/api.ts
+62
-0
feedback-regression.test.mjs
tests/feedback-regression.test.mjs
+17
-0
No files found.
src/App.tsx
View file @
37bafeba
...
@@ -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
...
...
src/components/PageTitle.tsx
View file @
37bafeba
...
@@ -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'
,
};
};
...
...
src/layouts/DashboardLayout.tsx
View file @
37bafeba
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 */
}
...
...
src/pages/Webhooks.tsx
0 → 100644
View file @
37bafeba
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
>
);
}
src/services/api.ts
View file @
37bafeba
...
@@ -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'
);
...
...
tests/feedback-regression.test.mjs
View file @
37bafeba
...
@@ -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/
);
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment