Files
hanarang-dashboard/frontend/app/admin/page.tsx
narang-ai 033c142c0e feat(sprint-004): admin features + API auth guard
Sprint 003 non-blocking 처리:
- API Key Guard (passport-http-bearer) + AuthModule
- Helmet 적용 (main.ts)
- SisterNamePipe - name 파라미터 Controller 검증
- lib/sisters.ts - formatLastSeen/sisterEmojis/sisterDisplayNames 공통 추출

Sprint 004 본문:
- TASK-012: POST /api/admin/sisters/:name/{restart,reset} (ApiKeyGuard 적용)
- TASK-013: GET/PUT /api/admin/harness/:name/:file (허용 파일 allowlist)
- TASK-015: GET /api/admin/logs/:name
- 관리자 전 API @UseGuards(ApiKeyGuard)
- TASK-014: /admin 자매 관리 (재시작/리셋 + ConfirmModal)
- TASK-014: /admin/harness 하네스 편집 (CodeEditor + 파일트리)
- TASK-015: /admin/logs 로그 뷰어 (LogTerminal + 검색/에러필터)
- /admin/repos 저장소 목록
- AdminLayout (API Key 로컬스토리지 저장)
- 테스트 22/22 pass, FE 11 routes build 성공
2026-04-04 12:11:06 +09:00

176 lines
5.8 KiB
TypeScript

'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import StatusBadge from '@/components/common/StatusBadge';
import ConfirmModal from '@/components/admin/ConfirmModal';
import { theme } from '@/styles/theme';
import { API_URL } from '@/lib/config';
import { adminFetch } from '@/lib/adminFetch';
import { SISTER_EMOJIS, SISTER_DISPLAY_NAMES } from '@/lib/sisters';
type Status = 'online' | 'offline' | 'working' | 'unknown';
interface ActionResult {
success: boolean;
output: string;
error: string | null;
}
const Table = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const Row = styled.div`
background: ${theme.colors.cardBg};
border: 1px solid ${theme.colors.border};
border-radius: 10px;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 16px;
`;
const SisterInfo = styled.div`
display: flex;
align-items: center;
gap: 10px;
flex: 1;
`;
const Emoji = styled.span`font-size: 22px;`;
const Name = styled.span`
font-size: 15px;
font-weight: 600;
color: ${theme.colors.textPrimary};
min-width: 70px;
`;
const Actions = styled.div`
display: flex;
gap: 8px;
`;
const ActionBtn = styled.button<{ $danger?: boolean }>`
padding: 6px 14px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid;
transition: all 0.15s;
${({ $danger }) =>
$danger
? `background: rgba(255,23,68,0.08); border-color: rgba(255,23,68,0.3); color: #FF1744;
&:hover { background: rgba(255,23,68,0.18); }`
: `background: rgba(88,166,255,0.08); border-color: rgba(88,166,255,0.3); color: #58A6FF;
&:hover { background: rgba(88,166,255,0.18); }`}
`;
const ResultBanner = styled.div<{ $success: boolean }>`
margin-top: 8px;
padding: 8px 12px;
background: ${({ $success }) => $success ? 'rgba(0,230,118,0.08)' : 'rgba(255,23,68,0.08)'};
border: 1px solid ${({ $success }) => $success ? 'rgba(0,230,118,0.3)' : 'rgba(255,23,68,0.3)'};
border-radius: 6px;
font-size: 12px;
color: ${({ $success }) => $success ? theme.colors.online : theme.colors.offline};
font-family: monospace;
white-space: pre-wrap;
`;
export default function AdminSistersPage() {
const [sisters, setSisters] = useState<any[]>([]);
const [modal, setModal] = useState<{ action: 'restart' | 'reset'; name: string } | null>(null);
const [results, setResults] = useState<Record<string, ActionResult>>({});
const [loading, setLoading] = useState<Record<string, boolean>>({});
useEffect(() => {
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
const iv = setInterval(() => {
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
}, 15000);
return () => clearInterval(iv);
}, []);
const doAction = async (action: 'restart' | 'reset', name: string) => {
setModal(null);
setLoading((p) => ({ ...p, [`${action}:${name}`]: true }));
try {
const res = await adminFetch(`/api/admin/sisters/${name}/${action}`, { method: 'POST' });
const data: ActionResult = await res.json();
setResults((p) => ({ ...p, [`${action}:${name}`]: data }));
} catch (e) {
setResults((p) => ({
...p,
[`${action}:${name}`]: { success: false, output: '', error: (e as Error).message },
}));
} finally {
setLoading((p) => ({ ...p, [`${action}:${name}`]: false }));
}
};
return (
<>
<Table>
{sisters.map((s) => {
const restartResult = results[`restart:${s.name}`];
const resetResult = results[`reset:${s.name}`];
return (
<div key={s.id}>
<Row>
<SisterInfo>
<Emoji>{SISTER_EMOJIS[s.name] ?? '🤖'}</Emoji>
<Name>{SISTER_DISPLAY_NAMES[s.name] ?? s.name}</Name>
<StatusBadge status={s.status as Status} />
</SisterInfo>
<Actions>
<ActionBtn
onClick={() => setModal({ action: 'restart', name: s.name })}
disabled={loading[`restart:${s.name}`]}
>
{loading[`restart:${s.name}`] ? '...' : '🔄 재시작'}
</ActionBtn>
<ActionBtn
$danger
onClick={() => setModal({ action: 'reset', name: s.name })}
disabled={loading[`reset:${s.name}`]}
>
{loading[`reset:${s.name}`] ? '...' : '🗑️ 리셋'}
</ActionBtn>
</Actions>
</Row>
{restartResult && (
<ResultBanner $success={restartResult.success}>
: {restartResult.success ? '✅ 성공' : `${restartResult.error}`}
{restartResult.output && `\n${restartResult.output}`}
</ResultBanner>
)}
{resetResult && (
<ResultBanner $success={resetResult.success}>
: {resetResult.success ? '✅ 성공' : `${resetResult.error}`}
</ResultBanner>
)}
</div>
);
})}
</Table>
{modal && (
<ConfirmModal
title={modal.action === 'restart' ? '게이트웨이 재시작' : '세션 리셋'}
message={`${SISTER_DISPLAY_NAMES[modal.name] ?? modal.name}${modal.action === 'restart' ? 'OpenClaw Gateway를 재시작' : '메인 세션을 초기화'}하시겠어요?`}
confirmLabel={modal.action === 'restart' ? '재시작' : '리셋'}
danger={modal.action === 'reset'}
onConfirm={() => doAction(modal.action, modal.name)}
onCancel={() => setModal(null)}
/>
)}
</>
);
}