Files
hanarang-dashboard/frontend/app/admin/harness/page.tsx

210 lines
6.2 KiB
TypeScript

'use client';
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import CodeEditor from '@/components/admin/CodeEditor';
import { adminFetch } from '@/lib/adminFetch';
const SISTERS = ['harang', 'narang', 'darang', 'erang'];
const FILES = ['SOUL.md', 'AGENTS.md', 'TOOLS.md', 'PROTOCOL.md', 'HEARTBEAT.md'];
const DISPLAY_NAMES: Record<string, string> = {
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
};
const Layout = styled.div`
display: grid;
grid-template-columns: 180px 1fr;
gap: 20px;
`;
const FileTree = styled.div`
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 10px;
overflow: hidden;
`;
const TreeSection = styled.div`
border-bottom: 1px solid var(--border-color);
padding: 6px;
&:last-child { border-bottom: none; }
`;
const TreeLabel = styled.div`
font-size: 11px;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 6px 8px 4px;
`;
const TreeItem = styled.button<{ $active: boolean }>`
width: 100%;
text-align: left;
padding: 6px 10px;
font-size: 12px;
border: none;
border-radius: 5px;
cursor: pointer;
background: ${({ $active }) => $active ? 'rgba(88,166,255,0.12)' : 'transparent'};
color: ${({ $active }) => $active ? '#58A6FF' : 'var(--text-secondary)'};
transition: background 0.15s;
&:hover { background: rgba(240,246,252,0.06); color: var(--text-primary); }
`;
const EditorPane = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
const Toolbar = styled.div`
display: flex;
align-items: center;
gap: 10px;
`;
const PathLabel = styled.span`
font-size: 13px;
color: var(--text-secondary);
font-family: monospace;
flex: 1;
`;
const Btn = styled.button<{ $primary?: boolean; $danger?: boolean }>`
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid;
transition: all 0.15s;
disabled: ${({ disabled }) => disabled ? 'not-allowed' : 'pointer'};
${({ $primary }) =>
$primary &&
`background: rgba(88,166,255,0.12); border-color: rgba(88,166,255,0.4); color: #58A6FF;
&:hover { background: rgba(88,166,255,0.22); }`}
${({ $danger }) =>
$danger &&
`background: transparent; border-color: rgba(240,246,252,0.15); color: #8B949E;
&:hover { color: #E6EDF3; }`}
${({ $primary, $danger }) =>
!$primary && !$danger &&
`background: transparent; border-color: rgba(240,246,252,0.15); color: #8B949E;
&:hover { color: #E6EDF3; }`}
`;
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)'};
`;
export default function HarnessPage() {
const [selected, setSelected] = useState({ sister: 'narang', file: 'SOUL.md' });
const [content, setContent] = useState('');
const [saved, setSaved] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [result, setResult] = useState<{ success: boolean; msg: string } | null>(null);
useEffect(() => {
setContent('');
setSaved('');
setResult(null);
setLoading(true);
adminFetch(`/api/admin/harness/${selected.sister}/${selected.file}`)
.then((r) => r.json())
.then((d) => { setContent(d.content ?? ''); setSaved(d.content ?? ''); })
.catch(() => setContent('(로드 실패)'))
.finally(() => setLoading(false));
}, [selected.sister, selected.file]);
const handleSave = async () => {
setSaving(true);
setResult(null);
try {
const res = await adminFetch(`/api/admin/harness/${selected.sister}/${selected.file}`, {
method: 'PUT',
body: JSON.stringify({ content }),
});
if (res.status === 401) {
setResult({ success: false, msg: '인증 실패 — API Key 또는 JWT 토큰을 확인해' });
return;
}
if (!res.ok) {
const text = await res.text();
setResult({ success: false, msg: `서버 오류 ${res.status}: ${text.slice(0, 100)}` });
return;
}
const d = await res.json();
if (d.success) {
setSaved(content);
setResult({ success: true, msg: '저장 완료' });
} else {
setResult({ success: false, msg: d.error ?? '저장 실패' });
}
} catch (e) {
setResult({ success: false, msg: (e as Error).message });
} finally {
setSaving(false);
}
};
const isDirty = content !== saved;
return (
<Layout>
<FileTree>
{SISTERS.map((s) => (
<TreeSection key={s}>
<TreeLabel>{DISPLAY_NAMES[s] ?? s}</TreeLabel>
{FILES.map((f) => (
<TreeItem
key={f}
$active={selected.sister === s && selected.file === f}
onClick={() => setSelected({ sister: s, file: f })}
>
{f}
</TreeItem>
))}
</TreeSection>
))}
</FileTree>
<EditorPane>
<Toolbar>
<PathLabel>
~/.openclaw/workspace/{selected.file} ({DISPLAY_NAMES[selected.sister]})
</PathLabel>
{isDirty && (
<span style={{ fontSize: '11px', color: '#FF9800' }}> </span>
)}
<Btn onClick={() => { setContent(saved); setResult(null); }} $danger></Btn>
<Btn $primary onClick={handleSave} disabled={!isDirty || saving}>
{saving ? '저장 중...' : '💾 저장'}
</Btn>
</Toolbar>
{result && <ResultMsg $success={result.success}>{result.msg}</ResultMsg>}
{loading ? (
<div style={{ padding: '32px', textAlign: 'center', color: 'var(--text-secondary)', fontSize: '13px' }}>
...
</div>
) : (
<CodeEditor value={content} onChange={setContent} />
)}
</EditorPane>
</Layout>
);
}