Building a Native Proxmox Dashboard in Flutter: Linux Desktop + iOS

How I built MyOfficeLab — a Flutter app with native Proxmox API integration, live CT metrics, Riverpod 3 state management, CEF webviews, and a cyberpunk design system — targeting both Linux desktop and iOS from one codebase.

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

<rect x="28" y="34" width="44" height="38" rx="8" fill="#14b8a620"/> <text x="50" y="52" text-anchor="middle" fill="#14b8a6" font-size="14">⬡</text> <text x="50" y="66" text-anchor="middle" fill="#14b8a6" font-size="7" font-family="monospace">Home</text> <rect x="28" y="79" width="44" height="38" rx="8" fill="none"/> <text x="50" y="97" text-anchor="middle" fill="#6b7280" font-size="14">▦</text> <text x="50" y="111" text-anchor="middle" fill="#6b7280" font-size="7" font-family="monospace">Proxmox</text> <rect x="28" y="124" width="44" height="38" rx="8" fill="none"/> <text x="50" y="142" text-anchor="middle" fill="#6b7280" font-size="14">⟊</text> <text x="50" y="156" text-anchor="middle" fill="#6b7280" font-size="7" font-family="monospace">Grafana</text> <rect x="28" y="169" width="44" height="38" rx="8" fill="none"/> <text x="50" y="187" text-anchor="middle" fill="#6b7280" font-size="14">◈</text> <text x="50" y="201" text-anchor="middle" fill="#6b7280" font-size="7" font-family="monospace">Ollama</text> <rect x="28" y="214" width="44" height="38" rx="8" fill="none"/> <text x="50" y="232" text-anchor="middle" fill="#6b7280" font-size="14">⬡</text> <text x="50" y="246" text-anchor="middle" fill="#6b7280" font-size="7" font-family="monospace">Term</text> Dashboard <rect x="90" y="65" width="120" height="80" rx="8" fill="#111118" stroke="#1f2937" stroke-width="1"/> <text x="150" y="84" text-anchor="middle" fill="#a1a1aa" font-size="9" font-family="monospace">pvelab01</text> <text x="150" y="108" text-anchor="middle" fill="#14b8a6" font-size="22" font-family="monospace" font-weight="bold">18%</text> <text x="150" y="124" text-anchor="middle" fill="#71717a" font-size="8" font-family="monospace">CPU · RAM 62%</text> <circle cx="198" cy="72" r="4" fill="#22c55e"/> <rect x="230" y="65" width="120" height="80" rx="8" fill="#111118" stroke="#1f2937" stroke-width="1"/> <text x="290" y="84" text-anchor="middle" fill="#a1a1aa" font-size="9" font-family="monospace">pvelab02</text> <text x="290" y="108" text-anchor="middle" fill="#14b8a6" font-size="22" font-family="monospace" font-weight="bold">34%</text> <text x="290" y="124" text-anchor="middle" fill="#71717a" font-size="8" font-family="monospace">CPU · RAM 78%</text> <circle cx="338" cy="72" r="4" fill="#22c55e"/> <rect x="370" y="65" width="120" height="80" rx="8" fill="#111118" stroke="#1f2937" stroke-width="1"/> <text x="430" y="84" text-anchor="middle" fill="#a1a1aa" font-size="9" font-family="monospace">pvelab03</text> <text x="430" y="108" text-anchor="middle" fill="#14b8a6" font-size="22" font-family="monospace" font-weight="bold">12%</text> <text x="430" y="124" text-anchor="middle" fill="#71717a" font-size="8" font-family="monospace">CPU · RAM 55%</text> <circle cx="478" cy="72" r="4" fill="#22c55e"/> <rect x="510" y="65" width="120" height="80" rx="8" fill="#111118" stroke="#1f2937" stroke-width="1"/> <text x="570" y="84" text-anchor="middle" fill="#a1a1aa" font-size="9" font-family="monospace">pvelab04</text> <text x="570" y="108" text-anchor="middle" fill="#14b8a6" font-size="22" font-family="monospace" font-weight="bold">9%</text> <text x="570" y="124" text-anchor="middle" fill="#71717a" font-size="8" font-family="monospace">CPU · RAM 40%</text> <circle cx="618" cy="72" r="4" fill="#22c55e"/> Grafana Alerts● Normal · All clearCluster CPU 30m

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 nodesProvider with proper dispose/cancel behavior
  • .value for the successful state (not .valueOrNull — use .value in Riverpod 3)
  • keepAlive: true for providers that should outlive their widget (navigation history, cluster stats)
Riverpod 3 breaking change: Ref in provider signatures

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

1Create the Flutter app with desktop target5 min
Create Flutter project with Linux target

flutter create myofficelab_app \
--org com.myofficelab \
--platforms linux,ios \
--description "MyOfficeLab homelab control panel"
2Add dependencies to pubspec.yaml5 min

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.0
3Configure CEF webview for Linux10 min

webview_flutter has no Linux support. Use webview_cef instead:

dependencies:
  webview_cef: ^0.5.1

Add 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
}
First build downloads 330 MB

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

1Create ProxmoxApi with PVE token authentication15 min

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

1Create the cluster CPU sparkline with 30s sampling15 min

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

Development run

flutter run -d linux
Production AppImage build

flutter build linux --release
# Bundle as AppImage (after release build)
bash tool/build_appimage.sh
# → /root/MyOfficeLab-x86_64.AppImage
Deploy to another Linux machine

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.