Files
xingrin/frontend/components/endpoints/endpoints-columns.tsx
2025-12-22 11:14:46 +08:00

338 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
},
},
]
}