refactor(admin): #329 admin/page.tsx 분리 + localStorage 통일
- page.tsx 2817 LOC → 107 LOC (탭 라우팅 + CacheFlushButton만)
- _panels/ChannelsPanel.tsx (222), VideosPanel.tsx (1282),
RestaurantsPanel.tsx (675), UsersPanel.tsx (383), DaemonPanel.tsx (231)
- localStorage.getItem("tasteby_token") 10곳 → getAdminToken() (lib/admin-utils)
- 패널 내부 로직/state 그대로 (점진적 접근, 회귀 위험 최소화)
후속 분리:
- (신규) SSE 파싱 6곳을 consumeSseStream으로 통일
설계서: docs/design/329-admin-split/README.md
Refs: #329 (Developer)
This commit is contained in:
231
frontend/src/app/admin/_panels/DaemonPanel.tsx
Normal file
231
frontend/src/app/admin/_panels/DaemonPanel.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { DaemonConfig } from "@/lib/api";
|
||||
|
||||
// #329 — admin/page.tsx에서 추출
|
||||
export function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [config, setConfig] = useState<DaemonConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [running, setRunning] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
|
||||
// Editable fields
|
||||
const [scanEnabled, setScanEnabled] = useState(false);
|
||||
const [scanInterval, setScanInterval] = useState(60);
|
||||
const [processEnabled, setProcessEnabled] = useState(false);
|
||||
const [processInterval, setProcessInterval] = useState(60);
|
||||
const [processLimit, setProcessLimit] = useState(10);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api.getDaemonConfig().then((cfg) => {
|
||||
setConfig(cfg);
|
||||
setScanEnabled(cfg.scan_enabled);
|
||||
setScanInterval(cfg.scan_interval_min);
|
||||
setProcessEnabled(cfg.process_enabled);
|
||||
setProcessInterval(cfg.process_interval_min);
|
||||
setProcessLimit(cfg.process_limit);
|
||||
}).catch(console.error).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setResult(null);
|
||||
try {
|
||||
await api.updateDaemonConfig({
|
||||
scan_enabled: scanEnabled,
|
||||
scan_interval_min: scanInterval,
|
||||
process_enabled: processEnabled,
|
||||
process_interval_min: processInterval,
|
||||
process_limit: processLimit,
|
||||
});
|
||||
setResult("설정 저장 완료");
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
setResult(e instanceof Error ? e.message : "저장 실패");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunScan = async () => {
|
||||
setRunning("scan");
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await api.runDaemonScan();
|
||||
setResult(`채널 스캔 완료: 신규 ${res.new_videos}개 영상`);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
setResult(e instanceof Error ? e.message : "스캔 실패");
|
||||
} finally {
|
||||
setRunning(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunProcess = async () => {
|
||||
setRunning("process");
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await api.runDaemonProcess(processLimit);
|
||||
setResult(`영상 처리 완료: ${res.restaurants_extracted}개 식당 추출`);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
setResult(e instanceof Error ? e.message : "처리 실패");
|
||||
} finally {
|
||||
setRunning(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p className="text-gray-500">로딩 중...</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Schedule Config */}
|
||||
<div className="bg-surface rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">스케줄 설정</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
데몬이 실행 중일 때, 아래 설정에 따라 자동으로 채널 스캔 및 영상 처리를 수행합니다.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Scan config */}
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">채널 스캔</h3>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scanEnabled}
|
||||
onChange={(e) => setScanEnabled(e.target.checked)}
|
||||
disabled={!isAdmin}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className={`text-sm ${scanEnabled ? "text-green-600 font-medium" : "text-gray-500"}`}>
|
||||
{scanEnabled ? "활성" : "비활성"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">주기 (분)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={scanInterval}
|
||||
onChange={(e) => setScanInterval(Number(e.target.value))}
|
||||
disabled={!isAdmin}
|
||||
min={1}
|
||||
className="border rounded px-3 py-1.5 text-sm w-32"
|
||||
/>
|
||||
</div>
|
||||
{config?.last_scan_at && (
|
||||
<p className="text-xs text-gray-400">마지막 스캔: {config.last_scan_at}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Process config */}
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">영상 처리</h3>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={processEnabled}
|
||||
onChange={(e) => setProcessEnabled(e.target.checked)}
|
||||
disabled={!isAdmin}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className={`text-sm ${processEnabled ? "text-green-600 font-medium" : "text-gray-500"}`}>
|
||||
{processEnabled ? "활성" : "비활성"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">주기 (분)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={processInterval}
|
||||
onChange={(e) => setProcessInterval(Number(e.target.value))}
|
||||
disabled={!isAdmin}
|
||||
min={1}
|
||||
className="border rounded px-3 py-1.5 text-sm w-32"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">처리 건수</label>
|
||||
<input
|
||||
type="number"
|
||||
value={processLimit}
|
||||
onChange={(e) => setProcessLimit(Number(e.target.value))}
|
||||
disabled={!isAdmin}
|
||||
min={1}
|
||||
max={50}
|
||||
className="border rounded px-3 py-1.5 text-sm w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{config?.last_process_at && (
|
||||
<p className="text-xs text-gray-400">마지막 처리: {config.last_process_at}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-brand-600 text-white text-sm rounded hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "저장 중..." : "설정 저장"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual Triggers */}
|
||||
<div className="bg-surface rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">수동 실행</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
스케줄과 관계없이 즉시 실행합니다. 처리 시간이 걸릴 수 있습니다.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleRunScan}
|
||||
disabled={running !== null}
|
||||
className="px-4 py-2 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{running === "scan" ? "스캔 중..." : "채널 스캔 실행"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRunProcess}
|
||||
disabled={running !== null}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm rounded hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{running === "process" ? "처리 중..." : "영상 처리 실행"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<p className={`mt-3 text-sm ${result.includes("실패") || result.includes("API") ? "text-red-600" : "text-green-600"}`}>
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config updated_at */}
|
||||
{config?.updated_at && (
|
||||
<p className="text-xs text-gray-400 text-right">설정 수정일: {config.updated_at}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user