Files
k-frame/spa/src/pages/data-sources.tsx
Gabriel Kaszewski a51d22649a internal data sources (clock, static text), connection indicator, rendering fixes
DataSourceConfig refactored to enum: External/Clock/StaticText. Clock
generates formatted time via chrono, static text emits configured string.

ESP32: connection status indicator (green/red dot bottom-right), per-widget
clear before redraw, RenderEvent enum for local + server messages.

Polling uses DataUpdate instead of ScreenUpdate to avoid wiping widget state.
Empty mappings passthrough raw source data for internal sources.
2026-06-19 11:26:49 +02:00

461 lines
13 KiB
TypeScript

import { useState } from "react"
import {
useDataSources,
useCreateDataSource,
useUpdateDataSource,
useDeleteDataSource,
} from "@/api/data-sources"
import type { DataSource, DataSourceConfig, SourceType } from "@/api/types"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Plus, Pencil, Trash2, X, Eye, EyeOff } from "lucide-react"
import { toast } from "sonner"
const SOURCE_TYPES: SourceType[] = [
"weather",
"media",
"rss",
"http_json",
"webhook",
"clock",
"static_text",
]
const EXTERNAL_TYPES: SourceType[] = ["weather", "media", "rss", "http_json", "webhook"]
function defaultConfigFor(sourceType: SourceType): DataSourceConfig {
if (sourceType === "clock") return { type: "clock", format: "%H:%M:%S", timezone: "UTC" }
if (sourceType === "static_text") return { type: "static_text", text: "" }
return { type: "external", url: null, api_key: null, headers: [] }
}
const EMPTY: DataSource = {
id: 0,
name: "",
source_type: "http_json",
poll_interval_secs: 300,
config: { type: "external", url: null, api_key: null, headers: [] },
}
export function DataSourcesPage() {
const { data: sources = [], isLoading } = useDataSources()
const create = useCreateDataSource()
const update = useUpdateDataSource()
const del = useDeleteDataSource()
const [editing, setEditing] = useState<DataSource | null>(null)
const [deleting, setDeleting] = useState<number | null>(null)
function openNew() {
const nextId =
sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1
setEditing({ ...EMPTY, id: nextId })
}
function openEdit(ds: DataSource) {
setEditing({ ...ds })
}
async function save() {
if (!editing) return
const isNew = !sources.some((s) => s.id === editing.id)
try {
if (isNew) {
await create.mutateAsync(editing)
toast.success("Data source created")
} else {
await update.mutateAsync(editing)
toast.success("Data source updated")
}
setEditing(null)
} catch (e) {
toast.error(String(e))
}
}
async function confirmDelete() {
if (deleting == null) return
try {
await del.mutateAsync(deleting)
toast.success("Data source deleted")
} catch (e) {
toast.error(String(e))
}
setDeleting(null)
}
if (isLoading) return <div className="text-muted-foreground p-4">Loading</div>
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Data Sources
</h1>
<p className="text-muted-foreground text-sm">
Configure external data feeds
</p>
</div>
<Button onClick={openNew}>
<Plus className="mr-2 h-4 w-4" />
Add Source
</Button>
</div>
{sources.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">
No data sources configured yet.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{sources.map((ds) => (
<Card key={ds.id}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<div className="space-y-1">
<CardTitle className="text-base">{ds.name}</CardTitle>
<CardDescription className="flex items-center gap-2">
<Badge variant="secondary">{ds.source_type}</Badge>
{ds.poll_interval_secs > 0 && <span>every {ds.poll_interval_secs}s</span>}
{ds.config.type === "external" && ds.config.url && (
<span className="text-muted-foreground max-w-xs truncate text-xs">
{ds.config.url}
</span>
)}
</CardDescription>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(ds)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleting(ds.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
</Card>
))}
</div>
)}
{/* Edit / Create Dialog */}
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editing && sources.some((s) => s.id === editing.id)
? "Edit Data Source"
: "New Data Source"}
</DialogTitle>
</DialogHeader>
{editing && (
<DataSourceForm value={editing} onChange={setEditing} />
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button
onClick={save}
disabled={
!editing?.name ||
(EXTERNAL_TYPES.includes(editing.source_type) &&
editing.source_type !== "webhook" &&
editing.poll_interval_secs <= 0) ||
(EXTERNAL_TYPES.includes(editing.source_type) &&
editing.source_type !== "webhook" &&
editing.config.type === "external" &&
!editing.config.url)
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog
open={deleting != null}
onOpenChange={(o) => !o && setDeleting(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete data source?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove this data source. Widgets referencing
it will lose their feed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
const SENSITIVE_KEYS = ["password", "secret", "token", "api_key", "apikey"]
function isSensitiveKey(key: string) {
return SENSITIVE_KEYS.some((s) => key.toLowerCase().includes(s))
}
function HeaderRow({
headerKey,
headerValue,
onChangeKey,
onChangeValue,
onRemove,
}: {
headerKey: string
headerValue: string
onChangeKey: (v: string) => void
onChangeValue: (v: string) => void
onRemove: () => void
}) {
const sensitive = isSensitiveKey(headerKey)
const [visible, setVisible] = useState(!sensitive)
return (
<div className="flex items-center gap-2">
<Input
value={headerKey}
onChange={(e) => onChangeKey(e.target.value)}
placeholder="key"
className="flex-1"
/>
<div className="relative flex-1">
<Input
type={sensitive && !visible ? "password" : "text"}
value={headerValue}
onChange={(e) => onChangeValue(e.target.value)}
placeholder="value"
className={sensitive ? "pr-9" : ""}
/>
{sensitive && (
<Button
variant="ghost"
size="icon"
className="absolute top-0 right-0 h-full w-9"
onClick={() => setVisible((v) => !v)}
>
{visible ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
<Button variant="ghost" size="icon" onClick={onRemove}>
<X className="h-3 w-3" />
</Button>
</div>
)
}
function DataSourceForm({
value,
onChange,
}: {
value: DataSource
onChange: (ds: DataSource) => void
}) {
const set = <K extends keyof DataSource>(k: K, v: DataSource[K]) =>
onChange({ ...value, [k]: v })
const setConfig = (patch: Partial<DataSourceConfig>) =>
onChange({ ...value, config: { ...value.config, ...patch } as DataSourceConfig })
const onSourceTypeChange = (t: SourceType) => {
onChange({ ...value, source_type: t, config: defaultConfigFor(t) })
}
const isExternal = value.config.type === "external"
const isClock = value.config.type === "clock"
const isStaticText = value.config.type === "static_text"
return (
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label>Name</Label>
<Input
value={value.name}
onChange={(e) => set("name", e.target.value)}
placeholder="e.g. weather"
/>
</div>
<div className="grid gap-2">
<Label>Source Type</Label>
<Select
value={value.source_type}
onValueChange={(v) => onSourceTypeChange(v as SourceType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOURCE_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isExternal && (
<>
<div className="grid gap-2">
<Label>URL</Label>
<Input
value={value.config.url ?? ""}
onChange={(e) => setConfig({ url: e.target.value || null })}
placeholder="https://..."
/>
</div>
<div className="grid gap-2">
<Label>API Key</Label>
<Input
type="password"
value={value.config.api_key ?? ""}
onChange={(e) => setConfig({ api_key: e.target.value || null })}
placeholder="Optional"
/>
</div>
</>
)}
{isClock && (
<>
<div className="grid gap-2">
<Label>Format</Label>
<Input
value={value.config.format}
onChange={(e) => setConfig({ format: e.target.value })}
placeholder="%H:%M:%S"
/>
</div>
<div className="grid gap-2">
<Label>Timezone</Label>
<Input
value={value.config.timezone}
onChange={(e) => setConfig({ timezone: e.target.value })}
placeholder="Europe/Warsaw"
/>
</div>
</>
)}
{isStaticText && (
<div className="grid gap-2">
<Label>Text</Label>
<Input
value={value.config.text}
onChange={(e) => setConfig({ text: e.target.value })}
placeholder="Hello world"
/>
</div>
)}
<div className="grid gap-2">
<Label>Poll Interval (seconds)</Label>
<Input
type="number"
value={value.poll_interval_secs}
onChange={(e) => set("poll_interval_secs", Number(e.target.value))}
min={1}
/>
</div>
{isExternal && (
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>Headers</Label>
<Button
variant="outline"
size="sm"
onClick={() =>
setConfig({ headers: [...value.config.headers, ["", ""]] })
}
>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
</div>
{value.config.headers.map(([k, v], i) => (
<HeaderRow
key={i}
headerKey={k}
headerValue={v}
onChangeKey={(newKey) => {
const next = [...value.config.headers] as [string, string][]
next[i] = [newKey, v]
setConfig({ headers: next })
}}
onChangeValue={(newVal) => {
const next = [...value.config.headers] as [string, string][]
next[i] = [k, newVal]
setConfig({ headers: next })
}}
onRemove={() =>
setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) })
}
/>
))}
</div>
)}
</div>
)
}