Compare commits
34 Commits
feature/sp
...
feature/sp
| Author | SHA1 | Date | |
|---|---|---|---|
| 339678b962 | |||
| 035ba96381 | |||
| ce323fdfc1 | |||
| a942f4e62d | |||
|
|
e0fd04b4f1 | ||
| b387c638c9 | |||
| 2089283386 | |||
| 286f472afd | |||
| 9a86d04731 | |||
| b469a1337a | |||
|
|
0ce70ec615 | ||
| 97668007b1 | |||
| 230d3847ef | |||
| 5e6c7085d3 | |||
|
|
23fd1ecb09 | ||
|
|
be052ec707 | ||
| b3690c9206 | |||
| 914903466c | |||
|
|
929b4e0745 | ||
| 64802c30c5 | |||
| fa33308fe4 | |||
| dcf5073693 | |||
|
|
50ea60f944 | ||
| 3d8a1efa2a | |||
| 033c142c0e | |||
|
|
4e5a077d40 | ||
| 86751a4879 | |||
| 4015e5f22f | |||
|
|
dd6a2595f7 | ||
| e48aae1f04 | |||
| 0b23deca48 | |||
| e0041172a7 | |||
| 52732e88b9 | |||
|
|
1c8d015810 |
150
.plans/design/DESIGN-SYSTEM.md
Normal file
150
.plans/design/DESIGN-SYSTEM.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# 하나랑 대시보드 — 디자인 시스템 v2
|
||||||
|
|
||||||
|
## 디자인 철학
|
||||||
|
미니멀 터미널 UI. 장식 최소화, 정보 밀도 최대화.
|
||||||
|
glassmorphism 제거 → 1px 보더 카드 + 모노스페이스 라벨.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 색상 (Color Tokens)
|
||||||
|
|
||||||
|
| Token | Value | 용도 |
|
||||||
|
|-------|-------|------|
|
||||||
|
| `--bg-main` | `#151515` | 전체 배경 |
|
||||||
|
| `--bg-surface` | `#151515` | 카드/패널 배경 (배경과 동일, 보더로 구분) |
|
||||||
|
| `--bg-input` | `#1a1a1a` | 인풋/검색 배경 |
|
||||||
|
| `--bg-code` | `#0d0d0d` | 로그 테이블/코드 블록 배경 |
|
||||||
|
| `--text-primary` | `#F5F5F5` | 주 텍스트 |
|
||||||
|
| `--text-secondary` | `#8A8A8A` | 보조 텍스트, 라벨 |
|
||||||
|
| `--border-color` | `#333333` | 카드 보더, 구분선 |
|
||||||
|
| `--border-hover` | `#555555` | hover 시 보더 |
|
||||||
|
| `--accent-hover` | `#FFFFFF` | 네비 active, 강조 |
|
||||||
|
|
||||||
|
### 상태 색상 (조직도 전용)
|
||||||
|
| Token | Value | 용도 |
|
||||||
|
|-------|-------|------|
|
||||||
|
| `--level-high` | `#f43f5e` | 최상위/긴급 |
|
||||||
|
| `--level-mid` | `#3b82f6` | 중간/R&D |
|
||||||
|
| `--level-low` | `#10b981` | 하위/운영 |
|
||||||
|
| `--level-none` | `#525252` | 비활성 |
|
||||||
|
|
||||||
|
### 로그 태그 색상
|
||||||
|
| Tag | Color |
|
||||||
|
|-----|-------|
|
||||||
|
| SYSTEM | `#888` |
|
||||||
|
| SECURITY | `#ff5f5f` |
|
||||||
|
| ORG | `#5fafff` |
|
||||||
|
| BACKUP | `#5fff8a` |
|
||||||
|
|
||||||
|
### 상태 표시 색상
|
||||||
|
| Status | Color |
|
||||||
|
|--------|-------|
|
||||||
|
| PASS / 성공 | `#00FF00` |
|
||||||
|
| ACTIVE dot | `#FFF` + `box-shadow: 0 0 8px rgba(255,255,255,0.3)` |
|
||||||
|
| INACTIVE dot | `#555` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 타이포그래피
|
||||||
|
|
||||||
|
| 용도 | Font | Size | Weight |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| 본문 | `--font-sans` | 14px | 400 |
|
||||||
|
| 페이지 제목 | `--font-sans` | 28px | 600 |
|
||||||
|
| 섹션 제목 | `--font-sans` | 14px | 600 |
|
||||||
|
| 라벨 (label-meta) | `--font-sans` | 11px | 700, uppercase |
|
||||||
|
| 브라켓 값 | `--font-sans` | 32px | 600 |
|
||||||
|
| 타임스탬프 | `--font-mono` | 11px | 400 |
|
||||||
|
| 로그 텍스트 | `--font-mono` | 12px | 400 |
|
||||||
|
| 브라켓 인풋 | `--font-mono` | 14px | 400 |
|
||||||
|
| 뱃지 | `--font-mono` | 9px | 400, uppercase |
|
||||||
|
|
||||||
|
**폰트 스택:**
|
||||||
|
- `--font-sans`: `-apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif`
|
||||||
|
- `--font-mono`: `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 스페이싱
|
||||||
|
|
||||||
|
| Token | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| `--space-xs` | 4px |
|
||||||
|
| `--space-sm` | 8px |
|
||||||
|
| `--space-md` | 16px |
|
||||||
|
| `--space-lg` | 24px |
|
||||||
|
| `--space-xl` | 40px |
|
||||||
|
| `--space-xxl` | 64px |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컴포넌트
|
||||||
|
|
||||||
|
### Sidebar
|
||||||
|
- 너비: 240px (데스크톱), 64px (모바일 아이콘 모드)
|
||||||
|
- `border-right: 1px solid var(--border-color)`
|
||||||
|
- `position: sticky; top: 0; height: 100vh`
|
||||||
|
- 로고: ● + ◗ (circle-full + circle-half)
|
||||||
|
- 네비 active: `[대시]` 브라켓 표기 + 흰색 텍스트
|
||||||
|
- 네비 inactive: `#8A8A8A`
|
||||||
|
- 메뉴: 대시, 활동, 프로, 자매, 조직, 설정, 관리
|
||||||
|
|
||||||
|
### Card (상태 카드)
|
||||||
|
- `border: 1px solid var(--border-color)`
|
||||||
|
- `background: var(--bg-surface)`
|
||||||
|
- `padding: var(--space-lg)`
|
||||||
|
- hover: `border-color: #555`
|
||||||
|
- 내부: header(SYS:이름) + bracket-value([ON]) + label + tech-bar
|
||||||
|
|
||||||
|
### Tech Bar (진행 바)
|
||||||
|
- `height: 2px; background: var(--border-color)`
|
||||||
|
- fill: `background: var(--text-primary)`
|
||||||
|
|
||||||
|
### Timeline
|
||||||
|
- 왼쪽 세로선: `left: 3px; width: 1px; background: var(--border-color)`
|
||||||
|
- 도트: `width: 7px; height: 7px; border: 1px solid var(--text-primary); border-radius: 50%`
|
||||||
|
|
||||||
|
### Toggle Switch
|
||||||
|
- 34x18px, border 1px
|
||||||
|
- checked: border `var(--text-primary)`, dot 흰색
|
||||||
|
|
||||||
|
### Bracket Input
|
||||||
|
- `[ value ]` 형태, mono font
|
||||||
|
- 투명 배경, 포커스 시 흰색 텍스트
|
||||||
|
|
||||||
|
### Button
|
||||||
|
- 기본: 투명 + 보더, uppercase 12px
|
||||||
|
- primary: 흰색 배경 + 검정 텍스트, hover 반전
|
||||||
|
- toggle: fit-content, 11px, active 시 흰색 배경
|
||||||
|
|
||||||
|
### Log Table
|
||||||
|
- 배경: `#0d0d0d`, border 1px
|
||||||
|
- th: 11px uppercase, `--text-secondary`
|
||||||
|
- td: 12px mono, hover 배경 `#1a1a1a`
|
||||||
|
|
||||||
|
### Terminal Card (조직도)
|
||||||
|
- `border-left: 3px solid` (레벨별 색상)
|
||||||
|
- `box-shadow: 0 10px 30px rgba(0,0,0,0.5)`
|
||||||
|
- 뱃지: 9px mono, `#1a1a1a` 배경
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 반응형 (Breakpoints)
|
||||||
|
|
||||||
|
| Breakpoint | 화면 | 변경 사항 |
|
||||||
|
|------------|------|-----------|
|
||||||
|
| `≥1200px` | Desktop | 기본 레이아웃 |
|
||||||
|
| `768–1199px` | Tablet | 사이드바 64px 아이콘 모드, 카드 2열, 2열 그리드→1열 |
|
||||||
|
| `≤767px` | Mobile | 사이드바 하단 탭바, 카드 1열, 노드 엔트리 수직 스택, 테이블 카드 변환 |
|
||||||
|
|
||||||
|
### Mobile 세부 규칙
|
||||||
|
1. **사이드바 → 하단 탭바**: 고정 하단, 아이콘+짧은 라벨, `height: 56px`
|
||||||
|
2. **상태 카드 그리드**: `grid-template-columns: repeat(2, 1fr)` → `1fr` (모바일)
|
||||||
|
3. **data-columns (대시 하단)**: `1.5fr 1fr` → `1fr` 수직 스택
|
||||||
|
4. **project-grid (프로젝트 상세)**: `1fr 320px` → `1fr` 수직 스택
|
||||||
|
5. **node-entry (자매 관리)**: 3열 가로 → 수직 스택
|
||||||
|
6. **settings-grid**: 2열 → 1열
|
||||||
|
7. **log-table**: 가로 스크롤 또는 카드 형태 변환
|
||||||
|
8. **조직도 tree**: 수평 스크롤 + 핀치 줌
|
||||||
|
9. **padding 축소**: `--space-xxl` → `--space-lg`, `--space-xl` → `--space-md`
|
||||||
|
10. **bracket-value 크기**: 32px → 24px (모바일)
|
||||||
510
.plans/design/references/01-dashboard.html
Normal file
510
.plans/design/references/01-dashboard.html
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
The code below contains a design. This design should be used to create a new app or be added to an existing one.
|
||||||
|
|
||||||
|
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
|
||||||
|
|
||||||
|
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
|
||||||
|
|
||||||
|
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
|
||||||
|
|
||||||
|
Run the dev command so the user can see the app once finished.
|
||||||
|
|
||||||
|
```
|
||||||
|
<html lang="ko" vid="0"><head vid="1">
|
||||||
|
<meta charset="UTF-8" vid="2">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
|
||||||
|
<title vid="4">하나랑 대시보드</title>
|
||||||
|
<style vid="5">
|
||||||
|
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-main: #151515;
|
||||||
|
--bg-surface: #151515;
|
||||||
|
--text-primary: #F5F5F5;
|
||||||
|
--text-secondary: #8A8A8A;
|
||||||
|
--border-color: #333333;
|
||||||
|
--accent-hover: #FFFFFF;
|
||||||
|
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
|
||||||
|
--space-xs: 4px;
|
||||||
|
--space-sm: 8px;
|
||||||
|
--space-md: 16px;
|
||||||
|
--space-lg: 24px;
|
||||||
|
--space-xl: 40px;
|
||||||
|
--space-xxl: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background-color: var(--bg-main);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 14px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.label-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-meta span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-right: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--space-xl) var(--space-lg);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: var(--space-xxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-circle-full {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-circle-half {
|
||||||
|
width: 10px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
border-radius: 0 20px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover, .nav-link.active {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active::before {
|
||||||
|
content: "[";
|
||||||
|
margin-right: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.nav-link.active::after {
|
||||||
|
content: "]";
|
||||||
|
margin-left: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: var(--space-xl) var(--space-xxl);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-data-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-bar {
|
||||||
|
height: 2px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--border-color);
|
||||||
|
margin-top: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-bar-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.data-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5fr 1fr;
|
||||||
|
gap: var(--space-xxl);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: var(--space-sm);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.project-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px 1fr auto;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-md) 0;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
filter: grayscale(100%) contrast(120%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 3px;
|
||||||
|
width: 1px;
|
||||||
|
background-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
padding-left: var(--space-lg);
|
||||||
|
padding-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 6px;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
background-color: var(--bg-main);
|
||||||
|
border: 1px solid var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-stamp {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body vid="6">
|
||||||
|
|
||||||
|
<div class="app-container" vid="7">
|
||||||
|
|
||||||
|
<aside class="sidebar" vid="8">
|
||||||
|
<div class="logo-container" vid="9">
|
||||||
|
<div class="logo-circle-full" vid="10"></div>
|
||||||
|
<div class="logo-circle-half" vid="11"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav vid="12">
|
||||||
|
<ul class="nav-menu" vid="13">
|
||||||
|
<li class="nav-item" vid="14"><a href="#" class="nav-link active" vid="15">대시</a></li>
|
||||||
|
<li class="nav-item" vid="16"><a href="#" class="nav-link" vid="17">프로</a></li>
|
||||||
|
<li class="nav-item" vid="18"><a href="#" class="nav-link" vid="19">자매</a></li>
|
||||||
|
<li class="nav-item" vid="20"><a href="#" class="nav-link" vid="21">조직</a></li>
|
||||||
|
<li class="nav-item" vid="22"><a href="#" class="nav-link" vid="23">설정</a></li>
|
||||||
|
<li class="nav-item" vid="24"><a href="#" class="nav-link" vid="25">관리</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
|
||||||
|
<main class="main-content" vid="26">
|
||||||
|
<header class="page-header" vid="27">
|
||||||
|
<h1 class="page-title" vid="28">하나랑 대시보드</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
<section class="status-grid" vid="29">
|
||||||
|
<div class="card" vid="30">
|
||||||
|
<div class="card-header" vid="31">
|
||||||
|
<div class="label-meta" vid="32"><span vid="33">SYS:</span>하랑</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-data-row" vid="34">
|
||||||
|
<div class="bracket-value" vid="35">[ON]</div>
|
||||||
|
<div class="label-meta" vid="36">Active Node</div>
|
||||||
|
</div>
|
||||||
|
<div class="tech-bar" vid="37"><div class="tech-bar-fill" style="width: 100%;" vid="38"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" vid="39">
|
||||||
|
<div class="card-header" vid="40">
|
||||||
|
<div class="label-meta" vid="41"><span vid="42">SYS:</span>나랑</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-data-row" vid="43">
|
||||||
|
<div class="bracket-value" vid="44">[84]</div>
|
||||||
|
<div class="label-meta" vid="45">Capacity %</div>
|
||||||
|
</div>
|
||||||
|
<div class="tech-bar" vid="46"><div class="tech-bar-fill" style="width: 84%;" vid="47"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" vid="48">
|
||||||
|
<div class="card-header" vid="49">
|
||||||
|
<div class="label-meta" vid="50"><span vid="51">SYS:</span>다랑</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-data-row" vid="52">
|
||||||
|
<div class="bracket-value" style="color: var(--text-secondary);" vid="53">[--]</div>
|
||||||
|
<div class="label-meta" vid="54">Standby</div>
|
||||||
|
</div>
|
||||||
|
<div class="tech-bar" vid="55"><div class="tech-bar-fill" style="width: 0%;" vid="56"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" vid="57">
|
||||||
|
<div class="card-header" vid="58">
|
||||||
|
<div class="label-meta" vid="59"><span vid="60">SYS:</span>이랑</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-data-row" vid="61">
|
||||||
|
<div class="bracket-value" vid="62">[12]</div>
|
||||||
|
<div class="label-meta" vid="63">Sync Queue</div>
|
||||||
|
</div>
|
||||||
|
<div class="tech-bar" vid="64"><div class="tech-bar-fill" style="width: 12%;" vid="65"></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="data-columns" vid="66">
|
||||||
|
|
||||||
|
<section vid="67">
|
||||||
|
<div class="section-title" vid="68">
|
||||||
|
<span vid="69">ONGOING PROJECTS</span>
|
||||||
|
<span class="label-meta" vid="70">VOL: 04</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-list" vid="71">
|
||||||
|
<div class="project-row" vid="72">
|
||||||
|
<div class="avatar" vid="73">
|
||||||
|
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23444'/%3E%3Ccircle cx='50' cy='40' r='20' fill='%23666'/%3E%3Cpath d='M20 90 Q50 60 80 90' stroke='%23666' stroke-width='10' fill='none'/%3E%3C/svg%3E" alt="avatar" vid="74">
|
||||||
|
</div>
|
||||||
|
<div class="project-details" vid="75">
|
||||||
|
<div class="project-name" vid="76">데이터 파이프라인 최적화</div>
|
||||||
|
<div class="project-desc" vid="77">하랑 및 나랑 시스템 간의 실시간 동기화 지연율 개선.</div>
|
||||||
|
</div>
|
||||||
|
<div class="label-meta" vid="78">PHASE 2</div>
|
||||||
|
</div>
|
||||||
|
<div class="project-row" vid="79">
|
||||||
|
<div class="avatar" vid="80">
|
||||||
|
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23555'/%3E%3Cpath d='M30 80 L50 30 L70 80' stroke='%23777' stroke-width='8' fill='none'/%3E%3C/svg%3E" alt="avatar" vid="81">
|
||||||
|
</div>
|
||||||
|
<div class="project-details" vid="82">
|
||||||
|
<div class="project-name" vid="83">새로운 자매 노드 배포 (아랑)</div>
|
||||||
|
<div class="project-desc" vid="84">인프라 구성 및 초기 보안 감사 진행 중.</div>
|
||||||
|
</div>
|
||||||
|
<div class="label-meta" vid="85">INIT</div>
|
||||||
|
</div>
|
||||||
|
<div class="project-row" vid="86">
|
||||||
|
<div class="avatar" vid="87">
|
||||||
|
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23333'/%3E%3Ccircle cx='50' cy='50' r='25' fill='none' stroke='%23888' stroke-width='6'/%3E%3C/svg%3E" alt="avatar" vid="88">
|
||||||
|
</div>
|
||||||
|
<div class="project-details" vid="89">
|
||||||
|
<div class="project-name" vid="90">관리자 권한 체계 개편</div>
|
||||||
|
<div class="project-desc" vid="91">조직별 접근 제어 정책 재수립 및 적용.</div>
|
||||||
|
</div>
|
||||||
|
<div class="label-meta" vid="92">REVIEW</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section vid="93">
|
||||||
|
<div class="section-title" vid="94">
|
||||||
|
<span vid="95">ACTIVITY FEED</span>
|
||||||
|
<span class="label-meta" vid="96">LIVE</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline" vid="97">
|
||||||
|
<div class="timeline-item" vid="98">
|
||||||
|
<span class="time-stamp" vid="99">14:02:45 GMT+9</span>
|
||||||
|
<div class="timeline-content" vid="100">
|
||||||
|
<strong vid="101">System (다랑)</strong> status updated to Standby mode by admin protocol.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-item" vid="102">
|
||||||
|
<span class="time-stamp" vid="103">11:30:12 GMT+9</span>
|
||||||
|
<div class="timeline-content" vid="104">
|
||||||
|
<strong vid="105">Security Audit</strong> completed for project [데이터 파이프라인 최적화]. No critical vulnerabilities found.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-item" vid="106">
|
||||||
|
<span class="time-stamp" vid="107">09:15:00 GMT+9</span>
|
||||||
|
<div class="timeline-content" vid="108">
|
||||||
|
New user batch imported to <strong vid="109">Organization [Beta]</strong> structure.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-item" vid="110">
|
||||||
|
<span class="time-stamp" vid="111">08:00:05 GMT+9</span>
|
||||||
|
<div class="timeline-content" vid="112">
|
||||||
|
Daily automated backup initiated across all active nodes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</body></html>
|
||||||
|
```
|
||||||
465
.plans/design/references/02-project-detail.html
Normal file
465
.plans/design/references/02-project-detail.html
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
The code below contains a design. This design should be used to create a new app or be added to an existing one.
|
||||||
|
|
||||||
|
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
|
||||||
|
|
||||||
|
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
|
||||||
|
|
||||||
|
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
|
||||||
|
|
||||||
|
Run the dev command so the user can see the app once finished.
|
||||||
|
|
||||||
|
```
|
||||||
|
<html lang="ko" vid="0"><head vid="1">
|
||||||
|
<meta charset="UTF-8" vid="2">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
|
||||||
|
<title vid="4">하나랑 - 프로젝트 상세</title>
|
||||||
|
<style vid="5">
|
||||||
|
:root {
|
||||||
|
--bg-main: #151515;
|
||||||
|
--bg-surface: #151515;
|
||||||
|
--text-primary: #F5F5F5;
|
||||||
|
--text-secondary: #8A8A8A;
|
||||||
|
--border-color: #333333;
|
||||||
|
--accent-hover: #FFFFFF;
|
||||||
|
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
|
||||||
|
--space-xs: 4px;
|
||||||
|
--space-sm: 8px;
|
||||||
|
--space-md: 16px;
|
||||||
|
--space-lg: 24px;
|
||||||
|
--space-xl: 40px;
|
||||||
|
--space-xxl: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background-color: var(--bg-main);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 14px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-meta span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-right: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--space-xl) var(--space-lg);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: var(--space-xxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-circle-full {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-circle-half {
|
||||||
|
width: 10px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
border-radius: 0 20px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover, .nav-link.active {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active::before { content: "["; margin-right: 4px; color: var(--text-secondary); }
|
||||||
|
.nav-link.active::after { content: "]"; margin-left: 4px; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: var(--space-xl) var(--space-xxl);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
gap: var(--space-xxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: var(--space-sm);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.phase-timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-box {
|
||||||
|
border-left: 2px solid var(--border-color);
|
||||||
|
padding-left: var(--space-lg);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-box.active {
|
||||||
|
border-left-color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-box.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -5px;
|
||||||
|
top: 0;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.checklist {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-box {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-box.checked::after {
|
||||||
|
content: '■';
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item.done {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.node-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-mini-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: #00FF00;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.audit-log {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-row {
|
||||||
|
background: var(--bg-main);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 1fr 80px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-header {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag {
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag.pass { border-color: #00FF00; color: #00FF00; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body vid="6">
|
||||||
|
|
||||||
|
<div class="app-container" vid="7">
|
||||||
|
<aside class="sidebar" vid="8">
|
||||||
|
<div class="logo-container" vid="9">
|
||||||
|
<div class="logo-circle-full" vid="10"></div>
|
||||||
|
<div class="logo-circle-half" vid="11"></div>
|
||||||
|
</div>
|
||||||
|
<nav vid="12">
|
||||||
|
<ul class="nav-menu" vid="13">
|
||||||
|
<li class="nav-item" vid="14"><a href="#" class="nav-link" vid="15">대시</a></li>
|
||||||
|
<li class="nav-item" vid="16"><a href="#" class="nav-link active" vid="17">프로</a></li>
|
||||||
|
<li class="nav-item" vid="18"><a href="#" class="nav-link" vid="19">자매</a></li>
|
||||||
|
<li class="nav-item" vid="20"><a href="#" class="nav-link" vid="21">조직</a></li>
|
||||||
|
<li class="nav-item" vid="22"><a href="#" class="nav-link" vid="23">설정</a></li>
|
||||||
|
<li class="nav-item" vid="24"><a href="#" class="nav-link" vid="25">관리</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content" vid="26">
|
||||||
|
<div class="breadcrumb" vid="27">PROJECTS / P-402 / SUMMARY</div>
|
||||||
|
<header class="page-header" vid="28">
|
||||||
|
<div class="page-title-row" vid="29">
|
||||||
|
<h1 class="page-title" vid="30">데이터 파이프라인 최적화</h1>
|
||||||
|
<div class="label-meta" vid="31"><span vid="32">STATUS:</span>ACTIVE / PHASE 02</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="project-grid" vid="33">
|
||||||
|
<div class="content-left" vid="34">
|
||||||
|
<section vid="35">
|
||||||
|
<div class="section-title" vid="36">
|
||||||
|
<span vid="37">PHASE TIMELINE</span>
|
||||||
|
<span class="label-meta" vid="38">CURRENT: P2</span>
|
||||||
|
</div>
|
||||||
|
<div class="phase-timeline" vid="39">
|
||||||
|
<div class="phase-item" vid="40">
|
||||||
|
<div class="phase-meta" vid="41">COMPLETED<br vid="42">2023.10.12</div>
|
||||||
|
<div class="phase-box" vid="43">
|
||||||
|
<div class="phase-name" vid="44">PHASE 01: 아키텍처 분석</div>
|
||||||
|
<div class="phase-desc" vid="45">기존 하랑-나랑 노드 간의 데이터 흐름 병목 현상 진단 및 병렬 처리 로직 설계 완료.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="phase-item" vid="46">
|
||||||
|
<div class="phase-meta" vid="47">IN PROGRESS<br vid="48">EST: 11.05</div>
|
||||||
|
<div class="phase-box active" vid="49">
|
||||||
|
<div class="phase-name" vid="50">PHASE 02: 실시간 동기화 구현</div>
|
||||||
|
<div class="phase-desc" vid="51">Redis 스트림을 활용한 비동기 메시지 큐 시스템 구축 및 데이터 무결성 검증 레이어 통합.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="phase-item" vid="52">
|
||||||
|
<div class="phase-meta" vid="53">PENDING<br vid="54">TBD</div>
|
||||||
|
<div class="phase-box" vid="55">
|
||||||
|
<div class="phase-name" vid="56">PHASE 03: 스트레스 테스트 및 배포</div>
|
||||||
|
<div class="phase-desc" vid="57">최대 트래픽 상황에서의 안정성 확보 및 프로덕션 환경 순차 배포 예정.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section vid="58">
|
||||||
|
<div class="section-title" vid="59">
|
||||||
|
<span vid="60">SECURITY AUDIT LOG</span>
|
||||||
|
<span class="label-meta" vid="61">LAST CHECK: 4H AGO</span>
|
||||||
|
</div>
|
||||||
|
<div class="audit-log" vid="62">
|
||||||
|
<div class="audit-row audit-header" vid="63">
|
||||||
|
<div vid="64">TIMESTAMP</div>
|
||||||
|
<div vid="65">ACTION / RESOURCE</div>
|
||||||
|
<div vid="66">RESULT</div>
|
||||||
|
</div>
|
||||||
|
<div class="audit-row" vid="67">
|
||||||
|
<div vid="68">14:02:45</div>
|
||||||
|
<div vid="69">CRYPTO_KEY_ROTATION / NODE_HARANG</div>
|
||||||
|
<div class="status-tag pass" vid="70">PASS</div>
|
||||||
|
</div>
|
||||||
|
<div class="audit-row" vid="71">
|
||||||
|
<div vid="72">11:30:12</div>
|
||||||
|
<div vid="73">CODE_SCAN_STATIC / PIPELINE_V2</div>
|
||||||
|
<div class="status-tag pass" vid="74">PASS</div>
|
||||||
|
</div>
|
||||||
|
<div class="audit-row" vid="75">
|
||||||
|
<div vid="76">09:15:00</div>
|
||||||
|
<div vid="77">ACCESS_LOG_REVIEW / ADMIN_ROOT</div>
|
||||||
|
<div class="status-tag" vid="78">WARN</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-right" vid="79">
|
||||||
|
<section style="margin-bottom: var(--space-xl);" vid="80">
|
||||||
|
<div class="section-title" vid="81">ASSIGNED NODES</div>
|
||||||
|
<div class="node-stack" vid="82">
|
||||||
|
<div class="node-mini-card" vid="83">
|
||||||
|
<span class="label-meta" vid="84">하랑 [PRIMARY]</span>
|
||||||
|
<div class="node-status-dot" vid="85"></div>
|
||||||
|
</div>
|
||||||
|
<div class="node-mini-card" vid="86">
|
||||||
|
<span class="label-meta" vid="87">나랑 [SECONDARY]</span>
|
||||||
|
<div class="node-status-dot" vid="88"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section vid="89">
|
||||||
|
<div class="section-title" vid="90">TASK CHECKLIST</div>
|
||||||
|
<ul class="checklist" vid="91">
|
||||||
|
<li class="check-item done" vid="92">
|
||||||
|
<div class="check-box checked" vid="93"></div>
|
||||||
|
<span vid="94">인프라 요구사항 명세 작성</span>
|
||||||
|
</li>
|
||||||
|
<li class="check-item done" vid="95">
|
||||||
|
<div class="check-box checked" vid="96"></div>
|
||||||
|
<span vid="97">보안 프로토콜 V3 적용</span>
|
||||||
|
</li>
|
||||||
|
<li class="check-item" vid="98">
|
||||||
|
<div class="check-box" vid="99"></div>
|
||||||
|
<span vid="100">캐시 무효화 로직 테스트</span>
|
||||||
|
</li>
|
||||||
|
<li class="check-item" vid="101">
|
||||||
|
<div class="check-box" vid="102"></div>
|
||||||
|
<span vid="103">부하 분산 알고리즘 튜닝</span>
|
||||||
|
</li>
|
||||||
|
<li class="check-item" vid="104">
|
||||||
|
<div class="check-box" vid="105"></div>
|
||||||
|
<span vid="106">최종 문서화 및 코드 리뷰</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body></html>
|
||||||
|
```
|
||||||
472
.plans/design/references/03-sisters.html
Normal file
472
.plans/design/references/03-sisters.html
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
The code below contains a design. This design should be used to create a new app or be added to an existing one.
|
||||||
|
|
||||||
|
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
|
||||||
|
|
||||||
|
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
|
||||||
|
|
||||||
|
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
|
||||||
|
|
||||||
|
Run the dev command so the user can see the app once finished.
|
||||||
|
|
||||||
|
```
|
||||||
|
<html lang="ko" vid="0"><head vid="1">
|
||||||
|
<meta charset="UTF-8" vid="2">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
|
||||||
|
<title vid="4">하나랑 - 자매 노드 관리</title>
|
||||||
|
<style vid="5">
|
||||||
|
:root {
|
||||||
|
--bg-main: #151515;
|
||||||
|
--bg-surface: #151515;
|
||||||
|
--text-primary: #F5F5F5;
|
||||||
|
--text-secondary: #8A8A8A;
|
||||||
|
--border-color: #333333;
|
||||||
|
--accent-hover: #FFFFFF;
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
--space-xs: 4px;
|
||||||
|
--space-sm: 8px;
|
||||||
|
--space-md: 16px;
|
||||||
|
--space-lg: 24px;
|
||||||
|
--space-xl: 40px;
|
||||||
|
--space-xxl: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background-color: var(--bg-main);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 14px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-meta span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-right: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--space-xl) var(--space-lg);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: var(--space-xxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-circle-full {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-circle-half {
|
||||||
|
width: 10px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
border-radius: 0 20px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover, .nav-link.active {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active::before {
|
||||||
|
content: "[";
|
||||||
|
margin-right: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.nav-link.active::after {
|
||||||
|
content: "]";
|
||||||
|
margin-left: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: var(--space-xl) var(--space-xxl);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr 300px;
|
||||||
|
gap: var(--space-xxl);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-name {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.active {
|
||||||
|
background-color: #FFF;
|
||||||
|
box-shadow: 0 0 8px rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: fit-content;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle:hover {
|
||||||
|
border-color: #666;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle.active {
|
||||||
|
background: var(--text-primary);
|
||||||
|
color: var(--bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capacity-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-container {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-bar {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #333;
|
||||||
|
transition: height 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-bar.highlight {
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-history {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-row span:last-child {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-pair {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-pair .val {
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body vid="6">
|
||||||
|
|
||||||
|
<div class="app-container" vid="7">
|
||||||
|
<aside class="sidebar" vid="8">
|
||||||
|
<div class="logo-container" vid="9">
|
||||||
|
<div class="logo-circle-full" vid="10"></div>
|
||||||
|
<div class="logo-circle-half" vid="11"></div>
|
||||||
|
</div>
|
||||||
|
<nav vid="12">
|
||||||
|
<ul class="nav-menu" vid="13">
|
||||||
|
<li class="nav-item" vid="14"><a href="#" class="nav-link" vid="15">대시</a></li>
|
||||||
|
<li class="nav-item" vid="16"><a href="#" class="nav-link" vid="17">프로</a></li>
|
||||||
|
<li class="nav-item" vid="18"><a href="#" class="nav-link active" vid="19">자매</a></li>
|
||||||
|
<li class="nav-item" vid="20"><a href="#" class="nav-link" vid="21">조직</a></li>
|
||||||
|
<li class="nav-item" vid="22"><a href="#" class="nav-link" vid="23">설정</a></li>
|
||||||
|
<li class="nav-item" vid="24"><a href="#" class="nav-link" vid="25">관리</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content" vid="26">
|
||||||
|
<header class="page-header" vid="27">
|
||||||
|
<h1 class="page-title" vid="28">자매 노드 관리</h1>
|
||||||
|
<div class="label-meta" vid="29"><span vid="30">TOTAL NODES:</span> 04</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="node-grid" vid="31">
|
||||||
|
|
||||||
|
<div class="node-entry" vid="32">
|
||||||
|
<div class="node-info" vid="33">
|
||||||
|
<div class="node-name" vid="34">
|
||||||
|
<div class="status-indicator active" vid="35"></div>
|
||||||
|
하랑 (Primary)
|
||||||
|
</div>
|
||||||
|
<div class="meta-pair" vid="36">
|
||||||
|
<div class="label-meta" vid="37">Uptime</div>
|
||||||
|
<div class="val" vid="38">342:12:05</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-controls" vid="39">
|
||||||
|
<button class="btn-toggle active" vid="40">ACTIVE</button>
|
||||||
|
<button class="btn-toggle" vid="41">REBOOT</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="capacity-section" vid="42">
|
||||||
|
<div class="label-meta" vid="43">CAPACITY HISTORY (24H)</div>
|
||||||
|
<div class="graph-container" vid="44">
|
||||||
|
<div class="graph-bar" style="height: 40%" vid="45"></div>
|
||||||
|
<div class="graph-bar" style="height: 45%" vid="46"></div>
|
||||||
|
<div class="graph-bar" style="height: 60%" vid="47"></div>
|
||||||
|
<div class="graph-bar" style="height: 55%" vid="48"></div>
|
||||||
|
<div class="graph-bar" style="height: 70%" vid="49"></div>
|
||||||
|
<div class="graph-bar" style="height: 85%" vid="50"></div>
|
||||||
|
<div class="graph-bar highlight" style="height: 92%" vid="51"></div>
|
||||||
|
<div class="graph-bar highlight" style="height: 84%" vid="52"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sync-row" vid="53"><span vid="54">LOAD INDEX</span><span vid="55">84.2%</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="sync-history" vid="56">
|
||||||
|
<div class="label-meta" vid="57">SYNC LOG</div>
|
||||||
|
<div class="sync-row" vid="58"><span vid="59">14:05:22</span><span vid="60">SUCCESS</span></div>
|
||||||
|
<div class="sync-row" vid="61"><span vid="62">13:55:01</span><span vid="63">SUCCESS</span></div>
|
||||||
|
<div class="sync-row" vid="64"><span vid="65">13:44:10</span><span vid="66">SUCCESS</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="node-entry" vid="67">
|
||||||
|
<div class="node-info" vid="68">
|
||||||
|
<div class="node-name" vid="69">
|
||||||
|
<div class="status-indicator active" vid="70"></div>
|
||||||
|
나랑 (Secondary)
|
||||||
|
</div>
|
||||||
|
<div class="meta-pair" vid="71">
|
||||||
|
<div class="label-meta" vid="72">Uptime</div>
|
||||||
|
<div class="val" vid="73">112:44:21</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-controls" vid="74">
|
||||||
|
<button class="btn-toggle active" vid="75">ACTIVE</button>
|
||||||
|
<button class="btn-toggle" vid="76">REBOOT</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="capacity-section" vid="77">
|
||||||
|
<div class="label-meta" vid="78">CAPACITY HISTORY (24H)</div>
|
||||||
|
<div class="graph-container" vid="79">
|
||||||
|
<div class="graph-bar" style="height: 30%" vid="80"></div>
|
||||||
|
<div class="graph-bar" style="height: 32%" vid="81"></div>
|
||||||
|
<div class="graph-bar" style="height: 35%" vid="82"></div>
|
||||||
|
<div class="graph-bar" style="height: 40%" vid="83"></div>
|
||||||
|
<div class="graph-bar" style="height: 38%" vid="84"></div>
|
||||||
|
<div class="graph-bar" style="height: 45%" vid="85"></div>
|
||||||
|
<div class="graph-bar highlight" style="height: 50%" vid="86"></div>
|
||||||
|
<div class="graph-bar highlight" style="height: 48%" vid="87"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sync-row" vid="88"><span vid="89">LOAD INDEX</span><span vid="90">48.7%</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="sync-history" vid="91">
|
||||||
|
<div class="label-meta" vid="92">SYNC LOG</div>
|
||||||
|
<div class="sync-row" vid="93"><span vid="94">14:02:11</span><span vid="95">SUCCESS</span></div>
|
||||||
|
<div class="sync-row" vid="96"><span vid="97">13:51:44</span><span vid="98">SUCCESS</span></div>
|
||||||
|
<div class="sync-row" vid="99"><span vid="100">13:40:02</span><span vid="101">SUCCESS</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="node-entry" vid="102">
|
||||||
|
<div class="node-info" vid="103">
|
||||||
|
<div class="node-name" vid="104">
|
||||||
|
<div class="status-indicator" vid="105"></div>
|
||||||
|
다랑 (Standby)
|
||||||
|
</div>
|
||||||
|
<div class="meta-pair" vid="106">
|
||||||
|
<div class="label-meta" vid="107">Last Active</div>
|
||||||
|
<div class="val" vid="108">02:15:00 ago</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-controls" vid="109">
|
||||||
|
<button class="btn-toggle" vid="110">STANDBY</button>
|
||||||
|
<button class="btn-toggle" vid="111">ACTIVATE</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="capacity-section" vid="112">
|
||||||
|
<div class="label-meta" vid="113">CAPACITY HISTORY (24H)</div>
|
||||||
|
<div class="graph-container" vid="114">
|
||||||
|
<div class="graph-bar" style="height: 10%" vid="115"></div>
|
||||||
|
<div class="graph-bar" style="height: 10%" vid="116"></div>
|
||||||
|
<div class="graph-bar" style="height: 5%" vid="117"></div>
|
||||||
|
<div class="graph-bar" style="height: 5%" vid="118"></div>
|
||||||
|
<div class="graph-bar" style="height: 5%" vid="119"></div>
|
||||||
|
<div class="graph-bar" style="height: 5%" vid="120"></div>
|
||||||
|
<div class="graph-bar" style="height: 2%" vid="121"></div>
|
||||||
|
<div class="graph-bar" style="height: 2%" vid="122"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sync-row" vid="123"><span vid="124">LOAD INDEX</span><span vid="125">0.0%</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="sync-history" vid="126">
|
||||||
|
<div class="label-meta" vid="127">SYNC LOG</div>
|
||||||
|
<div class="sync-row" vid="128"><span vid="129">14:00:00</span><span vid="130">IDLE</span></div>
|
||||||
|
<div class="sync-row" vid="131"><span vid="132">13:45:00</span><span vid="133">IDLE</span></div>
|
||||||
|
<div class="sync-row" vid="134"><span vid="135">13:30:00</span><span vid="136">IDLE</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="node-entry" vid="137">
|
||||||
|
<div class="node-info" vid="138">
|
||||||
|
<div class="node-name" vid="139">
|
||||||
|
<div class="status-indicator active" vid="140"></div>
|
||||||
|
이랑 (Sync)
|
||||||
|
</div>
|
||||||
|
<div class="meta-pair" vid="141">
|
||||||
|
<div class="label-meta" vid="142">Queue Depth</div>
|
||||||
|
<div class="val" vid="143">12 PKTS</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-controls" vid="144">
|
||||||
|
<button class="btn-toggle active" vid="145">SYNCING</button>
|
||||||
|
<button class="btn-toggle" vid="146">FLUSH</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="capacity-section" vid="147">
|
||||||
|
<div class="label-meta" vid="148">CAPACITY HISTORY (24H)</div>
|
||||||
|
<div class="graph-container" vid="149">
|
||||||
|
<div class="graph-bar" style="height: 20%" vid="150"></div>
|
||||||
|
<div class="graph-bar" style="height: 25%" vid="151"></div>
|
||||||
|
<div class="graph-bar" style="height: 15%" vid="152"></div>
|
||||||
|
<div class="graph-bar" style="height: 30%" vid="153"></div>
|
||||||
|
<div class="graph-bar" style="height: 40%" vid="154"></div>
|
||||||
|
<div class="graph-bar" style="height: 35%" vid="155"></div>
|
||||||
|
<div class="graph-bar highlight" style="height: 20%" vid="156"></div>
|
||||||
|
<div class="graph-bar highlight" style="height: 15%" vid="157"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sync-row" vid="158"><span vid="159">LOAD INDEX</span><span vid="160">15.2%</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="sync-history" vid="161">
|
||||||
|
<div class="label-meta" vid="162">SYNC LOG</div>
|
||||||
|
<div class="sync-row" vid="163"><span vid="164">14:06:01</span><span vid="165">SYNCING</span></div>
|
||||||
|
<div class="sync-row" vid="166"><span vid="167">14:05:45</span><span vid="168">SYNCING</span></div>
|
||||||
|
<div class="sync-row" vid="169"><span vid="170">14:05:30</span><span vid="171">SYNCING</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body></html>
|
||||||
|
```
|
||||||
502
.plans/design/references/04-settings.html
Normal file
502
.plans/design/references/04-settings.html
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
The code below contains a design. This design should be used to create a new app or be added to an existing one.
|
||||||
|
|
||||||
|
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
|
||||||
|
|
||||||
|
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
|
||||||
|
|
||||||
|
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
|
||||||
|
|
||||||
|
Run the dev command so the user can see the app once finished.
|
||||||
|
|
||||||
|
```
|
||||||
|
<html lang="ko" vid="0"><head vid="1">
|
||||||
|
<meta charset="UTF-8" vid="2">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
|
||||||
|
<title vid="4">하나랑 대시보드 - 설정</title>
|
||||||
|
<style vid="5">
|
||||||
|
:root {
|
||||||
|
--bg-main: #151515;
|
||||||
|
--bg-surface: #151515;
|
||||||
|
--text-primary: #F5F5F5;
|
||||||
|
--text-secondary: #8A8A8A;
|
||||||
|
--border-color: #333333;
|
||||||
|
--accent-hover: #FFFFFF;
|
||||||
|
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
|
||||||
|
--space-xs: 4px;
|
||||||
|
--space-sm: 8px;
|
||||||
|
--space-md: 16px;
|
||||||
|
--space-lg: 24px;
|
||||||
|
--space-xl: 40px;
|
||||||
|
--space-xxl: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background-color: var(--bg-main);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 14px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-meta span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-right: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--space-xl) var(--space-lg);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: var(--space-xxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-circle-full {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-circle-half {
|
||||||
|
width: 10px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
border-radius: 0 20px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover, .nav-link.active {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active::before {
|
||||||
|
content: "[";
|
||||||
|
margin-right: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.nav-link.active::after {
|
||||||
|
content: "]";
|
||||||
|
margin-left: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: var(--space-xl) var(--space-xxl);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: var(--space-sm);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--space-xxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-md) 0;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 34px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
transition: .2s;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 10px;
|
||||||
|
width: 10px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: var(--text-secondary);
|
||||||
|
transition: .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider {
|
||||||
|
background-color: #151515;
|
||||||
|
border-color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider:before {
|
||||||
|
transform: translateX(16px);
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.bracket-input-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
outline: none;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-input:focus {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: var(--space-xl);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: var(--space-sm) var(--space-lg);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
border-color: var(--text-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--text-primary);
|
||||||
|
color: var(--bg-main);
|
||||||
|
border-color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body vid="6">
|
||||||
|
|
||||||
|
<div class="app-container" vid="7">
|
||||||
|
<aside class="sidebar" vid="8">
|
||||||
|
<div class="logo-container" vid="9">
|
||||||
|
<div class="logo-circle-full" vid="10"></div>
|
||||||
|
<div class="logo-circle-half" vid="11"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav vid="12">
|
||||||
|
<ul class="nav-menu" vid="13">
|
||||||
|
<li class="nav-item" vid="14"><a href="#" class="nav-link" vid="15">대시</a></li>
|
||||||
|
<li class="nav-item" vid="16"><a href="#" class="nav-link" vid="17">프로</a></li>
|
||||||
|
<li class="nav-item" vid="18"><a href="#" class="nav-link" vid="19">자매</a></li>
|
||||||
|
<li class="nav-item" vid="20"><a href="#" class="nav-link" vid="21">조직</a></li>
|
||||||
|
<li class="nav-item" vid="22"><a href="#" class="nav-link active" vid="23">설정</a></li>
|
||||||
|
<li class="nav-item" vid="24"><a href="#" class="nav-link" vid="25">관리</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content" vid="26">
|
||||||
|
<header class="page-header" vid="27">
|
||||||
|
<h1 class="page-title" vid="28">시스템 설정</h1>
|
||||||
|
<div class="label-meta" vid="29"><span vid="30">CONFIG:</span>v2.0.4-STABLE</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="settings-grid" vid="31">
|
||||||
|
|
||||||
|
<section class="settings-section" vid="32">
|
||||||
|
<div class="section-title" vid="33">
|
||||||
|
<span vid="34">ADMIN PROTOCOL RULES</span>
|
||||||
|
<span class="label-meta" vid="35">SEC: 01</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" vid="36">
|
||||||
|
<div class="setting-info" vid="37">
|
||||||
|
<div class="setting-label" vid="38">강제 2단계 인증 (2FA)</div>
|
||||||
|
<div class="setting-desc" vid="39">모든 관리자 계정에 대해 생체 인식 또는 하드웨어 키 요구.</div>
|
||||||
|
</div>
|
||||||
|
<label class="switch" vid="40">
|
||||||
|
<input type="checkbox" checked="" vid="41">
|
||||||
|
<span class="slider" vid="42"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" vid="43">
|
||||||
|
<div class="setting-info" vid="44">
|
||||||
|
<div class="setting-label" vid="45">세션 유효 시간</div>
|
||||||
|
<div class="setting-desc" vid="46">비활동 시 관리 콘솔 자동 로그아웃 시간(분).</div>
|
||||||
|
</div>
|
||||||
|
<div class="bracket-input-group" vid="47">
|
||||||
|
[ <input type="text" class="bracket-input" value="15" vid="48"> ]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" vid="49">
|
||||||
|
<div class="setting-info" vid="50">
|
||||||
|
<div class="setting-label" vid="51">IP 화이트리스트</div>
|
||||||
|
<div class="setting-desc" vid="52">지정된 대역에서만 관리자 권한 접근 허용.</div>
|
||||||
|
</div>
|
||||||
|
<label class="switch" vid="53">
|
||||||
|
<input type="checkbox" vid="54">
|
||||||
|
<span class="slider" vid="55"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section class="settings-section" vid="56">
|
||||||
|
<div class="section-title" vid="57">
|
||||||
|
<span vid="58">BACKUP & REDUNDANCY</span>
|
||||||
|
<span class="label-meta" vid="59">SEC: 02</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" vid="60">
|
||||||
|
<div class="setting-info" vid="61">
|
||||||
|
<div class="setting-label" vid="62">자동 백업 활성화</div>
|
||||||
|
<div class="setting-desc" vid="63">전체 시스템 상태의 주기적인 스냅샷 생성.</div>
|
||||||
|
</div>
|
||||||
|
<label class="switch" vid="64">
|
||||||
|
<input type="checkbox" checked="" vid="65">
|
||||||
|
<span class="slider" vid="66"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" vid="67">
|
||||||
|
<div class="setting-info" vid="68">
|
||||||
|
<div class="setting-label" vid="69">백업 주기 (시간)</div>
|
||||||
|
<div class="setting-desc" vid="70">스냅샷 생성 간격 설정.</div>
|
||||||
|
</div>
|
||||||
|
<div class="bracket-input-group" vid="71">
|
||||||
|
[ <input type="text" class="bracket-input" value="08:00" vid="72"> ]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" vid="73">
|
||||||
|
<div class="setting-info" vid="74">
|
||||||
|
<div class="setting-label" vid="75">보관 주기 (일)</div>
|
||||||
|
<div class="setting-desc" vid="76">백업 데이터 자동 삭제 전 유지 기간.</div>
|
||||||
|
</div>
|
||||||
|
<div class="bracket-input-group" vid="77">
|
||||||
|
[ <input type="text" class="bracket-input" value="30" vid="78"> ]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section class="settings-section" vid="79">
|
||||||
|
<div class="section-title" vid="80">
|
||||||
|
<span vid="81">SYNC QUEUE THRESHOLDS</span>
|
||||||
|
<span class="label-meta" vid="82">SEC: 03</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" vid="83">
|
||||||
|
<div class="setting-info" vid="84">
|
||||||
|
<div class="setting-label" vid="85">대기열 경고 임계값</div>
|
||||||
|
<div class="setting-desc" vid="86">동기화 대기 항목이 설정치를 초과할 시 경보 발생.</div>
|
||||||
|
</div>
|
||||||
|
<div class="bracket-input-group" vid="87">
|
||||||
|
[ <input type="text" class="bracket-input" value="500" vid="88"> ]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" vid="89">
|
||||||
|
<div class="setting-info" vid="90">
|
||||||
|
<div class="setting-label" vid="91">자동 스케일링</div>
|
||||||
|
<div class="setting-desc" vid="92">부하 증가 시 임시 노드 리소스 할당.</div>
|
||||||
|
</div>
|
||||||
|
<label class="switch" vid="93">
|
||||||
|
<input type="checkbox" checked="" vid="94">
|
||||||
|
<span class="slider" vid="95"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section class="settings-section" vid="96">
|
||||||
|
<div class="section-title" vid="97">
|
||||||
|
<span vid="98">NODE ALERT TRIGGERS</span>
|
||||||
|
<span class="label-meta" vid="99">SEC: 04</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" vid="100">
|
||||||
|
<div class="setting-info" vid="101">
|
||||||
|
<div class="setting-label" vid="102">CPU 사용량 임계값 (%)</div>
|
||||||
|
<div class="setting-desc" vid="103">시스템 부하 경고 기준점.</div>
|
||||||
|
</div>
|
||||||
|
<div class="bracket-input-group" vid="104">
|
||||||
|
[ <input type="text" class="bracket-input" value="90" vid="105"> ]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" vid="106">
|
||||||
|
<div class="setting-info" vid="107">
|
||||||
|
<div class="setting-label" vid="108">레이턴시 경보 (ms)</div>
|
||||||
|
<div class="setting-desc" vid="109">응답 속도 지연에 대한 임계값 설정.</div>
|
||||||
|
</div>
|
||||||
|
<div class="bracket-input-group" vid="110">
|
||||||
|
[ <input type="text" class="bracket-input" value="250" vid="111"> ]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" vid="112">
|
||||||
|
<div class="setting-info" vid="113">
|
||||||
|
<div class="setting-label" vid="114">노드 오프라인 알림</div>
|
||||||
|
<div class="setting-desc" vid="115">활성 노드 연결 해제 시 즉시 알림.</div>
|
||||||
|
</div>
|
||||||
|
<label class="switch" vid="116">
|
||||||
|
<input type="checkbox" checked="" vid="117">
|
||||||
|
<span class="slider" vid="118"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-bar" vid="119">
|
||||||
|
<button class="btn" vid="120">초기화</button>
|
||||||
|
<button class="btn btn-primary" vid="121">설정 저장</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body></html>
|
||||||
|
```
|
||||||
408
.plans/design/references/05-activity-log.html
Normal file
408
.plans/design/references/05-activity-log.html
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
The code below contains a design. This design should be used to create a new app or be added to an existing one.
|
||||||
|
|
||||||
|
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
|
||||||
|
|
||||||
|
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
|
||||||
|
|
||||||
|
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
|
||||||
|
|
||||||
|
Run the dev command so the user can see the app once finished.
|
||||||
|
|
||||||
|
```
|
||||||
|
<html lang="ko" vid="0"><head vid="1">
|
||||||
|
<meta charset="UTF-8" vid="2">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
|
||||||
|
<title vid="4">하나랑 대시보드 - ACTIVITY LOG</title>
|
||||||
|
<style vid="5">
|
||||||
|
:root {
|
||||||
|
--bg-main: #151515;
|
||||||
|
--bg-surface: #151515;
|
||||||
|
--text-primary: #F5F5F5;
|
||||||
|
--text-secondary: #8A8A8A;
|
||||||
|
--border-color: #333333;
|
||||||
|
--accent-hover: #FFFFFF;
|
||||||
|
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
|
||||||
|
--space-xs: 4px;
|
||||||
|
--space-sm: 8px;
|
||||||
|
--space-md: 16px;
|
||||||
|
--space-lg: 24px;
|
||||||
|
--space-xl: 40px;
|
||||||
|
--space-xxl: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background-color: var(--bg-main);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 14px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--space-xl) var(--space-lg);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: var(--space-xxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-circle-full {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-circle-half {
|
||||||
|
width: 10px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
border-radius: 0 20px 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover, .nav-link.active {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active::before { content: "["; margin-right: 4px; color: var(--text-secondary); }
|
||||||
|
.nav-link.active::after { content: "]"; margin-left: 4px; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: var(--space-xl) var(--space-xxl);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: var(--space-md);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.log-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
border-color: #555;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: var(--text-primary);
|
||||||
|
color: var(--bg-main);
|
||||||
|
border-color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
position: relative;
|
||||||
|
flex-grow: 1;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: #0d0d0d;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-table th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row:hover {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-time { width: 180px; color: var(--text-secondary); }
|
||||||
|
.col-type { width: 100px; font-weight: bold; }
|
||||||
|
.col-event { color: var(--text-primary); }
|
||||||
|
.col-status { width: 100px; text-align: right; }
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-sys { color: #888; }
|
||||||
|
.tag-sec { color: #ff5f5f; }
|
||||||
|
.tag-org { color: #5fafff; }
|
||||||
|
.tag-bak { color: #5fff8a; }
|
||||||
|
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-top: var(--space-md);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-nav {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-nav:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body vid="6">
|
||||||
|
|
||||||
|
<div class="app-container" vid="7">
|
||||||
|
<aside class="sidebar" vid="8">
|
||||||
|
<div class="logo-container" vid="9">
|
||||||
|
<div class="logo-circle-full" vid="10"></div>
|
||||||
|
<div class="logo-circle-half" vid="11"></div>
|
||||||
|
</div>
|
||||||
|
<nav vid="12">
|
||||||
|
<ul class="nav-menu" vid="13">
|
||||||
|
<li class="nav-item" vid="14"><a href="#" class="nav-link" vid="15">대시</a></li>
|
||||||
|
<li class="nav-item" vid="16"><a href="#" class="nav-link active" vid="17">활동</a></li>
|
||||||
|
<li class="nav-item" vid="18"><a href="#" class="nav-link" vid="19">프로</a></li>
|
||||||
|
<li class="nav-item" vid="20"><a href="#" class="nav-link" vid="21">자매</a></li>
|
||||||
|
<li class="nav-item" vid="22"><a href="#" class="nav-link" vid="23">조직</a></li>
|
||||||
|
<li class="nav-item" vid="24"><a href="#" class="nav-link" vid="25">설정</a></li>
|
||||||
|
<li class="nav-item" vid="26"><a href="#" class="nav-link" vid="27">관리</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content" vid="28">
|
||||||
|
<header class="page-header" vid="29">
|
||||||
|
<h1 class="page-title" vid="30">활동 로그 <span style="color:var(--text-secondary); font-size: 18px;" vid="31">/ ACTIVITY LOG</span></h1>
|
||||||
|
<div class="header-meta" vid="32">SYS_LOG_V.4.2 // RUNTIME: 428:12:05</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="log-controls" vid="33">
|
||||||
|
<div class="filter-group" vid="34">
|
||||||
|
<button class="filter-btn active" vid="35">ALL</button>
|
||||||
|
<button class="filter-btn" vid="36">SYSTEM</button>
|
||||||
|
<button class="filter-btn" vid="37">SECURITY</button>
|
||||||
|
<button class="filter-btn" vid="38">ORG</button>
|
||||||
|
<button class="filter-btn" vid="39">BACKUP</button>
|
||||||
|
</div>
|
||||||
|
<div class="search-box" vid="40">
|
||||||
|
<input type="text" class="search-input" placeholder="SEARCH LOGS..." vid="41">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="log-container" vid="42">
|
||||||
|
<table class="log-table" vid="43">
|
||||||
|
<thead vid="44">
|
||||||
|
<tr vid="45">
|
||||||
|
<th vid="46">TIMESTAMP</th>
|
||||||
|
<th vid="47">TYPE</th>
|
||||||
|
<th vid="48">EVENT DESCRIPTION</th>
|
||||||
|
<th style="text-align: right;" vid="49">CODE</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody vid="50">
|
||||||
|
<tr class="log-row" vid="51">
|
||||||
|
<td class="col-time" vid="52">2023-10-27 14:02:45</td>
|
||||||
|
<td class="col-type" vid="53"><span class="tag tag-sys" vid="54">SYSTEM</span></td>
|
||||||
|
<td class="col-event" vid="55">Node <span class="bracket-label" vid="56">[다랑]</span> transitioned to status: <span class="bracket-label" vid="57">STANDBY</span>. Protocol initiated by admin_auth_01.</td>
|
||||||
|
<td class="col-status" vid="58">0x402</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="log-row" vid="59">
|
||||||
|
<td class="col-time" vid="60">2023-10-27 13:45:12</td>
|
||||||
|
<td class="col-type" vid="61"><span class="tag tag-org" vid="62">ORG</span></td>
|
||||||
|
<td class="col-event" vid="63">New sub-directory created under <span class="bracket-label" vid="64">[Organization Beta]</span>. Path mapping complete.</td>
|
||||||
|
<td class="col-status" vid="65">0x112</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="log-row" vid="66">
|
||||||
|
<td class="col-time" vid="67">2023-10-27 11:30:12</td>
|
||||||
|
<td class="col-type" vid="68"><span class="tag tag-sec" vid="69">SECURITY</span></td>
|
||||||
|
<td class="col-event" vid="70">Full security sweep completed for project <span class="bracket-label" vid="71">[DATA_PIPE_OPT]</span>. Zero vulnerabilities identified.</td>
|
||||||
|
<td class="col-status" vid="72">0x000</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="log-row" vid="73">
|
||||||
|
<td class="col-time" vid="74">2023-10-27 10:15:33</td>
|
||||||
|
<td class="col-type" vid="75"><span class="tag tag-sys" vid="76">SYSTEM</span></td>
|
||||||
|
<td class="col-event" vid="77">Memory allocation threshold reached on <span class="bracket-label" vid="78">[나랑]</span>. Auto-scaling provisioned +4GB swap.</td>
|
||||||
|
<td class="col-status" vid="79">0x284</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="log-row" vid="80">
|
||||||
|
<td class="col-time" vid="81">2023-10-27 09:15:00</td>
|
||||||
|
<td class="col-type" vid="82"><span class="tag tag-org" vid="83">ORG</span></td>
|
||||||
|
<td class="col-event" vid="84">Batch import: 42 new identities integrated into <span class="bracket-label" vid="85">[Beta]</span> structure. </td>
|
||||||
|
<td class="col-status" vid="86">0x105</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="log-row" vid="87">
|
||||||
|
<td class="col-time" vid="88">2023-10-27 08:00:05</td>
|
||||||
|
<td class="col-type" vid="89"><span class="tag tag-bak" vid="90">BACKUP</span></td>
|
||||||
|
<td class="col-event" vid="91">Scheduled snapshot <span class="bracket-label" vid="92">[DAILY_BKP_2710]</span> finalized. Target: S3_COLD_STORAGE.</td>
|
||||||
|
<td class="col-status" vid="93">0x900</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="log-row" vid="94">
|
||||||
|
<td class="col-time" vid="95">2023-10-27 07:42:11</td>
|
||||||
|
<td class="col-type" vid="96"><span class="tag tag-sec" vid="97">SECURITY</span></td>
|
||||||
|
<td class="col-event" vid="98">SSH login attempt blocked. IP: 192.168.1.104. Failed attempts: 3. Protocol: BAN_IP.</td>
|
||||||
|
<td class="col-status" vid="99">0x666</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="log-row" vid="100">
|
||||||
|
<td class="col-time" vid="101">2023-10-27 04:20:00</td>
|
||||||
|
<td class="col-type" vid="102"><span class="tag tag-sys" vid="103">SYSTEM</span></td>
|
||||||
|
<td class="col-event" vid="104">Garbage collection cycle completed on all active nodes. Freed 1.2GB.</td>
|
||||||
|
<td class="col-status" vid="105">0x401</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="log-row" vid="106">
|
||||||
|
<td class="col-time" vid="107">2023-10-27 01:12:45</td>
|
||||||
|
<td class="col-type" vid="108"><span class="tag tag-sys" vid="109">SYSTEM</span></td>
|
||||||
|
<td class="col-event" vid="110">Kernel patch 1.2.4 deployed to <span class="bracket-label" vid="111">[하랑]</span>. Restart bypassed via hotpatching.</td>
|
||||||
|
<td class="col-status" vid="112">0x400</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="pagination" vid="113">
|
||||||
|
<span class="page-info" vid="114">SHOWING 1-9 OF 2,410 ENTRIES</span>
|
||||||
|
<div style="display: flex; gap: var(--space-md);" vid="115">
|
||||||
|
<span class="page-nav" vid="116">[PREV]</span>
|
||||||
|
<span style="color: var(--text-primary);" vid="117">PAGE 01</span>
|
||||||
|
<span class="page-nav" vid="118">[NEXT]</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body></html>
|
||||||
|
```
|
||||||
363
.plans/design/references/06-org-tree.html
Normal file
363
.plans/design/references/06-org-tree.html
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
The code below contains a design. This design should be used to create a new app or be added to an existing one.
|
||||||
|
|
||||||
|
Look at the current open project to determine if a project exists. If no project is open, create a new Vite project then create this view in React after componentizing it.
|
||||||
|
|
||||||
|
If a project does exist, determine the framework being used and implement the design within that framework. Identify whether reusable components already exist that can be used to implement the design faithfully and if so use them, otherwise create new components. If other views already exist in the project, make sure to place the view in a sensible route and connect it to the other views.
|
||||||
|
|
||||||
|
Ensure the visual characteristics, layout, and interactions in the design are preserved with perfect fidelity.
|
||||||
|
|
||||||
|
Run the dev command so the user can see the app once finished.
|
||||||
|
|
||||||
|
```
|
||||||
|
<html lang="ko" vid="0"><head vid="1">
|
||||||
|
<meta charset="UTF-8" vid="2">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" vid="3">
|
||||||
|
<title vid="4">하나랑 - 시스템 아키텍처</title>
|
||||||
|
<style vid="5">
|
||||||
|
:root {
|
||||||
|
--bg-main: #0a0a0a;
|
||||||
|
--bg-card: #141414;
|
||||||
|
--text-primary: #e0e0e0;
|
||||||
|
--text-secondary: #707070;
|
||||||
|
--border-color: #262626;
|
||||||
|
|
||||||
|
--level-high: #f43f5e;
|
||||||
|
--level-mid: #3b82f6;
|
||||||
|
--level-low: #10b981;
|
||||||
|
--level-none: #525252;
|
||||||
|
|
||||||
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background-color: var(--bg-main);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 13px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 64px;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 24px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-circle-full { width: 16px; height: 16px; background: #fff; border-radius: 50%; }
|
||||||
|
.logo-circle-half { width: 8px; height: 16px; background: #fff; border-radius: 0 16px 16px 0; margin-left: 8px; }
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: mixed;
|
||||||
|
transform: rotate(180px);
|
||||||
|
margin: 16px 0;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.nav-item.active { color: #fff; }
|
||||||
|
|
||||||
|
.main-viewport {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 40px;
|
||||||
|
background-image: radial-gradient(circle, #1a1a1a 1px, transparent 1px);
|
||||||
|
background-size: 30px 30px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.tree-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-branch {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
padding-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.tree-branch::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
height: 40px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-children {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.tree-children::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: calc(100% - 100px);
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.terminal-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-left: 3px solid var(--level-none);
|
||||||
|
width: 200px;
|
||||||
|
padding: 12px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-card.high { border-left-color: var(--level-high); }
|
||||||
|
.terminal-card.mid { border-left-color: var(--level-mid); }
|
||||||
|
.terminal-card.low { border-left-color: var(--level-low); }
|
||||||
|
|
||||||
|
.node-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: 1px solid #262626;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge b { color: var(--text-primary); font-weight: normal; }
|
||||||
|
|
||||||
|
|
||||||
|
.node-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -8px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body vid="6">
|
||||||
|
|
||||||
|
<div class="app-container" vid="7">
|
||||||
|
<aside class="sidebar" vid="8">
|
||||||
|
<div class="logo-container" vid="9">
|
||||||
|
<div class="logo-circle-full" vid="10"></div>
|
||||||
|
<div class="logo-circle-half" vid="11"></div>
|
||||||
|
</div>
|
||||||
|
<a href="#" class="nav-item" vid="12">DASH</a>
|
||||||
|
<a href="#" class="nav-item" vid="13">PROJ</a>
|
||||||
|
<a href="#" class="nav-item active" vid="14">ORG_TREE</a>
|
||||||
|
<a href="#" class="nav-item" vid="15">CONF</a>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-viewport" vid="16">
|
||||||
|
<header class="header-info" vid="17">
|
||||||
|
<div vid="18">SYS_TYPE: <span style="color: #fff;" vid="19">CORE_ROOT_01</span></div>
|
||||||
|
<div vid="20">TIMESTAMP: <span style="color: var(--text-secondary);" vid="21">2023.10.27_14:42</span></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tree-container" vid="22">
|
||||||
|
|
||||||
|
<div class="node-group" vid="23">
|
||||||
|
<div class="terminal-card high" vid="24">
|
||||||
|
<div class="node-label" vid="25"><span vid="26">[HQ]</span> 하나랑 글로벌</div>
|
||||||
|
<div class="badge-row" vid="27">
|
||||||
|
<div class="badge" vid="28">MEM: <b vid="29">142</b></div>
|
||||||
|
<div class="badge" vid="30">S_ADMIN</div>
|
||||||
|
<div class="badge" vid="31">LV_09</div>
|
||||||
|
</div>
|
||||||
|
<div class="toggle" vid="32">−</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="tree-branch" vid="33">
|
||||||
|
<div class="tree-children" vid="34">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="node-group" vid="35">
|
||||||
|
<div class="terminal-card mid" vid="36">
|
||||||
|
<div class="node-label" vid="37"><span vid="38">[R&D]</span> 기술연구소</div>
|
||||||
|
<div class="badge-row" vid="39">
|
||||||
|
<div class="badge" vid="40">MEM: <b vid="41">48</b></div>
|
||||||
|
<div class="badge" vid="42">LEAD</div>
|
||||||
|
<div class="badge" vid="43">LV_07</div>
|
||||||
|
</div>
|
||||||
|
<div class="toggle" vid="44">−</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tree-branch" vid="45">
|
||||||
|
<div class="tree-children" vid="46">
|
||||||
|
<div class="terminal-card mid" vid="47">
|
||||||
|
<div class="node-label" vid="48">Core Engine</div>
|
||||||
|
<div class="badge-row" vid="49">
|
||||||
|
<div class="badge" vid="50">MEM: <b vid="51">12</b></div>
|
||||||
|
<div class="badge" vid="52">WRITE</div>
|
||||||
|
<div class="badge" vid="53">LV_05</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-card mid" vid="54">
|
||||||
|
<div class="node-label" vid="55">Infrastructure</div>
|
||||||
|
<div class="badge-row" vid="56">
|
||||||
|
<div class="badge" vid="57">MEM: <b vid="58">36</b></div>
|
||||||
|
<div class="badge" vid="59">DEPLOY</div>
|
||||||
|
<div class="badge" vid="60">LV_06</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="node-group" vid="61">
|
||||||
|
<div class="terminal-card low" vid="62">
|
||||||
|
<div class="node-label" vid="63"><span vid="64">[OPS]</span> 운영지원팀</div>
|
||||||
|
<div class="badge-row" vid="65">
|
||||||
|
<div class="badge" vid="66">MEM: <b vid="67">94</b></div>
|
||||||
|
<div class="badge" vid="68">MGR</div>
|
||||||
|
<div class="badge" vid="69">LV_04</div>
|
||||||
|
</div>
|
||||||
|
<div class="toggle" vid="70">−</div>
|
||||||
|
</div>
|
||||||
|
<div class="tree-branch" vid="71">
|
||||||
|
<div class="terminal-card low" vid="72">
|
||||||
|
<div class="node-label" vid="73">Monitoring</div>
|
||||||
|
<div class="badge-row" vid="74">
|
||||||
|
<div class="badge" vid="75">MEM: <b vid="76">24</b></div>
|
||||||
|
<div class="badge" vid="77">READ</div>
|
||||||
|
<div class="badge" vid="78">LV_02</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="node-group" vid="79">
|
||||||
|
<div class="terminal-card low" vid="80">
|
||||||
|
<div class="node-label" vid="81"><span vid="82">[EXT]</span> 외부 협력사</div>
|
||||||
|
<div class="badge-row" vid="83">
|
||||||
|
<div class="badge" vid="84">MEM: <b vid="85">12</b></div>
|
||||||
|
<div class="badge" vid="86">GUEST</div>
|
||||||
|
<div class="badge" vid="87">LV_01</div>
|
||||||
|
</div>
|
||||||
|
<div class="toggle" vid="88">−</div>
|
||||||
|
</div>
|
||||||
|
<div class="tree-branch" vid="89">
|
||||||
|
<div class="terminal-card low" vid="90">
|
||||||
|
<div class="node-label" vid="91">Security Audit</div>
|
||||||
|
<div class="badge-row" vid="92">
|
||||||
|
<div class="badge" vid="93">MEM: <b vid="94">04</b></div>
|
||||||
|
<div class="badge" vid="95">LIMIT</div>
|
||||||
|
<div class="badge" vid="96">LV_01</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer style="margin-top: auto; width: 100%; max-width: 1000px; padding: 24px 0; display: flex; justify-content: space-between; border-top: 1px solid var(--border-color); font-family: var(--font-mono); color: var(--text-secondary); font-size: 10px;" vid="97">
|
||||||
|
<div vid="98">TOTAL_NODES: 07</div>
|
||||||
|
<div vid="99">STATUS: SYNC_COMPLETE</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body></html>
|
||||||
|
```
|
||||||
81
.plans/design/ui/activity-log-design.md
Normal file
81
.plans/design/ui/activity-log-design.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# 활동 로그 (`/activities`) — 디자인 v2
|
||||||
|
|
||||||
|
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #5
|
||||||
|
|
||||||
|
## 레이아웃
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┬──────────────────────────────────────────────────┐
|
||||||
|
│ Sidebar │ 활동 로그 / ACTIVITY LOG SYS_LOG_V.4.2 //... │
|
||||||
|
│ │ ────────────────────────────────────────────── │
|
||||||
|
│ │ │
|
||||||
|
│ │ [ALL] [SYSTEM] [SECURITY] [ORG] [BACKUP] 🔍... │
|
||||||
|
│ │ │
|
||||||
|
│ │ ┌──────────────────────────────────────────────┐│
|
||||||
|
│ │ │ TIMESTAMP TYPE EVENT CODE││
|
||||||
|
│ │ │ 2023-10-27 14:02 SYSTEM Node [다랑] 0x402││
|
||||||
|
│ │ │ 2023-10-27 13:45 ORG New sub-dir 0x112││
|
||||||
|
│ │ │ 2023-10-27 11:30 SECURITY Full sweep 0x000││
|
||||||
|
│ │ │ 2023-10-27 10:15 SYSTEM Memory alloc 0x284││
|
||||||
|
│ │ │ 2023-10-27 09:15 ORG Batch import 0x105││
|
||||||
|
│ │ │ 2023-10-27 08:00 BACKUP Snapshot 0x900││
|
||||||
|
│ │ │ 2023-10-27 07:42 SECURITY SSH blocked 0x666││
|
||||||
|
│ │ │ 2023-10-27 04:20 SYSTEM GC cycle 0x401││
|
||||||
|
│ │ │ 2023-10-27 01:12 SYSTEM Kernel patch 0x400││
|
||||||
|
│ │ └──────────────────────────────────────────────┘│
|
||||||
|
│ │ SHOWING 1-9 OF 2,410 [PREV] 01 [NEXT]│
|
||||||
|
└──────────┴──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 네비게이션 변경
|
||||||
|
- 사이드바에 **"활동"** 메뉴 추가 (대시 다음)
|
||||||
|
|
||||||
|
## 페이지 헤더
|
||||||
|
- page-title: "활동 로그" (24px 600) + ` / ACTIVITY LOG` (18px secondary)
|
||||||
|
- header-meta: mono 11px, "SYS_LOG_V.4.2 // RUNTIME: 428:12:05"
|
||||||
|
- border-bottom 1px
|
||||||
|
|
||||||
|
## 필터 + 검색 (Log Controls)
|
||||||
|
- `justify-content: space-between; align-items: center`
|
||||||
|
- **filter-group**: 가로 flex, gap 16px
|
||||||
|
- filter-btn: 11px 700 uppercase, border 1px
|
||||||
|
- active: 흰색 배경 + 검정 텍스트
|
||||||
|
- 옵션: ALL / SYSTEM / SECURITY / ORG / BACKUP
|
||||||
|
- **search-box**: max-width 300px, flex-grow
|
||||||
|
- search-input: `#1a1a1a` 배경, border 1px, mono 12px
|
||||||
|
- placeholder: "SEARCH LOGS..."
|
||||||
|
|
||||||
|
## 로그 테이블 (Log Container)
|
||||||
|
- 외곽: border 1px, `background: #0d0d0d`
|
||||||
|
- table: 전체 너비, border-collapse
|
||||||
|
- **th**: 11px uppercase secondary, padding 16px, border-bottom 1px
|
||||||
|
- **td**: 12px mono, padding 12px 16px, border-bottom `#1a1a1a`
|
||||||
|
- **hover**: 배경 `#1a1a1a`
|
||||||
|
|
||||||
|
### 컬럼
|
||||||
|
| 컬럼 | 너비 | 스타일 |
|
||||||
|
|------|------|--------|
|
||||||
|
| TIMESTAMP | 180px | secondary |
|
||||||
|
| TYPE | 100px | bold + tag |
|
||||||
|
| EVENT | auto | primary, `[이름]` bold 강조 |
|
||||||
|
| CODE | 100px | right align |
|
||||||
|
|
||||||
|
### 태그 (Tag)
|
||||||
|
- 10px, padding 2px 6px, border 1px currentColor
|
||||||
|
- SYSTEM: `#888`
|
||||||
|
- SECURITY: `#ff5f5f`
|
||||||
|
- ORG: `#5fafff`
|
||||||
|
- BACKUP: `#5fff8a`
|
||||||
|
|
||||||
|
## 페이지네이션
|
||||||
|
- `justify-content: flex-end` / mono 11px
|
||||||
|
- "SHOWING 1-9 OF 2,410 ENTRIES"
|
||||||
|
- `[PREV]` `PAGE 01` `[NEXT]`
|
||||||
|
- PREV/NEXT: primary 색, hover underline
|
||||||
|
|
||||||
|
## 모바일 반응형
|
||||||
|
- filter-group: 가로 스크롤 또는 드롭다운 변환
|
||||||
|
- search-box: 풀 너비
|
||||||
|
- log-table: 가로 스크롤 wrapper 또는 카드 형태 변환
|
||||||
|
- 카드: 타임스탬프 + 태그 상단, 이벤트 본문, 코드 하단
|
||||||
|
- 페이지네이션: 간소화 (< 01 >)
|
||||||
@@ -1,47 +1,47 @@
|
|||||||
# 관리자 페이지 — UI 디자인 (`/admin/*`)
|
# 관리자 페이지 (`/admin/*`) — 디자인 v2
|
||||||
|
|
||||||
## 자매 관리 (`/admin/sisters`)
|
> 참조: DESIGN-SYSTEM.md
|
||||||
|
> 관리자 페이지는 기존 Sprint 004에서 구현된 기능을 v2 디자인 시스템에 맞춰 리스타일링.
|
||||||
|
> 별도 디자인 파일이 없으므로, 다른 페이지의 패턴을 조합.
|
||||||
|
|
||||||
```
|
## 페이지 목록
|
||||||
┌──────────────────────────────────────┐
|
- `/admin` — 자매 관리 (재시작/리셋)
|
||||||
│ 자매 관리 │
|
- `/admin/harness` — 하네스 파일 편집
|
||||||
├──────────────────────────────────────┤
|
- `/admin/logs` — 로그 뷰어
|
||||||
│ ┌─────────────────────────────────┐ │
|
- `/admin/costs` — 비용 대시보드
|
||||||
│ │ 하랑이 [온라인] [재시작] [리셋] │ │
|
- `/admin/repos` — 저장소 관리
|
||||||
│ │ 나랑이 [온라인] [재시작] [리셋] │ │
|
|
||||||
│ │ 다랑이 [오프라인] [재시작] [리셋] │ │
|
|
||||||
│ │ 이랑이 [온라인] [재시작] [리셋] │ │
|
|
||||||
│ └─────────────────────────────────┘ │
|
|
||||||
└──────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
- 각 자매 행: 이름 + 상태 + 액션 버튼
|
## 공통 스타일
|
||||||
- 재시작/리셋 버튼은 확인 모달 필수
|
- page-header: 제목 28px + label-meta (ADMIN PANEL)
|
||||||
- 실시간 상태 업데이트
|
- section-title 패턴 동일
|
||||||
|
- 모든 입력/버튼: DESIGN-SYSTEM.md의 btn, bracket-input, switch 사용
|
||||||
|
- 인증 필요 표시: 상단에 `label-meta` — `AUTH: BEARER TOKEN REQUIRED`
|
||||||
|
|
||||||
## 하네스 편집 (`/admin/harness`)
|
## /admin — 자매 관리
|
||||||
|
- node-mini-card 스타일 (프로젝트 상세의 ASSIGNED NODES와 동일)
|
||||||
|
- 각 자매: 이름 + 상태 도트 + btn-toggle (RESTART / RESET)
|
||||||
|
- 결과 표시: audit-row 스타일 (타임스탬프 + 액션 + 결과)
|
||||||
|
|
||||||
- 좌측: 파일 트리 (자매 선택 → 파일 선택)
|
## /admin/harness — 하네스 편집
|
||||||
- 우측: 코드 에디터 (Monaco Editor 또는 CodeMirror)
|
- 드롭다운: 자매 선택 + 파일 선택
|
||||||
- 저장 버튼 → SSH로 파일 쓰기 + SSOT push
|
- 텍스트 에디터: `#0d0d0d` 배경, mono 12px, border 1px
|
||||||
- 변경 사항 diff 표시
|
- 하단: btn (취소) + btn-primary (저장)
|
||||||
|
|
||||||
## 로그 뷰어 (`/admin/logs`)
|
## /admin/logs — 로그 뷰어
|
||||||
|
- activity-log-design.md의 로그 테이블 패턴 활용
|
||||||
|
- 자매 필터 + lines 제한
|
||||||
|
- 실시간 스트림: mono 텍스트, 자동 스크롤
|
||||||
|
|
||||||
- 자매 선택 탭
|
## /admin/costs — 비용 대시보드
|
||||||
- 실시간 로그 스트림 (terminal 스타일)
|
- 상단: 요약 카드 3개 (card 스타일)
|
||||||
- 검색 + 에러 필터
|
- 총 토큰 / 총 비용 / 평균 일일 비용
|
||||||
- 다크 터미널 배경
|
- bracket-value로 표시
|
||||||
|
- 중앙: BarChart (graph-container 스타일 확장)
|
||||||
|
- 일별 바, highlight 최근
|
||||||
|
- border-left + border-bottom
|
||||||
|
- 하단: 기간 필터 (filter-btn 스타일)
|
||||||
|
|
||||||
## 비용 모니터 (`/admin/costs`)
|
## 모바일 반응형
|
||||||
|
- 카드/행: 1열 수직 스택
|
||||||
- 모델별 토큰 사용량 차트 (bar chart)
|
- 텍스트 에디터: 풀 너비, 높이 축소
|
||||||
- 자매별 사용량 비교
|
- 비용 차트: 가로 스크롤
|
||||||
- 기간 필터 (일/주/월)
|
|
||||||
- 예상 비용 계산
|
|
||||||
|
|
||||||
## 공통 컴포넌트
|
|
||||||
- `<ConfirmModal />` — 확인 모달
|
|
||||||
- `<CodeEditor />` — 코드 에디터
|
|
||||||
- `<LogTerminal />` — 터미널 스타일 로그 뷰어
|
|
||||||
- `<CostChart />` — 비용 차트
|
|
||||||
|
|||||||
@@ -1,67 +1,58 @@
|
|||||||
# 메인 대시보드 — UI 디자인 (`/`)
|
# 대시보드 메인 (`/`) — 디자인 v2
|
||||||
|
|
||||||
|
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #1
|
||||||
|
|
||||||
## 레이아웃
|
## 레이아웃
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────┐
|
┌──────────┬─────────────────────────────────────────────┐
|
||||||
│ [사이드바] [메인 콘텐츠] │
|
│ │ 하나랑 대시보드 │
|
||||||
│ ┌──────┐ ┌────────────────────────────────────┐ │
|
│ ●◗ │ │
|
||||||
│ │ 로고 │ │ 헤더: "하나랑 대시보드" │ │
|
│ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||||
│ │ │ ├────────────────────────────────────┤ │
|
│ [대시] │ │SYS: │ │SYS: │ │SYS: │ │SYS: │ │
|
||||||
│ │ 메뉴 │ │ [자매 상태 카드 4개 - 가로 배치] │ │
|
│ 활동 │ │하랑 │ │나랑 │ │다랑 │ │이랑 │ │
|
||||||
│ │ - 대시 │ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ │
|
│ 프로 │ │[ON] │ │[84] │ │[--] │ │[12] │ │
|
||||||
│ │ - 프로 │ │ │하랑 │ │나랑 │ │다랑 │ │이랑 │ │ │
|
│ 자매 │ │▬▬▬▬▬ │ │▬▬▬▬░ │ │░░░░░ │ │▬░░░░ │ │
|
||||||
│ │ - 자매 │ │ └────┘ └────┘ └────┘ └────┘ │ │
|
│ 조직 │ └──────┘ └──────┘ └──────┘ └──────┘ │
|
||||||
│ │ - 조직 │ ├────────────────────────────────────┤ │
|
│ 설정 │ │
|
||||||
│ │ - 설정 │ │ [진행 중 프로젝트] [최근 활동 피드] │ │
|
│ 관리 │ ONGOING PROJECTS ACTIVITY FEED │
|
||||||
│ │ - 관리 │ │ 카드 리스트 타임라인 │ │
|
│ │ ───────────────── ────────────── │
|
||||||
│ └──────┘ └────────────────────────────────────┘ │
|
│ │ ● 데이터 파이프라인 PHASE 2 │ 14:02:45 │
|
||||||
└─────────────────────────────────────────────────────┘
|
│ │ ● 새로운 자매 노드 INIT │ System... │
|
||||||
|
│ │ ● 관리자 권한 체계 REVIEW │ 11:30:12 │
|
||||||
|
│ │ │ Security... │
|
||||||
|
└──────────┴─────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## 자매 상태 카드
|
## 상태 카드 (Status Grid)
|
||||||
- **크기:** 가로 4등분 (반응형: 태블릿 2열, 모바일 1열)
|
- `grid-template-columns: repeat(4, 1fr)` / gap: 24px
|
||||||
- **내용:**
|
- 각 카드: `card` 컴포넌트 (DESIGN-SYSTEM.md 참조)
|
||||||
- 이름 + 역할 (예: "하랑이 · Orchestrator")
|
- 구조:
|
||||||
- 상태 뱃지 (온라인/오프라인/작업중) + 상태 색상 dot
|
- header: `label-meta` — `SYS:` (secondary) + 이름 (primary)
|
||||||
- 마지막 활동 시간
|
- body: `bracket-value` — `[ON]`, `[84]`, `[--]`, `[12]`
|
||||||
- 현재 작업 요약 (있으면)
|
- sub-label: Active Node / Capacity % / Standby / Sync Queue
|
||||||
- **스타일:**
|
- tech-bar: 2px 진행 바 (100% / 84% / 0% / 12%)
|
||||||
- glassmorphism 카드 (반투명 배경 + blur)
|
- standby 카드: bracket-value `color: var(--text-secondary)`
|
||||||
- 호버 시 살짝 확대 + 그림자 강화
|
|
||||||
- 상태별 좌측 보더 색상: 온라인=#00E676, 오프라인=#FF1744, 작업중=#2979FF
|
|
||||||
|
|
||||||
## 진행 중 프로젝트 섹션
|
## 하단 2열 (Data Columns)
|
||||||
- 카드 리스트: 프로젝트명 + 현재 Sprint + 진행률 바
|
- `grid-template-columns: 1.5fr 1fr` / gap: 64px
|
||||||
- 클릭 시 `/projects/:id`로 이동
|
|
||||||
|
|
||||||
## 최근 활동 피드
|
### 좌: ONGOING PROJECTS
|
||||||
- 타임라인 형태
|
- section-title: `ONGOING PROJECTS` + `VOL: 04`
|
||||||
- 각 항목: 시간 + 자매 아이콘 + 활동 내용
|
- project-row: `grid-template-columns: 40px 1fr auto`
|
||||||
- 예: "10:47 🦊 하랑이 — Sprint 001 핸드오프 전달"
|
- 아바타: 32px 원형, grayscale, SVG placeholder
|
||||||
|
- 프로젝트명: 15px 500
|
||||||
|
- 설명: 13px secondary
|
||||||
|
- 상태 라벨: label-meta (PHASE 2 / INIT / REVIEW)
|
||||||
|
|
||||||
## 사이드바
|
### 우: ACTIVITY FEED
|
||||||
- 고정 사이드바 (접기 가능)
|
- section-title: `ACTIVITY FEED` + `LIVE`
|
||||||
- 로고 + 메뉴 아이콘
|
- timeline 컴포넌트 (세로선 + 도트 + 타임스탬프 + 내용)
|
||||||
- 현재 페이지 하이라이트
|
- 타임스탬프: mono 11px, `14:02:45 GMT+9`
|
||||||
- 하단: 관리자 메뉴 (접이식)
|
- 내용: 13px, bold로 시스템명 강조
|
||||||
|
|
||||||
## 색상 (다크 테마)
|
## 모바일 반응형
|
||||||
| 용도 | 색상 |
|
- 상태 카드: `repeat(2, 1fr)` → `1fr`
|
||||||
|------|------|
|
- data-columns: 1열 수직 스택 (프로젝트 먼저, 활동 아래)
|
||||||
| 배경 | #0D1117 |
|
- bracket-value: 24px
|
||||||
| 카드 배경 | rgba(22, 27, 34, 0.8) |
|
- 패딩 축소
|
||||||
| 텍스트 (주) | #E6EDF3 |
|
|
||||||
| 텍스트 (부) | #8B949E |
|
|
||||||
| 액센트 | #58A6FF |
|
|
||||||
| 보더 | rgba(240, 246, 252, 0.1) |
|
|
||||||
| 온라인 | #00E676 |
|
|
||||||
| 오프라인 | #FF1744 |
|
|
||||||
| 작업중 | #2979FF |
|
|
||||||
|
|
||||||
## 컴포넌트
|
|
||||||
- `<SisterCard />` — 자매 상태 카드
|
|
||||||
- `<ProjectCard />` — 프로젝트 카드
|
|
||||||
- `<ActivityFeed />` — 활동 피드 타임라인
|
|
||||||
- `<Sidebar />` — 사이드바 네비게이션
|
|
||||||
- `<StatusBadge />` — 상태 뱃지 (색상 dot + 텍스트)
|
|
||||||
|
|||||||
@@ -1,32 +1,97 @@
|
|||||||
# 조직도 — UI 디자인 (`/org`)
|
# 조직도 (`/org`) — 디자인 v2
|
||||||
|
|
||||||
|
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #6
|
||||||
|
|
||||||
## 레이아웃
|
## 레이아웃
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────┐
|
┌─────┬──────────────────────────────────────────────────────┐
|
||||||
│ 자기야 │
|
│ 64px│ SYS_TYPE: CORE_ROOT_01 2023.10.27_14:42 │
|
||||||
│ │ │
|
│ │ ────────────────────────────────────────────────── │
|
||||||
│ ┌──────┴──────┐ │
|
│ │ │
|
||||||
│ │ 하랑이 │ │
|
│ DASH│ ┌─────────────────┐ │
|
||||||
│ │ Orchestrator │ │
|
│ PROJ│ │ [HQ] 하나랑 글로벌 │ │
|
||||||
│ └──────┬──────┘ │
|
│◗ORG │ │ MEM:142 S_ADMIN │ │
|
||||||
│ ┌───────────┼───────────┐ │
|
│ CONF│ └────────┬────────┘ │
|
||||||
│ ┌─────┴─────┐ ┌───┴────┐ ┌───┴────┐ │
|
│ │ ┌───────────┼───────────┐ │
|
||||||
│ │ 나랑이 │ │ 다랑이 │ │ 이랑이 │ │
|
│ │ ┌──────┴──────┐ ┌─┴────┐ ┌───┴───┐ │
|
||||||
│ │ Generator │ │Evaluator│ │ Infra │ │
|
│ │ │[R&D] 기술연구│ │[OPS] │ │[EXT] │ │
|
||||||
│ └─────┬─────┘ └───┬────┘ └────────┘ │
|
│ │ │ MEM:48 LEAD │ │운영팀 │ │외부협력│ │
|
||||||
│ 서브에이전트 서브에이전트 │
|
│ │ └──────┬──────┘ └──┬───┘ └───┬───┘ │
|
||||||
└──────────────────────────────────────────────────┘
|
│ │ ┌─────┼─────┐ │ │ │
|
||||||
|
│ │ ┌──┴──┐ ┌┴─────┐ ┌──┴───┐ ┌───┴────┐ │
|
||||||
|
│ │ │Core │ │Infra │ │Monitor│ │Security│ │
|
||||||
|
│ │ │Eng. │ │struct│ │ing │ │Audit │ │
|
||||||
|
│ │ └─────┘ └──────┘ └──────┘ └────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ │ TOTAL_NODES: 07 STATUS: SYNC_COMPLETE │
|
||||||
|
└─────┴──────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## 시각화
|
## 사이드바 (조직도 전용)
|
||||||
- 트리 구조 (위→아래)
|
- **64px 너비** (다른 페이지의 240px과 다름)
|
||||||
- 각 노드: glassmorphism 카드
|
- 세로 텍스트: `writing-mode: vertical-rl`
|
||||||
- 상태 색상 적용
|
- 아이콘 모드: DASH / PROJ / ORG_TREE / CONF
|
||||||
- 파이프라인 흐름 화살표: 하랑이 → 나랑이 ↔ 다랑이 → 이랑이
|
- 로고: 세로 배치 (circle-full 위, circle-half 아래)
|
||||||
- 호버 시 해당 자매 정보 팝업
|
|
||||||
|
|
||||||
## 컴포넌트
|
## 메인 뷰포트
|
||||||
- `<OrgTree />` — 조직도 트리
|
- 배경: dot grid pattern — `radial-gradient(circle, #1a1a1a 1px, transparent 1px)` / 30px 30px
|
||||||
- `<OrgNode />` — 개별 노드 카드
|
- `overflow: auto` (스크롤 가능)
|
||||||
- `<PipelineArrow />` — 파이프라인 화살표
|
- padding: 40px, 중앙 정렬
|
||||||
|
|
||||||
|
## 헤더
|
||||||
|
- 좌: `SYS_TYPE: CORE_ROOT_01` (mono, 값은 흰색)
|
||||||
|
- 우: `TIMESTAMP: 2023.10.27_14:42` (mono secondary)
|
||||||
|
- border-bottom 1px
|
||||||
|
|
||||||
|
## 트리 구조
|
||||||
|
|
||||||
|
### Terminal Card
|
||||||
|
- 200px 너비, padding 12px
|
||||||
|
- `border: 1px solid var(--border-color)`
|
||||||
|
- **border-left: 3px solid** (레벨별 색상)
|
||||||
|
- high (`#f43f5e`): HQ
|
||||||
|
- mid (`#3b82f6`): R&D, Core Engine, Infrastructure
|
||||||
|
- low (`#10b981`): OPS, EXT, Monitoring, Security Audit
|
||||||
|
- `box-shadow: 0 10px 30px rgba(0,0,0,0.5)`
|
||||||
|
- node-label: mono 13px 600
|
||||||
|
- 접두사 span: `[HQ]` `[R&D]` `[OPS]` `[EXT]` — secondary 11px
|
||||||
|
- badge-row: flex wrap, gap 6px
|
||||||
|
- badge: mono 9px, `#1a1a1a` bg, border 1px `#262626`
|
||||||
|
- `MEM: 142` (값은 primary)
|
||||||
|
- `S_ADMIN`, `LEAD`, `MGR`, `GUEST` 등
|
||||||
|
- `LV_09`, `LV_07` 등
|
||||||
|
|
||||||
|
### 접기/펼치기 토글
|
||||||
|
- `−` / `+` 아이콘, 16x16px
|
||||||
|
- 카드 하단 중앙 absolute 배치
|
||||||
|
- bg: card bg, border 1px
|
||||||
|
|
||||||
|
### 연결선
|
||||||
|
- 세로선: `width: 1px; background: var(--border-color)`
|
||||||
|
- 가로선 (형제 연결): children `::before` — 상단 -40px
|
||||||
|
|
||||||
|
### 트리 구조
|
||||||
|
```
|
||||||
|
하나랑 글로벌 (HQ, high)
|
||||||
|
├── 기술연구소 (R&D, mid)
|
||||||
|
│ ├── Core Engine (mid)
|
||||||
|
│ └── Infrastructure (mid)
|
||||||
|
├── 운영지원팀 (OPS, low)
|
||||||
|
│ └── Monitoring (low)
|
||||||
|
└── 외부 협력사 (EXT, low)
|
||||||
|
└── Security Audit (low)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 푸터
|
||||||
|
- `margin-top: auto; border-top: 1px solid`
|
||||||
|
- 좌: `TOTAL_NODES: 07`
|
||||||
|
- 우: `STATUS: SYNC_COMPLETE`
|
||||||
|
- mono 10px secondary
|
||||||
|
|
||||||
|
## 모바일 반응형
|
||||||
|
- 사이드바: 하단 탭바로 전환
|
||||||
|
- 트리: 수평 스크롤 컨테이너 + 핀치 줌
|
||||||
|
- terminal-card: 너비 160px으로 축소
|
||||||
|
- badge 폰트: 8px
|
||||||
|
- dot grid: 20px 간격으로 축소
|
||||||
|
|||||||
@@ -1,47 +1,69 @@
|
|||||||
# 프로젝트 상세 — UI 디자인 (`/projects/:id`)
|
# 프로젝트 상세 (`/projects/[id]`) — 디자인 v2
|
||||||
|
|
||||||
|
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #2
|
||||||
|
|
||||||
## 레이아웃
|
## 레이아웃
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────┐
|
┌──────────┬────────────────────────────────────────────┐
|
||||||
│ [사이드바] [메인 콘텐츠] │
|
│ Sidebar │ PROJECTS / P-402 / SUMMARY │
|
||||||
│ ┌──────────────────────────────┐ │
|
│ │ 데이터 파이프라인 최적화 STATUS: ACTIVE/P2 │
|
||||||
│ │ 헤더: 프로젝트명 + Gitea 링크 │ │
|
│ │ ───────────────────────────────────────── │
|
||||||
│ ├──────────────────────────────┤ │
|
│ │ │
|
||||||
│ │ [탭: Sprint | Tasks | Plans] │ │
|
│ │ PHASE TIMELINE │ ASSIGNED NODES │
|
||||||
│ ├──────────────────────────────┤ │
|
│ │ ──────────── │ ───────────── │
|
||||||
│ │ Sprint 목록 (아코디언) │ │
|
│ │ P1: 아키텍처 분석 ✓ │ 하랑 [PRIMARY] ● │
|
||||||
│ │ ┌─ Sprint 001 ✅ ──────────┐ │ │
|
│ │ ●P2: 실시간 동기화 │ 나랑 [SECONDARY]● │
|
||||||
│ │ │ TASK-001 ✅ done │ │ │
|
│ │ P3: 스트레스 테스트 │ │
|
||||||
│ │ │ TASK-002 ✅ done │ │ │
|
│ │ │ TASK CHECKLIST │
|
||||||
│ │ │ TASK-003 ✅ done │ │ │
|
│ │ SECURITY AUDIT LOG │ ───────────── │
|
||||||
│ │ └──────────────────────────┘ │ │
|
│ │ ─────────────── │ ■ 인프라 요구사항 │
|
||||||
│ │ ┌─ Sprint 002 🔄 진행중 ───┐ │ │
|
│ │ 14:02 CRYPTO_KEY PASS │ ■ 보안 프로토콜 │
|
||||||
│ │ │ TASK-004 🔵 in_progress │ │ │
|
│ │ 11:30 CODE_SCAN PASS │ □ 캐시 무효화 │
|
||||||
│ │ │ TASK-005 ⏳ pending │ │ │
|
│ │ 09:15 ACCESS_LOG WARN │ □ 부하 분산 │
|
||||||
│ │ └──────────────────────────┘ │ │
|
└──────────┴────────────────────────────────────────────┘
|
||||||
│ └──────────────────────────────┘ │
|
|
||||||
└──────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sprint 섹션
|
## 헤더
|
||||||
- 아코디언: Sprint 번호 + 이름 + 상태 아이콘
|
- breadcrumb: mono 11px uppercase, secondary
|
||||||
- 펼치면 Task 목록
|
- page-title-row: 제목(28px) + STATUS label-meta
|
||||||
- 진행률 바 (done/total)
|
|
||||||
- Sprint 상태: pending(⏳), in_progress(🔄), review(🔍), done(✅), failed(❌)
|
|
||||||
|
|
||||||
## Task Ledger 탭
|
## 본문 그리드
|
||||||
- 테이블: Task ID | 제목 | 담당 | 상태 | 반복 횟수
|
- `grid-template-columns: 1fr 320px` / gap: 64px
|
||||||
- 상태 필터링
|
|
||||||
- 상태별 색상 뱃지
|
|
||||||
|
|
||||||
## .plans/ 뷰어 탭
|
### 좌: Phase Timeline + Audit Log
|
||||||
- 파일 트리 + 마크다운 렌더러
|
|
||||||
- Gitea raw URL에서 실시간 로드
|
|
||||||
|
|
||||||
## 컴포넌트
|
#### Phase Timeline
|
||||||
- `<SprintAccordion />` — Sprint 아코디언
|
- phase-item: `grid-template-columns: 120px 1fr`
|
||||||
- `<TaskTable />` — Task 테이블
|
- 왼쪽: phase-meta — mono 11px, "COMPLETED / 2023.10.12"
|
||||||
- `<PlansViewer />` — .plans/ 마크다운 뷰어
|
- 오른쪽: phase-box — `border-left: 2px solid`
|
||||||
- `<ProgressBar />` — 진행률 바
|
- active: border 흰색 + 8px 원형 도트
|
||||||
- `<TabNav />` — 탭 네비게이션
|
- 완료/대기: border `--border-color`
|
||||||
|
- phase-name: 600 weight
|
||||||
|
- phase-desc: 13px secondary
|
||||||
|
|
||||||
|
#### Security Audit Log
|
||||||
|
- section-title: `SECURITY AUDIT LOG` + `LAST CHECK: 4H AGO`
|
||||||
|
- audit-log: 1px gap 줄무늬 (배경 #333)
|
||||||
|
- audit-row: `grid-template-columns: 100px 1fr 80px`
|
||||||
|
- header row: secondary uppercase 700
|
||||||
|
- status-tag: 10px, border 1px
|
||||||
|
- `.pass`: border #00FF00, color #00FF00
|
||||||
|
- 기본: border #333
|
||||||
|
|
||||||
|
### 우: Assigned Nodes + Task Checklist
|
||||||
|
|
||||||
|
#### Assigned Nodes
|
||||||
|
- node-mini-card: border 1px, 가로 정렬 (이름 + 녹색 도트)
|
||||||
|
- 녹색 도트: 6x6px, `#00FF00`
|
||||||
|
|
||||||
|
#### Task Checklist
|
||||||
|
- check-item: mono 12px, flex 가로
|
||||||
|
- check-box: 14x14px, border 1px
|
||||||
|
- checked: `■` 10px 텍스트
|
||||||
|
- done 항목: secondary + line-through
|
||||||
|
|
||||||
|
## 모바일 반응형
|
||||||
|
- project-grid: 1열 수직 스택 (좌측 먼저, 우측 아래)
|
||||||
|
- phase-item: 1열 (meta 위, box 아래)
|
||||||
|
- audit-row: 가로 스크롤 또는 카드 변환
|
||||||
|
|||||||
65
.plans/design/ui/settings-design.md
Normal file
65
.plans/design/ui/settings-design.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# 설정 (`/settings`) — 디자인 v2
|
||||||
|
|
||||||
|
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #4
|
||||||
|
|
||||||
|
## 레이아웃
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┬────────────────────────────────────────────────┐
|
||||||
|
│ Sidebar │ 시스템 설정 CONFIG: v2.0.4-STABLE │
|
||||||
|
│ │ │
|
||||||
|
│ │ ADMIN PROTOCOL RULES │ BACKUP & REDUNDANCY │
|
||||||
|
│ │ ───────────────── │ ────────────────── │
|
||||||
|
│ │ 강제 2FA [●] │ 자동 백업 [●] │
|
||||||
|
│ │ 세션 유효 시간 [ 15 ] │ 백업 주기 [08:00] │
|
||||||
|
│ │ IP 화이트리스트 [○] │ 보관 주기 [ 30 ]│
|
||||||
|
│ │ │ │
|
||||||
|
│ │ SYNC QUEUE THRESHOLDS │ NODE ALERT TRIGGERS │
|
||||||
|
│ │ ───────────────── │ ────────────────── │
|
||||||
|
│ │ 대기열 경고 [ 500 ] │ CPU 임계값 [ 90 ]│
|
||||||
|
│ │ 자동 스케일링 [●] │ 레이턴시 경보 [ 250 ] │
|
||||||
|
│ │ │ 노드 오프라인 [●] │
|
||||||
|
│ │ │
|
||||||
|
│ │ [초기화] [설정 저장] │
|
||||||
|
└──────────┴────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 페이지 헤더
|
||||||
|
- page-title: "시스템 설정" (28px 600)
|
||||||
|
- label-meta: `CONFIG: v2.0.4-STABLE`
|
||||||
|
|
||||||
|
## 설정 그리드
|
||||||
|
- `grid-template-columns: repeat(2, 1fr)` / gap: 64px
|
||||||
|
|
||||||
|
### 섹션 구조 (4개)
|
||||||
|
1. **ADMIN PROTOCOL RULES** (SEC: 01)
|
||||||
|
2. **BACKUP & REDUNDANCY** (SEC: 02)
|
||||||
|
3. **SYNC QUEUE THRESHOLDS** (SEC: 03)
|
||||||
|
4. **NODE ALERT TRIGGERS** (SEC: 04)
|
||||||
|
|
||||||
|
### 설정 행 (Settings Row)
|
||||||
|
- `justify-content: space-between; align-items: center`
|
||||||
|
- `border-bottom: 1px solid #222`
|
||||||
|
- 좌: setting-label (14px 500) + setting-desc (12px secondary)
|
||||||
|
- 우: Toggle Switch 또는 Bracket Input
|
||||||
|
|
||||||
|
### Toggle Switch
|
||||||
|
- 34x18px
|
||||||
|
- unchecked: `#2a2a2a` 배경, border `#333`, dot `#8A8A8A`
|
||||||
|
- checked: `#151515` 배경, border `#F5F5F5`, dot `#F5F5F5`, translate 16px
|
||||||
|
|
||||||
|
### Bracket Input
|
||||||
|
- `[ value ]` 형태
|
||||||
|
- `--font-mono` 14px, transparent bg, center text
|
||||||
|
- 포커스: 흰색 텍스트
|
||||||
|
- 너비: 60px
|
||||||
|
|
||||||
|
## Action Bar
|
||||||
|
- `margin-top: auto; justify-content: flex-end`
|
||||||
|
- 초기화: 기본 btn (투명 + 보더)
|
||||||
|
- 설정 저장: btn-primary (흰색 배경, hover 반전)
|
||||||
|
- 12px 600 uppercase
|
||||||
|
|
||||||
|
## 모바일 반응형
|
||||||
|
- settings-grid: 2열 → 1열
|
||||||
|
- action-bar: 풀 너비 버튼 또는 sticky bottom
|
||||||
@@ -1,45 +1,69 @@
|
|||||||
# 자매 상세 — UI 디자인 (`/sisters/:name`)
|
# 자매 노드 관리 (`/sisters`) — 디자인 v2
|
||||||
|
|
||||||
|
> 참조: DESIGN-SYSTEM.md | 원본: 자기야 디자인 파일 #3
|
||||||
|
|
||||||
## 레이아웃
|
## 레이아웃
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────┐
|
┌──────────┬──────────────────────────────────────────────┐
|
||||||
│ [사이드바] [메인 콘텐츠] │
|
│ Sidebar │ 자매 노드 관리 TOTAL NODES: 04 │
|
||||||
│ ┌──────────────────────────────┐ │
|
│ │ ─────────────────────────────────────────── │
|
||||||
│ │ 헤더: 자매 이름 + 상태 뱃지 │ │
|
│ │ │
|
||||||
│ │ 역할 + IP + LXC 정보 │ │
|
│ │ ● 하랑 (Primary) │ CAPACITY 24H │ SYNC LOG│
|
||||||
│ ├──────────────────────────────┤ │
|
│ │ Uptime: 342:12 │ ▁▂▃▂▄▆█▇ │ 14:05 ✓│
|
||||||
│ │ [탭: 개요 | 설정 | 세션 | 서브] │ │
|
│ │ [ACTIVE][REBOOT]│ LOAD: 84.2% │ 13:55 ✓│
|
||||||
│ ├──────────────────────────────┤ │
|
│ │ ─────────────────────────────────────────── │
|
||||||
│ │ 개요 탭: │ │
|
│ │ ● 나랑 (Secondary)│ CAPACITY 24H │ SYNC LOG│
|
||||||
│ │ - 상태 카드 (큰 버전) │ │
|
│ │ Uptime: 112:44 │ ▁▁▁▂▂▃▃▃ │ 14:02 ✓│
|
||||||
│ │ - 최근 활동 타임라인 │ │
|
│ │ [ACTIVE][REBOOT]│ LOAD: 48.7% │ 13:51 ✓│
|
||||||
│ │ - 현재 작업 │ │
|
│ │ ─────────────────────────────────────────── │
|
||||||
│ └──────────────────────────────┘ │
|
│ │ ○ 다랑 (Standby) │ CAPACITY 24H │ SYNC LOG│
|
||||||
└──────────────────────────────────────────────┘
|
│ │ Last: 02:15 ago │ ░░░░░░░░ │ IDLE │
|
||||||
|
│ │ [STANDBY][ACTIV]│ LOAD: 0.0% │ IDLE │
|
||||||
|
│ │ ─────────────────────────────────────────── │
|
||||||
|
│ │ ● 이랑 (Sync) │ CAPACITY 24H │ SYNC LOG│
|
||||||
|
│ │ Queue: 12 PKTS │ ▁▂▁▂▃▂▁▁ │ SYNCING │
|
||||||
|
│ │ [SYNCING][FLUSH]│ LOAD: 15.2% │ SYNCING │
|
||||||
|
└──────────┴──────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## 탭별 내용
|
## 페이지 헤더
|
||||||
|
- page-title: "자매 노드 관리" (28px 600)
|
||||||
|
- label-meta: `TOTAL NODES: 04`
|
||||||
|
- border-bottom 1px
|
||||||
|
|
||||||
### 개요 탭
|
## 노드 엔트리 (Node Entry)
|
||||||
- 큰 상태 카드 (상세 정보 포함)
|
- `grid-template-columns: 240px 1fr 300px` / gap: 64px
|
||||||
- 최근 활동 5개
|
- `border-bottom: 1px solid var(--border-color)` / padding-bottom: 40px
|
||||||
- 현재 진행 중인 Task
|
|
||||||
|
|
||||||
### 설정 탭
|
### 좌: Node Info (240px)
|
||||||
- openclaw.json 내용 (코드 뷰어, read-only)
|
- node-name: 20px 600 + status-indicator
|
||||||
- 모델, 채널, 플러그인 정보
|
- active: 8px 흰색 원 + glow shadow
|
||||||
|
- inactive: 8px `#555` 원
|
||||||
|
- meta-pair: label-meta + val (16px mono)
|
||||||
|
- 하랑: Uptime 342:12:05
|
||||||
|
- 다랑: Last Active 02:15:00 ago
|
||||||
|
- 이랑: Queue Depth 12 PKTS
|
||||||
|
- node-controls: btn-toggle 2개
|
||||||
|
- active 상태: 흰색 배경 + 검정 텍스트
|
||||||
|
- 비active: 투명 + 보더
|
||||||
|
|
||||||
### 세션 탭
|
### 중: Capacity Section
|
||||||
- 최근 세션 목록 (시간, 메시지 수, 토큰 사용량)
|
- label-meta: "CAPACITY HISTORY (24H)"
|
||||||
- 세션 클릭 시 로그 뷰어
|
- graph-container: 60px 높이, flex-end
|
||||||
|
- graph-bar: 등분, `#333` 기본
|
||||||
|
- highlight: 최근 2개 바 `var(--text-primary)`
|
||||||
|
- border-left + border-bottom 1px
|
||||||
|
- sync-row: "LOAD INDEX" + 값 (mono)
|
||||||
|
|
||||||
### 서브에이전트 탭
|
### 우: Sync History (300px)
|
||||||
- 서브에이전트 목록 + 실행 이력
|
- label-meta: "SYNC LOG"
|
||||||
- 성공/실패 비율
|
- sync-row: 타임스탬프 + 상태 (mono 11px)
|
||||||
|
- 성공: `var(--text-primary)` 색 "SUCCESS"
|
||||||
|
- IDLE: secondary
|
||||||
|
- SYNCING: primary
|
||||||
|
|
||||||
## 컴포넌트
|
## 모바일 반응형
|
||||||
- `<SisterHeader />` — 자매 헤더 (이름 + 상태 + 정보)
|
- node-entry: 3열 → 수직 스택 (info → capacity → sync)
|
||||||
- `<ConfigViewer />` — JSON 코드 뷰어
|
- graph-container: 높이 유지, 너비 100%
|
||||||
- `<SessionList />` — 세션 목록
|
- btn-toggle: 가로 나란히
|
||||||
- `<SubagentList />` — 서브에이전트 목록
|
|
||||||
|
|||||||
72
.plans/sprints/SPRINT-006.md
Normal file
72
.plans/sprints/SPRINT-006.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# SPRINT-006: 버그 수정 + 인증 시스템
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
기존 버그 3건 수정 + JWT 기반 관리자 인증 시스템 구축
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-020: 버그 수정 3건
|
||||||
|
**우선순위: 최우선 (인증 전환 전에 기존 기능 안정화)**
|
||||||
|
|
||||||
|
#### BUG-1: 하네스 편집 저장 실패 (PUT /api/admin/harness/:name/:file)
|
||||||
|
- 증상: API Key 입력해도 저장 실패
|
||||||
|
- 원인 조사: CORS PUT 메서드, DTO validation, AdminService.updateHarnessFile() 로직 확인
|
||||||
|
- FE 측: Authorization 헤더 전달, Content-Type, 에러 핸들링 확인
|
||||||
|
|
||||||
|
#### BUG-2: 로그 뷰어 초기 로드 안 됨
|
||||||
|
- 증상: /admin/logs 페이지 진입 시 로그가 비어있음 (수동 새로고침 필요)
|
||||||
|
- 수정: 컴포넌트 마운트 시 자동 fetch (useEffect 초기 로드)
|
||||||
|
- 자매/lines 기본값 설정 후 자동 조회
|
||||||
|
|
||||||
|
#### BUG-3: /api/admin/costs/record/:name 작동 안 함
|
||||||
|
- 증상: POST 요청 시 응답 없거나 에러
|
||||||
|
- 원인 조사: SSH 연결, python3 의존성, main.json 경로, 파싱 로직 확인
|
||||||
|
- python3 미설치 시 fallback (jq 또는 Node.js 스크립트)
|
||||||
|
|
||||||
|
### TASK-021: JWT 인증 BE
|
||||||
|
- User 테이블 추가 (Prisma 마이그레이션)
|
||||||
|
```
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String @unique
|
||||||
|
password String // bcrypt hash
|
||||||
|
role String @default("admin")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 인증 API:
|
||||||
|
- `POST /api/auth/register` — 초대 코드 필수 (INVITE_CODE env), bcrypt hash
|
||||||
|
- `POST /api/auth/login` — JWT access token (15분) + refresh token (7일)
|
||||||
|
- `POST /api/auth/refresh` — refresh token으로 새 access token 발급
|
||||||
|
- `GET /api/auth/me` — 현재 사용자 정보
|
||||||
|
- JWT_SECRET, INVITE_CODE 환경변수
|
||||||
|
- Rate limiting: 로그인 엔드포인트 (5회/분)
|
||||||
|
- 테스트: auth 관련 유닛 테스트
|
||||||
|
|
||||||
|
### TASK-022: 로그인/회원가입 페이지 FE
|
||||||
|
- `/login` 페이지: username + password + 로그인 버튼
|
||||||
|
- `/register` 페이지: username + password + 초대 코드 + 가입 버튼
|
||||||
|
- 디자인: DESIGN-SYSTEM.md v2 준수 (미니멀 터미널 UI)
|
||||||
|
- 로그인 실패 시 에러 메시지 표시
|
||||||
|
- 토큰 저장: localStorage (access) + httpOnly cookie 검토
|
||||||
|
- 라우트 보호: 비로그인 시 `/login`으로 리다이렉트
|
||||||
|
- AuthContext/AuthProvider 구현
|
||||||
|
|
||||||
|
### TASK-023: API Key Guard → JWT Guard 전환
|
||||||
|
- JwtGuard 생성 (passport-jwt)
|
||||||
|
- 기존 ApiKeyGuard 유지 + JwtGuard 병행 (CompositeGuard: JWT 먼저 → API Key fallback)
|
||||||
|
- 하위호환: API Key는 Sprint 008까지 유지, 이후 제거 예정
|
||||||
|
- WebSocket 인증: Socket.IO handshake에 JWT 토큰 검증 추가
|
||||||
|
- 모든 @UseGuards 교체 (ApiKeyGuard → CompositeGuard)
|
||||||
|
|
||||||
|
## 의존성
|
||||||
|
- TASK-020 먼저 완료 → TASK-021 → TASK-022 → TASK-023
|
||||||
|
- TASK-022는 TASK-021 완료 필요 (API가 있어야 FE 연동)
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- 버그 3건 모두 재현 → 수정 → 동작 확인
|
||||||
|
- 회원가입(초대 코드) → 로그인 → JWT 발급 → 보호된 API 접근 성공
|
||||||
|
- API Key로도 기존 방식 그대로 접근 가능 (하위호환)
|
||||||
|
- WebSocket 연결 시 JWT 검증
|
||||||
|
- npm run build 성공 + 테스트 통과
|
||||||
61
.plans/sprints/SPRINT-007.md
Normal file
61
.plans/sprints/SPRINT-007.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# SPRINT-007: Gitea 연동 + 활동 로그 실시간화
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
프로젝트 데이터를 Gitea 기반으로 전환 + 활동 로그 자동 기록
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-024: Gitea 동기화 API
|
||||||
|
- GiteaService 강화 (기존 optional 패턴 유지)
|
||||||
|
- API 엔드포인트:
|
||||||
|
- `POST /api/admin/gitea/sync` — Gitea org의 전체 repo를 DB에 동기화
|
||||||
|
- `GET /api/projects` — DB에서 프로젝트 목록 (Gitea 데이터 포함)
|
||||||
|
- 동기화 로직:
|
||||||
|
- Gitea API `/api/v1/orgs/hanarang/repos` 호출
|
||||||
|
- 각 repo → Project 테이블 upsert (giteaId, name, repoUrl, description)
|
||||||
|
- 마지막 동기화 시간 기록
|
||||||
|
- 자동 동기화: EventsScheduler에서 5분마다 폴링 (선택)
|
||||||
|
|
||||||
|
### TASK-025: Commit/Branch/PR 조회 API
|
||||||
|
- API 엔드포인트:
|
||||||
|
- `GET /api/projects/:id/commits` — 최근 커밋 목록 (Gitea API `/repos/:owner/:repo/commits`)
|
||||||
|
- `GET /api/projects/:id/branches` — 브랜치 목록
|
||||||
|
- `GET /api/projects/:id/pulls` — PR 목록 (open/closed)
|
||||||
|
- 응답 포맷: 커밋 해시, 메시지, 작성자, 날짜 / 브랜치명, 최신 커밋 / PR 제목, 상태
|
||||||
|
- 에러 핸들링: Gitea 연결 실패 시 graceful fallback
|
||||||
|
|
||||||
|
### TASK-026: 프로젝트 페이지 FE (Gitea 데이터)
|
||||||
|
- `/projects` 목록 페이지:
|
||||||
|
- DB 프로젝트 목록 표시 (이름, 설명, 상태, repo URL)
|
||||||
|
- 동기화 버튼 (관리자)
|
||||||
|
- `/projects/[id]` 상세 페이지:
|
||||||
|
- 탭: Overview / Commits / Branches / PRs
|
||||||
|
- Commits: 커밋 히스토리 테이블 (해시, 메시지, 작성자, 날짜)
|
||||||
|
- Branches: 브랜치 카드 목록
|
||||||
|
- PRs: PR 리스트 (상태 태그: open/merged/closed)
|
||||||
|
- 디자인: DESIGN-SYSTEM.md v2 준수 (project-detail-design.md 확장)
|
||||||
|
- 로딩: 스켈레톤 UI
|
||||||
|
|
||||||
|
### TASK-027: 활동 로그 자동 기록
|
||||||
|
- ActivityLog 모델 활용 (기존 테이블 사용)
|
||||||
|
- 자동 기록 트리거:
|
||||||
|
- 자매 상태 변경 시 (online ↔ offline)
|
||||||
|
- Gitea 동기화 시 (새 repo/커밋 감지)
|
||||||
|
- 관리자 액션 시 (재시작, 하네스 편집, 설정 변경)
|
||||||
|
- ActivityService에 `logActivity()` 메서드 추가 → 각 서비스에서 호출
|
||||||
|
- WebSocket으로 실시간 푸시 (`activity:new` 이벤트 활용)
|
||||||
|
- `/activities` 페이지: 실제 ActivityLog 데이터 표시 (하드코딩 제거)
|
||||||
|
- 필터/검색/페이지네이션 실제 동작
|
||||||
|
|
||||||
|
## 의존성
|
||||||
|
- Sprint 006 완료 필요 (JWT 인증)
|
||||||
|
- TASK-024 → TASK-025 (repo가 DB에 있어야 commit 조회 가능)
|
||||||
|
- TASK-026은 TASK-025 완료 후 연동
|
||||||
|
- TASK-027은 독립적 (다른 태스크와 병렬 가능)
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- Gitea 동기화 → DB에 프로젝트 생성 확인
|
||||||
|
- 커밋/브랜치/PR 조회 정상 동작
|
||||||
|
- 활동 로그 자동 기록 + WebSocket 실시간 푸시
|
||||||
|
- /activities 페이지에서 실제 데이터 표시 (필터/페이지네이션 동작)
|
||||||
|
- npm run build 성공 + 테스트 통과
|
||||||
99
.plans/sprints/SPRINT-008.md
Normal file
99
.plans/sprints/SPRINT-008.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# SPRINT-008: 실시간 전환 + 설정 + 자매 강화
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
모든 하드코딩 데이터를 실시간 전환 + 설정 페이지 구현 + 자매 프로필 강화
|
||||||
|
|
||||||
|
## 태스크
|
||||||
|
|
||||||
|
### TASK-028: 자매 실시간 데이터 (Uptime/시스템 정보)
|
||||||
|
- SistersService 확장:
|
||||||
|
- SSH로 실제 uptime 조회: `cat /proc/uptime`
|
||||||
|
- CPU 사용률: `top -bn1 | grep Cpu`
|
||||||
|
- 메모리: `free -m`
|
||||||
|
- 디스크: `df -h /`
|
||||||
|
- API 응답에 시스템 정보 추가:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uptime": "342:12:05",
|
||||||
|
"cpu": 23.4,
|
||||||
|
"memory": { "used": 1024, "total": 4096 },
|
||||||
|
"disk": { "used": "12G", "total": "50G" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 자매 페이지(/sisters): 실제 Uptime 표시 (하드코딩 제거)
|
||||||
|
- 자매 카드: Capacity → 실제 CPU/메모리 기반
|
||||||
|
- WebSocket: 30초마다 자매 상태 업데이트에 시스템 정보 포함
|
||||||
|
|
||||||
|
### TASK-029: 자매 프로필 사진 API
|
||||||
|
- API: `GET /api/sisters/:name/avatar`
|
||||||
|
- SSH로 각 자매 서버에서 프로필 사진 가져오기:
|
||||||
|
- 경로: `~/.openclaw/avatar.png` 또는 `~/.openclaw/avatar.jpg`
|
||||||
|
- SCP/SSH cat으로 바이너리 전송
|
||||||
|
- 캐싱: 메모리 캐시 (5분 TTL) — 매번 SSH 호출 방지
|
||||||
|
- 대체(fallback): 프로필 사진 없으면 이름 이니셜 아바타 생성
|
||||||
|
- FE: 자매 카드/상세 페이지에서 아바타 이미지 교체
|
||||||
|
|
||||||
|
### TASK-030: 설정 페이지 BE + FE
|
||||||
|
- SystemSettings 테이블 (Prisma):
|
||||||
|
```
|
||||||
|
model SystemSettings {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
key String @unique
|
||||||
|
value String @db.Text
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- API:
|
||||||
|
- `GET /api/admin/settings` — 전체 설정 조회
|
||||||
|
- `PUT /api/admin/settings` — 설정 일괄 저장
|
||||||
|
- 설정 항목 (settings-design.md 기반):
|
||||||
|
- 강제 2FA (boolean)
|
||||||
|
- 세션 유효 시간 (number, 분)
|
||||||
|
- IP 화이트리스트 (boolean)
|
||||||
|
- 자동 백업 (boolean)
|
||||||
|
- 백업 주기 (string, HH:mm)
|
||||||
|
- 보관 주기 (number, 일)
|
||||||
|
- 대기열 경고 임계값 (number)
|
||||||
|
- 자동 스케일링 (boolean)
|
||||||
|
- CPU 임계값 (number, %)
|
||||||
|
- 레이턴시 경보 (number, ms)
|
||||||
|
- 노드 오프라인 알림 (boolean)
|
||||||
|
- FE /settings 페이지:
|
||||||
|
- 마운트 시 설정 로드 (API fetch)
|
||||||
|
- 토글/인풋 변경 → 로컬 state
|
||||||
|
- "설정 저장" → PUT API → 성공 토스트
|
||||||
|
- "초기화" → 기본값 복원
|
||||||
|
|
||||||
|
### TASK-031: 하드코딩 제거 + 스켈레톤 UI
|
||||||
|
**대상 목록:**
|
||||||
|
|
||||||
|
| 페이지 | 하드코딩 항목 | 전환 방식 |
|
||||||
|
|--------|-------------|-----------|
|
||||||
|
| `/` (대시보드) | 상태 카드 값 ([ON]/[84]/[--]/[12]) | → /api/sisters 실시간 |
|
||||||
|
| `/` (대시보드) | ONGOING PROJECTS 목록 | → /api/projects 실시간 |
|
||||||
|
| `/` (대시보드) | ACTIVITY FEED | → /api/activities 실시간 |
|
||||||
|
| `/sisters` | Uptime, Load Index, Sync Log | → TASK-028 API |
|
||||||
|
| `/org` | 조직도 데이터 | → /api/org (이미 연동, 확인만) |
|
||||||
|
| `/settings` | 토글/인풋 값 | → TASK-030 API |
|
||||||
|
| `/activities` | 전체 로그 테이블 | → /api/activities (TASK-027에서 연동) |
|
||||||
|
|
||||||
|
- 모든 API 호출에 **스켈레톤 UI** 추가:
|
||||||
|
- 카드 스켈레톤: 회색 박스 펄스 애니메이션
|
||||||
|
- 테이블 스켈레톤: 행 placeholder
|
||||||
|
- 타임라인 스켈레톤: 도트 + 라인 placeholder
|
||||||
|
- 에러 상태: "데이터를 불러올 수 없습니다" fallback UI
|
||||||
|
|
||||||
|
## 의존성
|
||||||
|
- Sprint 007 완료 필요 (Gitea 데이터 + 활동 로그가 있어야 실시간 전환 가능)
|
||||||
|
- TASK-028, TASK-029는 독립적 (병렬 가능)
|
||||||
|
- TASK-030은 독립적
|
||||||
|
- TASK-031은 TASK-028 + TASK-030 완료 후 최종 정리
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
- 자매 페이지에서 실제 Uptime/CPU/메모리 표시
|
||||||
|
- 프로필 사진 정상 렌더링 (없으면 이니셜 fallback)
|
||||||
|
- 설정 저장/로드/초기화 정상 동작 + 토스트
|
||||||
|
- 모든 페이지에서 하드코딩 0건 (curl/검색으로 확인)
|
||||||
|
- 스켈레톤 UI 동작 (느린 네트워크에서 확인)
|
||||||
|
- npm run build 성공 + 테스트 통과
|
||||||
|
- **외부 URL QA 필수**
|
||||||
92
.qa/SPRINT-002-review-1.md
Normal file
92
.qa/SPRINT-002-review-1.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# SPRINT-002 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 02:41 UTC
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ❌ FAILED (blocking 2건)
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install (backend) | ✅ |
|
||||||
|
| npm install (frontend) | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test (backend) | ✅ 11/11 pass |
|
||||||
|
| npm run build (backend) | ✅ |
|
||||||
|
| npm run build (frontend) | ✅ |
|
||||||
|
| FE SSR 렌더링 확인 | ✅ |
|
||||||
|
| /projects/[id] 동적 라우트 | ✅ |
|
||||||
|
|
||||||
|
## Sprint 001 non-blocking 수정 확인
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| CORS 화이트리스트 | ✅ 해결 |
|
||||||
|
| DATABASE_URL throw | ✅ 해결 |
|
||||||
|
| SSH_KEY_PATH throw | ✅ 해결 |
|
||||||
|
| SSH 에러 마스킹 | ✅ 해결 |
|
||||||
|
| LayoutShell 분리 | ✅ 해결 |
|
||||||
|
| Sidebar↔MainContent 연동 | ✅ 해결 |
|
||||||
|
| API 응답 IP 제거 | ✅ 해결 |
|
||||||
|
| console.log → Logger | ✅ 해결 |
|
||||||
|
| theme.ts 적용 | ✅ 직접 import 패턴 (OK) |
|
||||||
|
| npm audit 7개 | ❌ 미해결 (devDep chain) |
|
||||||
|
|
||||||
|
## Sprint 002 설계 대조
|
||||||
|
|
||||||
|
| 설계 항목 | 일치 |
|
||||||
|
|-----------|------|
|
||||||
|
| GiteaService (org repos, repo detail, PRs) | ✅ |
|
||||||
|
| GET /api/projects | ✅ |
|
||||||
|
| GET /api/projects/:id | ✅ |
|
||||||
|
| GET /api/projects/:id/tasks | ✅ |
|
||||||
|
| POST /api/projects/:id/sprints | ✅ |
|
||||||
|
| PATCH /api/tasks/:id | ✅ |
|
||||||
|
| GET /api/activity (limit, offset) | ✅ |
|
||||||
|
| GET /api/projects/:id/activity | ✅ |
|
||||||
|
| SprintAccordion 컴포넌트 | ✅ |
|
||||||
|
| TaskTable + 필터링 | ✅ |
|
||||||
|
| TabNav (Sprints/Tasks/Activity) | ✅ |
|
||||||
|
| ProgressBar | ✅ |
|
||||||
|
| TaskBadge (7개 상태) | ✅ |
|
||||||
|
| ActivityFeed 타임라인 | ✅ |
|
||||||
|
| ProjectCard + 링크 | ✅ |
|
||||||
|
| 메인 대시보드 프로젝트 섹션 | ✅ |
|
||||||
|
| 메인 대시보드 활동 피드 | ✅ |
|
||||||
|
|
||||||
|
## 🔴 Blocking 이슈
|
||||||
|
|
||||||
|
### CR-001: GiteaService GITEA_BASE_URL/GITEA_TOKEN 미설정 시 앱 부팅 실패
|
||||||
|
- **파일:** `backend/src/gitea/gitea.service.ts`
|
||||||
|
- **문제:** constructor에서 `throw new Error()` → DI 실패 → 앱 전체 부팅 실패
|
||||||
|
- **설계 불일치:** 핸드오프에서 "Gitea API 토큰 없으면 graceful fallback으로 DB only"라고 명시했으나 코드는 throw
|
||||||
|
- **수정:** Optional 패턴으로 변경. client를 null로 두고 메서드에서 빈 배열/null 반환
|
||||||
|
|
||||||
|
### CR-002: CreateSprintDto/UpdateTaskDto에 class-validator 미적용
|
||||||
|
- **파일:** `backend/src/tasks/tasks.service.ts`
|
||||||
|
- **문제:** plain class DTO — ValidationPipe 없이 아무 값이나 DB 직행
|
||||||
|
- **위험:** status에 임의 문자열, number에 음수, 예상 외 필드 주입 가능
|
||||||
|
- **수정:** class-validator 데코레이터 추가 + main.ts에 ValidationPipe 적용
|
||||||
|
|
||||||
|
## Non-blocking 이슈
|
||||||
|
|
||||||
|
### 코드 품질 (code-reviewer)
|
||||||
|
| ID | 이슈 |
|
||||||
|
|----|------|
|
||||||
|
| CR-003 | iteration 증가 로직 — 정확하지만 메서드 분리로 가독성 개선 권장 |
|
||||||
|
| CR-004 | limit/offset Query 파라미터 NaN 미처리 — ParseIntPipe 적용 권장 |
|
||||||
|
| CR-006 | giteaRepos 키 매칭 — giteaId null 방어 필요 |
|
||||||
|
| CR-007 | ProjectDetailPage useState<any> 3곳 — 인터페이스 정의 권장 |
|
||||||
|
| CR-008 | CORS 에러에 origin 값 노출 — generic 에러 반환 권장 |
|
||||||
|
| CR-009 | npm audit 7개 — devDependency 확인 후 prod 0이면 수용 가능 |
|
||||||
|
|
||||||
|
### 보안 감사 (security-auditor)
|
||||||
|
| ID | 이슈 | 심각도 |
|
||||||
|
|----|------|--------|
|
||||||
|
| SA-005 | API 인증 없음 (전체) — Sprint-003 필수 | Medium |
|
||||||
|
| SA-006 | PATCH /tasks/:id 인증 없음 (write) | High |
|
||||||
|
| SA-007 | POST /projects/:id/sprints 인증 없음 (write) | High |
|
||||||
|
| SA-008 | limit/offset 클램핑 없음 (대량 조회 가능) | Medium |
|
||||||
|
| SA-012 | Helmet 미적용 | Medium |
|
||||||
|
| SA-013 | Rate limiting 없음 | Medium |
|
||||||
|
| SA-014 | GiteaService 에러 시 Authorization 헤더 노출 가능 | Medium |
|
||||||
29
.qa/SPRINT-002-review-2.md
Normal file
29
.qa/SPRINT-002-review-2.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# SPRINT-002 QA Review — Iteration 2
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 02:44 UTC
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## Blocking 수정 확인
|
||||||
|
|
||||||
|
| ID | 이슈 | 수정 확인 |
|
||||||
|
|----|------|----------|
|
||||||
|
| CR-001 | GiteaService throw → graceful fallback | ✅ client=null + isAvailable() 가드 + 빈 배열/null 반환 |
|
||||||
|
| CR-002 | DTO class-validator + ValidationPipe | ✅ @IsNumber/@Min/@IsString/@IsOptional/@IsIn 적용 + main.ts ValidationPipe(whitelist, forbidNonWhitelisted, transform) |
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| git fetch + reset | ✅ |
|
||||||
|
| npm install | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test (backend) | ✅ 11/11 pass |
|
||||||
|
| npm run build (backend) | ✅ |
|
||||||
|
| npm run build (frontend) | ✅ |
|
||||||
|
|
||||||
|
## 판정 근거
|
||||||
|
- Iteration 1의 blocking 2건 모두 정확하게 수정됨
|
||||||
|
- 테스트/빌드 전부 통과
|
||||||
|
- 설계 문서와 코드 일치 (Iteration 1에서 이미 확인)
|
||||||
|
- Non-blocking 이슈 14건은 Sprint-003에서 해결 권고
|
||||||
71
.qa/SPRINT-003-review-1.md
Normal file
71
.qa/SPRINT-003-review-1.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# SPRINT-003 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 03:00 UTC
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| git clone + checkout | ✅ |
|
||||||
|
| npm install (BE/FE) | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test (backend) | ✅ 16/16 pass |
|
||||||
|
| npm run build (backend) | ✅ |
|
||||||
|
| npm run build (frontend) | ✅ 7 routes |
|
||||||
|
|
||||||
|
## 설계 대조
|
||||||
|
|
||||||
|
| 설계 항목 | 일치 |
|
||||||
|
|-----------|------|
|
||||||
|
| SisterDetailService (SSH config/sessions/subagents) | ✅ |
|
||||||
|
| GET /api/sisters/:name/config | ⚠️ (SOUL.md+AGENTS.md, 설계는 openclaw.json — 문서 업데이트 필요) |
|
||||||
|
| GET /api/sisters/:name/sessions | ✅ |
|
||||||
|
| GET /api/sisters/:name/subagents | ✅ |
|
||||||
|
| GET /api/sisters/:name/activity | ✅ |
|
||||||
|
| GET /api/org | ✅ |
|
||||||
|
| /sisters 목록 페이지 | ✅ |
|
||||||
|
| /sisters/[name] 상세 (4탭) | ✅ |
|
||||||
|
| /org 조직도 (트리, 호버 팝업) | ✅ |
|
||||||
|
| /settings 설정 뷰어 (드롭다운) | ✅ |
|
||||||
|
| SSH graceful fallback | ✅ |
|
||||||
|
|
||||||
|
## Sprint 002 non-blocking 처리 확인
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| API_URL/POLL_INTERVAL lib/config.ts 공통 추출 | ✅ |
|
||||||
|
| 폴링 간격 env화 | ✅ |
|
||||||
|
|
||||||
|
## Non-blocking 이슈
|
||||||
|
|
||||||
|
### 코드 품질 (code-reviewer)
|
||||||
|
| ID | 이슈 |
|
||||||
|
|----|------|
|
||||||
|
| N1 | name 파라미터 Controller 검증 추가 권장 (BadRequestException) |
|
||||||
|
| N2 | /api/sisters/:name/config — 설계 문서(openclaw.json) vs 구현(SOUL.md+AGENTS.md) 불일치 → api-design.md 업데이트 필요 |
|
||||||
|
| N3 | useState<any> 다수 — types/ 공유 인터페이스 정의 권장 |
|
||||||
|
| N4 | ls -lt 파싱 취약 — find/stat 기반으로 개선 권장 |
|
||||||
|
| N5 | OrgTree에서 API pipeline 데이터 미사용 (하드코딩 라벨) |
|
||||||
|
| N6 | formatLastSeen / sisterEmojis / sisterDisplayNames 3곳 중복 → lib/utils.ts, lib/constants.ts 추출 |
|
||||||
|
| N7 | SisterDetailPage 폴링 없음 |
|
||||||
|
| N8 | catch 블록 silent 처리 — 최소 에러 상태 표시 필요 |
|
||||||
|
|
||||||
|
### 보안 감사 (security-auditor)
|
||||||
|
| ID | 이슈 | 심각도 |
|
||||||
|
|----|------|--------|
|
||||||
|
| H-1 | name 파라미터 입력 검증 없음 | High (non-blocking) |
|
||||||
|
| H-2 | API 인증/인가 전면 부재 — Sprint-004 필수 | High (non-blocking) |
|
||||||
|
| M-1 | SOUL.md 원문 60줄 노출 — 프롬프트 인젝션 활용 가능 | Medium |
|
||||||
|
| M-2 | lxcId 내부 인프라 정보 노출 | Medium |
|
||||||
|
| M-3 | CORS 에러에 origin 노출 | Medium |
|
||||||
|
| M-4 | Helmet 미적용 | Medium |
|
||||||
|
| L-1 | npm audit 7건 (dev dependency) | Low |
|
||||||
|
|
||||||
|
## 판정 근거
|
||||||
|
- 테스트/빌드 전부 통과
|
||||||
|
- 설계 대조 일치 (config 엔드포인트만 문서 업데이트 필요)
|
||||||
|
- blocking 0건 (code-reviewer B1,B2 재검증 후 non-blocking 하향)
|
||||||
|
- SSH graceful fallback 일관적
|
||||||
|
- OrgTree 순수 CSS 구현 양호
|
||||||
51
.qa/SPRINT-004-review-1.md
Normal file
51
.qa/SPRINT-004-review-1.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# SPRINT-004 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 03:16 UTC
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ❌ FAILED (blocking 3건)
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test (backend) | ✅ 22/22 pass |
|
||||||
|
| npm run build (backend) | ✅ |
|
||||||
|
| npm run build (frontend) | ✅ 11 routes |
|
||||||
|
|
||||||
|
## Sprint 003 non-blocking 수정 확인
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| API Key Guard (passport-http-bearer) | ✅ |
|
||||||
|
| Helmet 적용 | ✅ |
|
||||||
|
| SisterNamePipe (name 검증) | ✅ |
|
||||||
|
| lib/sisters.ts 공통 함수 추출 | ✅ |
|
||||||
|
|
||||||
|
## 🔴 Blocking 이슈
|
||||||
|
|
||||||
|
### B1: UpdateHarnessDto class-validator 미적용
|
||||||
|
- **파일:** `admin/admin.controller.ts`
|
||||||
|
- **문제:** `@IsString() @IsNotEmpty()` 없음 → 빈 content로 하네스 파일 삭제 가능
|
||||||
|
- **수정:** `@IsString() @IsNotEmpty() @MaxLength(50000)` 추가
|
||||||
|
|
||||||
|
### B2: getSisterLogs lines 파라미터 상한 없음
|
||||||
|
- **파일:** `admin/admin.service.ts`
|
||||||
|
- **문제:** `lines=999999999` → journalctl/tail 메모리 폭발
|
||||||
|
- **수정:** `Math.min(Math.max(parseInt(lines, 10) || 100, 1), 500)` clamp
|
||||||
|
|
||||||
|
### B3: CORS methods에 PUT 누락
|
||||||
|
- **파일:** `main.ts`
|
||||||
|
- **문제:** `PUT /api/admin/harness/:name/:file`가 preflight 차단
|
||||||
|
- **수정:** methods 배열에 `'PUT'` 추가
|
||||||
|
|
||||||
|
## Non-blocking 이슈
|
||||||
|
|
||||||
|
| ID | 이슈 | 심각도 |
|
||||||
|
|----|------|--------|
|
||||||
|
| N1 | 셸 escape에서 null byte 미처리 | Medium |
|
||||||
|
| N2 | localStorage 평문 API Key | Medium |
|
||||||
|
| N3 | CORS 에러에 origin 노출 | Low |
|
||||||
|
| N4 | resetSisterSession catch에서 activity.log 누락 | Low |
|
||||||
|
| N5 | /api/sisters 비인증 노출 (config/sessions 포함) | Low |
|
||||||
26
.qa/SPRINT-004-review-2.md
Normal file
26
.qa/SPRINT-004-review-2.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# SPRINT-004 QA Review — Iteration 2
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 03:19 UTC
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## Blocking 수정 확인
|
||||||
|
|
||||||
|
| ID | 이슈 | 수정 확인 |
|
||||||
|
|----|------|----------|
|
||||||
|
| B1 | UpdateHarnessDto validation | ✅ @IsString @IsNotEmpty @MaxLength(50000) |
|
||||||
|
| B2 | lines clamp | ✅ Controller에서 Math.min(Math.max(NaN→100, 1), 500) |
|
||||||
|
| B3 | CORS PUT 추가 | ✅ methods: ['GET','POST','PATCH','PUT','DELETE','OPTIONS'] |
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| git fetch + reset | ✅ |
|
||||||
|
| npm test | ✅ 22/22 pass |
|
||||||
|
| npm run build | ✅ |
|
||||||
|
|
||||||
|
## 판정 근거
|
||||||
|
- Blocking 3건 전부 정확히 수정됨
|
||||||
|
- 테스트/빌드 통과
|
||||||
|
- Non-blocking 5건은 Sprint-005 권고
|
||||||
67
.qa/SPRINT-005-review-1.md
Normal file
67
.qa/SPRINT-005-review-1.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# SPRINT-005 QA Review — Iteration 1 (최종 Sprint)
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 03:33 UTC
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| git clone + checkout | ✅ |
|
||||||
|
| npm install (BE/FE) | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test (backend) | ✅ 22/22 pass |
|
||||||
|
| npm run build (backend) | ✅ |
|
||||||
|
| npm run build (frontend) | ✅ 12 routes |
|
||||||
|
|
||||||
|
## 설계 대조
|
||||||
|
|
||||||
|
| 설계 항목 | 일치 |
|
||||||
|
|-----------|------|
|
||||||
|
| CostLog 모델 + 인덱스 | ✅ |
|
||||||
|
| GET /api/admin/costs (period=day/week/month) | ✅ |
|
||||||
|
| POST /api/admin/costs/record/:name | ✅ |
|
||||||
|
| @UseGuards(ApiKeyGuard) on costs API | ✅ |
|
||||||
|
| WebSocket Gateway (/ws namespace) | ✅ |
|
||||||
|
| EventsScheduler 30초 브로드캐스트 | ✅ |
|
||||||
|
| sisters:update + activity:new 이벤트 | ✅ |
|
||||||
|
| ActivityService 실시간 연동 | ✅ |
|
||||||
|
| /admin/costs 비용 대시보드 (BarChart+요약) | ✅ |
|
||||||
|
| 기간 필터 (day/week/month) | ✅ |
|
||||||
|
| FE useSocket 훅 | ✅ |
|
||||||
|
| 메인 대시보드 실시간 반영 | ✅ |
|
||||||
|
|
||||||
|
## Sprint 004 non-blocking 수정 확인
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| null byte 방지 (셸 escape) | ✅ |
|
||||||
|
|
||||||
|
## Non-blocking 이슈
|
||||||
|
|
||||||
|
### 코드 품질 (code-reviewer)
|
||||||
|
| ID | 이슈 | 심각도 |
|
||||||
|
|----|------|--------|
|
||||||
|
| N1 | WebSocket Gateway 인증 없음 — handleConnection에 token 검증 추가 권장 | Medium |
|
||||||
|
| N2 | useSocket stale closure — useRef 패턴으로 개선 권장 | Low |
|
||||||
|
| N3 | recordCosts python3 의존 — jq 또는 Node.js 대안 권장 | Low |
|
||||||
|
| N4 | estimatedUsd Float 정밀도 — Decimal 또는 정수(cents) 저장 권장 | Low |
|
||||||
|
| N5 | MODEL_PRICING 하드코딩 — config/DB 분리 권장 | Low |
|
||||||
|
| N6 | recordAll Promise.all → Promise.allSettled 권장 | Low |
|
||||||
|
| N7 | EventsScheduler setInterval → setTimeout 재스케줄링 권장 | Low |
|
||||||
|
| N8 | WebSocket + REST 이중 폴링 — connected 시 간격 조절 권장 | Low |
|
||||||
|
| N9 | POST record/:name 응답 body 없음 — {recorded: true} 반환 권장 | Low |
|
||||||
|
|
||||||
|
### 보안 감사 (security-auditor)
|
||||||
|
| ID | 이슈 | 심각도 |
|
||||||
|
|----|------|--------|
|
||||||
|
| SEC-001 | WebSocket Gateway 무인증 — origin/token 검증 추가 권장 | Medium |
|
||||||
|
| SEC-002 | lodash 의존성 취약점 (Prototype Pollution) — @nestjs/config 업그레이드 권고 | High (실제 악용 Low) |
|
||||||
|
|
||||||
|
## 판정 근거
|
||||||
|
- 테스트/빌드 전부 통과
|
||||||
|
- 설계 문서와 코드 일치
|
||||||
|
- blocking 0건 (code-reviewer B1~B3 재검증 후 non-blocking 하향)
|
||||||
|
- CostLog API 인증 적용, CORS 일관, Helmet, ValidationPipe 등 보안 기반 양호
|
||||||
|
- WebSocket 인증은 내부망 대시보드 특성상 non-blocking으로 판정
|
||||||
52
.qa/SPRINT-006-review-1.md
Normal file
52
.qa/SPRINT-006-review-1.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# SPRINT-006 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 15:10 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ❌ FAILED (blocking 5건)
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test (backend) | ✅ 26/26 pass |
|
||||||
|
| npm run build (backend) | ✅ |
|
||||||
|
| npm run build (frontend) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## 🔴 Blocking 이슈
|
||||||
|
|
||||||
|
### B1: JWT_SECRET fallback 'changeme' — 3곳 (Critical)
|
||||||
|
- **파일:** auth.service.ts:85, auth.module.ts:22, events.gateway.ts:48
|
||||||
|
- **문제:** JWT_SECRET 미설정 시 'changeme'로 토큰 서명 → 공격자가 유효한 JWT 위조 가능
|
||||||
|
- **모순:** jwt.strategy.ts는 throw하는데 나머지는 fallback → 불일치
|
||||||
|
- **수정:** 3곳 모두 `?? 'changeme'` 제거, ConfigService.getOrThrow 또는 throw 처리
|
||||||
|
|
||||||
|
### B2: User.role 기본값 "admin" (Critical)
|
||||||
|
- **파일:** prisma/schema.prisma
|
||||||
|
- **문제:** 모든 가입자 자동 admin → 초대코드만 알면 SSH restart, 하네스 수정 등 전체 인프라 제어
|
||||||
|
- **수정:** `@default("viewer")` + admin 승격 별도 로직 + AdminController에 RoleGuard 추가
|
||||||
|
|
||||||
|
### B3: WebSocket JWT 실패 시 disconnect 안 함
|
||||||
|
- **파일:** events.gateway.ts:52-53
|
||||||
|
- **문제:** invalid token 시 연결 유지 → 미인증 실시간 데이터 수신
|
||||||
|
- **수정:** catch 블록에서 `client.disconnect()` 호출
|
||||||
|
|
||||||
|
### B4: `throw new Error` → HttpException
|
||||||
|
- **파일:** auth.controller.ts:32
|
||||||
|
- **문제:** refresh token 없을 때 `throw new Error` → 500 반환
|
||||||
|
- **수정:** `throw new BadRequestException('Refresh token required')`
|
||||||
|
|
||||||
|
### B5: register 엔드포인트 rate limit 없음
|
||||||
|
- **파일:** auth.controller.ts:18-20
|
||||||
|
- **문제:** 초대코드 brute-force 가능
|
||||||
|
- **수정:** `@UseGuards(ThrottlerGuard) @Throttle({ default: { limit: 3, ttl: 60000 } })` 추가
|
||||||
|
|
||||||
|
## Non-blocking 이슈 (7건)
|
||||||
|
- N1: refresh token localStorage 평문 (httpOnly cookie 전환 권장)
|
||||||
|
- N2: AuthProvider 토큰 만료 시 자동 refresh 안 함
|
||||||
|
- N3: register에서 refreshToken 미저장
|
||||||
|
- N4: login 후 userId: 0 하드코딩
|
||||||
|
- N5: DTO 서비스 파일 내 정의 (별도 파일 권장)
|
||||||
|
- N6: adminFetch 주석-코드 불일치
|
||||||
|
- N7: refresh secret suffix 방식 → 별도 env 권장
|
||||||
23
.qa/SPRINT-006-review-2.md
Normal file
23
.qa/SPRINT-006-review-2.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# SPRINT-006 QA Review — Iteration 2
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 15:17 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## Blocking 수정 확인
|
||||||
|
|
||||||
|
| ID | 이슈 | 수정 확인 |
|
||||||
|
|----|------|----------|
|
||||||
|
| B1 (Critical) | JWT_SECRET 'changeme' 4곳 | ✅ 전부 제거, 0건 grep 확인 |
|
||||||
|
| B2 (Critical) | User.role "admin" | ✅ @default("viewer") + RoleGuard + @Roles('admin') on Admin/Costs |
|
||||||
|
| B3 | WS disconnect | ✅ catch 블록에서 client.disconnect() 호출 |
|
||||||
|
| B4 | throw Error | ✅ BadRequestException 변경 |
|
||||||
|
| B5 | register Throttle | ✅ @Throttle 3회/분 적용 |
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| git fetch + reset | ✅ |
|
||||||
|
| npm test | ✅ 26/26 pass |
|
||||||
|
| npm run build | ✅ |
|
||||||
39
.qa/SPRINT-007-review-1.md
Normal file
39
.qa/SPRINT-007-review-1.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# SPRINT-007 QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 15:38 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ❌ FAILED (blocking 1건)
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install | ✅ |
|
||||||
|
| prisma generate | ✅ |
|
||||||
|
| npm test | ✅ 26/26 pass |
|
||||||
|
| npm run build (BE) | ✅ |
|
||||||
|
| npm run build (FE) | ✅ 16 routes |
|
||||||
|
|
||||||
|
## Sprint 006 NB 처리 확인
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| 자동 refresh (401 시) | ✅ |
|
||||||
|
| refreshToken 저장 | ✅ |
|
||||||
|
|
||||||
|
## 🔴 Blocking
|
||||||
|
|
||||||
|
### B1: getProjectCommits limit 파라미터 NaN 미처리
|
||||||
|
- **파일:** projects.controller.ts:25
|
||||||
|
- **문제:** `parseInt(limit, 10)` → NaN 가능 → Gitea API에 `?limit=NaN`
|
||||||
|
- **수정:** `isNaN` 체크 + `Math.min(Math.max(값, 1), 100)` clamp
|
||||||
|
|
||||||
|
## Non-blocking (8건)
|
||||||
|
- N1: ProjectsController 인증 없음 — 공개 의도라면 주석 명시
|
||||||
|
- N2: userId: 0 하드코딩 (refresh 후) — 현재 FE에서 미참조이나 개선 권장
|
||||||
|
- N3: syncRepos N+1 쿼리 + 트랜잭션 미사용
|
||||||
|
- N4: login 후 /me 실패 시 userId: 0 fallback
|
||||||
|
- N5: fetch 에러 시 에러 상태 없이 빈 목록
|
||||||
|
- N6: lastSyncAt 메모리만 저장
|
||||||
|
- N7: getOpenPRs/getPulls 중복
|
||||||
|
- N8: useEffect deps 불완전
|
||||||
18
.qa/SPRINT-007-review-2.md
Normal file
18
.qa/SPRINT-007-review-2.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# SPRINT-007 QA Review — Iteration 2
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 15:41 KST
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## Blocking 수정 확인
|
||||||
|
|
||||||
|
| ID | 이슈 | 수정 확인 |
|
||||||
|
|----|------|----------|
|
||||||
|
| B1 | commits limit NaN | ✅ isNaN 체크 + clamp(1~100) |
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| git fetch + reset | ✅ |
|
||||||
|
| npm test | ✅ 26/26 pass |
|
||||||
60
.qa/SPRINT-REDESIGN-review-1.md
Normal file
60
.qa/SPRINT-REDESIGN-review-1.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# SPRINT-REDESIGN QA Review — Iteration 1
|
||||||
|
|
||||||
|
- **검증일시:** 2026-04-04 04:13 UTC
|
||||||
|
- **검증자:** 다랑이 (Evaluator)
|
||||||
|
- **결과:** ✅ PASSED
|
||||||
|
|
||||||
|
## 검증 항목
|
||||||
|
|
||||||
|
| 항목 | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| npm install | ✅ |
|
||||||
|
| npm run build | ✅ 14 routes |
|
||||||
|
| 백엔드 변경 | 없음 (FE only) |
|
||||||
|
|
||||||
|
## DESIGN-SYSTEM.md 충실도
|
||||||
|
|
||||||
|
| 디자인 토큰 | 코드 일치 |
|
||||||
|
|-------------|-----------|
|
||||||
|
| 색상 (--bg-main, --text-primary, --border-color 등) | ✅ |
|
||||||
|
| 폰트 (--font-sans, --font-mono) | ✅ |
|
||||||
|
| 스페이싱 (--space-xs ~ --space-xxl) | ✅ |
|
||||||
|
| Sidebar 240px / 64px / 하단 탭바 | ✅ |
|
||||||
|
| 로고 ● + ◗ | ✅ |
|
||||||
|
| 네비 [브라켓] active 표기 | ✅ |
|
||||||
|
| Card 1px border + hover | ✅ |
|
||||||
|
| TechBar 2px | ✅ |
|
||||||
|
| Timeline 세로선 + 7px dot | ✅ |
|
||||||
|
| BracketValue 32px → 24px 모바일 | ✅ |
|
||||||
|
| LabelMeta 11px 700 uppercase | ✅ |
|
||||||
|
|
||||||
|
## 반응형 3단계
|
||||||
|
|
||||||
|
| Breakpoint | 확인 |
|
||||||
|
|------------|------|
|
||||||
|
| ≥1200px Desktop | ✅ 기본 레이아웃 |
|
||||||
|
| 768-1199px Tablet | ✅ Sidebar 64px 아이콘, 카드 2열 |
|
||||||
|
| ≤767px Mobile | ✅ 하단 탭바 56px, 카드 1열(2→1), padding-bottom 72px |
|
||||||
|
|
||||||
|
## 새 페이지/컴포넌트
|
||||||
|
|
||||||
|
| 항목 | 확인 |
|
||||||
|
|------|------|
|
||||||
|
| /activities 페이지 | ✅ |
|
||||||
|
| /projects 목록 페이지 | ✅ |
|
||||||
|
| components/ui/base.tsx 공통 컴포넌트 | ✅ |
|
||||||
|
| CSS vars 전면 적용 (theme.ts 하드코딩 대체) | ✅ |
|
||||||
|
|
||||||
|
## Non-blocking 이슈
|
||||||
|
|
||||||
|
| ID | 이슈 |
|
||||||
|
|----|------|
|
||||||
|
| N1 | 일부 admin 컴포넌트(BarChart, CodeEditor 등)에서 old theme import 잔존 가능 — 빌드 통과했으므로 미사용 import만 정리하면 됨 |
|
||||||
|
| N2 | SidebarContext가 아직 존재하나 LayoutShell에서 Provider 감싸지 않음 — 사용 안 되면 삭제 권장 |
|
||||||
|
|
||||||
|
## 판정 근거
|
||||||
|
- FE 빌드 14 routes 전부 통과
|
||||||
|
- DESIGN-SYSTEM.md 디자인 토큰 전부 충실히 구현
|
||||||
|
- 반응형 3단계 breakpoint 코드 확인
|
||||||
|
- glassmorphism → 미니멀 터미널 UI 전환 완료
|
||||||
|
- 백엔드 변경 없어 기능 회귀 위험 없음
|
||||||
749
backend/package-lock.json
generated
749
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,15 +24,29 @@
|
|||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/platform-socket.io": "^11.1.18",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
|
"@nestjs/websockets": "^11.1.18",
|
||||||
"@prisma/adapter-mariadb": "^7.6.0",
|
"@prisma/adapter-mariadb": "^7.6.0",
|
||||||
"@prisma/client": "^7.6.0",
|
"@prisma/client": "^7.6.0",
|
||||||
|
"axios": "^1.14.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^17.4.0",
|
"dotenv": "^17.4.0",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"mariadb": "^3.5.2",
|
"mariadb": "^3.5.2",
|
||||||
"node-ssh": "^13.2.1",
|
"node-ssh": "^13.2.1",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-http-bearer": "^1.0.1",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
"prisma": "^7.6.0",
|
"prisma": "^7.6.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1",
|
||||||
|
"socket.io": "^4.8.3"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "ts-node prisma/seed.ts"
|
"seed": "ts-node prisma/seed.ts"
|
||||||
@@ -40,12 +54,15 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.17",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/passport-http-bearer": "^1.0.42",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
|||||||
@@ -78,3 +78,27 @@ model ActivityLog {
|
|||||||
|
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model CostLog {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
sisterName String // harang, narang, darang, erang
|
||||||
|
model String // model name
|
||||||
|
inputTokens Int @default(0)
|
||||||
|
outputTokens Int @default(0)
|
||||||
|
totalTokens Int @default(0)
|
||||||
|
estimatedUsd Float @default(0)
|
||||||
|
sessionId String? @db.VarChar(128)
|
||||||
|
recordedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([sisterName, recordedAt])
|
||||||
|
@@index([recordedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String @unique
|
||||||
|
password String // bcrypt hash
|
||||||
|
role String @default("viewer")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
31
backend/src/activity/activity.controller.ts
Normal file
31
backend/src/activity/activity.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Controller, Get, Param, Query, ParseIntPipe } from '@nestjs/common';
|
||||||
|
import { ActivityService } from './activity.service';
|
||||||
|
|
||||||
|
@Controller('api')
|
||||||
|
export class ActivityController {
|
||||||
|
constructor(private readonly activityService: ActivityService) {}
|
||||||
|
|
||||||
|
@Get('activity')
|
||||||
|
getFeed(
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
@Query('offset') offset?: string,
|
||||||
|
) {
|
||||||
|
return this.activityService.getFeed(
|
||||||
|
limit ? parseInt(limit, 10) : 50,
|
||||||
|
offset ? parseInt(offset, 10) : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('projects/:id/activity')
|
||||||
|
getProjectFeed(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
@Query('offset') offset?: string,
|
||||||
|
) {
|
||||||
|
return this.activityService.getProjectFeed(
|
||||||
|
id,
|
||||||
|
limit ? parseInt(limit, 10) : 30,
|
||||||
|
offset ? parseInt(offset, 10) : 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/activity/activity.module.ts
Normal file
13
backend/src/activity/activity.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ActivityController } from './activity.controller';
|
||||||
|
import { ActivityService } from './activity.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { EventsModule } from '../events/events.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, EventsModule],
|
||||||
|
controllers: [ActivityController],
|
||||||
|
providers: [ActivityService],
|
||||||
|
exports: [ActivityService],
|
||||||
|
})
|
||||||
|
export class ActivityModule {}
|
||||||
59
backend/src/activity/activity.service.spec.ts
Normal file
59
backend/src/activity/activity.service.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ActivityService } from './activity.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
describe('ActivityService', () => {
|
||||||
|
let service: ActivityService;
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
activityLog: {
|
||||||
|
create: jest.fn().mockResolvedValue({ id: 1, action: 'test', createdAt: new Date() }),
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ActivityService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<ActivityService>(ActivityService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('log: activityLog.create 호출', async () => {
|
||||||
|
await service.log({ action: 'test_action', detail: 'detail' });
|
||||||
|
expect(mockPrisma.activityLog.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: { action: 'test_action', detail: 'detail' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getFeed: items + total 반환', async () => {
|
||||||
|
mockPrisma.activityLog.findMany.mockResolvedValue([{ id: 1 }]);
|
||||||
|
mockPrisma.activityLog.count.mockResolvedValue(1);
|
||||||
|
|
||||||
|
const result = await service.getFeed(10, 0);
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.limit).toBe(10);
|
||||||
|
expect(result.offset).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getProjectFeed: projectId 필터 적용', async () => {
|
||||||
|
mockPrisma.activityLog.findMany.mockResolvedValue([]);
|
||||||
|
mockPrisma.activityLog.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await service.getProjectFeed(42);
|
||||||
|
|
||||||
|
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { projectId: 42 } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
backend/src/activity/activity.service.ts
Normal file
67
backend/src/activity/activity.service.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { EventsGateway } from '../events/events.gateway';
|
||||||
|
|
||||||
|
export interface LogActivityDto {
|
||||||
|
sisterId?: number;
|
||||||
|
projectId?: number;
|
||||||
|
action: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ActivityService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
@Optional() private readonly events?: EventsGateway,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async log(dto: LogActivityDto) {
|
||||||
|
const record = await this.prisma.activityLog.create({
|
||||||
|
data: dto,
|
||||||
|
include: {
|
||||||
|
sister: { select: { name: true } },
|
||||||
|
project: { select: { name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 실시간 브로드캐스트 (gateway 사용 가능한 경우)
|
||||||
|
if (this.events) {
|
||||||
|
this.events.broadcastActivity(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFeed(limit = 50, offset = 0) {
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.activityLog.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
include: {
|
||||||
|
sister: { select: { name: true } },
|
||||||
|
project: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.activityLog.count(),
|
||||||
|
]);
|
||||||
|
return { items, total, limit, offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectFeed(projectId: number, limit = 30, offset = 0) {
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.activityLog.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
include: {
|
||||||
|
sister: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.activityLog.count({ where: { projectId } }),
|
||||||
|
]);
|
||||||
|
return { items, total, limit, offset };
|
||||||
|
}
|
||||||
|
}
|
||||||
68
backend/src/admin/admin.controller.ts
Normal file
68
backend/src/admin/admin.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Put,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AdminService } from './admin.service';
|
||||||
|
import { CompositeGuard } from '../auth/jwt.guard';
|
||||||
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
|
import { SisterNamePipe } from '../common/sister-name.pipe';
|
||||||
|
import type { SisterName } from '../common/sister-name.pipe';
|
||||||
|
|
||||||
|
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
class UpdateHarnessDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(50000)
|
||||||
|
content!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('api/admin')
|
||||||
|
@UseGuards(CompositeGuard, RoleGuard)
|
||||||
|
@Roles('admin')
|
||||||
|
export class AdminController {
|
||||||
|
constructor(private readonly adminService: AdminService) {}
|
||||||
|
|
||||||
|
@Post('sisters/:name/restart')
|
||||||
|
restartSister(@Param('name', SisterNamePipe) name: SisterName) {
|
||||||
|
return this.adminService.restartSister(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sisters/:name/reset')
|
||||||
|
resetSister(@Param('name', SisterNamePipe) name: SisterName) {
|
||||||
|
return this.adminService.resetSisterSession(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('harness/:name/:file')
|
||||||
|
getHarness(
|
||||||
|
@Param('name', SisterNamePipe) name: SisterName,
|
||||||
|
@Param('file') file: string,
|
||||||
|
) {
|
||||||
|
return this.adminService.getHarnessFile(name, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('harness/:name/:file')
|
||||||
|
updateHarness(
|
||||||
|
@Param('name', SisterNamePipe) name: SisterName,
|
||||||
|
@Param('file') file: string,
|
||||||
|
@Body() dto: UpdateHarnessDto,
|
||||||
|
) {
|
||||||
|
return this.adminService.updateHarnessFile(name, file, dto.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('logs/:name')
|
||||||
|
getLogs(
|
||||||
|
@Param('name', SisterNamePipe) name: SisterName,
|
||||||
|
@Query('lines') lines?: string,
|
||||||
|
) {
|
||||||
|
const parsed = lines ? parseInt(lines, 10) : 100;
|
||||||
|
const clamped = Math.min(Math.max(isNaN(parsed) ? 100 : parsed, 1), 500);
|
||||||
|
return this.adminService.getSisterLogs(name, clamped);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/admin/admin.module.ts
Normal file
14
backend/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AdminController } from './admin.controller';
|
||||||
|
import { AdminService } from './admin.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { SistersModule } from '../sisters/sisters.module';
|
||||||
|
import { ActivityModule } from '../activity/activity.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, SistersModule, ActivityModule, AuthModule],
|
||||||
|
controllers: [AdminController],
|
||||||
|
providers: [AdminService],
|
||||||
|
})
|
||||||
|
export class AdminModule {}
|
||||||
91
backend/src/admin/admin.service.spec.ts
Normal file
91
backend/src/admin/admin.service.spec.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AdminService } from './admin.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SshService } from '../sisters/ssh.service';
|
||||||
|
import { ActivityService } from '../activity/activity.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('AdminService', () => {
|
||||||
|
let service: AdminService;
|
||||||
|
|
||||||
|
const mockSister = {
|
||||||
|
id: 2, name: 'narang', ip: '10.10.10.216', user: 'narang',
|
||||||
|
lxcId: 105, status: 'online', lastSeen: new Date(),
|
||||||
|
sshKeyPath: null, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
sisterConfig: { findUnique: jest.fn().mockResolvedValue(mockSister) },
|
||||||
|
};
|
||||||
|
const mockSsh = { executeCommand: jest.fn() };
|
||||||
|
const mockActivity = { log: jest.fn().mockResolvedValue({}) };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AdminService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
{ provide: SshService, useValue: mockSsh },
|
||||||
|
{ provide: ActivityService, useValue: mockActivity },
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: { get: jest.fn().mockReturnValue('/home/narang/.ssh/id_rsa') },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AdminService>(AdminService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockPrisma.sisterConfig.findUnique.mockResolvedValue(mockSister);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restartSister: SSH 성공 시 success=true', async () => {
|
||||||
|
mockSsh.executeCommand.mockResolvedValue({ stdout: 'RESTART_OK', stderr: '', code: 0 });
|
||||||
|
|
||||||
|
const result = await service.restartSister('narang');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockActivity.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'gateway_restart' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restartSister: SSH 실패 시 success=false graceful', async () => {
|
||||||
|
mockSsh.executeCommand.mockRejectedValue(new Error('timeout'));
|
||||||
|
|
||||||
|
const result = await service.restartSister('narang');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getHarnessFile: 허용되지 않은 파일 → BadRequestException', async () => {
|
||||||
|
await expect(service.getHarnessFile('narang', 'passwd')).rejects.toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getHarnessFile: 허용된 파일 + SSH 실패 → 빈 content fallback', async () => {
|
||||||
|
mockSsh.executeCommand.mockRejectedValue(new Error('timeout'));
|
||||||
|
|
||||||
|
const result = await service.getHarnessFile('narang', 'SOUL.md');
|
||||||
|
|
||||||
|
expect(result.file).toBe('SOUL.md');
|
||||||
|
expect(result.content).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateHarnessFile: 허용되지 않은 파일 → BadRequestException', async () => {
|
||||||
|
await expect(
|
||||||
|
service.updateHarnessFile('narang', '../../etc/passwd', 'bad'),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSisterLogs: SSH 실패 시 fallback 메시지 반환', async () => {
|
||||||
|
mockSsh.executeCommand.mockRejectedValue(new Error('timeout'));
|
||||||
|
|
||||||
|
const result = await service.getSisterLogs('narang');
|
||||||
|
|
||||||
|
expect(result.lines).toContain('(SSH 연결 불가)');
|
||||||
|
});
|
||||||
|
});
|
||||||
175
backend/src/admin/admin.service.ts
Normal file
175
backend/src/admin/admin.service.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SshService } from '../sisters/ssh.service';
|
||||||
|
import { ActivityService } from '../activity/activity.service';
|
||||||
|
import { SisterName } from '../common/sister-name.pipe';
|
||||||
|
|
||||||
|
const ALLOWED_HARNESS_FILES = ['AGENTS.md', 'SOUL.md', 'PROTOCOL.md', 'TOOLS.md', 'HEARTBEAT.md'] as const;
|
||||||
|
type HarnessFile = (typeof ALLOWED_HARNESS_FILES)[number];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminService {
|
||||||
|
private readonly logger = new Logger(AdminService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly ssh: SshService,
|
||||||
|
private readonly activity: ActivityService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async restartSister(name: SisterName) {
|
||||||
|
const sister = await this.getSister(name);
|
||||||
|
const keyPath = this.getKeyPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.ssh.executeCommand(
|
||||||
|
sister.ip,
|
||||||
|
sister.user,
|
||||||
|
keyPath,
|
||||||
|
'openclaw gateway restart && echo "RESTART_OK"',
|
||||||
|
);
|
||||||
|
|
||||||
|
const success = result.stdout.includes('RESTART_OK');
|
||||||
|
|
||||||
|
await this.activity.log({
|
||||||
|
sisterId: sister.id,
|
||||||
|
action: 'gateway_restart',
|
||||||
|
detail: success ? 'Gateway restart successful' : `stderr: ${result.stderr}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success, output: result.stdout, error: result.stderr || null };
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`Restart failed for ${name}`);
|
||||||
|
await this.activity.log({
|
||||||
|
sisterId: sister.id,
|
||||||
|
action: 'gateway_restart',
|
||||||
|
detail: `failed: ${(e as Error).message}`,
|
||||||
|
});
|
||||||
|
return { success: false, output: '', error: (e as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetSisterSession(name: SisterName) {
|
||||||
|
const sister = await this.getSister(name);
|
||||||
|
const keyPath = this.getKeyPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.ssh.executeCommand(
|
||||||
|
sister.ip,
|
||||||
|
sister.user,
|
||||||
|
keyPath,
|
||||||
|
'rm -f ~/.openclaw/sessions/main.json && echo "RESET_OK"',
|
||||||
|
);
|
||||||
|
|
||||||
|
const success = result.stdout.includes('RESET_OK');
|
||||||
|
|
||||||
|
await this.activity.log({
|
||||||
|
sisterId: sister.id,
|
||||||
|
action: 'session_reset',
|
||||||
|
detail: success ? 'Session reset successful' : `stderr: ${result.stderr}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success, output: result.stdout, error: result.stderr || null };
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`Reset failed for ${name}`);
|
||||||
|
return { success: false, output: '', error: (e as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHarnessFile(name: SisterName, file: string) {
|
||||||
|
this.validateHarnessFile(file);
|
||||||
|
const sister = await this.getSister(name);
|
||||||
|
const keyPath = this.getKeyPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.ssh.executeCommand(
|
||||||
|
sister.ip,
|
||||||
|
sister.user,
|
||||||
|
keyPath,
|
||||||
|
`cat ~/.openclaw/workspace/${file} 2>/dev/null || echo ""`,
|
||||||
|
);
|
||||||
|
return { name, file, content: result.stdout };
|
||||||
|
} catch {
|
||||||
|
return { name, file, content: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateHarnessFile(name: SisterName, file: string, content: string) {
|
||||||
|
this.validateHarnessFile(file);
|
||||||
|
const sister = await this.getSister(name);
|
||||||
|
const keyPath = this.getKeyPath();
|
||||||
|
|
||||||
|
// 내용에서 위험한 셸 escape + null byte 방지
|
||||||
|
const escaped = content.replace(/\x00/g, '').replace(/'/g, "'\\''");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.ssh.executeCommand(
|
||||||
|
sister.ip,
|
||||||
|
sister.user,
|
||||||
|
keyPath,
|
||||||
|
[
|
||||||
|
`cd ~/.openclaw/workspace`,
|
||||||
|
`printf '%s' '${escaped}' > ${file}`,
|
||||||
|
`git add ${file} && git commit -m "admin: update ${file}" --allow-empty 2>&1`,
|
||||||
|
`echo "WRITE_OK"`,
|
||||||
|
].join(' && '),
|
||||||
|
);
|
||||||
|
|
||||||
|
const success = result.stdout.includes('WRITE_OK');
|
||||||
|
|
||||||
|
await this.activity.log({
|
||||||
|
sisterId: sister.id,
|
||||||
|
action: 'harness_updated',
|
||||||
|
detail: `${file} updated${success ? '' : ' (with errors)'}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success, output: result.stdout, error: result.stderr || null };
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`Harness write failed for ${name}/${file}`);
|
||||||
|
return { success: false, output: '', error: (e as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSisterLogs(name: SisterName, lines = 100) {
|
||||||
|
const sister = await this.getSister(name);
|
||||||
|
const keyPath = this.getKeyPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.ssh.executeCommand(
|
||||||
|
sister.ip,
|
||||||
|
sister.user,
|
||||||
|
keyPath,
|
||||||
|
`journalctl --user -u openclaw-gateway --no-pager -n ${lines} 2>/dev/null || tail -n ${lines} ~/.openclaw/logs/gateway.log 2>/dev/null || echo "(로그 없음)"`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
lines: result.stdout.split('\n').filter((l) => l),
|
||||||
|
total: result.stdout.split('\n').filter((l) => l).length,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { name, lines: ['(SSH 연결 불가)'], total: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateHarnessFile(file: string) {
|
||||||
|
if (!ALLOWED_HARNESS_FILES.includes(file as HarnessFile)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid file. Allowed: ${ALLOWED_HARNESS_FILES.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSister(name: SisterName) {
|
||||||
|
const sister = await this.prisma.sisterConfig.findUnique({ where: { name } });
|
||||||
|
if (!sister) throw new Error(`Sister ${name} not found`);
|
||||||
|
return sister;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getKeyPath(): string {
|
||||||
|
const p = this.config.get<string>('SSH_KEY_PATH');
|
||||||
|
if (!p) throw new Error('SSH_KEY_PATH is not set');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,39 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { SistersModule } from './sisters/sisters.module';
|
import { SistersModule } from './sisters/sisters.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { GiteaModule } from './gitea/gitea.module';
|
||||||
|
import { ProjectsModule } from './projects/projects.module';
|
||||||
|
import { TasksModule } from './tasks/tasks.module';
|
||||||
|
import { ActivityModule } from './activity/activity.module';
|
||||||
|
import { OrgModule } from './org/org.module';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { AdminModule } from './admin/admin.module';
|
||||||
|
import { EventsModule } from './events/events.module';
|
||||||
|
import { CostsModule } from './costs/costs.module';
|
||||||
|
import { GiteaSyncModule } from './gitea-sync/gitea-sync.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
SistersModule,
|
SistersModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
GiteaModule,
|
||||||
|
ProjectsModule,
|
||||||
|
TasksModule,
|
||||||
|
ActivityModule,
|
||||||
|
OrgModule,
|
||||||
|
AuthModule,
|
||||||
|
AdminModule,
|
||||||
|
EventsModule,
|
||||||
|
CostsModule,
|
||||||
|
GiteaSyncModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
5
backend/src/auth/api-key.guard.ts
Normal file
5
backend/src/auth/api-key.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeyGuard extends AuthGuard('api-key') {}
|
||||||
30
backend/src/auth/api-key.strategy.ts
Normal file
30
backend/src/auth/api-key.strategy.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { Strategy } from 'passport-http-bearer';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeyStrategy extends PassportStrategy(Strategy, 'api-key') {
|
||||||
|
private readonly validKeys: Set<string>;
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
super();
|
||||||
|
const raw = config.get<string>('ADMIN_API_KEYS') ?? '';
|
||||||
|
this.validKeys = new Set(
|
||||||
|
raw
|
||||||
|
.split(',')
|
||||||
|
.map((k) => k.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(token: string): { role: string; username: string } {
|
||||||
|
if (!this.validKeys.size) {
|
||||||
|
throw new UnauthorizedException('ADMIN_API_KEYS not configured');
|
||||||
|
}
|
||||||
|
if (!this.validKeys.has(token)) {
|
||||||
|
throw new UnauthorizedException('Invalid API key');
|
||||||
|
}
|
||||||
|
return { role: "admin", username: "api-key" };
|
||||||
|
}
|
||||||
|
}
|
||||||
44
backend/src/auth/auth.controller.ts
Normal file
44
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Body,
|
||||||
|
Headers,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthService, RegisterDto, LoginDto } from './auth.service';
|
||||||
|
import { JwtGuard } from './jwt.guard';
|
||||||
|
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
|
||||||
|
@Controller('api/auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@UseGuards(ThrottlerGuard)
|
||||||
|
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||||
|
@Post('register')
|
||||||
|
register(@Body() dto: RegisterDto) {
|
||||||
|
return this.authService.register(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(ThrottlerGuard)
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
|
@Post('login')
|
||||||
|
login(@Body() dto: LoginDto) {
|
||||||
|
return this.authService.login(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
refresh(@Headers('x-refresh-token') token: string) {
|
||||||
|
if (!token) throw new BadRequestException('Refresh token required');
|
||||||
|
return this.authService.refresh(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
@Get('me')
|
||||||
|
getMe(@Request() req: any) {
|
||||||
|
return this.authService.getMe(req.user.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/src/auth/auth.module.ts
Normal file
32
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
|
import { ApiKeyStrategy } from './api-key.strategy';
|
||||||
|
import { JwtGuard, CompositeGuard } from './jwt.guard';
|
||||||
|
import { ApiKeyGuard } from './api-key.guard';
|
||||||
|
import { RoleGuard } from './role.guard';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PassportModule,
|
||||||
|
ConfigModule,
|
||||||
|
PrismaModule,
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: (() => { const s = config.get<string>('JWT_SECRET'); if (!s) throw new Error('JWT_SECRET is not configured'); return s; })(),
|
||||||
|
signOptions: { expiresIn: '15m' },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy, ApiKeyStrategy, JwtGuard, CompositeGuard, ApiKeyGuard, RoleGuard],
|
||||||
|
exports: [JwtGuard, CompositeGuard, ApiKeyGuard, RoleGuard, AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
79
backend/src/auth/auth.service.spec.ts
Normal file
79
backend/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { BadRequestException, ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockJwt = {
|
||||||
|
sign: jest.fn().mockReturnValue('mock.jwt.token'),
|
||||||
|
verify: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
get: jest.fn().mockImplementation((key: string) => {
|
||||||
|
if (key === 'JWT_SECRET') return 'test-secret';
|
||||||
|
if (key === 'INVITE_CODE') return 'VALID_CODE';
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
{ provide: JwtService, useValue: mockJwt },
|
||||||
|
{ provide: ConfigService, useValue: mockConfig },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AuthService>(AuthService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('register: 잘못된 초대 코드 → BadRequestException', async () => {
|
||||||
|
await expect(
|
||||||
|
service.register({ username: 'test', password: 'password123', inviteCode: 'WRONG' }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('register: 중복 username → ConflictException', async () => {
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue({ id: 1, username: 'test' });
|
||||||
|
await expect(
|
||||||
|
service.register({ username: 'test', password: 'password123', inviteCode: 'VALID_CODE' }),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('register: 성공 시 토큰 반환', async () => {
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrisma.user.create.mockResolvedValue({ id: 1, username: 'newuser', role: 'admin' });
|
||||||
|
|
||||||
|
const result = await service.register({
|
||||||
|
username: 'newuser',
|
||||||
|
password: 'password123',
|
||||||
|
inviteCode: 'VALID_CODE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('accessToken');
|
||||||
|
expect(result).toHaveProperty('refreshToken');
|
||||||
|
expect(result.username).toBe('newuser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('login: 존재하지 않는 사용자 → UnauthorizedException', async () => {
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.login({ username: 'unknown', password: 'password123' }),
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
101
backend/src/auth/auth.service.ts
Normal file
101
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Injectable, UnauthorizedException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import { IsString, IsNotEmpty, MinLength, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterDto {
|
||||||
|
@IsString() @IsNotEmpty() @MaxLength(32)
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@IsString() @MinLength(8) @MaxLength(64)
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@IsString() @IsNotEmpty()
|
||||||
|
inviteCode!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@IsString() @IsNotEmpty()
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@IsString() @IsNotEmpty()
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(dto: RegisterDto) {
|
||||||
|
const inviteCode = this.config.get<string>('INVITE_CODE');
|
||||||
|
if (!inviteCode) throw new BadRequestException('Invite code not configured');
|
||||||
|
if (dto.inviteCode !== inviteCode) throw new BadRequestException('Invalid invite code');
|
||||||
|
|
||||||
|
const existing = await this.prisma.user.findUnique({ where: { username: dto.username } });
|
||||||
|
if (existing) throw new ConflictException('Username already taken');
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(dto.password, 12);
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: { username: dto.username, password: hash },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.signTokens(user.id, user.username, user.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(dto: LoginDto) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { username: dto.username } });
|
||||||
|
if (!user) throw new UnauthorizedException('Invalid credentials');
|
||||||
|
|
||||||
|
const ok = await bcrypt.compare(dto.password, user.password);
|
||||||
|
if (!ok) throw new UnauthorizedException('Invalid credentials');
|
||||||
|
|
||||||
|
return this.signTokens(user.id, user.username, user.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(refreshToken: string) {
|
||||||
|
try {
|
||||||
|
const payload = this.jwt.verify<{ sub: number; username: string; role: string }>(
|
||||||
|
refreshToken,
|
||||||
|
{ secret: this.config.get<string>('JWT_SECRET') + '_refresh' },
|
||||||
|
);
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
|
||||||
|
if (!user) throw new UnauthorizedException();
|
||||||
|
return this.signTokens(user.id, user.username, user.role);
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMe(userId: number) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, username: true, role: true, createdAt: true },
|
||||||
|
});
|
||||||
|
if (!user) throw new UnauthorizedException();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private signTokens(userId: number, username: string, role: string) {
|
||||||
|
const secret = this.config.get<string>('JWT_SECRET');
|
||||||
|
if (!secret) throw new Error('JWT_SECRET is not configured');
|
||||||
|
const payload = { sub: userId, username, role };
|
||||||
|
|
||||||
|
const accessToken = this.jwt.sign(payload, {
|
||||||
|
secret,
|
||||||
|
expiresIn: '15m',
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshToken = this.jwt.sign(payload, {
|
||||||
|
secret: secret + '_refresh',
|
||||||
|
expiresIn: '7d',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { accessToken, refreshToken, username, role };
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/src/auth/jwt.guard.ts
Normal file
22
backend/src/auth/jwt.guard.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtGuard extends AuthGuard('jwt') {
|
||||||
|
handleRequest<T>(err: Error, user: T): T {
|
||||||
|
if (err || !user) throw new UnauthorizedException('JWT authentication required');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 먼저 시도 → 실패 시 API Key fallback
|
||||||
|
* Sprint 008까지 하위호환 유지
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CompositeGuard extends AuthGuard(['jwt', 'api-key']) {
|
||||||
|
handleRequest<T>(err: Error, user: T): T {
|
||||||
|
if (err || !user) throw new UnauthorizedException('Authentication required');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/src/auth/jwt.strategy.ts
Normal file
28
backend/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: number;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
const secret = config.get<string>('JWT_SECRET');
|
||||||
|
if (!secret) throw new Error('JWT_SECRET not set');
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: secret,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(payload: JwtPayload) {
|
||||||
|
if (!payload?.sub) throw new UnauthorizedException();
|
||||||
|
return { userId: payload.sub, username: payload.username, role: payload.role };
|
||||||
|
}
|
||||||
|
}
|
||||||
29
backend/src/auth/role.guard.ts
Normal file
29
backend/src/auth/role.guard.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RoleGuard implements CanActivate {
|
||||||
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!requiredRoles || requiredRoles.length === 0) return true;
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user) throw new ForbiddenException('Authentication required');
|
||||||
|
|
||||||
|
const hasRole = requiredRoles.includes(user.role);
|
||||||
|
if (!hasRole) throw new ForbiddenException(`Role '${user.role}' not authorized. Required: ${requiredRoles.join(', ')}`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/src/common/sister-name.pipe.ts
Normal file
16
backend/src/common/sister-name.pipe.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
const VALID_SISTER_NAMES = ['harang', 'narang', 'darang', 'erang'] as const;
|
||||||
|
export type SisterName = (typeof VALID_SISTER_NAMES)[number];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SisterNamePipe implements PipeTransform {
|
||||||
|
transform(value: string): SisterName {
|
||||||
|
if (!VALID_SISTER_NAMES.includes(value as SisterName)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid sister name. Must be one of: ${VALID_SISTER_NAMES.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value as SisterName;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/src/costs/costs.controller.ts
Normal file
25
backend/src/costs/costs.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Controller, Get, Post, Query, Param, UseGuards } from '@nestjs/common';
|
||||||
|
import { CostsService, CostPeriod } from './costs.service';
|
||||||
|
import { CompositeGuard } from '../auth/jwt.guard';
|
||||||
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
|
import { SisterNamePipe } from '../common/sister-name.pipe';
|
||||||
|
import type { SisterName } from '../common/sister-name.pipe';
|
||||||
|
|
||||||
|
@Controller('api/admin/costs')
|
||||||
|
@UseGuards(CompositeGuard, RoleGuard)
|
||||||
|
@Roles('admin')
|
||||||
|
export class CostsController {
|
||||||
|
constructor(private readonly costsService: CostsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getCosts(@Query('period') period?: string) {
|
||||||
|
const validPeriods: CostPeriod[] = ['day', 'week', 'month'];
|
||||||
|
const p = validPeriods.includes(period as CostPeriod) ? (period as CostPeriod) : 'week';
|
||||||
|
return this.costsService.getCosts(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('record/:name')
|
||||||
|
recordCosts(@Param('name', SisterNamePipe) name: SisterName) {
|
||||||
|
return this.costsService.recordCosts(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/costs/costs.module.ts
Normal file
13
backend/src/costs/costs.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CostsController } from './costs.controller';
|
||||||
|
import { CostsService } from './costs.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { SistersModule } from '../sisters/sisters.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, SistersModule, AuthModule],
|
||||||
|
controllers: [CostsController],
|
||||||
|
providers: [CostsService],
|
||||||
|
})
|
||||||
|
export class CostsModule {}
|
||||||
179
backend/src/costs/costs.service.ts
Normal file
179
backend/src/costs/costs.service.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SshService } from '../sisters/ssh.service';
|
||||||
|
import { SisterName } from '../common/sister-name.pipe';
|
||||||
|
|
||||||
|
// Claude Sonnet 기준 가격 (USD per 1M tokens)
|
||||||
|
const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
||||||
|
'claude-sonnet-4': { input: 3.0, output: 15.0 },
|
||||||
|
'claude-sonnet-4.6': { input: 3.0, output: 15.0 },
|
||||||
|
'claude-3-5-sonnet': { input: 3.0, output: 15.0 },
|
||||||
|
'gpt-4o': { input: 2.5, output: 10.0 },
|
||||||
|
'gpt-4o-mini': { input: 0.15, output: 0.6 },
|
||||||
|
default: { input: 3.0, output: 15.0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SISTER_NAMES: SisterName[] = ['harang', 'narang', 'darang', 'erang'];
|
||||||
|
|
||||||
|
export type CostPeriod = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CostsService {
|
||||||
|
private readonly logger = new Logger(CostsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly ssh: SshService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getCosts(period: CostPeriod = 'week') {
|
||||||
|
const since = this.getSince(period);
|
||||||
|
|
||||||
|
const [summary, bySister, byModel, timeline] = await Promise.all([
|
||||||
|
this.getSummary(since),
|
||||||
|
this.getBySister(since),
|
||||||
|
this.getByModel(since),
|
||||||
|
this.getTimeline(since, period),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { period, since: since.toISOString(), summary, bySister, byModel, timeline };
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordCosts(sisterName: SisterName) {
|
||||||
|
const keyPath = this.config.get<string>('SSH_KEY_PATH');
|
||||||
|
if (!keyPath) return;
|
||||||
|
|
||||||
|
const sister = await this.prisma.sisterConfig.findUnique({ where: { name: sisterName } });
|
||||||
|
if (!sister) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// OpenClaw 세션에서 토큰 사용량 파싱 (python3 → node.js → jq fallback)
|
||||||
|
const result = await this.ssh.executeCommand(
|
||||||
|
sister.ip,
|
||||||
|
sister.user,
|
||||||
|
keyPath,
|
||||||
|
`SESSION_FILE=~/.openclaw/sessions/main.json; \
|
||||||
|
if [ ! -f "$SESSION_FILE" ]; then echo "unknown 0 0"; \
|
||||||
|
elif command -v node >/dev/null 2>&1; then \
|
||||||
|
node -e "try{const d=require('fs').readFileSync(process.env.HOME+'/.openclaw/sessions/main.json','utf8');const j=JSON.parse(d);const u=j.usage||{};console.log((j.model||'unknown')+' '+(u.input_tokens||0)+' '+(u.output_tokens||0))}catch(e){console.log('unknown 0 0')}" 2>/dev/null; \
|
||||||
|
elif command -v python3 >/dev/null 2>&1; then \
|
||||||
|
cat $SESSION_FILE | python3 -c "import json,sys;d=json.load(sys.stdin);u=d.get('usage',{});print(d.get('model','unknown'),u.get('input_tokens',0),u.get('output_tokens',0))" 2>/dev/null; \
|
||||||
|
elif command -v jq >/dev/null 2>&1; then \
|
||||||
|
echo "$(jq -r '.model // "unknown"' $SESSION_FILE) $(jq -r '.usage.input_tokens // 0' $SESSION_FILE) $(jq -r '.usage.output_tokens // 0' $SESSION_FILE)" 2>/dev/null; \
|
||||||
|
else echo "unknown 0 0"; fi`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parts = result.stdout.trim().split(' ');
|
||||||
|
if (parts.length < 3) return;
|
||||||
|
|
||||||
|
const [model, inputStr, outputStr] = parts;
|
||||||
|
const inputTokens = parseInt(inputStr, 10) || 0;
|
||||||
|
const outputTokens = parseInt(outputStr, 10) || 0;
|
||||||
|
const totalTokens = inputTokens + outputTokens;
|
||||||
|
|
||||||
|
if (totalTokens === 0) return;
|
||||||
|
|
||||||
|
const pricing = MODEL_PRICING[model] ?? MODEL_PRICING.default;
|
||||||
|
const estimatedUsd =
|
||||||
|
(inputTokens / 1_000_000) * pricing.input +
|
||||||
|
(outputTokens / 1_000_000) * pricing.output;
|
||||||
|
|
||||||
|
await this.prisma.costLog.create({
|
||||||
|
data: {
|
||||||
|
sisterName,
|
||||||
|
model,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
totalTokens,
|
||||||
|
estimatedUsd,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(`Cost recording failed for ${sisterName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSummary(since: Date) {
|
||||||
|
const result = await this.prisma.costLog.aggregate({
|
||||||
|
where: { recordedAt: { gte: since } },
|
||||||
|
_sum: { inputTokens: true, outputTokens: true, totalTokens: true, estimatedUsd: true },
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTokens: result._sum.totalTokens ?? 0,
|
||||||
|
inputTokens: result._sum.inputTokens ?? 0,
|
||||||
|
outputTokens: result._sum.outputTokens ?? 0,
|
||||||
|
estimatedUsd: Math.round((result._sum.estimatedUsd ?? 0) * 10000) / 10000,
|
||||||
|
recordCount: result._count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBySister(since: Date) {
|
||||||
|
const rows = await this.prisma.costLog.groupBy({
|
||||||
|
by: ['sisterName'],
|
||||||
|
where: { recordedAt: { gte: since } },
|
||||||
|
_sum: { totalTokens: true, estimatedUsd: true },
|
||||||
|
orderBy: { _sum: { estimatedUsd: 'desc' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
name: r.sisterName,
|
||||||
|
totalTokens: r._sum.totalTokens ?? 0,
|
||||||
|
estimatedUsd: Math.round((r._sum.estimatedUsd ?? 0) * 10000) / 10000,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getByModel(since: Date) {
|
||||||
|
const rows = await this.prisma.costLog.groupBy({
|
||||||
|
by: ['model'],
|
||||||
|
where: { recordedAt: { gte: since } },
|
||||||
|
_sum: { totalTokens: true, estimatedUsd: true },
|
||||||
|
_count: true,
|
||||||
|
orderBy: { _sum: { estimatedUsd: 'desc' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
model: r.model,
|
||||||
|
totalTokens: r._sum.totalTokens ?? 0,
|
||||||
|
estimatedUsd: Math.round((r._sum.estimatedUsd ?? 0) * 10000) / 10000,
|
||||||
|
count: r._count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTimeline(since: Date, period: CostPeriod) {
|
||||||
|
const logs = await this.prisma.costLog.findMany({
|
||||||
|
where: { recordedAt: { gte: since } },
|
||||||
|
orderBy: { recordedAt: 'asc' },
|
||||||
|
select: { sisterName: true, totalTokens: true, estimatedUsd: true, recordedAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 날짜별 집계
|
||||||
|
const grouped: Record<string, { date: string; totalTokens: number; estimatedUsd: number }> = {};
|
||||||
|
|
||||||
|
for (const log of logs) {
|
||||||
|
const dateKey = log.recordedAt.toISOString().slice(0, 10);
|
||||||
|
if (!grouped[dateKey]) {
|
||||||
|
grouped[dateKey] = { date: dateKey, totalTokens: 0, estimatedUsd: 0 };
|
||||||
|
}
|
||||||
|
grouped[dateKey].totalTokens += log.totalTokens;
|
||||||
|
grouped[dateKey].estimatedUsd += log.estimatedUsd;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(grouped).map((d) => ({
|
||||||
|
...d,
|
||||||
|
estimatedUsd: Math.round(d.estimatedUsd * 10000) / 10000,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSince(period: CostPeriod): Date {
|
||||||
|
const now = new Date();
|
||||||
|
switch (period) {
|
||||||
|
case 'day': return new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
case 'week': return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
case 'month': return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
backend/src/events/events.gateway.ts
Normal file
91
backend/src/events/events.gateway.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
SubscribeMessage,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
OnGatewayInit,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@WebSocketGateway({
|
||||||
|
cors: {
|
||||||
|
origin: (process.env.CORS_ORIGINS ?? 'http://localhost:3004')
|
||||||
|
.split(',')
|
||||||
|
.map((o) => o.trim()),
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
namespace: '/ws',
|
||||||
|
})
|
||||||
|
export class EventsGateway
|
||||||
|
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
||||||
|
{
|
||||||
|
@WebSocketServer()
|
||||||
|
server!: Server;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(EventsGateway.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
afterInit() {
|
||||||
|
this.logger.log('WebSocket Gateway initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleConnection(client: Socket) {
|
||||||
|
// JWT 인증 필수 — 토큰 없거나 유효하지 않으면 disconnect
|
||||||
|
const token =
|
||||||
|
(client.handshake.auth?.token as string) ??
|
||||||
|
(client.handshake.headers.authorization as string)?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.logger.debug(`WS rejected (no token): ${client.id}`);
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = this.config.get<string>('JWT_SECRET');
|
||||||
|
if (!secret) {
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = this.jwtService.verify(token, { secret });
|
||||||
|
(client as any).user = payload;
|
||||||
|
this.logger.debug(`WS client connected: ${client.id} (${payload.username})`);
|
||||||
|
} catch {
|
||||||
|
this.logger.debug(`WS rejected (invalid token): ${client.id}`);
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: Socket) {
|
||||||
|
this.logger.debug(`Client disconnected: ${client.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('ping')
|
||||||
|
handlePing(client: Socket) {
|
||||||
|
client.emit('pong', { ts: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 자매 상태 전체 브로드캐스트 */
|
||||||
|
broadcastSisterStatus(sisters: unknown[]) {
|
||||||
|
this.server.emit('sisters:update', { sisters, ts: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 새 활동 로그 브로드캐스트 */
|
||||||
|
broadcastActivity(item: unknown) {
|
||||||
|
this.server.emit('activity:new', { item, ts: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 연결된 클라이언트 수 */
|
||||||
|
getClientCount(): number {
|
||||||
|
return this.server.sockets.sockets.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/src/events/events.module.ts
Normal file
23
backend/src/events/events.module.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { EventsGateway } from './events.gateway';
|
||||||
|
import { EventsScheduler } from './events.scheduler';
|
||||||
|
import { SistersModule } from '../sisters/sisters.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
SistersModule,
|
||||||
|
ConfigModule,
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: (() => { const s = config.get<string>('JWT_SECRET'); if (!s) throw new Error('JWT_SECRET is not configured'); return s; })(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [EventsGateway, EventsScheduler],
|
||||||
|
exports: [EventsGateway],
|
||||||
|
})
|
||||||
|
export class EventsModule {}
|
||||||
35
backend/src/events/events.scheduler.ts
Normal file
35
backend/src/events/events.scheduler.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { SistersService } from '../sisters/sisters.service';
|
||||||
|
import { EventsGateway } from './events.gateway';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EventsScheduler implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(EventsScheduler.name);
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
private readonly INTERVAL_MS = parseInt(process.env.WS_POLL_INTERVAL_MS ?? '30000', 10);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly sistersService: SistersService,
|
||||||
|
private readonly gateway: EventsGateway,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.timer = setInterval(() => this.tick(), this.INTERVAL_MS);
|
||||||
|
this.logger.log(`WebSocket scheduler started (interval: ${this.INTERVAL_MS}ms)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tick() {
|
||||||
|
if (this.gateway.getClientCount() === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sisters = await this.sistersService.getAllSistersStatus();
|
||||||
|
this.gateway.broadcastSisterStatus(sisters);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`Scheduler tick failed: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
backend/src/gitea-sync/gitea-sync.controller.ts
Normal file
21
backend/src/gitea-sync/gitea-sync.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Controller, Post, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import { GiteaSyncService } from './gitea-sync.service';
|
||||||
|
import { CompositeGuard } from '../auth/jwt.guard';
|
||||||
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
|
|
||||||
|
@Controller('api/admin/gitea')
|
||||||
|
@UseGuards(CompositeGuard, RoleGuard)
|
||||||
|
@Roles('admin')
|
||||||
|
export class GiteaSyncController {
|
||||||
|
constructor(private readonly giteaSyncService: GiteaSyncService) {}
|
||||||
|
|
||||||
|
@Post('sync')
|
||||||
|
sync() {
|
||||||
|
return this.giteaSyncService.syncRepos();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
status() {
|
||||||
|
return { lastSyncAt: this.giteaSyncService.getLastSyncAt() };
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/src/gitea-sync/gitea-sync.module.ts
Normal file
15
backend/src/gitea-sync/gitea-sync.module.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { GiteaSyncController } from './gitea-sync.controller';
|
||||||
|
import { GiteaSyncService } from './gitea-sync.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { GiteaModule } from '../gitea/gitea.module';
|
||||||
|
import { ActivityModule } from '../activity/activity.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, GiteaModule, ActivityModule, AuthModule],
|
||||||
|
controllers: [GiteaSyncController],
|
||||||
|
providers: [GiteaSyncService],
|
||||||
|
exports: [GiteaSyncService],
|
||||||
|
})
|
||||||
|
export class GiteaSyncModule {}
|
||||||
79
backend/src/gitea-sync/gitea-sync.service.ts
Normal file
79
backend/src/gitea-sync/gitea-sync.service.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { GiteaService } from '../gitea/gitea.service';
|
||||||
|
import { ActivityService } from '../activity/activity.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GiteaSyncService {
|
||||||
|
private readonly logger = new Logger(GiteaSyncService.name);
|
||||||
|
private lastSyncAt: Date | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly gitea: GiteaService,
|
||||||
|
private readonly activity: ActivityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async syncRepos(): Promise<{ synced: number; created: number; updated: number }> {
|
||||||
|
const repos = await this.gitea.getOrgRepos();
|
||||||
|
|
||||||
|
if (!repos.length) {
|
||||||
|
this.logger.warn('Gitea returned 0 repos (unavailable or empty org)');
|
||||||
|
return { synced: 0, created: 0, updated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
for (const repo of repos) {
|
||||||
|
const existing = await this.prisma.project.findUnique({
|
||||||
|
where: { giteaId: repo.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await this.prisma.project.create({
|
||||||
|
data: {
|
||||||
|
giteaId: repo.id,
|
||||||
|
name: repo.name,
|
||||||
|
repoUrl: repo.html_url,
|
||||||
|
description: repo.description ?? null,
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
|
||||||
|
await this.activity.log({
|
||||||
|
action: 'gitea_sync_new_repo',
|
||||||
|
detail: `New project synced from Gitea: [${repo.name}]`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.prisma.project.update({
|
||||||
|
where: { giteaId: repo.id },
|
||||||
|
data: {
|
||||||
|
name: repo.name,
|
||||||
|
repoUrl: repo.html_url,
|
||||||
|
description: repo.description ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastSyncAt = new Date();
|
||||||
|
|
||||||
|
if (created > 0 || updated > 0) {
|
||||||
|
await this.activity.log({
|
||||||
|
action: 'gitea_sync_complete',
|
||||||
|
detail: `Gitea sync: ${created} created, ${updated} updated`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Gitea sync: ${repos.length} repos, ${created} new, ${updated} updated`);
|
||||||
|
return { synced: repos.length, created, updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastSyncAt() {
|
||||||
|
return this.lastSyncAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/gitea/gitea.module.ts
Normal file
10
backend/src/gitea/gitea.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { GiteaService } from './gitea.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
providers: [GiteaService],
|
||||||
|
exports: [GiteaService],
|
||||||
|
})
|
||||||
|
export class GiteaModule {}
|
||||||
148
backend/src/gitea/gitea.service.ts
Normal file
148
backend/src/gitea/gitea.service.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
export interface GiteaRepo {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
full_name: string;
|
||||||
|
description: string;
|
||||||
|
html_url: string;
|
||||||
|
default_branch: string;
|
||||||
|
open_issues_count: number;
|
||||||
|
open_pr_counter: number;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaPR {
|
||||||
|
id: number;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
html_url: string;
|
||||||
|
user: { login: string };
|
||||||
|
created_at: string;
|
||||||
|
merged: boolean;
|
||||||
|
merged_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaCommit {
|
||||||
|
sha: string;
|
||||||
|
commit: {
|
||||||
|
message: string;
|
||||||
|
author: { name: string; date: string };
|
||||||
|
};
|
||||||
|
author?: { login: string; avatar_url: string } | null;
|
||||||
|
html_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaBranch {
|
||||||
|
name: string;
|
||||||
|
commit: { id: string; created: string };
|
||||||
|
protected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GiteaService {
|
||||||
|
private readonly logger = new Logger(GiteaService.name);
|
||||||
|
private readonly client: AxiosInstance;
|
||||||
|
private readonly org: string;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {
|
||||||
|
const baseURL = config.get<string>('GITEA_BASE_URL');
|
||||||
|
const token = config.get<string>('GITEA_TOKEN');
|
||||||
|
this.org = config.get<string>('GITEA_ORG') ?? 'hanarang';
|
||||||
|
|
||||||
|
if (!baseURL || !token) {
|
||||||
|
this.logger.warn('GITEA_BASE_URL or GITEA_TOKEN not set — Gitea features disabled');
|
||||||
|
this.client = null as any;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL: `${baseURL}/api/v1`,
|
||||||
|
headers: { Authorization: `token ${token}` },
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAvailable(): boolean {
|
||||||
|
return this.client !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrgRepos(): Promise<GiteaRepo[]> {
|
||||||
|
if (!this.isAvailable()) return [];
|
||||||
|
try {
|
||||||
|
const { data } = await this.client.get<GiteaRepo[]>(`/orgs/${this.org}/repos`, {
|
||||||
|
params: { limit: 50 },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn('Failed to fetch Gitea repos');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRepo(repoName: string): Promise<GiteaRepo | null> {
|
||||||
|
if (!this.isAvailable()) return null;
|
||||||
|
try {
|
||||||
|
const { data } = await this.client.get<GiteaRepo>(`/repos/${this.org}/${repoName}`);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpenPRs(repoName: string): Promise<GiteaPR[]> {
|
||||||
|
if (!this.isAvailable()) return [];
|
||||||
|
try {
|
||||||
|
const { data } = await this.client.get<GiteaPR[]>(
|
||||||
|
`/repos/${this.org}/${repoName}/pulls`,
|
||||||
|
{ params: { state: 'open', limit: 20 } },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommits(repoName: string, limit = 20): Promise<GiteaCommit[]> {
|
||||||
|
if (!this.isAvailable()) return [];
|
||||||
|
try {
|
||||||
|
const { data } = await this.client.get<GiteaCommit[]>(
|
||||||
|
`/repos/${this.org}/${repoName}/commits`,
|
||||||
|
{ params: { limit } },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(`Failed to fetch commits for ${repoName}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBranches(repoName: string): Promise<GiteaBranch[]> {
|
||||||
|
if (!this.isAvailable()) return [];
|
||||||
|
try {
|
||||||
|
const { data } = await this.client.get<GiteaBranch[]>(
|
||||||
|
`/repos/${this.org}/${repoName}/branches`,
|
||||||
|
{ params: { limit: 50 } },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPulls(repoName: string, state: 'open' | 'closed' | 'all' = 'open'): Promise<GiteaPR[]> {
|
||||||
|
if (!this.isAvailable()) return [];
|
||||||
|
try {
|
||||||
|
const { data } = await this.client.get<GiteaPR[]>(
|
||||||
|
`/repos/${this.org}/${repoName}/pulls`,
|
||||||
|
{ params: { state, limit: 30, type: 'pulls' } },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,34 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
app.enableCors();
|
|
||||||
await app.listen(process.env.BACKEND_PORT ?? 3005);
|
app.use(helmet());
|
||||||
console.log(`🚀 Backend running on port ${process.env.BACKEND_PORT ?? 3005}`);
|
|
||||||
|
const allowedOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:3004')
|
||||||
|
.split(',')
|
||||||
|
.map((o) => o.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: (origin, callback) => {
|
||||||
|
if (!origin || allowedOrigins.includes(origin)) {
|
||||||
|
callback(null, true);
|
||||||
|
} else {
|
||||||
|
callback(new Error(`CORS: ${origin} not allowed`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));
|
||||||
|
|
||||||
|
const port = process.env.BACKEND_PORT ?? 3005;
|
||||||
|
await app.listen(port);
|
||||||
|
Logger.log(`🚀 Backend running on port ${port}`, 'Bootstrap');
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
12
backend/src/org/org.controller.ts
Normal file
12
backend/src/org/org.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { SisterDetailService } from '../sisters/sister-detail.service';
|
||||||
|
|
||||||
|
@Controller('api/org')
|
||||||
|
export class OrgController {
|
||||||
|
constructor(private readonly sisterDetail: SisterDetailService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getOrgData() {
|
||||||
|
return this.sisterDetail.getOrgData();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/org/org.module.ts
Normal file
9
backend/src/org/org.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { OrgController } from './org.controller';
|
||||||
|
import { SistersModule } from '../sisters/sisters.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [SistersModule],
|
||||||
|
controllers: [OrgController],
|
||||||
|
})
|
||||||
|
export class OrgModule {}
|
||||||
@@ -6,7 +6,10 @@ import { PrismaMariaDb } from '@prisma/adapter-mariadb';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
constructor(config: ConfigService) {
|
constructor(config: ConfigService) {
|
||||||
const dbUrl = config.get<string>('DATABASE_URL') ?? '';
|
const dbUrl = config.get<string>('DATABASE_URL');
|
||||||
|
if (!dbUrl) {
|
||||||
|
throw new Error('DATABASE_URL is not set. Check your .env file.');
|
||||||
|
}
|
||||||
const adapter = new PrismaMariaDb(dbUrl);
|
const adapter = new PrismaMariaDb(dbUrl);
|
||||||
super({ adapter });
|
super({ adapter });
|
||||||
}
|
}
|
||||||
|
|||||||
42
backend/src/projects/projects.controller.ts
Normal file
42
backend/src/projects/projects.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Controller, Get, Param, Query, ParseIntPipe, UseGuards, Post } from '@nestjs/common';
|
||||||
|
import { ProjectsService } from './projects.service';
|
||||||
|
import { CompositeGuard } from '../auth/jwt.guard';
|
||||||
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
|
|
||||||
|
@Controller('api/projects')
|
||||||
|
export class ProjectsController {
|
||||||
|
constructor(private readonly projectsService: ProjectsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getProjects() {
|
||||||
|
return this.projectsService.getProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
getProjectById(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.projectsService.getProjectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/commits')
|
||||||
|
getCommits(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
) {
|
||||||
|
const parsed = limit ? parseInt(limit, 10) : 20;
|
||||||
|
const clamped = Math.min(Math.max(isNaN(parsed) ? 20 : parsed, 1), 100);
|
||||||
|
return this.projectsService.getProjectCommits(id, clamped);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/branches')
|
||||||
|
getBranches(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.projectsService.getProjectBranches(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/pulls')
|
||||||
|
getPulls(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Query('state') state?: 'open' | 'closed' | 'all',
|
||||||
|
) {
|
||||||
|
return this.projectsService.getProjectPulls(id, state ?? 'open');
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/projects/projects.module.ts
Normal file
13
backend/src/projects/projects.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ProjectsController } from './projects.controller';
|
||||||
|
import { ProjectsService } from './projects.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { GiteaModule } from '../gitea/gitea.module';
|
||||||
|
import { ActivityModule } from '../activity/activity.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, GiteaModule, ActivityModule],
|
||||||
|
controllers: [ProjectsController],
|
||||||
|
providers: [ProjectsService],
|
||||||
|
})
|
||||||
|
export class ProjectsModule {}
|
||||||
106
backend/src/projects/projects.service.ts
Normal file
106
backend/src/projects/projects.service.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { GiteaService } from '../gitea/gitea.service';
|
||||||
|
import { ActivityService } from '../activity/activity.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectsService {
|
||||||
|
private readonly logger = new Logger(ProjectsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly gitea: GiteaService,
|
||||||
|
private readonly activity: ActivityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getProjects() {
|
||||||
|
// DB에 등록된 프로젝트 + Gitea 최신 정보 병합
|
||||||
|
const dbProjects = await this.prisma.project.findMany({
|
||||||
|
include: { sprints: { include: { tasks: true } } },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let giteaRepos: Record<number, { openPRs: number; updatedAt: string }> = {};
|
||||||
|
try {
|
||||||
|
const repos = await this.gitea.getOrgRepos();
|
||||||
|
for (const repo of repos) {
|
||||||
|
giteaRepos[repo.id] = {
|
||||||
|
openPRs: repo.open_pr_counter,
|
||||||
|
updatedAt: repo.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.logger.warn('Gitea unavailable, using DB data only');
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbProjects.map((p) => {
|
||||||
|
const totalTasks = p.sprints.flatMap((s) => s.tasks).length;
|
||||||
|
const doneTasks = p.sprints.flatMap((s) => s.tasks).filter((t) => t.status === 'done').length;
|
||||||
|
const gitea = giteaRepos[p.giteaId];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
giteaId: p.giteaId,
|
||||||
|
name: p.name,
|
||||||
|
repoUrl: p.repoUrl,
|
||||||
|
description: p.description,
|
||||||
|
status: p.status,
|
||||||
|
progress: totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0,
|
||||||
|
totalTasks,
|
||||||
|
doneTasks,
|
||||||
|
openPRs: gitea?.openPRs ?? 0,
|
||||||
|
updatedAt: gitea?.updatedAt ?? p.updatedAt.toISOString(),
|
||||||
|
sprintCount: p.sprints.length,
|
||||||
|
currentSprint: p.sprints.find((s) => s.status === 'in_progress')?.name ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectById(id: number) {
|
||||||
|
const project = await this.prisma.project.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
sprints: {
|
||||||
|
include: { tasks: { orderBy: { taskId: 'asc' } } },
|
||||||
|
orderBy: { number: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) throw new NotFoundException(`Project ${id} not found`);
|
||||||
|
|
||||||
|
let giteaPRs: ReturnType<typeof Array.prototype.map> = [];
|
||||||
|
try {
|
||||||
|
const repoName = project.repoUrl.split('/').pop() ?? '';
|
||||||
|
giteaPRs = await this.gitea.getOpenPRs(repoName);
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(`Gitea PR fetch failed for project ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...project, openPRs: giteaPRs };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectCommits(id: number, limit = 20) {
|
||||||
|
const project = await this.getProjectMeta(id);
|
||||||
|
const repoName = project.repoUrl.split('/').pop() ?? '';
|
||||||
|
return this.gitea.getCommits(repoName, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectBranches(id: number) {
|
||||||
|
const project = await this.getProjectMeta(id);
|
||||||
|
const repoName = project.repoUrl.split('/').pop() ?? '';
|
||||||
|
return this.gitea.getBranches(repoName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjectPulls(id: number, state: 'open' | 'closed' | 'all' = 'open') {
|
||||||
|
const project = await this.getProjectMeta(id);
|
||||||
|
const repoName = project.repoUrl.split('/').pop() ?? '';
|
||||||
|
return this.gitea.getPulls(repoName, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getProjectMeta(id: number) {
|
||||||
|
const project = await this.prisma.project.findUnique({ where: { id }, select: { id: true, repoUrl: true } });
|
||||||
|
if (!project) throw new NotFoundException(`Project ${id} not found`);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
backend/src/sisters/sister-detail.service.spec.ts
Normal file
90
backend/src/sisters/sister-detail.service.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { SisterDetailService } from './sister-detail.service';
|
||||||
|
import { SshService } from './ssh.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('SisterDetailService', () => {
|
||||||
|
let service: SisterDetailService;
|
||||||
|
|
||||||
|
const mockSister = {
|
||||||
|
id: 2, name: 'narang', ip: '10.10.10.216', user: 'narang',
|
||||||
|
lxcId: 105, status: 'online', lastSeen: new Date(),
|
||||||
|
sshKeyPath: null, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
sisterConfig: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
findMany: jest.fn().mockResolvedValue([mockSister]),
|
||||||
|
},
|
||||||
|
activityLog: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSsh = { executeCommand: jest.fn() };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
SisterDetailService,
|
||||||
|
{ provide: SshService, useValue: mockSsh },
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: { get: jest.fn().mockReturnValue('/home/narang/.ssh/id_rsa') },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<SisterDetailService>(SisterDetailService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSisterConfig: SSH 실패 시 fallback 반환', async () => {
|
||||||
|
mockPrisma.sisterConfig.findUnique.mockResolvedValue(mockSister);
|
||||||
|
mockSsh.executeCommand.mockRejectedValue(new Error('timeout'));
|
||||||
|
|
||||||
|
const result = await service.getSisterConfig('narang');
|
||||||
|
|
||||||
|
expect(result.name).toBe('narang');
|
||||||
|
expect(result.role).toBe('Generator');
|
||||||
|
expect(result.raw).toContain('SSH 연결 불가');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSisterConfig: sister 없으면 NotFoundException', async () => {
|
||||||
|
mockPrisma.sisterConfig.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.getSisterConfig('narang')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSisterSessions: SSH 실패 시 빈 배열', async () => {
|
||||||
|
mockPrisma.sisterConfig.findUnique.mockResolvedValue(mockSister);
|
||||||
|
mockSsh.executeCommand.mockRejectedValue(new Error('timeout'));
|
||||||
|
|
||||||
|
const result = await service.getSisterSessions('narang');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getOrgData: 파이프라인 포함 조직도 반환', async () => {
|
||||||
|
const result = await service.getOrgData();
|
||||||
|
|
||||||
|
expect(result.owner.name).toBe('자기야');
|
||||||
|
expect(result.sisters).toHaveLength(1);
|
||||||
|
expect(result.pipeline.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSisterActivityLog: sisterId 필터 적용', async () => {
|
||||||
|
mockPrisma.sisterConfig.findUnique.mockResolvedValue(mockSister);
|
||||||
|
mockPrisma.activityLog.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.getSisterActivityLog('narang');
|
||||||
|
|
||||||
|
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { sisterId: mockSister.id } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
153
backend/src/sisters/sister-detail.service.ts
Normal file
153
backend/src/sisters/sister-detail.service.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SshService } from './ssh.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
const SISTER_NAMES = ['harang', 'narang', 'darang', 'erang'] as const;
|
||||||
|
type SisterName = (typeof SISTER_NAMES)[number];
|
||||||
|
|
||||||
|
const SISTER_ROLES: Record<SisterName, string> = {
|
||||||
|
harang: 'Orchestrator',
|
||||||
|
narang: 'Generator',
|
||||||
|
darang: 'Evaluator',
|
||||||
|
erang: 'Infra Manager',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SISTER_DESCRIPTIONS: Record<SisterName, string> = {
|
||||||
|
harang: '기획과 오케스트레이션. 스프린트를 설계하고 자매들에게 작업을 배분한다.',
|
||||||
|
narang: '코드 생성과 개발. 스프린트를 구현하고 PR을 만든다.',
|
||||||
|
darang: '품질 검증. 코드 리뷰와 보안 감사를 수행한다.',
|
||||||
|
erang: '인프라와 배포. 서버 관리, merge, 프로덕션 배포를 담당한다.',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SisterDetailService {
|
||||||
|
private readonly logger = new Logger(SisterDetailService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly ssh: SshService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getSisterConfig(name: SisterName) {
|
||||||
|
const sister = await this.getSisterRecord(name);
|
||||||
|
const sshKeyPath = this.getKeyPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.ssh.executeCommand(
|
||||||
|
sister.ip,
|
||||||
|
sister.user,
|
||||||
|
sshKeyPath,
|
||||||
|
'cat ~/.openclaw/workspace/SOUL.md 2>/dev/null | head -60; echo "---AGENTS---"; cat ~/.openclaw/workspace/AGENTS.md 2>/dev/null | head -40',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
role: SISTER_ROLES[name],
|
||||||
|
description: SISTER_DESCRIPTIONS[name],
|
||||||
|
raw: result.stdout || '(설정 파일 없음)',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
role: SISTER_ROLES[name],
|
||||||
|
description: SISTER_DESCRIPTIONS[name],
|
||||||
|
raw: '(SSH 연결 불가)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSisterSessions(name: SisterName) {
|
||||||
|
const sister = await this.getSisterRecord(name);
|
||||||
|
const sshKeyPath = this.getKeyPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.ssh.executeCommand(
|
||||||
|
sister.ip,
|
||||||
|
sister.user,
|
||||||
|
sshKeyPath,
|
||||||
|
'ls -lt ~/.openclaw/sessions/ 2>/dev/null | head -20 || echo ""',
|
||||||
|
);
|
||||||
|
|
||||||
|
const lines = result.stdout.split('\n').filter((l) => l && !l.startsWith('total'));
|
||||||
|
return lines.map((line) => {
|
||||||
|
const parts = line.trim().split(/\s+/);
|
||||||
|
return {
|
||||||
|
name: parts[parts.length - 1] ?? '',
|
||||||
|
modified: parts.slice(5, 8).join(' '),
|
||||||
|
size: parts[4] ?? '0',
|
||||||
|
};
|
||||||
|
}).filter((s) => s.name && s.name !== '');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSisterSubagents(name: SisterName) {
|
||||||
|
const sister = await this.getSisterRecord(name);
|
||||||
|
const sshKeyPath = this.getKeyPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.ssh.executeCommand(
|
||||||
|
sister.ip,
|
||||||
|
sister.user,
|
||||||
|
sshKeyPath,
|
||||||
|
'ls ~/.openclaw/workspace/agents/ 2>/dev/null || echo ""',
|
||||||
|
);
|
||||||
|
|
||||||
|
const agents = result.stdout.split('\n').filter((l) => l.trim().endsWith('.md'));
|
||||||
|
return agents.map((a) => ({ name: a.trim().replace('.md', ''), file: a.trim() }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSisterActivityLog(name: SisterName) {
|
||||||
|
const sister = await this.getSisterRecord(name);
|
||||||
|
const [logs, total] = await Promise.all([
|
||||||
|
this.prisma.activityLog.findMany({
|
||||||
|
where: { sisterId: sister.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
}),
|
||||||
|
this.prisma.activityLog.count({ where: { sisterId: sister.id } }),
|
||||||
|
]);
|
||||||
|
return { items: logs, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrgData() {
|
||||||
|
const sisters = await this.prisma.sisterConfig.findMany();
|
||||||
|
return {
|
||||||
|
owner: { name: '자기야', role: 'Owner' },
|
||||||
|
sisters: sisters.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
role: SISTER_ROLES[s.name as SisterName] ?? 'Unknown',
|
||||||
|
description: SISTER_DESCRIPTIONS[s.name as SisterName] ?? '',
|
||||||
|
status: s.status,
|
||||||
|
lastSeen: s.lastSeen,
|
||||||
|
lxcId: s.lxcId,
|
||||||
|
})),
|
||||||
|
pipeline: [
|
||||||
|
{ from: 'harang', to: 'narang', label: '핸드오프' },
|
||||||
|
{ from: 'narang', to: 'darang', label: '리뷰 요청' },
|
||||||
|
{ from: 'darang', to: 'narang', label: '수정 요청' },
|
||||||
|
{ from: 'darang', to: 'harang', label: '검증 완료' },
|
||||||
|
{ from: 'harang', to: 'erang', label: '배포 요청' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSisterRecord(name: SisterName) {
|
||||||
|
const sister = await this.prisma.sisterConfig.findUnique({ where: { name } });
|
||||||
|
if (!sister) throw new NotFoundException(`Sister ${name} not found`);
|
||||||
|
return sister;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getKeyPath(): string {
|
||||||
|
const p = this.config.get<string>('SSH_KEY_PATH');
|
||||||
|
if (!p) throw new Error('SSH_KEY_PATH is not set');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,38 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get, Param } from '@nestjs/common';
|
||||||
import { SistersService } from './sisters.service';
|
import { SistersService } from './sisters.service';
|
||||||
|
import { SisterDetailService } from './sister-detail.service';
|
||||||
|
import { SisterNamePipe } from '../common/sister-name.pipe';
|
||||||
|
import type { SisterName } from '../common/sister-name.pipe';
|
||||||
|
|
||||||
@Controller('api/sisters')
|
@Controller('api/sisters')
|
||||||
export class SistersController {
|
export class SistersController {
|
||||||
constructor(private readonly sistersService: SistersService) {}
|
constructor(
|
||||||
|
private readonly sistersService: SistersService,
|
||||||
|
private readonly sisterDetail: SisterDetailService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async getSistersStatus() {
|
async getSistersStatus() {
|
||||||
return this.sistersService.getAllSistersStatus();
|
return this.sistersService.getAllSistersStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':name/config')
|
||||||
|
async getSisterConfig(@Param('name', SisterNamePipe) name: SisterName) {
|
||||||
|
return this.sisterDetail.getSisterConfig(name as SisterName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':name/sessions')
|
||||||
|
async getSisterSessions(@Param('name', SisterNamePipe) name: SisterName) {
|
||||||
|
return this.sisterDetail.getSisterSessions(name as SisterName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':name/subagents')
|
||||||
|
async getSisterSubagents(@Param('name', SisterNamePipe) name: SisterName) {
|
||||||
|
return this.sisterDetail.getSisterSubagents(name as SisterName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':name/activity')
|
||||||
|
async getSisterActivity(@Param('name', SisterNamePipe) name: SisterName) {
|
||||||
|
return this.sisterDetail.getSisterActivityLog(name as SisterName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { SistersController } from './sisters.controller';
|
import { SistersController } from './sisters.controller';
|
||||||
import { SistersService } from './sisters.service';
|
import { SistersService } from './sisters.service';
|
||||||
|
import { SisterDetailService } from './sister-detail.service';
|
||||||
import { SshService } from './ssh.service';
|
import { SshService } from './ssh.service';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
controllers: [SistersController],
|
controllers: [SistersController],
|
||||||
providers: [SistersService, SshService],
|
providers: [SistersService, SisterDetailService, SshService],
|
||||||
|
exports: [SistersService, SisterDetailService, SshService],
|
||||||
})
|
})
|
||||||
export class SistersModule {}
|
export class SistersModule {}
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ describe('SistersService', () => {
|
|||||||
{ provide: PrismaService, useValue: mockPrisma },
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
{
|
{
|
||||||
provide: ConfigService,
|
provide: ConfigService,
|
||||||
useValue: { get: jest.fn().mockReturnValue('/home/narang/.ssh/id_rsa') },
|
useValue: { get: jest.fn().mockImplementation((key: string) => {
|
||||||
|
if (key === 'SSH_KEY_PATH') return '/home/narang/.ssh/id_rsa';
|
||||||
|
if (key === 'DATABASE_URL') return 'mysql://test:test@localhost:3306/test';
|
||||||
|
return undefined;
|
||||||
|
}) },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@@ -54,6 +58,7 @@ describe('SistersService', () => {
|
|||||||
expect(result[0].status).toBe('online');
|
expect(result[0].status).toBe('online');
|
||||||
expect(result[0].name).toBe('harang');
|
expect(result[0].name).toBe('harang');
|
||||||
expect(result[0].role).toBe('Orchestrator');
|
expect(result[0].role).toBe('Orchestrator');
|
||||||
|
expect((result[0] as any).ip).toBeUndefined(); // IP 노출 제거 확인
|
||||||
});
|
});
|
||||||
|
|
||||||
it('SSH 실패 시 offline graceful fallback', async () => {
|
it('SSH 실패 시 offline graceful fallback', async () => {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, Optional } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SshService } from './ssh.service';
|
import { SshService } from './ssh.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { ActivityService } from '../activity/activity.service';
|
||||||
|
|
||||||
export interface SisterStatus {
|
export interface SisterStatus {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
|
||||||
user: string;
|
user: string;
|
||||||
lxcId: number;
|
lxcId: number;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -30,11 +30,15 @@ export class SistersService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly ssh: SshService,
|
private readonly ssh: SshService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
@Optional() private readonly activity?: ActivityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getAllSistersStatus(): Promise<SisterStatus[]> {
|
async getAllSistersStatus(): Promise<SisterStatus[]> {
|
||||||
const sisters = await this.prisma.sisterConfig.findMany();
|
const sisters = await this.prisma.sisterConfig.findMany();
|
||||||
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH') ?? '/home/narang/.ssh/id_rsa';
|
const sshKeyPath = this.config.get<string>('SSH_KEY_PATH');
|
||||||
|
if (!sshKeyPath) {
|
||||||
|
throw new Error('SSH_KEY_PATH is not set. Check your .env file.');
|
||||||
|
}
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
sisters.map((sister) => this.checkSisterStatus(sister, sshKeyPath)),
|
sisters.map((sister) => this.checkSisterStatus(sister, sshKeyPath)),
|
||||||
@@ -49,7 +53,6 @@ export class SistersService {
|
|||||||
return {
|
return {
|
||||||
id: sister.id,
|
id: sister.id,
|
||||||
name: sister.name,
|
name: sister.name,
|
||||||
ip: sister.ip,
|
|
||||||
user: sister.user,
|
user: sister.user,
|
||||||
lxcId: sister.lxcId,
|
lxcId: sister.lxcId,
|
||||||
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
||||||
@@ -61,7 +64,7 @@ export class SistersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkSisterStatus(
|
private async checkSisterStatus(
|
||||||
sister: { id: number; name: string; ip: string; user: string; lxcId: number; lastSeen: Date | null },
|
sister: { id: number; name: string; ip: string; user: string; lxcId: number; lastSeen: Date | null; status: string },
|
||||||
sshKeyPath: string,
|
sshKeyPath: string,
|
||||||
): Promise<SisterStatus> {
|
): Promise<SisterStatus> {
|
||||||
try {
|
try {
|
||||||
@@ -76,17 +79,25 @@ export class SistersService {
|
|||||||
const status: 'online' | 'offline' = isActive ? 'online' : 'offline';
|
const status: 'online' | 'offline' = isActive ? 'online' : 'offline';
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
// 온라인이면 lastSeen 업데이트
|
// 온라인이면 lastSeen 업데이트 + 상태 변경 감지
|
||||||
|
const prevStatus = sister.status;
|
||||||
await this.prisma.sisterConfig.update({
|
await this.prisma.sisterConfig.update({
|
||||||
where: { id: sister.id },
|
where: { id: sister.id },
|
||||||
data: { lastSeen: new Date(), status },
|
data: { lastSeen: new Date(), status },
|
||||||
});
|
});
|
||||||
|
// 상태 변경 시 ActivityLog 기록
|
||||||
|
if (prevStatus !== status && this.activity) {
|
||||||
|
await this.activity.log({
|
||||||
|
sisterId: sister.id,
|
||||||
|
action: 'status_changed',
|
||||||
|
detail: `[${sister.name}] status: ${prevStatus} → ${status}`,
|
||||||
|
}).catch(() => {}); // 비동기 오류 무시
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: sister.id,
|
id: sister.id,
|
||||||
name: sister.name,
|
name: sister.name,
|
||||||
ip: sister.ip,
|
|
||||||
user: sister.user,
|
user: sister.user,
|
||||||
lxcId: sister.lxcId,
|
lxcId: sister.lxcId,
|
||||||
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
||||||
@@ -95,11 +106,10 @@ export class SistersService {
|
|||||||
currentTask: null,
|
currentTask: null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
this.logger.warn(`Failed to check status for ${sister.name} (${sister.ip})`);
|
this.logger.warn(`Failed to check status for ${sister.name}`);
|
||||||
return {
|
return {
|
||||||
id: sister.id,
|
id: sister.id,
|
||||||
name: sister.name,
|
name: sister.name,
|
||||||
ip: sister.ip,
|
|
||||||
user: sister.user,
|
user: sister.user,
|
||||||
lxcId: sister.lxcId,
|
lxcId: sister.lxcId,
|
||||||
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
role: SISTER_ROLES[sister.name] ?? 'Unknown',
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class SshService {
|
|||||||
host,
|
host,
|
||||||
username,
|
username,
|
||||||
privateKeyPath,
|
privateKeyPath,
|
||||||
readyTimeout: 5000,
|
readyTimeout: parseInt(process.env.SSH_TIMEOUT_MS ?? '5000', 10),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await ssh.execCommand(command);
|
const result = await ssh.execCommand(command);
|
||||||
@@ -34,8 +34,11 @@ export class SshService {
|
|||||||
code: result.code,
|
code: result.code,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(`SSH connection failed to ${host}: ${(error as Error).message}`);
|
// 민감정보 마스킹 — host/user만 노출, 에러 메시지 제한
|
||||||
throw error;
|
const msg = error instanceof Error ? error.message : 'unknown';
|
||||||
|
const safeMsg = msg.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '[IP]');
|
||||||
|
this.logger.warn(`SSH failed [${username}@host]: ${safeMsg}`);
|
||||||
|
throw new Error(`SSH connection failed for ${username}`);
|
||||||
} finally {
|
} finally {
|
||||||
ssh.dispose();
|
ssh.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
28
backend/src/tasks/tasks.controller.ts
Normal file
28
backend/src/tasks/tasks.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Controller, Get, Post, Patch, Param, Body, ParseIntPipe } from '@nestjs/common';
|
||||||
|
import { TasksService, CreateSprintDto, UpdateTaskDto } from './tasks.service';
|
||||||
|
|
||||||
|
@Controller('api')
|
||||||
|
export class TasksController {
|
||||||
|
constructor(private readonly tasksService: TasksService) {}
|
||||||
|
|
||||||
|
@Get('projects/:id/tasks')
|
||||||
|
getTaskLedger(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.tasksService.getTaskLedger(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('projects/:id/sprints')
|
||||||
|
createSprint(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: CreateSprintDto,
|
||||||
|
) {
|
||||||
|
return this.tasksService.createSprint(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('tasks/:id')
|
||||||
|
updateTask(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: UpdateTaskDto,
|
||||||
|
) {
|
||||||
|
return this.tasksService.updateTask(id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/tasks/tasks.module.ts
Normal file
12
backend/src/tasks/tasks.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TasksController } from './tasks.controller';
|
||||||
|
import { TasksService } from './tasks.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { ActivityModule } from '../activity/activity.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, ActivityModule],
|
||||||
|
controllers: [TasksController],
|
||||||
|
providers: [TasksService],
|
||||||
|
})
|
||||||
|
export class TasksModule {}
|
||||||
79
backend/src/tasks/tasks.service.spec.ts
Normal file
79
backend/src/tasks/tasks.service.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { TasksService } from './tasks.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ActivityService } from '../activity/activity.service';
|
||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('TasksService', () => {
|
||||||
|
let service: TasksService;
|
||||||
|
|
||||||
|
const mockTask = {
|
||||||
|
id: 1, taskId: 'TASK-001', title: 'Test', assignee: 'narang',
|
||||||
|
status: 'pending', iteration: 0, sprintId: 1,
|
||||||
|
createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSprint = {
|
||||||
|
id: 1, projectId: 1, number: 1, name: 'Sprint 001',
|
||||||
|
status: 'in_progress', startedAt: null, completedAt: null,
|
||||||
|
createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
tasks: [mockTask],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
project: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
sprint: {
|
||||||
|
create: jest.fn(),
|
||||||
|
findUnique: jest.fn().mockResolvedValue(mockSprint),
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockActivity = { log: jest.fn().mockResolvedValue({}) };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
TasksService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
{ provide: ActivityService, useValue: mockActivity },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<TasksService>(TasksService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getTaskLedger: project 없으면 NotFoundException', async () => {
|
||||||
|
mockPrisma.project.findUnique.mockResolvedValue(null);
|
||||||
|
await expect(service.getTaskLedger(99)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getTaskLedger: sprint + task + progress 반환', async () => {
|
||||||
|
mockPrisma.project.findUnique.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
sprints: [{ ...mockSprint, tasks: [{ ...mockTask, status: 'done' }, mockTask] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getTaskLedger(1);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].progress).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateTask: 상태 변경 시 ActivityLog 기록', async () => {
|
||||||
|
mockPrisma.task.findUnique.mockResolvedValue({ ...mockTask, status: 'in_progress' });
|
||||||
|
mockPrisma.task.update.mockResolvedValue({ ...mockTask, status: 'done', sprintId: 1 });
|
||||||
|
|
||||||
|
await service.updateTask(1, { status: 'done' });
|
||||||
|
|
||||||
|
expect(mockActivity.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'task_updated' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
111
backend/src/tasks/tasks.service.ts
Normal file
111
backend/src/tasks/tasks.service.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ActivityService } from '../activity/activity.service';
|
||||||
|
|
||||||
|
import { IsNumber, IsString, IsOptional, IsIn, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateSprintDto {
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
number!: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_STATUSES = ['pending', 'in_progress', 'review', 'done', 'failed', 'blocked', 'escalated'] as const;
|
||||||
|
|
||||||
|
export class UpdateTaskDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(VALID_STATUSES)
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
assignee?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TasksService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly activity: ActivityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getTaskLedger(projectId: number) {
|
||||||
|
const project = await this.prisma.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
include: {
|
||||||
|
sprints: {
|
||||||
|
include: { tasks: { orderBy: { taskId: 'asc' } } },
|
||||||
|
orderBy: { number: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) throw new NotFoundException(`Project ${projectId} not found`);
|
||||||
|
|
||||||
|
return project.sprints.map((sprint) => ({
|
||||||
|
id: sprint.id,
|
||||||
|
number: sprint.number,
|
||||||
|
name: sprint.name,
|
||||||
|
status: sprint.status,
|
||||||
|
startedAt: sprint.startedAt,
|
||||||
|
completedAt: sprint.completedAt,
|
||||||
|
tasks: sprint.tasks,
|
||||||
|
progress: sprint.tasks.length > 0
|
||||||
|
? Math.round(sprint.tasks.filter((t) => t.status === 'done').length / sprint.tasks.length * 100)
|
||||||
|
: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSprint(projectId: number, dto: CreateSprintDto) {
|
||||||
|
const project = await this.prisma.project.findUnique({ where: { id: projectId } });
|
||||||
|
if (!project) throw new NotFoundException(`Project ${projectId} not found`);
|
||||||
|
|
||||||
|
const sprint = await this.prisma.sprint.create({
|
||||||
|
data: { projectId, number: dto.number, name: dto.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activity.log({
|
||||||
|
projectId,
|
||||||
|
action: 'sprint_created',
|
||||||
|
detail: `Sprint ${dto.number} "${dto.name}" created`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTask(taskId: number, dto: UpdateTaskDto) {
|
||||||
|
const task = await this.prisma.task.findUnique({ where: { id: taskId } });
|
||||||
|
if (!task) throw new NotFoundException(`Task ${taskId} not found`);
|
||||||
|
|
||||||
|
const prevStatus = task.status;
|
||||||
|
const updated = await this.prisma.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
iteration: dto.status && dto.status !== prevStatus && prevStatus !== 'pending'
|
||||||
|
? { increment: 1 }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sprint = await this.prisma.sprint.findUnique({
|
||||||
|
where: { id: updated.sprintId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.activity.log({
|
||||||
|
projectId: sprint?.projectId,
|
||||||
|
action: 'task_updated',
|
||||||
|
detail: `Task ${updated.taskId} status: ${prevStatus} → ${updated.status}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
303
frontend/app/activities/page.tsx
Normal file
303
frontend/app/activities/page.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { PageTitle, LabelMeta, BtnToggle } from '@/components/ui/base';
|
||||||
|
import { API_URL } from '@/lib/config';
|
||||||
|
|
||||||
|
// ─── Styled ───
|
||||||
|
const Header = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: var(--space-md);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeaderMeta = styled.div`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LogControls = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FilterGroup = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SearchBox = styled.div`
|
||||||
|
position: relative;
|
||||||
|
flex-grow: 1;
|
||||||
|
max-width: 300px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SearchInput = styled.input`
|
||||||
|
width: 100%;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:focus { border-color: var(--border-hover); }
|
||||||
|
&::placeholder { color: var(--text-secondary); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LogContainer = styled.div`
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: #0d0d0d;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow-x: auto;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LogTable = styled.table`
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Th = styled.th`
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Tr = styled.tr`
|
||||||
|
&:hover td { background: #1a1a1a; }
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Td = styled.td`
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
vertical-align: top;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TdTime = styled(Td)`
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TdEvent = styled(Td)`
|
||||||
|
color: var(--text-primary);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Tag = styled.span<{ $type: string }>`
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
${({ $type }) => {
|
||||||
|
switch ($type) {
|
||||||
|
case 'SECURITY': return 'color: #ff5f5f; border-color: #ff5f5f44;';
|
||||||
|
case 'ORG': return 'color: #5fafff; border-color: #5fafff44;';
|
||||||
|
case 'BACKUP': return 'color: #5fff8a; border-color: #5fff8a44;';
|
||||||
|
default: return 'color: #888; border-color: #44444444;';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BracketLabel = styled.span`
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Pagination = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-md);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PaginationBtns = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PageBtn = styled.button<{ $active?: boolean }>`
|
||||||
|
background: ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: ${({ $active }) => $active ? '#000' : 'var(--text-secondary)'};
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
color: ${({ $active }) => $active ? '#000' : 'var(--text-primary)'};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Tag helpers ───
|
||||||
|
function getTagType(action: string): string {
|
||||||
|
if (/security|auth|key|guard|block/i.test(action)) return 'SECURITY';
|
||||||
|
if (/org|sister|node|role/i.test(action)) return 'ORG';
|
||||||
|
if (/backup|snapshot|save/i.test(action)) return 'BACKUP';
|
||||||
|
return 'SYSTEM';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetail(detail: string | null, action: string): React.ReactNode {
|
||||||
|
if (!detail) return action;
|
||||||
|
// bracket [텍스트] 하이라이트
|
||||||
|
const parts = detail.split(/(\[.*?\])/g);
|
||||||
|
return parts.map((part, i) =>
|
||||||
|
part.startsWith('[') ? <BracketLabel key={i}>{part}</BracketLabel> : part,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const ALL_TYPES = ['ALL', 'SYSTEM', 'SECURITY', 'ORG', 'BACKUP'];
|
||||||
|
|
||||||
|
export default function ActivitiesPage() {
|
||||||
|
const [items, setItems] = useState<any[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [filter, setFilter] = useState('ALL');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/activity?limit=200&offset=0`);
|
||||||
|
if (res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
setItems(d.items ?? []);
|
||||||
|
setTotal(d.total ?? 0);
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const iv = setInterval(load, 30000);
|
||||||
|
return () => clearInterval(iv);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = items.filter((item) => {
|
||||||
|
const tagType = getTagType(item.action);
|
||||||
|
if (filter !== 'ALL' && tagType !== filter) return false;
|
||||||
|
if (search) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
const text = `${item.action} ${item.detail ?? ''} ${item.sister?.name ?? ''}`.toLowerCase();
|
||||||
|
if (!text.includes(q)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||||
|
const paged = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||||
|
|
||||||
|
const runtimeStr = (() => {
|
||||||
|
const h = Math.floor(total * 0.5);
|
||||||
|
return `SYS_LOG_V.4.2 // ENTRIES: ${total}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header>
|
||||||
|
<PageTitle>
|
||||||
|
활동 로그 <span style={{ color: 'var(--text-secondary)', fontSize: '18px' }}>/ ACTIVITY LOG</span>
|
||||||
|
</PageTitle>
|
||||||
|
<HeaderMeta>{runtimeStr}</HeaderMeta>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<LogControls>
|
||||||
|
<FilterGroup>
|
||||||
|
{ALL_TYPES.map((t) => (
|
||||||
|
<BtnToggle key={t} $active={filter === t} onClick={() => { setFilter(t); setPage(0); }}>
|
||||||
|
{t}
|
||||||
|
</BtnToggle>
|
||||||
|
))}
|
||||||
|
</FilterGroup>
|
||||||
|
<SearchBox>
|
||||||
|
<SearchInput
|
||||||
|
placeholder="SEARCH LOGS..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||||
|
/>
|
||||||
|
</SearchBox>
|
||||||
|
</LogControls>
|
||||||
|
|
||||||
|
<LogContainer>
|
||||||
|
<LogTable>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<Th>TIME</Th>
|
||||||
|
<Th>TYPE</Th>
|
||||||
|
<Th>EVENT</Th>
|
||||||
|
<Th>CODE</Th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paged.length === 0 ? (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={4} style={{ textAlign: 'center', padding: 'var(--space-xl)', color: 'var(--text-secondary)' }}>
|
||||||
|
로그 없음
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
) : (
|
||||||
|
paged.map((item) => {
|
||||||
|
const tagType = getTagType(item.action);
|
||||||
|
const code = `0x${item.id.toString(16).toUpperCase().padStart(3, '0')}`;
|
||||||
|
return (
|
||||||
|
<Tr key={item.id}>
|
||||||
|
<TdTime>
|
||||||
|
{new Date(item.createdAt).toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})}
|
||||||
|
</TdTime>
|
||||||
|
<Td><Tag $type={tagType}>{tagType}</Tag></Td>
|
||||||
|
<TdEvent>{formatDetail(item.detail, item.action)}</TdEvent>
|
||||||
|
<Td style={{ fontFamily: 'var(--font-mono)', fontSize: '11px' }}>{code}</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</LogTable>
|
||||||
|
<Pagination>
|
||||||
|
<span>SHOWING {paged.length} / {filtered.length} ENTRIES</span>
|
||||||
|
<PaginationBtns>
|
||||||
|
<PageBtn onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}>PREV</PageBtn>
|
||||||
|
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => (
|
||||||
|
<PageBtn key={i} $active={page === i} onClick={() => setPage(i)}>{i + 1}</PageBtn>
|
||||||
|
))}
|
||||||
|
{totalPages > 5 && <span>...</span>}
|
||||||
|
<PageBtn onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}>NEXT</PageBtn>
|
||||||
|
</PaginationBtns>
|
||||||
|
</Pagination>
|
||||||
|
</LogContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
frontend/app/admin/costs/page.tsx
Normal file
230
frontend/app/admin/costs/page.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import BarChart from '@/components/admin/BarChart';
|
||||||
|
import { adminFetch } from '@/lib/adminFetch';
|
||||||
|
|
||||||
|
type Period = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
interface CostData {
|
||||||
|
period: string;
|
||||||
|
since: string;
|
||||||
|
summary: {
|
||||||
|
totalTokens: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
estimatedUsd: number;
|
||||||
|
recordCount: number;
|
||||||
|
};
|
||||||
|
bySister: Array<{ name: string; totalTokens: number; estimatedUsd: number }>;
|
||||||
|
byModel: Array<{ model: string; totalTokens: number; estimatedUsd: number; count: number }>;
|
||||||
|
timeline: Array<{ date: string; totalTokens: number; estimatedUsd: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Controls = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PeriodBtn = styled.button<{ $active: boolean }>`
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: ${({ $active }) => $active ? '600' : '400'};
|
||||||
|
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.15)' : 'transparent'};
|
||||||
|
border: 1px solid ${({ $active }) => $active ? 'rgba(88,166,255,0.4)' : 'var(--border-color)'};
|
||||||
|
color: ${({ $active }) => $active ? '#58A6FF' : 'var(--text-secondary)'};
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
&:hover { color: #58A6FF; border-color: rgba(88,166,255,0.3); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SummaryGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SummaryCard = styled.div`
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SummaryLabel = styled.div`
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SummaryValue = styled.div`
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SummaryUnit = styled.span`
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ChartsGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Card = styled.div`
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const RecordBtn = styled.button`
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(88,166,255,0.08);
|
||||||
|
border: 1px solid rgba(88,166,255,0.25);
|
||||||
|
color: #58A6FF;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
&:hover { background: rgba(88,166,255,0.18); }
|
||||||
|
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const EmptyNote = styled.div`
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CostsPage() {
|
||||||
|
const [period, setPeriod] = useState<Period>('week');
|
||||||
|
const [data, setData] = useState<CostData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
|
||||||
|
const load = async (p: Period) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await adminFetch(`/api/admin/costs?period=${p}`);
|
||||||
|
if (res.ok) setData(await res.json());
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(period); }, [period]);
|
||||||
|
|
||||||
|
const recordAll = async () => {
|
||||||
|
setRecording(true);
|
||||||
|
try {
|
||||||
|
await Promise.all(['harang', 'narang', 'darang', 'erang'].map((name) =>
|
||||||
|
adminFetch(`/api/admin/costs/record/${name}`, { method: 'POST' }),
|
||||||
|
));
|
||||||
|
await load(period);
|
||||||
|
} finally {
|
||||||
|
setRecording(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sisterChartData = data?.bySister.map((s) => ({
|
||||||
|
label: { harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이' }[s.name] ?? s.name,
|
||||||
|
value: s.estimatedUsd,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
const modelChartData = data?.byModel.map((m) => ({
|
||||||
|
label: m.model.split('/').pop() ?? m.model,
|
||||||
|
value: m.totalTokens,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
const timelineChartData = data?.timeline.map((t) => ({
|
||||||
|
label: t.date.slice(5),
|
||||||
|
value: t.estimatedUsd,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Controls>
|
||||||
|
{(['day', 'week', 'month'] as Period[]).map((p) => (
|
||||||
|
<PeriodBtn key={p} $active={period === p} onClick={() => setPeriod(p)}>
|
||||||
|
{p === 'day' ? '오늘' : p === 'week' ? '7일' : '30일'}
|
||||||
|
</PeriodBtn>
|
||||||
|
))}
|
||||||
|
<RecordBtn onClick={recordAll} disabled={recording} style={{ marginLeft: 'auto' }}>
|
||||||
|
{recording ? '수집 중...' : '📊 토큰 수집'}
|
||||||
|
</RecordBtn>
|
||||||
|
</Controls>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>로딩 중...</div>
|
||||||
|
) : !data || data.summary.recordCount === 0 ? (
|
||||||
|
<EmptyNote>
|
||||||
|
아직 비용 데이터가 없어.<br />
|
||||||
|
위 "토큰 수집" 버튼으로 자매들의 세션 토큰을 수집해.
|
||||||
|
</EmptyNote>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SummaryGrid>
|
||||||
|
<SummaryCard>
|
||||||
|
<SummaryLabel>총 토큰</SummaryLabel>
|
||||||
|
<SummaryValue>{formatTokens(data.summary.totalTokens)}<SummaryUnit>tokens</SummaryUnit></SummaryValue>
|
||||||
|
</SummaryCard>
|
||||||
|
<SummaryCard>
|
||||||
|
<SummaryLabel>예상 비용</SummaryLabel>
|
||||||
|
<SummaryValue>${data.summary.estimatedUsd.toFixed(4)}<SummaryUnit>USD</SummaryUnit></SummaryValue>
|
||||||
|
</SummaryCard>
|
||||||
|
<SummaryCard>
|
||||||
|
<SummaryLabel>입력 토큰</SummaryLabel>
|
||||||
|
<SummaryValue>{formatTokens(data.summary.inputTokens)}</SummaryValue>
|
||||||
|
</SummaryCard>
|
||||||
|
<SummaryCard>
|
||||||
|
<SummaryLabel>출력 토큰</SummaryLabel>
|
||||||
|
<SummaryValue>{formatTokens(data.summary.outputTokens)}</SummaryValue>
|
||||||
|
</SummaryCard>
|
||||||
|
</SummaryGrid>
|
||||||
|
|
||||||
|
<ChartsGrid>
|
||||||
|
<Card>
|
||||||
|
<BarChart data={sisterChartData} unit="$" title="자매별 비용 (USD)" />
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<BarChart data={modelChartData} unit=" tok" title="모델별 토큰 사용량" />
|
||||||
|
</Card>
|
||||||
|
</ChartsGrid>
|
||||||
|
|
||||||
|
{timelineChartData.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<BarChart data={timelineChartData} unit="$" title="일별 비용 추이" />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
frontend/app/admin/harness/page.tsx
Normal file
209
frontend/app/admin/harness/page.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import CodeEditor from '@/components/admin/CodeEditor';
|
||||||
|
import { adminFetch } from '@/lib/adminFetch';
|
||||||
|
|
||||||
|
const SISTERS = ['harang', 'narang', 'darang', 'erang'];
|
||||||
|
const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md'];
|
||||||
|
|
||||||
|
const DISPLAY_NAMES: Record<string, string> = {
|
||||||
|
harang: '🦊 하랑이', narang: '🦊 나랑이', darang: '🐱 다랑이', erang: '🐺 이랑이',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Layout = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FileTree = styled.div`
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TreeSection = styled.div`
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 6px;
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TreeLabel = styled.div`
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 6px 8px 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TreeItem = styled.button<{ $active: boolean }>`
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.12)' : 'transparent'};
|
||||||
|
color: ${({ $active }) => $active ? '#58A6FF' : 'var(--text-secondary)'};
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover { background: rgba(240,246,252,0.06); color: var(--text-primary); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const EditorPane = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Toolbar = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PathLabel = styled.span`
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
flex: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Btn = styled.button<{ $primary?: boolean; $danger?: boolean }>`
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid;
|
||||||
|
transition: all 0.15s;
|
||||||
|
disabled: ${({ disabled }) => disabled ? 'not-allowed' : 'pointer'};
|
||||||
|
|
||||||
|
${({ $primary }) =>
|
||||||
|
$primary &&
|
||||||
|
`background: rgba(88,166,255,0.12); border-color: rgba(88,166,255,0.4); color: #58A6FF;
|
||||||
|
&:hover { background: rgba(88,166,255,0.22); }`}
|
||||||
|
${({ $danger }) =>
|
||||||
|
$danger &&
|
||||||
|
`background: transparent; border-color: rgba(240,246,252,0.15); color: #8B949E;
|
||||||
|
&:hover { color: #E6EDF3; }`}
|
||||||
|
${({ $primary, $danger }) =>
|
||||||
|
!$primary && !$danger &&
|
||||||
|
`background: transparent; border-color: rgba(240,246,252,0.15); color: #8B949E;
|
||||||
|
&:hover { color: #E6EDF3; }`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ResultMsg = styled.div<{ $success: boolean }>`
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
background: ${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};
|
||||||
|
color: ${({ $success }) => $success ? '#00FF00' : '#FF1744'};
|
||||||
|
border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function HarnessPage() {
|
||||||
|
const [selected, setSelected] = useState({ sister: 'narang', file: 'SOUL.md' });
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [saved, setSaved] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [result, setResult] = useState<{ success: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setContent('');
|
||||||
|
setSaved('');
|
||||||
|
setResult(null);
|
||||||
|
setLoading(true);
|
||||||
|
adminFetch(`/api/admin/harness/${selected.sister}/${selected.file}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => { setContent(d.content ?? ''); setSaved(d.content ?? ''); })
|
||||||
|
.catch(() => setContent('(로드 실패)'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [selected.sister, selected.file]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const res = await adminFetch(`/api/admin/harness/${selected.sister}/${selected.file}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
setResult({ success: false, msg: '인증 실패 — API Key 또는 JWT 토큰을 확인해' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
setResult({ success: false, msg: `서버 오류 ${res.status}: ${text.slice(0, 100)}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const d = await res.json();
|
||||||
|
if (d.success) {
|
||||||
|
setSaved(content);
|
||||||
|
setResult({ success: true, msg: '저장 완료' });
|
||||||
|
} else {
|
||||||
|
setResult({ success: false, msg: d.error ?? '저장 실패' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setResult({ success: false, msg: (e as Error).message });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDirty = content !== saved;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<FileTree>
|
||||||
|
{SISTERS.map((s) => (
|
||||||
|
<TreeSection key={s}>
|
||||||
|
<TreeLabel>{DISPLAY_NAMES[s] ?? s}</TreeLabel>
|
||||||
|
{FILES.map((f) => (
|
||||||
|
<TreeItem
|
||||||
|
key={f}
|
||||||
|
$active={selected.sister === s && selected.file === f}
|
||||||
|
onClick={() => setSelected({ sister: s, file: f })}
|
||||||
|
>
|
||||||
|
{f}
|
||||||
|
</TreeItem>
|
||||||
|
))}
|
||||||
|
</TreeSection>
|
||||||
|
))}
|
||||||
|
</FileTree>
|
||||||
|
|
||||||
|
<EditorPane>
|
||||||
|
<Toolbar>
|
||||||
|
<PathLabel>
|
||||||
|
~/.openclaw/workspace/{selected.file} ({DISPLAY_NAMES[selected.sister]})
|
||||||
|
</PathLabel>
|
||||||
|
{isDirty && (
|
||||||
|
<span style={{ fontSize: '11px', color: '#FF9800' }}>● 변경됨</span>
|
||||||
|
)}
|
||||||
|
<Btn onClick={() => { setContent(saved); setResult(null); }} $danger>되돌리기</Btn>
|
||||||
|
<Btn $primary onClick={handleSave} disabled={!isDirty || saving}>
|
||||||
|
{saving ? '저장 중...' : '💾 저장'}
|
||||||
|
</Btn>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
{result && <ResultMsg $success={result.success}>{result.msg}</ResultMsg>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: '32px', textAlign: 'center', color: 'var(--text-secondary)', fontSize: '13px' }}>
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CodeEditor value={content} onChange={setContent} />
|
||||||
|
)}
|
||||||
|
</EditorPane>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
frontend/app/admin/layout.tsx
Normal file
112
frontend/app/admin/layout.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { LabelMeta, SectionTitle } from '@/components/ui/base';
|
||||||
|
|
||||||
|
const ADMIN_TABS = [
|
||||||
|
{ href: '/admin', label: '자매 관리' },
|
||||||
|
{ href: '/admin/harness', label: '하네스 편집' },
|
||||||
|
{ href: '/admin/logs', label: '로그 뷰어' },
|
||||||
|
{ href: '/admin/costs', label: '비용 모니터' },
|
||||||
|
{ href: '/admin/repos', label: '저장소' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AdminHeader = styled.div`
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: var(--space-md);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TabNav = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TabLink = styled(Link)<{ $active: boolean }>`
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
|
||||||
|
text-decoration: none;
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
|
||||||
|
&:hover { color: var(--text-primary); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ApiKeyRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ApiKeyInput = styled.input`
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 300px;
|
||||||
|
padding: 2px 0;
|
||||||
|
|
||||||
|
&:focus { border-bottom-color: var(--text-primary); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setApiKey(localStorage.getItem('hanarang_admin_key') ?? '');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveKey = (v: string) => {
|
||||||
|
setApiKey(v);
|
||||||
|
localStorage.setItem('hanarang_admin_key', v);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<AdminHeader>
|
||||||
|
<div style={{ fontSize: '28px', fontWeight: 600, letterSpacing: '-0.02em', marginBottom: 'var(--space-md)' }}>
|
||||||
|
관리자
|
||||||
|
</div>
|
||||||
|
<TabNav>
|
||||||
|
{ADMIN_TABS.map((tab) => (
|
||||||
|
<TabLink key={tab.href} href={tab.href} $active={pathname === tab.href}>
|
||||||
|
{tab.label}
|
||||||
|
</TabLink>
|
||||||
|
))}
|
||||||
|
</TabNav>
|
||||||
|
</AdminHeader>
|
||||||
|
|
||||||
|
<ApiKeyRow>
|
||||||
|
<span>KEY [</span>
|
||||||
|
<ApiKeyInput
|
||||||
|
type="password"
|
||||||
|
placeholder="ADMIN_API_KEY"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => saveKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span>]</span>
|
||||||
|
</ApiKeyRow>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
frontend/app/admin/logs/page.tsx
Normal file
115
frontend/app/admin/logs/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import LogTerminal from '@/components/admin/LogTerminal';
|
||||||
|
import { adminFetch } from '@/lib/adminFetch';
|
||||||
|
|
||||||
|
const SISTERS = [
|
||||||
|
{ value: 'harang', label: '🦊 하랑이' },
|
||||||
|
{ value: 'narang', label: '🦊 나랑이' },
|
||||||
|
{ value: 'darang', label: '🐱 다랑이' },
|
||||||
|
{ value: 'erang', label: '🐺 이랑이' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Controls = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Select = styled.select`
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
&:focus { border-color: #58A6FF; }
|
||||||
|
option { background: #1a1f2a; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LinesInput = styled.input`
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 80px;
|
||||||
|
outline: none;
|
||||||
|
&:focus { border-color: #58A6FF; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FetchBtn = styled.button`
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(88,166,255,0.1);
|
||||||
|
border: 1px solid rgba(88,166,255,0.35);
|
||||||
|
color: #58A6FF;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
&:hover { background: rgba(88,166,255,0.2); }
|
||||||
|
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Label = styled.span`
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function LogsPage() {
|
||||||
|
const [sister, setSister] = useState('narang');
|
||||||
|
const [lines, setLines] = useState(100);
|
||||||
|
const [logLines, setLogLines] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await adminFetch(`/api/admin/logs/${sister}?lines=${lines}`);
|
||||||
|
const d = await res.json();
|
||||||
|
setLogLines(d.lines ?? []);
|
||||||
|
} catch {
|
||||||
|
setLogLines(['(로그 로드 실패)']);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [sister, lines]);
|
||||||
|
|
||||||
|
// 마운트 시 자동 로드
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
}, [fetchLogs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Controls>
|
||||||
|
<Select value={sister} onChange={(e) => setSister(e.target.value)}>
|
||||||
|
{SISTERS.map((s) => (
|
||||||
|
<option key={s.value} value={s.value}>{s.label}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Label>최근</Label>
|
||||||
|
<LinesInput
|
||||||
|
type="number"
|
||||||
|
value={lines}
|
||||||
|
onChange={(e) => setLines(parseInt(e.target.value, 10) || 100)}
|
||||||
|
min={10}
|
||||||
|
max={500}
|
||||||
|
/>
|
||||||
|
<Label>줄</Label>
|
||||||
|
<FetchBtn onClick={fetchLogs} disabled={loading}>
|
||||||
|
{loading ? '로딩 중...' : '📋 로그 가져오기'}
|
||||||
|
</FetchBtn>
|
||||||
|
</Controls>
|
||||||
|
|
||||||
|
<LogTerminal lines={logLines} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
frontend/app/admin/page.tsx
Normal file
174
frontend/app/admin/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import StatusBadge from '@/components/common/StatusBadge';
|
||||||
|
import ConfirmModal from '@/components/admin/ConfirmModal';
|
||||||
|
import { API_URL } from '@/lib/config';
|
||||||
|
import { adminFetch } from '@/lib/adminFetch';
|
||||||
|
import { SISTER_EMOJIS, SISTER_DISPLAY_NAMES } from '@/lib/sisters';
|
||||||
|
|
||||||
|
type Status = 'online' | 'offline' | 'working' | 'unknown';
|
||||||
|
|
||||||
|
interface ActionResult {
|
||||||
|
success: boolean;
|
||||||
|
output: string;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Table = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Row = styled.div`
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SisterInfo = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Emoji = styled.span`font-size: 22px;`;
|
||||||
|
|
||||||
|
const Name = styled.span`
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 70px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Actions = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ActionBtn = styled.button<{ $danger?: boolean }>`
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
${({ $danger }) =>
|
||||||
|
$danger
|
||||||
|
? `background: rgba(255,23,68,0.08); border-color: rgba(255,23,68,0.3); color: #FF1744;
|
||||||
|
&:hover { background: rgba(255,23,68,0.18); }`
|
||||||
|
: `background: rgba(88,166,255,0.08); border-color: rgba(88,166,255,0.3); color: #58A6FF;
|
||||||
|
&:hover { background: rgba(88,166,255,0.18); }`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ResultBanner = styled.div<{ $success: boolean }>`
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: ${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};
|
||||||
|
border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${({ $success }) => $success ? '#00FF00' : '#FF1744'};
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function AdminSistersPage() {
|
||||||
|
const [sisters, setSisters] = useState<any[]>([]);
|
||||||
|
const [modal, setModal] = useState<{ action: 'restart' | 'reset'; name: string } | null>(null);
|
||||||
|
const [results, setResults] = useState<Record<string, ActionResult>>({});
|
||||||
|
const [loading, setLoading] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
|
||||||
|
}, 15000);
|
||||||
|
return () => clearInterval(iv);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const doAction = async (action: 'restart' | 'reset', name: string) => {
|
||||||
|
setModal(null);
|
||||||
|
setLoading((p) => ({ ...p, [`${action}:${name}`]: true }));
|
||||||
|
try {
|
||||||
|
const res = await adminFetch(`/api/admin/sisters/${name}/${action}`, { method: 'POST' });
|
||||||
|
const data: ActionResult = await res.json();
|
||||||
|
setResults((p) => ({ ...p, [`${action}:${name}`]: data }));
|
||||||
|
} catch (e) {
|
||||||
|
setResults((p) => ({
|
||||||
|
...p,
|
||||||
|
[`${action}:${name}`]: { success: false, output: '', error: (e as Error).message },
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setLoading((p) => ({ ...p, [`${action}:${name}`]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
{sisters.map((s) => {
|
||||||
|
const restartResult = results[`restart:${s.name}`];
|
||||||
|
const resetResult = results[`reset:${s.name}`];
|
||||||
|
return (
|
||||||
|
<div key={s.id}>
|
||||||
|
<Row>
|
||||||
|
<SisterInfo>
|
||||||
|
<Emoji>{SISTER_EMOJIS[s.name] ?? '🤖'}</Emoji>
|
||||||
|
<Name>{SISTER_DISPLAY_NAMES[s.name] ?? s.name}</Name>
|
||||||
|
<StatusBadge status={s.status as Status} />
|
||||||
|
</SisterInfo>
|
||||||
|
<Actions>
|
||||||
|
<ActionBtn
|
||||||
|
onClick={() => setModal({ action: 'restart', name: s.name })}
|
||||||
|
disabled={loading[`restart:${s.name}`]}
|
||||||
|
>
|
||||||
|
{loading[`restart:${s.name}`] ? '...' : '🔄 재시작'}
|
||||||
|
</ActionBtn>
|
||||||
|
<ActionBtn
|
||||||
|
$danger
|
||||||
|
onClick={() => setModal({ action: 'reset', name: s.name })}
|
||||||
|
disabled={loading[`reset:${s.name}`]}
|
||||||
|
>
|
||||||
|
{loading[`reset:${s.name}`] ? '...' : '🗑️ 리셋'}
|
||||||
|
</ActionBtn>
|
||||||
|
</Actions>
|
||||||
|
</Row>
|
||||||
|
{restartResult && (
|
||||||
|
<ResultBanner $success={restartResult.success}>
|
||||||
|
재시작: {restartResult.success ? '✅ 성공' : `❌ ${restartResult.error}`}
|
||||||
|
{restartResult.output && `\n${restartResult.output}`}
|
||||||
|
</ResultBanner>
|
||||||
|
)}
|
||||||
|
{resetResult && (
|
||||||
|
<ResultBanner $success={resetResult.success}>
|
||||||
|
리셋: {resetResult.success ? '✅ 성공' : `❌ ${resetResult.error}`}
|
||||||
|
</ResultBanner>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{modal && (
|
||||||
|
<ConfirmModal
|
||||||
|
title={modal.action === 'restart' ? '게이트웨이 재시작' : '세션 리셋'}
|
||||||
|
message={`${SISTER_DISPLAY_NAMES[modal.name] ?? modal.name}의 ${modal.action === 'restart' ? 'OpenClaw Gateway를 재시작' : '메인 세션을 초기화'}하시겠어요?`}
|
||||||
|
confirmLabel={modal.action === 'restart' ? '재시작' : '리셋'}
|
||||||
|
danger={modal.action === 'reset'}
|
||||||
|
onConfirm={() => doAction(modal.action, modal.name)}
|
||||||
|
onCancel={() => setModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
frontend/app/admin/repos/page.tsx
Normal file
101
frontend/app/admin/repos/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { API_URL } from '@/lib/config';
|
||||||
|
|
||||||
|
const Grid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Card = styled.a`
|
||||||
|
display: block;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.15s, border-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(88,166,255,0.3);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const RepoName = styled.div`
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #58A6FF;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const RepoDesc = styled.div`
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Stats = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Stat = styled.span`
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PRBadge = styled.span<{ $count: number }>`
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: ${({ $count }) => $count > 0 ? 'rgba(88,166,255,0.12)' : 'rgba(139,148,158,0.08)'};
|
||||||
|
color: ${({ $count }) => $count > 0 ? '#58A6FF' : 'var(--text-secondary)'};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NoProjects = styled.div`
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function ReposPage() {
|
||||||
|
const [projects, setProjects] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_URL}/api/projects`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setProjects)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>로딩 중...</div>;
|
||||||
|
|
||||||
|
if (!projects.length) return <NoProjects>등록된 프로젝트 없음</NoProjects>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<Card key={p.id} href={p.repoUrl} target="_blank" rel="noopener">
|
||||||
|
<RepoName>📁 {p.name}</RepoName>
|
||||||
|
{p.description && <RepoDesc>{p.description}</RepoDesc>}
|
||||||
|
<Stats>
|
||||||
|
<PRBadge $count={p.openPRs}>PR {p.openPRs}</PRBadge>
|
||||||
|
<Stat>Sprint {p.sprintCount}개</Stat>
|
||||||
|
<Stat>진행률 {p.progress}%</Stat>
|
||||||
|
</Stats>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +1,13 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import StyledComponentsRegistry from '@/lib/registry';
|
import StyledComponentsRegistry from '@/lib/registry';
|
||||||
import GlobalStyle from '@/styles/GlobalStyle';
|
import GlobalStyle from '@/styles/GlobalStyle';
|
||||||
import Sidebar from '@/components/common/Sidebar';
|
import AppShell from '@/components/common/AppShell';
|
||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: '하나랑 대시보드',
|
title: '하나랑 대시보드',
|
||||||
description: '4자매 멀티에이전트 파이프라인 관제 대시보드',
|
description: '4자매 멀티에이전트 파이프라인 관제 대시보드',
|
||||||
};
|
};
|
||||||
|
|
||||||
const LayoutRoot = styled.div`
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #0D1117;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MainContent = styled.main`
|
|
||||||
flex: 1;
|
|
||||||
margin-left: 220px;
|
|
||||||
min-height: 100vh;
|
|
||||||
transition: margin-left 0.2s ease;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -32,10 +18,7 @@ export default function RootLayout({
|
|||||||
<body>
|
<body>
|
||||||
<StyledComponentsRegistry>
|
<StyledComponentsRegistry>
|
||||||
<GlobalStyle />
|
<GlobalStyle />
|
||||||
<LayoutRoot>
|
<AppShell>{children}</AppShell>
|
||||||
<Sidebar />
|
|
||||||
<MainContent>{children}</MainContent>
|
|
||||||
</LayoutRoot>
|
|
||||||
</StyledComponentsRegistry>
|
</StyledComponentsRegistry>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
189
frontend/app/login/page.tsx
Normal file
189
frontend/app/login/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAuth } from '@/lib/AuthContext';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
const Page = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-main);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Box = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: var(--space-xl);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Logo = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: var(--space-xxl);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CircleFull = styled.div`
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
background: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CircleHalf = styled.div`
|
||||||
|
width: 10px; height: 20px;
|
||||||
|
background: var(--text-primary);
|
||||||
|
border-radius: 0 20px 20px 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.h1`
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Subtitle = styled.p`
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Form = styled.form`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FieldLabel = styled.label`
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
display: block;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Input = styled.input`
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
|
&:focus { border-color: var(--border-hover); }
|
||||||
|
&::placeholder { color: var(--text-secondary); opacity: 0.5; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SubmitBtn = styled.button`
|
||||||
|
background: var(--text-primary);
|
||||||
|
border: 1px solid var(--text-primary);
|
||||||
|
color: #000;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
|
||||||
|
&:hover { background: transparent; color: var(--text-primary); }
|
||||||
|
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ErrorMsg = styled.div`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: #ff5f5f;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border: 1px solid #ff5f5f44;
|
||||||
|
background: rgba(255,95,95,0.05);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FooterLink = styled.div`
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
a { color: var(--text-primary); text-decoration: none;
|
||||||
|
&:hover { text-decoration: underline; } }
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
router.push('/');
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message ?? '로그인 실패');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<Box>
|
||||||
|
<Logo><CircleFull /><CircleHalf /></Logo>
|
||||||
|
<Title>하나랑 대시보드</Title>
|
||||||
|
<Subtitle>SYSTEM_ACCESS // AUTHENTICATE</Subtitle>
|
||||||
|
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>USERNAME</FieldLabel>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>PASSWORD</FieldLabel>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <ErrorMsg>ERR: {error}</ErrorMsg>}
|
||||||
|
<SubmitBtn type="submit" disabled={loading}>
|
||||||
|
{loading ? 'AUTHENTICATING...' : 'LOGIN'}
|
||||||
|
</SubmitBtn>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<FooterLink>
|
||||||
|
계정 없음? <Link href="/register">REGISTER</Link>
|
||||||
|
</FooterLink>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
280
frontend/app/org/page.tsx
Normal file
280
frontend/app/org/page.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { API_URL } from '@/lib/config';
|
||||||
|
|
||||||
|
// ─── Styled ───
|
||||||
|
const MainViewport = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
min-height: 80vh;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeaderInfo = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: var(--space-md);
|
||||||
|
|
||||||
|
span { color: var(--text-primary); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TreeContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: var(--space-xl) 0;
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeGroup = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TerminalCard = styled.div<{ $level: 'high' | 'mid' | 'low' | 'none' }>`
|
||||||
|
border: 1px solid ${({ $level }) => ({
|
||||||
|
high: '#f43f5e',
|
||||||
|
mid: '#3b82f6',
|
||||||
|
low: '#10b981',
|
||||||
|
none: '#525252',
|
||||||
|
})[$level]};
|
||||||
|
border-left-width: 3px;
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
background: var(--bg-main);
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 260px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
position: relative;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 12px 36px rgba(0,0,0,0.7);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeLabel = styled.div`
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-right: var(--space-xs);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BadgeRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Badge = styled.div`
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
b { color: var(--text-primary); font-weight: normal; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TreeConnector = styled.div`
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 auto;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TreeBranch = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TreeChildren = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
align-items: flex-start;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: calc(var(--space-xl) / 2 + 100px);
|
||||||
|
right: calc(var(--space-xl) / 2 + 100px);
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
|
||||||
|
&::before { display: none; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PageFooter = styled.footer`
|
||||||
|
margin-top: auto;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-lg) 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
type Level = 'high' | 'mid' | 'low' | 'none';
|
||||||
|
|
||||||
|
interface OrgData {
|
||||||
|
owner: { name: string; role: string };
|
||||||
|
sisters: Array<{
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
lxcId?: number;
|
||||||
|
}>;
|
||||||
|
pipeline: Array<{ from: string; to: string; label: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_MAP: Record<string, Level> = {
|
||||||
|
Orchestrator: 'high',
|
||||||
|
Generator: 'mid',
|
||||||
|
Evaluator: 'mid',
|
||||||
|
'Infra Manager': 'low',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_TAG: Record<string, string> = {
|
||||||
|
Orchestrator: 'ORCH',
|
||||||
|
Generator: 'GEN',
|
||||||
|
Evaluator: 'EVAL',
|
||||||
|
'Infra Manager': 'INFRA',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LXC_ID: Record<string, number> = {
|
||||||
|
harang: 104,
|
||||||
|
narang: 105,
|
||||||
|
darang: 106,
|
||||||
|
erang: 107,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OrgPage() {
|
||||||
|
const [orgData, setOrgData] = useState<OrgData | null>(null);
|
||||||
|
const ts = new Date().toLocaleDateString('ko-KR').replace(/\. /g, '.').replace('.', '') + '_' +
|
||||||
|
new Date().toLocaleTimeString('ko-KR', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_URL}/api/org`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setOrgData)
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sisters = orgData?.sisters ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainViewport>
|
||||||
|
<HeaderInfo>
|
||||||
|
<div>SYS_TYPE: <span>CORE_ROOT_01</span></div>
|
||||||
|
<div>TIMESTAMP: <span style={{ color: 'var(--text-secondary)' }}>{ts}</span></div>
|
||||||
|
</HeaderInfo>
|
||||||
|
|
||||||
|
<TreeContainer>
|
||||||
|
<NodeGroup>
|
||||||
|
{/* HQ */}
|
||||||
|
<TerminalCard $level="high">
|
||||||
|
<NodeLabel><span>[HQ]</span> 하나랑 글로벌</NodeLabel>
|
||||||
|
<BadgeRow>
|
||||||
|
<Badge>MEM: <b>{sisters.length + 1}</b></Badge>
|
||||||
|
<Badge>S_ADMIN</Badge>
|
||||||
|
<Badge>LV_09</Badge>
|
||||||
|
</BadgeRow>
|
||||||
|
</TerminalCard>
|
||||||
|
<TreeConnector />
|
||||||
|
|
||||||
|
{/* 자매 레벨 */}
|
||||||
|
<TreeBranch>
|
||||||
|
<TreeChildren>
|
||||||
|
{/* 하랑이 (Orchestrator) */}
|
||||||
|
{sisters.filter((s) => s.role === 'Orchestrator').map((s) => (
|
||||||
|
<NodeGroup key={s.name}>
|
||||||
|
<TerminalCard $level={LEVEL_MAP[s.role] ?? 'none'}>
|
||||||
|
<NodeLabel>
|
||||||
|
<span>[{ROLE_TAG[s.role] ?? s.role}]</span>
|
||||||
|
{s.name === 'harang' ? '하랑이' : s.name}
|
||||||
|
</NodeLabel>
|
||||||
|
<BadgeRow>
|
||||||
|
<Badge>LXC: <b>{LXC_ID[s.name] ?? '---'}</b></Badge>
|
||||||
|
<Badge>{ROLE_TAG[s.role] ?? s.role}</Badge>
|
||||||
|
<Badge>{s.status === 'online' ? 'ACTIVE' : s.status === 'offline' ? 'STBY' : 'RUN'}</Badge>
|
||||||
|
</BadgeRow>
|
||||||
|
</TerminalCard>
|
||||||
|
<TreeConnector />
|
||||||
|
<TreeBranch>
|
||||||
|
<TreeChildren>
|
||||||
|
{/* 나랑/다랑/이랑 */}
|
||||||
|
{sisters.filter((s2) => s2.role !== 'Orchestrator').map((s2) => (
|
||||||
|
<NodeGroup key={s2.name}>
|
||||||
|
<TerminalCard $level={LEVEL_MAP[s2.role] ?? 'none'}>
|
||||||
|
<NodeLabel>
|
||||||
|
{s2.name === 'narang' ? '나랑이' : s2.name === 'darang' ? '다랑이' : '이랑이'}
|
||||||
|
</NodeLabel>
|
||||||
|
<BadgeRow>
|
||||||
|
<Badge>LXC: <b>{LXC_ID[s2.name] ?? '---'}</b></Badge>
|
||||||
|
<Badge>{ROLE_TAG[s2.role] ?? s2.role}</Badge>
|
||||||
|
<Badge>{s2.status === 'online' ? 'ON' : s2.status === 'offline' ? '--' : 'RUN'}</Badge>
|
||||||
|
</BadgeRow>
|
||||||
|
</TerminalCard>
|
||||||
|
</NodeGroup>
|
||||||
|
))}
|
||||||
|
</TreeChildren>
|
||||||
|
</TreeBranch>
|
||||||
|
</NodeGroup>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Orchestrator 없을 때 fallback */}
|
||||||
|
{sisters.filter((s) => s.role === 'Orchestrator').length === 0 && (
|
||||||
|
<NodeGroup>
|
||||||
|
<TerminalCard $level="none">
|
||||||
|
<NodeLabel>데이터 없음</NodeLabel>
|
||||||
|
<BadgeRow><Badge>N/A</Badge></BadgeRow>
|
||||||
|
</TerminalCard>
|
||||||
|
</NodeGroup>
|
||||||
|
)}
|
||||||
|
</TreeChildren>
|
||||||
|
</TreeBranch>
|
||||||
|
</NodeGroup>
|
||||||
|
</TreeContainer>
|
||||||
|
|
||||||
|
<PageFooter>
|
||||||
|
<div>TOTAL_NODES: {String(sisters.length + 1).padStart(2, '0')}</div>
|
||||||
|
<div>STATUS: SYNC_COMPLETE</div>
|
||||||
|
</PageFooter>
|
||||||
|
</MainViewport>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,174 +1,261 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import SisterCard from '@/components/dashboard/SisterCard';
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
PageHeader, PageTitle, Card, BracketValue, LabelMeta,
|
||||||
|
TechBar, TechBarFill, SectionTitle, Timeline, TimelineItem,
|
||||||
|
TimeStamp, TimelineContent,
|
||||||
|
} from '@/components/ui/base';
|
||||||
|
import { API_URL, POLL_INTERVAL_MS } from '@/lib/config';
|
||||||
|
import { useSocket } from '@/lib/useSocket';
|
||||||
|
|
||||||
type Status = 'online' | 'offline' | 'working' | 'unknown';
|
// ─── Styled ───
|
||||||
|
const StatusGrid = styled.section`
|
||||||
interface SisterStatus {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
ip: string;
|
|
||||||
user: string;
|
|
||||||
lxcId: number;
|
|
||||||
role: string;
|
|
||||||
status: Status;
|
|
||||||
lastSeen: string | null;
|
|
||||||
currentTask: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOCK_SISTERS: SisterStatus[] = [
|
|
||||||
{ id: 1, name: 'harang', ip: '10.10.10.112', user: 'harang', lxcId: 104, role: 'Orchestrator', status: 'online', lastSeen: new Date().toISOString(), currentTask: 'Sprint 001 기획 중' },
|
|
||||||
{ id: 2, name: 'narang', ip: '10.10.10.216', user: 'narang', lxcId: 105, role: 'Generator', status: 'working', lastSeen: new Date().toISOString(), currentTask: 'SPRINT-001 구현 중' },
|
|
||||||
{ id: 3, name: 'darang', ip: '10.10.10.136', user: 'darang', lxcId: 106, role: 'Evaluator', status: 'online', lastSeen: new Date().toISOString(), currentTask: null },
|
|
||||||
{ id: 4, name: 'erang', ip: '10.10.10.163', user: 'erang', lxcId: 107, role: 'Infra Manager', status: 'online', lastSeen: new Date().toISOString(), currentTask: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3005';
|
|
||||||
|
|
||||||
const PageWrapper = styled.div`
|
|
||||||
padding: 32px;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #0D1117;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PageHeader = styled.div`
|
|
||||||
margin-bottom: 32px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PageTitle = styled.h1`
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #E6EDF3;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PageSubtitle = styled.p`
|
|
||||||
font-size: 14px;
|
|
||||||
color: #8B949E;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SectionTitle = styled.h2`
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #8B949E;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SistersGrid = styled.div`
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
> * { flex: 1 1 calc(50% - 8px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
flex-direction: column;
|
|
||||||
> * { flex: 1 1 auto; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TwoColumnRow = styled.div`
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 20px;
|
gap: var(--space-lg);
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (min-width: 768px) and (max-width: 1199px) {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--space-md);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PlaceholderCard = styled.div`
|
const DataColumns = styled.div`
|
||||||
background: rgba(22, 27, 34, 0.8);
|
display: grid;
|
||||||
backdrop-filter: blur(12px);
|
grid-template-columns: 1.5fr 1fr;
|
||||||
border: 1px solid rgba(240, 246, 252, 0.1);
|
gap: var(--space-xxl);
|
||||||
border-radius: 12px;
|
align-items: start;
|
||||||
padding: 24px;
|
|
||||||
min-height: 200px;
|
@media (max-width: 1199px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProjectList = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProjectRow = styled(Link)`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px 1fr auto;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-md) 0;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
&:hover { opacity: 0.8; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Avatar = styled.div`
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
filter: grayscale(100%) contrast(120%);
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #8B949E;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ErrorBanner = styled.div`
|
const ProjectDetails = styled.div`
|
||||||
padding: 10px 14px;
|
display: flex;
|
||||||
background: rgba(255, 23, 68, 0.08);
|
flex-direction: column;
|
||||||
border: 1px solid rgba(255, 23, 68, 0.3);
|
gap: 2px;
|
||||||
border-radius: 8px;
|
min-width: 0;
|
||||||
font-size: 13px;
|
|
||||||
color: #FF1744;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const ProjectName = styled.div`
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProjectDesc = styled.div`
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const WsIndicator = styled.span<{ $on: boolean }>`
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: ${({ $on }) => $on ? 'var(--text-secondary)' : '#333'};
|
||||||
|
margin-left: var(--space-md);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Helpers ───
|
||||||
|
const SISTER_SHORT: Record<string, string> = {
|
||||||
|
harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SISTER_SUB_LABEL: Record<string, string> = {
|
||||||
|
harang: 'Active Node',
|
||||||
|
narang: 'Capacity %',
|
||||||
|
darang: 'Standby',
|
||||||
|
erang: 'Sync Queue',
|
||||||
|
};
|
||||||
|
|
||||||
|
function sisterBracketValue(s: any): string {
|
||||||
|
if (s.status === 'online') return '[ON]';
|
||||||
|
if (s.status === 'working') return '[RUN]';
|
||||||
|
if (s.status === 'offline') return '[--]';
|
||||||
|
return '[??]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sisterBarWidth(s: any): number {
|
||||||
|
if (s.status === 'online') return 100;
|
||||||
|
if (s.status === 'working') return 84;
|
||||||
|
if (s.status === 'offline') return 0;
|
||||||
|
return 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleTimeString('ko-KR', { hour12: false }) + ' GMT+9';
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_SISTERS = [
|
||||||
|
{ id: 1, name: 'harang', status: 'online', lastSeen: null, currentTask: null },
|
||||||
|
{ id: 2, name: 'narang', status: 'working', lastSeen: null, currentTask: null },
|
||||||
|
{ id: 3, name: 'darang', status: 'offline', lastSeen: null, currentTask: null },
|
||||||
|
{ id: 4, name: 'erang', status: 'online', lastSeen: null, currentTask: null },
|
||||||
|
];
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [sisters, setSisters] = useState<SisterStatus[]>(MOCK_SISTERS);
|
const [sisters, setSisters] = useState<any[]>(MOCK_SISTERS);
|
||||||
const [loading, setLoading] = useState(false);
|
const [projects, setProjects] = useState<any[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [activityItems, setActivityItems] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const handleSistersUpdate = useCallback((s: any[]) => setSisters(s), []);
|
||||||
|
const handleActivityNew = useCallback((item: any) => {
|
||||||
|
setActivityItems((prev) => [item, ...prev].slice(0, 10));
|
||||||
|
}, []);
|
||||||
|
const { connected } = useSocket({ onSistersUpdate: handleSistersUpdate, onActivityNew: handleActivityNew });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSisters = async () => {
|
const fetch_ = async () => {
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/sisters`, { cache: 'no-store' });
|
const [sRes, pRes, aRes] = await Promise.allSettled([
|
||||||
if (!res.ok) throw new Error(`API error ${res.status}`);
|
fetch(`${API_URL}/api/sisters`),
|
||||||
const data = await res.json();
|
fetch(`${API_URL}/api/projects`),
|
||||||
setSisters(data);
|
fetch(`${API_URL}/api/activity?limit=8`),
|
||||||
setError(null);
|
]);
|
||||||
} catch (e) {
|
if (sRes.status === 'fulfilled' && sRes.value.ok) setSisters(await sRes.value.json());
|
||||||
// API 미준비 시 mock 데이터 유지
|
if (pRes.status === 'fulfilled' && pRes.value.ok) setProjects(await pRes.value.json());
|
||||||
setError('API 연결 실패 — mock 데이터로 표시 중');
|
if (aRes.status === 'fulfilled' && aRes.value.ok) {
|
||||||
} finally {
|
const d = await aRes.value.json();
|
||||||
setLoading(false);
|
setActivityItems(d.items ?? []);
|
||||||
}
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
};
|
};
|
||||||
|
fetch_();
|
||||||
fetchSisters();
|
const iv = setInterval(fetch_, POLL_INTERVAL_MS);
|
||||||
const interval = setInterval(fetchSisters, 30000);
|
return () => clearInterval(iv);
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageWrapper>
|
<>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<PageTitle>하나랑 대시보드</PageTitle>
|
<PageTitle>
|
||||||
<PageSubtitle>4자매 멀티에이전트 파이프라인 관제 현황</PageSubtitle>
|
하나랑 대시보드
|
||||||
|
<WsIndicator $on={connected}>● {connected ? 'LIVE' : 'POLL'}</WsIndicator>
|
||||||
|
</PageTitle>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{error && <ErrorBanner>⚠️ {error}</ErrorBanner>}
|
{/* 상태 카드 */}
|
||||||
|
<StatusGrid>
|
||||||
<SectionTitle>자매 상태</SectionTitle>
|
{sisters.map((s) => (
|
||||||
<SistersGrid>
|
<Card key={s.id ?? s.name} as={Link} href={`/sisters/${s.name}`} style={{ textDecoration: 'none' }}>
|
||||||
{sisters.map((sister) => (
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<SisterCard
|
<LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta>
|
||||||
key={sister.id}
|
</div>
|
||||||
name={sister.name}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}>
|
||||||
role={sister.role}
|
<BracketValue $dimmed={s.status === 'offline'}>
|
||||||
status={sister.status}
|
{sisterBracketValue(s)}
|
||||||
lastSeen={sister.lastSeen}
|
</BracketValue>
|
||||||
currentTask={sister.currentTask}
|
<LabelMeta>{SISTER_SUB_LABEL[s.name] ?? s.status}</LabelMeta>
|
||||||
ip={sister.ip}
|
</div>
|
||||||
/>
|
<TechBar>
|
||||||
|
<TechBarFill $width={sisterBarWidth(s)} />
|
||||||
|
</TechBar>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</SistersGrid>
|
</StatusGrid>
|
||||||
|
|
||||||
<TwoColumnRow>
|
{/* 하단 2열 */}
|
||||||
<div>
|
<DataColumns>
|
||||||
<SectionTitle>진행 중 프로젝트</SectionTitle>
|
{/* ONGOING PROJECTS */}
|
||||||
<PlaceholderCard>Sprint 002에서 구현 예정</PlaceholderCard>
|
<section>
|
||||||
</div>
|
<SectionTitle>
|
||||||
<div>
|
<span>ONGOING PROJECTS</span>
|
||||||
<SectionTitle>최근 활동 피드</SectionTitle>
|
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
|
||||||
<PlaceholderCard>Sprint 002에서 구현 예정</PlaceholderCard>
|
</SectionTitle>
|
||||||
</div>
|
<ProjectList>
|
||||||
</TwoColumnRow>
|
{projects.length === 0 ? (
|
||||||
</PageWrapper>
|
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', padding: 'var(--space-md) 0' }}>
|
||||||
|
등록된 프로젝트 없음
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
projects.map((p) => (
|
||||||
|
<ProjectRow key={p.id} href={`/projects/${p.id}`}>
|
||||||
|
<Avatar>📁</Avatar>
|
||||||
|
<ProjectDetails>
|
||||||
|
<ProjectName>{p.name}</ProjectName>
|
||||||
|
<ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc>
|
||||||
|
</ProjectDetails>
|
||||||
|
<LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta>
|
||||||
|
</ProjectRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ProjectList>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ACTIVITY FEED */}
|
||||||
|
<section>
|
||||||
|
<SectionTitle>
|
||||||
|
<span>ACTIVITY FEED</span>
|
||||||
|
<LabelMeta>{connected ? 'LIVE' : 'FEED'}</LabelMeta>
|
||||||
|
</SectionTitle>
|
||||||
|
<Timeline>
|
||||||
|
{activityItems.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', paddingLeft: 'var(--space-lg)' }}>
|
||||||
|
활동 기록 없음
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
activityItems.map((item) => (
|
||||||
|
<TimelineItem key={item.id}>
|
||||||
|
<TimeStamp>{formatTimestamp(item.createdAt)}</TimeStamp>
|
||||||
|
<TimelineContent>
|
||||||
|
{item.sister && <strong>{item.sister.name} </strong>}
|
||||||
|
{item.detail ?? item.action}
|
||||||
|
</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Timeline>
|
||||||
|
</section>
|
||||||
|
</DataColumns>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
543
frontend/app/projects/[id]/page.tsx
Normal file
543
frontend/app/projects/[id]/page.tsx
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { LabelMeta, PageTitle, SectionTitle, TechBar, TechBarFill } from '@/components/ui/base';
|
||||||
|
import { API_URL } from '@/lib/config';
|
||||||
|
|
||||||
|
// ─── Styled ───
|
||||||
|
const Breadcrumb = styled.div`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PageTitleRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TabBar = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
&::-webkit-scrollbar { display: none; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TabBtn = styled.button<{ $active: boolean }>`
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
&:hover { color: var(--text-primary); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CommitTable = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-code);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CommitRow = styled.a`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr 120px 100px;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.1s;
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
&:hover { background: #1a1a1a; }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
grid-template-columns: 70px 1fr;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CommitHash = styled.span`
|
||||||
|
font-size: 12px;
|
||||||
|
color: #5fafff;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CommitMsg = styled.span`
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CommitMeta = styled.span`
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BranchGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: var(--space-md);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BranchCard = styled.div`
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
&:hover { border-color: var(--border-hover); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BranchName = styled.div`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BranchMeta = styled.div`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PRList = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PRItem = styled.a`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: 12px var(--space-lg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
&:hover { border-color: var(--border-hover); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PRState = styled.span<{ $state: string }>`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
${({ $state }) => {
|
||||||
|
switch ($state) {
|
||||||
|
case 'open': return "color: #5fff8a; border-color: #5fff8a44;";
|
||||||
|
case 'closed': return "color: var(--text-secondary); border-color: var(--border-color);";
|
||||||
|
case 'merged': return "color: #5fafff; border-color: #5fafff44;";
|
||||||
|
default: return "color: var(--text-secondary); border-color: var(--border-color);";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PRTitle = styled.span`
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PRMeta = styled.span`
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FilterRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FilterBtn = styled.button<{ $active: boolean }>`
|
||||||
|
background: ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: ${({ $active }) => $active ? '#000' : 'var(--text-secondary)'};
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 4px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
transition: all 0.15s;
|
||||||
|
&:hover { border-color: var(--border-hover); color: ${({ $active }) => $active ? '#000' : 'var(--text-primary)'}; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Empty = styled.div`
|
||||||
|
padding: var(--space-xl) 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Phase timeline 컴포넌트들
|
||||||
|
const ProjectGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
gap: var(--space-xxl);
|
||||||
|
align-items: start;
|
||||||
|
@media (max-width: 1199px) { grid-template-columns: 1fr; gap: var(--space-xl); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PhaseTimeline = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PhaseItem = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 1fr;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
align-items: flex-start;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PhaseMeta = styled.div`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding-top: var(--space-sm);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PhaseBox = styled.div<{ $active?: boolean }>`
|
||||||
|
border: 1px solid ${({ $active }) => $active ? 'var(--border-hover)' : 'var(--border-color)'};
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
background: ${({ $active }) => $active ? '#1a1a1a' : 'transparent'};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PhaseName = styled.div`
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PhaseDesc = styled.div`
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeStack = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeMiniCard = styled.div`
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeDot = styled.div`
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
background: #00FF00;
|
||||||
|
border-radius: 50%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Checklist = styled.ul`
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CheckItem = styled.li<{ $done?: boolean }>`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
border-bottom: 1px solid #1a1a1a;
|
||||||
|
font-size: 13px;
|
||||||
|
color: ${({ $done }) => $done ? 'var(--text-secondary)' : 'var(--text-primary)'};
|
||||||
|
text-decoration: ${({ $done }) => $done ? 'line-through' : 'none'};
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CheckBox = styled.div<{ $checked?: boolean }>`
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'};
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'transparent'};
|
||||||
|
position: relative;
|
||||||
|
${({ $checked }) => $checked && `&::after { content: '✓'; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 9px; color: #000; }`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Helpers ───
|
||||||
|
function getSprintMeta(sprint: any): { label: string; isActive: boolean } {
|
||||||
|
if (sprint.status === 'done') return { label: `COMPLETED`, isActive: false };
|
||||||
|
if (sprint.status === 'in_progress') return { label: 'IN PROGRESS', isActive: true };
|
||||||
|
return { label: 'PENDING', isActive: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(str: string): string {
|
||||||
|
return new Date(str).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'overview', label: 'Overview' },
|
||||||
|
{ id: 'commits', label: 'Commits' },
|
||||||
|
{ id: 'branches', label: 'Branches' },
|
||||||
|
{ id: 'prs', label: 'Pull Requests' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ProjectDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [project, setProject] = useState<any>(null);
|
||||||
|
const [tasks, setTasks] = useState<any[]>([]);
|
||||||
|
const [commits, setCommits] = useState<any[]>([]);
|
||||||
|
const [branches, setBranches] = useState<any[]>([]);
|
||||||
|
const [pulls, setPulls] = useState<any[]>([]);
|
||||||
|
const [prFilter, setPrFilter] = useState<'open' | 'closed' | 'all'>('open');
|
||||||
|
const [tab, setTab] = useState('overview');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [tabLoading, setTabLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.allSettled([
|
||||||
|
fetch(`${API_URL}/api/projects/${id}`),
|
||||||
|
fetch(`${API_URL}/api/projects/${id}/tasks`),
|
||||||
|
]).then(([pRes, tRes]) => {
|
||||||
|
if (pRes.status === 'fulfilled' && pRes.value.ok) pRes.value.json().then(setProject);
|
||||||
|
if (tRes.status === 'fulfilled' && tRes.value.ok) tRes.value.json().then(setTasks);
|
||||||
|
}).finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadCommits = useCallback(async () => {
|
||||||
|
setTabLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/projects/${id}/commits?limit=30`);
|
||||||
|
if (res.ok) setCommits(await res.json());
|
||||||
|
} finally { setTabLoading(false); }
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadBranches = useCallback(async () => {
|
||||||
|
setTabLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/projects/${id}/branches`);
|
||||||
|
if (res.ok) setBranches(await res.json());
|
||||||
|
} finally { setTabLoading(false); }
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadPulls = useCallback(async (state: 'open' | 'closed' | 'all') => {
|
||||||
|
setTabLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/projects/${id}/pulls?state=${state}`);
|
||||||
|
if (res.ok) setPulls(await res.json());
|
||||||
|
} finally { setTabLoading(false); }
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab === 'commits' && commits.length === 0) loadCommits();
|
||||||
|
if (tab === 'branches' && branches.length === 0) loadBranches();
|
||||||
|
if (tab === 'prs' && pulls.length === 0) loadPulls(prFilter);
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab === 'prs') loadPulls(prFilter);
|
||||||
|
}, [prFilter]);
|
||||||
|
|
||||||
|
const allTasks = tasks.flatMap((s: any) => s.tasks ?? []);
|
||||||
|
const doneTasks = allTasks.filter((t: any) => t.status === 'done');
|
||||||
|
const activeSprint = tasks.find((s: any) => s.status === 'in_progress');
|
||||||
|
|
||||||
|
if (loading) return <Empty>LOADING...</Empty>;
|
||||||
|
if (!project) return <Empty>PROJECT NOT FOUND</Empty>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Breadcrumb>
|
||||||
|
<Link href="/projects">PROJECTS</Link> / P-{String(project.id).padStart(3, '0')} / {project.name}
|
||||||
|
</Breadcrumb>
|
||||||
|
<PageTitleRow>
|
||||||
|
<PageTitle>
|
||||||
|
{project.name}
|
||||||
|
{project.repoUrl && (
|
||||||
|
<a href={project.repoUrl} target="_blank" rel="noopener"
|
||||||
|
style={{ fontSize: '12px', color: '#5fafff', marginLeft: 'var(--space-md)', fontWeight: 400 }}>
|
||||||
|
↗ GITEA
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</PageTitle>
|
||||||
|
<LabelMeta><span>STATUS:</span>{project.status?.toUpperCase()} / {activeSprint?.name ?? 'PLANNING'}</LabelMeta>
|
||||||
|
</PageTitleRow>
|
||||||
|
|
||||||
|
<TabBar>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
|
||||||
|
{t.label}
|
||||||
|
</TabBtn>
|
||||||
|
))}
|
||||||
|
</TabBar>
|
||||||
|
|
||||||
|
{/* OVERVIEW */}
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<ProjectGrid>
|
||||||
|
<div>
|
||||||
|
<SectionTitle>
|
||||||
|
<span>PHASE TIMELINE</span>
|
||||||
|
<LabelMeta>CURRENT: {activeSprint ? `S${activeSprint.number}` : 'N/A'}</LabelMeta>
|
||||||
|
</SectionTitle>
|
||||||
|
<PhaseTimeline>
|
||||||
|
{tasks.map((sprint: any) => {
|
||||||
|
const { label, isActive } = getSprintMeta(sprint);
|
||||||
|
return (
|
||||||
|
<PhaseItem key={sprint.id}>
|
||||||
|
<PhaseMeta>{label}</PhaseMeta>
|
||||||
|
<PhaseBox $active={isActive}>
|
||||||
|
<PhaseName>SPRINT {String(sprint.number).padStart(2, '0')}: {sprint.name}</PhaseName>
|
||||||
|
<PhaseDesc>태스크 {sprint.tasks?.length ?? 0}개 · 완료 {sprint.tasks?.filter((t: any) => t.status === 'done').length ?? 0}개</PhaseDesc>
|
||||||
|
<TechBar style={{ marginTop: 'var(--space-sm)' }}>
|
||||||
|
<TechBarFill $width={sprint.progress ?? 0} />
|
||||||
|
</TechBar>
|
||||||
|
</PhaseBox>
|
||||||
|
</PhaseItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{tasks.length === 0 && <PhaseItem><PhaseMeta>PENDING</PhaseMeta><PhaseBox><PhaseName>Sprint 없음</PhaseName></PhaseBox></PhaseItem>}
|
||||||
|
</PhaseTimeline>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionTitle style={{ marginBottom: 'var(--space-md)' }}>ASSIGNED NODES</SectionTitle>
|
||||||
|
<NodeStack>
|
||||||
|
{['harang', 'narang', 'darang', 'erang'].map((name) => (
|
||||||
|
<NodeMiniCard key={name}>
|
||||||
|
<LabelMeta>{name === 'harang' ? '하랑' : name === 'narang' ? '나랑' : name === 'darang' ? '다랑' : '이랑'} [{name === 'harang' ? 'PRIMARY' : name === 'narang' ? 'GEN' : name === 'darang' ? 'EVAL' : 'INFRA'}]</LabelMeta>
|
||||||
|
<NodeDot />
|
||||||
|
</NodeMiniCard>
|
||||||
|
))}
|
||||||
|
</NodeStack>
|
||||||
|
|
||||||
|
<SectionTitle>TASK CHECKLIST</SectionTitle>
|
||||||
|
<Checklist>
|
||||||
|
{allTasks.slice(0, 8).map((task: any) => (
|
||||||
|
<CheckItem key={task.id} $done={task.status === 'done'}>
|
||||||
|
<CheckBox $checked={task.status === 'done'} />
|
||||||
|
<span>[{task.taskId}] {task.title}</span>
|
||||||
|
</CheckItem>
|
||||||
|
))}
|
||||||
|
{allTasks.length === 0 && <CheckItem><CheckBox /><span style={{ color: 'var(--text-secondary)' }}>태스크 없음</span></CheckItem>}
|
||||||
|
</Checklist>
|
||||||
|
</div>
|
||||||
|
</ProjectGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* COMMITS */}
|
||||||
|
{tab === 'commits' && (
|
||||||
|
<>
|
||||||
|
{tabLoading ? <Empty>LOADING COMMITS...</Empty> : (
|
||||||
|
<>
|
||||||
|
<CommitTable>
|
||||||
|
{commits.length === 0 ? (
|
||||||
|
<div style={{ padding: 'var(--space-xl)', color: 'var(--text-secondary)', fontSize: '12px', textAlign: 'center', fontFamily: 'var(--font-mono)' }}>NO COMMITS</div>
|
||||||
|
) : (
|
||||||
|
commits.map((c: any) => (
|
||||||
|
<CommitRow key={c.sha} href={c.html_url} target="_blank" rel="noopener">
|
||||||
|
<CommitHash>{c.sha?.slice(0, 7)}</CommitHash>
|
||||||
|
<CommitMsg>{c.commit?.message?.split('\n')[0]}</CommitMsg>
|
||||||
|
<CommitMeta style={{ display: 'var(--media-hide, initial)' }}>
|
||||||
|
{c.commit?.author?.name ?? c.author?.login ?? '-'}
|
||||||
|
</CommitMeta>
|
||||||
|
<CommitMeta>
|
||||||
|
{c.commit?.author?.date ? formatDate(c.commit.author.date) : '-'}
|
||||||
|
</CommitMeta>
|
||||||
|
</CommitRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CommitTable>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* BRANCHES */}
|
||||||
|
{tab === 'branches' && (
|
||||||
|
<>
|
||||||
|
{tabLoading ? <Empty>LOADING BRANCHES...</Empty> : (
|
||||||
|
<BranchGrid>
|
||||||
|
{branches.length === 0 ? (
|
||||||
|
<Empty>NO BRANCHES</Empty>
|
||||||
|
) : (
|
||||||
|
branches.map((b: any) => (
|
||||||
|
<BranchCard key={b.name}>
|
||||||
|
<BranchName>{b.name}</BranchName>
|
||||||
|
<BranchMeta>
|
||||||
|
{b.commit?.id?.slice(0, 7) ?? '-'}
|
||||||
|
{b.protected && ' · PROTECTED'}
|
||||||
|
</BranchMeta>
|
||||||
|
</BranchCard>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</BranchGrid>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PULL REQUESTS */}
|
||||||
|
{tab === 'prs' && (
|
||||||
|
<>
|
||||||
|
<FilterRow>
|
||||||
|
{(['open', 'closed', 'all'] as const).map((s) => (
|
||||||
|
<FilterBtn key={s} $active={prFilter === s} onClick={() => setPrFilter(s)}>
|
||||||
|
{s.toUpperCase()}
|
||||||
|
</FilterBtn>
|
||||||
|
))}
|
||||||
|
</FilterRow>
|
||||||
|
{tabLoading ? <Empty>LOADING PULL REQUESTS...</Empty> : (
|
||||||
|
<PRList>
|
||||||
|
{pulls.length === 0 ? (
|
||||||
|
<Empty>NO PULL REQUESTS ({prFilter})</Empty>
|
||||||
|
) : (
|
||||||
|
pulls.map((pr: any) => (
|
||||||
|
<PRItem key={pr.id} href={pr.html_url} target="_blank" rel="noopener">
|
||||||
|
<PRState $state={pr.merged ? 'merged' : pr.state}>
|
||||||
|
{pr.merged ? 'MERGED' : pr.state?.toUpperCase()}
|
||||||
|
</PRState>
|
||||||
|
<PRTitle>#{pr.number} {pr.title}</PRTitle>
|
||||||
|
<PRMeta>{pr.user?.login ?? '-'} · {pr.created_at ? formatDate(pr.created_at) : '-'}</PRMeta>
|
||||||
|
</PRItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</PRList>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
frontend/app/projects/page.tsx
Normal file
161
frontend/app/projects/page.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { PageTitle, LabelMeta, TechBar, TechBarFill, Btn, BtnPrimary } from '@/components/ui/base';
|
||||||
|
import { API_URL } from '@/lib/config';
|
||||||
|
import { adminFetch } from '@/lib/adminFetch';
|
||||||
|
|
||||||
|
const ProjectList = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProjectRow = styled(Link)`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px 1fr auto;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-md) 0;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
&:hover { opacity: 0.75; }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
grid-template-columns: 32px 1fr auto;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Avatar = styled.div`
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #333;
|
||||||
|
filter: grayscale(100%) contrast(120%);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProjectDetails = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProjectName = styled.div`
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProjectMeta = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProjectDesc = styled.div`
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 400px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const EmptyState = styled.div`
|
||||||
|
padding: var(--space-xl) 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function ProjectsPage() {
|
||||||
|
const [projects, setProjects] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadProjects = () => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`${API_URL}/api/projects`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setProjects)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
setSyncResult(null);
|
||||||
|
try {
|
||||||
|
const res = await adminFetch('/api/admin/gitea/sync', { method: 'POST' });
|
||||||
|
const d = await res.json();
|
||||||
|
setSyncResult(`sync: ${d.created ?? 0} created, ${d.updated ?? 0} updated`);
|
||||||
|
loadProjects();
|
||||||
|
} catch (e) {
|
||||||
|
setSyncResult('sync failed');
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadProjects(); }, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-lg)' }}>
|
||||||
|
<PageTitle>프로젝트</PageTitle>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
|
||||||
|
{syncResult && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)' }}>
|
||||||
|
{syncResult}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
|
||||||
|
<Btn onClick={handleSync} disabled={syncing}>
|
||||||
|
{syncing ? 'SYNCING...' : '↻ GITEA SYNC'}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<EmptyState>LOADING...</EmptyState>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<EmptyState>NO PROJECTS REGISTERED</EmptyState>
|
||||||
|
) : (
|
||||||
|
<ProjectList>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<ProjectRow key={p.id} href={`/projects/${p.id}`}>
|
||||||
|
<Avatar>📁</Avatar>
|
||||||
|
<ProjectDetails>
|
||||||
|
<ProjectName>{p.name}</ProjectName>
|
||||||
|
<ProjectMeta>
|
||||||
|
<ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc>
|
||||||
|
<TechBar style={{ width: '80px', flexShrink: 0 }}>
|
||||||
|
<TechBarFill $width={p.progress ?? 0} />
|
||||||
|
</TechBar>
|
||||||
|
<LabelMeta style={{ flexShrink: 0, fontSize: '10px' }}>{p.progress ?? 0}%</LabelMeta>
|
||||||
|
</ProjectMeta>
|
||||||
|
</ProjectDetails>
|
||||||
|
<LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta>
|
||||||
|
</ProjectRow>
|
||||||
|
))}
|
||||||
|
</ProjectList>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
frontend/app/register/page.tsx
Normal file
215
frontend/app/register/page.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { API_URL } from '@/lib/config';
|
||||||
|
|
||||||
|
const Page = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-main);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Box = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: var(--space-xl);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Logo = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: var(--space-xxl);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CircleFull = styled.div`
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
background: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CircleHalf = styled.div`
|
||||||
|
width: 10px; height: 20px;
|
||||||
|
background: var(--text-primary);
|
||||||
|
border-radius: 0 20px 20px 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.h1`
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Subtitle = styled.p`
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Form = styled.form`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FieldLabel = styled.label`
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
display: block;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Input = styled.input`
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
|
&:focus { border-color: var(--border-hover); }
|
||||||
|
&::placeholder { color: var(--text-secondary); opacity: 0.5; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SubmitBtn = styled.button`
|
||||||
|
background: var(--text-primary);
|
||||||
|
border: 1px solid var(--text-primary);
|
||||||
|
color: #000;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
|
||||||
|
&:hover { background: transparent; color: var(--text-primary); }
|
||||||
|
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ErrorMsg = styled.div`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: #ff5f5f;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border: 1px solid #ff5f5f44;
|
||||||
|
background: rgba(255,95,95,0.05);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SuccessMsg = styled.div`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: #00FF00;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border: 1px solid rgba(0,255,0,0.3);
|
||||||
|
background: rgba(0,255,0,0.03);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FooterLink = styled.div`
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
a { color: var(--text-primary); text-decoration: none;
|
||||||
|
&:hover { text-decoration: underline; } }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const InviteNote = styled.div`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-top: 4px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const [form, setForm] = useState({ username: '', password: '', inviteCode: '' });
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/auth/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
|
||||||
|
const d = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(Array.isArray(d.message) ? d.message[0] : (d.message ?? '등록 실패'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('hanarang_access_token', d.accessToken);
|
||||||
|
setSuccess('등록 완료. 대시보드로 이동...');
|
||||||
|
setTimeout(() => router.push('/'), 1000);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const set = (key: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setForm((p) => ({ ...p, [key]: e.target.value }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<Box>
|
||||||
|
<Logo><CircleFull /><CircleHalf /></Logo>
|
||||||
|
<Title>계정 등록</Title>
|
||||||
|
<Subtitle>SYSTEM_ACCESS // REGISTER_NEW_NODE</Subtitle>
|
||||||
|
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>USERNAME</FieldLabel>
|
||||||
|
<Input type="text" placeholder="username" value={form.username} onChange={set('username')} required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>PASSWORD</FieldLabel>
|
||||||
|
<Input type="password" placeholder="•••••••• (8자 이상)" value={form.password} onChange={set('password')} required minLength={8} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>INVITE CODE</FieldLabel>
|
||||||
|
<Input type="text" placeholder="초대 코드" value={form.inviteCode} onChange={set('inviteCode')} required />
|
||||||
|
<InviteNote>관리자에게 초대 코드를 요청하세요</InviteNote>
|
||||||
|
</div>
|
||||||
|
{error && <ErrorMsg>ERR: {error}</ErrorMsg>}
|
||||||
|
{success && <SuccessMsg>✓ {success}</SuccessMsg>}
|
||||||
|
<SubmitBtn type="submit" disabled={loading}>
|
||||||
|
{loading ? 'REGISTERING...' : 'REGISTER'}
|
||||||
|
</SubmitBtn>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<FooterLink>
|
||||||
|
이미 계정 있음? <Link href="/login">LOGIN</Link>
|
||||||
|
</FooterLink>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
281
frontend/app/settings/page.tsx
Normal file
281
frontend/app/settings/page.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { PageTitle, LabelMeta, SectionTitle, Btn, BtnPrimary } from '@/components/ui/base';
|
||||||
|
|
||||||
|
// ─── Styled ───
|
||||||
|
const SettingsGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SettingsSection = styled.section``;
|
||||||
|
|
||||||
|
const SettingsRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
padding: var(--space-md) 0;
|
||||||
|
border-bottom: 1px solid #1f1f1f;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SettingInfo = styled.div`
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SettingLabel = styled.div`
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SettingDesc = styled.div`
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Toggle Switch
|
||||||
|
const SwitchWrapper = styled.label`
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 34px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SwitchInput = styled.input`
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Slider = styled.span<{ $checked: boolean }>`
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'};
|
||||||
|
background: transparent;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: ${({ $checked }) => $checked ? '16px' : '2px'};
|
||||||
|
top: 2px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--text-secondary)'};
|
||||||
|
transition: left 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BracketInputGroup = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BracketInput = styled.input`
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
width: 60px;
|
||||||
|
outline: none;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
&:focus { border-bottom-color: var(--text-primary); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ActionBar = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding-top: var(--space-xl);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
`;
|
||||||
|
|
||||||
|
function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) {
|
||||||
|
return (
|
||||||
|
<SwitchWrapper onClick={onChange}>
|
||||||
|
<SwitchInput type="checkbox" checked={checked} onChange={() => {}} />
|
||||||
|
<Slider $checked={checked} />
|
||||||
|
</SwitchWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
return (
|
||||||
|
<BracketInputGroup>
|
||||||
|
[<BracketInput value={value} onChange={(e) => onChange(e.target.value)} />]
|
||||||
|
</BracketInputGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
twofa: true,
|
||||||
|
sessionTimeout: '15',
|
||||||
|
ipWhitelist: false,
|
||||||
|
autoBackup: true,
|
||||||
|
backupInterval: '08:00',
|
||||||
|
backupRetention: '30',
|
||||||
|
queueThreshold: '500',
|
||||||
|
autoScaling: true,
|
||||||
|
cpuThreshold: '90',
|
||||||
|
latencyThreshold: '250',
|
||||||
|
nodeOfflineAlert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = (key: keyof typeof settings) =>
|
||||||
|
setSettings((p) => ({ ...p, [key]: !p[key] }));
|
||||||
|
const setVal = (key: keyof typeof settings, val: string) =>
|
||||||
|
setSettings((p) => ({ ...p, [key]: val }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 'var(--space-lg)' }}>
|
||||||
|
<PageTitle>시스템 설정</PageTitle>
|
||||||
|
<LabelMeta><span>CONFIG:</span>v2.0.4-STABLE</LabelMeta>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsGrid>
|
||||||
|
{/* SEC 01 */}
|
||||||
|
<SettingsSection>
|
||||||
|
<SectionTitle>
|
||||||
|
<span>ADMIN PROTOCOL RULES</span>
|
||||||
|
<LabelMeta>SEC: 01</LabelMeta>
|
||||||
|
</SectionTitle>
|
||||||
|
<SettingsRow>
|
||||||
|
<SettingInfo>
|
||||||
|
<SettingLabel>강제 2단계 인증 (2FA)</SettingLabel>
|
||||||
|
<SettingDesc>모든 관리자 계정에 대해 생체 인식 또는 하드웨어 키 요구.</SettingDesc>
|
||||||
|
</SettingInfo>
|
||||||
|
<Toggle checked={settings.twofa} onChange={() => toggle('twofa')} />
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow>
|
||||||
|
<SettingInfo>
|
||||||
|
<SettingLabel>세션 유효 시간</SettingLabel>
|
||||||
|
<SettingDesc>비활동 시 관리 콘솔 자동 로그아웃 시간(분).</SettingDesc>
|
||||||
|
</SettingInfo>
|
||||||
|
<BInput value={settings.sessionTimeout} onChange={(v) => setVal('sessionTimeout', v)} />
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow>
|
||||||
|
<SettingInfo>
|
||||||
|
<SettingLabel>IP 화이트리스트</SettingLabel>
|
||||||
|
<SettingDesc>지정된 대역에서만 관리자 권한 접근 허용.</SettingDesc>
|
||||||
|
</SettingInfo>
|
||||||
|
<Toggle checked={settings.ipWhitelist} onChange={() => toggle('ipWhitelist')} />
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* SEC 02 */}
|
||||||
|
<SettingsSection>
|
||||||
|
<SectionTitle>
|
||||||
|
<span>BACKUP & REDUNDANCY</span>
|
||||||
|
<LabelMeta>SEC: 02</LabelMeta>
|
||||||
|
</SectionTitle>
|
||||||
|
<SettingsRow>
|
||||||
|
<SettingInfo>
|
||||||
|
<SettingLabel>자동 백업 활성화</SettingLabel>
|
||||||
|
<SettingDesc>전체 시스템 상태의 주기적인 스냅샷 생성.</SettingDesc>
|
||||||
|
</SettingInfo>
|
||||||
|
<Toggle checked={settings.autoBackup} onChange={() => toggle('autoBackup')} />
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow>
|
||||||
|
<SettingInfo>
|
||||||
|
<SettingLabel>백업 주기 (시간)</SettingLabel>
|
||||||
|
<SettingDesc>스냅샷 생성 간격 설정.</SettingDesc>
|
||||||
|
</SettingInfo>
|
||||||
|
<BInput value={settings.backupInterval} onChange={(v) => setVal('backupInterval', v)} />
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow>
|
||||||
|
<SettingInfo>
|
||||||
|
<SettingLabel>보관 주기 (일)</SettingLabel>
|
||||||
|
<SettingDesc>백업 데이터 자동 삭제 전 유지 기간.</SettingDesc>
|
||||||
|
</SettingInfo>
|
||||||
|
<BInput value={settings.backupRetention} onChange={(v) => setVal('backupRetention', v)} />
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* SEC 03 */}
|
||||||
|
<SettingsSection>
|
||||||
|
<SectionTitle>
|
||||||
|
<span>SYNC QUEUE THRESHOLDS</span>
|
||||||
|
<LabelMeta>SEC: 03</LabelMeta>
|
||||||
|
</SectionTitle>
|
||||||
|
<SettingsRow>
|
||||||
|
<SettingInfo>
|
||||||
|
<SettingLabel>대기열 경고 임계값</SettingLabel>
|
||||||
|
<SettingDesc>동기화 대기 항목이 설정치를 초과할 시 경보 발생.</SettingDesc>
|
||||||
|
</SettingInfo>
|
||||||
|
<BInput value={settings.queueThreshold} onChange={(v) => setVal('queueThreshold', v)} />
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow>
|
||||||
|
<SettingInfo>
|
||||||
|
<SettingLabel>자동 스케일링</SettingLabel>
|
||||||
|
<SettingDesc>부하 증가 시 임시 노드 리소스 할당.</SettingDesc>
|
||||||
|
</SettingInfo>
|
||||||
|
<Toggle checked={settings.autoScaling} onChange={() => toggle('autoScaling')} />
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* SEC 04 */}
|
||||||
|
<SettingsSection>
|
||||||
|
<SectionTitle>
|
||||||
|
<span>NODE ALERT TRIGGERS</span>
|
||||||
|
<LabelMeta>SEC: 04</LabelMeta>
|
||||||
|
</SectionTitle>
|
||||||
|
<SettingsRow>
|
||||||
|
<SettingInfo>
|
||||||
|
<SettingLabel>CPU 사용량 임계값 (%)</SettingLabel>
|
||||||
|
<SettingDesc>시스템 부하 경고 기준점.</SettingDesc>
|
||||||
|
</SettingInfo>
|
||||||
|
<BInput value={settings.cpuThreshold} onChange={(v) => setVal('cpuThreshold', v)} />
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow>
|
||||||
|
<SettingInfo>
|
||||||
|
<SettingLabel>레이턴시 경보 (ms)</SettingLabel>
|
||||||
|
<SettingDesc>응답 속도 지연에 대한 임계값 설정.</SettingDesc>
|
||||||
|
</SettingInfo>
|
||||||
|
<BInput value={settings.latencyThreshold} onChange={(v) => setVal('latencyThreshold', v)} />
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow>
|
||||||
|
<SettingInfo>
|
||||||
|
<SettingLabel>노드 오프라인 알림</SettingLabel>
|
||||||
|
<SettingDesc>활성 노드 연결 해제 시 즉시 알림.</SettingDesc>
|
||||||
|
</SettingInfo>
|
||||||
|
<Toggle checked={settings.nodeOfflineAlert} onChange={() => toggle('nodeOfflineAlert')} />
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsGrid>
|
||||||
|
|
||||||
|
<ActionBar>
|
||||||
|
<Btn onClick={() => setSettings((p) => ({ ...p }))}>초기화</Btn>
|
||||||
|
<BtnPrimary>설정 저장</BtnPrimary>
|
||||||
|
</ActionBar>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
frontend/app/sisters/[name]/page.tsx
Normal file
211
frontend/app/sisters/[name]/page.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { PageTitle, LabelMeta, SectionTitle, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent } from '@/components/ui/base';
|
||||||
|
import { API_URL } from '@/lib/config';
|
||||||
|
import { SISTER_ROLES } from '@/lib/sisters';
|
||||||
|
|
||||||
|
const Breadcrumb = styled.div`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DetailGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
gap: var(--space-xxl);
|
||||||
|
align-items: start;
|
||||||
|
|
||||||
|
@media (max-width: 1199px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MetaPanel = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MetaCard = styled.div`
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatusRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatusDot = styled.div<{ $on: boolean }>`
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${({ $on }) => $on ? '#FFF' : '#555'};
|
||||||
|
${({ $on }) => $on && `box-shadow: 0 0 8px rgba(255,255,255,0.3);`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ConfigPre = styled.pre`
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: var(--space-md);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8888aa;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 400px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.6;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TabBar = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TabBtn = styled.button<{ $active: boolean }>`
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
|
||||||
|
&:hover { color: var(--text-primary); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SISTER_DISPLAY: Record<string, string> = {
|
||||||
|
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SisterDetailPage() {
|
||||||
|
const { name } = useParams<{ name: string }>();
|
||||||
|
const [sisterInfo, setSisterInfo] = useState<any>(null);
|
||||||
|
const [configData, setConfigData] = useState<any>(null);
|
||||||
|
const [activity, setActivity] = useState<any[]>([]);
|
||||||
|
const [tab, setTab] = useState('overview');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.allSettled([
|
||||||
|
fetch(`${API_URL}/api/sisters`),
|
||||||
|
fetch(`${API_URL}/api/sisters/${name}/config`),
|
||||||
|
fetch(`${API_URL}/api/sisters/${name}/activity`),
|
||||||
|
]).then(([sRes, cRes, aRes]) => {
|
||||||
|
if (sRes.status === 'fulfilled' && sRes.value.ok) {
|
||||||
|
sRes.value.json().then((d: any[]) => setSisterInfo(d.find((s) => s.name === name)));
|
||||||
|
}
|
||||||
|
if (cRes.status === 'fulfilled' && cRes.value.ok) cRes.value.json().then(setConfigData);
|
||||||
|
if (aRes.status === 'fulfilled' && aRes.value.ok) {
|
||||||
|
aRes.value.json().then((d: any) => setActivity(d.items ?? []));
|
||||||
|
}
|
||||||
|
}).finally(() => setLoading(false));
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
const isActive = sisterInfo?.status === 'online' || sisterInfo?.status === 'working';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Breadcrumb>
|
||||||
|
<Link href="/sisters">자매 노드 관리</Link> / {SISTER_DISPLAY[name] ?? name}
|
||||||
|
</Breadcrumb>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}>
|
||||||
|
<PageTitle>
|
||||||
|
{SISTER_DISPLAY[name] ?? name}
|
||||||
|
<span style={{ fontSize: '16px', color: 'var(--text-secondary)', fontWeight: 400, marginLeft: 'var(--space-sm)' }}>
|
||||||
|
/ {SISTER_ROLES[name] ?? 'UNKNOWN'}
|
||||||
|
</span>
|
||||||
|
</PageTitle>
|
||||||
|
<LabelMeta><span>STATUS:</span>{sisterInfo?.status?.toUpperCase() ?? 'UNKNOWN'}</LabelMeta>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div>
|
||||||
|
) : (
|
||||||
|
<DetailGrid>
|
||||||
|
<MetaPanel>
|
||||||
|
<MetaCard>
|
||||||
|
<StatusRow>
|
||||||
|
<StatusDot $on={isActive} />
|
||||||
|
<LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
|
||||||
|
</StatusRow>
|
||||||
|
<div style={{ margin: 'var(--space-md) 0', fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
||||||
|
LXC: {sisterInfo?.lxcId ?? '--'}<br />
|
||||||
|
LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}<br />
|
||||||
|
ROLE: {SISTER_ROLES[name] ?? '--'}
|
||||||
|
</div>
|
||||||
|
<TechBar>
|
||||||
|
<TechBarFill $width={isActive ? 100 : 0} />
|
||||||
|
</TechBar>
|
||||||
|
</MetaCard>
|
||||||
|
|
||||||
|
{configData?.description && (
|
||||||
|
<MetaCard>
|
||||||
|
<LabelMeta style={{ marginBottom: 'var(--space-sm)', display: 'block' }}>DESC</LabelMeta>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
||||||
|
{configData.description}
|
||||||
|
</div>
|
||||||
|
</MetaCard>
|
||||||
|
)}
|
||||||
|
</MetaPanel>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<TabBar>
|
||||||
|
{[
|
||||||
|
{ id: 'overview', label: '개요' },
|
||||||
|
{ id: 'config', label: '설정' },
|
||||||
|
].map((t) => (
|
||||||
|
<TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
|
||||||
|
{t.label}
|
||||||
|
</TabBtn>
|
||||||
|
))}
|
||||||
|
</TabBar>
|
||||||
|
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<>
|
||||||
|
<SectionTitle>
|
||||||
|
<span>RECENT ACTIVITY</span>
|
||||||
|
<LabelMeta>{activity.length} ENTRIES</LabelMeta>
|
||||||
|
</SectionTitle>
|
||||||
|
<Timeline>
|
||||||
|
{activity.slice(0, 8).map((item) => (
|
||||||
|
<TimelineItem key={item.id}>
|
||||||
|
<TimeStamp>{new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}</TimeStamp>
|
||||||
|
<TimelineContent>{item.detail ?? item.action}</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
))}
|
||||||
|
{activity.length === 0 && (
|
||||||
|
<TimelineItem>
|
||||||
|
<TimelineContent style={{ color: 'var(--text-secondary)' }}>활동 기록 없음</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
)}
|
||||||
|
</Timeline>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'config' && (
|
||||||
|
<>
|
||||||
|
<SectionTitle>SOUL.md / AGENTS.md</SectionTitle>
|
||||||
|
<ConfigPre>{configData?.raw || '(설정 파일 없음)'}</ConfigPre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DetailGrid>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
264
frontend/app/sisters/page.tsx
Normal file
264
frontend/app/sisters/page.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { PageTitle, LabelMeta, BtnToggle } from '@/components/ui/base';
|
||||||
|
import { API_URL } from '@/lib/config';
|
||||||
|
|
||||||
|
const SISTER_ROLES: Record<string, string> = {
|
||||||
|
harang: 'Primary',
|
||||||
|
narang: 'Secondary',
|
||||||
|
darang: 'Standby',
|
||||||
|
erang: 'Sync',
|
||||||
|
};
|
||||||
|
|
||||||
|
const GRAPH_BARS: Record<string, number[]> = {
|
||||||
|
harang: [40, 45, 60, 55, 70, 85, 92, 84],
|
||||||
|
narang: [30, 32, 35, 40, 38, 45, 50, 48],
|
||||||
|
darang: [10, 10, 5, 5, 5, 5, 2, 2],
|
||||||
|
erang: [20, 25, 15, 30, 40, 35, 20, 15],
|
||||||
|
};
|
||||||
|
|
||||||
|
const NodeGrid = styled.section`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeEntry = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr 160px;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
|
&:hover { border-color: var(--border-hover); }
|
||||||
|
|
||||||
|
@media (max-width: 1199px) {
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeInfo = styled.div`
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeName = styled(Link)`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
|
||||||
|
&:hover { color: var(--accent-hover); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatusIndicator = styled.div<{ $active: boolean }>`
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${({ $active }) => $active ? '#FFF' : '#555'};
|
||||||
|
flex-shrink: 0;
|
||||||
|
${({ $active }) => $active && `box-shadow: 0 0 8px rgba(255,255,255,0.3);`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MetaPair = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MetaVal = styled.div`
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeControls = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CapacitySection = styled.div`
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
|
||||||
|
@media (max-width: 1199px) {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const GraphContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 3px;
|
||||||
|
height: 48px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const GraphBar = styled.div<{ $height: number; $highlight?: boolean }>`
|
||||||
|
flex: 1;
|
||||||
|
height: ${({ $height }) => $height}%;
|
||||||
|
min-height: 2px;
|
||||||
|
background: ${({ $highlight }) => $highlight ? 'var(--text-primary)' : 'var(--border-hover)'};
|
||||||
|
transition: height 0.3s;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SyncRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 2px 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SyncHistory = styled.div`
|
||||||
|
padding: var(--space-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
|
||||||
|
@media (max-width: 1199px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function getNodeStatus(s: any): boolean {
|
||||||
|
return s.status === 'online' || s.status === 'working';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBtns(s: any): [string, string] {
|
||||||
|
if (s.status === 'offline') return ['STANDBY', 'ACTIVATE'];
|
||||||
|
if (s.status === 'working') return ['ACTIVE', 'REBOOT'];
|
||||||
|
return ['ACTIVE', 'REBOOT'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastSeen(lastSeen: string | null): string {
|
||||||
|
if (!lastSeen) return '—';
|
||||||
|
const diff = Date.now() - new Date(lastSeen).getTime();
|
||||||
|
const h = Math.floor(diff / 3600000);
|
||||||
|
const m = Math.floor((diff % 3600000) / 60000);
|
||||||
|
const s = Math.floor((diff % 60000) / 1000);
|
||||||
|
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_SISTERS = [
|
||||||
|
{ id: 1, name: 'harang', status: 'online', lastSeen: new Date(Date.now() - 3600000).toISOString() },
|
||||||
|
{ id: 2, name: 'narang', status: 'working', lastSeen: new Date(Date.now() - 1800000).toISOString() },
|
||||||
|
{ id: 3, name: 'darang', status: 'offline', lastSeen: new Date(Date.now() - 7200000).toISOString() },
|
||||||
|
{ id: 4, name: 'erang', status: 'online', lastSeen: new Date(Date.now() - 900000).toISOString() },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SistersPage() {
|
||||||
|
const [sisters, setSisters] = useState<any[]>(MOCK_SISTERS);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_URL}/api/sisters`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setSisters)
|
||||||
|
.catch(() => {});
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
|
||||||
|
}, 15000);
|
||||||
|
return () => clearInterval(iv);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-lg)' }}>
|
||||||
|
<PageTitle>자매 노드 관리</PageTitle>
|
||||||
|
<LabelMeta><span>TOTAL NODES:</span> {String(sisters.length).padStart(2, '0')}</LabelMeta>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NodeGrid>
|
||||||
|
{sisters.map((s) => {
|
||||||
|
const isActive = getNodeStatus(s);
|
||||||
|
const [btn1, btn2] = getStatusBtns(s);
|
||||||
|
const bars = GRAPH_BARS[s.name] ?? [5, 5, 5, 5, 5, 5, 5, 5];
|
||||||
|
const loadIndex = isActive
|
||||||
|
? `${(bars[bars.length - 1]).toFixed(1)}%`
|
||||||
|
: '0.0%';
|
||||||
|
const metaLabel = s.status === 'offline' ? 'Last Active' : s.name === 'erang' ? 'Queue Depth' : 'Uptime';
|
||||||
|
const metaVal = s.status === 'offline'
|
||||||
|
? formatLastSeen(s.lastSeen)
|
||||||
|
: s.name === 'erang' ? '12 PKTS'
|
||||||
|
: formatLastSeen(s.lastSeen).replace(' ago', '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeEntry key={s.id ?? s.name}>
|
||||||
|
<NodeInfo>
|
||||||
|
<NodeName href={`/sisters/${s.name}`}>
|
||||||
|
<StatusIndicator $active={isActive} />
|
||||||
|
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'}{' '}
|
||||||
|
({SISTER_ROLES[s.name] ?? s.name})
|
||||||
|
</NodeName>
|
||||||
|
<MetaPair>
|
||||||
|
<LabelMeta>{metaLabel}</LabelMeta>
|
||||||
|
<MetaVal>{metaVal}</MetaVal>
|
||||||
|
</MetaPair>
|
||||||
|
<NodeControls>
|
||||||
|
<BtnToggle $active>{btn1}</BtnToggle>
|
||||||
|
<BtnToggle>{btn2}</BtnToggle>
|
||||||
|
</NodeControls>
|
||||||
|
</NodeInfo>
|
||||||
|
|
||||||
|
<CapacitySection>
|
||||||
|
<LabelMeta>CAPACITY HISTORY (24H)</LabelMeta>
|
||||||
|
<GraphContainer>
|
||||||
|
{bars.map((h, i) => (
|
||||||
|
<GraphBar key={i} $height={h} $highlight={i >= bars.length - 2} />
|
||||||
|
))}
|
||||||
|
</GraphContainer>
|
||||||
|
<SyncRow>
|
||||||
|
<span>LOAD INDEX</span>
|
||||||
|
<span>{loadIndex}</span>
|
||||||
|
</SyncRow>
|
||||||
|
</CapacitySection>
|
||||||
|
|
||||||
|
<SyncHistory>
|
||||||
|
<LabelMeta>SYNC LOG</LabelMeta>
|
||||||
|
{[
|
||||||
|
{ time: '14:05:22', status: isActive ? 'SUCCESS' : 'IDLE' },
|
||||||
|
{ time: '13:55:01', status: isActive ? 'SUCCESS' : 'IDLE' },
|
||||||
|
{ time: '13:44:10', status: isActive ? 'SUCCESS' : 'IDLE' },
|
||||||
|
].map((row) => (
|
||||||
|
<SyncRow key={row.time}>
|
||||||
|
<span>{row.time}</span>
|
||||||
|
<span style={{ color: row.status === 'SUCCESS' ? '#00FF00' : 'var(--text-secondary)' }}>
|
||||||
|
{row.status}
|
||||||
|
</span>
|
||||||
|
</SyncRow>
|
||||||
|
))}
|
||||||
|
</SyncHistory>
|
||||||
|
</NodeEntry>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</NodeGrid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
frontend/components/admin/BarChart.tsx
Normal file
113
frontend/components/admin/BarChart.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { theme } from '@/styles/theme';
|
||||||
|
|
||||||
|
interface BarChartProps {
|
||||||
|
data: Array<{ label: string; value: number; color?: string }>;
|
||||||
|
maxValue?: number;
|
||||||
|
unit?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.div`
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ChartArea = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
height: 140px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BarWrapper = styled.div`
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Bar = styled.div<{ $height: number; $color: string }>`
|
||||||
|
width: 100%;
|
||||||
|
height: ${({ $height }) => $height}%;
|
||||||
|
min-height: 2px;
|
||||||
|
background: ${({ $color }) => $color};
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
transition: height 0.4s ease;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover::after {
|
||||||
|
content: attr(data-value);
|
||||||
|
position: absolute;
|
||||||
|
top: -24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
background: rgba(22,27,34,0.95);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Label = styled.div`
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 60px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Empty = styled.div`
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const COLORS = ['#58A6FF', '#00FF00', '#FF9800', '#E040FB', '#00BCD4'];
|
||||||
|
|
||||||
|
export default function BarChart({ data, maxValue, unit = '', title }: BarChartProps) {
|
||||||
|
if (!data.length) return <Empty>데이터 없음</Empty>;
|
||||||
|
|
||||||
|
const max = maxValue ?? Math.max(...data.map((d) => d.value), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
{title && <Title>{title}</Title>}
|
||||||
|
<ChartArea>
|
||||||
|
{data.map((item, i) => {
|
||||||
|
const height = Math.max((item.value / max) * 100, item.value > 0 ? 2 : 0);
|
||||||
|
const color = item.color ?? COLORS[i % COLORS.length];
|
||||||
|
const displayValue = unit === '$'
|
||||||
|
? `$${item.value.toFixed(4)}`
|
||||||
|
: `${item.value.toLocaleString()}${unit}`;
|
||||||
|
return (
|
||||||
|
<BarWrapper key={item.label}>
|
||||||
|
<Bar $height={height} $color={color} data-value={displayValue} />
|
||||||
|
<Label title={item.label}>{item.label}</Label>
|
||||||
|
</BarWrapper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ChartArea>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
frontend/components/admin/CodeEditor.tsx
Normal file
44
frontend/components/admin/CodeEditor.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const Textarea = styled.textarea`
|
||||||
|
width: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
background: #0a0e14;
|
||||||
|
border: 1px solid #1e2430;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #a8c4e0;
|
||||||
|
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 16px;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
tab-size: 2;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: #58A6FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar { width: 4px; }
|
||||||
|
&::-webkit-scrollbar-thumb { background: #1e2430; border-radius: 2px; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface CodeEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CodeEditor({ value, onChange, readOnly }: CodeEditorProps) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
readOnly={readOnly}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
frontend/components/admin/ConfirmModal.tsx
Normal file
115
frontend/components/admin/ConfirmModal.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { theme } from '@/styles/theme';
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Overlay = styled.div`
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
z-index: 300;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Modal = styled.div`
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 28px;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 480px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.h2`
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Message = styled.p`
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Buttons = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Btn = styled.button<{ $danger?: boolean; $primary?: boolean }>`
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
${({ $danger }) =>
|
||||||
|
$danger &&
|
||||||
|
`
|
||||||
|
background: rgba(255,23,68,0.12);
|
||||||
|
border-color: rgba(255,23,68,0.4);
|
||||||
|
color: #FF1744;
|
||||||
|
&:hover { background: rgba(255,23,68,0.2); }
|
||||||
|
`}
|
||||||
|
|
||||||
|
${({ $primary }) =>
|
||||||
|
$primary &&
|
||||||
|
`
|
||||||
|
background: rgba(88,166,255,0.12);
|
||||||
|
border-color: rgba(88,166,255,0.4);
|
||||||
|
color: #58A6FF;
|
||||||
|
&:hover { background: rgba(88,166,255,0.2); }
|
||||||
|
`}
|
||||||
|
|
||||||
|
${({ $danger, $primary }) =>
|
||||||
|
!$danger && !$primary &&
|
||||||
|
`
|
||||||
|
background: transparent;
|
||||||
|
border-color: rgba(240,246,252,0.15);
|
||||||
|
color: #8B949E;
|
||||||
|
&:hover { background: rgba(240,246,252,0.06); color: #E6EDF3; }
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function ConfirmModal({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = '확인',
|
||||||
|
danger,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: ConfirmModalProps) {
|
||||||
|
return (
|
||||||
|
<Overlay onClick={onCancel}>
|
||||||
|
<Modal onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Title>{title}</Title>
|
||||||
|
<Message>{message}</Message>
|
||||||
|
<Buttons>
|
||||||
|
<Btn onClick={onCancel}>취소</Btn>
|
||||||
|
<Btn $danger={danger} $primary={!danger} onClick={onConfirm}>
|
||||||
|
{confirmLabel}
|
||||||
|
</Btn>
|
||||||
|
</Buttons>
|
||||||
|
</Modal>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
frontend/components/admin/LogTerminal.tsx
Normal file
120
frontend/components/admin/LogTerminal.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
background: #0a0e14;
|
||||||
|
border: 1px solid #1e2430;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Toolbar = styled.div`
|
||||||
|
background: #0d1117;
|
||||||
|
border-bottom: 1px solid #1e2430;
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SearchInput = styled.input`
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid #1e2430;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #8B949E;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
outline: none;
|
||||||
|
width: 200px;
|
||||||
|
|
||||||
|
&:focus { border-color: #58A6FF; color: #E6EDF3; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FilterBtn = styled.button<{ $active: boolean }>`
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid ${({ $active }) => $active ? 'rgba(255,23,68,0.5)' : '#1e2430'};
|
||||||
|
background: ${({ $active }) => $active ? 'rgba(255,23,68,0.1)' : 'transparent'};
|
||||||
|
color: ${({ $active }) => $active ? '#FF1744' : '#8B949E'};
|
||||||
|
transition: all 0.15s;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Terminal = styled.div`
|
||||||
|
height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
font-family: 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar { width: 4px; }
|
||||||
|
&::-webkit-scrollbar-thumb { background: #1e2430; border-radius: 2px; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LogLine = styled.div<{ $isError: boolean }>`
|
||||||
|
color: ${({ $isError }) => $isError ? '#FF1744' : '#a8c4e0'};
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
&:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LineNum = styled.span`
|
||||||
|
color: #3d4f5a;
|
||||||
|
margin-right: 12px;
|
||||||
|
user-select: none;
|
||||||
|
min-width: 32px;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: right;
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface LogTerminalProps {
|
||||||
|
lines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogTerminal({ lines }: LogTerminalProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [errorsOnly, setErrorsOnly] = useState(false);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [lines]);
|
||||||
|
|
||||||
|
const filtered = lines.filter((line) => {
|
||||||
|
if (errorsOnly && !/error|fail|exception|warn/i.test(line)) return false;
|
||||||
|
if (search && !line.toLowerCase().includes(search.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<Toolbar>
|
||||||
|
<SearchInput
|
||||||
|
placeholder="검색..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<FilterBtn $active={errorsOnly} onClick={() => setErrorsOnly(!errorsOnly)}>
|
||||||
|
🔴 에러만
|
||||||
|
</FilterBtn>
|
||||||
|
<span style={{ fontSize: '11px', color: '#3d4f5a', marginLeft: 'auto' }}>
|
||||||
|
{filtered.length}/{lines.length} lines
|
||||||
|
</span>
|
||||||
|
</Toolbar>
|
||||||
|
<Terminal>
|
||||||
|
{filtered.map((line, i) => (
|
||||||
|
<LogLine key={i} $isError={/error|fail|exception/i.test(line)}>
|
||||||
|
<LineNum>{i + 1}</LineNum>
|
||||||
|
{line}
|
||||||
|
</LogLine>
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</Terminal>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user