Files
app/frontend/src/routes/_layout/index.tsx
T
stroblmeandClaude Opus 5 86e02b49a3 Grow the flow editor in from the centre, hide the empty brain band
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
2026-08-20 08:43:16 +02:00

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>
)
}