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 바디 형태를 유지해야 한다면 그 방식으로도 바로 변형 예제를 드릴게요.
|
||||
@ -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,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,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')
|
||||
|
||||
Loading…
Reference in new issue