Skip to content

Latest commit

 

History

History
468 lines (369 loc) · 20.1 KB

widgets.md

File metadata and controls

468 lines (369 loc) · 20.1 KB

Adding a widget

A widget is a self-contained folder under ui/widgets/<name>/. Everything the dashboard needs lives in that folder, so adding a widget touches no file outside it. The backend discovers the folder automatically, builds the admin config form from the manifest, and serves the manifest to the dashboard, which builds the widget's iframe URL from it.

The manifest drives the config form and the view routing; data.js runs server-side and talks to the outside service; index.html runs in a sandboxed iframe and only ever fetches your own API.

Copy widget-template/ to ui/widgets/<name>/ for a working starting point.

Folder structure

ui/widgets/mywidget/
  widget.json    manifest: the config form, label, and sizes
  data.js        backend function that fetches/produces the widget's data
                 (omit it if the widget renders entirely in the browser)
  index.html     frontend page that renders it

1. The manifest (widget.json)

Describes the widget: its label, the card sizes it offers, the config form, and, for a multi-view widget, its views. label is the name shown in the admin list and the type picker, and the default widget name when the user saves without entering one. sizes is the set of card sizes offered.

{
  "name": "mywidget",
  "label": "My Widget",
  "sizes": ["small", "medium"],
  "fields": [
    { "key": "url",    "type": "text",   "label": "Service URL", "placeholder": "http://host:port" },
    { "key": "apiKey", "type": "secret", "label": "API key" },
    { "key": "source", "type": "select", "label": "Show",
      "options": [
        { "value": "recent", "label": "Recent" },
        { "value": "all",    "label": "All" }
      ]
    }
  ]
}

name must match the folder name. An invalid manifest is skipped at startup with a logged reason rather than crashing the server, so a typo disables just that widget.

Views (multiple looks)

A widget can ship more than one frontend file and let the user pick between them (the GitHub widget's Pull Requests and Contributions, the clock's Digital and Analog). Declare each view and its entry file, the config field that holds the choice, and the default:

{
  "viewField": "clockStyle",
  "defaultView": "digital",
  "views": {
    "digital": { "label": "Digital", "src": "digital.html" },
    "analog":  { "label": "Analog",  "src": "analog.html" }
  }
}

viewField names a field the user sets (commonly a select), whose value is matched against the views keys to choose the entry file. With no views block the entry file is index.html. A single-view widget whose file is not index.html still declares it as one view, with no viewField.

Card background

The card behind the widget is glass by default: dark, semi-transparent, blurred, so the wallpaper reads through. A widget that wants a different card names one of three backgrounds:

card what it looks like
dark Solid dark, #1c1c1e. Nothing shows through.
light Solid white. Nothing shows through.
translucent Darker than the default but more transparent, with a stronger blur.
{ "card": "dark" }

A card inside a views entry overrides the widget-level one for that view, so one view can be solid while another stays translucent:

{
  "views": {
    "map": { "src": "map.html", "card": "dark" },
    "vpn": { "src": "vpn.html", "card": "translucent" }
  }
}

Declaring nothing keeps the default glass, which is what a widget wants when it paints its own interior. The weather widget does this: it fills its own body with white by day and dark by night, following the weather region rather than the dashboard, which no fixed choice here could express.

An unknown name is rejected and the widget is skipped, the same as any other invalid manifest.

Under the operating system's increased-contrast setting, translucent becomes as dense as the default card, since a contrast request outranks a transparency preference. Solid backgrounds already meet it and keep their colour.

Field types

type control the user sees
text Inline-edit row (tap the pencil to edit).
number Inline-edit row that stores a number.
secret Inline-edit row for a masked value. Shows Configured once set. The saved value is kept server-side and never sent back to the browser, so to change it the user re-enters it; leaving it blank keeps the existing value.
toggle On/off switch, stored as a boolean.
color The swatch and hue/saturation/brightness control used elsewhere in the admin UI. Saves a #rrggbb string.
select A dropdown by default. Add "variant": "pills" to render the options as a radio group instead. With optionsFrom it also shows a Fetch button (see below).
multiselect A checklist dropdown; the value is an array of the chosen values.
group A repeatable set of sub-fields, each entry rendered as its own card with Add / Remove. Put the sub-fields in a nested "fields" array. Groups cannot be nested inside a group or object.
picklist A fixed number of dropdowns filled from one shared fetch. Saves an array of scalars, one per row, null where unset. Needs count or countBySize, plus options or optionsFrom. rowLabel names the rows, defaulting to label.
object A single nested set of sub-fields in a "fields" array, saved one level deep (for example network.password). Rendered as its own card. Objects cannot be nested inside a group or another object.

