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
Expand all
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
This diff is collapsed.
Click to expand it.
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