ae67bff5a3
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
262 lines
15 KiB
TypeScript
262 lines
15 KiB
TypeScript
"use client";
|
||
/* Forge home screens: Dashboard, Project Overview, Onboarding wizard. */
|
||
import { useState } from "react";
|
||
import { Icon } from "../icons";
|
||
import { Sparkline, StatusPill, Tile, Field, Toggle, EmptyState } from "../primitives";
|
||
import { DashboardStats } from "@/lib/api";
|
||
import { fmtUSD } from "@/lib/data";
|
||
|
||
const fmtLatencyMs = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`);
|
||
|
||
export interface ProjectCard {
|
||
id: string; name: string; slug: string; status: string;
|
||
workflows: number; tools: number; runs7d: number; spark: number[]; edited: string;
|
||
}
|
||
|
||
/* ============ DASHBOARD ============ */
|
||
export function DashboardScreen({
|
||
projects = [],
|
||
loaded = false,
|
||
stats = null,
|
||
onOpenProject,
|
||
onNewProject,
|
||
onDeleteProject,
|
||
}: {
|
||
projects?: ProjectCard[];
|
||
loaded?: boolean;
|
||
// Fetched once by the parent (App) and shared - avoids a second /stats/dashboard call.
|
||
stats?: DashboardStats | null;
|
||
onOpenProject: (id: string) => void;
|
||
onNewProject: () => void;
|
||
onDeleteProject?: (project: { id: string; name: string }) => Promise<void> | void;
|
||
}) {
|
||
const empty = loaded && projects.length === 0;
|
||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||
|
||
const fmtLatency = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`);
|
||
const kpis = [
|
||
{ label: "Runs · 7 days", value: stats ? stats.runs_7d.toLocaleString() : "-", sub: stats ? `${stats.total_runs.toLocaleString()} all-time` : "" },
|
||
{ label: "Success rate", value: stats && stats.runs_7d ? `${stats.success_rate}%` : "-", sub: "completed runs" },
|
||
{ label: "Avg latency", value: stats && stats.runs_7d ? fmtLatency(stats.avg_latency_ms) : "-", sub: "per run" },
|
||
{ label: "Spend · 7 days", value: stats ? fmtUSD(stats.spend_7d) : "-", sub: "tracked cost" },
|
||
];
|
||
return (
|
||
<div className="scroll-y" style={{ flex: 1, padding: "28px 32px" }}>
|
||
<div className="fade-up" style={{ maxWidth: 1600, margin: "0 auto" }}>
|
||
<div className="row spread" style={{ marginBottom: 22, alignItems: "flex-end" }}>
|
||
<div>
|
||
<div className="t-display-lg">Welcome to Forge</div>
|
||
<div className="fg-1" style={{ marginTop: 4 }}>Self-hosted agent platform · {projects.length} project{projects.length === 1 ? "" : "s"}</div>
|
||
</div>
|
||
<div className="row gap2">
|
||
<button className="btn btn-primary" onClick={onNewProject}><Icon name="plus" size={15} />New project</button>
|
||
</div>
|
||
</div>
|
||
|
||
{empty ? (
|
||
<div className="card" style={{ padding: 8 }}>
|
||
<EmptyState
|
||
icon="layers"
|
||
title="Forge your first project"
|
||
sub="A project is a workspace for agents, tools, knowledge, and workflows. Create one to begin building."
|
||
action={<button className="btn btn-primary btn-lg" onClick={onNewProject} style={{ marginTop: 6 }}><Icon name="plus" size={16} />New project</button>}
|
||
/>
|
||
</div>
|
||
) : !loaded ? (
|
||
<div className="fg-2" style={{ padding: 40, textAlign: "center" }}>Loading…</div>
|
||
) : (
|
||
<>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: 16, marginBottom: 24 }}>
|
||
{kpis.map((k, i) => (
|
||
<div key={i} className="card" style={{ padding: 16 }}>
|
||
<div className="t-micro" style={{ marginBottom: 8 }}>{k.label}</div>
|
||
<div className="t-display" style={{ fontSize: 26 }}>{k.value}</div>
|
||
<div className="fg-2 t-caption" style={{ marginTop: 2 }}>{k.sub}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "1.5fr 1fr", gap: 20 }}>
|
||
<div>
|
||
<div className="row spread" style={{ marginBottom: 12 }}>
|
||
<div className="t-h1">Projects</div>
|
||
</div>
|
||
<div className="col gap3">
|
||
{projects.map((p) => (
|
||
<div key={p.id} className="card card-hover" style={{ padding: 14 }} onClick={() => onOpenProject(p.id)}>
|
||
<div className="row gap3">
|
||
<Tile icon="layers" color={p.status === "draft" ? "var(--fg-2)" : "var(--accent)"} size={40} />
|
||
<div className="grow" style={{ minWidth: 0 }}>
|
||
<div className="row gap2"><span className="t-h2">{p.name}</span><StatusPill status={p.status} /></div>
|
||
<div className="fg-2 t-caption row gap3" style={{ marginTop: 3 }}>
|
||
<span>{p.workflows} workflows</span><span>{p.tools} tools</span><span>edited {p.edited}</span>
|
||
</div>
|
||
</div>
|
||
<div className="col" style={{ alignItems: "flex-end", gap: 4 }}>
|
||
<Sparkline data={p.spark} w={92} h={26} color="var(--accent)" />
|
||
<div className="fg-2 t-caption">{p.runs7d.toLocaleString()} runs / 7d</div>
|
||
</div>
|
||
{onDeleteProject && (
|
||
<button
|
||
className="iconbtn"
|
||
title="Delete project"
|
||
disabled={deletingId === p.id}
|
||
onClick={async (e) => {
|
||
e.stopPropagation();
|
||
setDeletingId(p.id);
|
||
try { await onDeleteProject({ id: p.id, name: p.name }); }
|
||
finally { setDeletingId(null); }
|
||
}}
|
||
>
|
||
<Icon name="trash" size={15} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="row spread" style={{ marginBottom: 12 }}>
|
||
<div className="t-h1">Recent runs</div>
|
||
<span className="pill pill-muted" style={{ height: 18 }}>live</span>
|
||
</div>
|
||
<div className="card" style={{ overflow: "hidden" }}>
|
||
{(stats?.recent || []).map((r, i, arr) => (
|
||
<div key={r.id} className="row gap3" style={{ padding: "11px 14px", borderBottom: i < arr.length - 1 ? "1px solid var(--line)" : "none" }}>
|
||
<div className="grow" style={{ minWidth: 0 }}>
|
||
<div className="truncate" style={{ fontSize: 13, fontWeight: 600 }}>{r.workflow}</div>
|
||
<div className="fg-2 t-caption truncate">{r.project} · {r.status}</div>
|
||
</div>
|
||
<div className="col" style={{ alignItems: "flex-end" }}>
|
||
<div className="mono-sm" style={{ color: "var(--fg-1)" }}>{r.tokens.toLocaleString()} tok</div>
|
||
<div className="fg-2 t-caption">{r.latency_ms}ms{r.started_at ? " · " + r.started_at.slice(11, 16) : ""}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{(!stats || stats.recent.length === 0) && (
|
||
<div className="fg-2 t-caption" style={{ padding: 22, textAlign: "center" }}>No runs yet. Run a workflow in the Playground.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* All-time usage by project (incl. Forge Assistant share) */}
|
||
<div className="row spread" style={{ margin: "26px 0 12px" }}>
|
||
<div className="t-h1">Reports</div>
|
||
</div>
|
||
<div className="card" style={{ overflow: "hidden" }}>
|
||
<table className="tbl">
|
||
<thead><tr><th>Project</th><th>Runs</th><th>Tokens</th><th>Avg latency</th><th>Assistant</th><th>Total cost</th></tr></thead>
|
||
<tbody>
|
||
{(stats?.reports || []).map((r, i) => (
|
||
<tr key={i}>
|
||
<td>
|
||
<div className="row gap2">
|
||
<Tile icon="layers" color="var(--accent)" size={24} />
|
||
<span style={{ fontWeight: 600, fontSize: 13 }}>{r.project}</span>
|
||
</div>
|
||
</td>
|
||
<td className="mono-sm">{r.runs.toLocaleString()}</td>
|
||
<td className="mono-sm">{r.tokens.toLocaleString()}</td>
|
||
<td className="mono-sm">{fmtLatencyMs(r.avg_latency_ms)}</td>
|
||
<td className="mono-sm">{r.assistant_turns ? `${fmtUSD(r.assistant_cost_usd)} · ${r.assistant_turns} turns` : "-"}</td>
|
||
<td className="mono-sm">{fmtUSD(r.cost_usd)}</td>
|
||
</tr>
|
||
))}
|
||
{(!stats || !stats.reports?.length) && <tr><td colSpan={6}><div className="fg-2 t-caption" style={{ padding: 22, textAlign: "center" }}>No usage yet.</div></td></tr>}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ============ ONBOARDING WIZARD ============ */
|
||
export function OnboardingScreen({ onCreate, onCancel }: { onCreate: (p: { name: string; template: string; keys: Record<string, string> }) => void; onCancel: () => void }) {
|
||
const [step, setStep] = useState(0);
|
||
const [name, setName] = useState("");
|
||
const [tmpl, setTmpl] = useState("blank");
|
||
const [busy, setBusy] = useState(false);
|
||
const [models, setModels] = useState<Record<string, boolean>>({ anthropic: true, openai: false, google: false });
|
||
const [keys, setKeys] = useState<Record<string, string>>({});
|
||
const provId: Record<string, string> = { anthropic: "anthropic", openai: "openai", google: "google_genai" };
|
||
const steps = ["Project", "Models", "Create"];
|
||
const templates = [
|
||
{ id: "blank", name: "Blank canvas", desc: "Start from an empty graph", icon: "workflows" },
|
||
{ id: "support", name: "Support agent", desc: "Router → agent → tools → HITL", icon: "agents" },
|
||
{ id: "rag", name: "RAG Q&A", desc: "Retrieval + grounded answers", icon: "knowledge" },
|
||
{ id: "mcp", name: "MCP toolbox", desc: "Expose tools over MCP", icon: "connect" },
|
||
];
|
||
return (
|
||
<div className="col center" style={{ flex: 1, padding: 24, background: "var(--bg-0)" }}>
|
||
<div className="card fade-up" style={{ width: 640, maxWidth: "94vw", overflow: "hidden" }}>
|
||
<div style={{ padding: "18px 22px", borderBottom: "1px solid var(--line)" }}>
|
||
<div className="row spread" style={{ marginBottom: 14 }}>
|
||
<div className="t-h1">New project</div>
|
||
<button className="iconbtn" onClick={onCancel}><Icon name="x" size={17} /></button>
|
||
</div>
|
||
<div className="row gap2">
|
||
{steps.map((s, i) => (
|
||
<div key={i} className="row gap2 grow">
|
||
<div style={{ width: 22, height: 22, borderRadius: "50%", flex: "none", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, fontWeight: 700, fontFamily: "var(--font-mono)", background: i < step ? "var(--accent)" : i === step ? "var(--accent-glow)" : "var(--bg-3)", color: i < step ? "var(--fg-on-accent)" : i === step ? "var(--accent)" : "var(--fg-2)", border: i === step ? "1px solid var(--accent)" : "none" }}>
|
||
{i < step ? <Icon name="check" size={13} /> : i + 1}
|
||
</div>
|
||
<span style={{ fontSize: 12.5, fontWeight: 600, color: i <= step ? "var(--fg-0)" : "var(--fg-2)" }}>{s}</span>
|
||
{i < steps.length - 1 && <div className="grow" style={{ height: 1, background: i < step ? "var(--accent)" : "var(--line)" }} />}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div style={{ padding: 22, minHeight: 280 }}>
|
||
{step === 0 && (
|
||
<div className="fade-in">
|
||
<Field label="Project name" help="A workspace for related workflows, tools, and knowledge." required>
|
||
<input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Customer Support" />
|
||
</Field>
|
||
<div className="field-label">Start from</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginTop: 6 }}>
|
||
{templates.map((t) => (
|
||
<button key={t.id} onClick={() => setTmpl(t.id)} style={{ textAlign: "left", padding: 12, borderRadius: 10, cursor: "pointer", background: "var(--bg-1)", border: "1px solid " + (tmpl === t.id ? "var(--accent)" : "var(--line)"), boxShadow: tmpl === t.id ? "0 0 0 3px var(--accent-glow)" : "none" }}>
|
||
<div className="row gap2" style={{ marginBottom: 6 }}><Tile icon={t.icon} color="var(--accent)" size={28} />{tmpl === t.id && <Icon name="check" size={16} style={{ color: "var(--accent)", marginLeft: "auto" }} />}</div>
|
||
<div style={{ fontWeight: 600, fontSize: 13 }}>{t.name}</div>
|
||
<div className="fg-2 t-caption" style={{ marginTop: 2 }}>{t.desc}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{step === 1 && (
|
||
<div className="fade-in">
|
||
<div className="fg-1" style={{ marginBottom: 14 }}>Connect at least one model provider. Keys are stored encrypted in your secret store - they never leave your instance.</div>
|
||
{[["anthropic", "Anthropic", "claude-sonnet-4-6, haiku-4-2"], ["openai", "OpenAI", "gpt-5.4, gpt-5.4-mini"], ["google", "Google", "gemini-3.1-pro, 3.5-flash"]].map((p) => (
|
||
<div key={p[0]} className="row gap3" style={{ padding: "12px 14px", borderRadius: 10, border: "1px solid var(--line)", marginBottom: 10 }}>
|
||
<Tile icon="n_llm" color="var(--fg-2)" size={32} />
|
||
<div className="grow"><div style={{ fontWeight: 600 }}>{p[1]}</div><div className="fg-2 t-caption">{p[2]}</div></div>
|
||
{models[p[0]] && <input className="input mono" style={{ width: 200 }} type="password" placeholder="sk-… (optional, encrypted)" value={keys[provId[p[0]]] || ""} onChange={(e) => setKeys((k) => ({ ...k, [provId[p[0]]]: e.target.value }))} />}
|
||
<Toggle on={models[p[0]]} onChange={(v) => setModels((m) => ({ ...m, [p[0]]: v }))} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{step === 2 && (
|
||
<div className="fade-in col center" style={{ textAlign: "center", gap: 10, paddingTop: 16 }}>
|
||
<Tile icon="check" color="var(--ok)" size={52} glow />
|
||
<div className="t-h1">Create “{name || "Untitled"}”</div>
|
||
<div className="fg-1" style={{ maxWidth: 380 }}>We’ll create an empty project so you can register tools, add knowledge, and build your first workflow from scratch.</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="row spread" style={{ padding: "14px 22px", borderTop: "1px solid var(--line)" }}>
|
||
<button className="btn btn-ghost" onClick={() => (step === 0 ? onCancel() : setStep(step - 1))}>{step === 0 ? "Cancel" : "Back"}</button>
|
||
<button className="btn btn-primary" disabled={(step === 0 && !name) || busy} onClick={() => { if (step < 2) { setStep(step + 1); } else { setBusy(true); onCreate({ name, template: tmpl, keys }); } }}>
|
||
{step < 2 ? "Continue" : busy ? "Creating…" : "Create project"}<Icon name="chevright" size={15} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|