Field options

These keys can go on any field:

key meaning
label Shown to the user (required).
placeholder Greyed hint shown in an empty text/number/secret row.
default Value used when none is saved yet.
hint Short help text. Shown under the field, except on a group, where it renders at the bottom of the whole section.
optional If true, the field is not required to save. A required secret is only reported as missing when nothing is stored yet, since a blank input means "keep the stored value".
transient If true, the field is rendered and sent to an optionsFrom fetch but is left out of the saved config. Use it for a search box whose text only feeds a picker. Top-level fields only.
carries For select with optionsFrom: extra config keys this picker writes, supplied by the chosen option's set block.
showIf Show the field only when another field matches: { "field": "provider", "equals": "adguard" }, or match several with { "field": "provider", "in": ["adguard", "pihole"] }. Inside a group, the named field is the one in the same row.
optionsFrom For select: the name of a data endpoint that returns the option list at config time (see below).
variant For select: "pills" renders a radio group instead of a dropdown.
min / max For group: the fewest and most entries allowed.
maxBySize For group: a per-size cap, e.g. { "small": 2, "medium": 5 }. Overrides max for the selected widget size; falls back to max for sizes not listed. Extra entries are trimmed when switching to a smaller size.
countBySize For group: a per-size fixed row count, e.g. { "small": 1, "medium": 3 }. The section always shows exactly that many rows, with no Add or Remove. Overrides min, max and maxBySize for the sizes it names.

Varying a field by another field's value

Two sibling fields may share a key, so the same saved value can be asked for differently depending on another field. Give each declaration a showIf:

{ "key": "url", "type": "text", "label": "Metrics URL", "placeholder": "conduit:9090",
  "showIf": { "field": "type", "equals": "conduit" } },
{ "key": "url", "type": "text", "label": "Management API URL", "placeholder": "netbird:33073",
  "showIf": { "field": "type", "equals": "netbird" } }

Hidden fields are skipped when values are read back, so only the visible one is saved. A repeated key without a showIf on every declaration is rejected by the validator, because two visible fields writing one key would silently leave the last one to win. The validator does not check that the conditions are mutually exclusive; that is on you.

Limiting sizes per view

A view can narrow the widget's own sizes, for a layout that only works at one size:

"views": {
  "map": { "label": "Map", "src": "connections-map.html", "sizes": ["medium"] },
  "vpn": { "label": "VPN", "src": "connections-vpn.html" }
}

Each entry must be a subset of the widget's top-level sizes. A view without sizes offers all of them. The size tiles redraw when the viewField changes.

A fixed list of picks (picklist)

For a widget that stores a plain array of ids, one per physical slot, and fills them all from one call:

{ "key": "bays", "type": "picklist", "label": "Bays", "rowLabel": "Bay",
  "optionsFrom": "devices", "countBySize": { "small": 4, "medium": 10 } }

One Fetch button loads the options once and every row shares them, rather than each row fetching for itself. The saved value is ["sda-abc", null, ...], always count entries long.

A group whose min equals its max is fixed-length too: it renders that many rows with no Add or Remove.

Nested settings (object)

Use object when a widget already stores part of its config one level deep and you do not want to flatten it:

{ "key": "vpn", "type": "object", "label": "Connection", "fields": [
  { "key": "url", "type": "text", "label": "Control server URL" },
  { "key": "apiKey", "type": "secret", "label": "API key", "optional": true }
] }

That saves { "vpn": { "url": "...", "apiKey": "..." } }. A sub-field's showIf names a sibling inside the same object, and its secrets are scrubbed and preserved exactly like top-level ones.

Loading options from the service (optionsFrom)

When a select can only be filled in after the user enters a URL and key, give it "optionsFrom": "<endpoint>" instead of static options. The form shows a Fetch button, which calls your data.js with ctx.endpoint set to that name; return { options: [ { value, label }, ... ] }.

The fetch is sent the form's current values, including any field marked transient, so a search box can supply the query without being saved.

An option can also write keys other than the field's own. List them in the field's carries and return them in the option's set:

