first
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# --- ASF Web (Next.js) ---
|
||||
NEXT_PUBLIC_ASF_CORE_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_APP_NAME=Azurian Software Factory
|
||||
NEXT_PUBLIC_APP_ENV=development
|
||||
|
||||
# --- Dev trust headers (bootstrap only) ---
|
||||
# These are sent to asf-core so the dev-mode trust-header path injects tenant context.
|
||||
# Remove in production and use real auth (JWT / session).
|
||||
NEXT_PUBLIC_DEV_TENANT_ID=00000000-0000-0000-0000-000000000001
|
||||
NEXT_PUBLIC_DEV_USER_ID=00000000-0000-0000-0000-000000000002
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Sparkles,
|
||||
Send,
|
||||
X,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { askAsf, jobs } from "@/lib/api";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge, JobStatusBadge } from "@/components/ui/Badge";
|
||||
import { cn, fmtDate, truncate } from "@/lib/utils";
|
||||
import type { Job } from "@/lib/types";
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "system" | "assistant";
|
||||
kind?: "question" | "info" | "success" | "error";
|
||||
content: string;
|
||||
ts: Date;
|
||||
jobId?: string;
|
||||
}
|
||||
|
||||
export function AskAsfCommandBar() {
|
||||
const open = useAppStore((s) => s.asfOpen);
|
||||
const closeAsf = useAppStore((s) => s.closeAsf);
|
||||
const openAsf = useAppStore((s) => s.openAsf);
|
||||
|
||||
const [q, setQ] = useState("");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{
|
||||
id: "welcome",
|
||||
role: "system",
|
||||
kind: "info",
|
||||
content:
|
||||
"Ask anything about your tenant data: requirements without tests, why a build failed, which services implement REQ-441, … This UI wires to the AI Gateway through asf-worker — in bootstrap you'll see a transparent MockProvider response.",
|
||||
ts: new Date(),
|
||||
},
|
||||
]);
|
||||
const [pollingJobId, setPollingJobId] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// open via global event + ⌘K
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const meta = e.metaKey || e.ctrlKey;
|
||||
if (meta && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
if (open) closeAsf(); else openAsf();
|
||||
}
|
||||
if (e.key === "Escape" && open) closeAsf();
|
||||
}
|
||||
function onOpenEv() { openAsf(); }
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("asf:open-command", onOpenEv);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("asf:open-command", onOpenEv);
|
||||
};
|
||||
}, [open, openAsf, closeAsf]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}, [open]);
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (question: string) =>
|
||||
askAsf.query({ question }),
|
||||
onMutate: (question) => {
|
||||
setMessages((m) => [
|
||||
...m,
|
||||
{ id: "u" + Date.now(), role: "user", kind: "question", content: question, ts: new Date() },
|
||||
]);
|
||||
},
|
||||
onSuccess: (resp) => {
|
||||
setMessages((m) => [
|
||||
...m,
|
||||
{
|
||||
id: "subm" + Date.now(),
|
||||
role: "system",
|
||||
kind: "info",
|
||||
content: `Query accepted. Polling job ${resp.job_id.slice(0, 8)}… — status: queued`,
|
||||
ts: new Date(),
|
||||
jobId: resp.job_id,
|
||||
},
|
||||
]);
|
||||
setPollingJobId(resp.job_id);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setMessages((m) => [
|
||||
...m,
|
||||
{ id: "e" + Date.now(), role: "system", kind: "error", content: err.message, ts: new Date() },
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
// long-poll the accepted job until terminal
|
||||
useQuery({
|
||||
queryKey: ["ask-job", pollingJobId],
|
||||
enabled: !!pollingJobId,
|
||||
refetchInterval: (q) => (q.state.data?.status === "completed" || q.state.data?.status === "failed" || q.state.data?.status === "cancelled" ? false : 1500),
|
||||
queryFn: async (): Promise<Job | undefined> => {
|
||||
if (!pollingJobId) return undefined;
|
||||
const job = await jobs.get(pollingJobId);
|
||||
if (job && (job.status === "completed" || job.status === "failed" || job.status === "cancelled")) {
|
||||
setPollingJobId(null);
|
||||
setMessages((m) => {
|
||||
// avoid adding duplicate result messages
|
||||
if (m.some(x => x.id === `res-${job.id}`)) return m;
|
||||
if (job.status === "completed" && job.result) {
|
||||
const answer =
|
||||
(job.result as Record<string, unknown>).answer as string ||
|
||||
JSON.stringify(job.result, null, 2);
|
||||
return [
|
||||
...m,
|
||||
{
|
||||
id: `res-${job.id}`,
|
||||
role: "assistant",
|
||||
kind: "success",
|
||||
content: answer,
|
||||
ts: new Date(),
|
||||
jobId: job.id,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (job.status === "failed") {
|
||||
return [
|
||||
...m,
|
||||
{
|
||||
id: `fail-${job.id}`,
|
||||
role: "system",
|
||||
kind: "error",
|
||||
content: job.error || "Unknown AI job failure.",
|
||||
ts: new Date(),
|
||||
jobId: job.id,
|
||||
},
|
||||
];
|
||||
}
|
||||
return m;
|
||||
});
|
||||
}
|
||||
return job;
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!q.trim()) return;
|
||||
submitMutation.mutate(q.trim());
|
||||
setQ("");
|
||||
}
|
||||
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] px-4 bg-slate-950/40 backdrop-blur-[2px] animate-fade-in"
|
||||
onClick={closeAsf}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-2xl bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center gap-3 px-5 py-3.5 border-b border-slate-200 bg-gradient-to-r from-brand-50/60 to-transparent">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-500 to-accent grid place-items-center text-white shadow-sm">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-slate-800 leading-tight">Ask ASF</div>
|
||||
<div className="text-[11px] text-slate-500 leading-tight">
|
||||
Context-aware queries backed by the ASF AI Gateway
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone="violet">
|
||||
<MessageCircle className="w-3 h-3 mr-1" />
|
||||
mock provider
|
||||
</Badge>
|
||||
<button className="asf-btn-ghost !p-1.5" onClick={closeAsf} aria-label="Close">
|
||||
<X className="w-4.5 h-4.5 w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* messages */}
|
||||
<div className="px-5 py-4 space-y-3 max-h-[50vh] overflow-y-auto">
|
||||
{messages.map((m) => (
|
||||
<MessageBubble key={m.id} m={m} />
|
||||
))}
|
||||
{submitMutation.isPending && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 animate-fade-in">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Submitting query to ASF AI job queue…
|
||||
</div>
|
||||
)}
|
||||
{pollingJobId && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 animate-fade-in">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Polling job <code className="asf-monospace text-slate-700 bg-slate-50 rounded px-1.5 py-0.5 border border-slate-200">{pollingJobId.slice(0,12)}</code> until terminal state…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* toggle for expanded mini-chat */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="hidden w-full items-center justify-center gap-1 py-2 text-xs text-slate-500 border-t border-slate-100 hover:bg-slate-50"
|
||||
>
|
||||
{expanded ? <>Collapse <ChevronDown className="w-3 h-3" /></> : <>Expand <ChevronUp className="w-3 h-3" /></>}
|
||||
</button>
|
||||
|
||||
{/* input */}
|
||||
<form onSubmit={onSubmit} className="px-5 py-3.5 border-t border-slate-200 bg-slate-50/50 flex gap-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={2}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) onSubmit(e);
|
||||
}}
|
||||
placeholder={
|
||||
expanded
|
||||
? 'e.g. "Show requirements without tests", "Which services implement REQ-441?"'
|
||||
: "Ask ASF… (⌘↩ to send)"
|
||||
}
|
||||
className="asf-input resize-none"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="self-end shrink-0"
|
||||
disabled={!q.trim() || submitMutation.isPending}
|
||||
>
|
||||
{submitMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
<span className="hidden md:inline">Send</span>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="px-5 pb-4 flex flex-wrap gap-2 bg-slate-50/50">
|
||||
{[
|
||||
"Show requirements without tests.",
|
||||
"Why did yesterday's build fail?",
|
||||
"Which services implement REQ-441?",
|
||||
"Generate tests for TASK-921.",
|
||||
].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setQ(s)}
|
||||
className="asf-chip hover:bg-brand-50 hover:text-brand-700 hover:border-brand-100 transition-colors"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ m }: { m: ChatMessage }) {
|
||||
const Icon =
|
||||
m.kind === "success" ? CheckCircle2 :
|
||||
m.kind === "error" ? AlertCircle :
|
||||
m.kind === "question" ? MessageCircle : MessageCircle;
|
||||
const tone =
|
||||
m.role === "assistant" ? "bg-brand-50 border-brand-100" :
|
||||
m.role === "user" ? "bg-slate-50 border-slate-200" :
|
||||
m.kind === "error" ? "bg-red-50 border-red-100" :
|
||||
"bg-white border-slate-200";
|
||||
const toneIcon =
|
||||
m.kind === "success" ? "text-emerald-600" :
|
||||
m.kind === "error" ? "text-red-600" :
|
||||
m.role === "user" ? "text-slate-600" :
|
||||
"text-brand-600";
|
||||
return (
|
||||
<div className={cn("flex gap-3 animate-fade-in")}>
|
||||
<div className={cn("w-7 h-7 rounded-lg grid place-items-center shrink-0 border", tone)}>
|
||||
<Icon className={cn("w-4 h-4", toneIcon)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-[11px] text-slate-500">
|
||||
<span className="font-medium capitalize text-slate-600">{m.role}</span>
|
||||
<span>· {fmtDate(m.ts)}</span>
|
||||
{m.jobId && <JobStatusBadge status={"queued"} />}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"mt-1 rounded-lg border text-sm px-3 py-2 whitespace-pre-wrap break-words",
|
||||
tone,
|
||||
m.role === "assistant" ? "asf-monospace !font-sans leading-relaxed" : "",
|
||||
)}>
|
||||
{truncate(m.content, 1200)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ClipboardList,
|
||||
Search,
|
||||
Code2,
|
||||
GitBranch,
|
||||
Bot,
|
||||
GitFork,
|
||||
Rocket,
|
||||
ShieldCheck,
|
||||
Activity,
|
||||
Plug,
|
||||
BarChart3,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
export const NAV: NavItem[] = [
|
||||
{ href: "/", label: "Overview", icon: LayoutDashboard },
|
||||
{ href: "/requirements", label: "Requirements", icon: ClipboardList },
|
||||
{ href: "/context", label: "Context Explorer", icon: Search },
|
||||
{ href: "/code", label: "Code", icon: Code2 },
|
||||
{ href: "/traceability", label: "Traceability", icon: GitBranch },
|
||||
{ href: "/ask-asf", label: "Ask ASF", icon: Sparkles, badge: "AI" },
|
||||
{ href: "/jobs", label: "Jobs", icon: Activity },
|
||||
{ href: "/ai-agents", label: "AI Agents", icon: Bot, badge: "α" },
|
||||
{ href: "/pipelines", label: "Pipelines", icon: GitFork },
|
||||
{ href: "/deployments", label: "Deployments", icon: Rocket },
|
||||
{ href: "/quality", label: "Quality", icon: ShieldCheck },
|
||||
{ href: "/observability", label: "Observability", icon: Activity },
|
||||
{ href: "/integrations", label: "Integrations", icon: Plug },
|
||||
{ href: "/usage", label: "Usage", icon: BarChart3 },
|
||||
{ href: "/settings", label: "Settings", icon: Settings2 },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const path = usePathname();
|
||||
return (
|
||||
<aside className="hidden lg:flex w-64 shrink-0 border-r border-slate-200/80 bg-white/60 backdrop-blur-sm flex-col">
|
||||
<div className="h-16 flex items-center gap-3 px-5 border-b border-slate-200/80">
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-brand-500 to-accent shadow-soft grid place-items-center text-white font-bold">
|
||||
A
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-800 leading-tight">
|
||||
{process.env.NEXT_PUBLIC_APP_NAME || "ASF"}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 leading-tight">
|
||||
Software Factory
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto p-3 space-y-0.5">
|
||||
{NAV.map(({ href, label, icon: Icon, badge }) => {
|
||||
const active = href === "/" ? path === "/" : path.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn("asf-nav-item", active && "asf-nav-item-active")}
|
||||
>
|
||||
<Icon className="w-4.5 h-4.5 w-[18px] h-[18px] shrink-0" />
|
||||
<span className="flex-1">{label}</span>
|
||||
{badge && (
|
||||
<span className="asf-chip border-accent/20 bg-accent/10 text-accent-muted !px-1.5">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-slate-200/80">
|
||||
<button
|
||||
onClick={() => {
|
||||
// keyboard shortcut is handled in layout; this button also triggers.
|
||||
const ev = new CustomEvent("asf:open-command");
|
||||
window.dispatchEvent(ev);
|
||||
}}
|
||||
className="w-full asf-btn-secondary justify-between !px-3"
|
||||
type="button"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-accent" />
|
||||
<span className="text-slate-700">Ask ASF…</span>
|
||||
</span>
|
||||
<kbd className="asf-chip !text-[10px]">⌘ K</kbd>
|
||||
</button>
|
||||
<p className="mt-3 text-[11px] text-slate-400 leading-relaxed">
|
||||
Tip: every lifecycle element in ASF is an <span className="font-mono text-slate-600 bg-slate-100 rounded px-1">Artifact</span>.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Building2,
|
||||
FolderKanban,
|
||||
Bell,
|
||||
Search,
|
||||
ChevronDown,
|
||||
UserCircle,
|
||||
} from "lucide-react";
|
||||
import { orgs, projects } from "@/lib/api";
|
||||
import { Dropdown, type Option } from "@/components/ui/Dropdown";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Topbar({ className }: { className?: string }) {
|
||||
const setOrg = useAppStore((s) => s.setOrg);
|
||||
const setProject = useAppStore((s) => s.setProject);
|
||||
const currentOrg = useAppStore((s) => s.org_id);
|
||||
const currentProject = useAppStore((s) => s.project_id);
|
||||
const openAsf = useAppStore((s) => s.openAsf);
|
||||
|
||||
const { data: orgList = [] } = useQuery({
|
||||
queryKey: ["orgs"],
|
||||
queryFn: () => orgs.list({ limit: 50 }),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: projectList = [] } = useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: () => projects.list({ limit: 100 }),
|
||||
initialData: [],
|
||||
enabled: !!currentOrg,
|
||||
});
|
||||
|
||||
const orgOptions: Option<string>[] = orgList.map((o) => ({
|
||||
value: o.id,
|
||||
label: o.name,
|
||||
description: o.slug,
|
||||
}));
|
||||
if (orgOptions.length === 0) {
|
||||
// Dev-mode synthetic org (matches NEXT_PUBLIC_DEV_TENANT_ID)
|
||||
orgOptions.push({
|
||||
value: currentOrg || "00000000-0000-0000-0000-000000000001",
|
||||
label: "Azurian Labs",
|
||||
description: "azurian-labs (bootstrap demo)",
|
||||
});
|
||||
}
|
||||
|
||||
const projectOptions: Option<string>[] = projectList.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.key} · ${p.name}`,
|
||||
description: p.description || undefined,
|
||||
}));
|
||||
// Add a synthetic "All projects" option
|
||||
projectOptions.unshift({
|
||||
value: "__all__",
|
||||
label: "All projects",
|
||||
description: "Tenant-wide view",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProject && projectList.length > 0) {
|
||||
setProject(projectList[0].id);
|
||||
}
|
||||
}, [currentProject, projectList, setProject]);
|
||||
|
||||
return (
|
||||
<header className={cn(
|
||||
"h-16 shrink-0 border-b border-slate-200/80 bg-white/80 backdrop-blur",
|
||||
"flex items-center gap-4 px-4 md:px-6",
|
||||
className,
|
||||
)}>
|
||||
{/* Mobile brand */}
|
||||
<div className="lg:hidden flex items-center gap-2 font-semibold text-slate-800">
|
||||
<div className="w-8 h-8 rounded-md bg-gradient-to-br from-brand-500 to-accent text-white grid place-items-center text-sm font-bold">
|
||||
A
|
||||
</div>
|
||||
ASF
|
||||
</div>
|
||||
|
||||
<div className="flex-1 max-w-xl hidden md:block">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAsf}
|
||||
className="w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-slate-500
|
||||
bg-slate-50 hover:bg-slate-100 border border-slate-200 transition-colors text-left"
|
||||
>
|
||||
<Search className="w-4 h-4 text-slate-400 group-hover:text-slate-600" />
|
||||
<span className="flex-1">Search artifacts, projects, requirements… or press <span className="font-medium text-slate-600">⌘ K</span></span>
|
||||
<kbd className="asf-chip !text-[10px]">Search</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 md:gap-3">
|
||||
<Dropdown
|
||||
label="Organization"
|
||||
size="sm"
|
||||
icon={<Building2 className="w-4 h-4" />}
|
||||
options={orgOptions}
|
||||
value={currentOrg}
|
||||
onChange={(v) => {
|
||||
setOrg(v);
|
||||
setProject(undefined);
|
||||
}}
|
||||
/>
|
||||
<Dropdown
|
||||
label="Project"
|
||||
size="sm"
|
||||
icon={<FolderKanban className="w-4 h-4" />}
|
||||
options={projectOptions}
|
||||
value={currentProject === undefined ? "__all__" : currentProject}
|
||||
onChange={(v) => setProject(v === "__all__" ? undefined : v)}
|
||||
/>
|
||||
<Button variant="ghost" size="md" aria-label="Notifications">
|
||||
<Bell className="w-4.5 h-4.5 w-[18px] h-[18px]" />
|
||||
</Button>
|
||||
<div className="h-9 w-9 rounded-full bg-slate-100 border border-slate-200 grid place-items-center text-slate-600">
|
||||
<UserCircle className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type BadgeTone =
|
||||
| "neutral"
|
||||
| "brand"
|
||||
| "green"
|
||||
| "amber"
|
||||
| "red"
|
||||
| "violet"
|
||||
| "blue";
|
||||
|
||||
const TONES: Record<BadgeTone, string> = {
|
||||
neutral: "bg-slate-100 text-slate-700 border-slate-200",
|
||||
brand: "bg-brand-50 text-brand-700 border-brand-100",
|
||||
green: "bg-emerald-50 text-emerald-700 border-emerald-100",
|
||||
amber: "bg-amber-50 text-amber-700 border-amber-100",
|
||||
red: "bg-red-50 text-red-700 border-red-100",
|
||||
violet: "bg-violet-50 text-violet-700 border-violet-100",
|
||||
blue: "bg-sky-50 text-sky-700 border-sky-100",
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
tone = "neutral",
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLSpanElement> & { tone?: BadgeTone }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full border",
|
||||
TONES[tone],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function JobStatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, BadgeTone> = {
|
||||
queued: "neutral",
|
||||
running: "blue",
|
||||
completed: "green",
|
||||
failed: "red",
|
||||
cancelled: "amber",
|
||||
};
|
||||
return <Badge tone={map[status] ?? "neutral"}>{status}</Badge>;
|
||||
}
|
||||
|
||||
export function ArtifactTypeBadge({ type }: { type: string }) {
|
||||
const map: Record<string, BadgeTone> = {
|
||||
requirement: "brand",
|
||||
epic: "violet",
|
||||
story: "brand",
|
||||
task: "blue",
|
||||
architecture_decision: "violet",
|
||||
document: "neutral",
|
||||
source_file: "neutral",
|
||||
commit: "green",
|
||||
pull_request: "blue",
|
||||
build: "amber",
|
||||
test: "green",
|
||||
security_finding: "red",
|
||||
deployment: "green",
|
||||
release: "green",
|
||||
runtime_event: "amber",
|
||||
incident: "red",
|
||||
ai_analysis: "violet",
|
||||
};
|
||||
return <Badge tone={map[type] ?? "neutral"}>{type}</Badge>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
||||
type Size = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
as?: "button" | "a";
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const variantClass: Record<Variant, string> = {
|
||||
primary: "asf-btn-primary",
|
||||
secondary: "asf-btn-secondary",
|
||||
ghost: "asf-btn-ghost",
|
||||
danger: "asf-btn bg-red-50 text-red-700 hover:bg-red-100 border-red-100",
|
||||
};
|
||||
|
||||
const sizeClass: Record<Size, string> = {
|
||||
sm: "text-xs px-2.5 py-1.5",
|
||||
md: "text-sm px-3.5 py-2",
|
||||
lg: "text-base px-5 py-2.5",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className,
|
||||
as = "button",
|
||||
href,
|
||||
children,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const cls = cn(variantClass[variant], sizeClass[size], className);
|
||||
if (as === "a" && href) {
|
||||
return (
|
||||
<a href={href} className={cls} {...(rest as unknown as React.AnchorHTMLAttributes<HTMLAnchorElement>)}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button className={cls} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("asf-card", className)} {...rest}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("asf-card-header", className)} {...rest}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("asf-card-title", className)} {...rest}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardBody({
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("asf-card-body", className)} {...rest}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardFooter({
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("px-5 py-3 border-t border-slate-100 text-xs text-slate-500 flex items-center justify-between", className)} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface Option<T = string> {
|
||||
value: T;
|
||||
label: React.ReactNode;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function Dropdown<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select...",
|
||||
className,
|
||||
label,
|
||||
size = "md",
|
||||
tone = "secondary",
|
||||
icon,
|
||||
empty = "No options",
|
||||
}: {
|
||||
options: Option<T>[];
|
||||
value?: T;
|
||||
onChange: (v: T) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
label?: string;
|
||||
size?: "sm" | "md";
|
||||
tone?: "secondary" | "ghost";
|
||||
icon?: React.ReactNode;
|
||||
empty?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function h(e: MouseEvent) {
|
||||
if (!ref.current) return;
|
||||
if (!ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", h);
|
||||
return () => document.removeEventListener("mousedown", h);
|
||||
}, []);
|
||||
|
||||
const current = options.find((o) => o.value === value);
|
||||
const btnClass = cn(
|
||||
"inline-flex items-center gap-2 border rounded-lg transition-colors",
|
||||
size === "sm" ? "text-xs px-2.5 py-1.5" : "text-sm px-3 py-2",
|
||||
tone === "secondary"
|
||||
? "bg-white border-slate-200 text-slate-700 hover:bg-slate-50"
|
||||
: "bg-transparent border-transparent text-slate-700 hover:bg-slate-100",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("relative inline-block text-left", className)} ref={ref}>
|
||||
{label && <div className="text-[11px] uppercase tracking-wide text-slate-500 mb-1">{label}</div>}
|
||||
<button
|
||||
type="button"
|
||||
className={btnClass}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{icon && <span className="text-slate-500">{icon}</span>}
|
||||
<span className={cn("truncate", !current && "text-slate-400")}>
|
||||
{current?.label ?? placeholder}
|
||||
</span>
|
||||
<ChevronDown className={cn("w-4 h-4 text-slate-400 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-30 mt-1 right-0 min-w-[240px] rounded-xl bg-white border border-slate-200 shadow-soft overflow-hidden animate-fade-in">
|
||||
{options.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-slate-500 text-center">{empty}</div>
|
||||
) : (
|
||||
<ul className="max-h-80 overflow-auto py-1">
|
||||
{options.map((o) => (
|
||||
<li key={String(o.value)}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 text-sm transition-colors",
|
||||
value === o.value
|
||||
? "bg-brand-50 text-brand-700 font-medium"
|
||||
: "text-slate-700 hover:bg-slate-50",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(o.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="truncate">{o.label}</div>
|
||||
{o.description && (
|
||||
<div className="text-xs text-slate-500 mt-0.5 truncate">{o.description}</div>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Inbox, ShieldCheck } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./Button";
|
||||
|
||||
export function EmptyState({
|
||||
title = "Nothing here yet",
|
||||
description = "Create some data in ASF or wait a moment for it to load.",
|
||||
icon: Icon = Inbox,
|
||||
action,
|
||||
actionLabel,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
action?: () => void;
|
||||
actionLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center py-14 px-6 text-center border border-dashed border-slate-200 rounded-xl bg-slate-50/60 animate-fade-in",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-white border border-slate-200 grid place-items-center text-slate-500 shadow-sm mb-4">
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-slate-800 mb-1">{title}</h3>
|
||||
<p className="text-sm text-slate-500 max-w-md mb-5">{description}</p>
|
||||
{action && actionLabel && (
|
||||
<Button variant="primary" size="sm" onClick={action}>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { TrendingUp, TrendingDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardBody } from "./Card";
|
||||
import { Badge } from "./Badge";
|
||||
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
tone = "neutral",
|
||||
icon,
|
||||
subtitle,
|
||||
isPercentage,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
delta?: number; // percentage change, e.g. +5.2
|
||||
tone?: "neutral" | "brand" | "green" | "red" | "amber" | "violet" | "blue";
|
||||
icon?: React.ReactNode;
|
||||
subtitle?: string;
|
||||
isPercentage?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const showValue = isPercentage
|
||||
? `${Number(value).toFixed(1)}%`
|
||||
: typeof value === "number"
|
||||
? value.toLocaleString()
|
||||
: value;
|
||||
|
||||
return (
|
||||
<Card className={cn("relative overflow-hidden", className)}>
|
||||
<CardBody className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">{label}</div>
|
||||
<div className="mt-1.5 text-2xl font-semibold text-slate-900 tabular-nums">
|
||||
{showValue}
|
||||
</div>
|
||||
{subtitle !== undefined && (
|
||||
<div className="mt-1 text-xs text-slate-500">{subtitle}</div>
|
||||
)}
|
||||
{delta !== undefined && (
|
||||
<div className="mt-2 inline-flex items-center gap-1 text-xs">
|
||||
{delta >= 0 ? (
|
||||
<>
|
||||
<TrendingUp className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span className="text-emerald-700 font-medium">+{delta.toFixed(1)}%</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrendingDown className="w-3.5 h-3.5 text-red-600" />
|
||||
<span className="text-red-700 font-medium">{delta.toFixed(1)}%</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-slate-400 ml-1">vs last period</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-lg grid place-items-center shrink-0 border",
|
||||
tone === "brand" && "bg-brand-50 border-brand-100 text-brand-600",
|
||||
tone === "green" && "bg-emerald-50 border-emerald-100 text-emerald-600",
|
||||
tone === "red" && "bg-red-50 border-red-100 text-red-600",
|
||||
tone === "amber" && "bg-amber-50 border-amber-100 text-amber-600",
|
||||
tone === "violet" && "bg-violet-50 border-violet-100 text-violet-600",
|
||||
tone === "blue" && "bg-sky-50 border-sky-100 text-sky-600",
|
||||
tone === "neutral" && "bg-slate-50 border-slate-200 text-slate-500",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricPill({
|
||||
label,
|
||||
value,
|
||||
tone = "neutral",
|
||||
isPercentage,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
tone?: "neutral" | "brand" | "green" | "red" | "amber" | "blue";
|
||||
isPercentage?: boolean;
|
||||
}) {
|
||||
const shown = isPercentage ? `${Number(value).toFixed(1)}%` : value;
|
||||
const toneMap: Record<typeof tone, string> = {
|
||||
neutral: "bg-slate-100 text-slate-700",
|
||||
brand: "bg-brand-50 text-brand-700",
|
||||
blue: "bg-sky-50 text-sky-700",
|
||||
green: "bg-emerald-50 text-emerald-700",
|
||||
red: "bg-red-50 text-red-700",
|
||||
amber: "bg-amber-50 text-amber-700",
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">{label}</span>
|
||||
<Badge tone={tone === "neutral" ? "neutral" : tone === "brand" ? "brand" : tone === "green" ? "green" : tone === "red" ? "red" : "amber"}>
|
||||
<span className="tabular-nums font-semibold">{shown}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Donut-style progress ring.
|
||||
*
|
||||
* Used in the Overview dashboard for things like implementation coverage,
|
||||
* test coverage, project health score, traceability score.
|
||||
*/
|
||||
export function ProgressRing({
|
||||
value,
|
||||
size = 80,
|
||||
stroke = 8,
|
||||
tone = "brand",
|
||||
label,
|
||||
sublabel,
|
||||
className,
|
||||
}: {
|
||||
value: number; // 0..100
|
||||
size?: number;
|
||||
stroke?: number;
|
||||
tone?: "brand" | "green" | "amber" | "red" | "violet";
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const r = (size - stroke) / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
const clamped = Math.max(0, Math.min(100, Number(value) || 0));
|
||||
const offset = c - (clamped / 100) * c;
|
||||
|
||||
const toneMap: Record<string, string> = {
|
||||
brand: "text-brand-600",
|
||||
green: "text-emerald-600",
|
||||
amber: "text-amber-600",
|
||||
red: "text-red-600",
|
||||
violet: "text-violet-600",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-4", className)}>
|
||||
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className={cn("-rotate-90", toneMap[tone])}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
stroke="currentColor"
|
||||
strokeOpacity={0.12}
|
||||
fill="transparent"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
stroke="currentColor"
|
||||
fill="transparent"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={c}
|
||||
strokeDashoffset={offset}
|
||||
className="transition-all duration-500 ease-out"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 grid place-items-center">
|
||||
<div className="text-sm font-semibold tabular-nums text-slate-900">
|
||||
{clamped.toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(label || sublabel) && (
|
||||
<div className="min-w-0">
|
||||
{label && <div className="text-sm font-medium text-slate-800">{label}</div>}
|
||||
{sublabel && <div className="text-xs text-slate-500 mt-0.5">{sublabel}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -0,0 +1,18 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ["lucide-react"],
|
||||
experimental: {
|
||||
optimizePackageImports: ["lucide-react"],
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/v1/:path*",
|
||||
destination: `${process.env.NEXT_PUBLIC_ASF_CORE_URL || "http://localhost:8000"}/api/v1/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
Generated
+6909
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "asf-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Azurian Software Factory SaaS UI shell",
|
||||
"scripts": {
|
||||
"dev": "next dev --hostname 0.0.0.0 -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start --hostname 0.0.0.0 -p 3000",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.56.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.445.0",
|
||||
"next": "14.2.15",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"reactflow": "^11.11.4",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.15",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface AppState {
|
||||
org_id?: string;
|
||||
project_id?: string;
|
||||
asfOpen: boolean; // Ask ASF command / chat bar
|
||||
|
||||
setOrg: (org_id?: string) => void;
|
||||
setProject: (project_id?: string) => void;
|
||||
openAsf: () => void;
|
||||
closeAsf: () => void;
|
||||
toggleAsf: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
org_id: process.env.NEXT_PUBLIC_DEV_TENANT_ID,
|
||||
project_id: undefined,
|
||||
asfOpen: false,
|
||||
setOrg: (org_id) => set({ org_id }),
|
||||
setProject: (project_id) => set({ project_id }),
|
||||
openAsf: () => set({ asfOpen: true }),
|
||||
closeAsf: () => set({ asfOpen: false }),
|
||||
toggleAsf: () => set((s) => ({ asfOpen: !s.asfOpen })),
|
||||
}));
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: "#f0f7ff",
|
||||
100: "#e0efff",
|
||||
200: "#bae0ff",
|
||||
300: "#7cc7ff",
|
||||
400: "#35a9ff",
|
||||
500: "#0b8cff",
|
||||
600: "#006fe0",
|
||||
700: "#0057b4",
|
||||
800: "#084a8f",
|
||||
900: "#0d3f75",
|
||||
950: "#08264c",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "#7c5cff",
|
||||
muted: "#5b44c0",
|
||||
},
|
||||
surface: {
|
||||
DEFAULT: "#ffffff",
|
||||
dark: "#0b1220",
|
||||
},
|
||||
ink: {
|
||||
DEFAULT: "#0f172a",
|
||||
muted: "#64748b",
|
||||
},
|
||||
border: "#e2e8f0",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"Inter",
|
||||
"ui-sans-serif",
|
||||
"system-ui",
|
||||
"-apple-system",
|
||||
"Segoe UI",
|
||||
"Roboto",
|
||||
"sans-serif",
|
||||
],
|
||||
mono: ["JetBrains Mono", "Menlo", "ui-monospace", "monospace"],
|
||||
},
|
||||
boxShadow: {
|
||||
soft: "0 2px 10px -2px rgba(15,23,42,0.06), 0 1px 3px rgba(15,23,42,0.04)",
|
||||
card: "0 1px 2px rgba(15,23,42,0.04), 0 4px 20px -8px rgba(15,23,42,0.08)",
|
||||
},
|
||||
keyframes: {
|
||||
"fade-in": {
|
||||
"0%": { opacity: "0", transform: "translateY(4px)" },
|
||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"fade-in": "fade-in 180ms ease-out both",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@/app/*": ["./app/*"],
|
||||
"@/components/*": ["./components/*"],
|
||||
"@/lib/*": ["./lib/*"],
|
||||
"@/stores/*": ["./stores/*"],
|
||||
"@/types/*": ["./types/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user