Bundle

Weatherdeck

Weatherdeck is a status board for machines and the work running on them (a TV or phone page fed by small pushers), not a weather app and not a monitoring or alerting system.


88
Spark score
out of 100
Updated yesterday
Source checked Oct 17, 2026
Version 0.10.1

Add to Favorites

Source

Get it from source

Spark does not host a copy of it.

Open source

Reports

Agent outcome reports

No reports yet

Overview

Weatherdeck

What it does

Weatherdeck

Weatherdeck is a status board for machines and the work running on them (a TV or phone page fed by small pushers), not a weather app and not a monitoring or alerting system.

What you see. Every picture here is the demo (weatherdeck run --demo, with #theme=ship-deck on the page address). Its sources are invented and nothing in it is real.

  • Waiting on you. The amber plate counts the calls that need a person and says how long each has waited.
  • Set aside, not counted. A hold with a date on it, and a row that someone else owns, are not counted as waiting on you. On the phone page they sit under the calls in a folded line ("Deferred 2 · skipper's turn 3"); tapping it lists each with its date or its owner, and why.
  • Honest progress. Progress shows as a percent range where the source gives one (for example 38-42%). A lane the board cannot read is counted as "can't tell" (the "+10 more" line says 1), not shown as fine.
  • A quiet source is named. The lamp by the clock counts live sources. When one stops reporting it reads "2/3 live", and that machine's ship is drawn in fog and labelled silent.
  • One board for TV and phone. The same page is the open sea on a wide screen and one column on a phone.

In the clip, Cinder (one of the invented sources) stops reporting. Still frame.

The top of the board on a phone, 390 pixels wide: a header reading 2 of 3 feeds report, an amber plate of three calls that need you, a folded line reading Deferred 2 · skipper's turn 3 with Tap to list, a list of five problems that includes one lane that cannot be read right now, and an ALL FINE tile left dark because calls and problems are open.

The phone page from a few minutes into the demo, cropped after the ALL FINE tile: the calls, a folded line for what is set aside, then the problems, then that tile, which is lit only when no call or problem is open and every feed reports.

The same phone page with the folded line under the three calls tapped open. It says Not counted in Needs you, then lists two deferred rows: Inkwell 1.9 release notes, until Tue 20 Oct, and Take the second tide-gauge reading, until Thu 22 Oct, each with the reason it was put off. Under it, three rows in skipper's turn: Netcode PR held for the release train, 2 blocked notices for the lobby countdown, and Borealis summary 40 min old, each marked as owned by skipper with how long ago it was set aside.

The folded line opened. Deferred rows show the date they come back and the reason; the other rows show who owns them and since when. None of them is counted in "Needs you" above.

It is an at-a-glance board for work reported by many sources, meant to be left on a TV or a
phone: what is waiting on you, what is moving and how far along it honestly is, what got done, what
is next, and whether you can trust what you are looking at.

  • Any tool can leave a card. One small JSON file in a drop directory becomes a status tile; the board only reads it (docs/ADDINS.md).
  • Any tool can feed it. A source is anything that can send one JSON document over HTTP. Sources are discovered, never configured; machines and items appear on their own.
  • Checkable. Every push a host receives is one line in its received log (weatherdeck received --diff): what each source sent and what changed on the board.
  • Honest about gaps. A source that stops reporting stays on screen, marked silent and fogged. The board never shows stale data as fresh, and says so when a source is missing. It claims no value for itself or any tool until a number is measured (docs/honesty.md).
  • Read-only, local, tiny. Python standard library only, one SQLite file, one plain HTML/JS/CSS page. No build step, no CDN, no telemetry, no root.
  • Runs anywhere. The same command natively on Windows, macOS and Linux, no admin needed. Run several hosts at once; every page says which host serves it.
  • Deterministic. No model or LLM call anywhere; the only outbound traffic is an adapter pushing to a host you name.

What this is not. It is not a monitoring or alerting system: it never pages anyone, and it shows only what sources push to
it. It is not a hosted service: you run it yourself, on machines you choose. And it is pre-1.0 hobby software, open-sourced
because it solved a problem the author had: details may change, and support is best effort.

Five-minute quickstart

You need Python 3.9 to 3.14 (an older one stops at startup with a one-line message; the Microsoft Store one is fine for trying it; to run it at logon or run the
beat, see docs/runbook-windows.md). Nothing to install: run it from a source checkout or the release zip. Get the code by cloning this repository
(git clone it, then cd into the folder), or by unzipping the zip that scripts/build-package builds from a clone. pip install is not supported yet and stops with a
message saying so. Below, weatherdeck means python3 bin/weatherdeck on macOS/Linux and bin\weatherdeck.cmd on Windows (cmd or PowerShell).

  1. See it with synthetic data.

    weatherdeck run --demo
    

    Open http://127.0.0.1:8787/. Three invented sources report; one stops reporting after about
    two and a half minutes, shows late from about 2:40 and silent about three minutes after start, so you can see the offline state. Ctrl-C stops it. (--port N to change the port.)

  2. Run it for real.

    weatherdeck on        # background; prints the URLs
    weatherdeck status
    weatherdeck off
    

    The data lives in your per-user data directory (--data DIR or $WEATHERDECK_DATA to choose another).
    On Windows, weatherdeck startup install also starts it at every logon, with no admin rights.

  3. Send it something. Any tool, one POST (from the same machine, no setup needed):

    curl -X POST http://127.0.0.1:8787/api/ingest -H 'Content-Type: application/json' -d '{
      "schema": "lookout.feed", "schema_version": "1.0",
      "source": { "id": "my-laptop" },
      "items": [{ "id": "build", "title": "Nightly build", "state": "running" }]
    }'
    

    The schema id reads lookout.feed on purpose: it is the wire name every adapter already speaks, kept unchanged
    when the product was renamed (see docs/feed-spec.md, "Schema ids").

    To feed it continuously, run the example adapter, which turns a hand-edited list plus this
    machine's vitals into a feed and pushes it to every host in a list:

    cp examples/adapter/work.example.json work.json
    cp examples/adapter/sinks.example.txt sinks.txt     # edit the hosts
    python3 examples/adapter/push_example.py --work work.json --sinks sinks.txt
    

    To feed it from your own tool, see docs/adapters.md, "Write a pusher for your tool".

  4. Let other devices see it. Add addresses to hosts.bind in your config file (a LAN address, a
    VPN range, or both), create a token per source
    (weatherdeck token new my-laptop), and open the page from your phone. Once any token
    exists, pushes need Authorization: Bearer <token>. For a TV, open the page full-screen on any
    machine that can reach the host, or in kiosk mode on the host itself.

    The config file is <data>/config.json, in the data directory from step 2 (weatherdeck status prints that
    directory; --config FILE or $WEATHERDECK_CONFIG names another file). It does not exist until you create it. A minimal one:

    { "hosts": { "bind": ["localhost", "auto:192.168.1.0/24"] } }
    

    Restart the host after editing it (weatherdeck restart). Every key is in docs/hosting.md.

  5. Remove it. Stop the host with weatherdeck off, then delete the folder you unpacked or cloned and the data directory
    (where weatherdeck status says it is). On Windows, run weatherdeck startup remove first if you ran startup install.
    Nothing else is installed: no service, no registry entry, no files elsewhere.