{ "key": "city", "type": "select", "optionsFrom": "geocode", "carries": ["lat", "lon"] }
return { options: [{ value: 'Ottawa, Ontario, Canada', label: 'Ottawa, Ontario, Canada', set: { lat: 45.42, lon: -75.7 } }] };

Saving picks up city, lat and lon. Values already saved under the carried keys are kept when the widget is edited without touching the picker, so the coordinates survive a change to an unrelated field.

A select inside a group can use optionsFrom too. Each row fetches on its own, and ctx.row holds that row's values so the picker reads the URL and key the row was filled in with:

if (ctx.endpoint === 'jobs') {
  const slot = ctx.row || {};
  const r = await ctx.fetchJSON(`${ctx.normalizeBase(slot.url)}/api/jobs`, { /* ... */ });
  return { options: r.data.map(j => ({ value: j.id, label: j.name })) };
}

ctx.config still holds the whole widget config, so secrets in the row are preserved the same way they are for a top-level field.

2. Providing data (data.js)

Runs on the backend (Node, CommonJS). Export a single async function taking ctx; the saved config is on ctx.config.

module.exports = async function (ctx) {
  const { url, apiKey } = ctx.config;
  const r = await ctx.fetchJSON(`${url}/api/items`, {
    headers: { 'X-Api-Key': apiKey },
    timeout: 8000,
  });
  return { items: r.data.slice(0, 10) };
};

What you return is served as-is at /api/widget-data/<id>. Keep upstream calls behind ctx.fetchJSON so they inherit the SSRF guard, IP pinning, size limit, and the app's TLS-skip setting.

ctx reference

property what it is
ctx.config The widget's saved config, including any secrets (server-side only).
ctx.settings Global non-secret dashboard settings.
ctx.endpoint The endpoint name, set when serving optionsFrom or a multi-view widget; otherwise the default.
ctx.row For an optionsFrom fetch from a field inside a group, that row's values. null otherwise.
ctx.params Extra query parameters from the request, as a URLSearchParams.
ctx.fetchJSON(url, opts) Fetch a URL and parse the body. JSON is returned as-is; Prometheus text and XML are auto-parsed. Pass { raw: true } to get the untouched text body instead, for a custom parser. Returns { status, data } or throws. Respects the app's TLS-skip setting.
ctx.parsePrometheus(text) Parse a Prometheus metrics body into an object.
ctx.normalizeBase(raw) Tidy a user-entered base URL (add scheme, drop trailing slash).
ctx.metrics Host metrics for stats-style widgets: { cpuSample, ramPercent, cpuTemp, diskStats, procCount, uptimeSeconds }. Each is a function. cpuSample() is async and returns { cpu, iowait } (both percentages) from a single sampling window; the rest return directly. cpuTemp(zone) defaults to zone 0, diskStats(mountPoint) takes a mount path. These read the host's /proc and /sys, so they report host-wide usage, not the container's cgroup limits.
ctx.dispatchProvider(handlers, opts) Run the handler for the provider the user picked, for a widget that supports several backends. handlers is { providerKey: async (ctx) => result }. opts.field is the config field holding the key (default provider), opts.default the key to fall back to, opts.onError(err, ctx) an optional wrapper turning a thrown handler error into the widget's own error shape.
ctx.log The structured logger.

For XML responses, ctx.fetchJSON returns data keyed by the root tag: attributes and child elements both become keys, a repeated tag becomes an array, and a text-only element becomes that text. Numbers are converted only when they round-trip exactly, so 007 and 1.10 stay strings.

If your select uses optionsFrom, handle that path in the same function:

module.exports = async function (ctx) {
  if (ctx.endpoint === 'lists') {
    const r = await ctx.fetchJSON(`${ctx.config.url}/api/lists`, { /* ... */ });
    return { options: r.data.map(l => ({ value: l.id, label: l.name })) };
  }
  // normal poll path
  return { items: [] };
};

Demo mode (demo.js)

The public demo has no reachable services, so a widget can ship a demo.js beside its data.js returning an invented body. It is optional, and it is only required when the dashboard runs with DEMO_MODE=true, so it costs a normal install nothing.

module.exports = function (ctx) {
  const { wave, round } = ctx.demo;
  return { items: [{ name: 'Example' }], total: Math.round(wave(600, 8, 20)) };
};

It receives the same ctx as data.js, plus ctx.demo holding wave and round. wave(periodSec, min, max, phase) is a clock-driven oscillation, so numbers built with it drift between polls and every widget on the demo moves together. Structural data that should not reshuffle, like a calendar grid, is better built once and cached in a module-level variable.

