DEV Community

Keanred
Keanred

Posted on

reveille: a morning dashboard that survives the internet

I used to start every day the same way: open a browser, then five tabs. Weather. Calendar. GitHub, to see which PRs needed me. A todo list. A sunrise time, because I live in Finland and in December that number is the difference between "I'll walk" and "I absolutely will not."

None of that needs a browser. It's a handful of API calls and a bit of formatting. So I built reveille, one terminal command that reveilles those live sources into a single Ink-rendered view. Type reveille, get your morning, close the terminal.

The feature list is almost beside the point. The interesting part, and the reason I kept working on it past the weekend it should have taken, is that building a dashboard that reads a dozen flaky external APIs is really a lesson in what to do when those APIs misbehave. A dashboard that hangs because one weather endpoint is having a bad day is worse than no dashboard at all.

What it actually shows

Out of the box, and configurable from a TOML file, reveille pulls together:

  • Weather and Finnish sunrise/sunset (daylight)
  • Calendar events, via Google Calendar OAuth
  • git working-tree status and GitHub PR/review state
  • Todos
  • System health, including running Docker containers and local service checks
  • A clock, a Hacker News-style headline feed, and an escape hatch: a generic http-json source for any JSON endpoint you want on screen

Panels lay out as masonry columns using Ink's flexbox (Yoga under the hood), so the dashboard reflows to your terminal width instead of assuming a fixed grid.

Two commands earn their keep beyond the main view:

