Compare commits

...

7 Commits

Author SHA1 Message Date
d48d15f09a feat(ui): 대시보드 페이지당 저장소 10개로 조정
PAGE_SIZE 20 → 10. 한 화면에 부담 없이 보이도록. 페이저는 기존대로
동적 계산(ceil(total/PAGE_SIZE)). 테스트도 10/페이지 기준으로 갱신.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:06:27 +09:00
d79eb716bb docs: 폐쇄망(venv) 반입·구동 가이드 추가
컨테이너 없이 Python 가상환경으로 구동하는 서버관리망용 반입 절차
(소스 + wheelhouse 오프라인 설치). 정적 자산은 이미 동봉되어 런타임
인터넷 불필요.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:06:08 +09:00
9b686e35ba feat(ui): 콘솔 재디자인 적용 (Toss 스타일) + Pico v2 호환 수정
디자인 핸드오프(design_handoff_console_redesign)를 실제 앱에 반영.
CSS 드롭인 교체 + 최소 마크업 추가, HTMX 훅·id·data 속성은 모두 보존.

- app.css: 재디자인 토큰/컴포넌트로 교체(요약카드 호버 리프트, 표
  제브라, 중앙 경고형 배포 모달, secondary/ghost/danger 버튼 체계,
  진행률·상태칩·스켈레톤 등).
- base.html: 브랜드 로고 큐브(.brand-mark) 추가 + 데모 배너.
- repo_list: 요약 카드 .card-top/.card-ico + 검색 돋보기 아이콘.
- table_wrap: 회전형 정렬 인디케이터, 동기화 버튼 아이콘.
- progress: .sync-head/.sync-pct/.is-active/.indeterminate 구조.
- deploy_modal: 중앙 경고형 + 현재→대상 프리뷰 카드 + 타입-투-컨펌
  체크/X 시각 상태.
- deploy_success: 그려지는 체크 원 배너.
- version_list: 표 → .version-item 카드 리스트.

Pico v2 호환(미리보기는 pico-shim 기준이라 안 드러났던 것):
- Pico 의 `:is(...).secondary`(0,2,0)·hover(0,4,0) 특정성을 이기도록
  오버라이드를 :is() 형태로 상향 → 동기화 버튼 흰배경+파란글씨 유지,
  hover 시 어두워지던 문제 해결.
- `input[type=search]` 의 Pico 기본 돋보기 배경 제거(아이콘 중복).
- `td` 배경을 투명 처리해 tr 제브라/hover 가 보이도록(Pico 의
  `td{background:var(--pico-background-color)}` 가 덮던 문제), 줄무늬
  색을 --bg-alt 로 또렷하게.
- .row-action 을 40px 고정 → 버튼↔진행률↔배지 교체 시 높이 흔들림·
  위아래 테두리 잘림 해소.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:05:17 +09:00
07094d9352 feat(demo): expose demo_mode to templates + 연결 배지 데모 표시
- deps.py: PULP_DEMO 설정을 Jinja 전역(demo_mode)으로 노출 → 모든
  템플릿에서 참조 가능. 실연동 오인 방지(데모는 화면 확인 전용).
- health.html: 데모 모드일 때 연결 배지를 "데모 모드 · 가짜 데이터"
  칩으로 표시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:03:45 +09:00
c43d18ceea chore: 폐쇄망 반입 준비 및 python 버전 일치 2026-07-09 18:21:36 +09:00
c303040ae2 chore: htmx 2.0.10 갱신 (폐쇄망 반입 준비) 2026-07-09 17:12:08 +09:00
87db12117e docs: add Pulp integration guide; stop tracking MANUAL.md
- PULP_INTEGRATION.md: 실제 Pulp 연동 절차(서버 준비·Remote/Repo/Distribution·환경변수)
  + 실서버 첫 연동 시 필드 매핑 보정 체크리스트 + 트러블슈팅
- MANUAL.md(Coder 워크스페이스 안내, 이 레포 산출물 아님) 추적 해제 + .gitignore
  (로컬 파일은 유지)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:09:49 +09:00
22 changed files with 3214 additions and 703 deletions

4
.gitignore vendored
View File

@@ -6,3 +6,7 @@ venv/
.pytest_cache/
.ruff_cache/
audit-log.jsonl
# 폐쇄망 반입용 wheel 묶음 — 로컬 생성물, 레포에 커밋하지 않음
wheels/
# 워크스페이스(Coder) 안내 문서 — 이 레포 산출물 아님, 로컬에만 유지
MANUAL.md

159
AIRGAP_DEPLOY.md Normal file
View File

@@ -0,0 +1,159 @@
# 폐쇄망 반입·구동 가이드 (Python venv, 컨테이너 미사용)
서버관리망이 컨테이너 없이 **Python 가상환경**으로 구동하는 환경일 때의 반입 절차.
핵심은 **소스 + wheelhouse(오프라인 .whl 묶음)** 를 매체로 들고 들어가 거기서 venv로 설치·실행하는 것.
> 정적 자산(pico·htmx·Pretendard·JetBrains Mono)은 이미 `app/static/`에 동봉돼 있어 **런타임 인터넷 불필요**.
> 외부 의존은 Python 패키지(pip)뿐 → 이걸 wheelhouse로 해결한다.
---
## 0. 사전 확인 (가장 중요)
폐쇄망 **타깃 서버**에서 Python 버전·아키텍처를 확인한다. wheel은 이 값에 맞춰 받아야 한다.
```bash
python3 --version # 예: Python 3.12.x → cp312
uname -m # 예: x86_64
```
- 타깃 Python이 **3.11/3.12** 등이면 wheelhouse도 그 버전으로 받아야 한다(컴파일 wheel: pydantic-core, psycopg 등).
- 가능하면 **타깃과 같은 OS/파이썬**(예: Rocky9 + python3.12)에서 wheelhouse를 만드는 게 가장 안전하다.
---
## 1. 인터넷 되는 곳에서 산출물 준비
**(a) 소스** — git 메타·가상환경 제외하고 압축
```bash
git archive --format=tar.gz -o pulp-console-src.tgz HEAD
# (또는 repo 폴더에서 .git/.venv/tests 빼고 zip)
```
**(b) wheelhouse** — 타깃 Python/플랫폼에 맞춰 다운로드
```bash
# 타깃과 동일 환경(권장):
pip download -r requirements.txt -d vendor/
# 빌드 머신과 타깃이 다르면 플랫폼/버전 명시:
pip download -r requirements.txt -d vendor/ \
--only-binary=:all: --implementation cp \
--platform manylinux2014_x86_64 --python-version 312
```
`vendor/`에 모든 의존성 .whl이 모인다. (개수 확인: `ls vendor | wc -l`)
> 참고: `uvicorn[standard]`는 uvloop/httptools/watchfiles 등 컴파일 wheel을 포함한다. 버전 매칭이
> 까다로우면 `requirements.txt`의 `uvicorn[standard]`를 `uvicorn`(순정)으로 바꿔 wheel 세트를 줄일 수 있다
> (운영은 `--reload` 안 쓰므로 watchfiles 불필요).
---
## 2. 반입 매체에 담을 것
- `pulp-console-src.tgz` (소스 — 정적자산 포함)
- `vendor/` (wheelhouse)
- `boknet-ca.pem` (사내 HTTPS Pulp 검증용 CA)
- 환경변수 값 메모: `PULP_BASE_URL` / `PULP_USERNAME` / `PULP_PASSWORD` / `DATABASE_URL`
(비밀번호는 안전한 방법으로)
---
## 3. 폐쇄망에서 설치·구동
```bash
mkdir -p /opt/pulp-console && cd /opt/pulp-console
tar xzf /media/pulp-console-src.tgz # 소스 풀기
cp -r /media/vendor ./vendor # wheelhouse 복사
python3 -m venv .venv
.venv/bin/pip install --no-index --find-links vendor -r requirements.txt
```
환경변수(`.env` 또는 systemd) 설정 후 실행 — **운영은 `--reload` 금지**:
```bash
PULP_BASE_URL=https://repo.서버관리망:8080 \
PULP_USERNAME=admin PULP_PASSWORD='***' \
PULP_VERIFY_TLS=true PULP_CA_FILE=/opt/pulp-console/boknet-ca.pem \
DATABASE_URL='postgresql://...' \
.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
```
---
## 4. 상시 구동 (systemd 예시)
`/etc/systemd/system/pulp-console.service`:
```ini
[Unit]
Description=Pulp 패치 관리 콘솔
After=network.target
[Service]
WorkingDirectory=/opt/pulp-console
EnvironmentFile=/opt/pulp-console/.env
ExecStart=/opt/pulp-console/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=on-failure
User=pulpconsole
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload && sudo systemctl enable --now pulp-console
```
`.env`(권한 600)에 `PULP_*`, `DATABASE_URL`, `PULP_CA_FILE` 등 기입.
---
## 5. 구동 확인
```bash
curl 127.0.0.1:8000/healthz # {"ok": true} (앱 생존)
curl 127.0.0.1:8000/pulp-status # Pulp 연결 배지 (실패 시 사유)
```
브라우저로 대시보드 접속 → 실제 저장소 목록 확인. 연동 검증은 `PULP_INTEGRATION.md §5` 체크리스트 참고.
---
## 6. 업데이트(다음 버전 반입)
- 소스만 바뀌면: 새 `pulp-console-src.tgz`만 반입해 교체 후 `systemctl restart pulp-console`.
- 의존성이 바뀌면(`requirements.txt` 변경): `vendor/`에 새 wheel도 함께 반입 후 `pip install --no-index --find-links vendor -r requirements.txt` 재실행.
---
## venv / pip 가 없을 때 (대안)
venv는 필수가 아니다. **필요한 건 Python 인터프리터 하나**뿐. 점점 더 미니멀한 순서:
**① venv만 없음 — `pip --target` + PYTHONPATH**
```bash
python3 -m pip install --no-index --find-links vendor --target ./libs -r requirements.txt
PYTHONPATH=./libs python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000
```
**② pip도 없음 — pip wheel 부트스트랩** (반입물에 `pip-*.whl` 포함: `pip download pip -d vendor/`)
```bash
python3 vendor/pip-*.whl/pip install --no-index --find-links vendor --target ./libs -r requirements.txt
# wheel 은 zip 이라 설치 없이 바로 실행됨. (또는 python3 -m ensurepip)
```
**③ pip 자체가 불가 — wheel 압축해제 + PYTHONPATH** (pip 0개)
```bash
mkdir libs && cd libs
for w in ../vendor/*.whl; do unzip -oq "$w"; done # wheel=zip, 컴파일 .so 포함
cd .. && PYTHONPATH=./libs python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000
```
**④ Python 자체가 없음 — 독립 실행형 Python 반입**
- python-build-standalone(astral-sh) tarball을 풀어 그 안의 `bin/python3`로 ①~③ 수행.
단, 그 Python 버전에 맞춰 wheelhouse를 받을 것(§0).
systemd 로 상시 구동 시 `ExecStart`를 위 실행 커맨드로 바꾸면 된다
(예: `Environment=PYTHONPATH=/opt/pulp-console/libs` + `ExecStart=/usr/bin/python3 -m uvicorn ...`).
---
## 폐쇄망에 이미 있어야 하는 것
- **Python 3.x** (타깃 버전 — §0). venv/pip 없어도 위 대안으로 가능.
- **Pulp 서버** (붙는 대상, 이미 존재)
- **Postgres** (감사 로그용; 없으면 배포는 되나 "감사 로그 기록 실패" 경고)
- **사내 CA(.pem)** (HTTPS Pulp 검증)

486
MANUAL.md
View File

