Files
xingrin/frontend/components/ip-addresses/ip-addresses-view.tsx
yyhuni a5c48fe4d4 feat(frontend,backend): implement IP address management and export functionality
- Add IP address DTO, handler, service, and repository layers in Go backend
- Implement IP address bulk delete endpoint at /ip-addresses/bulk-delete/
- Add IP address export endpoint with optional IP filtering by target
- Simplify IP address hosts column display using ExpandableCell component
- Update IP address export to support filtering selected IPs for download
- Add error handling and toast notifications for export operations
- Internationalize IP address column labels and tooltips in Chinese
- Update IP address service to support filtered exports with comma-separated IPs
- Add host-port mapping seeding for test data generation
- Refactor scope filter and repository queries to support IP address operations
2026-01-13 16:42:57 +08:00

316 lines
9.8 KiB
TypeScript

"use client"
import React, { useCallback, useMemo, useState } from "react"
import { AlertTriangle } from "lucide-react"
import { useTranslations, useLocale } from "next-intl"
import { IPAddressesDataTable } from "./ip-addresses-data-table"
import { createIPAddressColumns } from "./ip-addresses-columns"
import { DataTableSkeleton } from "@/components/ui/data-table-skeleton"
import { Button } from "@/components/ui/button"
import { useTargetIPAddresses, useScanIPAddresses } from "@/hooks/use-ip-addresses"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { getDateLocale } from "@/lib/date-utils"
import type { IPAddress } from "@/types/ip-address.types"
import { IPAddressService } from "@/services/ip-address.service"
import { toast } from "sonner"
export function IPAddressesView({
targetId,
scanId,
}: {
targetId?: number
scanId?: number
}) {
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
})
const [selectedIPAddresses, setSelectedIPAddresses] = useState<IPAddress[]>([])
const [filterQuery, setFilterQuery] = useState("")
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
// Internationalization
const tColumns = useTranslations("columns")
const tCommon = useTranslations("common")
const tTooltips = useTranslations("tooltips")
const tToast = useTranslations("toast")
const tStatus = useTranslations("common.status")
const locale = useLocale()
// Build translation object
const translations = useMemo(() => ({
columns: {
ipAddress: tColumns("ipAddress.ipAddress"),
hosts: tColumns("ipAddress.hosts"),
createdAt: tColumns("common.createdAt"),
openPorts: tColumns("ipAddress.openPorts"),
},
actions: {
selectAll: tCommon("actions.selectAll"),
selectRow: tCommon("actions.selectRow"),
},
tooltips: {
allHosts: tTooltips("allHosts"),
allOpenPorts: tTooltips("allOpenPorts"),
},
}), [tColumns, tCommon, tTooltips])
const handleFilterChange = (value: string) => {
setFilterQuery(value)
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
}
const targetQuery = useTargetIPAddresses(
targetId || 0,
{
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
filter: filterQuery || undefined,
},
{ enabled: !!targetId }
)
const scanQuery = useScanIPAddresses(
scanId || 0,
{
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
filter: filterQuery || undefined,
},
{ enabled: !!scanId }
)
const activeQuery = targetId ? targetQuery : scanQuery
const { data, isLoading, error, refetch } = activeQuery
const formatDate = useCallback((dateString: string) => {
return new Date(dateString).toLocaleString(getDateLocale(locale), {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
}, [locale])
const columns = useMemo(
() =>
createIPAddressColumns({
formatDate,
t: translations,
}),
[formatDate, translations]
)
const ipAddresses: IPAddress[] = useMemo(() => {
return data?.results ?? []
}, [data])
const paginationInfo = data
? {
total: data.total,
page: data.page,
pageSize: data.pageSize,
totalPages: data.totalPages,
}
: undefined
const handleSelectionChange = useCallback((selectedRows: IPAddress[]) => {
setSelectedIPAddresses(selectedRows)
}, [])
// Handle download all IP addresses
const handleDownloadAll = async () => {
try {
let blob: Blob | null = null
if (scanId) {
blob = await IPAddressService.exportIPAddressesByScanId(scanId)
} else if (targetId) {
blob = await IPAddressService.exportIPAddressesByTargetId(targetId)
} else {
if (!ipAddresses || ipAddresses.length === 0) {
return
}
// Frontend CSV generation (fallback when no scanId/targetId)
const csvContent = generateCSV(ipAddresses)
blob = new Blob([csvContent], { type: "text/csv;charset=utf-8" })
}
if (!blob) return
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
const prefix = scanId ? `scan-${scanId}` : targetId ? `target-${targetId}` : "ip-addresses"
a.href = url
a.download = `${prefix}-ip-addresses-${Date.now()}.csv`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (error) {
console.error("Failed to download IP address list", error)
toast.error(tToast("downloadFailed"))
}
}
// Format date as YYYY-MM-DD HH:MM:SS (consistent with backend)
const formatDateForCSV = (dateString: string): string => {
if (!dateString) return ''
const date = new Date(dateString)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
// Generate CSV content (original format: one row per host+port combination)
const generateCSV = (items: IPAddress[]): string => {
const BOM = '\ufeff'
const headers = ['ip', 'host', 'port', 'created_at']
const escapeCSV = (value: string): string => {
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
return `"${value.replace(/"/g, '""')}"`
}
return value
}
// Expand aggregated data to original format: one row per (ip, host, port) combination
const rows: string[] = []
for (const item of items) {
for (const host of item.hosts) {
for (const port of item.ports) {
rows.push([
escapeCSV(item.ip),
escapeCSV(host),
escapeCSV(String(port)),
escapeCSV(formatDateForCSV(item.createdAt))
].join(','))
}
}
}
return BOM + [headers.join(','), ...rows].join('\n')
}
// Handle download selected IP addresses
const handleDownloadSelected = async () => {
if (selectedIPAddresses.length === 0) {
return
}
try {
// Get selected IPs and call backend export API
const ips = selectedIPAddresses.map(ip => ip.ip)
let blob: Blob | null = null
if (targetId) {
blob = await IPAddressService.exportIPAddressesByTargetId(targetId, ips)
} else if (scanId) {
// For scan, use frontend CSV generation as fallback (scan export doesn't support IP filter yet)
const csvContent = generateCSV(selectedIPAddresses)
blob = new Blob([csvContent], { type: "text/csv;charset=utf-8" })
} else {
const csvContent = generateCSV(selectedIPAddresses)
blob = new Blob([csvContent], { type: "text/csv;charset=utf-8" })
}
if (!blob) return
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
const prefix = scanId ? `scan-${scanId}` : targetId ? `target-${targetId}` : "ip-addresses"
a.href = url
a.download = `${prefix}-ip-addresses-selected-${Date.now()}.csv`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (error) {
console.error("Failed to download selected IP addresses", error)
toast.error(tToast("downloadFailed"))
}
}
// Handle bulk delete
const handleBulkDelete = async () => {
if (selectedIPAddresses.length === 0) return
setIsDeleting(true)
try {
// IP addresses are aggregated, pass IP strings instead of IDs
const ips = selectedIPAddresses.map(ip => ip.ip)
const result = await IPAddressService.bulkDelete(ips)
toast.success(tToast("deleteSuccess", { count: result.deletedCount }))
setSelectedIPAddresses([])
setDeleteDialogOpen(false)
refetch()
} catch (error) {
console.error("Failed to delete IP addresses", error)
toast.error(tToast("deleteFailed"))
} finally {
setIsDeleting(false)
}
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12">
<div className="rounded-full bg-destructive/10 p-3 mb-4">
<AlertTriangle className="h-10 w-10 text-destructive" />
</div>
<h3 className="text-lg font-semibold mb-2">{tStatus("error")}</h3>
<p className="text-muted-foreground text-center mb-4">
{error.message || tStatus("error")}
</p>
<Button onClick={() => refetch()}>{tCommon("actions.retry")}</Button>
</div>
)
}
if (isLoading && !data) {
return (
<DataTableSkeleton
toolbarButtonCount={1}
rows={6}
columns={4}
/>
)
}
return (
<>
<IPAddressesDataTable
data={ipAddresses}
columns={columns}
filterValue={filterQuery}
onFilterChange={handleFilterChange}
pagination={pagination}
setPagination={setPagination}
paginationInfo={paginationInfo}
onSelectionChange={handleSelectionChange}
onDownloadAll={handleDownloadAll}
onDownloadSelected={handleDownloadSelected}
onBulkDelete={targetId ? () => setDeleteDialogOpen(true) : undefined}
/>
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={tCommon("actions.confirmDelete")}
description={tCommon("actions.deleteConfirmMessage", { count: selectedIPAddresses.length })}
onConfirm={handleBulkDelete}
loading={isDeleting}
variant="destructive"
/>
</>
)
}