158 lines
6.1 KiB
TypeScript
158 lines
6.1 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import { BarChart3, Plus, Loader2, ListChecks } from 'lucide-react';
|
||
import { usePluginContext } from './client';
|
||
import { CredentialPanel } from './CredentialPanel';
|
||
import { NewJobDialog } from './NewJobDialog';
|
||
import { JobDetail } from './JobDetail';
|
||
import type { S3Credential, AnalysisJob } from './types';
|
||
|
||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||
|
||
interface Props {
|
||
pluginName: string;
|
||
}
|
||
|
||
export function Dashboard({ pluginName }: Props) {
|
||
const { client } = usePluginContext();
|
||
const [credentials, setCredentials] = useState<S3Credential[]>([]);
|
||
const [credLoading, setCredLoading] = useState(true);
|
||
const [jobs, setJobs] = useState<AnalysisJob[]>([]);
|
||
const [jobLoading, setJobLoading] = useState(true);
|
||
const [showNewJob, setShowNewJob] = useState(false);
|
||
const [openJobId, setOpenJobId] = useState<string | null>(null);
|
||
const [error, setError] = useState('');
|
||
|
||
const loadCredentials = useCallback(async () => {
|
||
setCredLoading(true);
|
||
try {
|
||
const resp = await client.get<{ credentials: S3Credential[] }>(`${API_BASE}/credentials`);
|
||
setCredentials(resp.data.credentials || []);
|
||
} catch (e: unknown) {
|
||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||
setError(msg || '加载凭证失败');
|
||
} finally {
|
||
setCredLoading(false);
|
||
}
|
||
}, [client]);
|
||
|
||
const loadJobs = useCallback(async () => {
|
||
setJobLoading(true);
|
||
try {
|
||
const resp = await client.get<{ jobs: AnalysisJob[] }>(`${API_BASE}/jobs`);
|
||
setJobs(resp.data.jobs || []);
|
||
} catch (e: unknown) {
|
||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||
setError(msg || '加载任务失败');
|
||
} finally {
|
||
setJobLoading(false);
|
||
}
|
||
}, [client]);
|
||
|
||
useEffect(() => {
|
||
loadCredentials();
|
||
loadJobs();
|
||
}, [loadCredentials, loadJobs]);
|
||
|
||
// 轮询任务列表,方便看状态变化
|
||
useEffect(() => {
|
||
const t = setInterval(loadJobs, 5000);
|
||
return () => clearInterval(t);
|
||
}, [loadJobs]);
|
||
|
||
return (
|
||
<div className="h-full overflow-y-auto p-6 bg-bg-base">
|
||
<div className="max-w-5xl mx-auto space-y-6">
|
||
<div className="flex items-center gap-3 pb-2">
|
||
<div className="w-10 h-10 rounded-xl bg-accent-light text-accent flex items-center justify-center">
|
||
<BarChart3 size={20} />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-lg font-bold text-text-primary">数据分析</h2>
|
||
<p className="text-xs text-text-muted mt-0.5">
|
||
对接 S3,让 agent 自主决定分析路径(python_executor / ray_submit)。{' '}
|
||
<span className="font-mono text-[10px] opacity-70">{pluginName}</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{error}</div>
|
||
)}
|
||
|
||
<CredentialPanel credentials={credentials} loading={credLoading} onChanged={loadCredentials} />
|
||
|
||
<div className="bg-bg-card rounded-2xl border border-border-primary p-5">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||
<ListChecks size={16} className="text-accent" /> 分析任务
|
||
</h3>
|
||
<p className="text-xs text-text-muted mt-0.5">点行查看详情和事件流。每 5 秒自动刷新。</p>
|
||
</div>
|
||
<button
|
||
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 transition flex items-center gap-1.5 disabled:opacity-50"
|
||
onClick={() => setShowNewJob(true)}
|
||
disabled={credentials.length === 0}
|
||
title={credentials.length === 0 ? '请先添加凭证' : ''}
|
||
>
|
||
<Plus size={14} /> 新建任务
|
||
</button>
|
||
</div>
|
||
|
||
{jobLoading && jobs.length === 0 ? (
|
||
<div className="flex items-center justify-center py-8 text-text-muted">
|
||
<Loader2 size={20} className="animate-spin" />
|
||
</div>
|
||
) : jobs.length === 0 ? (
|
||
<div className="text-sm text-text-muted text-center py-8 border border-dashed border-border-primary rounded-xl">
|
||
还没有分析任务。点右上角「新建任务」开始。
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{jobs.map((j) => (
|
||
<button
|
||
key={j.job_id}
|
||
className="w-full text-left p-3 bg-bg-secondary rounded-xl border border-border-secondary hover:border-accent transition flex items-center justify-between gap-3"
|
||
onClick={() => setOpenJobId(j.job_id)}
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-sm text-text-primary truncate">{j.description}</div>
|
||
<div className="text-[11px] text-text-muted mt-0.5 font-mono">
|
||
{j.job_id.slice(0, 8)} · {j.created_at?.slice(0, 19).replace('T', ' ')}
|
||
</div>
|
||
</div>
|
||
<StatusBadge status={j.status} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{showNewJob && (
|
||
<NewJobDialog
|
||
credentials={credentials}
|
||
onClose={() => setShowNewJob(false)}
|
||
onCreated={loadJobs}
|
||
/>
|
||
)}
|
||
{openJobId && <JobDetail jobId={openJobId} onClose={() => setOpenJobId(null)} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatusBadge({ status }: { status: string }) {
|
||
const map: Record<string, string> = {
|
||
pending: 'bg-bg-base text-text-muted border-border-primary',
|
||
running: 'bg-warning-bg text-warning border-warning/20',
|
||
completed: 'bg-success-bg text-success border-success/20',
|
||
failed: 'bg-danger-bg text-danger border-danger/20',
|
||
};
|
||
const cls = map[status] || map.pending;
|
||
return (
|
||
<span className={`text-[10px] font-medium px-2 py-1 rounded-lg border ${cls} shrink-0`}>
|
||
{status}
|
||
</span>
|
||
);
|
||
}
|