Known issues

  • Safari, iPhone and the TV: only the dashboard depth scene was signed off (2026-10-10); nothing else has been checked on those devices (the Support table above, docs/device-checklist.md).
  • Firefox has been checked headless only (2026-10-16), not on a real screen, and that check is run by hand, not each release.
  • Headless WebKit does not draw the page board's sea panel (its picture and captions) at 1440 px and wider, where the lists are in two or three columns: it takes its space but stays empty. At 390 px (one column) it is drawn, and the open sea is not affected. Chromium and Firefox draw it. Whether Safari on a Mac does the same is not known (docs/device-checklist.md).
  • A source that cuts a name before it sends it (with its own ellipsis) stays cut on the board: lane rows show the title as sent, and the TV log's single line cuts it at a whole word (docs/theme-spec.md, section 24).
  • A machine's lane count adds up only the homes that send one; a home that sends nothing for that machine is not in it, so the row can show fewer lanes than the machine runs, and its "N homes" counts only those that sent one (docs/feed-spec.md, "Whose turn, dated holds and lanes per machine").
  • The hosted Windows test runs use Windows Server, not Windows 11 (see the note under the Support table).

Documents

  • docs/honesty.md: the honesty contract on one page: what the board never does (green when silent, zero for unknown, a made-up event), where every number, creature and light comes from in the feed, the machine-alert debounce (two feeds to raise, two to clear, one keepalive of latency), and why no value number is shown until it is measured.
  • docs/ledger-spec.md: the tool value ledger: one append-only file per tool, unknown written as unknown with its reason, the honest verbs (surfaced, refused, placed, landed, not dispatched), and the reading rule that turns fourteen days of lines into KEEP, QUIET, CUT-CANDIDATE or UNMEASURED. No value number is shown until it is measured.
  • docs/feed-spec.md: the feed schema (1.12), how sources merge, the five lane classes, machine rest and strain, lane state (one word and one sentence per lane), goals (the goal each lane serves, with a count under it), allowance chips, black holes (no agent at the work, outside waits, queues, coming up, the shipping gap, freshness, burn rate), the library (one honest age and word per mirrored folder), the view at a glance (since you last looked, grouping by project), and what silence looks like.
  • CHANGELOG.md: what changed in each release, and what it was checked on; docs/device-checklist.md: what only a person can check on a real Mac, iPhone and TV.
  • docs/ADDINS.md: the card contract: one small JSON file per tool in a drop directory, shown as a tile with a stale mark. Nothing is run or imported.
  • docs/adapters.md: write a pusher for your tool: what to send, the sinks file, the honesty rules for adapters, and how to test one by recording and replaying the files it reads.
  • docs/hosting.md: the weatherdeck command, binding and profiles, every config key, transports, pushing to several hosts.
  • docs/runbook-windows.md and docs/runbook-macos.md: half a page each to run a host.
  • docs/runbook-feed-gap.md: a silent feed versus a machine that is down: the read-only checks in order (uptime, lingering, timers, the journal or cron log, the beat's age) for Linux, macOS and Windows, what each answer means, and whether KillMode=process matters for a pusher package.
  • docs/beat.md: the machine beat (bin/weatherdeck-beat): memory pressure, swap rate, run queue and disk per machine, its cost, and how it reaches the board.
  • In the source repository: scripts/build-package builds the distributable zip (the product only; every entry carries the commit time of HEAD, or SOURCE_DATE_EPOCH when set, so the same commit built with the same Python and zlib on the same OS gives byte-identical zips and sha256sum is a real comparison there; across machines or OSes compare per-file content instead, in a scratch directory: unzip -q <zip> -d x && (cd x && find . -type f | LC_ALL=C sort | xargs shasum -a 256 | shasum -a 256), which works on Linux and macOS; file modes still differ on a Windows checkout; a build from a tree that is not a git checkout stamps 1980-01-01, so compare only builds of the same commit or of the same kind of tree), scripts/self-audit scans for secrets and names before a release, scripts/release-scan runs it and also scans the whole git history for names, e-mail addresses, home paths and tokens (see CONTRIBUTING.md). Packages that bundle an adapter for a specific tool are built from that adapter's own repository, not here.
  • examples/: the synthetic demo feeds, a tiny generic adapter and an example host config (examples/host/).

Try it without installing

Open the static demo: a folder of plain files that shows the board with a recorded feed of invented sources, no server and no
Python needed to look at it. It is built in the source repository, not from this package (which does not carry the build scripts): there,
python3 scripts/build-static-demo writes dist/static-demo/; open dist/static-demo/index.html in a browser, or put that folder on any static host. A yellow bar, DEMO: recorded synthetic feed,
stays on the screen and says nothing is live. The page replays three recorded frames in order, a slow step every 25 seconds, so
the calls, lanes and weather change as they did in the recording (with reduced motion it holds one frame and offers a button for
the next). To record the frames again, in the source repository, python3 scripts/record-demo-feed.

What is on the screen

On a TV, a desk screen or a laptop (any window 700 CSS px or wider) the front door is the open sea (#/sea, or no route): the
fleet on the water at full screen, one ship per machine with its name plate, the headline card for the calls on the left and the
log along the bottom, on a 640 x 360 art-pixel stage. Each ship plate carries a quiet badge: how many asks wait on you from
that machine, and how many of its lanes are off course or alerting. A ship opens its plan, a place opens its list. The
Lists door beside the ? (and in a ship's plan bar) opens the page board (#/board,
docs/theme-spec.md section 24): a header with the way back to the sea and the key, a three-state strip (needs you, needs
attention, all fine), the lanes grouped under the goal they serve, else by project (a state word and icon, the job, one
sentence, the worker's own notes behind a tap) and the sea with a caption for every picture. On a phone the page board is
#/sea itself. The tv view never shows the page board: there the sea is the whole display. The list below describes the
stage's parts (one column on a phone with the tv view).

  1. Waiting on you (the one amber plate): decisions, approvals, capacity and machine problems, with where to act. A critical takes over the bar instead.

  2. Trust: a lamp by the clock says how many sources are live and how old the newest report is; a silent source is named there, its machine reads SILENT and its work keeps its state with LAST KNOWN beside it.

  3. Machines: a pip and a word each (OK, STRUGGLING judged against the machine's own norm, a low disk, ALERT, SILENT, LOST, or ASLEEP for a machine you list in display.may_sleep that went quiet during quiet hours), with vitals on the default board and the phone. A machine its owner declared RESERVED, under MAINTENANCE or DRAINING is shown calmly, with its reason and until when, and its silence is not an alarm.

  4. The log: off course first: work that needs action (failed, blocked, a call) and work running long, nothing else. Then the rest, calmer: under way, waiting (on a gate, a PR, a person, capacity or quiet hours, and until when, counted apart), no word, landed. Each row has a project chip, a plain title cut only at a whole word, one of six row words (Waiting on you, Stuck, Can't tell, Working, Waiting on someone else, Finished) and one sentence (never a raw status line or a task id; the full text is inside the opened row; see docs/theme-spec.md section 9.5) and an honest percent range, or its phase ("2 of 4") when the source knows the phase and not a percent; rows that do not fit fold into one "+N more" row. Its last row carries one chip per allowance account: provider, account, window, percent left and reset time, in a fixed order.

  5. On the phone, below: waiting items, work under way, waiting, landed in the last 24 hours (or since your day start), up next, releases, allowance, machine vitals, sources and the last 12 hours.

  6. Click in. Every plate is a door to its place, and every row in a place opens in full: the calls (each with the exact line to say in chat and a Copy button; the board never sends anything), the lanes with their steps and history, what is next, what landed, the machines with their stations and reporters, and the log of every push the host received. Each level is a URL (#/ship/<machine>/deck/bridge), so Back, a shared link and a pinned TV work; a TV left on a deck or on the lists returns to the open sea after a minute. See docs/theme-spec.md section 16.

Quiet hours with nothing waiting show a dim resting screen on the TV; a lost host shows a plate that
counts down to the next try.

Themes. The core is generic; each look is a folder under web/themes/ (theme.name in the
config). default is plain slate with system faces. ship-deck draws the fleet as a ship at sea in
hi-bit pixel art (machines are deck sections, work is the crew), with self-hosted OFL fonts. Each sailor stands at the
station for its lane's stage, and a few creatures carry one piece of news each: a longboat for a landing, dolphins
escorting the first one in ten minutes, gulls that leave a machine whose reporters go late, a whale for a release, slack
sails for work gone quiet, and a lighthouse for the board's own host. Each is written to the Logbook. Opt-in sound
(off by default) rings bells for a new call. See docs/theme-spec.md sections 16 and 17;
weatherdeck run --demo --scenario sea-life plays every one in about three minutes.

Preview any state without touching data: add #controls=1 to the page URL and use the buttons
(late, stale, lost, host down, empty, loading, night, anchor, sky, motion, theme). #night=1 forces
the night palette, #theme=ship-deck previews a theme.

Layout

bin/weatherdeck(.cmd)    the command (launchers for src/)
src/weatherdeck/         server, ingest, merge, history store, demo player
web/                 the page: index.html, theme.js, frontdoor.js, model.js, board.js, decks.js, page.js, sightings.js, livery.js, weather.js, world.js, app.js, style.css; themes/
examples/            demo feeds, a generic adapter, an example host config
docs/                spec and hosting guide
scripts/            self-audit, release-scan, merged-check, build-package, build-static-demo, record-demo-feed (source repo only)

Licence, contributing and support

MIT licensed, see LICENSE. The name is "Weatherdeck" (one word); the wire schema ids keep the earlier name
(docs/feed-spec.md, "Schema ids").

  • Contributing: CONTRIBUTING.md. Open an issue first for anything beyond a small fix.
  • Questions and bugs: SUPPORT.md, through GitHub issues.
  • Security reports: SECURITY.md, through GitHub's private vulnerability reporting, never a public issue.
  • Conduct: CODE_OF_CONDUCT.md.

examples/adapter/work.example.json

{
  "source": { "id": "my-laptop", "label": "My laptop", "kind": "standalone", "peers": [] },
  "groups": [{ "id": "site", "label": "Website" }],
  "items": [
    { "id": "rebuild-index", "group": "site", "title": "Rebuild the search index", "state": "running", "doing": "shard 2 of 6", "percent": 33 },
    { "id": "write-docs", "group": "site", "title": "Write the deploy guide", "state": "waiting", "doing": "needs a review",
      "summary": "draft done, waiting for a review", "waiting_on": "human" }
  ],
  "attention": [
    { "id": "review-docs", "kind": "approval", "severity": "action", "title": "Review the deploy guide", "since": "2026-01-01T09:00:00Z", "how": "Reply in the tracker" }
  ],
  "next": [{ "id": "release-notes", "group": "site", "title": "Release notes", "kind": "queued", "reason": "after the guide" }],
  "done": []
}

examples/adapter/sinks.example.txt

# One dashboard host per line: name  transport  target  credential
# Start a line with ! to switch that host off without deleting it.
# A host that is down is normal: the adapter logs it once and retries with backoff.
#
# The first line works as it is on a fresh machine: a host you started on this machine
# (`weatherdeck run`) with no tokens accepts pushes from this machine with no credential (`-`).
local-board  http   http://127.0.0.1:8787   -
#
# Once the host has any token (`weatherdeck token new <source-id>`), every push needs one: save the
# token it printed to a file, put the file in the credential column, and drop the line above.
#local-board http   http://127.0.0.1:8787   token-file:~/.config/weatherdeck/local-board.token
# A token-file line whose file does not exist is reported as "token file not found: <path>".
!laptop      http   http://laptop.example:8787   token-file:~/.config/weatherdeck/laptop.token
# Same machine as the host, no network:  kiosk  local  /path/to/weatherdeck/bin/weatherdeck  -

examples/adapter/push_example.py

#!/usr/bin/env python3
"""A tiny generic adapter: turn a hand-edited work list plus this machine's vitals into a
lookout.feed document and push it to every enabled host in sinks.txt.

    python3 push_example.py --work work.json --sinks sinks.txt           # push every 30 s
    python3 push_example.py --work work.json --sinks sinks.txt --once    # push once and exit
    python3 push_example.py --work work.json --sinks sinks.txt --back-off 1.5   # slow down on a busy machine, and say so

Standard library only. It shows the three things any adapter needs: build the document, give it
a per-sink sequence number, and treat a host that is down as normal (one log line when it goes
down, one when it comes back, retry with backoff, never delay the other hosts). With --back-off it shows a fourth: a
tool that runs on someone's machine gives way when the machine is busy and says so in the feed (`pace`, feed 1.4), so the
board reads its slower reports as slowed on purpose, not as silence.
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request


def now_iso(t=None):
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t or time.time()))


def machine_row(machine_id):
    load = os.getloadavg() if hasattr(os, "getloadavg") else None  # not available on Windows
    disk = shutil.disk_usage(os.path.abspath(os.sep))
    return {"id": machine_id, "label": machine_id, "role": "this machine", "status": "ok", "as_of": now_iso(),
            "load": {"l1": round(load[0], 2), "l5": round(load[1], 2), "cores": os.cpu_count()} if load else None,
            "disks": [{"label": "disk", "free_gb": round(disk.free / 1e9), "free_pct": round(100 * disk.free / disk.total),
                       "warn_gb": 20, "crit_gb": 5}]}


def pace_for(every, threshold):
    """The whole back-off of this example: every interval is stretched by one step for each multiple of `threshold` of
    1-minute load per core (at most 4x). Returns (factor, the feed's `pace` object)."""
    load = os.getloadavg()[0] / (os.cpu_count() or 1) if hasattr(os, "getloadavg") else None
    factor = min(4, 1 + int(load // threshold)) if load is not None and threshold else 1
    return factor, {"factor": factor, "why": ["load"] if factor > 1 else [], "since": None, "pass_s": every * factor,
                    "normal_pass_s": every, "keepalive_s": every * factor, "normal_keepalive_s": every,
                    "load_per_core": round(load, 2) if load is not None else None}


def build(work, interval, boot_id, seq, pace=None):
    src = dict(work["source"])
    machine_id = src.setdefault("machine", src["id"])
    items = []
    for it in work.get("items", []):
        pct = it.get("percent")
        items.append({
            "id": it["id"], "group": it.get("group"), "machine": machine_id, "title": it.get("title", it["id"]),
            "state": it.get("state", "running"), "doing": it.get("doing"),
            # feed 1.1, all optional: a project chip, the row's one line, and what a held item waits on (and until when)
            "project": it.get("project"), "summary": it.get("summary"), "waiting_on": it.get("waiting_on"), "until": it.get("until"),
            "progress": {"basis": "coarse" if pct is None else "default", "percent_floor": pct, "percent_ceiling": pct,
                         "reason": "no percentage given" if pct is None else None, "steps": []},
        })
    feed = {"schema": "lookout.feed", "schema_version": "1.4" if pace else "1.1", "source": src,
            "producer": {"name": "push_example", "version": "1", "boot_id": boot_id, "seq": seq, "mode": "live"},
            "generated_at": now_iso(), "interval_s": interval, "machines": [machine_row(machine_id)], "items": items,
            "attention": work.get("attention", []), "next": work.get("next", []), "done": work.get("done", []),
            "groups": work.get("groups", [])}
    if pace:
        feed["pace"] = pace
    return feed


def read_sinks(path):
    sinks = []
    for line in open(path, encoding="utf-8"):
        line = line.strip()
        if not line or line.startswith("#") or line.startswith("!"):
            continue
        parts = line.split(None, 3)
        if len(parts) >= 3:
            sinks.append({"name": parts[0], "transport": parts[1], "target": parts[2], "cred": parts[3] if len(parts) > 3 else "-"})
    return sinks


class Refused(Exception):
    """A sink this adapter cannot even try, named plainly (not the host being down)."""


def credential(spec):
    if spec.startswith("token-file:"):
        path = os.path.expanduser(spec[11:])
        try:
            return open(path, encoding="utf-8").read().strip()
        except FileNotFoundError:
            raise Refused(f"token file not found: {path}") from None
    if spec.startswith("env:"):
        return os.environ.get(spec[4:], "")
    return None


def send(sink, body):
    """Return (ok, text, refused). Raises nothing. `refused` is true when this adapter would not send at all
    (a missing token file), as opposed to a host that did not answer."""
    try:
        if sink["transport"] == "http":
            req = urllib.request.Request(sink["target"].rstrip("/") + "/api/ingest", data=body, method="POST",
                                         headers={"Content-Type": "application/json"})
            token = credential(sink["cred"])
            if token:
                req.add_header("Authorization", "Bearer " + token)
            with urllib.request.urlopen(req, timeout=8) as resp:
                return True, resp.read().decode().strip(), False
        if sink["transport"] == "local":
            res = subprocess.run([sink["target"], "ingest"], input=body, capture_output=True, timeout=20)
            return res.returncode == 0, res.stdout.decode().strip(), False
        return False, f"unknown transport {sink['transport']!r} (this example does http and local)", False
    except Refused as exc:
        return False, str(exc), True
    except urllib.error.HTTPError as exc:
        return False, f"HTTP {exc.code}: {exc.read().decode(errors='replace').strip()}", False
    except (OSError, subprocess.SubprocessError, ValueError) as exc:
        return False, str(exc), False


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--work", required=True, help="JSON file: {source, items, attention, next, done, groups}")
    ap.add_argument("--sinks", required=True, help="one host per line: name transport target credential")
    ap.add_argument("--every", type=int, default=30)
    ap.add_argument("--once", action="store_true")
    ap.add_argument("--back-off", type=float, metavar="LOAD_PER_CORE",
                    help="stretch every interval one step for each multiple of this 1-minute load per core, and say so in the feed")
    args = ap.parse_args()
    boot_id = f"example-{int(time.time())}"
    where = ", ".join(f"{k['name']} ({k['target']})" for k in read_sinks(args.sinks)) or "no host (every line in the sinks file is off or empty)"
    print(f"push_example: pushing to {where}" + (" once" if args.once else f" every {args.every} s until Ctrl-C"), flush=True)
    state = {}  # per sink: seq, down flag, next attempt, backoff
    while True:
        work = json.load(open(args.work, encoding="utf-8"))
        factor, pace = pace_for(args.every, args.back_off) if args.back_off else (1, None)
        for sink in read_sinks(args.sinks):
            st = state.setdefault(sink["name"], {"seq": 0, "down": False, "next": 0, "backoff": args.every})
            if time.time() < st["next"]:
                continue
            st["seq"] += 1
            ok, text, refused = send(sink, json.dumps(build(work, args.every * factor, boot_id, st["seq"], pace)).encode())
            if ok:
                if st["down"]:
                    print(f"{sink['name']}: back up")
                st.update(down=False, backoff=args.every, next=0)
            else:
                st["seq"] -= 1  # nothing was accepted, so the host never saw this number
                if not st["down"]:
                    print(f"{sink['name']}: {text}; retrying quietly" if refused else f"{sink['name']}: down ({text}); retrying quietly")
                st.update(down=True, backoff=min(st["backoff"] * 2, 300))
                st["next"] = time.time() + st["backoff"]
        if args.once:
            return 0
        time.sleep(args.every * factor)


if __name__ == "__main__":
    sys.exit(main())
Source README

Weatherdeck

Weatherdeck is a status board for machines and the work running on them (a TV or phone page fed by small pushers), not a weather app and not a monitoring or alerting system.

What you see. Every picture here is the demo (weatherdeck run --demo, with #theme=ship-deck on the page address). Its sources are invented and nothing in it is real.

  • Waiting on you. The amber plate counts the calls that need a person and says how long each has waited.
  • Set aside, not counted. A hold with a date on it, and a row that someone else owns, are not counted as waiting on you. On the phone page they sit under the calls in a folded line ("Deferred 2 · skipper's turn 3"); tapping it lists each with its date or its owner, and why.
  • Honest progress. Progress shows as a percent range where the source gives one (for example 38-42%). A lane the board cannot read is counted as "can't tell" (the "+10 more" line says 1), not shown as fine.
  • A quiet source is named. The lamp by the clock counts live sources. When one stops reporting it reads "2/3 live", and that machine's ship is drawn in fog and labelled silent.
  • One board for TV and phone. The same page is the open sea on a wide screen and one column on a phone.

In the clip, Cinder (one of the invented sources) stops reporting. Still frame.

The top of the board on a phone, 390 pixels wide: a header reading 2 of 3 feeds report, an amber plate of three calls that need you, a folded line reading Deferred 2 · skipper's turn 3 with Tap to list, a list of five problems that includes one lane that cannot be read right now, and an ALL FINE tile left dark because calls and problems are open.

The phone page from a few minutes into the demo, cropped after the ALL FINE tile: the calls, a folded line for what is set aside, then the problems, then that tile, which is lit only when no call or problem is open and every feed reports.

The same phone page with the folded line under the three calls tapped open. It says Not counted in Needs you, then lists two deferred rows: Inkwell 1.9 release notes, until Tue 20 Oct, and Take the second tide-gauge reading, until Thu 22 Oct, each with the reason it was put off. Under it, three rows in skipper's turn: Netcode PR held for the release train, 2 blocked notices for the lobby countdown, and Borealis summary 40 min old, each marked as owned by skipper with how long ago it was set aside.

The folded line opened. Deferred rows show the date they come back and the reason; the other rows show who owns them and since when. None of them is counted in "Needs you" above.

It is an at-a-glance board for work reported by many sources, meant to be left on a TV or a
phone: what is waiting on you, what is moving and how far along it honestly is, what got done, what
is next, and whether you can trust what you are looking at.

  • Any tool can leave a card. One small JSON file in a drop directory becomes a status tile; the board only reads it (docs/ADDINS.md).
  • Any tool can feed it. A source is anything that can send one JSON document over HTTP. Sources are discovered, never configured; machines and items appear on their own.
  • Checkable. Every push a host receives is one line in its received log (weatherdeck received --diff): what each source sent and what changed on the board.
  • Honest about gaps. A source that stops reporting stays on screen, marked silent and fogged. The board never shows stale data as fresh, and says so when a source is missing. It claims no value for itself or any tool until a number is measured (docs/honesty.md).
  • Read-only, local, tiny. Python standard library only, one SQLite file, one plain HTML/JS/CSS page. No build step, no CDN, no telemetry, no root.
  • Runs anywhere. The same command natively on Windows, macOS and Linux, no admin needed. Run several hosts at once; every page says which host serves it.
  • Deterministic. No model or LLM call anywhere; the only outbound traffic is an adapter pushing to a host you name.

What this is not. It is not a monitoring or alerting system: it never pages anyone, and it shows only what sources push to
it. It is not a hosted service: you run it yourself, on machines you choose. And it is pre-1.0 hobby software, open-sourced
because it solved a problem the author had: details may change, and support is best effort.

Five-minute quickstart

You need Python 3.9 to 3.14 (an older one stops at startup with a one-line message; the Microsoft Store one is fine for trying it; to run it at logon or run the
beat, see docs/runbook-windows.md). Nothing to install: run it from a source checkout or the release zip. Get the code by cloning this repository
(git clone it, then cd into the folder), or by unzipping the zip that scripts/build-package builds from a clone. pip install is not supported yet and stops with a
message saying so. Below, weatherdeck means python3 bin/weatherdeck on macOS/Linux and bin\weatherdeck.cmd on Windows (cmd or PowerShell).

  1. See it with synthetic data.

    weatherdeck run --demo
    

    Open http://127.0.0.1:8787/. Three invented sources report; one stops reporting after about
    two and a half minutes, shows late from about 2:40 and silent about three minutes after start, so you can see the offline state. Ctrl-C stops it. (--port N to change the port.)

  2. Run it for real.

    weatherdeck on        # background; prints the URLs
    weatherdeck status
    weatherdeck off
    

    The data lives in your per-user data directory (--data DIR or $WEATHERDECK_DATA to choose another).
    On Windows, weatherdeck startup install also starts it at every logon, with no admin rights.

  3. Send it something. Any tool, one POST (from the same machine, no setup needed):

    curl -X POST http://127.0.0.1:8787/api/ingest -H 'Content-Type: application/json' -d '{
      "schema": "lookout.feed", "schema_version": "1.0",
      "source": { "id": "my-laptop" },
      "items": [{ "id": "build", "title": "Nightly build", "state": "running" }]
    }'
    

    The schema id reads lookout.feed on purpose: it is the wire name every adapter already speaks, kept unchanged
    when the product was renamed (see docs/feed-spec.md, "Schema ids").

    To feed it continuously, run the example adapter, which turns a hand-edited list plus this
    machine's vitals into a feed and pushes it to every host in a list:

    cp examples/adapter/work.example.json work.json
    cp examples/adapter/sinks.example.txt sinks.txt     # edit the hosts
    python3 examples/adapter/push_example.py --work work.json --sinks sinks.txt
    

    To feed it from your own tool, see docs/adapters.md, "Write a pusher for your tool".

  4. Let other devices see it. Add addresses to hosts.bind in your config file (a LAN address, a
    VPN range, or both), create a token per source
    (weatherdeck token new my-laptop), and open the page from your phone. Once any token
    exists, pushes need Authorization: Bearer <token>. For a TV, open the page full-screen on any
    machine that can reach the host, or in kiosk mode on the host itself.

    The config file is <data>/config.json, in the data directory from step 2 (weatherdeck status prints that
    directory; --config FILE or $WEATHERDECK_CONFIG names another file). It does not exist until you create it. A minimal one:

    { "hosts": { "bind": ["localhost", "auto:192.168.1.0/24"] } }
    

    Restart the host after editing it (weatherdeck restart). Every key is in docs/hosting.md.

  5. Remove it. Stop the host with weatherdeck off, then delete the folder you unpacked or cloned and the data directory
    (where weatherdeck status says it is). On Windows, run weatherdeck startup remove first if you ran startup install.
    Nothing else is installed: no service, no registry entry, no files elsewhere.

Known issues

  • Safari, iPhone and the TV: only the dashboard depth scene was signed off (2026-10-10); nothing else has been checked on those devices (the Support table above, docs/device-checklist.md).
  • Firefox has been checked headless only (2026-10-16), not on a real screen, and that check is run by hand, not each release.
  • Headless WebKit does not draw the page board's sea panel (its picture and captions) at 1440 px and wider, where the lists are in two or three columns: it takes its space but stays empty. At 390 px (one column) it is drawn, and the open sea is not affected. Chromium and Firefox draw it. Whether Safari on a Mac does the same is not known (docs/device-checklist.md).
  • A source that cuts a name before it sends it (with its own ellipsis) stays cut on the board: lane rows show the title as sent, and the TV log's single line cuts it at a whole word (docs/theme-spec.md, section 24).
  • A machine's lane count adds up only the homes that send one; a home that sends nothing for that machine is not in it, so the row can show fewer lanes than the machine runs, and its "N homes" counts only those that sent one (docs/feed-spec.md, "Whose turn, dated holds and lanes per machine").
  • The hosted Windows test runs use Windows Server, not Windows 11 (see the note under the Support table).

Documents

  • docs/honesty.md: the honesty contract on one page: what the board never does (green when silent, zero for unknown, a made-up event), where every number, creature and light comes from in the feed, the machine-alert debounce (two feeds to raise, two to clear, one keepalive of latency), and why no value number is shown until it is measured.
  • docs/ledger-spec.md: the tool value ledger: one append-only file per tool, unknown written as unknown with its reason, the honest verbs (surfaced, refused, placed, landed, not dispatched), and the reading rule that turns fourteen days of lines into KEEP, QUIET, CUT-CANDIDATE or UNMEASURED. No value number is shown until it is measured.
  • docs/feed-spec.md: the feed schema (1.12), how sources merge, the five lane classes, machine rest and strain, lane state (one word and one sentence per lane), goals (the goal each lane serves, with a count under it), allowance chips, black holes (no agent at the work, outside waits, queues, coming up, the shipping gap, freshness, burn rate), the library (one honest age and word per mirrored folder), the view at a glance (since you last looked, grouping by project), and what silence looks like.
  • CHANGELOG.md: what changed in each release, and what it was checked on; docs/device-checklist.md: what only a person can check on a real Mac, iPhone and TV.
  • docs/ADDINS.md: the card contract: one small JSON file per tool in a drop directory, shown as a tile with a stale mark. Nothing is run or imported.
  • docs/adapters.md: write a pusher for your tool: what to send, the sinks file, the honesty rules for adapters, and how to test one by recording and replaying the files it reads.
  • docs/hosting.md: the weatherdeck command, binding and profiles, every config key, transports, pushing to several hosts.
  • docs/runbook-windows.md and docs/runbook-macos.md: half a page each to run a host.
  • docs/runbook-feed-gap.md: a silent feed versus a machine that is down: the read-only checks in order (uptime, lingering, timers, the journal or cron log, the beat's age) for Linux, macOS and Windows, what each answer means, and whether KillMode=process matters for a pusher package.
  • docs/beat.md: the machine beat (bin/weatherdeck-beat): memory pressure, swap rate, run queue and disk per machine, its cost, and how it reaches the board.
  • In the source repository: scripts/build-package builds the distributable zip (the product only; every entry carries the commit time of HEAD, or SOURCE_DATE_EPOCH when set, so the same commit built with the same Python and zlib on the same OS gives byte-identical zips and sha256sum is a real comparison there; across machines or OSes compare per-file content instead, in a scratch directory: unzip -q <zip> -d x && (cd x && find . -type f | LC_ALL=C sort | xargs shasum -a 256 | shasum -a 256), which works on Linux and macOS; file modes still differ on a Windows checkout; a build from a tree that is not a git checkout stamps 1980-01-01, so compare only builds of the same commit or of the same kind of tree), scripts/self-audit scans for secrets and names before a release, scripts/release-scan runs it and also scans the whole git history for names, e-mail addresses, home paths and tokens (see CONTRIBUTING.md). Packages that bundle an adapter for a specific tool are built from that adapter's own repository, not here.
  • examples/: the synthetic demo feeds, a tiny generic adapter and an example host config (examples/host/).

Try it without installing

Open the static demo: a folder of plain files that shows the board with a recorded feed of invented sources, no server and no
Python needed to look at it. It is built in the source repository, not from this package (which does not carry the build scripts): there,
python3 scripts/build-static-demo writes dist/static-demo/; open dist/static-demo/index.html in a browser, or put that folder on any static host. A yellow bar, DEMO: recorded synthetic feed,
stays on the screen and says nothing is live. The page replays three recorded frames in order, a slow step every 25 seconds, so
the calls, lanes and weather change as they did in the recording (with reduced motion it holds one frame and offers a button for
the next). To record the frames again, in the source repository, python3 scripts/record-demo-feed.

What is on the screen

On a TV, a desk screen or a laptop (any window 700 CSS px or wider) the front door is the open sea (#/sea, or no route): the
fleet on the water at full screen, one ship per machine with its name plate, the headline card for the calls on the left and the
log along the bottom, on a 640 x 360 art-pixel stage. Each ship plate carries a quiet badge: how many asks wait on you from
that machine, and how many of its lanes are off course or alerting. A ship opens its plan, a place opens its list. The
Lists door beside the ? (and in a ship's plan bar) opens the page board (#/board,
docs/theme-spec.md section 24): a header with the way back to the sea and the key, a three-state strip (needs you, needs
attention, all fine), the lanes grouped under the goal they serve, else by project (a state word and icon, the job, one
sentence, the worker's own notes behind a tap) and the sea with a caption for every picture. On a phone the page board is
#/sea itself. The tv view never shows the page board: there the sea is the whole display. The list below describes the
stage's parts (one column on a phone with the tv view).

  1. Waiting on you (the one amber plate): decisions, approvals, capacity and machine problems, with where to act. A critical takes over the bar instead.

  2. Trust: a lamp by the clock says how many sources are live and how old the newest report is; a silent source is named there, its machine reads SILENT and its work keeps its state with LAST KNOWN beside it.

  3. Machines: a pip and a word each (OK, STRUGGLING judged against the machine's own norm, a low disk, ALERT, SILENT, LOST, or ASLEEP for a machine you list in display.may_sleep that went quiet during quiet hours), with vitals on the default board and the phone. A machine its owner declared RESERVED, under MAINTENANCE or DRAINING is shown calmly, with its reason and until when, and its silence is not an alarm.

  4. The log: off course first: work that needs action (failed, blocked, a call) and work running long, nothing else. Then the rest, calmer: under way, waiting (on a gate, a PR, a person, capacity or quiet hours, and until when, counted apart), no word, landed. Each row has a project chip, a plain title cut only at a whole word, one of six row words (Waiting on you, Stuck, Can't tell, Working, Waiting on someone else, Finished) and one sentence (never a raw status line or a task id; the full text is inside the opened row; see docs/theme-spec.md section 9.5) and an honest percent range, or its phase ("2 of 4") when the source knows the phase and not a percent; rows that do not fit fold into one "+N more" row. Its last row carries one chip per allowance account: provider, account, window, percent left and reset time, in a fixed order.

  5. On the phone, below: waiting items, work under way, waiting, landed in the last 24 hours (or since your day start), up next, releases, allowance, machine vitals, sources and the last 12 hours.

  6. Click in. Every plate is a door to its place, and every row in a place opens in full: the calls (each with the exact line to say in chat and a Copy button; the board never sends anything), the lanes with their steps and history, what is next, what landed, the machines with their stations and reporters, and the log of every push the host received. Each level is a URL (#/ship/<machine>/deck/bridge), so Back, a shared link and a pinned TV work; a TV left on a deck or on the lists returns to the open sea after a minute. See docs/theme-spec.md section 16.

Quiet hours with nothing waiting show a dim resting screen on the TV; a lost host shows a plate that
counts down to the next try.

Themes. The core is generic; each look is a folder under web/themes/ (theme.name in the
config). default is plain slate with system faces. ship-deck draws the fleet as a ship at sea in
hi-bit pixel art (machines are deck sections, work is the crew), with self-hosted OFL fonts. Each sailor stands at the
station for its lane's stage, and a few creatures carry one piece of news each: a longboat for a landing, dolphins
escorting the first one in ten minutes, gulls that leave a machine whose reporters go late, a whale for a release, slack
sails for work gone quiet, and a lighthouse for the board's own host. Each is written to the Logbook. Opt-in sound
(off by default) rings bells for a new call. See docs/theme-spec.md sections 16 and 17;
weatherdeck run --demo --scenario sea-life plays every one in about three minutes.

Preview any state without touching data: add #controls=1 to the page URL and use the buttons
(late, stale, lost, host down, empty, loading, night, anchor, sky, motion, theme). #night=1 forces
the night palette, #theme=ship-deck previews a theme.

Layout

bin/weatherdeck(.cmd)    the command (launchers for src/)
src/weatherdeck/         server, ingest, merge, history store, demo player
web/                 the page: index.html, theme.js, frontdoor.js, model.js, board.js, decks.js, page.js, sightings.js, livery.js, weather.js, world.js, app.js, style.css; themes/
examples/            demo feeds, a generic adapter, an example host config
docs/                spec and hosting guide
scripts/            self-audit, release-scan, merged-check, build-package, build-static-demo, record-demo-feed (source repo only)

Licence, contributing and support

MIT licensed, see LICENSE. The name is "Weatherdeck" (one word); the wire schema ids keep the earlier name
(docs/feed-spec.md, "Schema ids").

  • Contributing: CONTRIBUTING.md. Open an issue first for anything beyond a small fix.
  • Questions and bugs: SUPPORT.md, through GitHub issues.
  • Security reports: SECURITY.md, through GitHub's private vulnerability reporting, never a public issue.
  • Conduct: CODE_OF_CONDUCT.md.

examples/adapter/work.example.json

{
  "source": { "id": "my-laptop", "label": "My laptop", "kind": "standalone", "peers": [] },
  "groups": [{ "id": "site", "label": "Website" }],
  "items": [
    { "id": "rebuild-index", "group": "site", "title": "Rebuild the search index", "state": "running", "doing": "shard 2 of 6", "percent": 33 },
    { "id": "write-docs", "group": "site", "title": "Write the deploy guide", "state": "waiting", "doing": "needs a review",
      "summary": "draft done, waiting for a review", "waiting_on": "human" }
  ],
  "attention": [
    { "id": "review-docs", "kind": "approval", "severity": "action", "title": "Review the deploy guide", "since": "2026-01-01T09:00:00Z", "how": "Reply in the tracker" }
  ],
  "next": [{ "id": "release-notes", "group": "site", "title": "Release notes", "kind": "queued", "reason": "after the guide" }],
  "done": []
}

examples/adapter/sinks.example.txt

# One dashboard host per line: name  transport  target  credential
# Start a line with ! to switch that host off without deleting it.
# A host that is down is normal: the adapter logs it once and retries with backoff.
#
# The first line works as it is on a fresh machine: a host you started on this machine
# (`weatherdeck run`) with no tokens accepts pushes from this machine with no credential (`-`).
local-board  http   http://127.0.0.1:8787   -
#
# Once the host has any token (`weatherdeck token new <source-id>`), every push needs one: save the
# token it printed to a file, put the file in the credential column, and drop the line above.
#local-board http   http://127.0.0.1:8787   token-file:~/.config/weatherdeck/local-board.token
# A token-file line whose file does not exist is reported as "token file not found: <path>".
!laptop      http   http://laptop.example:8787   token-file:~/.config/weatherdeck/laptop.token
# Same machine as the host, no network:  kiosk  local  /path/to/weatherdeck/bin/weatherdeck  -

examples/adapter/push_example.py

#!/usr/bin/env python3
"""A tiny generic adapter: turn a hand-edited work list plus this machine's vitals into a
lookout.feed document and push it to every enabled host in sinks.txt.

    python3 push_example.py --work work.json --sinks sinks.txt           # push every 30 s
    python3 push_example.py --work work.json --sinks sinks.txt --once    # push once and exit
    python3 push_example.py --work work.json --sinks sinks.txt --back-off 1.5   # slow down on a busy machine, and say so

Standard library only. It shows the three things any adapter needs: build the document, give it
a per-sink sequence number, and treat a host that is down as normal (one log line when it goes
down, one when it comes back, retry with backoff, never delay the other hosts). With --back-off it shows a fourth: a
tool that runs on someone's machine gives way when the machine is busy and says so in the feed (`pace`, feed 1.4), so the
board reads its slower reports as slowed on purpose, not as silence.
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request


def now_iso(t=None):
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t or time.time()))


def machine_row(machine_id):
    load = os.getloadavg() if hasattr(os, "getloadavg") else None  # not available on Windows
    disk = shutil.disk_usage(os.path.abspath(os.sep))
    return {"id": machine_id, "label": machine_id, "role": "this machine", "status": "ok", "as_of": now_iso(),
            "load": {"l1": round(load[0], 2), "l5": round(load[1], 2), "cores": os.cpu_count()} if load else None,
            "disks": [{"label": "disk", "free_gb": round(disk.free / 1e9), "free_pct": round(100 * disk.free / disk.total),
                       "warn_gb": 20, "crit_gb": 5}]}


def pace_for(every, threshold):
    """The whole back-off of this example: every interval is stretched by one step for each multiple of `threshold` of
    1-minute load per core (at most 4x). Returns (factor, the feed's `pace` object)."""
    load = os.getloadavg()[0] / (os.cpu_count() or 1) if hasattr(os, "getloadavg") else None
    factor = min(4, 1 + int(load // threshold)) if load is not None and threshold else 1
    return factor, {"factor": factor, "why": ["load"] if factor > 1 else [], "since": None, "pass_s": every * factor,
                    "normal_pass_s": every, "keepalive_s": every * factor, "normal_keepalive_s": every,
                    "load_per_core": round(load, 2) if load is not None else None}


def build(work, interval, boot_id, seq, pace=None):
    src = dict(work["source"])
    machine_id = src.setdefault("machine", src["id"])
    items = []
    for it in work.get("items", []):
        pct = it.get("percent")
        items.append({
            "id": it["id"], "group": it.get("group"), "machine": machine_id, "title": it.get("title", it["id"]),
            "state": it.get("state", "running"), "doing": it.get("doing"),
            # feed 1.1, all optional: a project chip, the row's one line, and what a held item waits on (and until when)
            "project": it.get("project"), "summary": it.get("summary"), "waiting_on": it.get("waiting_on"), "until": it.get("until"),
            "progress": {"basis": "coarse" if pct is None else "default", "percent_floor": pct, "percent_ceiling": pct,
                         "reason": "no percentage given" if pct is None else None, "steps": []},
        })
    feed = {"schema": "lookout.feed", "schema_version": "1.4" if pace else "1.1", "source": src,
            "producer": {"name": "push_example", "version": "1", "boot_id": boot_id, "seq": seq, "mode": "live"},
            "generated_at": now_iso(), "interval_s": interval, "machines": [machine_row(machine_id)], "items": items,
            "attention": work.get("attention", []), "next": work.get("next", []), "done": work.get("done", []),
            "groups": work.get("groups", [])}
    if pace:
        feed["pace"] = pace
    return feed


def read_sinks(path):
    sinks = []
    for line in open(path, encoding="utf-8"):
        line = line.strip()
        if not line or line.startswith("#") or line.startswith("!"):
            continue
        parts = line.split(None, 3)
        if len(parts) >= 3:
            sinks.append({"name": parts[0], "transport": parts[1], "target": parts[2], "cred": parts[3] if len(parts) > 3 else "-"})
    return sinks


class Refused(Exception):
    """A sink this adapter cannot even try, named plainly (not the host being down)."""


def credential(spec):
    if spec.startswith("token-file:"):
        path = os.path.expanduser(spec[11:])
        try:
            return open(path, encoding="utf-8").read().strip()
        except FileNotFoundError:
            raise Refused(f"token file not found: {path}") from None
    if spec.startswith("env:"):
        return os.environ.get(spec[4:], "")
    return None


def send(sink, body):
    """Return (ok, text, refused). Raises nothing. `refused` is true when this adapter would not send at all
    (a missing token file), as opposed to a host that did not answer."""
    try:
        if sink["transport"] == "http":
            req = urllib.request.Request(sink["target"].rstrip("/") + "/api/ingest", data=body, method="POST",
                                         headers={"Content-Type": "application/json"})
            token = credential(sink["cred"])
            if token:
                req.add_header("Authorization", "Bearer " + token)
            with urllib.request.urlopen(req, timeout=8) as resp:
                return True, resp.read().decode().strip(), False
        if sink["transport"] == "local":
            res = subprocess.run([sink["target"], "ingest"], input=body, capture_output=True, timeout=20)
            return res.returncode == 0, res.stdout.decode().strip(), False
        return False, f"unknown transport {sink['transport']!r} (this example does http and local)", False
    except Refused as exc:
        return False, str(exc), True
    except urllib.error.HTTPError as exc:
        return False, f"HTTP {exc.code}: {exc.read().decode(errors='replace').strip()}", False
    except (OSError, subprocess.SubprocessError, ValueError) as exc:
        return False, str(exc), False


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--work", required=True, help="JSON file: {source, items, attention, next, done, groups}")
    ap.add_argument("--sinks", required=True, help="one host per line: name transport target credential")
    ap.add_argument("--every", type=int, default=30)
    ap.add_argument("--once", action="store_true")
    ap.add_argument("--back-off", type=float, metavar="LOAD_PER_CORE",
                    help="stretch every interval one step for each multiple of this 1-minute load per core, and say so in the feed")
    args = ap.parse_args()
    boot_id = f"example-{int(time.time())}"
    where = ", ".join(f"{k['name']} ({k['target']})" for k in read_sinks(args.sinks)) or "no host (every line in the sinks file is off or empty)"
    print(f"push_example: pushing to {where}" + (" once" if args.once else f" every {args.every} s until Ctrl-C"), flush=True)
    state = {}  # per sink: seq, down flag, next attempt, backoff
    while True:
        work = json.load(open(args.work, encoding="utf-8"))
        factor, pace = pace_for(args.every, args.back_off) if args.back_off else (1, None)
        for sink in read_sinks(args.sinks):
            st = state.setdefault(sink["name"], {"seq": 0, "down": False, "next": 0, "backoff": args.every})
            if time.time() < st["next"]:
                continue
            st["seq"] += 1
            ok, text, refused = send(sink, json.dumps(build(work, args.every * factor, boot_id, st["seq"], pace)).encode())
            if ok:
                if st["down"]:
                    print(f"{sink['name']}: back up")
                st.update(down=False, backoff=args.every, next=0)
            else:
                st["seq"] -= 1  # nothing was accepted, so the host never saw this number
                if not st["down"]:
                    print(f"{sink['name']}: {text}; retrying quietly" if refused else f"{sink['name']}: down ({text}); retrying quietly")
                st.update(down=True, backoff=min(st["backoff"] * 2, 300))
                st["next"] = time.time() + st["backoff"]
        if args.once:
            return 0
        time.sleep(args.every * factor)


if __name__ == "__main__":
    sys.exit(main())

Discussion

Questions & comments · 0

Sign In Sign in to leave a comment.