2025-12-12 18:04:57 +08:00
|
|
|
|
"use client"
|
|
|
|
|
|
|
|
|
|
|
|
import React from "react"
|
|
|
|
|
|
import { ColumnDef } from "@tanstack/react-table"
|
|
|
|
|
|
import { Button } from "@/components/ui/button"
|
|
|
|
|
|
import { Badge } from "@/components/ui/badge"
|
|
|
|
|
|
import { Checkbox } from "@/components/ui/checkbox"
|
2025-12-13 19:34:56 +08:00
|
|
|
|
import { ChevronsUpDown, ChevronUp, ChevronDown } from "lucide-react"
|
2025-12-12 18:04:57 +08:00
|
|
|
|
import type { Endpoint } from "@/types/endpoint.types"
|
2025-12-13 19:34:56 +08:00
|
|
|
|
import { TruncatedCell, TruncatedUrlCell } from "@/components/ui/truncated-cell"
|
2025-12-12 18:04:57 +08:00
|
|
|
|
|
|
|
|
|
|
interface CreateColumnsProps {
|
|
|
|
|
|
formatDate: (dateString: string) => string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function DataTableColumnHeader({
|
|
|
|
|
|
column,
|
|
|
|
|
|
title,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
column: { getCanSort: () => boolean; getIsSorted: () => false | "asc" | "desc"; toggleSorting: (desc?: boolean) => void }
|
|
|
|
|
|
title: string
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (!column.getCanSort()) {
|
|
|
|
|
|
return <div className="-ml-3 font-medium">{title}</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isSorted = column.getIsSorted()
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
|
|
|
|
className="-ml-3 h-8 data-[state=open]:bg-accent hover:bg-muted"
|
|
|
|
|
|
>
|
|
|
|
|
|
{title}
|
|
|
|
|
|
{isSorted === "asc" ? (
|
|
|
|
|
|
<ChevronUp />
|
|
|
|
|
|
) : isSorted === "desc" ? (
|
|
|
|
|
|
<ChevronDown />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ChevronsUpDown />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function HttpStatusBadge({ statusCode }: { statusCode: number | null | undefined }) {
|
|
|
|
|
|
if (statusCode === null || statusCode === undefined) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Badge variant="outline" className="text-muted-foreground px-2 py-1 font-mono">
|
|
|
|
|
|
-
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const getStatusVariant = (code: number): "default" | "secondary" | "destructive" | "outline" => {
|
|
|
|
|
|
if (code >= 200 && code < 300) {
|
|
|
|
|
|
return "outline"
|
|
|
|
|
|
} else if (code >= 300 && code < 400) {
|
|
|
|
|
|
return "secondary"
|
|
|
|
|
|
} else if (code >= 400 && code < 500) {
|
|
|
|
|
|
return "default"
|
|
|
|
|
|
} else if (code >= 500) {
|
|
|
|
|
|
return "destructive"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return "secondary"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const variant = getStatusVariant(statusCode)
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Badge variant={variant} className="px-2 py-1 font-mono tabular-nums">
|
|
|
|
|
|
{statusCode}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 11:14:46 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Body Preview 单元格组件 - 最多显示3行,超出折叠,点击展开查看完整内容
|
|
|
|
|
|
*/
|
|
|
|
|
|
function BodyPreviewCell({ value }: { value: string | null | undefined }) {
|
|
|
|
|
|
const [expanded, setExpanded] = React.useState(false)
|
|
|
|
|
|
|
|
|
|
|
|
if (!value) {
|
|
|
|
|
|
return <span className="text-muted-foreground text-sm">-</span>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`text-sm text-muted-foreground break-all leading-relaxed whitespace-normal cursor-pointer hover:text-foreground transition-colors ${!expanded ? 'line-clamp-3' : ''}`}
|
|
|
|
|
|
onClick={() => setExpanded(!expanded)}
|
|
|
|
|
|
title={expanded ? "点击收起" : "点击展开"}
|
|
|
|
|
|
>
|
|
|
|
|
|
{value}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{value.length > 100 && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setExpanded(!expanded)}
|
|
|
|
|
|
className="text-xs text-primary hover:underline self-start"
|
|
|
|
|
|
>
|
|
|
|
|
|
{expanded ? "收起" : "展开"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-12 18:04:57 +08:00
|
|
|
|
export function createEndpointColumns({
|
|
|
|
|
|
formatDate,
|
|
|
|
|
|
}: CreateColumnsProps): ColumnDef<Endpoint>[] {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "select",
|
2025-12-22 10:04:27 +08:00
|
|
|
|
size: 40,
|
|
|
|
|
|
minSize: 40,
|
|
|
|
|
|
maxSize: 40,
|
|
|
|
|
|
enableResizing: false,
|
2025-12-12 18:04:57 +08:00
|
|
|
|
header: ({ table }) => (
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={
|
|
|
|
|
|
table.getIsAllPageRowsSelected() ||
|
|
|
|
|
|
(table.getIsSomePageRowsSelected() && "indeterminate")
|
|
|
|
|
|
}
|
|
|
|
|
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
|
|
|
|
|
aria-label="Select all"
|
|
|
|
|
|
/>
|
|
|
|
|
|
),
|
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={row.getIsSelected()}
|
|
|
|
|
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
|
|
|
|
aria-label="Select row"
|
|
|
|
|
|
/>
|
|
|
|
|
|
),
|
|
|
|
|
|
enableSorting: false,
|
|
|
|
|
|
enableHiding: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "url",
|
|
|
|
|
|
header: ({ column }) => (
|
|
|
|
|
|
<DataTableColumnHeader column={column} title="URL" />
|
|
|
|
|
|
),
|
2025-12-22 10:04:27 +08:00
|
|
|
|
size: 400,
|
2025-12-12 18:04:57 +08:00
|
|
|
|
minSize: 200,
|
2025-12-22 10:04:27 +08:00
|
|
|
|
maxSize: 700,
|
|
|
|
|
|
cell: ({ row }) => {
|
|
|
|
|
|
const url = row.getValue("url") as string
|
|
|
|
|
|
return <TruncatedUrlCell value={url} />
|
|
|
|
|
|
},
|
2025-12-12 18:04:57 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "title",
|
|
|
|
|
|
header: ({ column }) => (
|
|
|
|
|
|
<DataTableColumnHeader column={column} title="Title" />
|
|
|
|
|
|
),
|
2025-12-22 10:04:27 +08:00
|
|
|
|
size: 150,
|
|
|
|
|
|
minSize: 100,
|
|
|
|
|
|
maxSize: 300,
|
2025-12-13 19:34:56 +08:00
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
|
<TruncatedCell value={row.getValue("title")} maxLength="title" />
|
|
|
|
|
|
),
|
2025-12-12 18:04:57 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "statusCode",
|
|
|
|
|
|
header: ({ column }) => (
|
|
|
|
|
|
<DataTableColumnHeader column={column} title="Status" />
|
|
|
|
|
|
),
|
2025-12-22 10:04:27 +08:00
|
|
|
|
size: 80,
|
|
|
|
|
|
minSize: 60,
|
|
|
|
|
|
maxSize: 100,
|
2025-12-12 18:04:57 +08:00
|
|
|
|
cell: ({ row }) => {
|
|
|
|
|
|
const status = row.getValue("statusCode") as number | null | undefined
|
|
|
|
|
|
return <HttpStatusBadge statusCode={status} />
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "contentLength",
|
|
|
|
|
|
header: ({ column }) => (
|
|
|
|
|
|
<DataTableColumnHeader column={column} title="Content Length" />
|
|
|
|
|
|
),
|
2025-12-22 10:04:27 +08:00
|
|
|
|
size: 100,
|
|
|
|
|
|
minSize: 80,
|
|
|
|
|
|
maxSize: 150,
|
2025-12-12 18:04:57 +08:00
|
|
|
|
cell: ({ row }) => {
|
|
|
|
|
|
const len = row.getValue("contentLength") as number | null | undefined
|
|
|
|
|
|
if (len === null || len === undefined) {
|
|
|
|
|
|
return <span className="text-muted-foreground text-sm">-</span>
|
|
|
|
|
|
}
|
|
|
|
|
|
return <span className="font-mono tabular-nums">{new Intl.NumberFormat().format(len)}</span>
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "location",
|
|
|
|
|
|
header: ({ column }) => (
|
|
|
|
|
|
<DataTableColumnHeader column={column} title="Location" />
|
|
|
|
|
|
),
|
2025-12-22 10:04:27 +08:00
|
|
|
|
size: 150,
|
|
|
|
|
|
minSize: 100,
|
|
|
|
|
|
maxSize: 300,
|
2025-12-13 19:34:56 +08:00
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
|
<TruncatedCell value={row.getValue("location")} maxLength="location" />
|
|
|
|
|
|
),
|
2025-12-12 18:04:57 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "webserver",
|
|
|
|
|
|
header: ({ column }) => (
|
|
|
|
|
|
<DataTableColumnHeader column={column} title="Web Server" />
|
|
|
|
|
|
),
|
2025-12-22 10:04:27 +08:00
|
|
|
|
size: 120,
|
|
|
|
|
|
minSize: 80,
|
|
|
|
|
|
maxSize: 200,
|
2025-12-13 19:34:56 +08:00
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
|
<TruncatedCell value={row.getValue("webserver")} maxLength="webServer" />
|
|
|
|
|
|
),
|
2025-12-12 18:04:57 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "contentType",
|
|
|
|
|
|
header: ({ column }) => (
|
|
|
|
|
|
<DataTableColumnHeader column={column} title="Content Type" />
|
|
|
|
|
|
),
|
2025-12-22 10:04:27 +08:00
|
|
|
|
size: 120,
|
|
|
|
|
|
minSize: 80,
|
|
|
|
|
|
maxSize: 200,
|
2025-12-13 19:34:56 +08:00
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
|
<TruncatedCell value={row.getValue("contentType")} maxLength="contentType" />
|
|
|
|
|
|
),
|
2025-12-12 18:04:57 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "tech",
|
|
|
|
|
|
header: ({ column }) => (
|
|
|
|
|
|
<DataTableColumnHeader column={column} title="Technologies" />
|
|
|
|
|
|
),
|
2025-12-22 10:04:27 +08:00
|
|
|
|
size: 200,
|
|
|
|
|
|
minSize: 150,
|
2025-12-12 18:04:57 +08:00
|
|
|
|
cell: ({ row }) => {
|
|
|
|
|
|
const tech = (row.getValue("tech") as string[] | null | undefined) || []
|
|
|
|
|
|
if (!tech.length) return <span className="text-sm text-muted-foreground">-</span>
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2025-12-22 11:14:46 +08:00
|
|
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
|
|
|
|
{tech.map((t, index) => (
|
2025-12-12 18:04:57 +08:00
|
|
|
|
<Badge key={index} variant="outline" className="text-xs">
|
|
|
|
|
|
{t}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "bodyPreview",
|
|
|
|
|
|
header: ({ column }) => (
|
|
|
|
|
|
<DataTableColumnHeader column={column} title="Body Preview" />
|
|
|
|
|
|
),
|
2025-12-22 11:14:46 +08:00
|
|
|
|
size: 350,
|
|
|
|
|
|
minSize: 250,
|
2025-12-13 19:34:56 +08:00
|
|
|
|
cell: ({ row }) => (
|
2025-12-22 11:14:46 +08:00
|
|
|
|
<BodyPreviewCell value={row.getValue("bodyPreview")} />
|
2025-12-13 19:34:56 +08:00
|
|
|
|
),
|
2025-12-12 18:04:57 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "vhost",
|
|
|
|
|
|
header: ({ column }) => (
|
|
|
|
|
|
<DataTableColumnHeader column={column} title="VHost" />
|
|
|
|
|
|
),
|
2025-12-22 10:04:27 +08:00
|
|
|
|
size: 80,
|
|
|
|
|
|
minSize: 60,
|
|
|
|
|
|
maxSize: 100,
|
2025-12-12 18:04:57 +08:00
|
|
|
|
cell: ({ row }) => {
|
|
|
|
|
|
const vhost = row.getValue("vhost") as boolean | null | undefined
|
|
|
|
|
|
if (vhost === null || vhost === undefined) return <span className="text-sm text-muted-foreground">-</span>
|
|
|
|
|
|
return <span className="text-sm font-mono">{vhost ? "true" : "false"}</span>
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "tags",
|
|
|
|
|
|
header: ({ column }) => (
|
|
|
|
|
|
<DataTableColumnHeader column={column} title="Tags" />
|
|
|
|
|
|
),
|
2025-12-22 10:04:27 +08:00
|
|
|
|
size: 150,
|
|
|
|
|
|
minSize: 100,
|
|
|
|
|
|
maxSize: 250,
|
2025-12-12 18:04:57 +08:00
|
|
|
|
cell: ({ row }) => {
|
|
|
|
|
|
const tags = (row.getValue("tags") as string[] | null | undefined) || []
|
|
|
|
|
|
if (!tags.length) {
|
|
|
|
|
|
return <span className="text-muted-foreground text-sm">-</span>
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-wrap gap-1">
|
|
|
|
|
|
{tags.map((tag, idx) => (
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
key={idx}
|
|
|
|
|
|
variant={/xss|sqli|idor|rce|ssrf|lfi|rfi|xxe|csrf|open.?redirect|interesting/i.test(tag) ? "destructive" : "secondary"}
|
|
|
|
|
|
className="text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
{tag}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
enableSorting: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "responseTime",
|
|
|
|
|
|
header: ({ column }) => (
|
|
|
|
|
|
<DataTableColumnHeader column={column} title="Response Time" />
|
|
|
|
|
|
),
|
2025-12-22 10:04:27 +08:00
|
|
|
|
size: 100,
|
|
|
|
|
|
minSize: 80,
|
|
|
|
|
|
maxSize: 150,
|
2025-12-12 18:04:57 +08:00
|
|
|
|
cell: ({ row }) => {
|
|
|
|
|
|
const rt = row.getValue("responseTime") as number | null | undefined
|
|
|
|
|
|
if (rt === null || rt === undefined) {
|
|
|
|
|
|
return <span className="text-muted-foreground text-sm">-</span>
|
|
|
|
|
|
}
|
|
|
|
|
|
const formatted = `${rt.toFixed(4)}s`
|
|
|
|
|
|
return <span className="font-mono text-emerald-600 dark:text-emerald-400">{formatted}</span>
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "discoveredAt",
|
|
|
|
|
|
header: ({ column }) => (
|
|
|
|
|
|
<DataTableColumnHeader column={column} title="Discovered At" />
|
|
|
|
|
|
),
|
2025-12-22 10:04:27 +08:00
|
|
|
|
size: 150,
|
|
|
|
|
|
minSize: 120,
|
|
|
|
|
|
maxSize: 200,
|
2025-12-12 18:04:57 +08:00
|
|
|
|
cell: ({ row }) => {
|
|
|
|
|
|
const discoveredAt = row.getValue("discoveredAt") as string | undefined
|
|
|
|
|
|
return <div className="text-sm">{discoveredAt ? formatDate(discoveredAt) : "-"}</div>
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|