@@ -1,486 +0,0 @@
# 개발환경 사용 매뉴얼 (Coder)
사내 개발은 **Coder**(웹 기반 개발 워크스페이스)에서 합니다.
브라우저만 있으면 됩니다.
이 개발환경에는 DB, MinIO, Coding Agent, git, 배포환경이 미리 연결돼 있어, 로그인 후 바로 이용할 수 있습니다.
---
## 0. 준비물
- 인터넷 접속 가능한 브라우저 (Chrome/Edge 권장)
### 주소(URL) 한눈에
| 용도 | 주소 | 쓰는 곳 | 계정(ID/PW) |
|---|---|---|---|
| **개발 워크스페이스** | https://coder.bokdev.in | 로그인·VS Code (1~3번) | 웹메일 주소 / `bok1234!!행번` |
| **코드 저장소(Git)** | https://gitea.bokdev.in | repo 생성·push (11번) | 웹메일 주소 / `bok1234!!행번` |
| **배포(Coolify)** | https://coolify.bokdev.in | 앱 빌드·배포·로그 (12번) | 웹메일 주소 / `bok1234!!행번` |
| **파일저장소(MinIO 콘솔)** | https://minioc.bokdev.in | 업로드된 파일 눈으로 확인 (선택) |
| **개발 중 앱 미리보기** | `https://<자동생성>.coder.bokdev.in` | 로컬 실행 앱 브라우저 확인 (9·10번, VS Code PORTS가 자동 발급) |
| **배포된 앱 주소** | `https://<이름>.apps.bokdev.in` | 실제 서비스 확인 (12번, Coolify에서 도메인 지정) |
> 코드에서 쓰는 주소(자동 주입, 직접 입력 불필요): **DB** `10.200.0.152:5432` · **MinIO API** `https://minio.bokdev.in` · **AI 게이트웨이** `https://litellm.bok.or.kr`.
> (콘솔 `minioc` 와 API `minio` 는 다릅니다 — 코드는 `minio`, 사람이 눈으로 볼 땐 `minioc`.)
---
## 1. Coder 로그인
1. 브라우저에서 **https://coder.bokdev.in** 접속
2. 로그인
- **Username**: 본인 행번 (예: `2620227`)
- **Password**: `bok1234!!` + 본인 사번 (예: `bok1234!!2620227`)
→ 최초 로그인 후 비밀번호를 바꾸시기 바랍니다. (우측 상단 계정 메뉴 → `Account``Security`)
---
## 2. 워크스페이스 만들기 (최초 1회)
워크스페이스 = 본인 전용 개발용 컨테이너(VS Code + 각종 도구가 깔린 PC).
1. 로그인하면 **Workspaces** 화면. **`Create Workspace`** 클릭 (또는 **Templates → `aidev`** 선택)
2. 설정값 입력
- **Name**: 워크스페이스 이름 (예: `ws-aidev-<사번>` 또는 자유롭게)
- **External Authentication** : Gitea(그대로 둠)
- **CPU / Memory / Home disk size**: 기본값(2 Core / 4 GiB / 10 GiB)으로 두면 됩니다. 필요하면 나중에 늘릴 수 있습니다.
* (Optional) Gitea 계정 연동
* git 원격 계정을 사전 연동할 수 있습니다. 지금 연동하지 않아도 추후 workspace 터미널에서 연동 가능합니다.
* `External Authentication` - `애플리케이션 승인`
![Gitea 계정 연동](image-1.png)
3. **`Create Workspace`** 클릭
4. 워크스페이스가 빌드됩니다 (**처음엔 이미지 다운로드로 2~5분** 걸릴 수 있습니다. VS Code Web 까지 아이콘이 표시될 때까지 기다리세요).
- 상태가 **Running** 이 되면 준비 완료.
> 이 워크스페이스는 한번 만들면 계속 사용합니다. 한번 만들어진 워크스페이스는 가상 PC처럼 **Start** 만 누르면 됩니다.
---
## 3. VS Code 열기
1. 워크스페이스 화면에서 **`VS Code Web`** 버튼(두번째 아이콘) 클릭
2. 브라우저에 VS Code가 열리고, 자동으로 **`/home/coder/projects`** 폴더가 열립니다. (Yes. I trust the authors 클릭)
3. 그 안에 **`sample`** 폴더가 이미 있습니다 — 참조용 예제입니다(직접 고치지 말고 복사해 쓰세요, 7번 참고).
> 작업 파일은 반드시 **`/home/coder/projects`** 아래에 두세요. 이 폴더만 영구 보존됩니다.
> (워크스페이스를 stop/재시작해도 유지. 그 밖의 위치는 사라질 수 있습니다.)
---
## 4. 미리 연결된 것들 (별도 설정 불필요)
새 워크스페이스에는 아래가 자동으로 준비돼 있습니다.
| 항목 | 내용 |
|---|---|
| **개발 도구** | Java(JDK)/Maven, Node 22, Python 3.12, git, psql, `tree`, `net-tools`(netstat/ifconfig) |
| **컨테이너** | `podman` (그리고 `docker` 명령도 동일하게 동작 — podman 별칭) |
| **DB** | 본인 전용 Postgres 스키마에 자동 연결 (`$DATABASE_URL`) |
| **VS Code 확장** | Claude Code, Codex (이미 설치됨) |
| **AI CLI** | `claude`, `codex`, `gemini` (LiteLLM 게이트웨이 연동, 아래 5번 참고) |
---
## 5. AI 에이전트 사용 설정 (최초 1회) — LiteLLM 키 입력
Claude Code / Codex / Gemini 가 사내 AI 게이트웨이를 쓰려면 **본인 LiteLLM virtual key**가 필요합니다.
키는 **한 곳에만** 넣으면 모든 도구(CLI·확장)가 공유합니다.
1. 터미널 열기: VS Code 좌상단 맨위 메뉴 버튼(작대기 3개) **Terminal → New Terminal**
2. 화면 아래 터미널 창이 나오면, 다음 명령을 입력하고, 안내가 나오면 발급받은 본인 키(`sk-...`)를 붙여넣습니다:
```bash
update-litellm-key
```
```
LiteLLM virtual key 입력 (sk-...): sk-여기에-본인-키-붙여넣기
키 갱신 완료 (len=25). 현재 터미널에 즉시 적용됨.
```
> `update-litellm-key` 는 키를 파일에 저장하고 **현재 터미널에 바로 적용**합니다.
> 터미널을 새로 열거나 `source` 할 필요가 없습니다. 키를 바꿀 때도 같은 명령을 다시 쓰면 됩니다.
3. 확인:
```bash
echo $ANTHROPIC_BASE_URL # https://litellm.bok.or.kr 가 나오면 정상
claude # Claude Code CLI 실행
```
```bash
echo $GOOGLE_GEMINI_BASE_URL # https://litellm.bok.or.kr 가 나오면 정상
gemini # Gemini CLI 실행
```
```bash
echo $OPENAI_BASE_URL # https://litellm.bok.or.kr/v1 가 나오면 정상
codex # Codex CLI 실행
```
각 도구의 **기본 모델**은 사내 게이트웨이에 맞춰 미리 설정돼 있습니다(바꾸려면 각 설정 파일 수정):
| 도구 | 기본 모델 | 설정 파일 |
|---|---|---|
| Claude Code | `claude-opus-4-8` | `~/.claude/settings.json` |
| Codex | `gpt-5.5` | `~/.codex/config.toml` |
| Gemini | `gemini-3.1-pro-preview` | `~/.gemini/settings.json` |
> 키는 본인 워크스페이스 안에만 저장되고, 코드/깃에는 올라가지 않습니다.
> 게이트웨이 주소(`https://litellm.bok.or.kr`)는 이미 설정돼 있으니 건드릴 필요 없습니다.
---
## 6. Git(Gitea) 사용 — 최초 1회 승인
코드 저장소는 사내 **Gitea**(https://gitea.bokdev.in)입니다. 토큰 입력 없이 자동 인증됩니다.
1. 터미널에서 처음 `git clone` / `git push` 등을 하면, **Gitea 승인 화면**으로 안내됩니다.
- 또는 Coder 화면의 **`Gitea`** external auth 항목에서 **Authorize** 를 미리 눌러도 됩니다.
2. 한 번 **Authorize(승인)** 하면, 이후로는 비밀번호 입력 없이 clone/push 가 됩니다.
예:
```bash
cd ~/projects
## 만약 새로운 repo를 다운로드 하고 싶을 경우
git clone https://gitea.bokdev.in/<org>/<repo>.git
## 사용예
git clone https://gitea.bokdev.in/playground/sample.git
## 새로운 버전으로 동기화
cd ~/projects/sample
git add .
git pull
# gitea 행번 / 비밀번호
```
---
## 7. 새 프로젝트 시작하기 (sample 복사)
`~/projects/sample` 은 바로 돌려볼 수 있는 Node 예제이며 **읽기 전용 참조**입니다.
직접 고치지 말고, **`new-project` 명령으로 복사**해서 본인 프로젝트를 시작하세요.
**(1) 터미널 열기** — VS Code 상단 메뉴 **Terminal → New Terminal** (화면 아래쪽에 터미널 창이 뜹니다)
**(2) 프로젝트 만들기** — 터미널에 입력:
```bash
new-project myapp # sample 을 ~/projects/myapp 으로 복사 + .project-env(DB/S3) 자동생성
```
> `myapp` 은 예시입니다. 원하는 이름으로 바꿔도 됩니다.
**(3) VS Code 로 그 폴더 열기** — 만든 폴더를 편집기에 띄웁니다(왼쪽 파일 목록에 보이게):
- 상단 메뉴 **File → Open Folder…**
- 경로 입력칸에 **`/home/coder/projects/myapp`** 입력 → **OK**
- (또는 왼쪽 맨 위 📁 **Explorer** 아이콘 → **Open Folder** 버튼)
- 창이 새로고침되며 왼쪽에 `myapp` 의 파일들이 보이면 성공입니다.
> 폴더를 열면 VS Code 가 그 폴더를 "작업 공간"으로 삼습니다. 이제 왼쪽 목록에서 파일을 눌러 편집하고,
> 터미널도 자동으로 그 폴더(`~/projects/myapp`)에서 시작됩니다.
**(4) 라이브러리 설치** — 다시 터미널(Terminal → New Terminal)에서:
```bash
npm install # 처음 1회: 필요한 라이브러리 설치 (이걸 안 하면 실행이 실패합니다)
```
여기까지 하면 프로젝트 준비 완료입니다. **이제 8번부터 본격적으로 개발**하면 됩니다(코드 편집 → AI 활용 → 실행 → 배포).
> **`.project-env` 가 그 프로젝트의 설정 파일입니다** (DB·S3). 값을 바꾸려면 이 파일 한 곳만 고치면 됩니다.
> 프로젝트 폴더에 들어가면(cd) 자동으로 환경변수에 반영됩니다. (git에는 올라가지 않습니다)
> LiteLLM 키만은 프로젝트가 아니라 워크스페이스 전체 공용이라 `~/.env`(5번 `update-litellm-key`)에서 관리합니다.
---
## 📌 전체 개발 흐름 (한눈에)
```
7. 새 프로젝트 만들기 (new-project → 폴더 열기 → 설치) ← 위에서 완료
8. 개발하기 (코드 편집 + AI + DB/S3 사용)
9. 로컬에서 실행·확인 (npm run dev → 브라우저 미리보기)
10. 컨테이너로 빌드·확인 (podman build/run — 배포와 동일한 방식으로 검증)
11. Gitea에 올리기 (git push)
12. Coolify로 배포·확인 (실제 서비스로 띄우기)
```
아래는 각 단계를 순서대로 설명합니다.
---
## 8. 개발하기
(7번에서 **File → Open Folder 로 본인 프로젝트 폴더를 열어 둔 상태**에서)
- **코드 편집**: 왼쪽 파일 목록에서 파일(예: `src/server.js`)을 눌러 수정 → **Ctrl+S 로 저장**.
- **AI 도구 활용**: 터미널에서 `claude`(또는 `codex`/`gemini`) 실행. **반드시 본인 프로젝트 폴더 안에서** 실행해야 그 프로젝트를 봅니다(폴더를 Open Folder 해뒀으면 새 터미널은 이미 그 폴더에서 시작). 키 설정은 5번.
- **내 DB 직접 접속**(필요 시):
```bash
cd ~/projects/<본인 프로젝트> # 프로젝트 폴더에서 (그래야 .project-env 가 로드됨)
psql "$DATABASE_URL" # 본인 전용 스키마로 바로 접속됨
```
- **참고**: DB(`$DATABASE_URL`)·S3 접속정보는 `.project-env` 에 이미 들어 있고, 프로젝트 폴더에 들어가면 자동 적용됩니다. 코드에서는 그냥 `process.env.DATABASE_URL` 등으로 쓰면 됩니다.
### 8-1. AI 도구를 잘 쓰는 법 (팁)
- **항상 프로젝트 폴더 안에서 실행**하세요. AI 는 "지금 폴더"의 파일을 읽어 맥락을 잡습니다.
- **`CLAUDE.md` 를 활용**하세요. sample 에는 이미 `CLAUDE.md`(이 프로젝트 규칙: podman 개발, Coolify 배포, 비밀값 금지 등)가 들어 있고, Claude 가 자동으로 읽습니다. 프로젝트 규칙·주의사항을 여기에 적어두면 AI 가 그대로 따릅니다.
- **구체적으로 시키세요**. "로그인 API 만들어줘" 보다 "`src/` 에 POST /login 엔드포인트 추가, 입력 검증하고 실패 시 401 반환" 처럼.
- **확인은 직접**: AI 가 만든 코드도 9번(로컬 실행)·10번(컨테이너)으로 **반드시 본인이 동작 확인** 후 커밋하세요.
- 키가 안 먹으면(401) → 5번 `update-litellm-key`. 모델 오류(400)면 → 5번 표의 모델명.
### 8-2. bkit — AI 개발 보조 플러그인 (선택, 권장)
**bkit**(Vibecoding Kit, https://www.bkit.ai/ )은 Claude Code 에 **체계적 개발 절차(PDCA: 계획→설계→구현→검증)** 와 다수의 전문 스킬을 더해주는 플러그인입니다. "무엇을 만들지"만 설명하면 bkit 이 계획부터 구현·검증까지 단계적으로 진행해 줍니다.
**설치 (최초 1회, Claude Code 안에서 입력)**
```
claude
# claude cli 프롬프트에서
/plugin marketplace add popup-studio-ai/bkit-claude-code
/plugin install bkit
```
**vscode extension (web)에서는 plugin 을 이용할 수 없습니다. plugin 을 이용하려면 cli 를 이용하십시오.**
**자주 쓰는 명령** (Claude Code 프롬프트에 입력)
- `/pdca pm <기능이름>` — 기능 하나를 계획→구현→검증까지 한 번에. **처음엔 이거 하나면 충분**합니다.
하지만 보통은
- `/pdca plan `
- `/pdca design`
- `/pdca do`
- `/pdca analyze`
- `/pdca iterate`
- `/sprint` — 여러 기능을 묶은 릴리스 단위 작업.
- `/control` — AI 가 얼마나 자동으로 진행할지(자율도) 조절.
> bkit 의 세부 절차를 몰라도 됩니다 — **원하는 걸 자연어로 말하면** bkit 이 알맞은 흐름을 골라 줍니다.
> 더 알아보려면 공식 사이트(bkit.ai) / GitHub(`popup-studio-ai/bkit-claude-code`) 참고.
---
## 9. 로컬에서 실행하고 브라우저로 확인하기
코드를 바로 실행해 동작을 확인하는 단계입니다(가장 빠름).
> **빌드·테스트는 4단계로 점점 "실제 배포에 가깝게" 검증합니다.**
> | 단계 | 무엇으로 | 무엇을 확인 | 어디서 |
> |---|---|---|---|
> | **9. 소스 실행** | `npm run dev` | 코드 로직 (가장 빠름) | 워크스페이스 |
> | **10. 컨테이너 빌드** | `podman build`+`run` | Dockerfile 이 제대로 빌드/기동되는지 | 워크스페이스 |
> | **11. Git push** | `git push` | 배포에 쓸 코드를 저장소에 올림 | Gitea |
> | **12. 배포 빌드** | Coolify | 실제 서비스로 빌드/기동 | Coolify |
> 앞 단계가 통과해야 뒤 단계가 거의 그대로 됩니다(같은 코드·같은 Dockerfile). 막히면 **앞 단계로 돌아가** 고치세요.
**1) 실행**
```bash
cd ~/projects/<본인 프로젝트>
npm run dev # 코드를 고치면 자동으로 다시 시작됨(핫리로드)
```
실행되면 터미널에 `sample listening on :3000` 같은 줄이 뜹니다. **에러 없이** 이 줄이 보이면 기동 성공입니다.
(빨간 에러가 뜨면 거의 ① `npm install` 안 함 ② 코드 문법 오류 ③ `.project-env` 미로딩(=폴더 밖에서 실행) 셋 중 하나입니다.)
**2) 연결만 빠르게 점검**(앱 안 띄우고 DB/파일저장소 자격증명·연결 확인):
```bash
npm run db:check # → "DB OK: { now: 2026-... }" 이면 DB 연결 정상
npm run minio:check # → "S3 OK: { bucket: 'coolify-user-data', sampleKeys: [...] }" 이면 정상
```
> `DB FAIL` / `S3 FAIL` 이 나오면 `.project-env` 값(host·키)을 확인하세요. **여기서 통과하면 배포 환경에서도 거의 됩니다.**
**3) 브라우저로 미리보기** — 워크스페이스는 사내 클러스터 안이라 `localhost:3000` 이 PC 브라우저엔 바로 안 열립니다. VS Code 의 포트 기능을 씁니다:
1. VS Code 하단 **`PORTS`** 탭 클릭 → **`Forward a Port`** → 포트 번호(`3000`) 입력
(앱이 뜨면 자동 감지해 알림이 뜨기도 합니다)
2. 포워딩된 포트 옆 **🌐 (Open in Browser)** 클릭 → 새 탭에 앱이 열립니다.
- 열리는 주소: `https://<자동생성>.coder.bokdev.in` (본인 전용 임시 URL, 사내 정식 TLS)
![브라우저 오픈](image-3.png)
**4) 엔드포인트로 동작 확인** — 브라우저 주소 뒤에 경로를 붙이거나, 터미널에서 `curl` 로 확인합니다. **각 응답의 `"ok": true` 와 아래 기대값을 확인**하세요: (서비스 실행 상태에서)
| 경로 | 의미 | 정상 응답(예) |
|---|---|---|
| `/healthz` | 앱이 살아있나 | `{"ok":true}` |
| `/db` | 내 Postgres 연결 | `{"ok":true,"now":"2026-..."}` |
| `/s3` | 파일저장소(MinIO) 연결 | `{"ok":true,"bucket":"coolify-user-data","sampleKeys":[...]}` |
```bash
curl 127.0.0.1:3000/healthz # {"ok":true}
curl 127.0.0.1:3000/db # DB 연결 + 현재시각
curl 127.0.0.1:3000/s3 # 버킷명 + 파일목록 일부
```
> `"ok": false` + `error` 가 보이면 그 메시지가 원인입니다(예: DB 비번 틀림, 버킷 없음). `/db 500` 은 보통 `.project-env` 의 `DATABASE_URL` 문제입니다.
> 이 임시 URL 은 본인만 접근 가능하고 워크스페이스를 끄면 사라집니다. **미리보기용**이며, 정식 배포는 12번입니다.
---
## 10. 컨테이너로 빌드해서 확인하기 (podman) — 배포 전 점검
9번은 코드를 그냥 실행한 것이고, 실제 배포(Coolify)는 **Dockerfile 로 컨테이너를 빌드**해서 띄웁니다.
배포 전에 **같은 방식(컨테이너)으로 한 번 돌려보면** 배포 후 문제를 미리 잡을 수 있습니다.
(Coolify 도 똑같은 `Dockerfile` 을 쓰므로, **여기서 빌드가 되면 12번 배포 빌드도 거의 됩니다.**)
**1) 빌드**
```bash
cd ~/projects/<본인 프로젝트>
podman build -t myapp . # 현재 폴더의 Dockerfile 로 이미지 빌드
```
(`docker build ...` 도 동일하게 동작합니다 — 실제 런타임이 podman 일 뿐입니다.)
**빌드 로그 읽는 법** — 한 줄씩 `STEP 1/9`, `STEP 2/9` … 식으로 진행됩니다(이게 Dockerfile 의 각 명령). 마지막에
```
COMMIT myapp
Successfully tagged localhost/myapp:latest
<이미지ID>
```
가 보이면 **빌드 성공**입니다. 빌드된 이미지는 `podman images` 로 확인할 수 있습니다.
- **실패하면** 빨간 `Error:` 줄과 **몇 번째 STEP 에서 멈췄는지**를 보세요. 가장 흔한 건 `npm ci`/`npm install` 단계 실패(=의존성 문제, `package.json` 확인)입니다.
**2) 실행**
```bash
podman run --rm -p 3000:3000 --env-file .project-env myapp # 빌드한 이미지를 컨테이너로 실행
# 또는 (빌드+실행 한 번에): podman compose up --build
```
- `--env-file .project-env` 로 DB/S3 값을 컨테이너에 넣어줍니다(이게 없으면 컨테이너 안에서 `/db`·`/s3` 가 실패합니다).
- 기동되면 9번과 똑같이 `sample listening on :3000` 이 보입니다.
**3) 동작 확인** — 컨테이너 접속 확인은 **`127.0.0.1`** 로 하세요(`localhost` 가 간혹 안 잡힙니다). 기대 응답은 9번 표와 동일합니다:
```bash
curl 127.0.0.1:3000/healthz # {"ok":true}
curl 127.0.0.1:3000/db # {"ok":true,"now":"2026-..."}
curl 127.0.0.1:3000/s3 # {"ok":true,"bucket":"coolify-user-data",...}
```
- 브라우저로 보려면 9번과 동일하게 **PORTS 패널 → Forward Port 3000 → Open in Browser**(`https://<자동생성>.coder.bokdev.in`).
- 확인이 끝나면 터미널에서 **Ctrl+C** 로 컨테이너를 멈춥니다(`--rm` 이라 자동 삭제됨).
* 종료되지 않는 경우
```bash
podman ps
podman stop {이름 또는 ID}
```
- 빌드/실행 권한은 워크스페이스에 이미 설정돼 있습니다(별도 설정 불필요).
> 여기서 **빌드 성공 + 3개 엔드포인트 모두 `ok:true`** 면 Coolify 배포도 거의 그대로 됩니다. 안 되면 코드/Dockerfile 을 먼저 고치세요.
---
## 11. Gitea(git)에 올리기
`new-project` 로 만든 폴더는 아직 git 저장소가 아닙니다(`.git` 없음). 아래처럼 올립니다.
(배포(12번)는 Gitea repo 를 받아서 빌드하므로, 배포 전에 반드시 올려야 합니다.)
> 아래 명령의 `myapp` 은 **본인이 `new-project` 로 만든 프로젝트 이름**으로 바꿔 쓰세요.
**1) Gitea에 빈 저장소 만들기**
- **https://gitea.bokdev.in** → 우측 상단 **`+` → New Repository**
- Repository Name 입력(예: `myapp`)
- **README/.gitignore/License 는 체크하지 마세요**(빈 저장소여야 충돌이 없습니다) → Create
- 생성되면 주소가 나옵니다: `https://gitea.bokdev.in/<본인사번>/myapp.git`
**2) 워크스페이스 터미널에서 올리기**
```bash
cd ~/projects/myapp
git init
git add .
git commit -m "first commit(혹은 자유롭게)"
git branch -M main
git remote add origin https://gitea.bokdev.in/<본인사번>/myapp.git
git push -u origin main
```
- 인증은 자동입니다(6번 Gitea 승인을 한 번 했다면). 처음이면 승인 화면이 한 번 뜹니다.
- 이후 수정한 뒤에는 `git add . && git commit -m "..." && git push` 만 반복하면 됩니다.
> **순서는 상관없습니다.** 코드를 먼저 만들고(권장) 나중에 저장소를 만들어도 됩니다. `git push` 시점에 Gitea 저장소만 있으면 됩니다.
> **`.project-env` 는 git에 올라가지 않습니다**(DB·S3 자격증명 보호 — 정상). `git status` 에 안 보여도 맞습니다. 배포 환경값은 Coolify에 따로 넣습니다(12번).
---
## 12. Coolify로 배포하고 확인하기
운영(실제 서비스) 배포는 **Coolify**가 담당합니다. 흐름은 "**Gitea에 push → Coolify가 Dockerfile로 자동 빌드·배포**" 입니다. (먼저 11번으로 Gitea에 올려두세요.)
### 1) 앱(리소스) 만들기
1. **Coolify**(https://coolify.bokdev.in) 로그인.
2. **`aidev` 팀**으로 설정되어 있는지 확인.
3. 좌측 메뉴 **`Projects`** → **`aidev`**로 서버 및 DB가 연결된 프로젝트 환경 선택
4. `aidev` 프로젝트로 들어가면 환경(기본 **`production`**)이 보입니다. 거기서 **`+ New`** 클릭.
5. 리소스 종류 선택 화면에서 **`Public Repository`**(Git 기반 Public) 선택.
![New Resource 선택](image-8.png)
6. **Repository URL 은 반드시 전체 주소**로 입력: `https://gitea.bokdev.in/<본인사번>/myapp.git`
(`<본인사번>/myapp` 처럼 줄여 쓰면 clone 이 실패합니다.) `Check Repository`를 눌러 연결을 확인합니다.
![New Application 생성](image-5.png)
7. **Build Pack** 을 **`Dockerfile`** 로 지정, Branch `main`, Port `3000`.
- (배포할 서버를 고르는 항목이 있으면 `localhost` 선택. 서버가 안 보이면 0번 팀 확인으로 돌아가세요.)
### 2) 도메인(공개 주소) 지정 — 앱의 **Configuration → Domains**
- **`Generate Domain`(자동 생성) 버튼을 누르면** `https://<랜덤>.apps.bokdev.in` 형태로 **자동 입력**됩니다
- 원하는 이름으로 바꾸려면 **`https://<원하는이름>.apps.bokdev.in`** 으로 직접 입력하세요(예: `https://myapp.apps.bokdev.in`).
- 반드시 **`.apps.bokdev.in` 로 끝나는** 주소여야 외부에서 https 로 열립니다(와일드카드 TLS·라우팅이 이 대역에만 준비돼 있음). `<이름>` 은 **남과 겹치지 않게**(예: `사번-앱이름`). 미리보기(9번)의 `*.coder.bokdev.in` 과는 별개 대역입니다.
### 3) 환경변수 입력 (앱마다 **최초 Deploy시 1회만**, 이후 배포엔 유지됨)
- Coolify의 **Environment Variables** 에 입력: `DATABASE_URL`, `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `PORT`
![환경변수 추가](image-6.png)
- 가장 쉬운 방법: 워크스페이스 `.project-env` 파일을 열어 그 안의 값을 **`Developer view`에서 그대로 복사**해 넣으세요.
![환경변수 일괄 입력](image-7.png)
- 파일 보기: 터미널에서 `cat ~/projects/myapp/.project-env` (또는 VS Code 에서 그 파일 열기)
- `DATABASE_URL` 은 아래처럼 생겼습니다. **`%20`·`%3D` 까지 그대로** 넣으세요(libpq 인코딩이라 빼면 DB 연결이 깨집니다):
```
DATABASE_URL=postgresql://emp_<사번>:<pw>@10.200.0.152:5432/appdb?options=-c%20search_path%3Demp_<사번>
```
### 4) 배포(빌드) 실행 + 로그 보기
1. 앱 화면에서 **Deploy** 클릭.
2. **Deployments** 탭(또는 우측 알림)에서 방금 빌드를 누르면 **빌드 로그가 실시간으로** 흐릅니다. 단계는 대략:
- `Cloning ...` (Gitea 에서 코드 받기) → `Building image ...`(여러분의 `Dockerfile` 로 빌드, 10번 podman build 와 같은 STEP 들) → `Starting container ...` → **`New container started`** / `Deployment finished` 가 보이면 **배포 성공**.
3. **실패하면 로그의 빨간 줄**을 보세요. 위치로 원인이 갈립니다:
- `Cloning` 에서 실패 → Repository URL(전체 주소) 문제 (FAQ `does not appear to be a git repository`).
- `Building` 에서 실패 → Dockerfile/의존성 문제. **10번에서 `podman build` 가 됐다면 여기서도 거의 됩니다** → 안 되면 push 한 코드가 최신인지(11번) 확인.
- 빌드는 됐는데 컨테이너가 `Unhealthy`/재시작 반복 → 보통 Env 누락(아래 5번 확인).
### 5) 배포된 앱 주소 확인 + 테스트
1. 앱 주소는 **1)-3에서 지정한 `https://<원하는이름>.apps.bokdev.in`** 입니다(배포가 끝나면 그 주소로 외부에서 열립니다).
2. 그 주소로 **9·10번과 똑같이** 엔드포인트를 확인합니다(브라우저 또는 PC 터미널 `curl`):
```bash
curl https://<원하는이름>.apps.bokdev.in/healthz # {"ok":true} ← 앱 기동 OK
curl https://<원하는이름>.apps.bokdev.in/db # {"ok":true,"now":...} ← DB Env OK
curl https://<원하는이름>.apps.bokdev.in/s3 # {"ok":true,"bucket":...} ← S3 Env OK
```
- `/healthz` 만 되고 `/db`·`/s3` 가 500 이면 → **Coolify Env 문제**(2번). `.project-env` 값과 정확히 같은지(특히 `DATABASE_URL` 의 `%20`/`%3D`) 다시 확인하고 재배포.
3. 이후 코드를 고쳐 **`git push` 하면 자동으로 다시 빌드·배포**됩니다(Deployments 에서 새 빌드 로그 확인 → 같은 주소로 재확인).
> 자주 막히는 것: ① Repository URL 전체주소 ② `DATABASE_URL` host 가 `10.200.0.152` + 인코딩(`%20`/`%3D`) 그대로인지 ③ Env 를 앱에 저장했는지 ④ 도메인을 `.apps.bokdev.in` 으로 지정했는지. (FAQ 참고)
---
## 13. 워크스페이스 켜고 끄기
- **그만 쓸 때**: Coder 워크스페이스 화면 → **Stop** (자원 절약. 파일은 보존됩니다.)
- **다시 쓸 때**: **Start** (수십 초 내 기동)
- **업데이트 안내가 뜨면**(`Update` 버튼): 눌러서 최신 환경으로 갱신하세요. `~/projects` 파일은 유지됩니다.
---
## 자주 묻는 것
- **Q. 파일이 사라졌어요** → `~/projects` 밖에 저장했을 가능성. 작업물은 항상 `~/projects` 아래에.
- **Q. AI 도구가 인증 오류(401)** → `update-litellm-key` 를 다시 실행해 본인 키를 입력하세요 (5번). 키가 `sk-` 로 시작하는지 확인.
- **Q. AI 도구가 `Invalid model` 오류(400)** → 인증은 됐지만 모델명이 게이트웨이에 없는 경우. 5번 표의 기본 모델명을 쓰세요.
- **Q. `$DATABASE_URL` 이 비어있어요** → 프로젝트 폴더 안에서 실행했는지 확인하세요. `.project-env` 는 그 폴더에 `cd` 해야 적용됩니다(7번).
- **Q. sample 을 고쳤는데 git pull 이 안 돼요** → sample 은 참조용(읽기 전용)입니다. `new-project <이름>` 으로 복사한 폴더에서 작업하세요(7번).
- **Q. git push가 인증을 물어봐요** → Gitea Authorize를 한 번도 안 했을 때. 6번 참고.
- **Q. Coolify 배포가 `does not appear to be a git repository` 로 실패해요** → Repository URL 을 전체 주소(`https://gitea.bokdev.in/...git`)로 넣었는지 확인하세요(12번).
- **Q. 배포한 앱에서 DB 연결이 안 돼요(`/db` 500)** → ① Coolify Env 의 `DATABASE_URL` host 가 `10.200.0.152` 인지, ② 끝의 `?options=-c%20search_path%3D...` 가 `%20`/`%3D` 까지 그대로인지 확인하세요. 직접 타이핑하다 인코딩을 빼면 깨집니다 — `.project-env` 값을 그대로 복사하는 게 가장 안전합니다(12번). 컨테이너명/내부 DNS 는 배포 환경에선 안 됩니다.
- **Q. `npm run dev` 가 `Cannot find package 'express'` 로 실패해요** → 처음 1회 `npm install` 을 안 한 경우입니다. 프로젝트 폴더에서 `npm install` 후 다시 실행하세요(7번).
- **Q. `podman build` 가 `npm ci`/`npm install` 단계에서 실패해요** → 의존성 문제입니다. 먼저 워크스페이스에서 `npm install` 이 되는지 확인하고, `package.json` 에 빠진 패키지가 없는지 보세요(10번).
- **Q. Coolify 빌드는 성공했는데 앱이 안 떠요(Unhealthy/재시작 반복)** → 보통 Env 누락입니다. `/healthz` 만 확인해 보고, DB/S3 Env(2번)를 `.project-env` 그대로 넣었는지 확인 후 재배포하세요(12번).
- **Q. Coolify 가 옛날 코드로 빌드돼요** → `git push` 가 됐는지(11번), Coolify 의 Branch 가 `main` 인지 확인하세요. push 후 Deploy(또는 자동배포)를 다시 도세요.
- **Q. 배포된 앱 주소가 안 열려요(접속 안 됨 / 인증서 경고)** → 도메인을 **`.apps.bokdev.in` 으로 끝나게** 지정했는지 확인하세요(12번 1)-3). 그 대역만 외부 https 가 준비돼 있습니다. 다른 대역(예: `*.bokdev.in` 루트, 임의 도메인)은 안 열리거나 인증서 경고가 납니다. 이름이 남과 겹쳐도 충돌하니 `사번-앱이름` 처럼 고유하게 정하세요.
- **Q. 빌드/실행은 됐는데 브라우저로 안 열려요** → 워크스페이스 앱은 `localhost` 가 PC 에서 안 열립니다. VS Code **PORTS → Forward 3000 → Open in Browser**(`https://<자동생성>.coder.bokdev.in`)로 여세요(9번). 배포된 앱은 Coolify 가 준 공개 주소로 엽니다(12번).
- **Q. DB가 비어있어요** → 정상입니다. 본인 전용 빈 스키마가 제공됩니다. 테이블은 직접 만들면 됩니다.
- **Q. K8s(쿠버네티스)는 어떻게 봐요?** → 직원은 K8s에 직접 접근하지 않습니다. DB/스토리지/배포는 위 도구들로 충분합니다.
---
문의: 인프라 담당자
---
- TODO: md 미리보기로 보면 bash 입력인지 claude 입력인지 구분이 잘 안됨

