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.
461 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|