diff --git a/lib/src/app.dart b/lib/src/app.dart index c5f6086..45e18c0 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:pi_dashboard/src/proxmox_lister/proxmox_lister_list_view.dart'; import 'settings/settings_controller.dart'; @@ -51,9 +50,9 @@ class MyApp extends StatelessWidget { case SettingsView.routeName: return SettingsView(controller: settingsController); case ProxmoxListerView.routeName: - return ProxmoxListerView(); + return ProxmoxListerView(settings: settingsController); default: - return ProxmoxListerView(); + return ProxmoxListerView(settings: settingsController); } }, ); diff --git a/lib/src/proxmox_lister/proxmox_lister_list_view.dart b/lib/src/proxmox_lister/proxmox_lister_list_view.dart index d313585..a4839bb 100644 --- a/lib/src/proxmox_lister/proxmox_lister_list_view.dart +++ b/lib/src/proxmox_lister/proxmox_lister_list_view.dart @@ -1,17 +1,70 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:pi_dashboard/src/proxmox_webservice/model.dart'; +import 'package:pi_dashboard/src/proxmox_webservice/service.dart'; +import 'package:pi_dashboard/src/settings/settings_controller.dart'; +import 'package:pi_dashboard/src/settings/settings_service.dart'; import '../settings/settings_view.dart'; import 'vm_card.dart'; -import 'proxomx_vm.dart'; -class ProxmoxListerView extends StatelessWidget { - ProxmoxListerView({ +class ProxmoxListerView extends StatefulWidget { + const ProxmoxListerView({ super.key, - this.vms = const [ProxmoxVM(id: 1, name: "template")], + required this.settings, }); static const routeName = '/proxmox_lister'; - List vms; + final SettingsController settings; + + @override + State createState() => _ProxmoxListerState(); +} + +class _ProxmoxListerState extends State { + late Future nodes; + late SettingsController settings; + + late ProxmoxWebService _service; + + @override + void initState() { + super.initState(); + settings = super.widget.settings; + + nodes = Future.delayed(Duration.zero, () => getVms()); + + Timer.periodic(const Duration(seconds: 3), (_) { + syncVMs(); + }); + } + + Future getVms() async { + await settings.loadSettings(); + _service = ProxmoxWebService( + hostname: settings.hostname, + username: settings.username, + password: settings.password, + ); + final success = await _service.authenticate(); + if (!success) { + return Future.error(Exception("couldn't authenticate against Proxmox")); + } + + ProxmoxNodeMap map = {}; + final nodes = await _service.listNodes(); + for (final node in nodes) { + map[node] = await _service.listVms(node.node); + } + return map; + } + + void syncVMs() async { + nodes = getVms(); + await nodes; + setState(() {}); + } @override Widget build(BuildContext context) { @@ -22,23 +75,47 @@ class ProxmoxListerView extends StatelessWidget { IconButton( icon: const Icon(Icons.sync), onPressed: () { - Navigator.restorablePushNamed(context, SettingsView.routeName); + syncVMs(); }, ), IconButton( icon: const Icon(Icons.settings), - onPressed: () {}, + onPressed: () { + Navigator.restorablePushNamed(context, SettingsView.routeName); + }, ), ], ), - body: ListView.builder( - restorationId: "proxmoxVMLister", - itemCount: vms.length, - itemBuilder: (BuildContext ctx, int index) { - final vm = vms[index]; + body: FutureBuilder( + future: nodes, + builder: (ctx, snapshot) { + if (snapshot.hasData) { + if (snapshot.requireData.isEmpty) { + return const Center(child: Icon(Icons.block)); + } - return ProxmoxVmCard(vm: vm); + final nodeEntry = snapshot.requireData.entries.first; + return ListView.builder( + restorationId: "proxmoxVMLister", + itemCount: nodeEntry.value.length, + itemBuilder: (BuildContext ctx, int index) { + return ProxmoxVmCard( + node: nodeEntry.key, + vm: nodeEntry.value[index], + pm_service: _service, + ); + }, + ); + } else if (snapshot.hasError) { + return AlertDialog( + title: const Text("error"), + content: Text(snapshot.error.toString()), + ); + } else { + return const Center(child: CircularProgressIndicator()); + } }, )); } + } diff --git a/lib/src/proxmox_lister/proxomx_vm.dart b/lib/src/proxmox_lister/proxomx_vm.dart deleted file mode 100644 index 99879d6..0000000 --- a/lib/src/proxmox_lister/proxomx_vm.dart +++ /dev/null @@ -1,12 +0,0 @@ - -class ProxmoxVM { - const ProxmoxVM({ - this.id = 0, - this.name = "", - this.isRunning = true, - }); - - final int id; - final String name; - final bool isRunning; -} \ No newline at end of file diff --git a/lib/src/proxmox_lister/vm_card.dart b/lib/src/proxmox_lister/vm_card.dart index 2782111..52e5dfd 100644 --- a/lib/src/proxmox_lister/vm_card.dart +++ b/lib/src/proxmox_lister/vm_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:pi_dashboard/src/proxmox_lister/proxomx_vm.dart'; +import 'package:pi_dashboard/src/proxmox_webservice/model.dart'; +import 'package:pi_dashboard/src/proxmox_webservice/service.dart'; class RunningIndicator extends StatelessWidget { const RunningIndicator({ @@ -23,10 +24,14 @@ class RunningIndicator extends StatelessWidget { class ProxmoxVmCard extends StatelessWidget { ProxmoxVmCard({ super.key, - this.vm = const ProxmoxVM(), + required this.node, + required this.vm, + required this.pm_service, }); - final ProxmoxVM vm; + final ProxmoxNode node; + final ProxmoxVm vm; + final ProxmoxWebService pm_service; @override Widget build(_) { @@ -37,16 +42,19 @@ class ProxmoxVmCard extends StatelessWidget { leading: const Icon(Icons.dns), title: Row( children: [ - Text(vm.name), + Text(vm.name ?? ""), const Spacer(), - RunningIndicator(isRunning: vm.isRunning), + RunningIndicator(isRunning: vm.status == "running"), ] ), subtitle: Row( children: [ - Text("ID: ${vm.id.toString()}"), + Text("ID: ${vm.vmid.toString()}"), const Spacer(), - TextButton(onPressed: () => {}, child: const Icon(Icons.power_settings_new)), + IconButton( + icon: const Icon(Icons.power_settings_new), + onPressed: () => pm_service.toggleVm(node, vm), + ), ], ), ), diff --git a/lib/src/proxmox_webservice/model.dart b/lib/src/proxmox_webservice/model.dart new file mode 100644 index 0000000..1292c6f --- /dev/null +++ b/lib/src/proxmox_webservice/model.dart @@ -0,0 +1,157 @@ +import 'dart:ffi'; + +typedef ProxmoxNodeMap = Map>; + +class ProxmoxNode { + final String node; + final String status; + final double? cpu; + final String? level; + final int? maxcpu; + final int? maxmem; + final int? mem; + final String? sslFingerprint; + final int? uptime; + final String? id; + final String? type; + final int? disk; + + ProxmoxNode({ + required this.node, + required this.status, + this.cpu, + this.level, + this.maxcpu, + this.maxmem, + this.mem, + this.sslFingerprint, + this.uptime, + this.id, + this.type, + this.disk, + }); + + factory ProxmoxNode.fromJson(Map json) { + return switch (json) { + { + 'node': String node, + 'status': String status, + 'cpu': double? cpu, + 'level': String? level, + 'maxcpu': int? maxcpu, + 'maxmem': int? maxmem, + 'mem': int? mem, + 'ssl_fingerprint': String? sslFingerprint, + 'uptime': int? uptime, + 'id': String? id, + 'type': String? type, + 'disk': int? disk, + } => + ProxmoxNode( + node: node, + status: status, + cpu: cpu, + level: level, + maxcpu: maxcpu, + maxmem: maxmem, + mem: mem, + sslFingerprint: sslFingerprint, + uptime: uptime, + id: id, + type: type, + disk: disk, + ), + _ => throw FormatException( + "failed to parse proxmox node into object: $json"), + }; + } +} + +class ProxmoxVm { + final String status; + final num vmid; + final num? cpus; + final num? mem; + final String? name; + final num? diskwrite; + final num? netout; + final num? uptime; + final num? cpu; + final num? maxdisk; + final num? netin; + final num? diskread; + final num? disk; + final num? maxmem; + + ProxmoxVm({ + required this.status, + required this.vmid, + this.cpus, + this.mem, + this.name, + this.diskwrite, + this.netout, + this.uptime, + this.cpu, + this.maxdisk, + this.netin, + this.diskread, + this.disk, + this.maxmem, + }); + + factory ProxmoxVm.fromJson(Map json) { + return ProxmoxVm( + status: json['status'], + vmid: json['vmid'], + cpus: json['cpus'], + mem: json['mem'], + name: json['name'], + diskwrite: json['diskwrite'], + netout: json['netout'], + uptime: json['uptime'], + cpu: json['cpu'], + maxdisk: json['maxdisk'], + netin: json['netin'], + diskread: json['diskread'], + disk: json['disk'], + maxmem: json['maxmem'], + ); + return switch (json) { + { + 'status': String status, + 'vmid': num vmid, + 'cpus': num? cpus, + 'mem': num? mem, + 'name': String? name, + 'diskwrite': num? diskwrite, + 'netout': num? netout, + 'uptime': num? uptime, + 'cpu': num? cpu, + 'maxdisk': num? maxdisk, + 'netin': num? netin, + 'diskread': num? diskread, + 'disk': num? disk, + 'maxmem': num? maxmem, + } => + ProxmoxVm( + status: status, + vmid: vmid, + cpus: cpus, + mem: mem, + name: name, + diskwrite: diskwrite, + netout: netout, + uptime: uptime, + cpu: cpu, + maxdisk: maxdisk, + netin: netin, + diskread: diskread, + disk: disk, + maxmem: maxmem, + ), + _ => throw FormatException( + "failed to parse proxmox node into object: $json"), + }; + } +} diff --git a/lib/src/proxmox_webservice/service.dart b/lib/src/proxmox_webservice/service.dart index 42db019..b40d388 100644 --- a/lib/src/proxmox_webservice/service.dart +++ b/lib/src/proxmox_webservice/service.dart @@ -1,3 +1,8 @@ +import 'dart:convert' as convert; + +import 'package:http/http.dart' as http; +import 'model.dart'; + class ProxmoxWebService { ProxmoxWebService({ this.hostname = "", @@ -9,8 +14,114 @@ class ProxmoxWebService { final String username; final String password; - Future authenticate() async { - + String? _csrfToken; + String? _ticket; + + bool _authenticated = false; + + Future authenticate() async { + final resp = await http.post( + Uri.https( + hostname, + "/api2/json/access/ticket", + { + 'username': username, + 'password': password, + }, + ), + ); + + if (resp.statusCode == 200) { + final body = convert.jsonDecode(resp.body) as Map; + final data = body["data"] as Map; + _csrfToken = data["CSRFPreventionToken"]; + _ticket = data["ticket"]; + + _authenticated = true; + } else { + _authenticated = false; + } + + return _authenticated; + } + + Future?> _doGet(String endpoint, {debug = false}) async { + if (!_authenticated) return null; + final resp = await http.get( + Uri.https( + hostname, + endpoint, + ), + headers: { + "CSRFPreventionToken": _csrfToken as String, + "Cookie": "PVEAuthCookie=$_ticket" + }); + if (debug) print(resp.body); + + if (resp.statusCode != 200) return null; + + return convert.jsonDecode(resp.body) as Map; + } + + Future?> _doPost( + String endpoint, Map payload, + {debug = false}) async { + if (!_authenticated) return null; + + final resp = await http.post( + Uri.https( + hostname, + endpoint, + ), + headers: { + "CSRFPreventionToken": _csrfToken as String, + "Cookie": "PVEAuthCookie=$_ticket" + }, + body: payload, + ); + if (debug) print(resp.body); + + if (resp.statusCode != 200) return null; + + return convert.jsonDecode(resp.body) as Map; + } + + Future> listNodes() async { + List nodes = []; + + final resp = await _doGet("/api2/json/nodes"); + if (resp == null) return []; + + for (final nodeJson in resp["data"]) { + nodes.add(ProxmoxNode.fromJson(nodeJson as Map)); + } + + return nodes; + } + + Future> listVms(String node) async { + List vms = []; + final resp = await _doGet("/api2/json/nodes/$node/qemu"); + if (resp == null) return []; + + for (final vmJson in resp["data"]) { + vms.add(ProxmoxVm.fromJson(vmJson as Map)); + } + + vms.sort((a, b) => a.vmid.compareTo(b.vmid)); + return vms; + } + + Future toggleVm(ProxmoxNode node, ProxmoxVm vm) async { + if (!_authenticated) return false; + + final endpoint = + "/api2/json/nodes/${node.node}/qemu/${vm.vmid}/status/${vm.status == "running" ? "stop" : "start"}"; + + final resp = await _doPost(endpoint, {}, debug: true); + if (resp == null) return false; + + return true; } } \ No newline at end of file diff --git a/lib/src/settings/settings_service.dart b/lib/src/settings/settings_service.dart index 28f7d5e..056c5d4 100644 --- a/lib/src/settings/settings_service.dart +++ b/lib/src/settings/settings_service.dart @@ -31,11 +31,6 @@ class SettingsService { } Future setKeyStr(String key, String value) async { final SharedPreferences prefs = await SharedPreferences.getInstance(); - final res = await prefs.setString(key, value); - if(res) { - print("successfully saved"); - print(key); - print(value); - } + await prefs.setString(key, value); } } diff --git a/lib/src/settings/settings_view.dart b/lib/src/settings/settings_view.dart index 353569a..51b6680 100644 --- a/lib/src/settings/settings_view.dart +++ b/lib/src/settings/settings_view.dart @@ -1,6 +1,4 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:pi_dashboard/src/settings/settings_service.dart'; import 'settings_controller.dart'; @@ -9,75 +7,72 @@ import 'settings_controller.dart'; /// When a user changes a setting, the SettingsController is updated and /// Widgets that listen to the SettingsController are rebuilt. class SettingsView extends StatelessWidget { - const SettingsView({super.key, required this.controller}); +const SettingsView({super.key, required this.controller}); - static const routeName = '/settings'; +static const routeName = '/settings'; - final SettingsController controller; +final SettingsController controller; - @override +@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Settings'), ), body: Padding( - padding: const EdgeInsets.all(16), - // Glue the SettingsController to the theme selection DropdownButton. - // - // When a user selects a theme from the dropdown list, the - // SettingsController is updated, which rebuilds the MaterialApp. - child: Column(children: [ - DropdownButton( - // Read the selected themeMode from the controller - value: controller.themeMode, - // Call the updateThemeMode method any time the user selects a theme. - onChanged: controller.updateThemeMode, - items: const [ - DropdownMenuItem( - value: ThemeMode.system, - child: Text('System Theme'), + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const Text("Config file is stored under \$HOME/.local/share"), + DropdownButton( + value: controller.themeMode, + onChanged: controller.updateThemeMode, + items: const [ + DropdownMenuItem( + value: ThemeMode.system, + child: Text('System Theme'), + ), + DropdownMenuItem( + value: ThemeMode.light, + child: Text('Light Theme'), + ), + DropdownMenuItem( + value: ThemeMode.dark, + child: Text('Dark Theme'), + ) + ], ), - DropdownMenuItem( - value: ThemeMode.light, - child: Text('Light Theme'), + TextFormField( + decoration: const InputDecoration( + border: UnderlineInputBorder(), + labelText: "Hostname", + ), + initialValue: controller.hostname, + onChanged: controller.setHostname, + ), + TextFormField( + decoration: const InputDecoration( + border: UnderlineInputBorder(), + labelText: "Username", + ), + initialValue: controller.username, + onChanged: controller.setUsername, + ), + TextFormField( + decoration: const InputDecoration( + border: UnderlineInputBorder(), + labelText: "Password", + ), + obscureText: true, + enableSuggestions: false, + autocorrect: false, + initialValue: controller.password, + onChanged: controller.setPassword, ), - DropdownMenuItem( - value: ThemeMode.dark, - child: Text('Dark Theme'), - ) ], - ), - TextFormField( - decoration: const InputDecoration( - border: UnderlineInputBorder(), - labelText: "Hostname", - ), - initialValue: controller.hostname, - onChanged: controller.setHostname, - ), - TextFormField( - decoration: const InputDecoration( - border: UnderlineInputBorder(), - labelText: "Username", - ), - initialValue: controller.username, - onChanged: controller.setUsername, - ), - TextFormField( - decoration: const InputDecoration( - border: UnderlineInputBorder(), - labelText: "Password", - ), - obscureText: true, - enableSuggestions: false, - autocorrect: false, - initialValue: controller.password, - onChanged: controller.setPassword, - ), - ],) + ) - ), + ), ); } }