- 디자인 시스템: #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 성공
175 lines
5.7 KiB
TypeScript
175 lines
5.7 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 { 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: var(--bg-surface);
|
|
border: 1px solid var(--border-color);
|
|
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: var(--text-primary);
|
|
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 ? '#00FF00' : '#FF1744'};
|
|
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)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|