fix(dashboard): Top 클라이언트 IP/ID 토글 및 메트릭 표시 오류 수정

- 토글 활성 상태 시각적 구분 강화 (bg-secondary + font-medium)
- IP 모드 "-" 표시 수정 — 백엔드 client 필드명 매핑 보정
- ID 데이터 없을 때 섹션 사라지는 대신 안내 메시지 표시
- 쿼리 이력(ApiMetrics)에 client_id 컬럼 추가
- history SQL에 client_id 컬럼 포함

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-27 07:04:36 +09:00
부모 d6ab622480
커밋 d19a33b233
4개의 변경된 파일34개의 추가작업 그리고 22개의 파일을 삭제

파일 보기

@ -208,6 +208,7 @@ export interface QueryMetricRow {
cache_hit_days: number cache_hit_days: number
db_query_days: number db_query_days: number
client_ip: string | null client_ip: string | null
client_id: string | null
} }
export interface QueryMetricsPage { export interface QueryMetricsPage {
@ -252,7 +253,8 @@ export interface TimeSeriesBucket {
} }
export interface TopClient { export interface TopClient {
client_ip: string client: string
client_ip?: string
query_count: number query_count: number
avg_elapsed_ms: number avg_elapsed_ms: number
} }
@ -261,6 +263,7 @@ export interface QueryMetricsTimeSeries {
buckets: TimeSeriesBucket[] buckets: TimeSeriesBucket[]
topClients: TopClient[] topClients: TopClient[]
granularity: 'HOURLY' | 'DAILY' granularity: 'HOURLY' | 'DAILY'
groupBy?: 'ip' | 'id'
} }
export interface QueryMetricsParams { export interface QueryMetricsParams {

파일 보기

@ -136,6 +136,10 @@ export default function ApiMetrics() {
key: 'client_ip', label: t('metrics.clientIp'), sortable: false, key: 'client_ip', label: t('metrics.clientIp'), sortable: false,
render: (row) => row.client_ip ? <span className="font-mono text-xs">{row.client_ip}</span> : '-', render: (row) => row.client_ip ? <span className="font-mono text-xs">{row.client_ip}</span> : '-',
}, },
{
key: 'client_id', label: 'ID', sortable: false,
render: (row) => row.client_id ? <span className="font-mono text-xs">{row.client_id}</span> : '-',
},
] ]
return ( return (

파일 보기

@ -326,30 +326,31 @@ export default function Dashboard() {
</div> </div>
{/* Top Clients */} {/* Top Clients */}
{queryTs.topClients.length > 0 && (
<div> <div>
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span className="text-sm font-medium text-muted">{t('dashboard.topClients')}</span> <span className="text-sm font-medium text-muted">{t('dashboard.topClients')}</span>
<div className="flex overflow-hidden rounded-md border border-[var(--border-primary)] text-xs"> <div className="flex overflow-hidden rounded-md border border-[var(--border-primary)] text-xs">
<button <button
type="button" type="button"
className={`px-2 py-0.5 ${clientGroupBy === 'ip' ? 'bg-[var(--accent-primary)] text-white' : 'text-[var(--text-secondary)]'}`} className={`px-2 py-0.5 transition-colors ${clientGroupBy === 'ip' ? 'bg-[var(--accent-primary)] text-white font-medium' : 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]'}`}
onClick={() => setClientGroupBy('ip')} onClick={() => setClientGroupBy('ip')}
>IP</button> >IP</button>
<button <button
type="button" type="button"
className={`px-2 py-0.5 ${clientGroupBy === 'id' ? 'bg-[var(--accent-primary)] text-white' : 'text-[var(--text-secondary)]'}`} className={`px-2 py-0.5 transition-colors ${clientGroupBy === 'id' ? 'bg-[var(--accent-primary)] text-white font-medium' : 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]'}`}
onClick={() => setClientGroupBy('id')} onClick={() => setClientGroupBy('id')}
>ID</button> >ID</button>
</div> </div>
</div> </div>
{queryTs.topClients.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{queryTs.topClients.map((c, i) => { {queryTs.topClients.map((c, i) => {
const maxCount = queryTs.topClients[0].query_count const maxCount = queryTs.topClients[0].query_count
const pct = maxCount > 0 ? (c.query_count / maxCount) * 100 : 0 const pct = maxCount > 0 ? (c.query_count / maxCount) * 100 : 0
const label = c.client ?? c.client_ip ?? '-'
return ( return (
<div key={c.client_ip ?? i} className="flex items-center gap-3 text-sm"> <div key={label + i} className="flex items-center gap-3 text-sm">
<span className="w-32 truncate font-mono text-xs">{c.client_ip ?? '-'}</span> <span className="w-40 truncate font-mono text-xs" title={label}>{label}</span>
<div className="flex-1"> <div className="flex-1">
<div className="h-4 rounded bg-surface-hover"> <div className="h-4 rounded bg-surface-hover">
<div <div
@ -365,8 +366,12 @@ export default function Dashboard() {
) )
})} })}
</div> </div>
) : (
<div className="py-4 text-center text-xs text-muted">
{clientGroupBy === 'id' ? '사용자 ID 데이터가 없습니다' : '클라이언트 데이터가 없습니다'}
</div> </div>
)} )}
</div>
</> </>
) : ( ) : (
<div className="py-8 text-center text-sm text-muted">{t('dashboard.noChartData')}</div> <div className="py-8 text-center text-sm text-muted">{t('dashboard.noChartData')}</div>

파일 보기

@ -106,7 +106,7 @@ public class QueryMetricsController {
zoom_level, requested_mmsi, unique_vessels, total_tracks, zoom_level, requested_mmsi, unique_vessels, total_tracks,
total_points, points_after_simplify, total_chunks, total_points, points_after_simplify, total_chunks,
response_bytes, elapsed_ms, db_query_ms, simplify_ms, response_bytes, elapsed_ms, db_query_ms, simplify_ms,
cache_hit_days, db_query_days, client_ip cache_hit_days, db_query_days, client_ip, client_id
FROM signal.t_query_metrics FROM signal.t_query_metrics
""" + whereClause + """ + whereClause +
" ORDER BY " + sortBy + " " + direction + " ORDER BY " + sortBy + " " + direction +