Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
Signed-off-by: stroblme <stroblme@posteo.de>
149 lines
5.0 KiB
TypeScript
149 lines
5.0 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">
|
|
<BrainView />
|
|
|
|
<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>
|
|
)
|
|
}
|