Files
hanarang-dashboard/frontend/app/activities/page.tsx
narang-ai 230d3847ef feat(redesign-v2): 미니멀 터미널 UI 전체 리디자인
- 디자인 시스템: #151515 배경, 1px 보더 카드, CSS vars, mono 포인트
- GlobalStyle/theme.ts v2 (DESIGN-SYSTEM.md 기반)
- 공통 컴포넌트 (ui/base.tsx): LabelMeta, BracketValue, Card, TechBar, Timeline 등
- Sidebar: [대시] 브라켓 네비 + 모바일 하단 탭바 (767px)
- LayoutShell: SidebarContext 제거, 단순화
- 대시보드 (/): SYS 상태 카드 4개 + ONGOING PROJECTS + ACTIVITY FEED
- 활동 로그 (/activities): 로그 테이블 + 필터 + 검색 + 페이지네이션 (신규)
- 설정 (/settings): 4섹션 Toggle/Bracket Input 그리드
- 자매 노드 관리 (/sisters): 3열 수평 레이아웃 (INFO/GRAPH/SYNC)
- 자매 상세 (/sisters/[name]): 간소화 + 터미널 스타일
- 조직도 (/org): 터미널 카드 트리 구조 + 레벨 색상
- 프로젝트 목록 (/projects): 새 라우트
- 프로젝트 상세 (/projects/[id]): Phase Timeline + Audit Log + Checklist
- FE 14 routes build 성공
2026-04-04 13:10:57 +09:00

304 lines
8.4 KiB
TypeScript

'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>
</>
);
}