The editor's mount-time fitView animated from React Flow's default viewport, which read as the graph swiping in from the corner on every open. The first fit is instant now, later ones stay animated, and a scaleIn wrapper gives the same entrance the brain view has — with a re-measure on completion so the handle bounds are not stored mid-scale. Home only renders the brain band once some flow has nodes, so a fresh install no longer reserves a screenful of empty space above the flows card. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
152 lines
5.2 KiB
TypeScript
152 lines
5.2 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import { createFileRoute, Link } from "@tanstack/react-router"
|
|
import { AlertCircle, Workflow } from "lucide-react"
|
|
import { useState } from "react"
|
|
|
|
import { type FlowSummary, FlowsService } from "@/client"
|
|
import { DEFAULT_RANGE } from "@/components/Common/RangePicker"
|
|
import { BrainView } from "@/components/Flow/BrainView"
|
|
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
|
|
import { HealthActivity } from "@/components/Health/HealthActivity"
|
|
import { HealthOverview } from "@/components/Health/HealthOverview"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Card } from "@/components/ui/card"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { Switch } from "@/components/ui/switch"
|
|
import useCustomToast from "@/hooks/useCustomToast"
|
|
|
|
export const Route = createFileRoute("/_layout/")({
|
|
component: Dashboard,
|
|
head: () => ({
|
|
meta: [
|
|
{
|
|
title: "Dashboard - Fluksio",
|
|
},
|
|
],
|
|
}),
|
|
})
|
|
|
|
/** Another tab can stop a flow, and the engine can fail one on its own. */
|
|
const REFRESH_INTERVAL = 10_000
|
|
|
|
function FlowRow({ flow }: { flow: FlowSummary }) {
|
|
const queryClient = useQueryClient()
|
|
const { showErrorToast } = useCustomToast()
|
|
const enabled = flow.enabled ?? true
|
|
|
|
const toggle = useMutation({
|
|
mutationFn: (next: boolean) =>
|
|
next
|
|
? FlowsService.startFlow({ name: flow.name })
|
|
: FlowsService.stopFlow({ name: flow.name }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: flowKeys.all })
|
|
queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow.name) })
|
|
},
|
|
onError: () => showErrorToast("The flow could not be started or stopped."),
|
|
})
|
|
|
|
return (
|
|
<div
|
|
className="flex items-center gap-3 border-b border-border px-5 py-3 last:border-b-0"
|
|
data-testid="dashboard-flow-row"
|
|
>
|
|
<Link
|
|
to="/flows/$flowName"
|
|
params={{ flowName: flow.name }}
|
|
className="min-w-0 flex-1"
|
|
>
|
|
<p className="truncate font-medium">{flow.title || flow.name}</p>
|
|
<p className="truncate text-sm text-muted-foreground">
|
|
{flow.node_count === 1 ? "1 node" : `${flow.node_count} nodes`}
|
|
{flow.has_draft ? " · unpublished changes" : ""}
|
|
</p>
|
|
</Link>
|
|
|
|
{(flow.error_count ?? 0) > 0 ? (
|
|
<Badge variant="destructive" className="gap-1">
|
|
<AlertCircle />
|
|
{flow.error_count}
|
|
</Badge>
|
|
) : null}
|
|
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
enabled
|
|
? flow.paused
|
|
? "border-transparent bg-primary/15 text-primary"
|
|
: "border-transparent bg-status-success/15 text-status-success"
|
|
: "text-muted-foreground"
|
|
}
|
|
>
|
|
{enabled ? (flow.paused ? "Paused" : "Running") : "Stopped"}
|
|
</Badge>
|
|
|
|
<Switch
|
|
checked={enabled}
|
|
disabled={toggle.isPending}
|
|
onCheckedChange={(next) => toggle.mutate(next)}
|
|
aria-label={`Run ${flow.title || flow.name}`}
|
|
data-testid="flow-enabled-switch"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The one overview: what the engine is wired up as, what is running, and how
|
|
* it has been doing. The brain and the health screens compose in here rather
|
|
* than living at routes of their own.
|
|
*/
|
|
function Dashboard() {
|
|
// The health block's window: one choice, read by the tiles, the flow table,
|
|
// the charts and the lists under them.
|
|
const [range, setRange] = useState(DEFAULT_RANGE)
|
|
const { data, isPending } = useQuery({
|
|
...flowsQueryOptions(),
|
|
refetchInterval: REFRESH_INTERVAL,
|
|
})
|
|
|
|
const flows = data?.data ?? []
|
|
|
|
return (
|
|
// `[&>*]:min-w-0`: a grid item's automatic minimum is its content, so one
|
|
// long name or wide table widens the whole column and the page with it.
|
|
// Every section here is free to shrink instead. See DESIGN-GUIDELINES.md
|
|
// → Responsive.
|
|
<div className="grid gap-6 [&>*]:min-w-0">
|
|
{/* Nothing wired up yet means nothing to draw, and the band would still
|
|
hold a screenful of empty space above the flows card. Node count
|
|
rather than flow count: a flow made a minute ago has none. */}
|
|
{flows.some((flow) => (flow.node_count ?? 0) > 0) ? <BrainView /> : null}
|
|
|
|
<Card className="gap-0 py-0">
|
|
{isPending ? (
|
|
<div className="grid gap-3 p-5">
|
|
<Skeleton className="h-5 w-40" />
|
|
<Skeleton className="h-5 w-28" />
|
|
</div>
|
|
) : flows.length === 0 ? (
|
|
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
|
<Workflow className="size-5" />
|
|
</span>
|
|
<p className="text-sm text-muted-foreground">
|
|
Flows you build show up here, with what they are doing.
|
|
</p>
|
|
<Link to="/flows" className="text-sm font-medium underline">
|
|
Go to flows
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
flows.map((flow) => <FlowRow key={flow.name} flow={flow} />)
|
|
)}
|
|
</Card>
|
|
|
|
<HealthOverview range={range} onRangeChange={setRange} />
|
|
<HealthActivity range={range} />
|
|
</div>
|
|
)
|
|
}
|