reveille --once fetches everything once, prints a single compact line (the next event and its countdown, today's event count, unchecked todos), then exits. Colors strip automatically when stdout isn't a TTY, so it drops straight into a shell prompt:

Next: Standup in 12m  ยท  4 events  ยท  2 due
Enter fullscreen mode Exit fullscreen mode

reveille doctor is the first thing I run when a panel misbehaves. It validates the config and checks every source's credentials, then tells me which secret failed to resolve and where it looked, which turns "the weather panel is blank" from a debugging session into a one-line answer.

The core idea: everything is a Source<T>

The whole system is built around one small contract. A Source<T> is a pure, abortable, typed data feed that, deliberately, knows nothing about rendering:

interface Source<T = unknown> {
  readonly id: string;      // stable; also the cache key
  readonly kind: string;    // source type; selects the panel renderer
  readonly label: string;   // panel title
  readonly ttl: number;     // refresh interval, ms
  readonly timeout: number; // per-fetch timeout, ms
  fetch(ctx: SourceContext): Promise<T>; // must honor ctx.signal
}
Enter fullscreen mode Exit fullscreen mode

Keeping rendering out of the source is the decision everything else hangs off. A source is just "here's how to get some typed data, here's how long it's good for, here's when to give up." That makes every source trivially testable in isolation (no terminal, no React, no mocking a renderer), and it means the UI can treat all eleven source kinds identically.

The state of a source at any moment is a discriminated union, and this is where the resilience story lives:

type SourceState<T> =
  | { status: 'loading' }
  | { status: 'ok'; data: T; fetchedAt: number }
  | { status: 'stale'; data: T; fetchedAt: number; error: Error }
  | { status: 'error'; error: Error };
Enter fullscreen mode Exit fullscreen mode

The stale case is the one that matters. It carries the last good data and the error that happened on the most recent refresh. That single shape is what makes graceful degradation fall out of the type system instead of being bolted on with try/catch scattered through the UI. A panel whose latest fetch failed but whose previous fetch succeeded doesn't go blank and it doesn't lie. It shows the last good data with a "stale" badge. The failure is honest and local.

Resilience by construction

Three decisions turn that contract into something that feels solid to use.

Cold start paints from cache. Every successful fetch is written to an atomic JSON cache on disk under ~/.config/reveille/cache/. On launch, reveille hydrates every panel from that cache first, so the dashboard is fully drawn before a single network request completes. Then each source refreshes on its own cadence in the background. You never look at a screen full of spinners.

Every fetch is timeout-bounded and abortable. Each source declares its own timeout, and fetches run on native fetch with AbortSignal.any / AbortSignal.timeout. A source that hangs gets cut off at its deadline and degrades to stale (or error if there's no cached data), so it can't hold the rest of the dashboard hostage. This is the whole reason the fetch(ctx) signature forces you to honor ctx.signal: a source that ignores cancellation is a bug the contract makes hard to write.

Failure is per-panel, never global. Because state is per-source and the UI renders each SourceState independently, one dead endpoint costs you exactly one panel. The other ten keep refreshing. There is no shared failure mode where a single bad API takes down the view.

The result is a dashboard that behaves well precisely when the network doesn't, which, for a tool you run first thing every morning on whatever connection you woke up to, is the only behavior that matters.

Extending it, with the compiler as a guardrail

Adding a new source is four small steps, and the type system walks you through all of them:

  1. Add the config variant to the Zod discriminatedUnion in config/schema.ts.
  2. Write fooSource(cfg): Source<T>.
  3. Wire it into the registry's switch, which maps a config entry to a live source.
  4. Add a panel body under ui/panels/ and register it in bodyFor().

The registry switch ends in a never exhaustiveness guard, so a config kind you forgot to handle is a compile error, not a runtime surprise you discover in production at 7am. Between that and the Zod schema validating config at load time, the two failure classes that plague this kind of tool (malformed config and unhandled source types) are both caught before the dashboard ever renders. You extend the union, and TypeScript tells you everything you still have to do.

Secrets without plaintext in your config

Every config field that holds a credential accepts a secret reference rather than a raw value, resolved by a small reference-resolver in core/secrets.ts:

Reference Resolves to
keychain:NAME OS keychain (service reveille, account NAME)
env:NAME environment variable NAME
cmd:<command> stdout of a shell command
anything else used as a literal value

The default advice is keychain: for anything the dashboard reads unattended, because env: refs only resolve when the variables are actually exported. An env:-based source silently fails when you launch reveille from a bare prompt with no .env loaded, and I'd rather the tool steer you away from that trap than let you rediscover it.

The keychain support (keytar) is an optional native dependency. If it fails to build or load, which native modules love to do, reveille degrades to REVEILLE_SECRET_* environment variables instead of crashing on startup. Same philosophy as the sources: a missing capability should narrow what works, not break the whole thing.

The stack, and why

Concern Choice
Language TypeScript, end-to-end, ESM
Renderer Ink (React for the terminal)
Runtime Node 20.12+ (native fetch, --env-file)
HTTP native fetch + AbortSignal.any/timeout
Config TOML via @iarna/toml, validated with Zod
Cache atomic JSON on disk
Secrets OS keychain via keytar, env-var fallback
Tests vitest

Ink lets me describe the UI as React components and get flexbox layout in a terminal for free, which is what makes the masonry reflow trivial. Leaning on a modern Node runtime meant no HTTP client dependency and no dotenv: native fetch, AbortSignal, and --env-file cover it. And because the sources are pure and rendering-free, the test suite is mostly about the interesting behavior: does a timeout produce stale, does a cache hit hydrate before the network resolves, does the exhaustiveness guard hold.

Using it

npm install     # keytar is optional; a failed native build is non-fatal
npm run dev     # run from source with tsx
Enter fullscreen mode Exit fullscreen mode

On first run with no config, reveille offers to scaffold a starter one at ~/.config/reveille/config.toml (or run reveille init). You get a clock and a headline panel out of the box; edit the config to add sources.

Command Does
reveille Launch the interactive dashboard
reveille init Scaffold a starter config
reveille doctor Validate config and check every credential
reveille login google Authorize Google Calendar; stores a refresh token
reveille --once Print a one-line summary and exit

Press q or Ctrl-C to quit.

Where it's going

The Source<T> contract makes the roadmap mostly a question of "what else do I want on screen at 7am." Because adding a feed is a source plus a panel, the answer is cheap to act on: RSS, CI status, a train departure board, whatever. The --once summary mode also hints at a direction I like: the same sources, rendered as a prompt line, a status bar, a notification. The data layer is decoupled enough that none of those need the dashboard at all.

It's MIT-licensed and built to be the tool I actually reach for, which remains the only spec I trust.

Repo: https://github.com/Keanred/reveille

Top comments (0)