139
PULP_INTEGRATION.md Normal file
View File

@@ -0,0 +1,139 @@
# 실제 Pulp 연동 가이드
이 콘솔은 **BFF**로서 실제 **Pulp 3** 서버의 REST API(`/pulp/api/v3/`)에 붙어 동작한다.
지금까지 본 화면은 `PULP_DEMO=true`(가짜 인메모리 데이터)였고, **실연동하려면 Pulp 서버 +
환경변수**가 필요하다. 이 문서는 그 절차와, 실서버에서 흔히 보정이 필요한 지점을 정리한다.
---
## 0. 준비물
- **Pulp 3 서버** (RPM 플러그인 `pulp_rpm` 설치; DEB면 `pulp_deb`)
- Pulp **admin 계정**(또는 API 권한 계정) — 사용자/비밀번호
- 콘솔이 Pulp 주소에 **네트워크 도달 가능**할 것
- (선택) **Postgres** — 배포 감사 로그(`deploy_audit`) 저장용 `DATABASE_URL`
- 사내 HTTPS Pulp면 **사내 CA(.pem)** 또는 검증 끄기 옵션
---
## 1. (테스트용) Pulp 서버 빠르게 띄우기
실서버가 없으면 컨테이너로 올인원 Pulp를 띄워 테스트할 수 있다(podman/docker 동일).
```bash
mkdir -p ~/pulp/{settings,pgsql,storage}
podman run -d --name pulp -p 8080:80 \
-v ~/pulp/settings:/etc/pulp \
-v ~/pulp/pgsql:/var/lib/pgsql \
-v ~/pulp/storage:/var/lib/pulp \
docker.io/pulp/pulp
# 기동까지 1~2분. admin 비밀번호 설정:
podman exec -it pulp pulpcore-manager reset-admin-password
```
- API 주소: `http://localhost:8080/pulp/api/v3/`
- 상태 확인: `curl -u admin:<pw> http://localhost:8080/pulp/api/v3/status/`
> 폐쇄망 운영 Pulp는 인프라팀이 구축한 주소/계정을 그대로 쓰면 된다(위 컨테이너는 개발 검증용).
---
## 2. 화면에 보일 콘텐츠 만들기 (Remote → Repository → Distribution)
대시보드에 저장소가 보이려면 Pulp에 **Repository**가 있어야 하고, 동기화하려면 **Remote**가,
배포하려면 **Distribution**이 있어야 한다. `pulp` CLI(`pip install pulp-cli`)로 RPM 예시:
```bash
# pulp CLI 설정 (한 번)
pulp config create --base-url http://localhost:8080 --username admin --password <pw>
# 1) Remote (외부 미러 소스 정의) — 검증을 켜려면 gpgkey/tls_validation 설정
pulp rpm remote create --name rocky9-baseos \
--url https://dl.rockylinux.org/pub/rocky/9/BaseOS/x86_64/os/
# 2) Repository (그릇) + remote 연결
pulp rpm repository create --name rocky9-baseos --remote rocky9-baseos \
--autopublish # 동기화 후 자동 publication (배포 단계 단순화에 도움)
# 3) (콘솔에서 동기화해도 되고) CLI 동기화로 첫 스냅샷 생성
pulp rpm repository sync --name rocky9-baseos
# 4) Distribution (운영이 보는 공개 URL) — 배포 확정이 PATCH 할 대상
pulp rpm distribution create --name rocky9-baseos \
--base-path rocky9-baseos --repository rocky9-baseos
```
- 이렇게 하면 콘솔 대시보드에 `rocky9-baseos`가 뜨고, **동기화/버전 목록/배포 확정**을 UI에서 시험할 수 있다.
- **배포 확정**은 Distribution을 특정 publication으로 교체하므로, distribution이 미리 있어야 한다.
`--autopublish` + distribution을 repository에 연결해두면 우리 콘솔의 역추적(publication→version)이 잘 맞는다.
---
## 3. 콘솔 환경변수 설정
`.env`(루트, git 미추적) 또는 실행 환경에 설정. **`PULP_DEMO`는 빼거나 false.**
```bash
PULP_BASE_URL=http://localhost:8080 # 실제 Pulp 주소
PULP_USERNAME=admin
PULP_PASSWORD=******** # 비밀번호 (커밋 금지)
PULP_VERIFY_TLS=true # 사내 HTTPS+CA면 PULP_CA_FILE 사용
PULP_CA_FILE=/path/boknet-ca.pem # 사내 self-signed CA 경로(있을 때만)
DATABASE_URL=postgresql://... # 배포 감사 로그용(없으면 배포는 되나 기록 실패 경고)
# PULP_DEMO 미설정
```
> 비밀번호/`DATABASE_URL`은 코드/깃에 두지 않는다. Coolify 배포 시엔 Environment Variables에 넣는다.
---
## 4. 실행 & 확인
```bash
.venv/Scripts/python.exe -m uvicorn app.main:app --reload # Windows
```
| 확인 | 기대 |
|---|---|
| `GET /healthz` | `{"ok": true}` (앱 생존, Pulp와 무관) |
| `GET /pulp-status` | 🟢 "Pulp 연결됨" (실패 시 사유 표시) |
| 대시보드 `/` | 실제 저장소 목록·검증 배지·패키지 수 |
| 동기화 버튼 | sync task 시작 → 진행률 폴링 → 새 버전 |
| 버전 페이지 | 스냅샷 목록 + 현재 배포 버전 배지 |
| 배포 확정 | publication 생성 → distribution 교체 → 감사 로그 |
---
## 5. 실서버 첫 연동 시 보정 체크리스트
스펙/일반적 Pulp 구조 기준으로 구현했으므로, 실제 응답 형태에 따라 아래가 안 맞을 수 있다.
각 항목이 이상하면 해당 코드를 실제 JSON에 맞춰 조정한다.
| 증상 | 확인할 Pulp 필드 | 고칠 곳 |
|---|---|---|
| 검증 배지가 항상 ⚪/⚠️ | `repository.repo_config``gpgcheck` / `repo-gpgcheck` 키 형태 | `app/views.py` `gpg_status()` |
| 패키지 수·증감이 0 | `version.content_summary.present/added/removed` 구조 | `app/views.py` `_content_count()` |
| "현재 배포 버전" 배지가 안 뜸 | `distribution.publication` 유무 / `publication.repository_version` 경로, 또는 distribution이 `repository` 직접 배포(최신 추종) | `app/routes/versions.py` `_deployed_version_number()`, `app/routes/deploy.py` `_distribution_for_repo()` |
| 동기화 진행률/완료 안 잡힘 | task의 `progress_reports[]`, `created_resources[]`, `state` | `app/views.py` `task_progress()` |
| 배포가 distribution을 못 찾음 | repo↔distribution 매핑 규칙 | `app/routes/deploy.py` `_distribution_for_repo()` |
> 실서버 응답을 한 번 떠보면 빠르다: `curl -u admin:<pw> http://<pulp>/pulp/api/v3/repositories/rpm/rpm/ | jq`.
---
## 6. 트러블슈팅
- **`/pulp-status` 연결 실패** → 주소/포트/방화벽, HTTPS면 CA(`PULP_CA_FILE`) 또는 임시로 `PULP_VERIFY_TLS=false`.
- **401 Unauthorized** → `PULP_USERNAME`/`PULP_PASSWORD` 확인.
- **대시보드가 비어 있음** → Pulp에 Repository가 없거나 RPM 플러그인 미설치. §2로 콘텐츠 생성.
- **배포 버튼 비활성** → 해당 저장소가 검증(pass) 상태가 아님(= remote에 gpgkey/tls_validation 미설정 → `repo_config`에 gpgcheck 없음). 의도된 안전장치.
- **"감사 로그 기록 실패"** → `DATABASE_URL` 미설정/접속 불가. 배포 자체는 성공.
---
## 7. DEB(Ubuntu/Debian) 저장소
현재 라우트는 RPM(`/repositories/rpm/rpm/` 등) 경로에 맞춰져 있다. DEB는 `rpm/rpm` 자리를
`deb/apt`로 바꾸면 된다 — `app/pulp_client.py`의 경로를 콘텐츠 타입으로 파라미터화하면 양쪽을
지원할 수 있다(1차 MVP는 RPM 집중).

