per-source polling, initial client state, webhook, preview, client tracking

- per-source poll intervals: spawn task per source with own interval,
  manager re-checks sources every 30s for add/remove
- initial screen update on TCP connect: send layout + widget states
- client tracking: ClientRegistry port, GET /api/clients, dashboard list
- webhook adapter: POST /api/webhook/{source_id} feeds data into projection
- widget preview: GET /api/widgets/{id}/preview returns current state
- serve SPA from Axum: ServeDir + index.html fallback via KFRAME_SPA_DIR
- layout builder delete confirmation with AlertDialog
- form validation: required fields disable save button
- guide page at /guide
- fix architecture: ClientDto to api-types, ClientRegistry + WidgetStateReader
  ports in domain, DataProjection has internal Mutex, no adapter cross-deps
- ESP32: full screen clear on layout change (stale pixel fix)
This commit is contained in:
2026-06-19 00:42:31 +02:00
parent 26ebfad3a2
commit 1d7b5324d6
39 changed files with 1232 additions and 158 deletions

View File

@@ -9,11 +9,24 @@ import { useDataSources } from "@/api/data-sources"
import { useWidgets } from "@/api/widgets"
import { useLayout } from "@/api/layout"
import { usePresets } from "@/api/presets"
import { Activity, Box, Layers, Database } from "lucide-react"
import { useClients } from "@/api/clients"
import { Activity, Box, Layers, Database, Monitor } from "lucide-react"
import { Badge } from "@/components/ui/badge"
function countNodes(node: { children?: { node: unknown }[] }): number {
if (!node.children) return 1
return 1 + node.children.reduce((sum, c) => sum + countNodes(c.node as typeof node), 0)
return (
1 +
node.children.reduce(
(sum, c) => sum + countNodes(c.node as typeof node),
0,
)
)
}
function formatConnectedAt(ts: number): string {
const d = new Date(ts * 1000)
return d.toLocaleTimeString()
}
export function DashboardPage() {
@@ -21,8 +34,15 @@ export function DashboardPage() {
const widgets = useWidgets()
const layout = useLayout()
const presets = usePresets()
const clients = useClients()
const stats = [
{
label: "Clients",
value: clients.data?.length ?? "—",
icon: Monitor,
desc: "Connected displays",
},
{
label: "Data Sources",
value: sources.data?.length ?? "—",
@@ -56,7 +76,7 @@ export function DashboardPage() {
<p className="text-muted-foreground text-sm">K-Frame system overview</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{stats.map((s) => (
<Card key={s.label}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
@@ -70,6 +90,29 @@ export function DashboardPage() {
</Card>
))}
</div>
{clients.data && clients.data.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Connected Clients</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-2">
{clients.data.map((c) => (
<div
key={c.addr}
className="flex items-center justify-between text-sm"
>
<span className="font-mono text-xs">{c.addr}</span>
<Badge variant="outline">
since {formatConnectedAt(c.connected_at)}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -192,7 +192,15 @@ export function DataSourcesPage() {
<Button variant="outline" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button onClick={save} disabled={!editing?.name}>
<Button
onClick={save}
disabled={
!editing?.name ||
(editing.source_type !== "webhook" &&
editing.poll_interval_secs <= 0) ||
(editing.source_type !== "webhook" && !editing.url)
}
>
Save
</Button>
</DialogFooter>

350
spa/src/pages/guide.tsx Normal file
View File

@@ -0,0 +1,350 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
export function GuidePage() {
return (
<div className="mx-auto max-w-3xl space-y-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Guide</h1>
<p className="text-muted-foreground text-sm">
How to set up K-Frame from scratch
</p>
</div>
{/* Overview */}
<Card>
<CardHeader>
<CardTitle>How K-Frame Works</CardTitle>
<CardDescription>The data pipeline at a glance</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p>
K-Frame is an IoT dashboard system. The server polls external data
sources (weather APIs, Navidrome, RSS feeds, etc.), extracts values,
and pushes them to connected display clients (ESP32 screens) over
TCP.
</p>
<div className="bg-muted rounded-md p-3 font-mono text-xs">
Data Source poll raw JSON Widget mappings Widget State
Layout ESP32 display
</div>
<p>
You configure everything through this UI. Changes to layout are
pushed to clients immediately.
</p>
</CardContent>
</Card>
{/* Step 1 */}
<Card>
<CardHeader>
<CardTitle>Step 1: Add a Data Source</CardTitle>
<CardDescription>
Where to pull data from
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p>
Go to <strong>Data Sources Add Source</strong>. A data source is
an external feed that the server polls at a regular interval.
</p>
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b">
<th className="py-2 pr-4 font-medium">Field</th>
<th className="py-2 font-medium">Description</th>
</tr>
</thead>
<tbody className="text-muted-foreground">
<tr className="border-b">
<td className="py-2 pr-4 font-mono text-xs">name</td>
<td className="py-2">Human-readable label (e.g. "weather", "navidrome")</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-mono text-xs">source_type</td>
<td className="py-2">Determines which adapter handles polling (see reference below)</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-mono text-xs">url</td>
<td className="py-2">Base URL of the API to poll</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-mono text-xs">api_key</td>
<td className="py-2">Optional API key (masked in the UI)</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-mono text-xs">poll_interval</td>
<td className="py-2">How often to fetch data, in seconds</td>
</tr>
<tr>
<td className="py-2 pr-4 font-mono text-xs">headers</td>
<td className="py-2">
Key-value pairs for authentication or custom config. For
example, the media adapter reads{" "}
<code className="bg-muted rounded px-1">username</code> and{" "}
<code className="bg-muted rounded px-1">password</code> from
headers
</td>
</tr>
</tbody>
</table>
</CardContent>
</Card>
{/* Step 2 */}
<Card>
<CardHeader>
<CardTitle>Step 2: Create a Widget</CardTitle>
<CardDescription>
Extract and name the data you want to display
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p>
Go to <strong>Widgets Add Widget</strong>. A widget is a display
primitive it extracts specific values from a data source's raw
response and gives them names the client can render.
</p>
<h4 className="font-medium">Display Hints</h4>
<div className="flex gap-2">
<Badge variant="secondary">icon_value</Badge>
<Badge variant="secondary">text_block</Badge>
<Badge variant="secondary">key_value</Badge>
</div>
<p className="text-muted-foreground">
Hints tell the client how to render the widget. The client decides
the actual visual treatment.
</p>
<Separator />
<h4 className="font-medium">Key Mappings</h4>
<p>
Mappings define how to extract data from the raw API response.
Each mapping has two fields:
</p>
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b">
<th className="py-2 pr-4 font-medium">Field</th>
<th className="py-2 font-medium">Description</th>
</tr>
</thead>
<tbody className="text-muted-foreground">
<tr className="border-b">
<td className="py-2 pr-4 font-mono text-xs">source_path</td>
<td className="py-2">
JSONPath expression into the raw response (e.g.{" "}
<code className="bg-muted rounded px-1">$.title</code>,{" "}
<code className="bg-muted rounded px-1">$.main.temp</code>,{" "}
<code className="bg-muted rounded px-1">$.weather[0].icon</code>)
</td>
</tr>
<tr>
<td className="py-2 pr-4 font-mono text-xs">target_key</td>
<td className="py-2">
The name the extracted value gets in the widget's state.
This is what the client sees (e.g.{" "}
<code className="bg-muted rounded px-1">value</code>,{" "}
<code className="bg-muted rounded px-1">label</code>,{" "}
<code className="bg-muted rounded px-1">icon</code>)
</td>
</tr>
</tbody>
</table>
<div className="bg-muted rounded-md p-3 font-mono text-xs">
Example: Navidrome "now playing"<br />
<span className="text-muted-foreground">source_path</span> $.title <span className="text-muted-foreground">target_key</span> value<br />
<span className="text-muted-foreground">source_path</span> $.artist <span className="text-muted-foreground">target_key</span> label
</div>
</CardContent>
</Card>
{/* Step 3 */}
<Card>
<CardHeader>
<CardTitle>Step 3: Build a Layout</CardTitle>
<CardDescription>
Arrange widgets on the display
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p>
Go to <strong>Layout</strong>. The layout is a recursive tree of
containers and widgets.
</p>
<h4 className="font-medium">Node Types</h4>
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b">
<th className="py-2 pr-4 font-medium">Type</th>
<th className="py-2 font-medium">Description</th>
</tr>
</thead>
<tbody className="text-muted-foreground">
<tr className="border-b">
<td className="py-2 pr-4">Container (row)</td>
<td className="py-2">Children laid out horizontally</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4">Container (column)</td>
<td className="py-2">Children laid out vertically</td>
</tr>
<tr>
<td className="py-2 pr-4">Leaf (widget)</td>
<td className="py-2">Renders a specific widget's data</td>
</tr>
</tbody>
</table>
<h4 className="font-medium">Sizing</h4>
<p>Each child in a container has a sizing mode:</p>
<ul className="text-muted-foreground list-inside list-disc space-y-1">
<li>
<strong>Flex(weight)</strong> — proportional share of remaining
space. Two children with flex 1 and 2 get 1/3 and 2/3 of the
space.
</li>
<li>
<strong>Fixed(pixels)</strong> — exact pixel width (in rows) or
height (in columns).
</li>
</ul>
<h4 className="font-medium">Gap & Padding</h4>
<p className="text-muted-foreground">
<strong>Gap</strong> adds uniform spacing between children.{" "}
<strong>Padding</strong> insets the container's content area on all
sides. Both are in pixels. Typically use padding on the root
container to keep content off screen edges.
</p>
</CardContent>
</Card>
{/* Step 4 */}
<Card>
<CardHeader>
<CardTitle>Step 4: Save & Push</CardTitle>
<CardDescription>
Changes go live immediately
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p>
Click <strong>Save Layout</strong> to persist the layout and push
it to all connected clients immediately. The ESP32 will re-render
with the new layout without needing a restart.
</p>
<p className="text-muted-foreground">
Data updates are pushed automatically whenever a poll detects
changed values no action needed from you.
</p>
</CardContent>
</Card>
{/* Presets */}
<Card>
<CardHeader>
<CardTitle>Presets</CardTitle>
<CardDescription>
Save and restore layout snapshots
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p>
Go to <strong>Presets</strong> to save the current layout as a
named preset. You can load a preset later to restore its layout,
or delete presets you no longer need.
</p>
<p className="text-muted-foreground">
Loading a preset replaces the active layout and pushes to clients
immediately.
</p>
</CardContent>
</Card>
{/* Source Types Reference */}
<Card>
<CardHeader>
<CardTitle>Source Type Reference</CardTitle>
<CardDescription>
What each adapter expects
</CardDescription>
</CardHeader>
<CardContent className="text-sm">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b">
<th className="py-2 pr-4 font-medium">Type</th>
<th className="py-2 pr-4 font-medium">Direction</th>
<th className="py-2 font-medium">Notes</th>
</tr>
</thead>
<tbody className="text-muted-foreground">
<tr className="border-b">
<td className="py-2 pr-4 font-mono text-xs">weather</td>
<td className="py-2 pr-4">poll</td>
<td className="py-2">
OpenWeather-compatible. Set URL with API key in query params.
Returns nested JSON use paths like{" "}
<code className="bg-muted rounded px-1">$.main.temp</code>
</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-mono text-xs">media</td>
<td className="py-2 pr-4">poll</td>
<td className="py-2">
Navidrome/Subsonic. Set base URL, add{" "}
<code className="bg-muted rounded px-1">username</code> and{" "}
<code className="bg-muted rounded px-1">password</code>{" "}
as headers. Returns{" "}
<code className="bg-muted rounded px-1">$.playing</code>,{" "}
<code className="bg-muted rounded px-1">$.title</code>,{" "}
<code className="bg-muted rounded px-1">$.artist</code>,{" "}
<code className="bg-muted rounded px-1">$.album</code>,{" "}
<code className="bg-muted rounded px-1">$.duration</code>
</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-mono text-xs">rss</td>
<td className="py-2 pr-4">poll</td>
<td className="py-2">
Any RSS feed URL. Returns{" "}
<code className="bg-muted rounded px-1">$.title</code>,{" "}
<code className="bg-muted rounded px-1">$.items</code> (array
of items with title, link, description)
</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-mono text-xs">http_json</td>
<td className="py-2 pr-4">poll</td>
<td className="py-2">
Generic polls any URL, returns raw JSON. Use JSONPath in
mappings to extract what you need.
</td>
</tr>
<tr>
<td className="py-2 pr-4 font-mono text-xs">webhook</td>
<td className="py-2 pr-4">push</td>
<td className="py-2">
Receives incoming HTTP POSTs. Poll interval must be 0.
Not yet wired in the polling loop.
</td>
</tr>
</tbody>
</table>
</CardContent>
</Card>
</div>
)
}

View File

@@ -16,6 +16,16 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
@@ -110,6 +120,7 @@ export function LayoutBuilderPage() {
const [root, setRoot] = useState<LayoutNode | null>(null)
const [selected, setSelected] = useState<Path | null>(null)
const [initialized, setInitialized] = useState(false)
const [pendingDelete, setPendingDelete] = useState<Path | null>(null)
if (!initialized && currentLayout?.root) {
setRoot(structuredClone(currentLayout.root))
@@ -255,7 +266,7 @@ export function LayoutBuilderPage() {
onAddContainer={(path, dir) =>
addChild(path, makeContainerChild(dir))
}
onRemove={() => removeChild(selected)}
onRemove={() => setPendingDelete(selected)}
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
isRoot={selected.length === 0}
widgets={widgets}
@@ -264,7 +275,7 @@ export function LayoutBuilderPage() {
<LeafProps
path={selected}
widgetId={selectedNode?.widget_id ?? 0}
onRemove={() => removeChild(selected)}
onRemove={() => setPendingDelete(selected)}
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
widgets={widgets}
sizing={
@@ -284,6 +295,39 @@ export function LayoutBuilderPage() {
</CardContent>
</Card>
</div>
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(o) => !o && setPendingDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pendingDelete?.length === 0
? "Clear entire layout?"
: "Remove this node?"}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete?.length === 0
? "This will remove the entire layout tree. You can rebuild it afterward."
: "This will remove the selected node and all its children."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (pendingDelete !== null) {
removeChild(pendingDelete)
setPendingDelete(null)
}
}}
>
{pendingDelete?.length === 0 ? "Clear" : "Remove"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import {
useCreateWidget,
useUpdateWidget,
useDeleteWidget,
useWidgetPreview,
} from "@/api/widgets"
import { useDataSources } from "@/api/data-sources"
import type { Widget, DisplayHint, KeyMapping } from "@/api/types"
@@ -42,7 +43,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Plus, Pencil, Trash2, X } from "lucide-react"
import { Plus, Pencil, Trash2, X, Eye } from "lucide-react"
import { toast } from "sonner"
const DISPLAY_HINTS: DisplayHint[] = ["icon_value", "text_block", "key_value"]
@@ -66,6 +67,7 @@ export function WidgetsPage() {
const [editing, setEditing] = useState<Widget | null>(null)
const [deleting, setDeleting] = useState<number | null>(null)
const [previewing, setPreviewing] = useState<number | null>(null)
function openNew() {
const nextId =
@@ -145,6 +147,14 @@ export function WidgetsPage() {
</CardDescription>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setPreviewing(w.id)}
title="Preview current data"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -186,7 +196,14 @@ export function WidgetsPage() {
<Button variant="outline" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button onClick={save} disabled={!editing?.name}>
<Button
onClick={save}
disabled={
!editing?.name ||
!editing.data_source_id ||
editing.mappings.length === 0
}
>
Save
</Button>
</DialogFooter>
@@ -213,10 +230,59 @@ export function WidgetsPage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{previewing != null && (
<WidgetPreviewDialog
widgetId={previewing}
widgetName={widgets.find((w) => w.id === previewing)?.name ?? ""}
onClose={() => setPreviewing(null)}
/>
)}
</div>
)
}
function WidgetPreviewDialog({
widgetId,
widgetName,
onClose,
}: {
widgetId: number
widgetName: string
onClose: () => void
}) {
const { data, isLoading, isError } = useWidgetPreview(widgetId, true)
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Preview: {widgetName}</DialogTitle>
</DialogHeader>
<div className="py-2">
{isLoading && (
<p className="text-muted-foreground text-sm">Loading</p>
)}
{isError && (
<p className="text-muted-foreground text-sm">
No data yet widget hasn't been polled
</p>
)}
{data && (
<pre className="bg-muted rounded-md p-3 text-xs">
{JSON.stringify(data, null, 2)}
</pre>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function WidgetForm({
value,