存档
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2, Loader2, Key, Eye, EyeOff } from 'lucide-react';
|
||||
import { usePluginContext } from './client';
|
||||
import type { S3Credential } from './types';
|
||||
|
||||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||||
|
||||
interface Props {
|
||||
credentials: S3Credential[];
|
||||
loading: boolean;
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export function CredentialPanel({ credentials, loading, onChanged }: Props) {
|
||||
const { client } = usePluginContext();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
display_name: '',
|
||||
endpoint_url: '',
|
||||
region: 'us-east-1',
|
||||
access_key: '',
|
||||
secret_key: '',
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
setForm({ display_name: '', endpoint_url: '', region: 'us-east-1', access_key: '', secret_key: '' });
|
||||
setError('');
|
||||
setShowSecret(false);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.display_name.trim() || !form.access_key.trim() || !form.secret_key.trim()) {
|
||||
setError('显示名 / Access Key / Secret Key 必填');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await client.post(`${API_BASE}/credentials`, {
|
||||
display_name: form.display_name.trim(),
|
||||
endpoint_url: form.endpoint_url.trim() || null,
|
||||
region: form.region.trim() || 'us-east-1',
|
||||
access_key: form.access_key,
|
||||
secret_key: form.secret_key,
|
||||
});
|
||||
reset();
|
||||
setShowForm(false);
|
||||
onChanged();
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||||
setError(msg || '保存失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (cred_id: string) => {
|
||||
if (!confirm('确定删除该凭证?删除后该凭证下的任务将无法继续运行。')) return;
|
||||
try {
|
||||
await client.delete(`${API_BASE}/credentials/${cred_id}`);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Key size={16} className="text-accent" /> S3 凭证
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">访问密钥加密存储于本地 SQLite。</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"
|
||||
onClick={() => { setShowForm((s) => !s); setError(''); }}
|
||||
>
|
||||
<Plus size={14} /> {showForm ? '取消' : '新增'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="space-y-3 mb-4 p-4 bg-bg-secondary rounded-xl border border-border-secondary">
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="显示名(如 prod-aws)"
|
||||
value={form.display_name}
|
||||
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Endpoint URL(可选,自托管 S3 / MinIO 填写)"
|
||||
value={form.endpoint_url}
|
||||
onChange={(e) => setForm({ ...form, endpoint_url: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Region(默认 us-east-1)"
|
||||
value={form.region}
|
||||
onChange={(e) => setForm({ ...form, region: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="w-full px-3 py-2 text-sm font-mono rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Access Key"
|
||||
value={form.access_key}
|
||||
onChange={(e) => setForm({ ...form, access_key: e.target.value })}
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
className="w-full px-3 py-2 pr-10 text-sm font-mono rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
placeholder="Secret Key"
|
||||
value={form.secret_key}
|
||||
onChange={(e) => setForm({ ...form, secret_key: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
|
||||
onClick={() => setShowSecret((s) => !s)}
|
||||
>
|
||||
{showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-accent text-white hover:opacity-90 disabled:opacity-50 transition flex items-center justify-center gap-2"
|
||||
onClick={submit}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy && <Loader2 size={14} className="animate-spin" />} 保存
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-text-muted">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : credentials.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">
|
||||
{credentials.map((c) => (
|
||||
<div
|
||||
key={c.cred_id}
|
||||
className="flex items-center justify-between p-3 bg-bg-secondary rounded-xl border border-border-secondary"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-text-primary truncate">{c.display_name}</div>
|
||||
<div className="text-[11px] text-text-muted font-mono mt-0.5">
|
||||
{c.endpoint_url || 'aws-s3'} · {c.region} · {c.access_key}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="p-1.5 text-text-muted hover:text-danger transition"
|
||||
onClick={() => remove(c.cred_id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, X, Activity } from 'lucide-react';
|
||||
import { usePluginContext } from './client';
|
||||
import type { AnalysisJob } from './types';
|
||||
|
||||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||||
|
||||
interface Props {
|
||||
jobId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface StreamEvent {
|
||||
type?: string;
|
||||
ts?: number;
|
||||
payload?: unknown;
|
||||
raw?: string;
|
||||
}
|
||||
|
||||
export function JobDetail({ jobId, onClose }: Props) {
|
||||
const { client, token, apiBase } = usePluginContext();
|
||||
const [job, setJob] = useState<AnalysisJob | null>(null);
|
||||
const [events, setEvents] = useState<StreamEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const eventBoxRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 初次加载 + 后台轮询
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchJob = async () => {
|
||||
try {
|
||||
const resp = await client.get<AnalysisJob>(`${API_BASE}/jobs/${jobId}`);
|
||||
if (!cancelled) setJob(resp.data);
|
||||
} catch (e) {
|
||||
console.error('fetch job failed', e);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchJob();
|
||||
const t = setInterval(fetchJob, 4000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [client, jobId]);
|
||||
|
||||
// SSE 事件流(用 fetch + ReadableStream,因为 EventSource 不支持自定义 header)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const run = async () => {
|
||||
try {
|
||||
const url = `${apiBase || ''}${API_BASE}/jobs/${jobId}/stream`;
|
||||
const resp = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.body) return;
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buf = '';
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split('\n\n');
|
||||
buf = parts.pop() || '';
|
||||
for (const part of parts) {
|
||||
const line = part.split('\n').find((l) => l.startsWith('data:'));
|
||||
if (!line) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
try {
|
||||
setEvents((prev) => [...prev, JSON.parse(payload)]);
|
||||
} catch {
|
||||
setEvents((prev) => [...prev, { raw: payload }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== 'AbortError') console.error('SSE error', e);
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => controller.abort();
|
||||
}, [apiBase, jobId, token]);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (eventBoxRef.current) {
|
||||
eventBoxRef.current.scrollTop = eventBoxRef.current.scrollHeight;
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-3xl max-h-[85vh] bg-bg-card rounded-2xl border border-border-primary shadow-xl flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-primary">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<Activity size={16} className="text-accent" />
|
||||
任务详情
|
||||
</h3>
|
||||
<span className="text-[11px] font-mono text-text-muted">{jobId}</span>
|
||||
</div>
|
||||
<button className="p-1 text-text-muted hover:text-text-primary" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-5 space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-text-muted">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : job ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Field label="状态" value={job.task_status || job.status} />
|
||||
<Field label="描述" value={job.description} />
|
||||
{job.task_error && <Field label="错误" value={job.task_error} danger />}
|
||||
</div>
|
||||
|
||||
{job.task_result !== undefined && job.task_result !== null && (
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary mb-1.5">执行结果</div>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-words bg-bg-secondary border border-border-secondary rounded-lg p-3 max-h-64 overflow-auto">
|
||||
{typeof job.task_result === 'string'
|
||||
? job.task_result
|
||||
: JSON.stringify(job.task_result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary mb-1.5">事件流(SSE)</div>
|
||||
<div
|
||||
ref={eventBoxRef}
|
||||
className="text-[11px] font-mono bg-bg-secondary border border-border-secondary rounded-lg p-3 max-h-72 overflow-auto space-y-1"
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<span className="text-text-muted">(等待事件…)</span>
|
||||
) : (
|
||||
events.map((e, i) => (
|
||||
<div key={i} className="text-text-secondary">
|
||||
<span className="text-accent">{e.type || 'event'}</span>{' '}
|
||||
{e.payload !== undefined ? (
|
||||
<span>{JSON.stringify(e.payload)}</span>
|
||||
) : e.raw ? (
|
||||
<span>{e.raw}</span>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-text-muted text-center py-8">任务不存在或已被删除</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, danger }: { label: string; value: string; danger?: boolean }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="text-xs text-text-muted w-16 shrink-0 pt-0.5">{label}</div>
|
||||
<div className={`text-sm flex-1 break-words ${danger ? 'text-danger' : 'text-text-primary'}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Send, X } from 'lucide-react';
|
||||
import { usePluginContext } from './client';
|
||||
import type { S3Credential } from './types';
|
||||
|
||||
const API_BASE = '/api/v1/plugin/data_analytics';
|
||||
|
||||
interface Props {
|
||||
credentials: S3Credential[];
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function NewJobDialog({ credentials, onClose, onCreated }: Props) {
|
||||
const { client } = usePluginContext();
|
||||
const [credId, setCredId] = useState(credentials[0]?.cred_id || '');
|
||||
const [description, setDescription] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const submit = async () => {
|
||||
if (!credId) {
|
||||
setError('请选择 S3 凭证');
|
||||
return;
|
||||
}
|
||||
if (!description.trim()) {
|
||||
setError('请描述要做的分析');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await client.post(`${API_BASE}/jobs`, {
|
||||
cred_id: credId,
|
||||
description: description.trim(),
|
||||
});
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail;
|
||||
setError(msg || '提交失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-lg bg-bg-card rounded-2xl border border-border-primary shadow-xl">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-primary">
|
||||
<h3 className="font-semibold text-text-primary">新建分析任务</h3>
|
||||
<button className="p-1 text-text-muted hover:text-text-primary" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-secondary block mb-1.5">S3 凭证</label>
|
||||
{credentials.length === 0 ? (
|
||||
<div className="text-xs text-warning bg-warning-bg/50 border border-warning/20 rounded-lg p-2">
|
||||
请先在上方添加 S3 凭证。
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent"
|
||||
value={credId}
|
||||
onChange={(e) => setCredId(e.target.value)}
|
||||
>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.cred_id} value={c.cred_id}>
|
||||
{c.display_name} · {c.region}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-secondary block mb-1.5">任务描述</label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-bg-base border border-border-primary focus:outline-none focus:border-accent min-h-[120px] resize-y"
|
||||
placeholder="例如:分析 s3://my-bucket/sales/2026-q1/ 的销售趋势,输出按月汇总"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<p className="text-[11px] text-text-muted mt-1">
|
||||
Agent 会先用 s3_peek/s3_list_objects 探查数据,然后选择 python_executor 或 ray_submit 执行分析。
|
||||
</p>
|
||||
</div>
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-border-primary">
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-border-primary text-text-secondary hover:text-text-primary"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-accent text-white hover:opacity-90 disabled:opacity-50 transition flex items-center gap-1.5"
|
||||
onClick={submit}
|
||||
disabled={busy || credentials.length === 0}
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
||||
提交
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// vite lib 模式 build 完后写一份 wc-manifest.json,给后端 /ui-manifest 端点读
|
||||
const distDir = 'dist';
|
||||
if (!existsSync(distDir)) {
|
||||
console.error('dist/ not found; run vite build first');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = readdirSync(distDir);
|
||||
const js = files.find((f) => f.endsWith('.js')) || 'plugin-element.js';
|
||||
const css = files.filter((f) => f.endsWith('.css'));
|
||||
|
||||
const manifest = {
|
||||
tag: 'plugin-data-analytics',
|
||||
js,
|
||||
css,
|
||||
};
|
||||
|
||||
writeFileSync(join(distDir, 'wc-manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
console.log(`wrote dist/wc-manifest.json: ${JSON.stringify(manifest)}`);
|
||||
@@ -0,0 +1,30 @@
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface PluginContextValue {
|
||||
client: AxiosInstance;
|
||||
token: string;
|
||||
apiBase: string;
|
||||
}
|
||||
|
||||
export const PluginContext = createContext<PluginContextValue | null>(null);
|
||||
|
||||
export function usePluginContext(): PluginContextValue {
|
||||
const ctx = useContext(PluginContext);
|
||||
if (!ctx) throw new Error('PluginContext missing — Web Component not initialized');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function makeClient(token: string, apiBase: string): AxiosInstance {
|
||||
const c = axios.create({
|
||||
baseURL: apiBase || undefined,
|
||||
});
|
||||
c.interceptors.request.use((cfg) => {
|
||||
if (token) {
|
||||
cfg.headers = cfg.headers || {};
|
||||
(cfg.headers as Record<string, string>).Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return cfg;
|
||||
});
|
||||
return c;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Dashboard } from './Dashboard';
|
||||
import { PluginContext, makeClient } from './client';
|
||||
|
||||
// 把 build 出来的 CSS 当字符串收入,作为 ConstructableStyleSheet 注入到 shadow root,
|
||||
// 既能享受 shadow DOM 的样式隔离,也不需要额外的 fetch 步骤。
|
||||
import css from './styles.css?inline';
|
||||
|
||||
const TAG = 'plugin-data-analytics';
|
||||
|
||||
class DataAnalyticsElement extends HTMLElement {
|
||||
private root?: Root;
|
||||
private mount?: HTMLDivElement;
|
||||
|
||||
static get observedAttributes() {
|
||||
return ['token', 'api-base'];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
const shadow = this.attachShadow({ mode: 'open' });
|
||||
|
||||
// 用 <style> 注入 CSS(ConstructableStyleSheet 兼容性更好但 vite 注入字符串更直接)
|
||||
const style = document.createElement('style');
|
||||
style.textContent = css;
|
||||
shadow.appendChild(style);
|
||||
|
||||
this.mount = document.createElement('div');
|
||||
this.mount.style.cssText = 'height:100%;width:100%';
|
||||
shadow.appendChild(this.mount);
|
||||
|
||||
this.root = createRoot(this.mount);
|
||||
this.render();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
if (this.root) this.render();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.root?.unmount();
|
||||
this.root = undefined;
|
||||
}
|
||||
|
||||
private render() {
|
||||
const token = this.getAttribute('token') ?? '';
|
||||
const apiBase = this.getAttribute('api-base') ?? '';
|
||||
const client = makeClient(token, apiBase);
|
||||
this.root!.render(
|
||||
<React.StrictMode>
|
||||
<PluginContext.Provider value={{ client, token, apiBase }}>
|
||||
<Dashboard pluginName="data_analytics" />
|
||||
</PluginContext.Provider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get(TAG)) {
|
||||
customElements.define(TAG, DataAnalyticsElement);
|
||||
}
|
||||
+1744
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "plugin-data-analytics",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build && node build-manifest.mjs",
|
||||
"dev": "vite build --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^8.0.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 在 shadow DOM 内 :root 不匹配,用 :host 给 Web Component 自身定义主题 token。
|
||||
颜色名称跟主前端 frontend/src/index.css 保持一致——这样组件里的 bg-bg-card / text-accent
|
||||
等类名在插件 build 时也能解析到对应的 var()。 */
|
||||
@theme {
|
||||
--color-bg-primary: var(--bg-primary);
|
||||
--color-bg-secondary: var(--bg-secondary);
|
||||
--color-bg-tertiary: var(--bg-tertiary);
|
||||
--color-bg-card: var(--bg-card);
|
||||
--color-bg-sidebar: var(--bg-sidebar);
|
||||
--color-bg-input: var(--bg-input);
|
||||
--color-bg-hover: var(--bg-hover);
|
||||
--color-bg-active: var(--bg-active);
|
||||
--color-bg-base: var(--bg-base);
|
||||
--color-border-primary: var(--border-primary);
|
||||
--color-border-secondary: var(--border-secondary);
|
||||
--color-text-primary: var(--text-primary);
|
||||
--color-text-secondary: var(--text-secondary);
|
||||
--color-text-tertiary: var(--text-tertiary);
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-light: var(--accent-light);
|
||||
--color-danger: var(--danger);
|
||||
--color-danger-bg: var(--danger-bg);
|
||||
--color-success: var(--success);
|
||||
--color-success-bg: var(--success-bg);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-bg: var(--warning-bg);
|
||||
}
|
||||
|
||||
:host {
|
||||
/* light theme defaults — 跟主前端保持一致 */
|
||||
--bg-primary: #f2f0ed;
|
||||
--bg-secondary: #eae8e4;
|
||||
--bg-tertiary: #e0ddd8;
|
||||
--bg-card: #faf9f7;
|
||||
--bg-sidebar: #eae8e4;
|
||||
--bg-input: #f2f0ed;
|
||||
--bg-hover: rgba(255, 255, 255, 0.4);
|
||||
--bg-active: rgba(156, 175, 136, 0.08);
|
||||
--bg-base: #f2f0ed;
|
||||
--border-primary: #e0ddd8;
|
||||
--border-secondary: #eae8e4;
|
||||
--text-primary: #3d3d3d;
|
||||
--text-secondary: #5a5a5a;
|
||||
--text-tertiary: #8c8680;
|
||||
--text-muted: #b5afa8;
|
||||
--accent: #9caf88;
|
||||
--accent-hover: #8a9e78;
|
||||
--accent-light: rgba(156, 175, 136, 0.12);
|
||||
--danger: #c4917a;
|
||||
--danger-bg: rgba(196, 145, 122, 0.08);
|
||||
--success: #7a8e6a;
|
||||
--success-bg: rgba(122, 142, 106, 0.08);
|
||||
--warning: #c4a882;
|
||||
--warning-bg: rgba(196, 168, 130, 0.08);
|
||||
|
||||
display: block;
|
||||
height: 100%;
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 跟随系统/主前端的暗色主题:宿主元素加 [data-theme="dark"] 时切换 */
|
||||
:host([data-theme="dark"]) {
|
||||
--bg-primary: #1c1b19;
|
||||
--bg-secondary: #232220;
|
||||
--bg-tertiary: #2d2b28;
|
||||
--bg-card: #252421;
|
||||
--bg-sidebar: #1e1d1b;
|
||||
--bg-input: #2d2b28;
|
||||
--bg-hover: rgba(255, 255, 255, 0.04);
|
||||
--bg-active: rgba(156, 175, 136, 0.1);
|
||||
--bg-base: #1c1b19;
|
||||
--border-primary: rgba(255, 255, 255, 0.06);
|
||||
--border-secondary: rgba(255, 255, 255, 0.03);
|
||||
--text-primary: #e8e6e3;
|
||||
--text-secondary: #c8c5c0;
|
||||
--text-tertiary: #a09c96;
|
||||
--text-muted: #7a7772;
|
||||
--accent: #a8bc94;
|
||||
--accent-hover: #b8caa6;
|
||||
--accent-light: rgba(156, 175, 136, 0.15);
|
||||
--danger: #d4a894;
|
||||
--danger-bg: rgba(196, 145, 122, 0.1);
|
||||
--success: #9caf88;
|
||||
--success-bg: rgba(156, 175, 136, 0.1);
|
||||
--warning: #c4a882;
|
||||
--warning-bg: rgba(196, 168, 130, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["*.ts", "*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface S3Credential {
|
||||
cred_id: string;
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
endpoint_url: string | null;
|
||||
region: string;
|
||||
access_key: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface AnalysisJob {
|
||||
job_id: string;
|
||||
user_id: string;
|
||||
cred_id: string | null;
|
||||
description: string;
|
||||
status: string;
|
||||
org_task_id: string | null;
|
||||
result: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
task_status?: string;
|
||||
task_result?: unknown;
|
||||
task_error?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: 'index.tsx',
|
||||
formats: ['es'],
|
||||
fileName: () => 'plugin-element.js',
|
||||
},
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: (info) => {
|
||||
if (info.name && info.name.endsWith('.css')) return 'plugin-element.css';
|
||||
return 'assets/[name]-[hash][extname]';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user