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
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:
@@ -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.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user