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:
@@ -3762,7 +3762,7 @@ export const WidgetDefSchema = {
|
||||
},
|
||||
type: {
|
||||
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: {
|
||||
|
||||
@@ -1297,7 +1297,7 @@ export type WebPushKey = {
|
||||
*/
|
||||
export type WidgetDef = {
|
||||
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;
|
||||
layout?: {
|
||||
[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 = {
|
||||
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>
|
||||
|
||||
{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">
|
||||
<Label className="text-sm font-normal">Text</Label>
|
||||
<Input
|
||||
|
||||
@@ -86,6 +86,7 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
|
||||
clock: "Clock",
|
||||
media: "Media",
|
||||
player: "Player",
|
||||
embed: "Website",
|
||||
button: "Button",
|
||||
switch: "Switch",
|
||||
slider: "Slider",
|
||||
@@ -108,6 +109,7 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
|
||||
clock: { w: 3, h: 2 },
|
||||
media: { w: 4, h: 4 },
|
||||
player: { w: 4, h: 3 },
|
||||
embed: { w: 6, h: 4 },
|
||||
button: { w: 3, h: 2 },
|
||||
switch: { w: 3, h: 2 },
|
||||
slider: { w: 4, h: 2 },
|
||||
@@ -130,10 +132,14 @@ export const seriesOf = (widget: WidgetDef): Series[] =>
|
||||
* fetching the message catalogue first.
|
||||
*/
|
||||
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
|
||||
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") {
|
||||
const runs = (cfg.runs ?? {}) as {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -653,6 +695,7 @@ const RENDERERS: Partial<
|
||||
clock: ClockWidget,
|
||||
media: MediaWidget,
|
||||
player: PlayerWidget,
|
||||
embed: EmbedWidget,
|
||||
button: ButtonWidget,
|
||||
switch: SwitchWidget,
|
||||
slider: SliderWidget,
|
||||
|
||||
Reference in New Issue
Block a user