View File

@@ -15,6 +15,9 @@ from .pulp_client import PulpClient
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
# 데모(가짜 데이터) 모드를 모든 템플릿에서 참조 가능하게 전역 노출 → 배너/배지로 명시.
# 설정은 lru_cache 라 프로세스 내 상수. 실연동 오인 방지용(스펙: 데모는 화면 확인 전용).
templates.env.globals["demo_mode"] = get_settings().pulp_demo
def get_pulp_client() -> Iterator[PulpClient]:

View File

@@ -23,7 +23,7 @@ from ..pulp_client import PulpClient
router = APIRouter()
PAGE_SIZE = 20
PAGE_SIZE = 10
def _load_repos(

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -24,9 +24,16 @@
<script src="/static/htmx.min.js" defer></script>
</head>
<body>
{% if demo_mode %}
<div class="demo-banner" role="alert">
<span class="demo-dot"></span>
<span>데모 모드 — 화면의 저장소·버전·배포는 모두 <strong>가짜 데이터</strong>입니다.
실제 Pulp 에 연결되어 있지 않습니다.</span>
</div>
{% endif %}
<nav class="app-nav">
<div class="container">
<p class="brand"><span class="accent">Pulp</span> 패치 관리 콘솔</p>
<p class="brand"><span class="brand-mark">P</span>Pulp <span class="accent">패치 관리 콘솔</span></p>
<button class="theme-toggle" type="button" onclick="toggleTheme()"
aria-label="라이트/다크 전환" title="라이트/다크 전환">
<svg class="icon moon" viewBox="0 0 24 24" width="18" height="18" fill="none"

View File

@@ -1,50 +1,65 @@
<dialog open>
<dialog open class="deploy-dialog">
<article class="deploy-modal">
<header>
<h3>이 버전을 운영망에 배포</h3>
</header>
{% if error %}
<p class="status-line status-bad">{{ error }}</p>
{% endif %}
<p class="deploy-note">운영 서버(yum/dnf)가 실제로 내려받는 버전이 바뀝니다. 신중히 확인하세요.</p>
{% if preview %}
<table class="deploy-preview">
<tbody>
<tr>
<th>현재 운영</th>
<td class="num">{% if preview.current_version is not none %}v{{ preview.current_version }} · {{ preview.current_count }}개{% else %}없음{% endif %}</td>
</tr>
<tr>
<th>배포 대상</th>
<td class="num">v{{ preview.target_version }} · {{ preview.target_count }}개</td>
</tr>
<tr>
<th>순 변화</th>
<td class="num">
<span class="{{ 'delta-add' if preview.net_sign == '+' else 'delta-del' }}">{{ preview.net_sign }}{{ preview.net }}개</span>
</td>
</tr>
</tbody>
</table>
{% endif %}
<form hx-post="/repos/{{ uuid }}/deploy" hx-target="#modal" hx-swap="innerHTML">
<input type="hidden" name="version_href" value="{{ version_href }}">
<label class="confirm-label">
확인을 위해 저장소 이름 <strong class="mono">{{ repo_name }}</strong> 을(를) 입력하세요
<input type="text" name="confirm_name" autocomplete="off" required class="mono"
data-confirm="{{ repo_name }}"
oninput="document.getElementById('deploy-confirm-btn').disabled = (this.value !== this.dataset.confirm)">
</label>
{# TODO(auth): 로그인 도입 시 배포 비밀번호 재확인 입력 추가 #}
<div class="modal-actions">
<button type="button" class="ghost"
onclick="document.getElementById('modal').innerHTML=''">취소</button>
<button type="submit" id="deploy-confirm-btn" class="danger" disabled>배포 확정</button>
<div class="modal-head">
<span class="warn-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/></svg></span>
<div>
<h3>운영 배포 확인</h3>
<span class="head-sub">되돌리기 어려운 작업입니다</span>
</div>
</form>
</div>
<div class="modal-body">
{% if error %}
<p class="error-note" role="alert">
<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/></svg>
<span>{{ error }}</span>
</p>
{% endif %}
<p class="deploy-note" style="text-align:center">
<span class="repo-hl">{{ repo_name }}</span> 저장소를 운영망에 배포합니다.
운영 서버(yum/dnf)가 실제로 내려받는 버전이 바뀝니다.
</p>
{% if preview %}
<div class="deploy-preview">
<div class="pv-box">
<div class="pv-label">현재 (운영)</div>
{% if preview.current_version is not none %}
<div class="pv-ver">v{{ preview.current_version }}</div>
<div class="pv-pkg">{{ preview.current_count }} pkgs</div>
{% else %}
<div class="pv-ver">없음</div>
<div class="pv-pkg"></div>
{% endif %}
</div>
<div class="pv-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></div>
<div class="pv-box pv-target">
<div class="pv-label">대상</div>
<div class="pv-ver">v{{ preview.target_version }}</div>
<div class="pv-pkg">{{ preview.target_count }} pkgs · <span class="{{ 'delta-add' if preview.net_sign == '+' else 'delta-del' }}">{{ preview.net_sign }}{{ preview.net }}</span></div>
</div>
</div>
{% endif %}
<form hx-post="/repos/{{ uuid }}/deploy" hx-target="#modal" hx-swap="innerHTML">
<input type="hidden" name="version_href" value="{{ version_href }}">
<label class="confirm-label">확인을 위해 저장소 이름 <span class="repo-hl">{{ repo_name }}</span> 을(를) 입력하세요.
<div class="confirm-field" data-confirm="{{ repo_name }}">
<input type="text" name="confirm_name" autocomplete="off" required class="mono"
placeholder="{{ repo_name }}"
oninput="var f=this.closest('.confirm-field');var v=this.value.trim();var ok=(v===f.dataset.confirm);f.classList.toggle('is-valid',ok&&v!=='');f.classList.toggle('is-invalid',!ok&&v!=='');document.getElementById('deploy-confirm-btn').disabled=!ok;">
<svg class="confirm-state ok" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 13 4 4L19 7"/></svg>
<svg class="confirm-state bad" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>
</div>
</label>
{# TODO(auth): 로그인 도입 시 배포 비밀번호 재확인 입력 추가 #}
<div class="modal-actions">
<button type="button" class="ghost"
onclick="document.getElementById('modal').innerHTML=''">취소</button>
<button type="submit" id="deploy-confirm-btn" class="danger" disabled>배포 확정</button>
</div>
</form>
</div>
</article>
</dialog>

View File

@@ -5,8 +5,9 @@
<div id="flash" hx-swap-oob="true">
<div class="flash-banner">
<span class="flash-dot"></span>
<span>배포 완료 · <strong class="mono">v{{ to_version }}</strong>{% if from_version is not none %} <span class="flash-sub">(이전 v{{ from_version }})</span>{% endif %}</span>
<span class="flash-check"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 13 4 4L19 7"/></svg></span>
<span>배포 완료 · <strong class="mono">v{{ to_version }}</strong></span>
{% if from_version is not none %}<span class="flash-sub">이전 v{{ from_version }} · 되돌리기: 이전 버전 재배포</span>{% endif %}
{% if not audit_ok %}<span class="flash-warn">감사 로그 기록 실패</span>{% endif %}
</div>
</div>

View File

@@ -1,4 +1,6 @@
{% if online %}
{% if demo_mode %}
<span class="status-line status-demo">데모 모드 · 가짜 데이터</span>
{% elif online %}
<span class="status-line status-ok">{{ detail }}</span>
{% else %}
<span class="status-line status-bad">{{ detail }}</span>

View File

@@ -9,14 +9,21 @@
{% elif progress.idle %}
{# 추적 중인 작업 없음 — 빈 조각 #}
{% else %}
<div class="sync-progress">
{% if progress.total %}
<progress value="{{ progress.done }}" max="{{ progress.total }}"></progress>
<small class="num">{{ progress.done }}/{{ progress.total }} · {{ progress.percent }}%</small>
{% else %}
<progress></progress>
<small>준비 중…</small>
{% endif %}
{% if progress.total %}
<div class="sync-progress is-active">
<div class="sync-head">
<small>동기화 중</small>
<small class="sync-pct">{{ progress.percent }}%</small>
</div>
<progress value="{{ progress.done }}" max="{{ progress.total }}"></progress>
</div>
{% else %}
<div class="sync-progress is-active indeterminate">
<div class="sync-head">
<small>준비 중…</small>
</div>
<progress></progress>
</div>
{% endif %}
{% endif %}
</div>

View File

@@ -3,14 +3,39 @@
<p role="alert" class="status-line status-bad">저장소 목록을 불러오지 못했습니다: {{ error }}</p>
{% else %}
<div class="summary">
<div class="card"><div class="value">{{ summary.total }}</div><div class="label">전체 저장소</div></div>
<div class="card"><div class="value{% if summary.syncing %} is-accent{% endif %}">{{ summary.syncing }}</div><div class="label">동기화 중</div></div>
<div class="card"><div class="value is-success">{{ summary.gpg_pass }}</div><div class="label">GPG 검증 통과</div></div>
<div class="card"><div class="value">{{ summary.total_packages }}</div><div class="label">총 패키지</div></div>
<div class="card is-accent">
<div class="card-top">
<span class="label">전체 저장소</span>
<span class="card-ico"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg></span>
</div>
<div class="value is-accent">{{ summary.total }}</div>
</div>
<div class="card">
<div class="card-top">
<span class="label">동기화 중</span>
<span class="card-ico"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg></span>
</div>
<div class="value">{{ summary.syncing }}</div>
</div>
<div class="card is-success">
<div class="card-top">
<span class="label">GPG 검증 통과</span>
<span class="card-ico"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 8.5 8 10 4.6-1.5 8-5 8-10V5z"/><path d="m9 12 2 2 4-4"/></svg></span>
</div>
<div class="value is-success">{{ summary.gpg_pass }}</div>
</div>
<div class="card">
<div class="card-top">
<span class="label">총 패키지</span>
<span class="card-ico"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="m3.3 7 8.7 5 8.7-5M12 22V12"/></svg></span>
</div>
<div class="value">{{ summary.total_packages }}</div>
</div>
</div>
<div class="list-toolbar">
<!-- 검색창은 표 바깥에 둬서 입력 중 포커스 유지. 정렬 상태([data-state])를 함께 전송 -->
<svg class="search-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input id="repo-search" class="repo-search" type="search" name="q" value="{{ q }}"
placeholder="저장소 이름 검색"
hx-get="/repos/table" hx-target="#repo-table-wrap" hx-swap="outerHTML"

View File

@@ -9,11 +9,11 @@
{% macro sorth(col, label, extra="") %}
<th class="{{ extra }}">
<a href="#" class="th-sort{% if sort == col %} is-active{% endif %}"
<a href="#" class="th-sort{% if sort == col %} is-active{% if dir == 'desc' %} desc{% endif %}{% endif %}"
hx-get="/repos/table" hx-target="#repo-table-wrap" hx-swap="outerHTML"
hx-include="#repo-search"
hx-vals='{"sort": "{{ col }}", "dir": "{{ 'desc' if (sort == col and dir == 'asc') else 'asc' }}"}'>
{{ label }}{% if sort == col %} <span class="sort-ind">{{ '↑' if dir == 'asc' else '↓' }}</span>{% endif %}
{{ label }}{% if sort == col %} <span class="sort-ind"></span>{% endif %}
</a>
</th>
{% endmacro %}
@@ -41,11 +41,14 @@
<td class="mono">{{ repo.last_sync or "—" }}</td>
<td>
<div id="sync-{{ repo.uuid }}" class="row-action">
<button class="secondary btn-sm"
<button class="secondary btn-sm btn-icon"
hx-post="/repos/{{ repo.uuid }}/sync"
hx-target="#sync-{{ repo.uuid }}"
hx-swap="innerHTML"
hx-disabled-elt="this">동기화</button>
hx-disabled-elt="this">
<svg class="icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg>
동기화
</button>
</div>
</td>
</tr>

View File

@@ -1,46 +1,27 @@
<div id="version-list" class="table-card"{% if oob %} hx-swap-oob="true"{% endif %}>
<div id="version-list" class="version-list"{% if oob %} hx-swap-oob="true"{% endif %}>
{% if versions %}
<table>
<thead>
<tr>
<th class="num">버전</th>
<th>생성일시</th>
<th class="num">패키지</th>
<th class="num">증감</th>
<th>검증</th>
<th>배포 상태</th>
<th></th>
</tr>
</thead>
<tbody>
{% for v in versions %}
<tr>
<td class="num">v{{ v.number }}</td>
<td class="mono">{{ v.created or "—" }}</td>
<td class="num">{{ v.package_count }}</td>
<td class="num">
{%- if v.added %}<span class="delta-add">+{{ v.added }}</span>{% endif -%}
{%- if v.removed %} <span class="delta-del">{{ v.removed }}</span>{% endif -%}
{%- if not v.added and not v.removed %}—{% endif -%}
</td>
<td>{% if v.gpg %}<span class="badge badge-{{ v.gpg.level }}">{{ v.gpg.label }}</span>{% endif %}</td>
<td>{% if v.is_deployed %}<span class="badge badge-deployed">운영 배포 중</span>{% endif %}</td>
<td>
{% if v.is_deployed %}
{# 배포 상태 칸에 배지 표시 #}
{% elif v.deployable %}
<button class="secondary btn-sm"
hx-get="/repos/{{ uuid }}/deploy/confirm?version_href={{ v.href | urlencode }}"
hx-target="#modal" hx-swap="innerHTML">이 버전 배포</button>
{% else %}
<button class="secondary btn-sm" disabled title="검증을 통과하지 못해 배포할 수 없습니다">이 버전 배포</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% for v in versions %}
<div class="version-item">
<span class="ver-no">v{{ v.number }}</span>
{% if v.is_deployed %}<span class="badge badge-deployed">운영 배포 중</span>{% endif %}
{% if v.gpg %}<span class="badge badge-{{ v.gpg.level }}">{{ v.gpg.label }}</span>{% endif %}
<span class="ver-meta">{{ v.created or "—" }} · {{ v.package_count }} pkgs
{%- if v.added %} · <span class="delta-add">+{{ v.added }}</span>{% endif -%}
{%- if v.removed %} <span class="delta-del">{{ v.removed }}</span>{% endif -%}
</span>
<span class="spacer"></span>
{% if v.is_deployed %}
<button class="ghost btn-sm" disabled>현재 운영 버전</button>
{% elif v.deployable %}
<button class="secondary btn-sm"
hx-get="/repos/{{ uuid }}/deploy/confirm?version_href={{ v.href | urlencode }}"
hx-target="#modal" hx-swap="innerHTML">이 버전 배포</button>
{% else %}
<button class="secondary btn-sm" disabled title="검증을 통과하지 못해 배포할 수 없습니다">이 버전 배포</button>
{% endif %}
</div>
{% endfor %}
{% else %}
<p style="padding:16px;">버전이 없습니다. 먼저 동기화를 실행하세요.</p>
<p class="empty-note">버전이 없습니다. 먼저 동기화를 실행하세요.</p>
{% endif %}
</div>

View File

@@ -0,0 +1,237 @@
# Handoff: 폐쇄망 Pulp 운영 콘솔 — 시각 디자인 개선
## Overview
한국은행 IT전략부 클라우드팀의 폐쇄망(인터넷 차단) Pulp 3 관리 웹 UI의 **시각 디자인 개선**입니다.
운영자가 저장소를 동기화 → 버전 스냅샷 확인 → 검증 → 운영 배포하는 도구로, "관공서 기본 폼"
느낌을 벗고 Toss 스타일의 신뢰감 있고 정돈된 운영 도구로 끌어올리는 것이 목표입니다.
기능/스택 변경 없이 **CSS(토큰 + 컴포넌트) + 최소한의 마크업 추가**만으로 개선했습니다.
## About the Design Files
이 번들의 파일은 **HTML로 만든 디자인 레퍼런스**입니다. 다만 이 프로젝트는 특수하게도
**타깃 환경이 이미 정해져 있고, 산출물이 그 환경에 그대로 들어가는 CSS**입니다:
- `app.css`**실제 산출물.** 기존 앱의 `app/static/app.css`를 이 파일로 교체하면 됩니다.
(Pico.css v2 뒤에 로드되어 변수를 오버라이드하는 커스텀 스타일. 순수 CSS, 외부 의존 0.)
- `preview.html`**렌더링 참고용.** 개선안이 실제로 어떻게 보이는지 확인하는 하니스입니다.
앱에 넣지 마세요.
- `pico-shim.css`**미리보기 전용, 앱에 넣지 마세요.** 폐쇄망에서 CDN 없이 preview.html을
렌더링하려고 Pico의 최소 기본 스타일만 흉내 낸 파일입니다. 실제 앱은 진짜 Pico.css v2를 씁니다.
**작업의 본질:** `app.css`를 교체하고, 아래 "컴포넌트별 변경 + 필요 마크업"에 적힌 소량의
템플릿 마크업(Jinja2)을 추가/조정하는 것입니다. 프레임워크를 바꾸지 않습니다.
## Fidelity
**High-fidelity.** 최종 색/타이포/간격/인터랙션이 확정된 목업입니다. 아래 값 그대로 구현하세요.
## 절대 지켜야 할 기술 제약
1. **빌드 단계 없음.** React/Vue/Vite/npm/Tailwind 금지. 순수 HTML + HTMX + Jinja2 +
Pico.css v2 + 커스텀 CSS(`app.css`)만.
2. **폐쇄망.** 런타임에 외부 네트워크(구글폰트/CDN/원격 아이콘/fetch) 참조 금지. 모든 자원 로컬 동봉.
`@import`(원격)/CDN `<link>`/`<script>` 추가 금지. **아이콘은 인라인 SVG.**
3. **폰트는 이미 로컬 동봉:** 본문 `Pretendard Variable`, 코드/숫자 `JetBrains Mono`
(`@font-face``/static/fonts/*.woff2` 로드). 새 폰트 추가 금지. (`app.css`@font-face 포함됨)
4. **크롬에 장식용 이모지 금지.** 상태는 색 있는 chip/dot으로.
5. 뷰는 HTML 조각(fragment)을 반환, HTMX가 swap. 라이트 모드 기본, 다크는 토글 옵션.
### 보존해야 하는 훅 (바꾸면 기능 깨짐)
- 모든 HTMX 속성: `hx-get/hx-post/hx-target/hx-swap/hx-trigger/hx-include/hx-vals/hx-disabled-elt`
(값·타깃 셀렉터 유지).
- element id: `#repo-list`, `#repo-table-wrap`, `#repo-search`, `#modal`, `#flash`,
`#sync-{uuid}`, `#deploy-confirm-btn`.
- data 속성: `data-state`(정렬), `data-confirm`(타입-투-컨펌), `data-theme`(테마).
- 배포 확정 버튼의 `oninput` 비활성화 로직, 테마 토글/FOUC 방지 인라인 스크립트.
- 클래스명을 리네임하려면 CSS와 템플릿 **양쪽 다** 맞춰 바꿀 것.
> 주: `preview.html`의 검색 input은 데모라 id가 없습니다. 실제 앱에서는 `#repo-search`를
> 그대로 두고 `.repo-search` 클래스만 유지하세요.
---
## 컴포넌트별 변경 + 필요 마크업
대부분은 기존 마크업 그대로 두고 CSS만 바뀌면 됩니다. 아래는 **새 요소/구조가 필요한 부분**만
정리했습니다. 각 스니펫은 `preview.html`에서 실제로 렌더되는 형태입니다.
### 0. 상단 내비 브랜드 마크 (선택)
차분한 단색 사각 로고 큐브. 인라인 SVG 없이 CSS만으로.
```html
<p class="brand"><span class="brand-mark">P</span>Pulp <span class="accent">Console</span></p>
```
### 1. 요약 카드 (대시보드)
카드 상단에 **라벨 + 아이콘 칩** 한 줄(`.card-top`)을 추가. 값은 그대로 `.value`.
`.is-accent`(전체 저장소=파랑), `.is-success`(GPG 통과=민트)로 강조.
```html
<div class="card is-accent">
<div class="card-top">
<span class="label">전체 저장소</span>
<span class="card-ico"><svg class="icon" viewBox="0 0 24 24" ...></svg></span>
</div>
<div class="value is-accent">18</div>
</div>
```
호버 시 살짝 떠오름(translateY -2px)+그림자. (reduced-motion에서 자동 비활성)
### 2. 검색 툴바
`.list-toolbar`를 relative 래퍼로 두고 왼쪽에 돋보기 인라인 SVG(`.search-ico`).
input(`#repo-search`, `.repo-search`)의 왼쪽 패딩을 아이콘 자리만큼(40px) 확보(→ CSS가 처리).
```html
<div class="list-toolbar">
<svg class="search-ico" viewBox="0 0 24 24" ...><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input id="repo-search" class="repo-search" type="search" placeholder="저장소 이름으로 검색…"
hx-get="..." hx-target="#repo-table-wrap" hx-trigger="input changed delay:300ms" ...>
</div>
```
### 3. 저장소 표
- 카드로 감싸기(`.table-card`) — 기존 유지.
- 확정안 = **제브라 스트라이프**: 짝수 행에 옅은 톤(CSS `tbody tr:nth-child(even)`), hover는 틴티드 블루.
- 정렬 헤더: `a.th-sort`(활성 시 `.is-active`), 방향 인디케이터 `.sort-ind`(desc면 `.th-sort.is-active.desc`가 화살표 회전).
- 검증 배지: `.badge.badge-pass|warn|unset|deployed`(dot + 라벨, 이모지 없음).
- 저장소명 셀: `.repo-name > a`(모노, hover 시 accent).
- 액션 셀: 폭·높이 고정 박스 `.row-action`(버튼↔진행률↔배지로 내용이 바뀌어도 레이아웃 안 흔들림).
```html
<td><div class="row-action"> …버튼/진행률/배지… </div></td>
```
### 4. 버튼 색 체계 (중요)
- **동기화(반복 노출)** = `.secondary`**흰 배경 + 파란 글씨 + 옅은 파란 테두리.** 표에 여러 개 떠도 안 번잡.
- **새로고침 등 유틸리티** = `.ghost` → 중립(투명 배경 + 회색 테두리). 동기화와 확실히 구분.
- **위험(배포 확정)** = `.danger` → 빨강 채움 + 흰 글씨.
- 공통: press 시 `scale(0.97)`, hover 시 옅은 그림자.
- **로딩 상태**: 버튼에 `.is-loading` 클래스를 붙이면 텍스트가 스피너로 대체됨. HTMX 요청 시
`hx-disabled-elt`와 함께 `.is-loading`을 토글하면 자연스럽다.
```html
<button class="secondary btn-sm btn-icon" hx-post="/sync/{{ repo.id }}"
hx-target="#sync-{{ repo.id }}" hx-disabled-elt="this">
<svg class="icon" ...></svg> 동기화
</button>
```
### 5. 동기화 진행률
```html
<div class="sync-progress is-active">
<div class="sync-head"><small>동기화 중</small><small class="sync-pct">64%</small></div>
<progress value="64" max="100"></progress>
</div>
```
값을 모르는 초기 단계엔 `.sync-progress.indeterminate`(움직이는 인디터미닛 바)를 쓸 수 있음.
### 6. 배포 확인 모달 (`<dialog id="modal">`) — 위험 강조 + 타입-투-컨펌
확정안 = **중앙 경고형.** 헤더 밴드 대신 상단 중앙에 원형 경고 아이콘.
```html
<dialog id="modal">
<article class="deploy-modal">
<div class="modal-head">
<span class="warn-ico"><svg viewBox="0 0 24 24" ...>△!</svg></span>
<div><h3>운영 배포 확인</h3><span class="head-sub">되돌리기 어려운 작업입니다</span></div>
</div>
<div class="modal-body">
<p class="deploy-note" style="text-align:center"><span class="repo-hl">rhel8-baseos</span> 저장소를 운영에 배포합니다.</p>
<!-- 현재 → 대상 카드형 프리뷰 -->
<div class="deploy-preview">
<div class="pv-box"><div class="pv-label">현재 (운영)</div><div class="pv-ver">v140</div><div class="pv-pkg">9,001 pkgs</div></div>
<div class="pv-arrow"><svg ...></svg></div>
<div class="pv-box pv-target"><div class="pv-label">대상</div><div class="pv-ver">v142</div><div class="pv-pkg">9,204 pkgs · <span class="delta-add">+203</span></div></div>
</div>
<!-- 타입-투-컨펌: 이름이 정확히 일치해야 확정 버튼 열림 -->
<label class="confirm-label">확인을 위해 저장소 이름 <span class="repo-hl">rhel8-baseos</span> 을 입력하세요.
<div class="confirm-field" data-confirm="rhel8-baseos">
<input type="text" autocomplete="off" placeholder="rhel8-baseos"
oninput="document.getElementById('deploy-confirm-btn').disabled = (this.value.trim() !== this.closest('.confirm-field').dataset.confirm)">
<svg class="confirm-state ok" ...></svg>
<svg class="confirm-state bad" ...></svg>
</div>
</label>
<!-- 힌트 문구 없음: 상태는 초록 체크/초록 테두리(일치) · 빨강 X/빨강 테두리(불일치)로만 표시 -->
<div class="modal-actions">
<button class="ghost" onclick="this.closest('dialog').close()">취소</button>
<button class="danger" id="deploy-confirm-btn" disabled>운영에 배포</button>
</div>
</div>
</article>
</dialog>
```
입력 상태 시각화는 **`.confirm-field``.is-valid` / `.is-invalid` 클래스**로 제어됩니다.
- 순수 오조작 방지만 필요하면 위 `oninput`처럼 `#deploy-confirm-btn.disabled`만 토글해도 됨.
- 체크/X 아이콘·테두리 색까지 원하면 값 일치 여부에 따라 `.confirm-field`
`is-valid`/`is-invalid`도 함께 토글(입력이 비면 둘 다 제거).
- 열릴 때 배경 스크롤 잠금은 CSS `html:has(dialog[open]){overflow:hidden}` + 진입 애니메이션을
translate 없이 scale/opacity로만 처리해 **순간 스크롤바 깜빡임이 없음.**
### 7. 버전 목록 (저장소 상세 — 저장소명 클릭 시 화면)
```html
<a href="{{ url_for('dashboard') }}" role="button" class="ghost btn-sm btn-icon back-btn">
<svg class="icon" ...></svg> 대시보드
</a>
<div class="verify-detail"><span class="badge badge-pass">GPG 통과</span><small>서명 검증 통과 · … · 키 0x3A9F…</small></div>
<div class="version-list">
<div class="version-item">
<span class="ver-no">v142</span>
<span class="badge badge-deployed">운영 중</span>
<span class="ver-meta">2026-07-09 04:12 · 9,204 pkgs</span>
<span class="spacer"></span>
<button class="ghost btn-sm" disabled>현재 운영 버전</button>
</div>
<div class="version-item">
<span class="ver-no">v141</span>
<span class="ver-meta">… · <span class="delta-add">+40</span> <span class="delta-del">16</span></span>
<span class="spacer"></span>
<button class="secondary btn-sm" hx-get="/deploy_modal/{{ repo }}/{{ v }}" hx-target="#modal">이 버전 배포</button>
</div>
</div>
```
### 8. 상태 조각들
- **배포 완료 배너 `#flash`** (`.flash-banner`): 성공 체크 원(`.flash-check`, 그려지는 SVG 체크) +
본문 + `.flash-sub` + `.flash-warn`(되돌리기 안내). 등장 시 페이드-업.
- **데모 배너** (`.demo-banner`): 가짜 데이터 경고, 앰버 톤, 펄스 dot.
- **연결 상태 칩** (`.status-line.status-ok|bad|loading|demo`): dot + 텍스트. ok는 펄스, loading은 blink.
- **빈 상태** (`.empty-note` + `.empty-ico`/`.empty-title`/`.empty-sub`).
- **에러 상태** (`.error-note`): 경고 아이콘 + 메시지, danger 톤.
- **스켈레톤** (`.skeleton.skeleton-row`): 셔머 로딩. 표 초기/재조회 자리표시.
- HTMX 스왑 중 페이드: `.htmx-swapping{opacity:.4}` 자동 적용(이미 CSS에 포함).
---
## Design Tokens (라이트 / `:root`)
색은 `app.css``:root`(라이트)와 `[data-theme="dark"]`(다크) 두 곳에서 정의되고, 컴포넌트는
전부 이 변수를 참조하므로 **토큰만 손보면 라이트/다크가 함께 따라옵니다.**
- 배경: `--bg #ffffff` · `--bg-alt #f9fafb` · `--surface #f2f4f6` · `--surface-2 #e8f3ff`(틴티드 블루)
- 텍스트: `--text #191f28` · `--text-secondary #333d4b` · `--text-muted #6b7684` · `--text-dim #8b95a1`
- 테두리: `--border #e5e8eb` · `--border-strong #d1d6db`
- 액센트(Toss Blue를 살짝 톤다운): `--accent oklch(0.63 0.147 255)` · `--accent-hover oklch(0.55 0.16 255)`
· `--accent-soft #e8f3ff`
- 상태: `--success-text #00a37a` · `--warning-text #c5700a` · `--danger #f04452` (+ `*-soft` 반투명 배경)
- 라운드: 카드 `--radius-card 12px`, 버튼 8px(`--pico-border-radius`)
- 그림자: `--shadow-card`(얇게) · `--shadow-card-hover` · `--shadow-modal`(무겁게)
- 모션: `--ease cubic-bezier(0.32,0.72,0,1)` · `--dur-fast .12s` · `--dur .2s` · `--dur-slow .32s`
- 타이포: 본문 `Pretendard Variable`, 숫자/버전/시각/저장소명은 `JetBrains Mono`(+ `tnum` tabular).
헤더 th는 한글 라벨이라 모노 제외.
**모든 애니메이션은 `prefers-reduced-motion: reduce`에서 자동 비활성**(파일 맨 끝 미디어쿼리).
## Interactions & Behavior
- 버튼: hover 그림자, active `scale(0.97)`. `.is-loading`으로 스피너 교체.
- 표: 행 hover 배경 전환, 정렬 화살표 회전, 검증 dot(pass)·연결 dot(ok) 은은한 펄스.
- 진행률: 결정형 `<progress>` width 트랜지션, 초기엔 인디터미닛 바.
- 모달: 배경 페이드 + 본문 scale/opacity 진입, 배경 스크롤 잠금(스크롤바 깜빡임 방지),
타입-투-컨펌(이름 일치 시에만 확정 활성 + 초록 체크 / 불일치 시 빨강 X).
- 성공 배너: 체크 SVG stroke-dash 그리기 애니메이션.
## 적용 순서 (Claude Code에게)
1. 기존 `app/static/app.css`를 이 번들의 `app.css`로 교체.
2. 위 "컴포넌트별 변경 + 필요 마크업"에 따라 각 Jinja2 템플릿에 새 요소(.card-top/.card-ico,
.search-ico, .modal-head/.warn-ico/.pv-box/.confirm-state 등)를 추가. **HTMX 훅·id·data 속성은 그대로.**
3. `preview.html`을 브라우저로 열어 렌더 기준과 대조(라이트/다크 토글).
4. `PULP_DEMO=true`로 데모 데이터 띄워 표/검색/정렬/모달 실동작 확인.
5. 회귀 확인: `pytest -q`(현행 58 통과), `ruff check app`.
## Files
- `app.css` — 실제 산출물 (drop-in).
- `preview.html` — 렌더 참고 하니스 (앱에 넣지 말 것).
- `pico-shim.css` — 미리보기 전용 Pico 최소 흉내 (앱에 넣지 말 것).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,142 @@
/* ─────────────────────────────────────────────────────────────
pico-shim.css — 미리보기 전용 (배포 대상 아님)
실제 앱은 Pico.css v2를 로드합니다. 폐쇄망 미리보기에서
외부 CDN 없이 화면을 재현하려고, app.css가 참조하는 --pico-*
변수 위에서 동작하는 최소 기본 스타일만 담았습니다.
───────────────────────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--pico-font-family, system-ui, sans-serif);
font-size: 16px;
line-height: var(--pico-line-height, 1.5);
background: var(--pico-background-color);
color: var(--pico-color);
}
.container {
width: 100%;
margin-inline: auto;
padding-inline: 20px;
}
h1, h2, h3, h4 {
margin: 0 0 0.4em;
line-height: 1.2;
}
h1 { color: var(--pico-h1-color); }
h2 { color: var(--pico-h2-color); }
h3 { color: var(--pico-h3-color); }
p { margin: 0 0 1rem; }
a { color: var(--accent); }
small { font-size: 0.8125em; }
/* 폼 요소 */
input, button, select, textarea {
font-family: inherit;
font-size: 1rem;
}
input[type="search"],
input[type="text"],
input:not([type]) {
display: block;
width: 100%;
padding: calc(var(--pico-form-element-spacing-vertical, 0.75rem)) 0.875rem;
background: var(--pico-form-element-background-color, var(--surface));
color: var(--pico-form-element-color, var(--text));
border: 1px solid var(--pico-form-element-border-color, var(--border));
border-radius: var(--pico-border-radius, 7px);
outline: none;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
input::placeholder { color: var(--text-dim); }
input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}
/* 버튼 기본 (app.css가 세부를 오버라이드).
버튼 요소에서 --pico-background-color 등을 primary 로 "리셋"해 두면,
app.css 의 .secondary/.ghost/.danger 가 이 변수를 덮어써서 실제 Pico처럼 동작. */
button,
[role="button"] {
display: inline-block;
padding: 11px 18px;
--pico-background-color: var(--pico-primary-background, var(--accent));
--pico-border-color: var(--pico-primary-border, var(--accent));
--pico-color: var(--pico-primary, #fff);
background: var(--pico-background-color);
color: var(--pico-color);
border: 1px solid var(--pico-border-color);
border-radius: var(--pico-border-radius, 7px);
cursor: pointer;
font-size: 1rem;
}
button:hover,
[role="button"]:hover {
--pico-background-color: var(--pico-primary-hover-background, var(--accent-hover));
--pico-border-color: var(--pico-primary-hover-border, var(--accent-hover));
}
/* 표 */
table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
th, td {
padding: 0.75rem 1rem;
}
/* 진행바 */
progress {
-webkit-appearance: none;
appearance: none;
display: block;
width: 100%;
height: 6px;
border: none;
border-radius: 999px;
background: var(--pico-progress-background-color, var(--surface));
overflow: hidden;
}
progress::-webkit-progress-bar {
background: var(--pico-progress-background-color, var(--surface));
border-radius: 999px;
}
progress::-webkit-progress-value {
background: var(--accent);
border-radius: 999px;
transition: width 0.3s ease;
}
progress::-moz-progress-bar {
background: var(--accent);
border-radius: 999px;
}
/* 카드/아티클 */
article {
background: var(--pico-card-background-color, var(--bg));
border: 1px solid var(--pico-card-border-color, var(--border));
border-radius: var(--pico-border-radius, 7px);
padding: 1.25rem;
}
/* 다이얼로그 */
dialog {
margin: auto;
border: none;
padding: 0;
background: transparent;
color: var(--pico-color);
max-width: 90vw;
}
dialog::backdrop {
background: rgba(15, 20, 30, 0.45);
}

View File

@@ -0,0 +1,425 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>폐쇄망 콘솔 — 재디자인 미리보기</title>
<!-- FOUC 방지 + 테마 복원 (실제 base.html 의 인라인 스크립트와 동일 동작) -->
<script>
(function () {
var t = localStorage.getItem("theme");
if (t) document.documentElement.setAttribute("data-theme", t);
})();
</script>
<link rel="stylesheet" href="pico-shim.css" />
<link rel="stylesheet" href="app.css" />
<style>
/* 미리보기 하니스 전용 (배포 CSS 아님) */
.demo-note {
max-width: 1040px;
margin: 28px auto 0;
padding: 0 20px;
}
.demo-note .callout {
background: var(--surface-2);
color: var(--accent-hover);
border-radius: 12px;
padding: 14px 18px;
font-size: 14px;
line-height: 1.6;
}
.state-grid { display: grid; gap: 18px; }
hr.sep { border: none; border-top: 1px solid var(--border); margin: 0 0 28px; }
</style>
</head>
<body>
<!-- 데모 배너 -->
<div class="demo-banner">
<span class="demo-dot"></span>
<span><strong>데모 모드</strong> · 가짜 저장소 데이터입니다. 실제 Pulp 서버와 연동되어 있지 않습니다.</span>
</div>
<!-- 상단 내비 -->
<nav class="app-nav">
<div class="container">
<p class="brand"><span class="brand-mark">P</span>Pulp <span class="accent">Console</span></p>
<button class="theme-toggle" id="theme-toggle" aria-label="테마 전환">
<svg class="icon moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<svg class="icon sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
</button>
</div>
</nav>
<div class="demo-note">
<div class="callout">
<b>재디자인 미리보기 (확정안).</b> 요약 카드 = 화이트+호버 리프트, 저장소 표 = 제브라, 배포 모달 = 중앙 경고형.
동기화 버튼은 흰 배경·파란 글씨로, 새로고침(중립 아웃라인)과 구분됩니다. 우상단 아이콘으로 라이트·다크를 전환해 보세요.
</div>
</div>
<main class="container">
<!-- ═══════════ 대시보드 ═══════════ -->
<section>
<div class="status-row" style="margin-bottom:24px">
<span class="status-label">연결 상태</span>
<span class="status-line status-ok">Pulp 서버 연결됨</span>
<span class="status-line status-demo">데모 데이터</span>
</div>
<div class="summary">
<div class="card is-accent">
<div class="card-top">
<span class="label">전체 저장소</span>
<span class="card-ico"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg></span>
</div>
<div class="value is-accent">18</div>
</div>
<div class="card">
<div class="card-top">
<span class="label">동기화 중</span>
<span class="card-ico"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg></span>
</div>
<div class="value">2</div>
</div>
<div class="card is-success">
<div class="card-top">
<span class="label">GPG 통과</span>
<span class="card-ico"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 4 5v6c0 5 3.4 8.5 8 10 4.6-1.5 8-5 8-10V5z"/><path d="m9 12 2 2 4-4"/></svg></span>
</div>
<div class="value is-success">16</div>
</div>
<div class="card">
<div class="card-top">
<span class="label">총 패키지</span>
<span class="card-ico"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="m3.3 7 8.7 5 8.7-5M12 22V12"/></svg></span>
</div>
<div class="value">48,120</div>
</div>
</div>
<div class="section-head">
<h2>저장소</h2>
<button class="ghost btn-sm btn-icon" id="refresh-btn">
<svg class="icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg>
새로고침
</button>
</div>
<div class="list-toolbar">
<svg class="search-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input class="repo-search" type="search" placeholder="저장소 이름으로 검색…" />
</div>
<div class="table-card" id="repo-table-wrap">
<table>
<thead>
<tr>
<th><a class="th-sort is-active" href="#">저장소 <span class="sort-ind"></span></a></th>
<th>검증</th>
<th><a class="th-sort" href="#">최신 버전</a></th>
<th class="num"><a class="th-sort" href="#">패키지</a></th>
<th><a class="th-sort" href="#">마지막 동기화</a></th>
<th></th>
</tr>
</thead>
<tbody id="repo-list">
<tr>
<td class="repo-name"><a href="#versions">rhel8-baseos</a></td>
<td><span class="badge badge-pass">GPG 통과</span></td>
<td class="mono">v142</td>
<td class="num">9,204</td>
<td class="mono">2026-07-09 04:12</td>
<td>
<div class="row-action">
<button class="secondary btn-sm btn-icon" onclick="document.getElementById('modal').showModal()">
<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg>
동기화
</button>
</div>
</td>
</tr>
<tr>
<td class="repo-name"><a href="#versions">rhel8-appstream</a></td>
<td><span class="badge badge-pass">GPG 통과</span></td>
<td class="mono">v98</td>
<td class="num">12,880</td>
<td class="mono">동기화 중…</td>
<td>
<div class="row-action">
<div class="sync-progress is-active">
<div class="sync-head">
<small>동기화 중</small>
<small class="sync-pct">64%</small>
</div>
<progress value="64" max="100"></progress>
</div>
</div>
</td>
</tr>
<tr>
<td class="repo-name"><a href="#versions">ubuntu2204-main</a></td>
<td><span class="badge badge-warn">서명 확인 필요</span></td>
<td class="mono">v51</td>
<td class="num">6,412</td>
<td class="mono">2026-07-08 23:40</td>
<td>
<div class="row-action">
<button class="secondary btn-sm btn-icon" onclick="document.getElementById('modal').showModal()">
<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg>
동기화
</button>
</div>
</td>
</tr>
<tr>
<td class="repo-name"><a href="#versions">epel8-extras</a></td>
<td><span class="badge badge-deployed">운영 배포됨</span></td>
<td class="mono">v220</td>
<td class="num">3,051</td>
<td class="mono">2026-07-09 01:05</td>
<td>
<div class="row-action">
<button class="secondary btn-sm btn-icon" onclick="document.getElementById('modal').showModal()">
<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg>
동기화
</button>
</div>
</td>
</tr>
<tr>
<td class="repo-name"><a href="#versions">centos-stream9</a></td>
<td><span class="badge badge-unset">미검증</span></td>
<td class="mono">v7</td>
<td class="num">1,120</td>
<td class="mono">2026-07-07 18:22</td>
<td>
<div class="row-action">
<button class="secondary btn-sm btn-icon" onclick="document.getElementById('modal').showModal()">
<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg>
동기화
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div class="pager">
<button class="ghost btn-sm" disabled>이전</button>
<span class="mono">15 / 18</span>
<button class="ghost btn-sm">다음</button>
</div>
</div>
</section>
<hr class="sep" />
<!-- ═══════════ 상태 · 인터랙션 데모 ═══════════ -->
<section>
<div class="section-head"><h2>상태 · 인터랙션 데모</h2></div>
<div class="state-grid">
<div class="flash-banner">
<span class="flash-check"><svg viewBox="0 0 24 24"><path d="m5 13 4 4L19 7"/></svg></span>
<span>운영 배포 완료 · <span class="mono">rhel8-baseos v142</span></span>
<span class="flash-sub">3,204개 패키지 반영</span>
<span class="flash-warn">되돌리기: 이전 버전 재배포</span>
</div>
<div class="table-card" style="padding:20px">
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
<button class="secondary" id="sync-demo-btn">동기화 시작 (진행 애니메이션)</button>
<div class="sync-progress" id="sync-demo" style="width:220px;display:none">
<div class="sync-head"><small id="sync-demo-label">준비 중</small><small class="sync-pct" id="sync-demo-pct">0%</small></div>
<progress id="sync-demo-bar" value="0" max="100"></progress>
</div>
<button class="secondary" style="width:auto" id="loading-demo">로딩 상태</button>
</div>
</div>
<div class="table-card">
<div class="empty-note">
<span class="empty-ico"><svg class="icon" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg></span>
<div class="empty-title">검색 결과가 없습니다</div>
<div class="empty-sub"><span class="mono">"nginx"</span> 와 일치하는 저장소가 없어요. 다른 키워드로 검색해 보세요.</div>
</div>
</div>
<div class="error-note">
<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/></svg>
<span>Pulp 서버에 연결할 수 없습니다. 네트워크 및 서비스 상태를 확인하세요.</span>
</div>
<div class="table-card" style="padding:18px">
<div style="display:flex;flex-direction:column;gap:14px">
<div class="skeleton skeleton-row" style="width:40%"></div>
<div class="skeleton skeleton-row" style="width:70%"></div>
<div class="skeleton skeleton-row" style="width:55%"></div>
</div>
</div>
</div>
</section>
<hr class="sep" />
<!-- ═══════════ 버전 목록 (저장소 상세 — 저장소 이름 클릭 시 화면) ═══════════ -->
<section id="versions">
<a href="#" role="button" class="ghost btn-sm btn-icon back-btn">
<svg class="icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
대시보드
</a>
<div class="section-head">
<h2 class="mono">rhel8-baseos</h2>
<button class="secondary btn-sm btn-icon" onclick="document.getElementById('modal').showModal()">
<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/></svg>
동기화
</button>
</div>
<div class="verify-detail">
<span class="badge badge-pass">GPG 통과</span>
<small>서명 검증 통과 · 2026-07-09 04:12 · 키 0x3A9F&hellip;C21</small>
</div>
<div class="version-list">
<div class="version-item">
<span class="ver-no">v142</span>
<span class="badge badge-deployed">운영 중</span>
<span class="ver-meta">2026-07-09 04:12 · 9,204 pkgs</span>
<span class="spacer"></span>
<button class="ghost btn-sm" disabled>현재 운영 버전</button>
</div>
<div class="version-item">
<span class="ver-no">v141</span>
<span class="ver-meta">2026-07-08 22:03 · 9,180 pkgs · <span class="delta-add">+40</span> <span class="delta-del">16</span></span>
<span class="spacer"></span>
<button class="secondary btn-sm" onclick="document.getElementById('modal').showModal()">이 버전 배포</button>
</div>
<div class="version-item">
<span class="ver-no">v140</span>
<span class="ver-meta">2026-07-08 09:15 · 9,001 pkgs · <span class="delta-add">+112</span></span>
<span class="spacer"></span>
<button class="secondary btn-sm" onclick="document.getElementById('modal').showModal()">이 버전 배포</button>
</div>
<div class="version-item">
<span class="ver-no">v139</span>
<span class="ver-meta">2026-07-07 20:41 · 8,889 pkgs</span>
<span class="spacer"></span>
<button class="secondary btn-sm" onclick="document.getElementById('modal').showModal()">이 버전 배포</button>
</div>
</div>
</section>
</main>
<!-- ═══════════ 배포 확인 모달 (중앙 경고형) ═══════════ -->
<dialog id="modal">
<article class="deploy-modal">
<div class="modal-head">
<span class="warn-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/></svg></span>
<div>
<h3>운영 배포 확인</h3>
<span class="head-sub">되돌리기 어려운 작업입니다</span>
</div>
</div>
<div class="modal-body">
<p class="deploy-note" style="text-align:center"><span class="repo-hl">rhel8-baseos</span> 저장소를 운영에 배포합니다.</p>
<div class="deploy-preview">
<div class="pv-box">
<div class="pv-label">현재 (운영)</div>
<div class="pv-ver">v140</div>
<div class="pv-pkg">9,001 pkgs</div>
</div>
<div class="pv-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></div>
<div class="pv-box pv-target">
<div class="pv-label">대상</div>
<div class="pv-ver">v142</div>
<div class="pv-pkg">9,204 pkgs · <span class="delta-add">+203</span></div>
</div>
</div>
<label class="confirm-label">확인을 위해 저장소 이름 <span class="repo-hl">rhel8-baseos</span> 을 입력하세요.
<div class="confirm-field" data-confirm="rhel8-baseos">
<input type="text" autocomplete="off" placeholder="rhel8-baseos" />
<svg class="confirm-state ok" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m5 13 4 4L19 7"/></svg>
<svg class="confirm-state bad" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</div>
</label>
<div class="modal-actions">
<button class="ghost" onclick="this.closest('dialog').close()">취소</button>
<button class="danger" id="deploy-confirm-btn" disabled>운영에 배포</button>
</div>
</div>
</article>
</dialog>
<script>
// 테마 토글 (실제 base.html 동작과 동일)
document.getElementById("theme-toggle").addEventListener("click", function () {
var el = document.documentElement;
var next = el.getAttribute("data-theme") === "dark" ? "light" : "dark";
el.setAttribute("data-theme", next);
localStorage.setItem("theme", next);
});
// 타입-투-컨펌: 입력값이 data-confirm 과 일치하면 확정 버튼 활성 + 시각 상태
(function () {
var field = document.querySelector("#modal .confirm-field");
var input = field.querySelector("input");
var target = field.getAttribute("data-confirm");
var btn = document.getElementById("deploy-confirm-btn");
input.addEventListener("input", function () {
var v = input.value.trim();
field.classList.remove("is-valid", "is-invalid");
if (v === "") { btn.disabled = true; return; }
if (v === target) {
field.classList.add("is-valid");
btn.disabled = false;
} else {
field.classList.add("is-invalid");
btn.disabled = true;
}
});
btn.addEventListener("click", function () {
if (btn.disabled) return;
btn.classList.add("is-loading");
btn.textContent = "배포 중";
setTimeout(function () {
btn.closest("dialog").close();
btn.classList.remove("is-loading");
btn.textContent = "운영에 배포";
btn.disabled = true;
field.classList.remove("is-valid", "is-invalid");
input.value = "";
}, 1400);
});
})();
// 진행바 데모
document.getElementById("sync-demo-btn").addEventListener("click", function () {
var wrap = document.getElementById("sync-demo");
var bar = document.getElementById("sync-demo-bar");
var pct = document.getElementById("sync-demo-pct");
var label = document.getElementById("sync-demo-label");
wrap.style.display = "flex";
wrap.classList.add("indeterminate");
label.textContent = "연결 중…";
var v = 0;
setTimeout(function () {
wrap.classList.remove("indeterminate");
label.textContent = "패키지 내려받는 중";
var t = setInterval(function () {
v += Math.max(1, Math.round((100 - v) * 0.12));
if (v >= 100) { v = 100; clearInterval(t); label.textContent = "완료"; }
bar.value = v;
pct.textContent = v + "%";
}, 240);
}, 900);
});
// 로딩 버튼 데모
document.getElementById("loading-demo").addEventListener("click", function () {
var b = this;
b.classList.add("is-loading");
setTimeout(function () { b.classList.remove("is-loading"); }, 1600);
});
</script>
</body>
</html>

View File

@@ -5,4 +5,5 @@ httpx==0.28.1
jinja2==3.1.4
python-multipart==0.0.20
pydantic-settings==2.7.1
psycopg[binary]==3.3.4
# psycopg 3.3.x는 Python 3.10+ 전용 → 대상 서버(Py3.9)용으로 3.2.x 고정
psycopg[binary]==3.2.13

View File

@@ -133,10 +133,11 @@ def test_repos_table_paginates():
page1 = client.get(
"/repos/table", params={"sort": "name", "dir": "asc", "page": 1}
).text
page2 = client.get(
"/repos/table", params={"sort": "name", "dir": "asc", "page": 2}
page3 = client.get(
"/repos/table", params={"sort": "name", "dir": "asc", "page": 3}
).text
assert "repo-01" in page1 and "repo-20" in page1
assert "repo-21" not in page1
assert "repo-21" in page2 and "repo-25" in page2
assert "1 / 2" in page1
# PAGE_SIZE=10 → 25개는 3페이지 (10/10/5)
assert "repo-01" in page1 and "repo-10" in page1
assert "repo-11" not in page1
assert "repo-21" in page3 and "repo-25" in page3
assert "1 / 3" in page1