Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion ai/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,14 @@ async def _report_event(self, track_id: int, confidence: float, clip_frames: lis
# 1. 일단 사건 발생 동시 보고
local_clip_path = self._save_clip(clip_frames, f"{track_id}_{int(datetime.datetime.now().timestamp())}")

# 분류기(ai/classifier) 학습 전이라 line crossing 트리거를 단순 placeholder 로 매핑.
# 학습 후 EfficientNet 분류 결과(jump/crawling/tailgating/unpaid)로 교체.
payload = {
"camera_id": self.config.camera_id,
"track_id": track_id,
"confidence": round(float(confidence), 3),
"clip_url": local_clip_path
"clip_url": local_clip_path,
"event_type": "unpaid",
}

headers = {"Authorization": f"Bearer {token}"}
Expand Down
33 changes: 11 additions & 22 deletions frontend/docs/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,8 @@ Dashboard pages share a consistent layout: `<Sidebar />` (left, fixed w-64) + `<
### Data Flow (EventsPage)

- `src/hooks/useEventsPage.ts` 단일 훅이 데이터·필터·페이지네이션 모두 담당
- **서버사이드 필터**: `status`, `type`, `camera_id`, `date_from`/`date_to`, `search` → 백엔드 전송
- **서버사이드 페이지네이션**: `offset=(page-1)*pageSize`, `limit=pageSize+1` (hasNextPage 감지)
- **클라이언트 필터**: `station` → 현재 페이지 내에서만 적용 (백엔드 `station` 파라미터 미지원)
- **서버사이드 필터**: `status`, `type`, `camera_id`, `station`, `date_from`/`date_to`, `search` → 백엔드 전송
- **서버사이드 페이지네이션**: `offset=(page-1)*pageSize`, `limit=pageSize`. 응답 헤더 `X-Total-Count` 로 총 건수 받아 `totalPages` 계산 (`getEventsPaged` 사용)
- `search` 입력 400ms 디바운스 적용 (`EventsFilter` 로컬 state → 지연 후 onChange 호출)
- WebSocket `NEW_EVENT` → AppContext `subscribeWsEvent` 구독 (전용 WS 연결 없음)
- WebSocket 수신 시 현재 필터+페이지 그대로 서버 re-fetch
Expand Down Expand Up @@ -132,7 +131,6 @@ Dashboard pages share a consistent layout: `<Sidebar />` (left, fixed w-64) + `<

- `GET /api/notifications/` — 알림 목록 ✅ 프론트 연동
- `PATCH /api/notifications/{notification_id}/read` — 읽음 처리 ✅ 프론트 연동
- `POST /api/notifications/read-all` — 전체 읽음 처리 (프론트 미사용)

### GET /api/events/ 응답 필드

Expand Down Expand Up @@ -193,16 +191,15 @@ AI 분류기 실제 CLASSES: `tailgating | jump | crawling | unpaid` (4종만
- FalseAlarmModal — `onSubmitted(reason)` 으로 reason 반환
- WebSocket NEW_EVENT 시 stats + cameraStats 낙관적 업데이트
- EventsPage (전체 발생내역)
- 서버사이드 필터: status, type, camera_id, 기간(date_from/date_to), search
- 서버사이드 페이지네이션: offset+limit, hasNextPage 방식
- 클라이언트 필터: station (현재 페이지 내, 백엔드 파라미터 대기 중)
- 서버사이드 필터: status, type, camera_id, station, 기간(date_from/date_to), search
- 서버사이드 페이지네이션: offset+limit, X-Total-Count 헤더 기반 totalPages 표시
- search 입력 400ms 디바운스
- WebSocket NEW_EVENT → AppContext subscribeWsEvent 경유 re-fetch (전용 WS 없음)
- CSV 내보내기 → limit=10000 전체 재조회 후 export
- EventDetailModal 재사용
- 테이블 table-fixed 레이아웃 — 페이지 이동 시 컬럼 너비 고정
- 담당자 컬럼: handled_by (숫자 ID) 표시
- StatsPage (ECharts 통계 시각화) — 최근 1000건 기준
- StatsPage (ECharts 통계 시각화) — 차트는 서버 집계(`/stats/by-type|hourly|daily`), 오탐 사유·평균 처리시간은 events 1000건 기준
- StatSummaryCards — 누적발생(events.length)/일평균/오탐율/평균처리시간 4개 카드 (모두 events 배열 기준)
- DailyTrendChart — 최근 12일 라인 차트
- EventTypeChart — 감지 유형 비율 도넛 차트 (event_type 데이터 없으면 "데이터 없음")
Expand All @@ -220,18 +217,10 @@ AI 분류기 실제 CLASSES: `tailgating | jump | crawling | unpaid` (4종만

1. **역무원 파견** — confirm 후 버튼 비활성화(`dispatched` 로컬 state)까지만 구현. 백엔드 API 없어서 실제 파견 처리 불가. 모달 닫고 재열면 파견 상태 리셋됨

### 백엔드 추가 요청 대기 중
### 백엔드 연동 완료 (PR #30, #31)

- `GET /api/cameras/` ORDER BY id 추가 — 현재 정렬 기준 없어 새로고침마다 순서 변동
- `GET /api/events/`에 `station: Optional[str]` 파라미터 추가 → 완료되면 `useEventsPage.ts` 클라이언트 필터 제거
- `GET /api/events/` 응답에 `total` 필드 추가 → 완료되면 `EventsPagination` 페이지 번호 목록 표시 가능
- `GET /api/events/stats/daily` — 날짜별 발생 건수 (`days: int = 12`) → 완료되면 DailyTrendChart 1000건 제한 해소
- `GET /api/events/stats/hourly` — 시간대별 발생 건수 → 완료되면 HourlyDistributionChart 1000건 제한 해소
- 비활성 카메라(`is_active=false`) 이벤트 수신 시 `400` 반환 처리 추가

### 백엔드 완료되면 프론트 후속 작업 필요한 것들

- station 파라미터 → useEventsPage.ts 클라이언트 필터 제거
- total 필드 → getEvents 반환 타입 변경 + EventsPagination 페이지 번호 UI 구현
- stats/daily, stats/hourly → StatsPage fetch 교체,
buildDailyData/buildHourlyData 제거
- ✅ `GET /api/cameras/` ORDER BY id (PR #30)
- ✅ `GET /api/events/?station=<역이름>` 서버사이드 필터 (PR #30 / useEventsPage 가 직접 전달)
- ✅ `GET /api/events/` 응답 `X-Total-Count` 헤더 (PR #31 / `getEventsPaged`)
- ✅ `GET /api/events/stats/by-type` `stats/hourly` `stats/daily?days=N` (PR #31 / StatsPage 차트는 서버 집계 사용)
- ✅ 비활성 카메라 이벤트 거부 — 미존재 404 / `is_active=false` 400 (PR #30)
36 changes: 33 additions & 3 deletions frontend/src/api/events.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,36 @@
import api from "./axios";
import type { EventResponse, EventStats, CameraEventStats } from "@/types";
import type {
EventResponse,
EventStats,
CameraEventStats,
EventTypeStat,
HourlyStat,
DailyStat,
EventsPagedResult,
} from "@/types";

export const getEvents = (params?: {
export interface EventsQuery {
limit?: number;
offset?: number;
status?: string;
camera_id?: number;
type?: string;
station?: string; // 서버사이드 역이름 부분일치 (PR #30)
date_from?: string;
date_to?: string;
search?: string;
}) => api.get<EventResponse[]>("/api/events/", { params }).then((r) => r.data);
}

export const getEvents = (params?: EventsQuery) =>
api.get<EventResponse[]>("/api/events/", { params }).then((r) => r.data);

// X-Total-Count 헤더까지 같이 받는 버전 — 페이지네이션 메타 필요할 때 사용 (PR #31)
export const getEventsPaged = async (params?: EventsQuery): Promise<EventsPagedResult> => {
const res = await api.get<EventResponse[]>("/api/events/", { params });
const totalHeader = res.headers["x-total-count"];
const total = totalHeader ? Number(totalHeader) : res.data.length;
return { items: res.data, total: Number.isFinite(total) ? total : res.data.length };
};

export const getEventById = (id: number) =>
api.get<EventResponse>(`/api/events/${id}`).then((r) => r.data);
Expand All @@ -23,6 +43,16 @@ export const getEventStatsByCamera = () =>
.get<CameraEventStats[]>("/api/events/stats/by-camera")
.then((r) => r.data);

// 서버 통계 3종 (PR #31) — StatsPage 가 클라 1000건 집계 대신 사용
export const getEventStatsByType = () =>
api.get<EventTypeStat[]>("/api/events/stats/by-type").then((r) => r.data);

export const getEventStatsHourly = (params?: { date_from?: string; date_to?: string }) =>
api.get<HourlyStat[]>("/api/events/stats/hourly", { params }).then((r) => r.data);

export const getEventStatsDaily = (days = 30) =>
api.get<DailyStat[]>("/api/events/stats/daily", { params: { days } }).then((r) => r.data);

export const updateEventStatus = (id: number, status: string) =>
api.patch(`/api/events/${id}/status`, { status }).then((r) => r.data);

Expand Down
8 changes: 7 additions & 1 deletion frontend/src/components/events/EventsPagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ interface EventsPaginationProps {
page: number;
pageSize: number;
hasNextPage: boolean;
totalPages?: number; // PR #31 — X-Total-Count 기반. 있으면 "page/total" 표시
total?: number; // 전체 건수
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}
Expand All @@ -14,13 +16,17 @@ export default function EventsPagination({
page,
pageSize,
hasNextPage,
totalPages,
total,
onPageChange,
onPageSizeChange,
}: EventsPaginationProps) {
return (
<div className="flex flex-col sm:flex-row items-center gap-3 sm:gap-0 sm:justify-between py-1">
<p className="text-sm text-gray-500 dark:text-gray-400 hidden sm:block">
{page}페이지
{totalPages != null
? `${page} / ${totalPages} 페이지${total != null ? ` (총 ${total.toLocaleString()}건)` : ""}`
: `${page}페이지`}
</p>

<div className="flex items-center gap-2">
Expand Down
31 changes: 18 additions & 13 deletions frontend/src/hooks/useEventsPage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { getEvents } from "@/api/events";
import { getEvents, getEventsPaged } from "@/api/events";
import { getCameras } from "@/api/cameras";
import { useAppContext } from "./useAppContext";
import type { EventResponse, CameraResponse } from "@/types";
Expand Down Expand Up @@ -27,6 +27,7 @@ export function useEventsPage() {
const [pageSize, setPageSize] = useState(8);
const [rawEvents, setRawEvents] = useState<EventResponse[]>([]);
const [hasNextPage, setHasNextPage] = useState(false);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const cameraMapRef = useRef<Map<number, CameraResponse>>(new Map());
const [cameras, setCameras] = useState<CameraResponse[]>([]);
Expand All @@ -51,21 +52,23 @@ export function useEventsPage() {
const doFetch = useCallback(async (f: EventFilters, p: number, ps: number) => {
setLoading(true);
const { date_from, date_to } = periodToDates(f.period);
const params: Parameters<typeof getEvents>[0] = {
limit: ps + 1, // 다음 페이지 존재 여부 판단용 +1
const params: Parameters<typeof getEventsPaged>[0] = {
limit: ps,
offset: (p - 1) * ps,
...(f.status ? { status: f.status } : {}),
...(f.type ? { type: f.type } : {}),
...(f.cameraId ? { camera_id: Number(f.cameraId) } : {}),
...(f.station ? { station: f.station } : {}), // PR #30 — 백엔드 서버사이드 필터
...(date_from ? { date_from } : {}),
...(date_to ? { date_to } : {}),
...(f.search ? { search: f.search } : {}),
};
try {
const result = await getEvents(params);
setHasNextPage(result.length > ps);
const { items, total: t } = await getEventsPaged(params);
setTotal(t);
setHasNextPage(p * ps < t);
setRawEvents(
result.slice(0, ps).map((e) => ({
items.map((e) => ({
...e,
camera: cameraMapRef.current.get(e.camera_id) ?? e.camera,
}))
Expand All @@ -89,13 +92,12 @@ export function useEventsPage() {
setPage(1);
}, []);

// station: 백엔드 파라미터 미지원 → 현재 페이지 내 클라이언트 필터
const displayEvents = useMemo(() => {
if (!filters.station) return rawEvents;
return rawEvents.filter(
(e) => e.camera?.station_name === filters.station
);
}, [rawEvents, filters.station]);
// station 필터는 백엔드(PR #30)에서 처리되므로 클라 후필터 불필요
const displayEvents = rawEvents;
const totalPages = useMemo(
() => Math.max(1, Math.ceil(total / pageSize)),
[total, pageSize]
);

const cameraOptions = useMemo(
() =>
Expand All @@ -119,6 +121,7 @@ export function useEventsPage() {
...(filters.status ? { status: filters.status } : {}),
...(filters.type ? { type: filters.type } : {}),
...(filters.cameraId ? { camera_id: Number(filters.cameraId) } : {}),
...(filters.station ? { station: filters.station } : {}),
...(date_from ? { date_from } : {}),
...(date_to ? { date_to } : {}),
...(filters.search ? { search: filters.search } : {}),
Expand Down Expand Up @@ -146,6 +149,8 @@ export function useEventsPage() {
pageSize,
setPageSize: handleSetPageSize,
hasNextPage,
total,
totalPages,
cameraOptions,
stationOptions,
refetch: () => doFetch(filters, page, pageSize),
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/pages/EventsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export default function EventsPage() {
pageSize,
setPageSize,
hasNextPage,
total,
totalPages,
cameraOptions,
stationOptions,
refetch,
Expand Down Expand Up @@ -49,6 +51,8 @@ export default function EventsPage() {
page={page}
pageSize={pageSize}
hasNextPage={hasNextPage}
totalPages={totalPages}
total={total}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
Expand Down
100 changes: 81 additions & 19 deletions frontend/src/pages/StatsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,61 @@ import EventTypeChart from "@/components/stats/EventTypeChart";
import HourlyDistributionChart from "@/components/stats/HourlyDistributionChart";
import FalseAlarmTable from "@/components/stats/FalseAlarmTable";
import CameraRankingTable from "@/components/stats/CameraRankingTable";
import { getEvents, getEventStats, getEventStatsByCamera } from "@/api/events";
import { getCameras } from "@/api/cameras";
import {
buildDailyData,
buildHourlyData,
buildTypeData,
buildFalseAlarmData,
DAILY_DAYS,
} from "@/lib/stats";
import type { EventResponse, EventStats, CameraEventStats, CameraResponse } from "@/types";
getEvents,
getEventStats,
getEventStatsByCamera,
getEventStatsByType,
getEventStatsHourly,
getEventStatsDaily,
} from "@/api/events";
import { getCameras } from "@/api/cameras";
import { buildFalseAlarmData, DAILY_DAYS } from "@/lib/stats";
import { labelEventType } from "@/constants/eventTypes";
import type {
EventResponse,
EventStats,
CameraEventStats,
CameraResponse,
EventTypeStat,
HourlyStat,
DailyStat,
} from "@/types";

const HOUR_SLOTS = [
"00-02", "02-04", "04-06", "06-08", "08-10", "10-12",
"12-14", "14-16", "16-18", "18-20", "20-22", "22-24",
];

export default function StatsPage() {
// falseAlarm reason 집계 + 평균값 계산용 (서버 엔드포인트 없음)
const [events, setEvents] = useState<EventResponse[]>([]);
const [stats, setStats] = useState<EventStats | null>(null);
const [cameraStats, setCameraStats] = useState<CameraEventStats[]>([]);
// 서버 집계 결과 (PR #31)
const [typeStats, setTypeStats] = useState<EventTypeStat[]>([]);
const [hourlyStats, setHourlyStats] = useState<HourlyStat[]>([]);
const [dailyStats, setDailyStats] = useState<DailyStat[]>([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
const fetch = async () => {
setLoading(true);
const [evResult, statsResult, camStatsResult, camResult] = await Promise.allSettled([
getEvents({ limit: 1000 }),
getEventStats(),
getEventStatsByCamera(),
getCameras(),
]);
const [evResult, statsResult, camStatsResult, camResult, typeResult, hourlyResult, dailyResult] =
await Promise.allSettled([
getEvents({ limit: 1000 }),
getEventStats(),
getEventStatsByCamera(),
getCameras(),
getEventStatsByType(),
getEventStatsHourly(),
getEventStatsDaily(DAILY_DAYS),
]);
if (evResult.status === "fulfilled") setEvents(evResult.value);
if (statsResult.status === "fulfilled") setStats(statsResult.value);
if (typeResult.status === "fulfilled") setTypeStats(typeResult.value);
if (hourlyResult.status === "fulfilled") setHourlyStats(hourlyResult.value);
if (dailyResult.status === "fulfilled") setDailyStats(dailyResult.value);
if (camStatsResult.status === "fulfilled") {
const cameraMap = new Map<number, CameraResponse>(
camResult.status === "fulfilled"
Expand All @@ -56,9 +83,44 @@ export default function StatsPage() {
fetch();
}, []);

const dailyData = useMemo(() => buildDailyData(events), [events]);
const hourlyData = useMemo(() => buildHourlyData(events), [events]);
const typeData = useMemo(() => buildTypeData(events), [events]);
// 일별 추이 — 서버 응답을 `MM/DD` 키 record 로 변환 (최근 DAILY_DAYS 일)
const dailyData = useMemo(() => {
const result: Record<string, number> = {};
for (let i = DAILY_DAYS - 1; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
result[`${d.getMonth() + 1}/${d.getDate()}`] = 0;
}
dailyStats.forEach((s) => {
const d = new Date(s.day);
const key = `${d.getMonth() + 1}/${d.getDate()}`;
if (key in result) result[key] = s.count;
});
return result;
}, [dailyStats]);

// 시간대(서버 0~23) → 2시간 슬롯 record 로 합산
const hourlyData = useMemo(() => {
const result: Record<string, number> = Object.fromEntries(HOUR_SLOTS.map((s) => [s, 0]));
hourlyStats.forEach((h) => {
const start = Math.floor(h.hour / 2) * 2;
const key = `${String(start).padStart(2, "0")}-${String(start + 2).padStart(2, "0")}`;
if (key in result) result[key] += h.count;
});
return result;
}, [hourlyStats]);

// event_type → 한글 label
const typeData = useMemo(() => {
const result: Record<string, number> = {};
typeStats.forEach((t) => {
const label = labelEventType(t.event_type);
result[label] = (result[label] ?? 0) + t.count;
});
return result;
}, [typeStats]);

// 오탐 reason 집계 — 서버 엔드포인트 없어 events 1000건 기준 클라 집계
const falseAlarmData = useMemo(() => buildFalseAlarmData(events), [events]);

const avgDaily = useMemo(() => {
Expand Down Expand Up @@ -97,7 +159,7 @@ export default function StatsPage() {
<main className="flex-1 p-3 sm:p-6 space-y-4 sm:space-y-5">
{/* 데이터 범위 안내 */}
<p className="text-xs text-gray-400">
통계는 최근 수집된 최대 1,000건의 이벤트를 기준으로 산출됩니다.
차트(일별·시간대별·유형별)는 서버에서 전체 데이터 기준으로 집계됩니다. 오탐 사유·평균 처리시간은 최근 1,000건 기준.
</p>

{/* 요약 카드 */}
Expand Down
Loading
Loading