No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Julian Imhof dcedd3253a
All checks were successful
Build and Publish APK / build (push) Successful in 3m47s
fix: expire the schema cache instead of keeping it for the client's life
The module UI schema was cached until the app process died, so a
server-side change — a module updated in place, or a thunk-registered
schema reshaping itself — kept rendering as yesterday's page for as long
as the app stayed alive. The cache now expires after a minute, which
keeps navigation inside a module free while a redeploy shows up promptly;
a refetch that fails falls back to the stale copy, because an old page
beats an error where a page used to be.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 22:01:10 +02:00
.forgejo/workflows docs: hedge the last unmeasured cancellation clause, and scope the CRLF trap 2026-09-02 12:55:50 +02:00
app fix: expire the schema cache instead of keeping it for the client's life 2026-09-02 22:01:10 +02:00
gradle build: spotless import-order check, the rule review kept enforcing by hand 2026-08-13 23:18:41 +02:00
.gitattributes docs: the CRLF rule an existing clone cannot see, and the binaries not tracked yet 2026-08-24 16:41:01 +02:00
.gitignore feat(push): FCM-triggered local alert notifications 2026-07-17 11:45:01 +02:00
build.gradle.kts build: make the import rule the repo's, not :app's 2026-08-24 16:43:41 +02:00
CLAUDE.md docs: hedge the last unmeasured cancellation clause, and scope the CRLF trap 2026-09-02 12:55:50 +02:00
gradle.properties feat: native android client for opsdeck 2026-07-15 14:42:24 +02:00
gradlew feat: native android client for opsdeck 2026-07-15 14:42:24 +02:00
gradlew.bat build: run the gates where review looks, and enforce the LF the build assumed 2026-08-23 22:39:53 +02:00
README.md fix(schema): forms join the run lock, the watchdog stops being one-shot 2026-08-12 21:57:27 +02:00
settings.gradle.kts feat: native android client for opsdeck 2026-07-15 14:42:24 +02:00

OpsDeck Mobile

Native Android client for OpsDeck — Kotlin + Jetpack Compose (Material 3), no cross-platform layer, fully module-agnostic.

How it renders modules

The app contains zero module-specific code. Every module — built-in or third-party — registers a portable UI schema on the server (ctx.ui.register(...), see core/docs/modules.md), and the app renders it natively:

  1. GET /api/core/modules — discover enabled modules (hasUi flag)

  2. GET /api/core/modules/<name>/ui — fetch the module's page schema

  3. Blocks (fields, table, list, chart, usage, tree, logText, actions, form) are rendered with native Compose components; data binds by dot-path into the module's REST responses under /api/mod/<name>/…

  4. Charts combine /api/core/metrics/query history with live-channel ticks; pages re-fetch on the schema's refresh.topics (pushed over the live channel; intervalSec covers channel downtime); logText blocks with a follow topic stream appends from a live-channel provider (e.g. docker log tails); actions POST with optional confirmation and show the work's progress. A repeated key REPLACES that entity's row instead of appending, which is what keeps a multi-GB image pull from filling the card with thousands of near-identical frames; events without one are plain text.

    An action's progress arrives one of two ways, and the RESPONSE decides which — no schema field selects it:

    • { runId } — the route started a host-owned task run and the work outlives the request. The app follows core:task:<runId>, a retained topic: the snapshot is the whole run so far, deltas follow it, and a reconnect replays the gap. The run ends when its status is terminal, so backgrounding the app and returning resumes rather than loses it, and following a run that already finished works the same as one just started. Fields a viewer may not see arrive empty, named in withheld; the progress card names them rather than reading as a silent run.
    • a schema progressTopic (deprecated) — at-most-once {msg} frames whose end is inferred from a line starting "done" or "error". Kept for servers and modules that have not migrated.
  5. Detail pages (stack/:project) are navigated from list links with param interpolation; unknown block types from newer servers degrade to a hint instead of failing the page

A new module dropped onto the server appears in the app with zero app changes.

Server contract

  • Auth: OIDC browser login only (opaque opsdeck_session cookie, no token endpoint). The app opens /auth/login?app_challenge=<S256> in a Custom Tab (system browser, so passkeys work); the server redirects to opsdeck://auth?code=… and the app trades code + PKCE verifier for the session id at POST /auth/app-exchange, then attaches it natively. 401 anywhere returns to login. OPSDECK_AUTH=disabled skips login.
  • Every non-GET carries x-opsdeck-csrf: 1.
  • One shared WebSocket to the live push channel at /api/core/live: per-topic subscriptions with acks and a heartbeat, held only while a screen collects the topic — the server produces data only for what is actually visible. Delivery is at-most-once: after a reconnect (exponential backoff) every subscription re-fetches its REST baseline. Requires a server with the live channel (core ≥ the version that ships /api/core/live); there is no SSE fallback.
  • Alert push (optional, admins): content-free FCM data message wakes the app, which fetches /api/core/notifications and builds local system notifications — alert content never passes through Google. No Firebase config is compiled in: the app fetches it from /api/core/push/config after admin login and initializes Firebase at runtime; servers without push configured fall back to live-channel/poll updates while the app is open. Setup (server-side only): core/docs/push-notifications.md.
  • System info (optional, admins): GET /api/core/system backs the unlisted System screen — versions, runtime facts, and which modules loaded versus why they did not. It is reached by tapping the server URL in Settings five times, and then takes over the navbar's module slot for as long as it is open — the same way the web shell shows it in the sidebar only while active. Servers without the endpoint answer 404 and the screen says so. Web equivalent: /system.

Build

./gradlew assembleDebug          # -> app/build/outputs/apk/debug/app-debug.apk
./gradlew installDebug

Requires JDK 17+ and Android SDK platform 36 (local.properties, not committed).

Development against a local server

cd ../core
OPSDECK_AUTH=disabled OPSDECK_FAKE_DATA=1 OPSDECK_MODULES=hardware,zfs,docker,demo deno task start

App server URL: http://10.0.2.2:8080 (emulator) or http://<lan-ip>:8080 (device). Cleartext HTTP is allowed in the manifest for the self-hosted/LAN case.

Architecture

data/
  model/        core DTOs (User, ModuleInfo, Notification, metrics) +
                Ui.kt — schema models (polymorphic blocks, unknown-safe)
  JsonPath.kt   dot-path resolution into arbitrary module JSON
  net/          ApiClient (cookie/CSRF interceptor, generic moduleGet/
                modulePost/moduleUi), Retrofit core API, LiveClient
                (live push channel WebSocket: topic subs, acks, resync)
  SettingsRepository   DataStore: server URL, session cookie, push config,
                chart range, update registry, notification markers
ui/
  AppViewModel  root state machine: NeedsServer -> NeedsLogin -> Ready
  nav/          RootNav + MainScaffold (static tabs: Home, last-used
                module, Alerts for admins, Settings)
push/         FCM wake-up handling, local alert notifications (per-level
              channels, grouped, dismiss action syncing back to the server)
  schema/       SchemaViewModel (page match, data fetch, live refresh,
                log follow, charts, actions) + SchemaScreen (all block
                renderers)
  common/       LineChart (Canvas), formatters incl. schema formats, cards
  home/ notifications/ settings/ setup/ login/   core screens