Gemini를 활용하여 디자인 전면 수정, 화면 refresh 할 때 main.html으로 가는 버그 수정, 주석 갱신

main
icksishu@gmail.com 2 weeks ago
parent 7da201a635
commit a5c1027431

@ -0,0 +1,372 @@
# 나중에 삭제할 것
---
좋습니다. 요청하신 시나리오를 **“dfxconsole가 JSON을 ARIA-GCM으로 암호화해서 전송 → dfxagent가 /listen, /command에서 복호화”**로 딱 맞춰서, 바로 붙여서 테스트 가능한 형태로 예제를 드리겠습니다. (Java/Spring Boot 기준)
아래 예제는 **키 1개(대칭키)** + **keyId(버전)** + **nonce(IV)** + **ciphertext**(+tag 포함) 구조로 갑니다.
---
## 0) 전송 포맷(권장)
dfxconsole → dfxagent로 보내는 요청 Body를 “암호화 봉투(envelope)”로 통일합니다.
```json
{
"alg": "ARIA-256-GCM",
"kid": "K20251224-01",
"iv": "base64(12 bytes)",
"ct": "base64(ciphertext+tag)"
}
```
* **iv(Nonce)**: 12바이트 랜덤 (Base64)
* **ct**: `cipher.doFinal()` 결과를 Base64로 (여기에 **GCM tag**가 자동 포함)
* **kid**: 키 로테이션 대비 (나중에 온라인 배포/교체 때 중요)
---
## 1) 키 생성 & 오프라인 배포 파일
### 1-1. dfxconsole에서 키 생성(예: CLI/유틸)
```java
import java.nio.file.*;
import java.security.SecureRandom;
import java.util.Base64;
public class AriaKeyGen {
public static void main(String[] args) throws Exception {
// ARIA-256 = 32 bytes
byte[] key = new byte[32];
new SecureRandom().nextBytes(key);
String kid = "K20251224-01";
String b64 = Base64.getEncoder().encodeToString(key);
String json = """
{
"kid": "%s",
"kty": "oct",
"alg": "ARIA-256-GCM",
"k": "%s"
}
""".formatted(kid, b64);
Files.writeString(Path.of("dfx-aria-key.json"), json);
System.out.println("Wrote dfx-aria-key.json");
}
}
```
생성된 `dfx-aria-key.json`를 **오프라인(USB 등)으로 dfxagent 서버에 배포**합니다.
### 1-2. dfxagent에서 키 로딩(서버 시작 시)
`application.yml`:
```yml
crypto:
keyFile: /opt/dfx/keys/dfx-aria-key.json
```
키 파일 DTO:
```java
public record KeyFile(String kid, String kty, String alg, String k) {}
```
로더:
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
@Component
public class KeyManager {
private final ObjectMapper om = new ObjectMapper();
@Value("${crypto.keyFile}")
private String keyFile;
private volatile String currentKid;
private volatile SecretKey currentKey;
@PostConstruct
public void load() throws Exception {
String json = Files.readString(Path.of(keyFile));
KeyFile kf = om.readValue(json, KeyFile.class);
byte[] keyBytes = Base64.getDecoder().decode(kf.k());
this.currentKid = kf.kid();
this.currentKey = new SecretKeySpec(keyBytes, "ARIA");
}
public String kid() { return currentKid; }
public SecretKey keyByKid(String kid) {
// 지금은 단일 키만 운용한다고 가정
if (!currentKid.equals(kid)) throw new IllegalArgumentException("Unknown kid: " + kid);
return currentKey;
}
}
```
---
## 2) ARIA-GCM 암/복호화 공통 유틸(양쪽 동일)
> **중요:** ARIA 알고리즘 지원은 JCE 기본 Provider에 없을 수 있어, 보통 **BouncyCastle Provider**를 추가합니다.
Gradle:
```gradle
implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
```
Provider 등록(앱 시작 시 1회):
```java
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import java.security.Security;
public class CryptoProviders {
static {
if (Security.getProvider("BC") == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
}
```
암호화/복호화 서비스:
```java
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.security.SecureRandom;
public class AriaGcm {
private static final int NONCE_LEN = 12; // 96-bit
private static final int TAG_LEN_BITS = 128;
private final SecureRandom rnd = new SecureRandom();
public byte[] randomNonce() {
byte[] nonce = new byte[NONCE_LEN];
rnd.nextBytes(nonce);
return nonce;
}
public byte[] encrypt(SecretKey key, byte[] nonce, byte[] plaintext, byte[] aad) throws Exception {
Cipher cipher = Cipher.getInstance("ARIA/GCM/NoPadding", "BC");
GCMParameterSpec spec = new GCMParameterSpec(TAG_LEN_BITS, nonce);
cipher.init(Cipher.ENCRYPT_MODE, key, spec);
if (aad != null && aad.length > 0) cipher.updateAAD(aad);
return cipher.doFinal(plaintext); // ciphertext + tag
}
public byte[] decrypt(SecretKey key, byte[] nonce, byte[] ciphertextAndTag, byte[] aad) throws Exception {
Cipher cipher = Cipher.getInstance("ARIA/GCM/NoPadding", "BC");
GCMParameterSpec spec = new GCMParameterSpec(TAG_LEN_BITS, nonce);
cipher.init(Cipher.DECRYPT_MODE, key, spec);
if (aad != null && aad.length > 0) cipher.updateAAD(aad);
return cipher.doFinal(ciphertextAndTag); // tag 검증 실패 시 예외
}
}
```
---
## 3) dfxconsole: JSON을 암호화해서 dfxagent로 POST 전송
Envelope DTO:
```java
public record EncryptedEnvelope(String alg, String kid, String iv, String ct) {}
```
전송 서비스(예: RestTemplate/WebClient 어느 쪽이든 OK — 여기선 WebClient):
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
@Service
public class DfxConsoleSender extends CryptoProviders {
private final WebClient webClient = WebClient.builder().build();
private final ObjectMapper om = new ObjectMapper();
private final AriaGcm aria = new AriaGcm();
// dfxconsole이 보유한 송신용 키(오프라인 배포 전제)
private final String kid = "K20251224-01";
private final SecretKey key; // 구성/주입으로 가져오세요
public DfxConsoleSender(SecretKey key) {
this.key = key;
}
public void sendEncryptedJson(String url, Object jsonPayload, String endpointPathForAAD) throws Exception {
byte[] plain = om.writeValueAsBytes(jsonPayload);
byte[] nonce = aria.randomNonce();
// AAD(추가 인증 데이터): 경로/메소드 등을 넣으면 “다른 엔드포인트로 재전송” 같은 것을 일부 완화
byte[] aad = ("POST:" + endpointPathForAAD).getBytes(StandardCharsets.UTF_8);
byte[] ct = aria.encrypt(key, nonce, plain, aad);
EncryptedEnvelope env = new EncryptedEnvelope(
"ARIA-256-GCM",
kid,
Base64.getEncoder().encodeToString(nonce),
Base64.getEncoder().encodeToString(ct)
);
webClient.post()
.uri(url)
.bodyValue(env)
.retrieve()
.toBodilessEntity()
.block();
}
}
```
사용 예:
```java
// /listen 으로 보낼 JSON
Map<String,Object> payload = Map.of("type","MESSAGE","data", Map.of("a",1,"b","x"));
sender.sendEncryptedJson("https://dfxagent-host/listen", payload, "/listen");
// /command 으로 보낼 JSON
Map<String,Object> cmd = Map.of("cmd","PING","ts", System.currentTimeMillis());
sender.sendEncryptedJson("https://dfxagent-host/command", cmd, "/command");
```
---
## 4) dfxagent: /listen, /command에서 수신 후 복호화
dfxagent는 컨트롤러에서 `EncryptedEnvelope`를 받아서 복호화한 뒤 **원래 JSON(Map/DTO)** 로 변환합니다.
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
@RestController
public class DfxAgentReceiver extends CryptoProviders {
private final ObjectMapper om = new ObjectMapper();
private final AriaGcm aria = new AriaGcm();
private final KeyManager km;
public DfxAgentReceiver(KeyManager km) {
this.km = km;
}
@PostMapping("/listen")
public Map<String,Object> listen(@RequestBody EncryptedEnvelope env) throws Exception {
Map<String,Object> data = decryptEnvelope(env, "POST:/listen");
// TODO: 기존 /listen 처리 로직에 data 넘기기
return Map.of("ok", true, "receivedType", data.get("type"));
}
@PostMapping("/command")
public Map<String,Object> command(@RequestBody EncryptedEnvelope env) throws Exception {
Map<String,Object> data = decryptEnvelope(env, "POST:/command");
// TODO: 기존 /command 처리 로직에 data 넘기기
return Map.of("ok", true, "cmd", data.get("cmd"));
}
private Map<String,Object> decryptEnvelope(EncryptedEnvelope env, String aadStr) throws Exception {
byte[] nonce = Base64.getDecoder().decode(env.iv());
byte[] ct = Base64.getDecoder().decode(env.ct());
byte[] aad = aadStr.getBytes(StandardCharsets.UTF_8);
var key = km.keyByKid(env.kid());
byte[] plain = aria.decrypt(key, nonce, ct, aad); // tag 검증 실패 시 예외 발생
return om.readValue(plain, Map.class);
}
}
```
### 복호화 실패 처리(권장)
GCM tag가 틀리면 `AEADBadTagException`류 예외가 납니다. 이걸 400/401로 정리해 두면 운영이 편합니다.
```java
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import javax.crypto.AEADBadTagException;
@RestControllerAdvice
public class CryptoExceptionHandler {
@ExceptionHandler(AEADBadTagException.class)
public ResponseEntity<?> badTag(AEADBadTagException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("ok", false, "error", "INVALID_TAG"));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<?> badReq(IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("ok", false, "error", e.getMessage()));
}
}
```
---
## 5) 운영에서 꼭 넣어야 하는 체크포인트
1. **Nonce 재사용 금지**
* 위 예제는 매번 랜덤 생성 → OK
* (대량 트래픽이면) “랜덤 충돌” 가능성이 이론상 있으니, 트래픽이 매우 크면 **카운터 기반(CTR-style) nonce 전략**도 고려
2. **키는 코드/DB에 박지 말고 파일/환경변수/KMS**
* 지금은 오프라인 파일 OK
* 추후 온라인 배포 시에는 `kid → key` 다중 보관 + 롤링
3. **AAD 사용 권장**
* 예제처럼 `POST:/listen` 등을 넣으면, 암호문을 다른 엔드포인트로 그대로 옮겨 재전송하는 류를 일부 줄입니다.
4. **ct에 tag 포함**
* GCM은 `doFinal()` 결과에 tag가 붙는 구현이 일반적
* “ct / tag”를 분리 저장하고 싶으면 분리 포맷으로 바꿔도 됩니다.
---
## 6) 다음 단계(원하시면 바로 붙여드릴 것)
* dfxagent에서 **“암호화 envelope 자동 복호화”**:
컨트롤러마다 decrypt 호출 안 하고, `@RequestBody` 앞단에서 필터/어드바이스로 자동화
* 키 로테이션: `kid`별 키 다중 로딩 + “현재 kid로 암호화, 과거 kid도 복호화 지원”
* 온라인 배포(추후): dfxconsole → dfxagent 키 갱신 API + 서명(키 배포용 공개키 기반)
원하시는 방향이 **“/listen, /command는 기존 그대로 두고(평문 JSON 파라미터), 네트워크 구간만 암호화”**인지, 아니면 **“바디 자체를 envelope로 바꿔도 된다”**인지에 따라 구현 위치(필터/어드바이스/컨트롤러)가 달라집니다.
지금 예제는 가장 단순한 **envelope 방식**인데, dfxagent 기존 API 바디 형태를 유지해야 한다면 그 방식으로도 바로 변형 예제를 드릴게요.

@ -4,10 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DFXConsole</title>
<title>DFX Console — Data Flow eXchange</title>
</head>
<body>
<div id="app" class="container d-flex min-vh-100 flex-column"></div>
<div id="app" class="d-flex min-vh-100 flex-column w-100"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

@ -1,25 +1,34 @@
<script setup>
import { RouterLink, RouterView, useRouter } from 'vue-router'
import { onMounted } from 'vue'
import { userApi } from '@/components/userInfo'
const router = useRouter()
onMounted(async () => {
const isLogin = await userApi.loginCheck()
if (isLogin) {
router.push('/main.html')
} else {
router.replace('/login.html')
}
})
/**
* 최상위 루트 컴포넌트 (App.vue)
* - 라우터 뷰(RouterView)가 렌더링되는 메인 셸 영역과 하단 공통 푸터(Footer)를 구성합니다.
* - 초기 세션 체크 및 페이지 이동 제어는 router/index.js의 beforeEach 가드에서 일괄 처리합니다.
*/
import { RouterView } from 'vue-router'
</script>
<template>
<!-- 라우터에 의해 LoginView 또는 MainView가 표시되는 영역 -->
<div class="dfx-app-shell d-flex flex-column flex-grow-1 w-100">
<RouterView />
<footer class="w-100 border-top d-flex">
<div class="bg-info-subtle p-2 d-flex align-items-center w-100 bg-purple">바닥글(저작권, 링크 등) 영역</div>
</div>
<!-- 하단 공통 바닥글(Footer) 영역: 브랜드 표기 및 버전 정보 -->
<footer class="dfx-footer w-100 d-flex align-items-center justify-content-between px-4">
<div class="d-flex align-items-center gap-3">
<span class="fw-semibold text-secondary">BSM LAB</span>
<span class="text-muted">|</span>
<span>DFX Console — Data Flow eXchange Management Platform</span>
</div>
<div class="d-flex align-items-center gap-3 dfx-mono">
<span class="text-muted">v0.1</span>
</div>
</footer>
</template>
<style scoped></style>
<style scoped>
/* 하단 푸터 높이(36px)를 제외한 나머지 전체 화면 높이를 차지하도록 설정 */
.dfx-app-shell {
min-height: calc(100vh - 36px);
}
</style>

@ -1,42 +1,29 @@
@import './base.css';
/* Vue 마운트 루트 컨테이너: 가로 최대폭 제한 없이 전체 화면(Full-Width)을 사용하도록 설정 */
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
flex-wrap: wrap;
flex-direction: row;
align-items: stretch;
width: 100%;
min-height: 100vh;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
background-color: var(--dfx-bg-base);
}
a,
.green {
/* 기본 링크 스타일 초기화 */
a {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
padding: 0 2rem;
}
color: inherit;
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
}
footer {
min-height: 2rem;
max-height: 2rem;
/* 하단 공통 푸터 바 스타일 */
footer.dfx-footer {
height: 36px;
min-height: 36px;
background-color: var(--dfx-surface-1);
border-top: 1px solid var(--dfx-border);
color: var(--dfx-text-muted);
font-size: 12px;
}

@ -1,91 +1,72 @@
/* color palette from <https://github.com/vuejs/theme>
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
*/
/* semantic color variables for this project
/**
* DFX Console 전역 디자인 토큰 (base.css)
* - 플랫(Flat) 다크 테마에 사용되는 배경색, 표면(Surface) 계층색, 테두리, 상태 강조색 및 폰트를 정의합니다.
*/
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
/* 배경 및 표면(Surface) 계층 컬러 */
--dfx-bg-base: #0b0f17; /* 최하단 기본 배경색 */
--dfx-bg-canvas: #0f1520; /* 메인 컨텐츠 영역 캔버스 배경색 */
--dfx-surface-1: #151d2a; /* 카드, 사이드바, 헤더, 모달의 기본 배경색 */
--dfx-surface-2: #1c2636; /* 카드 헤더, 테이블 헤더, 입력창 읽기전용 배경색 */
--dfx-surface-3: #243146; /* 마우스 오버(Hover) 및 활성 보조 배경색 */
--section-gap: 160px;
}
/* 직각 경계선(Border) 컬러 */
--dfx-border: #2a374b; /* 기본 1px 실선 테두리 */
--dfx-border-strong: #384963; /* 강조 구분선 및 모달/테이블 헤더 하단 테두리 */
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
/* 의미론적(Semantic) 상태 및 포인트 컬러 */
--dfx-primary: #2563eb; /* 기본 액센트 블루 */
--dfx-primary-hover: #1d4ed8; /* 기본 버튼 호버 블루 */
--dfx-primary-subtle: rgba(37, 99, 235, 0.15);
--dfx-accent: #06b6d4; /* 보조 포인트 시안(Cyan) */
--dfx-success: #10b981; /* 정상/성공(STATUS_OK, PROCESS_DONE) 그린 */
--dfx-success-subtle: rgba(16, 185, 129, 0.14);
--dfx-warning: #f59e0b; /* 경고/강조 앰버(Amber) */
--dfx-warning-subtle: rgba(245, 158, 11, 0.14);
--dfx-danger: #ef4444; /* 오류/삭제(PROCESS_FAIL) 레드 */
--dfx-danger-subtle: rgba(239, 68, 68, 0.14);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
/* 텍스트 계층 컬러 */
--dfx-text-primary: #f1f5f9; /* 본문 및 주요 타이틀 텍스트 */
--dfx-text-secondary: #94a3b8; /* 보조 라벨 및 부제목 텍스트 */
--dfx-text-muted: #64748b; /* 비활성 또는 힌트 텍스트 */
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
/* 타이포그래피: 본문용 산세리프 & 식별자/코드/수치용 고정폭(Monospace) 폰트 */
--dfx-font-sans:
'Pretendard',
'맑은 고딕',
'Malgun Gothic',
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
--dfx-font-mono:
'JetBrains Mono',
'Consolas',
'Courier New',
monospace;
}
*/
/* 모든 엘리먼트 선언 */
/* 모든 요소의 박스 모델을 border-box로 통일하고 기본 여백 초기화 */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
/* 문서 전체 기본 높이, 배경색, 폰트 렌더링 설정 */
html,
body {
/*
color: var(--color-text);
background: var(--color-background);
*/
width: 100%;
min-height: 100vh;
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
'맑은 고딕',
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
background-color: var(--dfx-bg-base);
color: var(--dfx-text-primary);
font-family: var(--dfx-font-sans);
font-size: 14px;
line-height: 1.5;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;

@ -1,45 +1,505 @@
/* 상단 타이틀 및 바로가기 영역 */
.dfx-top-title-bar {
/*
/**
* DFX Console 공통 컴포넌트 및 레이아웃 스타일 (main.css)
* - 기본 디자인 원칙: 플랫 디자인(Flat Design) + 모든 사각 요소 라운드 제거(border-radius: 0)
*/
background-color: rebeccapurple;
min-height: 2rem;
}
/* 왼쪽 사이드바 영역 */
.dfx-left-side {
background-color: aqua;
min-width: 14rem;
}
/* 메뉴 내비게이션 영역 */
.dfx-left-menu-nav {
background-color: cornflowerblue;
}
/* 메인 컨텐츠 전체 영역 */
.dfx-main {
background-color: var(--bs-tertiary-bg);
min-height: calc(100vh - 6rem);
}
:root {
/* 기본 radius */
:root,
[data-bs-theme='dark'] {
/* Bootstrap 전역 border-radius 변수를 모두 0(직각)으로 재정의 */
--bs-border-radius: 0;
--bs-border-radius-sm: 0;
--bs-border-radius-lg: 0;
--bs-border-radius-xl: 0;
--bs-border-radius-xxl: 0;
--bs-border-radius-2xl: 0;
--bs-border-radius-pill: 0;
/* 컴포넌트별 radius */
/* 버튼, 카드, 모달, 드롭다운, 배지, 입력창의 개별 radius 변수도 모두 0으로 설정 */
--bs-btn-border-radius: 0;
--bs-btn-border-radius-sm: 0;
--bs-btn-border-radius-lg: 0;
--bs-card-border-radius: 0;
--bs-card-inner-border-radius: 0;
--bs-modal-border-radius: 0;
--bs-dropdown-border-radius: 0;
--bs-badge-border-radius: 0;
--bs-input-border-radius: 0;
/* Bootstrap 다크 테마 기본 색상 변수를 DFX 디자인 토큰과 연동 */
--bs-body-bg: var(--dfx-bg-base);
--bs-body-color: var(--dfx-text-primary);
--bs-border-color: var(--dfx-border);
--bs-tertiary-bg: var(--dfx-bg-canvas);
--bs-secondary-bg: var(--dfx-surface-1);
}
/* 모든 사각형 UI 컴포넌트에 대해 모서리 둥글기(border-radius)를 0으로 강제 적용 */
.card,
.card-header,
.card-footer,
.btn,
.btn-sm,
.btn-lg,
.form-control,
.form-select,
.input-group-text,
.modal-content,
.modal-header,
.modal-footer,
.dropdown-menu,
.dropdown-item,
.nav-link,
.nav-tabs .nav-link,
.nav-pills .nav-link,
.badge,
.page-link,
.page-item:first-child .page-link,
.page-item:last-child .page-link,
.alert,
.list-group-item,
.table,
.form-check-input[type='checkbox'],
.rounded,
.rounded-1,
.rounded-2,
.rounded-3,
.rounded-4,
.rounded-top,
.rounded-bottom,
.rounded-start,
.rounded-end {
border-radius: 0 !important;
}
/* 플랫 디자인: 불필요한 입체 그림자(box-shadow)와 배경 그라데이션 제거 */
.btn,
.card,
.form-control,
.form-select,
.input-group-text,
.page-link {
box-shadow: none !important;
background-image: none;
}
/* 입력창(input, select) 기본 플랫 스타일 */
.form-control,
.form-select {
background-color: var(--dfx-bg-base);
border: 1px solid var(--dfx-border);
color: var(--dfx-text-primary);
font-size: 13px;
padding: 0.42rem 0.65rem;
}
/* 입력창 포커스 시 번지는 그림자 대신 선명한 1px 파란색 테두리 강조 */
.form-control:focus,
.form-select:focus {
background-color: var(--dfx-bg-base);
border-color: var(--dfx-primary) !important;
color: var(--dfx-text-primary);
outline: 1px solid var(--dfx-primary);
outline-offset: -1px;
}
/* 읽기 전용(readonly) 및 비활성(disabled) 입력창 스타일 */
.form-control[readonly],
.form-control:disabled {
background-color: var(--dfx-surface-2);
border-color: var(--dfx-border);
color: var(--dfx-text-secondary);
}
/* 입력 그룹 라벨(input-group-text) 스타일 */
.input-group-text {
background-color: var(--dfx-surface-2);
border: 1px solid var(--dfx-border);
color: var(--dfx-text-secondary);
font-size: 12.5px;
font-weight: 600;
}
/* 사각 체크박스 스타일 */
.form-check-input[type='checkbox'] {
background-color: var(--dfx-bg-base);
border: 1px solid var(--dfx-border-strong);
cursor: pointer;
}
.form-check-input[type='checkbox']:checked {
background-color: var(--dfx-primary);
border-color: var(--dfx-primary);
}
.form-check-input:focus {
box-shadow: none !important;
border-color: var(--dfx-primary);
}
/* 플랫 버튼 공통 및 색상별 스타일 */
.btn {
font-weight: 600;
font-size: 13px;
letter-spacing: 0.01em;
padding: 0.42rem 0.85rem;
border-width: 1px;
transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.btn-sm {
font-size: 12px;
padding: 0.28rem 0.65rem;
}
.btn-primary {
background-color: var(--dfx-primary);
border-color: var(--dfx-primary);
color: #ffffff;
}
.btn-primary:hover,
.btn-primary:focus {
background-color: var(--dfx-primary-hover);
border-color: var(--dfx-primary-hover);
color: #ffffff;
}
.btn-success {
background-color: #059669;
border-color: #059669;
color: #ffffff;
}
.btn-success:hover,
.btn-success:focus {
background-color: #047857;
border-color: #047857;
color: #ffffff;
}
.btn-danger {
background-color: #dc2626;
border-color: #dc2626;
color: #ffffff;
}
.btn-danger:hover,
.btn-danger:focus {
background-color: #b91c1c;
border-color: #b91c1c;
color: #ffffff;
}
.btn-info {
background-color: #0284c7;
border-color: #0284c7;
color: #ffffff;
}
.btn-info:hover,
.btn-info:focus {
background-color: #0369a1;
border-color: #0369a1;
color: #ffffff;
}
/* 보조(Ghost) 버튼 스타일 (새로고침, 계정 버튼 등) */
.btn-outline-secondary,
.dfx-btn-ghost {
background-color: var(--dfx-surface-2);
border: 1px solid var(--dfx-border);
color: var(--dfx-text-primary);
}
.btn-outline-secondary:hover,
.dfx-btn-ghost:hover {
background-color: var(--dfx-surface-3);
border-color: var(--dfx-border-strong);
color: #ffffff;
}
/* 상단 타이틀 및 바로가기 헤더 영역 */
.dfx-top-title-bar {
background-color: var(--dfx-surface-1);
border-bottom: 1px solid var(--dfx-border);
height: 52px;
min-height: 52px;
padding: 0 1.25rem;
z-index: 1020;
}
/* 좌측 사이드바 메뉴 영역 */
.dfx-left-side {
background-color: var(--dfx-surface-1);
border-right: 1px solid var(--dfx-border);
width: 230px;
min-width: 230px;
max-width: 230px;
}
/* 우측 메인 컨텐츠 전체 영역 */
.dfx-main {
background-color: var(--dfx-bg-canvas);
min-height: calc(100vh - 88px);
flex: 1 1 auto;
padding: 1.5rem 1.75rem;
max-width: 100%;
}
/* 각 화면 상단 제목(Page Header) 영역 */
.dfx-page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 1rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--dfx-border);
}
/* 화면 제목 텍스트 (좌측에 4px 수직 포인트 바 표시) */
.dfx-page-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--dfx-text-primary);
margin: 0;
display: flex;
align-items: center;
gap: 0.6rem;
letter-spacing: -0.01em;
}
.dfx-page-title::before {
content: '';
display: inline-block;
width: 4px;
height: 18px;
background-color: var(--dfx-primary);
}
/* 화면 제목 하단 부연 설명 텍스트 */
.dfx-page-subtitle {
font-size: 12px;
color: var(--dfx-text-secondary);
margin-top: 2px;
}
/* 플랫 카드 및 패널 컨테이너 */
.card,
.dfx-panel {
background-color: var(--dfx-surface-1);
border: 1px solid var(--dfx-border);
border-radius: 0 !important;
}
.card-header,
.dfx-panel-header {
background-color: var(--dfx-surface-2);
border-bottom: 1px solid var(--dfx-border);
padding: 0.65rem 1rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
}
.card-body,
.dfx-panel-body {
padding: 1rem;
}
/* 대시보드 상단 요약 지표(KPI) 플랫 카드 (상단 3px 컬러 보더 강조) */
.dfx-kpi-card {
background-color: var(--dfx-surface-1);
border: 1px solid var(--dfx-border);
border-top: 3px solid var(--dfx-primary);
padding: 1rem 1.15rem;
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
}
.dfx-kpi-card.kpi-cyan {
border-top-color: var(--dfx-accent);
}
.dfx-kpi-card.kpi-emerald {
border-top-color: var(--dfx-success);
}
.dfx-kpi-card.kpi-amber {
border-top-color: var(--dfx-warning);
}
.dfx-kpi-label {
font-size: 11.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--dfx-text-secondary);
}
.dfx-kpi-value {
font-size: 1.65rem;
font-weight: 700;
font-family: var(--dfx-font-mono);
color: var(--dfx-text-primary);
line-height: 1.2;
margin-top: 0.35rem;
}
.dfx-kpi-meta {
font-size: 12px;
color: var(--dfx-text-muted);
margin-top: 0.35rem;
}
/* 데이터 테이블(Table) 플랫 다크 스타일 */
.table {
--bs-table-bg: var(--dfx-surface-1);
--bs-table-striped-bg: rgba(28, 38, 54, 0.55);
--bs-table-hover-bg: rgba(37, 99, 235, 0.08);
--bs-table-border-color: var(--dfx-border);
color: var(--dfx-text-primary);
margin-bottom: 0;
font-size: 13px;
}
.table > thead > tr > th {
background-color: var(--dfx-surface-2);
color: var(--dfx-text-secondary);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.65rem 0.75rem;
border-bottom: 2px solid var(--dfx-border-strong);
vertical-align: middle;
}
.table > tbody > tr > td {
padding: 0.6rem 0.75rem;
vertical-align: middle;
border-color: var(--dfx-border);
}
.table-group-divider {
border-top-color: var(--dfx-border-strong) !important;
}
/* 직각 상태 배지(.dfx-badge) 및 상태별 컬러 스타일 */
.dfx-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.55rem;
font-size: 11.5px;
font-weight: 600;
font-family: var(--dfx-font-mono);
letter-spacing: 0.02em;
border: 1px solid transparent;
border-radius: 0 !important;
line-height: 1.3;
}
/* 배지 내부의 작은 사각 상태 인디케이터 점 */
.dfx-badge-dot {
width: 6px;
height: 6px;
display: inline-block;
}
.dfx-badge-success {
background-color: var(--dfx-success-subtle);
color: #34d399;
border-color: rgba(16, 185, 129, 0.35);
}
.dfx-badge-success .dfx-badge-dot {
background-color: #10b981;
}
.dfx-badge-danger {
background-color: var(--dfx-danger-subtle);
color: #f87171;
border-color: rgba(239, 68, 68, 0.35);
}
.dfx-badge-danger .dfx-badge-dot {
background-color: #ef4444;
}
.dfx-badge-info {
background-color: rgba(6, 182, 212, 0.14);
color: #22d3ee;
border-color: rgba(6, 182, 212, 0.35);
}
.dfx-badge-info .dfx-badge-dot {
background-color: #06b6d4;
}
.dfx-badge-neutral {
background-color: var(--dfx-surface-2);
color: var(--dfx-text-secondary);
border-color: var(--dfx-border);
}
.dfx-badge-neutral .dfx-badge-dot {
background-color: var(--dfx-text-muted);
}
/* ID, 호스트명, 포트, 시간, UUID 등에 사용하는 고정폭 폰트 유틸리티 클래스 */
.dfx-mono {
font-family: var(--dfx-font-mono);
font-size: 12.5px;
}
/* 플랫 모달(Modal) 팝업창 스타일 */
.modal-content {
background-color: var(--dfx-surface-1);
border: 1px solid var(--dfx-border-strong);
box-shadow: none !important;
}
.modal-header {
background-color: var(--dfx-surface-2);
border-bottom: 1px solid var(--dfx-border);
padding: 0.75rem 1.25rem;
}
.modal-footer {
background-color: var(--dfx-surface-2);
border-top: 1px solid var(--dfx-border);
}
/* 플랫 탭(Nav Tabs) 스타일 (활성 탭 상단 2px 파란색 라인 강조) */
.nav-tabs {
border-bottom: 1px solid var(--dfx-border);
gap: 2px;
}
.nav-tabs .nav-link {
background-color: var(--dfx-surface-2);
border: 1px solid var(--dfx-border);
border-bottom: none;
color: var(--dfx-text-secondary);
font-weight: 600;
font-size: 13px;
padding: 0.55rem 1.15rem;
margin-bottom: 0;
}
.nav-tabs .nav-link:hover {
background-color: var(--dfx-surface-3);
color: var(--dfx-text-primary);
border-color: var(--dfx-border-strong);
}
.nav-tabs .nav-link.active {
background-color: var(--dfx-surface-1);
color: #ffffff;
border-color: var(--dfx-border-strong);
border-top: 2px solid var(--dfx-primary);
}

File diff suppressed because it is too large Load Diff

@ -1,28 +1,35 @@
<script setup>
/**
* 송수신 메시지 원본 데이터(Payload) 상세 조회 모달 컴포넌트 (MessageDataView.vue)
* - 메시지 이력에 포함된 JSON 문자열(messageData)을 파싱하여 동적 컬럼 테이블로 표시합니다.
* - 조회된 테이블 데이터를 Excel(.xlsx) 파일로 즉시 다운로드할 수 있는 기능을 제공합니다.
*/
import { defineProps, ref, onMounted, onBeforeUnmount } from 'vue'
import * as bootstrap from 'bootstrap'
import * as XSLX from 'xlsx'
// 부모 컴포넌트로부터 전달받는 모달 고유 ID 및 선택된 메시지 DTO
const props = defineProps({
modalId: String,
dto: Object,
})
const modalEl = ref(null)
let bsModal = null
let dataList = ref([])
let columnList = ref([])
const modalEl = ref(null) // Bootstrap 모달 DOM 참조
let bsModal = null // Bootstrap Modal 인스턴스
let dataList = ref([]) // JSON 파싱된 메시지 데이터 행(Row) 배열
let columnList = ref([]) // 첫 번째 행의 Key 목록에서 추출한 테이블 컬럼명 배열
// 컴포넌트 마운트 시 Bootstrap 모달 인스턴스 초기화 (외부 클릭 및 ESC 닫기 방지)
onMounted(() => {
if (modalEl.value) {
// 인스턴스가 있으면 재사용, 없으면 생성
bsModal = bootstrap.Modal.getOrCreateInstance(modalEl.value, {
backdrop: 'static', // 바깥 클릭으로 닫히지 않게
keyboard: false, // ESC로 닫히지 않게 (원하시면 true 로)
backdrop: 'static',
keyboard: false,
})
}
})
// 컴포넌트 언마운트 전 모달 인스턴스를 안전하게 닫고 메모리 해제
onBeforeUnmount(() => {
if (!bsModal) return
const instance = bsModal
@ -42,16 +49,23 @@ onBeforeUnmount(() => {
bsModal = null
})
/**
* 모달 팝업 열기 (부모 컴포넌트에서 ref를 통해 호출)
* - 전달받은 DTO의 messageData(JSON 문자열)를 파싱하여 컬럼 목록과 데이터 행을 구성합니다.
*/
function open() {
if (bsModal) {
if (props.dto.messageData) {
dataList.value = JSON.parse(props.dto.messageData)
columnList.value = Object.keys(dataList.value[0])
columnList.value = dataList.value.length > 0 ? Object.keys(dataList.value[0]) : []
}
bsModal.show()
}
}
/**
* 모달 팝업 닫기 및 내부 데이터 초기화
*/
function close() {
if (bsModal) {
bsModal.hide()
@ -60,6 +74,10 @@ function close() {
}
}
/**
* 현재 화면에 렌더링된 데이터 테이블을 Excel(.xlsx) 파일로 변환하여 다운로드합니다.
* - 파일명은 송신 Postman ID가 있으면 '{senderPostmanId}.xlsx', 없으면 'interface-messages.xlsx'로 저장됩니다.
*/
function excelExport() {
const table = document.getElementById(props.modalId + 'Table')
if (!table) {
@ -72,6 +90,7 @@ function excelExport() {
XSLX.writeFile(workbook, fileName)
}
// 부모 컴포넌트에서 open(), close()를 직접 호출할 수 있도록 노출
defineExpose({
open,
close,
@ -79,32 +98,69 @@ defineExpose({
</script>
<template>
<!-- Modal -->
<div class="modal fade" :id="props.modalId" ref="modalEl" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" :aria-labelledby="props.modalId + 'Label'" aria-hidden="true">
<!-- 메시지 데이터(Payload) 상세 조회 모달 -->
<div
class="modal fade"
:id="props.modalId"
ref="modalEl"
data-bs-backdrop="static"
data-bs-keyboard="false"
tabindex="-1"
:aria-labelledby="props.modalId + 'Label'"
aria-hidden="true"
>
<div class="modal-dialog modal-xl data-modal-dialog">
<div class="modal-content data-modal-content">
<!-- 모달 헤더: 송수신 에이전트 경로, 메시지 UUID, 행 개수 배지 및 EXCEL 다운로드 버튼 -->
<div class="modal-header justify-content-between">
<h1 class="modal-title fs-5 pt-0" :id="props.modalId + 'Label'">{{ props.dto.senderAgentId }} <i class="bi bi-arrow-right"></i> {{ props.dto.recipientAgentId }}</h1>
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="dfx-badge dfx-badge-info">PAYLOAD VIEWER</span>
<h1 class="modal-title fs-6 fw-bold mb-0 dfx-mono" :id="props.modalId + 'Label'">
{{ props.dto.senderAgentId }} <i class="bi bi-arrow-right text-primary mx-1"></i> {{ props.dto.recipientAgentId }}
</h1>
<span v-if="props.dto.messageUuid" class="dfx-mono text-secondary" style="font-size: 11.5px">
[UUID: {{ props.dto.messageUuid }}]
</span>
</div>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-success btn-sm" @click="excelExport">EXCEL</button>
<span class="dfx-badge dfx-badge-neutral">{{ dataList.length }} ROWS</span>
<button type="button" class="btn btn-success btn-sm d-flex align-items-center gap-1" @click="excelExport">
<i class="bi bi-file-earmark-spreadsheet"></i>
<span>EXCEL</span>
</button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
</div>
<!-- 모달 바디: 상단 헤더가 고정(Sticky)되는 스크롤형 동적 데이터 테이블 -->
<div class="modal-body data-modal-body">
<div class="data-table-wrapper table-responsive">
<table class="table table-striped table-bordered table-hover table-sm align-middle fs-7 data-table" :id="props.modalId + 'Table'">
<table
class="table table-striped table-bordered table-hover table-sm align-middle fs-7 data-table"
:id="props.modalId + 'Table'"
>
<thead>
<tr>
<th scope="col" class="text-center" v-if="columnList.length > 0" v-for="columnName in columnList">{{ columnName }}</th>
<th
scope="col"
class="text-center dfx-mono"
v-if="columnList.length > 0"
v-for="columnName in columnList"
:key="columnName"
>
{{ columnName }}
</th>
<th scope="col" class="text-center" v-else>no datas.</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr v-if="dataList.length > 0" v-for="dataMap in dataList">
<td class="text-center" v-for="columnName in columnList">{{ dataMap[columnName] }}</td>
<tr v-if="dataList.length > 0" v-for="(dataMap, rIdx) in dataList" :key="rIdx">
<td class="text-center dfx-mono" v-for="columnName in columnList" :key="columnName">
{{ dataMap[columnName] }}
</td>
</tr>
<tr v-else>
<td scope="row" class="text-center">no datas.</td>
<td scope="row" class="text-center py-4 text-secondary">no datas.</td>
</tr>
</tbody>
</table>
@ -116,58 +172,57 @@ defineExpose({
</template>
<style scoped>
/* 모달이 화면을 거의 꽉 채우게 */
/* 화면 전체 영역을 넓게 활용하는 대형 모달 다이얼로그 크기 설정 */
.data-modal-dialog {
max-width: calc(100% - 4vh); /* 좌우 95% */
margin: 2vh auto; /* 위아래 여백 조금 */
max-width: calc(100% - 4vh);
margin: 2vh auto;
}
/* 모달 전체 높이를 화면의 95% 정도로 */
.data-modal-content {
height: 95vh;
display: flex;
flex-direction: column;
}
/* 헤더/푸터 빼고 나머지 영역을 테이블 영역으로 */
.data-modal-body {
flex: 1 1 auto;
overflow: hidden; /* 여기서는 스크롤 안 하고 */
padding: 0.5rem;
overflow: hidden;
padding: 0.75rem;
background-color: var(--dfx-bg-canvas);
}
/* 실제 스크롤이 일어나는 영역 */
/* 테이블 스크롤 래퍼 */
.data-table-wrapper {
height: 100%;
overflow: auto; /* 세로/가로 스크롤 모두 */
overflow: auto;
border: 1px solid var(--dfx-border);
background-color: var(--dfx-surface-1);
}
/* 컬럼이 많을 때 한 줄에 계속 붙게(가로 스크롤) */
/* 줄바꿈 방지 및 고정 헤더(Sticky Header)를 위한 테이블 스타일 */
.data-table {
white-space: nowrap;
border-collapse: separate;
border-spacing: 0;
margin-bottom: 0;
}
/* 헤더 전체를 sticky 처리 */
.data-table thead {
position: sticky;
top: 0;
z-index: 2;
}
/* 헤더 배경/글자 색 지정 */
.data-table thead th {
background-color: #343a40;
color: #fff;
background-color: var(--dfx-surface-2);
color: var(--dfx-text-primary);
border-bottom: 2px solid var(--dfx-border-strong);
}
/* 모달 header 패딩 조정 */
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
/* 상·하 4px, 좌·우는 기존처럼 16px */
padding: 0.5rem 1rem;
padding: 0.65rem 1.25rem;
}
</style>

@ -1,15 +1,28 @@
<script setup>
/**
* 공통 페이지네이션(Pagination) 컴포넌트
* - SearchParameterDto의 페이지 상태(page, pageList, totalItemCount 등)를 기반으로
* 이전(Previous) / 페이지 번호 / 다음(Next) 네비게이션 버튼을 렌더링합니다.
*/
import { defineProps } from 'vue'
// 부모 컴포넌트로부터 검색/페이징 DTO와 목록 재조회 콜백 함수를 전달받음
const props = defineProps({
searchParameterDto: Object,
listFunction: Function,
searchParameterDto: Object, // 현재 페이지 및 페이지 블록 정보를 담고 있는 SearchParameterDto 객체
listFunction: Function, // 페이지 클릭 시 호출할 부모의 데이터 조회 함수
})
/**
* 특정 페이지 번호 클릭 시 해당 페이지 데이터를 조회합니다.
* @param {number} pageIndex 조회할 페이지 번호
*/
async function retrieveDataList(pageIndex) {
props.listFunction(pageIndex)
}
/**
* 'Previous(이전)' 버튼 클릭 시 현재 페이지에서 1 감소시킨 페이지를 조회합니다.
*/
async function retrievePreviousDataList() {
if (props.searchParameterDto.page < 2) {
return
@ -18,6 +31,9 @@ async function retrievePreviousDataList() {
}
}
/**
* 'Next(다음)' 버튼 클릭 시 최대 페이지를 초과하지 않는 범위 내에서 다음 페이지를 조회합니다.
*/
async function retrieveNextDataList() {
const maxPage = Math.ceil(props.searchParameterDto.totalItemCount / props.searchParameterDto.itemCountPerPage)
if (maxPage == 0 || props.searchParameterDto.page == maxPage) {
@ -29,19 +45,58 @@ async function retrieveNextDataList() {
</script>
<template>
<!-- 테이블 하단 페이지 네비게이션 바 -->
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
<li class="page-item">
<a class="page-link" @click.prevent="retrievePreviousDataList">Previous</a>
<ul class="pagination pagination-sm justify-content-center mb-0 dfx-pagination">
<!-- 이전 페이지 버튼 (1페이지일 때 비활성화) -->
<li class="page-item" :class="{ disabled: props.searchParameterDto.page < 2 }">
<a class="page-link" style="cursor: pointer" @click.prevent="retrievePreviousDataList">
<i class="bi bi-chevron-left me-1"></i>Previous
</a>
</li>
<li class="page-item" v-for="pageNo in props.searchParameterDto.pageList" :class="pageNo == props.searchParameterDto.page ? 'active' : ''" :aria-current="pageNo == props.searchParameterDto.page ? 'page' : ''">
<a class="page-link" @click.prevent="retrieveDataList(pageNo)">{{ pageNo }}</a>
<!-- 계산된 페이지 번호 목록 렌더링 (현재 페이지는 active 클래스 적용) -->
<li
class="page-item"
v-for="pageNo in props.searchParameterDto.pageList"
:key="pageNo"
:class="pageNo == props.searchParameterDto.page ? 'active' : ''"
:aria-current="pageNo == props.searchParameterDto.page ? 'page' : ''"
>
<a class="page-link dfx-mono" style="cursor: pointer" @click.prevent="retrieveDataList(pageNo)">{{ pageNo }}</a>
</li>
<!-- 다음 페이지 버튼 -->
<li class="page-item">
<a class="page-link" @click.prevent="retrieveNextDataList">Next</a>
<a class="page-link" style="cursor: pointer" @click.prevent="retrieveNextDataList">
Next<i class="bi bi-chevron-right ms-1"></i>
</a>
</li>
</ul>
</nav>
</template>
<style></style>
<style scoped>
/* 플랫 디자인 페이지네이션 버튼 기본 스타일 */
.dfx-pagination .page-link {
background-color: var(--dfx-surface-2);
border: 1px solid var(--dfx-border);
color: var(--dfx-text-secondary);
padding: 0.35rem 0.75rem;
font-weight: 600;
}
/* 마우스 오버 시 강조 스타일 */
.dfx-pagination .page-link:hover {
background-color: var(--dfx-surface-3);
color: var(--dfx-text-primary);
border-color: var(--dfx-border-strong);
}
/* 현재 선택된 페이지 번호 강조 스타일 */
.dfx-pagination .page-item.active .page-link {
background-color: var(--dfx-primary);
border-color: var(--dfx-primary);
color: #ffffff;
}
</style>

@ -1,18 +1,33 @@
/**
* 목록 조회 및 페이징 처리를 위한 검색 파라미터 DTO 생성자
* - 백엔드 목록 조회 API와 연동하여 현재 페이지, 페이지당 행 수, 전체 건수 및 검색 조건(parameters)을 관리합니다.
*/
function SearchParameterDto() {
this.page = 1
this.itemCountPerPage = 20
this.totalItemCount = 0
this.pageList = [1]
this.parameters = {}
this.page = 1 // 현재 선택된 페이지 번호 (1부터 시작)
this.itemCountPerPage = 20 // 페이지당 표시할 항목 개수 (기본값: 20)
this.totalItemCount = 0 // 전체 검색 결과 항목 수
this.pageList = [1] // 하단 페이지네이션 바에 표시할 페이지 번호 배열 (최대 10개 단위 블록)
this.parameters = {} // 검색 필터 조건을 담는 Key-Value 객체
}
/**
* 전체 데이터 건수를 기반으로 페이지네이션 번호 목록(pageList)을 계산합니다.
* @param {number} currentPage 이동하려는 현재 페이지 번호
* @param {number} currentItemCountPerPage 페이지당 표시할 데이터 개수 (기본값: 20)
* @param {number} currentTotalItemCount 서버에서 조회된 전체 데이터 건수
*/
SearchParameterDto.prototype.calculatePageList = function (currentPage, currentItemCountPerPage = 20, currentTotalItemCount) {
this.itemCountPerPage = currentItemCountPerPage
this.totalItemCount = currentTotalItemCount
if (this.totalItemCount > 0) {
// 전체 페이지 수 산출
let pageSize = Math.ceil(this.totalItemCount / this.itemCountPerPage) + 1
pageSize = pageSize < 1 ? 1 : pageSize
// 유효 범위 내로 현재 페이지 번호 보정
this.page = currentPage >= 1 && currentPage <= pageSize ? currentPage : 1
// 10페이지 단위 블록의 시작 페이지와 끝 페이지 계산 (예: 1~10, 11~20)
let startPage = Math.floor((this.page - 1) / 10) * 10 + 1
let lastPage = Math.min(startPage + 9, pageSize)
let pageArray = []
@ -21,19 +36,33 @@ SearchParameterDto.prototype.calculatePageList = function (currentPage, currentI
}
this.pageList = pageArray
} else {
// 데이터가 없을 경우 기본 1페이지만 표시
this.pageList = [1]
this.page = 1
}
}
/**
* 검색 조건 파라미터 설정
* @param {string} key 검색 조건 키 (예: 'processStatusCode')
* @param {*} value 검색 조건 값
*/
SearchParameterDto.prototype.setParameter = function (key, value) {
this.parameters[key] = value
}
/**
* 설정된 검색 조건 파라미터 조회
* @param {string} key 검색 조건 키
* @returns {*} 설정된 검색 조건 값
*/
SearchParameterDto.prototype.getParameter = function (key) {
return this.parameters[key]
}
/**
* 모든 검색 조건 파라미터 초기화
*/
SearchParameterDto.prototype.clearParameter = function () {
this.parameters = {}
}

@ -1,9 +1,16 @@
import axios from 'axios'
import router from '@/router'
// JSON 통신을 기본으로 하는 공통 Axios 클라이언트 인스턴스 생성
const apiClient = axios.create({ headers: { 'Content-Type': 'application/json' } })
// 중복 인증 오류 처리를 방지하기 위한 플래그
let isHandlingAuthError = false
/**
* 401(미인증) 또는 403(권한 없음) 발생 시 로그아웃 처리 후 로그인 화면으로 이동
* @param {number} status HTTP 응답 상태 코드
*/
async function handleAuthError(status) {
if (isHandlingAuthError) {
return
@ -13,11 +20,12 @@ async function handleAuthError(status) {
try {
await apiClient.post('/public-api/logoutProcess')
} catch (e) {
// 로그아웃 안되도 무시
// 서버 세션 만료 등으로 로그아웃 요청이 실패하더라도 무시하고 진행
}
if (router.currentRoute.value.path != '/login') {
// 현재 화면이 로그인 페이지가 아니면 로그인 페이지(/login.html)로 이동
if (router.currentRoute.value.path != '/login.html') {
router.replace({
path: '/login',
path: '/login.html',
query: {
reason: String(status),
},
@ -30,11 +38,12 @@ async function handleAuthError(status) {
}
}
// 응답 인터셉터: API 호출 중 세션 만료(401/403) 감지 시 자동 로그인 페이지 리다이렉트
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const status = error?.response?.status
// 기타 오류 처리시 로직 추가
// 인증/인가 오류 발생 시 공통 처리
if (status === 401 || status === 403) {
await handleAuthError(status)
}

@ -1,14 +1,30 @@
import { reactive } from 'vue'
import axios from 'axios'
/**
* 전역 사용자 인증 상태 (Reactive State)
* - isLogin: 현재 세션이 로그인(인증) 상태인지 여부
* - userId : 로그인한 사용자의 계정 ID (예: 이메일 또는 계정명)
*/
const userInfo = reactive({
isLogin: false,
userId: '',
})
/**
* 사용자 인증 및 세션 관리 API 모음
* - 로그인, 로그아웃, 세션 유효성 확인, 비밀번호 암호화 유틸리티 제공
*/
export const userApi = {
/**
* 로그인 처리 요청
* @param {string} inputUsername 사용자 계정 ID
* @param {string} inputUserPassword 사용자 비밀번호
* @returns {Promise<boolean>} 로그인 성공 여부
*/
loginProcess: async function (inputUsername, inputUserPassword) {
const data = { username: inputUsername, password: inputUserPassword }
// 세션 쿠키(JSESSIONID 등)를 포함하여 로그인 API 호출
const response = await axios.post('/public-api/loginProcess', data, { withCredentials: true })
if (response.status == 200 && response.data && response.data.success == true) {
userInfo.isLogin = true
@ -18,6 +34,12 @@ export const userApi = {
return false
}
},
/**
* 로그아웃 처리 요청
* - 서버 세션을 만료시키고 프론트엔드 인증 상태를 초기화합니다.
* @returns {Promise<boolean>} 로그아웃 성공 여부
*/
logoutProcess: async function () {
const response = await axios.post('/public-api/logoutProcess')
if (response.status == 200 && response.data && response.data.success == true) {
@ -28,6 +50,12 @@ export const userApi = {
return false
}
},
/**
* 현재 브라우저 세션의 로그인 유효성 확인
* - 화면 새로고침(F5) 또는 라우트 이동 시 서버 세션이 유지되고 있는지 검증합니다.
* @returns {Promise<boolean>} 유효한 로그인 세션 존재 여부
*/
loginCheck: async function () {
const response = await axios.get('/public-api/sessionCheck', { withCredentials: true })
if (response.status == 200 && response.data && response.data.success == true) {
@ -40,11 +68,20 @@ export const userApi = {
return false
}
},
/**
* 평문 비밀번호를 서버 암호화 유틸리티를 통해 암호문으로 변환
* @param {string} plainText 평문 문자열
* @returns {Promise<string>} 암호화된 문자열
*/
getEncryptText: async function (plainText) {
const response = await axios.post('/public-api/passwordGenerator', { password: plainText })
return response.data.password
},
}
// 디버깅 및 전역 참조를 위해 window 객체에 노출
window.userApi = userApi
window.userInfo = userInfo
export default userInfo

@ -1,14 +1,24 @@
/**
* DFX Console 프론트엔드 엔트리 포인트 (main.js)
* - Bootstrap CSS/JS, Bootstrap Icons 및 DFX Flat Design 전역 스타일시트 로드
* - Vue 3 애플리케이션 인스턴스 생성, Vue Router 연결, 전역 API 클라이언트 등록 및 DOM 마운트
*/
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap'
import 'bootstrap-icons/font/bootstrap-icons.css'
import './assets/app.css'
import './assets/main.css'
import { createApp, getCurrentInstance } from 'vue'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import apiClient from './components/apiClient'
// 루트 컴포넌트(App.vue)를 기반으로 Vue 앱 생성
const app = createApp(App)
// 모든 컴포넌트에서 $apiClient로 Axios 인스턴스에 접근할 수 있도록 전역 속성에 등록
app.config.globalProperties.$apiClient = apiClient
// SPA 라우터 플러그인 등록 후 #app 엘리먼트에 마운트
app.use(router)
app.mount('#app')

@ -1,18 +1,31 @@
import { createRouter, createWebHistory } from 'vue-router'
import LoginView from '@/views/LoginView.vue'
import MainView from '@/views/MainView.vue'
import userInfo from '@/components/userInfo'
import userInfo, { userApi } from '@/components/userInfo'
/**
* Vue Router 설정
* - 각 메뉴 URL(/.html)과 MainView에 전달할 화면 식별자(contentId)를 매핑합니다.
* - meta.isRequiredAuth 값으로 로그인 인증이 필요한 화면인지 구분합니다.
*/
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
// 루트('/') 접속 시 기본 메인(대시보드) 화면으로 리다이렉트
path: '/',
redirect: '/main.html',
},
{
// 로그인 화면 (/login.html 및 /login 별칭 지원)
path: '/login.html',
alias: ['/login'],
name: 'login',
component: LoginView,
meta: { isRequiredAuth: false },
},
{
// 대시보드 화면
path: '/main.html',
name: 'main',
component: MainView,
@ -20,6 +33,7 @@ const router = createRouter({
meta: { isRequiredAuth: true },
},
{
// 에이전트 관리 화면
path: '/agent-manage.html',
name: 'agent-manage',
component: MainView,
@ -27,6 +41,7 @@ const router = createRouter({
meta: { isRequiredAuth: true },
},
{
// 암호화 키 관리 화면
path: '/key-manage.html',
name: 'key-manage',
component: MainView,
@ -34,6 +49,7 @@ const router = createRouter({
meta: { isRequiredAuth: true },
},
{
// 송수신 메시지 이력 조회 화면
path: '/message-history.html',
name: 'message-history',
component: MainView,
@ -43,10 +59,30 @@ const router = createRouter({
],
})
router.beforeEach((to, from, next) => {
// 브라우저 최초 진입 또는 새로고침(F5) 시 서버 세션 확인 여부를 추적하는 플래그
let isInitialSessionChecked = false
/**
* 전역 라우터 가드 (화면 이동 전 인증 상태 확인)
* 1. 최초 진입/새로고침이거나, 인증이 필요한 화면인데 클라이언트 로그인 상태(userInfo.isLogin)가 false인 경우
* 서버의 /public-api/sessionCheck API를 호출하여 실제 세션 유효 여부를 먼저 동기화합니다.
* 2. 인증이 필요한 화면에 비로그인 상태로 접근 시 '/login.html'로 이동시킵니다.
* 3. 이미 로그인된 상태에서 로그인 페이지('/login.html')나 루트('/')로 접근 시 '/main.html'로 이동시킵니다.
* 4. 그 외(로그인 상태에서 '/agent-manage.html' 등 새로고침 포함)에는 현재 요청한 URL을 그대로 유지합니다.
*/
router.beforeEach(async (to, from, next) => {
if (!isInitialSessionChecked || (!userInfo.isLogin && to.meta.isRequiredAuth)) {
isInitialSessionChecked = true
try {
await userApi.loginCheck()
} catch (e) {
// 세션 확인 중 네트워크 오류 등이 발생하면 비로그인 상태로 간주
}
}
if (to.meta.isRequiredAuth && !userInfo.isLogin) {
next('/login.html')
} else if ((to.path === '/login.html' || to.path === '/') && userInfo.isLogin) {
} else if ((to.path === '/login.html' || to.path === '/login' || to.path === '/') && userInfo.isLogin) {
next('/main.html')
} else {
next()

@ -1,70 +1,181 @@
<script setup>
/**
* 에이전트 관리 화면 컴포넌트 (AgentManageView.vue)
* - 서버에 등록된 에이전트 노드 목록(/app-api/agent/getAgentInfoDtoList)을 조회하고 키워드 필터링을 제공합니다.
* - 각 에이전트의 '설정' 버튼 클릭 시 원격 환경설정 팝업(AgentConfigView)을 엽니다.
*/
import '@/assets/main.css'
import { useApiClient } from '@/components/apiClient'
import { onMounted, ref, reactive } from 'vue'
import { onMounted, ref, reactive, computed } from 'vue'
import AgentConfigView from '@/components/AgentConfigView.vue'
const apiClient = useApiClient()
/**
* 에이전트 목록 조회 API 호출
*/
async function getAgentInfoDtoList() {
const response = await apiClient.post('/app-api/agent/getAgentInfoDtoList')
return response.data
}
// 에이전트 전체 목록 및 검색어 상태
let dfxAgentInfoDtoList = ref([])
const searchKeyword = ref('')
// 에이전트 설정 모달(AgentConfigView)에 전달할 데이터 객체 및 모달 참조
const agentConfigDto = reactive({})
const agentConfigViewModal = ref(null)
/**
* 검색어(Agent ID, 호스트명, 설명) 기준 실시간 필터링된 에이전트 목록
*/
const filteredAgentList = computed(() => {
const kw = searchKeyword.value.trim().toLowerCase()
if (!kw) return dfxAgentInfoDtoList.value
return dfxAgentInfoDtoList.value.filter(
(a) =>
(a.agentId && a.agentId.toLowerCase().includes(kw)) ||
(a.hostName && a.hostName.toLowerCase().includes(kw)) ||
(a.description && a.description.toLowerCase().includes(kw)),
)
})
/**
* 선택한 에이전트의 JSON 설정 문자열(settingsData)을 객체로 변환하여 설정 모달 오픈
* @param {Object} dfxAgentInfoDto 선택한 에이전트 정보
*/
function openAgentConfigView(dfxAgentInfoDto) {
const dto = JSON.parse(dfxAgentInfoDto.settingsData)
Object.assign(agentConfigDto, dto)
agentConfigViewModal.value.open()
}
/**
* 에이전트 목록 새로고침
*/
async function reloadList() {
dfxAgentInfoDtoList.value = await getAgentInfoDtoList()
}
// 컴포넌트 마운트 시 에이전트 목록 최초 로드
onMounted(async () => {
dfxAgentInfoDtoList.value = await getAgentInfoDtoList()
})
</script>
<template>
<main class="dfx-main container w-100">
<div class="row">
<div class="col-12 pt-2 border-bottom">
<h3 class="h3">에이전트 관리</h3>
<main class="dfx-main">
<!-- 화면 상단 헤더 및 새로고침 버튼 -->
<div class="dfx-page-header">
<div>
<h3 class="dfx-page-title">에이전트 관리</h3>
<div class="dfx-page-subtitle">등록된 DFX 에이전트 노드 상태 조회 및 원격 환경설정(DropBox / Postman) 제어</div>
</div>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-sm dfx-btn-ghost d-flex align-items-center gap-1" @click="reloadList">
<i class="bi bi-arrow-clockwise"></i>
<span>새로고침</span>
</button>
</div>
</div>
<!-- 상단 상태 요약 배지 및 검색 필터 바 -->
<div class="card mb-3">
<div class="card-body py-2 px-3 d-flex flex-wrap align-items-center justify-content-between gap-2">
<div class="d-flex align-items-center gap-2">
<span class="dfx-badge dfx-badge-neutral">전체 {{ dfxAgentInfoDtoList.length }}</span>
<span class="dfx-badge dfx-badge-success">
<span class="dfx-badge-dot"></span>
정상 {{ dfxAgentInfoDtoList.filter((a) => a.statusCode === 'STATUS_OK').length }}
</span>
</div>
<div class="d-flex align-items-center gap-2" style="min-width: 280px">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input
type="text"
class="form-control"
placeholder="Agent ID, 호스트명, 설명 검색..."
v-model="searchKeyword"
/>
</div>
</div>
</div>
<div class="row">
<article class="col-12 pt-3">
<table class="table table-striped table-bordered align-middle">
</div>
<!-- 에이전트 목록 테이블 카드 -->
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-hdd-network text-primary"></i>
<span>에이전트 노드 목록 (Agent Nodes)</span>
</span>
<span class="dfx-mono text-secondary" style="font-size: 11.5px">SHOWING {{ filteredAgentList.length }} NODES</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-bordered table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col" class="text-center">Agent ID</th>
<th scope="col" class="text-center">Host</th>
<th scope="col" class="text-center">port</th>
<th scope="col" class="text-center">Description</th>
<th scope="col" class="text-center">Postman Count</th>
<th scope="col" class="text-center">Dropbox Count</th>
<th scope="col" class="text-center">Last Check</th>
<th scope="col" class="text-center">Status</th>
<th scope="col" class="text-center">Setting</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr v-if="dfxAgentInfoDtoList.length > 0" v-for="dfxAgentInfoDto in dfxAgentInfoDtoList" :key="`${dfxAgentInfoDto.agentId}-${dfxAgentInfoDto.hostName}`">
<td scope="row">{{ dfxAgentInfoDto.agentId }}</td>
<td>{{ dfxAgentInfoDto.hostName }}</td>
<td>{{ dfxAgentInfoDto.listenPort }}</td>
<td class="text-end">{{ dfxAgentInfoDto.postmanCount }}</td>
<td class="text-end">{{ dfxAgentInfoDto.dropboxCount }}</td>
<td class="text-center"><i class="bi bi-lightbulb-fill" :class="dfxAgentInfoDto.statusCode == 'STATUS_OK' ? 'text-success' : 'text-secondary'"></i></td>
<td class="text-center"><button type="button" class="btn btn-primary btn-sm" @click.prevent="openAgentConfigView(dfxAgentInfoDto)">설정</button></td>
<tr
v-if="filteredAgentList.length > 0"
v-for="dfxAgentInfoDto in filteredAgentList"
:key="`${dfxAgentInfoDto.agentId}-${dfxAgentInfoDto.hostName}`"
>
<td scope="row" class="dfx-mono fw-semibold text-light">
<i class="bi bi-hdd-rack text-primary me-2"></i>{{ dfxAgentInfoDto.agentId }}
</td>
<td class="dfx-mono text-secondary">{{ dfxAgentInfoDto.hostName }}</td>
<td class="text-center dfx-mono">{{ dfxAgentInfoDto.listenPort }}</td>
<td class="text-secondary">{{ dfxAgentInfoDto.description || '-' }}</td>
<td class="text-end dfx-mono fw-semibold">{{ dfxAgentInfoDto.postmanCount }}</td>
<td class="text-end dfx-mono fw-semibold">{{ dfxAgentInfoDto.dropboxCount }}</td>
<td class="text-center dfx-mono text-secondary" style="font-size: 12px">
{{ dfxAgentInfoDto.lastStatusTimeString || '-' }}
</td>
<td class="text-center">
<span
class="dfx-badge"
:class="dfxAgentInfoDto.statusCode == 'STATUS_OK' ? 'dfx-badge-success' : 'dfx-badge-neutral'"
>
<span class="dfx-badge-dot"></span>
{{ dfxAgentInfoDto.statusCode == 'STATUS_OK' ? 'STATUS_OK' : dfxAgentInfoDto.statusCode || 'OFFLINE' }}
</span>
</td>
<td class="text-center">
<button
type="button"
class="btn btn-primary btn-sm"
@click.prevent="openAgentConfigView(dfxAgentInfoDto)"
>
<i class="bi bi-sliders me-1"></i>설정
</button>
</td>
</tr>
<tr v-else>
<td scope="row" class="text-center" colspan="7">no datas.</td>
<td scope="row" class="text-center py-4 text-secondary" colspan="9">no datas.</td>
</tr>
</tbody>
</table>
</article>
</div>
</div>
</div>
</main>
<!-- 에이전트 설정 모달 팝업 컴포넌트 -->
<agent-config-view modal-id="agentConfigModalView" :dto="agentConfigDto" ref="agentConfigViewModal"></agent-config-view>
</template>

@ -1,4 +1,8 @@
<script setup>
/**
* 암호화 키 관리 화면 컴포넌트 (CryptoKeyManageView.vue)
* - 에이전트(dfxagent) 간 구간 및 데이터 암호화에 사용되는 대칭키 목록을 조회하고 신규 키를 생성합니다.
*/
import '@/assets/main.css'
import { useApiClient } from '@/components/apiClient'
import { Modal } from 'bootstrap'
@ -6,55 +10,96 @@ import { onMounted, ref, reactive } from 'vue'
const apiClient = useApiClient()
/**
* 서버에 등록된 암호화 키 목록을 조회합니다.
* @returns {Promise<Array>} 암호화 키 DTO 배열
*/
async function getCryptoKeyDtoList() {
const response = await apiClient.post('/app-api/agent/getCryptoKeyDtoList')
return response.data
}
// 신규 암호화 키 생성 모달 관련 상태 및 인스턴스
const newKeyModalEl = ref(null)
let newKeyModalInstance = null
const isOpenedNewKeyModal = ref(false)
/**
* 신규 암호화 키 생성 모달 팝업 열기
* - 입력 키워드를 초기화한 뒤 모달을 표시합니다.
*/
function openNewKeyModal() {
dfxCryptoKeyDto.keyword = ''
isOpenedNewKeyModal.value = true
newKeyModalInstance?.show()
}
/**
* 신규 암호화 키 생성 모달 팝업 닫기
*/
function closeNewKeyModal() {
newKeyModalInstance?.hide()
}
// 신규 암호화 키 생성 요청에 사용되는 기본 DTO 상태
const dfxCryptoKeyDto = reactive({
keyword: '',
keyId: '',
keyTypeName: 'oct',
algorithmName: 'ARIA-256-GCM',
targetAgentHostId: '',
applyTs: 0,
dataEncryptionYn: 'N',
keyword: '', // 키 식별을 위한 관리 키워드
keyId: '', // 생성될 키 ID
keyTypeName: 'oct', // JWK 키 타입 (대칭키: Octet sequence)
algorithmName: 'ARIA-256-GCM', // 암호화 알고리즘
targetAgentHostId: '', // 적용 대상 에이전트 호스트 ID
applyTs: 0, // 에이전트 적용 일시 타임스탬프 (0이면 미적용)
dataEncryptionYn: 'N', // 데이터 암호화 사용 여부 ('Y' / 'N')
})
/**
* 신규 데이터 암호화 키 생성 요청
* - 사용자 확인 후 서버에 키 생성 API를 호출하고 목록을 갱신합니다.
*/
function generateNewKey() {
if (dfxCryptoKeyDto.keyword && confirm(dfxCryptoKeyDto.keyword + '의 데이터 암호화 키를 생성하시겠습니까?')) {
apiClient
.post('/app-api/agent/saveCryptoKeyDto', dfxCryptoKeyDto)
.then((response) => {
getCryptoKeyDtoList()
.then(async () => {
dfxCryptoKeyDtoList.value = await getCryptoKeyDtoList()
})
.catch((error) => {
console.error(error)
debugger
alert('데이터 암호화 키 생성 중 오류가 발생하였습니다.')
})
closeNewKeyModal()
}
}
/**
* 변경된 암호화 키 설정 목록 저장 (추후 확장용 핸들러)
*/
async function saveList() {}
/**
* 암호화 키 목록 수동 새로고침
*/
async function reloadKeys() {
dfxCryptoKeyDtoList.value = await getCryptoKeyDtoList()
}
/**
* 적용 타임스탬프(applyTs)를 읽기 쉬운 날짜/시간 문자열로 포맷팅합니다.
* @param {number|string} ts 타임스탬프(ms)
* @returns {string} 포맷팅된 일시 또는 '미적용 (0)'
*/
function formatApplyTs(ts) {
if (!ts || Number(ts) === 0) return '미적용 (0)'
const d = new Date(Number(ts))
if (isNaN(d.getTime())) return String(ts)
return d.toISOString().replace('T', ' ').substring(0, 19)
}
// 화면에 렌더링할 암호화 키 목록 상태
let dfxCryptoKeyDtoList = ref([])
const cryptoKeyDto = reactive({})
// 화면 마운트 시 모달 인스턴스를 초기화하고 암호화 키 목록을 최초 조회
onMounted(async () => {
newKeyModalInstance = new Modal(newKeyModalEl.value, { backdrop: 'static', keyboard: false })
newKeyModalInstance.hide()
@ -63,22 +108,44 @@ onMounted(async () => {
</script>
<template>
<main class="dfx-main container w-100">
<div class="row">
<div class="col-12 pt-2 border-bottom">
<h3 class="h3">암호화 키 관리</h3>
<main class="dfx-main">
<!-- 페이지 상단 헤더 영역: 화면 제목 및 액션 버튼(새로고침, 키 생성, 저장) -->
<div class="dfx-page-header">
<div>
<h3 class="dfx-page-title">암호화 키 관리</h3>
<div class="dfx-page-subtitle">에이전트 간 구간·데이터 암호화를 위한 대칭키(JWK / ARIA-256-GCM) 생성 및 배포 관리</div>
</div>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-sm dfx-btn-ghost d-flex align-items-center gap-1" @click="reloadKeys">
<i class="bi bi-arrow-clockwise"></i>
<span>새로고침</span>
</button>
<button type="button" class="btn btn-success btn-sm d-flex align-items-center gap-1" @click="openNewKeyModal">
<i class="bi bi-plus-square"></i>
<span>Generate new Key</span>
</button>
<button type="button" class="btn btn-primary btn-sm d-flex align-items-center gap-1" @click="saveList">
<i class="bi bi-check2-square"></i>
<span>Save</span>
</button>
</div>
<div class="row">
<article class="col-12 pt-3">
<div class="d-flex flex-row-reverse">
<button type="button" class="btn btn-primary btn-sm m-1" @click="saveList">Save</button>
<button type="button" class="btn btn-success btn-sm m-1" @click="openNewKeyModal">Generate new Key</button>
</div>
<table class="table table-striped table-bordered align-middle">
<!-- 등록된 암호화 키 목록 테이블 카드 -->
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-key text-primary"></i>
<span>등록된 암호화 키 목록 (Cryptographic Keys)</span>
</span>
<span class="dfx-badge dfx-badge-neutral">TOTAL {{ dfxCryptoKeyDtoList.length }}</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-bordered table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col" class="text-center">선택</th>
<th scope="col" class="text-center" style="width: 56px">선택</th>
<th scope="col" class="text-center">Key ID</th>
<th scope="col" class="text-center">Type</th>
<th scope="col" class="text-center">Algorithm</th>
@ -89,62 +156,116 @@ onMounted(async () => {
</thead>
<tbody class="table-group-divider">
<tr v-if="dfxCryptoKeyDtoList.length > 0" v-for="(cryptoKeyDto, index) in dfxCryptoKeyDtoList" :key="index">
<td scope="row" class="text-center"><input type="checkbox" class="form-check-input" v-model="cryptoKeyDto.selected" /></td>
<td class="text-center">{{ cryptoKeyDto.keyId }}</td>
<td class="text-center">{{ cryptoKeyDto.keyTypeName }}</td>
<td class="text-center">{{ cryptoKeyDto.algorithmName }}</td>
<td class="text-center">{{ cryptoKeyDto.targetAgentHostId }}</td>
<td class="text-center">{{ cryptoKeyDto.applyTs }}</td>
<td class="text-center">{{ cryptoKeyDto.dataEncryptionYn }}</td>
<td scope="row" class="text-center">
<input type="checkbox" class="form-check-input" v-model="cryptoKeyDto.selected" />
</td>
<td class="dfx-mono fw-semibold text-light">
<i class="bi bi-shield-lock text-info me-2"></i>{{ cryptoKeyDto.keyId }}
</td>
<td class="text-center">
<span class="dfx-badge dfx-badge-neutral">{{ cryptoKeyDto.keyTypeName }}</span>
</td>
<td class="text-center">
<span class="dfx-badge dfx-badge-info">{{ cryptoKeyDto.algorithmName }}</span>
</td>
<td class="text-center dfx-mono text-secondary">
{{ cryptoKeyDto.targetAgentHostId || '-' }}
</td>
<td class="text-center dfx-mono text-secondary">
{{ formatApplyTs(cryptoKeyDto.applyTs) }}
</td>
<td class="text-center">
<span
class="dfx-badge"
:class="cryptoKeyDto.dataEncryptionYn === 'Y' ? 'dfx-badge-success' : 'dfx-badge-neutral'"
>
<span class="dfx-badge-dot"></span>
{{ cryptoKeyDto.dataEncryptionYn || 'N' }}
</span>
</td>
</tr>
<tr v-else>
<td scope="row" class="text-center" colspan="8">no datas.</td>
<td scope="row" class="text-center py-4 text-secondary" colspan="7">no datas.</td>
</tr>
</tbody>
</table>
</article>
</div>
</div>
</div>
</main>
<!-- 암호화 키 생성 또는 정보 저장 -->
<div class="modal fade" ref="newKeyModalEl" id="crypto-key-manage-view-input-form-modal" tabindex="-1" aria-labelledby="crypto-key-manage-view-input-form-modal-label" aria-hidden="true">
<div class="modal-dialog">
<!-- 신규 암호화 키 생성 모달 팝업 -->
<div
class="modal fade"
ref="newKeyModalEl"
id="crypto-key-manage-view-input-form-modal"
tabindex="-1"
aria-labelledby="crypto-key-manage-view-input-form-modal-label"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header justify-content-between">
<h1 class="modal-title fs-5 pt-0" id="crypto-key-manage-view-input-form-modal-label">데이터 암호화 키 생성</h1>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-success btn-sm" @click.prevent="generateNewKey">생성</button>
<button type="button" class="btn-close" aria-label="Close" @click.prevent="closeNewKeyModal"></button>
</div>
</div>
<div class="modal-body">
<div class="form-wrapper">
<div class="row align-items-center">
<div class="col-3">
<label for="crypto-key-manage-view-new-keyword" class="col-form-label">Keyword</label>
</div>
<div class="col-auto">
<input type="text" id="crypto-key-manage-view-new-keyword" class="form-control" aria-describedby="crypto-key-manage-view-new-keyword-help-line" maxlength="32" v-model="dfxCryptoKeyDto.keyword" />
</div>
<div class="col-auto">
<p id="crypto-key-manage-view-new-keyword-help-line" class="form-text">관리를 위한 ID/이름 등의 키워드를 입력하여 주십시오.</p>
<p class="form-text">암호화키는 무작위로 생성됩니다.</p>
<i class="bi bi-key-fill text-primary"></i>
<h1 class="modal-title fs-6 fw-bold mb-0" id="crypto-key-manage-view-input-form-modal-label">
데이터 암호화 키 생성
</h1>
</div>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-success btn-sm" @click.prevent="generateNewKey">
<i class="bi bi-plus-lg me-1"></i>생성
</button>
<button type="button" class="btn-close" aria-label="Close" @click.prevent="closeNewKeyModal"></button>
</div>
<div class="row align-items-center">
<div class="col-3">
<label for="crypto-key-manage-view-new-key-type-name" class="col-form-label">키 타입</label>
</div>
<div class="col-auto">
<input type="text" id="crypto-key-manage-view-new-key-type-name" class="form-control" readonly v-model="dfxCryptoKeyDto.keyTypeName" />
<div class="modal-body p-4">
<div class="form-wrapper d-flex flex-column gap-3">
<!-- 키 식별용 키워드 입력 필드 -->
<div>
<label for="crypto-key-manage-view-new-keyword" class="form-label fw-semibold text-secondary mb-1">
Keyword <span class="text-danger">*</span>
</label>
<input
type="text"
id="crypto-key-manage-view-new-keyword"
class="form-control dfx-mono"
placeholder="예: kdn-mkami"
aria-describedby="crypto-key-manage-view-new-keyword-help-line"
maxlength="32"
v-model="dfxCryptoKeyDto.keyword"
/>
<div id="crypto-key-manage-view-new-keyword-help-line" class="form-text text-secondary mt-2" style="font-size: 12px">
<div>• 관리를 위한 ID/이름 등의 키워드를 입력하여 주십시오.</div>
<div>• 암호화키는 안전한 난수로 무작위 생성됩니다.</div>
</div>
</div>
<div class="row align-items-center">
<div class="col-3">
<label for="crypto-key-manage-view-new-algorithm-name" class="col-form-label">알고리즘</label>
<!-- 키 타입 및 암호화 알고리즘 (읽기 전용 표시) -->
<div class="row g-3">
<div class="col-6">
<label for="crypto-key-manage-view-new-key-type-name" class="form-label fw-semibold text-secondary mb-1">
키 타입 (Key Type)
</label>
<input
type="text"
id="crypto-key-manage-view-new-key-type-name"
class="form-control dfx-mono"
readonly
v-model="dfxCryptoKeyDto.keyTypeName"
/>
</div>
<div class="col-auto">
<input type="text" id="crypto-key-manage-view-new-algorithm-name" class="form-control" readonly v-model="dfxCryptoKeyDto.algorithmName" />
<div class="col-6">
<label for="crypto-key-manage-view-new-algorithm-name" class="form-label fw-semibold text-secondary mb-1">
알고리즘 (Algorithm)
</label>
<input
type="text"
id="crypto-key-manage-view-new-algorithm-name"
class="form-control dfx-mono"
readonly
v-model="dfxCryptoKeyDto.algorithmName"
/>
</div>
</div>
</div>

@ -1,93 +1,342 @@
<script setup>
/**
* 대시보드 화면 컴포넌트 (DashboardView.vue)
* - 전체 에이전트 가동 현황, Postman/DropBox 채널 수, 누적 송수신 메시지 수를 요약(KPI)하여 표시합니다.
* - 에이전트별 분당 처리량(막대 차트) 및 주간 처리량 추이(선 차트)를 시각화합니다.
* - 하단 에이전트 목록에서 바로 '설정' 모달(AgentConfigView)을 열어 구성을 확인/편집할 수 있습니다.
*/
import '@/assets/main.css'
import { ref, reactive, computed, onMounted } from 'vue'
import { useApiClient } from '@/components/apiClient'
import AgentConfigView from '@/components/AgentConfigView.vue'
import { Bar, Line } from 'vue-chartjs'
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, PointElement, LineElement, CategoryScale, LinearScale } from 'chart.js'
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
BarElement,
PointElement,
LineElement,
CategoryScale,
LinearScale,
} from 'chart.js'
// Chart.js 필수 플러그인 및 스케일 등록
ChartJS.register(Title, Tooltip, Legend, BarElement, PointElement, LineElement, CategoryScale, LinearScale)
const apiClient = useApiClient()
// 에이전트 목록 및 누적 메시지 건수 상태
const dfxAgentInfoDtoList = ref([])
const totalMessageCount = ref(0)
// 에이전트 설정 모달(AgentConfigView)에 전달할 데이터 객체 및 컴포넌트 참조
const agentConfigDto = reactive({})
const agentConfigViewModal = ref(null)
/**
* 대시보드 초기 데이터 로드 함수
* - 에이전트 목록(/app-api/agent/getAgentInfoDtoList)과
* 누적 메시지 총 건수(/app-api/agent/getAgentMessageDtoListTotalCount)를 병렬 조회합니다.
*/
async function loadDashboardData() {
try {
const [agentRes, msgCountRes] = await Promise.all([
apiClient.post('/app-api/agent/getAgentInfoDtoList'),
apiClient.post('/app-api/agent/getAgentMessageDtoListTotalCount'),
])
dfxAgentInfoDtoList.value = agentRes.data || []
totalMessageCount.value = msgCountRes.data || 0
} catch (e) {
console.error(e)
}
}
/**
* 선택한 에이전트의 JSON 설정 데이터(settingsData)를 파싱하여 설정 모달 팝업을 엽니다.
* @param {Object} dfxAgentInfoDto 에이전트 정보 DTO
*/
function openAgentConfigView(dfxAgentInfoDto) {
if (!dfxAgentInfoDto?.settingsData) return
const dto = JSON.parse(dfxAgentInfoDto.settingsData)
Object.assign(agentConfigDto, dto)
agentConfigViewModal.value?.open()
}
// 상단 KPI 카드 표시용 계산 속성(Computed): 정상 에이전트 수, 총 Postman 수, 총 DropBox 수
const activeAgentCount = computed(() =>
dfxAgentInfoDtoList.value.filter((a) => a.statusCode === 'STATUS_OK').length,
)
const totalPostmanCount = computed(() =>
dfxAgentInfoDtoList.value.reduce((sum, a) => sum + (Number(a.postmanCount) || 0), 0),
)
const totalDropboxCount = computed(() =>
dfxAgentInfoDtoList.value.reduce((sum, a) => sum + (Number(a.dropboxCount) || 0), 0),
)
// 에이전트별 분당 처리량 가로 막대 차트(Bar) 데이터
const shortThroughputCahrtData = {
labels: ['agent-mkami', 'agent-ami'],
datasets: [{ label: '분당 처리량', data: [4568, 3232] }],
datasets: [
{
label: '분당 처리량 (건/분)',
backgroundColor: '#2563eb',
borderColor: '#3b82f6',
borderWidth: 1,
barThickness: 26,
data: [4568, 3232],
},
],
}
// 에이전트별 분당 처리량 차트 옵션 (다크 테마 그리드 및 폰트 적용)
const shortThroughputChartOptions = {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: {
labels: { color: '#94a3b8', boxWidth: 12, usePointStyle: false },
},
},
scales: {
x: {
grid: { color: '#2a374b' },
ticks: { color: '#94a3b8' },
},
y: {
grid: { color: '#2a374b' },
ticks: { color: '#f1f5f9', font: { family: 'Consolas, monospace' } },
},
},
}
// 주간 처리량 추이 선 차트(Line) 데이터 (직선형 tension: 0 및 사각 포인트 적용)
const daysThroughputChartData = {
labels: ['2025-09-01', '2025-09-02', '2025-09-03', '2025-09-04', '2025-09-05', '2025-09-06', '2025-09-07'],
datasets: [
{
label: '주간 처리량',
backgroundColor: '#f87979',
label: '주간 처리량 (건/일)',
backgroundColor: '#06b6d4',
borderColor: '#06b6d4',
borderWidth: 2,
pointBackgroundColor: '#06b6d4',
pointRadius: 4,
pointStyle: 'rect',
tension: 0,
data: [40, 39, 10, 40, 39, 80, 40],
},
],
}
// 주간 처리량 추이 차트 옵션
const daysThroughputChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: { color: '#94a3b8', boxWidth: 12, usePointStyle: false },
},
},
scales: {
x: {
grid: { color: '#2a374b' },
ticks: { color: '#94a3b8', font: { family: 'Consolas, monospace', size: 11 } },
},
y: {
grid: { color: '#2a374b' },
ticks: { color: '#94a3b8' },
},
},
}
// 화면 마운트 시 대시보드 데이터 최초 조회
onMounted(async () => {
await loadDashboardData()
})
</script>
<template>
<main class="dfx-main container w-100">
<div class="row">
<div class="col-12 pt-2 border-bottom">
<h3 class="h3">Dashboard</h3>
<main class="dfx-main">
<!-- 화면 상단 타이틀 및 새로고침 버튼 -->
<div class="dfx-page-header">
<div>
<h3 class="dfx-page-title">Dashboard</h3>
<div class="dfx-page-subtitle">분산 에이전트 상태 및 데이터 송수신 처리량 종합 현황</div>
</div>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-sm dfx-btn-ghost d-flex align-items-center gap-1" @click="loadDashboardData">
<i class="bi bi-arrow-clockwise"></i>
<span>새로고침</span>
</button>
</div>
<div class="row">
<article class="col-12 pt-3">
<div class="row">
<div class="col-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">agent-mkami <i class="bi bi-lightbulb-fill text-success"></i></h5>
<p class="card-text">Host : mkami-oracle</p>
<p class="card-text">처리량 : 789</p>
</div>
<!-- 상단 핵심 지표(KPI) 요약 카드 4종 -->
<div class="row g-3 mb-4">
<div class="col-12 col-sm-6 col-xl-3">
<div class="dfx-kpi-card kpi-emerald">
<div class="d-flex align-items-center justify-content-between">
<span class="dfx-kpi-label">ACTIVE AGENTS</span>
<i class="bi bi-hdd-network text-success"></i>
</div>
<div class="dfx-kpi-value">{{ activeAgentCount }} <span class="text-secondary fs-6">/ {{ dfxAgentInfoDtoList.length }}</span></div>
<div class="dfx-kpi-meta">정상 가동 에이전트 / 전체 등록 노드</div>
</div>
<div class="col-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">agent-ami <i class="bi bi-lightbulb-fill text-success"></i></h5>
<p class="card-text">상태 : ami-cubrid</p>
<p class="card-text">처리량 : 345</p>
</div>
<div class="col-12 col-sm-6 col-xl-3">
<div class="dfx-kpi-card">
<div class="d-flex align-items-center justify-content-between">
<span class="dfx-kpi-label">POSTMAN CHANNELS</span>
<i class="bi bi-send text-primary"></i>
</div>
<div class="dfx-kpi-value">{{ totalPostmanCount }}</div>
<div class="dfx-kpi-meta">활성 데이터 송신 스케줄러(Postman) 합계</div>
</div>
</div>
</article>
<div class="col-12 col-sm-6 col-xl-3">
<div class="dfx-kpi-card kpi-cyan">
<div class="d-flex align-items-center justify-content-between">
<span class="dfx-kpi-label">DROPBOX CHANNELS</span>
<i class="bi bi-inbox text-info"></i>
</div>
<div class="row">
<article class="col-12 pt-3">
<div class="row">
<div class="col-6">
<div class="card">
<div class="card-body">
<div class="dfx-kpi-value">{{ totalDropboxCount }}</div>
<div class="dfx-kpi-meta">활성 데이터 수신 핸들러(DropBox) 합계</div>
</div>
</div>
<div class="col-12 col-sm-6 col-xl-3">
<div class="dfx-kpi-card kpi-amber">
<div class="d-flex align-items-center justify-content-between">
<span class="dfx-kpi-label">TOTAL MESSAGES</span>
<i class="bi bi-activity text-warning"></i>
</div>
<div class="dfx-kpi-value">{{ totalMessageCount.toLocaleString() }}</div>
<div class="dfx-kpi-meta">누적 송수신 집계 메시지 건수</div>
</div>
</div>
</div>
<!-- 개별 에이전트 노드 실시간 상태 카드 그리드 -->
<div class="mb-4">
<div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="dfx-section-heading mb-0">
<i class="bi bi-cpu me-1 text-primary"></i> 주요 에이전트 실시간 상태 (Node Telemetry)
</h6>
</div>
<div class="row g-3">
<template v-if="dfxAgentInfoDtoList.length > 0">
<div class="col-12 col-md-6 col-xl-3" v-for="agent in dfxAgentInfoDtoList" :key="agent.agentId">
<div class="card dfx-agent-node-card h-100">
<div class="card-header py-2 px-3">
<span class="dfx-mono fw-bold text-light text-truncate" :title="agent.agentId">{{ agent.agentId }}</span>
<span class="dfx-badge" :class="agent.statusCode == 'STATUS_OK' ? 'dfx-badge-success' : 'dfx-badge-neutral'">
<span class="dfx-badge-dot"></span>
{{ agent.statusCode == 'STATUS_OK' ? 'ONLINE' : 'OFFLINE' }}
</span>
</div>
<div class="card-body py-2 px-3 d-flex flex-column justify-content-between">
<div>
<div class="d-flex justify-content-between py-1 border-bottom" style="border-color: var(--dfx-border) !important">
<span class="text-secondary" style="font-size: 12px">Host</span>
<span class="dfx-mono text-light">{{ agent.hostName }}:{{ agent.listenPort }}</span>
</div>
<div class="d-flex justify-content-between py-1 border-bottom" style="border-color: var(--dfx-border) !important">
<span class="text-secondary" style="font-size: 12px">Postman / DropBox</span>
<span class="dfx-mono">
<span class="text-primary fw-bold">{{ agent.postmanCount }}</span>
<span class="text-muted mx-1">/</span>
<span class="text-info fw-bold">{{ agent.dropboxCount }}</span>
</span>
</div>
</div>
<div class="mt-2 pt-1 d-flex align-items-center justify-content-between text-muted" style="font-size: 11.5px">
<span class="text-truncate" :title="agent.description">{{ agent.description || '-' }}</span>
</div>
</div>
</div>
</div>
</template>
<!-- 조회된 에이전트가 없을 때의 기본 표시 카드 -->
<template v-else>
<div class="col-12 col-md-6 col-xl-3">
<div class="card dfx-agent-node-card">
<div class="card-header py-2 px-3">
<span class="dfx-mono fw-bold">agent-mkami</span>
<span class="dfx-badge dfx-badge-success"><span class="dfx-badge-dot"></span>ONLINE</span>
</div>
<div class="card-body py-2 px-3">
<p class="card-text mb-1 text-secondary">Host : <span class="dfx-mono text-light">mkami-oracle</span></p>
<p class="card-text mb-0 text-secondary">처리량 : <span class="dfx-mono text-light">789</span></p>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card dfx-agent-node-card">
<div class="card-header py-2 px-3">
<span class="dfx-mono fw-bold">agent-ami</span>
<span class="dfx-badge dfx-badge-success"><span class="dfx-badge-dot"></span>ONLINE</span>
</div>
<div class="card-body py-2 px-3">
<p class="card-text mb-1 text-secondary">상태 : <span class="dfx-mono text-light">ami-cubrid</span></p>
<p class="card-text mb-0 text-secondary">처리량 : <span class="dfx-mono text-light">345</span></p>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- 중단 차트 영역: 분당 처리량(좌) & 주간 처리량 추이(우) -->
<div class="row g-3 mb-4">
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-bar-chart-steps text-primary"></i>
<span>에이전트별 분당 처리량 (Short-Term Throughput)</span>
</span>
<span class="dfx-mono text-secondary" style="font-size: 11px">UNIT: MSG/MIN</span>
</div>
<div class="card-body" style="height: 240px">
<Bar id="shortThoughtputChart" :options="shortThroughputChartOptions" :data="shortThroughputCahrtData"></Bar>
</div>
</div>
</div>
<div class="col-6">
<div class="card">
<div class="card-body">
<Line :option="daysThroughputChartOptions" :data="daysThroughputChartData"></Line>
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-graph-up text-info"></i>
<span>주간 처리량 추이 (Weekly Throughput Trend)</span>
</span>
<span class="dfx-mono text-secondary" style="font-size: 11px">LAST 7 DAYS</span>
</div>
<div class="card-body" style="height: 240px">
<Line :options="daysThroughputChartOptions" :data="daysThroughputChartData"></Line>
</div>
</div>
</div>
</article>
</div>
<div class="row">
<article class="col-12 pt-3">
<h5 class="h5">에이전트 목록</h5>
<table class="table table-striped table-bordered align-middle">
<!-- 하단 에이전트 요약 목록 테이블 -->
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-list-ul text-primary"></i>
<span>에이전트 목록 (Registered Agents)</span>
</span>
<span class="dfx-badge dfx-badge-neutral">TOTAL {{ dfxAgentInfoDtoList.length }}</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-bordered table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col" class="text-center">Agent ID</th>
<th scope="col" class="text-center">Host</th>
<th scope="col" class="text-center">port</th>
<th scope="col" class="text-center">Port</th>
<th scope="col" class="text-center">Description</th>
<th scope="col" class="text-center">Postman Count</th>
<th scope="col" class="text-center">Dropbox Count</th>
<th scope="col" class="text-center">Status</th>
@ -95,29 +344,55 @@ const daysThroughputChartOptions = {
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<td scope="row">agent-mkami</td>
<td>mkami.foxsoft.kr</td>
<td>17801</td>
<td class="text-end">32</td>
<td class="text-end">25</td>
<td class="text-center"><i class="bi bi-lightbulb-fill text-success"></i></td>
<td class="text-center"><button type="button" class="btn btn-primary btn-sm">설정</button></td>
<tr
v-if="dfxAgentInfoDtoList.length > 0"
v-for="agent in dfxAgentInfoDtoList"
:key="`${agent.agentId}-${agent.hostName}`"
>
<td scope="row" class="dfx-mono fw-semibold text-light">{{ agent.agentId }}</td>
<td class="dfx-mono text-secondary">{{ agent.hostName }}</td>
<td class="text-center dfx-mono">{{ agent.listenPort }}</td>
<td class="text-secondary">{{ agent.description || '-' }}</td>
<td class="text-end dfx-mono fw-semibold">{{ agent.postmanCount }}</td>
<td class="text-end dfx-mono fw-semibold">{{ agent.dropboxCount }}</td>
<td class="text-center">
<span class="dfx-badge" :class="agent.statusCode == 'STATUS_OK' ? 'dfx-badge-success' : 'dfx-badge-neutral'">
<span class="dfx-badge-dot"></span>
{{ agent.statusCode == 'STATUS_OK' ? 'STATUS_OK' : agent.statusCode || 'UNKNOWN' }}
</span>
</td>
<td class="text-center">
<button type="button" class="btn btn-primary btn-sm" @click.prevent="openAgentConfigView(agent)">
<i class="bi bi-sliders me-1"></i>설정
</button>
</td>
</tr>
<tr>
<td scope="row">agent-ami</td>
<td>ami.foxsoft.kr</td>
<td>17801</td>
<td class="text-end">32</td>
<td class="text-end">25</td>
<td class="text-center"><i class="bi bi-lightbulb-fill text-success"></i></td>
<td class="text-center"><button type="button" class="btn btn-primary btn-sm">설정</button></td>
<tr v-else>
<td colspan="8" class="text-center py-4 text-secondary">등록된 에이전트 데이터가 없습니다.</td>
</tr>
</tbody>
</table>
</article>
</div>
</div>
</div>
</main>
<!-- 에이전트 설정 조회/편집 모달 컴포넌트 -->
<agent-config-view modal-id="dashboardAgentConfigModalView" :dto="agentConfigDto" ref="agentConfigViewModal"></agent-config-view>
</template>
<style></style>
<style scoped>
/* 섹션 소제목 스타일 */
.dfx-section-heading {
font-size: 13px;
font-weight: 700;
color: var(--dfx-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
/* 에이전트 노드 카드 좌측 3px 포인트 보더 */
.dfx-agent-node-card {
border-left: 3px solid var(--dfx-primary);
}
</style>

@ -1,15 +1,29 @@
<script setup>
import { onMounted, reactive } from 'vue'
/**
* 관리자 로그인 화면 컴포넌트 (LoginView.vue)
* - 이메일 형식의 아이디와 비밀번호를 입력받아 /public-api/loginProcess로 인증을 수행합니다.
* - 인증 성공 시 메인 대시보드(/main.html)로 이동합니다.
*/
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { userApi } from '@/components/userInfo'
// 로그인 입력 폼 반응형 상태 객체
const loginForm = reactive({
username: '',
password: '',
})
// 중복 로그인 요청 방지를 위한 진행 상태 플래그
const isSubmitting = ref(false)
const router = useRouter()
/**
* 로그인 처리 함수
* 1. 이메일 정규식 검사 및 비밀번호 입력 여부를 검증합니다.
* 2. userApi.loginProcess를 호출하여 서버 세션을 생성합니다.
* 3. 성공 시 '/main.html'로 이동하고, 실패 시 안내창 표시 후 비밀번호 입력란에 포커스를 맞춥니다.
*/
async function loginProcess() {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(loginForm.username)) {
@ -20,73 +34,222 @@ async function loginProcess() {
alert('비밀번호를 입력하여 주십시오.')
return
}
isSubmitting.value = true
try {
let loginResult = await userApi.loginProcess(loginForm.username, loginForm.password)
if (!loginResult) {
alert('사용자 아이디 또는 비밀번호가 일치하지 않습니다.')
document.getElementById('floatingPassword').focus()
document.getElementById('floatingPassword')?.focus()
return
} else {
router.push('/main.html')
}
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<main class="form-signin container flex-fill">
<form @submit.prevent="loginProcess">
<h1 class="h3 mb-3 fw-normal">Please sign in</h1>
<!-- 전체 화면 중앙 정렬 로그인 컨테이너 -->
<main class="dfx-login-page d-flex flex-grow-1 align-items-center justify-content-center w-100 p-4">
<div class="dfx-login-frame d-flex flex-column flex-lg-row w-100">
<!-- 좌측: 시스템 소개 및 주요 기능 안내 패널 -->
<section class="dfx-login-brand d-flex flex-column justify-content-between p-4 p-lg-5">
<div>
<div class="d-inline-flex align-items-center gap-2 mb-4">
<span class="dfx-brand-mark">DFX</span>
<span class="dfx-brand-sub dfx-mono">CONSOLE // ENTERPRISE</span>
</div>
<h1 class="dfx-brand-title mb-3">Data Flow eXchange<br />Management System</h1>
<p class="dfx-brand-desc mb-4">
이기종 데이터베이스 및 분산 에이전트 간 안전한 고속 데이터 연계·송수신 파이프라인을 중앙에서 제어하고 실시간 모니터링합니다.
</p>
</div>
<!-- 시스템 핵심 기능 요약 카드 영역 -->
<div class="dfx-spec-grid">
<div class="dfx-spec-item">
<div class="dfx-spec-label">AGENT CONTROL</div>
<div class="dfx-spec-val">Postman / DropBox 원격 구성 제어</div>
</div>
<div class="dfx-spec-item">
<div class="dfx-spec-label">SECURITY</div>
<div class="dfx-spec-val">데이터 암호화 키 생성 및 에이전트 배포 관리</div>
</div>
<div class="dfx-spec-item">
<div class="dfx-spec-label">TELEMETRY</div>
<div class="dfx-spec-val">실시간 송수신 이력 및 전문 데이터 검증</div>
</div>
</div>
</section>
<!-- 우측: 관리자 계정 로그인 입력 폼 패널 -->
<section class="dfx-login-form-panel d-flex flex-column justify-content-center p-4 p-lg-5">
<div class="mb-4 pb-3 border-bottom" style="border-color: var(--dfx-border) !important">
<div class="dfx-mono text-uppercase text-secondary mb-1" style="font-size: 11px; letter-spacing: 0.08em">
AUTHENTICATION REQUIRED
</div>
<h2 class="h4 fw-bold mb-1 text-light">관리자 로그인</h2>
<p class="text-secondary mb-0" style="font-size: 13px">승인된 관리자 계정(이메일)으로 접속하십시오.</p>
</div>
<form @submit.prevent="loginProcess" novalidate>
<!-- 이메일 아이디 입력 필드 -->
<div class="mb-3">
<label for="floatingInput" class="form-label dfx-field-label">이메일 아이디 (Email ID)</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-envelope"></i></span>
<input
type="email"
class="form-control form-control-lg"
id="floatingInput"
placeholder="name@bsm-lab.com"
autocomplete="username"
v-model="loginForm.username"
/>
</div>
</div>
<div class="form-floating">
<input type="email" class="form-control" id="floatingInput" placeholder="name@example.com" v-model="loginForm.username" />
<label for="floatingInput">Email address</label>
<!-- 비밀번호 입력 필드 -->
<div class="mb-3">
<label for="floatingPassword" class="form-label dfx-field-label">비밀번호 (Password)</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-shield-lock"></i></span>
<input
type="password"
class="form-control form-control-lg"
id="floatingPassword"
placeholder="••••••••"
autocomplete="current-password"
v-model="loginForm.password"
/>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="floatingPassword" placeholder="Password" v-model="loginForm.password" />
<label for="floatingPassword">Password</label>
</div>
<div class="checkbox mb-3">
<label> <input type="checkbox" value="remember-me" /> Remember me </label>
<!-- 계정 기억하기 체크박스 -->
<div class="d-flex align-items-center justify-content-between mb-4 pt-1">
<label class="d-inline-flex align-items-center gap-2 text-secondary" style="font-size: 13px; cursor: pointer">
<input type="checkbox" class="form-check-input m-0" value="remember-me" />
<span>로그인 계정 기억하기</span>
</label>
</div>
<button class="w-100 btn btn-lg btn-primary" type="submit">Sign in</button>
<!-- 로그인 제출 버튼 -->
<button class="w-100 btn btn-primary btn-lg d-flex align-items-center justify-content-center gap-2" type="submit" :disabled="isSubmitting">
<i class="bi bi-box-arrow-in-right"></i>
<span>{{ isSubmitting ? '인증 처리 중...' : '콘솔 로그인 (SIGN IN)' }}</span>
</button>
</form>
</section>
</div>
</main>
</template>
<style scoped>
.form-signin {
width: 100%;
max-width: 330px;
padding: 15px;
margin: auto;
margin-top: 20%;
/* 로그인 페이지 격자(Grid) 패턴 배경 */
.dfx-login-page {
background-color: var(--dfx-bg-base);
background-image:
linear-gradient(to right, rgba(42, 55, 75, 0.22) 1px, transparent 1px),
linear-gradient(to bottom, rgba(42, 55, 75, 0.22) 1px, transparent 1px);
background-size: 32px 32px;
}
/* 로그인 전체 외곽 직각 프레임 */
.dfx-login-frame {
max-width: 920px;
border: 1px solid var(--dfx-border-strong);
background-color: var(--dfx-surface-1);
}
/* 좌측 브랜드 소개 영역 */
.dfx-login-brand {
flex: 1.15;
background-color: var(--dfx-bg-canvas);
border-bottom: 1px solid var(--dfx-border);
border-top: 4px solid var(--dfx-primary);
}
@media (min-width: 992px) {
.dfx-login-brand {
border-bottom: none;
border-right: 1px solid var(--dfx-border);
}
}
/* 우측 로그인 폼 영역 */
.dfx-login-form-panel {
flex: 1;
background-color: var(--dfx-surface-1);
}
.dfx-brand-mark {
background-color: var(--dfx-primary);
color: #ffffff;
font-weight: 800;
font-size: 13px;
letter-spacing: 0.08em;
padding: 0.25rem 0.6rem;
}
.dfx-brand-sub {
font-size: 11.5px;
color: var(--dfx-text-secondary);
letter-spacing: 0.06em;
}
.dfx-brand-title {
font-size: 1.75rem;
font-weight: 800;
line-height: 1.25;
color: var(--dfx-text-primary);
letter-spacing: -0.02em;
}
.dfx-brand-desc {
font-size: 13.5px;
color: var(--dfx-text-secondary);
line-height: 1.65;
}
.dfx-spec-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.65rem;
}
.form-signin .checkbox {
font-weight: 400;
/* 좌측 기능 요약 아이템 박스 (좌측 3px 포인트 보더) */
.dfx-spec-item {
background-color: var(--dfx-surface-1);
border: 1px solid var(--dfx-border);
border-left: 3px solid var(--dfx-accent);
padding: 0.6rem 0.85rem;
}
.form-signin .form-floating:focus-within {
z-index: 2;
.dfx-spec-label {
font-family: var(--dfx-font-mono);
font-size: 10.5px;
font-weight: 700;
color: var(--dfx-accent);
letter-spacing: 0.06em;
}
.form-signin input[type='email'] {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
.dfx-spec-val {
font-size: 12.5px;
color: var(--dfx-text-primary);
margin-top: 2px;
}
.form-signin input[type='password'] {
margin-bottom: 10px;
border-top-left-radius: 0;
border-top-right-radius: 0;
.dfx-field-label {
font-size: 12px;
font-weight: 600;
color: var(--dfx-text-secondary);
margin-bottom: 0.35rem;
}
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
.form-control-lg {
font-size: 14px;
padding: 0.6rem 0.8rem;
}
</style>

@ -1,6 +1,11 @@
<script setup>
/**
* 메인 레이아웃 컴포넌트 (MainView.vue)
* - 로그인 이후 모든 관리 화면의 공통 상단 헤더(Header)와 좌측 메뉴 내비게이션(Sidebar)을 제공합니다.
* - 라우터로부터 전달받은 props.contentId에 따라 우측 본문 영역에 표시할 뷰 컴포넌트를 동적으로 전환합니다.
*/
import '@/assets/main.css'
import { userApi } from '@/components/userInfo'
import userInfo, { userApi } from '@/components/userInfo'
import { useRouter, RouterLink } from 'vue-router'
import DashboardView from './DashboardView.vue'
import AgentManageView from './AgentManageView.vue'
@ -8,12 +13,17 @@ import CryptoKeyManageView from './CryptoKeyManageView.vue'
import MessageHistoryView from './MessageHistoryView.vue'
import { computed } from 'vue'
// 라우터에서 전달받는 현재 화면 식별자 (기본값: 'dashboard-view')
const props = defineProps({
contentId: { type: String, default: 'dashboard-view' },
})
const router = useRouter()
/**
* 로그아웃 처리 함수
* - 서버 세션을 종료하고 로그인 화면(/login.html)으로 이동합니다.
*/
async function logout() {
let result = await userApi.logoutProcess()
if (result) {
@ -21,6 +31,9 @@ async function logout() {
}
}
/**
* contentId 값에 따라 본문 영역에 렌더링할 화면 컴포넌트 결정
*/
const currentContent = computed(() => {
if (props.contentId == 'dashboard-view') {
return DashboardView
@ -34,51 +47,226 @@ const currentContent = computed(() => {
return DashboardView
}
})
/**
* 상단 헤더에 표시할 현재 메뉴 위치 경로(섹션명 / 메뉴명)
*/
const currentMenuLabel = computed(() => {
switch (props.contentId) {
case 'dashboard-view':
return 'MONITORING / Dashboard'
case 'agent-manage-view':
return 'CONFIGURATION / Agents'
case 'key-manage-view':
return 'CONFIGURATION / Crypto Keys'
case 'message-history-view':
return 'MONITORING / Messages'
default:
return 'MONITORING / Dashboard'
}
})
</script>
<template>
<header class="dfx-top-title-bar p-2 d-flex align-items-center w-100 top-0 start-0 text-white bg-purple rounded shadow-sm" style="min-width: 100%">
<h3 class="flex-grow-1">DFX Console</h3>
<nav class="navbar navbar-expand-lg p-0">
<div class="container-fluid">
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle p-0" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <i class="bi bi-person-gear" style="font-size: 2rem"></i> </a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" style="cursor: pointer" @click="logout"><i class="bi bi-box-arrow-right"></i> Log out</a>
<!-- 상단 타이틀 및 계정 메뉴 바 -->
<header class="dfx-top-title-bar d-flex align-items-center justify-content-between w-100">
<!-- 좌측: 콘솔 로고 및 현재 선택된 메뉴 경로 표시 -->
<div class="d-flex align-items-center gap-3">
<RouterLink to="/main.html" class="d-flex align-items-center gap-2 text-decoration-none">
<span class="dfx-header-logo">DFX</span>
<span class="dfx-header-title">DFX CONSOLE</span>
</RouterLink>
<span class="dfx-header-divider"></span>
<span class="dfx-mono text-secondary d-none d-md-inline" style="font-size: 12px; letter-spacing: 0.03em">
{{ currentMenuLabel }}
</span>
</div>
<!-- 우측: 로그인 사용자 아이디 표시 및 계정 드롭다운(로그아웃) -->
<div class="d-flex align-items-center gap-3">
<div class="d-none d-sm-flex align-items-center gap-2 px-2 py-1 dfx-user-chip">
<span class="dfx-online-dot"></span>
<i class="bi bi-person-fill text-secondary"></i>
<span class="dfx-mono" style="font-size: 12px">{{ userInfo.userId || 'smbaek@bsm-lab.com' }}</span>
</div>
<div class="dropdown">
<button
class="btn btn-sm dfx-btn-ghost d-flex align-items-center gap-2"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<i class="bi bi-gear"></i>
<span class="d-none d-md-inline">계정</span>
<i class="bi bi-chevron-down" style="font-size: 10px"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end dfx-dropdown-menu">
<li class="px-3 py-2 border-bottom" style="border-color: var(--dfx-border) !important">
<div class="text-secondary" style="font-size: 11px">SIGNED IN AS</div>
<div class="dfx-mono text-light fw-semibold" style="font-size: 12px">{{ userInfo.userId || 'Administrator' }}</div>
</li>
<li><hr class="dropdown-divider" /></li>
<li><a class="dropdown-item" style="cursor: pointer">Something else here</a></li>
</ul>
<li>
<a class="dropdown-item d-flex align-items-center gap-2 py-2" style="cursor: pointer" @click="logout">
<i class="bi bi-box-arrow-right text-danger"></i>
<span>로그아웃 (Sign out)</span>
</a>
</li>
</ul>
</div>
</div>
</nav>
</header>
<div class="container p-0 d-flex">
<aside class="dfx-left-side d-flex flex-column flex-shrink-0 p-3 bg-body-secondary">
<div><i class="bi bi-laptop" style="font-size: 2rem"></i></div>
<ul class="nav nav-pills flex-column mb-auto">
<!-- 중앙 본문 영역 (좌측 사이드바 + 우측 동적 컨텐츠 뷰) -->
<div class="d-flex flex-grow-1 w-100">
<!-- 좌측 사이드바 내비게이션 메뉴 -->
<aside class="dfx-left-side d-flex flex-column flex-shrink-0">
<div class="py-3">
<!-- 모니터링 메뉴 그룹 -->
<div class="dfx-nav-section-label px-3 mb-2">MONITORING</div>
<ul class="nav flex-column mb-3">
<li class="nav-item">
<RouterLink to="/main.html" class="nav-link" :class="{ active: props.contentId == 'dashboard-view', 'link-body-emphasis': props.contentId == 'dashboard-view' }" aria-current="page"> <i class="bi bi-cast" style="font-size: 1.1rem; padding-right: 0.5rem"></i> Dashboard </RouterLink>
<RouterLink
to="/main.html"
class="dfx-nav-link"
:class="{ active: props.contentId == 'dashboard-view' }"
>
<i class="bi bi-grid-1x2"></i>
<span>Dashboard</span>
<span class="ms-auto dfx-nav-kor">대시보드</span>
</RouterLink>
</li>
<li>
<RouterLink to="/agent-manage.html" class="nav-link" :class="{ active: props.contentId == 'agent-manage-view', 'link-body-emphasis': props.contentId != 'agent-manage-view' }" aria-current="page"> <i class="bi bi-cpu" style="font-size: 1.1rem; padding-right: 0.5rem"></i> Agents </RouterLink>
<li class="nav-item">
<RouterLink
to="/message-history.html"
class="dfx-nav-link"
:class="{ active: props.contentId == 'message-history-view' }"
>
<i class="bi bi-activity"></i>
<span>Messages</span>
<span class="ms-auto dfx-nav-kor">송수신 이력</span>
</RouterLink>
</li>
<li>
<RouterLink to="/key-manage.html" class="nav-link" :class="{ active: props.contentId == 'key-manage-view', 'link-body-emphasis': props.contentId != 'key-manage-view' }" aria-current="page"> <i class="bi bi-key" style="font-size: 1.1rem; padding-right: 0.5rem"></i> Crypto Keys </RouterLink>
</ul>
<!-- 환경설정 및 관리 메뉴 그룹 -->
<div class="dfx-nav-section-label px-3 mb-2">CONFIGURATION</div>
<ul class="nav flex-column">
<li class="nav-item">
<RouterLink
to="/agent-manage.html"
class="dfx-nav-link"
:class="{ active: props.contentId == 'agent-manage-view' }"
>
<i class="bi bi-hdd-network"></i>
<span>Agents</span>
<span class="ms-auto dfx-nav-kor">에이전트</span>
</RouterLink>
</li>
<li>
<RouterLink to="/message-history.html" class="nav-link" :class="{ active: props.contentId == 'message-history-view', 'link-body-emphasis': props.contentId != 'message-history-view' }" aria-current="page"> <i class="bi bi-card-checklist" style="font-size: 1.1rem; padding-right: 0.5rem"></i> Messages </RouterLink>
<li class="nav-item">
<RouterLink
to="/key-manage.html"
class="dfx-nav-link"
:class="{ active: props.contentId == 'key-manage-view' }"
>
<i class="bi bi-key"></i>
<span>Crypto Keys</span>
<span class="ms-auto dfx-nav-kor">암호화 키</span>
</RouterLink>
</li>
</ul>
</div>
</aside>
<!-- main content - dashboard, agent list, history -->
<!-- 우측 메인 컨텐츠 영역: 대시보드, 에이전트 관리, 암호화 키 관리, 메시지 이력 중 선택된 화면 렌더링 -->
<component :is="currentContent"></component>
</div>
</template>
<style></style>
<style scoped>
/* 상단 헤더 로고 사각 배지 */
.dfx-header-logo {
background-color: var(--dfx-primary);
color: #ffffff;
font-weight: 800;
font-size: 12px;
letter-spacing: 0.08em;
padding: 0.2rem 0.5rem;
}
.dfx-header-title {
font-weight: 800;
font-size: 15px;
letter-spacing: 0.03em;
color: var(--dfx-text-primary);
}
.dfx-header-divider {
width: 1px;
height: 18px;
background-color: var(--dfx-border);
}
/* 상단 로그인 사용자 표시 칩 */
.dfx-user-chip {
background-color: var(--dfx-surface-2);
border: 1px solid var(--dfx-border);
}
.dfx-online-dot {
width: 6px;
height: 6px;
background-color: #10b981;
display: inline-block;
}
.dfx-dropdown-menu {
background-color: var(--dfx-surface-1);
border: 1px solid var(--dfx-border-strong);
min-width: 210px;
padding: 0;
}
/* 좌측 사이드바 섹션 타이틀 */
.dfx-nav-section-label {
font-family: var(--dfx-font-mono);
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--dfx-text-muted);
}
/* 좌측 사이드바 메뉴 아이템 (활성화 시 좌측 3px 파란색 직선 표시) */
.dfx-nav-link {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.65rem 1rem;
color: var(--dfx-text-secondary);
font-weight: 600;
font-size: 13.5px;
border-left: 3px solid transparent;
transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.dfx-nav-link:hover {
background-color: var(--dfx-surface-2);
color: var(--dfx-text-primary);
}
.dfx-nav-link.active {
background-color: var(--dfx-surface-2);
color: #ffffff;
border-left-color: var(--dfx-primary);
}
.dfx-nav-link.active i {
color: var(--dfx-primary);
}
.dfx-nav-kor {
font-size: 11.5px;
font-weight: 400;
color: var(--dfx-text-muted);
}
</style>

@ -1,20 +1,40 @@
<script setup>
/**
* 송수신 메시지 이력 조회 화면 컴포넌트 (MessageHistoryView.vue)
* - 에이전트 간 전송(Postman) 및 수신(DropBox) 처리 이력을 페이징 및 상태별로 조회합니다.
* - 각 메시지의 'Data View' 버튼을 통해 원본 페이로드(MessageDataView 모달)를 확인할 수 있습니다.
*/
import '@/assets/main.css'
import { useApiClient } from '@/components/apiClient'
import { nextTick, onMounted, ref } from 'vue'
import { nextTick, onMounted, ref, computed } from 'vue'
import SearchParameterDto from '@/components/SearchParameterDto'
import Pagination from '@/components/Pagination.vue'
import MessageDataView from '@/components/MessageDataView.vue'
const apiClient = useApiClient()
const dfxAgentMessageDtoList = ref([])
const messageData = ref({})
const messageDataView = ref(null)
const messageDataViewId = 'messageDataListModal'
const searchParameterDto = ref(new SearchParameterDto())
// 메시지 목록 및 검색/모달 제어를 위한 반응형 상태
const dfxAgentMessageDtoList = ref([]) // 서버에서 조회된 현재 페이지의 메시지 DTO 목록
const selectedStatus = ref('') // 처리 상태 필터 선택값 ('', 'PROCESS_RECEIVE', 'PROCESS_DONE', 'PROCESS_FAIL')
const searchKeyword = ref('') // 화면 내 즉시 필터링용 검색어 (UUID, 에이전트 ID, Postman/DropBox ID)
const messageData = ref({}) // 상세 데이터 모달에 전달할 선택된 메시지 DTO
const messageDataView = ref(null) // MessageDataView 자식 컴포넌트 참조
const messageDataViewId = 'messageDataListModal' // 데이터 상세 모달 DOM ID
const searchParameterDto = ref(new SearchParameterDto()) // 페이징 및 검색 조건 DTO
/**
* 지정한 페이지 번호의 송수신 메시지 목록을 서버로부터 조회합니다.
* 1) 선택된 처리 상태(processStatusCode)를 검색 파라미터에 반영
* 2) 전체 건수(Total Count) 조회 후 페이지네이션 번호 계산
* 3) 해당 페이지의 메시지 목록 데이터 조회
* @param {number} pageIndex 조회할 페이지 번호 (1부터 시작)
*/
async function retrieveDataList(pageIndex) {
if (selectedStatus.value) {
searchParameterDto.value.setParameter('processStatusCode', selectedStatus.value)
} else {
searchParameterDto.value.clearParameter()
}
const totalCountResponse = await apiClient.post('/app-api/agent/getAgentMessageDtoListTotalCount', searchParameterDto.value)
const totalCount = totalCountResponse.data
searchParameterDto.value.calculatePageList(pageIndex, searchParameterDto.value.itemCountPerPage, totalCount)
@ -22,6 +42,42 @@ async function retrieveDataList(pageIndex) {
dfxAgentMessageDtoList.value = response.data
}
/**
* 조회된 현재 페이지 데이터 중 상태 선택값 및 키워드(UUID, 송/수신 에이전트, Postman/DropBox ID)에 일치하는 목록만 필터링합니다.
*/
const filteredMessageList = computed(() => {
return dfxAgentMessageDtoList.value.filter((msg) => {
if (selectedStatus.value && msg.processStatusCode !== selectedStatus.value) {
return false
}
const kw = searchKeyword.value.trim().toLowerCase()
if (!kw) return true
return (
(msg.messageUuid && msg.messageUuid.toLowerCase().includes(kw)) ||
(msg.senderAgentId && msg.senderAgentId.toLowerCase().includes(kw)) ||
(msg.senderPostmanId && msg.senderPostmanId.toLowerCase().includes(kw)) ||
(msg.recipientAgentId && msg.recipientAgentId.toLowerCase().includes(kw)) ||
(msg.recipientDropboxId && msg.recipientDropboxId.toLowerCase().includes(kw))
)
})
})
/**
* 메시지 처리 상태 코드(processStatusCode)에 따른 배지 스타일 클래스를 반환합니다.
* @param {string} status 처리 상태 코드
* @returns {string} 배지 CSS 클래스명
*/
function getStatusBadgeClass(status) {
if (status === 'PROCESS_DONE') return 'dfx-badge-success'
if (status === 'PROCESS_FAIL') return 'dfx-badge-danger'
if (status === 'PROCESS_RECEIVE' || status === 'PROCESS_RECEIVED') return 'dfx-badge-info'
return 'dfx-badge-neutral'
}
/**
* 선택한 메시지의 원본 데이터(Payload) 상세 조회 모달을 엽니다.
* @param {Object} dfxAgentMessageDto 선택된 메시지 DTO
*/
function openDataView(dfxAgentMessageDto) {
messageData.value = dfxAgentMessageDto
nextTick(() => {
@ -29,39 +85,83 @@ function openDataView(dfxAgentMessageDto) {
})
}
// 화면 최초 진입 시 1페이지 데이터 조회
onMounted(async () => {
await retrieveDataList(1)
})
</script>
<template>
<main class="dfx-main container w-100">
<div class="row">
<div class="col-12 pt-2 border-bottom">
<h3 class="h3">송수신 메시지 목록</h3>
</div>
</div>
<div class="row">
<article class="col-12 pt-3">
<div class="row">
<div class="col-1">
<label for="search-process-status" class="form-label">Status</label>
</div>
<div class="col-9">
<select class="form-select form-select-sm" name="search-process-status" aria-label="Status">
<option value="">전체</option>
<option value="PROCESS_RECEIVE">수신</option>
<option value="PROCESS_DONE">완료</option>
<option value="PROCESS_FAIL">오류</option>
<main class="dfx-main">
<!-- 페이지 상단 헤더 영역: 화면 제목 및 전체 메시지 건수 배지 -->
<div class="dfx-page-header">
<div>
<h3 class="dfx-page-title">송수신 메시지 목록</h3>
<div class="dfx-page-subtitle">에이전트 간 전송(Postman) 및 수신(DropBox) 처리 이력과 원본 페이로드(Message Data) 조회</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="dfx-badge dfx-badge-neutral">
TOTAL {{ searchParameterDto.totalItemCount.toLocaleString() }} MESSAGES
</span>
</div>
</div>
<!-- 상단 검색 및 필터 바: 처리 상태(Status) 선택, 키워드 검색어 입력, 조회 버튼 -->
<div class="card mb-3">
<div class="card-body py-2 px-3">
<div class="row g-2 align-items-center">
<div class="col-12 col-md-4 col-lg-3">
<div class="input-group input-group-sm">
<label class="input-group-text" for="search-process-status">Status</label>
<select
id="search-process-status"
class="form-select form-select-sm"
name="search-process-status"
aria-label="Status"
v-model="selectedStatus"
>
<option value="">전체 (ALL)</option>
<option value="PROCESS_RECEIVE">수신 (PROCESS_RECEIVE)</option>
<option value="PROCESS_DONE">완료 (PROCESS_DONE)</option>
<option value="PROCESS_FAIL">오류 (PROCESS_FAIL)</option>
</select>
</div>
<div class="col-2">
<button class="btn btn-sm btn-success">조회</button>
</div>
<div class="col-12 col-md-5 col-lg-6">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input
type="text"
class="form-control"
placeholder="UUID, 송신/수신 에이전트, Postman/DropBox ID 필터..."
v-model="searchKeyword"
/>
</div>
</div>
<div class="col-12 col-md-3 col-lg-3 d-flex justify-content-end gap-2">
<button class="btn btn-sm btn-success px-3 d-flex align-items-center gap-1" @click="retrieveDataList(1)">
<i class="bi bi-funnel"></i>
<span>조회</span>
</button>
</div>
<div class="row">
<div class="col-12">
<table class="table table-striped table-bordered align-middle fs-7">
</div>
</div>
</div>
<!-- 송수신 메시지 트랜잭션 로그 테이블 카드 -->
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-activity text-primary"></i>
<span>송수신 트랜잭션 로그 (Message Transactions)</span>
</span>
<span class="dfx-mono text-secondary" style="font-size: 11.5px">
PAGE {{ searchParameterDto.page }} ({{ filteredMessageList.length }} ROWS)
</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-bordered table-hover align-middle fs-7 mb-0">
<thead>
<tr>
<th scope="col" class="text-center">Message UUID</th>
@ -75,35 +175,66 @@ onMounted(async () => {
</tr>
</thead>
<tbody class="table-group-divider">
<tr v-if="dfxAgentMessageDtoList.length > 0" v-for="dfxAgentMessageDto in dfxAgentMessageDtoList" :key="`${dfxAgentMessageDto.senderAgentId}-${dfxAgentMessageDto.messageUuid}`">
<td scope="row" class="text-start">{{ dfxAgentMessageDto.messageUuid }}</td>
<td class="text-center">{{ dfxAgentMessageDto.senderAgentId }}({{ dfxAgentMessageDto.senderPostmanId }})</td>
<td class="text-center">{{ dfxAgentMessageDto.recipientAgentId }}({{ dfxAgentMessageDto.recipientDropboxId }})</td>
<td class="text-center">{{ dfxAgentMessageDto.senderTimeString }}</td>
<td class="text-center">{{ dfxAgentMessageDto.recipientTimeString }}</td>
<td class="text-center">{{ dfxAgentMessageDto.processStatusCode }}</td>
<td class="text-end">{{ dfxAgentMessageDto.messageDataCount }}</td>
<td class="text-center"><button type="button" class="btn btn-primary btn-sm" @click.prevent="openDataView(dfxAgentMessageDto)">Data View</button></td>
<tr
v-if="filteredMessageList.length > 0"
v-for="dfxAgentMessageDto in filteredMessageList"
:key="`${dfxAgentMessageDto.senderAgentId}-${dfxAgentMessageDto.messageUuid}`"
>
<td scope="row" class="text-start dfx-mono text-light">
{{ dfxAgentMessageDto.messageUuid }}
</td>
<td class="text-start">
<div class="dfx-mono fw-semibold text-light">{{ dfxAgentMessageDto.senderAgentId }}</div>
<div class="dfx-mono text-secondary" style="font-size: 11px">({{ dfxAgentMessageDto.senderPostmanId }})</div>
</td>
<td class="text-start">
<div class="dfx-mono fw-semibold text-light">{{ dfxAgentMessageDto.recipientAgentId }}</div>
<div class="dfx-mono text-secondary" style="font-size: 11px">({{ dfxAgentMessageDto.recipientDropboxId }})</div>
</td>
<td class="text-center dfx-mono text-secondary">{{ dfxAgentMessageDto.senderTimeString }}</td>
<td class="text-center dfx-mono text-secondary">{{ dfxAgentMessageDto.recipientTimeString }}</td>
<td class="text-center">
<span class="dfx-badge" :class="getStatusBadgeClass(dfxAgentMessageDto.processStatusCode)">
<span class="dfx-badge-dot"></span>
{{ dfxAgentMessageDto.processStatusCode }}
</span>
</td>
<td class="text-end dfx-mono fw-bold">{{ dfxAgentMessageDto.messageDataCount }}</td>
<td class="text-center">
<button
type="button"
class="btn btn-primary btn-sm"
@click.prevent="openDataView(dfxAgentMessageDto)"
>
<i class="bi bi-table me-1"></i>Data View
</button>
</td>
</tr>
<tr v-else>
<td scope="row" class="text-center" colspan="8">no datas.</td>
<td scope="row" class="text-center py-4 text-secondary" colspan="8">no datas.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 카드 푸터: 페이지네이션 컴포넌트 -->
<div class="card-footer bg-transparent border-top py-2" style="border-color: var(--dfx-border) !important">
<pagination :list-function="retrieveDataList" :search-parameter-dto="searchParameterDto" />
</article>
</div>
</div>
</main>
<!-- 메시지 원본 데이터(Payload) 상세 조회 모달 컴포넌트 -->
<message-data-view :modal-id="messageDataViewId" :dto="messageData" ref="messageDataView"></message-data-view>
</template>
<style>
/* 조밀한 로그 테이블 가독성을 위한 폰트 크기 유틸리티 */
.fs-7 {
font-size: 0.75rem !important;
} /* 예: 12px */
font-size: 0.78rem !important;
}
.fs-8 {
font-size: 0.625rem !important;
} /* 예: 10px */
font-size: 0.65rem !important;
}
</style>

@ -10,10 +10,11 @@ import org.springframework.web.bind.annotation.RequestMapping;
@Slf4j
public class ForwardController {
/**
* Spring 에서 클라이언트 라우터 경로를 모두 index.html로 포워딩
* Spring Boot에서 Vue 클라이언트 라우터(SPA) 경로를 모두 index.html로 포워딩합니다.
* - 사용자가 서브 메뉴 URL에서 브라우저 새로고침(F5)을 실행해도 404가 발생하지 않고
* Vue Router가 해당 경로를 이어받아 렌더링할 수 있도록 지원합니다.
*/
// @RequestMapping(value = { "/", "/{x:[\\w\\-]+}", "/{x:^(?!api|public-api|app-api).*$}/**" })
@RequestMapping(value = {"/login.html", "main.html"})
@RequestMapping(value = {"/login.html", "/main.html", "/agent-manage.html", "/key-manage.html", "/message-history.html"})
public String forward() {
return "forward:/index.html";
}

Loading…
Cancel
Save