diff --git a/astro.config.mjs b/astro.config.mjs index ca7fcab..5719d5a 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -10,6 +10,11 @@ const SITE = 'https://stackyard.sandobserver.com'; export default defineConfig({ site: SITE, + redirects: { + '/docs/development': '/docs/contributing/', + '/docs/adding-services': '/docs/apps-and-folders/', + '/docs/advanced-configuration': '/docs/installation/docker/', + }, markdown: { rehypePlugins: [ [rehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }], @@ -72,23 +77,28 @@ export default defineConfig({ baseUrl: 'https://github.com/SandObserver/stackyard-docs/edit/main/', }, sidebar: [ - { label: 'Introduction', link: '/docs/' }, - { label: 'Compare dashboards', link: '/docs/is-stackyard-for-you/' }, { - label: 'Installation', + label: 'Get started', items: [ - { label: 'Docker', link: '/docs/installation/docker/' }, - { label: 'Unraid', link: '/docs/installation/unraid/' }, + { label: 'Introduction', link: '/docs/' }, + { label: 'Comparison', link: '/docs/is-stackyard-for-you/' }, + { label: 'Install with Docker', link: '/docs/installation/docker/' }, + { label: 'Install on Unraid', link: '/docs/installation/unraid/' }, { label: 'Build from source', link: '/docs/installation/build-from-source/' }, + { label: 'First setup', link: '/docs/first-setup/' }, + ], + }, + { + label: 'Build your dashboard', + items: [ + { label: 'Add apps and folders', link: '/docs/apps-and-folders/' }, + { label: 'Show status badges', link: '/docs/badges/' }, + { label: 'Wallpaper and themes', link: '/docs/customization/' }, ], }, - { label: 'First setup', link: '/docs/first-setup/' }, - { label: 'Settings reference', link: '/docs/settings-reference/' }, - { label: 'Advanced configuration', link: '/docs/advanced-configuration/' }, - { label: 'Adding services', link: '/docs/adding-services/' }, - { label: 'Badges', link: '/docs/badges/' }, { label: 'Widgets', + collapsed: true, items: [ { label: 'Overview', link: '/docs/widgets/' }, { label: 'Backup', link: '/docs/widgets/backup/' }, @@ -105,23 +115,51 @@ export default defineConfig({ { label: 'Weather', link: '/docs/widgets/weather/' }, ], }, - { label: 'Customization', link: '/docs/customization/' }, { - label: 'Import and export', + label: 'Run Stackyard', items: [ - { label: 'Backup and restore', link: '/docs/import-export/backup-and-restore/' }, - { - label: 'Migrating from another dashboard', - link: '/docs/import-export/migrating/', - }, + { label: 'Back up and restore', link: '/docs/import-export/backup-and-restore/' }, + { label: 'Import', link: '/docs/import-export/migrating/' }, + { label: 'Use a reverse proxy', link: '/docs/reverse-proxy/' }, + { label: 'Security', link: '/docs/security/' }, ], }, - { label: 'Security', link: '/docs/security/' }, - { label: 'Accessibility', link: '/docs/accessibility/' }, + { label: 'Settings', link: '/docs/settings-reference/' }, { label: 'Troubleshooting', link: '/docs/troubleshooting/' }, - { label: 'Support', link: '/docs/support/' }, - { label: 'Development', link: '/docs/development/' }, - { label: 'Changelog', link: '/docs/changelog/' }, + { + label: 'Create a widget', + collapsed: true, + items: [ + { label: 'Build your first widget', link: '/docs/create-a-widget/' }, + { label: 'Manifest', link: '/docs/create-a-widget/manifest/' }, + { label: 'Data', link: '/docs/create-a-widget/data/' }, + { label: 'Widget page', link: '/docs/create-a-widget/widget-page/' }, + { label: 'Checklist', link: '/docs/create-a-widget/checklist/' }, + ], + }, + { + label: 'Contributing', + collapsed: true, + items: [ + { label: 'How to contribute', link: '/docs/contributing/' }, + { label: 'Architecture', link: '/docs/contributing/architecture/' }, + { label: 'Design system', link: '/docs/contributing/design-system/' }, + { label: 'Translations', link: '/docs/contributing/translations/' }, + { label: 'API errors', link: '/docs/contributing/api-errors/' }, + { label: 'Releasing', link: '/docs/contributing/releasing/' }, + ], + }, + { + label: 'About', + collapsed: true, + items: [ + { label: 'Accessibility', link: '/docs/accessibility/' }, + { label: 'Security policy', link: '/docs/security-policy/' }, + { label: 'Governance', link: '/docs/governance/' }, + { label: 'Support', link: '/docs/support/' }, + { label: 'Changelog', link: '/docs/changelog/' }, + ], + }, ], }), mdx(), diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 0000000..c72b990 --- /dev/null +++ b/public/_redirects @@ -0,0 +1,6 @@ +/docs/development /docs/contributing/ 301 +/docs/development/ /docs/contributing/ 301 +/docs/adding-services /docs/apps-and-folders/ 301 +/docs/adding-services/ /docs/apps-and-folders/ 301 +/docs/advanced-configuration /docs/installation/docker/ 301 +/docs/advanced-configuration/ /docs/installation/docker/ 301 diff --git a/public/api/widget-data/tpl-mywidget b/public/api/widget-data/tpl-mywidget new file mode 100644 index 0000000..a487d89 --- /dev/null +++ b/public/api/widget-data/tpl-mywidget @@ -0,0 +1 @@ +{"items":[{"name":"First item"},{"name":"Second item"}],"total":12} diff --git a/public/img/stackyard-mark-light.svg b/public/img/stackyard-mark-light.svg new file mode 100644 index 0000000..7fa62b2 --- /dev/null +++ b/public/img/stackyard-mark-light.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/widgets/mywidget/data.js b/public/widgets/mywidget/data.js new file mode 100644 index 0000000..69745f0 --- /dev/null +++ b/public/widgets/mywidget/data.js @@ -0,0 +1,15 @@ +module.exports = async function (ctx) { + const { url, apiKey } = ctx.config; + if (!url) return { error: 'Not configured' }; + + const base = ctx.normalizeBase(url); + const r = await ctx.fetchJSON(`${base}/api/items`, { + headers: apiKey ? { 'X-Api-Key': apiKey } : {}, + timeout: 8000, + }); + + return { + items: (r.data.items || []).slice(0, 10).map(i => ({ name: i.name })), + total: r.data.total ?? 0, + }; +}; diff --git a/public/widgets/mywidget/demo.js b/public/widgets/mywidget/demo.js new file mode 100644 index 0000000..d3d4fdc --- /dev/null +++ b/public/widgets/mywidget/demo.js @@ -0,0 +1,16 @@ +/* Optional. Only used when the dashboard runs with DEMO_MODE=true, where your + service is unreachable, so the widget is handed this body instead of running + data.js. Delete this file if you do not care how your widget looks there. + + ctx is the same one data.js receives, plus ctx.demo. Using ctx.demo.wave for + anything that should look alive keeps your numbers drifting on the same clock + as every other widget's. */ + +module.exports = function (ctx) { + const { wave, round } = ctx.demo; + return { + items: [{ name: 'First item' }, { name: 'Second item' }], + total: Math.round(wave(600, 8, 20)), + ratio: round(wave(300, 0, 1), 2), + }; +}; diff --git a/public/widgets/mywidget/index.html b/public/widgets/mywidget/index.html new file mode 100644 index 0000000..ef9b38c --- /dev/null +++ b/public/widgets/mywidget/index.html @@ -0,0 +1,41 @@ + + + + + + + + + +
+ + + diff --git a/public/widgets/mywidget/widget.json b/public/widgets/mywidget/widget.json new file mode 100644 index 0000000..0bac3d5 --- /dev/null +++ b/public/widgets/mywidget/widget.json @@ -0,0 +1,11 @@ +{ + "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", "optional": true }, + { "key": "showTotal", "type": "toggle", "label": "Show total", "default": true } + ] +} diff --git a/scripts/check-dist.mjs b/scripts/check-dist.mjs index ed1be92..d78198e 100644 --- a/scripts/check-dist.mjs +++ b/scripts/check-dist.mjs @@ -97,13 +97,15 @@ report('secrets', secretHits); /* House style. */ report('style', files.filter((f) => f.startsWith('src') && read(f).includes('—'))); -/* The development, changelog and accessibility pages are generated from the application - repository. When that checkout is absent they render a link instead, which - is a valid page and would otherwise pass every check above. */ +/* The changelog, accessibility, security policy and governance pages are + generated from the application repository. When that checkout is absent they + render a link instead, which is a valid page and would otherwise pass every + check above. */ const generated = [ - ['dist/docs/development/index.html', 'CONTRIBUTING.md'], ['dist/docs/changelog/index.html', 'CHANGELOG.md'], ['dist/docs/accessibility/index.html', 'ACCESSIBILITY.md'], + ['dist/docs/security-policy/index.html', 'SECURITY.md'], + ['dist/docs/governance/index.html', 'GOVERNANCE.md'], ]; report( 'genpage', diff --git a/scripts/sync-widgets.mjs b/scripts/sync-widgets.mjs index 883baba..89c77c9 100644 --- a/scripts/sync-widgets.mjs +++ b/scripts/sync-widgets.mjs @@ -12,9 +12,10 @@ const ROBOTS = ''; for (const [from, to] of [ [join(REPO, 'ui', 'widgets'), join(PUB, 'widgets')], [join(REPO, 'ui', 'js'), join(PUB, 'js')], + [join(REPO, 'docs', 'widget-template'), join(PUB, 'widgets', 'mywidget')], ]) { rmSync(to, { recursive: true, force: true }); - cpSync(from, to, { recursive: true }); + cpSync(from, to, { recursive: true, filter: (src) => !src.endsWith('README.md') }); } function walk(dir, out = []) { diff --git a/src/components/LinkList.astro b/src/components/LinkList.astro new file mode 100644 index 0000000..fcc332a --- /dev/null +++ b/src/components/LinkList.astro @@ -0,0 +1,37 @@ +--- +import { inline } from '../lib/inline'; + +interface Row { + href: string; + name: string; + text: string; + value?: string; +} + +interface Props { + title: string; + items: Row[]; +} + +const { title, items } = Astro.props; +--- + +
+

{title}

+ +
diff --git a/src/components/ServiceList.astro b/src/components/ServiceList.astro new file mode 100644 index 0000000..f4d593a --- /dev/null +++ b/src/components/ServiceList.astro @@ -0,0 +1,68 @@ +--- +import { inline } from '../lib/inline'; + +interface Link { + label: string; + href: string; +} + +interface Service { + name: string; + kind?: string; + needs: string; + fields?: [string, string][]; + notes?: string[]; + links?: Link[]; +} + +interface Props { + title?: string; + footer?: string; + items: Service[]; +} + +const { title = 'Works with', footer, items } = Astro.props; +--- + +
+

{title}

+
+ { + items.map((s) => ( +
+ + + {s.name} + {s.kind && {s.kind}} + + + +
+ {s.fields && ( +
+ {s.fields.map(([name, text]) => ( + <> +
{name}
+
+ + ))} +
+ )} + {s.notes?.map((n) =>

)} + {s.links && ( +

+ )} +
+
+ )) + } +
+ {footer &&

} +

diff --git a/src/components/SettingList.astro b/src/components/SettingList.astro new file mode 100644 index 0000000..6c05744 --- /dev/null +++ b/src/components/SettingList.astro @@ -0,0 +1,33 @@ +--- +import { inline } from '../lib/inline'; + +interface Setting { + name: string; + text: string; + optional?: boolean; +} + +interface Props { + title: string; + items: Setting[]; +} + +const { title, items } = Astro.props; +--- + +
+

{title}

+ +
diff --git a/src/content/docs/docs/adding-services.md b/src/content/docs/docs/adding-services.md deleted file mode 100644 index d090f26..0000000 --- a/src/content/docs/docs/adding-services.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Adding services -description: Add app tiles, group them into folders, put them in the dock, and reorder the grid. ---- - -**In the admin:** Dashboard. See the [Settings reference](/docs/settings-reference/). - -Apps are added under **Dashboard** in the admin UI. Nothing here edits a file by hand. - -## Add an app - -Press Add, then fill in the app. - -
- The app edit form, showing Name, URL, Icon and ColorThe app edit form, showing Name, URL, Icon and Color -
Name, URL, icon and tile colour. Icon takes a name, a URL, or an upload.
-
- -## Icons - -Type a name and Stackyard searches four community catalogues together: [dashboard-icons](https://github.com/homarr-labs/dashboard-icons), [selfh.st](https://github.com/selfhst/icons), [simple-icons](https://github.com/simple-icons/simple-icons) and [lobehub](https://github.com/lobehub/lobe-icons). Typing `sonarr` finds the Sonarr icon. A service is matched by its name, by other spellings of it, and by the aliases a catalogue lists. Each suggestion names the catalogue it came from. - -Where a catalogue holds an icon as a light and a dark file, the picker offers a Variant choice. The choice is fixed, not theme-aware. - -You can also paste a full URL or upload a file. Uploads are stored in `./icons` and served from your own server. - -Icons load through the server, which fetches each one once, sanitizes SVGs, and caches it for 24 hours. The CDN does not learn which services your dashboard shows. - -If no icon resolves, the tile shows the first letter of the name. - -Color takes a swatch or a hex value. `Dark` and `Light` are fixed tile colours, not theme-aware: the dashboard is always dark, so a tile keeps the colour you set. - -## The dock - -The dock is the row of icons pinned at the bottom of the dashboard. Turn on Show in Dock on an app to put it there. - -It holds four apps. Once full, the toggle refuses more until you remove one. Widgets cannot go in the dock. - -The dock is hidden while no app is in it, and the grid uses the space instead. - -## Folders - -A folder groups apps behind one tile, whose artwork is a grid of the icons inside it. - -Create one from Add, name it, then use Add Apps to choose the contents. Folders cannot be nested, and a widget cannot go in a folder. - -## Reordering - -The Dashboard list holds everything on the grid in one flat list. List order is grid order. Drag a row's handle, or use the arrows on the row, and the new order is saved straight away. There is nothing to confirm. - -
- The Dashboard list, with drag handles, move arrows and an Edit button on each rowThe Dashboard list, with drag handles, move arrows and an Edit button on each row -
Each row carries a drag handle, the move arrows, and Edit.
-
- -Rows are tagged with what is configured on them. - -
- The list filtered to apps, showing Dock and Badge tagsThe list filtered to apps, showing Dock and Badge tags -
Filtered to apps. The move arrows are hidden while a filter is on, so reordering is disabled until you clear it.
-
- -## Next - -Add context to a tile with [Badges](/docs/badges/), or add a [Widget](/docs/widgets/). diff --git a/src/content/docs/docs/advanced-configuration.md b/src/content/docs/docs/advanced-configuration.md deleted file mode 100644 index 0ada4f7..0000000 --- a/src/content/docs/docs/advanced-configuration.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -title: Advanced configuration -description: Deployment-level settings. Environment variables, reverse proxies, host access, and Docker health checks. ---- - -Everyday setup happens in the web UI. Nothing on this page is required for a normal install. These are deployment concerns: how the container is reached, what it may reach, and how it identifies clients. - -## Environment variables - -Every variable is optional. The defaults are what the container ships with. - -| Variable | Default | What it does | -| --- | --- | --- | -| `ALLOW_PRIVATE_IPS` | unset | Turns the SSRF guard off, so badges and widgets may reach private, LAN and loopback addresses. Most homelab installs need it. | -| `SOCKET_PROXY_URL` | unset | A Docker socket proxy, for container health monitoring. | -| `TRUST_PROXY` | unset | Believe `X-Forwarded-Proto`, so a request through a TLS-terminating proxy gets a `Secure` cookie. | -| `TRUSTED_PROXY` | unset | Where a front proxy sits, so nginx can resolve the real client for rate limiting. | -| `SESSION_MAX_AGE_DAYS` | `0.5` | Idle session lifetime in days before re-login, so 12 hours by default. Accepts a fraction. A session in use is extended. | -| `PASSWORD_HASH_MEMORY` | `16mib` | Memory per password hash. One of `8mib`, `16mib`, `32mib`, `64mib`, `128mib`. | -| `LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`. `warn` and `error` behave the same. The General settings page also sets this, and that wins once the config has loaded. | -| `DEMO_MODE` | unset | Run as a read-only public showcase. | -| `CONFIG_PATH` | `/data/apps.json` | Where the config file lives. | -| `ICONS_PATH` | `/icons` | Where uploaded icons are written. | -| `WIDGETS_PATH` | `/usr/share/nginx/html/widgets` | Where widget folders are read from. A wrong path loads an empty registry and every widget reports as unknown. | -| `NODE_OPTIONS` | `--max-old-space-size=192` | The heap ceiling for the API. Node sizes its heap from the host's memory, not from the container's limit, so without this the API is killed instead of collecting. Keep it near half of the container's memory limit, and raise both together. | -| `PORT` | `80` | Only for a hosting platform that reads `PORT` to decide where to route. It must be `80`, the port the container serves on. It does not move anything inside the container. To reach the dashboard on another port, change the published port instead. | - -If `CONFIG_PATH` or `ICONS_PATH` points at a folder that does not exist, the container logs a warning at startup. Writes to that path fail until the folder exists. - -The repo's [`docker-compose.yml`](https://github.com/SandObserver/stackyard/blob/main/docker-compose.yml) carries each of these as a commented line. - -## Reaching services on private IPs - -The SSRF guard blocks requests to private, loopback and link-local addresses. Most homelab services live on private IPs, so most installs need the guard off: - -```yaml -environment: - - ALLOW_PRIVATE_IPS=true -``` - -Read [Security](/docs/security/) before setting it. Two things work without it: dotless hostnames such as Docker container names, and the host IP you set in General settings. - -## Reaching services on the Docker host - -On Linux a container cannot reach the host's LAN IP by default. Add: - -```yaml -extra_hosts: - - "host.docker.internal:host-gateway" -``` - -`host-gateway` is a Docker built-in that resolves to the host machine's IP. - -## Behind a reverse proxy - -Two variables apply, and they do different things. - -`TRUST_PROXY=true` makes Stackyard believe `X-Forwarded-Proto: https`, so the session cookie gets its `Secure` flag. Set it only when a proxy you control is actually in front of the app. - -:::caution -If `TRUST_PROXY=true` is set while Stackyard is also reachable directly, a client can claim `X-Forwarded-Proto: https` and be issued a `Secure` cookie over plain HTTP. -::: - -`TRUSTED_PROXY` tells nginx where the front proxy sits, so it can resolve the real client address for rate limiting: - -``` -TRUSTED_PROXY=172.18.0.0/16 -TRUSTED_PROXY="172.18.0.0/16 10.0.0.5" -``` - -Without it, every request through the proxy counts as the same client and rate limiting becomes one shared bucket. - -Rate-limit counters are held in memory and are not shared across replicas. Run a single instance behind any proxy. - -## Docker container health checks - -The health-check badge can read a container's state from the Docker daemon. This needs a Docker socket proxy, a separate container that exposes a narrowed read-only view of the socket. - -[tecnativa/docker-socket-proxy](https://github.com/tecnativa/docker-socket-proxy) is the usual choice: - -```yaml -services: - socket-proxy: - image: tecnativa/docker-socket-proxy - environment: - - CONTAINERS=1 - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - networks: - - socket_proxy - restart: unless-stopped - - stackyard: - environment: - - SOCKET_PROXY_URL=http://socket-proxy:2375 - networks: - - socket_proxy - -networks: - socket_proxy: -``` - -Then turn on Docker Container Health Checks in General. - -:::caution -Never mount the Docker socket into Stackyard itself. A name such as `http://socket-proxy:2375` resolves only when both containers share a network. An IP address works only when the proxy publishes its port beyond the host's own loopback. -::: - -Ping-based health checks need none of this. See [Badges](/docs/badges/). - -## Optional host mounts - -Two mounts extend what the System Summary widget can read when its source is This Machine: - -```yaml -volumes: - # CPU temperature sensors - - /sys/class/thermal:/sys/class/thermal:ro - # Disk usage for a mount path - - /mnt/your-drive:/mnt/your-drive:ro -``` - -## TLS - -Stackyard does not terminate TLS and serves plain HTTP only. Put it behind a reverse proxy that terminates TLS. See [Security](/docs/security/). diff --git a/src/content/docs/docs/apps-and-folders.md b/src/content/docs/docs/apps-and-folders.md new file mode 100644 index 0000000..be154d6 --- /dev/null +++ b/src/content/docs/docs/apps-and-folders.md @@ -0,0 +1,22 @@ +--- +title: Add apps and folders to your dashboard +description: Add app tiles with icons by name, group them in folders, and pin them to the dock in Stackyard. +--- + +**In the admin:** Dashboard. + +## Apps + +Press Add, choose App, and enter a name, URL, icon and colour. + +For the icon, type the service name, such as `sonarr`. Stackyard searches [dashboard-icons](https://github.com/homarr-labs/dashboard-icons), [selfh.st](https://github.com/selfhst/icons), [simple-icons](https://github.com/simple-icons/simple-icons) and [lobehub](https://github.com/lobehub/lobe-icons). A URL or an uploaded file also works. Icons load through your server, so the icon CDN never sees your list of services. + +Drag rows in the Dashboard list to reorder the grid. + +## Folders + +A folder holds apps behind one tile. Press Add, choose Folder, then Add Apps. Folders do not nest, and widgets cannot go in one. + +## Dock + +Turn on Show in Dock on an app to pin it to the bar at the bottom. The dock holds four apps. diff --git a/src/content/docs/docs/badges.md b/src/content/docs/docs/badges.md index cf1271a..b642e3e 100644 --- a/src/content/docs/docs/badges.md +++ b/src/content/docs/docs/badges.md @@ -1,183 +1,65 @@ --- -title: Badges -description: Health checks, fixed labels, and live activity counts on an app tile. +title: Status badges and health checks +description: Add health checks, fixed labels and live values from any API to Stackyard app tiles. --- -**In the admin:** Dashboard, then any app. See the [Settings reference](/docs/settings-reference/). - -A badge is the small pill on the corner of an app tile. It carries context without adding a widget. - -
-
- - ! - - Health check -
-
- - 4K - - Fixed label -
-
- - 7 - - Live activity -
-
- -Each kind is turned on per app, in that app's edit form under Badge. +**In the admin:** Dashboard, open an app, then Badge. + +A badge is the small pill on an app tile. Each app can have three kinds.
- The Badge section of an app's edit form: Health Check, Fixed Label and Live ActivityThe Badge section of an app's edit form: Health Check, Fixed Label and Live Activity -
The three kinds, each with its own toggle. Live Activity opens the API fields when it is on.
+ The Badge section of an app: Health Check, Fixed Label and Live ActivityThe Badge section of an app: Health Check, Fixed Label and Live Activity
## Health check -Reports whether a service is up. - -| Type | What it reads | -| --- | --- | -| Container | The container's state from the Docker daemon. | -| Ping | The HTTP status from a URL you give it. | - -Healthy shows green. A problem shows ! and hovering gives the reason: container not found, container not running, daemon reported it unhealthy, ping failed, or ping returned an error status. +Shows whether the service is up. -Container checks need Docker Container Health Checks turned on in General, plus socket access. See [Advanced configuration](/docs/advanced-configuration/). Ping checks need neither. +- **Ping** reads the HTTP status of a URL. +- **Container** reads the container state from Docker. It needs a socket proxy such as [docker-socket-proxy](https://github.com/tecnativa/docker-socket-proxy), its address in `SOCKET_PROXY_URL`, and Docker Container Health Checks on in General. Never mount the Docker socket into Stackyard. -Turn on Hide Healthy Badge in General to show a badge only when something is wrong. +A problem shows a red `!`. Hover it for the reason. Turn on Hide Healthy Badge in General to hide the green state. ## Fixed label -Text you type once, capped at 10 characters. Pick a colour from the swatches or give a hex value. +Up to 10 characters of text, in a colour you pick. ## Live activity -Numbers read from a service's own API. +A number from any API. Enter the API URL, add a header if the API needs a key, and press Fetch. Stackyard lists every number in the response. Pick one. -Enter the API URL and press Fetch. Stackyard reads the response and lists every number it found, including array lengths and counts of matching items, so you pick from a menu rather than writing a path. No custom widget, no code. - -
- -| Field | What it does | Example | -| --- | --- | --- | -| API URL | The endpoint to poll. | `https://requests.example.com/api/v1/request/count` | -| Authentication | A header or parameter to send. Tick Secret to keep the value out of the browser. | Header `X-Api-Key`, Secret ticked | -| Poll | How often to re-read the endpoint. | `300` seconds | +Tick **Secret** on a header to keep its value on the server. Poll sets how often it reads, in seconds. -
+### More than one value -### Labels - -One poll can feed several labels. Each names one number from the response and carries its own text, colour, unit and threshold. +One API can feed up to five values. Press Add Label for each.
- Two label cards in an app's edit form, each with Value, Label Text, Color, Unit and Show From, above the Add Label buttonTwo label cards in an app's edit form, each with Value, Label Text, Color, Unit and Show From, above the Add Label button -
Press Add Label for another. Drag a card by its handle to reorder it, or use the arrows.
+ Two labels, each with Value, Label Text, Color, Unit and Show FromTwo labels, each with Value, Label Text, Color, Unit and Show From
-
- -| Field | What it does | Example | -| --- | --- | --- | -| Value | Which number from the response this label reads. | `pending` | -| Label Text | The name shown in the list. Optional. | `pending` | -| Color | The badge fill. Optional. | `#ffcc00` | -| Unit | A short suffix for the number, shown in the list and read out by a screen reader. Optional. | `pending` | -| Show From | The count below which this label stays quiet. Optional. | `5` | - -
- -A label is quiet until its number reaches Show From. Leave Show From blank to report any count above zero. A count above 99 shows as `99+`, with the full number in the list. +- **Show From** keeps a value hidden below that count. +- The tile shows the first value that has something to report. A second pill behind it means more. +- Hover, tap or focus the badge to list all values. +- **Show as a Single Badge** adds them into one number. -The pill carries the number alone, so a unit can never make it wider than the icon it marks. The unit appears in the list beside the number. +Counts above 99 show as `99+`. -An app can have five labels. Only the first one that has something to report is drawn on the tile, so a queue that is never quite empty stays out of the way until it matters. +## When an app has more than one -### Reading more than one +A tile draws one badge. If an app has a health check, a fixed label and live activity together, the tile shows the first that applies: -When a second badge is reporting, a matching pill appears behind the first in that badge's colour. It means there is more to see. - -
- Three app tiles: two badges show a second pill behind them, one does not -
Jellyfin and Seerr each have more behind the badge. SABnzbd has one label, so nothing is stacked.
-
+| Order | Badge | When it shows | +| --- | --- | --- | +| 1 | Health problem | The service is down. It hides everything else, so a fault is never masked by a number. | +| 2 | Live activity | A value has reached its Show From. | +| 3 | Fixed label | Nothing above applies. | +| 4 | Healthy dot | The service is up and nothing else shows. Hidden by Hide Healthy Badge. | -Hover the badge, tap it on a phone, or move focus to the tile with a keyboard. Everything the tile is reporting opens in a list, with the full numbers and no truncation. Press Escape or tap elsewhere to close it. Tapping the badge opens the list without following the link. +The others stay available in the list that opens on hover or tap. -
- An app tile with its badge list open, showing pending and approved with their values -
The list carries whatever the badge has no room for, in the same order.
-
- -The list only appears when there is a second badge. One badge is already fully shown on the tile. - -### One total instead - -Turn on Show as a Single Badge to add every label's value together and show the sum as one number, in the first label's colour. This is how Live Activity behaved before labels, and dashboards that used it keep it. - -### Folders - -A folder shows the badge of the app inside it that is reporting, rather than a total. Opening its list names each row by the app it came from. - -## Which badge wins - -An app can have several configured. The first of these that applies is the one drawn. - -
-
- 1 - ! - Health problemAlways outranks a count, so a fault is never hidden behind a number. -
-
- 2 - 7 - Live activityThe first label at or above its Show From. -
-
- 3 - 4K - Fixed label -
-
- 4 -   - HealthyHidden when Hide Healthy Badge is on. -
-
- 5 - - Nothing -
-
- -Whatever is not drawn is still reachable: two or more badges on one tile open the list described above. +A folder shows the badge of the app inside it that is reporting. ## Stale values -If a poll fails, the last known badge stays on the tile and is marked stale. A failed poll is never read as zero. - -
-
- - 7 - - Current -
-
- - 7 - - Stale -
-
- -## Accessibility - -Every badge carries a text description for screen readers, so meaning is never carried by colour alone, and so does the list behind it. Badge text is white wherever white is readable against the fill, and dark only where white would fail the contrast requirement. - -The list opens on keyboard focus as well as on hover and tap, and label order can be changed with the arrow buttons as well as by dragging. +When a poll fails, the last value stays on the tile, dimmed with a dashed outline. A failed poll never reads as zero. diff --git a/src/content/docs/docs/contributing/api-errors.md b/src/content/docs/docs/contributing/api-errors.md new file mode 100644 index 0000000..d8b9630 --- /dev/null +++ b/src/content/docs/docs/contributing/api-errors.md @@ -0,0 +1,41 @@ +--- +title: Stackyard API error reference +description: The error shape the Stackyard API returns and what each error kind means. +--- + +Every API error has a readable `error`, a `kind` to branch on, and sometimes a `detail`. + +```json +{ "error": "Could not reach the service.", "kind": "network", "detail": { "code": "ECONNREFUSED" } } +``` + +Never match words in `error`. Use `kind`. + +The `error` text is written from the `kind`, never copied from the underlying error, so no internal host or path reaches the browser. The original is logged. + +## Kinds + +| Kind | Meaning | +| --- | --- | +| `network` | The target could not be reached. | +| `timeout` | The target was too slow. | +| `blocked` | Stackyard refused the request, by its guard or rate limit. | +| `auth` | The Stackyard session or password. Never an upstream key. | +| `upstream` | The target answered with an error. `detail.status` holds it. | +| `invalid` | A malformed request, or a missing item. | +| `internal` | Anything else. | + +An upstream 401 or 403 is `upstream`, not `auth`. Treat an unknown `kind` as `internal`. + +## detail + +Only these keys, only server-derived values, and omitted when empty. + +| Kind | Key | +| --- | --- | +| `network`, `timeout` | `code`, a Node error code | +| `upstream` | `status`, the HTTP status | +| `invalid` | `code`, such as `ERR_INVALID_URL` | +| `blocked` | `reason`, `private-address` | + +A new kind goes in `KIND` in both `api/src/api-error.js` and `ui/js/admin-error.js`. A test fails until they match. diff --git a/src/content/docs/docs/contributing/architecture.md b/src/content/docs/docs/contributing/architecture.md new file mode 100644 index 0000000..1e8fb1e --- /dev/null +++ b/src/content/docs/docs/contributing/architecture.md @@ -0,0 +1,22 @@ +--- +title: How Stackyard works +description: Stackyard architecture. Nginx and a dependency-free Node API in one container, a single JSON config, and widgets in frames. +--- + +Stackyard is one container. Nginx serves the interface from `ui/` and passes `/api/` to a Node server in `api/`. The API has no npm dependencies. + +## Config + +All state is one JSON file, `apps.json`. The admin writes it and the dashboard reads it. Secrets stay on the server and never reach the browser. + +## Widgets + +Each widget is a frame on the dashboard. The frame asks the API for its data. The API runs that widget's `data.js`, which calls the service. See [Build your first widget](/docs/create-a-widget/). + +## Outbound requests + +Every request to another host goes through `api/src/proxy.js`. A URL that arrives in a request is checked against private addresses. A URL from saved config is not. `ALLOW_PRIVATE_IPS` turns the check off. + +## Frontend + +Plain HTML, CSS and ES modules, with no build step. The dashboard and the admin are separate pages. diff --git a/src/content/docs/docs/contributing/design-system.mdx b/src/content/docs/docs/contributing/design-system.mdx new file mode 100644 index 0000000..c1dcaa1 --- /dev/null +++ b/src/content/docs/docs/contributing/design-system.mdx @@ -0,0 +1,60 @@ +--- +title: Stackyard design system +description: The Stackyard colours, logo and the few design rules that go beyond Apple's Human Interface Guidelines. +--- + +Stackyard follows Apple's [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines) for type, spacing, controls and motion. This page covers only what is specific to Stackyard. Tokens live in `ui/css/tokens.css`. + +## Colour + +Teal is the brand accent. Rules name a role below, never a raw colour. + +

Dark theme

+
+
Accent#00D2E0
+
Success#30D158
+
Warning#FF9230
+
Danger#FF4245
+
Info#0091FF
+
+ +

Light theme

+
+
Accent#0071A4
+
Success#238539
+
Warning#C93400
+
Danger#D70015
+
Info#0040DD
+
+ +The other hues and greys are Apple's system colours. + +## What differs from the guidelines + +- **The dashboard is always dark.** Only the Settings page has a light theme. +- **Text on cards uses `--sy-a11y-dim`.** Apple's secondary label colours fail WCAG contrast on Stackyard's cards. +- **Text on a user-picked tile colour is black or white,** whichever contrasts more. It is measured, never fixed. +- **Widgets cannot read tokens.** A widget copies the values it needs from the palette. + +## Logo + +Use the files as they are. Do not recolour, stretch or redraw them. + + diff --git a/src/content/docs/docs/contributing/index.md b/src/content/docs/docs/contributing/index.md new file mode 100644 index 0000000..26d728b --- /dev/null +++ b/src/content/docs/docs/contributing/index.md @@ -0,0 +1,59 @@ +--- +title: Contribute to Stackyard +description: The rules a Stackyard change must keep, how to run it locally, and the checks a pull request must pass. +--- + +## Rules + +- One container. No extra services, no database. +- No runtime dependencies. The frontend has no framework and no build step. +- Server code is CommonJS. Frontend code is ES modules. + +Open an issue first if a change needs a dependency or a build step. + +## Run locally + +```sh +docker build -t stackyard:local . +docker run -d -p 8700:80 -v ./data:/data -v ./icons:/icons stackyard:local +``` + +Without Docker, start the API with `npm start` in `api/`, setting `CONFIG_PATH`, `ICONS_PATH` and `WIDGETS_PATH=../ui/widgets`. Serve `ui/` with any server that proxies `/api/` and `/health` to `127.0.0.1:3000`, as `nginx/dashboard.conf` does. + +## Tests + +A behaviour change ships with its tests. A bug fix ships with a test that fails without it. + +```sh +cd api && npm test +cd ui/test && node --test +``` + +Playwright specs in `e2e/` run against `BASE_URL`, default `http://127.0.0.1:8730`. + +## Pull request checks + +CI runs `.github/actions/checks/action.yml`: + +```sh +npm ci +node scripts/changelog-check.js +node scripts/changelog-fragments.js --check +node scripts/bump-cache-busting.js --check +npm run paths:check +cd api && npm test +cd api && npx c8 check-coverage --lines 92 +cd ui/test && node --test +npm run lint +npm run format:check +npm run typecheck +npm run typecheck:ui +docker build -t stackyard:ci . +``` + +CodeQL and Trivy also run. Both block on a finding. + +- Do not edit `CHANGELOG.md`. Add a fragment in `changelog.d/` named `
-.md`. +- Write `?v=1` on new `/css/` and `/js/` imports. The release sets the real hash. +- A new `ui/js` module needs two entries in `tsconfig.frontend.json`: the path and its `?v=*` form. +- Run Biome through `npm run lint`. A bare `npx biome` runs an unrelated package. diff --git a/src/content/docs/docs/contributing/releasing.md b/src/content/docs/docs/contributing/releasing.md new file mode 100644 index 0000000..33affb2 --- /dev/null +++ b/src/content/docs/docs/contributing/releasing.md @@ -0,0 +1,28 @@ +--- +title: How Stackyard releases are made +description: How a Stackyard version is cut, signed and published, and how the public demo runs. +--- + +## Cut a release + +1. Run **Release prep** in Actions with the version, such as `1.8.0`. +2. It folds `changelog.d/` into the changelog, bumps the version, pins the demo image, and opens a pull request. +3. Merge it. The tag builds, scans, signs and publishes the image and the release page. +4. Merge the Community Applications pull request that follows. + +A tag like `v1.8.0-beta.1` publishes a pre-release and leaves `latest` and the demo alone. + +If a release build fails, fix `main`, delete the tag, and push it again. + +## Secrets + +- `RELEASE_APP_CLIENT_ID` and `RELEASE_APP_PRIVATE_KEY`: a GitHub App with read and write on Contents and Pull requests, installed on this repository only. The built-in token cannot trigger the release workflows. +- `DOCS_DEPLOY_HOOK_URL`: a Cloudflare Pages deploy hook. It rebuilds this site after a stable release. The release still succeeds without it. + +## Demo + +`DEMO_MODE=true` serves `api/demo/demo-config.json`, blocks every write, and makes no outbound requests. Widgets read their `demo.js` instead. + +`api/test/demo.test.js` fails on a private address, a secret or an unknown host in the demo config. + +On Render, `render.yaml` runs a pinned release image. Keep `PORT=80`. diff --git a/src/content/docs/docs/contributing/translations.md b/src/content/docs/docs/contributing/translations.md new file mode 100644 index 0000000..a06a6f9 --- /dev/null +++ b/src/content/docs/docs/contributing/translations.md @@ -0,0 +1,168 @@ +--- +title: Translations +description: How Stackyard translations work, how to add a key or a language, and the terms translators must keep. +--- + +Translations are plain JSON files in `ui/i18n/`, one per language. No build step and no dependency. `en.json` is the source, and the fallback for any missing key. + +A translator changes JSON files only. No application code is involved. + +## Languages + +`LANGUAGES` in `ui/js/i18n.js` is the one place a language is defined. Nothing else decides what is offered, which direction it reads, or how its name is spelled. + +| Code | Language | Direction | Status | +| --- | --- | --- | --- | +| `en` | English | ltr | source | +| `fa` | Persian | rtl | machine | +| `zh-Hans` | Chinese (Simplified) | ltr | machine | +| `es` | Spanish | ltr | machine | +| `de` | German | ltr | machine | +| `fr` | French | ltr | machine | + +Codes are BCP 47 tags. A code is also the catalog filename. + +`source` marks the language the strings are written in. `machine` marks a catalog produced by machine translation. **No speaker of that language has checked a `machine` catalog.** It is complete and structurally valid, but its wording is unverified. A correction from a speaker overrides what is there. + +## Translator notes + +- Translate values, not keys. Keep the JSON structure identical to `en.json`. +- Keep placeholders such as `{count}` and `{name}` exactly as they are. A renamed or dropped placeholder takes the value out of the sentence. +- Keep the markup tags ``, ``, `` and `
` intact. No other tag renders. +- Translate a whole message. Word order, articles and punctuation move when the language changes. +- Leave proper nouns and technical tokens as they are: Unsplash, Docker, URLs, environment variable names, HTTP header names. + +### Terms with a fixed meaning + +| Term | Meaning | +| --- | --- | +| tile | One item on the dashboard grid. | +| badge | The small status readout drawn on a tile. | +| folder | A tile that opens to hold other tiles. | +| widget | A tile that runs its own page in a frame. | +| Dock | The fixed row of tiles at the foot of the dashboard. | +| upstream | The service a tile points at, not Stackyard. | +| stale | The last reading is old. The service was not reached this time. | +| unavailable | The service answered, and it is not working. | + +Some languages have words that must or must not be used. `ui/test/i18n-coverage.test.mjs` holds that list and fails on a forbidden word. + +## Fallback + +Fallback is per key, not per locale. A key missing from the selected language falls back to English on its own, and the rest of the page stays translated. + +A key missing from English too renders as the key itself. The reachability test fails the build before that reaches a reader. + +## Counted messages + +A message with a count is stored once per plural category. The language's own rules choose the category: + +```json +"loaded_one": "Loaded {count} option", +"loaded_other": "Loaded {count} options" +``` + +Call it with a numeric `count`: + +```js +t('widgetCfg.loaded', { count: opts.length }); +``` + +Never choose the form in code. `count === 1` is an English rule. French and Persian put zero in `one`, Chinese has one form for every count, and other languages have `few` and `many`. + +Read the categories a language uses from the runtime: + +```sh +node -e "console.log(new Intl.PluralRules('fr').resolvedOptions().pluralCategories)" +``` + +Every category listed must exist in that catalog, and no others. + +## Add a key + +1. Add it to `ui/i18n/en.json`, under the section it belongs to. +2. Add the same key to the other five catalogs. +3. Reference it by its full dotted name, such as `t('general.logLevel')` or `data-i18n="general.logLevel"`, so the reachability test can see it. + +A key nothing references fails the test suite. So does a reference to a missing key, and English written straight into the source, which `ui/test/hardcoded-strings.test.mjs` catches. + +In static markup, name the key on the element: + +| Attribute | Sets | +| --- | --- | +| `data-i18n` | text content | +| `data-i18n-html` | text content, with the allowed markup tags | +| `data-i18n-ph` | `placeholder` | +| `data-i18n-al` | `aria-label` | +| `data-i18n-title` | `title` | + +## Add a language + +1. Add it to `LANGUAGES` in `ui/js/i18n.js`: + + ```js + { code: 'it', name: 'Italiano', english: 'Italian', dir: 'ltr', status: 'machine' }, + ``` + +2. Copy `en.json` to `ui/i18n/it.json` and translate the values. +3. Copy `en.json` to `ui/widgets//i18n/it.json` for every widget and translate those too. +4. Split each counted message into the categories the language uses. +5. Update the language table on this page. + +The language then appears under Settings, General, Language. + +## Development locales + +Two locales exist for testing and are never offered in the selector. Add `?lang=` to the dashboard or admin URL: + +``` +http://localhost:8080/?lang=en-XA +http://localhost:8080/admin/?lang=cimode +``` + +- **`en-XA`** accents every letter, pads the text by about 40% and brackets each message. Clipped text, bad wrapping and joined fragments show up while still readable. +- **`cimode`** loads no catalog, so every string renders as its key. Text that bypasses the translation system stands out. + +Neither is saved. `e2e/localisation.spec.js` loads the dashboard and Settings in `en-XA` and fails on any control whose text overflows without an ellipsis, at desktop and at 390px wide. + +## Validation + +```sh +cd ui/test && node --test i18n.test.mjs i18n-reachability.test.mjs i18n-coverage.test.mjs widget-i18n.test.mjs i18n-markup.test.mjs +``` + +These run offline and contact no translation service. + +## Widget strings + +A widget frame never loads the dashboard i18n module. The selected code arrives on the frame URL as `lang`. + +Everything a widget shows, in its settings form and in the widget itself, comes from its own folder: + +``` +ui/widgets// + widget.json + i18n/ + en.json the source + fa.json one file per language +``` + +In `widget.json`, write the key where the text would go: + +```json +{ "key": "dnsUrl", "type": "text", "label": "dnsUrl.label", "hint": "dnsUrl.hint" } +``` + +`label`, `placeholder`, `hint`, `rowLabel` and `fetchLabel` are resolved this way. The API substitutes the selected language when it serves the manifest. + +In the widget page, ask the toolbox: + +```js +import { loadStrings, wt } from '/js/widget-toolbox.js?v=1'; +await loadStrings(); +wt('ui.queriesBlocked', 'Queries Blocked'); +``` + +Resolution is the selected language, then the widget `en.json`, then the text passed in. The toolbox has no counted-message form. A widget that needs one selects the category itself with `Intl.PluralRules`. + +See [Build your first widget](/docs/create-a-widget/). diff --git a/src/content/docs/docs/create-a-widget/checklist.md b/src/content/docs/docs/create-a-widget/checklist.md new file mode 100644 index 0000000..41144d3 --- /dev/null +++ b/src/content/docs/docs/create-a-widget/checklist.md @@ -0,0 +1,29 @@ +--- +title: Widget checklist +description: What to check before opening a pull request for a new Stackyard widget. +--- + +CI validates every manifest. Run the same check locally with `cd api && node --test`. + +## Files + +- `widget.json` with `name` matching the folder, `label`, `sizes` and `fields`. +- `data.js`, unless the widget runs entirely in the browser. +- `index.html`. +- `i18n/en.json`, plus one catalog per shipped language. +- `demo.js`, optional. + +## Behaviour + +- Upstream calls go through `ctx.fetchJSON`. +- Failures use `ctx.fail`, never a returned error. +- Empty and failed look different. +- The page calls no other host. +- Spacing next to text uses logical properties. + +## Look + +- Transparent background, system font, palette colours. +- Readable at every size it offers. + +A refused manifest is logged at startup and nowhere else. Check `docker logs `. diff --git a/src/content/docs/docs/create-a-widget/data.md b/src/content/docs/docs/create-a-widget/data.md new file mode 100644 index 0000000..9223aca --- /dev/null +++ b/src/content/docs/docs/create-a-widget/data.md @@ -0,0 +1,104 @@ +--- +title: Widget data reference +description: The data.js contract for a Stackyard widget. The ctx object, fetching upstream, option endpoints, reporting failures and demo mode. +--- + +`data.js` runs on the server, in Node, as CommonJS. It exports one async function that takes `ctx`. The saved config is on `ctx.config`. + +```js +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) }; +}; +``` + +The return value is served as-is at `/api/widget-data/`. + +A widget that renders entirely in the browser, like [Clock](/docs/widgets/clock/) or [Dashboard switch](/docs/widgets/dashboard-switch/), ships no `data.js`. + +## ctx + +| Property | What it is | +| --- | --- | +| `ctx.config` | The saved widget config, secrets included. Server-side only. | +| `ctx.settings` | A frozen copy of the dashboard settings shared with widgets. An allowlist, currently `stats` only. Everything else is withheld. To share another key, add it to `SHARED_KEYS` in `api/src/widget-settings.js`. | +| `ctx.endpoint` | The endpoint name, set for an `optionsFrom` fetch or a multi-view widget. | +| `ctx.row` | For an `optionsFrom` fetch from a field in a `group`, that row's values. Otherwise `null`. | +| `ctx.params` | Extra query parameters, as `URLSearchParams`. | +| `ctx.fetchJSON(url, opts)` | Fetches a URL and parses the body. Returns `{ status, data }` or throws. JSON is returned as-is. Prometheus text and XML are parsed. Pass `{ raw: true }` for the untouched text. | +| `ctx.parsePrometheus(text)` | Parses a Prometheus metrics body. Non-string input gives an empty object. | +| `ctx.normalizeBase(raw)` | Tidies a user-entered base URL: adds a scheme, drops a trailing slash. | +| `ctx.metrics` | Host metrics: `cpuSample`, `ramPercent`, `cpuTemp`, `diskStats`, `procCount`, `uptimeSeconds`. Each is a function. `cpuSample()` is async and returns `{ cpu, iowait }`. They read `/proc` and `/sys`, so they report the host, not the container limits. | +| `ctx.dispatchProvider(handlers, opts)` | Runs the handler for the provider the user picked. `opts.field` holds the key, `provider` by default. `opts.default` is the fallback key. | +| `ctx.fail(message, opts)` | Reports a failure. Throws. `opts.kind` is one of `ctx.KIND`, `UPSTREAM` by default. | +| `ctx.KIND` | `AUTH`, `INVALID`, `UPSTREAM`, `NETWORK`, `TIMEOUT`, `BLOCKED`, `INTERNAL`. See [API errors](/docs/contributing/api-errors/). | +| `ctx.log` | The structured logger. | + +Keep every upstream call behind `ctx.fetchJSON`. It applies the SSRF guard, IP pinning, the size limit and the TLS setting. + +### Metrics and XML bodies + +Metrics are recognised from `application/openmetrics-text`, `text/plain; version=0.0.4`, or a bare `text/plain` containing a `# TYPE` comment. Other plain text comes back as a string. + +XML `data` is keyed by the root tag. Attributes and child elements become keys, a repeated tag becomes an array, and a text-only element becomes its text. Numbers convert only when they round-trip exactly, so `007` stays a string. + +Parsed XML and Prometheus objects have a null prototype. Use `Object.hasOwn(o, k)`, not `o.hasOwnProperty(k)`. A feed field called `__proto__` or `constructor` then stays an ordinary key. + +## Option endpoints + +A `select` with `optionsFrom` calls the same function with `ctx.endpoint` set: + +```js +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 })) }; + } + return { items: [] }; +}; +``` + +See [Options from the service](/docs/create-a-widget/manifest/#options-from-the-service). + +## Reporting a failure + +Throw. Never return an error as data. + +```js +if (!config.apiKey) ctx.fail('API key not configured', { kind: ctx.KIND.INVALID }); +if (r.status === 401) ctx.fail('Auth failed, check the API key', { kind: ctx.KIND.AUTH }); +if (r.status >= 400) ctx.fail('Service HTTP ' + r.status); +``` + +A thrown failure becomes a 502. The frontend `poll()` counts it toward `staleAfter`, keeps the last good render, and reports how long ago the data was fresh. A returned `{ error: ... }` arrives as HTTP 200, and `poll()` records it as a success. + +Do not catch what `ctx.fetchJSON` throws. Letting it propagate classifies it: a refused connection as a network failure, a deadline as a timeout, the guard as blocked. + +Use `ctx.fail`, not `throw new Error`. A thrown message is replaced with a generic one before it reaches the browser, because it may carry a hostname, a path or an upstream body. Never build a `ctx.fail` message from an upstream response or a caught error. A status code is fine. + +An error field inside a successful result is a different thing, and correct. A widget reporting several services marks the one that failed and returns the rest: + +```js +return { services: [{ name: 'VPN', error: 'Auth required' }, { name: 'Proxy', connected: true }] }; +``` + +## Demo mode + +The public demo has no reachable services. A widget can ship `demo.js` beside `data.js`, returning an invented body. It runs only with `DEMO_MODE=true`. + +```js +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`, plus `ctx.demo` with `wave` and `round`. `wave(periodSec, min, max, phase)` is a clock-driven oscillation, so values drift between polls and every widget on the demo moves together. Build structural data, such as a calendar grid, once and cache it in a module-level variable. + +A widget with no `demo.js` runs its real `data.js` on the demo. [System summary](/docs/widgets/system-summary/) has none, because `ctx.metrics` already returns invented figures there. + +Only polling gets a demo body. An `optionsFrom` fetch always runs the real code. diff --git a/src/content/docs/docs/create-a-widget/index.mdx b/src/content/docs/docs/create-a-widget/index.mdx new file mode 100644 index 0000000..d055cb5 --- /dev/null +++ b/src/content/docs/docs/create-a-widget/index.mdx @@ -0,0 +1,83 @@ +--- +title: Build a custom Stackyard widget +description: Build a Stackyard dashboard widget step by step, from the manifest to the data handler and the page that draws it. +--- + +import WidgetGroup from '../../../../components/WidgetGroup.astro'; + +A widget is one folder in `ui/widgets/`. This page builds one from the template in the repository. + + + +A widget has three files. `widget.json` defines its settings. `data.js` runs on the server and calls your service. `index.html` runs on the dashboard and draws the result. + +## 1. Copy the template + +```sh +cp -r docs/widget-template ui/widgets/mywidget +``` + +## 2. Settings + +```json title="widget.json" +{ + "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", "optional": true }, + { "key": "showTotal", "type": "toggle", "label": "Show total", "default": true } + ] +} +``` + +`name` must match the folder. Each field becomes a row in the widget's settings. A `secret` never returns to the browser. See [Manifest](/docs/create-a-widget/manifest/). + +## 3. Data + +```js title="data.js" +module.exports = async function (ctx) { + const { url, apiKey } = ctx.config; + if (!url) ctx.fail('Enter the service URL.', { kind: ctx.KIND.INVALID }); + + const r = await ctx.fetchJSON(`${ctx.normalizeBase(url)}/api/items`, { + headers: apiKey ? { 'X-Api-Key': apiKey } : {}, + timeout: 8000, + }); + + return { + items: (r.data.items || []).slice(0, 10).map(i => ({ name: i.name })), + total: r.data.total ?? 0, + }; +}; +``` + +The return value is served at `/api/widget-data/`. The template returns `{ error }` when the URL is missing. Use `ctx.fail` as shown, so the widget shows its failure state. See [Data](/docs/create-a-widget/data/). + +## 4. Page + +```html title="index.html" + +``` + +`poll()` fetches the data and handles loading, empty, stale and failed states. The template adds the markup and styles. See [Widget page](/docs/create-a-widget/widget-page/). + +## 5. Try it + +Restart Stackyard, then in Settings, **Dashboard**, press Add and pick **My Widget**. If it is missing, the container log says why. + +Before a pull request, go through the [checklist](/docs/create-a-widget/checklist/). diff --git a/src/content/docs/docs/create-a-widget/manifest.md b/src/content/docs/docs/create-a-widget/manifest.md new file mode 100644 index 0000000..0d42697 --- /dev/null +++ b/src/content/docs/docs/create-a-widget/manifest.md @@ -0,0 +1,191 @@ +--- +title: Widget manifest reference +description: Every widget.json option for a Stackyard widget. Views, sizes, list icons, card backgrounds, field types and option pickers. +--- + +`widget.json` describes a widget: its `label`, the card `sizes` it offers, the settings form, and for a multi-view widget, its views. + +```json +{ + "name": "mywidget", + "label": "My Widget", + "sizes": ["small", "medium"], + "fields": [ + { "key": "url", "type": "text", "label": "Service URL", "placeholder": "http://host:port" } + ] +} +``` + +- **`name`** must match the folder name. +- **`label`** is the name in the admin list and the type picker, and the default widget name when the user saves without one. +- **`sizes`** is the set of card sizes offered: `small`, `medium`, `large`, `xlarge`. + +An invalid manifest is skipped at startup with a logged reason. Only that widget is disabled. + +## Views + +A widget can ship more than one page and let the user pick, like the [GitHub](/docs/widgets/github/) and [Clock](/docs/widgets/clock/) widgets. Declare each view and its file, the field that holds the choice, and the default: + +```json +{ + "viewField": "clockStyle", + "defaultView": "digital", + "views": { + "digital": { "label": "Digital", "src": "digital.html" }, + "analog": { "label": "Analog", "src": "analog.html" } + } +} +``` + +`viewField` names a field the user sets, usually a `select`. Its value is matched against the `views` keys. With no `views` block, the page is `index.html`. A single-view widget whose file is not `index.html` declares it as one view, with no `viewField`. + +`viewField` must name a declared field. If that field lists `options`, its values and the `views` keys must be the same set. A manifest that breaks either rule is rejected, because both failures are otherwise silent. A field using `optionsFrom` is not checked, since its choices are fetched at runtime. + +### Sizes per view + +A view can narrow the widget sizes, for a layout that works at one size only. The [Connections](/docs/widgets/connections/) map is Medium only: + +```json +"views": { + "map": { "label": "Map", "src": "connections-map.html", "sizes": ["medium"] }, + "vpn": { "label": "VPN", "src": "connections-vpn.html" } +} +``` + +Each list must be a subset of the top-level `sizes`. A view without `sizes` offers all of them. + +## List icon + +Settings lists every item with an icon. A widget that names a `glyph` shows it. One that names none shows its card size. + +| `glyph` | What it depicts | +| --- | --- | +| `clock` | A dial and hands | +| `weather` | A sun behind a cloud | +| `gauge` | A dial with a needle, for a measured figure | +| `shield` | A shield with record lines, for a name server | +| `drive` | A drive with a trace across it | +| `archive` | A store with an arrow into it | +| `shelf` | Book spines | +| `play` | A play mark in a frame | +| `network` | Three linked nodes | +| `merge` | Two branches joining one | +| `panels` | Two panels, one handing over to the other | + +Two widgets may not name the same glyph. A test refuses it. A name not on this list is rejected at startup. + +## Card background + +The card behind a widget is glass by default: dark, semi-transparent and blurred, so the wallpaper reads through. A widget can name another: + +| `card` | What it looks like | +| --- | --- | +| `dark` | Solid dark, `#1c1c1e`. | +| `light` | Solid white. | +| `translucent` | Darker than the default but more transparent, with a stronger blur. | + +A `card` inside a `views` entry overrides the widget-level one for that view. + +Keep the default glass when the widget paints its own interior. The [Weather](/docs/widgets/weather/) widget does this, white by day and dark by night. + +Under the increased-contrast setting, `translucent` becomes as dense as the default card. An unknown name is rejected. + +## Field types + +| Type | What the user sees | +| --- | --- | +| `text` | An inline-edit row. | +| `number` | An inline-edit row that stores a number. | +| `secret` | An inline-edit row for a masked value. Shows `Configured` once set. The value stays on the server. Leaving it blank keeps the stored value. | +| `toggle` | An on and off switch, stored as a boolean. | +| `color` | The swatch and colour control used elsewhere in the admin. Saves `#rrggbb`. | +| `select` | A dropdown. `"variant": "pills"` renders a radio group. With `optionsFrom` it adds a Fetch button. | +| `multiselect` | A checklist dropdown. The value is an array. | +| `group` | A repeatable set of sub-fields in a nested `fields` array, each entry its own card with Add and Remove. Groups cannot nest. | +| `picklist` | A fixed number of dropdowns filled from one fetch. Saves an array, `null` where unset. Needs `count` or `countBySize`, plus `options` or `optionsFrom`. | +| `object` | One nested set of sub-fields in a `fields` array, saved one level deep. Objects cannot nest. | + +## Field options + +| Key | Meaning | +| --- | --- | +| `label` | Shown to the user. Required. | +| `placeholder` | The hint in an empty `text`, `number` or `secret` row. | +| `default` | The value used when none is saved. | +| `hint` | Short help under the field. On a `group`, it shows at the bottom of the section. | +| `optional` | When `true`, the field is not required to save. A required `secret` counts as missing only when nothing is stored. | +| `transient` | When `true`, the field is sent to an `optionsFrom` fetch but not saved. Use it for a search box. Top-level fields only. | +| `carries` | For a `select` with `optionsFrom`: extra config keys this picker writes, from the chosen option's `set` block. | +| `showIf` | Shows the field only when a sibling matches: `{ "field": "provider", "equals": "adguard" }`, or `{ "field": "provider", "in": ["adguard", "pihole"] }`. Anything else is rejected. | +| `optionsFrom` | For a `select`: the data endpoint that returns the options at config time. | +| `variant` | For a `select`: `"pills"` renders a radio group. | +| `min`, `max` | For a `group`: the fewest and most entries. | +| `maxBySize` | For a `group`: a cap per size, such as `{ "small": 2, "medium": 5 }`. Extra entries are trimmed on a smaller size. | +| `countBySize` | For a `group`: a fixed row count per size, such as `{ "small": 1, "medium": 3 }`, with no Add or Remove. | + +## One key, asked for differently + +Two sibling fields may share a `key`, so the same value is asked for differently depending on another field. Give each one a `showIf`: + +```json +{ "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 saves. A repeated key without a `showIf` on every declaration is rejected. The validator does not check that the conditions exclude each other. + +## A fixed list of picks + +A `picklist` stores a plain array of ids, one per physical slot, filled from one call. [Disk health](/docs/widgets/disk-health/) uses it for bays: + +```json +{ "key": "bays", "type": "picklist", "label": "Bays", "rowLabel": "Bay", + "optionsFrom": "devices", "countBySize": { "small": 4, "medium": 10 } } +``` + +One Fetch button loads the options once for every row. The saved value is always `count` entries long, such as `["sda-abc", null, ...]`. + +A `group` whose `min` equals its `max` is fixed-length too. + +## Nested settings + +Use `object` for config stored one level deep: + +```json +{ "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 `showIf` names a sibling inside the same object. Its secrets are scrubbed and kept like top-level ones. + +## Options from the service + +When a `select` can only be filled after the user enters a URL and key, give it `"optionsFrom": ""`. The form shows a **Fetch** button, which calls `data.js` with `ctx.endpoint` set to that name. Return `{ options: [{ value, label }, ...] }`. + +The fetch receives the current form values, `transient` fields included. + +An option can write other keys too. List them in `carries` and return them in the option's `set`. [Weather](/docs/widgets/weather/) stores coordinates this way: + +```json +{ "key": "city", "type": "select", "optionsFrom": "geocode", "carries": ["lat", "lon"] } +``` + +```js +return { options: [{ value: 'Ottawa, Ontario, Canada', label: 'Ottawa, Ontario, Canada', set: { lat: 45.42, lon: -75.7 } }] }; +``` + +Saved values under carried keys are kept when the widget is edited without touching the picker. + +A `select` inside a `group` can use `optionsFrom`. Each row fetches on its own, and `ctx.row` holds that row's values: + +```js +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 })) }; +} +``` diff --git a/src/content/docs/docs/create-a-widget/widget-page.md b/src/content/docs/docs/create-a-widget/widget-page.md new file mode 100644 index 0000000..5afbef3 --- /dev/null +++ b/src/content/docs/docs/create-a-widget/widget-page.md @@ -0,0 +1,145 @@ +--- +title: Widget page reference +description: The index.html side of a Stackyard widget. Frame parameters, the content policy, canvas sizes, text direction and the widget toolbox. +--- + +A widget page runs in a frame, scaled uniformly from a fixed design size to its card. It reads its `id` from the query string, fetches its own data, and draws it. + +- Keep styles and scripts inline or same-origin. There is no shared widget stylesheet. +- Never call an external host. Reach a service through `data.js`. +- The saved config, without secrets, is at `/api/widget-config/`. + +## Frame parameters + +| Parameter | What it is | +| --- | --- | +| `id` | The dashboard item id. Pass it to `/api/widget-data/` and `/api/widget-config/`. | +| `size` | The size the user placed this widget at, one of the manifest `sizes`. | +| `mobile` | `1` on the mobile layout, absent otherwise. | +| `lang` | The selected language code. See [Translations](/docs/contributing/translations/#widget-strings). | +| `v` | The cache version, stamped at release. Nothing to read. | + +## Canvas sizes + +| Size | Canvas | +| --- | --- | +| small | 170 × 170 | +| medium | 360 × 170 | +| large | 360 × 360 | +| xlarge | 360 × 540 | + +Match the existing look: a transparent background, the system font stack, the dark palette. See [Design system](/docs/contributing/design-system/). + +## What a page may load + +The widget frame has a stricter Content-Security-Policy than the dashboard. Scripts and styles must be inline or same-origin. `connect-src` is `'self'`, so the only host a widget can call is Stackyard. Images may come from the icon CDN or a `data:` URI. + +## Text direction + +The dashboard sets the frame direction and language when it mounts the widget. A widget must not set `dir` on its own ``. In Persian the frame becomes right to left, and text, flex rows and grid columns reverse with it. + +Write spacing next to text with logical properties, so it reverses too: + +```css +.flag { margin-inline-end: 5px } +.meta { padding-inline-start: 13px } +.left { border-inline-end: 1px solid rgba(255, 255, 255, 0.1) } +``` + +`left: 0; right: 0` is symmetric and needs no change. `left: 50%` with a translate is centring. Artwork is artwork, and mirroring it is usually wrong. `ui/test/rtl-logical-properties.test.mjs` enforces the rule for properties that carry text. + +Content that reads the same in every language, such as an IP address, a log tail or a chart axis, pins its own direction on the element: + +```html +
10.0.0.1
+``` + +Never on the document. The test refuses `dir` on `` or ``, and `direction` on `html`, `body` or `:root`. + +## Mobile active state + +A widget with an interior state a tap turns on, such as a selected row, takes part in this protocol. Without it, two widgets end up active at once. + +```js +parent.postMessage({ type: 'widget-active' }, window.location.origin); + +window.__clearActive = () => { /* drop the active state, hide any tooltip */ }; +``` + +The dashboard resets every other widget when it receives the message, and calls `__clearActive` when a tap lands outside any widget. The frames are same-origin, so a widget needs no `message` listener. If you add one, check `e.origin` against `window.location.origin` first. + +## Off-screen pages + +Every dashboard page is mounted at once. The dashboard slows the polling of widgets on other pages through `window.__setPollRate`, which the toolbox defines. A widget that uses `poll()` needs nothing. A widget with its own timer must read the same hook. + +Returning to a page refreshes a widget at once when its data is older than one normal interval. + +## Cache busting + +Nothing to do by hand. The release build hashes each widget entry file and stamps the version into the manifest. + +## Toolbox + +Optional, and it bundles the pieces widgets keep needing. Import from `/js/widget-toolbox.js` and keep the `?v=1`: + +```js +import { poll, fetchData, sparkline } from '/js/widget-toolbox.js?v=1'; +``` + +### Data + +- `widgetId()` returns this widget id from the frame URL. +- `fetchData(endpoint?)` fetches `/api/widget-data/` and returns the parsed JSON. Throws on a non-OK response. +- `getConfig()` fetches this widget config, without secrets. + +### Polling + +`poll(opts)` runs the fetch and render loop and handles the loading, empty, stale and error states. A single failed poll never blanks a working widget. + +```js +poll({ + render: data => { root.textContent = `${data.items.length} items`; }, + isEmpty: data => data.items.length === 0, + interval: 30000, +}); +``` + +A failure keeps the last good render. After `staleAfter` consecutive failures, 2 by default, it shows the failure and how long ago the last success was. `sinceLabel(ts)` gives that label on its own. + +The first fetch runs at once. Each repeat is spread by up to 15%, so widgets on one dashboard do not fetch on the same tick. + +### Failure and empty states + +Empty and failed are different claims and must look different. `isEmpty(data)` decides which applies. Without a custom handler, `poll()` draws both. With `onError`, add `onEmpty` too. + +`errorState(opts)` draws a failure state: + +```js +const state = errorState({ root, content: chartEl, caption: metaLineEl }); + +poll({ + render: d => { state.ok(); draw(d); }, + isEmpty: d => d.items.length === 0, + onEmpty: () => state.empty(wt('ui.noItems', 'Nothing here')), + onError: ({ error, everOk, stale, since }) => { + if (!everOk || stale) state.fail(error, { since, inert: everOk }); + }, +}); +``` + +- `content` is what goes inert. Without it, every child of `root` except the caption does. +- `caption` is the widget metadata slot. Without one, a line is placed at the foot of `root`, or over its centre with `place: 'center'`. +- `fail(err, { since, inert })` returns the line it drew. Pass `inert: false` when the widget never had data. +- Set `--wt-cap-color` on a widget with a light card. + +The wording comes from the failure kind, not the upstream message. `errorLine(err)` returns the same wording for a widget with its own designed state. + +### Links, markup and visuals + +- `openUrl(href)` opens a link in a new tab. Use it instead of `window.open`, which the sandbox can block. +- `esc(value)` HTML-escapes a value for `innerHTML`. Use it for anything from config or upstream. +- `sparkline(values, opts?)` returns an `` area and line chart. +- `barFill(percent, opts?)` returns a track and fill bar. It skips its transition under reduced motion. +- `smoothPath(points)` returns a smoothed SVG path through `[[x, y], ...]`. + +Check the toolbox before drawing a visual by hand. diff --git a/src/content/docs/docs/customization.md b/src/content/docs/docs/customization.md index 244bdee..05be575 100644 --- a/src/content/docs/docs/customization.md +++ b/src/content/docs/docs/customization.md @@ -1,5 +1,5 @@ --- -title: Customization +title: Wallpaper, themes and language description: Wallpaper, themes, app titles, language, and running Stackyard from a phone home screen. --- diff --git a/src/content/docs/docs/first-setup.md b/src/content/docs/docs/first-setup.md index 825c2dd..a385c52 100644 --- a/src/content/docs/docs/first-setup.md +++ b/src/content/docs/docs/first-setup.md @@ -1,5 +1,5 @@ --- -title: First setup +title: First setup after installing Stackyard description: Open the admin UI, set a password, and learn what the four sections do. --- @@ -22,8 +22,8 @@ The password is a gate between local users, not an authentication layer. See [Se | Section | What it covers | | --- | --- | | **General** | Server identity and behaviour: title, host IP, language, logging, password, Docker health checks, and config [import and export](/docs/import-export/backup-and-restore/). | -| **Appearance** | How the dashboard looks: wallpaper, labels, and theme. See [Customization](/docs/customization/). | -| **Dashboard** | What is on the dashboard: apps, widgets, folders, and their order. See [Adding services](/docs/adding-services/). | +| **Appearance** | How the dashboard looks: wallpaper, labels, and theme. See [Wallpaper and themes](/docs/customization/). | +| **Dashboard** | What is on the dashboard: apps, widgets, folders, and their order. See [Apps and folders](/docs/apps-and-folders/). | | **About** | Version, update notice, and links to the project. | Each section saves on its own with Save. @@ -32,4 +32,4 @@ Set **Host IP** if your services run on the same machine as Stackyard. Badge and ## Next -Add your services in [Adding services](/docs/adding-services/). +Add your apps in [Apps and folders](/docs/apps-and-folders/). diff --git a/src/content/docs/docs/import-export/backup-and-restore.md b/src/content/docs/docs/import-export/backup-and-restore.md index b360d00..18dcaa8 100644 --- a/src/content/docs/docs/import-export/backup-and-restore.md +++ b/src/content/docs/docs/import-export/backup-and-restore.md @@ -1,5 +1,5 @@ --- -title: Backup and restore +title: Back up and restore Stackyard description: Export your Stackyard configuration to a file, and import it back. --- @@ -31,4 +31,4 @@ If your dashboard is empty after a restart, look for an `apps.json.corrupt-*` fi ## Coming from another dashboard -To import a gethomepage or Dashy config instead, see [Migrating from another dashboard](/docs/import-export/migrating/). +To import a gethomepage or Dashy config instead, see [Import from Homepage or Dashy](/docs/import-export/migrating/). diff --git a/src/content/docs/docs/import-export/migrating.md b/src/content/docs/docs/import-export/migrating.md index 74926a0..a336235 100644 --- a/src/content/docs/docs/import-export/migrating.md +++ b/src/content/docs/docs/import-export/migrating.md @@ -1,6 +1,6 @@ --- -title: Migrating from another dashboard -description: Import links and folders from a gethomepage or Dashy YAML config. +title: Import from Homepage or Dashy +description: Import links and folders into Stackyard from a gethomepage (Homepage) or Dashy YAML config. --- **In the admin:** General, then Backup. See the [Settings reference](/docs/settings-reference/). @@ -19,7 +19,7 @@ This is one way. It does not keep the two in sync, and it writes nothing back. A widget in the source becomes a plain app tile, never a Stackyard widget. The two projects model widgets differently, so there is nothing to translate. -Descriptions, abbreviations, tags, and per-item colours are dropped. Icons are not carried over either, because Stackyard resolves icons by name. See [Adding services](/docs/adding-services/). +Descriptions, abbreviations, tags, and per-item colours are dropped. Icons are not carried over either, because Stackyard resolves icons by name. See [Apps and folders](/docs/apps-and-folders/). ## What is skipped diff --git a/src/content/docs/docs/index.md b/src/content/docs/docs/index.md index d57094f..f9cac65 100644 --- a/src/content/docs/docs/index.md +++ b/src/content/docs/docs/index.md @@ -1,39 +1,18 @@ --- -title: Introduction -description: What Stackyard is, who it is for, and why you would run it. +title: What is Stackyard +description: Stackyard is a calm, self-hosted homelab dashboard for your apps, with status badges and widgets, configured in a web UI. --- -Stackyard is a self-hosted dashboard for the services on your network: a launcher-style grid of app tiles, folders, and a small number of widgets, running as one container. +Stackyard is a self-hosted dashboard for your homelab. Apps, folders, a few widgets and status badges, in one container. -Most dashboards are a wall of numbers and charts. Stackyard is built to be glanced at a hundred times a day without feeling cluttered. +The Stackyard dashboard, with widgets, app tiles, a folder and the dock. -The Stackyard dashboard, showing widgets, app tiles, a folder and the dock. +- **Calm.** A health badge shows only when something is wrong. +- **Badges from any API.** Pick a value from a service's API response. No code. See [Badges](/docs/badges/). +- **Web UI only.** No YAML to edit. +- **Small.** One container, no database, no runtime dependencies. +- **Accessible.** Six languages including right to left, built to WCAG 2.2 AA. -That is the whole interface. Widgets across the top, apps and folders below, a dock at the bottom, and everything on it added through the web UI. +Try the [demo](https://demo.sandobserver.com). The first load can take a minute. -## What it does differently - -- Attention goes where it is needed. Health badges appear only when something is wrong. -- Widgets are small visuals, not readouts. -- Anything can be a badge. Point Stackyard at an API, pick a value from the response, and it appears on the tile. See [Badges](/docs/badges/). -- Everything is configured in the web UI. There are no configuration files to edit. -- Six languages, right to left included, and built to WCAG 2.2 level AA. See [Accessibility](/docs/accessibility/). -- It installs to a phone home screen and opens in its own window. - -## How it is built - -One container, two processes: nginx serves the static UI, a Node HTTP server handles the API. - -The API has no runtime dependencies. The frontend is vanilla JavaScript with no build step. State is a single JSON file on the data volume, and the web UI is the only thing that writes it. - -## Try it first - -A public read-only demo runs at [demo.sandobserver.com](https://demo.sandobserver.com). - -:::note -The first visit can take up to a minute. The demo runs on a free tier that sleeps when idle. -::: - -## Where to go next - -Install it with [Docker](/docs/installation/docker/), then work through [First setup](/docs/first-setup/). +Then [install with Docker](/docs/installation/docker/). diff --git a/src/content/docs/docs/installation/build-from-source.md b/src/content/docs/docs/installation/build-from-source.md index fe523cb..0d24880 100644 --- a/src/content/docs/docs/installation/build-from-source.md +++ b/src/content/docs/docs/installation/build-from-source.md @@ -1,5 +1,5 @@ --- -title: Build from source +title: Build Stackyard from source description: Clone the repository and build the Stackyard container image yourself. next: link: /docs/first-setup/ @@ -16,4 +16,4 @@ Then run `stackyard:local` the same way as the published image. See [Docker](/do ## Working on the code -To run Stackyard without Docker while developing, see [CONTRIBUTING.md](https://github.com/SandObserver/stackyard/blob/main/CONTRIBUTING.md) and [Development](/docs/development/). +To run Stackyard without Docker while developing, see [How to contribute](/docs/contributing/). diff --git a/src/content/docs/docs/installation/docker.mdx b/src/content/docs/docs/installation/docker.mdx index 1a27158..98e7a58 100644 --- a/src/content/docs/docs/installation/docker.mdx +++ b/src/content/docs/docs/installation/docker.mdx @@ -1,36 +1,16 @@ --- -title: Docker -description: Install Stackyard with Docker Compose or a single docker run command. +title: Install Stackyard with Docker Compose +description: Install Stackyard with Docker Compose or docker run, and the environment variables it reads. next: link: /docs/first-setup/ label: First setup --- -import { Tabs, TabItem } from '@astrojs/starlight/components'; +import SettingList from '../../../../components/SettingList.astro'; -Stackyard ships as one Docker image. There is nothing else to install, and no database to run. +Stackyard is one image for `linux/amd64` and `linux/arm64`. On Unraid, use the [Unraid template](/docs/installation/unraid/). -## Before you start - -New to Docker? It runs applications in containers, which are self-contained and do not install anything into your operating system. You need it installed before any of the steps below. - -- **Docker.** Install [Docker Desktop](https://docs.docker.com/get-started/get-docker/) on Windows or macOS, or Docker Engine on Linux. Docker Desktop includes Compose. On Linux, install the Compose plugin as well. -- **A 64-bit machine.** The image is built for `linux/amd64` and `linux/arm64`. A Raspberry Pi 4 or 5 running a 64-bit OS works. A 32-bit OS does not. -- **A free port on the host.** The examples use `8700`. Change it if something already uses it. -- **A folder for persistent data.** Stackyard writes its config and uploaded images to two volumes. Without them, your dashboard is empty after every restart. - -You do not need a domain, a reverse proxy, or TLS to start. Two features need HTTPS later: Keep Screen Awake, and logging in from behind a proxy. See [Security](/docs/security/). - -If you use Unraid, use the [Unraid](/docs/installation/unraid/) page instead. - -## Install - -Compose is the recommended path. It keeps your settings in a file you can edit, back up and re-apply. Use `docker run` if you would rather not keep a file. - - - - -Save this as `docker-compose.yml`: +## Compose ```yaml services: @@ -45,65 +25,50 @@ services: - ./icons:/icons ``` -Start it, from the folder holding that file: - ```sh docker compose up -d ``` -Then open `http://localhost:8700`. +Open `http://:8700`. -To apply a change to the file later, run the same command again. +`/data` holds the config file. `/icons` holds uploaded icons and wallpapers. The container runs as UID 1000 and must be able to write both. - - +The [compose file in the repository](https://github.com/SandObserver/stackyard/blob/main/docker-compose.yml) adds hardening and lists every variable below. + +## docker run ```sh -docker run -d \ - --name stackyard \ - --restart unless-stopped \ - -p 8700:80 \ - -v ./data:/data \ - -v ./icons:/icons \ +docker run -d --name stackyard --restart unless-stopped \ + -p 8700:80 -v ./data:/data -v ./icons:/icons \ ghcr.io/sandobserver/stackyard:latest ``` -Then open `http://localhost:8700`. - -Environment variables are passed with `-e`, for example `-e ALLOW_PRIVATE_IPS=true`. - -To change any of this later you must stop the container, remove it, and run the command again with your changes. This is the reason Compose is recommended. - - - - -## What the settings mean +## Environment variables -| Setting | Purpose | -| --- | --- | -| `8700:80` | The container listens on port 80. Change `8700` to publish it elsewhere on the host. | -| `./data:/data` | Holds `apps.json`, the single config file. | -| `./icons:/icons` | Holds uploaded icons and stored wallpaper images. | +All optional. -Both volumes must persist. The container runs as UID 1000, so the folders on the host must be writable by it. + -Environment variables are all optional. See [Advanced configuration](/docs/advanced-configuration/). + -## The fuller compose file - -The repo ships a longer [`docker-compose.yml`](https://github.com/SandObserver/stackyard/blob/main/docker-compose.yml) with hardening: - -- All Linux capabilities dropped, then only the needed ones added back. -- `no-new-privileges`, plus CPU, memory, process count and log size limits. -- An init process, so orphaned processes are reaped. -- A container health check. -- `extra_hosts` for reaching services on the host by IP. -- Environment variables for a reverse proxy, Docker health checks, and the SSRF guard, each as a commented line. - -Use it once Stackyard is working. See [Advanced configuration](/docs/advanced-configuration/). + ## Registries -The image is published to `ghcr.io/sandobserver/stackyard`, mirrored on Docker Hub as `sandobserver/stackyard`. - -Prefer `ghcr.io`. It is the registry the release signature covers. See [Security](/docs/security/). +`ghcr.io/sandobserver/stackyard`, mirrored to Docker Hub as `sandobserver/stackyard`. The release signature covers `ghcr.io`. diff --git a/src/content/docs/docs/installation/unraid.md b/src/content/docs/docs/installation/unraid.md index 6b4da82..6031a06 100644 --- a/src/content/docs/docs/installation/unraid.md +++ b/src/content/docs/docs/installation/unraid.md @@ -1,5 +1,5 @@ --- -title: Unraid +title: Install Stackyard on Unraid description: Install Stackyard on Unraid from Community Applications. next: link: /docs/first-setup/ @@ -10,6 +10,8 @@ Install Stackyard from [Community Applications](https://ca.unraid.net/apps/stack Search for Stackyard in the Apps tab, then install it. The template fills in the port and the two volume mappings for you. +The template also sets `ALLOW_PRIVATE_IPS`, `SOCKET_PROXY_URL` and `TRUST_PROXY`. Each is described under [Environment variables](/docs/installation/docker/#environment-variables). + The template is kept in the repo at [`templates/stackyard.xml`](https://github.com/SandObserver/stackyard/blob/main/templates/stackyard.xml). ## After installing diff --git a/src/content/docs/docs/is-stackyard-for-you.mdx b/src/content/docs/docs/is-stackyard-for-you.mdx index 0b51eec..799a66e 100644 --- a/src/content/docs/docs/is-stackyard-for-you.mdx +++ b/src/content/docs/docs/is-stackyard-for-you.mdx @@ -1,19 +1,19 @@ --- -title: Stackyard vs gethomepage, Dashy and Homarr +title: Stackyard vs Homepage, Dashy and Homarr description: How Stackyard compares to gethomepage, Dashy and Homarr on setup, integrations and dependencies, and who each one suits. --- import CompareTable from '../../../components/CompareTable.astro'; import CalmContrast from '../../../components/CalmContrast.astro'; -Self-hosted dashboards make different bets. Here is the one Stackyard makes, next to the three you are most likely to be choosing between. +How Stackyard compares with the three dashboards it is most often weighed against. Badges.', - 'Counted from each project\u2019s own documentation and source, September 2026. Services are self-hosted ones with a built-in integration. Dependencies are the external runtime packages each project lists.', - 'No user management. The dashboard is password protected. For more than one dashboard, run a container each with its own password and move between them with the Dashboard switch widget.', + 'Point a tile at any API and pick a value. No code. See Badges.', + 'From each project\u2019s docs and source, September 2026.', + 'One password per instance. Link instances with the Dashboard switch.', ]} rows={[ { @@ -69,14 +69,6 @@ A health badge is hidden while a service is fine, so a working Stackyard shows n - You need user accounts. There is one admin password and one shared dashboard per instance. For a separate dashboard per person, room or site, run separate instances and link them with the [Dashboard switch](/docs/widgets/dashboard-switch/) widget. See [Security](/docs/security/). - You want a plugin ecosystem. The widget set is fixed, plus a [Custom](/docs/widgets/custom/) widget that embeds any web page. -## Where to go next +## Switching -Stackyard imports links and folders from gethomepage and Dashy configuration -files, so moving over is a file upload. See -[Migrating from another dashboard](/docs/import-export/migrating/). - -Look around the [demo](https://demo.sandobserver.com). Give the first load a -moment. - -Install it with [Docker](/docs/installation/docker/), then work through -[First setup](/docs/first-setup/). +Stackyard imports apps and folders from Homepage and Dashy files. See [Import](/docs/import-export/migrating/). diff --git a/src/content/docs/docs/reverse-proxy.md b/src/content/docs/docs/reverse-proxy.md new file mode 100644 index 0000000..77a5c9b --- /dev/null +++ b/src/content/docs/docs/reverse-proxy.md @@ -0,0 +1,49 @@ +--- +title: Run Stackyard behind a reverse proxy with TLS +description: Put Stackyard behind a TLS-terminating reverse proxy, and set TRUST_PROXY and TRUSTED_PROXY so login and rate limiting work. +--- + +Stackyard serves plain HTTP only. It does not terminate TLS. For HTTPS, put it behind a reverse proxy that terminates TLS. + +Two features need HTTPS: Keep Screen Awake, and logging in from behind a proxy. + +## The two variables + +Two environment variables apply, and they do different things. + +`TRUST_PROXY=true` makes Stackyard believe `X-Forwarded-Proto: https`, so the session cookie gets its `Secure` flag. Set it only when a proxy you control is actually in front of the app. + +:::caution +If `TRUST_PROXY=true` is set while Stackyard is also reachable directly, a client can claim `X-Forwarded-Proto: https` and be issued a `Secure` cookie over plain HTTP. +::: + +`TRUSTED_PROXY` tells nginx where the front proxy sits, so it can resolve the real client address for rate limiting: + +``` +TRUSTED_PROXY=172.18.0.0/16 +TRUSTED_PROXY="172.18.0.0/16 10.0.0.5" +``` + +Without it, every request through the proxy counts as the same client, and rate limiting becomes one shared bucket. + +## In Compose + +```yaml +services: + stackyard: + environment: + - TRUST_PROXY=true + - TRUSTED_PROXY=172.18.0.0/16 +``` + +Make the proxy send `X-Forwarded-Proto: https`. + +## One instance + +Rate-limit counters are held in memory and are not shared across replicas. Run a single instance behind any proxy. + +## When login fails + +A login that returns to the login screen with no error usually means `TRUST_PROXY` is unset or the proxy does not send the header. See [Troubleshooting](/docs/troubleshooting/#i-cannot-log-in-or-i-get-bounced-back-to-the-login-screen). + +Stackyard is not hardened for the public internet, even behind a proxy. See [Security](/docs/security/). diff --git a/src/content/docs/docs/security.md b/src/content/docs/docs/security.md index 6cd9ecd..2a85dd8 100644 --- a/src/content/docs/docs/security.md +++ b/src/content/docs/docs/security.md @@ -15,7 +15,7 @@ Stackyard serves plain HTTP and does not terminate TLS. Stackyard is not designed or hardened for direct exposure to the public internet. Authentication exists to separate local users. It is not an internet-facing security boundary. ::: -Run it on a trusted network, or behind a reverse proxy that terminates TLS and adds its own access control. See [Advanced configuration](/docs/advanced-configuration/). +Run it on a trusted network, or behind a reverse proxy that terminates TLS and adds its own access control. See [Use a reverse proxy](/docs/reverse-proxy/). ## Authentication @@ -39,7 +39,27 @@ Secrets are stored in plain text in `apps.json` on the data volume. Protect that The server blocks outbound requests to private, loopback, link-local, carrier-grade NAT, multicast and reserved ranges, in IPv4 and IPv6. It resolves the host, checks the address, then pins the resolved IP so the connection cannot be re-pointed after the check. -`localhost` is refused by name. Dotless hostnames such as Docker container names are trusted, as is the host IP set in General. +`localhost` is refused by name. Dotless hostnames such as Docker container names are trusted, as is the host IP set in General. Only `http` and `https` URLs are fetched. + +| Range | Why | +| --- | --- | +| `0.0.0.0/8` | this network (RFC 1122) | +| `10.0.0.0/8` | private (RFC 1918) | +| `100.64.0.0/10` | carrier-grade NAT (RFC 6598) | +| `127.0.0.0/8` | loopback (RFC 1122) | +| `169.254.0.0/16` | link-local, includes cloud metadata (RFC 3927) | +| `172.16.0.0/12` | private (RFC 1918) | +| `192.0.0.0/24` | IETF protocol assignments (RFC 6890) | +| `192.168.0.0/16` | private (RFC 1918) | +| `198.18.0.0/15` | benchmarking (RFC 2544) | +| `224.0.0.0/4` | multicast (RFC 5771) | +| `240.0.0.0/4` | reserved, includes broadcast (RFC 1112) | +| `::1`, `::` | IPv6 loopback and unspecified | +| `fc00::/7` | IPv6 unique local | +| `fe80::/10` | IPv6 link-local | +| `ff00::/8` | IPv6 multicast | + +An IPv4 address inside an IPv6 literal is checked against the same table. `ALLOW_PRIVATE_IPS=true` disables the guard entirely. Most homelab installs need it. @@ -70,7 +90,7 @@ Every released image is signed with [cosign](https://docs.sigstore.dev/) using k ```sh cosign verify ghcr.io/sandobserver/stackyard:1.5.0 \ - --certificate-identity-regexp '^https://github.com/SandObserver/stackyard/' \ + --certificate-identity-regexp '^https://github.com/SandObserver/Stackyard/' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com ``` @@ -83,10 +103,10 @@ The build produces an SPDX SBOM listing what is inside the image. It is attached ```sh cosign verify-attestation ghcr.io/sandobserver/stackyard:1.5.0 \ --type spdxjson \ - --certificate-identity-regexp '^https://github.com/SandObserver/stackyard/' \ + --certificate-identity-regexp '^https://github.com/SandObserver/Stackyard/' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com ``` ## Reporting a problem -See [SECURITY.md](https://github.com/SandObserver/stackyard/blob/main/SECURITY.md). The full security notes are in [docs/security.md](https://github.com/SandObserver/stackyard/blob/main/docs/security.md). +See [Security policy](/docs/security-policy/). diff --git a/src/content/docs/docs/settings-reference.md b/src/content/docs/docs/settings-reference.md index 173a436..ebe7f87 100644 --- a/src/content/docs/docs/settings-reference.md +++ b/src/content/docs/docs/settings-reference.md @@ -1,5 +1,5 @@ --- -title: Settings reference +title: Stackyard settings reference description: Every setting in the admin, in the order you meet it, with a link to the page that explains it. --- @@ -23,7 +23,7 @@ At the top, before any group: | Setting | What it does | | --- | --- | -| Language | The interface language. English, Persian, Simplified Chinese, Spanish, German or French. Persian flips the whole layout to right to left. See [Customization](/docs/customization/#language). | +| Language | The interface language. English, Persian, Simplified Chinese, Spanish, German or French. Persian flips the whole layout to right to left. See [Wallpaper and themes](/docs/customization/#language). | ### Monitoring @@ -32,7 +32,7 @@ At the top, before any group: | Logging Level | How much detail goes to the container log. Errors shows warnings and errors. Security events are always logged. See [Support](/docs/support/#reading-the-logs). | | Docker Container Health Checks | Turns on container status for apps. Needs a socket proxy address below. See [Badges](/docs/badges/). | | Hide Healthy Badge | Shows the health dot only when something is wrong. | -| Socket URL | Where your Docker socket proxy is. Never the Docker socket itself. See [Advanced configuration](/docs/advanced-configuration/). | +| Socket URL | Where your Docker socket proxy is. Never the Docker socket itself. See [Health check](/docs/badges/#health-check). | If every app suddenly shows as unhealthy, the socket proxy address is the usual cause. See [Troubleshooting](/docs/troubleshooting/#every-app-with-a-container-shows-as-unhealthy-at-once). @@ -50,11 +50,11 @@ Locked out? See [password recovery](/docs/troubleshooting/#i-forgot-the-password | Setting | What it does | | --- | --- | | Import / Export | Downloads your whole config as one file, or restores it. See [Backup and restore](/docs/import-export/backup-and-restore/). | -| Import from another dashboard | Reads gethomepage and Dashy files. Apps and folders are added. See [Migrating](/docs/import-export/migrating/). | +| Import from another dashboard | Reads gethomepage and Dashy files. Apps and folders are added. See [Import](/docs/import-export/migrating/). | ## Appearance -How the dashboard looks. Every change applies immediately. Explained in full on [Customization](/docs/customization/). +How the dashboard looks. Every change applies immediately. Explained in full on [Wallpaper and themes](/docs/customization/). ### App Title @@ -93,7 +93,7 @@ Search narrows the list. The **All**, **Apps**, **Widgets** and **Folders** filt | To do this | See | | --- | --- | -| Add an app, a folder, or another dashboard page | [Adding services](/docs/adding-services/) | +| Add an app, a folder, or another dashboard page | [Apps and folders](/docs/apps-and-folders/) | | Put a live value or a health dot on an app | [Badges](/docs/badges/) | | Add a widget and fill in its fields | [Widgets](/docs/widgets/) | diff --git a/src/content/docs/docs/support.md b/src/content/docs/docs/support.md index 9608c19..468b071 100644 --- a/src/content/docs/docs/support.md +++ b/src/content/docs/docs/support.md @@ -1,5 +1,5 @@ --- -title: Support +title: Get help with Stackyard description: Where to get help, how to read the logs, and how to file a bug report that gets fixed. --- @@ -11,7 +11,7 @@ For a problem you are trying to solve yourself, start with [Troubleshooting](/do | --- | --- | | Bug reports and feature requests | [GitHub issues](https://github.com/SandObserver/stackyard/issues) | | Questions and setup help | A GitHub issue with the `question` label | -| Suspected security vulnerability | Privately, never a public issue. See [Security](/docs/security/). | +| Suspected security vulnerability | Privately, never a public issue. See [Security policy](/docs/security-policy/). | Search the existing issues first. Most setup questions have been asked already. @@ -67,7 +67,7 @@ A few things that are easy to misread: The format is logfmt. Anything shipping to Loki or Grafana parses it with `| logfmt` and no custom rules. -To change how much is logged, set **Logging Level** in **General**, or see [Advanced configuration](/docs/advanced-configuration/). +To change how much is logged, set **Logging Level** in **General**, or set `LOG_LEVEL`. See [Environment variables](/docs/installation/docker/#environment-variables). ## Supporting Stackyard diff --git a/src/content/docs/docs/troubleshooting.md b/src/content/docs/docs/troubleshooting.md index 1085f92..674ba52 100644 --- a/src/content/docs/docs/troubleshooting.md +++ b/src/content/docs/docs/troubleshooting.md @@ -1,5 +1,5 @@ --- -title: Troubleshooting +title: Troubleshooting Stackyard description: Find a problem by its symptom, then the cause and the fix. --- @@ -19,7 +19,7 @@ Set `TRUST_PROXY=true` and make the proxy send `X-Forwarded-Proto: https`. Only Logins are limited to 5 attempts per IP per 15 minutes. Behind another reverse proxy, Stackyard's nginx sees that proxy as the client, so every request through it shares one bucket. -Set `TRUSTED_PROXY` to where the proxy is, for example `TRUSTED_PROXY=172.18.0.0/16`. See [Advanced configuration](/docs/advanced-configuration/). +Set `TRUSTED_PROXY` to where the proxy is, for example `TRUSTED_PROXY=172.18.0.0/16`. See [Use a reverse proxy](/docs/reverse-proxy/). ### I forgot the password and I am locked out @@ -62,7 +62,7 @@ Icons are served with revalidation, so a re-upload appears on the next load. If The SSRF guard blocks private, loopback and link-local addresses by default, so a URL pointing at `192.168.x.x` or `10.x.x.x` is refused. -Set `ALLOW_PRIVATE_IPS=true`. Read [Security](/docs/security/) first, then see [Advanced configuration](/docs/advanced-configuration/). +Set `ALLOW_PRIVATE_IPS=true`. Read [Security](/docs/security/) first, then see [Environment variables](/docs/installation/docker/#environment-variables). Two things work without it. A dotless hostname such as a Docker container name is trusted. So is the Host IP set in **General**. @@ -106,7 +106,7 @@ Check the container logs for a definition that failed to load. A `WIDGETS_PATH` A definition that is refused is not listed, and the reason is written to the container log only. Nothing in the admin says why. -Run `docker logs ` and look for the refusal at startup. See [Development](/docs/development/). +Run `docker logs ` and look for the refusal at startup. See [Widget checklist](/docs/create-a-widget/checklist/). ## Docker and networking @@ -247,7 +247,7 @@ Messages shown in the admin, and what each one means. | `: children point at items that are not here: ...` | A folder in the config lists apps that are not in the same file. The named entries are missing. Usually a hand-edited or partly merged export. | | `Nothing to import. The file matches your current config.` | The imported file is identical to what is already stored. | | `Icon catalogues could not be reached` | No catalogue answered while searching. The field still takes a full URL or an upload. | -| ` is not a gethomepage or Dashy config.` | Only those two formats are recognised. See [Migrating](/docs/import-export/migrating/). | +| ` is not a gethomepage or Dashy config.` | Only those two formats are recognised. See [Import](/docs/import-export/migrating/). | | `(may be out of date)` | A stale reading. The last poll failed and the previous value is shown. | ## Not actually broken diff --git a/src/content/docs/docs/widgets/backup.mdx b/src/content/docs/docs/widgets/backup.mdx index bc68885..300a772 100644 --- a/src/content/docs/docs/widgets/backup.mdx +++ b/src/content/docs/docs/widgets/backup.mdx @@ -1,13 +1,13 @@ --- -title: Backup -description: Last and next run for Duplicati or Kopia jobs. +title: Backup widget +description: Last run, next run and live status of Duplicati and Kopia backup jobs on your Stackyard dashboard. --- import WidgetGroup from '../../../../components/WidgetGroup.astro'; +import ServiceList from '../../../../components/ServiceList.astro'; +import SettingList from '../../../../components/SettingList.astro'; -Backup job status, one slot per job. - -Sizes: Small holds one slot, Medium holds three. +Shows each backup job at a glance: the last run, the next scheduled run, and whether a run is in progress. An unreachable service says so. It never reports success in its place. -## Providers - -| Provider | Credentials | -| --- | --- | -| Duplicati | URL, and a password if the interface has one. | -| Kopia | URL, and a username and password if the server has them. | - -## Slot configuration - -| Field | What it does | Required | -| --- | --- | --- | -| Provider | Duplicati, Kopia, or None. | Yes | -| URL | The service address. Duplicati defaults to port 8200, Kopia to 51515. | Yes | -| Password | Stored as a secret. | No | -| Username | Kopia only. | No | -| Job or Source | Which job to watch. Enter the URL and credentials first, then load the list. | No | -| Click URL | Where clicking the slot opens. | No | -| Display Name | Overrides the name shown on the card. | No | -| Poll Interval (sec) | How often to re-read. Duplicati only. Defaults to 60. | No | - -Each slot shows the last run, the next scheduled run, and whether a run is in progress. An unreachable service says so rather than reporting success. - +Small holds one slot. Medium holds three. Each slot reads one job. + + + + diff --git a/src/content/docs/docs/widgets/books.mdx b/src/content/docs/docs/widgets/books.mdx index 80e47a4..4b909cd 100644 --- a/src/content/docs/docs/widgets/books.mdx +++ b/src/content/docs/docs/widgets/books.mdx @@ -1,46 +1,62 @@ --- -title: Books -description: Reading progress from Audiobookshelf, Komga, or Kavita. +title: Books widget +description: Reading progress and shelves from Audiobookshelf, Komga or Kavita on your Stackyard dashboard. --- import WidgetGroup from '../../../../components/WidgetGroup.astro'; +import ServiceList from '../../../../components/ServiceList.astro'; +import SettingList from '../../../../components/SettingList.astro'; Shows what you are reading and how far through you are. -Sizes: Small, Medium, Large. - -Small and medium hold one shelf. Large holds three, and each shelf shows its -own view of the same library. - -## Services - -| Service | Where the API key comes from | -| --- | --- | -| Audiobookshelf | Settings, API Keys (v2.26 and later), or your account token. | -| Komga | Account Settings, API Keys. | -| Kavita | User Settings, 3rd Party Clients. | - -## Configuration - -| Field | What it does | Required | -| --- | --- | --- | -| Service | Which library to read. | Yes | -| URL | The service address. Audiobookshelf defaults to port 13378, Komga to 25600, Kavita to 5000. | Yes | -| API key | The credential for that service. Stored as a secret. | Yes | -| Click URL | Where clicking the widget opens. | No | - -Each shelf is configured on its own. Small and medium show one Shelf section, -large shows three. - -| Shelf field | What it does | Required | -| --- | --- | --- | -| Name | The label above the shelf. Left empty, a Custom List shelf shows the name of the list, and the other shelves show what they hold. | No | -| Show | Recently Added, Unread and On-deck, or a Custom List. | Yes | -| List | Which collection or reading list to show. Enter the URL and key first, then Fetch to load the options. | Custom List only | - +Small and Medium hold one shelf. Large holds three, and each shelf shows its own view of the same library. + + + + + + diff --git a/src/content/docs/docs/widgets/clock.mdx b/src/content/docs/docs/widgets/clock.mdx index d99d7c0..25c91d2 100644 --- a/src/content/docs/docs/widgets/clock.mdx +++ b/src/content/docs/docs/widgets/clock.mdx @@ -1,31 +1,23 @@ --- -title: Clock -description: A digital or analog clock. It renders in the browser and makes no network calls. +title: Clock widget +description: A digital or analog clock for any timezone on your Stackyard dashboard. No service and no network requests. --- import WidgetGroup from '../../../../components/WidgetGroup.astro'; +import SettingList from '../../../../components/SettingList.astro'; -A clock in one of two styles. It makes no outbound request and needs no service. +A digital or analog clock. It runs in the browser and needs no service. -Sizes: Small. + +Small only. - - - - -Both show your own local time. - -## Configuration - -| Field | What it does | Required | -| --- | --- | --- | -| Style | Digital or Analog. | Yes | -| Theme | Dark or Light card. | Yes | -| Timezone | An IANA timezone name, for example `America/Toronto`. Leave blank for the browser's local time. | No | -| Show date | Shows the weekday and date under the time. | No | - + diff --git a/src/content/docs/docs/widgets/connections.mdx b/src/content/docs/docs/widgets/connections.mdx index 6950261..7557742 100644 --- a/src/content/docs/docs/widgets/connections.mdx +++ b/src/content/docs/docs/widgets/connections.mdx @@ -1,51 +1,69 @@ --- -title: Connections -description: VPN and network service status, as a map or a single connection tile. +title: Connections widget +description: VPN tunnel status and a world map of Gluetun, NetBird, Psiphon Conduit, Plausible and Umami services on your Stackyard dashboard. --- import WidgetGroup from '../../../../components/WidgetGroup.astro'; +import ServiceList from '../../../../components/ServiceList.astro'; +import SettingList from '../../../../components/SettingList.astro'; -Two views of your network connections. +Shows your network services on a world map, or one VPN connection on a tile. Pick one with the **View** setting. -Sizes: Small, Medium. +## Map view - +Medium only. One marker per service, with an optional legend. -## Views + -| View | What it shows | -| --- | --- | -| Map | Several services plotted on a world map, with an optional legend. | -| VPN | One VPN connection and its state. | + -## VPN view + -| Field | What it does | Required | -| --- | --- | --- | -| Service | Gluetun or NetBird. | Yes | -| Display Name | The label on the tile. | No | -| Control Server URL | The Gluetun control server, for example `http://gluetun:8000`. | Gluetun | -| Management API URL | The NetBird management API, for example `http://netbird:33073`. | NetBird | -| API Key | Gluetun credential. Stored as a secret. | No | -| Access Token (PAT) | NetBird credential. Stored as a secret. | NetBird | -| Click URL | Where clicking the widget opens. | No | -| Dot Color | The status dot colour. | Yes | + -## Map view +## VPN view -Add one entry per service. Each takes a type, a URL, and its own credentials. +Small or Medium. The state of one VPN connection. -| Service | What it needs | -| --- | --- | -| Gluetun | Control server URL. | -| Psiphon Conduit | Metrics URL, for example `conduit:9090`. | -| NetBird | Management API URL and an access token. | -| Plausible | Plausible URL, a site ID (the domain), and a stats API key. | -| Umami | Umami URL, a website ID, a username, and a password. | + -Each entry also takes an optional display name, an optional admin UI URL, and a dot colour. Show Legend adds a service key along the bottom of the map. + + diff --git a/src/content/docs/docs/widgets/custom.md b/src/content/docs/docs/widgets/custom.md deleted file mode 100644 index 70bd6d4..0000000 --- a/src/content/docs/docs/widgets/custom.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -title: Custom -description: Embed any web page on the dashboard as a widget. ---- - -Puts a URL of your choice on the grid, in an iframe. Use it for a service's own -status page, a Grafana panel, or anything else that renders in a frame. - -Sizes: Small, Medium, Large, X-Large. - -## Configuration - -| Field | What it does | Required | -| --- | --- | --- | -| Name | The label in the admin list. | Yes | -| Size | How many grid cells the card takes. | Yes | -| Iframe URL | The page to embed. | Yes | - -## Advanced - -These map to the iframe's own attributes. Leave them alone unless the embedded -page needs them. - -| Field | What it does | -| --- | --- | -| Referrer Policy | Which referrer the frame sends. Defaults to the browser's own behaviour. | -| Allow (feature policy) | The frame's `allow` attribute, for example `autoplay; fullscreen`. | -| Allow Fullscreen | Lets the embedded page go fullscreen. On by default. | -| Refresh Interval | Reloads the frame on a timer, in milliseconds. Minimum 250. | - -:::caution -A custom widget loads whatever the URL returns, straight into your dashboard. -Point it only at pages you trust. Unlike the built-in widgets, the request goes -from your browser to that page, so it is not proxied or sanitized by Stackyard, -and the page can see it is being framed. -::: - -## Notes - -A page that refuses to be framed will not render. Many sites set -`X-Frame-Options` or a `frame-ancestors` policy that blocks embedding, and there -is nothing Stackyard can do about that from its side. Check the browser console -if a frame stays blank. - -There is no preview of this widget here, because what it shows is entirely the -page you point it at. diff --git a/src/content/docs/docs/widgets/custom.mdx b/src/content/docs/docs/widgets/custom.mdx new file mode 100644 index 0000000..855a097 --- /dev/null +++ b/src/content/docs/docs/widgets/custom.mdx @@ -0,0 +1,33 @@ +--- +title: Custom widget +description: Embed any web page, such as a Grafana panel or a status page, as a widget on your Stackyard dashboard. +--- + +import SettingList from '../../../../components/SettingList.astro'; + +Puts a URL of your choice on the grid, in a frame. Use it for a service status page, a Grafana panel, or anything else that renders in a frame. + +Sizes: Small, Medium, Large, X-Large. There is no preview here, because the widget shows whatever page you point it at. + + + + + +:::caution +A custom widget loads whatever the URL returns, straight into your dashboard. Point it only at pages you trust. The request goes from your browser to that page. Stackyard does not proxy or sanitize it, and the page can see it is framed. +::: + +The frame is sandboxed. The page keeps scripts, forms, dialogs, pop-ups and downloads, so a real service still works. It cannot navigate the dashboard away. + +## A frame that stays blank + +A page that refuses to be framed does not render. Many sites send `X-Frame-Options` or a `frame-ancestors` policy that blocks embedding, and Stackyard cannot override that. Check the browser console when a frame stays blank. diff --git a/src/content/docs/docs/widgets/dashboard-switch.mdx b/src/content/docs/docs/widgets/dashboard-switch.mdx index 459d463..6d62531 100644 --- a/src/content/docs/docs/widgets/dashboard-switch.mdx +++ b/src/content/docs/docs/widgets/dashboard-switch.mdx @@ -1,15 +1,12 @@ --- -title: Dashboard switch -description: A keyring of links to your other Stackyard instances. +title: Dashboard switch widget +description: Link separate Stackyard dashboards, one per room or per site, as a keyring on the grid. --- import WidgetGroup from '../../../../components/WidgetGroup.astro'; +import SettingList from '../../../../components/SettingList.astro'; -A launcher for other dashboards, drawn as hanging keys. Use it to move between instances, for example one dashboard per room or per site. - -It renders in the browser and makes no outbound request. - -Sizes: Small holds two keychains, Medium holds five. +A launcher for other dashboards, drawn as hanging keys. Use it to move between instances, for example one dashboard per room or per site. It renders in the browser and makes no network request. -## Configuration - -Each keychain is one link. +Small holds two keychains. Medium holds five. -| Field | What it does | Required | -| --- | --- | --- | -| Name | The label on the key, for example `Living Room`. | No | -| Dashboard URL | Where the key opens. | Yes | -| Fob colour | The key colour. Leave on Auto to derive it from the URL. | No | + -One setting applies to the whole widget: - -| Field | What it does | Required | -| --- | --- | --- | -| Open in | Same tab or new tab. Defaults to same tab. | Yes | + :::note -Stackyard has no user accounts. This widget links between separate instances, it does not switch users. +Stackyard has no user accounts. This widget links separate instances. It does not switch users. ::: - diff --git a/src/content/docs/docs/widgets/disk-health.mdx b/src/content/docs/docs/widgets/disk-health.mdx index 5cac3e5..8780932 100644 --- a/src/content/docs/docs/widgets/disk-health.mdx +++ b/src/content/docs/docs/widgets/disk-health.mdx @@ -1,9 +1,11 @@ --- -title: Disk health -description: SMART status per drive bay, from Scrutiny or TrueNAS. +title: Disk health widget +description: SMART status for every drive bay, from Scrutiny or TrueNAS, on your Stackyard dashboard. --- import WidgetGroup from '../../../../components/WidgetGroup.astro'; +import ServiceList from '../../../../components/ServiceList.astro'; +import SettingList from '../../../../components/SettingList.astro'; One tile per drive bay, coloured by SMART status. @@ -13,24 +15,41 @@ One tile per drive bay, coloured by SMART status. { type: 'disk-health', file: 'index.html', id: 'gh-diskhealth-md', size: 'medium', title: 'Disk health widget, medium', label: 'Medium' }, ]} /> -Sizes: Small, Medium. Small holds four bays, Medium holds ten. - -## Sources - -| Source | Credentials | -| --- | --- | -| Scrutiny | Server URL. No key needed. | -| TrueNAS | Server URL and an API key. | - -## Configuration - -| Field | What it does | Required | -| --- | --- | --- | -| Source | Scrutiny or TrueNAS. Defaults to Scrutiny. | Yes | -| URL | The service address, for example `scrutiny:8080` or `truenas.local`. | Yes | -| TrueNAS API Key | Stored as a secret. | TrueNAS | -| Click URL | Where clicking the widget opens. | No | -| Bays | Which drive goes in which bay. Enter the URL first, then Fetch Drives and assign each bay. | Yes | - -States are Healthy, Warning, Failed, and Age Alert. A drive with no SMART data reports as such rather than as healthy. - +Small holds four bays. Medium holds ten. + + + + + +## Drive states + +A bay shows Healthy, Warning, Failed or Age Alert. A drive with no SMART data reports that. It is never shown as healthy. + +A bay shows `Not reporting` when its drive is no longer in the monitoring service list. The other bays are unaffected. diff --git a/src/content/docs/docs/widgets/dns.mdx b/src/content/docs/docs/widgets/dns.mdx index 374b472..9b4e68e 100644 --- a/src/content/docs/docs/widgets/dns.mdx +++ b/src/content/docs/docs/widgets/dns.mdx @@ -1,40 +1,67 @@ --- -title: DNS -description: Query and blocking counts from AdGuard Home, Pi-hole, Technitium, or NextDNS. +title: DNS widget +description: Blocked and total DNS query counts from AdGuard Home, Pi-hole, Technitium or NextDNS on your Stackyard dashboard. --- import WidgetGroup from '../../../../components/WidgetGroup.astro'; +import ServiceList from '../../../../components/ServiceList.astro'; +import SettingList from '../../../../components/SettingList.astro'; -Shows how many DNS queries were blocked, with a breakdown of allowed, cached, and blocked. - -Sizes: Small. +Shows how many DNS queries were blocked, with a breakdown of allowed, cached and blocked. -## Providers - -| Provider | Credentials | -| --- | --- | -| AdGuard Home | Server URL, optional username and password. | -| Pi-hole | Server URL, plus the password if one is set. | -| Technitium | Server URL and an API token (Administration, Sessions, Create Token). | -| NextDNS | An API key from my.nextdns.io (Account, API) and a profile ID. | - -NextDNS is a hosted service, so it takes no server URL. - -## Configuration +Small only. -| Field | What it does | Required | -| --- | --- | --- | -| Provider | Which DNS server to read. | Yes | -| Server URL | Container name and port, or a full URL. Not used by NextDNS. | Yes, except NextDNS | -| Click URL | Where clicking the widget opens. Leave blank to disable. | No | -| Username, Password | AdGuard Home only. | No | -| Pi-hole Password | Leave blank if your Pi-hole has no password. | No | -| API Token | Technitium only. Stored as a secret. | Technitium | -| API Key | NextDNS only. Stored as a secret. | NextDNS | -| Profile ID | The short NextDNS configuration ID. | NextDNS | + + diff --git a/src/content/docs/docs/widgets/github.mdx b/src/content/docs/docs/widgets/github.mdx index 7e0bbad..64b5b0f 100644 --- a/src/content/docs/docs/widgets/github.mdx +++ b/src/content/docs/docs/widgets/github.mdx @@ -1,51 +1,29 @@ --- -title: GitHub -description: Your open pull requests, or a contribution graph. +title: GitHub widget +description: Your open pull requests or your contribution graph from GitHub, on your Stackyard dashboard. --- import WidgetGroup from '../../../../components/WidgetGroup.astro'; +import SettingList from '../../../../components/SettingList.astro'; -Two views of one GitHub account. Pick which one the widget shows. +Your open pull requests, or your contribution graph. Pick one with the **View** setting. Both come in every size. -Sizes: Small, Medium, Large, X-Large. - - -## Views - -| View | What it shows | -| --- | --- | -| Pull Requests | Open pull requests, filtered by your relationship to them. | -| Contributions | The contribution graph. | - -## Configuration - -| Field | What it does | Required | -| --- | --- | --- | -| View | Pull Requests or Contributions. | Yes | -| GitHub Username | The account to read. | Yes | -| Personal Access Token | A classic PAT with `repo` and `read:user` scope, or a fine-grained PAT with equivalent read access. Stored as a secret and used only by this widget. | Yes | -| Click URL | Where clicking the widget opens. Leave blank to disable. | No | -| Pull request filters | Which open PRs to show: Created, Assigned, Mentioned, Review requested. Multiple filters combine. Defaults to Created. | Pull Requests view | - -### Getting a token - -1. Sign in to GitHub and open [Settings, Developer settings, Personal access tokens](https://github.com/settings/tokens). -2. Choose **Tokens (classic)**, then **Generate new token (classic)**. -3. Name it something you will recognise later, such as `stackyard`. -4. Set an expiry. GitHub will not show the token again after you leave the page. -5. Tick **repo** and **read:user**. -6. Generate it, then copy the value immediately. - -Paste it into the widget's **Personal Access Token** field and save. - -The `repo` scope is what lets the widget see pull requests in private repositories. If you only care about public ones, `public_repo` and `read:user` are enough. + -The Contributions view reads GitHub's GraphQL API, which classic tokens support in full. A fine-grained token works if you grant it equivalent read access. +## Token -The token is stored as a secret. It is kept on the server and never sent back to the browser. If the widget shows **Key rejected**, the value is wrong, expired, or missing a scope. +Create a classic token at [GitHub, Personal access tokens](https://github.com/settings/tokens) with the `repo` and `read:user` scopes. For public repositories only, `public_repo` and `read:user` are enough. A fine-grained token with the same read access also works. +`Key rejected` on the widget means the token is wrong, expired or missing a scope. diff --git a/src/content/docs/docs/widgets/index.md b/src/content/docs/docs/widgets/index.md deleted file mode 100644 index 1105d5b..0000000 --- a/src/content/docs/docs/widgets/index.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: Overview -description: What widgets are, how to add one, and which services each widget reads. ---- - -**In the admin:** Dashboard, then Add. See the [Settings reference](/docs/settings-reference/). - -A widget is a small visual on the dashboard grid, for information worth a glance rather than a readout. - -Each widget runs as a separate document in a sandboxed iframe and fetches only from Stackyard's own API. The server makes the outbound call. - -## Add a widget - -Under **Dashboard**, press Add and pick Widget. Choose a type, then a card size, then fill in the fields that type needs. - -Most types offer Small, some offer Medium or larger. Some fields change with the size, such as how many slots there is room for. - -Widgets reorder in the same list as apps and folders. See [Adding services](/docs/adding-services/). - -## The widgets - -| Widget | Reads from | -| --- | --- | -| [Backup](/docs/widgets/backup/) | Duplicati, Kopia | -| [Books](/docs/widgets/books/) | Audiobookshelf, Komga, Kavita | -| [Clock](/docs/widgets/clock/) | Nothing. It renders in the browser. | -| [Connections](/docs/widgets/connections/) | Gluetun, Psiphon Conduit, NetBird, Plausible, Umami | -| [Custom](/docs/widgets/custom/) | Any page you point it at, in an iframe. | -| [Dashboard switch](/docs/widgets/dashboard-switch/) | Nothing. It links to other dashboards. | -| [Disk health](/docs/widgets/disk-health/) | Scrutiny, TrueNAS | -| [DNS](/docs/widgets/dns/) | AdGuard Home, Pi-hole, Technitium, NextDNS | -| [GitHub](/docs/widgets/github/) | The GitHub API | -| [Now Playing](/docs/widgets/now-playing/) | Plex, Jellyfin, Emby, Navidrome | -| [System summary](/docs/widgets/system-summary/) | This machine, Glances, Beszel, Unraid | -| [Weather](/docs/widgets/weather/) | Open-Meteo. No API key needed. | - -## Credentials - -A field marked Secret is stored on the server and never sent back to the browser. It shows as set without revealing its value. - -Changing where a credential would be sent, by editing a URL or a non-secret field, clears the stored value and Stackyard names what to re-enter. This stops an imported config from taking a credential out of an install. - -## Writing a widget - -Adding a widget is a folder plus one registry entry, with no changes to the rest of the app. See [Development](/docs/development/). diff --git a/src/content/docs/docs/widgets/index.mdx b/src/content/docs/docs/widgets/index.mdx new file mode 100644 index 0000000..c8f2b0b --- /dev/null +++ b/src/content/docs/docs/widgets/index.mdx @@ -0,0 +1,47 @@ +--- +title: Dashboard widgets +description: Every Stackyard widget, what it shows, and which self-hosted services each one reads. +--- + +import LinkList from '../../../../components/LinkList.astro'; + +**In the admin:** Dashboard, then Add. See the [Settings reference](/docs/settings-reference/). + +A widget is a small visual on the dashboard grid, for information worth a glance rather than a readout. + +Each widget runs as a separate document in a sandboxed frame and fetches only from the Stackyard API. The server makes the outbound call. + +## Add a widget + +Under **Dashboard**, press Add and pick Widget. Choose a type, then a card size, then fill in the fields that type needs. + +Most types offer Small, some offer Medium or larger. Some fields change with the size, such as how many slots there is room for. + +Widgets reorder in the same list as apps and folders. See [Apps and folders](/docs/apps-and-folders/). + +## The widgets + + + +## Credentials + +A field marked Secret is stored on the server and never sent back to the browser. It shows as set without revealing its value. + +Changing where a credential would be sent, by editing a URL or a non-secret field, clears the stored value, and Stackyard names what to re-enter. This stops an imported config from taking a credential out of an install. + +## Writing a widget + +A widget is one folder, with no changes to the rest of the app. See [Build your first widget](/docs/create-a-widget/). diff --git a/src/content/docs/docs/widgets/now-playing.mdx b/src/content/docs/docs/widgets/now-playing.mdx index 70f03c2..95768aa 100644 --- a/src/content/docs/docs/widgets/now-playing.mdx +++ b/src/content/docs/docs/widgets/now-playing.mdx @@ -1,14 +1,14 @@ --- -title: Now Playing -description: What is playing right now on Plex, Jellyfin, Emby, or Navidrome. +title: Now Playing widget +description: What is playing right now on Plex, Jellyfin, Emby or Navidrome, with progress, on your Stackyard dashboard. --- import WidgetGroup from '../../../../components/WidgetGroup.astro'; +import ServiceList from '../../../../components/ServiceList.astro'; +import SettingList from '../../../../components/SettingList.astro'; Shows the current session on a media server, with progress. -Sizes: Small. - -## Media servers - -Pick one server per widget. Add a second widget for a second server. - -| Server | Credentials | -| --- | --- | -| Plex | Server URL and a Plex token (`X-Plex-Token`). | -| Jellyfin | URL and an API key. | -| Emby | URL and an API key. | -| Navidrome | URL, username, and password. | - -## Configuration +Small only. When nothing is playing, the widget says so. When several sessions are active, arrows step between them. -| Field | What it does | Required | -| --- | --- | --- | -| Media server | Which server to read. | Yes | -| Server URL | The server address. Plex defaults to port 32400, Jellyfin and Emby to 8096, Navidrome to 4533. | Yes | -| Token, API key, or password | The credential for that server. Stored as a secret. | Yes | -| Username | Navidrome only. | Navidrome | -| Click URL | Where clicking the widget opens. | No | +One server per widget. -When nothing is playing the widget says so rather than going blank. If several sessions are active, arrows step between them. + + diff --git a/src/content/docs/docs/widgets/system-summary.mdx b/src/content/docs/docs/widgets/system-summary.mdx index 3270df4..6272cfb 100644 --- a/src/content/docs/docs/widgets/system-summary.mdx +++ b/src/content/docs/docs/widgets/system-summary.mdx @@ -1,86 +1,124 @@ --- -title: System summary -description: CPU, memory, disk, temperature and network stats from this machine, Glances, Beszel, or Unraid. +title: System summary widget +description: CPU, memory, disk, temperature and network stats from the host, Glances, Beszel or Unraid, on your Stackyard dashboard. --- import WidgetGroup from '../../../../components/WidgetGroup.astro'; +import ServiceList from '../../../../components/ServiceList.astro'; +import SettingList from '../../../../components/SettingList.astro'; Three stat slots and an optional network row. -Sizes: Small, Medium. - -## Sources - -| Source | What it reads | -| --- | --- | -| This Machine | The container's own host metrics. | -| Glances | A Glances instance. | -| Beszel | A Beszel instance, for one registered system. | -| Unraid | The Unraid API. | - -Not every source reports everything. Beszel does not report a process count. Unraid reports neither IO wait nor a process count. - -## Source configuration +Sizes: Small, Medium. -| Field | What it does | Required | -| --- | --- | --- | -| Source | Which backend to read. Defaults to This Machine. | Yes | -| Glances URL | For example `glances:61208`. | Glances | -| Username, Password | Glances credentials, if it has any. | No | -| Beszel URL | For example `beszel:8090`. | Beszel | -| Account, Password | The Beszel login. | Beszel | -| System | Which registered Beszel system to read. Enter the URL and account first, then load the list. | Beszel | -| Unraid URL | For example `tower.local`. | Unraid | -| Unraid API Key | Stored as a secret. | Unraid | + + + + +## Host mounts + +This Machine reads temperature and disk use from paths mounted into the Stackyard container: + +```yaml +volumes: + - /sys/class/thermal:/sys/class/thermal:ro + - /mnt/your-drive:/mnt/your-drive:ro +``` ## Slots -The widget always shows three slots. Each one picks a resource. - -| Resource | Notes | -| --- | --- | -| CPU | | -| RAM | | -| Temperature | Needs a thermal zone (This Machine) or a sensor picked from the loaded list. | -| Disk Mount | Takes a first path and an optional second one. | -| IO Wait | Not available from Unraid. | -| Processes | Not available from Beszel or Unraid. | - -What a disk slot asks for depends on the source: mount paths for This Machine, filesystem names for Glances and Beszel, share names for Unraid. Leaving an Unraid share empty reads the whole array. +The widget always shows three slots. -Each slot takes a colour. - -:::note -A Glances running in Docker reports its own filesystems, not the host's. Mount the host paths into the Glances container for a disk slot to read them. -::: + ## Network row -Off by default. Turn on Show Network Row, then pick a mode. - -| Mode | What it shows | -| --- | --- | -| Speed | The last speed test result, from MySpeed or Speedtest Tracker. | -| Throughput | Live throughput on one interface. | -| Uptime | Uptime. | - -Speed mode reads MySpeed or Speedtest Tracker. - -| Field | What it does | Required | -| --- | --- | --- | -| Service URL | The address of MySpeed or Speedtest Tracker. | Yes | -| MySpeed Password | The MySpeed password, if it has one. | No | -| API Token | A Speedtest Tracker token with the `results:read` ability. Stored as a secret. | No | - -Without a token, Speedtest Tracker is read through an older route that needs no -authentication. With one, the widget reads Speedtest Tracker's documented v1 -API. - -Throughput mode takes an interface picked from the loaded list. - + + + diff --git a/src/content/docs/docs/widgets/weather.mdx b/src/content/docs/docs/widgets/weather.mdx index 28d9a3a..05b452e 100644 --- a/src/content/docs/docs/widgets/weather.mdx +++ b/src/content/docs/docs/widgets/weather.mdx @@ -1,13 +1,13 @@ --- -title: Weather -description: Current conditions from Open-Meteo. No API key required. +title: Weather widget +description: Current conditions for any city from Open-Meteo, with no account or API key, on your Stackyard dashboard. --- import WidgetGroup from '../../../../components/WidgetGroup.astro'; +import ServiceList from '../../../../components/ServiceList.astro'; +import SettingList from '../../../../components/SettingList.astro'; -Current weather for one place. Data comes from Open-Meteo, which needs no account and no API key. - -Sizes: Small. +Current weather for one place. -## Picking a location - -Type a city into Search, press Fetch, then pick the matching place from the list. Choosing a place stores its coordinates with the widget. - -## Configuration - -| Field | What it does | Required | -| --- | --- | --- | -| Search | A city name to look up. Not stored. | No | -| Location | The place picked from the search results. | Yes | -| Units | Celsius or Fahrenheit. Defaults to Celsius. | Yes | -| Show feels like | Adds the apparent temperature. Off by default. | No | -| Link URL | Where clicking the widget opens. | No | - +Small only. + + + + diff --git a/src/lib/inline.ts b/src/lib/inline.ts new file mode 100644 index 0000000..9ed1727 --- /dev/null +++ b/src/lib/inline.ts @@ -0,0 +1,9 @@ +const ESCAPES: Record = { '&': '&', '<': '<', '>': '>', '"': '"' }; + +export function inline(text: string): string { + return text + .replace(/[&<>"]/g, (c) => ESCAPES[c]) + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\[([^\]]+)\]\((\/[^)\s]*)\)/g, '$1'); +} diff --git a/src/pages/docs/development.astro b/src/pages/docs/development.astro deleted file mode 100644 index e2b8f7a..0000000 --- a/src/pages/docs/development.astro +++ /dev/null @@ -1,58 +0,0 @@ ---- -import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; -import { marked } from 'marked'; -import { readRepoFile, stripTitle, absolutiseLinks, withHeadingIds } from '../../lib/repo'; - -const REPO = 'https://github.com/SandObserver/stackyard'; -const raw = readRepoFile('CONTRIBUTING.md'); - -const parsed = raw - ? withHeadingIds(String(await marked.parse(absolutiseLinks(stripTitle(raw))))) - : { - html: `

The contributing guide is read from the Stackyard repository at build time, and that - checkout was not available for this build. Read it on - GitHub.

`, - headings: [], - }; -const { html, headings } = parsed; - -const reference = [ - ['architecture.md', 'Architecture', 'How a request is served, and where config lives.'], - ['design-system.md', 'Design system', 'Colour, type, geometry and motion.'], - ['api-errors.md', 'API errors', 'The error shape the API returns, and what each code means.'], - ['releasing.md', 'Releasing', 'How a version is cut and published.'], -]; ---- - - -

- This page is generated from CONTRIBUTING.md in the Stackyard repository, so it - matches the source. Links in it resolve to files on GitHub. -

- - - -

Reference

-

Deeper notes in the repository, beyond the ones linked above.

- - - - - - { - reference.map(([file, title, blurb]) => ( - - - - - )) - } - -
DocumentCovers
{title}{blurb}
-
diff --git a/src/pages/docs/governance.astro b/src/pages/docs/governance.astro new file mode 100644 index 0000000..15d7015 --- /dev/null +++ b/src/pages/docs/governance.astro @@ -0,0 +1,33 @@ +--- +import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; +import { marked } from 'marked'; +import { readRepoFile, stripTitle, absolutiseLinks, withHeadingIds } from '../../lib/repo'; + +const REPO = 'https://github.com/SandObserver/stackyard'; +const raw = readRepoFile('GOVERNANCE.md'); + +const parsed = raw + ? withHeadingIds(String(await marked.parse(absolutiseLinks(stripTitle(raw))))) + : { + html: `

The governance document is read from the Stackyard repository at build time, and + that checkout was not available for this build. Read it on + GitHub.

`, + headings: [], + }; +const { html, headings } = parsed; +--- + + +

+ This page is generated from GOVERNANCE.md in the Stackyard repository, so it + matches the source. +

+ + +
diff --git a/src/pages/docs/security-policy.astro b/src/pages/docs/security-policy.astro new file mode 100644 index 0000000..ef53be1 --- /dev/null +++ b/src/pages/docs/security-policy.astro @@ -0,0 +1,34 @@ +--- +import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; +import { marked } from 'marked'; +import { readRepoFile, stripTitle, absolutiseLinks, withHeadingIds } from '../../lib/repo'; + +const REPO = 'https://github.com/SandObserver/stackyard'; +const raw = readRepoFile('SECURITY.md'); + +const parsed = raw + ? withHeadingIds(String(await marked.parse(absolutiseLinks(stripTitle(raw))))) + : { + html: `

The security policy is read from the Stackyard repository at build time, and that + checkout was not available for this build. Read it on + GitHub.

`, + headings: [], + }; +const { html, headings } = parsed; +--- + + +

+ This page is generated from SECURITY.md in the Stackyard repository, so it matches + the source. How Stackyard protects an install is on Security. +

+ + +
diff --git a/src/styles/docs.css b/src/styles/docs.css index 79a960f..0cc55c3 100644 --- a/src/styles/docs.css +++ b/src/styles/docs.css @@ -939,3 +939,341 @@ button.sy-iconbtn:focus-visible { outline: 2px solid var(--sl-color-accent); outline-offset: -2px; } + +.sl-markdown-content :where( + .sy-views, .sy-seg, .sy-list, .sy-list__box, .sy-disc, .sy-disc__row, .sy-disc__main, + .sy-disc__body, .sy-disc__fields, .sy-disc__links, .sy-set__row, .sy-set__main, + .sy-link, .sy-link__row, .sy-sw-row, .sy-radii, .sy-logo +) > * { + margin-top: 0; +} + +.sl-markdown-content .sy-list { + margin-top: 1.4rem; + container-type: inline-size; +} +.sl-markdown-content .sy-list__head { + margin-bottom: 6px; + padding-inline: 16px; + font-size: 13px; + line-height: 1.38; + color: var(--sy-dim); +} +.sl-markdown-content .sy-list__box { + margin: 0; + padding: 0; + list-style: none; + background: var(--sy-surface); + border: 1px solid var(--sl-color-hairline); + border-radius: 18px; + overflow: hidden; +} +.sl-markdown-content .sy-list__foot { + margin-top: 6px; + padding-inline: 16px; + font-size: 13px; + line-height: 1.38; + color: var(--sy-dim); +} + +.sy-disc, +.sy-set__row, +.sy-link { + position: relative; +} +.sy-disc + .sy-disc::before, +.sy-set__row + .sy-set__row::before, +.sy-link + .sy-link::before { + content: ''; + position: absolute; + top: 0; + inset-inline: 16px 0; + height: 1px; + background: var(--sy-sep); +} + +.sy-disc__row, +.sy-link__row { + display: flex; + align-items: center; + gap: 12px; + min-height: 52px; + padding: 8px 16px; + cursor: pointer; + list-style: none; + color: inherit; + text-decoration: none; +} +.sy-disc__row::-webkit-details-marker { + display: none; +} +.sl-markdown-content .sy-disc { + margin: 0; + padding: 0; + border: 0; + background: none; +} +.sl-markdown-content .sy-disc__row { + margin: 0; + padding: 8px 16px; +} +.sl-markdown-content .sy-disc__fields dd { + padding: 0; +} +.sl-markdown-content .sy-disc__row::before, +.sl-markdown-content .sy-disc__row::marker { + content: none; + display: none; +} +.sy-disc__row:hover, +.sy-link__row:hover { + background: var(--sy-overlay); +} +.sy-disc__row:focus-visible, +.sy-link__row:focus-visible { + outline: 2px solid var(--sl-color-accent); + outline-offset: -2px; +} +.sy-disc__main, +.sy-set__main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} +.sy-disc__name, +.sy-set__name { + font-size: 15px; + line-height: 1.33; + font-weight: 500; + color: var(--sy-label); +} +.sy-disc__kind, +.sy-set__text { + font-size: 13px; + line-height: 1.38; + color: var(--sy-dim); +} +.sy-disc__needs { + max-width: 55%; + font-size: 14px; + line-height: 1.3; + color: var(--sy-dim); + text-align: end; +} +.sy-disc__chev, +.sy-link__chev { + flex-shrink: 0; + width: 7px; + height: 7px; + margin-inline: 2px 3px; + border-inline-end: 2px solid currentColor; + border-bottom: 2px solid currentColor; + color: var(--sy-a11y-border); + transform: rotate(45deg); + transition: transform 0.15s ease; +} +.sy-link__chev, +.sy-disc:not([open]) .sy-disc__chev { + transform: rotate(-45deg); +} +[dir='rtl'] .sy-link__chev, +[dir='rtl'] .sy-disc:not([open]) .sy-disc__chev { + transform: rotate(45deg) scaleX(-1); +} +.sy-disc__row code, +.sy-set__text code, +.sy-disc__kind code { + font-size: 0.92em; +} + +.sl-markdown-content .sy-disc__body { + padding: 0 16px 16px; +} +.sl-markdown-content .sy-disc__fields { + display: grid; + grid-template-columns: minmax(7rem, max-content) 1fr; + gap: 6px 18px; + margin: 0; + font-size: 14px; + line-height: 1.45; +} +.sl-markdown-content .sy-disc__fields dt { + margin: 0; + font-weight: 500; + color: var(--sy-label); +} +.sl-markdown-content .sy-disc__fields dd { + margin: 0; + color: var(--sl-color-gray-2); +} +.sl-markdown-content .sy-disc__note { + margin-top: 12px; + padding-inline-start: 10px; + border-inline-start: 2px solid var(--sl-color-hairline-light); + font-size: 14px; + line-height: 1.45; + color: var(--sl-color-gray-2); +} +.sl-markdown-content .sy-disc__links { + display: flex; + flex-wrap: wrap; + gap: 4px 18px; + margin-top: 12px; + font-size: 14px; +} + +.sy-set__row { + display: flex; + align-items: center; + gap: 12px; + min-height: 52px; + padding: 8px 16px; +} +.sy-set__tag { + flex-shrink: 0; + font-size: 13px; + color: var(--sy-dim); +} + +@container (max-width: 30rem) { + .sy-disc__row, + .sy-link__row { + flex-wrap: wrap; + row-gap: 2px; + } + .sy-disc__main { + flex-basis: calc(100% - 24px); + } + .sy-disc__chev, + .sy-link__chev { + order: 2; + } + .sy-disc__needs { + order: 3; + max-width: none; + width: 100%; + font-size: 13px; + text-align: start; + } + .sl-markdown-content .sy-disc__fields { + grid-template-columns: 1fr; + gap: 0; + } + .sl-markdown-content .sy-disc__fields dd + dt { + margin-top: 8px; + } +} + +@media (prefers-reduced-motion: reduce) { + .sy-seg__opt, + .sy-disc__chev { + transition: none; + } +} + +.sy-sw { + display: inline-block; + width: 14px; + height: 14px; + margin-inline-end: 6px; + border-radius: 4px; + vertical-align: -2px; + background: var(--c); + box-shadow: inset 0 0 0 1px rgba(128, 128, 128, 0.35); +} +.sl-markdown-content .sy-sw-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 1.2rem; +} +.sy-sw-chip { + display: flex; + flex-direction: column; + gap: 6px; + width: 7.2rem; + font-size: 12.5px; + color: var(--sy-dim); +} +.sy-sw-chip b { + display: block; + height: 56px; + border-radius: var(--sy-radius-md); + background: var(--c); + box-shadow: inset 0 0 0 1px rgba(128, 128, 128, 0.25); +} +.sy-sw-chip span { + color: var(--sy-label); + font-weight: 500; +} +.sl-markdown-content .sy-radii { + display: flex; + flex-wrap: wrap; + gap: 18px; + margin-top: 1.2rem; +} +.sy-radii figure { + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; + font-size: 12.5px; + color: var(--sy-dim); +} +.sy-radii figure > span { + display: block; + width: 96px; + height: 64px; + background: var(--sy-surface); + border: 1px solid var(--sl-color-hairline); +} +.sl-markdown-content .sy-logo { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 14px; + margin-top: 1.2rem; +} +.sy-logo figure { + margin: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 9rem; + padding: 20px; + border-radius: var(--sy-radius-lg); + border: 1px solid var(--sl-color-hairline); +} +.sy-logo figure img { + height: 40px; + width: auto; +} +.sy-logo figcaption { + font-size: 13px; +} +.sy-logo__dark { + background: #000000; + color: #a3a3a8; +} +.sy-logo__light { + background: #ffffff; + color: #6c6c70; +} +.sy-logo__dark a { + color: #3bddec; +} +.sy-logo__light a { + color: #00587e; +} + +.sl-markdown-content .sy-sw-label { + margin-top: 1.4rem; + font-size: 13px; + color: var(--sy-dim); +} +.sl-markdown-content .sy-sw-label + .sy-sw-row { + margin-top: 0.5rem; +}