mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-02-01 20:23:23 +08:00
338 lines
9.7 KiB
TypeScript
338 lines
9.7 KiB
TypeScript
"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"
|
||
import { ChevronsUpDown, ChevronUp, ChevronDown } from "lucide-react"
|
||
import type { Endpoint } from "@/types/endpoint.types"
|
||
import { TruncatedCell, TruncatedUrlCell } from "@/components/ui/truncated-cell"
|
||
|
||
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>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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>
|
||
)
|
||
}
|
||
|
||
export function createEndpointColumns({
|
||
formatDate,
|
||
}: CreateColumnsProps): ColumnDef<Endpoint>[] {
|
||
return [
|
||
{
|
||
id: "select",
|
||
size: 40,
|
||
minSize: 40,
|
||
maxSize: 40,
|
||
enableResizing: false,
|
||
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" />
|
||
),
|
||
size: 400,
|
||
minSize: 200,
|
||
maxSize: 700,
|
||
cell: ({ row }) => {
|
||
const url = row.getValue("url") as string
|
||
return <TruncatedUrlCell value={url} />
|
||
},
|
||
},
|
||
{
|
||
accessorKey: "title",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="Title" />
|
||
),
|
||
size: 150,
|
||
minSize: 100,
|
||
maxSize: 300,
|
||
cell: ({ row }) => (
|
||
<TruncatedCell value={row.getValue("title")} maxLength="title" />
|
||
),
|
||
},
|
||
{
|
||
accessorKey: "statusCode",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="Status" />
|
||
),
|
||
size: 80,
|
||
minSize: 60,
|
||
maxSize: 100,
|
||
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" />
|
||
),
|
||
size: 100,
|
||
minSize: 80,
|
||
maxSize: 150,
|
||
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" />
|
||
),
|
||
size: 150,
|
||
minSize: 100,
|
||
maxSize: 300,
|
||
cell: ({ row }) => (
|
||
<TruncatedCell value={row.getValue("location")} maxLength="location" />
|
||
),
|
||
},
|
||
{
|
||
accessorKey: "webserver",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="Web Server" />
|
||
),
|
||
size: 120,
|
||
minSize: 80,
|
||
maxSize: 200,
|
||
cell: ({ row }) => (
|
||
<TruncatedCell value={row.getValue("webserver")} maxLength="webServer" />
|
||
),
|
||
},
|
||
{
|
||
accessorKey: "contentType",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="Content Type" />
|
||
),
|
||
size: 120,
|
||
minSize: 80,
|
||
maxSize: 200,
|
||
cell: ({ row }) => (
|
||
<TruncatedCell value={row.getValue("contentType")} maxLength="contentType" />
|
||
),
|
||
},
|
||
{
|
||
accessorKey: "tech",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="Technologies" />
|
||
),
|
||
size: 200,
|
||
minSize: 150,
|
||
cell: ({ row }) => {
|
||
const tech = (row.getValue("tech") as string[] | null | undefined) || []
|
||
if (!tech.length) return <span className="text-sm text-muted-foreground">-</span>
|
||
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
{tech.map((t, index) => (
|
||
<Badge key={index} variant="outline" className="text-xs">
|
||
{t}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
accessorKey: "bodyPreview",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="Body Preview" />
|
||
),
|
||
size: 350,
|
||
minSize: 250,
|
||
cell: ({ row }) => (
|
||
<BodyPreviewCell value={row.getValue("bodyPreview")} />
|
||
),
|
||
},
|
||
{
|
||
accessorKey: "vhost",
|
||
header: ({ column }) => (
|
||
<DataTableColumnHeader column={column} title="VHost" />
|
||
),
|
||
size: 80,
|
||
minSize: 60,
|
||
maxSize: 100,
|
||
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" />
|
||
),
|
||
size: 150,
|
||
minSize: 100,
|
||
maxSize: 250,
|
||
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" />
|
||
),
|
||
size: 100,
|
||
minSize: 80,
|
||
maxSize: 150,
|
||
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" />
|
||
),
|
||
size: 150,
|
||
minSize: 120,
|
||
maxSize: 200,
|
||
cell: ({ row }) => {
|
||
const discoveredAt = row.getValue("discoveredAt") as string | undefined
|
||
return <div className="text-sm">{discoveredAt ? formatDate(discoveredAt) : "-"}</div>
|
||
},
|
||
},
|
||
]
|
||
}
|