Skip to content

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

Unraid Monitor

A mobile app for monitoring an Unraid server — array health, disks, Docker containers, and VMs — built with Laravel, Inertia, React, and NativePHP for Mobile.

This is deliberately not a responsive desktop dashboard. If you want the full interface, the Unraid web UI already exists. This is designed for a phone in one hand.

Stack

Layer Choice
Backend Laravel 13, PHP 8.3+
Transport Inertia v3 (inertiajs/inertia-laravel)
Front end React 19, Vite 8
Styling Tailwind CSS 4 (@theme tokens in resources/css/app.css)
Packaging nativephp/mobile v4 (MIT)

Quick start

You need PHP 8.3+, Composer, and Node 20.19+ (Vite 8's floor).

composer run setup

That installs both dependency trees, writes .env, generates an app key, runs the migrations, and builds the front end. The SQLite database is created on first migrate — there is nothing to provision.

Then start it:

composer run dev

Open http://127.0.0.1:8000. There is no login; the first screen asks which server to connect to.

No Unraid server? Skip to Trying it without a server.

Otherwise enter your server address and an API key (Unraid: Settings → Management Access → API Keys).

Trying it without a server

mock/ is a fake Unraid server plus fake media apps, so the whole app can be run end to end without owning the hardware.

composer run mock

Leave it running, and in another terminal start the app with composer run dev. Connect to http://127.0.0.1:8081 with any API key — the mock checks that a key is present, not what it is.

A scenario picks the shape of the server:

composer run mock minimal
Scenario What it is
full (default) Parity, two cache pools, Docker, VMs, a UPS, three media apps
minimal Two data disks. No parity, no cache, no Docker, no VMs, no UPS
degraded A hot disk logging errors, a parity check running, and running on battery
nomedia Docker containers, none of which this app knows how to talk to

minimal is the interesting one: it is a server that has almost nothing, which is the case most likely to be broken and the hardest to own. See mock/README.md for what each scenario is worth looking at, and for the ports each fake app listens on.

Running on a device

NativePHP runs the whole Laravel app on the phone, so the device talks to your Unraid server directly — there's no server to host.

php artisan native:run

Prerequisites, checked with php artisan native:debug:

  • Android — Android Studio, a JDK, and Gradle.
  • iOS — macOS with Xcode. Cannot be built from Linux or WSL.

Set NATIVEPHP_APP_ID in .env before your first build.

Build loop

Two things bite immediately and neither announces itself:

native:run does not build the front end. Run npm run build first or the device silently keeps the previous bundle.

NATIVEPHP_APP_VERSION must be DEBUG during development. NativePHP's AppUpdateManager only re-extracts the Laravel bundle when the version increases, with DEBUG as the one value that always re-extracts. Pin a real version and every rebuild installs a new binary that goes on running the PHP you first shipped — edits appear to do nothing. Set a real version (and bump NATIVEPHP_APP_VERSION_CODE) only when packaging for release.

NATIVEPHP_DEVELOPMENT_TEAM is read by native:install, not native:run. It is written into the Xcode project at install time, so after changing it — or NATIVEPHP_APP_ID — re-run php artisan native:install ios --skip-php.

native:install resets the Info.plist. Run php artisan ios:plist after it. nativephp/ is gitignored and install copies the vendor template over the whole directory, so the permission keys go with it. Without NSFaceIDUsageDescription iOS terminates the app the first time it asks for Face ID, and without NSLocalNetworkUsageDescription every request to the server is dropped — which is all of them. Two of the three keys also live in config('nativephp.permissions') and are reapplied by the next build; the third is nested and cannot be, which is why the command exists. See app/Support/InfoPlist.php. ios:plist --check reports without writing and exits non-zero, for CI.

native:install also restores the escape-hatch gesture. Run php artisan ios:harden after it. NativePHP's template attaches a three-finger swipe to the key window for its Jump demo client; here it is an undocumented gesture that does nothing, which App Review guideline 2.3.1 is about. Same reasoning as above — the vendor template comes back every install, so the fix has to be re-appliable rather than hand-made. Both checks together are one CI gate:

php artisan ios:plist --check && php artisan ios:harden --check

iOS WebView constraints

The app is served to a WKWebView through a custom php://127.0.0.1 URL scheme rather than over HTTP. That origin is not a browser origin, and five of its differences are load-bearing. All five are invisible in a desktop browser, so a change can look completely fine in composer run dev and fail on a phone.

Request bodies are dropped. WebKit does not populate httpBody for an XHR made against a custom scheme, and NativePHP's scheme handler forwards exactly that property. Headers survive — Content-Length still arrives, correct — while the body is gone, so every field fails required. resources/js/lib/nativeBody.js resends the body as base64 headers and App\Http\Middleware\RestoreNativeRequestBody reassembles it. Only XMLHttpRequest is patched, which covers Inertia (axios); a raw fetch() with a body would need the same treatment.

No Referer header. URL::previous() reads Referer first and only falls back to the session, so on device back() always takes the fallback. That makes what gets stored there matter: App\Http\Middleware\StartSession keeps JSON endpoints out of the slot (Laravel's own AJAX guard reads X-Requested-With, which fetch() never sends) and records Inertia page visits, which the framework skips as XHRs but which are the only record of where the user actually is.

A redirect needs a path. url('/') renders as http://127.0.0.1 — authority, no path — and the scheme handler derives the request URI from percentEncodedPath, which is an empty string rather than nil, so its ?? "/" fallback never fires. Laravel is asked to route "", matches nothing, and returns 404 after the request has already done its work. Every redirect to the site root hits this, including route('dashboard'). App\Http\Middleware\NormalizeRedirectPath puts the / back.

Static files are only served under _assets. The handler serves a file from public/ only when the path contains an _assets segment — Laravel's ASSET_URL is set to php://127.0.0.1/_assets/ on device, so asset() is correct and a hand-written absolute path like /media-icons/radarr.svg is not: it falls through to PHP and 404s. The media app logos are inlined into the bundle by resources/js/lib/mediaLogos.js instead, which removes the question.

A PHP response cannot carry binary. The runtime hands the response back to Swift as a NUL-terminated UTF-8 string, so a JPEG is cut at its first zero byte and never decodes — the media grid rendered placeholders while the proxy was returning perfectly good bytes with a correct Content-Type. MediaArtController answers with a base64 data: URI when asked (?data_uri=1), which costs a third more transfer and holds the image in memory rather than the HTTP cache. Anything else proxying bytes through PHP — fonts, downloads — has the same ceiling.

Every request from the WebView is serialised. NativePHP dispatches them onto one queue (PersistentPHPRuntime.dispatchQueue), so N requests are N round trips end to end, each waiting out the last. A poster wall fetched an image at a time took five to ten seconds on a fast connection. Artwork therefore goes out in batches: resources/js/lib/artBatch.js collects the requests a screen makes and sends them to POST /media/art as one, ArtFetcher runs the outbound fetches concurrently inside it, and resources/js/lib/nativeImage.js only asks for artwork within a screen of the viewport. Anything else that would fan out into many small requests on device needs the same shape.

Artwork is cached on disk and asked for at the size it is drawn. App\Support\ArtCache holds fetched bytes under a 64MB LRU cap, so a relaunch paints without touching the network, and MediaArt::url() takes the width the image is rendered at — TMDB gets /t/p/w342/, Servarr gets poster-500.jpg, each with the original as a fallback. Both matter far more than they look on a weak connection: the originals are routinely a megabyte to fill a 120pt slot. The cache lives in the backed-up storage directory, so it goes to iCloud alongside the database.

Reading device state. The simulator's container is on disk under ~/Library/Developer/CoreSimulator/Devices/<udid>/data/Containers/Data/Application/. For a physical device, xcrun devicectl device copy from --device <udid> --domain-type appDataContainer --domain-identifier <app id> --source <path> pulls files out — Library/Application Support/storage/logs/laravel.log and Library/Application Support/storage/inertia-devtools/ between them answer most questions, the latter recording real request and response headers.

Architecture notes

Per-screen queries. UnraidQueries defines one GraphQL document per screen. An earlier version fetched hardware inventory, every disk's SMART data, Docker, and VMs on every page load; on a phone that round-trip is the dominant cost. The Docker tab no longer asks for disk temperatures.

Presenters shape props. Controllers stay thin. app/Presenters/* turn raw GraphQL into exactly what a screen renders, so formatting has one home (App\Support\Format) and React components stay dumb.

Screens are gated on what the server has. Not every Unraid box has parity, a cache pool, Docker, VMs or a UPS, and a tab leading to a permanently empty screen reads as a fault rather than as an absence. App\Support\Feature describes each feature, ServerFeatures establishes whether it is present — concluding absence only from an answer, never from an unreachable server — and the feature: middleware keeps deep links to missing subsystems out of reach. Detection is free: it reads the bundle the dashboard already fetches.

Gating something away would make it undiscoverable, so Setup lists every feature whether present or not, with the reason it is missing and, where there is one, the way to get it. Power is the deliberate exception to the gate: /ups always opens, because its empty state names the setting to change (apcupsd or NUT), which is more use than Setup repeating that no UPS is attached.

Container icons. Unraid's dockerMan writes net.unraid.docker.icon onto containers it creates from a template (6.10+). App\Support\ContainerIcon reads it, resolves server-relative paths against the base URL, and discards anything that isn't plain http(s) — the value is container metadata, not our input. labels doesn't exist on every API version, so UnraidApi::docker() retries without it rather than letting one unknown field empty the screen. Icons pointing at dead CDN links fall back to a generic tile client-side.

Media apps use one capability-shaped adapter. Docker → Media apps can connect multiple Sonarr, Radarr, Lidarr, Readarr, Prowlarr, qBittorrent, Overseerr, Plex, Tautulli, Bazarr, Jellyfin, Emby, SABnzbd, and Transmission instances. App\Support\ArrType describes each app's API generation and capabilities, while MediaClientFactory selects the appropriate adapter. The screen normalises health, queue/request totals, transfer speeds, upcoming media, detailed queues, wanted items, indexers, active streams, and recently added media where each service supports them. Overseerr search, request creation and request management and safe qBittorrent torrent controls are protected by the same server-side biometric gate as Docker actions. A pipeline and companion-container drawer make Recyclarr, Notifiarr, FlareSolverr, Frigate, reverse proxies, and related helpers visible without pretending they share one stable control API. Missing endpoints are an unavailable capability, not a failed screen. Summary reads are cached for 30 seconds, and one unreachable app does not hide the others. Credentials use SecureStore and are removed with their app or owning server profile.

The media command center works across apps. Its order and visible sections are saved per server on-device. Universal search combines Overseerr discovery with Sonarr, Radarr, Lidarr, and Readarr lookup; the calendar merges monitored releases; and the library browser exposes posters, monitoring state, Sonarr seasons/episodes, add options, automatic actions, and interactive Sonarr/Radarr release search with custom-format scores and rejection reasons. Manual grabs, magnet adds, file priorities, torrent categories/tags/speed limits, and all other remote writes remain confirmation-gated. Optional connection adapters cover Bazarr, Jellyfin, Emby, SABnzbd, and Transmission.

Remote access is route-aware. A profile can try its LAN URL first and fall back to an alternate Tailscale or reverse-proxy URL. Media apps support custom HTTP headers stored beside credentials in SecureStore, never in their database row. Wake-on-LAN can broadcast to a configured subnet. Unraid log browsing uses the API's allow-listed logFiles paths, so a user chooses a server-provided name rather than sending an arbitrary filesystem path. The installed Unraid API currently exposes host logs, not individual container stdout.

Failure is a state, not a crash. An unreachable server renders the screen with a loadError prop and a retry button — the shell and tab bar stay up. Docker and VMs being disabled is treated as an empty list, not an error, since plenty of servers don't run them. App\Support\ConnectionError maps transport exceptions to readable text so cURL codes never reach the UI.

Screens are instant, then correct. resources/js/lib/screenCache.js keeps the props each screen last rendered with, and FastLink hands them to Inertia's instant navigation so a tab paints from the last known state while the response is in flight. warmScreens.js fetches the screens you haven't opened yet shortly after launch — code first, since a screen can't paint from a snapshot until its chunk has loaded. Snapshots are per-server and expire, and a warm-up is never allowed to consume a flash message meant for you (PreserveFlashInBackgroundRequests), which covers both the warm-up and the five-second poll.

History has a Netdata upgrade path. Without Netdata, MetricHistory folds whatever the open screens see into five-minute windows on the device. When a Netdata Agent is available on the Unraid host, System → History reads its CPU, memory, disk I/O, network, and sensor retention, and Docker rows can load their own CPU, memory, disk, network, and throttling charts. Anomaly samples are marked directly on the graphs, while Netdata health transitions have a read-only view in Alerts. The Agent is auto-discovered on port 19999; Settings supports a manual URL and a bearer token stored in SecureStore. Nothing runs in the background and missing collectors fall back cleanly.

History becomes intelligence, not chart clutter. Smart Overview calls out current pressure and locally forecasts array capacity without adding Netdata requests to the frequently-polled dashboard. System → Insights turns a week of retention into a health score, day-over-day personal baselines, correlated anomaly incidents, an alert timeline, and a weekly summary. A capability-aware Tools view detects block-device, network, cgroup, journal, and SMART coverage; privileged data stays unavailable until the container explicitly exposes it. Loaded Docker history also receives a 24-hour health profile.

Controls are discovered, not assumed. Unraid's mutations move between releases, so ServerCapabilities introspects the connected server once an hour and ServerActions decides which of those the app is willing to show. A server with introspection disabled reports nothing and simply keeps the controls it always had.

Polling, not meta-refresh. usePolling refreshes props every 5s via router.reload, preserving scroll and component state. It stops when the app is backgrounded (visibilitychange) so it doesn't drain battery, and never stacks requests. Pull-to-refresh is in usePullToRefresh.

Mobile CSS that matters. Safe-area insets (pt-safe / pb-safe) keep content clear of the notch and home indicator; 16px inputs stop iOS zooming on focus; overscroll-behavior stops the document rubber-banding; only the middle pane scrolls, so the browser's own chrome never appears.

Tests

php artisan test     # PHP: screens, props, actions, history, the biometric gate
npm run test:js      # the client-side screen cache, on Node's own test runner

Tests run against an in-memory SQLite database and never touch your .env database or your stored credentials, so they are safe to run on a working install.

Covers connection storage and validation, per-screen prop shaping, disk error escalation, container sorting, the disabled-service path, feature detection and gating, unreachable-server handling, and a regression test that the API key is never sent to the front end.

Security notes

  • The API key is never shared as an Inertia prop — only the hostname is, for display.
  • Keys go through App\Services\SecureStore, which prefers the device Keychain / Keystore and falls back to a Laravel-encrypted file outside the database. On a stock build the fallback is what runs, because SecureStorage is a paid NativePHP plugin (nativephp/mobile-secure-storage) and without it the iOS runtime registers no SecureStorage.Get/Set handler at all — set() returns false, get() returns null, and nothing raises. Being on a device is therefore not sufficient evidence of a keychain, so native() round-trips a canary through the bridge and only trusts it if the value comes back. Install the plugin and it upgrades to hardware-backed storage with no code change. See App\Support\NativeDevice for why the obvious platform checks aren't enough on their own.
  • The fallback is better protected than "a file on disk" suggests: NativePHP keeps APP_KEY itself in the iOS Keychain, so the credential file is encrypted at rest with a Keychain-held key inside the app sandbox. It lives under Library/Application Support/storage, not in the app bundle, so a DEBUG re-extraction does not wipe it.
  • Certificates are verified unless a server has been explicitly marked as self-signed during setup, which pins that choice to that one profile.
  • Anything that changes the server can be put behind Face ID or a fingerprint (Settings → Require biometrics for actions). Enforcement is middleware on the routes, not a check each screen has to remember, and one confirmation authorises one action.
  • /connect is rate limited to 10 requests/minute, container and discovered actions to 30.

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages