This commit is contained in:
valenti
2026-09-08 07:21:34 -04:00
commit a4b224da8e
169 changed files with 27483 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
"use client";
import React from "react";
import { Bot, Cpu, Zap, ShieldCheck } from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Badge, JobStatusBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { MetricCard } from "@/components/ui/MetricCard";
export default function AiAgentsPage() {
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<Bot className="w-6 h-6 text-accent" />
AI Agents
</h1>
<Badge tone="violet">α · Alpha</Badge>
</div>
<p className="mt-1 text-sm text-slate-500">
ASF AI Gateway — provider-agnostic inference, policy routing and usage metering. Interfaces are ready, runtime behaviors are stabilizing.
</p>
</div>
<div className="flex items-center gap-2">
<Badge tone="brand"><Cpu className="w-3 h-3" /> Gateway abstraction OK</Badge>
<Badge tone="green"><ShieldCheck className="w-3 h-3" /> No provider lock-in</Badge>
</div>
</header>
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<MetricCard label="Models routable" value={6} tone="violet" icon={<Bot className="w-5 h-5" />} subtitle="NVIDIA DGX · OpenAI compat · local" />
<MetricCard label="Active AI jobs" value={5} tone="brand" icon={<Zap className="w-5 h-5" />} delta={2.1} />
<MetricCard label="Tenant policies" value={2} tone="green" icon={<ShieldCheck className="w-5 h-5" />} subtitle="Cost ceilings, model allowlists" />
<MetricCard label="Queued prompts" value={12} tone="amber" icon={<Cpu className="w-5 h-5" />} />
</section>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Agent registry</CardTitle>
<Badge tone="neutral">0 active · 5 templates</Badge>
</CardHeader>
<CardBody>
<EmptyState
title="No custom agents yet"
description="Create a reusable AI agent by selecting a model, system prompt, tools and a cost policy. Templates below are ready to enable."
icon={Bot}
actionLabel="New AI agent"
/>
<div className="mt-6 divide-y divide-slate-100 border border-slate-100 rounded-lg overflow-hidden">
{[
{ name: "Traceability Analyst", desc: "Recommends missing links across the artifact graph.", model: "routing default", status: "queued" },
{ name: "Test Generator", desc: "Generates unit tests for a selected Task artifact.", model: "code-model", status: "running" },
{ name: "Incident Responder", desc: "Traces an incident backwards to its offending commit/PR.", model: "reasoning-model", status: "queued" },
{ name: "Requirement Cleric", desc: "Refines ambiguous requirements and proposes acceptance criteria.", model: "reasoning-model", status: "completed" },
{ name: "Impact Predictor", desc: "Estimates blast radius for a proposed requirement change.", model: "routing default", status: "queued" },
].map((a) => (
<div key={a.name} className="flex items-center gap-3 px-4 py-3 hover:bg-slate-50">
<div className="w-9 h-9 rounded-lg bg-violet-50 border border-violet-100 grid place-items-center text-violet-600 shrink-0">
<Bot className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-slate-800">{a.name}</div>
<div className="text-xs text-slate-500">{a.desc}</div>
</div>
<Badge tone="neutral" className="hidden sm:inline-flex asf-monospace">{a.model}</Badge>
<JobStatusBadge status={a.status} />
</div>
))}
</div>
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle>AI Gateway</CardTitle>
<Badge tone="violet">Interfaces</Badge>
</CardHeader>
<CardBody className="space-y-3 text-sm">
<div className="p-3 rounded-lg border border-violet-100 bg-violet-50/60">
<div className="font-semibold text-violet-900 flex items-center gap-1.5">
<Zap className="w-4 h-4" /> Provider adapter pattern
</div>
<p className="text-xs text-violet-800/80 mt-1 leading-relaxed">
Business logic depends on <code className="bg-white/70 px-1 rounded">asf-ai</code> interfaces only. Real providers plug in without touching domain code.
</p>
</div>
<ul className="space-y-2 text-xs">
{[
"Model routing by task + tenant policy",
"Usage metering (tokens, GPU-s, $)",
"Priority queues + cost ceilings",
"Audit log of every inference",
].map((t) => (
<li key={t} className="flex items-start gap-2 text-slate-600">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-600 mt-0.5 shrink-0" />
{t}
</li>
))}
</ul>
<div className="pt-3 border-t border-slate-100 text-xs text-slate-500">
Hint: use <span className="asf-chip font-mono">⌘ K</span> → Ask ASF to try the interactive query interface.
</div>
</CardBody>
</Card>
</div>
</div>
);
}
+674
View File
@@ -0,0 +1,674 @@
"use client";
import React, { useRef, useState, useEffect, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
Sparkles,
Send,
Loader2,
MessageSquare,
Bot,
User,
ChevronRight,
X,
Copy,
Check,
Clock,
AlertTriangle,
CheckCircle2,
GitBranch,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { askAsf, jobs, projects, artifacts as apiArtifacts } from "@/lib/api";
import { cn, fmtDate, shortId, truncate } from "@/lib/utils";
import type { Job, JobStatus, Artifact, Project } from "@/lib/types";
type MessageRole = "user" | "assistant" | "system";
type ChatStatus = "idle" | "queued" | "running" | "completed" | "failed" | "cancelled";
interface ChatMessage {
id: string;
role: MessageRole;
content: string;
timestamp: string;
status?: ChatStatus;
jobId?: string;
job?: Job | null;
error?: string;
}
const SUGGESTIONS = [
"Which services implement REQ-103 Requisito RBAC?",
"Summarize the current deployment chain from PR to PROD",
"What is the traceability score of project ASF Bootstrap?",
"List all tests that verify PR#88 feat(rbac)",
"How healthy is my project last 24h?",
];
const POLL_MS = 2000;
const MAX_POLLS = 120;
const statusTone: Record<Exclude<ChatStatus, "idle">, "brand" | "amber" | "green" | "red" | "neutral"> = {
queued: "neutral",
running: "brand",
completed: "green",
failed: "red",
cancelled: "neutral",
};
export default function AskAsfPage() {
const qc = useQueryClient();
const scrollRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState("");
const [projectId, setProjectId] = useState<string>("all");
const [ctxIds, setCtxIds] = useState<string[]>([]);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [pendingJob, setPendingJob] = useState<{ jobId: string; msgId: string; polls: number } | null>(null);
const [copied, setCopied] = useState<string>("");
const projectsQ = useMemo(() => ({ isFetched: false, data: [] as Project[], error: null }), []);
const [projectsList, setProjectsList] = useState<Project[]>([]);
const [artifactsList, setArtifactsList] = useState<Artifact[]>([]);
useEffect(() => {
(async () => {
try {
const [ps, as] = await Promise.all([
projects.list({ limit: 50 }),
apiArtifacts.list({ limit: 100 }),
]);
setProjectsList(ps);
setArtifactsList(as);
if (ps.length === 1) setProjectId(String(ps[0].id));
} catch {
// ignore offline fallback: demo suggestions
}
})();
}, []);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages, pendingJob]);
useEffect(() => {
if (!pendingJob) return;
let cancelled = false;
let polls = pendingJob.polls;
const tick = async () => {
if (cancelled) return;
polls += 1;
try {
const j = await jobs.get(pendingJob.jobId);
setMessages((prev) =>
prev.map((m) =>
m.id === pendingJob.msgId
? {
...m,
status: (j.status as ChatStatus) ?? m.status,
job: j,
content: pickAnswer(j, m.content),
error: j.status === "failed" ? j.error ?? "AI job failed" : undefined,
}
: m,
),
);
void qc.invalidateQueries({ queryKey: ["jobs-list"] }).catch(() => undefined);
if (j.status === "completed" || j.status === "failed" || j.status === "cancelled" || polls > MAX_POLLS) {
if (polls > MAX_POLLS) {
setMessages((prev) =>
prev.map((m) =>
m.id === pendingJob.msgId
? {
...m,
status: "failed",
error: "Timed out waiting for AI answer (max 240s).",
}
: m,
),
);
}
setPendingJob(null);
return;
}
setPendingJob({ ...pendingJob, polls });
} catch {
// keep retrying
}
if (!cancelled) setTimeout(tick, POLL_MS);
};
const t = setTimeout(tick, POLL_MS);
return () => {
cancelled = true;
clearTimeout(t);
};
}, [pendingJob, qc]);
const suggestions = useMemo(() => SUGGESTIONS, []);
const toggleCtxArtifact = (id: string) => {
setCtxIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
const q = input.trim();
if (!q || pendingJob) return;
const userMsg: ChatMessage = {
id: cryptoId(),
role: "user",
content: q,
timestamp: new Date().toISOString(),
};
const assistantMsgId = cryptoId();
const assistantMsg: ChatMessage = {
id: assistantMsgId,
role: "assistant",
content: "Thinking…",
timestamp: new Date().toISOString(),
status: "queued",
};
setMessages((prev) => [...prev, userMsg, assistantMsg]);
setInput("");
try {
const res = await askAsf.query({
question: q,
project_id: projectId !== "all" ? projectId : undefined,
context_artifact_ids: ctxIds.length ? ctxIds : undefined,
});
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId ? { ...m, jobId: res.job_id, status: "queued" } : m,
),
);
setPendingJob({ jobId: res.job_id, msgId: assistantMsgId, polls: 0 });
} catch (err) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? {
...m,
status: "failed",
error: err instanceof Error ? err.message : "Failed to enquire ASF",
}
: m,
),
);
}
};
const handleCopy = (id: string, text: string) => {
void navigator.clipboard?.writeText(text).then(() => {
setCopied(id);
setTimeout(() => setCopied(""), 1800);
});
};
return (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4">
<div className="flex items-start gap-3 flex-wrap">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-accent to-brand-600 shadow-soft grid place-items-center text-white shrink-0">
<Sparkles className="w-5.5 h-5.5 w-[22px] h-[22px]" />
</div>
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2 flex-wrap">
Ask ASF
<Badge tone="green" className="uppercase text-[10px] tracking-wide">
AI Live
</Badge>
</h1>
<p className="mt-1 text-sm text-slate-500 max-w-2xl">
Natural-language interface to the ASF graph. Questions are enqueued as{" "}
<span className="asf-monospace text-slate-700 bg-slate-100 rounded px-1">ai_interactive</span> jobs and
answered by the ASF Worker calling the asf-ai Gateway.
</p>
</div>
</div>
</header>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 lg:grid-cols-12 gap-4">
<Card className="lg:col-span-4 xl:col-span-3 flex flex-col min-h-0">
<CardHeader>
<CardTitle className="!text-sm">Context & Scope</CardTitle>
</CardHeader>
<CardBody className="overflow-auto flex-1 min-h-0 space-y-4 text-sm">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Project scope</label>
<select
className="asf-input text-sm"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
disabled={!!pendingJob}
>
<option value="all">All projects (tenant-wide)</option>
{projectsList.map((p) => (
<option key={p.id} value={String(p.id)}>
{p.name}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="block text-xs font-medium text-slate-500">Anchor artifacts</label>
{ctxIds.length > 0 && (
<button
type="button"
className="text-[11px] text-slate-400 hover:text-slate-600"
onClick={() => setCtxIds([])}
>
Clear ({ctxIds.length})
</button>
)}
</div>
<ul className="max-h-60 overflow-auto divide-y divide-slate-100 border border-slate-100 rounded-lg text-xs">
{artifactsList.length === 0 && (
<li className="p-3 text-slate-400">No artifacts loaded yet.</li>
)}
{artifactsList.map((a) => {
const on = ctxIds.includes(String(a.id));
return (
<li
key={a.id}
className={cn(
"flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors",
on ? "bg-brand-50/60" : "hover:bg-slate-50",
)}
onClick={() => toggleCtxArtifact(String(a.id))}
>
<span
className={cn(
"w-3.5 h-3.5 rounded border grid place-items-center shrink-0",
on ? "bg-brand-500 border-brand-500 text-white" : "border-slate-300 bg-white",
)}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
<ArtifactTypeBadge type={a.type} />
<div className="min-w-0 flex-1">
<div className="truncate text-slate-700 font-medium">
{a.external_id ?? shortId(a.id)}
</div>
<div className="truncate text-slate-500">{truncate(a.title, 44)}</div>
</div>
</li>
);
})}
</ul>
</div>
<div className="pt-2 border-t border-slate-100">
<p className="text-xs font-semibold text-slate-500 mb-2 uppercase tracking-wide">Try asking</p>
<div className="space-y-1.5">
{suggestions.map((s) => (
<button
key={s}
type="button"
disabled={!!pendingJob}
onClick={() => {
setInput(s);
setTimeout(() => handleSubmit(), 0);
}}
className="w-full text-left text-xs p-2 rounded-md border border-slate-100 hover:border-brand-200 hover:bg-brand-50/30 text-slate-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<ChevronRight className="w-3 h-3 text-brand-500 shrink-0" />
<span className="truncate">{s}</span>
</button>
))}
</div>
</div>
</CardBody>
</Card>
<Card className="lg:col-span-8 xl:col-span-9 flex flex-col min-h-0">
<CardHeader className="flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
<CardTitle className="!text-sm flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-slate-400" />
Conversation
{pendingJob && (
<Badge tone={statusTone[(jobsStatusByJobId(pendingJob.jobId, messages) as Exclude<ChatStatus, "idle">) ?? "running"]} className="animate-pulse">
<Loader2 className="w-3 h-3 inline mr-1 animate-spin" />
Processing…
</Badge>
)}
</CardTitle>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
disabled={messages.length === 0}
onClick={() => {
setMessages([]);
setPendingJob(null);
}}
>
<X className="w-3.5 h-3.5 mr-1" />
Clear
</Button>
</div>
</CardHeader>
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-auto px-5 py-4 space-y-5 bg-slate-50/40 border-y border-slate-100"
>
{messages.length === 0 ? (
<div className="h-full flex items-center justify-center">
<EmptyState
title="Ask anything about your ASF graph"
description="The AI worker will reason over projects, artifacts, relationships and jobs. Start by typing a question above or pick a suggestion."
icon={Sparkles}
/>
</div>
) : (
messages.map((m) => (
<MessageBubble
key={m.id}
m={m}
artifacts={artifactsList}
projects={projectsList}
onCopy={() => handleCopy(m.id, m.content)}
copied={copied === m.id}
onJumpArtifact={(id) => {
const u = `/context?artifact=${encodeURIComponent(id)}`;
window.open(u, "_blank", "noopener");
}}
/>
))
)}
</div>
<form
onSubmit={handleSubmit}
className="p-3 border-t border-slate-100 bg-white rounded-b-xl flex flex-col gap-2"
>
<div className="flex gap-2 items-end">
<div className="flex-1 relative">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSubmit();
}
}}
rows={2}
placeholder="Ask ASF: Which services implement REQ-103?… (Enter to send, Shift+Enter for newline)"
className="asf-input pr-24 resize-none min-h-[60px]"
disabled={!!pendingJob}
/>
<div className="absolute bottom-2.5 right-2.5 text-[11px] text-slate-400 pointer-events-none select-none">
{projectId !== "all" && (
<span className="asf-chip !py-0.5 !px-2 mr-1">Project: {projectsList.find((p) => String(p.id) === projectId)?.name ?? shortId(projectId)}</span>
)}
{ctxIds.length > 0 && (
<span className="asf-chip !py-0.5 !px-2">{ctxIds.length} anchors</span>
)}
</div>
</div>
<Button
type="submit"
disabled={!input.trim() || !!pendingJob}
className="h-[60px] !px-4"
>
{pendingJob ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Working…
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Send
</>
)}
</Button>
</div>
</form>
</Card>
</div>
</div>
);
}
function MessageBubble({
m,
artifacts,
projects,
onCopy,
copied,
onJumpArtifact,
}: {
m: ChatMessage;
artifacts: Artifact[];
projects: Project[];
onCopy: () => void;
copied: boolean;
onJumpArtifact: (id: string) => void;
}) {
const isUser = m.role === "user";
const projMap = useMemo(() => {
const mp = new Map<string, Project>();
for (const p of projects) mp.set(String(p.id), p);
return mp;
}, [projects]);
const artMap = useMemo(() => {
const mp = new Map<string, Artifact>();
for (const a of artifacts) mp.set(String(a.id), a);
return mp;
}, [artifacts]);
return (
<div className={cn("flex gap-3", isUser ? "justify-end" : "justify-start")}>
<div
className={cn(
"w-8 h-8 shrink-0 rounded-lg grid place-items-center border",
isUser
? "order-2 bg-slate-900 text-white border-slate-900"
: "bg-white border-slate-200 text-accent shadow-sm",
)}
>
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
</div>
<div className={cn("max-w-[82%] flex flex-col gap-1.5", isUser ? "items-end" : "items-start")}>
<div className="flex items-center gap-2 text-[11px] text-slate-400">
<span className="font-semibold text-slate-500">{isUser ? "You" : "ASF AI"}</span>
<span>· {fmtDate(m.timestamp)}</span>
{m.jobId && (
<>
<GitBranch className="w-3 h-3 ml-1" />
<span className="asf-monospace">{shortId(m.jobId)}</span>
</>
)}
{m.status && m.status !== "idle" && (
<Badge tone={statusTone[m.status as Exclude<ChatStatus, "idle">] ?? "neutral"}>
{statusIcon(m.status)} {m.status}
</Badge>
)}
</div>
<div
className={cn(
"px-4 py-3 rounded-2xl border text-sm leading-relaxed",
isUser
? "bg-brand-600 text-white rounded-tr-sm border-brand-600"
: "bg-white text-slate-800 border-slate-200 rounded-tl-sm shadow-sm",
)}
>
{m.status === "queued" || m.status === "running" ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-slate-600">
{m.status === "queued"
? "Query queued as ai_interactive job. Waiting for ASF Worker to claim it…"
: "Worker claimed the job. Reasoning over graph and calling asf-ai…"}
</span>
</div>
) : (
<>
<div className="whitespace-pre-wrap break-words">
{m.status === "failed" || m.error ? (
<div className="flex items-start gap-2 text-rose-700 bg-rose-50/60 -mx-1 px-3 py-2 rounded-lg">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<div className="font-semibold mb-0.5">AI execution failed</div>
<div className="text-[12px] text-rose-600">{m.error ?? "Unknown error"}</div>
</div>
</div>
) : (
<ContentAutoLink text={m.content} artMap={artMap} projMap={projMap} onJump={onJumpArtifact} />
)}
</div>
{m.job && (
<JobMeta job={m.job} />
)}
</>
)}
</div>
<div className="flex gap-1">
<button
type="button"
onClick={onCopy}
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-slate-600 px-2 py-0.5 rounded-md hover:bg-slate-100"
>
{copied ? <Check className="w-3 h-3 text-emerald-500" /> : <Copy className="w-3 h-3" />}
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
</div>
);
}
function ContentAutoLink({
text,
artMap,
projMap,
onJump,
}: {
text: string;
artMap: Map<string, Artifact>;
projMap: Map<string, Project>;
onJump: (id: string) => void;
}) {
const pattern = /\b(REQ|TASK|PR|TEST|BUILD|DEP|DOC|SRC|SEC|INC|EPIC)[-#]?\d*[A-Za-z0-9]*\b/g;
const parts: Array<string | JSX.Element> = [];
let last = 0;
let m: RegExpExecArray | null;
let key = 0;
while ((m = pattern.exec(text))) {
if (m.index > last) parts.push(text.slice(last, m.index));
const token = m[0];
const foundArt = Array.from(artMap.values()).find(
(a) => (a.external_id && a.external_id.toLowerCase() === token.toLowerCase()) || false,
);
if (foundArt) {
parts.push(
<button
key={`link-${key++}`}
type="button"
onClick={() => onJump(String(foundArt.id))}
className="asf-monospace inline-flex items-center gap-1 mx-0.5 -my-0.5 px-1.5 py-0.5 rounded bg-brand-50 text-brand-700 border border-brand-200 hover:bg-brand-100 transition-colors align-baseline"
>
{token}
<ChevronRight className="w-3 h-3" />
</button>,
);
} else {
parts.push(token);
}
last = m.index + token.length;
}
if (last < text.length) parts.push(text.slice(last));
return <>{parts}</>;
}
function JobMeta({ job }: { job: Job }) {
const usage = job.usage;
return (
<div className="mt-3 pt-3 border-t border-slate-100 text-[11px] text-slate-500 grid grid-cols-2 sm:grid-cols-4 gap-2">
<JobMetaPill label="Status" tone={statusTone[job.status as Exclude<ChatStatus, "idle">] ?? "neutral"} value={job.status} />
<JobMetaPill label="Started" value={job.started_at ? fmtDate(job.started_at) : "—"} />
<JobMetaPill label="Completed" value={job.completed_at ? fmtDate(job.completed_at) : "—"} />
<JobMetaPill label="Worker" value={job.worker ?? "asf-worker"} />
{usage?.input_tokens !== undefined && <JobMetaPill label="Input tok." value={String(usage.input_tokens)} />}
{usage?.output_tokens !== undefined && <JobMetaPill label="Output tok." value={String(usage.output_tokens)} />}
{usage?.cpu_seconds !== undefined && <JobMetaPill label="CPU s." value={usage.cpu_seconds.toFixed(2)} />}
{usage?.estimated_cost !== undefined && <JobMetaPill label="Cost" value={`$${usage.estimated_cost.toFixed(4)}`} />}
</div>
);
}
function JobMetaPill({ label, value, tone }: { label: string; value: string; tone?: "brand" | "green" | "red" | "neutral" | "amber" }) {
const toneCls = tone
? {
brand: "bg-brand-50 text-brand-700 border-brand-100",
green: "bg-emerald-50 text-emerald-700 border-emerald-100",
red: "bg-rose-50 text-rose-700 border-rose-100",
neutral: "bg-slate-50 text-slate-600 border-slate-200",
amber: "bg-amber-50 text-amber-700 border-amber-100",
}[tone]
: "bg-slate-50 text-slate-600 border-slate-200";
return (
<div className={cn("rounded-md border px-2 py-1", toneCls)}>
<div className="uppercase tracking-wide text-[9px] opacity-70">{label}</div>
<div className="asf-monospace font-semibold truncate">{value}</div>
</div>
);
}
function statusIcon(s: ChatStatus) {
switch (s) {
case "queued":
return <Clock className="w-3 h-3 inline mr-1" />;
case "running":
return <Loader2 className="w-3 h-3 inline mr-1 animate-spin" />;
case "completed":
return <CheckCircle2 className="w-3 h-3 inline mr-1" />;
case "failed":
return <AlertTriangle className="w-3 h-3 inline mr-1" />;
case "cancelled":
return <X className="w-3 h-3 inline mr-1" />;
default:
return null;
}
}
function jobsStatusByJobId(jid: string, msgs: ChatMessage[]): JobStatus | null {
const m = msgs.find((x) => x.jobId === jid);
return (m?.job?.status ?? m?.status) as JobStatus | null;
}
function pickAnswer(j: Job, fallback: string): string {
if (j.result) {
const r = j.result as Record<string, unknown>;
for (const k of ["message", "answer", "summary", "response", "text"]) {
if (typeof r[k] === "string" && (r[k] as string).length > 0) return r[k] as string;
}
try {
return JSON.stringify(j.result, null, 2);
} catch {
/* noop */
}
}
if (j.status === "failed") {
return j.error ?? "ASF AI could not answer this question.";
}
if (j.status === "completed") {
return "Done. (AI result was returned with no message field.)";
}
return fallback;
}
function cryptoId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return "id-" + Math.random().toString(36).slice(2) + Date.now().toString(36);
}
+697
View File
@@ -0,0 +1,697 @@
"use client";
import React, { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Code2,
Search,
AlertTriangle,
FileCode2,
CheckCircle2,
AlertOctagon,
AlertCircle,
Info,
ChevronRight,
ChevronDown,
GitBranch,
ArrowLeft,
Sparkles,
FileText,
Layers,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { artifacts as apiArtifacts, projects } from "@/lib/api";
import { cn, fmtDate, shortId, truncate } from "@/lib/utils";
import type { Artifact, ArtifactType, Project } from "@/lib/types";
type Scope = "all" | "source_file" | "smell";
type Severity = "all" | "low" | "medium" | "high" | "critical";
const SEVERITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
const SEVERITY_META: Record<Exclude<Severity, "all">, { icon: typeof AlertTriangle; tone: "red" | "amber" | "brand" | "neutral"; label: string }> = {
critical: { icon: AlertOctagon, tone: "red", label: "CRITICAL" },
high: { icon: AlertTriangle, tone: "amber", label: "HIGH" },
medium: { icon: AlertCircle, tone: "brand", label: "MEDIUM" },
low: { icon: Info, tone: "neutral", label: "LOW" },
};
const DEMO_ROWS: Artifact[] = [
{
id: "src-1", tenant_id: "t1", project_id: "p1",
type: "source_file", external_id: "SRC-DEMO-1",
title: "app/middleware/auth.py",
status: "active", source: "git",
content: "def authorize(request):\n return check_jwt(request.headers.get('authorization'))\n",
metadata: { language: "python", path: "app/middleware/auth.py", loc: 134, repo: "asf", branch: "main" },
created_at: "2025-11-01T09:00:00Z", updated_at: "2025-11-05T11:00:00Z",
},
{
id: "sm-1", tenant_id: "t1", project_id: "p1",
type: "ai_analysis", external_id: "CS-DEMO-1",
title: "SQL injection in user lookup (no ORM)",
status: "open", source: "bandit",
content: "Line 22 uses f-string on cursor.execute().",
metadata: { kind: "code_smell", language: "python", severity: "high", rule_id: "B608", engine: "bandit", lines: [20, 21, 22] },
created_at: "2025-11-08T09:00:00Z", updated_at: "2025-11-08T09:00:00Z",
},
];
function isSmell(a: Artifact): boolean {
const md = a.metadata ?? ({} as Record<string, unknown>);
return typeof md === "object" && (md as Record<string, unknown>)["kind"] === "code_smell";
}
function isSourceFile(a: Artifact): boolean {
return a.type === "source_file";
}
function getLanguage(a: Artifact): string {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["language"];
return typeof v === "string" ? v : "—";
}
function getSeverity(a: Artifact): "low" | "medium" | "high" | "critical" | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["severity"];
if (typeof v === "string" && (v === "low" || v === "medium" || v === "high" || v === "critical")) return v;
return null;
}
function getRule(a: Artifact): string | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["rule_id"];
return typeof v === "string" ? v : null;
}
function getEngine(a: Artifact): string | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["engine"];
return typeof v === "string" ? v : null;
}
function getPath(a: Artifact): string | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["path"];
return typeof v === "string" ? v : null;
}
function getLines(a: Artifact): number[] | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["lines"];
return Array.isArray(v) && v.every((x) => typeof x === "number") ? (v as number[]) : null;
}
function getLOC(a: Artifact): number | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["loc"];
return typeof v === "number" ? v : null;
}
function getRepo(a: Artifact): string | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["repo"];
return typeof v === "string" ? v : null;
}
function getBranch(a: Artifact): string | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["branch"];
return typeof v === "string" ? v : null;
}
function getConfidence(a: Artifact): number | null {
const md = a.metadata ?? ({} as Record<string, unknown>);
const v = (md as Record<string, unknown>)["confidence_pct"];
return typeof v === "number" ? v : null;
}
export default function CodePage() {
const [scope, setScope] = useState<Scope>("all");
const [projectFilter, setProjectFilter] = useState<string>("all");
const [languageFilter, setLanguageFilter] = useState<string>("all");
const [severityFilter, setSeverityFilter] = useState<Severity>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [q, setQ] = useState<string>("");
const [detailId, setDetailId] = useState<string>("");
const projectsQ = useQuery({
queryKey: ["code-projects"],
queryFn: () => projects.list({ limit: 50 }),
retry: false, refetchOnWindowFocus: false,
});
const apiResolved = projectsQ.isFetched && projectsQ.data !== undefined;
const projectRows: Project[] = apiResolved ? projectsQ.data! : [];
const projectNameById = useMemo(() => {
const m = new Map<string, string>();
for (const p of projectRows) m.set(String(p.id), p.name);
return m;
}, [projectRows]);
const sourceQ = useQuery({
queryKey: ["code-source", projectFilter, q.trim()],
queryFn: () =>
apiArtifacts.list({
type: "source_file" as ArtifactType,
project_id: projectFilter !== "all" ? projectFilter : undefined,
search: q.trim() || undefined,
limit: 200,
}),
retry: false, refetchOnWindowFocus: false,
});
const smellsQ = useQuery({
queryKey: ["code-smells", projectFilter, q.trim()],
queryFn: () =>
apiArtifacts.list({
type: "ai_analysis" as ArtifactType,
project_id: projectFilter !== "all" ? projectFilter : undefined,
search: q.trim() || undefined,
limit: 200,
}),
retry: false, refetchOnWindowFocus: false,
});
const sourceResolved = sourceQ.isFetched && sourceQ.data !== undefined;
const smellsResolved = smellsQ.isFetched && smellsQ.data !== undefined;
const isMock = !sourceResolved || !smellsResolved;
const rawRows: Artifact[] = useMemo(() => {
const allSources = sourceResolved ? sourceQ.data! : DEMO_ROWS.filter(isSourceFile);
const allAnalyses = smellsResolved ? smellsQ.data! : DEMO_ROWS.filter(isSmell);
const smellsOnly = allAnalyses.filter(isSmell);
const combined = [...allSources, ...smellsOnly];
// apply language + severity + status filters (server can't filter metadata)
return combined.filter((a) => {
if (scope === "source_file" && !isSourceFile(a)) return false;
if (scope === "smell" && !isSmell(a)) return false;
if (languageFilter !== "all" && getLanguage(a) !== languageFilter) return false;
if (severityFilter !== "all") {
if (!isSmell(a)) return false;
if (getSeverity(a) !== severityFilter) return false;
}
if (statusFilter !== "all" && (a.status ?? "") !== statusFilter) return false;
return true;
}).sort((b, a) => {
// severity first (only if smell), then updated_at desc
const sa = isSmell(a) ? (SEVERITY_ORDER[getSeverity(a) ?? "low"] ?? 9) : 99;
const sb = isSmell(b) ? (SEVERITY_ORDER[getSeverity(b) ?? "low"] ?? 9) : 99;
if (sa !== sb) return sa - sb;
return (a.updated_at ?? "").localeCompare(b.updated_at ?? "");
});
}, [sourceResolved, smellsResolved, sourceQ.data, smellsQ.data, scope, languageFilter, severityFilter, statusFilter]);
const languages = useMemo(() => {
const s = new Set<string>();
for (const r of rawRows) {
const l = getLanguage(r);
if (l && l !== "—") s.add(l);
}
return Array.from(s).sort();
}, [rawRows]);
const qc = useQueryClient();
const markFixedM = useMutation({
mutationFn: async ({ id, status }: { id: string; status: string }) =>
apiArtifacts.update(id, { status } as Partial<Artifact>),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["code-source"] });
qc.invalidateQueries({ queryKey: ["code-smells"] });
},
});
const stats = useMemo(() => {
const src = rawRows.filter(isSourceFile).length;
const sm = rawRows.filter(isSmell);
const smBySev = { low: 0, medium: 0, high: 0, critical: 0 } as Record<"low" | "medium" | "high" | "critical", number>;
for (const s of sm) {
const sv = getSeverity(s);
if (sv) smBySev[sv] += 1;
}
return { src, smells: sm.length, smBySev };
}, [rawRows]);
const effectiveDetail = useMemo(() => {
if (!detailId) return null;
return rawRows.find((r) => String(r.id) === String(detailId)) ?? null;
}, [detailId, rawRows]);
if (effectiveDetail) {
return (
<CodeDetailView
artifact={effectiveDetail}
projectName={projectNameById.get(String(effectiveDetail.project_id)) ?? String(effectiveDetail.project_id)}
onBack={() => setDetailId("")}
onMarkFixed={(status) =>
markFixedM.mutate({ id: String(effectiveDetail.id), status })
}
markFixedPending={markFixedM.isPending}
/>
);
}
return (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4">
<div className="flex items-start gap-3 flex-wrap">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500 to-violet-600 shadow-soft grid place-items-center text-white shrink-0">
<Code2 className="w-5 h-5" />
</div>
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2 flex-wrap">
Code Analysis
{isMock && <Badge tone="amber">Demo data</Badge>}
</h1>
<p className="mt-1 text-sm text-slate-500 max-w-3xl">
Source files indexed from your repositories + automated code smells detected by ASF AI Analyzer
(Ruff / ESLint / Bandit …). Click any row to see the detail with highlighted lines.
</p>
</div>
</div>
</header>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 flex flex-col gap-4 min-w-0">
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<StatCard label="Source files" value={stats.src} tone="brand" icon={FileCode2} />
<StatCard label="Code smells" value={stats.smells} tone="amber" icon={AlertTriangle} />
<StatCard label="Critical" value={stats.smBySev.critical} tone="red" icon={AlertOctagon} />
<StatCard label="High" value={stats.smBySev.high} tone="amber" icon={AlertTriangle} />
<StatCard label="Med/Low" value={stats.smBySev.medium + stats.smBySev.low} tone="neutral" icon={Info} />
</div>
<Card className="flex-1 min-h-0 flex flex-col">
<CardHeader className="flex-wrap items-end gap-3">
<div className="min-w-0 flex-1">
<CardTitle className="!text-sm flex items-center gap-2">
<Layers className="w-4 h-4 text-slate-400" />
Inventory
<span className="asf-chip">{rawRows.length}</span>
</CardTitle>
</div>
<div className="flex flex-wrap gap-2 items-center">
<div className="relative w-full md:w-64">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
className="asf-input pl-9"
placeholder="Search paths, titles, rules…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<select className="asf-input !w-auto" value={scope} onChange={(e) => setScope(e.target.value as Scope)}>
<option value="all">All code</option>
<option value="source_file">Source files only</option>
<option value="smell">Code smells only</option>
</select>
<select className="asf-input !w-auto" value={languageFilter} onChange={(e) => setLanguageFilter(e.target.value)}>
<option value="all">Any language</option>
{languages.map((l) => (
<option key={l} value={l}>{l.toUpperCase()}</option>
))}
</select>
<select className="asf-input !w-auto" value={severityFilter} onChange={(e) => setSeverityFilter(e.target.value as Severity)}>
<option value="all">Any severity</option>
<option value="critical">CRITICAL</option>
<option value="high">HIGH</option>
<option value="medium">MEDIUM</option>
<option value="low">LOW</option>
</select>
<select className="asf-input !w-auto" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">Any status</option>
{Array.from(new Set(rawRows.map((r) => r.status ?? "unknown").filter(Boolean))).sort().map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<select className="asf-input !w-auto" value={projectFilter} onChange={(e) => setProjectFilter(e.target.value)}>
<option value="all">All projects</option>
{projectRows.map((p) => (
<option key={p.id} value={String(p.id)}>{p.name}</option>
))}
</select>
</div>
</CardHeader>
<CardBody className="p-0 flex flex-col min-h-0 overflow-hidden">
{rawRows.length === 0 ? (
<div className="flex-1 grid place-items-center p-10">
<EmptyState title="No matches" description="Try a different combination of filters, or index a repository to seed source files." icon={FileCode2} />
</div>
) : (
<div className="overflow-auto flex-1 min-h-0">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-slate-50/95 backdrop-blur-sm border-b border-slate-200 text-[11px] uppercase tracking-wide text-slate-500">
<tr>
<th className="px-4 py-2.5 text-left w-16">Kind</th>
<th className="px-4 py-2.5 text-left">Artifact</th>
<th className="px-4 py-2.5 text-left w-28">Lang</th>
<th className="px-4 py-2.5 text-left w-28">Severity</th>
<th className="px-4 py-2.5 text-left w-28">Status</th>
<th className="px-4 py-2.5 text-left w-48">Project</th>
<th className="px-4 py-2.5 text-right w-28">Updated</th>
<th className="px-4 py-2.5 w-10" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rawRows.map((a) => (
<tr
key={a.id}
onClick={() => setDetailId(String(a.id))}
className="group cursor-pointer hover:bg-slate-50/80 transition-colors"
>
<td className="px-4 py-3 align-top">
{isSourceFile(a) ? (
<Badge tone="blue" className="flex items-center gap-1 w-fit">
<FileCode2 className="w-3 h-3" />
<span>file</span>
</Badge>
) : (
<Badge tone="amber" className="flex items-center gap-1 w-fit">
<Sparkles className="w-3 h-3" />
<span>smell</span>
</Badge>
)}
</td>
<td className="px-4 py-3 align-top min-w-0">
<div className="flex items-center gap-2">
<span className="asf-monospace text-[11px] text-slate-500">
{a.external_id ?? shortId(a.id)}
</span>
{isSmell(a) && getRule(a) && (
<Badge tone="neutral">{getRule(a)}</Badge>
)}
{isSourceFile(a) && getLOC(a) != null && (
<span className="asf-chip">{getLOC(a)} LOC</span>
)}
</div>
<div className="mt-0.5 font-medium text-slate-800 truncate max-w-[520px]">
{a.title}
</div>
{getPath(a) && (
<div className="mt-0.5 text-xs text-slate-500 truncate font-mono max-w-[520px]">
{getPath(a)}
</div>
)}
{isSmell(a) && getEngine(a) && (
<div className="mt-1 text-[11px] text-slate-400">
scanner: <span className="text-slate-600 font-medium">{getEngine(a)}</span>
</div>
)}
</td>
<td className="px-4 py-3 align-top">
<Badge tone="brand" className="uppercase">{getLanguage(a)}</Badge>
</td>
<td className="px-4 py-3 align-top">
{isSmell(a) && getSeverity(a) ? (
<SeverityBadge s={getSeverity(a)!} />
) : (
<span className="text-slate-300 text-xs">—</span>
)}
</td>
<td className="px-4 py-3 align-top">
{a.status ? <Badge tone={a.status === "open" ? "amber" : a.status === "fixed" ? "green" : a.status === "active" ? "blue" : "brand"}>{a.status}</Badge> : <span className="text-slate-300 text-xs">—</span>}
</td>
<td className="px-4 py-3 align-top text-slate-600 text-xs truncate max-w-[200px]">
{projectNameById.get(String(a.project_id)) ?? shortId(a.project_id)}
</td>
<td className="px-4 py-3 align-top text-right text-xs text-slate-500 whitespace-nowrap">
{fmtDate(a.updated_at)}
</td>
<td className="px-4 py-3 align-top text-right">
<ChevronRight className="w-4 h-4 text-slate-300 group-hover:text-brand-500 inline-block" />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardBody>
</Card>
</div>
</div>
);
}
function SeverityBadge({ s }: { s: "low" | "medium" | "high" | "critical" }) {
const m = SEVERITY_META[s];
const Icon = m.icon;
return (
<Badge tone={m.tone} className="inline-flex items-center gap-1">
<Icon className="w-3 h-3" />
{m.label}
</Badge>
);
}
function StatCard({
label, value, tone, icon: Icon,
}: {
label: string; value: number; tone: "brand" | "amber" | "red" | "neutral" | "green" | "blue";
icon: React.ComponentType<{ className?: string }>;
}) {
const toneBg: Record<string, string> = {
brand: "from-brand-500 to-brand-600",
amber: "from-amber-400 to-amber-500",
red: "from-rose-500 to-red-600",
neutral: "from-slate-400 to-slate-500",
green: "from-emerald-400 to-emerald-600",
blue: "from-sky-400 to-blue-600",
};
return (
<Card className="!shadow-none overflow-hidden">
<CardBody className="p-4 flex items-center gap-3">
<div className={cn("w-11 h-11 rounded-xl bg-gradient-to-br shadow-sm grid place-items-center text-white shrink-0", toneBg[tone])}>
<Icon className="w-5 h-5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs uppercase tracking-wide text-slate-500 font-medium">{label}</div>
<div className="text-2xl font-semibold text-slate-900 leading-tight mt-0.5">{value}</div>
</div>
</CardBody>
</Card>
);
}
function CodeDetailView({
artifact, projectName, onBack, onMarkFixed, markFixedPending,
}: {
artifact: Artifact;
projectName: string;
onBack: () => void;
onMarkFixed?: (status: string) => void;
markFixedPending?: boolean;
}) {
const lang = getLanguage(artifact);
const sev = getSeverity(artifact);
const path = getPath(artifact);
const rule = getRule(artifact);
const engine = getEngine(artifact);
const repo = getRepo(artifact);
const branch = getBranch(artifact);
const lines = getLines(artifact);
const confidence = getConfidence(artifact);
const smell = isSmell(artifact);
const source = isSourceFile(artifact);
const linesToHighlight = useMemo(() => {
const set = new Set<number>();
if (lines) {
for (const n of lines) set.add(n);
}
return set;
}, [lines]);
const contentLines = useMemo(() => {
const raw = artifact.content ?? "";
return raw.split("\n");
}, [artifact.content]);
return (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4">
<Button variant="ghost" size="sm" className="mb-3 -ml-2" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-1" /> Back to inventory
</Button>
<div className="flex items-start gap-3 flex-wrap">
<div className={cn(
"w-11 h-11 rounded-xl bg-gradient-to-br shadow-soft grid place-items-center text-white shrink-0",
smell
? "from-amber-400 to-rose-500"
: "from-brand-500 to-violet-600",
)}>
{smell ? <AlertTriangle className="w-5 h-5" /> : <FileCode2 className="w-5 h-5" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<ArtifactTypeBadge type={artifact.type as ArtifactType} />
{smell ? (
<Badge tone="amber">code_smell</Badge>
) : (
<Badge tone="blue">source_file</Badge>
)}
{sev && <SeverityBadge s={sev} />}
{rule && <Badge tone="neutral">rule {rule}</Badge>}
{lang && <Badge tone="brand" className="uppercase">{lang}</Badge>}
{artifact.status && (
<Badge tone={artifact.status === "open" ? "amber" : artifact.status === "fixed" ? "green" : artifact.status === "active" ? "blue" : "brand"}>
{artifact.status}
</Badge>
)}
<span className="asf-monospace text-xs text-slate-500">
{artifact.external_id ?? shortId(artifact.id)}
</span>
</div>
<h1 className="mt-2 text-xl font-semibold text-slate-900 break-words">{artifact.title}</h1>
{path && (
<div className="mt-1 text-xs text-slate-500 font-mono break-all">{path}</div>
)}
<div className="mt-1 text-xs text-slate-500 flex flex-wrap items-center gap-x-3 gap-y-1">
<span>Project: <span className="text-slate-700 font-medium">{projectName}</span></span>
{engine && <span>Scanner: <span className="text-slate-700 font-medium">{engine}</span></span>}
{repo && (
<span className="inline-flex items-center gap-1">
<GitBranch className="w-3 h-3 text-slate-400" />
<span className="text-slate-700 font-medium">{truncate(repo, 40)}{branch ? ` @ ${branch}` : ""}</span>
</span>
)}
{confidence != null && <span>Confidence: <span className="text-slate-700 font-medium">{confidence}%</span></span>}
<span>Updated: <span className="text-slate-700 font-medium">{fmtDate(artifact.updated_at)}</span></span>
{lines && <span>Affected lines: <span className="text-slate-700 font-medium">{lines.join(", ")}</span></span>}
{source && getLOC(artifact) != null && <span>Lines of code: <span className="text-slate-700 font-medium">{getLOC(artifact)!}</span></span>}
</div>
</div>
{smell && onMarkFixed && (
<div className="flex items-center gap-2 shrink-0 ml-auto">
{artifact.status === "fixed" ? (
<Badge tone="green" className="inline-flex items-center gap-1 !px-3 !py-1.5">
<CheckCircle2 className="w-3.5 h-3.5" /> Resolved
</Badge>
) : (
<Button
size="sm"
variant="primary"
disabled={markFixedPending}
onClick={() => onMarkFixed("fixed")}
className="!bg-emerald-500 hover:!bg-emerald-600"
>
<CheckCircle2 className="w-3.5 h-3.5 mr-1.5" />
{markFixedPending ? "Saving..." : "Mark as fixed"}
</Button>
)}
</div>
)}
</div>
</header>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 lg:grid-cols-12 gap-4">
<Card className="lg:col-span-7 flex flex-col min-h-0">
<CardHeader>
<CardTitle className="!text-sm flex items-center gap-2">
<FileText className="w-4 h-4 text-slate-400" />
{smell ? "Finding details" : "Source preview"}
</CardTitle>
</CardHeader>
<CardBody className="p-0 flex flex-col min-h-0 overflow-hidden">
{contentLines.length === 1 && contentLines[0] === "" ? (
<div className="p-10">
<EmptyState title="No content body available" description="This artifact is metadata-only. The blob content may still be fetchable from the Git integration." icon={FileCode2} />
</div>
) : (
<pre className="flex-1 min-h-0 overflow-auto text-[12.5px] leading-6 bg-slate-900 text-slate-100 p-4 font-mono rounded-b-xl">
{contentLines.map((ln, i) => {
const n = i + 1;
const hi = linesToHighlight.has(n);
return (
<div
key={n}
className={cn(
"flex -mx-4 px-4",
hi && "bg-rose-500/15 border-l-2 border-rose-400",
)}
>
<span className={cn(
"w-10 shrink-0 text-right pr-3 select-none",
hi ? "text-rose-200" : "text-slate-600",
)}>{n}</span>
<span className="flex-1 min-w-0 break-all whitespace-pre-wrap">{ln || " "}</span>
</div>
);
})}
</pre>
)}
</CardBody>
</Card>
<Card className="lg:col-span-5 flex flex-col min-h-0">
<CardHeader>
<CardTitle className="!text-sm flex items-center gap-2">
{smell ? <AlertTriangle className="w-4 h-4 text-amber-500" /> : <CheckCircle2 className="w-4 h-4 text-emerald-500" />}
{smell ? "Remediation guidance" : "File index info"}
</CardTitle>
</CardHeader>
<CardBody className="space-y-4 text-sm">
{smell ? (
<>
<SuggestionBlock
title="Root cause"
body={
`Rule ${rule ?? "unknown"} reported by ${engine ?? "scanner"}. ` +
(lines?.length ? `Focus attention on ${lines.length} affected line${lines.length > 1 ? "s" : ""}.` : "Inspect the highlighted rows in the source pane.")
}
tone="amber"
/>
<SuggestionBlock
title="Suggested fix"
body={
sev === "critical" || sev === "high"
? "Open a TASK linked to this finding, assign an owner, and block the PR/deployment pipeline until merged. Prefer writing a regression test that proves the issue."
: "Fix in the next planned sprint; add a suppression comment only if you've filed a follow-up TASK with justification."
}
tone={sev === "critical" || sev === "high" ? "red" : "brand"}
/>
<div className="grid grid-cols-2 gap-2 text-xs">
<InfoCell label="Version" value={artifact.version != null ? `v${artifact.version}` : "—"} />
<InfoCell label="Source" value={artifact.source ?? "—"} />
<InfoCell label="Created" value={fmtDate(artifact.created_at)} />
<InfoCell label="Updated" value={fmtDate(artifact.updated_at)} />
</div>
</>
) : (
<>
<SuggestionBlock
title="Traceability"
body="This file is linked to the ASF artifact graph. Open Context Explorer to inspect which PRs, commits, tasks, and deployments reference it."
tone="brand"
/>
<div className="grid grid-cols-2 gap-2 text-xs">
<InfoCell label="LOC" value={getLOC(artifact) != null ? String(getLOC(artifact)!) : "—"} />
<InfoCell label="Language" value={lang ? lang.toUpperCase() : "—"} />
<InfoCell label="Created" value={fmtDate(artifact.created_at)} />
<InfoCell label="Updated" value={fmtDate(artifact.updated_at)} />
</div>
</>
)}
</CardBody>
</Card>
</div>
</div>
);
}
function SuggestionBlock({
title, body, tone,
}: { title: string; body: string; tone: "brand" | "amber" | "red" | "green" | "blue" }) {
const toneCls: Record<string, string> = {
brand: "bg-brand-50 border-brand-100 text-brand-900",
amber: "bg-amber-50 border-amber-100 text-amber-900",
red: "bg-rose-50 border-rose-100 text-rose-900",
green: "bg-emerald-50 border-emerald-100 text-emerald-900",
blue: "bg-sky-50 border-sky-100 text-sky-900",
};
return (
<div className={cn("p-3 rounded-lg border", toneCls[tone])}>
<div className="text-xs font-semibold uppercase tracking-wide mb-1 opacity-80">{title}</div>
<p className="text-sm leading-snug">{body}</p>
</div>
);
}
function InfoCell({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-slate-100 bg-slate-50/60 px-2.5 py-1.5">
<div className="uppercase tracking-wide text-[9px] text-slate-400">{label}</div>
<div className="asf-monospace font-semibold text-slate-700 truncate">{value}</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
"use client";
import React from "react";
import { Rocket, Cloud, CheckCircle2, AlertTriangle, Clock } from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { MetricCard } from "@/components/ui/MetricCard";
import { fmtDate } from "@/lib/utils";
const DEPLOYS = [
{ id: "DEP-44", env: "prod-west-1", service: "asf-core", version: "v0.4.0", status: "done", at: "2025-11-14T14:12:00Z", by: "ci-bot" },
{ id: "DEP-43", env: "staging", service: "asf-core", version: "v0.4.0-rc3", status: "done", at: "2025-11-13T17:00:00Z", by: "ci-bot" },
{ id: "DEP-42", env: "prod-west-1", service: "asf-web", version: "v0.3.2", status: "done", at: "2025-11-11T09:30:00Z", by: "m.rossi" },
{ id: "DEP-41", env: "prod-west-1", service: "asf-worker", version: "v0.3.1", status: "failed", at: "2025-11-09T20:45:00Z", by: "ci-bot" },
{ id: "DEP-40", env: "staging", service: "asf-ai", version: "v0.3.0", status: "running", at: new Date().toISOString(), by: "ci-bot" },
];
export default function DeploymentsPage() {
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<Rocket className="w-6 h-6 text-emerald-600" />
Deployments
</h1>
<p className="mt-1 text-sm text-slate-500">
Release history per environment, linked back to Build, Pull Request and Requirement artifacts.
</p>
</div>
<Badge tone="neutral">Argo CD · readiness probe</Badge>
</header>
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<MetricCard label="Deployed (7d)" value={14} tone="green" icon={<Rocket className="w-5 h-5" />} delta={2.0} />
<MetricCard label="Success rate" value={92.6} isPercentage tone="brand" icon={<CheckCircle2 className="w-5 h-5" />} />
<MetricCard label="In progress" value={1} tone="blue" icon={<Clock className="w-5 h-5" />} />
<MetricCard label="Failed (7d)" value={1} tone="red" icon={<AlertTriangle className="w-5 h-5" />} />
</section>
<Card>
<CardHeader>
<CardTitle>Recent deployments</CardTitle>
<div className="flex items-center gap-2 text-xs">
<Badge tone="green"><Cloud className="w-3 h-3" /> prod-west-1</Badge>
<Badge tone="blue"><Cloud className="w-3 h-3" /> staging</Badge>
</div>
</CardHeader>
<CardBody className="p-0">
{DEPLOYS.length === 0 ? (
<div className="p-6"><EmptyState title="No deployments" description="Connect a CI/CD integration or create a Deployment artifact." /></div>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-2">ID</th>
<th className="text-left px-4 py-2">Env</th>
<th className="text-left px-4 py-2">Service</th>
<th className="text-left px-4 py-2">Version</th>
<th className="text-left px-4 py-2">Status</th>
<th className="text-left px-4 py-2">At</th>
<th className="text-left px-4 py-2">By</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{DEPLOYS.map((d) => (
<tr key={d.id} className="hover:bg-slate-50">
<td className="px-4 py-2.5 asf-monospace text-xs text-slate-500">{d.id}</td>
<td className="px-4 py-2.5">
<Badge tone={d.env.startsWith("prod") ? "green" : "blue"}>{d.env}</Badge>
</td>
<td className="px-4 py-2.5"><ArtifactTypeBadge type="deployment" /> <span className="ml-2 font-medium text-slate-800">{d.service}</span></td>
<td className="px-4 py-2.5 asf-monospace text-xs">{d.version}</td>
<td className="px-4 py-2.5">
<Badge tone={d.status === "done" ? "green" : d.status === "running" ? "blue" : "red"}>{d.status}</Badge>
</td>
<td className="px-4 py-2.5 text-xs text-slate-500 whitespace-nowrap">{fmtDate(d.at)}</td>
<td className="px-4 py-2.5 text-xs text-slate-600">{d.by}</td>
</tr>
))}
</tbody>
</table>
)}
</CardBody>
</Card>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light dark;
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
html, body, #__next {
height: 100%;
}
body {
font-family: var(--font-sans);
background:
radial-gradient(1200px 600px at 0% -10%, rgba(11, 140, 255, 0.08), transparent 60%),
radial-gradient(800px 500px at 100% 0%, rgba(124, 92, 255, 0.08), transparent 55%),
#f8fafc;
color: #0f172a;
-webkit-font-smoothing: antialiased;
}
@layer components {
.asf-card {
@apply bg-white/90 backdrop-blur border border-slate-200/80 rounded-xl shadow-card;
}
.asf-card-header {
@apply flex items-center justify-between px-5 py-4 border-b border-slate-100;
}
.asf-card-title {
@apply font-semibold text-slate-800 text-sm tracking-tight;
}
.asf-card-body {
@apply p-5;
}
.asf-btn {
@apply inline-flex items-center justify-center gap-2 px-3.5 py-2 rounded-lg text-sm font-medium
transition-colors border border-transparent;
}
.asf-btn-primary {
@apply asf-btn bg-brand-600 text-white hover:bg-brand-700 focus:ring-2 focus:ring-brand-300;
}
.asf-btn-secondary {
@apply asf-btn bg-white text-slate-700 border-slate-200 hover:bg-slate-50;
}
.asf-btn-ghost {
@apply asf-btn text-slate-600 hover:bg-slate-100;
}
.asf-input {
@apply w-full px-3 py-2 rounded-lg text-sm bg-white border border-slate-200
focus:border-brand-400 focus:ring-2 focus:ring-brand-200 outline-none;
}
.asf-nav-item {
@apply flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium
text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition-colors;
}
.asf-nav-item-active {
@apply bg-brand-50 text-brand-700 hover:bg-brand-50 hover:text-brand-800
ring-1 ring-inset ring-brand-100;
}
.asf-chip {
@apply inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5
rounded-full border border-slate-200 bg-slate-50 text-slate-600;
}
.asf-monospace {
font-family: "JetBrains Mono", Menlo, ui-monospace, monospace;
}
}
/* React Flow theme tweaks */
.react-flow__attribution {
display: none;
}
.react-flow__node {
font-size: 12px;
padding: 0;
}
.react-flow__node-default,
.react-flow__node-input,
.react-flow__node-output {
border: 1px solid #e2e8f0 !important;
box-shadow: 0 1px 2px rgba(15,23,42,0.04), 0 4px 20px -8px rgba(15,23,42,0.10);
}
/* Scrollbar */
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 999px; }
*::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
+112
View File
@@ -0,0 +1,112 @@
"use client";
import React from "react";
import { Plug, Github, GitBranch, CircleDot, BookMarked, Activity, CheckCircle2, AlertTriangle, Plus } from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
const CATALOG = [
{ key: "github", name: "GitHub", icon: Github, kind: "git", status: "available", desc: "Import PRs, commits, branches and release events." },
{ key: "gitlab", name: "GitLab", icon: GitBranch, kind: "git", status: "planned", desc: "Planned connector — same primitives as GitHub." },
{ key: "jira", name: "Jira / Linear", icon: CircleDot, kind: "requirements", status: "planned", desc: "Sync epics, stories and tasks as ASF Artifacts." },
{ key: "jenkins", name: "Jenkins CI", icon: GitBranch, kind: "ci", status: "planned", desc: "Map build results to Build & Test artifacts." },
{ key: "notion", name: "Notion / Confluence", icon: BookMarked, kind: "docs", status: "planned", desc: "ADRs and docs synchronized as Document artifacts." },
{ key: "datadog", name: "Datadog / Prometheus",icon: Activity, kind: "observability", status: "planned", desc: "Incidents and runtime events → incident artifacts." },
];
const CONNECTED = [
{ key: "github", name: "GitHub (azurian-org)", tenant: "default", status: "ok", since: "2025-11-01T00:00:00Z" },
];
export default function IntegrationsPage() {
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<Plug className="w-6 h-6 text-brand-600" />
Integrations
</h1>
<p className="mt-1 text-sm text-slate-500">
ASF connectors — plug in Git providers, requirements tools, CI/CD, docs and observability platforms. Core stays decoupled.
</p>
</div>
<Button variant="primary" size="sm"><Plus className="w-4 h-4" /> Add integration</Button>
</header>
<section className="mb-6">
<Card>
<CardHeader>
<CardTitle>Connected</CardTitle>
<Badge tone="green">{CONNECTED.length} active</Badge>
</CardHeader>
<CardBody className="p-0">
{CONNECTED.length === 0 ? (
<div className="p-6">
<EmptyState title="Nothing connected yet" description="Choose a provider from the catalog to start ingesting artifacts." icon={Plug} />
</div>
) : (
<ul className="divide-y divide-slate-100">
{CONNECTED.map((c) => (
<li key={c.key} className="p-4 flex items-center gap-4 hover:bg-slate-50">
<div className="w-10 h-10 rounded-lg bg-slate-50 border border-slate-200 grid place-items-center shrink-0">
<Github className="w-5 h-5 text-slate-700" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-slate-800">{c.name}</div>
<div className="text-xs text-slate-500">tenant: <span className="asf-monospace">{c.tenant}</span></div>
</div>
<Badge tone={c.status === "ok" ? "green" : "amber"} className="inline-flex items-center gap-1">
{c.status === "ok" ? <CheckCircle2 className="w-3 h-3" /> : <AlertTriangle className="w-3 h-3" />}
{c.status}
</Badge>
</li>
))}
</ul>
)}
</CardBody>
</Card>
</section>
<Card>
<CardHeader>
<CardTitle>Catalog</CardTitle>
<Badge tone="brand">asf-connectors package</Badge>
</CardHeader>
<CardBody>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{CATALOG.map((c) => {
const Icon = c.icon;
return (
<div key={c.key} className="p-4 rounded-xl border border-slate-200 bg-white hover:border-brand-200 hover:shadow-card transition-all">
<div className="flex items-start justify-between gap-3">
<div className="w-10 h-10 rounded-lg bg-brand-50 border border-brand-100 grid place-items-center text-brand-600 shrink-0">
<Icon className="w-5 h-5" />
</div>
<Badge
tone={c.status === "available" ? "green" : c.status === "planned" ? "neutral" : "amber"}
>
{c.status}
</Badge>
</div>
<div className="mt-3">
<div className="font-semibold text-slate-800">{c.name}</div>
<div className="text-xs text-slate-500 mt-1 capitalize">{c.kind}</div>
<p className="text-xs text-slate-600 mt-2 leading-relaxed">{c.desc}</p>
</div>
<div className="mt-4 flex justify-end">
<Button variant="secondary" size="sm" disabled={c.status !== "available"}>
{c.status === "available" ? "Connect" : "Coming soon"}
</Button>
</div>
</div>
);
})}
</div>
</CardBody>
</Card>
</div>
);
}
+622
View File
@@ -0,0 +1,622 @@
"use client";
import React, { useMemo, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Workflow,
Search,
Clock,
CheckCircle2,
XCircle,
Loader2,
CirclePause,
Circle,
PlayCircle,
Activity,
ChevronRight,
Send,
Tag,
Hash,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { jobs, projects } from "@/lib/api";
import { cn, fmtDate, shortId } from "@/lib/utils";
import type { Job, JobCreate, JobStatus, JobType } from "@/lib/types";
import { JOB_STATUSES, JOB_TYPES } from "@/lib/types";
const DEMO_JOBS: Job[] = [
{
id: "j-demo-1",
tenant_id: "t1",
project_id: "p1",
type: "ai_interactive",
priority: "normal",
status: "completed",
payload: { prompt: "Summarize the spec.", temperature: 0.2 },
correlation_id: "cid-summ-001",
requested_by: "u1",
worker: "asf-worker-3",
model: "asf-mock-1",
result: { summary: "Mock AI summary for demo purposes." },
usage: { input_tokens: 112, output_tokens: 48, cpu_seconds: 0.02 },
progress_percent: 100,
created_at: "2025-11-15T09:00:00Z",
started_at: "2025-11-15T09:00:01Z",
completed_at: "2025-11-15T09:00:06Z",
},
{
id: "j-demo-2",
tenant_id: "t1",
project_id: "p1",
type: "testing",
priority: "high",
status: "running",
payload: { suite: "smoke", pattern: "**/test_*.py" },
requested_by: "u2",
worker: "asf-worker-1",
progress_percent: 42,
created_at: "2025-11-15T09:18:00Z",
started_at: "2025-11-15T09:18:10Z",
},
{
id: "j-demo-3",
tenant_id: "t1",
project_id: "p2",
type: "code_analysis",
priority: "normal",
status: "failed",
payload: { repository: "asf/core", branch: "main" },
requested_by: "u1",
worker: "asf-worker-2",
error: "Repository clone failed",
error_detail: { exit_code: 128, stderr: "fatal: repository not found" },
created_at: "2025-11-15T08:55:00Z",
started_at: "2025-11-15T08:55:15Z",
completed_at: "2025-11-15T08:56:30Z",
},
];
const STATUS_ICON: Record<JobStatus, React.ComponentType<{ className?: string }>> = {
queued: Clock,
running: PlayCircle,
completed: CheckCircle2,
failed: XCircle,
cancelled: CirclePause,
};
const STATUS_TONE: Record<JobStatus, "blue" | "amber" | "green" | "red" | "neutral" | "brand" | "violet"> = {
queued: "neutral",
running: "blue",
completed: "green",
failed: "red",
cancelled: "amber",
};
const TYPE_TONE: Record<string, "brand" | "violet" | "blue" | "green" | "amber" | "neutral" | "red"> = {
ai_interactive: "violet",
ai_background: "violet",
indexing: "blue",
code_analysis: "brand",
testing: "green",
devops: "amber",
notification: "neutral",
custom: "red",
};
function queueForJobType(t: JobType | string): string {
// 1-to-1 mapping with asf.jobs DIRECT exchange queues
switch (t) {
case "ai_interactive": return "asf.ai.interactive";
case "ai_background": return "asf.ai.background";
case "indexing": return "asf.indexing";
case "code_analysis": return "asf.code_analysis";
case "testing": return "asf.testing";
case "devops": return "asf.devops";
case "notification": return "asf.notifications";
default: return `asf.${t ?? "custom"}`;
}
}
export default function JobsPage() {
const queryClient = useQueryClient();
const [q, setQ] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [selectedId, setSelectedId] = useState<string | null>(null);
// Create modal state
const [showNew, setShowNew] = useState(false);
const [newType, setNewType] = useState<JobType>("testing");
const [newProjectId, setNewProjectId] = useState<string>("");
const [newPriority, setNewPriority] = useState<"low" | "normal" | "high" | "urgent">("normal");
const [newSequence, setNewSequence] = useState<string>("hello-asf");
const projectsQ = useQuery({
queryKey: ["jobs-projects"],
queryFn: () => projects.list({ limit: 100 }),
retry: false,
refetchOnWindowFocus: false,
});
const listQ = useQuery({
queryKey: ["jobs-list", { status: statusFilter, type: typeFilter, q: q.trim() }],
queryFn: () =>
jobs.list({
status: statusFilter !== "all" ? (statusFilter as JobStatus) : undefined,
type: typeFilter !== "all" ? (typeFilter as JobType) : undefined,
limit: 200,
}),
retry: false,
refetchOnWindowFocus: false,
refetchInterval: 3000, // auto-refresh each 3s so running → completed appears without manual reload
});
const submitM = useMutation({
mutationFn: async (body: JobCreate) => jobs.submit(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["jobs-list"] }).catch(() => undefined);
setShowNew(false);
},
});
const apiResolved = listQ.isFetched && listQ.data !== undefined;
const rows: Job[] = apiResolved ? listQ.data! : DEMO_JOBS;
const isMock = !apiResolved;
const projectNameById = useMemo(() => {
const m = new Map<string, string>();
for (const p of projectsQ.data ?? []) {
if (p?.id) m.set(String(p.id), p.name ?? shortId(String(p.id)));
}
return m;
}, [projectsQ.data]);
// Default first project for new job dropdown (if any)
React.useEffect(() => {
if (!newProjectId && projectsQ.data?.length) setNewProjectId(String(projectsQ.data[0].id));
}, [newProjectId, projectsQ.data]);
const filtered = useMemo(() => {
const ql = q.trim().toLowerCase();
return rows.filter((j) => {
if (statusFilter !== "all" && j.status !== statusFilter) return false;
if (typeFilter !== "all" && j.type !== typeFilter) return false;
if (ql) {
const hay = [
String(j.id),
String(j.correlation_id ?? ""),
String(j.worker ?? ""),
String(j.model ?? ""),
String(j.error ?? ""),
JSON.stringify(j.payload ?? {}),
JSON.stringify(j.result ?? {}),
]
.join(" ")
.toLowerCase();
if (!hay.includes(ql)) return false;
}
return true;
});
}, [rows, statusFilter, typeFilter, q]);
const selected =
(selectedId !== null ? filtered.find((j) => String(j.id) === String(selectedId)) : null) ??
filtered[0] ??
null;
const statuses = Array.from(new Set(rows.map((j) => j.status)));
return (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<Workflow className="w-6 h-6 text-brand-600" /> Jobs
</h1>
<p className="mt-1 text-sm text-slate-500">
Asynchronous ASF work: queued → running → completed / failed. Each job is routed to its dedicated RabbitMQ queue.
</p>
</div>
<div className="flex items-center gap-2">
{isMock && <Badge tone="amber">Demo data</Badge>}
<Button
variant="secondary"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ["jobs-list"] })}
>
<Activity className="w-4 h-4" /> Refresh
</Button>
<Button variant="primary" size="sm" onClick={() => setShowNew(true)}>
<Send className="w-4 h-4" /> Enqueue job
</Button>
</div>
</header>
{/* Stats strip */}
<section className="px-6 lg:px-8 pb-3 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2 text-sm">
{(["queued", "running", "completed", "failed", "cancelled"] as JobStatus[]).map((s) => {
const count = rows.filter((r) => r.status === s).length;
const Icon = STATUS_ICON[s];
return (
<button
key={s}
onClick={() => setStatusFilter(statusFilter === s ? "all" : s)}
className={cn(
"text-left p-2 rounded-lg border transition-colors",
statusFilter === s
? "border-brand-300 bg-brand-50"
: "border-slate-200 bg-white hover:bg-slate-50",
)}
>
<div className="flex items-center justify-between text-slate-500 text-xs">
<span className="inline-flex items-center gap-1"><Icon className="w-3.5 h-3.5" />{s}</span>
<Badge tone={STATUS_TONE[s]}><span className="tabular-nums">{count}</span></Badge>
</div>
</button>
);
})}
<div className="p-2 rounded-lg border border-slate-200 bg-white">
<div className="flex items-center justify-between text-slate-500 text-xs">
<span className="inline-flex items-center gap-1"><Hash className="w-3.5 h-3.5" />Total</span>
<Badge tone="brand"><span className="tabular-nums">{rows.length}</span></Badge>
</div>
</div>
</section>
<div className="px-6 lg:px-8 pb-4 flex flex-wrap gap-2 items-center">
<div className="relative flex-1 min-w-[220px]">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
className="asf-input pl-9"
placeholder="Search id, correlation_id, worker, payload json, error…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<select
className="asf-input !py-1.5 !text-xs !w-auto"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">All statuses</option>
{JOB_STATUSES.concat(statuses).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<select
className="asf-input !py-1.5 !text-xs !w-auto"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="all">All types</option>
{JOB_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
</div>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 xl:grid-cols-5 gap-4">
<Card className="xl:col-span-3 flex flex-col min-h-0">
<CardHeader>
<CardTitle>Job list</CardTitle>
<span className="text-xs text-slate-500">Auto-refresh each 3s · click a row →</span>
</CardHeader>
<CardBody className="p-0 flex-1 min-h-0 overflow-auto">
{filtered.length === 0 ? (
<div className="p-6">
<EmptyState
title="No jobs match"
description="Try adjusting filters, or click Enqueue job to create a new echo testing job."
/>
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-slate-50/90 backdrop-blur text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-2">ID</th>
<th className="text-left px-4 py-2">Type</th>
<th className="text-left px-4 py-2">Status</th>
<th className="text-left px-4 py-2">Progress</th>
<th className="text-left px-4 py-2">Queue</th>
<th className="text-left px-4 py-2">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((j) => {
const active = selected && String(selected.id) === String(j.id);
const StatusIcon = STATUS_ICON[j.status] ?? Circle;
return (
<tr
key={String(j.id)}
onClick={() => setSelectedId(String(j.id))}
className={cn(
"cursor-pointer hover:bg-brand-50/40 transition-colors",
active && "bg-brand-50/70",
)}
>
<td className="px-4 py-2.5 asf-monospace text-xs text-slate-500 align-top">
{shortId(String(j.id))}
</td>
<td className="px-4 py-2.5 align-top">
<Badge tone={TYPE_TONE[j.type] ?? "neutral"}>{j.type}</Badge>
</td>
<td className="px-4 py-2.5 align-top">
<span className="inline-flex items-center gap-1.5">
<StatusIcon className={cn(
"w-4 h-4",
j.status === "running" && "animate-spin text-brand-600",
j.status === "completed" && "text-emerald-600",
j.status === "failed" && "text-rose-600",
j.status === "queued" && "text-slate-500",
)} />
<Badge tone={STATUS_TONE[j.status]}>{j.status}</Badge>
</span>
</td>
<td className="px-4 py-2.5 align-top">
<div className="flex items-center gap-2 min-w-[100px]">
<div className="h-1.5 flex-1 rounded-full bg-slate-100 overflow-hidden">
<div
className={cn(
"h-full transition-all",
j.status === "failed" ? "bg-rose-500" : j.status === "completed" ? "bg-emerald-500" : "bg-brand-500",
)}
style={{ width: `${j.progress_percent ?? (j.status === "completed" ? 100 : j.status === "running" ? 35 : 0)}%` }}
/>
</div>
<span className="text-xs tabular-nums text-slate-500 w-9 text-right">
{j.progress_percent ?? (j.status === "completed" ? 100 : j.status === "queued" ? 0 : "—")}%
</span>
</div>
</td>
<td className="px-4 py-2.5 align-top text-xs text-slate-500 asf-monospace whitespace-nowrap">
{queueForJobType(j.type)}
</td>
<td className="px-4 py-2.5 align-top text-xs text-slate-500 whitespace-nowrap">
{fmtDate(j.completed_at ?? j.started_at ?? j.created_at)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</CardBody>
</Card>
<Card className="xl:col-span-2 flex flex-col min-h-0">
<CardHeader>
<CardTitle>Detail</CardTitle>
<Link href="/ai-agents" className="text-xs text-brand-600 hover:underline inline-flex items-center gap-1">
AI Agents <ChevronRight className="w-3 h-3" />
</Link>
</CardHeader>
<CardBody className="overflow-auto flex-1 min-h-0">
{selected ? (
<div className="space-y-4 text-sm">
<div>
<div className="flex items-center gap-2 flex-wrap mb-1.5">
<span className="asf-monospace text-xs text-slate-500">{shortId(String(selected.id))}</span>
<Badge tone={TYPE_TONE[selected.type] ?? "neutral"}>{selected.type}</Badge>
<Badge tone={STATUS_TONE[selected.status]}>{selected.status}</Badge>
{selected.priority && <Badge tone="neutral">{selected.priority}</Badge>}
</div>
<h2 className="text-lg font-semibold text-slate-900 leading-tight">
{selected.type} job · {queueForJobType(selected.type)}
</h2>
</div>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<dt className="text-xs text-slate-500">Project</dt>
<dd className="font-medium text-slate-800">
{selected.project_id
? (projectNameById.get(String(selected.project_id)) ?? shortId(String(selected.project_id)))
: "—"}
</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Queue</dt>
<dd className="asf-monospace text-slate-700">{queueForJobType(selected.type)}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Worker</dt>
<dd className="text-slate-700">{selected.worker ?? "—"}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Model</dt>
<dd className="text-slate-700">{selected.model ?? "—"}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Requested by</dt>
<dd className="asf-monospace text-slate-700">
{selected.requested_by ? shortId(String(selected.requested_by)) : "—"}
</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Correlation</dt>
<dd className="asf-monospace text-slate-700">{selected.correlation_id ?? "—"}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Created</dt>
<dd className="text-slate-700">{fmtDate(selected.created_at)}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Completed</dt>
<dd className="text-slate-700">{selected.completed_at ? fmtDate(selected.completed_at) : "—"}</dd>
</div>
</dl>
{selected.payload !== undefined && (
<div>
<div className="text-xs text-slate-500 mb-1.5 flex items-center gap-1"><Tag className="w-3 h-3" /> payload</div>
<pre className="p-3 rounded-lg bg-slate-50 border border-slate-100 text-xs overflow-auto max-h-40">
{JSON.stringify(selected.payload, null, 2)}
</pre>
</div>
)}
{selected.result !== undefined && (
<div>
<div className="text-xs text-slate-500 mb-1.5 flex items-center gap-1"><CheckCircle2 className="w-3 h-3 text-emerald-600" /> result</div>
<pre className="p-3 rounded-lg bg-emerald-50 border border-emerald-100 text-xs overflow-auto max-h-48">
{JSON.stringify(selected.result, null, 2)}
</pre>
</div>
)}
{selected.error && (
<div>
<div className="text-xs text-slate-500 mb-1.5 flex items-center gap-1"><XCircle className="w-3 h-3 text-rose-600" /> error</div>
<div className="p-3 rounded-lg bg-rose-50 border border-rose-100 text-rose-700 text-xs overflow-auto max-h-40 space-y-2">
<div className="font-semibold">{selected.error}</div>
{selected.error_detail && (
<pre className="whitespace-pre-wrap break-all">{JSON.stringify(selected.error_detail, null, 2)}</pre>
)}
</div>
</div>
)}
{selected.usage && (
<div>
<div className="text-xs text-slate-500 mb-1.5">usage</div>
<div className="grid grid-cols-2 gap-2 text-xs">
{Object.entries(selected.usage).map(([k, v]) =>
v === undefined ? null : (
<div key={k} className="flex justify-between p-2 rounded bg-slate-50 border border-slate-100">
<span className="text-slate-500">{k}</span>
<span className="font-medium text-slate-800 tabular-nums">{JSON.stringify(v)}</span>
</div>
),
)}
</div>
</div>
)}
<div className="pt-3 border-t border-slate-100 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ["jobs-list"] })}
>
<Activity className="w-4 h-4" /> Refresh status
</Button>
<Button variant="primary" size="sm" onClick={() => setShowNew(true)}>
<Send className="w-4 h-4" /> Enqueue similar
</Button>
</div>
</div>
) : (
<EmptyState
title="Select a job"
description="Pick any row from the list to inspect payload, result, error and usage counters."
/>
)}
</CardBody>
</Card>
</div>
{/* New job modal */}
{showNew && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => setShowNew(false)}>
<div
className="w-full max-w-lg rounded-xl bg-white border border-slate-200 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<Send className="w-4 h-4 text-brand-600" /> Enqueue new job
</h3>
<button onClick={() => setShowNew(false)} className="text-slate-400 hover:text-slate-600 text-xl leading-none" aria-label="close">×</button>
</div>
<form
className="p-5 space-y-3"
onSubmit={(e) => {
e.preventDefault();
submitM.mutate({
project_id: newProjectId || undefined,
type: newType,
priority: newPriority,
payload: { sequence: newSequence, message: "hello-asf", ts: Date.now() },
});
}}
>
<div>
<label className="text-xs text-slate-500">Type (direct queue)</label>
<select
className="asf-input mt-1"
value={newType}
onChange={(e) => setNewType(e.target.value as JobType)}
>
{JOB_TYPES.map((t) => (
<option key={t} value={t}>{t} · {queueForJobType(t)}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Project</label>
<select
className="asf-input mt-1"
value={newProjectId}
onChange={(e) => setNewProjectId(e.target.value)}
>
<option value="">(no project)</option>
{(projectsQ.data ?? []).map((p) => (
<option key={String(p.id)} value={String(p.id)}>{p.name ?? shortId(String(p.id))}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Priority</label>
<select
className="asf-input mt-1"
value={newPriority}
onChange={(e) => setNewPriority(e.target.value as typeof newPriority)}
>
{(["low", "normal", "high", "urgent"] as const).map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Echo sequence (payload.sequence)</label>
<input
className="asf-input mt-1"
value={newSequence}
onChange={(e) => setNewSequence(e.target.value)}
placeholder="job-e2e-1"
/>
</div>
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => setShowNew(false)}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
disabled={submitM.isPending || !newType}
>
{submitM.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" /> Enqueuing…
</>
) : (
<>
<Send className="w-4 h-4" /> Enqueue
</>
)}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import "./globals.css";
import { Sidebar } from "@/components/shell/Sidebar";
import { Topbar } from "@/components/shell/Topbar";
import { AskAsfCommandBar } from "@/components/shell/AskAsfCommandBar";
import { AppProviders } from "@/components/shell/AppProviders";
export const metadata: Metadata = {
title: "ASF · Azurian Software Factory",
description:
"From Requirement to Production, everything is contextualized, traceable and AI-accessible.",
icons: { icon: "/favicon.svg" },
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="h-full">
<body className="h-full">
<AppProviders>
<div className="min-h-screen flex">
<Sidebar />
<div className="min-w-0 flex-1 flex flex-col">
<Topbar />
<main className="flex-1 min-w-0 overflow-x-hidden animate-fade-in">
{children}
</main>
</div>
</div>
<AskAsfCommandBar />
</AppProviders>
</body>
</html>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import React from "react";
import { Activity, Server, AlertOctagon, Clock } from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { MetricCard } from "@/components/ui/MetricCard";
import { fmtDate } from "@/lib/utils";
const SERVICES = [
{ name: "asf-core", status: "healthy", latency_ms: 42, error_rate: 0.1, rps: 184 },
{ name: "asf-worker", status: "healthy", latency_ms: 0, error_rate: 0.0, rps: 0, jobs: 6 },
{ name: "asf-context", status: "degraded", latency_ms: 310, error_rate: 2.3, rps: 42 },
{ name: "asf-ai", status: "healthy", latency_ms: 620, error_rate: 0.5, rps: 12 },
{ name: "mongodb", status: "healthy", latency_ms: 3, error_rate: 0.0, rps: 0 },
{ name: "rabbitmq", status: "healthy", latency_ms: 2, error_rate: 0.0, queue_depth: 14 },
];
const INCIDENTS = [
{ id: "INC-233", sev: "medium", title: "asf-context p99 latency spike", started: "2025-11-22T08:10:00Z", state: "open" },
{ id: "INC-232", sev: "low", title: "MinIO console certificate warn", started: "2025-11-21T14:40:00Z", state: "resolved" },
];
export default function ObservabilityPage() {
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<Activity className="w-6 h-6 text-brand-600" />
Observability
</h1>
<p className="mt-1 text-sm text-slate-500">
Service health, latency, error rates and active incidents. Structured JSON logs + correlation_id propagate across every ASF hop.
</p>
</div>
<Badge tone="neutral">OpenTelemetry · prepared</Badge>
</header>
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<MetricCard label="Services healthy" value={5} tone="green" icon={<Server className="w-5 h-5" />} subtitle="of 6 total" />
<MetricCard label="Requests / sec" value={238} tone="brand" icon={<Activity className="w-5 h-5" />} />
<MetricCard label="Error rate" value={0.6} isPercentage tone="amber" icon={<AlertOctagon className="w-5 h-5" />} />
<MetricCard label="Open incidents" value={1} tone="red" icon={<Clock className="w-5 h-5" />} />
</section>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Service inventory</CardTitle>
<Badge tone="brand">p50 latency</Badge>
</CardHeader>
<CardBody className="p-0">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-2">Service</th>
<th className="text-left px-4 py-2">Status</th>
<th className="text-right px-4 py-2">Latency</th>
<th className="text-right px-4 py-2">Error %</th>
<th className="text-right px-4 py-2">Load</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{SERVICES.map((s) => (
<tr key={s.name} className="hover:bg-slate-50">
<td className="px-4 py-2.5 font-mono text-xs">{s.name}</td>
<td className="px-4 py-2.5">
<Badge tone={s.status === "healthy" ? "green" : s.status === "degraded" ? "amber" : "red"}>{s.status}</Badge>
</td>
<td className="px-4 py-2.5 text-right tabular-nums">{s.latency_ms} ms</td>
<td className="px-4 py-2.5 text-right tabular-nums">{s.error_rate.toFixed(2)}%</td>
<td className="px-4 py-2.5 text-right text-xs text-slate-600 tabular-nums">
{s.rps ? `${s.rps} rps` : s.queue_depth !== undefined ? `q=${s.queue_depth}` : `${s.jobs} jobs`}
</td>
</tr>
))}
</tbody>
</table>
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle>Incidents</CardTitle>
<Badge tone="amber">correlation_id · audit</Badge>
</CardHeader>
<CardBody>
{INCIDENTS.length === 0 ? (
<EmptyState title="All quiet" description="No incidents in the last 24 hours." icon={Activity} />
) : (
<ul className="space-y-2 text-sm">
{INCIDENTS.map((i) => (
<li key={i.id} className="p-3 rounded-lg border border-slate-100 hover:border-slate-200">
<div className="flex items-center gap-2">
<Badge tone={i.sev === "high" || i.sev === "critical" ? "red" : i.sev === "medium" ? "amber" : "neutral"} className="uppercase">{i.sev}</Badge>
<span className="font-mono text-xs text-slate-500">{i.id}</span>
<Badge tone={i.state === "open" ? "amber" : "green"}>{i.state}</Badge>
</div>
<div className="mt-1.5 font-medium text-slate-800">{i.title}</div>
<div className="mt-1 text-xs text-slate-500">Started {fmtDate(i.started)}</div>
</li>
))}
</ul>
)}
</CardBody>
</Card>
</div>
</div>
);
}
+287
View File
@@ -0,0 +1,287 @@
"use client";
import React from "react";
import { useQuery } from "@tanstack/react-query";
import {
ClipboardList,
FolderKanban,
TestTube2,
Rocket,
GitCommit,
AlertTriangle,
Bot,
ShieldAlert,
RefreshCw,
PlayCircle,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle, CardFooter } from "@/components/ui/Card";
import { MetricCard } from "@/components/ui/MetricCard";
import { ProgressRing } from "@/components/ui/ProgressRing";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { overview, health } from "@/lib/api";
import type { OverviewMetrics } from "@/lib/types";
const DEMO_METRICS: OverviewMetrics = {
tenant_id: "demo-tenant",
total_projects: 6,
total_requirements: 142,
total_tasks: 318,
total_tests: 512,
total_deployments: 27,
total_commits: 1204,
jobs_running: 3,
jobs_queued: 8,
jobs_failed_24h: 2,
implementation_coverage_pct: 74.3,
test_coverage_pct: 68.9,
traceability_score_pct: 81.5,
project_health_pct: 77.2,
security_findings: 14,
failed_builds: 3,
active_ai_jobs: 5,
featured_projects: [
{ key: "ASF", name: "Azurian Software Factory" },
{ key: "NOVA", name: "Nova Customer Portal" },
{ key: "ORB", name: "Orbital Billing" },
],
mock_fields: ["*"],
};
export default function OverviewPage() {
const metricsQ = useQuery({
queryKey: ["overview"],
queryFn: () => overview.get(),
retry: false,
refetchOnWindowFocus: false,
});
const healthQ = useQuery({
queryKey: ["health"],
queryFn: () => health(),
retry: false,
refetchOnWindowFocus: false,
});
const m: OverviewMetrics = metricsQ.data ?? DEMO_METRICS;
const isMock = !metricsQ.data;
return (
<div className="p-6 lg:p-8 space-y-6 max-w-[1600px] mx-auto">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">
Overview
</h1>
{isMock && (
<Badge tone="amber" className="shrink-0">
Demo data
</Badge>
)}
{healthQ.data && (
<Badge tone={healthQ.data.status === "ok" ? "green" : "red"}>
{healthQ.data.service} v{healthQ.data.version} · {healthQ.data.status}
</Badge>
)}
</div>
<p className="mt-1 text-sm text-slate-500">
From Requirement to Production — everything contextualized, traceable and AI-accessible.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
void metricsQ.refetch();
void healthQ.refetch();
}}
>
<RefreshCw className="w-4 h-4" />
Refresh
</Button>
<Button variant="primary" size="sm">
<PlayCircle className="w-4 h-4" />
New AI Job
</Button>
</div>
</header>
<section className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
<MetricCard
label="Projects"
value={m.total_projects}
tone="brand"
icon={<FolderKanban className="w-5 h-5" />}
/>
<MetricCard
label="Requirements"
value={m.total_requirements}
tone="violet"
delta={3.4}
icon={<ClipboardList className="w-5 h-5" />}
/>
<MetricCard
label="Tasks"
value={m.total_tasks}
tone="blue"
icon={<ClipboardList className="w-5 h-5" />}
/>
<MetricCard
label="Tests"
value={m.total_tests}
tone="green"
delta={1.1}
icon={<TestTube2 className="w-5 h-5" />}
/>
<MetricCard
label="Deployments"
value={m.total_deployments}
tone="brand"
icon={<Rocket className="w-5 h-5" />}
/>
<MetricCard
label="Commits"
value={m.total_commits}
tone="neutral"
icon={<GitCommit className="w-5 h-5" />}
/>
</section>
<section className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card>
<CardHeader>
<CardTitle>Health & Coverage</CardTitle>
<Badge tone="neutral">Across all projects</Badge>
</CardHeader>
<CardBody className="space-y-4">
<ProgressRing
value={m.project_health_pct}
tone="green"
label="Project Health"
sublabel="Weighted stability score"
/>
<ProgressRing
value={m.implementation_coverage_pct}
tone="brand"
label="Implementation Coverage"
sublabel="Requirements → Tasks → Code"
/>
<ProgressRing
value={m.test_coverage_pct}
tone="amber"
label="Test Coverage"
sublabel="Automated vs total tests"
/>
<ProgressRing
value={m.traceability_score_pct}
tone="violet"
label="Traceability Score"
sublabel="Graph connectivity"
/>
</CardBody>
</Card>
<Card>
<CardHeader>
<CardTitle>Jobs & AI</CardTitle>
<Badge tone="brand">
<Bot className="w-3 h-3" /> AI
</Badge>
</CardHeader>
<CardBody className="space-y-3">
<MetricCard
label="Jobs running"
value={m.jobs_running}
tone="blue"
icon={<PlayCircle className="w-4 h-4" />}
/>
<MetricCard
label="Jobs queued"
value={m.jobs_queued}
tone="neutral"
icon={<RefreshCw className="w-4 h-4" />}
/>
<MetricCard
label="Jobs failed (24h)"
value={m.jobs_failed_24h}
tone="red"
icon={<AlertTriangle className="w-4 h-4" />}
/>
<MetricCard
label="Active AI jobs"
value={m.active_ai_jobs}
tone="violet"
icon={<Bot className="w-4 h-4" />}
/>
</CardBody>
<CardFooter>
<span className="text-slate-500">Priority queue: high → low</span>
<a className="text-brand-600 font-medium hover:underline cursor-pointer" href="/pipelines">
View pipelines →
</a>
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>Alerts</CardTitle>
<Badge tone="red">{m.security_findings + m.failed_builds}</Badge>
</CardHeader>
<CardBody className="space-y-3">
<div className="flex items-start gap-3 p-3 rounded-lg border border-red-100 bg-red-50/60">
<ShieldAlert className="w-4 h-4 text-red-600 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-red-900">
{m.security_findings} Security findings
</div>
<div className="text-xs text-red-700/80 mt-0.5">
Review findings in Quality → Security
</div>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg border border-amber-100 bg-amber-50/60">
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-amber-900">
{m.failed_builds} Failed builds
</div>
<div className="text-xs text-amber-800/80 mt-0.5">
Last 24 hours — inspect for root causes
</div>
</div>
</div>
{m.featured_projects && m.featured_projects.length > 0 ? (
<div className="pt-2">
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">
Featured projects
</div>
<ul className="divide-y divide-slate-100 border border-slate-100 rounded-lg overflow-hidden">
{m.featured_projects.map((p) => (
<li
key={p.key}
className="flex items-center justify-between px-3 py-2.5 text-sm hover:bg-slate-50"
>
<div className="min-w-0">
<div className="font-medium text-slate-800 truncate">{p.name}</div>
<div className="text-xs text-slate-500 asf-monospace">{p.key}</div>
</div>
<Badge tone="brand">Open</Badge>
</li>
))}
</ul>
</div>
) : (
<EmptyState
title="No projects yet"
description="Create a project to start tracking your software factory."
actionLabel="Create project"
/>
)}
</CardBody>
</Card>
</section>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import React from "react";
import { GitFork, PlayCircle, Clock, CheckCircle2, AlertTriangle } from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Badge, JobStatusBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { MetricCard } from "@/components/ui/MetricCard";
import { fmtDate } from "@/lib/utils";
const PIPELINES = [
{ id: "pl-indexing", name: "Repository Indexing", stage: "connectors → asf-worker → MongoDB", last: "2025-11-22T09:10:00Z", status: "running", runs: 184 },
{ id: "pl-code-analysis", name: "Code Analysis", stage: "PR event → asf-ai → artifact + relationships", last: "2025-11-22T08:40:00Z", status: "queued", runs: 52 },
{ id: "pl-test-gen", name: "AI Test Generation", stage: "Task → Prompt → LLM → PR", last: "2025-11-21T18:02:00Z", status: "completed", runs: 13 },
{ id: "pl-deploy", name: "Deployment Rollout", stage: "Build OK → Argo → runtime_events", last: "2025-11-20T14:11:00Z", status: "completed", runs: 27 },
{ id: "pl-incident", name: "Incident Traceback", stage: "Incident → asf-context → root-cause", last: "2025-11-19T10:55:00Z", status: "failed", runs: 4 },
];
export default function PipelinesPage() {
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<GitFork className="w-6 h-6 text-brand-600" />
Pipelines
</h1>
<p className="mt-1 text-sm text-slate-500">
RabbitMQ-backed flows: domain events → workers → downstream artifacts. This page lists currently registered pipeline topologies.
</p>
</div>
<Badge tone="neutral">Event backbone · RabbitMQ</Badge>
</header>
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<MetricCard label="Running" value={1} tone="blue" icon={<PlayCircle className="w-5 h-5" />} />
<MetricCard label="Queued" value={1} tone="neutral" icon={<Clock className="w-5 h-5" />} />
<MetricCard label="Completed (7d)" value={112} tone="green" icon={<CheckCircle2 className="w-5 h-5" />} delta={4.2} />
<MetricCard label="Failed (24h)" value={1} tone="red" icon={<AlertTriangle className="w-5 h-5" />} />
</section>
<Card>
<CardHeader>
<CardTitle>Registered pipelines</CardTitle>
<Badge tone="brand">{PIPELINES.length} topologies</Badge>
</CardHeader>
<CardBody className="p-0">
<ul className="divide-y divide-slate-100">
{PIPELINES.length === 0 ? (
<li className="p-6"><EmptyState title="No pipelines yet" description="Register an async topology in asf-worker to surface it here." /></li>
) : PIPELINES.map((p) => (
<li key={p.id} className="p-4 hover:bg-slate-50 transition-colors flex items-start gap-4">
<div className="w-10 h-10 mt-0.5 rounded-lg bg-brand-50 border border-brand-100 grid place-items-center text-brand-600 shrink-0">
<GitFork className="w-5 h-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<div className="font-semibold text-slate-800">{p.name}</div>
<Badge tone="neutral" className="asf-monospace">{p.id}</Badge>
<JobStatusBadge status={p.status} />
</div>
<div className="mt-1 text-xs text-slate-600 font-mono bg-slate-50 inline-block px-2 py-1 rounded-md">
{p.stage}
</div>
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-slate-500">
<span>Last run: <span className="text-slate-700">{fmtDate(p.last)}</span></span>
<span>Total runs: <span className="tabular-nums text-slate-700 font-semibold">{p.runs}</span></span>
</div>
</div>
</li>
))}
</ul>
</CardBody>
</Card>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import React from "react";
import { ShieldCheck, ShieldAlert, Bug, TestTube2, Lock } from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { MetricCard } from "@/components/ui/MetricCard";
import { ProgressRing } from "@/components/ui/ProgressRing";
const FINDINGS = [
{ sev: "critical", title: "Hardcoded secret in asf-core/config.py#L18", rule: "SECRET-LEAK-001", file: "services/asf-core/app/config.py" },
{ sev: "high", title: "SQL injection vector in raw query", rule: "SQLI-014", file: "services/asf-core/app/repositories/artifact_repo.py" },
{ sev: "medium", title: "CORS permissive in dev-trust mode", rule: "CORS-002", file: "services/asf-core/app/main.py" },
{ sev: "medium", title: "Missing rate limit on /ask-asf", rule: "RATE-010", file: "services/asf-core/app/api/v1/ask_asf.py" },
{ sev: "low", title: "Insecure cookie flags hint", rule: "COOKIE-004", file: "services/asf-core/app/dependencies.py" },
];
const SEV_TONES: Record<string, "red" | "amber" | "neutral"> = {
critical: "red", high: "red", medium: "amber", low: "neutral",
};
export default function QualityPage() {
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<ShieldCheck className="w-6 h-6 text-emerald-600" />
Quality & Security
</h1>
<p className="mt-1 text-sm text-slate-500">
Static analysis, test coverage, security findings, build health — consolidated from every ASF connector.
</p>
</div>
<Badge tone="neutral">SonarQube · Semgrep connectors</Badge>
</header>
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<MetricCard label="Critical findings" value={1} tone="red" icon={<ShieldAlert className="w-5 h-5" />} />
<MetricCard label="Tests passing" value={498} tone="green" icon={<TestTube2 className="w-5 h-5" />} subtitle="of 512 total" delta={0.6} />
<MetricCard label="Coverage" value={68.9} isPercentage tone="brand" icon={<Bug className="w-5 h-5" />} />
<MetricCard label="Reliability score" value="A-" tone="green" icon={<Lock className="w-5 h-5" />} />
</section>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
<Card>
<CardHeader><CardTitle>Scores</CardTitle></CardHeader>
<CardBody className="space-y-4">
<ProgressRing value={72} tone="green" label="Security" sublabel="Findings + hardening" />
<ProgressRing value={68.9} tone="brand" label="Coverage" sublabel="Statement coverage" />
<ProgressRing value={81} tone="amber" label="Maintainability" sublabel="Duplication + complexity" />
</CardBody>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Security findings</CardTitle>
<Badge tone="red">{FINDINGS.filter((f) => f.sev === "critical" || f.sev === "high").length} critical/high</Badge>
</CardHeader>
<CardBody className="p-0">
{FINDINGS.length === 0 ? (
<div className="p-6"><EmptyState title="No findings" description="Your first scan will populate this panel." icon={ShieldCheck} /></div>
) : (
<ul className="divide-y divide-slate-100">
{FINDINGS.map((f, i) => (
<li key={i} className="p-3 hover:bg-slate-50 flex items-start gap-3">
<Badge tone={SEV_TONES[f.sev]} className="uppercase mt-0.5">{f.sev}</Badge>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-slate-800">{f.title}</div>
<div className="mt-1 text-xs text-slate-500 flex flex-wrap items-center gap-3">
<span className="asf-monospace">{f.rule}</span>
<span className="asf-monospace truncate">{f.file}</span>
</div>
</div>
</li>
))}
</ul>
)}
</CardBody>
</Card>
</div>
</div>
);
}
+605
View File
@@ -0,0 +1,605 @@
"use client";
import React, { useMemo, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ClipboardList,
Filter,
Search,
ChevronRight,
ExternalLink,
Tag,
Calendar,
Loader2,
Plus,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge, ArtifactTypeBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { artifacts, projects } from "@/lib/api";
import { cn, fmtDate, shortId, truncate } from "@/lib/utils";
import type { Artifact } from "@/lib/types";
const DEMO_REQS: Artifact[] = [
{
id: "req-001-aaaaaaaa",
tenant_id: "t1",
project_id: "p1",
type: "requirement",
external_id: "REQ-441",
title: "Multi-tenant role-based access control",
content:
"As a platform administrator I want to define organization roles (owner, admin, member, viewer) so that I can enforce least-privilege access on every ASF module.",
status: "in_progress",
version: 3,
source: "jira",
created_by: "u1",
created_at: "2025-10-02T09:12:00Z",
updated_at: "2025-11-15T14:22:00Z",
},
{
id: "req-002-bbbbbbbb",
tenant_id: "t1",
project_id: "p1",
type: "requirement",
external_id: "REQ-442",
title: "Audit log immutability & retention",
content:
"Audit events must be append-only and retained for 365 days with tamper-evident hashing. Export to JSON/CSV must be signed.",
status: "backlog",
version: 1,
source: "manual",
created_by: "u2",
created_at: "2025-10-18T11:40:00Z",
updated_at: "2025-10-18T11:40:00Z",
},
{
id: "req-003-cccccccc",
tenant_id: "t1",
project_id: "p2",
type: "epic",
external_id: "EPIC-12",
title: "Traceability graph v1",
content:
"End-to-end artifact visualization: requirement → tasks → PR → builds → tests → deployments, with impact analysis sidebar.",
status: "in_progress",
version: 5,
source: "linear",
created_by: "u1",
created_at: "2025-09-10T08:00:00Z",
updated_at: "2025-11-20T09:02:00Z",
},
{
id: "req-004-dddddddd",
tenant_id: "t1",
project_id: "p2",
type: "requirement",
external_id: "REQ-501",
title: "AI Gateway provider abstraction",
content:
"Business logic must not depend on a specific LLM provider. Introduce ASF AI Gateway with adapter pattern, policy routing, usage metering.",
status: "done",
version: 2,
source: "manual",
created_at: "2025-08-22T16:15:00Z",
updated_at: "2025-11-05T12:30:00Z",
},
{
id: "req-005-eeeeeeee",
tenant_id: "t1",
project_id: "p1",
type: "requirement",
external_id: "REQ-518",
title: "SSO with SAML 2.0 & OIDC",
content:
"Support Enterprise SSO: Okta, Azure AD, Google Workspace. SCIM provisioning for automated user/group lifecycle.",
status: "blocked",
version: 1,
source: "jira",
created_at: "2025-11-01T10:10:00Z",
updated_at: "2025-11-22T08:00:00Z",
},
];
const STATUS_TONES: Record<string, "green" | "amber" | "red" | "brand" | "neutral" | "violet" | "blue"> = {
backlog: "neutral",
todo: "neutral",
in_progress: "blue",
review: "violet",
done: "green",
blocked: "red",
cancelled: "amber",
};
export default function RequirementsPage() {
const queryClient = useQueryClient();
const [q, setQ] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [projectFilter, setProjectFilter] = useState<string>("all");
const [selectedId, setSelectedId] = useState<string | null>(null);
/* ---------- New requirement modal ---------- */
const [showNew, setShowNew] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newExternalId, setNewExternalId] = useState("");
const [newStatus, setNewStatus] = useState<string>("backlog");
const [newProjectId, setNewProjectId] = useState<string>("");
const [newContent, setNewContent] = useState("");
const [newSource, setNewSource] = useState("manual");
const [newMetaText, setNewMetaText] = useState<string>("{}\n");
const [newMetaError, setNewMetaError] = useState<string | null>(null);
const parsedMeta = useMemo<Record<string, unknown> | null>(() => {
const s = newMetaText.trim();
if (!s) return {};
try {
const v = JSON.parse(s);
if (v === null || typeof v !== "object" || Array.isArray(v)) {
setNewMetaError("metadata must be a JSON object (not null/array/primitive).");
return null;
}
setNewMetaError(null);
return v as Record<string, unknown>;
} catch (e) {
setNewMetaError("Invalid JSON in metadata");
return null;
}
}, [newMetaText]);
const createReqM = useMutation({
mutationFn: async () => {
if (!newTitle.trim()) throw new Error("title required");
if (!newProjectId) throw new Error("project required");
if (parsedMeta === null) throw new Error("metadata JSON invalid");
return artifacts.create({
project_id: newProjectId,
type: "requirement",
title: newTitle.trim(),
external_id: newExternalId.trim() || undefined,
status: newStatus || undefined,
content: newContent.trim() || undefined,
source: newSource.trim() || undefined,
metadata: parsedMeta,
});
},
onSuccess: (data) => {
setShowNew(false);
setNewTitle(""); setNewExternalId(""); setNewStatus("backlog");
setNewProjectId(""); setNewContent(""); setNewSource("manual"); setNewMetaText("{}\n");
queryClient.invalidateQueries({ queryKey: ["artifacts", "list"] }).catch(() => undefined);
queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined);
setSelectedId(String(data.id));
},
});
const projectsQ = useQuery({
queryKey: ["projects", "list"],
queryFn: () => projects.list({ limit: 200 }),
retry: false,
refetchOnWindowFocus: false,
});
const listQ = useQuery({
queryKey: [
"artifacts",
"list",
{ status: statusFilter, type: typeFilter, project: projectFilter, q: q.trim() },
],
queryFn: () =>
artifacts.list({
type: typeFilter !== "all" ? (typeFilter as any) : undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
project_id: projectFilter !== "all" ? projectFilter : undefined,
search: q.trim() || undefined,
limit: 200,
}),
retry: false,
refetchOnWindowFocus: false,
});
const apiResolved = listQ.isFetched && listQ.data !== undefined;
const rows: Artifact[] = apiResolved ? listQ.data! : DEMO_REQS;
const isMock = !apiResolved;
const projectNameById = useMemo(() => {
const m = new Map<string, string>();
for (const p of projectsQ.data ?? []) {
if (p?.id) m.set(String(p.id), p.name ?? shortId(String(p.id)));
}
return m;
}, [projectsQ.data]);
const filtered = useMemo(() => {
return rows.filter((r) => {
if (projectFilter !== "all" && String(r.project_id) !== projectFilter) return false;
if (typeFilter !== "all" && r.type !== typeFilter) return false;
if (statusFilter !== "all" && (r.status ?? "backlog") !== statusFilter) return false;
const ql = q.trim().toLowerCase();
if (ql) {
const hay = `${r.title} ${r.external_id ?? ""} ${r.content ?? ""}`.toLowerCase();
if (!hay.includes(ql)) return false;
}
return true;
});
}, [rows, projectFilter, typeFilter, statusFilter, q]);
const selected = filtered.find((r) => r.id === selectedId) ?? filtered[0] ?? null;
const statuses = Array.from(new Set(rows.map((r) => r.status ?? "backlog")));
const types = Array.from(new Set(rows.map((r) => r.type)));
const projectOptions = useMemo(() => {
const setRows = new Map<string, string>();
for (const r of rows) setRows.set(String(r.project_id), projectNameById.get(String(r.project_id)) ?? shortId(String(r.project_id)));
for (const p of projectsQ.data ?? []) {
if (p?.id) setRows.set(String(p.id), p.name ?? shortId(String(p.id)));
}
return Array.from(setRows.entries()).map(([id, name]) => ({ id, name }));
}, [rows, projectsQ.data, projectNameById]);
return (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<ClipboardList className="w-6 h-6 text-brand-600" />
Requirements
</h1>
<p className="mt-1 text-sm text-slate-500">
Every lifecycle item in ASF is an Artifact. Requirements, epics, stories, tasks — searchable, linkable, traceable.
</p>
</div>
<div className="flex items-center gap-2">
{isMock && <Badge tone="amber">Demo data</Badge>}
<Button variant="primary" size="sm" onClick={() => {
setShowNew(true);
if ((projectsQ.data ?? []).length > 0 && !newProjectId) {
setNewProjectId(String(projectsQ.data![0].id));
}
}}>
<Plus className="w-4 h-4" /> New Requirement
</Button>
</div>
</header>
<div className="px-6 lg:px-8 pb-4 flex flex-wrap gap-2 items-center">
<div className="relative flex-1 min-w-[220px]">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
className="asf-input pl-9"
placeholder="Search by id, title, content…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<Filter className="w-3.5 h-3.5" />
<select
className="asf-input !py-1.5 !text-xs !w-auto"
value={projectFilter}
onChange={(e) => setProjectFilter(e.target.value)}
>
<option value="all">All projects</option>
{projectOptions.map(({ id, name }) => (
<option key={id} value={id}>{name}</option>
))}
</select>
<select
className="asf-input !py-1.5 !text-xs !w-auto"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">All statuses</option>
{statuses.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<select
className="asf-input !py-1.5 !text-xs !w-auto"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="all">All types</option>
{types.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<Badge tone="brand">
<span className="tabular-nums font-semibold">{filtered.length}</span> / {rows.length}
</Badge>
</div>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 xl:grid-cols-5 gap-4">
<Card className="xl:col-span-3 flex flex-col min-h-0">
<CardHeader>
<CardTitle>Artifact list</CardTitle>
<span className="text-xs text-slate-500">Click a row →</span>
</CardHeader>
<CardBody className="p-0 flex-1 min-h-0 overflow-auto">
{filtered.length === 0 ? (
<div className="p-6">
<EmptyState
title="No artifacts match"
description="Try adjusting filters or the search term."
/>
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-slate-50/90 backdrop-blur text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-2">ID</th>
<th className="text-left px-4 py-2">Type</th>
<th className="text-left px-4 py-2">Title</th>
<th className="text-left px-4 py-2">Status</th>
<th className="text-left px-4 py-2">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((r) => (
<tr
key={r.id}
onClick={() => setSelectedId(r.id)}
className={cn(
"cursor-pointer hover:bg-brand-50/40 transition-colors",
selected?.id === r.id && "bg-brand-50/70",
)}
>
<td className="px-4 py-2.5 asf-monospace text-xs text-slate-500">
{r.external_id ?? shortId(r.id)}
</td>
<td className="px-4 py-2.5">
<ArtifactTypeBadge type={r.type} />
</td>
<td className="px-4 py-2.5 font-medium text-slate-800 max-w-md truncate">
{r.title}
</td>
<td className="px-4 py-2.5">
<Badge tone={STATUS_TONES[r.status ?? "backlog"] ?? "neutral"}>
{r.status ?? "backlog"}
</Badge>
</td>
<td className="px-4 py-2.5 text-xs text-slate-500 whitespace-nowrap">
{fmtDate(r.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
)}
</CardBody>
</Card>
<Card className="xl:col-span-2 flex flex-col min-h-0">
<CardHeader>
<CardTitle>Detail</CardTitle>
<Link href="/traceability" className="text-xs text-brand-600 hover:underline inline-flex items-center gap-1">
Traceability <ChevronRight className="w-3 h-3" />
</Link>
</CardHeader>
<CardBody className="overflow-auto flex-1 min-h-0">
{selected ? (
<div className="space-y-4">
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="asf-monospace text-xs text-slate-500">
{selected.external_id ?? shortId(selected.id)}
</span>
<ArtifactTypeBadge type={selected.type} />
<Badge tone={STATUS_TONES[selected.status ?? "backlog"] ?? "neutral"}>
{selected.status ?? "backlog"}
</Badge>
{selected.version !== undefined && (
<Badge tone="neutral">v{selected.version}</Badge>
)}
</div>
<h2 className="text-lg font-semibold text-slate-900 leading-tight">
{selected.title}
</h2>
</div>
{selected.content && (
<div className="p-3 rounded-lg bg-slate-50 border border-slate-100 text-sm text-slate-700 leading-relaxed whitespace-pre-wrap">
{selected.content}
</div>
)}
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<dt className="text-xs text-slate-500">Project</dt>
<dd className="font-medium text-slate-800">
{projectNameById.get(String(selected.project_id)) ?? (
<span className="asf-monospace">{shortId(selected.project_id)}</span>
)}
</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Source</dt>
<dd className="font-medium text-slate-800 inline-flex items-center gap-1">
<ExternalLink className="w-3 h-3" /> {selected.source ?? "—"}
</dd>
</div>
<div>
<dt className="text-xs text-slate-500 flex items-center gap-1">
<Calendar className="w-3 h-3" /> Created
</dt>
<dd className="text-slate-700">{fmtDate(selected.created_at)}</dd>
</div>
<div>
<dt className="text-xs text-slate-500 flex items-center gap-1">
<Calendar className="w-3 h-3" /> Updated
</dt>
<dd className="text-slate-700">{fmtDate(selected.updated_at)}</dd>
</div>
</dl>
<div className="pt-3 border-t border-slate-100 flex flex-wrap gap-2">
<Button variant="secondary" size="sm">View relationships</Button>
<Button variant="secondary" size="sm">History</Button>
<Button variant="primary" size="sm">Ask ASF about this…</Button>
</div>
<p className="text-xs text-slate-400 pt-1">
Description preview: {truncate(selected.content ?? "", 160).length > 0 ? truncate(selected.content ?? "", 160) : "No description."}
</p>
</div>
) : (
<EmptyState
title="Select an artifact"
description="Pick any row on the left to inspect its metadata and content."
/>
)}
</CardBody>
</Card>
</div>
{/* New requirement modal */}
{showNew && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !createReqM.isPending && setShowNew(false)}>
<div
className="w-full max-w-2xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<Tag className="w-4 h-4 text-brand-600" /> New Requirement
</h3>
<button
onClick={() => !createReqM.isPending && setShowNew(false)}
disabled={createReqM.isPending}
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
aria-label="close"
>
×
</button>
</div>
<form
className="p-5 space-y-3"
onSubmit={(e) => {
e.preventDefault();
createReqM.mutate();
}}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="md:col-span-2">
<label className="text-xs text-slate-500">Title *</label>
<input
className="asf-input mt-1"
placeholder="As a user I want …"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
autoFocus
required
/>
</div>
<div>
<label className="text-xs text-slate-500">External ID</label>
<input
className="asf-input mt-1"
placeholder="REQ-104"
value={newExternalId}
onChange={(e) => setNewExternalId(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-slate-500">Project *</label>
<select
className="asf-input mt-1"
value={newProjectId}
onChange={(e) => setNewProjectId(e.target.value)}
required
>
<option value="">Select project…</option>
{(projectsQ.data ?? []).map((p) => (
<option key={String(p.id)} value={String(p.id)}>
{p.name ?? shortId(String(p.id))}
</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Status</label>
<select
className="asf-input mt-1"
value={newStatus}
onChange={(e) => setNewStatus(e.target.value)}
>
{["backlog","todo","in_progress","review","done","blocked","cancelled"].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Source</label>
<input
className="asf-input mt-1"
placeholder="manual"
value={newSource}
onChange={(e) => setNewSource(e.target.value)}
/>
</div>
</div>
<div>
<label className="text-xs text-slate-500">Content / Description</label>
<textarea
rows={4}
className="asf-input mt-1"
placeholder="Acceptance criteria, non-goals, links…"
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-slate-500 flex items-center justify-between mb-1">
<span>Metadata (JSON)</span>
{newMetaError && <span className="text-red-600 font-medium">{newMetaError}</span>}
</label>
<textarea
rows={4}
className={cn("asf-input mt-0", newMetaError && "border-red-300 focus:ring-red-200")}
value={newMetaText}
onChange={(e) => setNewMetaText(e.target.value)}
spellCheck={false}
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}
/>
<p className="text-[11px] text-slate-500 mt-1">
Example: <code className="bg-slate-100 rounded px-1">{"{\"priority\": \"P0\", \"points\": 8}"} </code>
</p>
</div>
{createReqM.isError && (
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{createReqM.error instanceof Error ? createReqM.error.message : "Failed to create requirement."}
</div>
)}
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setShowNew(false)}
disabled={createReqM.isPending}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
disabled={createReqM.isPending || !newTitle.trim() || !newProjectId || parsedMeta === null}
>
{createReqM.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" /> Creating…
</>
) : (
<>
<Plus className="w-4 h-4" /> Create requirement
</>
)}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+906
View File
@@ -0,0 +1,906 @@
"use client";
import React, { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Settings2,
User,
Building2,
ShieldCheck,
Bell,
Palette,
KeyRound,
Save,
Plus,
Loader2,
FolderKanban,
CheckCircle2,
XCircle,
Calendar,
Pencil,
Trash2,
AlertTriangle,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { orgs, projects as projectsApi } from "@/lib/api";
import type { Organization, Project } from "@/lib/types";
import { cn, fmtDate, shortId } from "@/lib/utils";
const SECTIONS = [
{ key: "profile", label: "Profile", icon: User },
{ key: "org", label: "Organization", icon: Building2 },
{ key: "security", label: "Security", icon: ShieldCheck },
{ key: "ai", label: "AI Policy", icon: KeyRound },
{ key: "notifs", label: "Notifications", icon: Bell },
{ key: "theme", label: "Appearance", icon: Palette },
] as const;
type SecKey = (typeof SECTIONS)[number]["key"];
const PLAN_TONES: Record<string, "brand" | "violet" | "green" | "amber" | "neutral" | "blue" | "red"> = {
Free: "neutral", Team: "blue", Business: "brand", Enterprise: "violet",
};
const STATUS_TONES: Record<string, "green" | "red" | "neutral" | "brand" | "violet" | "amber" | "blue"> = {
true: "green", false: "red", active: "green", inactive: "red",
};
export default function SettingsPage() {
const queryClient = useQueryClient();
const [sec, setSec] = useState<SecKey>("org");
const [name, setName] = useState("Mario Rossi");
const [email, setEmail] = useState("mario.rossi@azurian.example");
const [role, setRole] = useState("Owner");
const [mfa, setMfa] = useState(true);
const [ceiling, setCeiling] = useState("50");
/* ---------------- Organizations ---------------- */
const orgsQ = useQuery({
queryKey: ["orgs", "list"],
queryFn: () => orgs.list({ limit: 200 }),
retry: false,
refetchOnWindowFocus: false,
});
const [showNewOrg, setShowNewOrg] = useState(false);
const [newOrgName, setNewOrgName] = useState("");
const [newOrgSlug, setNewOrgSlug] = useState("");
const [newOrgPlan, setNewOrgPlan] = useState<"Free" | "Team" | "Business" | "Enterprise">("Team");
const [newOrgActive, setNewOrgActive] = useState(true);
const createOrgM = useMutation({
mutationFn: async () => {
const slugRgx = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/;
if (!newOrgName.trim()) throw new Error("Organization name required");
if (!slugRgx.test(newOrgSlug.trim())) throw new Error("Slug must be 3-60 chars: lowercase letters, digits, dashes (first+last alphanumeric)");
return orgs.create({
name: newOrgName.trim(),
slug: newOrgSlug.trim().toLowerCase(),
plan: newOrgPlan,
is_active: newOrgActive,
});
},
onSuccess: () => {
setShowNewOrg(false);
setNewOrgName(""); setNewOrgSlug(""); setNewOrgPlan("Team"); setNewOrgActive(true);
queryClient.invalidateQueries({ queryKey: ["orgs", "list"] }).catch(() => undefined);
},
});
const slugHint = useMemo(() => {
if (!newOrgSlug) return null;
const ok = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(newOrgSlug);
return ok ? "Valid slug format" : "⚠ Must be 3-60 a-z 0-9 -, first+last alphanumeric";
}, [newOrgSlug]);
/* ---------------- Projects ---------------- */
const projectsQ = useQuery({
queryKey: ["projects", "list"],
queryFn: () => projectsApi.list({ limit: 500 }),
retry: false,
refetchOnWindowFocus: false,
});
const [showNewProj, setShowNewProj] = useState(false);
const [newProjName, setNewProjName] = useState("");
const [newProjKey, setNewProjKey] = useState("");
const [newProjDesc, setNewProjDesc] = useState("");
const [newProjBranch, setNewProjBranch] = useState("main");
const [newProjActive, setNewProjActive] = useState(true);
const createProjM = useMutation({
mutationFn: async () => {
const keyRgx = /^[A-Z][A-Z0-9]{1,19}$/;
if (!newProjName.trim()) throw new Error("Project name required");
if (!keyRgx.test(newProjKey.trim())) throw new Error("Project key must be 2-20 uppercase letters/digits, starting with a letter (e.g. ASF, PROJ2)");
return projectsApi.create({
name: newProjName.trim(),
key: newProjKey.trim().toUpperCase(),
description: newProjDesc.trim() || undefined,
default_branch: newProjBranch.trim() || undefined,
is_active: newProjActive,
});
},
onSuccess: () => {
setShowNewProj(false);
setNewProjName(""); setNewProjKey(""); setNewProjDesc(""); setNewProjBranch("main"); setNewProjActive(true);
queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined);
},
});
const keyHint = useMemo(() => {
if (!newProjKey) return null;
const ok = /^[A-Z][A-Z0-9]{1,19}$/.test(newProjKey);
return ok ? "Valid project key format" : "⚠ Must be 2-20 A-Z 0-9 starting with letter (e.g. ASF)";
}, [newProjKey]);
/* ---------------- Edit Organization ---------------- */
const [showEditOrg, setShowEditOrg] = useState(false);
const [editOrg, setEditOrg] = useState<Organization | null>(null);
const [eoName, setEoName] = useState("");
const [eoSlug, setEoSlug] = useState("");
const [eoPlan, setEoPlan] = useState<"Free" | "Team" | "Business" | "Enterprise">("Team");
const [eoActive, setEoActive] = useState(true);
const openEditOrg = (o: Organization) => {
setEditOrg(o);
setEoName(o.name ?? "");
setEoSlug(o.slug ?? "");
setEoPlan((o.plan as typeof eoPlan) ?? "Team");
setEoActive(Boolean(o.is_active ?? true));
setShowEditOrg(true);
};
const editOrgM = useMutation({
mutationFn: async () => {
if (!editOrg) throw new Error("no organization");
const slugRgx = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/;
if (!eoName.trim()) throw new Error("Organization name required");
if (!slugRgx.test(eoSlug.trim())) throw new Error("Slug must be 3-60 chars: lowercase letters, digits, dashes (first+last alphanumeric)");
return orgs.update(String(editOrg.id), {
name: eoName.trim(),
slug: eoSlug.trim().toLowerCase(),
plan: eoPlan,
is_active: eoActive,
});
},
onSuccess: () => {
setShowEditOrg(false);
setEditOrg(null);
queryClient.invalidateQueries({ queryKey: ["orgs", "list"] }).catch(() => undefined);
},
});
const eoSlugHint = useMemo(() => {
if (!eoSlug) return null;
const ok = /^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(eoSlug);
return ok ? "Valid slug format" : "⚠ Must be 3-60 a-z 0-9 -, first+last alphanumeric";
}, [eoSlug]);
/* ---------------- Delete Organization ---------------- */
const [delOrg, setDelOrg] = useState<Organization | null>(null);
const deleteOrgM = useMutation({
mutationFn: async (id: string) => orgs.remove(id),
onSuccess: () => {
setDelOrg(null);
queryClient.invalidateQueries({ queryKey: ["orgs", "list"] }).catch(() => undefined);
},
});
/* ---------------- Edit Project ---------------- */
const [showEditProj, setShowEditProj] = useState(false);
const [editProj, setEditProj] = useState<Project | null>(null);
const [epName, setEpName] = useState("");
const [epKey, setEpKey] = useState("");
const [epDesc, setEpDesc] = useState("");
const [epBranch, setEpBranch] = useState("main");
const [epActive, setEpActive] = useState(true);
const openEditProj = (p: Project) => {
setEditProj(p);
setEpName(p.name ?? "");
setEpKey(p.key ?? "");
setEpDesc(p.description ?? "");
setEpBranch(p.default_branch ?? "main");
setEpActive(Boolean(p.is_active ?? true));
setShowEditProj(true);
};
const editProjM = useMutation({
mutationFn: async () => {
if (!editProj) throw new Error("no project");
const keyRgx = /^[A-Z][A-Z0-9]{1,19}$/;
if (!epName.trim()) throw new Error("Project name required");
if (!keyRgx.test(epKey.trim())) throw new Error("Project key must be 2-20 uppercase letters/digits, starting with a letter (e.g. ASF, PROJ2)");
return projectsApi.update(String(editProj.id), {
name: epName.trim(),
key: epKey.trim().toUpperCase(),
description: epDesc.trim() || undefined,
default_branch: epBranch.trim() || undefined,
is_active: epActive,
});
},
onSuccess: () => {
setShowEditProj(false);
setEditProj(null);
queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined);
},
});
const epKeyHint = useMemo(() => {
if (!epKey) return null;
const ok = /^[A-Z][A-Z0-9]{1,19}$/.test(epKey);
return ok ? "Valid project key format" : "⚠ Must be 2-20 A-Z 0-9 starting with letter (e.g. ASF)";
}, [epKey]);
/* ---------------- Delete Project ---------------- */
const [delProj, setDelProj] = useState<Project | null>(null);
const deleteProjM = useMutation({
mutationFn: async (id: string) => projectsApi.remove(id),
onSuccess: () => {
setDelProj(null);
queryClient.invalidateQueries({ queryKey: ["projects", "list"] }).catch(() => undefined);
},
});
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<Settings2 className="w-6 h-6 text-brand-600" />
Settings
</h1>
<p className="mt-1 text-sm text-slate-500">
User profile, organization, security, AI policy, notifications and appearance.
</p>
</header>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
<Card className="lg:col-span-1">
<CardBody className="p-2">
<ul className="space-y-0.5">
{SECTIONS.map(({ key, label, icon: Icon }) => (
<li key={key}>
<button
type="button"
onClick={() => setSec(key)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
sec === key
? "bg-brand-50 text-brand-700 ring-1 ring-inset ring-brand-100"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
)}
>
<Icon className="w-4 h-4" />
{label}
</button>
</li>
))}
</ul>
</CardBody>
</Card>
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle className="capitalize flex items-center gap-2">
{sec === "profile" && <>User Profile <Badge tone="brand">You</Badge></>}
{sec === "org" && "Organization & Projects"}
{sec === "security" && "Security"}
{sec === "ai" && "AI Policy"}
{sec === "notifs" && "Notifications"}
{sec === "theme" && "Appearance"}
</CardTitle>
</CardHeader>
<CardBody>
{sec === "profile" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl">
<Field label="Full name">
<input className="asf-input" value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<Field label="Email">
<input className="asf-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</Field>
<Field label="Role">
<select className="asf-input" value={role} onChange={(e) => setRole(e.target.value)}>
<option>Owner</option><option>Admin</option><option>Member</option><option>Viewer</option>
</select>
</Field>
<div />
<div className="md:col-span-2 flex justify-end">
<Button variant="primary"><Save className="w-4 h-4" /> Save changes</Button>
</div>
</div>
)}
{sec === "org" && (
<div className="space-y-8">
{/* Organizations Card */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Building2 className="w-5 h-5 text-slate-500" />
<h4 className="font-medium text-slate-800">Organizations</h4>
<Badge tone="brand">{orgsQ.data?.length ?? 0}</Badge>
</div>
<Button size="sm" variant="primary" onClick={() => setShowNewOrg(true)}>
<Plus className="w-4 h-4" /> New organization
</Button>
</div>
{orgsQ.isFetching && !orgsQ.data ? (
<div className="flex items-center gap-2 text-slate-500 text-sm px-3 py-6">
<Loader2 className="w-4 h-4 animate-spin" /> Loading organizations…
</div>
) : (orgsQ.data ?? []).length === 0 ? (
<EmptyState title="No organizations yet" description="Click 'New organization' above to create your first tenant." />
) : (
<div className="overflow-hidden rounded-xl border border-slate-200">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
<tr>
<th className="text-left font-medium px-4 py-2.5">Name</th>
<th className="text-left font-medium px-4 py-2.5">Slug</th>
<th className="text-left font-medium px-4 py-2.5">Plan</th>
<th className="text-left font-medium px-4 py-2.5">Status</th>
<th className="text-left font-medium px-4 py-2.5">Created</th>
<th className="text-right font-medium px-4 py-2.5">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(orgsQ.data ?? []).map((o: Organization) => (
<tr key={String(o.id)} className="hover:bg-slate-50/50">
<td className="px-4 py-3 font-medium text-slate-800">{o.name} <span className="text-[10px] text-slate-400 font-mono ml-2">{shortId(String(o.id))}</span></td>
<td className="px-4 py-3 font-mono text-xs text-slate-600">{o.slug}</td>
<td className="px-4 py-3"><Badge tone={PLAN_TONES[String(o.plan ?? "Free")]}>{o.plan ?? "Free"}</Badge></td>
<td className="px-4 py-3"><Badge tone={STATUS_TONES[String(o.is_active ?? true)]}>{o.is_active ?? true ? <><CheckCircle2 className="w-3 h-3 mr-1"/>Active</> : <><XCircle className="w-3 h-3 mr-1"/>Inactive</>}</Badge></td>
<td className="px-4 py-3 text-slate-500 text-xs flex items-center gap-1"><Calendar className="w-3 h-3" />{o.created_at ? fmtDate(o.created_at) : "—"}</td>
<td className="px-4 py-3 text-right">
<div className="inline-flex items-center gap-1">
<Button variant="ghost" size="xs" onClick={() => openEditOrg(o)} title="Edit organization">
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="xs"
className="!text-red-600 hover:!bg-red-50"
onClick={() => setDelOrg(o)}
title="Delete organization"
disabled={delOrg?.id === o.id && deleteOrgM.isPending}
>
{delOrg?.id === o.id && deleteOrgM.isPending
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <Trash2 className="w-3.5 h-3.5" />}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Projects Card */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<FolderKanban className="w-5 h-5 text-slate-500" />
<h4 className="font-medium text-slate-800">Projects</h4>
<Badge tone="brand">{projectsQ.data?.length ?? 0}</Badge>
</div>
<Button size="sm" variant="primary" onClick={() => setShowNewProj(true)}>
<Plus className="w-4 h-4" /> New project
</Button>
</div>
{projectsQ.isFetching && !projectsQ.data ? (
<div className="flex items-center gap-2 text-slate-500 text-sm px-3 py-6">
<Loader2 className="w-4 h-4 animate-spin" /> Loading projects…
</div>
) : (projectsQ.data ?? []).length === 0 ? (
<EmptyState title="No projects yet" description="Create a project to start tracking requirements, code and deployments." />
) : (
<div className="overflow-hidden rounded-xl border border-slate-200">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
<tr>
<th className="text-left font-medium px-4 py-2.5">Key</th>
<th className="text-left font-medium px-4 py-2.5">Name</th>
<th className="text-left font-medium px-4 py-2.5">Default branch</th>
<th className="text-left font-medium px-4 py-2.5">Status</th>
<th className="text-left font-medium px-4 py-2.5">Updated</th>
<th className="text-right font-medium px-4 py-2.5">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(projectsQ.data ?? []).map((p: Project) => (
<tr key={String(p.id)} className="hover:bg-slate-50/50">
<td className="px-4 py-3"><Badge tone="violet" className="font-mono">{p.key}</Badge></td>
<td className="px-4 py-3 font-medium text-slate-800">{p.name} <span className="text-[10px] text-slate-400 font-mono ml-2">{shortId(String(p.id))}</span></td>
<td className="px-4 py-3 font-mono text-xs text-slate-600">{p.default_branch ?? "—"}</td>
<td className="px-4 py-3"><Badge tone={STATUS_TONES[String(p.is_active ?? true)]}>{p.is_active ?? true ? <><CheckCircle2 className="w-3 h-3 mr-1"/>Active</> : <><XCircle className="w-3 h-3 mr-1"/>Archived</>}</Badge></td>
<td className="px-4 py-3 text-slate-500 text-xs flex items-center gap-1"><Calendar className="w-3 h-3" />{p.updated_at ? fmtDate(p.updated_at) : "—"}</td>
<td className="px-4 py-3 text-right">
<div className="inline-flex items-center gap-1">
<Button variant="ghost" size="xs" onClick={() => openEditProj(p)} title="Edit project">
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="xs"
className="!text-red-600 hover:!bg-red-50"
onClick={() => setDelProj(p)}
title="Delete project"
disabled={delProj?.id === p.id && deleteProjM.isPending}
>
{delProj?.id === p.id && deleteProjM.isPending
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
: <Trash2 className="w-3.5 h-3.5" />}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
{sec === "security" && (
<div className="space-y-4 max-w-2xl">
<Toggle label="Multi-factor authentication" hint="Require a second factor at every login." checked={mfa} onChange={setMfa} />
<Toggle label="Session timeout 15m" hint="Force re-authentication after idle period." checked={true} onChange={() => {}} />
<Toggle label="Audit log retention 365 days" hint="Append-only, tamper-evident export." checked={true} onChange={() => {}} />
</div>
)}
{sec === "ai" && (
<div className="space-y-4 max-w-2xl">
<Field label="Monthly $ ceiling (USD)">
<input className="asf-input" type="number" value={ceiling} onChange={(e) => setCeiling(e.target.value)} />
</Field>
<Toggle label="Allow local models only" hint="Never route prompts to external providers." checked={false} onChange={() => {}} />
<Toggle label="Audit every inference" hint="Append every prompt + response to audit log." checked={true} onChange={() => {}} />
<p className="text-xs text-slate-500">AI Policy can be further refined per-project and per-team inside asf-ai.</p>
</div>
)}
{sec === "notifs" && (
<div className="space-y-4 max-w-2xl">
{[
{ l: "Job failures", h: "Email + in-app when any job fails." },
{ l: "Security findings critical/high", h: "Instant via configured channel." },
{ l: "Deployment completed", h: "Production deployments only." },
{ l: "Weekly digest", h: "Health, coverage and cost snapshot." },
].map((t) => (
<Toggle key={t.l} label={t.l} hint={t.h} defaultChecked onChange={() => {}} />
))}
</div>
)}
{sec === "theme" && (
<div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-3 gap-3">
{[
{ k: "light", label: "Light", sw: "bg-white border-slate-300" },
{ k: "system", label: "System", sw: "bg-gradient-to-r from-white to-slate-900 border-slate-400" },
{ k: "dark", label: "Dark", sw: "bg-slate-900 border-slate-700" },
].map((t) => (
<button key={t.k} type="button" className={cn("rounded-xl border p-3 text-left hover:border-brand-400 transition-colors", t.k === "light" ? "border-brand-400 ring-2 ring-brand-100" : "border-slate-200")}>
<div className={cn("w-full h-16 rounded-lg border mb-2", t.sw)} />
<div className="text-sm font-medium text-slate-800">{t.label}</div>
<div className="text-xs text-slate-500">Default</div>
</button>
))}
</div>
<Toggle label="Density · Comfortable" hint="Looser paddings throughout the shell." checked={true} onChange={() => {}} />
</div>
)}
</CardBody>
</Card>
</div>
{/* ---------- New Organization modal ---------- */}
{showNewOrg && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !createOrgM.isPending && setShowNewOrg(false)}>
<div
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<Building2 className="w-4 h-4 text-brand-600" /> New organization
</h3>
<button
onClick={() => !createOrgM.isPending && setShowNewOrg(false)}
disabled={createOrgM.isPending}
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
aria-label="close"
>×</button>
</div>
<form
className="p-5 space-y-3"
onSubmit={(e) => { e.preventDefault(); createOrgM.mutate(); }}
>
<Field label="Name *">
<input className="asf-input" autoFocus required
placeholder="Acme Inc." value={newOrgName}
onChange={(e) => setNewOrgName(e.target.value)} />
</Field>
<Field label={<>Slug * <span className="text-[10px] text-slate-400">(URL identifier)</span></>}>
<input className="asf-input font-mono text-sm"
placeholder="acme-inc"
value={newOrgSlug}
onChange={(e) => setNewOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} />
<div className={cn("text-[11px] mt-1", slugHint && slugHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{slugHint ?? "3-60 chars, lowercase letters, digits, dashes. First and last must be alphanumeric."}</div>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Plan">
<select className="asf-input" value={newOrgPlan}
onChange={(e) => setNewOrgPlan(e.target.value as typeof newOrgPlan)}>
{(["Free","Team","Business","Enterprise"] as const).map((p) => <option key={p}>{p}</option>)}
</select>
</Field>
<Field label="Status">
<Toggle label="Active" hint="Organization visible and usable." checked={newOrgActive} onChange={setNewOrgActive} />
</Field>
</div>
{createOrgM.isError && (
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{createOrgM.error instanceof Error ? createOrgM.error.message : "Failed to create organization."}
</div>
)}
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" disabled={createOrgM.isPending} onClick={() => setShowNewOrg(false)}>Cancel</Button>
<Button type="submit" variant="primary" size="sm" disabled={createOrgM.isPending || !newOrgName || !/^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(newOrgSlug)}>
{createOrgM.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Creating…</>
) : (<><Plus className="w-4 h-4" /> Create organization</>)}
</Button>
</div>
</form>
</div>
</div>
)}
{/* ---------- New Project modal ---------- */}
{showNewProj && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !createProjM.isPending && setShowNewProj(false)}>
<div
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<FolderKanban className="w-4 h-4 text-brand-600" /> New project
</h3>
<button
onClick={() => !createProjM.isPending && setShowNewProj(false)}
disabled={createProjM.isPending}
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
aria-label="close"
>×</button>
</div>
<form
className="p-5 space-y-3"
onSubmit={(e) => { e.preventDefault(); createProjM.mutate(); }}
>
<Field label="Name *">
<input className="asf-input" required autoFocus
placeholder="ASF Platform MVP"
value={newProjName}
onChange={(e) => setNewProjName(e.target.value)} />
</Field>
<Field label={<>Key * <span className="text-[10px] text-slate-400">(short uppercase code)</span></>}>
<input className="asf-input font-mono text-sm uppercase tracking-wide"
placeholder="ASF"
maxLength={20}
value={newProjKey}
onChange={(e) => setNewProjKey(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""))} />
<div className={cn("text-[11px] mt-1", keyHint && keyHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{keyHint ?? "2-20 characters. Uppercase letters/digits only — starts with a letter."}</div>
</Field>
<Field label="Default branch">
<input className="asf-input font-mono text-xs"
placeholder="main"
value={newProjBranch}
onChange={(e) => setNewProjBranch(e.target.value)} />
</Field>
<Field label="Description">
<textarea className="asf-input" rows={3}
placeholder="Scope, goals and non-goals of this project."
value={newProjDesc}
onChange={(e) => setNewProjDesc(e.target.value)} />
</Field>
<Field label="Status">
<Toggle label="Active" hint="Project appears in pickers and filters." checked={newProjActive} onChange={setNewProjActive} />
</Field>
{createProjM.isError && (
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{createProjM.error instanceof Error ? createProjM.error.message : "Failed to create project."}
</div>
)}
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" disabled={createProjM.isPending} onClick={() => setShowNewProj(false)}>Cancel</Button>
<Button type="submit" variant="primary" size="sm"
disabled={createProjM.isPending || !newProjName || !/^[A-Z][A-Z0-9]{1,19}$/.test(newProjKey)}>
{createProjM.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Creating…</>
) : (<><Plus className="w-4 h-4" /> Create project</>)}
</Button>
</div>
</form>
</div>
</div>
)}
{/* ---------- Edit Organization modal ---------- */}
{showEditOrg && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !editOrgM.isPending && setShowEditOrg(false)}>
<div
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<Pencil className="w-4 h-4 text-brand-600" /> Edit organization
</h3>
<button
onClick={() => !editOrgM.isPending && setShowEditOrg(false)}
disabled={editOrgM.isPending}
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
aria-label="close"
>×</button>
</div>
<form
className="p-5 space-y-3"
onSubmit={(e) => { e.preventDefault(); void editOrgM.mutateAsync(); }}
>
<Field label="Name *">
<input className="asf-input" required autoFocus
placeholder="Acme Inc." value={eoName}
onChange={(e) => setEoName(e.target.value)} />
</Field>
<Field label={<>Slug * <span className="text-[10px] text-slate-400">(URL identifier)</span></>}>
<input className="asf-input font-mono text-sm"
placeholder="acme-inc"
value={eoSlug}
onChange={(e) => setEoSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} />
<div className={cn("text-[11px] mt-1", eoSlugHint && eoSlugHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{eoSlugHint ?? "3-60 chars, lowercase letters, digits, dashes. First and last must be alphanumeric."}</div>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Plan">
<select className="asf-input" value={eoPlan}
onChange={(e) => setEoPlan(e.target.value as typeof eoPlan)}>
{(["Free","Team","Business","Enterprise"] as const).map((p) => <option key={p}>{p}</option>)}
</select>
</Field>
<Field label="Status">
<Toggle label="Active" hint="Organization visible and usable." checked={eoActive} onChange={setEoActive} />
</Field>
</div>
{editOrgM.isError && (
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{editOrgM.error instanceof Error ? editOrgM.error.message : "Failed to update organization."}
</div>
)}
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" disabled={editOrgM.isPending} onClick={() => setShowEditOrg(false)}>Cancel</Button>
<Button type="submit" variant="primary" size="sm" disabled={editOrgM.isPending || !eoName || !/^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/.test(eoSlug)}>
{editOrgM.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Saving…</>
) : (<><Save className="w-4 h-4" /> Save changes</>)}
</Button>
</div>
</form>
</div>
</div>
)}
{/* ---------- Delete Organization confirm ---------- */}
{delOrg && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !deleteOrgM.isPending && setDelOrg(null)}>
<div
className="w-full max-w-md rounded-xl bg-white border border-slate-200 shadow-xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-red-50 grid place-items-center shrink-0">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-slate-900">Delete organization?</h3>
<p className="mt-1 text-sm text-slate-500">
Permanently delete <span className="font-semibold text-slate-700">{delOrg.name}</span>{" "}
<span className="font-mono text-xs">({delOrg.slug})</span>. All projects, artifacts and relationships under this tenant will be removed. This cannot be undone.
</p>
</div>
</div>
{deleteOrgM.isError && (
<div className="mx-5 mt-4 rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{deleteOrgM.error instanceof Error ? deleteOrgM.error.message : "Failed to delete organization."}
</div>
)}
<div className="px-5 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" disabled={deleteOrgM.isPending} onClick={() => setDelOrg(null)}>Cancel</Button>
<Button
type="button"
variant="primary"
size="sm"
className="!bg-red-600 hover:!bg-red-700 !text-white"
disabled={deleteOrgM.isPending}
onClick={() => void deleteOrgM.mutateAsync(String(delOrg.id))}
>
{deleteOrgM.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Deleting…</>
) : (<><Trash2 className="w-4 h-4" /> Delete forever</>)}
</Button>
</div>
</div>
</div>
)}
{/* ---------- Edit Project modal ---------- */}
{showEditProj && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !editProjM.isPending && setShowEditProj(false)}>
<div
className="w-full max-w-xl rounded-xl bg-white border border-slate-200 shadow-xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between sticky top-0 bg-white z-10">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<Pencil className="w-4 h-4 text-brand-600" /> Edit project
</h3>
<button
onClick={() => !editProjM.isPending && setShowEditProj(false)}
disabled={editProjM.isPending}
className="text-slate-400 hover:text-slate-600 text-xl leading-none disabled:opacity-50"
aria-label="close"
>×</button>
</div>
<form
className="p-5 space-y-3"
onSubmit={(e) => { e.preventDefault(); void editProjM.mutateAsync(); }}
>
<Field label="Name *">
<input className="asf-input" required autoFocus
placeholder="ASF Platform MVP"
value={epName}
onChange={(e) => setEpName(e.target.value)} />
</Field>
<Field label={<>Key * <span className="text-[10px] text-slate-400">(short uppercase code)</span></>}>
<input className="asf-input font-mono text-sm uppercase tracking-wide"
placeholder="ASF"
maxLength={20}
value={epKey}
onChange={(e) => setEpKey(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""))} />
<div className={cn("text-[11px] mt-1", epKeyHint && epKeyHint.startsWith("⚠") ? "text-red-600" : "text-emerald-600")}>{epKeyHint ?? "2-20 characters. Uppercase letters/digits only — starts with a letter."}</div>
</Field>
<Field label="Default branch">
<input className="asf-input font-mono text-xs"
placeholder="main"
value={epBranch}
onChange={(e) => setEpBranch(e.target.value)} />
</Field>
<Field label="Description">
<textarea className="asf-input" rows={3}
placeholder="Scope, goals and non-goals of this project."
value={epDesc}
onChange={(e) => setEpDesc(e.target.value)} />
</Field>
<Field label="Status">
<Toggle label="Active" hint="Project appears in pickers and filters." checked={epActive} onChange={setEpActive} />
</Field>
{editProjM.isError && (
<div className="rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{editProjM.error instanceof Error ? editProjM.error.message : "Failed to update project."}
</div>
)}
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" disabled={editProjM.isPending} onClick={() => setShowEditProj(false)}>Cancel</Button>
<Button type="submit" variant="primary" size="sm"
disabled={editProjM.isPending || !epName || !/^[A-Z][A-Z0-9]{1,19}$/.test(epKey)}>
{editProjM.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Saving…</>
) : (<><Save className="w-4 h-4" /> Save changes</>)}
</Button>
</div>
</form>
</div>
</div>
)}
{/* ---------- Delete Project confirm ---------- */}
{delProj && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => !deleteProjM.isPending && setDelProj(null)}>
<div
className="w-full max-w-md rounded-xl bg-white border border-slate-200 shadow-xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-red-50 grid place-items-center shrink-0">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-slate-900">Delete project?</h3>
<p className="mt-1 text-sm text-slate-500">
Permanently delete <span className="font-semibold text-slate-700">{delProj.name}</span>{" "}
{delProj.key && <span className="font-mono text-xs">([{delProj.key}])</span>}. All artifacts in this project will be removed. This cannot be undone.
</p>
</div>
</div>
{deleteProjM.isError && (
<div className="mx-5 mt-4 rounded-lg border border-red-200 bg-red-50 text-red-700 text-sm px-3 py-2">
{deleteProjM.error instanceof Error ? deleteProjM.error.message : "Failed to delete project."}
</div>
)}
<div className="px-5 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" disabled={deleteProjM.isPending} onClick={() => setDelProj(null)}>Cancel</Button>
<Button
type="button"
variant="primary"
size="sm"
className="!bg-red-600 hover:!bg-red-700 !text-white"
disabled={deleteProjM.isPending}
onClick={() => void deleteProjM.mutateAsync(String(delProj.id))}
>
{deleteProjM.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Deleting…</>
) : (<><Trash2 className="w-4 h-4" /> Delete forever</>)}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
function Field({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
return (
<label className="block">
<div className="text-xs font-medium text-slate-500 mb-1.5">{label}</div>
{children}
</label>
);
}
function Toggle({
label, hint, checked, defaultChecked, onChange,
}: {
label: string;
hint?: string;
checked?: boolean;
defaultChecked?: boolean;
onChange?: (v: boolean) => void;
}) {
const [internal, setInternal] = useState<boolean>(Boolean(defaultChecked));
const value = checked !== undefined ? checked : internal;
const toggle = () => {
const next = !value;
if (checked === undefined) setInternal(next);
onChange?.(next);
};
return (
<div className="flex items-start justify-between gap-4 p-3 rounded-lg border border-slate-100 hover:border-slate-200">
<div className="min-w-0">
<div className="text-sm font-medium text-slate-800">{label}</div>
{hint && <div className="text-xs text-slate-500 mt-0.5">{hint}</div>}
</div>
<button
type="button"
onClick={toggle}
role="switch"
aria-checked={value}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-300",
value ? "bg-brand-600" : "bg-slate-200",
)}
>
<span
className={cn(
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition-transform",
value ? "translate-x-5" : "translate-x-0",
)}
/>
</button>
</div>
);
}
+390
View File
@@ -0,0 +1,390 @@
"use client";
import React, { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import ReactFlow, {
Background,
Controls,
MiniMap,
MarkerType,
Handle,
Position,
type Node,
type Edge,
type NodeProps,
} from "reactflow";
import "reactflow/dist/style.css";
import {
GitBranch,
ZoomIn,
ZoomOut,
Maximize2,
RefreshCw,
Layers,
ChevronRight,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge, ArtifactTypeBadge, JobStatusBadge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { traceability, artifacts, projects } from "@/lib/api";
import { cn, shortId } from "@/lib/utils";
import type { TraceGraph, ArtifactType } from "@/lib/types";
const DEMO_GRAPH: TraceGraph = {
nodes: [
{ id: "REQ-441", type: "requirement", label: "Multi-tenant RBAC", project_id: "p1", status: "in_progress", version: 3 },
{ id: "TASK-921", type: "task", label: "Tenant middleware", project_id: "p1", status: "done" },
{ id: "TASK-922", type: "task", label: "Policy evaluator", project_id: "p1", status: "done" },
{ id: "PR-214", type: "pull_request", label: "feat(rbac) #214", project_id: "p1", status: "done" },
{ id: "TEST-88", type: "test", label: "tenant_isolation.py", project_id: "p1", status: "done" },
{ id: "BUILD-4412", type: "build", label: "build #4412", project_id: "p1", status: "done" },
{ id: "DEP-44", type: "deployment", label: "asf-core v0.4.0 → prod", project_id: "p1", status: "done" },
],
edges: [
{ id: "e1", source: "TASK-921", target: "REQ-441", label: "implements" },
{ id: "e2", source: "TASK-922", target: "REQ-441", label: "implements" },
{ id: "e3", source: "PR-214", target: "TASK-921", label: "generated_from" },
{ id: "e4", source: "PR-214", target: "TASK-922", label: "generated_from" },
{ id: "e5", source: "TEST-88", target: "PR-214", label: "verifies" },
{ id: "e6", source: "BUILD-4412", target: "PR-214", label: "generated_from" },
{ id: "e7", source: "DEP-44", target: "BUILD-4412", label: "deployed_as" },
],
mock_fields: ["*"],
};
const TYPE_COLORS: Record<ArtifactType | string, { node: string; ring: string; text: string }> = {
requirement: { node: "#e0efff", ring: "#006fe0", text: "#084a8f" },
epic: { node: "#ede9fe", ring: "#7c3aed", text: "#4c1d95" },
story: { node: "#e0efff", ring: "#006fe0", text: "#084a8f" },
task: { node: "#e0f2fe", ring: "#0284c7", text: "#075985" },
architecture_decision: { node: "#ede9fe", ring: "#7c3aed", text: "#4c1d95" },
document: { node: "#f1f5f9", ring: "#64748b", text: "#334155" },
source_file: { node: "#f1f5f9", ring: "#64748b", text: "#334155" },
commit: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
pull_request: { node: "#e0f2fe", ring: "#0284c7", text: "#075985" },
build: { node: "#fef3c7", ring: "#d97706", text: "#92400e" },
test: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
security_finding: { node: "#fee2e2", ring: "#dc2626", text: "#991b1b" },
deployment: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
release: { node: "#dcfce7", ring: "#16a34a", text: "#166534" },
runtime_event: { node: "#fef3c7", ring: "#d97706", text: "#92400e" },
incident: { node: "#fee2e2", ring: "#dc2626", text: "#991b1b" },
ai_analysis: { node: "#ede9fe", ring: "#7c3aed", text: "#4c1d95" },
};
function ArtifactNode({ data, selected }: NodeProps<{ label: string; type: ArtifactType | string; status?: string; version?: number }>) {
const c = TYPE_COLORS[data.type] ?? TYPE_COLORS.document;
return (
<div
className={cn(
"px-3 py-2 rounded-lg shadow-card border backdrop-blur min-w-[160px] transition-shadow",
selected && "ring-2 ring-offset-2 ring-brand-400",
)}
style={{ background: c.node, borderColor: c.ring + "44" }}
>
<Handle type="target" position={Position.Top} className="!bg-slate-300 !border-white !w-2.5 !h-2.5" />
<div className="flex items-center gap-2">
<div className="w-1.5 h-6 rounded-full" style={{ background: c.ring }} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<ArtifactTypeBadge type={data.type} />
{data.version !== undefined && <Badge tone="neutral">v{data.version}</Badge>}
</div>
<div className="mt-1 text-xs font-semibold leading-tight" style={{ color: c.text }}>
{data.label}
</div>
{data.status && (
<div className="mt-1">
<JobStatusBadge status={data.status} />
</div>
)}
</div>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-300 !border-white !w-2.5 !h-2.5" />
</div>
);
}
const nodeTypes = { artifact: ArtifactNode as unknown as React.ComponentType<NodeProps> };
function layoutLayered(graph: TraceGraph): { nodes: Node[]; edges: Edge[] } {
const nodeMap = new Map(graph.nodes.map((n) => [n.id, n]));
const outgoing = new Map<string, string[]>();
const incoming = new Map<string, string[]>();
graph.nodes.forEach((n) => {
outgoing.set(n.id, []);
incoming.set(n.id, []);
});
graph.edges.forEach((e) => {
outgoing.get(e.source)?.push(e.target);
incoming.get(e.target)?.push(e.source);
});
const depth = new Map<string, number>();
const visit = (id: string, d: number, seen: Set<string>) => {
if (seen.has(id)) return;
seen.add(id);
depth.set(id, Math.max(depth.get(id) ?? 0, d));
outgoing.get(id)?.forEach((next) => visit(next, d + 1, seen));
};
graph.nodes.forEach((n) => {
if ((incoming.get(n.id)?.length ?? 0) === 0) visit(n.id, 0, new Set());
});
graph.nodes.forEach((n) => {
if (!depth.has(n.id)) depth.set(n.id, 0);
});
const byDepth = new Map<number, string[]>();
depth.forEach((d, id) => {
if (!byDepth.has(d)) byDepth.set(d, []);
byDepth.get(d)!.push(id);
});
const nodes: Node[] = [];
const colW = 240;
const rowH = 110;
byDepth.forEach((ids, d) => {
ids.forEach((id, i) => {
const raw = nodeMap.get(id)!;
nodes.push({
id,
type: "artifact",
position: { x: d * colW, y: i * rowH },
data: { label: raw.label, type: raw.type, status: raw.status, version: raw.version },
});
});
});
const edges: Edge[] = graph.edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
label: e.label,
type: "smoothstep",
animated: false,
style: { stroke: "#94a3b8", strokeWidth: 1.5 },
labelStyle: { fontSize: 10, fill: "#64748b" },
labelBgStyle: { fill: "#ffffffcc" },
labelBgPadding: [4, 2],
markerEnd: { type: MarkerType.ArrowClosed, color: "#94a3b8" },
}));
return { nodes, edges };
}
export default function TraceabilityPage() {
const [rootId, setRootId] = useState<string>("");
const graphQ = useQuery({
queryKey: ["traceability", rootId],
queryFn: () => traceability.graph(rootId ? { artifact_id: rootId, max_depth: 4 } : { limit: 200 }),
retry: false,
refetchOnWindowFocus: false,
refetchInterval: 5000,
});
const artQ = useQuery({
queryKey: ["traceability-artifacts"],
queryFn: () => artifacts.list({ limit: 30 }),
retry: false,
refetchOnWindowFocus: false,
});
const projectsQ = useQuery({
queryKey: ["traceability-projects"],
queryFn: () => projects.list({ limit: 50 }),
retry: false,
refetchOnWindowFocus: false,
});
const apiResolved = graphQ.isFetched && graphQ.data !== undefined;
const rawGraph: TraceGraph = apiResolved ? graphQ.data! : DEMO_GRAPH;
const isMock = !apiResolved;
const { nodes, edges } = useMemo(() => layoutLayered(rawGraph), [rawGraph]);
const artifactsList = (artQ.data ?? []).length ? artQ.data! : [];
// Map project_id → pretty project name for the Inspector panel
const projectNameById = useMemo(() => {
const m = new Map<string, string>();
for (const p of projectsQ.data ?? []) {
if (p?.id) m.set(String(p.id), p.name ?? shortId(String(p.id)));
}
return m;
}, [projectsQ.data]);
const [selId, setSelId] = useState<string | null>(null);
const selectedNode = rawGraph.nodes.find((n) => n.id === selId);
const onPaneReady = useCallback(() => {}, []);
return (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<GitBranch className="w-6 h-6 text-brand-600" />
Traceability
</h1>
<p className="mt-1 text-sm text-slate-500">
End-to-end artifact graph: Requirement → Task → Pull Request → Build → Test → Deployment. Click a node to inspect.
</p>
</div>
<div className="flex items-center gap-2">
{isMock && <Badge tone="amber">Demo graph</Badge>}
<select
className="asf-input !py-1.5 !text-sm !w-auto"
value={rootId}
onChange={(e) => setRootId(e.target.value)}
>
<option value="">All projects</option>
{artifactsList.map((a) => (
<option key={a.id} value={a.id}>{a.external_id ?? shortId(a.id)} · {a.title.slice(0, 40)}</option>
))}
</select>
<Button variant="secondary" size="sm" onClick={() => void graphQ.refetch()}>
<RefreshCw className="w-4 h-4" /> Reload
</Button>
</div>
</header>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 xl:grid-cols-4 gap-4">
<Card className="xl:col-span-3 flex flex-col min-h-0 overflow-hidden">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layers className="w-4 h-4" />
Graph view
<Badge tone="neutral" className="ml-auto">
{rawGraph.nodes.length} nodes · {rawGraph.edges.length} edges
</Badge>
</CardTitle>
</CardHeader>
<CardBody className="p-0 flex-1 min-h-0 relative">
{nodes.length === 0 ? (
<div className="absolute inset-0 p-8">
<EmptyState title="Graph is empty" description="Create artifacts and relationships to populate the traceability view." />
</div>
) : (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onInit={onPaneReady}
onNodeClick={(_, n) => setSelId(n.id)}
onPaneClick={() => setSelId(null)}
fitView
fitViewOptions={{ padding: 0.3 }}
proOptions={{ hideAttribution: true }}
>
<Background gap={16} color="#e2e8f0" />
<Controls
className="!rounded-lg !border !border-slate-200 !shadow-card overflow-hidden"
showInteractive={false}
>
<button className="asf-btn-ghost !p-2" title="Zoom in"><ZoomIn className="w-4 h-4" /></button>
<button className="asf-btn-ghost !p-2" title="Zoom out"><ZoomOut className="w-4 h-4" /></button>
<button className="asf-btn-ghost !p-2" title="Fit"><Maximize2 className="w-4 h-4" /></button>
</Controls>
<MiniMap
pannable
zoomable
className="!rounded-lg !border !border-slate-200 !shadow-card"
nodeColor={(n) => (TYPE_COLORS[(n.data as { type?: string })?.type ?? "document"] ?? TYPE_COLORS.document).ring}
maskColor="rgba(15, 23, 42, 0.06)"
/>
</ReactFlow>
)}
</CardBody>
</Card>
<Card className="flex flex-col min-h-0">
<CardHeader>
<CardTitle>Inspector</CardTitle>
<Badge tone="neutral">{rawGraph.nodes.length} types</Badge>
</CardHeader>
<CardBody className="overflow-auto flex-1 min-h-0">
{selectedNode ? (
<div className="space-y-3">
<div>
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span className="asf-monospace text-xs text-slate-500">{selectedNode.id}</span>
<ArtifactTypeBadge type={selectedNode.type} />
</div>
<h2 className="text-base font-semibold text-slate-800 leading-tight">
{selectedNode.label}
</h2>
</div>
<dl className="text-sm space-y-1.5">
{selectedNode.status && (
<div className="flex items-center justify-between">
<dt className="text-slate-500">Status</dt>
<dd><JobStatusBadge status={selectedNode.status} /></dd>
</div>
)}
{selectedNode.version !== undefined && (
<div className="flex items-center justify-between">
<dt className="text-slate-500">Version</dt>
<dd className="asf-monospace">v{selectedNode.version}</dd>
</div>
)}
{selectedNode.project_id && (
<div className="flex items-center justify-between">
<dt className="text-slate-500">Project</dt>
<dd className="font-medium text-slate-800">
{projectNameById.get(String(selectedNode.project_id)) ?? (
<span className="asf-monospace text-xs">{shortId(selectedNode.project_id)}</span>
)}
</dd>
</div>
)}
{selectedNode.source && (
<div className="flex items-center justify-between">
<dt className="text-slate-500">Source</dt>
<dd>{selectedNode.source}</dd>
</div>
)}
</dl>
<div className="pt-2 flex flex-wrap gap-2">
<a href="/context" className="asf-btn-secondary inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg">
Open in Context <ChevronRight className="w-3 h-3" />
</a>
<a href="/requirements" className="asf-btn-ghost inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg">
Requirements
</a>
</div>
<div className="pt-3 border-t border-slate-100">
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">Connected edges</div>
<ul className="space-y-1 text-xs">
{rawGraph.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).map((e) => (
<li key={e.id} className="flex items-center gap-2 p-1.5 rounded hover:bg-slate-50">
<span className="asf-monospace text-slate-500">
{e.source === selectedNode.id ? "→" : "←"}
</span>
<span className="font-mono text-slate-700">{e.source === selectedNode.id ? e.target : e.source}</span>
<Badge tone="brand" className="ml-auto">{e.label}</Badge>
</li>
))}
</ul>
</div>
</div>
) : (
<EmptyState
title="Click a node"
description="Select any node on the graph to inspect its details and connections."
icon={GitBranch}
/>
)}
<div className="mt-4 pt-4 border-t border-slate-100">
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">Legend</div>
<ul className="grid grid-cols-1 gap-1.5 text-xs">
{Object.entries(TYPE_COLORS).slice(0, 10).map(([k, v]) => (
<li key={k} className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full border" style={{ background: v.node, borderColor: v.ring }} />
<span className="text-slate-700 capitalize">{k.replace("_", " ")}</span>
</li>
))}
</ul>
</div>
</CardBody>
</Card>
</div>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import React from "react";
import { BarChart3, Cpu, Clock, DollarSign, Users, Zap } from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { MetricCard } from "@/components/ui/MetricCard";
const MONTHS = ["Jul", "Aug", "Sep", "Oct", "Nov"];
const SERIES = [
{ label: "AI tokens (M)", data: [0.1, 0.8, 1.4, 2.9, 4.2], tone: "bg-violet-400" },
{ label: "Jobs executed", data: [120, 280, 412, 688, 904], tone: "bg-brand-400" },
];
function Bar({ label, values, tone }: { label: string; values: number[]; tone: string }) {
const max = Math.max(...values, 1);
return (
<div>
<div className="text-xs text-slate-500 mb-2 font-medium">{label}</div>
<div className="flex items-end gap-2 h-32">
{values.map((v, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div
className={["w-full rounded-t-md transition-all", tone].join(" ")}
style={{ height: `${(v / max) * 100}%`, minHeight: "4px" }}
title={`${v}`}
/>
<div className="text-[10px] text-slate-500">{MONTHS[i]}</div>
</div>
))}
</div>
</div>
);
}
const ROWS = [
{ name: "Tenant default", projects: 6, users: 14, jobs_7d: 412, tokens_7d: 2.1, cost_7d: 128.4 },
{ name: "Tenant acme-corp", projects: 2, users: 6, jobs_7d: 88, tokens_7d: 0.6, cost_7d: 24.1 },
{ name: "Tenant contoso-labs", projects: 3, users: 9, jobs_7d: 180, tokens_7d: 1.1, cost_7d: 52.7 },
];
export default function UsagePage() {
return (
<div className="p-6 lg:p-8 max-w-[1400px] mx-auto">
<header className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<BarChart3 className="w-6 h-6 text-brand-600" />
Usage
</h1>
<p className="mt-1 text-sm text-slate-500">
Tenant-level metering: AI tokens, background jobs, projects, seats and estimated cost.
</p>
</div>
<Badge tone="neutral">Monthly view</Badge>
</header>
<section className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<MetricCard label="Projects" value={11} tone="brand" icon={<BarChart3 className="w-5 h-5" />} />
<MetricCard label="Active users" value={29} tone="blue" icon={<Users className="w-5 h-5" />} />
<MetricCard label="Jobs (7d)" value={680} tone="green" icon={<Clock className="w-5 h-5" />} delta={12.4} />
<MetricCard label="AI tokens (7d, M)" value={3.8} tone="violet" icon={<Zap className="w-5 h-5" />} delta={18.7} />
<MetricCard label="Est. cost (7d, $)" value={205.2} tone="amber" icon={<DollarSign className="w-5 h-5" />} delta={9.2} />
</section>
<section className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<Card>
<CardHeader><CardTitle>Trend · AI tokens</CardTitle><Badge tone="violet">millions</Badge></CardHeader>
<CardBody>
<Bar label="" values={SERIES[0].data} tone={SERIES[0].tone} />
</CardBody>
</Card>
<Card>
<CardHeader><CardTitle>Trend · Jobs executed</CardTitle><Badge tone="brand">count</Badge></CardHeader>
<CardBody>
<Bar label="" values={SERIES[1].data} tone={SERIES[1].tone} />
</CardBody>
</Card>
</section>
<Card>
<CardHeader>
<CardTitle>Per-tenant breakdown</CardTitle>
<Badge tone="neutral">{ROWS.length} tenants</Badge>
</CardHeader>
<CardBody className="p-0">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-2">Tenant</th>
<th className="text-right px-4 py-2">Projects</th>
<th className="text-right px-4 py-2">Users</th>
<th className="text-right px-4 py-2">Jobs (7d)</th>
<th className="text-right px-4 py-2">Tokens (7d, M)</th>
<th className="text-right px-4 py-2">Cost (7d)</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{ROWS.map((r) => (
<tr key={r.name} className="hover:bg-slate-50">
<td className="px-4 py-2.5 font-medium text-slate-800">{r.name}</td>
<td className="px-4 py-2.5 text-right tabular-nums">{r.projects}</td>
<td className="px-4 py-2.5 text-right tabular-nums">{r.users}</td>
<td className="px-4 py-2.5 text-right tabular-nums">{r.jobs_7d}</td>
<td className="px-4 py-2.5 text-right tabular-nums">{r.tokens_7d.toFixed(2)}</td>
<td className="px-4 py-2.5 text-right tabular-nums">${r.cost_7d.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</CardBody>
</Card>
<div className="mt-4 flex items-center justify-end gap-3 text-xs text-slate-500">
<Badge tone="neutral"><Cpu className="w-3 h-3" /> GPU-s metered</Badge>
<Badge tone="neutral"><DollarSign className="w-3 h-3" /> Cost tables configurable per-tenant</Badge>
</div>
</div>
);
}