Commit c380673d authored by ThinhNC's avatar ThinhNC

feat(profile,dashboard): add user ID & joined date, password toggles, and cleanup health bar

parent e8a24824
......@@ -131,10 +131,10 @@ Tất cả các trang khi phát triển mới hoặc bảo trì **BẮT BUỘC P
---
## 8. Quy Chuẩn Giao Diện Dropdown & Đa Ngôn Ngữ (Dropdown & i18n Standards)
## 9. Quy Chuẩn Giao Diện Dropdown & Đa Ngôn Ngữ (Dropdown & i18n Standards)
- **Quy chuẩn Custom Dropdown (ThemeToggle Pattern)**:
- **TUYỆT ĐỐI KHÔNG** dùng thẻ `<select>` mặc định của trình duyệt cho các bộ chọn tùy chỉnh trong modal/form.
- Bắt buộc thiết kế Custom Dropdown theo phong cách của `ThemeToggle`:
- Bắt buộc thiết kế Custom Dropdown theo phong cách của `ThemeToggle` hoặc sử dụng `PageSizeSelect` (`src/components/common/page-size-select.tsx`) cho bộ chọn số dòng mỗi trang.
- **Trigger Button**: Bo góc mềm `rounded-2xl`, viền `border-border/80`, có icon phân loại, tên tùy chọn đang chọn và icon `<ChevronDown />` xoay 180° khi mở (`isDropdownOpen ? "rotate-180" : ""`).
- **Menu Popup**: `absolute``rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150`.
- **Option Item**: Thẻ `button` bo góc `rounded-xl px-2.5 py-2 text-xs flex items-center justify-between`. Khi được chọn (`isSelected`), hiển thị nền xanh nhẹ `bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold border border-emerald-500/20` kèm icon `<Check />`.
......@@ -144,3 +144,29 @@ Tất cả các trang khi phát triển mới hoặc bảo trì **BẮT BUỘC P
- **Đồng bộ hóa 100% Tiếng Anh**: Mọi khóa trong `translations.vi` đều phải có khóa tương ứng trong `translations.en`.
- **Định dạng ngày tháng theo Locale**: Hàm `formatDate(date, locale)` bắt buộc phải nhận `locale` từ hook `useLanguage()`, định dạng `en-US` khi Tiếng Anh và `vi-VN` khi Tiếng Việt để tránh dính chữ tiếng Việt (như `thg`) trên giao diện tiếng Anh.
---
## 10. Quy Chuẩn Thiết Kế & Đồng Bộ Bảng Dữ Liệu (Unified Table Standards) - BẮT BUỘC
Tất cả các bảng dữ liệu trong hệ thống (Tác vụ cào, Nhật ký kiểm toán, Quản lý người dùng, Tệp xuất,...) **BẮT BUỘC PHẢI TUÂN THỦ ĐỒNG BỘ 100%**:
- **Cấu trúc Thẻ Bảng Liền Mạch (Unified Table Card)**:
- Bảng dữ liệu và thanh phân trang chân bảng **BẮT BUỘC** phải nằm chung bên trong một card bo góc duy nhất: `overflow-hidden rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm transition-colors`.
- **TUYỆT ĐỐI KHÔNG** đặt thanh phân trang tách rời thành một container bên ngoài bảng.
- **Thanh Phân Trang Chân Bảng (TablePagination Component)**:
- Sử dụng component dùng chung `TablePagination` (`src/components/common/table-pagination.tsx`).
- Định dạng hiển thị bắt buộc:
- Bên trái: `Tổng số: <X> • Trang <Y> trên <Z>` (Việt) / `Total: <X> • Page <Y> of <Z>` (Anh).
- Bên phải: Nhãn `bản ghi / trang:` kết hợp component `PageSizeSelect` (mặc định mở hướng lên `direction="up"`) và 2 nút phân trang bo tròn nhỏ gọn `[<]``[>]`.
- Phân tách với phần thân bảng bằng viền `border-t border-border/70 bg-muted/20 px-4 py-3 text-xs text-muted-foreground`.
- **Tiêu Đề Cột Có Sắp Xếp (TableSortHeader Component)**:
- Tất cả các cột có hỗ trợ sắp xếp **BẮT BUỘC** sử dụng component `TableSortHeader` (`src/components/common/table-sort-header.tsx`).
- **Quy chuẩn hiển thị trạng thái**:
- Khi cột đang được sắp xếp: Chữ in đậm màu xanh emerald (`text-emerald-600 dark:text-emerald-400 font-bold`) kèm icon `<ArrowUp />` khi tăng dần (`asc`) hoặc `<ArrowDown />` khi giảm dần (`desc`).
- Khi cột chưa kích hoạt: Hiển thị icon `<ArrowUpDown />` với độ mờ nhẹ (`text-muted-foreground/50`), làm sáng màu khi hover.
- **Tránh Xung Đột Lớp Hiển Thị Dropdown & Bảng (Stacking Context & Z-Index)**:
- Khối bộ lọc phía trên bảng phải có `relative z-30` (hoặc `z-20`).
- Container bảng dữ liệu phải có `relative z-10` (hoặc `z-0`).
- Ngăn ngừa hiện tượng menu dropdown của bộ lọc bị bảng dữ liệu bên dưới che khuất.
- **Tối Giản Bố Cục Màn Hình Phân Hệ Quản Trị / Nhật Ký**:
- Không đặt các ô thống kê KPI rườm rà (Tổng số, Tạo mới, Cập nhật, Xóa) trên màn hình Nhật ký kiểm toán (`/audit-logs`) để tập trung tối đa không gian cho bảng dữ liệu dòng thời gian và bộ lọc nâng cao.
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Nhật Ký Kiểm Toán",
description:
"Nhật ký kiểm toán và an toàn hệ thống, theo dõi toàn bộ hoạt động, thay đổi dữ liệu và truy cập an ninh theo thời gian thực trong Data Crawler.",
};
export default function AuditLogsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
"use client";
import React from "react";
import { AuditLogsView } from "@/components/audit-logs/audit-logs-view";
export default function AuditLogsPage() {
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 animate-in fade-in-50 duration-200">
<AuditLogsView showHeader={true} />
</div>
);
}
import type { Metadata } from "next";
import { AuditLogsView } from "@/components/audit-logs/audit-logs-view";
export const metadata: Metadata = {
title: "Nhật Ký Kiểm Toán | Nhà Phát Triển",
description:
"Theo dõi toàn bộ các hoạt động, thay đổi dữ liệu và sự kiện an ninh hệ thống trong Data Crawler.",
};
export default function DeveloperAuditLogsPage() {
return (
<div className="space-y-6 animate-in fade-in-50 duration-200">
<AuditLogsView showHeader={false} />
</div>
);
}
This diff is collapsed.
import { redirect } from "next/navigation";
import type { Metadata } from "next";
import { UsersManagementView } from "@/components/users/users-management-view";
export const metadata: Metadata = {
title: "Quản Lý Người Dùng | Data Crawler",
description:
"Giao diện quản trị thành viên, phân quyền vai trò RBAC và kiểm soát trạng thái tài khoản người dùng trong hệ thống Data Crawler.",
};
export default function UsersPage() {
redirect("/settings/developer/users");
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 animate-in fade-in-50 duration-200">
<UsersManagementView showHeader={true} />
</div>
);
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { History, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import { useAuditLogs } from "@/hooks/use-audit-logs";
import { AuditLogItem, AuditLogQuery } from "@/types/audit-log";
import { AuditLogsFilters, DatePreset } from "./audit-logs-filters";
import { AuditLogsTable } from "./audit-logs-table";
import { AuditLogDetailDrawer } from "./audit-log-detail-drawer";
interface AuditLogsViewProps {
showHeader?: boolean;
}
export function AuditLogsView({ showHeader = true }: AuditLogsViewProps) {
const { t } = useLanguage();
// Filter States
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [action, setAction] = useState("");
const [datePreset, setDatePreset] = useState<DatePreset>("all");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [sortBy, setSortBy] = useState<"createdAt" | "action">("createdAt");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
// Drawer detail selection
const [selectedLog, setSelectedLog] = useState<AuditLogItem | null>(null);
const handleToggleSort = (column: "createdAt" | "action") => {
if (sortBy === column) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(column);
setSortOrder("desc");
}
setPage(1);
};
// Debounce search input by 350ms
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search.trim());
setPage(1);
}, 350);
return () => clearTimeout(timer);
}, [search]);
// Calculate ISO date strings based on preset
const { queryStartDate, queryEndDate } = useMemo(() => {
if (datePreset === "all") {
return { queryStartDate: undefined, queryEndDate: undefined };
}
const now = new Date();
if (datePreset === "today") {
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
return {
queryStartDate: startOfDay.toISOString(),
queryEndDate: now.toISOString(),
};
}
if (datePreset === "7d") {
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
return {
queryStartDate: sevenDaysAgo.toISOString(),
queryEndDate: now.toISOString(),
};
}
if (datePreset === "30d") {
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
return {
queryStartDate: thirtyDaysAgo.toISOString(),
queryEndDate: now.toISOString(),
};
}
if (datePreset === "custom") {
return {
queryStartDate: startDate ? new Date(startDate).toISOString() : undefined,
queryEndDate: endDate ? new Date(`${endDate}T23:59:59.999Z`).toISOString() : undefined,
};
}
return { queryStartDate: undefined, queryEndDate: undefined };
}, [datePreset, startDate, endDate]);
// Build query object
const queryParams: AuditLogQuery = useMemo(() => {
const q: AuditLogQuery = {
page,
limit,
sortBy,
order: sortOrder,
};
if (debouncedSearch) {
q.search = debouncedSearch;
}
if (action) {
q.action = action;
}
if (queryStartDate) {
q.startDate = queryStartDate;
}
if (queryEndDate) {
q.endDate = queryEndDate;
}
return q;
}, [page, limit, debouncedSearch, action, queryStartDate, queryEndDate, sortBy, sortOrder]);
// Fetch Audit Logs
const { data, isLoading, isError, isFetching, refetch } = useAuditLogs(queryParams);
const logs = data?.items || [];
const meta = data?.meta || { total: 0, page: 1, limit: 20, totalPages: 1 };
const totalLogs = meta.total;
// Reset Filters
const handleResetFilters = () => {
setSearch("");
setDebouncedSearch("");
setAction("");
setDatePreset("all");
setStartDate("");
setEndDate("");
setPage(1);
};
const hasActiveFilters = Boolean(
search || action || datePreset !== "all" || startDate || endDate
);
return (
<div className="space-y-6">
{/* Top Header Section (If enabled) */}
{showHeader && (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-1">
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<History className="h-3.5 w-3.5" />
<span>{t.auditLogs.badge}</span>
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground">
{t.auditLogs.title}
</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
{t.auditLogs.subtitle}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching || isLoading}
className="rounded-2xl border-border/80 text-xs font-semibold hover:bg-emerald-500/10 hover:text-emerald-600 dark:hover:text-emerald-400 hover:border-emerald-500/30 transition-all cursor-pointer shadow-xs"
>
<RefreshCw
className={`h-3.5 w-3.5 mr-1.5 ${
isFetching || isLoading ? "animate-spin text-emerald-500" : ""
}`}
/>
<span>{t.auditLogs.refreshBtn}</span>
</Button>
</div>
</div>
)}
{/* Filters Bar Component */}
<div className="relative z-30 rounded-3xl border border-emerald-500/15 bg-card/60 p-4 sm:p-5 backdrop-blur-sm shadow-xs">
<AuditLogsFilters
search={search}
onSearchChange={setSearch}
action={action}
onActionChange={(newAction) => {
setAction(newAction);
setPage(1);
}}
datePreset={datePreset}
onDatePresetChange={(preset) => {
setDatePreset(preset);
setPage(1);
}}
startDate={startDate}
endDate={endDate}
onCustomDateChange={(s, e) => {
setStartDate(s);
setEndDate(e);
setPage(1);
}}
onReset={handleResetFilters}
hasActiveFilters={hasActiveFilters}
/>
</div>
{/* Timeline Table Component */}
<div className="relative z-10">
<AuditLogsTable
logs={logs}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
onSelectLog={(log) => setSelectedLog(log)}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleToggleSort}
page={page}
totalPages={meta.totalPages}
total={meta.total}
limit={limit}
onPageChange={(p) => setPage(p)}
onLimitChange={(l) => {
setLimit(l);
setPage(1);
}}
/>
</div>
{/* Slide-over Detail Drawer */}
<AuditLogDetailDrawer
log={selectedLog}
isOpen={Boolean(selectedLog)}
onClose={() => setSelectedLog(null)}
/>
</div>
);
}
"use client";
import React, { useState, useRef, useEffect } from "react";
import { ChevronDown, Check, Rows3 } from "lucide-react";
import { cn } from "@/lib/utils";
export interface PageSizeSelectProps {
value: number;
onChange: (value: number) => void;
options?: number[];
direction?: "up" | "down";
className?: string;
disabled?: boolean;
unitLabel?: string;
}
const DEFAULT_OPTIONS = [10, 20, 50, 100];
export function PageSizeSelect({
value,
onChange,
options = DEFAULT_OPTIONS,
direction = "up",
className,
disabled = false,
unitLabel,
}: PageSizeSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click or Escape key
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
const handleSelect = (opt: number) => {
onChange(opt);
setIsOpen(false);
};
return (
<div
ref={containerRef}
className={cn("relative inline-block text-left", className)}
>
{/* Trigger Button - Conforming strictly to ThemeToggle pattern */}
<button
type="button"
disabled={disabled}
onClick={() => !disabled && setIsOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label="Chọn số dòng mỗi trang"
className={cn(
"inline-flex h-8 items-center justify-between gap-2 rounded-2xl border px-2.5 text-xs font-semibold shadow-xs transition-all cursor-pointer",
isOpen
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 ring-2 ring-emerald-500/20"
: "border-border/80 bg-card text-foreground hover:bg-muted/70 hover:border-emerald-500/30",
disabled && "opacity-50 cursor-not-allowed pointer-events-none"
)}
>
<div className="flex items-center gap-1.5">
<Rows3 className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400 shrink-0" />
<span className="tabular-nums font-mono">{value}</span>
{unitLabel && (
<span className="text-muted-foreground font-normal text-[11px]">
{unitLabel}
</span>
)}
</div>
<ChevronDown
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 shrink-0",
isOpen && "rotate-180 text-emerald-600 dark:text-emerald-400"
)}
/>
</button>
{/* Popup Menu - ThemeToggle Pattern */}
{isOpen && (
<div
role="listbox"
aria-label="Số dòng mỗi trang"
className={cn(
"absolute left-0 min-w-[5.5rem] rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150",
direction === "up" ? "bottom-full mb-1.5 origin-bottom-left" : "top-full mt-1.5 origin-top-left"
)}
>
<div className="space-y-0.5">
{options.map((opt) => {
const isSelected = opt === value;
return (
<button
key={opt}
role="option"
aria-selected={isSelected}
type="button"
onClick={() => handleSelect(opt)}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-xl px-2.5 py-1.5 text-xs transition-colors cursor-pointer text-left font-mono",
isSelected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold border border-emerald-500/20"
: "text-foreground hover:bg-muted/70 border border-transparent"
)}
>
<span>{opt}</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400 shrink-0" />
)}
</button>
);
})}
</div>
</div>
)}
</div>
);
}
"use client";
import React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PageSizeSelect } from "@/components/common/page-size-select";
import { useLanguage } from "@/providers/language-provider";
import { cn } from "@/lib/utils";
export interface TablePaginationProps {
page: number;
totalPages: number;
total: number;
limit: number;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
options?: number[];
totalLabel?: string;
perPageLabel?: string;
isLoading?: boolean;
className?: string;
}
export function TablePagination({
page,
totalPages,
total,
limit,
onPageChange,
onLimitChange,
options = [10, 20, 50, 100],
totalLabel,
perPageLabel,
isLoading = false,
className,
}: TablePaginationProps) {
const { t } = useLanguage();
return (
<div
className={cn(
"flex flex-col sm:flex-row items-center justify-between gap-3 px-4 py-3 border-t border-border/70 bg-muted/20 text-xs text-muted-foreground select-none",
className
)}
>
{/* Left: Total & Page info */}
<div className="flex items-center gap-2">
<span>
{totalLabel ?? t.table.totalJobs}:{" "}
<strong className="text-foreground font-semibold font-mono">
{total.toLocaleString()}
</strong>
</span>
<span className="text-muted-foreground/60"></span>
<span>
{t.table.page}{" "}
<strong className="text-foreground font-semibold font-mono">
{page}
</strong>{" "}
{t.table.of}{" "}
<strong className="text-foreground font-semibold font-mono">
{totalPages || 1}
</strong>
</span>
</div>
{/* Right: Page size select & Prev/Next buttons */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{perPageLabel ?? t.table.perPage}:</span>
<PageSizeSelect
value={limit}
onChange={onLimitChange}
options={options}
direction="up"
disabled={isLoading}
/>
</div>
{/* Prev / Next Page Buttons */}
<div className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
disabled={page <= 1 || isLoading}
onClick={() => onPageChange(Math.max(1, page - 1))}
className="h-7 w-7 p-0 rounded-xl border border-border/80 bg-background hover:bg-muted text-muted-foreground hover:text-foreground shadow-xs transition-colors disabled:opacity-40 cursor-pointer"
title={t.table.page}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages || isLoading}
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
className="h-7 w-7 p-0 rounded-xl border border-border/80 bg-background hover:bg-muted text-muted-foreground hover:text-foreground shadow-xs transition-colors disabled:opacity-40 cursor-pointer"
title={t.table.page}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
);
}
"use client";
import React from "react";
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface TableSortHeaderProps {
label: string;
field: string;
currentSortField?: string;
currentSortOrder?: "asc" | "desc";
onSort: (field: string) => void;
className?: string;
}
export function TableSortHeader({
label,
field,
currentSortField,
currentSortOrder = "desc",
onSort,
className,
}: TableSortHeaderProps) {
const isActive = currentSortField === field;
return (
<button
type="button"
onClick={() => onSort(field)}
className={cn(
"inline-flex items-center gap-1.5 cursor-pointer select-none transition-colors group hover:text-foreground text-left text-xs font-semibold focus:outline-none",
isActive
? "text-emerald-600 dark:text-emerald-400 font-bold"
: "text-muted-foreground",
className
)}
>
<span>{label}</span>
{isActive ? (
currentSortOrder === "asc" ? (
<ArrowUp className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400 shrink-0 transition-transform" />
) : (
<ArrowDown className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400 shrink-0 transition-transform" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50 group-hover:text-muted-foreground shrink-0 transition-colors" />
)}
</button>
);
}
......@@ -23,6 +23,8 @@ import { Input } from "@/components/ui/input";
import { formatDate, formatNumber } from "@/lib/utils";
import { CrawlJob, CrawlJobStatus } from "@/types/crawl-job";
import { useLanguage } from "@/providers/language-provider";
import { TableSortHeader } from "@/components/common/table-sort-header";
import { TablePagination } from "@/components/common/table-pagination";
import {
AlertCircle,
ArrowUpDown,
......@@ -276,36 +278,36 @@ export function CrawlerTaskTable() {
</TableHead>
{/* Column 2: Start URL / Domain */}
<TableHead
onClick={() => handleToggleSort("startUrl")}
className="cursor-pointer hover:text-foreground font-semibold text-xs py-3.5"
>
<div className="flex items-center gap-1.5">
<span>{t.table.name}</span>
<ArrowUpDown className="h-3 w-3 text-muted-foreground" />
</div>
<TableHead className="py-3.5">
<TableSortHeader
label={t.table.name}
field="startUrl"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => handleToggleSort(f as typeof sortBy)}
/>
</TableHead>
{/* Column 3: Status & Created Date (Sorts by createdAt) */}
<TableHead
onClick={() => handleToggleSort("createdAt")}
className="cursor-pointer hover:text-foreground font-semibold text-xs py-3.5"
>
<div className="flex items-center gap-1.5">
<span>{t.table.status}</span>
<ArrowUpDown className="h-3 w-3 text-muted-foreground" />
</div>
<TableHead className="py-3.5">
<TableSortHeader
label={t.table.status}
field="createdAt"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => handleToggleSort(f as typeof sortBy)}
/>
</TableHead>
{/* Column 4: Crawl Progress */}
<TableHead
onClick={() => handleToggleSort("totalPages")}
className="cursor-pointer hover:text-foreground font-semibold text-xs py-3.5 min-w-[170px] max-[767px]:hidden"
>
<div className="flex items-center gap-1.5">
<span>{t.table.progress}</span>
<ArrowUpDown className="h-3 w-3 text-muted-foreground" />
</div>
<TableHead className="py-3.5 min-w-[170px] max-[767px]:hidden">
<TableSortHeader
label={t.table.progress}
field="totalPages"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => handleToggleSort(f as typeof sortBy)}
/>
</TableHead>
{/* Column 5: Extracted Items */}
......@@ -532,59 +534,18 @@ export function CrawlerTaskTable() {
</Table>
{/* 6. Server-Side Pagination Bar */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-4 py-3 border-t border-border/70 bg-muted/20 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span>
{t.table.totalJobs}: <strong className="text-foreground">{total}</strong>
</span>
<span></span>
<span>
{t.table.page} <strong className="text-foreground">{page}</strong> {t.table.of}{" "}
<strong className="text-foreground">{totalPages}</strong>
</span>
</div>
<div className="flex items-center gap-3">
{/* Page size select */}
<div className="flex items-center gap-1.5">
<span>{t.table.perPage}:</span>
<select
value={limit}
onChange={(e) => {
setLimit(Number(e.target.value));
setPage(1);
}}
className="rounded-xl border border-border/80 bg-background px-2 py-1 text-xs text-foreground cursor-pointer"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</div>
{/* Prev / Next Page Buttons */}
<div className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
className="h-7 w-7 p-0 rounded-xl border border-border/80 bg-background hover:bg-muted text-muted-foreground hover:text-foreground shadow-xs transition-colors disabled:opacity-40"
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
className="h-7 w-7 p-0 rounded-xl border border-border/80 bg-background hover:bg-muted text-muted-foreground hover:text-foreground shadow-xs transition-colors disabled:opacity-40"
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<TablePagination
page={page}
totalPages={totalPages}
total={total}
limit={limit}
onPageChange={(p) => setPage(p)}
onLimitChange={(l) => {
setLimit(l);
setPage(1);
}}
options={[10, 20, 50, 100]}
/>
</div>
)}
</div>
......
......@@ -16,7 +16,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
export function ServiceHealthBar() {
const { readiness, metrics, isLoading, isFetching, refetch } = useServiceHealth();
const { readiness, metrics, liveness, isLoading, isFetching, refetch } = useServiceHealth();
const { t } = useLanguage();
const dbCheck = readiness?.checks?.database;
......@@ -26,7 +26,11 @@ export function ServiceHealthBar() {
const isRedisUp = redisCheck?.status === "up";
const isDegraded = redisCheck?.status === "degraded";
const isOverallHealthy = readiness?.status === "ready" && isDbUp && (isRedisUp || isDegraded);
const isNodeHealthy = liveness ? liveness.status === "ok" : true;
const nodeUptimeSeconds = liveness?.uptimeSeconds ?? metrics?.process?.uptimeSeconds ?? 0;
const isOverallHealthy =
readiness?.status === "ready" && isDbUp && (isRedisUp || isDegraded) && isNodeHealthy;
// Queue metrics from /health/metrics
const crawlQueue = typeof metrics?.queues?.crawl === "object" ? metrics.queues.crawl : null;
......@@ -34,7 +38,7 @@ export function ServiceHealthBar() {
const activeJobs = crawlQueue?.active ?? 0;
// Process uptime & RAM
const uptimeSeconds = metrics?.process?.uptimeSeconds ?? 0;
const uptimeSeconds = nodeUptimeSeconds;
const formatUptime = (sec: number) => {
if (sec < 60) return `${sec}s`;
const mins = Math.floor(sec / 60);
......
......@@ -10,6 +10,7 @@ import {
Terminal,
Users,
Shield,
History,
} from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
......@@ -27,6 +28,7 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
const pathname = usePathname();
const canManageUsers = hasPermission("users.read") || role === "ADMIN";
const canManageRoles = hasPermission("roles.read") || role === "ADMIN";
const canReadAuditLogs = hasPermission("audit_logs.read") || role === "ADMIN";
// Counts for badges
const { data: apiKeys = [] } = useApiKeysList();
......@@ -45,6 +47,9 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
pathname === "/settings/developer";
const isWebhooksTab = pathname.startsWith("/settings/developer/webhooks");
const isDocsTab = pathname.startsWith("/settings/developer/docs");
const isAuditLogsTab =
pathname.startsWith("/settings/developer/audit-logs") ||
pathname.startsWith("/audit-logs");
return (
<div className="min-h-screen bg-background pb-16">
......@@ -151,6 +156,21 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
<Terminal className="h-4 w-4" />
<span>{t.developer.tabs.docs}</span>
</Link>
{/* 5. Tab Nhật Ký Kiểm Toán (Audit Logs) */}
{canReadAuditLogs && (
<Link
href="/settings/developer/audit-logs"
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer whitespace-nowrap ${
isAuditLogsTab
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<History className="h-4 w-4" />
<span>{t.nav.auditLogs}</span>
</Link>
)}
</div>
{/* Tab Page Content */}
......
......@@ -19,6 +19,8 @@ import { Button } from "@/components/ui/button";
import { UserItem } from "@/types/user";
import { useLanguage } from "@/providers/language-provider";
import { formatDate } from "@/lib/utils";
import { TableSortHeader } from "@/components/common/table-sort-header";
import { TablePagination } from "@/components/common/table-pagination";
interface UserTableProps {
users: UserItem[];
......@@ -32,6 +34,15 @@ interface UserTableProps {
onCreateNew: () => void;
currentUserId?: string;
isTogglingStatus?: boolean;
sortBy?: string;
sortOrder?: "asc" | "desc";
onSort?: (field: string) => void;
page?: number;
totalPages?: number;
total?: number;
limit?: number;
onPageChange?: (page: number) => void;
onLimitChange?: (limit: number) => void;
}
export function UserTable({
......@@ -46,6 +57,15 @@ export function UserTable({
onCreateNew,
currentUserId,
isTogglingStatus,
sortBy,
sortOrder,
onSort,
page,
totalPages,
total,
limit,
onPageChange,
onLimitChange,
}: UserTableProps) {
const { t, locale } = useLanguage();
......@@ -221,27 +241,52 @@ export function UserTable({
{/* Table Header */}
<thead className="border-b border-border/70 bg-muted/40 text-[11px] uppercase tracking-wider text-muted-foreground select-none">
<tr>
<th scope="col" className="px-5 py-3.5 font-semibold">
{t.users.table.user}
<th scope="col" className="px-5 py-3.5">
<TableSortHeader
label={t.users.table.user}
field="fullName"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => onSort?.(f)}
/>
</th>
<th scope="col" className="px-4 py-3.5 font-semibold">
{t.users.table.email}
<th scope="col" className="px-4 py-3.5">
<TableSortHeader
label={t.users.table.email}
field="email"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => onSort?.(f)}
/>
</th>
<th scope="col" className="px-4 py-3.5 font-semibold">
<th scope="col" className="px-4 py-3.5 font-semibold text-muted-foreground">
{t.users.table.roles}
</th>
<th scope="col" className="px-4 py-3.5 font-semibold text-center">
{t.users.table.status}
<th scope="col" className="px-4 py-3.5 text-center">
<TableSortHeader
label={t.users.table.status}
field="isActive"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => onSort?.(f)}
className="justify-center"
/>
</th>
<th
scope="col"
className="hidden lg:table-cell px-4 py-3.5 font-semibold"
className="hidden lg:table-cell px-4 py-3.5"
>
{t.users.table.createdAt}
<TableSortHeader
label={t.users.table.createdAt}
field="createdAt"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => onSort?.(f)}
/>
</th>
<th
scope="col"
className="px-5 py-3.5 font-semibold text-right"
className="px-5 py-3.5 font-semibold text-right text-muted-foreground"
>
{t.users.table.actions}
</th>
......@@ -406,6 +451,20 @@ export function UserTable({
</tbody>
</table>
</div>
{/* Integrated Pagination Footer */}
{typeof total === "number" && total > 0 && onPageChange && onLimitChange && (
<TablePagination
page={page || 1}
totalPages={totalPages || 1}
total={total}
limit={limit || 20}
onPageChange={onPageChange}
onLimitChange={onLimitChange}
isLoading={isLoading}
options={[10, 20, 50, 100]}
/>
)}
</div>
);
}
......@@ -13,8 +13,6 @@ import {
UserX,
Shield,
RotateCcw,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
......@@ -46,6 +44,18 @@ export function UsersManagementView({
const [roleFilter, setRoleFilter] = useState<string>("ALL");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [sortBy, setSortBy] = useState<string>("createdAt");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const handleToggleSort = (column: string) => {
if (sortBy === column) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(column);
setSortOrder("desc");
}
setPage(1);
};
// Dropdown open states
const [isStatusDropdownOpen, setIsStatusDropdownOpen] = useState(false);
......@@ -114,8 +124,10 @@ export function UsersManagementView({
role: roleFilter === "ALL" ? undefined : roleFilter,
page,
limit,
sortBy,
order: sortOrder,
};
}, [debouncedSearch, statusFilter, roleFilter, page, limit]);
}, [debouncedSearch, statusFilter, roleFilter, page, limit, sortBy, sortOrder]);
// Fetch users with TanStack Query
const {
......@@ -126,7 +138,24 @@ export function UsersManagementView({
isFetching,
} = useUsersList(queryParams);
const users: UserItem[] = usersData?.items || [];
const rawUsers: UserItem[] = usersData?.items || [];
const users = useMemo(() => {
if (!sortBy) return rawUsers;
return [...rawUsers].sort((a, b) => {
let aVal: any = a[sortBy as keyof UserItem];
let bVal: any = b[sortBy as keyof UserItem];
if (sortBy === "fullName") {
aVal = a.fullName || a.email;
bVal = b.fullName || b.email;
}
if (aVal === bVal) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
const cmp = aVal < bVal ? -1 : 1;
return sortOrder === "asc" ? cmp : -cmp;
});
}, [rawUsers, sortBy, sortOrder]);
const meta = usersData?.meta || {
total: 0,
page: 1,
......@@ -311,7 +340,7 @@ export function UsersManagementView({
</div>
{/* Filter & Search Toolbar */}
<div className="flex flex-col lg:flex-row gap-3 items-stretch lg:items-center justify-between">
<div className="relative z-20 flex flex-col lg:flex-row gap-3 items-stretch lg:items-center justify-between">
{/* Search Bar */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
......@@ -452,70 +481,33 @@ export function UsersManagementView({
</div>
{/* Users Table */}
<UserTable
users={users}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
onEdit={(u) => setEditingUser(u)}
onManageRoles={(u) => setRolesUser(u)}
onDelete={(u) => setDeletingUser(u)}
onToggleStatus={handleToggleStatus}
onCreateNew={() => setIsCreateModalOpen(true)}
currentUserId={currentUser?.id}
isTogglingStatus={updateMutation.isPending}
/>
{/* Pagination */}
{meta.totalPages > 1 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 pt-1">
<p className="text-xs text-muted-foreground">
{t.users.pagination.showing}{" "}
<span className="font-semibold text-foreground">
{(page - 1) * limit + 1}
</span>{" "}
-{" "}
<span className="font-semibold text-foreground">
{Math.min(page * limit, meta.total)}
</span>{" "}
{t.users.pagination.of}{" "}
<span className="font-semibold text-foreground">{meta.total}</span>{" "}
{t.users.pagination.users}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || isFetching}
className="rounded-xl h-8 px-3 text-xs cursor-pointer"
>
<ChevronLeft className="h-3.5 w-3.5 mr-1" />
{t.users.pagination.prev}
</Button>
<div className="flex items-center gap-1 text-xs font-medium px-2">
<span className="text-emerald-600 dark:text-emerald-400 font-bold">
{meta.page}
</span>
<span className="text-muted-foreground">/</span>
<span className="text-muted-foreground">{meta.totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(meta.totalPages, p + 1))}
disabled={page >= meta.totalPages || isFetching}
className="rounded-xl h-8 px-3 text-xs cursor-pointer"
>
{t.users.pagination.next}
<ChevronRight className="h-3.5 w-3.5 ml-1" />
</Button>
</div>
</div>
)}
<div className="relative z-10">
<UserTable
users={users}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
onEdit={(u) => setEditingUser(u)}
onManageRoles={(u) => setRolesUser(u)}
onDelete={(u) => setDeletingUser(u)}
onToggleStatus={handleToggleStatus}
onCreateNew={() => setIsCreateModalOpen(true)}
currentUserId={currentUser?.id}
isTogglingStatus={updateMutation.isPending}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleToggleSort}
page={page}
totalPages={meta.totalPages}
total={meta.total}
limit={limit}
onPageChange={(p) => setPage(p)}
onLimitChange={(l) => {
setLimit(l);
setPage(1);
}}
/>
</div>
{/* Modals & Dialogs */}
{isCreateModalOpen && (
......
import { useQuery } from "@tanstack/react-query";
import { auditLogService } from "@/services/audit-log.service";
import { AuditLogQuery } from "@/types/audit-log";
export const AUDIT_LOGS_QUERY_KEYS = {
all: ["audit-logs"] as const,
list: (query?: AuditLogQuery) => ["audit-logs", "list", query] as const,
};
export function useAuditLogs(query?: AuditLogQuery) {
return useQuery({
queryKey: AUDIT_LOGS_QUERY_KEYS.list(query),
queryFn: () => auditLogService.getAuditLogs(query),
placeholderData: (previousData) => previousData,
staleTime: 10000,
retry: 1,
});
}
......@@ -4,6 +4,7 @@ import { systemService } from "@/services/system.service";
export const SYSTEM_QUERY_KEYS = {
readiness: ["system", "readiness"] as const,
metrics: ["system", "metrics"] as const,
liveness: ["system", "liveness"] as const,
health: ["system", "health"] as const,
};
......@@ -27,19 +28,35 @@ export function useServiceMetrics() {
});
}
export function useServiceLiveness() {
return useQuery({
queryKey: SYSTEM_QUERY_KEYS.liveness,
queryFn: () => systemService.getLiveness(),
refetchInterval: 15000,
staleTime: 10000,
retry: 1,
});
}
export function useServiceHealth() {
const readinessQuery = useServiceReadiness();
const metricsQuery = useServiceMetrics();
const livenessQuery = useServiceLiveness();
return {
readiness: readinessQuery.data,
metrics: metricsQuery.data,
isLoading: readinessQuery.isLoading || metricsQuery.isLoading,
isFetching: readinessQuery.isFetching || metricsQuery.isFetching,
isError: readinessQuery.isError || metricsQuery.isError,
error: readinessQuery.error || metricsQuery.error,
liveness: livenessQuery.data,
isLoading: readinessQuery.isLoading || metricsQuery.isLoading || livenessQuery.isLoading,
isFetching: readinessQuery.isFetching || metricsQuery.isFetching || livenessQuery.isFetching,
isError: readinessQuery.isError || metricsQuery.isError || livenessQuery.isError,
error: readinessQuery.error || metricsQuery.error || livenessQuery.error,
refetch: async () => {
await Promise.all([readinessQuery.refetch(), metricsQuery.refetch()]);
await Promise.all([
readinessQuery.refetch(),
metricsQuery.refetch(),
livenessQuery.refetch(),
]);
},
};
}
This diff is collapsed.
......@@ -29,3 +29,105 @@ export function formatNumber(value: number | string): string {
if (isNaN(num)) return String(value);
return new Intl.NumberFormat("en-US").format(num);
}
export function formatRelativeTime(
date: string | Date | number,
locale: string = "vi"
): string {
const now = Date.now();
const target = new Date(date).getTime();
if (isNaN(target)) return "";
const diffSec = Math.floor((now - target) / 1000);
const isEn = locale === "en";
if (diffSec < 45) {
return isEn ? "Just now" : "Vừa xong";
}
if (diffSec < 90) {
return isEn ? "1 minute ago" : "1 phút trước";
}
if (diffSec < 3600) {
const mins = Math.floor(diffSec / 60);
return isEn ? `${mins} minutes ago` : `${mins} phút trước`;
}
if (diffSec < 7200) {
return isEn ? "1 hour ago" : "1 giờ trước";
}
if (diffSec < 86400) {
const hours = Math.floor(diffSec / 3600);
return isEn ? `${hours} hours ago` : `${hours} giờ trước`;
}
if (diffSec < 172800) {
return isEn ? "Yesterday" : "Hôm qua";
}
if (diffSec < 604800) {
const days = Math.floor(diffSec / 86400);
return isEn ? `${days} days ago` : `${days} ngày trước`;
}
return formatDate(date, locale);
}
export interface ParsedUserAgent {
browser: string;
os: string;
short: string;
full: string;
}
export function parseUserAgent(uaString?: string | null): ParsedUserAgent {
if (!uaString) {
return {
browser: "Unknown",
os: "Unknown",
short: "Unknown",
full: "Unknown",
};
}
const ua = uaString;
// Browser detection
let browser = "Browser";
if (ua.includes("Edg/")) {
browser = "Edge";
} else if (ua.includes("Chrome/") && !ua.includes("Edg/")) {
browser = "Chrome";
} else if (ua.includes("Firefox/")) {
browser = "Firefox";
} else if (ua.includes("Safari/") && !ua.includes("Chrome/")) {
browser = "Safari";
} else if (ua.includes("PostmanRuntime")) {
browser = "Postman";
} else if (ua.toLowerCase().includes("curl")) {
browser = "cURL";
} else if (ua.toLowerCase().includes("axios")) {
browser = "Axios";
} else if (ua.toLowerCase().includes("node")) {
browser = "Node.js";
} else if (ua.toLowerCase().includes("bot") || ua.toLowerCase().includes("spider") || ua.toLowerCase().includes("crawler")) {
browser = "Bot/Crawler";
}
// OS detection
let os = "System";
if (ua.includes("Windows NT 10.0") || ua.includes("Windows")) {
os = "Windows";
} else if (ua.includes("Macintosh") || ua.includes("Mac OS X")) {
os = "macOS";
} else if (ua.includes("iPhone") || ua.includes("iPad")) {
os = "iOS";
} else if (ua.includes("Android")) {
os = "Android";
} else if (ua.includes("Linux") && !ua.includes("Android")) {
os = "Linux";
}
return {
browser,
os,
short: `${browser}${os}`,
full: uaString,
};
}
......@@ -18,6 +18,7 @@ const ADMIN_ROUTES = [
"/roles",
"/settings/developer/users",
"/settings/developer/roles",
"/settings/developer/audit-logs",
];
// Open public pages that do NOT require login (e.g. 403 forbidden, 404 not-found)
......
import apiClient from "@/lib/api-client";
import { AuditLogQuery, AuditLogResponse } from "@/types/audit-log";
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export const auditLogService = {
/**
* Lấy danh sách nhật ký kiểm toán với các bộ lọc phân trang
* GET /audit-logs (qua BFF Proxy)
*/
async getAuditLogs(query?: AuditLogQuery): Promise<AuditLogResponse> {
const response = await apiClient.get<ApiResponse<AuditLogResponse>>("/audit-logs", {
params: query,
});
if (response.data && response.data.data) {
return response.data.data;
}
return (response.data as unknown) as AuditLogResponse;
},
};
import apiClient, { ApiError } from "@/lib/api-client";
import { SystemMetricsResponse, SystemReadinessResponse } from "@/types/system";
import { SystemLivenessResponse, SystemMetricsResponse, SystemReadinessResponse } from "@/types/system";
import axios from "axios";
export const systemService = {
......@@ -66,4 +66,28 @@ export const systemService = {
};
}
},
/**
* Lấy trạng thái sống còn (Liveness) của tiến trình Node.js máy chủ
* GET /health/liveness (qua BFF Proxy)
*/
async getLiveness(): Promise<SystemLivenessResponse> {
try {
const response = await apiClient.get<SystemLivenessResponse>("/health/liveness");
return response.data;
} catch (error: unknown) {
if (error instanceof ApiError && error.data && typeof error.data === "object" && "status" in error.data) {
return error.data as SystemLivenessResponse;
}
if (axios.isAxiosError(error) && error.response?.data && typeof error.response.data === "object" && "status" in error.response.data) {
return error.response.data as SystemLivenessResponse;
}
return {
status: "degraded",
uptimeSeconds: 0,
timestamp: new Date().toISOString(),
nodeVersion: "unknown",
};
}
},
};
export type AuditActionCategory = "CREATE" | "UPDATE" | "DELETE" | "LOGIN" | "OTHER";
export interface AuditLogUser {
id: string;
email: string;
fullName: string | null;
role: string | null;
}
export interface AuditLogDetails {
jobId?: string;
startUrl?: string;
mode?: string;
userId?: string;
targetUserId?: string;
roleId?: string;
slug?: string;
templateId?: string;
apiKeyId?: string;
keyPrefix?: string;
webhookConfigId?: string;
exportId?: string;
updatedFields?: string[];
before?: Record<string, unknown>;
after?: Record<string, unknown>;
changes?: {
before?: Record<string, unknown>;
after?: Record<string, unknown>;
};
error?: string;
message?: string;
payload?: Record<string, unknown>;
status?: string;
[key: string]: unknown;
}
export interface AuditLogItem {
id: string;
userId: string | null;
action: string;
details: AuditLogDetails | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: string;
user: AuditLogUser | null;
}
export interface AuditLogQuery {
userId?: string;
action?: string;
startDate?: string;
endDate?: string;
search?: string;
sortBy?: "createdAt" | "action";
order?: "asc" | "desc";
page?: number;
limit?: number;
}
export interface AuditLogMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface AuditLogResponse {
items: AuditLogItem[];
meta: AuditLogMeta;
}
export function getActionCategory(action: string): AuditActionCategory {
const act = action.toUpperCase();
if (act.includes("CREATE") || act.includes("ASSIGN")) return "CREATE";
if (act.includes("UPDATE") || act.includes("CHANGE") || act.includes("REDELIVER")) return "UPDATE";
if (
act.includes("DELETE") ||
act.includes("CANCEL") ||
act.includes("REVOKE") ||
act.includes("DEACTIVATE") ||
act.includes("BLOCKED") ||
act.includes("ATTEMPT")
) {
return "DELETE";
}
if (
act.includes("LOGIN") ||
act.includes("LOGOUT") ||
act.includes("REGISTER") ||
act.includes("PASSWORD") ||
act.includes("VERIF")
) {
return "LOGIN";
}
return "OTHER";
}
export function getTargetResource(log: AuditLogItem): {
type: string;
label: string;
id?: string;
} {
const d = log.details || {};
if (d.jobId) return { type: "Job", label: `Job #${String(d.jobId).slice(0, 8)}`, id: String(d.jobId) };
if (d.targetUserId) return { type: "User", label: `User #${String(d.targetUserId).slice(0, 8)}`, id: String(d.targetUserId) };
if (d.roleId || d.slug) return { type: "Role", label: `Role ${d.slug || "#" + String(d.roleId).slice(0, 8)}`, id: String(d.roleId || d.slug) };
if (d.templateId) return { type: "Template", label: `Template #${String(d.templateId).slice(0, 8)}`, id: String(d.templateId) };
if (d.apiKeyId || d.keyPrefix) return { type: "API Key", label: `API Key ${d.keyPrefix ? d.keyPrefix + "..." : "#" + String(d.apiKeyId).slice(0, 8)}`, id: String(d.apiKeyId) };
if (d.webhookConfigId) return { type: "Webhook", label: `Webhook #${String(d.webhookConfigId).slice(0, 8)}`, id: String(d.webhookConfigId) };
if (d.exportId) return { type: "Export", label: `Export #${String(d.exportId).slice(0, 8)}`, id: String(d.exportId) };
if (d.userId && d.userId !== log.userId) return { type: "User", label: `User #${String(d.userId).slice(0, 8)}`, id: String(d.userId) };
// Fallback to action keyword
const act = log.action.toUpperCase();
if (act.includes("JOB")) return { type: "Job", label: "Job" };
if (act.includes("USER")) return { type: "User", label: "User" };
if (act.includes("ROLE") || act.includes("PERMISSION")) return { type: "Role", label: "Role" };
if (act.includes("KEY")) return { type: "API Key", label: "API Key" };
if (act.includes("WEBHOOK")) return { type: "Webhook", label: "Webhook" };
if (act.includes("EXPORT")) return { type: "Export", label: "Export" };
return { type: "System", label: "System" };
}
......@@ -48,3 +48,10 @@ export interface SystemMetricsResponse {
webhook: QueueMetricsResult;
};
}
export interface SystemLivenessResponse {
status: "ok" | string;
uptimeSeconds: number;
timestamp: string;
nodeVersion: string;
}
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