Add a website widget, so a page nobody modelled as a message can hang on a wall
Docs / docs (push) Successful in 24s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m24s
Playwright Tests / test-playwright (2, 2) (push) Failing after 14s
pre-commit / pre-commit (push) Failing after 2m34s
Test Backend / test-backend (push) Failing after 2m35s
Compose Smoke Test / test-compose (push) Failing after 14s
Playwright Tests / merge-reports (push) Failing after 2m21s

A tile that draws whatever an address serves: no binding, no flow, just an
iframe. Only http(s) loads — a `javascript:` src would run in the app's own
origin, and a dashboard is a document several people can edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KocbsBHWme1kfCHVhrgBnw
This commit is contained in:
2026-08-30 18:48:52 +02:00
co-authored by Claude Opus 5
parent 302be52921
commit c20f6a1b68
6 changed files with 106 additions and 7 deletions
+3 -2
View File
@@ -65,6 +65,7 @@ WidgetType = Literal[
"clock", "clock",
"media", "media",
"player", "player",
"embed",
# Input # Input
"button", "button",
"switch", "switch",
@@ -113,8 +114,8 @@ WIDGET_DTYPES: dict[str, set[str]] = {
# duration in one reading, because they are one thing and a player drawn # duration in one reading, because they are one thing and a player drawn
# from five separate messages would redraw itself five times. # from five separate messages would redraw itself five times.
"player": {"record"}, "player": {"record"},
# An icon maps weather strings, bool hints and numbers alike, and a clock # An icon maps weather strings, bool hints and numbers alike; a clock reads
# binds nothing at all, so neither has a row to be held to. # the wall and an embed a page of its own, so none has a row to be held to.
} }
+1 -1
View File
@@ -3762,7 +3762,7 @@ export const WidgetDefSchema = {
}, },
type: { type: {
type: 'string', type: 'string',
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'media', 'player', 'button', 'switch', 'slider', 'input', 'dropdown', 'color'], enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'media', 'player', 'embed', 'button', 'switch', 'slider', 'input', 'dropdown', 'color'],
title: 'Type' title: 'Type'
}, },
title: { title: {
+2 -2
View File
@@ -1297,7 +1297,7 @@ export type WebPushKey = {
*/ */
export type WidgetDef = { export type WidgetDef = {
id: string; id: string;
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color'; type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
title?: string; title?: string;
layout?: { layout?: {
[key: string]: Placement; [key: string]: Placement;
@@ -1307,7 +1307,7 @@ export type WidgetDef = {
}; };
}; };
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color'; export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
export type WorkerInfo = { export type WorkerInfo = {
name: string; name: string;
@@ -0,0 +1,39 @@
/**
* Which addresses an embed widget will load, checked.
*
* ponytail: a script rather than a suite, as with the other `.check.ts` files:
*
* cd frontend && bun run src/components/Dashboard/embed.check.ts
*
* The guard is the point. An `<iframe src>` of `javascript:` or `data:` runs
* in this app's own origin, so anything but `http(s)` has to come out empty.
*/
import assert from "node:assert/strict"
import type { WidgetDef } from "@/client"
import { embedUrl } from "./widgets"
const widget = (url: unknown): WidgetDef =>
({ id: "e", type: "embed", config: { url } }) as WidgetDef
for (const url of [
"https://embed.ventusky.com/?p=49.41;8.93;9",
"http://camera.local/ui",
" https://example.com/page ",
]) {
assert.equal(embedUrl(widget(url)), url.trim(), url)
}
for (const url of [
"javascript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"//example.com",
"/panel/main",
"",
undefined,
]) {
assert.equal(embedUrl(widget(url)), "", String(url))
}
console.log("embed: ok")
+17 -1
View File
@@ -539,7 +539,23 @@ export function WidgetPanel({
/> />
</div> </div>
{widget.type === "markdown" ? ( {widget.type === "embed" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal" htmlFor="embed-url">
Address
</Label>
<Input
id="embed-url"
value={str(cfg.url)}
placeholder="https://embed.ventusky.com/?p=49.41;8.93;9"
onChange={(event) => set({ url: event.target.value })}
/>
<p className="text-xs text-muted-foreground">
Any page that allows being framed. Sites that refuse show their
own refusal in the tile.
</p>
</div>
) : widget.type === "markdown" ? (
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label className="text-sm font-normal">Text</Label> <Label className="text-sm font-normal">Text</Label>
<Input <Input
+44 -1
View File
@@ -86,6 +86,7 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
clock: "Clock", clock: "Clock",
media: "Media", media: "Media",
player: "Player", player: "Player",
embed: "Website",
button: "Button", button: "Button",
switch: "Switch", switch: "Switch",
slider: "Slider", slider: "Slider",
@@ -108,6 +109,7 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
clock: { w: 3, h: 2 }, clock: { w: 3, h: 2 },
media: { w: 4, h: 4 }, media: { w: 4, h: 4 },
player: { w: 4, h: 3 }, player: { w: 4, h: 3 },
embed: { w: 6, h: 4 },
button: { w: 3, h: 2 }, button: { w: 3, h: 2 },
switch: { w: 3, h: 2 }, switch: { w: 3, h: 2 },
slider: { w: 4, h: 2 }, slider: { w: 4, h: 2 },
@@ -130,10 +132,14 @@ export const seriesOf = (widget: WidgetDef): Series[] =>
* fetching the message catalogue first. * fetching the message catalogue first.
*/ */
export function widgetIssue(widget: WidgetDef): string | null { export function widgetIssue(widget: WidgetDef): string | null {
// Neither draws a message: a clock reads the wall, markdown its own text. // None draws a message: a clock reads the wall, markdown its own text.
if (widget.type === "markdown" || widget.type === "clock") return null if (widget.type === "markdown" || widget.type === "clock") return null
const cfg = config(widget) const cfg = config(widget)
if (widget.type === "embed") {
return embedUrl(widget) ? null : "This widget has no address yet."
}
if (widget.type === "chart" && cfg.source === "runs") { if (widget.type === "chart" && cfg.source === "runs") {
const runs = (cfg.runs ?? {}) as { const runs = (cfg.runs ?? {}) as {
metric?: string metric?: string
@@ -434,6 +440,42 @@ function NotificationWidget({ widget }: WidgetProps) {
) )
} }
/**
* The page this widget embeds, or empty if it names none we will load.
*
* Only `http(s)`: an `<iframe src>` of `javascript:` or `data:` runs in this
* app's own origin, and a dashboard is a document several people can edit.
*/
export function embedUrl(widget: WidgetDef): string {
const url = text(config(widget).url).trim()
return /^https?:\/\//i.test(url) ? url : ""
}
/**
* Somebody else's page, drawn in the tile.
*
* Whatever the site serves — a weather map, a camera's own web UI, a timetable
* — with no binding of its own, so nothing has to be modelled as a message
* first. A site that refuses to be framed (`X-Frame-Options`) shows its own
* refusal inside the tile; that is between the browser and the site.
*/
function EmbedWidget({ widget }: WidgetProps) {
const url = embedUrl(widget)
if (!url) return <p className="text-muted-foreground">Give it an address.</p>
return (
<iframe
src={url}
title={widget.title || url}
// Same-origin is deliberately withheld: the page keeps its own storage
// and cookies only by being another origin, which it is.
sandbox="allow-scripts allow-popups allow-forms allow-same-origin"
referrerPolicy="no-referrer"
loading="lazy"
className="h-full w-full rounded-md border-0"
/>
)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Input // Input
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -653,6 +695,7 @@ const RENDERERS: Partial<
clock: ClockWidget, clock: ClockWidget,
media: MediaWidget, media: MediaWidget,
player: PlayerWidget, player: PlayerWidget,
embed: EmbedWidget,
button: ButtonWidget, button: ButtonWidget,
switch: SwitchWidget, switch: SwitchWidget,
slider: SliderWidget, slider: SliderWidget,