- QueryMetricsBufferService: ConcurrentLinkedQueue + 10초 batch flush - GisServiceV2: REST API 메트릭 수집 추가 - ChunkedTrackStreamingService: saveAsync → buffer.enqueue 전환 - QueryMetricsController: /history (페이지네이션+필터), /summary (P95 포함) - ApiMetrics.tsx: 요약카드 + 버튼그룹 필터 + 서버사이드 DataTable + 30s 폴링 - DataTable: server-side pagination props 확장 (하위 호환)
147 lines
4.5 KiB
TypeScript
147 lines
4.5 KiB
TypeScript
import { useState, useMemo, type ReactNode } from 'react'
|
|
import { useI18n } from '../../hooks/useI18n.ts'
|
|
|
|
export interface Column<T> {
|
|
key: string
|
|
label: string
|
|
render?: (row: T) => ReactNode
|
|
sortable?: boolean
|
|
align?: 'left' | 'center' | 'right'
|
|
}
|
|
|
|
interface DataTableProps<T> {
|
|
columns: Column<T>[]
|
|
data: T[]
|
|
keyExtractor: (row: T) => string | number
|
|
onRowClick?: (row: T) => void
|
|
emptyMessage?: string
|
|
pageSize?: number
|
|
// Server-side pagination (optional)
|
|
totalElements?: number
|
|
currentPage?: number
|
|
onPageChange?: (page: number) => void
|
|
}
|
|
|
|
export default function DataTable<T>({
|
|
columns,
|
|
data,
|
|
keyExtractor,
|
|
onRowClick,
|
|
emptyMessage,
|
|
pageSize = 20,
|
|
totalElements,
|
|
currentPage,
|
|
onPageChange,
|
|
}: DataTableProps<T>) {
|
|
const { t } = useI18n()
|
|
const [sortKey, setSortKey] = useState<string | null>(null)
|
|
const [sortAsc, setSortAsc] = useState(true)
|
|
const [page, setPage] = useState(0)
|
|
|
|
const isServerSide = totalElements != null && currentPage != null && onPageChange != null
|
|
|
|
const sorted = useMemo(() => {
|
|
if (isServerSide || !sortKey) return data
|
|
return [...data].sort((a, b) => {
|
|
const av = (a as Record<string, unknown>)[sortKey]
|
|
const bv = (b as Record<string, unknown>)[sortKey]
|
|
if (av == null || bv == null) return 0
|
|
const cmp = av < bv ? -1 : av > bv ? 1 : 0
|
|
return sortAsc ? cmp : -cmp
|
|
})
|
|
}, [data, sortKey, sortAsc, isServerSide])
|
|
|
|
const effectivePage = isServerSide ? currentPage! : page
|
|
const total = isServerSide ? totalElements! : sorted.length
|
|
const totalPages = Math.ceil(total / pageSize)
|
|
const paged = isServerSide ? sorted : sorted.slice(effectivePage * pageSize, (effectivePage + 1) * pageSize)
|
|
|
|
const handleSort = (key: string) => {
|
|
if (sortKey === key) {
|
|
setSortAsc(!sortAsc)
|
|
} else {
|
|
setSortKey(key)
|
|
setSortAsc(true)
|
|
}
|
|
}
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
if (isServerSide) {
|
|
onPageChange!(newPage)
|
|
} else {
|
|
setPage(newPage)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="sb-table-wrapper">
|
|
<table className="sb-table">
|
|
<thead>
|
|
<tr>
|
|
{columns.map(col => (
|
|
<th
|
|
key={col.key}
|
|
onClick={col.sortable !== false ? () => handleSort(col.key) : undefined}
|
|
style={{ textAlign: col.align ?? 'left', cursor: col.sortable !== false ? 'pointer' : 'default' }}
|
|
>
|
|
{col.label}
|
|
{sortKey === col.key && (sortAsc ? ' ▲' : ' ▼')}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{paged.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={columns.length} className="sb-table-empty">
|
|
{emptyMessage ?? t('common.noData')}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
paged.map(row => (
|
|
<tr
|
|
key={keyExtractor(row)}
|
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
style={{ cursor: onRowClick ? 'pointer' : 'default' }}
|
|
>
|
|
{columns.map(col => (
|
|
<td key={col.key} style={{ textAlign: col.align ?? 'left' }}>
|
|
{col.render
|
|
? col.render(row)
|
|
: String((row as Record<string, unknown>)[col.key] ?? '-')}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{totalPages > 1 && (
|
|
<div className="mt-3 flex items-center justify-between text-sm text-muted">
|
|
<span>
|
|
{total}{t('common.items')} {t('common.of')} {effectivePage * pageSize + 1}-{Math.min((effectivePage + 1) * pageSize, total)}
|
|
</span>
|
|
<div className="flex gap-1">
|
|
<button
|
|
onClick={() => handlePageChange(Math.max(0, effectivePage - 1))}
|
|
disabled={effectivePage === 0}
|
|
className="rounded border border-border px-2 py-1 disabled:opacity-40"
|
|
>
|
|
{t('common.prev')}
|
|
</button>
|
|
<button
|
|
onClick={() => handlePageChange(Math.min(totalPages - 1, effectivePage + 1))}
|
|
disabled={effectivePage >= totalPages - 1}
|
|
className="rounded border border-border px-2 py-1 disabled:opacity-40"
|
|
>
|
|
{t('common.next')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|