diff --git a/backend/src/gitea/gitea.service.ts b/backend/src/gitea/gitea.service.ts index 13fd56d..13634b8 100644 --- a/backend/src/gitea/gitea.service.ts +++ b/backend/src/gitea/gitea.service.ts @@ -12,6 +12,7 @@ export interface GiteaRepo { open_issues_count: number; open_pr_counter: number; updated_at: string; + private?: boolean; } export interface GiteaPR { diff --git a/backend/src/projects/projects.service.ts b/backend/src/projects/projects.service.ts index 59cc3de..30540ca 100644 --- a/backend/src/projects/projects.service.ts +++ b/backend/src/projects/projects.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { GiteaService } from '../gitea/gitea.service'; type QaStatus = 'passed' | 'failed' | 'unknown'; +type ProjectVisibility = 'public' | 'private'; type ProjectPhase = | 'PLANNING' | 'IMPLEMENT' @@ -33,9 +34,14 @@ interface ProjectMetaSummary { latestDeployAt: string | null; } -const QA_PATH_CANDIDATES = ['.plans/qa/', '.qa/']; -const HOTFIX_PATH_CANDIDATES = ['.plans/hotfix/', '.plans/sprints/']; -const SPRINT_PATH = '.plans/sprints/'; +const QA_PATH_CANDIDATES = ['.plans/qa/', '.qa/', 'qa/']; +const HOTFIX_PATH_CANDIDATES = [ + '.plans/hotfix/', + '.hotfix/', + '.plans/sprints/', +]; +const SPRINT_PATH_CANDIDATES = ['.plans/sprints/', 'sprints/']; +const ASSIGNED_NODE_ORDER = ['harang', 'narang', 'darang', 'erang'] as const; function normalizeRepoName(repoUrl: string): string { return repoUrl.replace(/\/+$/, '').split('/').pop() ?? ''; @@ -252,6 +258,7 @@ export class ProjectsService { ownerSister, openPRs: repoMeta?.openPRs ?? 0, updatedAt: repoMeta?.updatedAt ?? project.updatedAt.toISOString(), + visibility: repoMeta?.visibility ?? 'private', latestHotfix: meta.latestHotfix, latestQaLabel: meta.latestQaLabel, latestQaStatus: meta.latestQaStatus, @@ -327,6 +334,15 @@ export class ProjectsService { note: 'ERANG: LATEST MAIN REDEPLOY ONLY', }, latestHotfix: docs.hotfixes.at(-1) ?? null, + assignedNodes: ASSIGNED_NODE_ORDER.map((name) => ({ + name, + role: name, + assigned: + activeSprint?.tasks.some((task) => task.assignee === name) ?? + project.sprints.some((sprint) => + sprint.tasks.some((task) => task.assignee === name), + ), + })), }; } @@ -365,14 +381,17 @@ export class ProjectsService { } private async getRepoMetaMap() { - const repoMetaMap: Record = - {}; + const repoMetaMap: Record< + number, + { openPRs: number; updatedAt: string; visibility: ProjectVisibility } + > = {}; try { const repos = await this.gitea.getOrgRepos(); for (const repo of repos) { repoMetaMap[repo.id] = { openPRs: repo.open_pr_counter, updatedAt: repo.updated_at, + visibility: repo.private ? 'private' : 'public', }; } } catch { @@ -393,22 +412,29 @@ export class ProjectsService { } private async collectRepoDocuments(repoName: string) { - const [sprintPaths, hotfixPathGroups, qaPathGroups] = await Promise.all([ - this.gitea.getRepoTree(repoName, SPRINT_PATH), - Promise.all( - HOTFIX_PATH_CANDIDATES.map((path) => - this.gitea.getRepoTree(repoName, path), + const [sprintPathGroups, hotfixPathGroups, qaPathGroups] = + await Promise.all([ + Promise.all( + SPRINT_PATH_CANDIDATES.map((path) => + this.gitea.getRepoTree(repoName, path), + ), ), - ), - Promise.all( - QA_PATH_CANDIDATES.map((path) => - this.gitea.getRepoTree(repoName, path), + Promise.all( + HOTFIX_PATH_CANDIDATES.map((path) => + this.gitea.getRepoTree(repoName, path), + ), ), - ), - ]); + Promise.all( + QA_PATH_CANDIDATES.map((path) => + this.gitea.getRepoTree(repoName, path), + ), + ), + ]); - const sprintFiles = sprintPaths.filter((path) => - /SPRINT-\d+\.md$/i.test(path), + const sprintFiles = Array.from( + new Set( + sprintPathGroups.flat().filter((path) => /SPRINT-\d+\.md$/i.test(path)), + ), ); const hotfixFiles = Array.from( new Set( diff --git a/backend/src/sisters/sister-detail.service.ts b/backend/src/sisters/sister-detail.service.ts index 8e37e26..c3e9a31 100644 --- a/backend/src/sisters/sister-detail.service.ts +++ b/backend/src/sisters/sister-detail.service.ts @@ -67,22 +67,21 @@ export class SisterDetailService { sister.ip, sister.user, sshKeyPath, - 'SESSION_DIR=~/.hermes/sessions; [ -d "$SESSION_DIR" ] || SESSION_DIR=~/.openclaw/sessions; ls -lt "$SESSION_DIR"/ 2>/dev/null | head -20 || echo ""', + 'SESSION_DIR=~/.hermes/sessions; [ -d "$SESSION_DIR" ] || SESSION_DIR=~/.openclaw/sessions; find "$SESSION_DIR" -maxdepth 1 -type f -printf "%TY-%Tm-%Td %TH:%TM|%s|%f\n" 2>/dev/null | sort -r | head -20 || echo ""', ); - const lines = result.stdout - .split('\n') - .filter((l) => l && !l.startsWith('total')); + const lines = result.stdout.split('\n').filter((line) => line.trim()); return lines .map((line) => { - const parts = line.trim().split(/\s+/); + const [modified = '', size = '0', file = ''] = line.split('|'); return { - name: parts[parts.length - 1] ?? '', - modified: parts.slice(5, 8).join(' '), - size: parts[4] ?? '0', + id: file, + label: file, + title: file, + status: `${size}B · ${modified}`, }; }) - .filter((s) => s.name && s.name !== ''); + .filter((session) => session.id && session.id !== ''); } catch { return []; } @@ -125,6 +124,33 @@ export class SisterDetailService { return { items: logs, total }; } + async getSisterLogs(name: SisterName, lines = 100) { + const sister = await this.getSisterRecord(name); + const sshKeyPath = this.getKeyPath(); + + try { + const result = await this.ssh.executeCommand( + sister.ip, + sister.user, + sshKeyPath, + `journalctl --user -u hermes-agent --no-pager -n ${lines} 2>/dev/null || journalctl --user -u hermes-gateway --no-pager -n ${lines} 2>/dev/null || journalctl --user -u openclaw-gateway --no-pager -n ${lines} 2>/dev/null || tail -n ${lines} ~/.hermes/logs/gateway.log 2>/dev/null || tail -n ${lines} ~/.openclaw/logs/gateway.log 2>/dev/null || echo "(로그 없음)"`, + ); + + const output = result.stdout + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0); + + return { + name, + lines: output.length > 0 ? output : ['(로그 없음)'], + total: output.length > 0 ? output.length : 1, + }; + } catch { + return { name, lines: ['(로그 조회 실패)'], total: 1 }; + } + } + async getOrgData() { const sisters = await this.prisma.sisterConfig.findMany(); return { diff --git a/backend/src/sisters/sisters.controller.ts b/backend/src/sisters/sisters.controller.ts index da8232c..73508b5 100644 --- a/backend/src/sisters/sisters.controller.ts +++ b/backend/src/sisters/sisters.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Res } from '@nestjs/common'; +import { Controller, Get, Param, Query, Res } from '@nestjs/common'; import type { Response } from 'express'; import { SistersService } from './sisters.service'; import { SisterDetailService } from './sister-detail.service'; @@ -59,4 +59,17 @@ export class SistersController { async getSisterActivity(@Param('name', SisterNamePipe) name: SisterName) { return this.sisterDetail.getSisterActivityLog(name); } + + @Get(':name/logs') + async getSisterLogs( + @Param('name', SisterNamePipe) name: SisterName, + @Query('lines') lines?: string, + ) { + const parsed = lines ? parseInt(lines, 10) : 100; + const clamped = Math.min( + Math.max(Number.isNaN(parsed) ? 100 : parsed, 1), + 500, + ); + return this.sisterDetail.getSisterLogs(name, clamped); + } } diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index ded0b2b..627708f 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -273,21 +273,22 @@ const ConnectionDot = styled.span<{ $connected: boolean }>` flex-shrink: 0; `; -const OperatorRow = styled.div` - display: flex; - align-items: center; - gap: 12px; +const OperatorGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; `; -const OperatorText = styled.div` +const OperatorCard = styled.div` + border: 1px solid var(--border-color); + padding: 12px; display: flex; flex-direction: column; - gap: 4px; - min-width: 0; + gap: 6px; `; -const OperatorName = styled.div` - font-size: 15px; +const OperatorValue = styled.div` + font-size: 14px; color: var(--text-primary); font-weight: 600; `; @@ -1006,15 +1007,28 @@ export default function DashboardPage() { SYNC:{formatSyncLabel(derived.freshness.activityLatestAt)} - - - - {SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.label ?? '나랑이'} - - {SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.role ?? 'Implementation'} / SNAPSHOT OWNER - - - + + + focus owner + {SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.label ?? '미지정'} + SNAPSHOT OWNER + + + runtime lag + {formatSyncLabel(derived.freshness.sistersLatestAt)} + RUNTIME SNAPSHOT + + + activity lag + {formatSyncLabel(derived.freshness.activityLatestAt)} + EVENT MIRROR + + + qa doc lag + {formatSyncLabel(derived.freshness.qaDocLatestAt)} + DOC-DERIVED + + diff --git a/frontend/app/projects/[id]/page.tsx b/frontend/app/projects/[id]/page.tsx index 9c0822b..069b5c9 100644 --- a/frontend/app/projects/[id]/page.tsx +++ b/frontend/app/projects/[id]/page.tsx @@ -4,17 +4,76 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useParams } from 'next/navigation'; import styled from 'styled-components'; import Link from 'next/link'; -import { LabelMeta, PageTitle, SectionTitle, TechBar, TechBarFill, BtnToggle } from '@/components/ui/base'; +import { + LabelMeta, + PageTitle, + SectionTitle, + TechBar, + TechBarFill, + BtnToggle, +} from '@/components/ui/base'; import { API_URL } from '@/lib/config'; import SisterAvatar from '@/components/common/SisterAvatar'; -interface TaskItem { id: number; taskId: string; title: string; assignee: string; status: string; } -interface SprintItem { id: number; number: number; name: string; status: string; progress: number; tasks: TaskItem[]; } -interface HistoryItem { label: string; kind: 'sprint' | 'hotfix'; summary?: string | null; description?: string | null; } -interface SisterState { name: string; status: string; } -interface CommitItem { sha?: string; html_url?: string; commit?: { message?: string; author?: { date?: string; name?: string } }; author?: { login?: string } | null; } -interface BranchItem { name?: string; protected?: boolean; commit?: { id?: string } } -interface PullItem { id?: number; number?: number; title?: string; html_url?: string; state?: string; merged?: boolean; user?: { login?: string }; created_at?: string; } +interface TaskItem { + id: number; + taskId: string; + title: string; + assignee: string; + status: string; +} + +interface SprintItem { + id: number; + number: number; + name: string; + status: string; + progress: number; + tasks: TaskItem[]; +} + +interface HistoryItem { + label: string; + kind: 'sprint' | 'hotfix'; + summary?: string | null; + description?: string | null; +} + +interface SisterState { + name: string; + status: string; +} + +interface CommitItem { + sha?: string; + html_url?: string; + commit?: { message?: string; author?: { date?: string; name?: string } }; + author?: { login?: string } | null; +} + +interface BranchItem { + name?: string; + protected?: boolean; + commit?: { id?: string }; +} + +interface PullItem { + id?: number; + number?: number; + title?: string; + html_url?: string; + state?: string; + merged?: boolean; + user?: { login?: string }; + created_at?: string; +} + +interface AssignedNode { + name: string; + role?: string; + assigned?: boolean; +} + interface ProjectDetail { id: number; name: string; @@ -22,32 +81,215 @@ interface ProjectDetail { status: string; phase: string; currentSprint?: string | null; - latestQa: { label: string | null; status: 'passed' | 'failed' | 'unknown'; blockerCount: number; summary: string | null } | null; - deploy: { branch: string; status: string; latestDeployAt: string | null; redeployRequired: boolean; note: string }; + latestQa: { + label: string | null; + status: 'passed' | 'failed' | 'unknown'; + blockerCount: number; + summary: string | null; + } | null; + deploy: { + branch: string; + status: string; + latestDeployAt: string | null; + redeployRequired: boolean; + note: string; + }; + assignedNodes?: AssignedNode[]; } -const Breadcrumb = styled.div`font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);margin-bottom:var(--space-md);a{color:var(--text-secondary);text-decoration:none;&:hover{color:var(--text-primary);}}`; -const PageTitleRow = styled.div`display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-lg);margin-bottom:var(--space-xl);flex-wrap:wrap;`; -const ProjectGrid = styled.div`display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:var(--space-xl);align-items:start;@media (max-width:1199px){display:flex;flex-direction:column;}`; -const Panel = styled.section<{ $column?: 'main' | 'side'; $mobileOrder?: number }>`border:1px solid var(--border-color);padding:var(--space-lg);background:var(--bg-surface);${({$column})=>$column==='side'?'grid-column:2;':'grid-column:1;'}@media (max-width:1199px){order:${({$mobileOrder=0})=>$mobileOrder};}`; -const FlowList = styled.div`display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:var(--space-sm);@media (max-width:767px){grid-template-columns:1fr;}`; -const FlowItem = styled.div<{ $active:boolean }>`border:1px solid ${({$active})=>$active?'var(--text-primary)':'var(--border-color)'};padding:var(--space-md);background:${({$active})=>$active?'#111':'transparent'};display:flex;flex-direction:column;gap:4px;`; -const LedgerList = styled.div`display:flex;flex-direction:column;gap:var(--space-md);`; -const LedgerItem = styled.div`display:flex;flex-direction:column;gap:8px;border-top:1px solid #1a1a1a;padding-top:var(--space-md);&:first-child{border-top:none;padding-top:0;}`; -const Meta = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);`; -const NodeStack = styled.div`display:flex;flex-direction:column;gap:var(--space-sm);`; -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;gap:var(--space-md);`; -const NodeInfo = styled.div`display:flex;align-items:center;gap:var(--space-sm);`; -const NodeDot = styled.div<{ $status:string }>`width:8px;height:8px;border-radius:50%;background:${({$status})=>$status==='online'?'#00ff88':$status==='working'?'#5fafff':'#555'};`; -const Empty = styled.div`padding:var(--space-xl) 0;color:var(--text-secondary);font-size:13px;font-family:var(--font-mono);`; -const TabBar = styled.div`display:flex;gap:var(--space-sm);margin-bottom:var(--space-xl);flex-wrap:wrap;`; -const GitList = styled.div`display:flex;flex-direction:column;gap:var(--space-sm);`; -const GitItem = styled.a`border:1px solid var(--border-color);padding:12px var(--space-lg);text-decoration:none;color:var(--text-primary);display:flex;justify-content:space-between;gap:var(--space-md);align-items:center;`; -const GitPrimary = styled.div`display:flex;flex-direction:column;gap:4px;min-width:0;`; -const GitTitle = styled.div`font-size:13px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`; +const Breadcrumb = styled.div` + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + margin-bottom: var(--space-md); -const ROLE_LABEL: Record = { harang: 'ORCHESTRATOR', narang: 'GENERATOR', darang: 'EVALUATOR', erang: 'INFRA' }; -const ROLE_NAME: Record = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' }; + a { + color: var(--text-secondary); + text-decoration: none; + + &:hover { + color: var(--text-primary); + } + } +`; + +const PageTitleRow = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-lg); + margin-bottom: var(--space-xl); + flex-wrap: wrap; +`; + +const TabBar = styled.div` + display: flex; + gap: var(--space-sm); + margin-bottom: var(--space-xl); + flex-wrap: wrap; +`; + +const OverviewGrid = styled.div` + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: var(--space-xl); + align-items: start; + + @media (max-width: 1199px) { + grid-template-columns: 1fr; + } +`; + +const MainColumn = styled.div` + display: flex; + flex-direction: column; + gap: var(--space-xl); +`; + +const SideColumn = styled.div` + display: flex; + flex-direction: column; + gap: var(--space-xl); +`; + +const Panel = styled.section` + border: 1px solid var(--border-color); + padding: var(--space-lg); + background: var(--bg-surface); +`; + +const FlowList = styled.div` + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--space-sm); + + @media (max-width: 767px) { + grid-template-columns: 1fr; + } +`; + +const FlowItem = styled.div<{ $active: boolean }>` + border: 1px solid ${({ $active }) => ($active ? 'var(--text-primary)' : 'var(--border-color)')}; + padding: var(--space-md); + background: ${({ $active }) => ($active ? '#111' : 'transparent')}; + display: flex; + flex-direction: column; + gap: 4px; +`; + +const StatusStack = styled.div` + display: flex; + flex-direction: column; + gap: var(--space-xl); +`; + +const LedgerList = styled.div` + display: flex; + flex-direction: column; + gap: var(--space-md); +`; + +const LedgerItem = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + border-top: 1px solid #1a1a1a; + padding-top: var(--space-md); + + &:first-child { + border-top: none; + padding-top: 0; + } +`; + +const Meta = styled.div` + font-size: 11px; + color: var(--text-secondary); + font-family: var(--font-mono); +`; + +const NodeStack = styled.div` + display: flex; + flex-direction: column; + gap: var(--space-sm); +`; + +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; + gap: var(--space-md); +`; + +const NodeInfo = styled.div` + display: flex; + align-items: center; + gap: var(--space-sm); +`; + +const NodeDot = styled.div<{ $status: string }>` + width: 8px; + height: 8px; + border-radius: 50%; + background: ${({ $status }) => ($status === 'online' ? '#00ff88' : $status === 'working' ? '#5fafff' : '#555')}; +`; + +const Empty = styled.div` + padding: var(--space-xl) 0; + color: var(--text-secondary); + font-size: 13px; + font-family: var(--font-mono); +`; + +const GitList = styled.div` + display: flex; + flex-direction: column; + gap: var(--space-sm); +`; + +const GitItem = styled.a` + border: 1px solid var(--border-color); + padding: 12px var(--space-lg); + text-decoration: none; + color: var(--text-primary); + display: flex; + justify-content: space-between; + gap: var(--space-md); + align-items: center; +`; + +const GitPrimary = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +`; + +const GitTitle = styled.div` + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const ROLE_LABEL: Record = { + harang: 'ORCHESTRATOR', + narang: 'GENERATOR', + darang: 'EVALUATOR', + erang: 'INFRA', +}; + +const ROLE_NAME: Record = { + harang: '하랑', + narang: '나랑', + darang: '다랑', + erang: '이랑', +}; + +const DEFAULT_NODE_ORDER = ['harang', 'narang', 'darang', 'erang'] as const; function formatDate(value?: string | null): string { if (!value) return '-'; @@ -63,7 +305,7 @@ export default function ProjectDetailPage() { const [commits, setCommits] = useState([]); const [branches, setBranches] = useState([]); const [pulls, setPulls] = useState([]); - const [tab, setTab] = useState<'overview'|'commits'|'branches'|'prs'>('overview'); + const [tab, setTab] = useState<'overview' | 'commits' | 'branches' | 'prs'>('overview'); const [loading, setLoading] = useState(true); useEffect(() => { @@ -72,29 +314,47 @@ export default function ProjectDetailPage() { fetch(`${API_URL}/api/projects/${id}/tasks`), fetch(`${API_URL}/api/projects/${id}/history`), fetch(`${API_URL}/api/sisters`), - ]).then(async ([projectRes, tasksRes, historyRes, sistersRes]) => { - if (projectRes.status === 'fulfilled' && projectRes.value.ok) setProject(await projectRes.value.json()); - if (tasksRes.status === 'fulfilled' && tasksRes.value.ok) setTasks(await tasksRes.value.json()); - if (historyRes.status === 'fulfilled' && historyRes.value.ok) setHistory(await historyRes.value.json()); - if (sistersRes.status === 'fulfilled' && sistersRes.value.ok) setSisters(await sistersRes.value.json()); - }).finally(() => setLoading(false)); + ]) + .then(async ([projectRes, tasksRes, historyRes, sistersRes]) => { + if (projectRes.status === 'fulfilled' && projectRes.value.ok) { + setProject(await projectRes.value.json()); + } + if (tasksRes.status === 'fulfilled' && tasksRes.value.ok) { + setTasks(await tasksRes.value.json()); + } + if (historyRes.status === 'fulfilled' && historyRes.value.ok) { + setHistory(await historyRes.value.json()); + } + if (sistersRes.status === 'fulfilled' && sistersRes.value.ok) { + setSisters(await sistersRes.value.json()); + } + }) + .finally(() => setLoading(false)); }, [id]); useEffect(() => { - if (tab === 'commits' && commits.length === 0) fetch(`${API_URL}/api/projects/${id}/commits?limit=20`).then((r) => r.ok ? r.json() : []).then(setCommits).catch(() => {}); - if (tab === 'branches' && branches.length === 0) fetch(`${API_URL}/api/projects/${id}/branches`).then((r) => r.ok ? r.json() : []).then(setBranches).catch(() => {}); - if (tab === 'prs' && pulls.length === 0) fetch(`${API_URL}/api/projects/${id}/pulls?state=all`).then((r) => r.ok ? r.json() : []).then(setPulls).catch(() => {}); + if (tab === 'commits' && commits.length === 0) { + fetch(`${API_URL}/api/projects/${id}/commits?limit=20`) + .then((r) => (r.ok ? r.json() : [])) + .then(setCommits) + .catch(() => {}); + } + if (tab === 'branches' && branches.length === 0) { + fetch(`${API_URL}/api/projects/${id}/branches`) + .then((r) => (r.ok ? r.json() : [])) + .then(setBranches) + .catch(() => {}); + } + if (tab === 'prs' && pulls.length === 0) { + fetch(`${API_URL}/api/projects/${id}/pulls?state=all`) + .then((r) => (r.ok ? r.json() : [])) + .then(setPulls) + .catch(() => {}); + } }, [tab, id, commits.length, branches.length, pulls.length]); - const allTasks = useMemo(() => tasks.flatMap((sprint) => sprint.tasks ?? []), [tasks]); - const assignedNodes = useMemo(() => { - const names = Array.from(new Set(allTasks.map((task) => task.assignee).filter(Boolean))); - return (names.length > 0 ? names : ['harang', 'narang', 'darang', 'erang']).map((name) => ({ - name, - status: sisters.find((sister) => sister.name === name)?.status ?? 'offline', - })); - }, [allTasks, sisters]); const hotfixes = history.filter((entry) => entry.kind === 'hotfix'); + const currentSprintMeta = useMemo(() => { if (project?.currentSprint) return project.currentSprint; @@ -108,6 +368,15 @@ export default function ProjectDetailPage() { return 'BACKLOG'; }, [project, tasks]); + const assignedNodes = useMemo(() => { + const apiNodes = project?.assignedNodes ?? []; + return DEFAULT_NODE_ORDER.map((name) => ({ + name, + assigned: apiNodes.find((node) => node.name === name)?.assigned ?? false, + status: sisters.find((sister) => sister.name === name)?.status ?? 'offline', + })); + }, [project, sisters]); + if (loading) return LOADING...; if (!project) return PROJECT NOT FOUND; @@ -121,100 +390,169 @@ export default function ProjectDetailPage() { return ( <> - PROJECTS / P-{String(project.id).padStart(3, '0')} / {project.name} + + PROJECTS / P-{String(project.id).padStart(3, '0')} / {project.name} + + {project.name} - {project.repoUrl && ↗ GITEA} + {project.repoUrl && ( + + ↗ GITEA + + )} - STATUS:{project.status?.toUpperCase()} / SPRINT:{currentSprintMeta} / DEPLOY:{project.deploy.status} + + STATUS: + {project.status?.toUpperCase()} / SPRINT: + {currentSprintMeta} / DEPLOY: + {project.deploy.status} + - setTab('overview')}>Overview - setTab('commits')}>Commits - setTab('branches')}>Branches - setTab('prs')}>Pull Requests + setTab('overview')}> + Overview + + setTab('commits')}> + Commits + + setTab('branches')}> + Branches + + setTab('prs')}> + Pull Requests + {tab === 'overview' && ( - - - DELIVERY FLOW{project.phase} - - {flowStates.map((item) => ( - - {item.title} - {item.key} - - ))} - - + + + + + DELIVERY FLOW + {project.phase} + + + {flowStates.map((item) => ( + + {item.title} + {item.key} + + ))} + + - - QA STATUS{project.latestQa?.status ?? 'unknown'} - {project.latestQa?.label ?? 'QA 문서 없음'} -
{project.latestQa?.summary ?? 'latest QA 없음'}
- {project.latestQa?.status === 'passed' ? 'QA PASSED' : project.latestQa?.status === 'failed' ? `QA FAILED · ${project.latestQa.blockerCount} BLOCKERS` : 'QA PENDING'} -
- - - DEPLOY STATUS{project.deploy.branch} - {project.deploy.status} - {project.deploy.note} - {project.deploy.latestDeployAt ? formatDate(project.deploy.latestDeployAt) : 'LAST DEPLOY UNKNOWN'} - - - - SPRINT LEDGER{tasks.length} SPRINTS - - {tasks.map((sprint) => ( - -
-
-
SPRINT {String(sprint.number).padStart(2, '0')} · {sprint.name}
- {sprint.tasks?.length ?? 0} TASKS · {sprint.tasks?.filter((task) => task.status === 'done').length ?? 0} DONE + + + SPRINT LEDGER + {tasks.length} SPRINTS + + + {tasks.map((sprint) => ( + +
+
+
+ SPRINT {String(sprint.number).padStart(2, '0')} · {sprint.name} +
+ + {sprint.tasks?.length ?? 0} TASKS · {sprint.tasks?.filter((task) => task.status === 'done').length ?? 0} DONE + +
+ {sprint.status}
- {sprint.status} -
- - - ))} - {tasks.length === 0 && NO SPRINTS} - - + + + + + ))} + {tasks.length === 0 && NO SPRINTS} + + - - HOTFIX HISTORY{hotfixes.length} ITEMS - - {hotfixes.map((entry) => ( - -
{entry.label}
- {entry.summary ?? entry.description ?? 'hotfix summary 없음'} -
- ))} - {hotfixes.length === 0 && NO HOTFIX HISTORY} -
-
+ + + HOTFIX HISTORY + {hotfixes.length} ITEMS + + + {hotfixes.map((entry) => ( + +
{entry.label}
+ {entry.summary ?? entry.description ?? 'hotfix summary 없음'} +
+ ))} + {hotfixes.length === 0 && NO HOTFIX HISTORY} +
+
+ - - ASSIGNED NODESLIVE STATUS - - {assignedNodes.map((node) => ( - - - -
-
{ROLE_NAME[node.name] ?? node.name}
- {ROLE_LABEL[node.name] ?? 'NODE'} -
-
- -
- ))} -
-
- + + + + + QA STATUS + {project.latestQa?.status ?? 'unknown'} + + {project.latestQa?.label ?? 'QA 문서 없음'} +
+ {project.latestQa?.summary ?? 'latest QA 없음'} +
+ + {project.latestQa?.status === 'passed' + ? 'QA PASSED' + : project.latestQa?.status === 'failed' + ? `QA FAILED · ${project.latestQa.blockerCount} BLOCKERS` + : 'QA PENDING'} + +
+ + + + DEPLOY STATUS + {project.deploy.branch} + + {project.deploy.status} + {project.deploy.note} + + {project.deploy.latestDeployAt ? formatDate(project.deploy.latestDeployAt) : 'LAST DEPLOY UNKNOWN'} + + + + + + ASSIGNED NODES + 4 SISTERS + + + {assignedNodes.map((node) => ( + + + +
+
+ {ROLE_NAME[node.name] ?? node.name} +
+ + {ROLE_LABEL[node.name] ?? 'NODE'} · {node.assigned ? 'ASSIGNED' : 'STANDBY'} + +
+
+ +
+ ))} +
+
+
+
+ )} {tab === 'commits' && ( @@ -225,7 +563,9 @@ export default function ProjectDetailPage() { {commit.commit?.message?.split('\n')[0] ?? commit.sha ?? 'UNKNOWN COMMIT'} {commit.commit?.author?.name ?? commit.author?.login ?? '-'} - {commit.sha?.slice(0, 7) ?? '-'} · {formatDate(commit.commit?.author?.date)} + + {commit.sha?.slice(0, 7) ?? '-'} · {formatDate(commit.commit?.author?.date)} + ))} {commits.length === 0 && NO COMMITS} @@ -252,8 +592,12 @@ export default function ProjectDetailPage() { {pulls.map((pull) => ( - #{pull.number ?? '-'} {pull.title ?? 'UNKNOWN PR'} - {pull.user?.login ?? '-'} · {pull.merged ? 'MERGED' : pull.state?.toUpperCase() ?? 'UNKNOWN'} + + #{pull.number ?? '-'} {pull.title ?? 'UNKNOWN PR'} + + + {pull.user?.login ?? '-'} · {pull.merged ? 'MERGED' : pull.state?.toUpperCase() ?? 'UNKNOWN'} + {formatDate(pull.created_at)} diff --git a/frontend/app/projects/page.tsx b/frontend/app/projects/page.tsx index b42be40..e140957 100644 --- a/frontend/app/projects/page.tsx +++ b/frontend/app/projects/page.tsx @@ -1,9 +1,16 @@ 'use client'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import Link from 'next/link'; -import { PageTitle, LabelMeta, TechBar, TechBarFill, Btn } from '@/components/ui/base'; +import { + PageTitle, + LabelMeta, + TechBar, + TechBarFill, + Btn, + BtnToggle, +} from '@/components/ui/base'; import { API_URL } from '@/lib/config'; import { adminFetch } from '@/lib/adminFetch'; @@ -20,22 +27,154 @@ interface ProjectListItem { blockerCount: number; deployStatus: string; updatedAt: string; + visibility?: 'public' | 'private'; } -const ProjectList = styled.div`display:flex;flex-direction:column;gap:var(--space-md);`; -const ProjectRow = styled(Link)`display:grid;grid-template-columns:1.2fr 1fr 220px;gap:var(--space-lg);padding:var(--space-lg);border:1px solid var(--border-color);align-items:center;text-decoration:none;transition:border-color .15s, background .15s;&:hover{border-color:var(--border-hover);background:#111}@media (max-width:1199px){grid-template-columns:1fr;}`; -const Left = styled.div`display:flex;flex-direction:column;gap:6px;min-width:0;`; -const ProjectName = styled.div`font-size:15px;font-weight:600;color:var(--text-primary);`; -const ProjectDesc = styled.div`font-size:12px;color:var(--text-secondary);line-height:1.5;`; -const Middle = styled.div`display:flex;flex-direction:column;gap:10px;`; -const Right = styled.div`display:flex;flex-direction:column;gap:8px;align-items:flex-end;@media (max-width:1199px){align-items:flex-start;}`; -const Meta = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);`; -const PhaseBadge = styled.div<{ $phase:string }>`font-size:11px;font-family:var(--font-mono);padding:4px 8px;border:1px solid ${({$phase})=>$phase==='DEPLOYED'?'#5fafff44':$phase==='READY FOR DEPLOY'?'#5fff8a44':$phase==='QA'?'#ffcc6644':'var(--border-color)'};color:${({$phase})=>$phase==='DEPLOYED'?'#5fafff':$phase==='READY FOR DEPLOY'?'#5fff8a':$phase==='QA'?'#ffcc66':'var(--text-primary)'};white-space:nowrap;`; -const EmptyState = styled.div`padding:var(--space-xl) 0;color:var(--text-secondary);font-size:13px;font-family:var(--font-mono);`; +type VisibilityFilter = 'all' | 'public' | 'private'; + +const Toolbar = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-md); + flex-wrap: wrap; + margin-bottom: var(--space-lg); +`; + +const ToolbarGroup = styled.div` + display: flex; + align-items: center; + gap: var(--space-md); + flex-wrap: wrap; +`; + +const FilterGroup = styled.div` + display: flex; + gap: var(--space-sm); + flex-wrap: wrap; +`; + +const ProjectList = styled.div` + display: flex; + flex-direction: column; + gap: var(--space-md); +`; + +const ProjectRow = styled(Link)` + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr) 240px; + gap: var(--space-lg); + padding: var(--space-lg); + border: 1px solid var(--border-color); + align-items: center; + text-decoration: none; + transition: border-color 0.15s, background 0.15s; + + &:hover { + border-color: var(--border-hover); + background: #111; + } + + @media (max-width: 1199px) { + grid-template-columns: 1fr; + } +`; + +const Left = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +`; + +const ProjectName = styled.div` + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +`; + +const ProjectDesc = styled.div` + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; +`; + +const Middle = styled.div` + display: flex; + flex-direction: column; + gap: 10px; +`; + +const Right = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-end; + + @media (max-width: 1199px) { + align-items: flex-start; + } +`; + +const Meta = styled.div` + font-size: 11px; + color: var(--text-secondary); + font-family: var(--font-mono); +`; + +const MetaRow = styled.div` + display: flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; +`; + +const PhaseBadge = styled.div<{ $phase: string }>` + font-size: 11px; + font-family: var(--font-mono); + padding: 4px 8px; + border: 1px solid + ${({ $phase }) => + $phase === 'DEPLOYED' + ? '#5fafff44' + : $phase === 'READY FOR DEPLOY' + ? '#5fff8a44' + : $phase === 'QA' + ? '#ffcc6644' + : 'var(--border-color)'}; + color: + ${({ $phase }) => + $phase === 'DEPLOYED' + ? '#5fafff' + : $phase === 'READY FOR DEPLOY' + ? '#5fff8a' + : $phase === 'QA' + ? '#ffcc66' + : 'var(--text-primary)'}; + white-space: nowrap; +`; + +const VisibilityBadge = styled.div<{ $visibility: VisibilityFilter | 'public' | 'private' }>` + font-size: 11px; + font-family: var(--font-mono); + padding: 4px 8px; + border: 1px solid ${({ $visibility }) => ($visibility === 'public' ? 'rgba(95,175,255,0.35)' : 'rgba(255,122,198,0.35)')}; + color: ${({ $visibility }) => ($visibility === 'public' ? '#8ec7ff' : '#ff9ed5')}; + white-space: nowrap; +`; + +const EmptyState = styled.div` + padding: var(--space-xl) 0; + color: var(--text-secondary); + font-size: 13px; + font-family: var(--font-mono); +`; function qaLabel(project: ProjectListItem): string { if (project.latestQaStatus === 'passed') return 'QA PASSED'; - if (project.latestQaStatus === 'failed') return `QA FAILED${project.blockerCount > 0 ? ` · ${project.blockerCount} BLOCKERS` : ''}`; + if (project.latestQaStatus === 'failed') { + return `QA FAILED${project.blockerCount > 0 ? ` · ${project.blockerCount} BLOCKERS` : ''}`; + } return 'QA PENDING'; } @@ -44,10 +183,15 @@ export default function ProjectsPage() { const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); const [syncResult, setSyncResult] = useState(null); + const [visibility, setVisibility] = useState('all'); const loadProjects = () => { setLoading(true); - fetch(`${API_URL}/api/projects`).then((r) => r.json()).then(setProjects).catch(() => {}).finally(() => setLoading(false)); + fetch(`${API_URL}/api/projects`) + .then((r) => r.json()) + .then(setProjects) + .catch(() => {}) + .finally(() => setLoading(false)); }; const runSync = async (path: string, label: string) => { @@ -65,40 +209,80 @@ export default function ProjectsPage() { } }; - useEffect(() => { loadProjects(); }, []); + useEffect(() => { + loadProjects(); + }, []); + + const filteredProjects = useMemo(() => { + if (visibility === 'all') return projects; + return projects.filter((project) => (project.visibility ?? 'private') === visibility); + }, [projects, visibility]); return ( <> -
- 프로젝트 -
- {syncResult && {syncResult}} - VOL: {String(projects.length).padStart(2, '0')} - runSync('/api/admin/gitea/sync', 'gitea sync')} disabled={syncing}>{syncing ? 'SYNCING...' : '↻ GITEA SYNC'} - runSync('/api/projects/sync-all-sprints', 'sprint sync')} disabled={syncing}>{syncing ? '...' : '↻ SPRINT SYNC'} -
-
+ + + 프로젝트 + + {([ + { id: 'all', label: '전체' }, + { id: 'public', label: 'public' }, + { id: 'private', label: 'private' }, + ] as const).map((item) => ( + setVisibility(item.id)}> + {item.label} + + ))} + + - {loading ? LOADING... : projects.length === 0 ? NO PROJECTS REGISTERED : ( + + {syncResult && ( + + {syncResult} + + )} + VOL: {String(filteredProjects.length).padStart(2, '0')} + runSync('/api/admin/gitea/sync', 'gitea sync')} disabled={syncing}> + {syncing ? 'SYNCING...' : '↻ GITEA SYNC'} + + runSync('/api/projects/sync-all-sprints', 'sprint sync')} disabled={syncing}> + {syncing ? '...' : '↻ SPRINT SYNC'} + + + + + {loading ? ( + LOADING... + ) : filteredProjects.length === 0 ? ( + NO PROJECTS + ) : ( - {projects.map((project) => ( + {filteredProjects.map((project) => ( P-{String(project.id).padStart(3, '0')} {project.name} {project.description ?? '설명 없음'} + -
+ {project.phase} + + {project.visibility ?? 'private'} + {project.currentSprint ? `SPRINT ${project.currentSprint}` : 'PLANNING BACKLOG'} {project.latestHotfix && HOTFIX {project.latestHotfix}} -
+
- + + + {project.progress ?? 0}%
+ {qaLabel(project)} {project.deployStatus} diff --git a/frontend/app/sisters/[name]/page.tsx b/frontend/app/sisters/[name]/page.tsx index a806dbe..2faf6bd 100644 --- a/frontend/app/sisters/[name]/page.tsx +++ b/frontend/app/sisters/[name]/page.tsx @@ -1,10 +1,19 @@ 'use client'; -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; import styled from 'styled-components'; import Link from 'next/link'; -import { LabelMeta, TechBar, TechBarFill, Timeline, TimelineItem, TimeStamp, TimelineContent, Btn } from '@/components/ui/base'; +import { + LabelMeta, + TechBar, + TechBarFill, + Timeline, + TimelineItem, + TimeStamp, + TimelineContent, + Btn, +} from '@/components/ui/base'; import { API_URL } from '@/lib/config'; import SisterAvatar from '@/components/common/SisterAvatar'; import { SISTER_ROLES } from '@/lib/sisters'; @@ -12,28 +21,222 @@ import { adminFetch } from '@/lib/adminFetch'; import CodeEditor from '@/components/admin/CodeEditor'; import LogTerminal from '@/components/admin/LogTerminal'; -const Breadcrumb = styled.div`font-family: var(--font-mono);font-size: 11px;color: var(--text-secondary);margin-bottom: var(--space-md);a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }`; -const Hero = styled.section`display:grid;grid-template-columns:128px 1fr;gap:var(--space-xl);align-items:center;padding:var(--space-xl);border:1px solid var(--border-color);margin-bottom:var(--space-xl);@media (max-width:767px){grid-template-columns:1fr;justify-items:start;}`; -const HeroMeta = styled.div`display:flex;flex-direction:column;gap:var(--space-sm);`; -const HeroName = styled.div`font-size:36px;font-weight:700;letter-spacing:-0.03em;color:var(--text-primary);`; -const HeroRole = styled.div`font-size:14px;color:var(--text-secondary);font-family:var(--font-mono);`; -const HeroStatus = styled.div`display:flex;align-items:center;gap:var(--space-sm);margin-top:var(--space-sm);flex-wrap:wrap;`; -const StatusDot = styled.div<{ $on: boolean }>`width:10px;height:10px;border-radius:50%;background:${({$on})=>$on?'#FFF':'#555'};${({$on})=>$on&&`box-shadow:0 0 8px rgba(255,255,255,0.3);`}`; -const HeroStats = styled.div`display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--space-md);margin-top:var(--space-lg);@media (max-width:767px){grid-template-columns:repeat(2,minmax(0,1fr));}`; -const StatCard = styled.div`border:1px solid var(--border-color);padding:var(--space-md);`; -const StatLabel = styled.div`font-size:10px;color:var(--text-secondary);font-family:var(--font-mono);margin-bottom:6px;`; -const StatValue = styled.div`font-size:18px;color:var(--text-primary);font-family:var(--font-mono);`; -const DetailGrid = styled.div`display:grid;grid-template-columns:320px 1fr;gap:var(--space-xxl);align-items:start;@media (max-width:1199px){grid-template-columns:1fr;gap:var(--space-xl);}`; -const MetaPanel = styled.div`display:flex;flex-direction:column;gap:var(--space-lg);`; -const MetaCard = styled.div`border:1px solid var(--border-color);padding:var(--space-lg);`; -const TabBar = styled.div`display:flex;gap:var(--space-lg);border-bottom:1px solid var(--border-color);margin-bottom:var(--space-lg);overflow:auto;`; -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 .15s,border-color .15s;white-space:nowrap;&:hover{color:var(--text-primary);}`; -const Toolbar = styled.div`display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;`; -const Select = styled.select`background:var(--bg-surface);border:1px solid var(--border-color);border-radius:7px;color:var(--text-primary);padding:8px 12px;font-size:13px;cursor:pointer;outline:none;option{background:#1a1f2a;}`; -const LinesInput = styled.input`background:var(--bg-surface);border:1px solid var(--border-color);border-radius:7px;color:var(--text-primary);padding:8px 12px;font-size:13px;width:80px;outline:none;`; -const ResultMsg = styled.div<{ $success: boolean }>`padding:8px 12px;border-radius:6px;font-size:12px;font-family:monospace;background:${({$success})=>$success?'rgba(0,230,118,0.08)':'rgba(255,23,68,0.08)'};color:${({$success})=>$success?'#00FF00':'#FF1744'};border:1px solid ${({$success})=>$success?'rgba(0,230,118,0.3)':'rgba(255,23,68,0.3)'};margin-bottom:12px;`; -const SessionCard = styled.div`border:1px solid var(--border-color);padding:var(--space-md);margin-bottom:var(--space-sm);`; -const SISTER_DISPLAY: Record = { harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이' }; +const Breadcrumb = styled.div` + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + margin-bottom: var(--space-md); + + a { + color: var(--text-secondary); + text-decoration: none; + + &:hover { + color: var(--text-primary); + } + } +`; + +const Hero = styled.section` + display: grid; + grid-template-columns: 128px 1fr; + gap: var(--space-xl); + align-items: center; + padding: var(--space-xl); + border: 1px solid var(--border-color); + margin-bottom: var(--space-xl); + + @media (max-width: 767px) { + grid-template-columns: 1fr; + justify-items: start; + } +`; + +const HeroMeta = styled.div` + display: flex; + flex-direction: column; + gap: var(--space-sm); +`; + +const HeroName = styled.div` + font-size: 36px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--text-primary); +`; + +const HeroRole = styled.div` + font-size: 14px; + color: var(--text-secondary); + font-family: var(--font-mono); +`; + +const HeroStatus = styled.div` + display: flex; + align-items: center; + gap: var(--space-sm); + margin-top: var(--space-sm); + flex-wrap: wrap; +`; + +const StatusDot = styled.div<{ $on: boolean }>` + width: 10px; + height: 10px; + border-radius: 50%; + background: ${({ $on }) => ($on ? '#FFF' : '#555')}; + ${({ $on }) => $on && 'box-shadow:0 0 8px rgba(255,255,255,0.3);'} +`; + +const HeroStats = styled.div` + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-md); + margin-top: var(--space-lg); + + @media (max-width: 767px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +`; + +const StatCard = styled.div` + border: 1px solid var(--border-color); + padding: var(--space-md); +`; + +const StatLabel = styled.div` + font-size: 10px; + color: var(--text-secondary); + font-family: var(--font-mono); + margin-bottom: 6px; +`; + +const StatValue = styled.div` + font-size: 18px; + color: var(--text-primary); + font-family: var(--font-mono); +`; + +const DetailGrid = styled.div` + display: grid; + grid-template-columns: 320px 1fr; + gap: var(--space-xxl); + align-items: start; + + @media (max-width: 1199px) { + grid-template-columns: 1fr; + gap: var(--space-xl); + } +`; + +const MetaPanel = styled.div` + display: flex; + flex-direction: column; + gap: var(--space-lg); +`; + +const MetaCard = styled.div` + border: 1px solid var(--border-color); + padding: var(--space-lg); +`; + +const TabBar = styled.div` + display: flex; + gap: var(--space-lg); + border-bottom: 1px solid var(--border-color); + margin-bottom: var(--space-lg); + overflow: auto; +`; + +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; + white-space: nowrap; + + &:hover { + color: var(--text-primary); + } +`; + +const Toolbar = styled.div` + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + flex-wrap: wrap; +`; + +const Select = styled.select` + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: 7px; + color: var(--text-primary); + padding: 8px 12px; + font-size: 13px; + cursor: pointer; + outline: none; + + option { + background: #1a1f2a; + } +`; + +const LinesInput = styled.input` + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: 7px; + color: var(--text-primary); + padding: 8px 12px; + font-size: 13px; + width: 80px; + outline: none; +`; + +const ResultMsg = styled.div<{ $success: boolean }>` + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + font-family: monospace; + background: ${({ $success }) => ($success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)')}; + color: ${({ $success }) => ($success ? '#00FF00' : '#FF1744')}; + border: 1px solid ${({ $success }) => ($success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)')}; + margin-bottom: 12px; +`; + +const SessionCard = styled.div` + border: 1px solid var(--border-color); + padding: var(--space-md); + margin-bottom: var(--space-sm); +`; + +const OverviewBlock = styled.div` + border: 1px solid var(--border-color); + padding: var(--space-lg); + display: flex; + flex-direction: column; + gap: var(--space-md); +`; + +const EmptyState = styled.div` + color: var(--text-secondary); + font-size: 13px; + font-family: var(--font-mono); + padding: var(--space-lg) 0; +`; + +const SISTER_DISPLAY: Record = { + harang: '하랑이', + narang: '나랑이', + darang: '다랑이', + erang: '이랑이', +}; + const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md']; interface SisterInfo { @@ -54,6 +257,8 @@ interface SystemInfo { interface ConfigData { description?: string | null; + raw?: string; + role?: string; } interface ActivityItem { @@ -74,11 +279,6 @@ interface SessionItem { status?: string; } -interface SessionsResponse { - sessions?: SessionItem[]; - items?: SessionItem[]; -} - interface HarnessResponse { content?: string; } @@ -108,6 +308,8 @@ export default function SisterDetailPage() { const [content, setContent] = useState(''); const [saved, setSaved] = useState(''); const [saveResult, setSaveResult] = useState<{ success: boolean; msg: string } | null>(null); + const [sessionError, setSessionError] = useState(null); + const [activityError, setActivityError] = useState(null); useEffect(() => { Promise.allSettled([ @@ -116,46 +318,80 @@ export default function SisterDetailPage() { fetch(`${API_URL}/api/sisters/${name}/activity`), fetch(`${API_URL}/api/sisters/${name}/system`), fetch(`${API_URL}/api/sisters/${name}/sessions`), - ]).then(([sRes, cRes, aRes, syRes, ssRes]) => { - if (sRes.status === 'fulfilled' && sRes.value.ok) sRes.value.json().then((d: SisterInfo[]) => setSisterInfo(d.find((s) => s.name === name) ?? null)); - if (cRes.status === 'fulfilled' && cRes.value.ok) cRes.value.json().then((data: ConfigData) => setConfigData(data)); - if (aRes.status === 'fulfilled' && aRes.value.ok) aRes.value.json().then((d: ActivityResponse) => setActivity(d.items ?? [])); - if (syRes.status === 'fulfilled' && syRes.value.ok) syRes.value.json().then((data: SystemInfo) => setSystemInfo(data)); - if (ssRes.status === 'fulfilled' && ssRes.value.ok) ssRes.value.json().then((d: SessionsResponse) => setSessions(d.sessions ?? d.items ?? [])); - }).finally(() => setLoading(false)); + ]) + .then(async ([sRes, cRes, aRes, syRes, ssRes]) => { + if (sRes.status === 'fulfilled' && sRes.value.ok) { + const sisters = (await sRes.value.json()) as SisterInfo[]; + setSisterInfo(sisters.find((s) => s.name === name) ?? null); + } + if (cRes.status === 'fulfilled' && cRes.value.ok) { + setConfigData(await cRes.value.json()); + } + if (aRes.status === 'fulfilled' && aRes.value.ok) { + const data = (await aRes.value.json()) as ActivityResponse; + setActivity(data.items ?? []); + } else { + setActivityError('활동 조회 실패'); + } + if (syRes.status === 'fulfilled' && syRes.value.ok) { + setSystemInfo(await syRes.value.json()); + } + if (ssRes.status === 'fulfilled' && ssRes.value.ok) { + const raw = (await ssRes.value.json()) as SessionItem[] | { items?: SessionItem[]; sessions?: SessionItem[] }; + const items = Array.isArray(raw) ? raw : raw.sessions ?? raw.items ?? []; + setSessions(items); + } else { + setSessionError('세션 조회 실패'); + } + }) + .finally(() => setLoading(false)); }, [name]); const fetchLogs = useCallback(async () => { setLogLoading(true); try { - const res = await adminFetch(`/api/admin/logs/${name}?lines=${lines}`); - const d: LogResponse = await res.json(); - setLogLines(d.lines ?? []); + const res = await fetch(`${API_URL}/api/sisters/${name}/logs?lines=${lines}`); + const data = (await res.json()) as LogResponse; + setLogLines(data.lines ?? ['(로그 없음)']); } catch { - setLogLines(['(로그 로드 실패)']); + setLogLines(['(로그 조회 실패)']); } finally { setLogLoading(false); } }, [name, lines]); - useEffect(() => { if (tab === 'logs') fetchLogs(); }, [tab, fetchLogs]); + useEffect(() => { + if (tab === 'logs') { + void fetchLogs(); + } + }, [tab, fetchLogs]); useEffect(() => { if (tab !== 'harness') return; setSaveResult(null); adminFetch(`/api/admin/harness/${name}/${selectedFile}`) .then((r) => r.json()) - .then((d: HarnessResponse) => { setContent(d.content ?? ''); setSaved(d.content ?? ''); }) + .then((d: HarnessResponse) => { + setContent(d.content ?? ''); + setSaved(d.content ?? ''); + }) .catch(() => setContent('(로드 실패)')); }, [tab, name, selectedFile]); const handleSave = async () => { setSaveResult(null); try { - const res = await adminFetch(`/api/admin/harness/${name}/${selectedFile}`, { method: 'PUT', body: JSON.stringify({ content }) }); - const d: SaveHarnessResponse = await res.json(); - if (d.success) { setSaved(content); setSaveResult({ success: true, msg: '저장 완료' }); } - else setSaveResult({ success: false, msg: d.error ?? '저장 실패' }); + const res = await adminFetch(`/api/admin/harness/${name}/${selectedFile}`, { + method: 'PUT', + body: JSON.stringify({ content }), + }); + const d = (await res.json()) as SaveHarnessResponse; + if (d.success) { + setSaved(content); + setSaveResult({ success: true, msg: '저장 완료' }); + } else { + setSaveResult({ success: false, msg: d.error ?? '저장 실패' }); + } } catch (e) { setSaveResult({ success: false, msg: (e as Error).message }); } @@ -165,54 +401,198 @@ export default function SisterDetailPage() { return ( <> - 자매 노드 관리 / {SISTER_DISPLAY[name] ?? name} - {loading ?
LOADING...
: <> - - - - {SISTER_DISPLAY[name] ?? name} - {SISTER_ROLES[name] ?? 'UNKNOWN'} - - - {isActive ? 'ACTIVE' : 'STANDBY'} - LXC:{sisterInfo?.lxcId ?? '--'} - - - UPTIME{systemInfo?.uptime ?? '--:--:--'} - CPU{systemInfo?.cpu?.toFixed?.(1) ?? '0.0'}% - MEM{systemInfo?.memory?.used ?? 0}/{systemInfo?.memory?.total ?? 0}MB - DISK{systemInfo?.disk?.used ?? '0G'}/{systemInfo?.disk?.total ?? '0G'} - - - + + 자매 노드 관리 / {SISTER_DISPLAY[name] ?? name} + + {loading ? ( + LOADING... + ) : ( + <> + + + + {SISTER_DISPLAY[name] ?? name} + {SISTER_ROLES[name] ?? configData?.role ?? 'UNKNOWN'} + + + {isActive ? 'ACTIVE' : 'STANDBY'} + + LXC: + {sisterInfo?.lxcId ?? '--'} + + + + + UPTIME + {systemInfo?.uptime ?? '--:--:--'} + + + CPU + {systemInfo?.cpu?.toFixed?.(1) ?? '0.0'}% + + + MEM + + {systemInfo?.memory?.used ?? 0}/{systemInfo?.memory?.total ?? 0}MB + + + + DISK + + {systemInfo?.disk?.used ?? '0G'}/{systemInfo?.disk?.total ?? '0G'} + + + + + - - - - SYSTEM SUMMARY -
- LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'}
- ROLE: {SISTER_ROLES[name] ?? '--'}
- STATUS: {String(sisterInfo?.status ?? 'unknown').toUpperCase()}
- HOST_USER: {sisterInfo?.user ?? '--'} -
- -
- {configData?.description && DESC
{configData.description}
} -
-
- - {[{ id: 'overview', label: '개요' }, { id: 'sessions', label: '세션' }, { id: 'activity', label: '활동' }, { id: 'harness', label: '하네스' }, { id: 'logs', label: '로그' }].map((t) => setTab(t.id)}>{t.label})} - + + + + SYSTEM SUMMARY +
+ LAST_SEEN: {sisterInfo?.lastSeen ? new Date(sisterInfo.lastSeen).toLocaleString() : '--'} +
+ ROLE: {SISTER_ROLES[name] ?? '--'} +
+ STATUS: {String(sisterInfo?.status ?? 'unknown').toUpperCase()} +
+ HOST_USER: {sisterInfo?.user ?? '--'} +
+ + + +
+ + OVERVIEW +
+ {configData?.description ?? '설명 없음'} +
+
+
- {tab === 'overview' && {activity.slice(0, 8).map((item) => {new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}{item.detail ?? item.action})}} - {tab === 'sessions' && <>{sessions.length === 0 ?
세션 없음
: sessions.map((session, i: number) =>
{session.title ?? session.label ?? session.id ?? 'session'}
{session.status ?? ''}
)}} - {tab === 'activity' && {activity.map((item) => {new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })}{item.detail ?? item.action})}} - {tab === 'harness' && <> { setContent(saved); setSaveResult(null); }}>되돌리기저장{saveResult && {saveResult.msg}}} - {tab === 'logs' && <>최근 setLines(parseInt(e.target.value, 10) || 100)} min={10} max={500} />{logLoading ? '로딩 중...' : '로그 가져오기'}} -
-
- } +
+ + {[ + { id: 'overview', label: '개요' }, + { id: 'sessions', label: '세션' }, + { id: 'activity', label: '활동' }, + { id: 'harness', label: '하네스' }, + { id: 'logs', label: '로그' }, + ].map((item) => ( + setTab(item.id)}> + {item.label} + + ))} + + + {tab === 'overview' && ( + + CONFIG SNAPSHOT +
+ {configData?.raw ? ( +
{configData.raw}
+ ) : ( + '설정 파일 없음' + )} +
+
+ )} + + {tab === 'sessions' && ( + <> + {sessionError ? ( + {sessionError} + ) : sessions.length === 0 ? ( + 세션 없음 + ) : ( + sessions.map((session, index) => ( + +
+ {session.title ?? session.label ?? session.id ?? 'session'} +
+
+ {session.status ?? ''} +
+
+ )) + )} + + )} + + {tab === 'activity' && ( + <> + {activityError ? ( + {activityError} + ) : activity.length === 0 ? ( + 활동 없음 + ) : ( + + {activity.map((item) => ( + + {new Date(item.createdAt).toLocaleString('ko-KR', { hour12: false })} + {item.detail ?? item.action} + + ))} + + )} + + )} + + {tab === 'harness' && ( + <> + + + { + setContent(saved); + setSaveResult(null); + }} + > + 되돌리기 + + 저장 + + {saveResult && {saveResult.msg}} + + + )} + + {tab === 'logs' && ( + <> + + 최근 + setLines(parseInt(e.target.value, 10) || 100)} + min={10} + max={500} + /> + + void fetchLogs()}>{logLoading ? '로딩 중...' : '로그 가져오기'} + + + + )} +
+ + + )} ); }