feat: add Gateway page and Runs page with real-time updates

- Implemented a new Gateway page that provides a uniform ingress for deployed agents.
- Created a Runs page that lists recent pipeline executions with auto-refresh and error handling.
- Added a RunsListener component to handle real-time notifications for new runs via SSE.
- Updated the AppSidebar to include links to the new Gateway and Runs pages.
- Enhanced FlowNode component to improve status display and styling.
- Introduced EmptySection component for consistent empty state presentation.
- Updated API utility to include a new endpoint for the runs stream.
This commit is contained in:
patel-lyzr
2026-05-13 22:15:08 +05:30
parent 0284ff768a
commit b6647a537d
25 changed files with 1764 additions and 509 deletions
+145 -86
View File
@@ -4,14 +4,18 @@ import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Workflow,
LayoutGrid,
PlusCircle,
Activity,
BookOpen,
ExternalLink,
Github,
LayoutDashboard,
Bot,
GitBranch,
Play,
CheckSquare,
Layers,
Radio,
PanelLeft,
PanelLeftClose,
Sun,
MoonStar,
Monitor,
} from "lucide-react";
import {
@@ -19,18 +23,12 @@ import {
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarSeparator,
useSidebar,
} from "@/components/ui/sidebar";
import { PanelLeftClose, PanelLeft } from "lucide-react";
type NavItem = {
title: string;
@@ -40,6 +38,12 @@ type NavItem = {
};
const primary: NavItem[] = [
{
title: "Dashboard",
href: "/dashboard",
icon: LayoutDashboard,
match: (p) => p === "/dashboard",
},
{
title: "Agents",
href: "/agents",
@@ -49,38 +53,32 @@ const primary: NavItem[] = [
{
title: "Pipelines",
href: "/",
icon: LayoutGrid,
match: (p) => p === "/" || p.startsWith("/flows/view"),
icon: GitBranch,
match: (p) => p === "/" || p.startsWith("/flows"),
},
{
title: "New pipeline",
href: "/flows/new",
icon: PlusCircle,
match: (p) => p.startsWith("/flows/new"),
title: "Runs",
href: "/runs",
icon: Play,
match: (p) => p === "/runs" || p.startsWith("/executions"),
},
{
title: "Executions",
href: "/executions/view",
icon: Activity,
match: (p) => p.startsWith("/executions"),
},
];
const docs = [
{
title: "n8n compatibility",
href: "https://github.com/lyzrai/flow#n8n-compatible",
external: true,
title: "Approvals",
href: "/approvals",
icon: CheckSquare,
match: (p) => p.startsWith("/approvals"),
},
{
title: "Durability model",
href: "https://github.com/lyzrai/flow#durability",
external: true,
title: "Environments",
href: "/environments",
icon: Layers,
match: (p) => p.startsWith("/environments"),
},
{
title: "Embed as Go library",
href: "https://github.com/lyzrai/flow#embedding",
external: true,
title: "Gateway",
href: "/gateway",
icon: Radio,
match: (p) => p.startsWith("/gateway"),
},
];
@@ -88,20 +86,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname() || "/";
return (
<Sidebar variant="floating" {...props}>
<Sidebar variant="floating" collapsible="icon" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link href="/">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Workflow className="size-4" />
<LangshipMark />
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">flow</span>
<span className="text-xs text-muted-foreground">
pre-v0.1 · durable pipelines
</span>
<span className="font-semibold">Langship</span>
</div>
</Link>
</SidebarMenuButton>
@@ -111,7 +106,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
<SidebarMenu className="gap-1">
{primary.map((item) => {
const Icon = item.icon;
@@ -131,55 +125,15 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
})}
</SidebarMenu>
</SidebarGroup>
<SidebarSeparator />
<SidebarGroup>
<SidebarGroupLabel>
<BookOpen className="mr-1 size-3.5" />
Reference
</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton className="font-medium" disabled>
Documentation
</SidebarMenuButton>
<SidebarMenuSub className="ml-0 border-l-0 px-1.5">
{docs.map((d) => (
<SidebarMenuSubItem key={d.href}>
<SidebarMenuSubButton asChild>
<a href={d.href} target="_blank" rel="noreferrer">
<span className="truncate">{d.title}</span>
{d.external && (
<ExternalLink className="ml-auto size-3 opacity-60" />
)}
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<a
href="https://github.com/lyzrai/flow"
target="_blank"
rel="noreferrer"
>
<Github className="size-4" />
<span>GitHub</span>
<ExternalLink className="ml-auto size-3 opacity-60" />
</a>
</SidebarMenuButton>
<CollapseToggle />
</SidebarMenuItem>
<SidebarMenuItem>
<CollapseToggle />
<ThemeToggle />
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
@@ -187,6 +141,25 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
);
}
function LangshipMark() {
// Tiny anchor/route mark — placeholder for the real Langship logo.
return (
<svg
viewBox="0 0 24 24"
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="6" cy="6" r="2" />
<circle cx="18" cy="18" r="2" />
<path d="M6 8v6a4 4 0 0 0 4 4h6" />
</svg>
);
}
function CollapseToggle() {
const { toggleSidebar, state } = useSidebar();
const collapsed = state === "collapsed";
@@ -198,3 +171,89 @@ function CollapseToggle() {
</SidebarMenuButton>
);
}
// ThemeToggle is a 3-way segmented control (Light / System / Dark) that
// writes the theme to <html class>. Persists in localStorage. When the
// sidebar is collapsed to icons, it renders a single button that cycles
// through the three themes instead.
function ThemeToggle() {
const { state } = useSidebar();
const collapsed = state === "collapsed";
const [theme, setTheme] = React.useState<"light" | "dark" | "system">("system");
React.useEffect(() => {
const saved =
(typeof window !== "undefined" &&
(localStorage.getItem("flow-theme") as
| "light"
| "dark"
| "system"
| null)) ||
"system";
setTheme(saved);
apply(saved);
}, []);
function apply(t: "light" | "dark" | "system") {
const root = document.documentElement;
const prefersDark =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
const dark = t === "dark" || (t === "system" && prefersDark);
root.classList.toggle("dark", dark);
localStorage.setItem("flow-theme", t);
}
function pick(next: "light" | "dark" | "system") {
setTheme(next);
apply(next);
}
const items: { id: "light" | "system" | "dark"; icon: typeof Sun; label: string }[] = [
{ id: "light", icon: Sun, label: "Light" },
{ id: "system", icon: Monitor, label: "System" },
{ id: "dark", icon: MoonStar, label: "Dark" },
];
if (collapsed) {
const current = items.find((i) => i.id === theme) ?? items[1];
const Icon = current.icon;
const next = items[(items.findIndex((i) => i.id === theme) + 1) % items.length].id;
return (
<SidebarMenuButton
onClick={() => pick(next)}
className="text-muted-foreground"
title={`Theme: ${current.label} (click to cycle)`}
>
<Icon className="size-4" />
<span>{current.label}</span>
</SidebarMenuButton>
);
}
return (
<div className="mx-2 mb-1 flex rounded-md border bg-muted/40 p-0.5">
{items.map((it) => {
const Icon = it.icon;
const active = theme === it.id;
return (
<button
key={it.id}
type="button"
onClick={() => pick(it.id)}
aria-pressed={active}
className={
"flex flex-1 items-center justify-center gap-1 rounded-sm px-2 py-1 text-[11px] transition-colors " +
(active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground")
}
>
<Icon className="size-3.5" />
<span>{it.label}</span>
</button>
);
})}
</div>
);
}
+88 -27
View File
@@ -28,33 +28,34 @@ export function FlowNode({ data, selected }: NodeProps) {
const outputs = entry?.outputs ?? 1;
const isTrigger = pn.type === "flow-nodes-base.trigger";
// Per-type accent colour for the TYPE label. Keeps the existing
// catalog `color` (full bg) but extracts the hue for header text.
const typeAccent = typeAccentClass(entry?.color);
return (
<div
className={cn(
"min-w-[200px] rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow",
"min-w-[260px] rounded-xl border bg-card px-4 py-3 text-card-foreground shadow-sm transition-shadow",
selected ? "ring-2 ring-primary shadow-md" : "hover:shadow-md",
!entry && "border-destructive/60",
status === "running" && "ring-2 ring-sky-500 animate-pulse",
status === "success" && "ring-2 ring-emerald-500",
status === "running" && "ring-2 ring-sky-500/60",
status === "success" && "ring-1 ring-emerald-500/40",
status === "failed" && "ring-2 ring-rose-500",
status === "paused" && "ring-2 ring-amber-500"
)}
>
<div
className={cn(
"flex items-center gap-2 rounded-t-lg px-3 py-2 text-xs font-medium text-white",
entry?.color ?? "bg-destructive"
)}
>
<Icon className="size-3.5" />
<span className="truncate">{entry?.label ?? "Unsupported"}</span>
</div>
<div className="px-3 py-2">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{pn.name}</span>
<StatusIcon status={status} />
<div className="flex items-center justify-between gap-3">
<div className={cn("flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-widest", typeAccent)}>
<Icon className="size-3.5" />
<span>{(entry?.label ?? "Unsupported")}</span>
</div>
<div className="truncate text-[11px] text-muted-foreground">{pn.type}</div>
<StatusPill status={status} />
</div>
<div className="mt-1.5 flex items-center gap-2">
<span className="truncate text-xl font-semibold leading-tight">
{pn.name}
</span>
</div>
{/* Input handle: triggers have no inputs */}
@@ -85,15 +86,75 @@ export function FlowNode({ data, selected }: NodeProps) {
);
}
function StatusIcon({ status }: { status?: NodeRunStatus }) {
if (!status || status === "pending") return null;
if (status === "running")
return <Loader2 className="size-3.5 animate-spin text-sky-500" />;
if (status === "success")
return <CheckCircle2 className="size-3.5 text-emerald-500" />;
if (status === "failed")
return <XCircle className="size-3.5 text-rose-500" />;
if (status === "paused")
return <PauseCircle className="size-3.5 text-amber-500" />;
function StatusPill({ status }: { status?: NodeRunStatus }) {
if (!status || status === "pending") {
return (
<span className="rounded-full border bg-muted/40 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
pending
</span>
);
}
if (status === "running") {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-sky-500/40 bg-sky-500/15 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-sky-700 dark:text-sky-400">
<Loader2 className="size-3 animate-spin" />
running
</span>
);
}
if (status === "success") {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-500/40 bg-emerald-500/15 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-emerald-700 dark:text-emerald-400">
<CheckCircle2 className="size-3" />
succeeded
</span>
);
}
if (status === "failed") {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-rose-500/40 bg-rose-500/15 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-rose-700 dark:text-rose-400">
<XCircle className="size-3" />
failed
</span>
);
}
if (status === "paused") {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/15 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-amber-700 dark:text-amber-400">
<PauseCircle className="size-3" />
paused
</span>
);
}
return null;
}
// typeAccentClass picks a tailwind text-color class that pairs with the
// node's catalog `color` (bg-X-500) so the TYPE row reads as a distinct
// accent against the card background.
function typeAccentClass(bg?: string): string {
switch (bg) {
case "bg-emerald-500":
return "text-emerald-500";
case "bg-amber-500":
return "text-amber-500";
case "bg-sky-500":
return "text-sky-500";
case "bg-violet-500":
return "text-violet-500";
case "bg-indigo-500":
return "text-indigo-500";
case "bg-fuchsia-500":
return "text-fuchsia-500";
case "bg-rose-500":
case "bg-red-600":
return "text-rose-500";
case "bg-orange-500":
return "text-orange-500";
case "bg-slate-500":
case "bg-slate-400":
return "text-slate-500";
default:
return "text-primary";
}
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import type { ReactNode } from "react";
export function EmptySection(props: {
title: string;
description: ReactNode;
status?: "soon" | "alpha";
}) {
const { title, description, status = "soon" } = props;
return (
<div className="flex h-full flex-col items-start gap-6 p-8">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-semibold tracking-tight">{title}</h1>
<span className="rounded-md border bg-muted/30 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{status === "soon" ? "coming soon" : "alpha"}
</span>
</div>
<p className="max-w-lg text-sm text-muted-foreground">{description}</p>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Play, X } from "lucide-react";
import { api } from "@/lib/api";
interface RunCreated {
type: string;
executionId: string;
pipelineId?: string;
pipelineName?: string;
agentId?: string;
source?: string;
startedAt?: string;
}
type Toast = RunCreated & { receivedAt: number };
// RunsListener mounts once at the app root, opens an SSE stream to
// /api/runs/stream, and renders a stack of "new run" toasts in the
// bottom-right corner. Each toast links to the live run view.
//
// Toasts auto-dismiss after 12s. Clicking the stream's link navigates to
// the live execution page (which has its own per-execution SSE feed).
export function RunsListener() {
const [toasts, setToasts] = useState<Toast[]>([]);
useEffect(() => {
let es: EventSource | null = null;
let backoff = 1000; // ms — grows on consecutive errors, capped at 30s
function connect() {
es = new EventSource(api.runsStreamURL());
es.onopen = () => {
backoff = 1000;
};
es.onerror = () => {
es?.close();
es = null;
// EventSource doesn't reconnect cleanly when nginx / cloudflared
// drops the connection — fall back to manual reconnect with
// bounded backoff so we keep getting events after a server restart.
const wait = Math.min(backoff, 30_000);
backoff = Math.min(backoff * 2, 30_000);
setTimeout(connect, wait);
};
es.onmessage = (msg) => {
try {
const ev: RunCreated = JSON.parse(msg.data);
if (ev.type !== "run_created" || !ev.executionId) return;
setToasts((prev) => {
const next = [...prev, { ...ev, receivedAt: Date.now() }];
// Cap visible stack to 4
return next.slice(-4);
});
} catch {
/* ignore */
}
};
}
connect();
return () => {
es?.close();
};
}, []);
// Auto-dismiss
useEffect(() => {
if (toasts.length === 0) return;
const t = setInterval(() => {
const cutoff = Date.now() - 12_000;
setToasts((prev) => prev.filter((x) => x.receivedAt > cutoff));
}, 1000);
return () => clearInterval(t);
}, [toasts.length]);
function dismiss(id: string) {
setToasts((prev) => prev.filter((t) => t.executionId !== id));
}
if (toasts.length === 0) return null;
return (
<div className="pointer-events-none fixed bottom-6 right-6 z-40 flex flex-col gap-2">
{toasts.map((t) => (
<ToastCard key={t.executionId} toast={t} onDismiss={dismiss} />
))}
</div>
);
}
function ToastCard({
toast,
onDismiss,
}: {
toast: Toast;
onDismiss: (id: string) => void;
}) {
const sourceLabel =
toast.source === "github_push"
? "GitHub push"
: toast.source === "manual"
? "Manual trigger"
: (toast.source ?? "New run");
return (
<div className="pointer-events-auto flex w-80 items-start gap-3 rounded-lg border bg-background p-3 shadow-xl">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
<Play className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{sourceLabel}</div>
<div className="truncate text-xs text-muted-foreground">
{toast.pipelineName || toast.pipelineId || "pipeline"}
</div>
<Link
href={`/executions/view/?id=${encodeURIComponent(toast.executionId)}`}
className="mt-1 inline-block text-xs font-medium text-primary hover:underline"
>
Open live view
</Link>
</div>
<button
type="button"
onClick={() => onDismiss(toast.executionId)}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Dismiss"
>
<X className="size-3.5" />
</button>
</div>
);
}