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.
| 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) |
You need PHP 8.3+, Composer, and Node 20.19+ (Vite 8's floor).
composer run setupThat 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 devOpen 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).
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 mockLeave 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.
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:runPrerequisites, 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.
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 --checkThe 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.
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.
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 runnerTests 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.
- 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, becauseSecureStorageis a paid NativePHP plugin (nativephp/mobile-secure-storage) and without it the iOS runtime registers noSecureStorage.Get/Sethandler at all —set()returnsfalse,get()returnsnull, and nothing raises. Being on a device is therefore not sufficient evidence of a keychain, sonative()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. SeeApp\Support\NativeDevicefor 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_KEYitself in the iOS Keychain, so the credential file is encrypted at rest with a Keychain-held key inside the app sandbox. It lives underLibrary/Application Support/storage, not in the app bundle, so aDEBUGre-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.
/connectis rate limited to 10 requests/minute, container and discovered actions to 30.