- 디자인 시스템: #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 성공
304 lines
8.4 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|