The Proxmox web UI is capable but browser-only, slow to load, and requires a manual browser exception for the self-signed certificate. After using it daily for six months, I decided to build a dedicated native app — one that could sit in the taskbar, auto-refresh every 30 seconds, and eventually run on iOS too.
This is the architecture and key decisions behind MyOfficeLab, a Flutter app targeting Linux desktop (current) and iOS (planned).
What the app does
Nine tabs in the rail: Dashboard, Proxmox (node/CT list + lifecycle ops), Grafana (embedded webview), Open WebUI, Hermes, Wiki, Tailscale status, and an embedded terminal.
Architecture decisions
State management: Riverpod 3 with code generation
Every provider in this app uses @riverpod code generation:
@riverpod
Future<List<PveNode>> nodes(Ref ref) async {
final api = ref.read(proxmoxApiProvider);
return api.getNodes();
}
This gives you:
- Auto-generated
nodesProviderwith proper dispose/cancel behavior .valuefor the successful state (not.valueOrNull— use.valuein Riverpod 3)keepAlive: truefor providers that should outlive their widget (navigation history, cluster stats)
Riverpod 3 changed the generated signature — providers now receive Ref (not AutoDisposeRef or specific typed refs). If you see type errors after upgrading, update any manual provider overrides to accept Ref.
HTTP client: Dio with self-signed SSL bypass
The Proxmox API uses a self-signed certificate. Configure Dio to trust it for known IP ranges:
final dio = Dio();
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
return HttpClient()
..badCertificateCallback = (cert, host, port) =>
host.startsWith('10.25.144.');
};
Never return true unconditionally — scope it to your known lab IP range.
Data models: freezed + json_serializable
Each Proxmox API response maps to a freezed model:
@freezed
abstract class PveNode with _$PveNode {
const factory PveNode({
required String node,
required String status,
@JsonKey(name: 'maxcpu') required int maxCpu,
@JsonKey(name: 'maxmem') required int maxMem,
@JsonKey(name: 'mem') required int usedMem,
@JsonKey(name: 'cpu') required double cpuUsage,
}) = _PveNode;
factory PveNode.fromJson(Map<String, dynamic> json) => _$PveNodeFromJson(json);
}
Run dart run build_runner build --delete-conflicting-outputs after any model change.
Task 1: Set up the Flutter project
flutter create myofficelab_app \
--org com.myofficelab \
--platforms linux,ios \
--description "MyOfficeLab homelab control panel"
Key packages:
dependencies:
flutter_riverpod: ^3.0.0
riverpod_annotation: ^3.0.0
freezed_annotation: ^3.0.0
json_annotation: ^4.9.0
dio: ^5.0.0
flutter_secure_storage: ^9.0.0
fl_chart: ^0.68.0
percent_indicator: ^4.2.3
dev_dependencies:
riverpod_generator: ^3.0.0
freezed: ^3.0.0
build_runner: ^2.4.0webview_flutter has no Linux support. Use webview_cef instead:
dependencies:
webview_cef: ^0.5.1Add the required CEF hooks to linux/runner/main.cc before any other Flutter initialization:
#include "webview_cef/webview_cef_plugin.h"
int main(int argc, char** argv) {
// CEF sub-processes exit here before Flutter initializes
initCEFProcesses(argc, argv);
// ... rest of Flutter main
}webview_cef downloads the Chromium Embedded Framework (~3.1 GB extracted) on the first flutter build linux. This is a one-time download but requires a fast internet connection. Subsequent builds are instant.
Task 2: Build the Proxmox API client
The Proxmox API uses a custom Authorization header format:
class ProxmoxApi {
final Dio _dio;
ProxmoxApi(String host, String tokenId, String tokenSecret) :
_dio = _buildDio(host, tokenId, tokenSecret);
static Dio _buildDio(String host, String tokenId, String tokenSecret) {
final dio = Dio(BaseOptions(
baseUrl: 'https://$host:8006/api2/json',
headers: {
'Authorization': 'PVEAPIToken=$tokenId=$tokenSecret',
},
connectTimeout: const Duration(seconds: 10),
));
// Trust self-signed certs for known lab IPs
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () =>
HttpClient()..badCertificateCallback =
(cert, host, port) => host.startsWith('10.25.144.');
return dio;
}
Future<List<PveNode>> getNodes() async {
final r = await _dio.get('/nodes');
return (r.data['data'] as List)
.map((n) => PveNode.fromJson(n as Map<String, dynamic>))
.toList();
}
}Task 3: Build the dashboard screen
The dashboard sparkline samples cluster CPU average every time the nodesProvider auto-refreshes. Use a keepAlive provider so the history accumulates across tab navigations:
@Riverpod(keepAlive: true)
class ClusterCpuHistory extends _$ClusterCpuHistory {
@override
List<double> build() {
ref.listen(nodesProvider, (prev, next) {
next.whenData((nodes) {
final avg = nodes.isEmpty ? 0.0
: nodes.map((n) => n.cpuUsage).reduce((a, b) => a + b) / nodes.length;
state = [...state.takeLast(29), avg * 100];
});
});
return [];
}
}Plot with fl_chart’s LineChart, but never wrap fl_chart in IntrinsicHeight — it doesn’t implement intrinsic sizing and will throw at runtime. Use a plain Row with CrossAxisAlignment.start instead.
Key design decisions and gotchas
The design system: A cyberpunk-inspired dark palette with #07070E base, neon magenta/electric/lime accents, and ambient animated orbs behind the shell. The navigation rail is translucent, giving every tab the same backdrop — visual consistency without per-tab theming.
CEF webview crash fix: webview_cef 0.5.1 has a bug where injectUserScripts is declared as Map<int, InjectUserScripts?> but initialized with <int, InjectUserScripts>{} (non-nullable). The null check throws when any webview opens. Fix: always pass injectUserScripts: InjectUserScripts() when creating a webview.
AppImage deployment: Building a self-contained Linux AppImage requires bundling libgthread-2.0.so.0 — Debian links to it, but openSUSE Tumbleweed (the target) dropped it. The AppImage build script detects and bundles the missing lib into usr/bin/lib/.
Keyring resilience: flutter_secure_storage on Linux stores all settings as one libsecret item. If the GNOME login keyring is locked (session just started, daemon restart), the app drops to the first-launch gate — even if data is there. The fix is detecting KeyringLocked and showing an unlock prompt rather than treating it as empty.
Running the Linux build
flutter run -d linux
flutter build linux --release
# Bundle as AppImage (after release build)
bash tool/build_appimage.sh
# → /root/MyOfficeLab-x86_64.AppImage
rsync -av --progress /root/MyOfficeLab-x86_64.AppImage user@elitebook:~/Applications/
# Then: chmod +x ~/Applications/MyOfficeLab-x86_64.AppImage
# Create .desktop launcher for taskbar integration
The Flutter app is available for download as an AppImage (Linux x86_64) — see the About page for the link. Source code will be on GitHub once I’ve set up Codemagic for iOS builds.