Compare commits
24 Commits
feature/ho
...
194bc5a91c
| Author | SHA1 | Date | |
|---|---|---|---|
| 194bc5a91c | |||
| 339678b962 | |||
| 035ba96381 | |||
| ce323fdfc1 | |||
| a942f4e62d | |||
|
|
e0fd04b4f1 | ||
| b387c638c9 | |||
| 2089283386 | |||
| 286f472afd | |||
| 9a86d04731 | |||
| b469a1337a | |||
|
|
0ce70ec615 | ||
| 97668007b1 | |||
| 230d3847ef | |||
| 5e6c7085d3 | |||
|
|
23fd1ecb09 | ||
|
|
be052ec707 | ||
| b3690c9206 | |||
| 914903466c | |||
|
|
929b4e0745 | ||
| 64802c30c5 | |||
| fa33308fe4 | |||
| dcf5073693 | |||
|
|
50ea60f944 |
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│ ┌─────────────────┐ │
|
||||||
│ │ 하랑이 │ │
|
│ PROJ│ │ [HQ] 하나랑 글로벌 │ │
|
||||||
│ │ Orchestrator │ │
|
│◗ORG │ │ MEM:142 S_ADMIN │ │
|
||||||
│ └──────┬──────┘ │
|
│ CONF│ └────────┬────────┘ │
|
||||||
│ ┌───────────┼───────────┐ │
|
│ │ ┌───────────┼───────────┐ │
|
||||||
│ ┌─────┴─────┐ ┌───┴────┐ ┌───┴────┐ │
|
│ │ ┌──────┴──────┐ ┌─┴────┐ ┌───┴───┐ │
|
||||||
│ │ 나랑이 │ │ 다랑이 │ │ 이랑이 │ │
|
│ │ │[R&D] 기술연구│ │[OPS] │ │[EXT] │ │
|
||||||
│ │ Generator │ │Evaluator│ │ Infra │ │
|
│ │ │ 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 필수**
|
||||||
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 전환 완료
|
||||||
|
- 백엔드 변경 없어 기능 회귀 위험 없음
|
||||||
490
backend/package-lock.json
generated
490
backend/package-lock.json
generated
@@ -12,11 +12,16 @@
|
|||||||
"@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/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",
|
"axios": "^1.14.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^17.4.0",
|
"dotenv": "^17.4.0",
|
||||||
@@ -25,9 +30,11 @@
|
|||||||
"node-ssh": "^13.2.1",
|
"node-ssh": "^13.2.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-http-bearer": "^1.0.1",
|
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
@@ -35,10 +42,12 @@
|
|||||||
"@nestjs/cli": "^11.0.17",
|
"@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-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",
|
||||||
@@ -2285,6 +2294,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/jwt": {
|
||||||
|
"version": "11.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz",
|
||||||
|
"integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
|
"jsonwebtoken": "9.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/passport": {
|
"node_modules/@nestjs/passport": {
|
||||||
"version": "11.0.5",
|
"version": "11.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
|
||||||
@@ -2316,6 +2338,25 @@
|
|||||||
"@nestjs/core": "^11.0.0"
|
"@nestjs/core": "^11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/platform-socket.io": {
|
||||||
|
"version": "11.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.18.tgz",
|
||||||
|
"integrity": "sha512-DiFRpMIdFaHqZQFwqLqGHMdNurrKVkRkMHxIrecjooPHJNNIMgrbpYZ+oJW8hpwifUyZUL4r4uXXRy4+yFEMjA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"socket.io": "4.8.3",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/nest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^11.0.0",
|
||||||
|
"@nestjs/websockets": "^11.0.0",
|
||||||
|
"rxjs": "^7.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/schematics": {
|
"node_modules/@nestjs/schematics": {
|
||||||
"version": "11.0.10",
|
"version": "11.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.10.tgz",
|
||||||
@@ -2455,6 +2496,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/throttler": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||||
|
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||||
|
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nestjs/websockets": {
|
||||||
|
"version": "11.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.18.tgz",
|
||||||
|
"integrity": "sha512-HLO/QGlJoJaMMDphgjbcIwW7/8EA8nnFQjf+qPvA+wWLLfPKoiFPUezc5m9YgN2A7jmzwo6YmEAXeHyqO9tvTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iterare": "1.2.1",
|
||||||
|
"object-hash": "3.0.0",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^11.0.0",
|
||||||
|
"@nestjs/core": "^11.0.0",
|
||||||
|
"@nestjs/platform-socket.io": "^11.0.0",
|
||||||
|
"reflect-metadata": "^0.1.12 || ^0.2.0",
|
||||||
|
"rxjs": "^7.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@nestjs/platform-socket.io": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@noble/hashes": {
|
"node_modules/@noble/hashes": {
|
||||||
"version": "1.8.0",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
@@ -2936,6 +3011,12 @@
|
|||||||
"@sinonjs/commons": "^3.0.1"
|
"@sinonjs/commons": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -3059,6 +3140,16 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcrypt": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -3107,6 +3198,15 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/cors": {
|
||||||
|
"version": "2.8.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||||
|
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/eslint": {
|
"node_modules/@types/eslint": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||||
@@ -3226,6 +3326,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jsonwebtoken": {
|
||||||
|
"version": "9.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/keygrip": {
|
"node_modules/@types/keygrip": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz",
|
||||||
@@ -3267,6 +3377,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.12.2",
|
"version": "24.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||||
@@ -3298,6 +3414,28 @@
|
|||||||
"@types/passport": "*"
|
"@types/passport": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/passport-jwt": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/jsonwebtoken": "*",
|
||||||
|
"@types/passport-strategy": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/passport-strategy": {
|
||||||
|
"version": "0.2.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
|
||||||
|
"integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/express": "*",
|
||||||
|
"@types/passport": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -3380,6 +3518,15 @@
|
|||||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.35",
|
"version": "17.0.35",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||||
@@ -4546,6 +4693,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64id": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^4.5.0 || >= 5.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.14",
|
"version": "2.10.14",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz",
|
||||||
@@ -4559,6 +4715,20 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcrypt": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^8.3.0",
|
||||||
|
"node-gyp-build": "^4.8.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bcrypt-pbkdf": {
|
"node_modules/bcrypt-pbkdf": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||||
@@ -4722,6 +4892,12 @@
|
|||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -5544,6 +5720,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -5605,6 +5790,79 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/engine.io": {
|
||||||
|
"version": "6.6.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz",
|
||||||
|
"integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/cors": "^2.8.12",
|
||||||
|
"@types/node": ">=10.0.0",
|
||||||
|
"@types/ws": "^8.5.12",
|
||||||
|
"accepts": "~1.3.4",
|
||||||
|
"base64id": "2.0.0",
|
||||||
|
"cookie": "~0.7.2",
|
||||||
|
"cors": "~2.8.5",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.18.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io/node_modules/accepts": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "~2.1.34",
|
||||||
|
"negotiator": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io/node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io/node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io/node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.20.1",
|
"version": "5.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
|
||||||
@@ -8061,6 +8319,49 @@
|
|||||||
"graceful-fs": "^4.1.6"
|
"graceful-fs": "^4.1.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^4.0.1",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -8164,6 +8465,42 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.memoize": {
|
"node_modules/lodash.memoize": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||||
@@ -8178,6 +8515,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/log-symbols": {
|
"node_modules/log-symbols": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
|
||||||
@@ -8622,6 +8965,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "8.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
|
||||||
|
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20 || >= 21"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-emoji": {
|
"node_modules/node-emoji": {
|
||||||
"version": "1.11.0",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
|
||||||
@@ -8638,6 +8990,17 @@
|
|||||||
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build": {
|
||||||
|
"version": "4.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
|
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build": "bin.js",
|
||||||
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
"node-gyp-build-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-int64": {
|
"node_modules/node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
@@ -8748,6 +9111,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-hash": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -8964,6 +9336,16 @@
|
|||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/passport-jwt": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
|
"passport-strategy": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/passport-strategy": {
|
"node_modules/passport-strategy": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||||
@@ -9668,7 +10050,6 @@
|
|||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -9860,6 +10241,90 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/socket.io": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "~1.3.4",
|
||||||
|
"base64id": "~2.0.0",
|
||||||
|
"cors": "~2.8.5",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io": "~6.6.0",
|
||||||
|
"socket.io-adapter": "~2.5.2",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-adapter": {
|
||||||
|
"version": "2.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
|
||||||
|
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"ws": "~8.18.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser": {
|
||||||
|
"version": "4.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||||
|
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io/node_modules/accepts": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "~2.1.34",
|
||||||
|
"negotiator": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io/node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io/node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io/node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.7.4",
|
"version": "0.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
||||||
@@ -11288,6 +11753,27 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
@@ -24,11 +24,16 @@
|
|||||||
"@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/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",
|
"axios": "^1.14.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^17.4.0",
|
"dotenv": "^17.4.0",
|
||||||
@@ -37,9 +42,11 @@
|
|||||||
"node-ssh": "^13.2.1",
|
"node-ssh": "^13.2.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-http-bearer": "^1.0.1",
|
"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"
|
||||||
@@ -50,10 +57,12 @@
|
|||||||
"@nestjs/cli": "^11.0.17",
|
"@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-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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ActivityController } from './activity.controller';
|
import { ActivityController } from './activity.controller';
|
||||||
import { ActivityService } from './activity.service';
|
import { ActivityService } from './activity.service';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { EventsModule } from '../events/events.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule, EventsModule],
|
||||||
controllers: [ActivityController],
|
controllers: [ActivityController],
|
||||||
providers: [ActivityService],
|
providers: [ActivityService],
|
||||||
exports: [ActivityService],
|
exports: [ActivityService],
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ describe('ActivityService', () => {
|
|||||||
|
|
||||||
it('log: activityLog.create 호출', async () => {
|
it('log: activityLog.create 호출', async () => {
|
||||||
await service.log({ action: 'test_action', detail: 'detail' });
|
await service.log({ action: 'test_action', detail: 'detail' });
|
||||||
expect(mockPrisma.activityLog.create).toHaveBeenCalledWith({
|
expect(mockPrisma.activityLog.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
data: { action: 'test_action', detail: 'detail' },
|
data: { action: 'test_action', detail: 'detail' },
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getFeed: items + total 반환', async () => {
|
it('getFeed: items + total 반환', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { EventsGateway } from '../events/events.gateway';
|
||||||
|
|
||||||
export interface LogActivityDto {
|
export interface LogActivityDto {
|
||||||
sisterId?: number;
|
sisterId?: number;
|
||||||
@@ -10,10 +11,26 @@ export interface LogActivityDto {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ActivityService {
|
export class ActivityService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
@Optional() private readonly events?: EventsGateway,
|
||||||
|
) {}
|
||||||
|
|
||||||
async log(dto: LogActivityDto) {
|
async log(dto: LogActivityDto) {
|
||||||
return this.prisma.activityLog.create({ data: dto });
|
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) {
|
async getFeed(limit = 50, offset = 0) {
|
||||||
|
|||||||
@@ -9,16 +9,23 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
import { ApiKeyGuard } from '../auth/api-key.guard';
|
import { CompositeGuard } from '../auth/jwt.guard';
|
||||||
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
import { SisterNamePipe } from '../common/sister-name.pipe';
|
import { SisterNamePipe } from '../common/sister-name.pipe';
|
||||||
import type { SisterName } from '../common/sister-name.pipe';
|
import type { SisterName } from '../common/sister-name.pipe';
|
||||||
|
|
||||||
|
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
class UpdateHarnessDto {
|
class UpdateHarnessDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(50000)
|
||||||
content!: string;
|
content!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('api/admin')
|
@Controller('api/admin')
|
||||||
@UseGuards(ApiKeyGuard)
|
@UseGuards(CompositeGuard, RoleGuard)
|
||||||
|
@Roles('admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
constructor(private readonly adminService: AdminService) {}
|
constructor(private readonly adminService: AdminService) {}
|
||||||
|
|
||||||
@@ -54,6 +61,8 @@ export class AdminController {
|
|||||||
@Param('name', SisterNamePipe) name: SisterName,
|
@Param('name', SisterNamePipe) name: SisterName,
|
||||||
@Query('lines') lines?: string,
|
@Query('lines') lines?: string,
|
||||||
) {
|
) {
|
||||||
return this.adminService.getSisterLogs(name, lines ? parseInt(lines, 10) : 100);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ export class AdminService {
|
|||||||
const sister = await this.getSister(name);
|
const sister = await this.getSister(name);
|
||||||
const keyPath = this.getKeyPath();
|
const keyPath = this.getKeyPath();
|
||||||
|
|
||||||
// 내용에서 위험한 셸 escape 방지
|
// 내용에서 위험한 셸 escape + null byte 방지
|
||||||
const escaped = content.replace(/'/g, "'\\''");
|
const escaped = content.replace(/\x00/g, '').replace(/'/g, "'\\''");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ssh.executeCommand(
|
const result = await this.ssh.executeCommand(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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';
|
||||||
@@ -12,10 +13,14 @@ import { ActivityModule } from './activity/activity.module';
|
|||||||
import { OrgModule } from './org/org.module';
|
import { OrgModule } from './org/org.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { AdminModule } from './admin/admin.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,
|
||||||
@@ -26,6 +31,9 @@ import { AdminModule } from './admin/admin.module';
|
|||||||
OrgModule,
|
OrgModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
EventsModule,
|
||||||
|
CostsModule,
|
||||||
|
GiteaSyncModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ export class ApiKeyStrategy extends PassportStrategy(Strategy, 'api-key') {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
validate(token: string): boolean {
|
validate(token: string): { role: string; username: string } {
|
||||||
if (!this.validKeys.size) {
|
if (!this.validKeys.size) {
|
||||||
throw new UnauthorizedException('ADMIN_API_KEYS not configured');
|
throw new UnauthorizedException('ADMIN_API_KEYS not configured');
|
||||||
}
|
}
|
||||||
if (!this.validKeys.has(token)) {
|
if (!this.validKeys.has(token)) {
|
||||||
throw new UnauthorizedException('Invalid API key');
|
throw new UnauthorizedException('Invalid API key');
|
||||||
}
|
}
|
||||||
return true;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,32 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
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 { ApiKeyStrategy } from './api-key.strategy';
|
||||||
|
import { JwtGuard, CompositeGuard } from './jwt.guard';
|
||||||
import { ApiKeyGuard } from './api-key.guard';
|
import { ApiKeyGuard } from './api-key.guard';
|
||||||
|
import { RoleGuard } from './role.guard';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PassportModule, ConfigModule],
|
imports: [
|
||||||
providers: [ApiKeyStrategy, ApiKeyGuard],
|
PassportModule,
|
||||||
exports: [ApiKeyGuard],
|
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 {}
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,24 @@ export interface GiteaPR {
|
|||||||
html_url: string;
|
html_url: string;
|
||||||
user: { login: string };
|
user: { login: string };
|
||||||
created_at: 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()
|
@Injectable()
|
||||||
@@ -87,4 +105,44 @@ export class GiteaService {
|
|||||||
return [];
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async function bootstrap() {
|
|||||||
callback(new Error(`CORS: ${origin} not allowed`));
|
callback(new Error(`CORS: ${origin} not allowed`));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
|
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
|
import { Controller, Get, Param, Query, ParseIntPipe, UseGuards, Post } from '@nestjs/common';
|
||||||
import { ProjectsService } from './projects.service';
|
import { ProjectsService } from './projects.service';
|
||||||
|
import { CompositeGuard } from '../auth/jwt.guard';
|
||||||
|
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||||
|
|
||||||
@Controller('api/projects')
|
@Controller('api/projects')
|
||||||
export class ProjectsController {
|
export class ProjectsController {
|
||||||
@@ -14,4 +16,27 @@ export class ProjectsController {
|
|||||||
getProjectById(@Param('id', ParseIntPipe) id: number) {
|
getProjectById(@Param('id', ParseIntPipe) id: number) {
|
||||||
return this.projectsService.getProjectById(id);
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,4 +79,28 @@ export class ProjectsService {
|
|||||||
|
|
||||||
return { ...project, openPRs: giteaPRs };
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ import { PrismaModule } from '../prisma/prisma.module';
|
|||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
controllers: [SistersController],
|
controllers: [SistersController],
|
||||||
providers: [SistersService, SisterDetailService, SshService],
|
providers: [SistersService, SisterDetailService, SshService],
|
||||||
exports: [SisterDetailService, SshService],
|
exports: [SistersService, SisterDetailService, SshService],
|
||||||
})
|
})
|
||||||
export class SistersModule {}
|
export class SistersModule {}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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;
|
||||||
@@ -29,6 +30,7 @@ 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[]> {
|
||||||
@@ -62,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 {
|
||||||
@@ -77,11 +79,20 @@ 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 {
|
||||||
|
|||||||
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import CodeEditor from '@/components/admin/CodeEditor';
|
import CodeEditor from '@/components/admin/CodeEditor';
|
||||||
import { theme } from '@/styles/theme';
|
|
||||||
import { adminFetch } from '@/lib/adminFetch';
|
import { adminFetch } from '@/lib/adminFetch';
|
||||||
|
|
||||||
const SISTERS = ['harang', 'narang', 'darang', 'erang'];
|
const SISTERS = ['harang', 'narang', 'darang', 'erang'];
|
||||||
@@ -20,14 +19,14 @@ const Layout = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const FileTree = styled.div`
|
const FileTree = styled.div`
|
||||||
background: ${theme.colors.cardBg};
|
background: var(--bg-surface);
|
||||||
border: 1px solid ${theme.colors.border};
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const TreeSection = styled.div`
|
const TreeSection = styled.div`
|
||||||
border-bottom: 1px solid ${theme.colors.border};
|
border-bottom: 1px solid var(--border-color);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
&:last-child { border-bottom: none; }
|
&:last-child { border-bottom: none; }
|
||||||
`;
|
`;
|
||||||
@@ -35,7 +34,7 @@ const TreeSection = styled.div`
|
|||||||
const TreeLabel = styled.div`
|
const TreeLabel = styled.div`
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
padding: 6px 8px 4px;
|
padding: 6px 8px 4px;
|
||||||
@@ -50,10 +49,10 @@ const TreeItem = styled.button<{ $active: boolean }>`
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.12)' : 'transparent'};
|
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.12)' : 'transparent'};
|
||||||
color: ${({ $active }) => $active ? theme.colors.accent : theme.colors.textSecondary};
|
color: ${({ $active }) => $active ? '#58A6FF' : 'var(--text-secondary)'};
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
|
||||||
&:hover { background: rgba(240,246,252,0.06); color: ${theme.colors.textPrimary}; }
|
&:hover { background: rgba(240,246,252,0.06); color: var(--text-primary); }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const EditorPane = styled.div`
|
const EditorPane = styled.div`
|
||||||
@@ -70,7 +69,7 @@ const Toolbar = styled.div`
|
|||||||
|
|
||||||
const PathLabel = styled.span`
|
const PathLabel = styled.span`
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
`;
|
`;
|
||||||
@@ -105,7 +104,7 @@ const ResultMsg = styled.div<{ $success: boolean }>`
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
background: ${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};
|
background: ${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};
|
||||||
color: ${({ $success }) => $success ? theme.colors.online : theme.colors.offline};
|
color: ${({ $success }) => $success ? '#00FF00' : '#FF1744'};
|
||||||
border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};
|
border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -137,6 +136,15 @@ export default function HarnessPage() {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ content }),
|
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();
|
const d = await res.json();
|
||||||
if (d.success) {
|
if (d.success) {
|
||||||
setSaved(content);
|
setSaved(content);
|
||||||
@@ -189,7 +197,7 @@ export default function HarnessPage() {
|
|||||||
{result && <ResultMsg $success={result.success}>{result.msg}</ResultMsg>}
|
{result && <ResultMsg $success={result.success}>{result.msg}</ResultMsg>}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ padding: '32px', textAlign: 'center', color: theme.colors.textSecondary, fontSize: '13px' }}>
|
<div style={{ padding: '32px', textAlign: 'center', color: 'var(--text-secondary)', fontSize: '13px' }}>
|
||||||
로딩 중...
|
로딩 중...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -4,84 +4,67 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { theme } from '@/styles/theme';
|
import { LabelMeta, SectionTitle } from '@/components/ui/base';
|
||||||
|
|
||||||
const ADMIN_TABS = [
|
const ADMIN_TABS = [
|
||||||
{ href: '/admin', label: '자매 관리', icon: '⚙️' },
|
{ href: '/admin', label: '자매 관리' },
|
||||||
{ href: '/admin/harness', label: '하네스 편집', icon: '✏️' },
|
{ href: '/admin/harness', label: '하네스 편집' },
|
||||||
{ href: '/admin/logs', label: '로그 뷰어', icon: '📋' },
|
{ href: '/admin/logs', label: '로그 뷰어' },
|
||||||
{ href: '/admin/repos', label: '저장소', icon: '📁' },
|
{ href: '/admin/costs', label: '비용 모니터' },
|
||||||
|
{ href: '/admin/repos', label: '저장소' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const Wrapper = styled.div`
|
const Wrapper = styled.div`
|
||||||
padding: 32px;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: ${theme.colors.bg};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Header = styled.div`
|
|
||||||
margin-bottom: 24px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Title = styled.h1`
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: ${theme.colors.textPrimary};
|
|
||||||
margin-bottom: 4px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Subtitle = styled.p`
|
|
||||||
font-size: 13px;
|
|
||||||
color: ${theme.colors.textSecondary};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TabRow = styled.div`
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
flex-direction: column;
|
||||||
border-bottom: 1px solid ${theme.colors.border};
|
gap: var(--space-lg);
|
||||||
margin-bottom: 24px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Tab = styled(Link)<{ $active: boolean }>`
|
const AdminHeader = styled.div`
|
||||||
padding: 10px 16px;
|
border-bottom: 1px solid var(--border-color);
|
||||||
font-size: 13px;
|
padding-bottom: var(--space-md);
|
||||||
font-weight: ${({ $active }) => $active ? '600' : '400'};
|
`;
|
||||||
color: ${({ $active }) => $active ? theme.colors.textPrimary : theme.colors.textSecondary};
|
|
||||||
border-bottom: 2px solid ${({ $active }) => $active ? theme.colors.accent : 'transparent'};
|
const TabNav = styled.div`
|
||||||
transition: color 0.15s, border-color 0.15s;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
gap: var(--space-lg);
|
||||||
gap: 6px;
|
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;
|
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: ${theme.colors.textPrimary}; }
|
&:hover { color: var(--text-primary); }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ApiKeyRow = styled.div`
|
const ApiKeyRow = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 20px;
|
gap: var(--space-sm);
|
||||||
padding: 10px 14px;
|
font-family: var(--font-mono);
|
||||||
background: rgba(88,166,255,0.06);
|
font-size: 12px;
|
||||||
border: 1px solid rgba(88,166,255,0.15);
|
color: var(--text-secondary);
|
||||||
border-radius: 8px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ApiKeyInput = styled.input`
|
const ApiKeyInput = styled.input`
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: ${theme.colors.textPrimary};
|
border-bottom: 1px solid var(--border-color);
|
||||||
font-size: 13px;
|
color: var(--text-primary);
|
||||||
font-family: monospace;
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
outline: none;
|
outline: none;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
`;
|
max-width: 300px;
|
||||||
|
padding: 2px 0;
|
||||||
|
|
||||||
const ApiKeyLabel = styled.span`
|
&:focus { border-bottom-color: var(--text-primary); }
|
||||||
font-size: 12px;
|
|
||||||
color: ${theme.colors.textSecondary};
|
|
||||||
white-space: nowrap;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -99,29 +82,30 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<Header>
|
<AdminHeader>
|
||||||
<Title>⚙️ 관리자</Title>
|
<div style={{ fontSize: '28px', fontWeight: 600, letterSpacing: '-0.02em', marginBottom: 'var(--space-md)' }}>
|
||||||
<Subtitle>자매 관리, 하네스 편집, 로그 뷰어</Subtitle>
|
관리자
|
||||||
</Header>
|
</div>
|
||||||
|
<TabNav>
|
||||||
|
{ADMIN_TABS.map((tab) => (
|
||||||
|
<TabLink key={tab.href} href={tab.href} $active={pathname === tab.href}>
|
||||||
|
{tab.label}
|
||||||
|
</TabLink>
|
||||||
|
))}
|
||||||
|
</TabNav>
|
||||||
|
</AdminHeader>
|
||||||
|
|
||||||
<ApiKeyRow>
|
<ApiKeyRow>
|
||||||
<ApiKeyLabel>🔑 Admin Key:</ApiKeyLabel>
|
<span>KEY [</span>
|
||||||
<ApiKeyInput
|
<ApiKeyInput
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="ADMIN_API_KEYS 값 입력"
|
placeholder="ADMIN_API_KEY"
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => saveKey(e.target.value)}
|
onChange={(e) => saveKey(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<span>]</span>
|
||||||
</ApiKeyRow>
|
</ApiKeyRow>
|
||||||
|
|
||||||
<TabRow>
|
|
||||||
{ADMIN_TABS.map((tab) => (
|
|
||||||
<Tab key={tab.href} href={tab.href} $active={pathname === tab.href}>
|
|
||||||
{tab.icon} {tab.label}
|
|
||||||
</Tab>
|
|
||||||
))}
|
|
||||||
</TabRow>
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import LogTerminal from '@/components/admin/LogTerminal';
|
import LogTerminal from '@/components/admin/LogTerminal';
|
||||||
import { theme } from '@/styles/theme';
|
|
||||||
import { adminFetch } from '@/lib/adminFetch';
|
import { adminFetch } from '@/lib/adminFetch';
|
||||||
|
|
||||||
const SISTERS = [
|
const SISTERS = [
|
||||||
@@ -21,28 +20,28 @@ const Controls = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const Select = styled.select`
|
const Select = styled.select`
|
||||||
background: ${theme.colors.cardBg};
|
background: var(--bg-surface);
|
||||||
border: 1px solid ${theme.colors.border};
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
color: ${theme.colors.textPrimary};
|
color: var(--text-primary);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
&:focus { border-color: ${theme.colors.accent}; }
|
&:focus { border-color: #58A6FF; }
|
||||||
option { background: #1a1f2a; }
|
option { background: #1a1f2a; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const LinesInput = styled.input`
|
const LinesInput = styled.input`
|
||||||
background: ${theme.colors.cardBg};
|
background: var(--bg-surface);
|
||||||
border: 1px solid ${theme.colors.border};
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
color: ${theme.colors.textPrimary};
|
color: var(--text-primary);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
width: 80px;
|
width: 80px;
|
||||||
outline: none;
|
outline: none;
|
||||||
&:focus { border-color: ${theme.colors.accent}; }
|
&:focus { border-color: #58A6FF; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const FetchBtn = styled.button`
|
const FetchBtn = styled.button`
|
||||||
@@ -52,7 +51,7 @@ const FetchBtn = styled.button`
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: rgba(88,166,255,0.1);
|
background: rgba(88,166,255,0.1);
|
||||||
border: 1px solid rgba(88,166,255,0.35);
|
border: 1px solid rgba(88,166,255,0.35);
|
||||||
color: ${theme.colors.accent};
|
color: #58A6FF;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
&:hover { background: rgba(88,166,255,0.2); }
|
&:hover { background: rgba(88,166,255,0.2); }
|
||||||
@@ -61,7 +60,7 @@ const FetchBtn = styled.button`
|
|||||||
|
|
||||||
const Label = styled.span`
|
const Label = styled.span`
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function LogsPage() {
|
export default function LogsPage() {
|
||||||
@@ -83,6 +82,11 @@ export default function LogsPage() {
|
|||||||
}
|
}
|
||||||
}, [sister, lines]);
|
}, [sister, lines]);
|
||||||
|
|
||||||
|
// 마운트 시 자동 로드
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
}, [fetchLogs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Controls>
|
<Controls>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import StatusBadge from '@/components/common/StatusBadge';
|
import StatusBadge from '@/components/common/StatusBadge';
|
||||||
import ConfirmModal from '@/components/admin/ConfirmModal';
|
import ConfirmModal from '@/components/admin/ConfirmModal';
|
||||||
import { theme } from '@/styles/theme';
|
|
||||||
import { API_URL } from '@/lib/config';
|
import { API_URL } from '@/lib/config';
|
||||||
import { adminFetch } from '@/lib/adminFetch';
|
import { adminFetch } from '@/lib/adminFetch';
|
||||||
import { SISTER_EMOJIS, SISTER_DISPLAY_NAMES } from '@/lib/sisters';
|
import { SISTER_EMOJIS, SISTER_DISPLAY_NAMES } from '@/lib/sisters';
|
||||||
@@ -24,8 +23,8 @@ const Table = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const Row = styled.div`
|
const Row = styled.div`
|
||||||
background: ${theme.colors.cardBg};
|
background: var(--bg-surface);
|
||||||
border: 1px solid ${theme.colors.border};
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -45,7 +44,7 @@ const Emoji = styled.span`font-size: 22px;`;
|
|||||||
const Name = styled.span`
|
const Name = styled.span`
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: ${theme.colors.textPrimary};
|
color: var(--text-primary);
|
||||||
min-width: 70px;
|
min-width: 70px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -78,7 +77,7 @@ const ResultBanner = styled.div<{ $success: boolean }>`
|
|||||||
border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};
|
border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: ${({ $success }) => $success ? theme.colors.online : theme.colors.offline};
|
color: ${({ $success }) => $success ? '#00FF00' : '#FF1744'};
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { theme } from '@/styles/theme';
|
|
||||||
import { API_URL } from '@/lib/config';
|
import { API_URL } from '@/lib/config';
|
||||||
|
|
||||||
const Grid = styled.div`
|
const Grid = styled.div`
|
||||||
@@ -13,8 +12,8 @@ const Grid = styled.div`
|
|||||||
|
|
||||||
const Card = styled.a`
|
const Card = styled.a`
|
||||||
display: block;
|
display: block;
|
||||||
background: ${theme.colors.cardBg};
|
background: var(--bg-surface);
|
||||||
border: 1px solid ${theme.colors.border};
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -29,13 +28,13 @@ const Card = styled.a`
|
|||||||
const RepoName = styled.div`
|
const RepoName = styled.div`
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: ${theme.colors.accent};
|
color: #58A6FF;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const RepoDesc = styled.div`
|
const RepoDesc = styled.div`
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -49,7 +48,7 @@ const Stats = styled.div`
|
|||||||
|
|
||||||
const Stat = styled.span`
|
const Stat = styled.span`
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PRBadge = styled.span<{ $count: number }>`
|
const PRBadge = styled.span<{ $count: number }>`
|
||||||
@@ -58,13 +57,13 @@ const PRBadge = styled.span<{ $count: number }>`
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: ${({ $count }) => $count > 0 ? 'rgba(88,166,255,0.12)' : 'rgba(139,148,158,0.08)'};
|
background: ${({ $count }) => $count > 0 ? 'rgba(88,166,255,0.12)' : 'rgba(139,148,158,0.08)'};
|
||||||
color: ${({ $count }) => $count > 0 ? theme.colors.accent : theme.colors.textSecondary};
|
color: ${({ $count }) => $count > 0 ? '#58A6FF' : 'var(--text-secondary)'};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const NoProjects = styled.div`
|
const NoProjects = styled.div`
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -80,7 +79,7 @@ export default function ReposPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) return <div style={{ color: theme.colors.textSecondary, fontSize: '14px' }}>로딩 중...</div>;
|
if (loading) return <div style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>로딩 중...</div>;
|
||||||
|
|
||||||
if (!projects.length) return <NoProjects>등록된 프로젝트 없음</NoProjects>;
|
if (!projects.length) return <NoProjects>등록된 프로젝트 없음</NoProjects>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 LayoutShell from '@/components/common/LayoutShell';
|
import AppShell from '@/components/common/AppShell';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: '하나랑 대시보드',
|
title: '하나랑 대시보드',
|
||||||
@@ -18,7 +18,7 @@ export default function RootLayout({
|
|||||||
<body>
|
<body>
|
||||||
<StyledComponentsRegistry>
|
<StyledComponentsRegistry>
|
||||||
<GlobalStyle />
|
<GlobalStyle />
|
||||||
<LayoutShell>{children}</LayoutShell>
|
<AppShell>{children}</AppShell>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,102 +2,279 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import OrgTree from '@/components/org/OrgTree';
|
|
||||||
import { theme } from '@/styles/theme';
|
|
||||||
import { API_URL } from '@/lib/config';
|
import { API_URL } from '@/lib/config';
|
||||||
|
|
||||||
const Page = styled.div`
|
// ─── Styled ───
|
||||||
padding: 32px;
|
const MainViewport = styled.div`
|
||||||
min-height: 100vh;
|
|
||||||
background: ${theme.colors.bg};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PageTitle = styled.h1`
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: ${theme.colors.textPrimary};
|
|
||||||
margin-bottom: 6px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PageSubtitle = styled.p`
|
|
||||||
font-size: 14px;
|
|
||||||
color: ${theme.colors.textSecondary};
|
|
||||||
margin-bottom: 28px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Card = styled.div`
|
|
||||||
background: ${theme.colors.cardBg};
|
|
||||||
border: 1px solid ${theme.colors.border};
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 32px;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Legend = styled.div`
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
flex-direction: column;
|
||||||
justify-content: center;
|
gap: var(--space-xl);
|
||||||
margin-top: 24px;
|
min-height: 80vh;
|
||||||
padding-top: 20px;
|
`;
|
||||||
border-top: 1px solid ${theme.colors.border};
|
|
||||||
|
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;
|
flex-wrap: wrap;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const LegendItem = styled.div`
|
const Badge = styled.div`
|
||||||
display: flex;
|
background: #1a1a1a;
|
||||||
align-items: center;
|
border: 1px solid var(--border-color);
|
||||||
gap: 6px;
|
padding: 2px 6px;
|
||||||
font-size: 12px;
|
font-size: 9px;
|
||||||
color: ${theme.colors.textSecondary};
|
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 Dot = styled.div<{ $color: string }>`
|
const TreeConnector = styled.div`
|
||||||
width: 8px;
|
width: 1px;
|
||||||
height: 8px;
|
height: 24px;
|
||||||
border-radius: 50%;
|
background: var(--border-color);
|
||||||
background: ${({ $color }) => $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() {
|
export default function OrgPage() {
|
||||||
const [orgData, setOrgData] = useState<any>(null);
|
const [orgData, setOrgData] = useState<OrgData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const ts = new Date().toLocaleDateString('ko-KR').replace(/\. /g, '.').replace('.', '') + '_' +
|
||||||
|
new Date().toLocaleTimeString('ko-KR', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
fetch(`${API_URL}/api/org`)
|
||||||
try {
|
.then((r) => r.json())
|
||||||
const res = await fetch(`${API_URL}/api/org`);
|
.then(setOrgData)
|
||||||
if (res.ok) setOrgData(await res.json());
|
.catch(() => {});
|
||||||
} catch {
|
|
||||||
// silent
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const sisters = orgData?.sisters ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<MainViewport>
|
||||||
<PageTitle>조직도</PageTitle>
|
<HeaderInfo>
|
||||||
<PageSubtitle>하나랑 4자매 멀티에이전트 파이프라인 구조</PageSubtitle>
|
<div>SYS_TYPE: <span>CORE_ROOT_01</span></div>
|
||||||
|
<div>TIMESTAMP: <span style={{ color: 'var(--text-secondary)' }}>{ts}</span></div>
|
||||||
|
</HeaderInfo>
|
||||||
|
|
||||||
<Card>
|
<TreeContainer>
|
||||||
{loading ? (
|
<NodeGroup>
|
||||||
<div style={{ textAlign: 'center', color: theme.colors.textSecondary, padding: '40px' }}>
|
{/* HQ */}
|
||||||
로딩 중...
|
<TerminalCard $level="high">
|
||||||
</div>
|
<NodeLabel><span>[HQ]</span> 하나랑 글로벌</NodeLabel>
|
||||||
) : (
|
<BadgeRow>
|
||||||
<OrgTree sisters={orgData?.sisters ?? []} />
|
<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>
|
||||||
|
|
||||||
<Legend>
|
<PageFooter>
|
||||||
<LegendItem><Dot $color={theme.colors.online} /> 온라인</LegendItem>
|
<div>TOTAL_NODES: {String(sisters.length + 1).padStart(2, '0')}</div>
|
||||||
<LegendItem><Dot $color={theme.colors.working} /> 작업 중</LegendItem>
|
<div>STATUS: SYNC_COMPLETE</div>
|
||||||
<LegendItem><Dot $color={theme.colors.offline} /> 오프라인</LegendItem>
|
</PageFooter>
|
||||||
<LegendItem><Dot $color={theme.colors.textSecondary} /> 알 수 없음</LegendItem>
|
</MainViewport>
|
||||||
</Legend>
|
|
||||||
</Card>
|
|
||||||
</Page>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,220 +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 ProjectCard from '@/components/projects/ProjectCard';
|
import {
|
||||||
import ActivityFeed from '@/components/dashboard/ActivityFeed';
|
PageHeader, PageTitle, Card, BracketValue, LabelMeta,
|
||||||
import { theme } from '@/styles/theme';
|
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`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--space-lg);
|
||||||
|
|
||||||
interface SisterStatus {
|
@media (min-width: 768px) and (max-width: 1199px) {
|
||||||
id: number;
|
grid-template-columns: repeat(2, 1fr);
|
||||||
name: string;
|
|
||||||
user: string;
|
|
||||||
lxcId: number;
|
|
||||||
role: string;
|
|
||||||
status: Status;
|
|
||||||
lastSeen: string | null;
|
|
||||||
currentTask: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_SISTERS: SisterStatus[] = [
|
@media (max-width: 767px) {
|
||||||
{ id: 1, name: 'harang', user: 'harang', lxcId: 104, role: 'Orchestrator', status: 'online', lastSeen: new Date().toISOString(), currentTask: 'Sprint 002 기획 중' },
|
grid-template-columns: repeat(2, 1fr);
|
||||||
{ id: 2, name: 'narang', user: 'narang', lxcId: 105, role: 'Generator', status: 'working', lastSeen: new Date().toISOString(), currentTask: 'SPRINT-002 구현 중' },
|
gap: var(--space-md);
|
||||||
{ id: 3, name: 'darang', user: 'darang', lxcId: 106, role: 'Evaluator', status: 'online', lastSeen: new Date().toISOString(), currentTask: null },
|
}
|
||||||
{ id: 4, name: 'erang', user: 'erang', lxcId: 107, role: 'Infra Manager', status: 'online', lastSeen: new Date().toISOString(), currentTask: null },
|
`;
|
||||||
|
|
||||||
|
const DataColumns = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5fr 1fr;
|
||||||
|
gap: var(--space-xxl);
|
||||||
|
align-items: start;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProjectDetails = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
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 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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
import { API_URL, POLL_INTERVAL_MS } from '@/lib/config';
|
|
||||||
|
|
||||||
const PageWrapper = styled.div`
|
|
||||||
padding: 32px;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: ${theme.colors.bg};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PageHeader = styled.div`
|
|
||||||
margin-bottom: 32px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PageTitle = styled.h1`
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: ${theme.colors.textPrimary};
|
|
||||||
margin-bottom: 6px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PageSubtitle = styled.p`
|
|
||||||
font-size: 14px;
|
|
||||||
color: ${theme.colors.textSecondary};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SectionTitle = styled.h2`
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: ${theme.colors.textSecondary};
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TwoColumnRow = styled.div`
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ProjectsGrid = styled.div`
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Card = styled.div`
|
|
||||||
background: ${theme.colors.cardBg};
|
|
||||||
border: 1px solid ${theme.colors.border};
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ErrorBanner = styled.div`
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: rgba(255,23,68,0.08);
|
|
||||||
border: 1px solid rgba(255,23,68,0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: ${theme.colors.offline};
|
|
||||||
margin-bottom: 20px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PlaceholderText = styled.div`
|
|
||||||
padding: 32px;
|
|
||||||
text-align: center;
|
|
||||||
color: ${theme.colors.textSecondary};
|
|
||||||
font-size: 13px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [sisters, setSisters] = useState<SisterStatus[]>(MOCK_SISTERS);
|
const [sisters, setSisters] = useState<any[]>(MOCK_SISTERS);
|
||||||
const [projects, setProjects] = useState<any[]>([]);
|
const [projects, setProjects] = useState<any[]>([]);
|
||||||
const [activityItems, setActivityItems] = useState<any[]>([]);
|
const [activityItems, setActivityItems] = useState<any[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
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 fetchAll = async () => {
|
const fetch_ = async () => {
|
||||||
try {
|
try {
|
||||||
const [sRes, pRes, aRes] = await Promise.allSettled([
|
const [sRes, pRes, aRes] = await Promise.allSettled([
|
||||||
fetch(`${API_URL}/api/sisters`),
|
fetch(`${API_URL}/api/sisters`),
|
||||||
fetch(`${API_URL}/api/projects`),
|
fetch(`${API_URL}/api/projects`),
|
||||||
fetch(`${API_URL}/api/activity?limit=20`),
|
fetch(`${API_URL}/api/activity?limit=8`),
|
||||||
]);
|
]);
|
||||||
|
if (sRes.status === 'fulfilled' && sRes.value.ok) setSisters(await sRes.value.json());
|
||||||
if (sRes.status === 'fulfilled' && sRes.value.ok) {
|
if (pRes.status === 'fulfilled' && pRes.value.ok) setProjects(await pRes.value.json());
|
||||||
setSisters(await sRes.value.json());
|
|
||||||
}
|
|
||||||
if (pRes.status === 'fulfilled' && pRes.value.ok) {
|
|
||||||
setProjects(await pRes.value.json());
|
|
||||||
}
|
|
||||||
if (aRes.status === 'fulfilled' && aRes.value.ok) {
|
if (aRes.status === 'fulfilled' && aRes.value.ok) {
|
||||||
const d = await aRes.value.json();
|
const d = await aRes.value.json();
|
||||||
setActivityItems(d.items ?? []);
|
setActivityItems(d.items ?? []);
|
||||||
}
|
}
|
||||||
setError(null);
|
} catch { /* silent */ }
|
||||||
} catch {
|
|
||||||
setError('API 연결 실패 — 일부 데이터는 mock으로 표시 중');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
fetch_();
|
||||||
fetchAll();
|
const iv = setInterval(fetch_, POLL_INTERVAL_MS);
|
||||||
const interval = setInterval(fetchAll, POLL_INTERVAL_MS);
|
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>
|
||||||
/>
|
</div>
|
||||||
|
<TechBar>
|
||||||
|
<TechBarFill $width={sisterBarWidth(s)} />
|
||||||
|
</TechBar>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</SistersGrid>
|
</StatusGrid>
|
||||||
|
|
||||||
<SectionTitle>진행 중 프로젝트</SectionTitle>
|
{/* 하단 2열 */}
|
||||||
{projects.length > 0 ? (
|
<DataColumns>
|
||||||
<ProjectsGrid>
|
{/* ONGOING PROJECTS */}
|
||||||
{projects.map((p) => (
|
<section>
|
||||||
<ProjectCard
|
<SectionTitle>
|
||||||
key={p.id}
|
<span>ONGOING PROJECTS</span>
|
||||||
id={p.id}
|
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
|
||||||
name={p.name}
|
</SectionTitle>
|
||||||
description={p.description}
|
<ProjectList>
|
||||||
status={p.status}
|
{projects.length === 0 ? (
|
||||||
progress={p.progress}
|
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', padding: 'var(--space-md) 0' }}>
|
||||||
doneTasks={p.doneTasks}
|
등록된 프로젝트 없음
|
||||||
totalTasks={p.totalTasks}
|
</div>
|
||||||
openPRs={p.openPRs}
|
|
||||||
currentSprint={p.currentSprint}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ProjectsGrid>
|
|
||||||
) : (
|
) : (
|
||||||
<TwoColumnRow>
|
projects.map((p) => (
|
||||||
<Card>
|
<ProjectRow key={p.id} href={`/projects/${p.id}`}>
|
||||||
<PlaceholderText>등록된 프로젝트 없음</PlaceholderText>
|
<Avatar>📁</Avatar>
|
||||||
</Card>
|
<ProjectDetails>
|
||||||
<Card>
|
<ProjectName>{p.name}</ProjectName>
|
||||||
<SectionTitle>최근 활동 피드</SectionTitle>
|
<ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc>
|
||||||
<ActivityFeed items={activityItems} />
|
</ProjectDetails>
|
||||||
</Card>
|
<LabelMeta>{p.currentSprint ?? p.status?.toUpperCase() ?? 'ACTIVE'}</LabelMeta>
|
||||||
</TwoColumnRow>
|
</ProjectRow>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
|
</ProjectList>
|
||||||
|
</section>
|
||||||
|
|
||||||
{projects.length > 0 && (
|
{/* ACTIVITY FEED */}
|
||||||
<TwoColumnRow>
|
<section>
|
||||||
<div />
|
<SectionTitle>
|
||||||
<Card>
|
<span>ACTIVITY FEED</span>
|
||||||
<SectionTitle>최근 활동 피드</SectionTitle>
|
<LabelMeta>{connected ? 'LIVE' : 'FEED'}</LabelMeta>
|
||||||
<ActivityFeed items={activityItems} />
|
</SectionTitle>
|
||||||
</Card>
|
<Timeline>
|
||||||
</TwoColumnRow>
|
{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>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</PageWrapper>
|
</Timeline>
|
||||||
|
</section>
|
||||||
|
</DataColumns>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,197 +1,543 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import TabNav from '@/components/common/TabNav';
|
import { LabelMeta, PageTitle, SectionTitle, TechBar, TechBarFill } from '@/components/ui/base';
|
||||||
import SprintAccordion from '@/components/projects/SprintAccordion';
|
|
||||||
import TaskTable from '@/components/projects/TaskTable';
|
|
||||||
import ActivityFeed from '@/components/dashboard/ActivityFeed';
|
|
||||||
import ProgressBar from '@/components/common/ProgressBar';
|
|
||||||
import { theme } from '@/styles/theme';
|
|
||||||
|
|
||||||
import { API_URL } from '@/lib/config';
|
import { API_URL } from '@/lib/config';
|
||||||
|
|
||||||
const TABS = [
|
// ─── Styled ───
|
||||||
{ id: 'sprints', label: 'Sprints', icon: '📁' },
|
const Breadcrumb = styled.div`
|
||||||
{ id: 'tasks', label: 'Task Ledger', icon: '📋' },
|
font-family: var(--font-mono);
|
||||||
{ id: 'activity', label: '활동 로그', icon: '📝' },
|
font-size: 11px;
|
||||||
];
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
const Page = styled.div`
|
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
|
||||||
padding: 32px;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: ${theme.colors.bg};
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const BackLink = styled(Link)`
|
const PageTitleRow = styled.div`
|
||||||
font-size: 13px;
|
|
||||||
color: ${theme.colors.textSecondary};
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
transition: color 0.15s;
|
|
||||||
&:hover { color: ${theme.colors.textPrimary}; }
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Header = styled.div`
|
|
||||||
margin-bottom: 28px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Title = styled.h1`
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: ${theme.colors.textPrimary};
|
|
||||||
margin-bottom: 6px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
align-items: flex-start;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
flex-wrap: wrap;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const GitLink = styled.a`
|
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;
|
font-size: 12px;
|
||||||
color: ${theme.colors.accent};
|
color: #5fafff;
|
||||||
padding: 3px 8px;
|
font-family: var(--font-mono);
|
||||||
border: 1px solid rgba(88,166,255,0.3);
|
|
||||||
border-radius: 5px;
|
|
||||||
transition: background 0.15s;
|
|
||||||
&:hover { background: rgba(88,166,255,0.1); }
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Desc = styled.p`
|
const CommitMsg = styled.span`
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-primary);
|
||||||
margin-bottom: 12px;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ProgressRow = styled.div`
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: var(--space-md);
|
||||||
max-width: 400px;
|
padding: 12px var(--space-lg);
|
||||||
margin-bottom: 8px;
|
border: 1px solid var(--border-color);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
&:hover { border-color: var(--border-hover); }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ProgressLabel = styled.span`
|
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;
|
font-size: 13px;
|
||||||
color: ${theme.colors.textSecondary};
|
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;
|
white-space: nowrap;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PRCount = styled.span`
|
const FilterRow = styled.div`
|
||||||
font-size: 12px;
|
display: flex;
|
||||||
color: ${theme.colors.accent};
|
gap: var(--space-sm);
|
||||||
padding: 2px 8px;
|
margin-bottom: var(--space-lg);
|
||||||
background: rgba(88,166,255,0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Card = styled.div`
|
const FilterBtn = styled.button<{ $active: boolean }>`
|
||||||
background: ${theme.colors.cardBg};
|
background: ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
|
||||||
border: 1px solid ${theme.colors.border};
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 12px;
|
color: ${({ $active }) => $active ? '#000' : 'var(--text-secondary)'};
|
||||||
padding: 24px;
|
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 ErrorBanner = styled.div`
|
const Empty = styled.div`
|
||||||
padding: 10px 14px;
|
padding: var(--space-xl) 0;
|
||||||
background: rgba(255,23,68,0.08);
|
color: var(--text-secondary);
|
||||||
border: 1px solid rgba(255,23,68,0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: ${theme.colors.offline};
|
font-family: var(--font-mono);
|
||||||
margin-bottom: 20px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// 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() {
|
export default function ProjectDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [project, setProject] = useState<any>(null);
|
const [project, setProject] = useState<any>(null);
|
||||||
const [tasks, setTasks] = useState<any[]>([]);
|
const [tasks, setTasks] = useState<any[]>([]);
|
||||||
const [activity, setActivity] = useState<any[]>([]);
|
const [commits, setCommits] = useState<any[]>([]);
|
||||||
const [tab, setTab] = useState('sprints');
|
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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [tabLoading, setTabLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
Promise.allSettled([
|
||||||
try {
|
|
||||||
const [projRes, tasksRes, actRes] = await Promise.all([
|
|
||||||
fetch(`${API_URL}/api/projects/${id}`),
|
fetch(`${API_URL}/api/projects/${id}`),
|
||||||
fetch(`${API_URL}/api/projects/${id}/tasks`),
|
fetch(`${API_URL}/api/projects/${id}/tasks`),
|
||||||
fetch(`${API_URL}/api/projects/${id}/activity`),
|
]).then(([pRes, tRes]) => {
|
||||||
]);
|
if (pRes.status === 'fulfilled' && pRes.value.ok) pRes.value.json().then(setProject);
|
||||||
if (!projRes.ok) throw new Error(`Project API ${projRes.status}`);
|
if (tRes.status === 'fulfilled' && tRes.value.ok) tRes.value.json().then(setTasks);
|
||||||
setProject(await projRes.json());
|
}).finally(() => setLoading(false));
|
||||||
if (tasksRes.ok) setTasks(await tasksRes.json());
|
|
||||||
if (actRes.ok) {
|
|
||||||
const d = await actRes.json();
|
|
||||||
setActivity(d.items ?? []);
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
} catch (e) {
|
|
||||||
setError('API 연결 실패');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const allTasks = project?.sprints?.flatMap((s: any) => s.tasks) ?? [];
|
const loadCommits = useCallback(async () => {
|
||||||
const totalTasks = allTasks.length;
|
setTabLoading(true);
|
||||||
const doneTasks = allTasks.filter((t: any) => t.status === 'done').length;
|
try {
|
||||||
const progress = totalTasks > 0 ? Math.round(doneTasks / totalTasks * 100) : 0;
|
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 (
|
return (
|
||||||
<Page>
|
|
||||||
<BackLink href="/">‹ 대시보드</BackLink>
|
|
||||||
|
|
||||||
{error && <ErrorBanner>⚠️ {error}</ErrorBanner>}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<Desc>로딩 중...</Desc>
|
|
||||||
) : project ? (
|
|
||||||
<>
|
<>
|
||||||
<Header>
|
<Breadcrumb>
|
||||||
<Title>
|
<Link href="/projects">PROJECTS</Link> / P-{String(project.id).padStart(3, '0')} / {project.name}
|
||||||
|
</Breadcrumb>
|
||||||
|
<PageTitleRow>
|
||||||
|
<PageTitle>
|
||||||
{project.name}
|
{project.name}
|
||||||
{project.repoUrl && (
|
{project.repoUrl && (
|
||||||
<GitLink href={project.repoUrl} target="_blank" rel="noopener">
|
<a href={project.repoUrl} target="_blank" rel="noopener"
|
||||||
Gitea ↗
|
style={{ fontSize: '12px', color: '#5fafff', marginLeft: 'var(--space-md)', fontWeight: 400 }}>
|
||||||
</GitLink>
|
↗ GITEA
|
||||||
|
</a>
|
||||||
)}
|
)}
|
||||||
{(project.openPRs?.length ?? 0) > 0 && (
|
</PageTitle>
|
||||||
<PRCount>PR {project.openPRs.length}</PRCount>
|
<LabelMeta><span>STATUS:</span>{project.status?.toUpperCase()} / {activeSprint?.name ?? 'PLANNING'}</LabelMeta>
|
||||||
)}
|
</PageTitleRow>
|
||||||
</Title>
|
|
||||||
{project.description && <Desc>{project.description}</Desc>}
|
|
||||||
<ProgressRow>
|
|
||||||
<ProgressBar value={progress} />
|
|
||||||
<ProgressLabel>{progress}% ({doneTasks}/{totalTasks})</ProgressLabel>
|
|
||||||
</ProgressRow>
|
|
||||||
</Header>
|
|
||||||
|
|
||||||
<Card>
|
<TabBar>
|
||||||
<TabNav tabs={TABS} active={tab} onChange={setTab} />
|
{TABS.map((t) => (
|
||||||
|
<TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
|
||||||
|
{t.label}
|
||||||
|
</TabBtn>
|
||||||
|
))}
|
||||||
|
</TabBar>
|
||||||
|
|
||||||
{tab === 'sprints' && (
|
{/* OVERVIEW */}
|
||||||
<SprintAccordion sprints={tasks.length > 0 ? tasks : (project.sprints ?? [])} />
|
{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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'tasks' && (
|
{/* COMMITS */}
|
||||||
<TaskTable tasks={allTasks} />
|
{tab === 'commits' && (
|
||||||
)}
|
<>
|
||||||
|
{tabLoading ? <Empty>LOADING COMMITS...</Empty> : (
|
||||||
{tab === 'activity' && (
|
<>
|
||||||
<ActivityFeed items={activity} />
|
<CommitTable>
|
||||||
)}
|
{commits.length === 0 ? (
|
||||||
</Card>
|
<div style={{ padding: 'var(--space-xl)', color: 'var(--text-secondary)', fontSize: '12px', textAlign: 'center', fontFamily: 'var(--font-mono)' }}>NO COMMITS</div>
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<Desc>프로젝트를 찾을 수 없음</Desc>
|
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>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</Page>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,160 +1,281 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import ConfigViewer from '@/components/sisters/ConfigViewer';
|
import { PageTitle, LabelMeta, SectionTitle, Btn, BtnPrimary } from '@/components/ui/base';
|
||||||
import { theme } from '@/styles/theme';
|
|
||||||
import { API_URL } from '@/lib/config';
|
|
||||||
|
|
||||||
const SISTERS = [
|
// ─── Styled ───
|
||||||
{ value: 'harang', label: '🦊 하랑이 (Orchestrator)' },
|
const SettingsGrid = styled.div`
|
||||||
{ value: 'narang', label: '🦊 나랑이 (Generator)' },
|
display: grid;
|
||||||
{ value: 'darang', label: '🐱 다랑이 (Evaluator)' },
|
grid-template-columns: 1fr 1fr;
|
||||||
{ value: 'erang', label: '🐺 이랑이 (Infra Manager)' },
|
gap: var(--space-xl);
|
||||||
];
|
|
||||||
|
|
||||||
const Page = styled.div`
|
@media (max-width: 767px) {
|
||||||
padding: 32px;
|
grid-template-columns: 1fr;
|
||||||
min-height: 100vh;
|
gap: var(--space-lg);
|
||||||
background: ${theme.colors.bg};
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PageTitle = styled.h1`
|
const SettingsSection = styled.section``;
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
const SettingsRow = styled.div`
|
||||||
color: ${theme.colors.textPrimary};
|
display: flex;
|
||||||
margin-bottom: 6px;
|
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 PageSubtitle = styled.p`
|
const SettingInfo = styled.div`
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SettingLabel = styled.div`
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: ${theme.colors.textSecondary};
|
font-weight: 500;
|
||||||
margin-bottom: 28px;
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Controls = styled.div`
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 4px;
|
||||||
margin-bottom: 20px;
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Select = styled.select`
|
const BracketInput = styled.input`
|
||||||
background: ${theme.colors.cardBg};
|
background: transparent;
|
||||||
border: 1px solid ${theme.colors.border};
|
border: none;
|
||||||
border-radius: 8px;
|
color: var(--text-primary);
|
||||||
color: ${theme.colors.textPrimary};
|
font-family: var(--font-mono);
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
text-align: center;
|
||||||
|
width: 60px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s;
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
&:focus { border-color: ${theme.colors.accent}; }
|
&:focus { border-bottom-color: var(--text-primary); }
|
||||||
|
|
||||||
option {
|
|
||||||
background: #1a1f2a;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Card = styled.div`
|
const ActionBar = styled.div`
|
||||||
background: ${theme.colors.cardBg};
|
|
||||||
border: 1px solid ${theme.colors.border};
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const InfoRow = styled.div`
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
justify-content: flex-end;
|
||||||
margin-bottom: 16px;
|
gap: var(--space-md);
|
||||||
padding-bottom: 16px;
|
padding-top: var(--space-xl);
|
||||||
border-bottom: 1px solid ${theme.colors.border};
|
border-top: 1px solid var(--border-color);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const InfoItem = styled.div`
|
function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) {
|
||||||
font-size: 13px;
|
|
||||||
color: ${theme.colors.textSecondary};
|
|
||||||
|
|
||||||
strong {
|
|
||||||
color: ${theme.colors.textPrimary};
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SectionTitle = styled.h3`
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: ${theme.colors.textSecondary};
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
const [selected, setSelected] = useState('narang');
|
|
||||||
const [configData, setConfigData] = useState<any>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const load = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setConfigData(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/api/sisters/${selected}/config`);
|
|
||||||
if (res.ok) setConfigData(await res.json());
|
|
||||||
} catch {
|
|
||||||
// silent
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
}, [selected]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<SwitchWrapper onClick={onChange}>
|
||||||
<PageTitle>설정 뷰어</PageTitle>
|
<SwitchInput type="checkbox" checked={checked} onChange={() => {}} />
|
||||||
<PageSubtitle>자매별 SOUL.md / AGENTS.md 읽기 전용 뷰어</PageSubtitle>
|
<Slider $checked={checked} />
|
||||||
|
</SwitchWrapper>
|
||||||
<Controls>
|
);
|
||||||
<Select value={selected} onChange={(e) => setSelected(e.target.value)}>
|
}
|
||||||
{SISTERS.map((s) => (
|
|
||||||
<option key={s.value} value={s.value}>{s.label}</option>
|
function BInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
))}
|
return (
|
||||||
</Select>
|
<BracketInputGroup>
|
||||||
</Controls>
|
[<BracketInput value={value} onChange={(e) => onChange(e.target.value)} />]
|
||||||
|
</BracketInputGroup>
|
||||||
<Card>
|
);
|
||||||
{configData && (
|
}
|
||||||
<InfoRow>
|
|
||||||
<InfoItem>
|
export default function SettingsPage() {
|
||||||
<strong>{configData.name}</strong>
|
const [settings, setSettings] = useState({
|
||||||
이름
|
twofa: true,
|
||||||
</InfoItem>
|
sessionTimeout: '15',
|
||||||
<InfoItem>
|
ipWhitelist: false,
|
||||||
<strong>{configData.role}</strong>
|
autoBackup: true,
|
||||||
역할
|
backupInterval: '08:00',
|
||||||
</InfoItem>
|
backupRetention: '30',
|
||||||
<InfoItem style={{ flex: 1 }}>
|
queueThreshold: '500',
|
||||||
<strong>{configData.description}</strong>
|
autoScaling: true,
|
||||||
설명
|
cpuThreshold: '90',
|
||||||
</InfoItem>
|
latencyThreshold: '250',
|
||||||
</InfoRow>
|
nodeOfflineAlert: true,
|
||||||
)}
|
});
|
||||||
|
|
||||||
<SectionTitle>SOUL.md / AGENTS.md</SectionTitle>
|
const toggle = (key: keyof typeof settings) =>
|
||||||
|
setSettings((p) => ({ ...p, [key]: !p[key] }));
|
||||||
{loading ? (
|
const setVal = (key: keyof typeof settings, val: string) =>
|
||||||
<div style={{ padding: '32px', textAlign: 'center', color: theme.colors.textSecondary, fontSize: '13px' }}>
|
setSettings((p) => ({ ...p, [key]: val }));
|
||||||
로딩 중...
|
|
||||||
</div>
|
return (
|
||||||
) : (
|
<>
|
||||||
<ConfigViewer content={configData?.raw ?? ''} />
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 'var(--space-lg)' }}>
|
||||||
)}
|
<PageTitle>시스템 설정</PageTitle>
|
||||||
</Card>
|
<LabelMeta><span>CONFIG:</span>v2.0.4-STABLE</LabelMeta>
|
||||||
</Page>
|
</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,192 +4,208 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import SisterHeader from '@/components/sisters/SisterHeader';
|
import { PageTitle, LabelMeta, SectionTitle, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent } from '@/components/ui/base';
|
||||||
import ConfigViewer from '@/components/sisters/ConfigViewer';
|
|
||||||
import SessionList from '@/components/sisters/SessionList';
|
|
||||||
import SubagentList from '@/components/sisters/SubagentList';
|
|
||||||
import ActivityFeed from '@/components/dashboard/ActivityFeed';
|
|
||||||
import TabNav from '@/components/common/TabNav';
|
|
||||||
import { theme } from '@/styles/theme';
|
|
||||||
import { API_URL } from '@/lib/config';
|
import { API_URL } from '@/lib/config';
|
||||||
|
import { SISTER_ROLES } from '@/lib/sisters';
|
||||||
|
|
||||||
type Status = 'online' | 'offline' | 'working' | 'unknown';
|
const Breadcrumb = styled.div`
|
||||||
|
font-family: var(--font-mono);
|
||||||
const TABS = [
|
font-size: 11px;
|
||||||
{ id: 'overview', label: '개요', icon: '👤' },
|
color: var(--text-secondary);
|
||||||
{ id: 'config', label: '설정', icon: '⚙️' },
|
margin-bottom: var(--space-md);
|
||||||
{ id: 'sessions', label: '세션', icon: '💬' },
|
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
|
||||||
{ id: 'subagents', label: '서브에이전트', icon: '🤖' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const Page = styled.div`
|
|
||||||
padding: 32px;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: ${theme.colors.bg};
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const BackLink = styled(Link)`
|
const DetailGrid = styled.div`
|
||||||
font-size: 13px;
|
|
||||||
color: ${theme.colors.textSecondary};
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
transition: color 0.15s;
|
|
||||||
&:hover { color: ${theme.colors.textPrimary}; }
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Card = styled.div`
|
|
||||||
background: ${theme.colors.cardBg};
|
|
||||||
border: 1px solid ${theme.colors.border};
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const OverviewGrid = styled.div`
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 280px 1fr;
|
||||||
gap: 20px;
|
gap: var(--space-xxl);
|
||||||
|
align-items: start;
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1199px) {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-xl);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const SectionTitle = styled.h3`
|
const MetaPanel = styled.div`
|
||||||
font-size: 13px;
|
display: flex;
|
||||||
font-weight: 600;
|
flex-direction: column;
|
||||||
color: ${theme.colors.textSecondary};
|
gap: var(--space-lg);
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CurrentTaskBox = styled.div`
|
const MetaCard = styled.div`
|
||||||
padding: 12px 16px;
|
border: 1px solid var(--border-color);
|
||||||
background: rgba(88,166,255,0.06);
|
padding: var(--space-lg);
|
||||||
border: 1px solid rgba(88,166,255,0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: ${theme.colors.accent};
|
|
||||||
margin-bottom: 20px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ErrorBanner = styled.div`
|
const StatusRow = styled.div`
|
||||||
padding: 10px 14px;
|
display: flex;
|
||||||
background: rgba(255,23,68,0.08);
|
align-items: center;
|
||||||
border: 1px solid rgba(255,23,68,0.3);
|
gap: var(--space-sm);
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: ${theme.colors.offline};
|
|
||||||
margin-bottom: 20px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
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() {
|
export default function SisterDetailPage() {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
const [sisters, setSisters] = useState<any[]>([]);
|
const [sisterInfo, setSisterInfo] = useState<any>(null);
|
||||||
const [configData, setConfigData] = useState<any>(null);
|
const [configData, setConfigData] = useState<any>(null);
|
||||||
const [sessions, setSessions] = useState<any[]>([]);
|
|
||||||
const [subagents, setSubagents] = useState<any[]>([]);
|
|
||||||
const [activity, setActivity] = useState<any[]>([]);
|
const [activity, setActivity] = useState<any[]>([]);
|
||||||
const [tab, setTab] = useState('overview');
|
const [tab, setTab] = useState('overview');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
Promise.allSettled([
|
||||||
try {
|
|
||||||
const [sistersRes, configRes, sessionsRes, subagentsRes, activityRes] = await Promise.allSettled([
|
|
||||||
fetch(`${API_URL}/api/sisters`),
|
fetch(`${API_URL}/api/sisters`),
|
||||||
fetch(`${API_URL}/api/sisters/${name}/config`),
|
fetch(`${API_URL}/api/sisters/${name}/config`),
|
||||||
fetch(`${API_URL}/api/sisters/${name}/sessions`),
|
|
||||||
fetch(`${API_URL}/api/sisters/${name}/subagents`),
|
|
||||||
fetch(`${API_URL}/api/sisters/${name}/activity`),
|
fetch(`${API_URL}/api/sisters/${name}/activity`),
|
||||||
]);
|
]).then(([sRes, cRes, aRes]) => {
|
||||||
|
if (sRes.status === 'fulfilled' && sRes.value.ok) {
|
||||||
if (sistersRes.status === 'fulfilled' && sistersRes.value.ok) {
|
sRes.value.json().then((d: any[]) => setSisterInfo(d.find((s) => s.name === name)));
|
||||||
setSisters(await sistersRes.value.json());
|
|
||||||
}
|
}
|
||||||
if (configRes.status === 'fulfilled' && configRes.value.ok) {
|
if (cRes.status === 'fulfilled' && cRes.value.ok) cRes.value.json().then(setConfigData);
|
||||||
setConfigData(await configRes.value.json());
|
if (aRes.status === 'fulfilled' && aRes.value.ok) {
|
||||||
|
aRes.value.json().then((d: any) => setActivity(d.items ?? []));
|
||||||
}
|
}
|
||||||
if (sessionsRes.status === 'fulfilled' && sessionsRes.value.ok) {
|
}).finally(() => setLoading(false));
|
||||||
setSessions(await sessionsRes.value.json());
|
|
||||||
}
|
|
||||||
if (subagentsRes.status === 'fulfilled' && subagentsRes.value.ok) {
|
|
||||||
setSubagents(await subagentsRes.value.json());
|
|
||||||
}
|
|
||||||
if (activityRes.status === 'fulfilled' && activityRes.value.ok) {
|
|
||||||
const d = await activityRes.value.json();
|
|
||||||
setActivity(d.items ?? []);
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
} catch {
|
|
||||||
setError('API 연결 실패');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
}, [name]);
|
}, [name]);
|
||||||
|
|
||||||
const sisterInfo = sisters.find((s) => s.name === name);
|
const isActive = sisterInfo?.status === 'online' || sisterInfo?.status === 'working';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<>
|
||||||
<BackLink href="/">‹ 대시보드</BackLink>
|
<Breadcrumb>
|
||||||
|
<Link href="/sisters">자매 노드 관리</Link> / {SISTER_DISPLAY[name] ?? name}
|
||||||
{error && <ErrorBanner>⚠️ {error}</ErrorBanner>}
|
</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 ? (
|
{loading ? (
|
||||||
<div style={{ color: theme.colors.textSecondary, fontSize: '14px' }}>로딩 중...</div>
|
<div style={{ color: 'var(--text-secondary)', fontSize: '13px', fontFamily: 'var(--font-mono)' }}>LOADING...</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<DetailGrid>
|
||||||
<SisterHeader
|
<MetaPanel>
|
||||||
name={name}
|
<MetaCard>
|
||||||
role={configData?.role ?? ''}
|
<StatusRow>
|
||||||
description={configData?.description ?? ''}
|
<StatusDot $on={isActive} />
|
||||||
status={(sisterInfo?.status ?? 'unknown') as Status}
|
<LabelMeta>{isActive ? 'ACTIVE' : 'STANDBY'}</LabelMeta>
|
||||||
lastSeen={sisterInfo?.lastSeen ?? null}
|
</StatusRow>
|
||||||
lxcId={sisterInfo?.lxcId ?? 0}
|
<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>
|
||||||
|
|
||||||
<Card>
|
{configData?.description && (
|
||||||
<TabNav tabs={TABS} active={tab} onChange={setTab} />
|
<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' && (
|
{tab === 'overview' && (
|
||||||
<>
|
<>
|
||||||
{sisterInfo?.currentTask && (
|
<SectionTitle>
|
||||||
<CurrentTaskBox>📌 현재 작업: {sisterInfo.currentTask}</CurrentTaskBox>
|
<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>
|
||||||
)}
|
)}
|
||||||
<SectionTitle>최근 활동</SectionTitle>
|
</Timeline>
|
||||||
<ActivityFeed items={activity.slice(0, 5)} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'config' && (
|
{tab === 'config' && (
|
||||||
<>
|
<>
|
||||||
<SectionTitle>SOUL.md / AGENTS.md</SectionTitle>
|
<SectionTitle>SOUL.md / AGENTS.md</SectionTitle>
|
||||||
<ConfigViewer content={configData?.raw ?? ''} />
|
<ConfigPre>{configData?.raw || '(설정 파일 없음)'}</ConfigPre>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{tab === 'sessions' && (
|
</DetailGrid>
|
||||||
<>
|
|
||||||
<SectionTitle>최근 세션</SectionTitle>
|
|
||||||
<SessionList sessions={sessions} />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'subagents' && (
|
|
||||||
<>
|
|
||||||
<SectionTitle>서브에이전트 목록</SectionTitle>
|
|
||||||
<SubagentList agents={subagents} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Page>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,170 +3,262 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import StatusBadge from '@/components/common/StatusBadge';
|
import { PageTitle, LabelMeta, BtnToggle } from '@/components/ui/base';
|
||||||
import { theme } from '@/styles/theme';
|
|
||||||
import { API_URL } from '@/lib/config';
|
import { API_URL } from '@/lib/config';
|
||||||
|
|
||||||
const sisterEmojis: Record<string, string> = {
|
const SISTER_ROLES: Record<string, string> = {
|
||||||
harang: '🦊', narang: '🦊', darang: '🐱', erang: '🐺',
|
harang: 'Primary',
|
||||||
|
narang: 'Secondary',
|
||||||
|
darang: 'Standby',
|
||||||
|
erang: 'Sync',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sisterDisplayNames: Record<string, string> = {
|
const GRAPH_BARS: Record<string, number[]> = {
|
||||||
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
|
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 Page = styled.div`
|
const NodeGrid = styled.section`
|
||||||
padding: 32px;
|
display: flex;
|
||||||
min-height: 100vh;
|
flex-direction: column;
|
||||||
background: ${theme.colors.bg};
|
gap: var(--space-lg);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PageTitle = styled.h1`
|
const NodeEntry = styled.div`
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: ${theme.colors.textPrimary};
|
|
||||||
margin-bottom: 6px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const PageSubtitle = styled.p`
|
|
||||||
font-size: 14px;
|
|
||||||
color: ${theme.colors.textSecondary};
|
|
||||||
margin-bottom: 28px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Grid = styled.div`
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: 220px 1fr 160px;
|
||||||
gap: 20px;
|
gap: 0;
|
||||||
`;
|
border: 1px solid var(--border-color);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
const Card = styled(Link)`
|
&:hover { border-color: var(--border-hover); }
|
||||||
display: block;
|
|
||||||
background: ${theme.colors.cardBg};
|
|
||||||
border: 1px solid ${theme.colors.border};
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 24px;
|
|
||||||
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
@media (max-width: 1199px) {
|
||||||
transform: translateY(-3px);
|
grid-template-columns: 200px 1fr;
|
||||||
box-shadow: 0 8px 28px rgba(0,0,0,0.4);
|
}
|
||||||
border-color: rgba(88,166,255,0.3);
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CardTop = styled.div`
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: var(--space-sm);
|
||||||
margin-bottom: 14px;
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
|
||||||
|
&:hover { color: var(--accent-hover); }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Avatar = styled.div`
|
const StatusIndicator = styled.div<{ $active: boolean }>`
|
||||||
font-size: 36px;
|
width: 8px;
|
||||||
line-height: 1;
|
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 NameBlock = styled.div``;
|
const MetaPair = styled.div`
|
||||||
|
|
||||||
const Name = styled.div`
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: ${theme.colors.textPrimary};
|
|
||||||
margin-bottom: 4px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Role = styled.div`
|
|
||||||
font-size: 12px;
|
|
||||||
color: ${theme.colors.textSecondary};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Divider = styled.div`
|
|
||||||
height: 1px;
|
|
||||||
background: ${theme.colors.border};
|
|
||||||
margin: 12px 0;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MetaItem = styled.div`
|
|
||||||
font-size: 12px;
|
|
||||||
color: ${theme.colors.textSecondary};
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 2px;
|
||||||
margin-bottom: 6px;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const TaskBox = styled.div`
|
const MetaVal = styled.div`
|
||||||
margin-top: 10px;
|
font-size: 13px;
|
||||||
padding: 8px 12px;
|
color: var(--text-primary);
|
||||||
background: rgba(88,166,255,0.06);
|
font-family: var(--font-mono);
|
||||||
border: 1px solid rgba(88,166,255,0.15);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: ${theme.colors.accent};
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
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 {
|
function formatLastSeen(lastSeen: string | null): string {
|
||||||
if (!lastSeen) return '기록 없음';
|
if (!lastSeen) return '—';
|
||||||
const diff = Date.now() - new Date(lastSeen).getTime();
|
const diff = Date.now() - new Date(lastSeen).getTime();
|
||||||
const min = Math.floor(diff / 60000);
|
const h = Math.floor(diff / 3600000);
|
||||||
if (min < 1) return '방금 전';
|
const m = Math.floor((diff % 3600000) / 60000);
|
||||||
if (min < 60) return `${min}분 전`;
|
const s = Math.floor((diff % 60000) / 1000);
|
||||||
const hr = Math.floor(min / 60);
|
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ago`;
|
||||||
return hr < 24 ? `${hr}시간 전` : `${Math.floor(hr / 24)}일 전`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SistersListPage() {
|
const MOCK_SISTERS = [
|
||||||
const [sisters, setSisters] = useState<any[]>([]);
|
{ id: 1, name: 'harang', status: 'online', lastSeen: new Date(Date.now() - 3600000).toISOString() },
|
||||||
const [loading, setLoading] = useState(true);
|
{ 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(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
fetch(`${API_URL}/api/sisters`)
|
||||||
try {
|
.then((r) => r.json())
|
||||||
const res = await fetch(`${API_URL}/api/sisters`);
|
.then(setSisters)
|
||||||
if (res.ok) setSisters(await res.json());
|
.catch(() => {});
|
||||||
} catch {
|
const iv = setInterval(() => {
|
||||||
// silent
|
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
|
||||||
} finally {
|
}, 15000);
|
||||||
setLoading(false);
|
return () => clearInterval(iv);
|
||||||
}
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
const interval = setInterval(load, 30000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<>
|
||||||
<PageTitle>자매</PageTitle>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-lg)' }}>
|
||||||
<PageSubtitle>하나랑 4자매 멀티에이전트 현황. 카드 클릭으로 상세 보기.</PageSubtitle>
|
<PageTitle>자매 노드 관리</PageTitle>
|
||||||
|
<LabelMeta><span>TOTAL NODES:</span> {String(sisters.length).padStart(2, '0')}</LabelMeta>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
<NodeGrid>
|
||||||
<div style={{ color: theme.colors.textSecondary, fontSize: '14px' }}>로딩 중...</div>
|
{sisters.map((s) => {
|
||||||
) : (
|
const isActive = getNodeStatus(s);
|
||||||
<Grid>
|
const [btn1, btn2] = getStatusBtns(s);
|
||||||
{sisters.map((s) => (
|
const bars = GRAPH_BARS[s.name] ?? [5, 5, 5, 5, 5, 5, 5, 5];
|
||||||
<Card key={s.id} href={`/sisters/${s.name}`}>
|
const loadIndex = isActive
|
||||||
<CardTop>
|
? `${(bars[bars.length - 1]).toFixed(1)}%`
|
||||||
<Avatar>{sisterEmojis[s.name] ?? '🤖'}</Avatar>
|
: '0.0%';
|
||||||
<NameBlock>
|
const metaLabel = s.status === 'offline' ? 'Last Active' : s.name === 'erang' ? 'Queue Depth' : 'Uptime';
|
||||||
<Name>{sisterDisplayNames[s.name] ?? s.name}</Name>
|
const metaVal = s.status === 'offline'
|
||||||
<Role>{s.role}</Role>
|
? formatLastSeen(s.lastSeen)
|
||||||
</NameBlock>
|
: s.name === 'erang' ? '12 PKTS'
|
||||||
</CardTop>
|
: formatLastSeen(s.lastSeen).replace(' ago', '');
|
||||||
<StatusBadge status={s.status} />
|
|
||||||
<Divider />
|
return (
|
||||||
<MetaItem>🕐 마지막 활동: {formatLastSeen(s.lastSeen)}</MetaItem>
|
<NodeEntry key={s.id ?? s.name}>
|
||||||
<MetaItem>🖥️ LXC {s.lxcId}</MetaItem>
|
<NodeInfo>
|
||||||
{s.currentTask && <TaskBox>📌 {s.currentTask}</TaskBox>}
|
<NodeName href={`/sisters/${s.name}`}>
|
||||||
</Card>
|
<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} />
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</GraphContainer>
|
||||||
)}
|
<SyncRow>
|
||||||
</Page>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ const Overlay = styled.div`
|
|||||||
|
|
||||||
const Modal = styled.div`
|
const Modal = styled.div`
|
||||||
background: #161b22;
|
background: #161b22;
|
||||||
border: 1px solid ${theme.colors.border};
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
@@ -36,13 +36,13 @@ const Modal = styled.div`
|
|||||||
const Title = styled.h2`
|
const Title = styled.h2`
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: ${theme.colors.textPrimary};
|
color: var(--text-primary);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Message = styled.p`
|
const Message = styled.p`
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
`;
|
`;
|
||||||
|
|||||||
50
frontend/components/common/AppShell.tsx
Normal file
50
frontend/components/common/AppShell.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import { AuthProvider, useAuth } from '@/lib/AuthContext';
|
||||||
|
import LayoutShell from './LayoutShell';
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = ['/login', '/register'];
|
||||||
|
|
||||||
|
function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
const isPublic = PUBLIC_PATHS.includes(pathname);
|
||||||
|
if (!isAuthenticated && !isPublic) {
|
||||||
|
router.replace('/login');
|
||||||
|
}
|
||||||
|
if (isAuthenticated && isPublic) {
|
||||||
|
router.replace('/');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, loading, pathname, router]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
height: '100vh', background: '#151515', color: '#8A8A8A',
|
||||||
|
fontFamily: 'monospace', fontSize: '12px',
|
||||||
|
}}>
|
||||||
|
AUTHENTICATING...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPublic = PUBLIC_PATHS.includes(pathname);
|
||||||
|
if (isPublic) return <>{children}</>;
|
||||||
|
|
||||||
|
return <LayoutShell>{children}</LayoutShell>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthGate>{children}</AuthGate>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,35 +3,39 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Sidebar from './Sidebar';
|
import Sidebar from './Sidebar';
|
||||||
import { SidebarProvider, useSidebar } from '@/lib/SidebarContext';
|
|
||||||
|
|
||||||
const LayoutRoot = styled.div`
|
const LayoutRoot = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #0d1117;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const MainContent = styled.main<{ $collapsed: boolean }>`
|
const MainContent = styled.main`
|
||||||
flex: 1;
|
flex-grow: 1;
|
||||||
margin-left: ${({ $collapsed }) => ($collapsed ? '60px' : '220px')};
|
padding: var(--space-xl) var(--space-xxl);
|
||||||
min-height: 100vh;
|
display: flex;
|
||||||
transition: margin-left 0.2s ease;
|
flex-direction: column;
|
||||||
`;
|
gap: var(--space-xl);
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
function Inner({ children }: { children: React.ReactNode }) {
|
@media (max-width: 767px) {
|
||||||
const { collapsed } = useSidebar();
|
padding: var(--space-lg) var(--space-md);
|
||||||
return (
|
padding-bottom: 72px;
|
||||||
<LayoutRoot>
|
gap: var(--space-lg);
|
||||||
<Sidebar />
|
|
||||||
<MainContent $collapsed={collapsed}>{children}</MainContent>
|
|
||||||
</LayoutRoot>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) and (max-width: 1199px) {
|
||||||
|
padding: var(--space-xl) var(--space-xl);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export default function LayoutShell({ children }: { children: React.ReactNode }) {
|
export default function LayoutShell({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<LayoutRoot>
|
||||||
<Inner>{children}</Inner>
|
<Sidebar />
|
||||||
</SidebarProvider>
|
<MainContent>{children}</MainContent>
|
||||||
|
</LayoutRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ const Fill = styled.div<{ $value: number }>`
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: ${({ $value }) => $value}%;
|
width: ${({ $value }) => $value}%;
|
||||||
background: ${({ $value }) =>
|
background: ${({ $value }) =>
|
||||||
$value >= 80 ? theme.colors.online :
|
$value >= 80 ? '#00FF00' :
|
||||||
$value >= 40 ? theme.colors.accent :
|
$value >= 40 ? '#58A6FF' :
|
||||||
theme.colors.textSecondary};
|
'var(--text-secondary)'};
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
transition: width 0.4s ease;
|
transition: width 0.4s ease;
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -4,140 +4,198 @@ import React from 'react';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/lib/AuthContext';
|
||||||
import { useSidebar } from '@/lib/SidebarContext';
|
import { useSidebar } from '@/lib/SidebarContext';
|
||||||
|
|
||||||
const MENU_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: '/', icon: '⬛', label: '대시보드' },
|
{ href: '/', label: '대시' },
|
||||||
{ href: '/projects', icon: '📁', label: '프로젝트' },
|
{ href: '/activities', label: '활동' },
|
||||||
{ href: '/sisters', icon: '🦊', label: '자매' },
|
{ href: '/projects', label: '프로' },
|
||||||
{ href: '/org', icon: '🏢', label: '조직도' },
|
{ href: '/sisters', label: '자매' },
|
||||||
{ href: '/settings', icon: '⚙️', label: '설정' },
|
{ href: '/org', label: '조직' },
|
||||||
|
{ href: '/settings', label: '설정' },
|
||||||
|
{ href: '/admin', label: '관리' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ADMIN_ITEMS = [
|
// ─── Desktop Sidebar ───
|
||||||
{ href: '/admin', icon: '🔧', label: '관리자' },
|
const SidebarWrapper = styled.aside`
|
||||||
];
|
width: 240px;
|
||||||
|
|
||||||
const Wrapper = styled.aside<{ $collapsed: boolean }>`
|
|
||||||
width: ${({ $collapsed }) => ($collapsed ? '60px' : '220px')};
|
|
||||||
height: 100vh;
|
|
||||||
background: rgba(13, 17, 23, 0.95);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border-right: 1px solid rgba(240, 246, 252, 0.1);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
transition: width 0.2s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const LogoArea = styled.div`
|
|
||||||
padding: 20px 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
border-bottom: 1px solid rgba(240, 246, 252, 0.08);
|
|
||||||
min-height: 64px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const LogoText = styled.span`
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #E6EDF3;
|
|
||||||
white-space: nowrap;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const CollapseBtn = styled.button`
|
|
||||||
margin-left: auto;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #8B949E;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
&:hover { color: #E6EDF3; background: rgba(240, 246, 252, 0.06); }
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Nav = styled.nav`
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px 8px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
padding: var(--space-xl) var(--space-lg);
|
||||||
`;
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
const NavItem = styled(Link)<{ $active: boolean }>`
|
@media (min-width: 768px) and (max-width: 1199px) {
|
||||||
display: flex;
|
width: 64px;
|
||||||
|
padding: var(--space-xl) var(--space-md);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
}
|
||||||
padding: 10px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: ${({ $active }) => ($active ? '#E6EDF3' : '#8B949E')};
|
|
||||||
background: ${({ $active }) => ($active ? 'rgba(88, 166, 255, 0.12)' : 'transparent')};
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&:hover {
|
@media (max-width: 767px) {
|
||||||
background: rgba(240, 246, 252, 0.06);
|
display: none;
|
||||||
color: #E6EDF3;
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const NavIcon = styled.span`
|
const LogoContainer = styled.div`
|
||||||
font-size: 16px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: var(--space-xxl);
|
||||||
|
|
||||||
|
@media (min-width: 768px) and (max-width: 1199px) {
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CircleFull = styled.div`
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Divider = styled.div`
|
const CircleHalf = styled.div`
|
||||||
height: 1px;
|
width: 10px;
|
||||||
background: rgba(240, 246, 252, 0.08);
|
height: 20px;
|
||||||
margin: 8px 8px;
|
background-color: var(--text-primary);
|
||||||
|
border-radius: 0 20px 20px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const AdminSection = styled.div`
|
const NavMenu = styled.ul`
|
||||||
padding: 8px;
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NavItem = styled.li``;
|
||||||
|
|
||||||
|
const NavLink = styled(Link)<{ $active: boolean }>`
|
||||||
|
color: ${({ $active }) => $active ? 'var(--accent-hover)' : '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%;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
${({ $active }) => $active && `
|
||||||
|
&::before { content: "["; margin-right: 4px; color: var(--text-secondary); }
|
||||||
|
&::after { content: "]"; margin-left: 4px; color: var(--text-secondary); }
|
||||||
|
`}
|
||||||
|
|
||||||
|
@media (min-width: 768px) and (max-width: 1199px) {
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Mobile Bottom Tab Bar ───
|
||||||
|
const BottomTabBar = styled.nav`
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 56px;
|
||||||
|
background: var(--bg-main);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
z-index: 100;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
&::-webkit-scrollbar { display: none; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TabItem = styled(Link)<{ $active: boolean }>`
|
||||||
|
flex: 1;
|
||||||
|
min-width: 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 6px 4px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
border-top: ${({ $active }) => $active ? '1px solid var(--text-primary)' : '1px solid transparent'};
|
||||||
|
|
||||||
|
&:hover { color: var(--text-primary); }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { collapsed, setCollapsed } = useSidebar();
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
const isActive = (href: string) =>
|
||||||
|
href === '/' ? pathname === '/' : pathname.startsWith(href);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper $collapsed={collapsed}>
|
<>
|
||||||
<LogoArea>
|
<SidebarWrapper>
|
||||||
<span style={{ fontSize: '22px', flexShrink: 0 }}>🦊</span>
|
<LogoContainer>
|
||||||
{!collapsed && <LogoText>하나랑</LogoText>}
|
<CircleFull />
|
||||||
<CollapseBtn onClick={() => setCollapsed(!collapsed)}>
|
<CircleHalf />
|
||||||
{collapsed ? '›' : '‹'}
|
</LogoContainer>
|
||||||
</CollapseBtn>
|
<nav>
|
||||||
</LogoArea>
|
<NavMenu>
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
<Nav>
|
<NavItem key={item.href}>
|
||||||
{MENU_ITEMS.map((item) => (
|
<NavLink href={item.href} $active={isActive(item.href)}>
|
||||||
<NavItem key={item.href} href={item.href} $active={pathname === item.href}>
|
{item.label}
|
||||||
<NavIcon>{item.icon}</NavIcon>
|
</NavLink>
|
||||||
{!collapsed && item.label}
|
|
||||||
</NavItem>
|
</NavItem>
|
||||||
))}
|
))}
|
||||||
</Nav>
|
</NavMenu>
|
||||||
|
</nav>
|
||||||
|
{user && (
|
||||||
|
<div style={{ marginTop: 'auto', paddingTop: 'var(--space-xl)' }}>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--text-secondary)', fontFamily: 'var(--font-mono)', marginBottom: 'var(--space-sm)' }}>
|
||||||
|
[{user.username}]
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none', color: 'var(--text-secondary)',
|
||||||
|
fontSize: '13px', cursor: 'pointer', padding: 0, textAlign: 'left',
|
||||||
|
transition: 'color 0.15s', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-secondary)')}
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SidebarWrapper>
|
||||||
|
|
||||||
<Divider />
|
<BottomTabBar>
|
||||||
<AdminSection>
|
{NAV_ITEMS.map((item) => (
|
||||||
{ADMIN_ITEMS.map((item) => (
|
<TabItem key={item.href} href={item.href} $active={isActive(item.href)}>
|
||||||
<NavItem key={item.href} href={item.href} $active={pathname === item.href}>
|
{item.label}
|
||||||
<NavIcon>{item.icon}</NavIcon>
|
</TabItem>
|
||||||
{!collapsed && item.label}
|
|
||||||
</NavItem>
|
|
||||||
))}
|
))}
|
||||||
</AdminSection>
|
</BottomTabBar>
|
||||||
</Wrapper>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ const statusLabels: Record<Status, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusColors: Record<Status, string> = {
|
const statusColors: Record<Status, string> = {
|
||||||
online: theme.colors.online,
|
online: '#00FF00',
|
||||||
offline: theme.colors.offline,
|
offline: '#FF1744',
|
||||||
working: theme.colors.working,
|
working: '#2979FF',
|
||||||
unknown: theme.colors.textSecondary,
|
unknown: 'var(--text-secondary)',
|
||||||
};
|
};
|
||||||
|
|
||||||
const BadgeWrapper = styled.span<{ $status: Status }>`
|
const BadgeWrapper = styled.span<{ $status: Status }>`
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ interface TabNavProps {
|
|||||||
const Nav = styled.div`
|
const Nav = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
border-bottom: 1px solid ${theme.colors.border};
|
border-bottom: 1px solid var(--border-color);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -27,8 +27,8 @@ const TabBtn = styled.button<{ $active: boolean }>`
|
|||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 2px solid ${({ $active }) => $active ? theme.colors.accent : 'transparent'};
|
border-bottom: 2px solid ${({ $active }) => $active ? '#58A6FF' : 'transparent'};
|
||||||
color: ${({ $active }) => $active ? theme.colors.textPrimary : theme.colors.textSecondary};
|
color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: ${({ $active }) => $active ? '600' : '400'};
|
font-weight: ${({ $active }) => $active ? '600' : '400'};
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -38,7 +38,7 @@ const TabBtn = styled.button<{ $active: boolean }>`
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: ${theme.colors.textPrimary};
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ interface TaskBadgeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusConfig: Record<TaskStatus, { label: string; color: string; bg: string }> = {
|
const statusConfig: Record<TaskStatus, { label: string; color: string; bg: string }> = {
|
||||||
pending: { label: '대기', color: theme.colors.textSecondary, bg: 'rgba(139,148,158,0.1)' },
|
pending: { label: '대기', color: 'var(--text-secondary)', bg: 'rgba(139,148,158,0.1)' },
|
||||||
in_progress: { label: '진행중', color: theme.colors.working, bg: 'rgba(41,121,255,0.12)' },
|
in_progress: { label: '진행중', color: '#2979FF', bg: 'rgba(41,121,255,0.12)' },
|
||||||
review: { label: '리뷰', color: '#FF9800', bg: 'rgba(255,152,0,0.12)' },
|
review: { label: '리뷰', color: '#FF9800', bg: 'rgba(255,152,0,0.12)' },
|
||||||
done: { label: '완료', color: theme.colors.online, bg: 'rgba(0,230,118,0.12)' },
|
done: { label: '완료', color: '#00FF00', bg: 'rgba(0,230,118,0.12)' },
|
||||||
failed: { label: '실패', color: theme.colors.offline, bg: 'rgba(255,23,68,0.12)' },
|
failed: { label: '실패', color: '#FF1744', bg: 'rgba(255,23,68,0.12)' },
|
||||||
blocked: { label: '블로킹', color: '#FF5722', bg: 'rgba(255,87,34,0.12)' },
|
blocked: { label: '블로킹', color: '#FF5722', bg: 'rgba(255,87,34,0.12)' },
|
||||||
escalated: { label: '에스컬레이션', color: '#E040FB', bg: 'rgba(224,64,251,0.12)' },
|
escalated: { label: '에스컬레이션', color: '#E040FB', bg: 'rgba(224,64,251,0.12)' },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const Item = styled.li`
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
border-bottom: 1px solid ${theme.colors.border};
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
@@ -65,13 +65,13 @@ const Body = styled.div`
|
|||||||
|
|
||||||
const Action = styled.div`
|
const Action = styled.div`
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: ${theme.colors.textPrimary};
|
color: var(--text-primary);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Detail = styled.div`
|
const Detail = styled.div`
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -79,7 +79,7 @@ const Detail = styled.div`
|
|||||||
|
|
||||||
const Time = styled.span`
|
const Time = styled.span`
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
`;
|
`;
|
||||||
@@ -87,7 +87,7 @@ const Time = styled.span`
|
|||||||
const Empty = styled.div`
|
const Empty = styled.div`
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ interface SisterCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusBorderColors: Record<Status, string> = {
|
const statusBorderColors: Record<Status, string> = {
|
||||||
online: theme.colors.online,
|
online: '#00FF00',
|
||||||
offline: theme.colors.offline,
|
offline: '#FF1744',
|
||||||
working: theme.colors.working,
|
working: '#2979FF',
|
||||||
unknown: theme.colors.textSecondary,
|
unknown: 'var(--text-secondary)',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sisterEmojis: Record<string, string> = {
|
const sisterEmojis: Record<string, string> = {
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ const sisterDisplayNames: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusColors: Record<Status, string> = {
|
const statusColors: Record<Status, string> = {
|
||||||
online: theme.colors.online,
|
online: '#00FF00',
|
||||||
offline: theme.colors.offline,
|
offline: '#FF1744',
|
||||||
working: theme.colors.working,
|
working: '#2979FF',
|
||||||
unknown: theme.colors.textSecondary,
|
unknown: 'var(--text-secondary)',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── 조직도 레이아웃 ───
|
// ─── 조직도 레이아웃 ───
|
||||||
@@ -61,7 +61,7 @@ const Level = styled.div`
|
|||||||
const ConnectorV = styled.div`
|
const ConnectorV = styled.div`
|
||||||
width: 2px;
|
width: 2px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
background: linear-gradient(to bottom, ${theme.colors.border}, rgba(88,166,255,0.4));
|
background: linear-gradient(to bottom, var(--border-color), rgba(88,166,255,0.4));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -83,10 +83,10 @@ const BranchWrap = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const NodeCard = styled.div<{ $status?: Status; $isOwner?: boolean }>`
|
const NodeCard = styled.div<{ $status?: Status; $isOwner?: boolean }>`
|
||||||
background: ${theme.colors.cardBg};
|
background: var(--bg-surface);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
border: 1px solid ${({ $status }) =>
|
border: 1px solid ${({ $status }) =>
|
||||||
$status ? `${statusColors[$status]}66` : theme.colors.border};
|
$status ? `${statusColors[$status]}66` : 'var(--border-color)'};
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: ${({ $isOwner }) => $isOwner ? '14px 24px' : '12px 18px'};
|
padding: ${({ $isOwner }) => $isOwner ? '14px 24px' : '12px 18px'};
|
||||||
min-width: ${({ $isOwner }) => $isOwner ? '140px' : '130px'};
|
min-width: ${({ $isOwner }) => $isOwner ? '140px' : '130px'};
|
||||||
@@ -98,7 +98,7 @@ const NodeCard = styled.div<{ $status?: Status; $isOwner?: boolean }>`
|
|||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||||
border-color: ${({ $status }) => $status ? statusColors[$status] : theme.colors.accent};
|
border-color: ${({ $status }) => $status ? statusColors[$status] : '#58A6FF'};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -110,13 +110,13 @@ const NodeEmoji = styled.div`
|
|||||||
const NodeName = styled.div`
|
const NodeName = styled.div`
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: ${theme.colors.textPrimary};
|
color: var(--text-primary);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const NodeRole = styled.div`
|
const NodeRole = styled.div`
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StatusDot = styled.div<{ $status: Status }>`
|
const StatusDot = styled.div<{ $status: Status }>`
|
||||||
@@ -128,7 +128,7 @@ const StatusDot = styled.div<{ $status: Status }>`
|
|||||||
top: 8px;
|
top: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
${({ $status }) => $status === 'online' && `
|
${({ $status }) => $status === 'online' && `
|
||||||
box-shadow: 0 0 6px ${theme.colors.online};
|
box-shadow: 0 0 6px #00FF00;
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ const Popup = styled.div<{ $x: number; $y: number }>`
|
|||||||
top: ${({ $y }) => $y}px;
|
top: ${({ $y }) => $y}px;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
background: rgba(22,27,34,0.97);
|
background: rgba(22,27,34,0.97);
|
||||||
border: 1px solid ${theme.colors.border};
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
@@ -158,19 +158,19 @@ const Popup = styled.div<{ $x: number; $y: number }>`
|
|||||||
const PopupName = styled.div`
|
const PopupName = styled.div`
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: ${theme.colors.textPrimary};
|
color: var(--text-primary);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PopupRole = styled.div`
|
const PopupRole = styled.div`
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: ${theme.colors.accent};
|
color: #58A6FF;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PopupDesc = styled.div`
|
const PopupDesc = styled.div`
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ interface ProjectCardProps {
|
|||||||
|
|
||||||
const Card = styled(Link)`
|
const Card = styled(Link)`
|
||||||
display: block;
|
display: block;
|
||||||
background: ${theme.colors.cardBg};
|
background: var(--bg-surface);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
border: 1px solid ${theme.colors.border};
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
@@ -46,12 +46,12 @@ const Header = styled.div`
|
|||||||
const Name = styled.span`
|
const Name = styled.span`
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: ${theme.colors.textPrimary};
|
color: var(--text-primary);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Meta = styled.div`
|
const Meta = styled.div`
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -66,7 +66,7 @@ const Stats = styled.div`
|
|||||||
|
|
||||||
const Stat = styled.div`
|
const Stat = styled.div`
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -80,7 +80,7 @@ const ProgressLabel = styled.div`
|
|||||||
|
|
||||||
const ProgressText = styled.span`
|
const ProgressText = styled.span`
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PRBadge = styled.span<{ $count: number }>`
|
const PRBadge = styled.span<{ $count: number }>`
|
||||||
@@ -88,7 +88,7 @@ const PRBadge = styled.span<{ $count: number }>`
|
|||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: ${({ $count }) => $count > 0 ? 'rgba(88,166,255,0.12)' : 'rgba(139,148,158,0.08)'};
|
background: ${({ $count }) => $count > 0 ? 'rgba(88,166,255,0.12)' : 'rgba(139,148,158,0.08)'};
|
||||||
color: ${({ $count }) => $count > 0 ? theme.colors.accent : theme.colors.textSecondary};
|
color: ${({ $count }) => $count > 0 ? '#58A6FF' : 'var(--text-secondary)'};
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ const sprintStatusIcon: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sprintStatusColor: Record<string, string> = {
|
const sprintStatusColor: Record<string, string> = {
|
||||||
pending: theme.colors.textSecondary,
|
pending: 'var(--text-secondary)',
|
||||||
in_progress: theme.colors.working,
|
in_progress: '#2979FF',
|
||||||
review: '#FF9800',
|
review: '#FF9800',
|
||||||
done: theme.colors.online,
|
done: '#00FF00',
|
||||||
failed: theme.colors.offline,
|
failed: '#FF1744',
|
||||||
};
|
};
|
||||||
|
|
||||||
const assigneeEmojis: Record<string, string> = {
|
const assigneeEmojis: Record<string, string> = {
|
||||||
@@ -51,8 +51,8 @@ const Wrapper = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const AccordionItem = styled.div<{ $open: boolean }>`
|
const AccordionItem = styled.div<{ $open: boolean }>`
|
||||||
background: ${theme.colors.cardBg};
|
background: var(--bg-surface);
|
||||||
border: 1px solid ${theme.colors.border};
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
@@ -69,7 +69,7 @@ const Header = styled.button<{ $statusColor: string }>`
|
|||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: ${theme.colors.textPrimary};
|
color: var(--text-primary);
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -96,14 +96,14 @@ const ProgressWrap = styled.div`
|
|||||||
|
|
||||||
const Chevron = styled.span<{ $open: boolean }>`
|
const Chevron = styled.span<{ $open: boolean }>`
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
transform: ${({ $open }) => $open ? 'rotate(90deg)' : 'none'};
|
transform: ${({ $open }) => $open ? 'rotate(90deg)' : 'none'};
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Body = styled.div<{ $open: boolean }>`
|
const Body = styled.div<{ $open: boolean }>`
|
||||||
display: ${({ $open }) => $open ? 'block' : 'none'};
|
display: ${({ $open }) => $open ? 'block' : 'none'};
|
||||||
border-top: 1px solid ${theme.colors.border};
|
border-top: 1px solid var(--border-color);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const TaskRow = styled.div`
|
const TaskRow = styled.div`
|
||||||
@@ -111,7 +111,7 @@ const TaskRow = styled.div`
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border-bottom: 1px solid ${theme.colors.border};
|
border-bottom: 1px solid var(--border-color);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
||||||
&:last-child { border-bottom: none; }
|
&:last-child { border-bottom: none; }
|
||||||
@@ -120,14 +120,14 @@ const TaskRow = styled.div`
|
|||||||
|
|
||||||
const TaskId = styled.code`
|
const TaskId = styled.code`
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: ${theme.colors.accent};
|
color: #58A6FF;
|
||||||
min-width: 70px;
|
min-width: 70px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const TaskTitle = styled.span`
|
const TaskTitle = styled.span`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
color: ${theme.colors.textPrimary};
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -135,13 +135,13 @@ const TaskTitle = styled.span`
|
|||||||
|
|
||||||
const Assignee = styled.span`
|
const Assignee = styled.span`
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
min-width: 60px;
|
min-width: 60px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Iteration = styled.span<{ $count: number }>`
|
const Iteration = styled.span<{ $count: number }>`
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: ${({ $count }) => $count > 0 ? '#FF9800' : theme.colors.textSecondary};
|
color: ${({ $count }) => $count > 0 ? '#FF9800' : 'var(--text-secondary)'};
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
`;
|
`;
|
||||||
@@ -163,7 +163,7 @@ export default function SprintAccordion({ sprints }: { sprints: Sprint[] }) {
|
|||||||
<Wrapper>
|
<Wrapper>
|
||||||
{sprints.map((sprint) => {
|
{sprints.map((sprint) => {
|
||||||
const open = openIds.has(sprint.id);
|
const open = openIds.has(sprint.id);
|
||||||
const color = sprintStatusColor[sprint.status] ?? theme.colors.textSecondary;
|
const color = sprintStatusColor[sprint.status] ?? 'var(--text-secondary)';
|
||||||
return (
|
return (
|
||||||
<AccordionItem key={sprint.id} $open={open}>
|
<AccordionItem key={sprint.id} $open={open}>
|
||||||
<Header $statusColor={color} onClick={() => toggle(sprint.id)}>
|
<Header $statusColor={color} onClick={() => toggle(sprint.id)}>
|
||||||
@@ -178,7 +178,7 @@ export default function SprintAccordion({ sprints }: { sprints: Sprint[] }) {
|
|||||||
</Header>
|
</Header>
|
||||||
<Body $open={open}>
|
<Body $open={open}>
|
||||||
{sprint.tasks.length === 0 ? (
|
{sprint.tasks.length === 0 ? (
|
||||||
<TaskRow><TaskTitle style={{ color: theme.colors.textSecondary }}>태스크 없음</TaskTitle></TaskRow>
|
<TaskRow><TaskTitle style={{ color: 'var(--text-secondary)' }}>태스크 없음</TaskTitle></TaskRow>
|
||||||
) : (
|
) : (
|
||||||
sprint.tasks.map((task) => (
|
sprint.tasks.map((task) => (
|
||||||
<TaskRow key={task.id}>
|
<TaskRow key={task.id}>
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ const Filters = styled.div`
|
|||||||
const FilterBtn = styled.button<{ $active: boolean }>`
|
const FilterBtn = styled.button<{ $active: boolean }>`
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid ${({ $active }) => $active ? theme.colors.accent : theme.colors.border};
|
border: 1px solid ${({ $active }) => $active ? '#58A6FF' : 'var(--border-color)'};
|
||||||
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.12)' : 'transparent'};
|
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.12)' : 'transparent'};
|
||||||
color: ${({ $active }) => $active ? theme.colors.accent : theme.colors.textSecondary};
|
color: ${({ $active }) => $active ? '#58A6FF' : 'var(--text-secondary)'};
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
|
||||||
&:hover { border-color: ${theme.colors.accent}; color: ${theme.colors.accent}; }
|
&:hover { border-color: #58A6FF; color: #58A6FF; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Table = styled.table`
|
const Table = styled.table`
|
||||||
@@ -49,8 +49,8 @@ const Th = styled.th`
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
border-bottom: 1px solid ${theme.colors.border};
|
border-bottom: 1px solid var(--border-color);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
`;
|
`;
|
||||||
@@ -58,8 +58,8 @@ const Th = styled.th`
|
|||||||
const Td = styled.td`
|
const Td = styled.td`
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
border-bottom: 1px solid ${theme.colors.border};
|
border-bottom: 1px solid var(--border-color);
|
||||||
color: ${theme.colors.textPrimary};
|
color: var(--text-primary);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Tr = styled.tr`
|
const Tr = styled.tr`
|
||||||
@@ -68,19 +68,19 @@ const Tr = styled.tr`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const TaskIdCell = styled.code`
|
const TaskIdCell = styled.code`
|
||||||
color: ${theme.colors.accent};
|
color: #58A6FF;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const IterationCell = styled.span<{ $count: number }>`
|
const IterationCell = styled.span<{ $count: number }>`
|
||||||
color: ${({ $count }) => $count > 0 ? '#FF9800' : theme.colors.textSecondary};
|
color: ${({ $count }) => $count > 0 ? '#FF9800' : 'var(--text-secondary)'};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Empty = styled.div`
|
const Empty = styled.div`
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { theme } from '@/styles/theme';
|
|||||||
|
|
||||||
const Pre = styled.pre`
|
const Pre = styled.pre`
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
border: 1px solid ${theme.colors.border};
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -26,7 +26,7 @@ const Pre = styled.pre`
|
|||||||
const Empty = styled.div`
|
const Empty = styled.div`
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const Th = styled.th`
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
border-bottom: 1px solid ${theme.colors.border};
|
border-bottom: 1px solid var(--border-color);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
`;
|
`;
|
||||||
@@ -29,8 +29,8 @@ const Th = styled.th`
|
|||||||
const Td = styled.td`
|
const Td = styled.td`
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: ${theme.colors.textPrimary};
|
color: var(--text-primary);
|
||||||
border-bottom: 1px solid ${theme.colors.border};
|
border-bottom: 1px solid var(--border-color);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Tr = styled.tr`
|
const Tr = styled.tr`
|
||||||
@@ -39,7 +39,7 @@ const Tr = styled.tr`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const SessionName = styled.code`
|
const SessionName = styled.code`
|
||||||
color: ${theme.colors.accent};
|
color: #58A6FF;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
`;
|
`;
|
||||||
@@ -47,7 +47,7 @@ const SessionName = styled.code`
|
|||||||
const Empty = styled.div`
|
const Empty = styled.div`
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -39,12 +39,12 @@ const Avatar = styled.div<{ $status: Status }>`
|
|||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: ${theme.colors.cardBg};
|
background: var(--bg-surface);
|
||||||
border: 2px solid ${({ $status }) =>
|
border: 2px solid ${({ $status }) =>
|
||||||
$status === 'online' ? theme.colors.online :
|
$status === 'online' ? '#00FF00' :
|
||||||
$status === 'working' ? theme.colors.working :
|
$status === 'working' ? '#2979FF' :
|
||||||
$status === 'offline' ? theme.colors.offline :
|
$status === 'offline' ? '#FF1744' :
|
||||||
theme.colors.border};
|
'var(--border-color)'};
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -59,7 +59,7 @@ const Info = styled.div`
|
|||||||
const Name = styled.h1`
|
const Name = styled.h1`
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: ${theme.colors.textPrimary};
|
color: var(--text-primary);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -68,14 +68,14 @@ const Name = styled.h1`
|
|||||||
|
|
||||||
const Role = styled.span`
|
const Role = styled.span`
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Description = styled.p`
|
const Description = styled.p`
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
`;
|
`;
|
||||||
@@ -89,7 +89,7 @@ const MetaRow = styled.div`
|
|||||||
|
|
||||||
const MetaItem = styled.span`
|
const MetaItem = styled.span`
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const Grid = styled.div`
|
|||||||
|
|
||||||
const Card = styled.div`
|
const Card = styled.div`
|
||||||
background: rgba(0,0,0,0.2);
|
background: rgba(0,0,0,0.2);
|
||||||
border: 1px solid ${theme.colors.border};
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -34,20 +34,20 @@ const Info = styled.div``;
|
|||||||
const Name = styled.div`
|
const Name = styled.div`
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: ${theme.colors.textPrimary};
|
color: var(--text-primary);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const File = styled.div`
|
const File = styled.div`
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Empty = styled.div`
|
const Empty = styled.div`
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: ${theme.colors.textSecondary};
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
222
frontend/components/ui/base.tsx
Normal file
222
frontend/components/ui/base.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import styled, { css } from 'styled-components';
|
||||||
|
|
||||||
|
// ─── Label Meta ───
|
||||||
|
export const LabelMeta = styled.span`
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-right: var(--space-sm);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Bracket Value ───
|
||||||
|
export const BracketValue = styled.div<{ $dimmed?: boolean }>`
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: ${({ $dimmed }) => $dimmed ? 'var(--text-secondary)' : 'var(--text-primary)'};
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Card ───
|
||||||
|
export const Card = styled.div`
|
||||||
|
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;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Tech Bar ───
|
||||||
|
export const TechBar = styled.div`
|
||||||
|
height: 2px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--border-color);
|
||||||
|
margin-top: auto;
|
||||||
|
position: relative;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const TechBarFill = styled.div<{ $width: number }>`
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: ${({ $width }) => $width}%;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Section Title ───
|
||||||
|
export const SectionTitle = styled.div`
|
||||||
|
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;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Timeline ───
|
||||||
|
export const Timeline = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 3px;
|
||||||
|
width: 1px;
|
||||||
|
background-color: var(--border-color);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const TimelineItem = styled.div`
|
||||||
|
position: relative;
|
||||||
|
padding-left: var(--space-lg);
|
||||||
|
padding-bottom: var(--space-lg);
|
||||||
|
|
||||||
|
&::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%;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const TimeStamp = styled.span`
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
display: block;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const TimelineContent = styled.div`
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Page Container ───
|
||||||
|
export const PageContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Main Content ───
|
||||||
|
export const MainContent = styled.main`
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: var(--space-xl) var(--space-xxl);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
padding: var(--space-lg) var(--space-md);
|
||||||
|
padding-bottom: 72px; /* bottom tab bar */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) and (max-width: 1199px) {
|
||||||
|
padding: var(--space-xl) var(--space-xl);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Page Header ───
|
||||||
|
export const PageHeader = styled.header`
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PageTitle = styled.h1`
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ─── Btn ───
|
||||||
|
const btnBase = css`
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Btn = styled.button`${btnBase}`;
|
||||||
|
|
||||||
|
export const BtnPrimary = styled.button`
|
||||||
|
${btnBase}
|
||||||
|
background: var(--text-primary);
|
||||||
|
color: #000;
|
||||||
|
border-color: var(--text-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const BtnToggle = styled.button<{ $active?: boolean }>`
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
|
||||||
|
color: ${({ $active }) => $active ? '#000' : 'var(--text-secondary)'};
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 4px var(--space-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
color: ${({ $active }) => $active ? '#000' : 'var(--text-primary)'};
|
||||||
|
}
|
||||||
|
`;
|
||||||
122
frontend/lib/AuthContext.tsx
Normal file
122
frontend/lib/AuthContext.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { API_URL } from './config';
|
||||||
|
|
||||||
|
interface AuthUser {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: AuthUser | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue>({
|
||||||
|
user: null,
|
||||||
|
loading: true,
|
||||||
|
login: async () => {},
|
||||||
|
logout: () => {},
|
||||||
|
isAuthenticated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadUser = useCallback(async () => {
|
||||||
|
const token = localStorage.getItem('hanarang_access_token');
|
||||||
|
if (!token) { setLoading(false); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/auth/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setUser({ userId: data.id, username: data.username, role: data.role });
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
// access token 만료 → refresh 시도
|
||||||
|
const refreshToken = localStorage.getItem('hanarang_refresh_token');
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const rRes = await fetch(`${API_URL}/api/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'x-refresh-token': refreshToken },
|
||||||
|
});
|
||||||
|
if (rRes.ok) {
|
||||||
|
const d = await rRes.json();
|
||||||
|
localStorage.setItem('hanarang_access_token', d.accessToken);
|
||||||
|
if (d.refreshToken) localStorage.setItem('hanarang_refresh_token', d.refreshToken);
|
||||||
|
setUser({ userId: 0, username: d.username, role: d.role });
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('hanarang_access_token');
|
||||||
|
localStorage.removeItem('hanarang_refresh_token');
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('hanarang_access_token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadUser(); }, [loadUser]);
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
const res = await fetch(`${API_URL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
throw new Error(d.message ?? '로그인 실패');
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = await res.json();
|
||||||
|
localStorage.setItem('hanarang_access_token', d.accessToken);
|
||||||
|
if (d.refreshToken) {
|
||||||
|
localStorage.setItem('hanarang_refresh_token', d.refreshToken);
|
||||||
|
}
|
||||||
|
// me API로 실제 userId 가져오기
|
||||||
|
try {
|
||||||
|
const meRes = await fetch(`${API_URL}/api/auth/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${d.accessToken}` },
|
||||||
|
});
|
||||||
|
if (meRes.ok) {
|
||||||
|
const me = await meRes.json();
|
||||||
|
setUser({ userId: me.id, username: me.username, role: me.role });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* fallback */ }
|
||||||
|
setUser({ userId: 0, username: d.username, role: d.role });
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('hanarang_access_token');
|
||||||
|
localStorage.removeItem('hanarang_refresh_token');
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, logout, isAuthenticated: !!user }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
@@ -1,22 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* 관리자 API 호출 유틸.
|
* 관리자 API 호출 유틸.
|
||||||
* API_URL이 빈 문자열이면 상대경로 → Next.js rewrites → BE 프록시.
|
* - CSR: 상대경로 /api/... → Next.js rewrites → BE
|
||||||
* Authorization 헤더에 localStorage의 admin key 삽입.
|
* - JWT access token (우선) 또는 API Key fallback으로 Authorization 헤더 설정
|
||||||
*/
|
*/
|
||||||
export function adminFetch(path: string, options?: RequestInit) {
|
export function getAdminToken(): string {
|
||||||
const apiKey =
|
if (typeof window === 'undefined') return '';
|
||||||
typeof window !== 'undefined'
|
return (
|
||||||
? (localStorage.getItem('hanarang_admin_key') ?? '')
|
localStorage.getItem('hanarang_access_token') ??
|
||||||
: '';
|
localStorage.getItem('hanarang_admin_key') ??
|
||||||
|
''
|
||||||
// CSR: path가 /api/... 이면 상대경로 그대로 사용
|
);
|
||||||
// SSR에서는 adminFetch 미사용 (모든 admin 페이지가 'use client')
|
}
|
||||||
return fetch(path, {
|
|
||||||
...options,
|
export function adminFetch(path: string, options?: RequestInit) {
|
||||||
headers: {
|
const token = getAdminToken();
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
// Authorization: options?.headers보다 우선 (덮어쓰기 방지)
|
||||||
...(options?.headers ?? {}),
|
const headers: Record<string, string> = {
|
||||||
},
|
'Content-Type': 'application/json',
|
||||||
});
|
...(options?.headers as Record<string, string> ?? {}),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch(path, { ...options, headers });
|
||||||
}
|
}
|
||||||
|
|||||||
57
frontend/lib/useSocket.ts
Normal file
57
frontend/lib/useSocket.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
interface UseSocketOptions {
|
||||||
|
onSistersUpdate?: (sisters: any[]) => void;
|
||||||
|
onActivityNew?: (item: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSocket(options: UseSocketOptions = {}) {
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// next.config.ts rewrites가 없는 경우를 위해 빈 origin 처리
|
||||||
|
// 브라우저에서 WebSocket은 rewrites 대상이 아니므로 직접 BE URL 필요
|
||||||
|
const wsUrl = process.env.NEXT_PUBLIC_WS_URL ?? window.location.origin;
|
||||||
|
const token = localStorage.getItem('hanarang_access_token') ?? '';
|
||||||
|
|
||||||
|
const socket = io(`${wsUrl}/ws`, {
|
||||||
|
path: '/socket.io',
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
reconnectionDelay: 3000,
|
||||||
|
auth: { token },
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
setConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
setConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('sisters:update', (data: { sisters: any[] }) => {
|
||||||
|
options.onSistersUpdate?.(data.sisters);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('activity:new', (data: { item: any }) => {
|
||||||
|
options.onActivityNew?.(data.item);
|
||||||
|
});
|
||||||
|
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const ping = useCallback(() => {
|
||||||
|
socketRef.current?.emit('ping');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { connected, ping };
|
||||||
|
}
|
||||||
86
frontend/package-lock.json
generated
86
frontend/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"styled-components": "^6.3.12"
|
"styled-components": "^6.3.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1282,6 +1283,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -2845,6 +2852,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/engine.io-client": {
|
||||||
|
"version": "6.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||||
|
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.18.3",
|
||||||
|
"xmlhttprequest-ssl": "~2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-abstract": {
|
"node_modules/es-abstract": {
|
||||||
"version": "1.24.1",
|
"version": "1.24.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
|
||||||
@@ -6372,6 +6401,34 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/socket.io-client": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-client": "~6.6.1",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser": {
|
||||||
|
"version": "4.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||||
|
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -7283,6 +7340,35 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlhttprequest-ssl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"styled-components": "^6.3.12"
|
"styled-components": "^6.3.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
import { createGlobalStyle } from 'styled-components';
|
import { createGlobalStyle } from 'styled-components';
|
||||||
|
|
||||||
const GlobalStyle = createGlobalStyle`
|
const GlobalStyle = createGlobalStyle`
|
||||||
|
:root {
|
||||||
|
--bg-main: #151515;
|
||||||
|
--bg-surface: #151515;
|
||||||
|
--bg-input: #1a1a1a;
|
||||||
|
--bg-code: #0d0d0d;
|
||||||
|
--text-primary: #F5F5F5;
|
||||||
|
--text-secondary: #8A8A8A;
|
||||||
|
--border-color: #333333;
|
||||||
|
--border-hover: #555555;
|
||||||
|
--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;
|
||||||
|
}
|
||||||
|
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -9,10 +29,11 @@ const GlobalStyle = createGlobalStyle`
|
|||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
font-family: var(--font-sans);
|
||||||
Ubuntu, Cantarell, sans-serif;
|
background-color: var(--bg-main);
|
||||||
background-color: #0D1117;
|
color: var(--text-primary);
|
||||||
color: #E6EDF3;
|
line-height: 1.5;
|
||||||
|
font-size: 14px;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
@@ -22,22 +43,31 @@ const GlobalStyle = createGlobalStyle`
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, select {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 4px;
|
||||||
height: 6px;
|
height: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #0D1117;
|
background: var(--bg-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(240, 246, 252, 0.1);
|
background: var(--border-color);
|
||||||
border-radius: 3px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(240, 246, 252, 0.2);
|
background: var(--border-hover);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user