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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
350
spa/src/pages/guide.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user