Compare commits
1 Commits
HW_Dashboa
...
Dockerizin
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d19d8283e |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
npm-debug.log
|
||||||
|
uploads
|
||||||
|
*.xlsx
|
||||||
|
*.log
|
||||||
6
.env
6
.env
@@ -1,6 +0,0 @@
|
|||||||
DB_HOST=172.16.8.151
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_USER=itam_admin
|
|
||||||
DB_PASS=itam1234
|
|
||||||
DB_NAME=itam
|
|
||||||
PORT=3000
|
|
||||||
12
Dockerfile.backend
Normal file
12
Dockerfile.backend
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "server"]
|
||||||
12
Dockerfile.frontend
Normal file
12
Dockerfile.frontend
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
729
doc_readme.md
Normal file
729
doc_readme.md
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
# ITAM 도커라이징 실전 가이드
|
||||||
|
|
||||||
|
## 1. 문서 목적
|
||||||
|
|
||||||
|
이 문서는 Gitea에 올라가 있는 현재 저장소를 기준으로, 개발 PC에 WSL2와 Ubuntu만 설치되어 있는 상태에서 지금의 Docker 실행 구조를 재현하는 방법을 처음부터 끝까지 설명하는 실전 가이드다.
|
||||||
|
|
||||||
|
이 문서는 아래 상황을 가정한다.
|
||||||
|
|
||||||
|
1. 소스 코드는 아직 로컬에 없거나, Gitea에서 막 받아올 예정이다.
|
||||||
|
2. Windows에는 WSL2와 Ubuntu는 설치되어 있다.
|
||||||
|
3. 그 외 Docker 관련 세팅은 아직 안 되어 있을 수 있다.
|
||||||
|
4. 최종 목표는 현재 저장소 기준 `frontend + backend + external DB` 구조를 Docker로 재현하는 것이다.
|
||||||
|
|
||||||
|
이 문서의 목적은 아래 네 가지다.
|
||||||
|
|
||||||
|
1. 현재 시스템 구조와 Docker 구조를 먼저 이해하게 한다.
|
||||||
|
2. 기존 파일 중 무엇이 새로 추가되었고 무엇이 수정되었는지 정리한다.
|
||||||
|
3. 각 단계별로 정확히 어디에서 명령을 실행해야 하는지 명시한다.
|
||||||
|
4. Gitea 소스만 받은 상태에서 지금과 같은 Docker 실행 상태까지 도달하게 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 현재 시스템 구조 개요
|
||||||
|
|
||||||
|
## 2.1 애플리케이션 원래 구조
|
||||||
|
|
||||||
|
현재 저장소의 본래 실행 구조는 다음과 같다.
|
||||||
|
|
||||||
|
1. 프런트엔드: Vite 기반 TypeScript 앱
|
||||||
|
2. 백엔드: Express 기반 Node.js API 서버
|
||||||
|
3. 데이터베이스: 외부 MySQL 서버
|
||||||
|
|
||||||
|
즉, 원래부터 MySQL이 Docker 안에 들어 있던 구조가 아니다.
|
||||||
|
|
||||||
|
프런트와 백엔드는 각각 별도 프로세스로 실행되며, 프런트는 `/api` 상대 경로로 백엔드 API를 호출한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.2 현재 Docker 구조
|
||||||
|
|
||||||
|
현재 최종 Docker 구조는 아래와 같다.
|
||||||
|
|
||||||
|
1. `frontend` 컨테이너
|
||||||
|
2. `backend` 컨테이너
|
||||||
|
3. 외부 MySQL DB
|
||||||
|
|
||||||
|
즉, 지금은 내부 `db` 컨테이너가 없고, 내부 `db-bootstrap` 컨테이너도 없다.
|
||||||
|
|
||||||
|
현재 구조를 문장으로 풀면 다음과 같다.
|
||||||
|
|
||||||
|
1. 브라우저는 `http://localhost:8080`으로 `frontend` 컨테이너에 접속한다.
|
||||||
|
2. `frontend`는 `/api` 요청을 `backend:3000`으로 프록시한다.
|
||||||
|
3. `backend`는 `.env`에 적힌 외부 DB 정보로 외부 MySQL에 직접 접속한다.
|
||||||
|
4. 조회 결과 JSON을 프런트가 받아 화면에 렌더링한다.
|
||||||
|
|
||||||
|
간단한 흐름은 아래와 같다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser
|
||||||
|
-> frontend container :8080
|
||||||
|
-> Vite proxy (/api)
|
||||||
|
-> backend container :3000
|
||||||
|
-> external MySQL (.env)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.3 왜 이 구조가 맞는가
|
||||||
|
|
||||||
|
현재 구조가 적절한 이유는 다음과 같다.
|
||||||
|
|
||||||
|
1. 원래 시스템도 외부 MySQL을 쓰는 구조였다.
|
||||||
|
2. 지금 목표는 운영형 단일 배포가 아니라 현재 개발형 구조를 Docker로 재현하는 것이다.
|
||||||
|
3. 프런트는 Vite dev server 기반이라 운영형 nginx 정적 배포 구조로 억지로 바꾸는 것보다, 현 구조를 유지하는 편이 안전하다.
|
||||||
|
4. 실무 표준 관점에서도 앱 컨테이너는 무상태로 유지하고, DB는 외부 인프라를 사용하는 구성이 더 일반적이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 이번 도커라이징에서 추가되거나 수정된 파일 정리
|
||||||
|
|
||||||
|
아래 파일들은 이번 Docker 재현 구조를 위해 새로 추가되었거나 수정된 핵심 파일이다.
|
||||||
|
|
||||||
|
## 3.1 새로 추가된 파일
|
||||||
|
|
||||||
|
1. `Dockerfile.frontend`
|
||||||
|
2. `Dockerfile.backend`
|
||||||
|
3. `.dockerignore`
|
||||||
|
4. `docker-compose.yaml`
|
||||||
|
5. `start_docker_wsl.ps1`
|
||||||
|
6. `stop_docker_wsl.ps1`
|
||||||
|
7. `start_docker_wsl.bat`
|
||||||
|
8. `stop_docker_wsl.bat`
|
||||||
|
9. `docker/mysql/init/README.md`
|
||||||
|
10. `docker_task_plan.md`
|
||||||
|
11. `doc_readme2.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.2 기존 파일 중 수정된 핵심 파일
|
||||||
|
|
||||||
|
1. `server.js`
|
||||||
|
2. `vite.config.ts`
|
||||||
|
3. `doc_readme.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.3 각 파일의 역할
|
||||||
|
|
||||||
|
### `Dockerfile.frontend`
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. 프런트 Vite 개발 서버 이미지를 만든다.
|
||||||
|
2. 컨테이너 내부에서 `npm run dev -- --host 0.0.0.0`를 실행한다.
|
||||||
|
|
||||||
|
### `Dockerfile.backend`
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. 백엔드 Express 서버 이미지를 만든다.
|
||||||
|
2. 컨테이너 내부에서 `npm run server`를 실행한다.
|
||||||
|
|
||||||
|
### `.dockerignore`
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. `node_modules`, `build`, `.git`, `.env`, `uploads` 같은 불필요한 파일을 Docker build context에서 제외한다.
|
||||||
|
|
||||||
|
### `docker-compose.yaml`
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. `frontend`, `backend` 두 컨테이너를 동시에 띄운다.
|
||||||
|
2. `backend`는 `.env`의 외부 DB를 사용한다.
|
||||||
|
3. `frontend`는 `backend:3000`으로 프록시한다.
|
||||||
|
|
||||||
|
### `start_docker_wsl.ps1`
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. Windows 경로를 WSL 경로로 안전하게 바꾼다.
|
||||||
|
2. WSL 내부 Docker를 사용해 `docker compose up --build -d`를 실행한다.
|
||||||
|
3. 한글 경로와 공백 경로에서도 안정적으로 실행되게 한다.
|
||||||
|
|
||||||
|
### `stop_docker_wsl.ps1`
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. 같은 방식으로 WSL 내부에서 `docker compose down`을 실행한다.
|
||||||
|
|
||||||
|
### `start_docker_wsl.bat`, `stop_docker_wsl.bat`
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. PowerShell 스크립트를 쉽게 실행하는 래퍼 역할을 한다.
|
||||||
|
|
||||||
|
### `server.js`
|
||||||
|
|
||||||
|
중요 수정 사항:
|
||||||
|
|
||||||
|
1. `dotenv.config({ override: true })`가 아니라 `dotenv.config()`를 사용한다.
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
1. Compose나 실행 환경이 주는 환경변수를 `.env`가 덮어써 버리면 안 된다.
|
||||||
|
2. 외부 DB 정보와 포트 설정 등 실행 환경 우선 구조를 유지해야 한다.
|
||||||
|
|
||||||
|
### `vite.config.ts`
|
||||||
|
|
||||||
|
중요 수정 사항:
|
||||||
|
|
||||||
|
1. 프록시 타깃을 고정 `localhost:3000`이 아니라 환경변수 기반으로 받도록 바꿨다.
|
||||||
|
|
||||||
|
현재 구조:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
|
||||||
|
```
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
1. 로컬에서 직접 프런트를 띄울 때는 `localhost:3000`이 맞다.
|
||||||
|
2. Docker 안에서는 `frontend` 컨테이너에서 보는 `localhost`가 백엔드가 아니므로 `backend:3000`을 써야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 현재 `docker-compose.yaml` 기준 실제 동작 구조
|
||||||
|
|
||||||
|
현재 `docker-compose.yaml`은 아래 구조다.
|
||||||
|
|
||||||
|
### `backend`
|
||||||
|
|
||||||
|
1. `Dockerfile.backend`로 이미지를 빌드한다.
|
||||||
|
2. `.env`를 읽는다.
|
||||||
|
3. DB 관련 변수는 `${DB_HOST}`, `${DB_PORT}`, `${DB_USER}`, `${DB_PASS}`, `${DB_NAME}`를 그대로 사용한다.
|
||||||
|
4. 포트 `3000:3000`으로 노출한다.
|
||||||
|
5. `uploads`, `map_config.json`을 마운트한다.
|
||||||
|
|
||||||
|
### `frontend`
|
||||||
|
|
||||||
|
1. `Dockerfile.frontend`로 이미지를 빌드한다.
|
||||||
|
2. `VITE_DEV_PROXY_TARGET: http://backend:3000` 환경변수를 사용한다.
|
||||||
|
3. 포트 `8080:8080`으로 노출한다.
|
||||||
|
4. 브라우저의 `/api` 요청을 `backend`로 프록시한다.
|
||||||
|
|
||||||
|
즉, 현재 Compose는 DB를 띄우지 않고 앱 두 개만 띄운다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 사전 준비 사항
|
||||||
|
|
||||||
|
이 섹션은 Gitea에서 코드를 받기 전 또는 받은 직후에 확인해야 한다.
|
||||||
|
|
||||||
|
## 5.1 가정하는 기본 상태
|
||||||
|
|
||||||
|
이미 설치되어 있다고 가정하는 것:
|
||||||
|
|
||||||
|
1. Windows
|
||||||
|
2. WSL2
|
||||||
|
3. Ubuntu 배포판
|
||||||
|
|
||||||
|
아직 없을 수 있는 것:
|
||||||
|
|
||||||
|
1. Docker Desktop 또는 WSL 내부 Docker 사용 환경
|
||||||
|
2. Git 클라이언트
|
||||||
|
3. 프로젝트 `.env`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.2 권장 Docker 실행 방식
|
||||||
|
|
||||||
|
현재 저장소 구조상 가장 권장하는 방식은 다음이다.
|
||||||
|
|
||||||
|
1. Windows에 Docker Desktop 설치
|
||||||
|
2. Docker Desktop에서 WSL2 통합 활성화
|
||||||
|
3. Ubuntu WSL 내부에서 `docker` 명령을 사용할 수 있게 한다.
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
1. 현재 `start_docker_wsl.ps1`가 WSL 내부의 `docker`를 호출하는 구조다.
|
||||||
|
2. 실제 검증도 WSL 내부 Docker 기준으로 이루어졌다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.3 외부 DB 정보 준비
|
||||||
|
|
||||||
|
현재 구조는 외부 MySQL을 사용하므로 `.env` 파일이 반드시 필요하다.
|
||||||
|
|
||||||
|
최소한 아래 값이 필요하다.
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_HOST=<외부 MySQL 호스트>
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=<외부 MySQL 계정>
|
||||||
|
DB_PASS=<외부 MySQL 비밀번호>
|
||||||
|
DB_NAME=itam
|
||||||
|
```
|
||||||
|
|
||||||
|
필요 시 추가 환경변수는 현재 백엔드 코드 기준으로 함께 넣을 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Gitea에서 소스 받기
|
||||||
|
|
||||||
|
## 6.1 작업 실행 위치
|
||||||
|
|
||||||
|
이 단계는 **Windows PowerShell** 또는 **Windows 터미널의 PowerShell**에서 수행한다.
|
||||||
|
|
||||||
|
실행 위치 이유:
|
||||||
|
|
||||||
|
1. 이후 `start_docker_wsl.ps1`도 Windows PowerShell에서 실행하는 것이 가장 자연스럽다.
|
||||||
|
2. 로컬 작업 폴더를 Windows 경로 기준으로 준비할 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.2 소스 클론
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone <Gitea 저장소 URL>
|
||||||
|
cd <클론된 저장소 경로>
|
||||||
|
```
|
||||||
|
|
||||||
|
현재 프로젝트처럼 한글 경로를 사용할 수도 있지만, 가능하면 너무 복잡한 경로는 피하는 것이 좋다.
|
||||||
|
|
||||||
|
현재 실제 프로젝트 경로 예시는 아래였다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
c:\Users\user\Desktop\안건 파일\itam
|
||||||
|
```
|
||||||
|
|
||||||
|
이 경로도 현재 스크립트로는 동작 가능하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Docker 환경 준비
|
||||||
|
|
||||||
|
## 7.1 작업 실행 위치
|
||||||
|
|
||||||
|
이 단계는 **Windows PowerShell**과 **WSL Ubuntu 터미널**을 둘 다 사용한다.
|
||||||
|
|
||||||
|
1. 설치 확인은 Windows PowerShell에서 시작
|
||||||
|
2. 실제 Docker 동작 확인은 WSL Ubuntu에서 수행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7.2 Docker Desktop 설치 여부 확인
|
||||||
|
|
||||||
|
**실행 위치: Windows PowerShell**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker version
|
||||||
|
```
|
||||||
|
|
||||||
|
만약 여기서 바로 안 잡혀도 현재 프로젝트는 WSL 내부 Docker를 쓰므로, 다음 단계로 넘어가 WSL 내부 확인을 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7.3 WSL 내부 Docker 확인
|
||||||
|
|
||||||
|
**실행 위치: Windows PowerShell**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl -l -v
|
||||||
|
wsl sh -lc "docker --version"
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 기대 결과:
|
||||||
|
|
||||||
|
1. Ubuntu가 Running 상태
|
||||||
|
2. `docker --version`이 정상 출력
|
||||||
|
|
||||||
|
만약 `docker --version`이 실패하면, Docker Desktop 설치 및 WSL 통합을 먼저 완료해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `.env` 파일 준비
|
||||||
|
|
||||||
|
## 8.1 작업 실행 위치
|
||||||
|
|
||||||
|
이 단계는 **Windows PowerShell**, **VS Code**, 또는 아무 텍스트 편집기**에서 수행한다.
|
||||||
|
|
||||||
|
즉, 프로젝트 루트에 `.env` 파일을 만드는 작업이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8.2 `.env` 작성
|
||||||
|
|
||||||
|
프로젝트 루트에 `.env`를 만든다.
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_HOST=your-external-db-host
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=your-db-user
|
||||||
|
DB_PASS=your-db-password
|
||||||
|
DB_NAME=itam
|
||||||
|
```
|
||||||
|
|
||||||
|
주의:
|
||||||
|
|
||||||
|
1. 현재 Compose는 내부 DB를 만들지 않는다.
|
||||||
|
2. 따라서 이 값이 곧 실제 운영/개발 외부 DB 연결 정보다.
|
||||||
|
3. 이 정보가 틀리면 `backend`는 기동해도 API에서 DB 오류가 난다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 현재 Docker 파일이 어떻게 동작하는지 이해하기
|
||||||
|
|
||||||
|
## 9.1 `Dockerfile.frontend`
|
||||||
|
|
||||||
|
**확인 위치: 프로젝트 루트 / VS Code**
|
||||||
|
|
||||||
|
현재 내용 핵심:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
|
```
|
||||||
|
|
||||||
|
의미:
|
||||||
|
|
||||||
|
1. Node 20 Alpine 기반
|
||||||
|
2. 의존성 설치 후 전체 소스 복사
|
||||||
|
3. Vite 개발 서버 실행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9.2 `Dockerfile.backend`
|
||||||
|
|
||||||
|
**확인 위치: 프로젝트 루트 / VS Code**
|
||||||
|
|
||||||
|
현재 내용 핵심:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "run", "server"]
|
||||||
|
```
|
||||||
|
|
||||||
|
의미:
|
||||||
|
|
||||||
|
1. Node 20 Alpine 기반
|
||||||
|
2. Express 서버 실행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9.3 `vite.config.ts`
|
||||||
|
|
||||||
|
**확인 위치: 프로젝트 루트 / VS Code**
|
||||||
|
|
||||||
|
현재 핵심:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
|
||||||
|
```
|
||||||
|
|
||||||
|
그리고 `/api`, `/uploads`가 모두 `proxyTarget`으로 프록시된다.
|
||||||
|
|
||||||
|
의미:
|
||||||
|
|
||||||
|
1. 로컬 실행 시 기본값은 `localhost:3000`
|
||||||
|
2. Docker 실행 시 Compose가 `http://backend:3000`을 주입
|
||||||
|
|
||||||
|
이 수정이 있어야 Docker 안에서도 화면에 데이터가 표시된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Docker Compose 기동
|
||||||
|
|
||||||
|
## 10.1 작업 실행 위치
|
||||||
|
|
||||||
|
이 단계는 반드시 **Windows PowerShell**에서 수행하는 것을 권장한다.
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
1. `start_docker_wsl.ps1`가 Windows 경로를 받아 WSL 경로로 바꾸는 구조다.
|
||||||
|
2. 한글/공백 경로에서 가장 안전하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10.2 권장 기동 방법
|
||||||
|
|
||||||
|
**실행 위치: 프로젝트 루트의 Windows PowerShell**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
또는
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_docker_wsl.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
이 스크립트는 내부적으로 아래를 수행한다.
|
||||||
|
|
||||||
|
1. PowerShell 출력 인코딩을 UTF-8로 설정
|
||||||
|
2. 현재 Windows 경로를 WSL 경로로 변환
|
||||||
|
3. WSL 동작 확인
|
||||||
|
4. WSL 내부 Docker 동작 확인
|
||||||
|
5. `docker compose up --build -d` 수행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10.3 직접 기동이 필요할 때
|
||||||
|
|
||||||
|
**실행 위치: WSL Ubuntu 터미널**
|
||||||
|
|
||||||
|
직접 실행 예시는 아래와 같다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/c/Users/user/Desktop/안건\ 파일/itam
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
하지만 현재 프로젝트는 한글 경로 이슈가 있었기 때문에, 특별한 이유가 없으면 `start_docker_wsl.ps1`를 우선 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 컨테이너 기동 후 검증
|
||||||
|
|
||||||
|
## 11.1 컨테이너 상태 확인
|
||||||
|
|
||||||
|
**실행 위치: Windows PowerShell**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep itam"
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 기대 상태:
|
||||||
|
|
||||||
|
1. `itam-backend` -> `Up`
|
||||||
|
2. `itam-frontend` -> `Up`
|
||||||
|
|
||||||
|
현재는 `itam-db`, `itam-db-bootstrap`가 없어야 정상이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11.2 백엔드 API 확인
|
||||||
|
|
||||||
|
**실행 위치: Windows PowerShell**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri http://localhost:3000/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 기대값:
|
||||||
|
|
||||||
|
1. `200`
|
||||||
|
|
||||||
|
이 검사는 `backend`가 외부 DB에 정상 연결됐는지 보는 가장 직접적인 검사다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11.3 프런트 경유 API 확인
|
||||||
|
|
||||||
|
**실행 위치: Windows PowerShell**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri http://localhost:8080/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 기대값:
|
||||||
|
|
||||||
|
1. `200`
|
||||||
|
|
||||||
|
이 검사는 프런트 프록시가 정상인지 확인한다.
|
||||||
|
|
||||||
|
예전에 화면에 데이터가 안 보였던 것은 외부 DB 자체가 아니라, 이 프록시 경로가 잘못돼 있었기 때문이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11.4 브라우저 화면 확인
|
||||||
|
|
||||||
|
**실행 위치: 브라우저**
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
확인 포인트:
|
||||||
|
|
||||||
|
1. 화면이 열리는지
|
||||||
|
2. 목록/대시보드/테이블 데이터가 비어 있지 않은지
|
||||||
|
3. 모달 진입 시 데이터가 정상적으로 보이는지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 지금 데이터가 표시되는 원리
|
||||||
|
|
||||||
|
현재는 내부 DB로 데이터를 옮겨 담지 않는다.
|
||||||
|
|
||||||
|
현재 실제 동작 원리는 다음과 같다.
|
||||||
|
|
||||||
|
1. 브라우저가 `frontend`에 접속한다.
|
||||||
|
2. 프런트가 `/api/...`로 요청한다.
|
||||||
|
3. Vite 프록시가 `backend:3000`으로 요청을 넘긴다.
|
||||||
|
4. `backend`가 `.env`의 외부 MySQL에 직접 접속한다.
|
||||||
|
5. 조회 결과 JSON을 프런트가 받아 화면에 렌더링한다.
|
||||||
|
|
||||||
|
즉, 현재는 아래 구조다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser -> frontend -> backend -> external MySQL
|
||||||
|
```
|
||||||
|
|
||||||
|
예전 외부 DB 구조에서 화면에 데이터가 안 보였던 이유는 외부 DB 때문이 아니라, 프런트 컨테이너가 `localhost:3000`을 잘못 바라보고 있었기 때문이다.
|
||||||
|
|
||||||
|
지금은 `VITE_DEV_PROXY_TARGET: http://backend:3000`으로 수정되어 있기 때문에 정상 표시된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 자주 헷갈리는 포인트
|
||||||
|
|
||||||
|
## 13.1 현재는 내부 DB 컨테이너가 없다
|
||||||
|
|
||||||
|
현재 `docker-compose.yaml`에는 아래가 없다.
|
||||||
|
|
||||||
|
1. `db` 서비스
|
||||||
|
2. `db-bootstrap` 서비스
|
||||||
|
3. `itam_mysql_data` 볼륨
|
||||||
|
|
||||||
|
즉, DB는 Docker 스택 밖에 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13.2 현재는 `.env`가 곧 실제 DB 연결 정보다
|
||||||
|
|
||||||
|
현재 `backend`는 아래처럼 Compose에서 그대로 받는다.
|
||||||
|
|
||||||
|
1. `DB_HOST: ${DB_HOST}`
|
||||||
|
2. `DB_PORT: ${DB_PORT}`
|
||||||
|
3. `DB_USER: ${DB_USER}`
|
||||||
|
4. `DB_PASS: ${DB_PASS}`
|
||||||
|
5. `DB_NAME: ${DB_NAME}`
|
||||||
|
|
||||||
|
즉, `.env`를 틀리게 적으면 화면도 데이터가 안 뜬다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13.3 `server.js`는 여전히 중요하게 수정된 상태다
|
||||||
|
|
||||||
|
현재 `server.js`는 `dotenv.config()`를 사용한다.
|
||||||
|
|
||||||
|
이 구조는 이후 Compose나 실행 환경에서 변수를 주입할 때, 애플리케이션이 그 값을 받아들일 수 있게 하기 위해 유지해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 스택 중지 방법
|
||||||
|
|
||||||
|
## 14.1 작업 실행 위치
|
||||||
|
|
||||||
|
**Windows PowerShell / 프로젝트 루트**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14.2 권장 종료 명령
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\stop_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
또는
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\stop_docker_wsl.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
이 스크립트는 내부적으로 WSL 경로 변환 후 `docker compose down`을 수행한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 장애 발생 시 점검 순서
|
||||||
|
|
||||||
|
## 15.1 `frontend` 화면은 뜨는데 데이터가 없을 때
|
||||||
|
|
||||||
|
**실행 위치: Windows PowerShell**
|
||||||
|
|
||||||
|
먼저 아래 두 API를 분리해서 본다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri http://localhost:3000/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
|
||||||
|
Invoke-WebRequest -Uri http://localhost:8080/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
|
||||||
|
```
|
||||||
|
|
||||||
|
판단 기준:
|
||||||
|
|
||||||
|
1. `3000`은 200이고 `8080`만 실패 -> 프런트 프록시 문제
|
||||||
|
2. 둘 다 실패 -> 백엔드 또는 외부 DB 연결 문제
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15.2 백엔드가 외부 DB에 연결되지 않을 때
|
||||||
|
|
||||||
|
**실행 위치: Windows PowerShell**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker logs --tail=200 itam-backend"
|
||||||
|
```
|
||||||
|
|
||||||
|
점검 항목:
|
||||||
|
|
||||||
|
1. `.env`의 DB 정보가 정확한지
|
||||||
|
2. 외부 DB 서버 접근이 가능한지
|
||||||
|
3. 계정/비밀번호가 맞는지
|
||||||
|
4. 방화벽 또는 네트워크 이슈가 없는지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15.3 프런트 프록시가 의심될 때
|
||||||
|
|
||||||
|
**확인 위치: `vite.config.ts`, `docker-compose.yaml`**
|
||||||
|
|
||||||
|
다음 두 설정이 유지되는지 확인한다.
|
||||||
|
|
||||||
|
`vite.config.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker-compose.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
VITE_DEV_PROXY_TARGET: http://backend:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
이 둘 중 하나라도 바뀌면 Docker 안에서 화면 데이터가 다시 안 보일 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 현재 기준 재현 절차 요약
|
||||||
|
|
||||||
|
가장 짧게 정리하면 아래 순서다.
|
||||||
|
|
||||||
|
1. Gitea에서 소스를 클론한다.
|
||||||
|
2. Windows PowerShell에서 프로젝트 루트로 이동한다.
|
||||||
|
3. `.env`에 외부 MySQL 정보를 작성한다.
|
||||||
|
4. Docker Desktop + WSL 통합 또는 WSL 내부 Docker 사용 가능 상태를 만든다.
|
||||||
|
5. `start_docker_wsl.ps1`를 실행한다.
|
||||||
|
6. `http://localhost:3000/api/assets/master`가 200인지 확인한다.
|
||||||
|
7. `http://localhost:8080/api/assets/master`가 200인지 확인한다.
|
||||||
|
8. 브라우저에서 `http://localhost:8080`을 열어 실제 데이터 표시를 확인한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 현재 최종 결론
|
||||||
|
|
||||||
|
현재 저장소의 도커라이징 구조는 실무 표준에 맞는 `무상태 앱 컨테이너 + 외부 DB` 구조다.
|
||||||
|
|
||||||
|
현재 핵심은 아래 세 가지다.
|
||||||
|
|
||||||
|
1. `backend`는 외부 MySQL에 직접 연결한다.
|
||||||
|
2. `frontend`는 `backend:3000`으로 API 프록시한다.
|
||||||
|
3. WSL 경로 변환 스크립트를 통해 Windows 한글 경로에서도 안정적으로 실행한다.
|
||||||
|
|
||||||
|
즉, 이 문서대로 진행하면 Gitea 소스만 받은 상태에서 지금과 같은 Docker 실행 구조를 재현할 수 있다.
|
||||||
730
doc_readme2.md
Normal file
730
doc_readme2.md
Normal file
@@ -0,0 +1,730 @@
|
|||||||
|
# ITAM 도커라이징 최종 재현 가이드
|
||||||
|
|
||||||
|
## 1. 문서 목적
|
||||||
|
|
||||||
|
이 문서는 현재 Git 저장소에 올라간 파일만 가지고, 지금과 동일한 수준으로 ITAM 시스템을 도커라이징하고 실행하는 절차를 처음부터 끝까지 정리한 최종 가이드다.
|
||||||
|
|
||||||
|
이 문서만 읽어도 아래 목표를 달성할 수 있게 작성한다.
|
||||||
|
|
||||||
|
1. 현재 저장소 구조를 이해한다.
|
||||||
|
2. 왜 이렇게 도커라이징했는지 판단 근거를 안다.
|
||||||
|
3. WSL2 기반으로 실제 스택을 기동한다.
|
||||||
|
4. 외부 MySQL에서 내부 MySQL 컨테이너로 초기 데이터를 bootstrap 한다.
|
||||||
|
5. 프런트 8080과 백엔드 3000이 모두 정상 동작하는지 검증한다.
|
||||||
|
6. 재초기화, 재기동, 장애 확인까지 수행한다.
|
||||||
|
|
||||||
|
이 문서는 최종 성공 구조 기준이다. 실패 기록은 `doc_readme.md`를 본다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 최종 목표 구조
|
||||||
|
|
||||||
|
현재 최종 구조는 아래 4개 서비스/역할로 나뉜다.
|
||||||
|
|
||||||
|
1. `frontend`: Vite 개발 서버 컨테이너, 포트 8080
|
||||||
|
2. `backend`: Express API 서버 컨테이너, 포트 3000
|
||||||
|
3. `db`: MySQL 8 컨테이너, 포트 3306
|
||||||
|
4. `db-bootstrap`: 외부 MySQL -> 내부 MySQL로 1회성 복제 수행 후 종료되는 도우미 컨테이너
|
||||||
|
|
||||||
|
논리 흐름은 다음과 같다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
브라우저 -> frontend:8080 -> Vite proxy -> backend:3000 -> db:3306
|
||||||
|
\
|
||||||
|
-> /uploads -> backend 정적 경로
|
||||||
|
|
||||||
|
초기 1회 기동 시
|
||||||
|
외부 MySQL(.env) -> db-bootstrap -> 내부 MySQL(db)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 왜 이 구조를 선택했는가
|
||||||
|
|
||||||
|
이 저장소는 처음부터 운영형 정적 배포 앱이 아니었다. 실제 구조는 다음과 같았다.
|
||||||
|
|
||||||
|
1. 프런트는 Vite 개발 서버가 따로 돈다.
|
||||||
|
2. 백엔드는 Express API가 따로 돈다.
|
||||||
|
3. 프런트는 상대 경로 `/api`를 호출한다.
|
||||||
|
4. 백엔드는 프런트의 `dist`를 서빙하지 않는다.
|
||||||
|
|
||||||
|
따라서 내일 바로 시연 가능한 수준까지 빠르게 안정화하려면 아래 전략이 가장 맞다.
|
||||||
|
|
||||||
|
1. 프런트를 Vite dev server 그대로 컨테이너화한다.
|
||||||
|
2. 백엔드를 별도 컨테이너로 유지한다.
|
||||||
|
3. DB는 MySQL 8 컨테이너로 묶되, 초기 데이터는 외부 DB에서 복제한다.
|
||||||
|
4. 프런트 프록시는 컨테이너 네트워크 서비스명 `backend`로 붙게 한다.
|
||||||
|
|
||||||
|
즉, 현재 구조는 "개발형 구조를 Docker로 재현한 시연/개발용 Compose"다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 저장소 내 최종 관련 파일 목록
|
||||||
|
|
||||||
|
현재 도커라이징과 직접 관련된 핵심 파일은 아래와 같다.
|
||||||
|
|
||||||
|
1. `.dockerignore`
|
||||||
|
2. `Dockerfile.frontend`
|
||||||
|
3. `Dockerfile.backend`
|
||||||
|
4. `docker-compose.yaml`
|
||||||
|
5. `start_docker_wsl.ps1`
|
||||||
|
6. `stop_docker_wsl.ps1`
|
||||||
|
7. `start_docker_wsl.bat`
|
||||||
|
8. `stop_docker_wsl.bat`
|
||||||
|
9. `docker/mysql/init/README.md`
|
||||||
|
10. `server.js`
|
||||||
|
11. `vite.config.ts`
|
||||||
|
|
||||||
|
각 파일 역할은 다음과 같다.
|
||||||
|
|
||||||
|
### 4.1 `.dockerignore`
|
||||||
|
|
||||||
|
Docker build context에서 제외할 파일을 정의한다.
|
||||||
|
|
||||||
|
주요 제외 대상은 다음과 같다.
|
||||||
|
|
||||||
|
1. `node_modules`
|
||||||
|
2. `dist`
|
||||||
|
3. `build`
|
||||||
|
4. `.git`
|
||||||
|
5. `.env`
|
||||||
|
6. `uploads`
|
||||||
|
7. `*.xlsx`
|
||||||
|
8. `*.log`
|
||||||
|
|
||||||
|
### 4.2 `Dockerfile.frontend`
|
||||||
|
|
||||||
|
프런트 컨테이너 이미지 정의다.
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
|
```
|
||||||
|
|
||||||
|
이 이미지는 Vite dev server를 컨테이너에서 띄우기 위한 것이다.
|
||||||
|
|
||||||
|
### 4.3 `Dockerfile.backend`
|
||||||
|
|
||||||
|
백엔드 컨테이너 이미지 정의다.
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "server"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 `docker-compose.yaml`
|
||||||
|
|
||||||
|
전체 스택의 핵심 파일이다.
|
||||||
|
|
||||||
|
현재 최종 구성은 다음 논리를 가진다.
|
||||||
|
|
||||||
|
1. `db`는 MySQL 8 내부 DB다.
|
||||||
|
2. `db-bootstrap`은 외부 DB 데이터를 내부 DB로 1회 복제한다.
|
||||||
|
3. `backend`는 내부 `db`에 붙는다.
|
||||||
|
4. `frontend`는 `backend` 서비스명으로 프록시한다.
|
||||||
|
|
||||||
|
### 4.5 `start_docker_wsl.ps1`
|
||||||
|
|
||||||
|
Windows에서 WSL 경유로 Docker Compose를 안전하게 기동하는 진입점이다.
|
||||||
|
|
||||||
|
핵심은 다음 두 가지다.
|
||||||
|
|
||||||
|
1. 프로젝트 Windows 경로를 `wslpath`로 WSL 경로로 바꾼다.
|
||||||
|
2. 그 경로로 이동한 뒤 `docker compose up --build -d`를 수행한다.
|
||||||
|
|
||||||
|
### 4.6 `stop_docker_wsl.ps1`
|
||||||
|
|
||||||
|
같은 방식으로 WSL 내부에서 `docker compose down`을 수행해 스택을 안전하게 내린다.
|
||||||
|
|
||||||
|
### 4.7 `start_docker_wsl.bat`, `stop_docker_wsl.bat`
|
||||||
|
|
||||||
|
더블클릭 또는 간단 실행용 래퍼다. 내부적으로 PowerShell 스크립트를 호출한다.
|
||||||
|
|
||||||
|
### 4.8 `server.js`
|
||||||
|
|
||||||
|
중요 포인트는 다음 두 가지다.
|
||||||
|
|
||||||
|
1. `dotenv.config();`를 사용한다.
|
||||||
|
2. `dotenv.config({ override: true })`를 사용하지 않는다.
|
||||||
|
|
||||||
|
이 차이로 Compose 환경변수 `DB_HOST=db`가 `.env`보다 우선하도록 보장한다.
|
||||||
|
|
||||||
|
### 4.9 `vite.config.ts`
|
||||||
|
|
||||||
|
현재 프록시는 환경변수 기반으로 동작한다.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
|
||||||
|
```
|
||||||
|
|
||||||
|
로컬 PC에서 직접 Vite를 띄우면 기본값 `http://localhost:3000`을 쓴다.
|
||||||
|
컨테이너에서는 Compose가 `http://backend:3000`을 주입한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 현재 최종 `docker-compose.yaml` 구조 설명
|
||||||
|
|
||||||
|
아래는 실제 동작 관점에서 읽어야 할 핵심 내용이다.
|
||||||
|
|
||||||
|
### 5.1 `db` 서비스
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. 내부 MySQL 데이터 저장소
|
||||||
|
2. 앱이 최종적으로 붙는 DB
|
||||||
|
|
||||||
|
핵심 설정:
|
||||||
|
|
||||||
|
1. 이미지: `mysql:8.0`
|
||||||
|
2. DB 이름: `itam`
|
||||||
|
3. 앱 계정: `itam_admin`
|
||||||
|
4. 데이터 볼륨: `itam_mysql_data`
|
||||||
|
5. healthcheck 사용
|
||||||
|
|
||||||
|
healthcheck는 `mysqladmin ping`으로 동작하며, `backend`와 `db-bootstrap`은 이 상태를 기다린다.
|
||||||
|
|
||||||
|
### 5.2 `db-bootstrap` 서비스
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. 외부 원본 DB에서 내부 `db`로 초기 데이터 복제
|
||||||
|
2. 1회성 작업 후 종료
|
||||||
|
|
||||||
|
핵심 포인트:
|
||||||
|
|
||||||
|
1. `.env`를 읽어 외부 DB 접속 정보를 가져온다.
|
||||||
|
2. 내부 `db`에 `asset_core` 테이블이 이미 존재하면 아무 것도 하지 않고 종료한다.
|
||||||
|
3. 그렇지 않으면 `mysqldump | mysql` 파이프라인으로 복제한다.
|
||||||
|
4. `restart: "no"` 이므로 정상 종료 후 반복 실행하지 않는다.
|
||||||
|
|
||||||
|
또한 source DB와 target DB 변수는 분리돼 있다.
|
||||||
|
|
||||||
|
1. source: `SOURCE_DB_*`
|
||||||
|
2. target: `TARGET_DB_*`
|
||||||
|
|
||||||
|
이 구조로 외부 원본 DB 자격증명과 내부 컨테이너 DB 자격증명이 섞이지 않는다.
|
||||||
|
|
||||||
|
### 5.3 `backend` 서비스
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. Express API 제공
|
||||||
|
2. 내부 `db`에 연결
|
||||||
|
3. `/uploads` 정적 제공
|
||||||
|
|
||||||
|
핵심 포인트:
|
||||||
|
|
||||||
|
1. `env_file: .env`를 유지하지만,
|
||||||
|
2. Compose `environment`에서 `DB_HOST=db`, `DB_PORT=3306`, `DB_USER=itam_admin`, `DB_PASS=itam1234`, `DB_NAME=itam`를 다시 지정한다.
|
||||||
|
3. `depends_on`은 `db` healthy와 `db-bootstrap` 성공 종료를 모두 기다린다.
|
||||||
|
|
||||||
|
즉, 백엔드는 DB bootstrap이 끝난 뒤 시작한다.
|
||||||
|
|
||||||
|
### 5.4 `frontend` 서비스
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
1. Vite dev server 제공
|
||||||
|
2. 브라우저 요청 `/api`, `/uploads`를 `backend`로 프록시
|
||||||
|
|
||||||
|
핵심 포인트:
|
||||||
|
|
||||||
|
1. `VITE_DEV_PROXY_TARGET: http://backend:3000`
|
||||||
|
2. `CHOKIDAR_USEPOLLING: "true"`
|
||||||
|
3. `npm run dev -- --host 0.0.0.0`
|
||||||
|
|
||||||
|
중요한 이유는 컨테이너 안의 `localhost`가 호스트의 `localhost`가 아니기 때문이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 사전 준비 조건
|
||||||
|
|
||||||
|
이 저장소를 지금처럼 기동하려면 다음 전제가 필요하다.
|
||||||
|
|
||||||
|
### 6.1 운영체제와 런타임
|
||||||
|
|
||||||
|
1. Windows
|
||||||
|
2. WSL2 Ubuntu 설치 및 실행 중
|
||||||
|
3. Docker CLI가 WSL 내부에서 동작 가능
|
||||||
|
|
||||||
|
권장 확인 명령:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl -l -v
|
||||||
|
wsl sh -lc "docker --version"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 `.env` 파일
|
||||||
|
|
||||||
|
현재 최종 구조는 "첫 기동 시 외부 DB에서 내부 DB로 bootstrap" 하는 방식이므로 `.env`가 반드시 필요하다.
|
||||||
|
|
||||||
|
최소한 다음 값은 외부 원본 DB를 가리켜야 한다.
|
||||||
|
|
||||||
|
```env
|
||||||
|
DB_HOST=<external-mysql-host>
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=<external-db-user>
|
||||||
|
DB_PASS=<external-db-password>
|
||||||
|
DB_NAME=itam
|
||||||
|
```
|
||||||
|
|
||||||
|
주의:
|
||||||
|
|
||||||
|
1. `.env`는 `db-bootstrap`이 외부 원본 DB에 접속할 때 사용한다.
|
||||||
|
2. `backend`는 최종적으로 내부 `db` 컨테이너를 쓰므로, 런타임에서는 Compose `environment`가 우선한다.
|
||||||
|
|
||||||
|
### 6.3 한글 경로 주의
|
||||||
|
|
||||||
|
현재 프로젝트 경로는 한글과 공백을 포함한다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
c:\Users\user\Desktop\안건 파일\itam
|
||||||
|
```
|
||||||
|
|
||||||
|
이 때문에 Docker 관련 명령은 수동으로 경로를 조립하지 말고, `start_docker_wsl.ps1` / `stop_docker_wsl.ps1`을 우선 사용해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 첫 기동 절차
|
||||||
|
|
||||||
|
이 절차는 "Git에서 소스를 받은 뒤 처음 올리는 경우" 기준이다.
|
||||||
|
|
||||||
|
### 7.1 저장소 준비
|
||||||
|
|
||||||
|
1. 저장소를 받는다.
|
||||||
|
2. `.env`가 올바른 외부 원본 DB를 가리키는지 확인한다.
|
||||||
|
3. WSL이 켜져 있는지 확인한다.
|
||||||
|
|
||||||
|
### 7.2 권장 실행 방법
|
||||||
|
|
||||||
|
Windows PowerShell에서 프로젝트 루트로 이동한 뒤 아래 중 하나를 사용한다.
|
||||||
|
|
||||||
|
방법 A:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
방법 B:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_docker_wsl.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 내부 실행 순서
|
||||||
|
|
||||||
|
스크립트는 내부적으로 다음 순서로 동작한다.
|
||||||
|
|
||||||
|
1. 현재 Windows 경로를 WSL 경로로 변환한다.
|
||||||
|
2. WSL 동작 여부를 확인한다.
|
||||||
|
3. WSL 내부 Docker 사용 가능 여부를 확인한다.
|
||||||
|
4. `docker compose up --build -d`를 수행한다.
|
||||||
|
|
||||||
|
### 7.4 기대되는 컨테이너 순서
|
||||||
|
|
||||||
|
정상이라면 다음 순서로 올라온다.
|
||||||
|
|
||||||
|
1. `itam-db`
|
||||||
|
2. `itam-db-bootstrap`
|
||||||
|
3. `itam-backend`
|
||||||
|
4. `itam-frontend`
|
||||||
|
|
||||||
|
`itam-db-bootstrap`은 정상이라면 최종 상태가 `Exited (0)`이어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 첫 기동 후 검증 절차
|
||||||
|
|
||||||
|
기동 후에는 반드시 아래 검증을 수행한다.
|
||||||
|
|
||||||
|
### 8.1 컨테이너 상태 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep itam"
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 기대 상태:
|
||||||
|
|
||||||
|
1. `itam-db` -> `Up ... (healthy)`
|
||||||
|
2. `itam-db-bootstrap` -> `Exited (0)`
|
||||||
|
3. `itam-backend` -> `Up`
|
||||||
|
4. `itam-frontend` -> `Up`
|
||||||
|
|
||||||
|
### 8.2 백엔드 API 직접 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri http://localhost:3000/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 기대값:
|
||||||
|
|
||||||
|
1. `200`
|
||||||
|
|
||||||
|
### 8.3 프런트 경유 API 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri http://localhost:8080/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
|
||||||
|
```
|
||||||
|
|
||||||
|
정상 기대값:
|
||||||
|
|
||||||
|
1. `200`
|
||||||
|
|
||||||
|
### 8.4 데이터가 실제로 들어왔는지 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker exec itam-db mysql -uitam_admin -pitam1234 -D itam -e 'SHOW TABLES' | head -n 20"
|
||||||
|
```
|
||||||
|
|
||||||
|
정상이라면 아래와 같은 테이블들이 보여야 한다.
|
||||||
|
|
||||||
|
1. `asset_core`
|
||||||
|
2. `asset_remote`
|
||||||
|
3. `asset_spec`
|
||||||
|
4. `asset_location`
|
||||||
|
5. `asset_history`
|
||||||
|
6. `asset_software_perpetual`
|
||||||
|
7. `asset_software_subscription`
|
||||||
|
8. `hardware_components_master`
|
||||||
|
9. `job_spec_standards`
|
||||||
|
|
||||||
|
### 8.5 브라우저 화면 확인
|
||||||
|
|
||||||
|
브라우저에서 아래 주소를 연다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
목록/대시보드 데이터가 보이면 화면까지 정상 연결된 것이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 재기동 절차
|
||||||
|
|
||||||
|
코드만 수정됐고 DB는 유지하고 싶다면 다음처럼 하면 된다.
|
||||||
|
|
||||||
|
### 9.1 스택 종료
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\stop_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
또는
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\stop_docker_wsl.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 스택 재기동
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
이 경우 `itam_mysql_data` 볼륨이 유지되므로, `db-bootstrap`은 내부 DB에 `asset_core`가 이미 있음을 감지하고 빠르게 종료한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. DB를 완전히 다시 초기화하는 절차
|
||||||
|
|
||||||
|
외부 원본 DB에서 다시 처음부터 내부 DB를 복제하고 싶다면, MySQL 볼륨을 제거해야 한다.
|
||||||
|
|
||||||
|
### 10.1 스택 중지
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\stop_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 MySQL 데이터 볼륨 삭제
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker volume rm -f itam_itam_mysql_data"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 다시 시작
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
이때 `db-bootstrap`이 외부 DB에서 내부 DB로 전체를 다시 복제한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 현재 구조에서 꼭 알아야 할 설계 포인트
|
||||||
|
|
||||||
|
### 11.1 `server.js`의 `dotenv.config()` 변경 이유
|
||||||
|
|
||||||
|
백엔드가 내부 DB로 붙게 하려면 Compose가 준 환경변수가 `.env`보다 우선해야 한다.
|
||||||
|
|
||||||
|
만약 아래처럼 `override: true`를 쓰면 안 된다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
dotenv.config({ override: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
이렇게 되면 내부 `db`가 아니라 `.env`의 외부 DB로 다시 붙을 수 있다.
|
||||||
|
|
||||||
|
현재는 아래가 맞다.
|
||||||
|
|
||||||
|
```js
|
||||||
|
dotenv.config();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 왜 `docker-entrypoint-initdb.d` 기반 dump 파일을 안 쓰는가
|
||||||
|
|
||||||
|
처음에는 이 방식을 시도했지만, 실제 데이터의 긴 문자열/깨진 텍스트 때문에 import가 line 97에서 중단됐다.
|
||||||
|
|
||||||
|
그래서 현재는 더 안정적인 아래 방식을 쓴다.
|
||||||
|
|
||||||
|
1. 외부 DB에서 `mysqldump`
|
||||||
|
2. 파이프로 내부 `db`에 즉시 `mysql` import
|
||||||
|
|
||||||
|
즉, 파일 중간 생성물을 신뢰하지 않는 구조다.
|
||||||
|
|
||||||
|
### 11.3 왜 프런트 프록시 타깃을 환경변수화했는가
|
||||||
|
|
||||||
|
로컬 직접 실행과 컨테이너 실행의 네트워크 기준이 다르기 때문이다.
|
||||||
|
|
||||||
|
1. 로컬 직접 실행: `localhost:3000`이 맞다.
|
||||||
|
2. 컨테이너 내부 실행: `backend:3000`이 맞다.
|
||||||
|
|
||||||
|
그래서 `vite.config.ts`는 둘 다 수용할 수 있게 작성됐다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 문제 발생 시 진단 순서
|
||||||
|
|
||||||
|
이 프로젝트에서는 문제를 아래 순서로 자르면 가장 빠르다.
|
||||||
|
|
||||||
|
### 12.1 브라우저 화면에 데이터가 없을 때
|
||||||
|
|
||||||
|
먼저 다음 둘을 분리해서 본다.
|
||||||
|
|
||||||
|
1. `http://localhost:3000/api/assets/master`
|
||||||
|
2. `http://localhost:8080/api/assets/master`
|
||||||
|
|
||||||
|
판단 기준:
|
||||||
|
|
||||||
|
1. `3000`은 200이고 `8080`만 실패면 프런트 프록시 문제다.
|
||||||
|
2. 둘 다 실패면 백엔드 또는 DB 문제다.
|
||||||
|
|
||||||
|
### 12.2 DB bootstrap이 성공했는지 확인할 때
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep itam"
|
||||||
|
```
|
||||||
|
|
||||||
|
여기서 `itam-db-bootstrap`이 `Exited (0)`인지 본다.
|
||||||
|
|
||||||
|
### 12.3 내부 DB에 실제 데이터가 있는지 확인할 때
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker exec itam-db mysql -uitam_admin -pitam1234 -D itam -e 'SHOW TABLES'"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.4 백엔드 로그 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker logs --tail=200 itam-backend"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.5 DB 로그 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker logs --tail=200 itam-db"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.6 프런트 로그 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl sh -lc "docker logs --tail=200 itam-frontend"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 자주 나올 수 있는 장애와 해석
|
||||||
|
|
||||||
|
### 13.1 `docker` 명령이 PowerShell에서 안 보임
|
||||||
|
|
||||||
|
의미:
|
||||||
|
|
||||||
|
1. Windows 셸이 아니라 WSL에서 Docker를 쓰는 환경이다.
|
||||||
|
|
||||||
|
대응:
|
||||||
|
|
||||||
|
1. `start_docker_wsl.ps1` 사용
|
||||||
|
|
||||||
|
### 13.2 `asset_core` 테이블 없음
|
||||||
|
|
||||||
|
의미:
|
||||||
|
|
||||||
|
1. 내부 DB 초기화가 안 됐거나 bootstrap이 안 끝났다.
|
||||||
|
|
||||||
|
대응:
|
||||||
|
|
||||||
|
1. `db-bootstrap` 상태 확인
|
||||||
|
2. `.env` 외부 DB 접속 정보 확인
|
||||||
|
3. 필요하면 볼륨 삭제 후 재초기화
|
||||||
|
|
||||||
|
### 13.3 `3000` API는 되는데 화면은 비어 있음
|
||||||
|
|
||||||
|
의미:
|
||||||
|
|
||||||
|
1. DB는 정상이고 프런트 프록시 또는 화면 렌더링 문제다.
|
||||||
|
|
||||||
|
대응:
|
||||||
|
|
||||||
|
1. `8080/api/assets/master` 상태 먼저 확인
|
||||||
|
|
||||||
|
### 13.4 `db-bootstrap`가 실패 종료함
|
||||||
|
|
||||||
|
의미 후보:
|
||||||
|
|
||||||
|
1. `.env` 외부 DB 접속 정보 오류
|
||||||
|
2. 외부 DB 네트워크 접근 불가
|
||||||
|
3. 외부 계정 권한 문제
|
||||||
|
|
||||||
|
대응:
|
||||||
|
|
||||||
|
1. `docker logs itam-db-bootstrap` 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 현재 최종 검증 완료 상태
|
||||||
|
|
||||||
|
이 저장소는 아래 상태까지 검증이 완료됐다.
|
||||||
|
|
||||||
|
1. WSL2 Ubuntu에서 Docker 실행 가능
|
||||||
|
2. `start_docker_wsl.ps1`로 전체 스택 기동 가능
|
||||||
|
3. `db` 컨테이너 healthcheck 통과
|
||||||
|
4. `db-bootstrap`가 외부 DB에서 내부 DB로 데이터 복제 후 `Exited (0)` 종료
|
||||||
|
5. `backend`가 내부 `db`를 사용해 API 응답 가능
|
||||||
|
6. `frontend`가 `backend`를 프록시해 8080 기준 화면/API 동작 가능
|
||||||
|
7. 내부 MySQL에 실데이터 적재 확인
|
||||||
|
|
||||||
|
즉, 현재 Git에 올라간 상태만으로도 WSL2와 외부 원본 DB 정보만 있으면 지금과 같은 수준의 Docker 실행 재현이 가능하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 현재 구조의 한계와 다음 단계
|
||||||
|
|
||||||
|
현재 구조는 충분히 시연 가능하고 개발 재현도 가능하지만, 다음은 아직 별도 작업이 필요하다.
|
||||||
|
|
||||||
|
1. 운영형 정적 배포 구조 전환
|
||||||
|
2. 외부 DB 없이도 완전 독립 실행 가능한 정식 dump/backup 체계
|
||||||
|
3. `.env.example` 정리
|
||||||
|
4. DB bootstrap 전용 계정/권한 최소화
|
||||||
|
5. 장기적으로 `map_config.json` 파일 저장 정책 정리
|
||||||
|
|
||||||
|
하지만 "현재 저장소만으로 지금과 같은 Docker 실행 상태 재현"이라는 목표는 이미 충족한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 빠른 실행 요약
|
||||||
|
|
||||||
|
가장 짧게 요약하면 다음 순서다.
|
||||||
|
|
||||||
|
1. `.env`에 외부 원본 MySQL 접속 정보를 넣는다.
|
||||||
|
2. WSL2 Ubuntu와 WSL 내부 Docker가 살아 있는지 확인한다.
|
||||||
|
3. `start_docker_wsl.ps1`를 실행한다.
|
||||||
|
4. `itam-db-bootstrap`가 `Exited (0)`인지 확인한다.
|
||||||
|
5. `http://localhost:3000/api/assets/master`와 `http://localhost:8080/api/assets/master`가 모두 200인지 확인한다.
|
||||||
|
6. 브라우저에서 `http://localhost:8080`을 열어 데이터가 보이는지 확인한다.
|
||||||
|
|
||||||
|
이 순서대로 진행하면 현재 저장소 기준 Dockerized ITAM 시연 환경을 재현할 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 2026-06-16 최신 정정
|
||||||
|
|
||||||
|
이 문서의 상단 본문은 한동안 사용했던 `내부 db + db-bootstrap` 구조를 기준으로 작성됐다. 하지만 오늘 기준 현재 저장소의 실제 `docker-compose.yaml`은 다시 `무상태 앱 컨테이너 + 외부 DB` 구조로 되돌아가 있다.
|
||||||
|
|
||||||
|
따라서 현재 시점의 정답 아키텍처는 아래다.
|
||||||
|
|
||||||
|
1. `backend` 컨테이너
|
||||||
|
2. `frontend` 컨테이너
|
||||||
|
3. 외부 MySQL DB
|
||||||
|
|
||||||
|
현재는 더 이상 아래 항목이 없다.
|
||||||
|
|
||||||
|
1. `db` 서비스 없음
|
||||||
|
2. `db-bootstrap` 서비스 없음
|
||||||
|
3. `itam_mysql_data` 볼륨 없음
|
||||||
|
|
||||||
|
### 17.1 현재 실제 `docker-compose.yaml` 기준 backend 동작
|
||||||
|
|
||||||
|
현재 backend는 `.env`의 외부 DB 접속 정보를 그대로 사용한다.
|
||||||
|
|
||||||
|
즉, 아래 환경변수 매핑이 현재 기준이다.
|
||||||
|
|
||||||
|
1. `DB_HOST: ${DB_HOST}`
|
||||||
|
2. `DB_PORT: ${DB_PORT}`
|
||||||
|
3. `DB_USER: ${DB_USER}`
|
||||||
|
4. `DB_PASS: ${DB_PASS}`
|
||||||
|
5. `DB_NAME: ${DB_NAME}`
|
||||||
|
|
||||||
|
`PORT: 3000`만 Compose에서 고정한다.
|
||||||
|
|
||||||
|
### 17.2 현재 실제 기동 구조
|
||||||
|
|
||||||
|
현재 스택 기동 순서는 단순하다.
|
||||||
|
|
||||||
|
1. `backend` 기동
|
||||||
|
2. `frontend` 기동
|
||||||
|
3. backend는 외부 DB에 직접 접속
|
||||||
|
4. frontend는 `http://backend:3000`으로 프록시
|
||||||
|
|
||||||
|
즉, 현재는 DB 컨테이너 초기화 단계나 bootstrap 단계가 존재하지 않는다.
|
||||||
|
|
||||||
|
### 17.3 현재 기준 첫 실행 체크리스트
|
||||||
|
|
||||||
|
오늘 기준으로는 아래 순서가 맞다.
|
||||||
|
|
||||||
|
1. `.env`에 외부 DB 접속 정보 입력
|
||||||
|
2. `start_docker_wsl.ps1` 또는 `start_docker_wsl.bat` 실행
|
||||||
|
3. `http://localhost:3000/api/assets/master`가 200인지 확인
|
||||||
|
4. `http://localhost:8080/api/assets/master`가 200인지 확인
|
||||||
|
5. 브라우저에서 `http://localhost:8080` 접속 후 데이터 표시 확인
|
||||||
|
|
||||||
|
### 17.4 이 문서에서 현재 유효한 부분과 과거 이력 부분
|
||||||
|
|
||||||
|
현재도 그대로 유효한 내용은 아래다.
|
||||||
|
|
||||||
|
1. WSL2 기반 실행 방식
|
||||||
|
2. `start_docker_wsl.ps1` / `stop_docker_wsl.ps1` 사용 방식
|
||||||
|
3. `server.js`에서 Compose 환경변수가 `.env`보다 우선되도록 `dotenv.config()`를 유지해야 한다는 점
|
||||||
|
4. `vite.config.ts`에서 프록시 타깃을 환경변수화해야 한다는 점
|
||||||
|
|
||||||
|
현재는 과거 이력으로만 읽어야 하는 내용은 아래다.
|
||||||
|
|
||||||
|
1. 내부 `db` 서비스 설명
|
||||||
|
2. `db-bootstrap` 설명
|
||||||
|
3. `itam_mysql_data` 볼륨 설명
|
||||||
|
4. 내부 DB 재초기화 절차
|
||||||
|
5. 내부 테이블 확인 절차
|
||||||
|
|
||||||
|
### 17.5 현재 최종 한 줄 요약
|
||||||
|
|
||||||
|
오늘 날짜 기준 현재 저장소의 실사용 Compose 구조는 `frontend + backend + external DB`이며, 이전의 내부 DB/bootstrap 구조는 역사적으로 한 번 사용했던 임시 해결책으로만 남아 있다.
|
||||||
48
docker-compose.yaml
Normal file
48
docker-compose.yaml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.backend
|
||||||
|
container_name: itam-backend
|
||||||
|
working_dir: /app
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DB_HOST: ${DB_HOST}
|
||||||
|
DB_PORT: ${DB_PORT}
|
||||||
|
DB_USER: ${DB_USER}
|
||||||
|
DB_PASS: ${DB_PASS}
|
||||||
|
DB_NAME: ${DB_NAME}
|
||||||
|
PORT: 3000
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./:/app
|
||||||
|
- backend_node_modules:/app/node_modules
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
- ./map_config.json:/app/map_config.json
|
||||||
|
command: npm run server
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
|
container_name: itam-frontend
|
||||||
|
working_dir: /app
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
CHOKIDAR_USEPOLLING: "true"
|
||||||
|
VITE_DEV_PROXY_TARGET: http://backend:3000
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./:/app
|
||||||
|
- frontend_node_modules:/app/node_modules
|
||||||
|
command: npm run dev -- --host 0.0.0.0
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backend_node_modules:
|
||||||
|
frontend_node_modules:
|
||||||
16
docker/mysql/init/README.md
Normal file
16
docker/mysql/init/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# MySQL init directory
|
||||||
|
|
||||||
|
This directory is kept as a legacy hook for file-based MySQL initialization.
|
||||||
|
|
||||||
|
Current production path in this repository is not file-based import.
|
||||||
|
The live Docker flow uses the `db-bootstrap` service in `docker-compose.yaml` to stream data from the external source DB into the internal `db` container.
|
||||||
|
|
||||||
|
Use this directory only if you intentionally switch back to `docker-entrypoint-initdb.d` style initialization.
|
||||||
|
|
||||||
|
If you do that, typical naming would be:
|
||||||
|
|
||||||
|
- `01_schema.sql`
|
||||||
|
- `02_seed.sql`
|
||||||
|
- or a single `01_itam_dump.sql`
|
||||||
|
|
||||||
|
Remember that files in this directory are executed automatically by the MySQL container only on the first initialization of the data volume.
|
||||||
330
docker_task_plan.md
Normal file
330
docker_task_plan.md
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
# ITAM 도커라이징 작업 태스크 정리
|
||||||
|
|
||||||
|
## 1. 문서 목적
|
||||||
|
|
||||||
|
이 문서는 ITAM 자산관리 시스템의 도커라이징 작업을 실제 실행 단위로 쪼개서 정리한 태스크 문서다.
|
||||||
|
|
||||||
|
이 문서의 목표는 아래와 같다.
|
||||||
|
|
||||||
|
1. 내일까지 보여줄 시연 범위를 기준으로 우선순위를 정한다.
|
||||||
|
2. 시연용 작업과 운영형 전환 작업을 분리한다.
|
||||||
|
3. 개발 담당자가 바로 실행할 수 있는 체크리스트를 제공한다.
|
||||||
|
|
||||||
|
관련 배경과 구조 분석은 [doc_readme.md](c:/Users/user/Desktop/안건%20파일/itam/doc_readme.md) 문서를 기준으로 한다.
|
||||||
|
|
||||||
|
현재 구현/검증 상태:
|
||||||
|
|
||||||
|
- `Dockerfile.frontend` 생성 완료
|
||||||
|
- `Dockerfile.backend` 생성 완료
|
||||||
|
- `docker-compose.yaml` 생성 완료
|
||||||
|
- `.dockerignore` 생성 완료
|
||||||
|
- WSL2 Ubuntu에서 `docker compose up --build -d` 검증 완료
|
||||||
|
- frontend 8080 응답 확인 완료
|
||||||
|
- backend `/api/assets/master` 응답 확인 완료
|
||||||
|
- 현재 DB는 external MySQL 기준이며, DB 컨테이너 추가 작업은 다음 단계로 남아 있음
|
||||||
|
|
||||||
|
## 2. 이번 작업의 최우선 목표
|
||||||
|
|
||||||
|
이번 도커라이징의 1차 목표는 "운영 배포 완료"가 아니라 아래 상태를 재현하는 것이다.
|
||||||
|
|
||||||
|
1. frontend 컨테이너가 정상 기동한다.
|
||||||
|
2. backend 컨테이너가 정상 기동한다.
|
||||||
|
3. backend가 기존 외부 MySQL 또는 MySQL 컨테이너에 정상 연결된다.
|
||||||
|
4. 브라우저에서 화면이 열린다.
|
||||||
|
5. 핵심 API 호출이 정상 동작한다.
|
||||||
|
6. 업로드 저장 경로가 유지된다.
|
||||||
|
7. 필요 시 DB까지 함께 포함된 재현 가능한 스택을 제공한다.
|
||||||
|
|
||||||
|
## 3. 작업 범위 구분
|
||||||
|
|
||||||
|
### 3.1 이번 시연 범위에 포함
|
||||||
|
|
||||||
|
- Dockerfile.frontend 초안 작성
|
||||||
|
- Dockerfile.backend 초안 작성
|
||||||
|
- docker-compose.yaml 작성
|
||||||
|
- `.dockerignore` 작성
|
||||||
|
- MySQL 컨테이너 추가 설계
|
||||||
|
- 초기 SQL dump 또는 init SQL 적재 방식 정의
|
||||||
|
- `uploads` 볼륨 처리
|
||||||
|
- `map_config.json` 영속성 처리 방식 반영
|
||||||
|
- 컨테이너 기동 및 접속 확인
|
||||||
|
- 핵심 API 및 화면 확인
|
||||||
|
|
||||||
|
### 3.2 이번 시연 범위에서 제외
|
||||||
|
|
||||||
|
- DB 전체 마이그레이션 자동화
|
||||||
|
- nginx 기반 운영 배포 구조
|
||||||
|
- 단일 이미지 운영 구조 전환
|
||||||
|
- CI/CD 연계
|
||||||
|
|
||||||
|
## 4. 선행 확인 태스크
|
||||||
|
|
||||||
|
아래 태스크는 실제 Docker 파일 작성 전에 먼저 확인해야 한다.
|
||||||
|
|
||||||
|
### Task 1. 외부 MySQL 접근 가능 여부 확인
|
||||||
|
|
||||||
|
- 목적: 컨테이너에서 외부 DB 접속이 가능한지 확인
|
||||||
|
- 확인 항목:
|
||||||
|
- DB_HOST 접근 가능 여부
|
||||||
|
- DB_PORT 3306 접속 가능 여부
|
||||||
|
- 계정 권한 정상 여부
|
||||||
|
- 완료 기준:
|
||||||
|
- backend 컨테이너 기준 DB 연결 에러가 발생하지 않음
|
||||||
|
|
||||||
|
### Task 2. 기준 스키마 상태 확인
|
||||||
|
|
||||||
|
- 목적: 현재 앱이 요구하는 테이블 구조가 실제 DB와 맞는지 확인
|
||||||
|
- 확인 항목:
|
||||||
|
- `asset_core`
|
||||||
|
- `asset_spec`
|
||||||
|
- `asset_location`
|
||||||
|
- `asset_remote`
|
||||||
|
- `asset_history`
|
||||||
|
- `hardware_components_master`
|
||||||
|
- `job_spec_standards`
|
||||||
|
- 완료 기준:
|
||||||
|
- `/api/assets/master` 호출 시 쿼리 에러가 발생하지 않음
|
||||||
|
|
||||||
|
### Task 3. 파일 영속성 대상 확인
|
||||||
|
|
||||||
|
- 목적: 컨테이너 재시작 이후에도 유지되어야 할 파일/폴더 식별
|
||||||
|
- 대상:
|
||||||
|
- `uploads`
|
||||||
|
- `map_config.json`
|
||||||
|
- 완료 기준:
|
||||||
|
- 볼륨 설계 대상이 명확하게 문서화됨
|
||||||
|
|
||||||
|
### Task 4. DB 기준 데이터 소스 확정
|
||||||
|
|
||||||
|
- 목적: MySQL 컨테이너 최초 기동 시 어떤 데이터로 초기화할지 결정
|
||||||
|
- 선택지:
|
||||||
|
- 기존 사내 DB에서 추출한 SQL dump 사용
|
||||||
|
- 정리된 스키마 SQL + seed SQL 사용
|
||||||
|
- 수동 import 절차 사용
|
||||||
|
- 완료 기준:
|
||||||
|
- `docker/mysql/init` 기준 적재 전략 또는 수동 복원 절차가 확정됨
|
||||||
|
|
||||||
|
## 5. 시연용 도커라이징 태스크
|
||||||
|
|
||||||
|
### Task 5. 프런트 Dockerfile 작성
|
||||||
|
|
||||||
|
- 목적: Vite 개발 서버를 컨테이너에서 구동
|
||||||
|
- 작업 내용:
|
||||||
|
- Node 20 계열 이미지 사용
|
||||||
|
- `package*.json` 복사 후 `npm install`
|
||||||
|
- 8080 포트 노출
|
||||||
|
- `npm run dev -- --host 0.0.0.0` 실행
|
||||||
|
- 산출물:
|
||||||
|
- `Dockerfile.frontend`
|
||||||
|
- 완료 기준:
|
||||||
|
- 컨테이너에서 8080 포트가 정상 listen 상태가 됨
|
||||||
|
|
||||||
|
### Task 6. 백엔드 Dockerfile 작성
|
||||||
|
|
||||||
|
- 목적: Express API 서버를 컨테이너에서 구동
|
||||||
|
- 작업 내용:
|
||||||
|
- Node 20 계열 이미지 사용
|
||||||
|
- `package*.json` 복사 후 `npm install`
|
||||||
|
- 3000 포트 노출
|
||||||
|
- `npm run server` 실행
|
||||||
|
- 산출물:
|
||||||
|
- `Dockerfile.backend`
|
||||||
|
- 완료 기준:
|
||||||
|
- 컨테이너에서 3000 포트가 정상 listen 상태가 됨
|
||||||
|
|
||||||
|
### Task 7. MySQL Docker 구성 추가
|
||||||
|
|
||||||
|
- 목적: DB까지 포함한 재현 가능한 스택 구성
|
||||||
|
- 작업 내용:
|
||||||
|
- `mysql:8.0` 서비스 정의
|
||||||
|
- `MYSQL_DATABASE`, `MYSQL_USER`, `MYSQL_PASSWORD` 설정
|
||||||
|
- utf8mb4 문자셋 옵션 반영
|
||||||
|
- MySQL 데이터 volume 연결
|
||||||
|
- 초기 SQL 적재용 `docker/mysql/init` 디렉터리 설계
|
||||||
|
- 산출물:
|
||||||
|
- `docker-compose.yaml` 내 `db` 서비스 또는 별도 DB compose 확장안
|
||||||
|
- 완료 기준:
|
||||||
|
- MySQL 컨테이너가 정상 기동하고 3306 포트에서 응답 가능
|
||||||
|
|
||||||
|
### Task 8. backend DB 연결 전환
|
||||||
|
|
||||||
|
- 목적: backend가 external MySQL 대신 DB 컨테이너를 바라보도록 변경
|
||||||
|
- 작업 내용:
|
||||||
|
- `DB_HOST`를 `db`로 전환
|
||||||
|
- 필요 시 `.env.docker` 또는 compose 내부 환경변수 사용
|
||||||
|
- backend `depends_on`에 db 추가
|
||||||
|
- 산출물:
|
||||||
|
- DB 컨테이너용 backend 환경 정의
|
||||||
|
- 완료 기준:
|
||||||
|
- backend 로그에서 DB 연결 성공 확인
|
||||||
|
|
||||||
|
### Task 9. docker-compose.yaml 확장
|
||||||
|
|
||||||
|
- 목적: frontend/backend를 함께 기동
|
||||||
|
- 작업 내용:
|
||||||
|
- frontend 서비스 정의
|
||||||
|
- backend 서비스 정의
|
||||||
|
- db 서비스 정의
|
||||||
|
- 포트 매핑 추가
|
||||||
|
- `.env` 또는 docker 전용 환경변수 연결
|
||||||
|
- MySQL 데이터 볼륨 연결
|
||||||
|
- `uploads` 볼륨 연결
|
||||||
|
- `map_config.json` 처리 방식 반영
|
||||||
|
- 산출물:
|
||||||
|
- `docker-compose.yaml`
|
||||||
|
- 완료 기준:
|
||||||
|
- `docker compose up --build` 한 번으로 세 서비스가 모두 올라옴
|
||||||
|
|
||||||
|
### Task 10. `.dockerignore` 작성
|
||||||
|
|
||||||
|
- 목적: 불필요한 빌드 컨텍스트 제외
|
||||||
|
- 제외 권장 항목:
|
||||||
|
- `node_modules`
|
||||||
|
- `dist`
|
||||||
|
- `build`
|
||||||
|
- `.git`
|
||||||
|
- `uploads`
|
||||||
|
- `*.xlsx`
|
||||||
|
- 산출물:
|
||||||
|
- `.dockerignore`
|
||||||
|
- 완료 기준:
|
||||||
|
- 이미지 빌드 컨텍스트가 과도하게 커지지 않음
|
||||||
|
|
||||||
|
## 6. 시연 검증 태스크
|
||||||
|
|
||||||
|
### Task 11. WSL 컨테이너 기동 검증
|
||||||
|
|
||||||
|
- 실행 명령:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\start_docker_wsl.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
- 확인 항목:
|
||||||
|
- frontend 로그 에러 여부
|
||||||
|
- backend 로그 에러 여부
|
||||||
|
- db 로그 에러 여부
|
||||||
|
- backend와 db 연결 성공 여부
|
||||||
|
- 완료 기준:
|
||||||
|
- 세 컨테이너 모두 종료 없이 유지됨
|
||||||
|
|
||||||
|
### Task 12. 웹 접속 검증
|
||||||
|
|
||||||
|
- 확인 항목:
|
||||||
|
- `http://localhost:8080` 접속 가능 여부
|
||||||
|
- 첫 화면 로딩 여부
|
||||||
|
- 콘솔 에러 여부
|
||||||
|
- 완료 기준:
|
||||||
|
- 브라우저에서 초기 화면이 정상 표시됨
|
||||||
|
|
||||||
|
### Task 13. API 검증
|
||||||
|
|
||||||
|
- 확인 항목:
|
||||||
|
- `http://localhost:3000/api/assets/master`
|
||||||
|
- 프런트에서 `/api/assets/master` 호출 정상 여부
|
||||||
|
- 완료 기준:
|
||||||
|
- 200 응답 또는 정상 데이터 응답 확인
|
||||||
|
|
||||||
|
### Task 14. DB 초기 데이터 검증
|
||||||
|
|
||||||
|
- 확인 항목:
|
||||||
|
- MySQL 컨테이너 내부에 목표 DB가 생성되었는지
|
||||||
|
- 기준 테이블이 존재하는지
|
||||||
|
- 샘플 데이터 또는 실데이터가 적재되었는지
|
||||||
|
- 완료 기준:
|
||||||
|
- backend가 기대하는 최소 테이블과 데이터가 실제로 조회됨
|
||||||
|
|
||||||
|
### Task 15. 업로드/파일 저장 검증
|
||||||
|
|
||||||
|
- 확인 항목:
|
||||||
|
- `/api/upload` 호출 정상 여부
|
||||||
|
- 업로드 파일이 `uploads`에 실제 저장되는지
|
||||||
|
- `map_config.json` 수정 내용이 유지되는지
|
||||||
|
- 완료 기준:
|
||||||
|
- 컨테이너 재시작 후에도 저장 데이터가 유지됨
|
||||||
|
|
||||||
|
## 7. 시연 후 후속 태스크
|
||||||
|
|
||||||
|
### Task 16. 운영형 프런트 배포 구조 전환
|
||||||
|
|
||||||
|
- 목표: Vite dev server 대신 정적 빌드 기반 구조로 전환
|
||||||
|
- 후보:
|
||||||
|
- nginx 정적 서빙
|
||||||
|
- Express 정적 서빙
|
||||||
|
|
||||||
|
### Task 17. DB 초기화/마이그레이션 전략 통합
|
||||||
|
|
||||||
|
- 목표: 기준 스키마와 실행 순서를 단일 정책으로 통일
|
||||||
|
- 필요 작업:
|
||||||
|
- 기준 스키마 선정
|
||||||
|
- 초기화 스크립트 확정
|
||||||
|
- 마이그레이션 순서 정의
|
||||||
|
|
||||||
|
### Task 18. `.env.example` 및 배포 환경 분리
|
||||||
|
|
||||||
|
- 목표: 민감정보를 저장소에서 분리하고 배포별 설정 체계화
|
||||||
|
|
||||||
|
### Task 19. 운영 볼륨 및 백업 전략 정리
|
||||||
|
|
||||||
|
- 목표: 업로드 파일과 설정 파일, MySQL 데이터의 장기 보존 정책 정리
|
||||||
|
|
||||||
|
### Task 20. DB 백업/복원 절차 문서화
|
||||||
|
|
||||||
|
- 목표: 컨테이너 DB를 기준으로 dump/restore 절차를 문서화
|
||||||
|
|
||||||
|
## 8. 우선순위 정리
|
||||||
|
|
||||||
|
### P0: 내일까지 반드시 필요한 작업
|
||||||
|
|
||||||
|
1. Task 1. 외부 MySQL 접근 가능 여부 확인
|
||||||
|
2. Task 2. 기준 스키마 상태 확인
|
||||||
|
3. Task 4. DB 기준 데이터 소스 확정
|
||||||
|
4. Task 7. MySQL Docker 구성 추가
|
||||||
|
5. Task 8. backend DB 연결 전환
|
||||||
|
6. Task 9. docker-compose.yaml 확장
|
||||||
|
7. Task 11. WSL 컨테이너 기동 검증
|
||||||
|
8. Task 12. 웹 접속 검증
|
||||||
|
9. Task 13. API 검증
|
||||||
|
10. Task 14. DB 초기 데이터 검증
|
||||||
|
|
||||||
|
### P1: 시연 안정화를 위해 권장되는 작업
|
||||||
|
|
||||||
|
1. Task 3. 파일 영속성 대상 확인
|
||||||
|
2. Task 10. `.dockerignore` 작성
|
||||||
|
3. Task 15. 업로드/파일 저장 검증
|
||||||
|
|
||||||
|
### P2: 시연 이후 진행할 작업
|
||||||
|
|
||||||
|
1. Task 16. 운영형 프런트 배포 구조 전환
|
||||||
|
2. Task 17. DB 초기화/마이그레이션 전략 통합
|
||||||
|
3. Task 18. `.env.example` 및 배포 환경 분리
|
||||||
|
4. Task 19. 운영 볼륨 및 백업 전략 정리
|
||||||
|
5. Task 20. DB 백업/복원 절차 문서화
|
||||||
|
|
||||||
|
## 9. 개발자용 최종 작업 순서 제안
|
||||||
|
|
||||||
|
개발 담당자에게는 아래 순서로 진행하라고 전달하면 된다.
|
||||||
|
|
||||||
|
1. 외부 DB 연결 가능 여부부터 확인
|
||||||
|
2. 현재 DB 스키마가 앱 요구사항과 맞는지 확인
|
||||||
|
3. DB 기준 dump 또는 init SQL 확보
|
||||||
|
4. MySQL 컨테이너 구성 추가
|
||||||
|
5. backend의 DB 연결 대상을 `db`로 전환
|
||||||
|
6. WSL에서 `docker compose config` 확인
|
||||||
|
7. WSL에서 컨테이너 기동 테스트
|
||||||
|
8. 웹 접속 및 API 확인
|
||||||
|
9. 업로드 및 파일 영속성 확인
|
||||||
|
10. 시연 완료 후 운영형 구조로 분리 작업 진행
|
||||||
|
|
||||||
|
## 10. 완료 판단 기준
|
||||||
|
|
||||||
|
이번 도커라이징 1차 작업은 아래 조건을 만족하면 완료로 본다.
|
||||||
|
|
||||||
|
1. `docker compose up --build`로 프런트, 백엔드, DB가 모두 기동한다.
|
||||||
|
2. 브라우저에서 8080 화면이 열린다.
|
||||||
|
3. `/api/assets/master`가 정상 응답한다.
|
||||||
|
4. backend가 DB 컨테이너와 정상 연결된다.
|
||||||
|
5. DB 초기 테이블과 데이터가 기대 상태로 적재된다.
|
||||||
|
6. `uploads`, `map_config.json`, MySQL 데이터가 재시작 후에도 유지된다.
|
||||||
|
|
||||||
|
이 문서는 실제 구현 작업의 체크리스트로 사용한다.
|
||||||
47
server.js
47
server.js
@@ -4,7 +4,22 @@ import cors from 'cors';
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
dotenv.config({ override: true });
|
dotenv.config();
|
||||||
|
|
||||||
|
const dbConfig = {
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASS,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
port: parseInt(process.env.DB_PORT || '3306')
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDbConnectionSummary = () => ({
|
||||||
|
host: dbConfig.host || '(missing)',
|
||||||
|
port: dbConfig.port,
|
||||||
|
user: dbConfig.user || '(missing)',
|
||||||
|
database: dbConfig.database || '(missing)'
|
||||||
|
});
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -18,11 +33,11 @@ if (!fs.existsSync('uploads')) {
|
|||||||
|
|
||||||
// MySQL Pool Configuration
|
// MySQL Pool Configuration
|
||||||
const pool = mysql.createPool({
|
const pool = mysql.createPool({
|
||||||
host: process.env.DB_HOST,
|
host: dbConfig.host,
|
||||||
user: process.env.DB_USER,
|
user: dbConfig.user,
|
||||||
password: process.env.DB_PASS,
|
password: dbConfig.password,
|
||||||
database: process.env.DB_NAME,
|
database: dbConfig.database,
|
||||||
port: parseInt(process.env.DB_PORT || '3306'),
|
port: dbConfig.port,
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 10,
|
connectionLimit: 10,
|
||||||
queueLimit: 0
|
queueLimit: 0
|
||||||
@@ -48,7 +63,15 @@ const pool = mysql.createPool({
|
|||||||
`);
|
`);
|
||||||
console.log('✅ job_spec_standards table verification completed.');
|
console.log('✅ job_spec_standards table verification completed.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('❌ Failed to verify/create job_spec_standards table:', err);
|
console.error('❌ Failed to verify/create job_spec_standards table:', {
|
||||||
|
db: getDbConnectionSummary(),
|
||||||
|
code: err.code,
|
||||||
|
errno: err.errno,
|
||||||
|
syscall: err.syscall,
|
||||||
|
address: err.address,
|
||||||
|
port: err.port,
|
||||||
|
message: err.message
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (connection) connection.release();
|
if (connection) connection.release();
|
||||||
}
|
}
|
||||||
@@ -56,7 +79,15 @@ const pool = mysql.createPool({
|
|||||||
|
|
||||||
// Error Handler
|
// Error Handler
|
||||||
const handleError = (res, err, label) => {
|
const handleError = (res, err, label) => {
|
||||||
console.error(`❌ [${label}] Error:`, err);
|
console.error(`❌ [${label}] Error:`, {
|
||||||
|
db: getDbConnectionSummary(),
|
||||||
|
code: err.code,
|
||||||
|
errno: err.errno,
|
||||||
|
syscall: err.syscall,
|
||||||
|
address: err.address,
|
||||||
|
port: err.port,
|
||||||
|
message: err.message
|
||||||
|
});
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,58 +6,10 @@ import { createIcons, Laptop, Cpu, Shield, Zap, Monitor, AlertTriangle, ChevronR
|
|||||||
|
|
||||||
declare var Chart: any;
|
declare var Chart: any;
|
||||||
|
|
||||||
|
let jobChartInstance: any = null;
|
||||||
let donutChartInstance: any = null;
|
let donutChartInstance: any = null;
|
||||||
|
|
||||||
export function renderHwDashboard(container: HTMLElement) {
|
export function renderHwDashboard(container: HTMLElement) {
|
||||||
// 전역 툴팁 헬퍼 함수 등록
|
|
||||||
(window as any).showSpecTooltip = function(event: MouseEvent, element: HTMLElement, type: string, count: number) {
|
|
||||||
const container = element.closest('.spec-bar-container');
|
|
||||||
if (!container) return;
|
|
||||||
const tooltip = container.querySelector('.spec-tooltip') as HTMLElement;
|
|
||||||
if (!tooltip) return;
|
|
||||||
const textSpan = tooltip.querySelector('.tooltip-text') as HTMLElement;
|
|
||||||
if (textSpan) {
|
|
||||||
let color = '';
|
|
||||||
let label = '';
|
|
||||||
if (type === 'under') {
|
|
||||||
color = '#EF4444';
|
|
||||||
label = '부족';
|
|
||||||
} else if (type === 'normal') {
|
|
||||||
color = '#10B981';
|
|
||||||
label = '적정';
|
|
||||||
} else if (type === 'over') {
|
|
||||||
color = '#F59E0B';
|
|
||||||
label = '오버';
|
|
||||||
} else if (type === 'win11') {
|
|
||||||
color = '#7928ca';
|
|
||||||
label = '윈도우 11 불가';
|
|
||||||
}
|
|
||||||
textSpan.innerHTML = `<span style="color: ${color}; font-weight: 800;">${label} ${count}대</span>`;
|
|
||||||
}
|
|
||||||
tooltip.style.left = event.clientX + 'px';
|
|
||||||
tooltip.style.top = event.clientY + 'px';
|
|
||||||
tooltip.style.opacity = '1';
|
|
||||||
};
|
|
||||||
|
|
||||||
(window as any).updateSpecTooltipPos = function(event: MouseEvent, element: HTMLElement) {
|
|
||||||
const container = element.closest('.spec-bar-container');
|
|
||||||
if (!container) return;
|
|
||||||
const tooltip = container.querySelector('.spec-tooltip') as HTMLElement;
|
|
||||||
if (tooltip) {
|
|
||||||
tooltip.style.left = event.clientX + 'px';
|
|
||||||
tooltip.style.top = event.clientY + 'px';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
(window as any).hideSpecTooltip = function(element: HTMLElement) {
|
|
||||||
const container = element.closest('.spec-bar-container');
|
|
||||||
if (!container) return;
|
|
||||||
const tooltip = container.querySelector('.spec-tooltip') as HTMLElement;
|
|
||||||
if (tooltip) {
|
|
||||||
tooltip.style.opacity = '0';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. 개인용 PC 데이터 추출 (유형이 '개인PC'이거나 상태가 '재고' 또는 '대기' 상태인 PC 집계)
|
// 1. 개인용 PC 데이터 추출 (유형이 '개인PC'이거나 상태가 '재고' 또는 '대기' 상태인 PC 집계)
|
||||||
const pcs = (state.masterData.pc || []).filter((a: any) =>
|
const pcs = (state.masterData.pc || []).filter((a: any) =>
|
||||||
a.asset_type === '개인PC' ||
|
a.asset_type === '개인PC' ||
|
||||||
@@ -66,11 +18,19 @@ export function renderHwDashboard(container: HTMLElement) {
|
|||||||
|
|
||||||
// 2. 1페이지 매거진 리포트(제목바 제거, '| 제목' 미니멀리즘 스타일) HTML 빌드
|
// 2. 1페이지 매거진 리포트(제목바 제거, '| 제목' 미니멀리즘 스타일) HTML 빌드
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="view-container" style="overflow: hidden; padding: 0; background-color: #ffffff; height: calc(100vh - var(--header-height) - 48px); box-sizing: border-box; display: flex; flex-direction: column; gap: 0; font-family: 'Pretendard', sans-serif; color: #1E293B;">
|
<div class="view-container" style="overflow: hidden; padding: 0.4rem 1.2rem; background-color: #F8FAFC; height: calc(100vh - var(--header-height) - 48px); box-sizing: border-box; display: flex; flex-direction: column; gap: 0.5rem; font-family: 'Pretendard', sans-serif; color: #1E293B;">
|
||||||
|
|
||||||
|
<!-- 대시보드 타이틀 및 사용조직 필터 -->
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-end; flex-shrink: 0; padding-bottom: 0.4rem;">
|
||||||
|
<div style="border-left: 4px solid #1E5149; padding-left: 8px;">
|
||||||
|
<h2 style="font-size: 1.65rem; font-weight: 850; color: #1E5149; margin: 0; letter-spacing: -0.5px; display: flex; align-items: center; gap: 0.6rem;">
|
||||||
|
개인 PC 자산 대시보드
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 사용조직 필터 (브랜드 그린 매칭 칩 디자인) -->
|
<!-- 사용조직 필터 (브랜드 그린 매칭 칩 디자인) -->
|
||||||
<div style="display: flex; justify-content: flex-end; align-items: center; flex-shrink: 0; padding: 0.6rem 1.2rem; background: #ffffff;">
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
<div style="display: flex; align-items: center;">
|
<span style="font-size: 0.9rem; font-weight: 700; color: #475569; white-space: nowrap;">조직 필터:</span>
|
||||||
<div id="dashboard-dept-buttons" style="display: flex; gap: 0.3rem; background: #EEF2F6; padding: 4px; border-radius: 8px; border: 1px solid #E2E8F0;">
|
<div id="dashboard-dept-buttons" style="display: flex; gap: 0.3rem; background: #EEF2F6; padding: 4px; border-radius: 8px; border: 1px solid #E2E8F0;">
|
||||||
<button class="dept-filter-btn active" data-dept="" style="padding: 6px 14px; font-size: 0.88rem; font-weight: 700; border-radius: 6px; border: none; background: #1E5149; color: white; cursor: pointer; transition: all 0.2s;">전체</button>
|
<button class="dept-filter-btn active" data-dept="" style="padding: 6px 14px; font-size: 0.88rem; font-weight: 700; border-radius: 6px; border: none; background: #1E5149; color: white; cursor: pointer; transition: all 0.2s;">전체</button>
|
||||||
<button class="dept-filter-btn" data-dept="한맥" style="padding: 6px 14px; font-size: 0.88rem; font-weight: 700; border-radius: 6px; border: none; background: transparent; color: #475569; cursor: pointer; transition: all 0.2s;">한맥</button>
|
<button class="dept-filter-btn" data-dept="한맥" style="padding: 6px 14px; font-size: 0.88rem; font-weight: 700; border-radius: 6px; border: none; background: transparent; color: #475569; cursor: pointer; transition: all 0.2s;">한맥</button>
|
||||||
@@ -83,140 +43,89 @@ export function renderHwDashboard(container: HTMLElement) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 상단 섹션 (전체 높이의 약 35% 차지, stat-card와 donut/aging 나열) -->
|
<!-- 메인 2단 컬럼 레이아웃 (5:5 비율) -->
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0; height: 33%; min-height: 0; flex-shrink: 0; padding: 0.5rem 0;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; flex: 1; min-height: 0; margin-bottom: 0.1rem;">
|
||||||
|
|
||||||
<!-- 상단 좌측: 핵심 지표 4개 격자 그리드 -->
|
<!-- 좌측 컬럼 (Left Column) -->
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0; height: 100%;">
|
<div style="display: flex; flex-direction: column; gap: 0.5rem; min-height: 0;">
|
||||||
|
|
||||||
|
<!-- 핵심 지표 카드 -->
|
||||||
|
<div class="stat-card" style="background: transparent; border-radius: 0; padding: 0.75rem 0.25rem; border: none; border-bottom: 1px solid #E2E8F0; display: grid !important; grid-template-columns: 1fr 1fr; gap: 0.6rem 0.9rem; flex-shrink: 0;">
|
||||||
|
|
||||||
<!-- 1. 보유 자산 수량 -->
|
<!-- 1. 보유 자산 수량 -->
|
||||||
<div id="metric-card-total" style="background: #ffffff; padding: 1.5rem; display: flex; flex-direction: row; justify-content: space-between; align-items: flex-start; position: relative; overflow: hidden; transition: background-color 0.15s ease;"
|
<div style="border-right: 1px solid #EEF2F6; border-bottom: 1px solid #EEF2F6; padding-bottom: 0.65rem; padding-right: 1.0rem;">
|
||||||
onmouseover="this.style.backgroundColor='#F8FAFC';"
|
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
|
||||||
onmouseout="this.style.backgroundColor='#ffffff';">
|
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">보유 자산 수량</span>
|
||||||
<div style="display: flex; align-items: center; z-index: 1; border-left: 4px solid #1E5149; padding-left: 8px; height: 1.4rem;">
|
</div>
|
||||||
<span style="font-size: 1.1rem; font-weight: 850; color: #1E293B; letter-spacing: -0.3px;">보유 자산 수량</span>
|
<div style="display: flex; align-items: flex-end; justify-content: space-between;">
|
||||||
|
<div>
|
||||||
|
<div id="metric-total-pcs" style="font-size: 2.3rem; font-weight: 900; color: #1E5149; line-height: 1; margin-bottom: 0.35rem;">0대</div>
|
||||||
|
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">전사 보유 개인용 PC</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="metric-total-pcs" style="font-size: 2.1rem; font-weight: 900; color: #1E5149; line-height: 1.1; z-index: 1; margin-right: 2rem; margin-top: 1.8rem;">0대</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 2. 사양 부족 -->
|
<!-- 2. 사양 부족 -->
|
||||||
<div id="card-under-spec" style="background: #ffffff; padding: 1.5rem; display: flex; flex-direction: row; justify-content: space-between; align-items: flex-start; position: relative; overflow: hidden; cursor: pointer; transition: background-color 0.15s ease;">
|
<div id="card-under-spec" style="border-bottom: 1px solid #EEF2F6; padding-bottom: 0.65rem; padding-left: 1.0rem; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
|
||||||
<div style="display: flex; align-items: center; z-index: 1; border-left: 4px solid #EF4444; padding-left: 8px; height: 1.4rem;">
|
<div style="border-left: 4px solid #EF4444; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
|
||||||
<span style="font-size: 1.1rem; font-weight: 850; color: #1E293B; letter-spacing: -0.3px;">사양 부족</span>
|
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">사양 부족</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: flex-end; justify-content: space-between;">
|
||||||
|
<div>
|
||||||
|
<div id="metric-under-spec" style="font-size: 2.3rem; font-weight: 900; color: #EF4444; line-height: 1; margin-bottom: 0.35rem;">0대</div>
|
||||||
|
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">사양 교체 권고 자산</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="metric-under-spec" style="font-size: 2.1rem; font-weight: 900; color: #EF4444; line-height: 1.1; z-index: 1; margin-right: 2rem; margin-top: 1.8rem;">0대</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 3. 오버 스펙 -->
|
<!-- 3. 오버 스펙 -->
|
||||||
<div id="card-over-spec" style="background: #ffffff; padding: 1.5rem; display: flex; flex-direction: row; justify-content: space-between; align-items: flex-start; position: relative; overflow: hidden; cursor: pointer; transition: background-color 0.15s ease;">
|
<div id="card-over-spec" style="border-right: 1px solid #EEF2F6; padding-top: 0.65rem; padding-right: 1.0rem; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
|
||||||
<div style="display: flex; align-items: center; z-index: 1; border-left: 4px solid #F59E0B; padding-left: 8px; height: 1.4rem;">
|
<div style="border-left: 4px solid #F59E0B; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
|
||||||
<span style="font-size: 1.1rem; font-weight: 850; color: #1E293B; letter-spacing: -0.3px;">오버 스펙</span>
|
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">오버 스펙</span>
|
||||||
</div>
|
|
||||||
<div id="metric-over-spec" style="font-size: 2.1rem; font-weight: 900; color: #F59E0B; line-height: 1.1; z-index: 1; margin-right: 2rem; margin-top: 1.8rem;">0대</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 4. 윈도우 11 불가 PC -->
|
|
||||||
<div id="card-win11-incompatible" style="background: #ffffff; padding: 1.5rem; display: flex; flex-direction: row; justify-content: space-between; align-items: flex-start; position: relative; overflow: hidden; cursor: pointer; transition: background-color 0.15s ease;">
|
|
||||||
<div style="display: flex; align-items: center; z-index: 1; border-left: 4px solid #7928ca; padding-left: 8px; height: 1.4rem;">
|
|
||||||
<span style="font-size: 1.1rem; font-weight: 850; color: #1E293B; letter-spacing: -0.3px;">윈도우 11 불가</span>
|
|
||||||
</div>
|
|
||||||
<div id="metric-win11-incompatible" style="font-size: 2.1rem; font-weight: 900; color: #7928ca; line-height: 1.1; z-index: 1; margin-right: 2rem; margin-top: 1.8rem;">0대</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 상단 우측: 등급별 보유 비율 도넛 & 연도별 PC 노후도 통합 배치 (두 개의 개별 레이아웃으로 배치) -->
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0; min-height: 0; height: 100%;">
|
|
||||||
|
|
||||||
<!-- 1열: 조직별 사용 비율 도넛 영역 -->
|
|
||||||
<div style="background: #ffffff; padding: 1.5rem; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; gap: 0.3rem; min-height: 0; height: 100%;">
|
|
||||||
<!-- 서브 제목 -->
|
|
||||||
<div style="width: 100%; border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.15rem; display: flex; align-items: center; line-height: 1; flex-shrink: 0; height: 1.4rem;">
|
|
||||||
<span style="font-size: 1.1rem; font-weight: 850; color: #1E293B;">조직별 사용 비율</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 도넛 그래프 -->
|
|
||||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; width: 100%; min-height: 0;">
|
|
||||||
<div style="width: 170px; height: 170px; position: relative;">
|
|
||||||
<canvas id="chart-overall-donut"></canvas>
|
|
||||||
</div>
|
|
||||||
<!-- 커스텀 범례 -->
|
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 0.15rem 0.35rem; justify-content: center; align-items: center; margin-top: 6px; font-size: 0.8rem; font-weight: 800; color: #64748B; width: 100%;">
|
|
||||||
<div style="display: flex; align-items: center; gap: 3px;">
|
|
||||||
<span style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #D02121;"></span>
|
|
||||||
<span>한맥</span>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; align-items: center; gap: 3px;">
|
|
||||||
<span style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #F58120;"></span>
|
|
||||||
<span>삼안</span>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; align-items: center; gap: 3px;">
|
|
||||||
<span style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #3889C7;"></span>
|
|
||||||
<span>장헌</span>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; align-items: center; gap: 3px;">
|
|
||||||
<span style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #79B2D9;"></span>
|
|
||||||
<span>한라</span>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; align-items: center; gap: 3px;">
|
|
||||||
<span style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #10B981;"></span>
|
|
||||||
<span>기술개발센터</span>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; align-items: center; gap: 3px;">
|
|
||||||
<span style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #133D84;"></span>
|
|
||||||
<span>총괄기획실</span>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; align-items: center; gap: 3px;">
|
|
||||||
<span style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #94A3B8;"></span>
|
|
||||||
<span>기타</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display: flex; align-items: flex-end; justify-content: space-between;">
|
||||||
|
<div>
|
||||||
|
<div id="metric-over-spec" style="font-size: 2.3rem; font-weight: 900; color: #F59E0B; line-height: 1; margin-bottom: 0.35rem;">0대</div>
|
||||||
|
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">사양 회수 권고 자산</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 2열: PC 노후도 영역 (표 잘림 방지를 위해 아래 패딩을 줄이고 overflow auto 설정) -->
|
<!-- 4. 윈도우 11 불가 PC -->
|
||||||
<div style="background: #ffffff; padding: 1.5rem 1.5rem 0.5rem 1.5rem; display: flex; flex-direction: column; min-height: 0; height: 100%;">
|
<div id="card-win11-incompatible" style="padding-top: 0.65rem; padding-left: 1.0rem; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
|
||||||
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.35rem; display: flex; align-items: center; line-height: 1; flex-shrink: 0; height: 1.4rem;">
|
<div style="border-left: 4px solid #3B82F6; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
|
||||||
<span style="font-size: 1.1rem; font-weight: 850; color: #1E293B; white-space: nowrap;">PC 노후도</span>
|
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">윈도우 11 불가 PC</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: flex-end; justify-content: space-between;">
|
||||||
|
<div>
|
||||||
|
<div id="metric-win11-incompatible" style="font-size: 2.3rem; font-weight: 900; color: #3B82F6; line-height: 1; margin-bottom: 0.35rem;">0대</div>
|
||||||
|
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">업데이트 미지원 하드웨어</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex: 1; overflow-y: auto; min-height: 0; padding-right: 0.1rem;">
|
|
||||||
<table style="width: 100%; border-collapse: collapse; text-align: left; font-size: 1.05rem;">
|
|
||||||
<thead style="position: sticky; top: 0; background: white; z-index: 5;">
|
|
||||||
<tr style="border-bottom: 2px solid #1E5149; color: #475569; font-weight: 850;">
|
|
||||||
<th style="padding: 6px 8px; width: 70%; font-size: 1.02rem; background: white;">구분 (연한)</th>
|
|
||||||
<th style="padding: 6px 8px; text-align: center; width: 30%; font-size: 1.02rem; background: white;">보유</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="pc-aging-tbody">
|
|
||||||
<!-- Dynamic Aging Contents -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 하단 섹션 (등급별 자산 종합 현황 및 사양 적정성 분석 영역 - 높이 비율 65%로 확대) -->
|
<!-- 등급별 자산 종합 현황 (좌측 하단 단독 배치 및 크기 확대) -->
|
||||||
<div style="background: #ffffff; padding: 1.5rem 0; display: flex; flex-direction: column; height: 65%; min-height: 0;">
|
<div style="background: transparent; border-radius: 0; padding: 0.75rem 0.25rem; border: none; border-bottom: 1px solid #E2E8F0; display: flex; flex-direction: column; flex: 1.0; min-height: 0;">
|
||||||
<div style="display: flex; flex-direction: column; gap: 0.6rem; justify-content: flex-start; height: 100%;">
|
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.9rem; justify-content: flex-start; padding-left: 0.5rem; height: 100%;">
|
||||||
<!-- 메인 제목 -->
|
<!-- 메인 제목 -->
|
||||||
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.1rem; display: flex; align-items: center; line-height: 1; height: 1.6rem; flex-shrink: 0;">
|
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.4rem; display: flex; align-items: center; line-height: 1; height: 1.7rem; flex-shrink: 0;">
|
||||||
<span style="font-size: 1.25rem; font-weight: 850; color: #1E293B;">등급별 자산 종합 현황</span>
|
<span style="font-size: 1.25rem; font-weight: 850; color: #1E293B;">등급별 자산 종합 현황</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 종합 매트릭스 테이블 -->
|
<!-- 종합 매트릭스 테이블 (폰트 크기 1.25rem 으로 확대 및 꽉 채우기) -->
|
||||||
<div style="width: 100%; overflow-y: hidden; flex: 1; border-radius: 0;">
|
<div style="width: 100%; overflow-x: auto; flex: 1; display: flex; align-items: stretch;">
|
||||||
<table style="width: 100%; border-collapse: collapse; text-align: left; font-size: 1.05rem;">
|
<table style="width: 100%; border-collapse: collapse; text-align: left; font-size: 1.25rem; height: 100%;">
|
||||||
<thead style="position: sticky; top: 0; background: #F8FAFC; z-index: 10;">
|
<thead>
|
||||||
<tr style="border-bottom: 2px solid #E2E8F0; color: #475569; font-weight: 850;">
|
<tr style="border-bottom: 2px solid #1E5149; color: #475569; font-weight: 850;">
|
||||||
<th style="padding: 16px 10px; width: 18%; font-size: 1.05rem; background: #F8FAFC; border-bottom: 2px solid #1E5149;">구분 (등급)</th>
|
<th style="padding: 14px 10px; width: 32%; font-size: 1.25rem;">구분 (등급)</th>
|
||||||
<th style="padding: 16px 10px; text-align: center; width: 8%; font-size: 1.05rem; background: #F8FAFC; border-bottom: 2px solid #1E5149;">보유량</th>
|
<th style="padding: 14px 10px; text-align: center; width: 17%; font-size: 1.25rem;">보유량</th>
|
||||||
<th style="padding: 16px 10px; text-align: center; width: 8%; font-size: 1.05rem; background: #F8FAFC; border-bottom: 2px solid #1E5149;">운영중</th>
|
<th style="padding: 14px 10px; text-align: center; width: 17%; font-size: 1.25rem;">운영중</th>
|
||||||
<th style="padding: 16px 10px; text-align: center; width: 8%; font-size: 1.05rem; background: #F8FAFC; border-bottom: 2px solid #1E5149;">재고</th>
|
<th style="padding: 14px 10px; text-align: center; width: 17%; font-size: 1.25rem;">재고</th>
|
||||||
<th style="padding: 16px 10px; text-align: center; width: 8%; color: #EF4444; font-size: 1.05rem; background: #F8FAFC; border-bottom: 2px solid #1E5149;">부족분</th>
|
<th style="padding: 14px 10px; text-align: center; width: 17%; color: #EF4444; font-size: 1.25rem;">구매 필요</th>
|
||||||
<th style="padding: 16px 10px; text-align: center; width: 50%; font-size: 1.05rem; background: #F8FAFC; border-bottom: 2px solid #1E5149;">사양 적정성</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="pc-grade-matrix-tbody">
|
<tbody id="pc-grade-matrix-tbody">
|
||||||
@@ -225,9 +134,91 @@ export function renderHwDashboard(container: HTMLElement) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 우측 컬럼 (Right Column) -->
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.5rem; min-height: 0;">
|
||||||
|
|
||||||
|
<!-- 직무별 사양 적정성 분석 차트 카드 -->
|
||||||
|
<div style="background: transparent; border-radius: 0; padding: 0.7rem 0.25rem; border: none; border-bottom: 1px solid #E2E8F0; display: flex; flex-direction: column; flex: 1.0; min-height: 0;">
|
||||||
|
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1; flex-shrink: 0;">
|
||||||
|
<span style="font-size: 1.25rem; font-weight: 850; color: #1E293B;">직무별 사양 적정성 분석</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; min-height: 0; width: 100%; position: relative;">
|
||||||
|
<canvas id="chart-job-scores" style="width: 100%; height: 100%;"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 우측 하단: 등급별 보유 비율 도넛 & 연도별 PC 노후도 통합 배치 (너비 축소) -->
|
||||||
|
<div style="background: transparent; border-radius: 0; padding: 0.7rem 0.25rem; border: none; border-bottom: 1px solid #E2E8F0; display: grid; grid-template-columns: 1.15fr 1.25fr; gap: 0.8rem; flex: 1.0; min-height: 0;">
|
||||||
|
|
||||||
|
<!-- 1열: 등급별 보유 비율 도넛 영역 -->
|
||||||
|
<div style="display: flex; flex-direction: column; align-items: center; justify-content: flex-start; gap: 0.7rem; padding-top: 0.1rem; min-height: 0; height: 100%;">
|
||||||
|
<!-- 서브 제목 -->
|
||||||
|
<div style="width: 100%; border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1; flex-shrink: 0; height: 1.5rem;">
|
||||||
|
<span style="font-size: 1.25rem; font-weight: 850; color: #1E293B;">등급별 보유 비율</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 도넛 그래프 (크기 조절 및 수직 가운데 정렬) -->
|
||||||
|
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; width: 100%; min-height: 0;">
|
||||||
|
<div style="width: 180px; height: 180px; position: relative;">
|
||||||
|
<canvas id="chart-overall-donut"></canvas>
|
||||||
|
</div>
|
||||||
|
<!-- 커스텀 범례 (폰트 최적화) -->
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 0.4rem 0.6rem; justify-content: center; align-items: center; margin-top: 10px; font-size: 1.05rem; font-weight: 700; color: #475569; width: 100%;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px;">
|
||||||
|
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #11302B;"></span>
|
||||||
|
<span>최상급</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px;">
|
||||||
|
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #1E8E7C;"></span>
|
||||||
|
<span>상급</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px;">
|
||||||
|
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #10B981;"></span>
|
||||||
|
<span>중급</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px;">
|
||||||
|
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #F59E0B;"></span>
|
||||||
|
<span>보급</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 4px;">
|
||||||
|
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #EF4444;"></span>
|
||||||
|
<span>교체 대상</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2열: 연도별 PC 노후도 및 교체 주기 예측 카드 (너비 줄임) -->
|
||||||
|
<div style="display: flex; flex-direction: column; min-height: 0;">
|
||||||
|
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1; flex-shrink: 0; height: 1.5rem;">
|
||||||
|
<span style="font-size: 1.25rem; font-weight: 850; color: #1E293B; white-space: nowrap;">연도별 PC 노후도 및 예측</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; overflow: hidden; min-height: 0; padding-right: 0.2rem;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse; text-align: left; font-size: 1.15rem;">
|
||||||
|
<thead style="position: sticky; top: 0; background: white; z-index: 5;">
|
||||||
|
<tr style="border-bottom: 2px solid #1E5149; color: #475569; font-weight: 850;">
|
||||||
|
<th style="padding: 12px 10px; width: 45%; font-size: 1.15rem;">구분 (연한)</th>
|
||||||
|
<th style="padding: 12px 10px; text-align: center; width: 25%; font-size: 1.15rem;">보유</th>
|
||||||
|
<th style="padding: 12px 10px; text-align: center; width: 30%; font-size: 1.15rem;">권장 조치</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="pc-aging-tbody">
|
||||||
|
<!-- Dynamic Aging Contents -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 3. Lucide 아이콘 초기화
|
// 3. Lucide 아이콘 초기화
|
||||||
@@ -249,16 +240,7 @@ export function renderHwDashboard(container: HTMLElement) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
const dept = btn.getAttribute('data-dept') || '';
|
btn.style.background = '#1E5149';
|
||||||
let bgColor = '#1E5149';
|
|
||||||
if (dept === '한맥') bgColor = '#D02121';
|
|
||||||
else if (dept === '삼안') bgColor = '#F58120';
|
|
||||||
else if (dept === '장헌') bgColor = '#3889C7';
|
|
||||||
else if (dept === '한라') bgColor = '#79B2D9';
|
|
||||||
else if (dept === '기술개발센터') bgColor = '#10B981';
|
|
||||||
else if (dept === '총괄기획실') bgColor = '#133D84';
|
|
||||||
|
|
||||||
btn.style.background = bgColor;
|
|
||||||
btn.style.color = 'white';
|
btn.style.color = 'white';
|
||||||
|
|
||||||
const selectedDept = btn.getAttribute('data-dept') || '';
|
const selectedDept = btn.getAttribute('data-dept') || '';
|
||||||
@@ -284,31 +266,13 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 3. DB 기준 사양 데이터 맵핑 (state.masterData.jobSpecs 이용)
|
// 3. DB 기준 사양 데이터 맵핑 (state.masterData.jobSpecs 이용)
|
||||||
const jobSpecsMap: Record<string, string> = {};
|
const jobSpecsMap: Record<string, number> = {};
|
||||||
if (state.masterData.jobSpecs) {
|
if (state.masterData.jobSpecs) {
|
||||||
state.masterData.jobSpecs.forEach((s: any) => {
|
state.masterData.jobSpecs.forEach((s: any) => {
|
||||||
jobSpecsMap[s.job_name] = s.required_grade || '중급';
|
jobSpecsMap[s.job_name] = s.min_score;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사용자 이름 → 세부 직무 맵 생성 (system_users.position 기준, 더 정확한 직무 구분)
|
|
||||||
const userPositionMap: Record<string, string> = {};
|
|
||||||
if (state.masterData.users) {
|
|
||||||
state.masterData.users.forEach((u: any) => {
|
|
||||||
if (u.user_name && u.position) {
|
|
||||||
userPositionMap[u.user_name.trim()] = u.position.trim();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const GRADE_RANK: Record<string, number> = {
|
|
||||||
'premium': 4, '최상급': 4,
|
|
||||||
'high': 3, '상급': 3,
|
|
||||||
'normal': 2, '중급': 2,
|
|
||||||
'entry': 1, '보급': 1,
|
|
||||||
'replace': 0, '교체 대상': 0
|
|
||||||
};
|
|
||||||
|
|
||||||
const jobScores: Record<string, { totalScore: number; count: number; avg: number }> = {};
|
const jobScores: Record<string, { totalScore: number; count: number; avg: number }> = {};
|
||||||
pcs.forEach((p: any) => {
|
pcs.forEach((p: any) => {
|
||||||
const score = calculatePcScoreDeductive(p.cpu, p.ram, p.gpu, p.purchase_date);
|
const score = calculatePcScoreDeductive(p.cpu, p.ram, p.gpu, p.purchase_date);
|
||||||
@@ -356,7 +320,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
|
|||||||
currentGradeKey = 'high';
|
currentGradeKey = 'high';
|
||||||
} else if (score >= 40) {
|
} else if (score >= 40) {
|
||||||
currentGradeKey = 'normal';
|
currentGradeKey = 'normal';
|
||||||
} else if (score >= 20) {
|
} else if (score >= 20 && !win11Incompatible) {
|
||||||
currentGradeKey = 'entry';
|
currentGradeKey = 'entry';
|
||||||
} else {
|
} else {
|
||||||
currentGradeKey = 'replace';
|
currentGradeKey = 'replace';
|
||||||
@@ -373,32 +337,23 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
|
|||||||
currentTarget.active++;
|
currentTarget.active++;
|
||||||
currentTarget.activePcs.push(p);
|
currentTarget.activePcs.push(p);
|
||||||
|
|
||||||
// 직무 적정성 계산: system_users.position 우선 조회 → asset_core.user_position fallback
|
// 직무 적정성 계산 (재직 중이고 실 사용자 매핑 자산만 검토 대상)
|
||||||
const userName = (p[ASSET_SCHEMA.CURRENT_USER.key] || '').trim();
|
const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
|
||||||
const job = userPositionMap[userName] || p[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
|
const standardScore = jobSpecsMap[job] !== undefined ? jobSpecsMap[job] : (jobScores[job]?.avg || 0);
|
||||||
const requiredGrade = jobSpecsMap[job] || jobSpecsMap[p[ASSET_SCHEMA.USER_POSITION.key]] || '중급'; // 세부 직무 우선, 없으면 일반 직무, 없으면 기본 중급
|
|
||||||
|
|
||||||
// 미니 모달 표시용으로 해석된 세부 직무명 저장
|
|
||||||
p._resolved_position = job;
|
|
||||||
|
|
||||||
const actualGrade = currentGradeKey; // premium, high, normal, entry, replace 중 하나
|
|
||||||
|
|
||||||
const reqRank = GRADE_RANK[requiredGrade] !== undefined ? GRADE_RANK[requiredGrade] : 2; // '중급' rank
|
|
||||||
const actRank = GRADE_RANK[actualGrade] !== undefined ? GRADE_RANK[actualGrade] : 0;
|
|
||||||
|
|
||||||
let isUnder = false;
|
let isUnder = false;
|
||||||
|
|
||||||
if (job !== '재고PC') {
|
if (standardScore > 0 && job !== '재고PC') {
|
||||||
if (win11Incompatible) {
|
if (score < standardScore * 0.6) {
|
||||||
isUnder = true;
|
isUnder = true;
|
||||||
p._spec_status = '사양 부족';
|
p._spec_status = '사양 부족';
|
||||||
} else if (actRank < reqRank) {
|
} else if (score > standardScore * 1.5 && !win11Incompatible) {
|
||||||
isUnder = true;
|
|
||||||
p._spec_status = '사양 부족';
|
|
||||||
} else if (actRank > reqRank) {
|
|
||||||
p._spec_status = '오버스펙';
|
p._spec_status = '오버스펙';
|
||||||
criticalList.push(p);
|
criticalList.push(p);
|
||||||
overSpecCount++;
|
overSpecCount++;
|
||||||
|
} else if (win11Incompatible) {
|
||||||
|
isUnder = true;
|
||||||
|
p._spec_status = '사양 부족';
|
||||||
} else {
|
} else {
|
||||||
p._spec_status = '적정';
|
p._spec_status = '적정';
|
||||||
}
|
}
|
||||||
@@ -416,11 +371,16 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
|
|||||||
underSpecCount++;
|
underSpecCount++;
|
||||||
|
|
||||||
// 2. 사양 부족 시 교체받아야 할 직무별 권장 목표 등급 판정
|
// 2. 사양 부족 시 교체받아야 할 직무별 권장 목표 등급 판정
|
||||||
let targetGradeKey: keyof typeof matrix = 'normal';
|
let targetGradeKey: keyof typeof matrix;
|
||||||
if (requiredGrade === '최상급') targetGradeKey = 'premium';
|
if (standardScore >= 85) {
|
||||||
else if (requiredGrade === '상급') targetGradeKey = 'high';
|
targetGradeKey = 'premium';
|
||||||
else if (requiredGrade === '중급') targetGradeKey = 'normal';
|
} else if (standardScore >= 70) {
|
||||||
else if (requiredGrade === '보급') targetGradeKey = 'entry';
|
targetGradeKey = 'high';
|
||||||
|
} else if (standardScore >= 40) {
|
||||||
|
targetGradeKey = 'normal';
|
||||||
|
} else {
|
||||||
|
targetGradeKey = 'entry'; // 교체 대상은 최소 보급형 사양으로 교체
|
||||||
|
}
|
||||||
|
|
||||||
const targetGrade = matrix[targetGradeKey];
|
const targetGrade = matrix[targetGradeKey];
|
||||||
targetGrade.under++;
|
targetGrade.under++;
|
||||||
@@ -443,79 +403,20 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
|
|||||||
// 6. 종합 매트릭스 테이블 렌더링 및 바인딩
|
// 6. 종합 매트릭스 테이블 렌더링 및 바인딩
|
||||||
const matrixTbody = document.getElementById('pc-grade-matrix-tbody')!;
|
const matrixTbody = document.getElementById('pc-grade-matrix-tbody')!;
|
||||||
|
|
||||||
const getSpecStatusCounts = (activePcsList: any[]) => {
|
|
||||||
let win11 = 0;
|
|
||||||
let under = 0;
|
|
||||||
let normal = 0;
|
|
||||||
let over = 0;
|
|
||||||
activePcsList.forEach(p => {
|
|
||||||
if (isWindows11Incompatible(p.cpu, p.ram)) win11++;
|
|
||||||
else if (p._spec_status === '사양 부족') under++;
|
|
||||||
else if (p._spec_status === '오버스펙') over++;
|
|
||||||
else normal++;
|
|
||||||
});
|
|
||||||
return { win11, under, normal, over };
|
|
||||||
};
|
|
||||||
|
|
||||||
const maxTotal = Math.max(
|
|
||||||
matrix.premium.total,
|
|
||||||
matrix.high.total,
|
|
||||||
matrix.normal.total,
|
|
||||||
matrix.entry.total,
|
|
||||||
matrix.replace.total
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderMatrixRow = (gradeKey: keyof typeof matrix, label: string, color: string, shortage: number) => {
|
const renderMatrixRow = (gradeKey: keyof typeof matrix, label: string, color: string, shortage: number) => {
|
||||||
const data = matrix[gradeKey];
|
const data = matrix[gradeKey];
|
||||||
const totalRate = filtered.length > 0 ? Math.round((data.total / filtered.length) * 100) : 0;
|
const totalRate = filtered.length > 0 ? Math.round((data.total / filtered.length) * 100) : 0;
|
||||||
|
|
||||||
const cellStyle = `padding: 22px 8px; text-align: center; font-weight: 700; cursor: pointer; transition: background 0.2s; font-size: 1.05rem;`;
|
const cellStyle = `padding: 14px 12px; text-align: center; font-weight: 700; cursor: pointer; transition: background 0.2s; font-size: 1.25rem;`;
|
||||||
const hoverEvents = `onmouseover="this.style.background='#F1F5F9'" onmouseout="this.style.background='none'"`;
|
const hoverEvents = `onmouseover="this.style.background='#F1F5F9'" onmouseout="this.style.background='none'"`;
|
||||||
|
|
||||||
// 사양 적정성 분석 데이터 계산 (운영중인 자산만)
|
|
||||||
const { win11, under, normal, over } = getSpecStatusCounts(data.activePcs);
|
|
||||||
const activeCount = data.active;
|
|
||||||
|
|
||||||
const win11Pct = activeCount > 0 ? (win11 / activeCount) * 100 : 0;
|
|
||||||
const underPct = activeCount > 0 ? (under / activeCount) * 100 : 0;
|
|
||||||
const normalPct = activeCount > 0 ? (normal / activeCount) * 100 : 0;
|
|
||||||
const overPct = activeCount > 0 ? (over / activeCount) * 100 : 0;
|
|
||||||
|
|
||||||
const rowTotal = data.total;
|
|
||||||
const barWidthPct = maxTotal > 0 ? (rowTotal / maxTotal) * 100 : 0;
|
|
||||||
|
|
||||||
let barGraphHtml = '';
|
|
||||||
if (activeCount > 0) {
|
|
||||||
barGraphHtml = `
|
|
||||||
<div style="position: relative; display: inline-block; width: 100%; max-width: 100%; text-align: left;" class="spec-bar-container">
|
|
||||||
<!-- 게이지 바 (보유량 비례) -->
|
|
||||||
<div class="spec-bar-wrapper" style="display: flex; height: 16px; border-radius: 8px; overflow: hidden; background: #EEF2F6; width: ${barWidthPct}%; min-width: 15px; box-shadow: inset 0 1px 2px rgba(0,0,0,0.06); cursor: pointer;">
|
|
||||||
${win11 > 0 ? `<div style="width: ${win11Pct}%; background: #7928ca; border-right: 2px solid #ffffff; cursor: pointer; transition: opacity 0.15s;" title="윈도우 11 불가: ${win11}대" class="spec-segment-btn" data-grade="${gradeKey}" data-spec-status="윈도우 11 불가" onmouseover="showSpecTooltip(event, this, 'win11', ${win11}); this.style.opacity='0.85';" onmousemove="updateSpecTooltipPos(event, this);" onmouseout="hideSpecTooltip(this); this.style.opacity='1';"></div>` : ''}
|
|
||||||
${under > 0 ? `<div style="width: ${underPct}%; background: #EF4444; border-right: 2px solid #ffffff; cursor: pointer; transition: opacity 0.15s;" title="사양 부족: ${under}대" class="spec-segment-btn" data-grade="${gradeKey}" data-spec-status="사양 부족" onmouseover="showSpecTooltip(event, this, 'under', ${under}); this.style.opacity='0.85';" onmousemove="updateSpecTooltipPos(event, this);" onmouseout="hideSpecTooltip(this); this.style.opacity='1';"></div>` : ''}
|
|
||||||
${normal > 0 ? `<div style="width: ${normalPct}%; background: #1E5149; border-right: 2px solid #ffffff; cursor: pointer; transition: opacity 0.15s;" title="적정 사양: ${normal}대" class="spec-segment-btn" data-grade="${gradeKey}" data-spec-status="적정" onmouseover="showSpecTooltip(event, this, 'normal', ${normal}); this.style.opacity='0.85';" onmousemove="updateSpecTooltipPos(event, this);" onmouseout="hideSpecTooltip(this); this.style.opacity='1';"></div>` : ''}
|
|
||||||
${over > 0 ? `<div style="width: ${overPct}%; background: #F59E0B; cursor: pointer; transition: opacity 0.15s;" title="오버 스펙: ${over}대" class="spec-segment-btn" data-grade="${gradeKey}" data-spec-status="오버스펙" onmouseover="showSpecTooltip(event, this, 'over', ${over}); this.style.opacity='0.85';" onmousemove="updateSpecTooltipPos(event, this);" onmouseout="hideSpecTooltip(this); this.style.opacity='1';"></div>` : ''}
|
|
||||||
</div>
|
|
||||||
<!-- 마우스 오버 시 나타날 커스텀 말풍선 툴팁 -->
|
|
||||||
<div class="spec-tooltip" style="position: fixed; transform: translate(-50%, -100%); margin-top: -10px; background: #1E293B; color: #ffffff; padding: 6px 10px; border-radius: 6px; font-size: 11px; white-space: nowrap; box-shadow: 0 4px 6px rgba(0,0,0,0.15); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 9999; display: flex; gap: 8px; align-items: center;">
|
|
||||||
<span class="tooltip-text"></span>
|
|
||||||
<div style="position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: #1E293B;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
barGraphHtml = `<span style="font-size: 0.88rem; color: #94A3B8; font-weight: 550;">운영중 자산 없음</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr style="border-bottom: 1px solid #E2E8F0;">
|
<tr style="border-bottom: 1px solid #F1F5F9;">
|
||||||
<td style="padding: 22px 10px; font-weight: 800; color: ${color}; font-size: 1.05rem;">${label}</td>
|
<td style="padding: 14px 12px; font-weight: 800; color: ${color}; font-size: 1.25rem;">${label}</td>
|
||||||
<td class="matrix-cell" data-grade="${gradeKey}" data-type="total" style="${cellStyle}" ${hoverEvents}>${data.total}대 <span style="font-size:0.88rem; color:#64748B; font-weight:500;">(${totalRate}%)</span></td>
|
<td class="matrix-cell" data-grade="${gradeKey}" data-type="total" style="${cellStyle}" ${hoverEvents}>${data.total}대 <span style="font-size:1.0rem; color:#64748B; font-weight:500;">(${totalRate}%)</span></td>
|
||||||
<td class="matrix-cell" data-grade="${gradeKey}" data-type="active" style="${cellStyle}" ${hoverEvents}>${data.active}대</td>
|
<td class="matrix-cell" data-grade="${gradeKey}" data-type="active" style="${cellStyle}" ${hoverEvents}>${data.active}대</td>
|
||||||
<td class="matrix-cell" data-grade="${gradeKey}" data-type="stock" style="${cellStyle}" ${hoverEvents}>${data.stock}대</td>
|
<td class="matrix-cell" data-grade="${gradeKey}" data-type="stock" style="${cellStyle}" ${hoverEvents}>${data.stock}대</td>
|
||||||
<td class="matrix-cell" data-grade="${gradeKey}" data-type="under" style="${cellStyle} color: #EF4444;" ${hoverEvents}>${shortage}대</td>
|
<td class="matrix-cell" data-grade="${gradeKey}" data-type="under" style="${cellStyle} color: #EF4444;" ${hoverEvents}>${shortage}대</td>
|
||||||
<td style="padding: 22px 8px; text-align: center; font-weight: 700; font-size: 1.05rem; vertical-align: middle;">
|
|
||||||
${barGraphHtml}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
@@ -536,34 +437,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
|
|||||||
|
|
||||||
const totalShortage = premiumShortage + highShortage + normalShortage + entryShortage + replaceShortage;
|
const totalShortage = premiumShortage + highShortage + normalShortage + entryShortage + replaceShortage;
|
||||||
|
|
||||||
const totalActivePcs = filtered.filter(p => !isStock(p));
|
const cellStyleHeader = `padding: 14px 12px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC; font-size: 1.25rem;`;
|
||||||
const { win11: totWin11, under: totUnder, normal: totNormal, over: totOver } = getSpecStatusCounts(totalActivePcs);
|
|
||||||
const totUnderPct = totalActive > 0 ? (totUnder / totalActive) * 100 : 0;
|
|
||||||
const totNormalPct = totalActive > 0 ? (totNormal / totalActive) * 100 : 0;
|
|
||||||
const totOverPct = totalActive > 0 ? (totOver / totalActive) * 100 : 0;
|
|
||||||
|
|
||||||
let totBarGraphHtml = '';
|
|
||||||
if (totalActive > 0) {
|
|
||||||
totBarGraphHtml = `
|
|
||||||
<div style="position: relative; display: inline-block; width: 100%; max-width: 100%; text-align: left;" class="spec-bar-container">
|
|
||||||
<!-- 게이지 바 (합계는 100% 너비) -->
|
|
||||||
<div class="spec-bar-wrapper" style="display: flex; height: 16px; border-radius: 8px; overflow: hidden; background: #EEF2F6; width: 100%; box-shadow: inset 0 1px 2px rgba(0,0,0,0.06); cursor: pointer;">
|
|
||||||
${totUnder > 0 ? `<div style="width: ${totUnderPct}%; background: #EF4444; border-right: 2px solid #ffffff; cursor: pointer; transition: opacity 0.15s;" title="사양 부족: ${totUnder}대" class="spec-segment-btn" data-grade="all" data-spec-status="사양 부족" onmouseover="showSpecTooltip(event, this, 'under', ${totUnder}); this.style.opacity='0.85';" onmousemove="updateSpecTooltipPos(event, this);" onmouseout="hideSpecTooltip(this); this.style.opacity='1';"></div>` : ''}
|
|
||||||
${totNormal > 0 ? `<div style="width: ${totNormalPct}%; background: #1E5149; border-right: 2px solid #ffffff; cursor: pointer; transition: opacity 0.15s;" title="적정 사양: ${totNormal}대" class="spec-segment-btn" data-grade="all" data-spec-status="적정" onmouseover="showSpecTooltip(event, this, 'normal', ${totNormal}); this.style.opacity='0.85';" onmousemove="updateSpecTooltipPos(event, this);" onmouseout="hideSpecTooltip(this); this.style.opacity='1';"></div>` : ''}
|
|
||||||
${totOver > 0 ? `<div style="width: ${totOverPct}%; background: #F59E0B; cursor: pointer; transition: opacity 0.15s;" title="오버 스펙: ${totOver}대" class="spec-segment-btn" data-grade="all" data-spec-status="오버스펙" onmouseover="showSpecTooltip(event, this, 'over', ${totOver}); this.style.opacity='0.85';" onmousemove="updateSpecTooltipPos(event, this);" onmouseout="hideSpecTooltip(this); this.style.opacity='1';"></div>` : ''}
|
|
||||||
</div>
|
|
||||||
<!-- 마우스 오버 시 나타날 커스텀 말풍선 툴팁 -->
|
|
||||||
<div class="spec-tooltip" style="position: fixed; transform: translate(-50%, -100%); margin-top: -10px; background: #1E293B; color: #ffffff; padding: 6px 10px; border-radius: 6px; font-size: 11px; white-space: nowrap; box-shadow: 0 4px 6px rgba(0,0,0,0.15); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 9999; display: flex; gap: 8px; align-items: center;">
|
|
||||||
<span class="tooltip-text"></span>
|
|
||||||
<div style="position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: #1E293B;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
totBarGraphHtml = `<span style="font-size: 0.88rem; color: #94A3B8; font-weight: 550;">운영중 자산 없음</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cellStyleHeader = `padding: 12px 10px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC; font-size: 1.05rem;`;
|
|
||||||
const hoverEventsHeader = `onmouseover="this.style.background='#EEF2F6'" onmouseout="this.style.background='#F8FAFC'"`;
|
const hoverEventsHeader = `onmouseover="this.style.background='#EEF2F6'" onmouseout="this.style.background='#F8FAFC'"`;
|
||||||
|
|
||||||
matrixTbody.innerHTML = `
|
matrixTbody.innerHTML = `
|
||||||
@@ -571,7 +445,14 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
|
|||||||
${renderMatrixRow('high', '상급 PC (70점 ~ 85점)', '#1E8E7C', highShortage)}
|
${renderMatrixRow('high', '상급 PC (70점 ~ 85점)', '#1E8E7C', highShortage)}
|
||||||
${renderMatrixRow('normal', '중급 PC (40점 ~ 70점)', '#10B981', normalShortage)}
|
${renderMatrixRow('normal', '중급 PC (40점 ~ 70점)', '#10B981', normalShortage)}
|
||||||
${renderMatrixRow('entry', '보급 PC (20점 ~ 40점)', '#F59E0B', entryShortage)}
|
${renderMatrixRow('entry', '보급 PC (20점 ~ 40점)', '#F59E0B', entryShortage)}
|
||||||
${renderMatrixRow('replace', '교체 대상 PC (20점 미만)', '#EF4444', replaceShortage)}
|
${renderMatrixRow('replace', '교체 대상 PC (20점 미만 또는 Win11 불가)', '#EF4444', replaceShortage)}
|
||||||
|
<tr style="background: #F8FAFC; border-top: 2px solid #E2E8F0; font-weight: 800;">
|
||||||
|
<td style="padding: 14px 12px; color: #1E293B; font-weight: 800; font-size: 1.25rem;">합계 (Total)</td>
|
||||||
|
<td class="matrix-cell" data-grade="all" data-type="total" style="${cellStyleHeader}" ${hoverEventsHeader}>${totalPcs}대 <span style="font-size:1.125rem; color:#64748B; font-weight:600;">(100%)</span></td>
|
||||||
|
<td class="matrix-cell" data-grade="all" data-type="active" style="${cellStyleHeader}" ${hoverEventsHeader}>${totalActive}대</td>
|
||||||
|
<td class="matrix-cell" data-grade="all" data-type="stock" style="${cellStyleHeader}" ${hoverEventsHeader}>${totalStock}대</td>
|
||||||
|
<td class="matrix-cell" data-grade="all" data-type="under" style="${cellStyleHeader} color: #EF4444;" ${hoverEventsHeader}>${totalShortage}대</td>
|
||||||
|
</tr>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 셀별 동적 클릭 리스너 바인딩
|
// 셀별 동적 클릭 리스너 바인딩
|
||||||
@@ -596,7 +477,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
|
|||||||
if (t === 'total') return '보유';
|
if (t === 'total') return '보유';
|
||||||
if (t === 'active') return '운영중';
|
if (t === 'active') return '운영중';
|
||||||
if (t === 'stock') return '재고';
|
if (t === 'stock') return '재고';
|
||||||
if (t === 'under') return '부족분';
|
if (t === 'under') return '구매 필요';
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -628,48 +509,6 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 바그래프 세그먼트 또는 텍스트 클릭 리스너 설정
|
|
||||||
const handleSpecClick = (e: Event) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const target = e.currentTarget as HTMLElement;
|
|
||||||
const grade = target.getAttribute('data-grade')!;
|
|
||||||
const status = target.getAttribute('data-spec-status')!;
|
|
||||||
|
|
||||||
let targetPcs: any[] = [];
|
|
||||||
const filterFn = (p: any) => {
|
|
||||||
if (status === '윈도우 11 불가') {
|
|
||||||
return isWindows11Incompatible(p.cpu, p.ram);
|
|
||||||
} else if (status === '사양 부족') {
|
|
||||||
return !isWindows11Incompatible(p.cpu, p.ram) && p._spec_status === '사양 부족';
|
|
||||||
} else {
|
|
||||||
return p._spec_status === status;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (grade === 'all') {
|
|
||||||
targetPcs = filtered.filter(p => !isStock(p) && filterFn(p));
|
|
||||||
} else {
|
|
||||||
const data = matrix[grade as keyof typeof matrix];
|
|
||||||
targetPcs = data.activePcs.filter(filterFn);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getGradeLabel = (g: string) => {
|
|
||||||
if (g === 'premium') return '최상급 PC';
|
|
||||||
if (g === 'high') return '상급 PC';
|
|
||||||
if (g === 'normal') return '중급 PC';
|
|
||||||
if (g === 'entry') return '보급 PC';
|
|
||||||
if (g === 'replace') return '교체 대상 PC';
|
|
||||||
return '전체 PC';
|
|
||||||
};
|
|
||||||
|
|
||||||
const title = `${getGradeLabel(grade)} - ${status} 자산 목록`;
|
|
||||||
showMiniListModal(title, targetPcs);
|
|
||||||
};
|
|
||||||
|
|
||||||
matrixTbody.querySelectorAll('.spec-segment-btn, .spec-text-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', handleSpecClick);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. 연도별 PC 노후도 집계 및 렌더링
|
// 7. 연도별 PC 노후도 집계 및 렌더링
|
||||||
const agingCounts = {
|
const agingCounts = {
|
||||||
immediate: [] as any[], // 7년 이상
|
immediate: [] as any[], // 7년 이상
|
||||||
@@ -693,20 +532,23 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
|
|||||||
|
|
||||||
const agingTbody = document.getElementById('pc-aging-tbody')!;
|
const agingTbody = document.getElementById('pc-aging-tbody')!;
|
||||||
|
|
||||||
const renderAgingRow = (label: string, list: any[], ageGroupKey: string) => {
|
const renderAgingRow = (label: string, list: any[], badgeText: string, badgeStyle: string, ageGroupKey: string) => {
|
||||||
return `
|
return `
|
||||||
<tr style="border-bottom:1px solid #F1F5F9; cursor:pointer; transition: background 0.2s;" class="aging-row" data-group="${ageGroupKey}" onmouseover="this.style.background='#F8FAFC'" onmouseout="this.style.background='none'">
|
<tr style="border-bottom:1px solid #F1F5F9; cursor:pointer; transition: background 0.2s;" class="aging-row" data-group="${ageGroupKey}" onmouseover="this.style.background='#F8FAFC'" onmouseout="this.style.background='none'">
|
||||||
<td style="padding:5px 8px; font-weight:700; color:#334155; font-size: 1.05rem;">${label}</td>
|
<td style="padding:10px 10px; font-weight:700; color:#334155; font-size: 1.15rem;">${label}</td>
|
||||||
<td style="padding:5px 8px; text-align:center; font-weight:700; color:#334155; font-size: 1.05rem;">${list.length}대</td>
|
<td style="padding:10px 10px; text-align:center; font-weight:700; color:#334155; font-size: 1.15rem;">${list.length}대</td>
|
||||||
|
<td style="padding:10px 10px; text-align:center;">
|
||||||
|
<span style="padding:2px 8px; border-radius:4px; font-size:14px; font-weight:800; ${badgeStyle}">${badgeText}</span>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
agingTbody.innerHTML = `
|
agingTbody.innerHTML = `
|
||||||
${renderAgingRow('즉시 교체 (7년 이상)', agingCounts.immediate, 'immediate')}
|
${renderAgingRow('즉시 교체 (7년 이상)', agingCounts.immediate, '즉시 교체', 'background:#FFF1F2; color:#EF4444; border:1px solid #FCA5A5;', 'immediate')}
|
||||||
${renderAgingRow('교체 검토 (3년 ~ 7년)', agingCounts.review, 'review')}
|
${renderAgingRow('교체 검토 (3년 ~ 7년)', agingCounts.review, '교체 검토', 'background:#FFF7ED; color:#D97706; border:1px solid #FCD34D;', 'review')}
|
||||||
${renderAgingRow('정상 운용 (1년 ~ 3년)', agingCounts.normal, 'normal')}
|
${renderAgingRow('정상 운용 (1년 ~ 3년)', agingCounts.normal, '정상 운용', 'background:#ECFDF5; color:#059669; border:1px solid #A7F3D0;', 'normal')}
|
||||||
${renderAgingRow('최신 도입 (1년 미만)', agingCounts.fresh, 'fresh')}
|
${renderAgingRow('최신 도입 (1년 미만)', agingCounts.fresh, '최신 도입', 'background:#F0FDF4; color:#16A34A; border:1px solid #BBF7D0;', 'fresh')}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
agingTbody.querySelectorAll('.aging-row').forEach(row => {
|
agingTbody.querySelectorAll('.aging-row').forEach(row => {
|
||||||
@@ -724,14 +566,14 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 8. 요약 지표 카드 클릭 리스너 설정
|
// 8. 요약 지표 카드 클릭 리스너 설정
|
||||||
const bindCardClick = (id: string, gradeTitle: string, filterFn: (p: any) => boolean, hoverBgColor: string) => {
|
const bindCardClick = (id: string, gradeTitle: string, filterFn: (p: any) => boolean) => {
|
||||||
const card = document.getElementById(id)!;
|
const card = document.getElementById(id)!;
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
card.style.cursor = 'pointer';
|
card.style.cursor = 'pointer';
|
||||||
card.style.transition = 'background-color 0.15s ease';
|
card.style.transition = 'opacity 0.2s';
|
||||||
|
|
||||||
card.onmouseover = () => { card.style.backgroundColor = hoverBgColor; };
|
card.onmouseover = () => { card.style.opacity = '0.7'; };
|
||||||
card.onmouseout = () => { card.style.backgroundColor = '#ffffff'; };
|
card.onmouseout = () => { card.style.opacity = '1'; };
|
||||||
|
|
||||||
card.onclick = () => {
|
card.onclick = () => {
|
||||||
const pcsInGrade = filtered.filter(filterFn);
|
const pcsInGrade = filtered.filter(filterFn);
|
||||||
@@ -740,51 +582,54 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 사양 부족 / 오버 스펙 / 윈도우 11 불가 클릭 리스너 설정
|
// 사양 부족 / 오버 스펙 / 윈도우 11 불가 클릭 리스너 설정
|
||||||
bindCardClick('card-under-spec', '사양 부족 대상', p => p._spec_status === '사양 부족', '#FEF2F2');
|
bindCardClick('card-under-spec', '사양 부족 대상', p => p._spec_status === '사양 부족');
|
||||||
bindCardClick('card-over-spec', '오버 스펙 대상', p => p._spec_status === '오버스펙', '#FFFBEB');
|
bindCardClick('card-over-spec', '오버 스펙 대상', p => p._spec_status === '오버스펙');
|
||||||
bindCardClick('card-win11-incompatible', '윈도우 11 업그레이드 불가 PC', p => isWindows11Incompatible(p.cpu, p.ram), '#F5F3FF');
|
bindCardClick('card-win11-incompatible', '윈도우 11 업그레이드 불가 PC', p => isWindows11Incompatible(p.cpu, p.ram));
|
||||||
|
|
||||||
// 9. 조직별 사용 비율 집계 (전체 개인용 PC 기준)
|
// 9. 직무별 사양 적정성 대수 연산 및 차트 데이터 셋 구성 (누적 막대 그래프화)
|
||||||
const deptCounts: Record<string, number> = {
|
const activeJobs = Array.from(
|
||||||
'한맥': 0,
|
new Set(filtered.map((p: any) => p[ASSET_SCHEMA.USER_POSITION.key] || '미분류').filter(j => j !== '재고PC'))
|
||||||
'삼안': 0,
|
).sort();
|
||||||
'장헌': 0,
|
|
||||||
'한라': 0,
|
|
||||||
'기술개발센터': 0,
|
|
||||||
'총괄기획실': 0,
|
|
||||||
'기타': 0
|
|
||||||
};
|
|
||||||
|
|
||||||
pcs.forEach((p: any) => {
|
const underData: number[] = [];
|
||||||
const dept = String(p[ASSET_SCHEMA.CURRENT_DEPT.key] || '').trim();
|
const normalData: number[] = [];
|
||||||
let matched = false;
|
const overData: number[] = [];
|
||||||
for (const key of Object.keys(deptCounts)) {
|
|
||||||
if (key !== '기타' && dept.includes(key)) {
|
activeJobs.forEach(job => {
|
||||||
deptCounts[key]++;
|
const jobPcs = filtered.filter((p: any) => (p[ASSET_SCHEMA.USER_POSITION.key] || '미분류') === job);
|
||||||
matched = true;
|
const totalCount = jobPcs.length;
|
||||||
break;
|
if (totalCount === 0) {
|
||||||
|
underData.push(0);
|
||||||
|
normalData.push(0);
|
||||||
|
overData.push(0);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
let under = 0;
|
||||||
if (!matched) {
|
let normal = 0;
|
||||||
deptCounts['기타']++;
|
let over = 0;
|
||||||
|
|
||||||
|
jobPcs.forEach(p => {
|
||||||
|
const stockYn = isStock(p);
|
||||||
|
if (!stockYn) {
|
||||||
|
if (p._spec_status === '사양 부족') { under++; }
|
||||||
|
else if (p._spec_status === '오버스펙') { over++; }
|
||||||
|
else { normal++; }
|
||||||
|
} else {
|
||||||
|
normal++; // 예외 폴백
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const deptChartData = [
|
underData.push(under);
|
||||||
{ label: '한맥', count: deptCounts['한맥'], color: '#D02121' },
|
normalData.push(normal);
|
||||||
{ label: '삼안', count: deptCounts['삼안'], color: '#F58120' },
|
overData.push(over);
|
||||||
{ label: '장헌', count: deptCounts['장헌'], color: '#3889C7' },
|
});
|
||||||
{ label: '한라', count: deptCounts['한라'], color: '#79B2D9' },
|
|
||||||
{ label: '기술개발센터', count: deptCounts['기술개발센터'], color: '#10B981' },
|
|
||||||
{ label: '총괄기획실', count: deptCounts['총괄기획실'], color: '#133D84' },
|
|
||||||
{ label: '기타', count: deptCounts['기타'], color: '#94A3B8' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// 10. 도넛 차트 렌더링 호출
|
// 10. 차트들 렌더링 호출
|
||||||
renderDonutChart(deptChartData);
|
renderChart(activeJobs, underData, normalData, overData, filtered);
|
||||||
|
renderDonutChart(matrix.premium.total, matrix.high.total, matrix.normal.total, matrix.entry.total, matrix.replace.total);
|
||||||
|
|
||||||
// 전역 상태 등록
|
// 전역 상태 등록
|
||||||
state.activeCharts = [donutChartInstance];
|
state.activeCharts = [jobChartInstance, donutChartInstance];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -850,7 +695,7 @@ function showMiniListModal(title: string, list: any[]) {
|
|||||||
return `
|
return `
|
||||||
<tr style="border-bottom: 1px solid #F1F5F9; cursor: pointer; transition: background 0.2s;" class="mini-modal-row" data-id="${pc.id}" onmouseover="this.style.background='#F8FAFC'" onmouseout="this.style.background='none'">
|
<tr style="border-bottom: 1px solid #F1F5F9; cursor: pointer; transition: background 0.2s;" class="mini-modal-row" data-id="${pc.id}" onmouseover="this.style.background='#F8FAFC'" onmouseout="this.style.background='none'">
|
||||||
<td style="padding: 12px 4px; font-weight: 700; color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${user}">${user}</td>
|
<td style="padding: 12px 4px; font-weight: 700; color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${user}">${user}</td>
|
||||||
<td style="padding: 12px 4px; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${pc.current_dept || '-'} (${pc._resolved_position || pc.user_position || '-'})">${pc.current_dept || '-'} (${pc._resolved_position || pc.user_position || '-'})</td>
|
<td style="padding: 12px 4px; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${pc.current_dept || '-'} (${pc.user_position || '-'})">${pc.current_dept || '-'} (${pc.user_position || '-'})</td>
|
||||||
<td style="padding: 12px 4px; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${spec}">${spec}</td>
|
<td style="padding: 12px 4px; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${spec}">${spec}</td>
|
||||||
<td style="padding: 12px 4px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${badgeHTML}${scoreHTML}</td>
|
<td style="padding: 12px 4px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${badgeHTML}${scoreHTML}</td>
|
||||||
<td style="padding: 12px 4px; font-family: monospace; color: #475569; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${pc.asset_code || '-'}">${pc.asset_code || '-'}</td>
|
<td style="padding: 12px 4px; font-family: monospace; color: #475569; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${pc.asset_code || '-'}">${pc.asset_code || '-'}</td>
|
||||||
@@ -901,12 +746,144 @@ function showMiniListModal(title: string, list: any[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chart.js 가로형 100% 스택 막대 차트 (라이트 테마 튜닝)
|
||||||
|
*/
|
||||||
|
function renderChart(labels: string[], underData: number[], normalData: number[], overData: number[], currentFiltered: any[]) {
|
||||||
|
const ctx = document.getElementById('chart-job-scores') as HTMLCanvasElement;
|
||||||
|
if (!ctx || typeof Chart === 'undefined') return;
|
||||||
|
|
||||||
|
if (jobChartInstance) {
|
||||||
|
jobChartInstance.destroy();
|
||||||
|
jobChartInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
jobChartInstance = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: '사양 부족',
|
||||||
|
data: underData,
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.85)', // Rose Red
|
||||||
|
borderColor: 'rgb(239, 68, 68)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
barPercentage: 0.45,
|
||||||
|
categoryPercentage: 0.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '적정 사양',
|
||||||
|
data: normalData,
|
||||||
|
backgroundColor: 'rgba(30, 81, 73, 0.85)', // Hanmac Green
|
||||||
|
borderColor: 'rgb(30, 81, 73)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
barPercentage: 0.45,
|
||||||
|
categoryPercentage: 0.8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '오버 스펙',
|
||||||
|
data: overData,
|
||||||
|
backgroundColor: 'rgba(217, 119, 6, 0.85)', // Amber Orange
|
||||||
|
borderColor: 'rgb(217, 119, 6)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
barPercentage: 0.45,
|
||||||
|
categoryPercentage: 0.8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
onHover: (event: any, activeElements: any[]) => {
|
||||||
|
event.chart.canvas.style.cursor = activeElements.length ? 'pointer' : 'default';
|
||||||
|
},
|
||||||
|
onClick: (event: any, activeElements: any[]) => {
|
||||||
|
if (activeElements && activeElements.length > 0) {
|
||||||
|
const activeElement = activeElements[0];
|
||||||
|
const datasetIndex = activeElement.datasetIndex; // 0: 사양 부족, 1: 적정 사양, 2: 오버스펙
|
||||||
|
const index = activeElement.index; // 직무군 인덱스
|
||||||
|
|
||||||
|
const clickedJob = labels[index];
|
||||||
|
const statusLabels = ['사양 부족', '적정', '오버스펙'];
|
||||||
|
const clickedStatus = statusLabels[datasetIndex] || '적정';
|
||||||
|
|
||||||
|
// 해당 직무군과 사양 상태가 매칭되는 자산 목록 필터링
|
||||||
|
const matchedPcs = currentFiltered.filter((p: any) => {
|
||||||
|
const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
|
||||||
|
if (job !== clickedJob) return false;
|
||||||
|
|
||||||
|
const stockYn = p.hw_status === '재고' ||
|
||||||
|
p.hw_status === '대기' ||
|
||||||
|
!(p.user_current || '').trim();
|
||||||
|
|
||||||
|
let specStatus = '적정';
|
||||||
|
if (!stockYn) {
|
||||||
|
specStatus = p._spec_status || '적정';
|
||||||
|
}
|
||||||
|
return specStatus === clickedStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
showMiniListModal(`${clickedJob} - ${clickedStatus === '적정' ? '적정 사양' : (clickedStatus === '오버스펙' ? '오버 스펙' : clickedStatus)} 자산`, matchedPcs);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
align: 'end',
|
||||||
|
labels: {
|
||||||
|
font: { family: 'Pretendard', size: 16, weight: '700' },
|
||||||
|
color: '#475569',
|
||||||
|
boxWidth: 12,
|
||||||
|
boxHeight: 12,
|
||||||
|
usePointStyle: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
titleFont: { family: 'Pretendard', size: 12, weight: '700' },
|
||||||
|
bodyFont: { family: 'Pretendard', size: 12 },
|
||||||
|
callbacks: {
|
||||||
|
label: function (context: any) {
|
||||||
|
const datasetLabel = context.dataset.label;
|
||||||
|
const value = context.raw; // 실제 대수
|
||||||
|
const total = context.chart.data.datasets.reduce((sum: number, dataset: any) => sum + dataset.data[context.dataIndex], 0);
|
||||||
|
const percentage = total > 0 ? Math.round((value / total) * 100) : 0;
|
||||||
|
return `${datasetLabel}: ${value}대 (${percentage}%)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
ticks: {
|
||||||
|
callback: (val: any) => `${val}대`,
|
||||||
|
font: { family: 'Pretendard', size: 14, weight: '600' },
|
||||||
|
color: '#64748B'
|
||||||
|
},
|
||||||
|
grid: { color: '#EEF2F6' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
ticks: {
|
||||||
|
font: { family: 'Pretendard', size: 16, weight: '700' },
|
||||||
|
color: '#475569'
|
||||||
|
},
|
||||||
|
grid: { display: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 실시간 사양 적정률 원형 도넛 그래프 (Active Spec Rate)
|
* 실시간 사양 적정률 원형 도넛 그래프 (Active Spec Rate)
|
||||||
*/
|
*/
|
||||||
function renderDonutChart(deptData: { label: string; count: number; color: string }[]) {
|
function renderDonutChart(premium: number, high: number, normal: number, entry: number, replace: number) {
|
||||||
const ctx = document.getElementById('chart-overall-donut') as HTMLCanvasElement;
|
const ctx = document.getElementById('chart-overall-donut') as HTMLCanvasElement;
|
||||||
if (!ctx || typeof Chart === 'undefined') return;
|
if (!ctx || typeof Chart === 'undefined') return;
|
||||||
|
|
||||||
@@ -915,15 +892,21 @@ function renderDonutChart(deptData: { label: string; count: number; color: strin
|
|||||||
donutChartInstance = null;
|
donutChartInstance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = deptData.reduce((sum, d) => sum + d.count, 0);
|
const total = premium + high + normal + entry + replace;
|
||||||
|
|
||||||
donutChartInstance = new Chart(ctx, {
|
donutChartInstance = new Chart(ctx, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: {
|
data: {
|
||||||
labels: deptData.map(d => d.label),
|
labels: ['최상급', '상급', '중급', '보급', '교체 대상'],
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: deptData.map(d => d.count),
|
data: [premium, high, normal, entry, replace],
|
||||||
backgroundColor: deptData.map(d => d.color),
|
backgroundColor: [
|
||||||
|
'#11302B', // premium (Hanmac Dark Green)
|
||||||
|
'#1E8E7C', // high (Hanmac Teal)
|
||||||
|
'#10B981', // normal (Hanmac Mint)
|
||||||
|
'#F59E0B', // entry (Yellow-Orange)
|
||||||
|
'#EF4444' // replace (Red)
|
||||||
|
],
|
||||||
borderColor: '#ffffff',
|
borderColor: '#ffffff',
|
||||||
borderWidth: 2
|
borderWidth: 2
|
||||||
}]
|
}]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
10
start_docker_wsl.bat
Normal file
10
start_docker_wsl.bat
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
cd /d "%~dp0"
|
||||||
|
powershell -ExecutionPolicy Bypass -File "%~dp0start_docker_wsl.ps1"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] start_docker_wsl.ps1 failed.
|
||||||
|
pause
|
||||||
|
exit /b %errorlevel%
|
||||||
|
)
|
||||||
107
start_docker_wsl.ps1
Normal file
107
start_docker_wsl.ps1
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
$projectWindowsPath = $PSScriptRoot
|
||||||
|
$wslProjectPath = (wsl wslpath $projectWindowsPath).Trim()
|
||||||
|
$envFilePath = Join-Path $PSScriptRoot '.env'
|
||||||
|
|
||||||
|
function Get-EnvValue {
|
||||||
|
param(
|
||||||
|
[string]$FilePath,
|
||||||
|
[string]$Key
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path $FilePath)) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$line = Get-Content $FilePath | Where-Object { $_ -match "^$Key=" } | Select-Object -First 1
|
||||||
|
if (-not $line) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($line -split '=', 2)[1].Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-TcpPortFast {
|
||||||
|
param(
|
||||||
|
[string]$HostName,
|
||||||
|
[int]$Port,
|
||||||
|
[int]$TimeoutMs = 3000
|
||||||
|
)
|
||||||
|
|
||||||
|
$client = New-Object System.Net.Sockets.TcpClient
|
||||||
|
try {
|
||||||
|
$asyncResult = $client.BeginConnect($HostName, $Port, $null, $null)
|
||||||
|
if (-not $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
|
||||||
|
$client.Close()
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$client.EndConnect($asyncResult)
|
||||||
|
$client.Close()
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$client.Close()
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "============================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " HM ITAM WSL Docker Start" -ForegroundColor Cyan
|
||||||
|
Write-Host "============================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
Write-Host "[INFO] Checking WSL..."
|
||||||
|
wsl -l -v
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "[ERROR] WSL is not available." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[INFO] Checking Docker in WSL..."
|
||||||
|
wsl sh -lc "docker --version"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "[ERROR] Docker is not available inside WSL." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$dbHost = Get-EnvValue -FilePath $envFilePath -Key 'DB_HOST'
|
||||||
|
$dbPort = Get-EnvValue -FilePath $envFilePath -Key 'DB_PORT'
|
||||||
|
|
||||||
|
if (-not $dbPort) {
|
||||||
|
$dbPort = '3306'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $dbHost) {
|
||||||
|
Write-Host "[WARN] .env is missing DB_HOST. Containers will still start, but backend DB calls will fail until DB settings are fixed." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dbHost) {
|
||||||
|
Write-Host "[INFO] Checking external DB reachability..."
|
||||||
|
$dbReachable = Test-TcpPortFast -HostName $dbHost -Port ([int]$dbPort)
|
||||||
|
if (-not $dbReachable) {
|
||||||
|
Write-Host "[WARN] External DB is unreachable: $dbHost`:$dbPort" -ForegroundColor Yellow
|
||||||
|
Write-Host "[HINT] Containers will still start. Check VPN/private network connection, firewall rules, DB host/port in .env, or whether the DB server is running." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[INFO] Starting ITAM containers in WSL..."
|
||||||
|
wsl sh -lc "cd '$wslProjectPath' && docker compose up --build -d --remove-orphans"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "[WARN] Build-based startup failed. Retrying with cached images/containers..." -ForegroundColor Yellow
|
||||||
|
wsl sh -lc "cd '$wslProjectPath' && docker compose up -d --remove-orphans"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "[ERROR] Failed to start containers." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "============================================" -ForegroundColor Green
|
||||||
|
Write-Host " [OK] WSL Docker stack started." -ForegroundColor Green
|
||||||
|
Write-Host " [INFO] Frontend: http://localhost:8080"
|
||||||
|
Write-Host " [INFO] Backend : http://localhost:3000/api/assets/master"
|
||||||
|
Write-Host "============================================" -ForegroundColor Green
|
||||||
|
|
||||||
|
Start-Process "http://localhost:8080"
|
||||||
@@ -1,6 +1,49 @@
|
|||||||
# HM ITAM Server Start Script
|
# HM ITAM Server Start Script
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
function Get-EnvValue {
|
||||||
|
param(
|
||||||
|
[string]$FilePath,
|
||||||
|
[string]$Key
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path $FilePath)) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$line = Get-Content $FilePath | Where-Object { $_ -match "^$Key=" } | Select-Object -First 1
|
||||||
|
if (-not $line) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($line -split '=', 2)[1].Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-TcpPortFast {
|
||||||
|
param(
|
||||||
|
[string]$HostName,
|
||||||
|
[int]$Port,
|
||||||
|
[int]$TimeoutMs = 3000
|
||||||
|
)
|
||||||
|
|
||||||
|
$client = New-Object System.Net.Sockets.TcpClient
|
||||||
|
try {
|
||||||
|
$asyncResult = $client.BeginConnect($HostName, $Port, $null, $null)
|
||||||
|
if (-not $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
|
||||||
|
$client.Close()
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$client.EndConnect($asyncResult)
|
||||||
|
$client.Close()
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$client.Close()
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "============================================" -ForegroundColor Cyan
|
Write-Host "============================================" -ForegroundColor Cyan
|
||||||
Write-Host " HM ITAM System Start" -ForegroundColor Cyan
|
Write-Host " HM ITAM System Start" -ForegroundColor Cyan
|
||||||
Write-Host "============================================" -ForegroundColor Cyan
|
Write-Host "============================================" -ForegroundColor Cyan
|
||||||
@@ -21,6 +64,13 @@ if (-not (Test-Path "node_modules")) {
|
|||||||
Write-Host "[INFO] Checking ports..."
|
Write-Host "[INFO] Checking ports..."
|
||||||
$backendPort = 3000
|
$backendPort = 3000
|
||||||
$frontendPort = 8080
|
$frontendPort = 8080
|
||||||
|
$envFilePath = Join-Path $PSScriptRoot '.env'
|
||||||
|
$dbHost = Get-EnvValue -FilePath $envFilePath -Key 'DB_HOST'
|
||||||
|
$dbPort = Get-EnvValue -FilePath $envFilePath -Key 'DB_PORT'
|
||||||
|
|
||||||
|
if (-not $dbPort) {
|
||||||
|
$dbPort = '3306'
|
||||||
|
}
|
||||||
|
|
||||||
if (Get-NetTCPConnection -LocalPort $backendPort -ErrorAction SilentlyContinue) {
|
if (Get-NetTCPConnection -LocalPort $backendPort -ErrorAction SilentlyContinue) {
|
||||||
Write-Host "[WARNING] Port $backendPort [Backend] is already in use." -ForegroundColor Yellow
|
Write-Host "[WARNING] Port $backendPort [Backend] is already in use." -ForegroundColor Yellow
|
||||||
@@ -30,6 +80,21 @@ if (Get-NetTCPConnection -LocalPort $frontendPort -ErrorAction SilentlyContinue)
|
|||||||
Write-Host "[WARNING] Port $frontendPort [Frontend] is already in use." -ForegroundColor Yellow
|
Write-Host "[WARNING] Port $frontendPort [Frontend] is already in use." -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (-not $dbHost) {
|
||||||
|
Write-Host "[WARNING] .env is missing DB_HOST. Backend and frontend will still start, but DB calls will fail until DB settings are fixed." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "[INFO] Checking external DB reachability..."
|
||||||
|
$dbReachable = Test-TcpPortFast -HostName $dbHost -Port ([int]$dbPort)
|
||||||
|
if ($dbReachable) {
|
||||||
|
Write-Host "[INFO] External DB reachable: $dbHost`:$dbPort"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "[WARNING] External DB is unreachable: $dbHost`:$dbPort" -ForegroundColor Yellow
|
||||||
|
Write-Host "[WARNING] Backend and frontend will still start, but DB-backed screens and APIs may fail." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "[INFO] Starting Backend [Port: 3000]..."
|
Write-Host "[INFO] Starting Backend [Port: 3000]..."
|
||||||
Start-Process cmd -ArgumentList "/k npm run server"
|
Start-Process cmd -ArgumentList "/k npm run server"
|
||||||
|
|||||||
4
stop_docker_wsl.bat
Normal file
4
stop_docker_wsl.bat
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
cd /d "%~dp0"
|
||||||
|
powershell -ExecutionPolicy Bypass -File "%~dp0stop_docker_wsl.ps1"
|
||||||
13
stop_docker_wsl.ps1
Normal file
13
stop_docker_wsl.ps1
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
$projectWindowsPath = $PSScriptRoot
|
||||||
|
$wslProjectPath = (wsl wslpath $projectWindowsPath).Trim()
|
||||||
|
|
||||||
|
Write-Host "[INFO] Stopping ITAM WSL Docker stack..."
|
||||||
|
wsl sh -lc "cd '$wslProjectPath' && docker compose down --remove-orphans"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "[ERROR] Failed to stop containers." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[OK] WSL Docker stack stopped." -ForegroundColor Green
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
port: 8080,
|
port: 8080,
|
||||||
host: true, // Listen on all local IPs
|
host: true, // Listen on all local IPs
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: proxyTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/uploads': {
|
'/uploads': {
|
||||||
target: 'http://localhost:3000',
|
target: proxyTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user