feat: 시스템 관리 페이지 백엔드 연결 + 메트릭 카드
백엔드 API 추가:
- UserManagementController (admin:user-management)
- GET /api/admin/users : 사용자 목록 + 역할 코드
- GET /api/admin/users/stats : 상태별/역할별/인증방식별 카운트
- POST /api/admin/users/{id}/unlock : 잠금 해제 (@Auditable USER_UNLOCK)
- PUT /api/admin/users/{id}/status : 상태 변경 (@Auditable USER_STATUS_CHANGE)
- 권한 캐시 evict 자동 호출
- AdminStatsController (admin:audit-logs/access-logs/login-history READ)
- GET /api/admin/stats/audit : 전체/24시간/실패/액션별/시간별 통계
- GET /api/admin/stats/access : 전체/24시간/4xx/5xx/평균응답/인기경로
- GET /api/admin/stats/login : 성공률/사용자별/일별 추세
프론트엔드 연결:
- adminApi.ts 확장: AdminUser/UserStats/AuditStats/AccessStats/LoginStats
타입 정의 + 사용자/통계 fetch 함수
- AccessControl.tsx (시스템 관리 > 권한 관리):
- 4개 탭 모두 백엔드 연결
- 역할 관리: GET /api/roles + 사용자별 카운트 표시
- 사용자 관리: GET /api/admin/users + DataTable + 잠금 해제 버튼
+ 통계 카드 4개 (총/활성/잠금/비활성)
- 감사 로그: GET /api/admin/audit-logs + GET /api/admin/stats/audit
+ 액션별 분포 Badge + 통계 카드
- 보안 정책: 실제 백엔드 동작과 일치하도록 갱신
- AuditLogs.tsx: 메트릭 카드 4개 + 액션별 분포
- AccessLogs.tsx: 메트릭 카드 5개 (전체/24시간/4xx/5xx/평균) + Top 10 경로 테이블
- LoginHistoryView.tsx: 메트릭 카드 5개 + 사용자별 + 일별 추세
검증:
- /api/admin/users → 5명 (admin/operator/analyst/field/viewer)
- /api/admin/users/stats → byRole, byStatus, byProvider 카운트
- /api/admin/stats/audit → total 15, 액션 6종, hourly 추세
- /api/admin/stats/login → success 80%, byUser top, daily 추세
- 프론트엔드 빌드 통과 (493ms)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
bae2f33b86
커밋
fc1a686700
154
backend/src/main/java/gc/mda/kcg/admin/AdminStatsController.java
Normal file
154
backend/src/main/java/gc/mda/kcg/admin/AdminStatsController.java
Normal file
@ -0,0 +1,154 @@
|
||||
package gc.mda.kcg.admin;
|
||||
|
||||
import gc.mda.kcg.audit.AccessLogRepository;
|
||||
import gc.mda.kcg.audit.AuditLogRepository;
|
||||
import gc.mda.kcg.auth.LoginHistoryRepository;
|
||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 시스템 관리 대시보드 메트릭 API.
|
||||
*
|
||||
* - 감사 로그 / 접근 로그 / 로그인 이력 통계
|
||||
* - 24시간 / 7일 추세
|
||||
* - 액션별 / 상태별 분포
|
||||
*
|
||||
* 권한: admin:audit-logs, admin:access-logs, admin:login-history (READ)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/stats")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminStatsController {
|
||||
|
||||
private final AuditLogRepository auditLogRepository;
|
||||
private final AccessLogRepository accessLogRepository;
|
||||
private final LoginHistoryRepository loginHistoryRepository;
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
/**
|
||||
* 감사 로그 통계.
|
||||
* - total: 전체 건수
|
||||
* - last24h: 24시간 내 건수
|
||||
* - failed24h: 24시간 내 FAILED 건수
|
||||
* - byAction: 액션별 카운트 (top 10)
|
||||
* - hourly24: 시간별 24시간 추세
|
||||
*/
|
||||
@GetMapping("/audit")
|
||||
@RequirePermission(resource = "admin:audit-logs", operation = "READ")
|
||||
public Map<String, Object> auditStats() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", auditLogRepository.count());
|
||||
result.put("last24h", jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
|
||||
result.put("failed24h", jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
|
||||
|
||||
List<Map<String, Object>> byAction = jdbc.queryForList(
|
||||
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
|
||||
"WHERE created_at > now() - interval '7 days' " +
|
||||
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
|
||||
result.put("byAction", byAction);
|
||||
|
||||
List<Map<String, Object>> hourly = jdbc.queryForList(
|
||||
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
|
||||
"FROM kcg.auth_audit_log " +
|
||||
"WHERE created_at > now() - interval '24 hours' " +
|
||||
"GROUP BY hour ORDER BY hour");
|
||||
result.put("hourly24", hourly);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 접근 로그 통계.
|
||||
* - total: 전체 건수
|
||||
* - last24h: 24시간 내
|
||||
* - error4xx, error5xx: 24시간 내 에러
|
||||
* - avgDurationMs: 24시간 내 평균 응답 시간
|
||||
* - topPaths: 24시간 내 호출 많은 경로
|
||||
*/
|
||||
@GetMapping("/access")
|
||||
@RequirePermission(resource = "admin:access-logs", operation = "READ")
|
||||
public Map<String, Object> accessStats() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", accessLogRepository.count());
|
||||
result.put("last24h", jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
|
||||
result.put("error4xx", jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
|
||||
result.put("error5xx", jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
|
||||
|
||||
Double avg = jdbc.queryForObject(
|
||||
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
|
||||
Double.class);
|
||||
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
|
||||
|
||||
List<Map<String, Object>> topPaths = jdbc.queryForList(
|
||||
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
|
||||
"FROM kcg.auth_access_log " +
|
||||
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
|
||||
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
|
||||
result.put("topPaths", topPaths);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 통계.
|
||||
* - total: 전체 건수
|
||||
* - success24h: 24시간 내 성공
|
||||
* - failed24h: 24시간 내 실패
|
||||
* - locked24h: 24시간 내 잠금
|
||||
* - successRate: 성공률 (24시간 내, %)
|
||||
* - byUser: 사용자별 성공 카운트 (top 10)
|
||||
* - daily7d: 7일 일별 추세
|
||||
*/
|
||||
@GetMapping("/login")
|
||||
@RequirePermission(resource = "admin:login-history", operation = "READ")
|
||||
public Map<String, Object> loginStats() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", loginHistoryRepository.count());
|
||||
|
||||
Long success24h = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class);
|
||||
Long failed24h = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class);
|
||||
Long locked24h = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class);
|
||||
|
||||
result.put("success24h", success24h);
|
||||
result.put("failed24h", failed24h);
|
||||
result.put("locked24h", locked24h);
|
||||
|
||||
long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h);
|
||||
double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h;
|
||||
result.put("successRate", Math.round(rate * 10) / 10.0);
|
||||
|
||||
List<Map<String, Object>> byUser = jdbc.queryForList(
|
||||
"SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " +
|
||||
"WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " +
|
||||
"GROUP BY user_acnt ORDER BY count DESC LIMIT 10");
|
||||
result.put("byUser", byUser);
|
||||
|
||||
List<Map<String, Object>> daily = jdbc.queryForList(
|
||||
"SELECT date_trunc('day', login_dtm) AS day, " +
|
||||
"COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " +
|
||||
"COUNT(*) FILTER (WHERE result='FAILED') AS failed, " +
|
||||
"COUNT(*) FILTER (WHERE result='LOCKED') AS locked " +
|
||||
"FROM kcg.auth_login_hist " +
|
||||
"WHERE login_dtm > now() - interval '7 days' " +
|
||||
"GROUP BY day ORDER BY day");
|
||||
result.put("daily7d", daily);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
package gc.mda.kcg.admin;
|
||||
|
||||
import gc.mda.kcg.audit.annotation.Auditable;
|
||||
import gc.mda.kcg.auth.User;
|
||||
import gc.mda.kcg.auth.UserRepository;
|
||||
import gc.mda.kcg.permission.PermissionService;
|
||||
import gc.mda.kcg.permission.UserRoleRepository;
|
||||
import gc.mda.kcg.permission.annotation.RequirePermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 사용자 관리 API.
|
||||
* 권한: admin:user-management
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/users")
|
||||
@RequiredArgsConstructor
|
||||
public class UserManagementController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final UserRoleRepository userRoleRepository;
|
||||
private final PermissionService permissionService;
|
||||
|
||||
/**
|
||||
* 사용자 목록 조회 (역할 코드 포함).
|
||||
*/
|
||||
@GetMapping
|
||||
@RequirePermission(resource = "admin:user-management", operation = "READ")
|
||||
public List<Map<String, Object>> listUsers() {
|
||||
List<User> users = userRepository.findAll(
|
||||
org.springframework.data.domain.Sort.by("userAcnt").ascending());
|
||||
|
||||
return users.stream().<Map<String, Object>>map(u -> {
|
||||
List<String> roles = userRoleRepository.findRoleCodesByUserId(u.getUserId());
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("userId", u.getUserId().toString());
|
||||
m.put("userAcnt", u.getUserAcnt());
|
||||
m.put("userNm", u.getUserNm());
|
||||
m.put("rnkpNm", u.getRnkpNm());
|
||||
m.put("email", u.getEmail());
|
||||
m.put("userSttsCd", u.getUserSttsCd());
|
||||
m.put("authProvider", u.getAuthProvider());
|
||||
m.put("failCnt", u.getFailCnt());
|
||||
m.put("lastLoginDtm", u.getLastLoginDtm());
|
||||
m.put("createdAt", u.getCreatedAt());
|
||||
m.put("roles", roles);
|
||||
return m;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 통계 (역할별 카운트, 상태별 카운트).
|
||||
*/
|
||||
@GetMapping("/stats")
|
||||
@RequirePermission(resource = "admin:user-management", operation = "READ")
|
||||
public Map<String, Object> stats() {
|
||||
List<User> users = userRepository.findAll();
|
||||
|
||||
Map<String, Long> byStatus = users.stream()
|
||||
.collect(Collectors.groupingBy(User::getUserSttsCd, Collectors.counting()));
|
||||
|
||||
Map<String, Long> byProvider = users.stream()
|
||||
.collect(Collectors.groupingBy(User::getAuthProvider, Collectors.counting()));
|
||||
|
||||
// 역할별 사용자 수
|
||||
Map<String, Long> byRole = new LinkedHashMap<>();
|
||||
for (User u : users) {
|
||||
for (String role : userRoleRepository.findRoleCodesByUserId(u.getUserId())) {
|
||||
byRole.merge(role, 1L, Long::sum);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", (long) users.size());
|
||||
result.put("active", byStatus.getOrDefault("ACTIVE", 0L));
|
||||
result.put("locked", byStatus.getOrDefault("LOCKED", 0L));
|
||||
result.put("inactive", byStatus.getOrDefault("INACTIVE", 0L));
|
||||
result.put("pending", byStatus.getOrDefault("PENDING", 0L));
|
||||
result.put("byStatus", byStatus);
|
||||
result.put("byProvider", byProvider);
|
||||
result.put("byRole", byRole);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 잠긴 계정 해제.
|
||||
*/
|
||||
@Auditable(action = "USER_UNLOCK", resourceType = "USER")
|
||||
@PostMapping("/{userId}/unlock")
|
||||
@RequirePermission(resource = "admin:user-management", operation = "UPDATE")
|
||||
public Map<String, Object> unlockUser(@PathVariable String userId) {
|
||||
UUID uid = UUID.fromString(userId);
|
||||
User user = userRepository.findById(uid)
|
||||
.orElseThrow(() -> new IllegalArgumentException("USER_NOT_FOUND: " + userId));
|
||||
|
||||
user.setUserSttsCd("ACTIVE");
|
||||
user.setFailCnt(0);
|
||||
userRepository.save(user);
|
||||
permissionService.evictUserPermissions(uid);
|
||||
|
||||
log.info("계정 잠금 해제: {}", user.getUserAcnt());
|
||||
return Map.of(
|
||||
"userId", userId,
|
||||
"userAcnt", user.getUserAcnt(),
|
||||
"userSttsCd", user.getUserSttsCd()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정 상태 변경 (ACTIVE/LOCKED/INACTIVE).
|
||||
*/
|
||||
@Auditable(action = "USER_STATUS_CHANGE", resourceType = "USER")
|
||||
@PutMapping("/{userId}/status")
|
||||
@RequirePermission(resource = "admin:user-management", operation = "UPDATE")
|
||||
public Map<String, Object> changeStatus(@PathVariable String userId, @RequestBody Map<String, String> body) {
|
||||
String newStatus = body.get("status");
|
||||
if (newStatus == null || !Set.of("ACTIVE", "LOCKED", "INACTIVE", "PENDING").contains(newStatus)) {
|
||||
throw new IllegalArgumentException("INVALID_STATUS: " + newStatus);
|
||||
}
|
||||
UUID uid = UUID.fromString(userId);
|
||||
User user = userRepository.findById(uid)
|
||||
.orElseThrow(() -> new IllegalArgumentException("USER_NOT_FOUND: " + userId));
|
||||
user.setUserSttsCd(newStatus);
|
||||
if ("ACTIVE".equals(newStatus)) {
|
||||
user.setFailCnt(0);
|
||||
}
|
||||
userRepository.save(user);
|
||||
permissionService.evictUserPermissions(uid);
|
||||
|
||||
return Map.of("userId", userId, "userAcnt", user.getUserAcnt(), "userSttsCd", newStatus);
|
||||
}
|
||||
}
|
||||
@ -1,132 +1,222 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import {
|
||||
Shield, Users, UserCheck, Key, Clock, Search, Plus, Edit2, Trash2,
|
||||
Eye, Lock, AlertTriangle, FileText, ChevronDown, ChevronRight
|
||||
Shield, Users, UserCheck, Key, Lock, FileText, Loader2, RefreshCw, Eye,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
fetchUsers,
|
||||
fetchUserStats,
|
||||
fetchRoles,
|
||||
fetchAuditLogs,
|
||||
fetchAuditStats,
|
||||
unlockUser,
|
||||
type AdminUser,
|
||||
type UserStats,
|
||||
type RoleWithPermissions,
|
||||
type AuditLog as ApiAuditLog,
|
||||
type AuditStats,
|
||||
} from '@/services/adminApi';
|
||||
|
||||
/*
|
||||
* SFR-01: 역할 기반 권한 관리(RBAC)
|
||||
* - 조직·직급·직무에 따른 권한 관리
|
||||
* - 메뉴·기능·데이터 접근 권한 분리
|
||||
* - 감사 로그 기록 및 조회
|
||||
* - 비밀번호/계정 잠금 정책 설정
|
||||
* SFR-01: 역할 기반 권한 관리(RBAC) - 백엔드 연동 버전
|
||||
*
|
||||
* 4개 탭:
|
||||
* 1) 역할 관리 - GET /api/roles (admin:role-management) + 사용자 통계
|
||||
* 2) 사용자 관리 - GET /api/admin/users + 잠금 해제
|
||||
* 3) 감사 로그 - GET /api/admin/audit-logs + GET /api/admin/stats/audit
|
||||
* 4) 보안 정책 - 정적 정보
|
||||
*/
|
||||
|
||||
interface UserAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
rank: string;
|
||||
org: string;
|
||||
role: string;
|
||||
status: '활성' | '잠금' | '비활성';
|
||||
lastLogin: string;
|
||||
loginCount: number;
|
||||
}
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
ADMIN: 'bg-red-500/20 text-red-400',
|
||||
OPERATOR: 'bg-blue-500/20 text-blue-400',
|
||||
ANALYST: 'bg-purple-500/20 text-purple-400',
|
||||
FIELD: 'bg-green-500/20 text-green-400',
|
||||
VIEWER: 'bg-yellow-500/20 text-yellow-400',
|
||||
};
|
||||
|
||||
interface AuditLog {
|
||||
time: string;
|
||||
user: string;
|
||||
action: string;
|
||||
target: string;
|
||||
ip: string;
|
||||
result: '성공' | '실패' | '차단';
|
||||
}
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'bg-green-500/20 text-green-400',
|
||||
LOCKED: 'bg-red-500/20 text-red-400',
|
||||
INACTIVE: 'bg-gray-500/20 text-gray-400',
|
||||
PENDING: 'bg-yellow-500/20 text-yellow-400',
|
||||
};
|
||||
|
||||
const ROLES = [
|
||||
{ name: '시스템 관리자', level: 'ADMIN', count: 3, color: 'bg-red-500/20 text-red-400', menus: '전체 메뉴', data: '전체 데이터' },
|
||||
{ name: '상황실 운영자', level: 'OPERATOR', count: 12, color: 'bg-blue-500/20 text-blue-400', menus: '상황판·통계·경보', data: '관할 해역' },
|
||||
{ name: '분석 담당자', level: 'ANALYST', count: 8, color: 'bg-purple-500/20 text-purple-400', menus: 'AI모드·통계·항적', data: '분석 데이터' },
|
||||
{ name: '현장 단속요원', level: 'FIELD', count: 45, color: 'bg-green-500/20 text-green-400', menus: '함정Agent·모바일', data: '할당 구역' },
|
||||
{ name: '유관기관 열람자', level: 'VIEWER', count: 6, color: 'bg-yellow-500/20 text-yellow-400', menus: '공유 대시보드', data: '공개 정보' },
|
||||
];
|
||||
|
||||
const USERS: UserAccount[] = [
|
||||
{ id: 'U001', name: '김영수', rank: '사무관', org: '본청 정보통신과', role: '시스템 관리자', status: '활성', lastLogin: '2026-04-03 09:15', loginCount: 342 },
|
||||
{ id: 'U002', name: '이상호', rank: '경위', org: '서해지방해경청', role: '상황실 운영자', status: '활성', lastLogin: '2026-04-03 08:30', loginCount: 128 },
|
||||
{ id: 'U003', name: '박민수', rank: '경사', org: '5001함 삼봉', role: '현장 단속요원', status: '활성', lastLogin: '2026-04-02 22:15', loginCount: 67 },
|
||||
{ id: 'U004', name: '정해진', rank: '주무관', org: '남해지방해경청', role: '분석 담당자', status: '잠금', lastLogin: '2026-04-01 14:20', loginCount: 89 },
|
||||
{ id: 'U005', name: '최원석', rank: '6급', org: '해수부 어업관리과', role: '유관기관 열람자', status: '활성', lastLogin: '2026-03-28 10:00', loginCount: 12 },
|
||||
{ id: 'U006', name: '한지영', rank: '경장', org: '3009함', role: '현장 단속요원', status: '비활성', lastLogin: '2026-02-15 16:40', loginCount: 5 },
|
||||
];
|
||||
|
||||
const AUDIT_LOGS: AuditLog[] = [
|
||||
{ time: '2026-04-03 09:15:23', user: '김영수', action: '로그인', target: '시스템', ip: '10.20.30.1', result: '성공' },
|
||||
{ time: '2026-04-03 09:12:05', user: '미상', action: '로그인 시도', target: '시스템', ip: '192.168.5.99', result: '차단' },
|
||||
{ time: '2026-04-03 08:55:11', user: '이상호', action: '위험도 지도 조회', target: 'SFR-05', ip: '10.20.31.5', result: '성공' },
|
||||
{ time: '2026-04-03 08:30:44', user: '이상호', action: '로그인', target: '시스템', ip: '10.20.31.5', result: '성공' },
|
||||
{ time: '2026-04-03 07:45:00', user: '정해진', action: '로그인 시도(5회 실패)', target: '시스템', ip: '10.20.40.12', result: '실패' },
|
||||
{ time: '2026-04-03 07:44:30', user: '시스템', action: '계정 잠금 처리', target: '정해진(U004)', ip: '-', result: '성공' },
|
||||
{ time: '2026-04-02 22:15:10', user: '박민수', action: '불법어선 탐지 결과 조회', target: 'SFR-09', ip: '10.50.1.33', result: '성공' },
|
||||
{ time: '2026-04-02 21:00:00', user: '시스템', action: '일일 감사 로그 백업', target: 'DB', ip: '-', result: '성공' },
|
||||
];
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
ACTIVE: '활성',
|
||||
LOCKED: '잠금',
|
||||
INACTIVE: '비활성',
|
||||
PENDING: '승인대기',
|
||||
};
|
||||
|
||||
type Tab = 'roles' | 'users' | 'audit' | 'policy';
|
||||
|
||||
// DataTable 컬럼: 사용자 관리
|
||||
const userColumns: DataColumn<UserAccount & Record<string, unknown>>[] = [
|
||||
{ key: 'id', label: 'ID', width: '60px', render: (v) => <span className="text-hint font-mono">{v as string}</span> },
|
||||
{ key: 'name', label: '이름', width: '70px', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'rank', label: '직급', width: '60px' },
|
||||
{ key: 'org', label: '소속', sortable: true },
|
||||
{ key: 'role', label: '역할', width: '100px', sortable: true,
|
||||
render: (v) => <Badge className="bg-switch-background/50 text-label border-0 text-[9px]">{v as string}</Badge>,
|
||||
},
|
||||
{ key: 'status', label: '상태', width: '60px', sortable: true,
|
||||
render: (v) => {
|
||||
const s = v as string;
|
||||
const c = s === '활성' ? 'bg-green-500/20 text-green-400' : s === '잠금' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground';
|
||||
return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'lastLogin', label: '최종 로그인', width: '130px', sortable: true,
|
||||
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span>,
|
||||
},
|
||||
{ key: 'id', label: '관리', width: '70px', align: 'center', sortable: false,
|
||||
render: (_v, row) => (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button className="p-1 text-hint hover:text-blue-400" title="상세"><Eye className="w-3 h-3" /></button>
|
||||
<button className="p-1 text-hint hover:text-yellow-400" title="수정"><Edit2 className="w-3 h-3" /></button>
|
||||
{row.status === '잠금' && <button className="p-1 text-hint hover:text-green-400" title="잠금 해제"><Key className="w-3 h-3" /></button>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// DataTable 컬럼: 감사 로그
|
||||
const auditColumns: DataColumn<AuditLog & Record<string, unknown>>[] = [
|
||||
{ key: 'time', label: '일시', width: '160px', sortable: true,
|
||||
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span>,
|
||||
},
|
||||
{ key: 'user', label: '사용자', width: '70px', sortable: true },
|
||||
{ key: 'action', label: '행위', sortable: true, render: (v) => <span className="text-heading">{v as string}</span> },
|
||||
{ key: 'target', label: '대상', width: '80px' },
|
||||
{ key: 'ip', label: 'IP', width: '110px', render: (v) => <span className="text-hint font-mono">{v as string}</span> },
|
||||
{ key: 'result', label: '결과', width: '60px', sortable: true,
|
||||
render: (v) => {
|
||||
const r = v as string;
|
||||
const c = r === '성공' ? 'bg-green-500/20 text-green-400' : r === '실패' ? 'bg-red-500/20 text-red-400' : 'bg-orange-500/20 text-orange-400';
|
||||
return <Badge className={`border-0 text-[9px] ${c}`}>{r}</Badge>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function AccessControl() {
|
||||
const { t } = useTranslation('admin');
|
||||
const [tab, setTab] = useState<Tab>('roles');
|
||||
|
||||
const tabs: { key: Tab; icon: React.ElementType; label: string }[] = [
|
||||
{ key: 'roles', icon: Shield, label: '역할 관리' },
|
||||
{ key: 'users', icon: Users, label: '사용자 관리' },
|
||||
{ key: 'audit', icon: FileText, label: '감사 로그' },
|
||||
{ key: 'policy', icon: Lock, label: '보안 정책' },
|
||||
];
|
||||
// 공통 상태
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 사용자 목록
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [userStats, setUserStats] = useState<UserStats | null>(null);
|
||||
const [usersLoading, setUsersLoading] = useState(false);
|
||||
|
||||
// 역할 목록
|
||||
const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
|
||||
const [rolesLoading, setRolesLoading] = useState(false);
|
||||
|
||||
// 감사 로그
|
||||
const [auditLogs, setAuditLogs] = useState<ApiAuditLog[]>([]);
|
||||
const [auditStats, setAuditStats] = useState<AuditStats | null>(null);
|
||||
const [auditLoading, setAuditLoading] = useState(false);
|
||||
|
||||
// 사용자 + 통계 로드
|
||||
const loadUsers = useCallback(async () => {
|
||||
setUsersLoading(true); setError('');
|
||||
try {
|
||||
const [u, s] = await Promise.all([fetchUsers(), fetchUserStats()]);
|
||||
setUsers(u);
|
||||
setUserStats(s);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'unknown');
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRoles = useCallback(async () => {
|
||||
setRolesLoading(true); setError('');
|
||||
try {
|
||||
const r = await fetchRoles();
|
||||
setRoles(r);
|
||||
// 사용자 통계도 같이 로드 (역할별 카운트 사용)
|
||||
if (!userStats) {
|
||||
const s = await fetchUserStats();
|
||||
setUserStats(s);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'unknown');
|
||||
} finally {
|
||||
setRolesLoading(false);
|
||||
}
|
||||
}, [userStats]);
|
||||
|
||||
const loadAudit = useCallback(async () => {
|
||||
setAuditLoading(true); setError('');
|
||||
try {
|
||||
const [logs, stats] = await Promise.all([fetchAuditLogs(0, 100), fetchAuditStats()]);
|
||||
setAuditLogs(logs.content);
|
||||
setAuditStats(stats);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'unknown');
|
||||
} finally {
|
||||
setAuditLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 탭 전환 시 자동 로드
|
||||
useEffect(() => {
|
||||
if (tab === 'roles') loadRoles();
|
||||
else if (tab === 'users') loadUsers();
|
||||
else if (tab === 'audit') loadAudit();
|
||||
}, [tab, loadRoles, loadUsers, loadAudit]);
|
||||
|
||||
const handleUnlock = async (userId: string, acnt: string) => {
|
||||
if (!confirm(`계정 ${acnt} 잠금을 해제하시겠습니까?`)) return;
|
||||
try {
|
||||
await unlockUser(userId);
|
||||
await loadUsers();
|
||||
} catch (e: unknown) {
|
||||
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
|
||||
}
|
||||
};
|
||||
|
||||
// ── 사용자 테이블 컬럼 ──────────────
|
||||
const userColumns: DataColumn<AdminUser & Record<string, unknown>>[] = useMemo(() => [
|
||||
{ key: 'userAcnt', label: '계정', width: '90px',
|
||||
render: (v) => <span className="text-cyan-400 font-mono text-[11px]">{v as string}</span> },
|
||||
{ key: 'userNm', label: '이름', width: '80px', sortable: true,
|
||||
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'rnkpNm', label: '직급', width: '60px',
|
||||
render: (v) => <span className="text-muted-foreground">{(v as string) || '-'}</span> },
|
||||
{ key: 'email', label: '이메일',
|
||||
render: (v) => <span className="text-muted-foreground text-[10px]">{(v as string) || '-'}</span> },
|
||||
{ key: 'roles', label: '역할', width: '120px',
|
||||
render: (v) => {
|
||||
const list = (v as string[]) || [];
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{list.map((r) => (
|
||||
<Badge key={r} className={`${ROLE_COLORS[r] || ''} border-0 text-[9px]`}>{r}</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'userSttsCd', label: '상태', width: '70px', sortable: true,
|
||||
render: (v) => {
|
||||
const s = v as string;
|
||||
return <Badge className={`border-0 text-[9px] ${STATUS_COLORS[s] || ''}`}>{STATUS_LABELS[s] || s}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'failCnt', label: '실패', width: '50px', align: 'center',
|
||||
render: (v) => <span className={`text-[10px] ${(v as number) > 0 ? 'text-red-400' : 'text-hint'}`}>{v as number}</span> },
|
||||
{ key: 'authProvider', label: '인증', width: '70px',
|
||||
render: (v) => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ key: 'lastLoginDtm', label: '최종 로그인', width: '140px', sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted-foreground font-mono text-[10px]">
|
||||
{v ? new Date(v as string).toLocaleString('ko-KR') : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'userId', label: '관리', width: '70px', align: 'center', sortable: false,
|
||||
render: (_v, row) => (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button type="button" className="p-1 text-hint hover:text-blue-400" title="상세">
|
||||
<Eye className="w-3 h-3" />
|
||||
</button>
|
||||
{row.userSttsCd === 'LOCKED' && (
|
||||
<button type="button" onClick={() => handleUnlock(row.userId, row.userAcnt)}
|
||||
className="p-1 text-hint hover:text-green-400" title="잠금 해제">
|
||||
<Key className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
// ── 감사 로그 컬럼 ──────────────
|
||||
const auditColumns: DataColumn<ApiAuditLog & Record<string, unknown>>[] = useMemo(() => [
|
||||
{ key: 'createdAt', label: '일시', width: '160px', sortable: true,
|
||||
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{new Date(v as string).toLocaleString('ko-KR')}</span> },
|
||||
{ key: 'userAcnt', label: '사용자', width: '90px', sortable: true,
|
||||
render: (v) => <span className="text-cyan-400 font-mono">{(v as string) || '-'}</span> },
|
||||
{ key: 'actionCd', label: '액션', width: '180px', sortable: true,
|
||||
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'resourceType', label: '리소스', width: '110px',
|
||||
render: (v) => <span className="text-muted-foreground">{(v as string) || '-'}</span> },
|
||||
{ key: 'ipAddress', label: 'IP', width: '120px',
|
||||
render: (v) => <span className="text-hint font-mono text-[10px]">{(v as string) || '-'}</span> },
|
||||
{ key: 'result', label: '결과', width: '70px', sortable: true,
|
||||
render: (v) => {
|
||||
const r = v as string;
|
||||
const c = r === 'SUCCESS' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400';
|
||||
return <Badge className={`border-0 text-[9px] ${c}`}>{r || '-'}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'failReason', label: '실패 사유',
|
||||
render: (v) => <span className="text-red-400 text-[10px]">{(v as string) || '-'}</span> },
|
||||
], []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
@ -135,176 +225,222 @@ export function AccessControl() {
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('accessControl.desc')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{userStats && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-hint">
|
||||
<UserCheck className="w-3.5 h-3.5 text-green-500" />
|
||||
활성 사용자 <span className="text-green-400 font-bold">{USERS.filter((u) => u.status === '활성').length}</span>명
|
||||
활성 <span className="text-green-400 font-bold">{userStats.active}</span>명
|
||||
<span className="mx-1">|</span>
|
||||
총 등록 <span className="text-heading font-bold">{USERS.length}</span>명
|
||||
잠금 <span className="text-red-400 font-bold">{userStats.locked}</span>
|
||||
<span className="mx-1">|</span>
|
||||
총 <span className="text-heading font-bold">{userStats.total}</span>
|
||||
</div>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={() => { if (tab === 'roles') loadRoles(); else if (tab === 'users') loadUsers(); else if (tab === 'audit') loadAudit(); }}
|
||||
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-1">
|
||||
{tabs.map((t) => (
|
||||
{([
|
||||
{ key: 'roles', icon: Shield, label: '역할 관리' },
|
||||
{ key: 'users', icon: Users, label: '사용자 관리' },
|
||||
{ key: 'audit', icon: FileText, label: '감사 로그' },
|
||||
{ key: 'policy', icon: Lock, label: '보안 정책' },
|
||||
] as const).map((tt) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
key={tt.key}
|
||||
type="button"
|
||||
onClick={() => setTab(tt.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${
|
||||
tab === t.key ? 'bg-blue-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
tab === tt.key ? 'bg-blue-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<t.icon className="w-3.5 h-3.5" />
|
||||
{t.label}
|
||||
<tt.icon className="w-3.5 h-3.5" />
|
||||
{tt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
|
||||
{/* ── 역할 관리 ── */}
|
||||
{tab === 'roles' && (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{ROLES.map((r) => (
|
||||
<Card key={r.name} className="bg-surface-raised border-border">
|
||||
<div className="space-y-3">
|
||||
{rolesLoading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
{!rolesLoading && roles.map((r) => {
|
||||
const userCount = userStats?.byRole?.[r.roleCd] ?? 0;
|
||||
const grantCount = r.permissions?.filter((p) => p.grantYn === 'Y').length ?? 0;
|
||||
return (
|
||||
<Card key={r.roleSn} className="bg-surface-raised border-border">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className={`${r.color} border-0 text-[10px] px-2 py-0.5`}>{r.level}</Badge>
|
||||
<Badge className={`${ROLE_COLORS[r.roleCd] || 'bg-gray-500/20 text-gray-400'} border-0 text-[10px] px-2 py-0.5`}>
|
||||
{r.roleCd}
|
||||
</Badge>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-heading">{r.name}</div>
|
||||
<div className="text-[10px] text-hint">할당 인원: {r.count}명</div>
|
||||
<div className="text-sm font-bold text-heading">{r.roleNm}</div>
|
||||
<div className="text-[10px] text-hint">{r.roleDc || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-[10px]">
|
||||
<div>
|
||||
<span className="text-hint">메뉴 접근: </span>
|
||||
<span className="text-label">{r.menus}</span>
|
||||
<span className="text-hint">할당 인원: </span>
|
||||
<span className="text-heading font-bold">{userCount}명</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-hint">데이터 범위: </span>
|
||||
<span className="text-label">{r.data}</span>
|
||||
<span className="text-hint">명시 권한: </span>
|
||||
<span className="text-cyan-400 font-bold">{grantCount}개</span>
|
||||
</div>
|
||||
<button className="text-hint hover:text-blue-400"><Edit2 className="w-3.5 h-3.5" /></button>
|
||||
{r.builtinYn === 'Y' && <Badge className="bg-blue-500/20 text-blue-400 border-0 text-[9px]">BUILT-IN</Badge>}
|
||||
{r.dfltYn === 'Y' && <Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">DEFAULT</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<button className="flex items-center justify-center gap-1.5 py-3 border border-dashed border-slate-700/50 rounded-lg text-[11px] text-hint hover:text-blue-400 hover:border-blue-500/30 transition-colors">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
새 역할 추가
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!rolesLoading && roles.length === 0 && <div className="text-center text-hint py-8">역할이 없습니다.</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 사용자 관리 — DataTable 적용 ── */}
|
||||
{/* ── 사용자 관리 ── */}
|
||||
{tab === 'users' && (
|
||||
<>
|
||||
{/* 통계 카드 */}
|
||||
{userStats && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<StatCard label="총 사용자" value={userStats.total} color="text-heading" />
|
||||
<StatCard label="활성" value={userStats.active} color="text-green-400" />
|
||||
<StatCard label="잠금" value={userStats.locked} color="text-red-400" />
|
||||
<StatCard label="비활성" value={userStats.inactive} color="text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usersLoading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
{!usersLoading && (
|
||||
<DataTable
|
||||
data={USERS as (UserAccount & Record<string, unknown>)[]}
|
||||
data={users as (AdminUser & Record<string, unknown>)[]}
|
||||
columns={userColumns}
|
||||
pageSize={10}
|
||||
searchPlaceholder="이름, 소속, 역할 검색..."
|
||||
searchKeys={['name', 'org', 'role', 'rank']}
|
||||
searchPlaceholder="계정, 이름, 이메일 검색..."
|
||||
searchKeys={['userAcnt', 'userNm', 'email', 'rnkpNm']}
|
||||
exportFilename="사용자목록"
|
||||
showPagination
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 감사 로그 — DataTable 적용 ── */}
|
||||
{/* ── 감사 로그 ── */}
|
||||
{tab === 'audit' && (
|
||||
<>
|
||||
{/* 통계 카드 */}
|
||||
{auditStats && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<StatCard label="전체 로그" value={auditStats.total} color="text-heading" />
|
||||
<StatCard label="24시간" value={auditStats.last24h} color="text-blue-400" />
|
||||
<StatCard label="실패 (24시간)" value={auditStats.failed24h} color="text-red-400" />
|
||||
<StatCard label="액션 종류" value={auditStats.byAction.length} color="text-purple-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션별 분포 */}
|
||||
{auditStats && auditStats.byAction.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-2"><CardTitle className="text-xs text-label">액션별 분포 (7일)</CardTitle></CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{auditStats.byAction.map((a) => (
|
||||
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px]">
|
||||
{a.action} <span className="text-cyan-400 font-bold ml-1">{a.count}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{auditLoading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
{!auditLoading && (
|
||||
<DataTable
|
||||
data={AUDIT_LOGS as (AuditLog & Record<string, unknown>)[]}
|
||||
data={auditLogs as (ApiAuditLog & Record<string, unknown>)[]}
|
||||
columns={auditColumns}
|
||||
pageSize={10}
|
||||
searchPlaceholder="사용자, 행위, IP 검색..."
|
||||
searchKeys={['user', 'action', 'ip', 'target']}
|
||||
pageSize={20}
|
||||
searchPlaceholder="사용자, 액션, IP 검색..."
|
||||
searchKeys={['userAcnt', 'actionCd', 'resourceType', 'ipAddress']}
|
||||
exportFilename="감사로그"
|
||||
title="로그인/로그아웃·비정상 접속·중요 정보 접근 감사 로그"
|
||||
title="모든 운영자 의사결정 자동 기록 (audit_log)"
|
||||
showPagination
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 보안 정책 ── */}
|
||||
{tab === 'policy' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-2">
|
||||
<CardTitle className="text-xs text-label">비밀번호 정책</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-2">
|
||||
{[
|
||||
<PolicyCard title="비밀번호 정책" rows={[
|
||||
['최소 길이', '9자 이상'],
|
||||
['복잡도', '영문+숫자+특수문자 조합'],
|
||||
['변경 주기', '90일'],
|
||||
['재사용 제한', '최근 3회'],
|
||||
['만료 경고', '14일 전'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">{k}</span>
|
||||
<span className="text-label font-medium">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-2">
|
||||
<CardTitle className="text-xs text-label">계정 잠금 정책</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-2">
|
||||
{[
|
||||
]} />
|
||||
<PolicyCard title="계정 잠금 정책" rows={[
|
||||
['잠금 임계', '5회 연속 실패'],
|
||||
['잠금 시간', '30분'],
|
||||
['자동 해제', '활성'],
|
||||
['관리자 해제', '즉시 가능'],
|
||||
['비정상 접속 알림', 'SMS + 시스템 알림'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">{k}</span>
|
||||
<span className="text-label font-medium">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-2">
|
||||
<CardTitle className="text-xs text-label">세션 관리</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-2">
|
||||
{[
|
||||
['잠금 시간', '관리자 해제 시까지'],
|
||||
['실패 카운터 증가', 'PasswordAuthProvider'],
|
||||
['관리자 해제', 'POST /api/admin/users/{id}/unlock'],
|
||||
['감사 기록', 'auth_login_hist + auth_audit_log'],
|
||||
]} />
|
||||
<PolicyCard title="세션 관리" rows={[
|
||||
['세션 타임아웃', '30분 (미사용 시)'],
|
||||
['동시 접속', '1계정 1세션'],
|
||||
['중복 로그인', '이전 세션 종료'],
|
||||
['JWT 만료', '24시간'],
|
||||
['저장 방식', 'HttpOnly Cookie'],
|
||||
['세션 갱신', '활동 시 자동 연장'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">{k}</span>
|
||||
<span className="text-label font-medium">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-2">
|
||||
<CardTitle className="text-xs text-label">감사 로그 정책</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-2">
|
||||
{[
|
||||
['로그 보존', '1년 이상'],
|
||||
['기록 대상', '로그인·권한변경·데이터접근'],
|
||||
['무결성 보장', 'Hash 검증'],
|
||||
['백업 주기', '일 1회 자동'],
|
||||
['조회 권한', 'ADMIN 전용'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">{k}</span>
|
||||
<span className="text-label font-medium">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
]} />
|
||||
<PolicyCard title="감사 로그 정책" rows={[
|
||||
['감사 대상', '모든 @Auditable 액션 + 로그인'],
|
||||
['기록 위치', 'kcg.auth_audit_log'],
|
||||
['접근 로그', 'kcg.auth_access_log (비동기)'],
|
||||
['로그인 이력', 'kcg.auth_login_hist'],
|
||||
['조회 권한', 'admin:audit-logs (READ)'],
|
||||
]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[10px] text-hint">{label}</div>
|
||||
<div className={`text-2xl font-bold ${color} mt-1`}>{value.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyCard({ title, rows }: { title: string; rows: [string, string][] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-2"><CardTitle className="text-xs text-label">{title}</CardTitle></CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-2">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">{k}</span>
|
||||
<span className="text-label font-medium">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,25 +1,25 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { fetchAccessLogs, type AccessLog } from '@/services/adminApi';
|
||||
import { fetchAccessLogs, fetchAccessStats, type AccessLog, type AccessStats } from '@/services/adminApi';
|
||||
|
||||
/**
|
||||
* 접근 이력 조회 (모든 HTTP 요청).
|
||||
* 접근 이력 조회 + 메트릭 카드.
|
||||
* 권한: admin:access-logs (READ)
|
||||
*
|
||||
* 백엔드 AccessLogFilter가 모든 요청을 비동기로 기록.
|
||||
*/
|
||||
export function AccessLogs() {
|
||||
const [items, setItems] = useState<AccessLog[]>([]);
|
||||
const [stats, setStats] = useState<AccessStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const res = await fetchAccessLogs(0, 100);
|
||||
setItems(res.content);
|
||||
const [logs, st] = await Promise.all([fetchAccessLogs(0, 100), fetchAccessStats()]);
|
||||
setItems(logs.content);
|
||||
setStats(st);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'unknown');
|
||||
} finally {
|
||||
@ -29,20 +29,59 @@ export function AccessLogs() {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const statusColor = (s: number) => s >= 500 ? 'bg-red-500/20 text-red-400' : s >= 400 ? 'bg-orange-500/20 text-orange-400' : 'bg-green-500/20 text-green-400';
|
||||
const statusColor = (s: number) =>
|
||||
s >= 500 ? 'bg-red-500/20 text-red-400'
|
||||
: s >= 400 ? 'bg-orange-500/20 text-orange-400'
|
||||
: 'bg-green-500/20 text-green-400';
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-heading">접근 이력</h1>
|
||||
<p className="text-xs text-hint mt-1">모든 HTTP 요청 (AccessLogFilter 비동기 기록)</p>
|
||||
<p className="text-xs text-hint mt-1">AccessLogFilter가 모든 HTTP 요청 비동기 기록</p>
|
||||
</div>
|
||||
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
|
||||
<RefreshCw className="w-3.5 h-3.5" /> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<MetricCard label="전체 요청" value={stats.total} color="text-heading" />
|
||||
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" />
|
||||
<MetricCard label="4xx (24h)" value={stats.error4xx} color="text-orange-400" />
|
||||
<MetricCard label="5xx (24h)" value={stats.error5xx} color="text-red-400" />
|
||||
<MetricCard label="평균 응답(ms)" value={Math.round(stats.avgDurationMs)} color="text-purple-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && stats.topPaths.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-2"><CardTitle className="text-xs text-label">호출 빈도 Top 10 (24시간)</CardTitle></CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead className="text-hint">
|
||||
<tr>
|
||||
<th className="text-left py-1">경로</th>
|
||||
<th className="text-right py-1 w-24">호출수</th>
|
||||
<th className="text-right py-1 w-28">평균(ms)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.topPaths.map((p) => (
|
||||
<tr key={p.path} className="border-t border-border">
|
||||
<td className="py-1.5 text-heading font-mono text-[10px]">{p.path}</td>
|
||||
<td className="py-1.5 text-right text-cyan-400 font-bold">{p.count}</td>
|
||||
<td className="py-1.5 text-right text-muted-foreground">{p.avg_ms}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
|
||||
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
@ -87,3 +126,14 @@ export function AccessLogs() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[10px] text-hint">{label}</div>
|
||||
<div className={`text-2xl font-bold ${color} mt-1`}>{value.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,26 +1,25 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { fetchAuditLogs, type AuditLog } from '@/services/adminApi';
|
||||
import { fetchAuditLogs, fetchAuditStats, type AuditLog, type AuditStats } from '@/services/adminApi';
|
||||
|
||||
/**
|
||||
* 감사 로그 조회 화면.
|
||||
* 감사 로그 조회 + 메트릭 카드.
|
||||
* 권한: admin:audit-logs (READ)
|
||||
*
|
||||
* 모든 운영자 의사결정 액션 (CONFIRM/REJECT/EXCLUDE/LABEL/LOGIN/...)
|
||||
* 이 백엔드 AuditAspect를 통해 자동 기록됨.
|
||||
*/
|
||||
export function AuditLogs() {
|
||||
const [items, setItems] = useState<AuditLog[]>([]);
|
||||
const [stats, setStats] = useState<AuditStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const res = await fetchAuditLogs(0, 100);
|
||||
setItems(res.content);
|
||||
const [logs, st] = await Promise.all([fetchAuditLogs(0, 100), fetchAuditStats()]);
|
||||
setItems(logs.content);
|
||||
setStats(st);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'unknown');
|
||||
} finally {
|
||||
@ -35,13 +34,39 @@ export function AuditLogs() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-heading">감사 로그</h1>
|
||||
<p className="text-xs text-hint mt-1">모든 운영자 의사결정 액션 자동 기록 (LOGIN/REVIEW_PARENT/EXCLUDE/LABEL...)</p>
|
||||
<p className="text-xs text-hint mt-1">@Auditable AOP가 모든 운영자 의사결정 자동 기록</p>
|
||||
</div>
|
||||
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
|
||||
<RefreshCw className="w-3.5 h-3.5" /> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<MetricCard label="전체 로그" value={stats.total} color="text-heading" />
|
||||
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" />
|
||||
<MetricCard label="실패 (24시간)" value={stats.failed24h} color="text-red-400" />
|
||||
<MetricCard label="액션 종류 (7일)" value={stats.byAction.length} color="text-purple-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션별 분포 */}
|
||||
{stats && stats.byAction.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-2"><CardTitle className="text-xs text-label">액션별 분포 (최근 7일)</CardTitle></CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats.byAction.map((a) => (
|
||||
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px] px-2 py-1">
|
||||
{a.action} <span className="text-cyan-400 font-bold ml-1">{a.count}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
|
||||
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
@ -92,3 +117,14 @@ export function AuditLogs() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[10px] text-hint">{label}</div>
|
||||
<div className={`text-2xl font-bold ${color} mt-1`}>{value.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,23 +1,25 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { fetchLoginHistory, type LoginHistory } from '@/services/adminApi';
|
||||
import { fetchLoginHistory, fetchLoginStats, type LoginHistory, type LoginStats } from '@/services/adminApi';
|
||||
|
||||
/**
|
||||
* 로그인 이력 조회.
|
||||
* 로그인 이력 조회 + 메트릭 카드.
|
||||
* 권한: admin:login-history (READ)
|
||||
*/
|
||||
export function LoginHistoryView() {
|
||||
const [items, setItems] = useState<LoginHistory[]>([]);
|
||||
const [stats, setStats] = useState<LoginStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const res = await fetchLoginHistory(0, 100);
|
||||
setItems(res.content);
|
||||
const [logs, st] = await Promise.all([fetchLoginHistory(0, 100), fetchLoginStats()]);
|
||||
setItems(logs.content);
|
||||
setStats(st);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'unknown');
|
||||
} finally {
|
||||
@ -45,6 +47,53 @@ export function LoginHistoryView() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<MetricCard label="전체 시도" value={stats.total} color="text-heading" />
|
||||
<MetricCard label="성공 (24h)" value={stats.success24h} color="text-green-400" />
|
||||
<MetricCard label="실패 (24h)" value={stats.failed24h} color="text-orange-400" />
|
||||
<MetricCard label="잠금 (24h)" value={stats.locked24h} color="text-red-400" />
|
||||
<MetricCard label="성공률 (24h)" value={stats.successRate} color="text-cyan-400" suffix="%" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사용자별 + 일자별 추세 */}
|
||||
{stats && (stats.byUser.length > 0 || stats.daily7d.length > 0) && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{stats.byUser.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-2"><CardTitle className="text-xs text-label">사용자별 성공 로그인 (7일)</CardTitle></CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-1">
|
||||
{stats.byUser.map((u) => (
|
||||
<div key={u.user_acnt} className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-cyan-400 font-mono">{u.user_acnt}</span>
|
||||
<span className="text-heading font-bold">{u.count}회</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{stats.daily7d.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="px-4 pt-3 pb-2"><CardTitle className="text-xs text-label">일별 추세 (7일)</CardTitle></CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-1">
|
||||
{stats.daily7d.map((d) => (
|
||||
<div key={d.day} className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-muted-foreground font-mono">{new Date(d.day).toLocaleDateString('ko-KR')}</span>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-green-400">성공 {d.success}</span>
|
||||
<span className="text-orange-400">실패 {d.failed}</span>
|
||||
<span className="text-red-400">잠금 {d.locked}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
|
||||
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
|
||||
@ -87,3 +136,14 @@ export function LoginHistoryView() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, color, suffix }: { label: string; value: number; color: string; suffix?: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-[10px] text-hint">{label}</div>
|
||||
<div className={`text-2xl font-bold ${color} mt-1`}>{value.toLocaleString()}{suffix && <span className="text-sm ml-1">{suffix}</span>}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -98,3 +98,103 @@ export function fetchPermTree() {
|
||||
export function fetchRoles() {
|
||||
return apiGet<RoleWithPermissions[]>('/roles');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 사용자 관리
|
||||
// ============================================================================
|
||||
|
||||
export interface AdminUser {
|
||||
userId: string;
|
||||
userAcnt: string;
|
||||
userNm: string;
|
||||
rnkpNm: string | null;
|
||||
email: string | null;
|
||||
userSttsCd: string;
|
||||
authProvider: string;
|
||||
failCnt: number;
|
||||
lastLoginDtm: string | null;
|
||||
createdAt: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
total: number;
|
||||
active: number;
|
||||
locked: number;
|
||||
inactive: number;
|
||||
pending: number;
|
||||
byStatus: Record<string, number>;
|
||||
byProvider: Record<string, number>;
|
||||
byRole: Record<string, number>;
|
||||
}
|
||||
|
||||
export function fetchUsers() {
|
||||
return apiGet<AdminUser[]>('/admin/users');
|
||||
}
|
||||
|
||||
export function fetchUserStats() {
|
||||
return apiGet<UserStats>('/admin/users/stats');
|
||||
}
|
||||
|
||||
export async function unlockUser(userId: string) {
|
||||
const res = await fetch(`${API_BASE}/admin/users/${userId}/unlock`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`API ${res.status}: unlock`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function changeUserStatus(userId: string, status: string) {
|
||||
const res = await fetch(`${API_BASE}/admin/users/${userId}/status`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`API ${res.status}: status`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 통계 (대시보드 카드)
|
||||
// ============================================================================
|
||||
|
||||
export interface AuditStats {
|
||||
total: number;
|
||||
last24h: number;
|
||||
failed24h: number;
|
||||
byAction: { action: string; count: number }[];
|
||||
hourly24: { hour: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface AccessStats {
|
||||
total: number;
|
||||
last24h: number;
|
||||
error4xx: number;
|
||||
error5xx: number;
|
||||
avgDurationMs: number;
|
||||
topPaths: { path: string; count: number; avg_ms: number }[];
|
||||
}
|
||||
|
||||
export interface LoginStats {
|
||||
total: number;
|
||||
success24h: number;
|
||||
failed24h: number;
|
||||
locked24h: number;
|
||||
successRate: number;
|
||||
byUser: { user_acnt: string; count: number }[];
|
||||
daily7d: { day: string; success: number; failed: number; locked: number }[];
|
||||
}
|
||||
|
||||
export function fetchAuditStats() {
|
||||
return apiGet<AuditStats>('/admin/stats/audit');
|
||||
}
|
||||
|
||||
export function fetchAccessStats() {
|
||||
return apiGet<AccessStats>('/admin/stats/access');
|
||||
}
|
||||
|
||||
export function fetchLoginStats() {
|
||||
return apiGet<LoginStats>('/admin/stats/login');
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user