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>
|
<script setup>
|
||||||
import { RouterLink, RouterView, useRouter } from 'vue-router'
|
/**
|
||||||
import { onMounted } from 'vue'
|
* 최상위 루트 컴포넌트 (App.vue)
|
||||||
import { userApi } from '@/components/userInfo'
|
* - 라우터 뷰(RouterView)가 렌더링되는 메인 셸 영역과 하단 공통 푸터(Footer)를 구성합니다.
|
||||||
|
* - 초기 세션 체크 및 페이지 이동 제어는 router/index.js의 beforeEach 가드에서 일괄 처리합니다.
|
||||||
const router = useRouter()
|
*/
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
onMounted(async () => {
|
|
||||||
const isLogin = await userApi.loginCheck()
|
|
||||||
if (isLogin) {
|
|
||||||
router.push('/main.html')
|
|
||||||
} else {
|
|
||||||
router.replace('/login.html')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- 라우터에 의해 LoginView 또는 MainView가 표시되는 영역 -->
|
||||||
|
<div class="dfx-app-shell d-flex flex-column flex-grow-1 w-100">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
<footer class="w-100 border-top d-flex">
|
</div>
|
||||||
<div class="bg-info-subtle p-2 d-flex align-items-center w-100 bg-purple">바닥글(저작권, 링크 등) 영역</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>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
/* 하단 푸터 높이(36px)를 제외한 나머지 전체 화면 높이를 차지하도록 설정 */
|
||||||
|
.dfx-app-shell {
|
||||||
|
min-height: calc(100vh - 36px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -1,42 +1,29 @@
|
|||||||
@import './base.css';
|
@import './base.css';
|
||||||
|
|
||||||
|
/* Vue 마운트 루트 컨테이너: 가로 최대폭 제한 없이 전체 화면(Full-Width)을 사용하도록 설정 */
|
||||||
#app {
|
#app {
|
||||||
max-width: 1280px;
|
width: 100%;
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
font-weight: normal;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: stretch;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
a,
|
|
||||||
.green {
|
|
||||||
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;
|
display: flex;
|
||||||
place-items: center;
|
flex-direction: column;
|
||||||
|
background-color: var(--dfx-bg-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
/* 기본 링크 스타일 초기화 */
|
||||||
padding: 0 2rem;
|
a {
|
||||||
}
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
/* 하단 공통 푸터 바 스타일 */
|
||||||
min-height: 2rem;
|
footer.dfx-footer {
|
||||||
max-height: 2rem;
|
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;
|
:root,
|
||||||
min-height: 2rem;
|
[data-bs-theme='dark'] {
|
||||||
}
|
/* Bootstrap 전역 border-radius 변수를 모두 0(직각)으로 재정의 */
|
||||||
|
|
||||||
/* 왼쪽 사이드바 영역 */
|
|
||||||
.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 */
|
|
||||||
--bs-border-radius: 0;
|
--bs-border-radius: 0;
|
||||||
--bs-border-radius-sm: 0;
|
--bs-border-radius-sm: 0;
|
||||||
--bs-border-radius-lg: 0;
|
--bs-border-radius-lg: 0;
|
||||||
--bs-border-radius-xl: 0;
|
--bs-border-radius-xl: 0;
|
||||||
--bs-border-radius-xxl: 0;
|
--bs-border-radius-xxl: 0;
|
||||||
|
--bs-border-radius-2xl: 0;
|
||||||
--bs-border-radius-pill: 0;
|
--bs-border-radius-pill: 0;
|
||||||
|
|
||||||
/* 컴포넌트별 radius */
|
/* 버튼, 카드, 모달, 드롭다운, 배지, 입력창의 개별 radius 변수도 모두 0으로 설정 */
|
||||||
--bs-btn-border-radius: 0;
|
--bs-btn-border-radius: 0;
|
||||||
--bs-btn-border-radius-sm: 0;
|
--bs-btn-border-radius-sm: 0;
|
||||||
--bs-btn-border-radius-lg: 0;
|
--bs-btn-border-radius-lg: 0;
|
||||||
|
|
||||||
--bs-card-border-radius: 0;
|
--bs-card-border-radius: 0;
|
||||||
|
--bs-card-inner-border-radius: 0;
|
||||||
--bs-modal-border-radius: 0;
|
--bs-modal-border-radius: 0;
|
||||||
--bs-dropdown-border-radius: 0;
|
--bs-dropdown-border-radius: 0;
|
||||||
--bs-badge-border-radius: 0;
|
--bs-badge-border-radius: 0;
|
||||||
--bs-input-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/dist/css/bootstrap.min.css'
|
||||||
import 'bootstrap'
|
import 'bootstrap'
|
||||||
import 'bootstrap-icons/font/bootstrap-icons.css'
|
import 'bootstrap-icons/font/bootstrap-icons.css'
|
||||||
import './assets/app.css'
|
import './assets/app.css'
|
||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
import { createApp, getCurrentInstance } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import apiClient from './components/apiClient'
|
import apiClient from './components/apiClient'
|
||||||
|
|
||||||
|
// 루트 컴포넌트(App.vue)를 기반으로 Vue 앱 생성
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
// 모든 컴포넌트에서 $apiClient로 Axios 인스턴스에 접근할 수 있도록 전역 속성에 등록
|
||||||
app.config.globalProperties.$apiClient = apiClient
|
app.config.globalProperties.$apiClient = apiClient
|
||||||
|
|
||||||
|
// SPA 라우터 플러그인 등록 후 #app 엘리먼트에 마운트
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
Loading…
Reference in new issue