mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
feat(web): initialize Next.js project with Tailwind CSS and TypeScript setup
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { RefreshCw, Send } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api, type ExecutionStatus } from "@/lib/api";
|
||||
|
||||
export default function ExecutionPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading…</div>}>
|
||||
<ExecutionView />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function statusVariant(s?: string) {
|
||||
switch ((s || "").toLowerCase()) {
|
||||
case "success":
|
||||
case "completed":
|
||||
return "success" as const;
|
||||
case "running":
|
||||
case "pending":
|
||||
return "secondary" as const;
|
||||
case "failed":
|
||||
case "error":
|
||||
return "destructive" as const;
|
||||
case "waiting":
|
||||
case "paused":
|
||||
return "warning" as const;
|
||||
default:
|
||||
return "outline" as const;
|
||||
}
|
||||
}
|
||||
|
||||
function ExecutionView() {
|
||||
const params = useSearchParams();
|
||||
const id = params.get("id") ?? "";
|
||||
|
||||
const [status, setStatus] = useState<ExecutionStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [polling, setPolling] = useState(true);
|
||||
|
||||
// resume form
|
||||
const [awakeable, setAwakeable] = useState("");
|
||||
const [data, setData] = useState(`{"approved": true}`);
|
||||
const [resuming, setResuming] = useState(false);
|
||||
|
||||
async function refresh() {
|
||||
if (!id) return;
|
||||
try {
|
||||
const s = await api.getExecution(id);
|
||||
setStatus(s);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "load failed");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
if (!polling) return;
|
||||
const t = setInterval(() => {
|
||||
refresh();
|
||||
}, 2000);
|
||||
return () => clearInterval(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, polling]);
|
||||
|
||||
async function onResume() {
|
||||
setResuming(true);
|
||||
setError(null);
|
||||
try {
|
||||
let payload: unknown = {};
|
||||
if (data.trim()) payload = JSON.parse(data);
|
||||
await api.resumeExecution(id, { awakeable_id: awakeable, data: payload });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "resume failed");
|
||||
} finally {
|
||||
setResuming(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Missing <code>id</code> query param.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const s = (status?.status as string) || "unknown";
|
||||
const terminal = ["success", "completed", "failed", "error"].includes(s.toLowerCase());
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPolling((p) => !p)}
|
||||
>
|
||||
{polling ? "Stop polling" : "Resume polling"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={refresh}>
|
||||
<RefreshCw />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Execution</h1>
|
||||
<Badge variant={statusVariant(s)}>{s}</Badge>
|
||||
{!terminal && polling && (
|
||||
<span className="text-xs text-muted-foreground">polling every 2s…</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-xs text-muted-foreground">{id}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/40">
|
||||
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Status</CardTitle>
|
||||
<CardDescription>Live snapshot from the orchestrator</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="max-h-[600px] overflow-auto rounded-md border bg-muted/30 p-4 text-xs">
|
||||
{status ? JSON.stringify(status, null, 2) : "Loading…"}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resume</CardTitle>
|
||||
<CardDescription>
|
||||
Resolve a Restate awakeable to continue a paused workflow.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="awakeable">Awakeable ID</Label>
|
||||
<Input
|
||||
id="awakeable"
|
||||
value={awakeable}
|
||||
onChange={(e) => setAwakeable(e.target.value)}
|
||||
placeholder="awk_…"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data">Resolution data (JSON)</Label>
|
||||
<Textarea
|
||||
id="data"
|
||||
rows={6}
|
||||
value={data}
|
||||
onChange={(e) => setData(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={onResume}
|
||||
disabled={resuming || !awakeable}
|
||||
>
|
||||
<Send />
|
||||
{resuming ? "Sending…" : "Resume"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Save } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
const sample = `{
|
||||
"name": "Hello",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "When clicked",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [0, 0],
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Set",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 1,
|
||||
"position": [200, 0],
|
||||
"parameters": { "values": { "string": [{ "name": "msg", "value": "hello" }] } }
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When clicked": { "main": [[{ "node": "Set", "type": "main", "index": 0 }]] }
|
||||
}
|
||||
}`;
|
||||
|
||||
export default function NewFlowPage() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [definition, setDefinition] = useState(sample);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function onSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(definition);
|
||||
} catch (e) {
|
||||
throw new Error(`Definition must be valid JSON: ${(e as Error).message}`);
|
||||
}
|
||||
const { id } = await api.createFlow({ name, definition: parsed });
|
||||
router.push(`/flows/view/?id=${encodeURIComponent(id)}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">New flow</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Paste an n8n workflow export, or start from the sample below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Definition</CardTitle>
|
||||
<CardDescription>
|
||||
n8n-format JSON. Drafts are validated leniently; strict validation runs on
|
||||
execute.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My flow (optional — uses workflow.name if blank)"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="def">Workflow JSON</Label>
|
||||
<Textarea
|
||||
id="def"
|
||||
rows={20}
|
||||
value={definition}
|
||||
onChange={(e) => setDefinition(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link href="/">
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
</Link>
|
||||
<Button onClick={onSave} disabled={saving}>
|
||||
<Save />
|
||||
{saving ? "Saving…" : "Save flow"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Play, Save, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api, type StoredFlow } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
export default function FlowDetailPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading…</div>}>
|
||||
<FlowDetail />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowDetail() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const id = params.get("id") ?? "";
|
||||
|
||||
const [flow, setFlow] = useState<StoredFlow | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [definition, setDefinition] = useState("");
|
||||
const [input, setInput] = useState("[{}]");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [lastExecId, setLastExecId] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
if (!id) return;
|
||||
try {
|
||||
const f = await api.getFlow(id);
|
||||
setFlow(f);
|
||||
setName(f.name);
|
||||
setDefinition(JSON.stringify(f.definition, null, 2));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "load failed");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
async function onSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const parsed = JSON.parse(definition);
|
||||
await api.updateFlow(id, { name, definition: parsed });
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRun() {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
let parsedInput: unknown[] | undefined;
|
||||
if (input.trim()) {
|
||||
const v = JSON.parse(input);
|
||||
if (!Array.isArray(v)) throw new Error("Input must be a JSON array");
|
||||
parsedInput = v;
|
||||
}
|
||||
const res = await api.executeWorkflow({ workflow_id: id, input: parsedInput });
|
||||
setLastExecId(res.execution_id);
|
||||
router.push(`/executions/view/?id=${encodeURIComponent(res.execution_id)}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "run failed");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!confirm("Delete this flow? This cannot be undone.")) return;
|
||||
try {
|
||||
await api.deleteFlow(id);
|
||||
router.push("/");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "delete failed");
|
||||
}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Missing <code>id</code> query param.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{flow && (
|
||||
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{flow.id}</span>
|
||||
<span>·</span>
|
||||
<span>updated {formatDate(flow.updatedAt)}</span>
|
||||
<Badge variant="secondary">{flow.nodeCount} nodes</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{name || "Untitled flow"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Edit, save, and run this workflow against the orchestrator.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/40">
|
||||
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Definition</CardTitle>
|
||||
<CardDescription>n8n-format JSON</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="def">Workflow JSON</Label>
|
||||
<Textarea
|
||||
id="def"
|
||||
rows={24}
|
||||
value={definition}
|
||||
onChange={(e) => setDefinition(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={onDelete}>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</Button>
|
||||
<Button onClick={onSave} disabled={saving}>
|
||||
<Save />
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Run</CardTitle>
|
||||
<CardDescription>Submit to the orchestrator</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="input">Trigger input (JSON array)</Label>
|
||||
<Textarea
|
||||
id="input"
|
||||
rows={8}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="text-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
e.g. <code className="font-mono">[{`{"foo":"bar"}`}]</code>
|
||||
</p>
|
||||
</div>
|
||||
<Button className="w-full" onClick={onRun} disabled={running}>
|
||||
<Play />
|
||||
{running ? "Submitting…" : "Run flow"}
|
||||
</Button>
|
||||
{lastExecId && (
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-xs">
|
||||
<div className="font-medium">Last execution</div>
|
||||
<Link
|
||||
href={`/executions/view/?id=${encodeURIComponent(lastExecId)}`}
|
||||
className="font-mono text-primary hover:underline"
|
||||
>
|
||||
{lastExecId}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.6rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Breadcrumbs } from "@/components/breadcrumbs";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "flow",
|
||||
description: "Durable n8n-compatible workflow engine",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<SidebarProvider
|
||||
style={{ "--sidebar-width": "17rem" } as React.CSSProperties}
|
||||
>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="sticky top-0 z-30 flex h-14 shrink-0 items-center gap-2 border-b bg-background/80 px-4 backdrop-blur">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<Breadcrumbs />
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-4 p-6">{children}</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus, RefreshCw, Trash2, Activity } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { api, type FlowSummary } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [flows, setFlows] = useState<FlowSummary[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [health, setHealth] = useState<"ok" | "down" | "checking">("checking");
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const list = await api.listFlows();
|
||||
list.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
|
||||
setFlows(list);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "failed to load");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
api.health()
|
||||
.then(() => setHealth("ok"))
|
||||
.catch(() => setHealth("down"));
|
||||
}, []);
|
||||
|
||||
async function onDelete(id: string) {
|
||||
if (!confirm("Delete this flow?")) return;
|
||||
try {
|
||||
await api.deleteFlow(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "delete failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Flows</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Durable, n8n-compatible workflows. Paste exported JSON to import.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span
|
||||
className={
|
||||
health === "ok"
|
||||
? "h-2 w-2 rounded-full bg-emerald-500"
|
||||
: health === "down"
|
||||
? "h-2 w-2 rounded-full bg-rose-500"
|
||||
: "h-2 w-2 rounded-full bg-amber-500"
|
||||
}
|
||||
/>
|
||||
<span>API {health}</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={load}>
|
||||
<RefreshCw />
|
||||
Refresh
|
||||
</Button>
|
||||
<Link href="/flows/new">
|
||||
<Button size="sm">
|
||||
<Plus />
|
||||
New flow
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/40">
|
||||
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{flows === null ? (
|
||||
<SkeletonGrid />
|
||||
) : flows.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{flows.map((f) => (
|
||||
<Card key={f.id} className="group transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate">{f.name || "Untitled flow"}</CardTitle>
|
||||
<CardDescription className="mt-1 truncate font-mono text-[11px]">
|
||||
{f.id}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary">{f.status || "draft"}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
<span className="font-medium text-foreground">{f.nodeCount}</span> nodes
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(f.updatedAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Link href={`/flows/view/?id=${encodeURIComponent(f.id)}`}>
|
||||
<Button size="sm" variant="ghost">
|
||||
<Activity />
|
||||
Open
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(f.id)}
|
||||
aria-label="Delete flow"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonGrid() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<div className="h-4 w-1/2 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-3 w-1/3 animate-pulse rounded bg-muted" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<div className="rounded-full bg-muted p-3">
|
||||
<Plus className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">No flows yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Import an n8n workflow JSON to get started.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/flows/new">
|
||||
<Button size="sm">Create your first flow</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user