A widget with no demo.js runs its real data.js on the demo. That is the right choice when the data does not come from an unreachable service: the stats widget has none, because ctx.metrics already returns invented host figures and the real code path then gets exercised.

Only the widget's own polling gets a demo body. A config-time optionsFrom fetch always runs the real code, so a visitor pressing Fetch in the settings sees it fail rather than a list of options that do not exist.

3. The frontend (index.html)

Runs in a sandboxed iframe scaled to the widget's design resolution. Reads its id from the query string, fetches its own data, and draws it. Keep everything inline; there is no shared widget stylesheet, and the frontend must not make external network calls.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <style>
    *,*::before,*::after { margin:0; padding:0; box-sizing:border-box }
    html,body { width:100%; height:100%; overflow:hidden; background:transparent;
      font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display',sans-serif; color:#e8e8ea }
    #root { width:100%; height:100%; display:flex; align-items:center; justify-content:center }
  </style>
</head>
<body>
  <div id="root">Loading</div>
  <script>
    const id = new URLSearchParams(location.search).get('id') || '';
    const root = document.getElementById('root');
    async function tick() {
      try {
        const r = await fetch(`/api/widget-data/${encodeURIComponent(id)}`);
        const data = await r.json();
        root.textContent = `${data.items.length} items`;
      } catch (e) {
        /* leave the last good render in place on a failed poll */
      }
    }
    tick();
    setInterval(tick, 30000);
  </script>
</body>
</html>

The widget's saved config, if the frontend needs it, is available the same way at /api/widget-config/<id>.

Design canvas sizes

Widgets render at these fixed sizes and are scaled uniformly to fit their card.

size canvas
small 170 × 170
medium 360 × 170
large 360 × 360
xlarge 360 × 540

Match the existing look: transparent background, system font stack, dark palette.

Toolbox (optional)

Never required, but it bundles the repeatedly-useful frontend pieces. Import what you need from /js/widget-toolbox.js:

import { poll, fetchData, sparkline } from '/js/widget-toolbox.js?v=1';

Keep the ?v=1; the release cache-buster maintains it, like other /js/ imports.

Data

  • widgetId() returns this widget's id (read from the iframe URL).
  • fetchData(endpoint?) GETs /api/widget-data/<id> (optionally ?endpoint=) and returns the parsed JSON, throwing on a non-OK response.
  • getConfig() GETs this widget's secret-free config.

State / lifecycle

poll(opts) runs the fetch-and-render loop and handles loading, empty, stale, and error states, so a single failed poll never blanks a working widget. It replaces the hand-written loop in the example above:

poll({
  render: data => { root.textContent = `${data.items.length} items`; },
  isEmpty: data => data.items.length === 0,
  interval: 30000,
});

A failure keeps the last good render in place; only after staleAfter (default 2) consecutive failures does it surface errorText with how long ago the last success was. sinceLabel(ts) gives that "3m ago" label on its own.

Links

  • openUrl(href) opens a link in a new tab. Use this rather than window.open, which the widget sandbox can block; it clicks a real anchor and only falls back to window.open if that throws.

Markup

  • esc(value) HTML-escapes a value for innerHTML. Use it for anything that came from config or from upstream, rather than writing an escape helper per widget.

Visuals (self-contained inline SVG/DOM, no extra CSS)

  • sparkline(values, opts?) returns an <svg> area+line chart element.
  • barFill(percent, opts?) returns a track+fill bar element.
  • smoothPath(points) returns a smoothed SVG path string through [[x,y], ...].

Check the toolbox before building a new visual by hand.

Cache-busting

Nothing to do by hand. The release build hashes each widget entry file by content and stamps the cache version into the manifest, the same way it version-stamps /css/ and /js/ imports.

Checklist

Manifests are validated in CI, so a schema mistake fails the PR rather than silently disabling the widget at runtime. Run the same check locally with cd api && node --test.

  • ui/widgets/<name>/widget.json with name (matching the folder), label, sizes, and fields
  • ui/widgets/<name>/data.js exporting module.exports = async (ctx) => ..., for a widget that fetches. A widget that renders entirely in the browser (the clock, the dashboard switch) ships no data.js and never calls /api/widget-data/.
  • ui/widgets/<name>/index.html that reads ?id= and fetches /api/widget-data/<id>
  • For a multi-view widget: a views block with viewField and defaultView