]> git.proxmox.com Git - flutter/pve_flutter_frontend.git/blame - lib/pages/main_layout_slim.dart
tree-wide: prefer sized box for whitespace
[flutter/pve_flutter_frontend.git] / lib / pages / main_layout_slim.dart
CommitLineData
3628aaea 1import 'package:built_collection/built_collection.dart';
00a48038 2import 'package:flutter/material.dart';
3628aaea
TM
3import 'package:flutter/rendering.dart';
4import 'package:font_awesome_flutter/font_awesome_flutter.dart';
b6823e19 5import 'package:intl/intl.dart';
3628aaea
TM
6import 'package:provider/provider.dart';
7import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart';
5ad24bd7 8import 'package:pve_flutter_frontend/bloc/pve_access_management_bloc.dart';
3628aaea
TM
9import 'package:pve_flutter_frontend/bloc/pve_authentication_bloc.dart';
10import 'package:pve_flutter_frontend/bloc/pve_cluster_status_bloc.dart';
11import 'package:pve_flutter_frontend/bloc/pve_file_selector_bloc.dart';
12import 'package:pve_flutter_frontend/bloc/pve_resource_bloc.dart';
13import 'package:pve_flutter_frontend/bloc/pve_storage_selector_bloc.dart';
5ad24bd7 14import 'package:pve_flutter_frontend/states/pve_access_management_state.dart';
3628aaea
TM
15import 'package:pve_flutter_frontend/states/pve_cluster_status_state.dart';
16import 'package:pve_flutter_frontend/states/pve_file_selector_state.dart';
17import 'package:pve_flutter_frontend/states/pve_resource_state.dart';
18import 'package:pve_flutter_frontend/states/pve_storage_selector_state.dart';
19import 'package:pve_flutter_frontend/utils/renderers.dart';
3628aaea 20import 'package:pve_flutter_frontend/widgets/proxmox_capacity_indicator.dart';
ee36e7ca 21import 'package:pve_flutter_frontend/widgets/proxmox_custom_icon.dart';
bbc12f2a
TM
22import 'package:pve_flutter_frontend/widgets/proxmox_gauge_chart.dart';
23import 'package:pve_flutter_frontend/widgets/proxmox_heartbeat_indicator.dart';
3628aaea
TM
24import 'package:pve_flutter_frontend/widgets/proxmox_stream_builder_widget.dart';
25import 'package:pve_flutter_frontend/widgets/pve_file_selector_widget.dart';
5b5b3f73 26import 'package:pve_flutter_frontend/widgets/pve_guest_icon_widget.dart';
3628aaea 27import 'package:pve_flutter_frontend/widgets/pve_help_icon_button_widget.dart';
c96ce032 28import 'package:pve_flutter_frontend/widgets/pve_resource_data_card_widget.dart';
3628aaea 29import 'package:pve_flutter_frontend/widgets/pve_resource_status_chip_widget.dart';
bbc12f2a 30import 'package:pve_flutter_frontend/widgets/pve_subscription_alert_dialog.dart';
9bb60c76 31import 'package:pve_flutter_frontend/utils/proxmox_colors.dart';
3628aaea 32import 'package:rxdart/rxdart.dart';
00a48038 33
3628aaea 34class MainLayoutSlim extends StatefulWidget {
59df1bb3
TL
35 const MainLayoutSlim({super.key});
36
3628aaea
TM
37 @override
38 _MainLayoutSlimState createState() => _MainLayoutSlimState();
39}
40
41class _MainLayoutSlimState extends State<MainLayoutSlim> {
42 BehaviorSubject<int> pageSelector = BehaviorSubject.seeded(0);
00a48038
TM
43 @override
44 Widget build(BuildContext context) {
3628aaea 45 final apiClient = Provider.of<ProxmoxApiClient>(context);
5f79edf2
TM
46 return MultiProvider(
47 providers: [
48 Provider.value(
49 value: pageSelector,
50 ),
51 Provider<PveResourceBloc>(
52 create: (context) => PveResourceBloc(
53 apiClient: apiClient,
54 init: PveResourceState.init().rebuild(
d48d8e9d
TM
55 (b) => b
56 ..typeFilter.replace({'qemu', 'lxc', 'storage'})
57 ..statusFilter.replace(PveResourceStatusType.values),
5f79edf2
TM
58 ),
59 )..events.add(PollResources()),
60 dispose: (context, bloc) => bloc.dispose(),
61 ),
62 Provider<PveAccessManagementBloc>(
63 create: (context) => PveAccessManagementBloc(
64 apiClient: apiClient,
65 init:
66 PveAccessManagementState.init(apiClient.credentials.username))
67 ..events.add(LoadUsers()),
68 dispose: (context, bloc) => bloc.dispose(),
69 )
70 ],
d9e05b58
TM
71 child: WillPopScope(
72 onWillPop: () async {
73 if (pageSelector.value != 0) {
74 pageSelector.add(0);
75 return false;
3628aaea 76 }
d9e05b58 77 return true;
3628aaea 78 },
d9e05b58
TM
79 child: StreamBuilder<int>(
80 stream: pageSelector.stream,
81 initialData: pageSelector.value,
82 builder: (context, snapshot) {
83 if (snapshot.hasData) {
84 switch (snapshot.data) {
85 case 0:
4dc5fbe2 86 return const MobileDashboard();
d9e05b58
TM
87 break;
88 case 1:
4dc5fbe2 89 return const MobileResourceOverview();
d9e05b58
TM
90 break;
91 case 2:
f057791f
TM
92 Provider.of<PveAccessManagementBloc>(context)
93 .events
94 .add(LoadUsers());
4dc5fbe2 95 return const MobileAccessManagement();
f057791f 96
d9e05b58
TM
97 break;
98 default:
99 }
100 }
101 return Container();
102 },
103 ),
3628aaea
TM
104 ),
105 );
106 }
107
108 @override
109 dispose() {
110 pageSelector.close();
111 super.dispose();
112 }
113}
114
115class PveMobileBottomNavigationbar extends StatelessWidget {
59df1bb3
TL
116 const PveMobileBottomNavigationbar({super.key});
117
3628aaea
TM
118 @override
119 Widget build(BuildContext context) {
120 final pageSelector = Provider.of<BehaviorSubject<int>>(context);
9bb60c76 121 final light = Theme.of(context).colorScheme.brightness == Brightness.light;
3628aaea 122 return BottomNavigationBar(
095760b7 123 type: BottomNavigationBarType.fixed,
9bb60c76 124 backgroundColor: light ? Colors.white : ProxmoxColors.greyShade40,
3628aaea 125 items: [
5d2e7931 126 const BottomNavigationBarItem(
9bb60c76
TL
127 icon: Icon(Icons.dashboard),
128 label: "Dashboard",
129 ),
5d2e7931 130 const BottomNavigationBarItem(
9bb60c76 131 icon: Icon(Icons.developer_board),
453d9410 132 label: "Resources",
5ad24bd7 133 ),
5d2e7931 134 const BottomNavigationBarItem(
9bb60c76 135 icon: Icon(Icons.supervised_user_circle),
453d9410 136 label: "Access",
5ad24bd7 137 ),
5d2e7931 138 const BottomNavigationBarItem(
9bb60c76 139 icon: Icon(Icons.logout),
095760b7
TM
140 label: "Sites",
141 ),
3628aaea
TM
142 ],
143 currentIndex: pageSelector.value,
095760b7
TM
144 onTap: (index) {
145 if (index == 3) {
bf600e56
TL
146 Provider.of<PveAuthenticationBloc>(context, listen: false)
147 .events
148 .add(LoggedOut());
095760b7
TM
149 Navigator.of(context).pushReplacementNamed('/login');
150 } else {
151 pageSelector.add(index);
152 }
153 });
3628aaea
TM
154 }
155}
156
157class MobileDashboard extends StatelessWidget {
59df1bb3
TL
158 const MobileDashboard({super.key});
159
3628aaea
TM
160 @override
161 Widget build(BuildContext context) {
162 final cBloc = Provider.of<PveClusterStatusBloc>(context);
35e7d210 163 final rBloc = Provider.of<PveResourceBloc>(context);
00a48038 164 return Scaffold(
bbc12f2a 165 appBar: AppBar(
5d2e7931 166 title: const Column(
e6238281
TM
167 crossAxisAlignment: CrossAxisAlignment.start,
168 children: [
169 Text(
170 'Proxmox',
171 style: TextStyle(
172 fontSize: 14,
173 ),
174 ),
175 Text(
176 'Virtual Environment',
177 style: TextStyle(
178 fontSize: 14,
179 ),
180 )
181 ],
bbc12f2a
TM
182 ),
183 elevation: 0.0,
5d2e7931 184 leading: const Icon(
ee36e7ca
TM
185 ProxmoxIcons.proxmox,
186 size: 36,
bbc12f2a
TM
187 ),
188 automaticallyImplyLeading: false,
189 actions: <Widget>[
35b1eb44 190 PveHelpIconButton(
598a93b4
TL
191 baseUrl: (Provider.of<PveResourceBloc>(context)
192 .apiClient
193 ?.credentials
59df1bb3 194 .apiBaseUrl) ??
598a93b4 195 Uri.parse('https://pve.proxmox.com'),
35b1eb44 196 docPath: 'index.html'),
bbc12f2a
TM
197 ],
198 ),
199 body: Stack(children: [
200 Container(
201 height: 350,
9bb60c76 202 color: Theme.of(context).colorScheme.primary,
bbc12f2a
TM
203 ),
204 ProxmoxStreamBuilder<PveClusterStatusBloc, PveClusterStatusState>(
205 bloc: cBloc,
206 builder: (context, cState) {
207 return ListView(children: <Widget>[
208 if (cState.cluster != null) ...[
209 ListTile(
5d2e7931 210 title: const Text(
bbc12f2a
TM
211 "Status",
212 style: TextStyle(
213 fontWeight: FontWeight.bold,
214 color: Colors.white,
215 ),
216 ),
217 subtitle: Text(
218 cState.cluster?.name ?? "Datacenter",
5d2e7931 219 style: const TextStyle(
bbc12f2a
TM
220 color: Colors.white,
221 ),
222 ),
27a2bb4e 223 trailing: SizedBox(
bbc12f2a
TM
224 width: 96,
225 height: 48,
226 child: ProxmoxHeartbeatIndicator(
227 isHealthy: cState.healthy,
228 healthyColor: Colors.greenAccent,
229 warningColor: Colors.orangeAccent,
230 ),
231 ),
232 ),
233 ],
27a2bb4e 234 SizedBox(
bbc12f2a
TM
235 height: 80,
236 child: ListView(
5d2e7931 237 padding: const EdgeInsets.symmetric(horizontal: 8),
bbc12f2a
TM
238 shrinkWrap: true,
239 scrollDirection: Axis.horizontal,
240 children: [
241 if (cState.missingSubscription)
242 ActionChip(
9bb60c76 243 backgroundColor:
cfb55e5a 244 Theme.of(context).colorScheme.primaryContainer,
5d2e7931
TL
245 avatar: const Icon(Icons.report, color: Colors.red),
246 label: const Text(
bbc12f2a
TM
247 'Subscription',
248 style: TextStyle(
249 fontWeight: FontWeight.bold,
250 color: Colors.white),
251 ),
252 onPressed: () => showDialog(
253 context: context,
4dc5fbe2 254 builder: (c) => const PveSubscriptionAlertDialog(),
bbc12f2a
TM
255 ),
256 ),
257 Padding(
258 padding: const EdgeInsets.symmetric(horizontal: 8.0),
259 child: ActionChip(
5d2e7931 260 padding: const EdgeInsets.symmetric(
9bb60c76
TL
261 vertical: 4.0, horizontal: 8.0),
262 backgroundColor:
cfb55e5a 263 Theme.of(context).colorScheme.primaryContainer,
bbc12f2a
TM
264 avatar: Icon(
265 Renderers.getDefaultResourceIcon('qemu'),
9bb60c76 266 color: Theme.of(context).colorScheme.onPrimary,
bbc12f2a
TM
267 size: 20,
268 ),
5d2e7931 269 label: const Text(
bbc12f2a
TM
270 'Virtual Machines',
271 style: TextStyle(
272 fontWeight: FontWeight.bold,
273 color: Colors.white),
274 ),
d48d8e9d 275 onPressed: () {
7969a2e0
TL
276 Provider.of<BehaviorSubject<int>>(context,
277 listen: false)
bf600e56
TL
278 .add(1);
279 Provider.of<PveResourceBloc>(context, listen: false)
d48d8e9d
TM
280 .events
281 .add(FilterResources(
282 typeFilter: BuiltSet.from(['qemu']),
283 ));
284 },
bbc12f2a
TM
285 ),
286 ),
287 Padding(
288 padding: const EdgeInsets.symmetric(horizontal: 8.0),
289 child: ActionChip(
5d2e7931 290 padding: const EdgeInsets.symmetric(
9bb60c76
TL
291 vertical: 4.0, horizontal: 8.0),
292 backgroundColor:
cfb55e5a 293 Theme.of(context).colorScheme.primaryContainer,
bbc12f2a
TM
294 avatar: Icon(
295 Renderers.getDefaultResourceIcon('lxc'),
9bb60c76 296 color: Theme.of(context).colorScheme.onPrimary,
bbc12f2a
TM
297 size: 20,
298 ),
5d2e7931 299 label: const Text(
bbc12f2a
TM
300 'Linux Containers',
301 style: TextStyle(
302 fontWeight: FontWeight.bold,
303 color: Colors.white),
304 ),
d48d8e9d 305 onPressed: () {
7969a2e0
TL
306 Provider.of<BehaviorSubject<int>>(context,
307 listen: false)
bf600e56
TL
308 .add(1);
309 Provider.of<PveResourceBloc>(context, listen: false)
d48d8e9d
TM
310 .events
311 .add(FilterResources(
312 typeFilter: BuiltSet.from(['lxc']),
313 ));
314 },
bbc12f2a
TM
315 ),
316 ),
317 ],
318 ),
319 ),
320 ProxmoxStreamBuilder<PveResourceBloc, PveResourceState>(
35e7d210
TM
321 bloc: rBloc,
322 builder: (context, rState) {
bbc12f2a
TM
323 final nodes = rState.nodes;
324 var aggrCpus = 0.0;
325 var aggrCpuUsage = 0.0;
326 var aggrMemUsage = 0.0;
327 var aggrMem = 0.0;
328 nodes.forEach((element) {
329 aggrCpuUsage +=
330 (element.cpu ?? 0) * (element.maxcpu ?? 0);
331 aggrCpus += element.maxcpu ?? 0;
332 aggrMemUsage += element.mem ?? 0;
333 aggrMem += element.maxmem ?? 0;
35e7d210 334 });
bbc12f2a
TM
335 final cpuUsagePercent =
336 ((aggrCpuUsage / aggrCpus) * 100).toStringAsFixed(2);
337 final memUsagePercent =
338 ((aggrMemUsage / aggrMem) * 100).toStringAsFixed(2);
339 return PveResourceDataCardWidget(
5d2e7931 340 title: const Text(
bbc12f2a
TM
341 'Analytics',
342 style: TextStyle(
343 fontWeight: FontWeight.bold,
344 fontSize: 20,
345 ),
346 ),
5d2e7931 347 subtitle: const Text('Usage across all online nodes'),
bbc12f2a 348 children: [
35e7d210 349 Padding(
bbc12f2a
TM
350 padding: const EdgeInsets.symmetric(vertical: 16.0),
351 child: ProxmoxGaugeChartListTile(
5d2e7931 352 title: const Text('CPU'),
bbc12f2a
TM
353 subtitle:
354 Text('$aggrCpus Cores ${nodes.length} Nodes'),
355 legend: Text('$cpuUsagePercent %'),
356 value: aggrCpuUsage,
357 maxValue: aggrCpus,
358 ),
359 ),
360 Padding(
361 padding: const EdgeInsets.symmetric(vertical: 16.0),
362 child: ProxmoxGaugeChartListTile(
5d2e7931 363 title: const Text('Memory'),
bbc12f2a
TM
364 subtitle: Text(
365 '${Renderers.formatSize(aggrMemUsage)} of ${Renderers.formatSize(aggrMem)}'),
366 legend: Text('$memUsagePercent %'),
367 value: aggrMemUsage,
368 maxValue: aggrMem,
35e7d210
TM
369 ),
370 ),
3628aaea 371 ],
35e7d210
TM
372 );
373 }),
bbc12f2a 374 PveResourceDataCardWidget(
5d2e7931 375 title: const Text(
bbc12f2a
TM
376 'Nodes',
377 style: TextStyle(
378 fontWeight: FontWeight.bold,
379 fontSize: 20,
3628aaea 380 ),
bbc12f2a
TM
381 ),
382 children: [
383 ...cState.nodes.map((node) {
384 return PveNodeListTile(
385 name: node.name,
5f9cc4f9 386 online: node.online ?? false,
bbc12f2a
TM
387 type: node.type,
388 level: node.level,
389 ip: node.ip,
390 );
391 }),
392 ],
393 ),
394 ProxmoxStreamBuilder<PveResourceBloc, PveResourceState>(
395 bloc: rBloc,
396 builder: (context, rState) {
397 final onlineVMs = rState.vms.where((e) =>
398 e.getStatus() == PveResourceStatusType.running);
399 final onlineCTs = rState.container.where((e) =>
400 e.getStatus() == PveResourceStatusType.running);
401 final totalVMs = rState.vms.length;
402 final offVMs = totalVMs - onlineVMs.length;
bbc12f2a 403 final totalCTs = rState.container.length;
25e03f15 404 final offCTs = totalCTs - onlineCTs.length;
bbc12f2a 405 return PveResourceDataCardWidget(
5d2e7931 406 title: const Text(
bbc12f2a
TM
407 'Guests',
408 style: TextStyle(
409 fontWeight: FontWeight.bold,
410 fontSize: 20,
411 ),
412 ),
413 children: <Widget>[
414 ListTile(
5d2e7931 415 title: const Text("Virtual Machines"),
598a93b4 416 trailing: Text(totalVMs.toString()),
bbc12f2a
TM
417 leading:
418 Icon(Renderers.getDefaultResourceIcon('qemu')),
d48d8e9d 419 onTap: () {
7969a2e0
TL
420 Provider.of<BehaviorSubject<int>>(context,
421 listen: false)
422 .add(1);
423 Provider.of<PveResourceBloc>(context,
424 listen: false)
d48d8e9d
TM
425 .events
426 .add(FilterResources(
427 typeFilter: BuiltSet.from(['qemu']),
428 ));
429 },
bbc12f2a
TM
430 ),
431 ListTile(
d48d8e9d 432 dense: true,
5d2e7931 433 title: const Text(
d48d8e9d
TM
434 "Online",
435 style: TextStyle(fontSize: 14),
436 ),
5d2e7931 437 leading: const Icon(Icons.play_circle_outline,
d48d8e9d
TM
438 color: Colors.green),
439 trailing: Text(onlineVMs.length.toString()),
440 onTap: () {
7969a2e0
TL
441 Provider.of<BehaviorSubject<int>>(context,
442 listen: false)
d48d8e9d 443 .add(1);
7969a2e0
TL
444 Provider.of<PveResourceBloc>(context,
445 listen: false)
d48d8e9d
TM
446 .events
447 .add(FilterResources(
448 typeFilter: BuiltSet.from(['qemu']),
449 statusFilter: BuiltSet.from(
450 [PveResourceStatusType.running]),
451 ));
452 }),
bbc12f2a
TM
453 ListTile(
454 dense: true,
5d2e7931 455 title: const Text(
bbc12f2a 456 "Offline",
f5aa4476
TM
457 style: TextStyle(
458 fontSize: 14,
459 ),
bbc12f2a 460 ),
5d2e7931 461 leading: const Icon(Icons.stop),
bbc12f2a 462 trailing: Text(offVMs.toString()),
d48d8e9d 463 onTap: () {
7969a2e0
TL
464 Provider.of<BehaviorSubject<int>>(context,
465 listen: false)
466 .add(1);
467 Provider.of<PveResourceBloc>(context,
468 listen: false)
d48d8e9d
TM
469 .events
470 .add(FilterResources(
471 typeFilter: BuiltSet.from(['qemu']),
472 statusFilter: BuiltSet.from(
473 [PveResourceStatusType.stopped]),
474 ));
475 },
bbc12f2a 476 ),
5d2e7931 477 const Divider(
d84177c2
TM
478 indent: 10,
479 ),
bbc12f2a 480 ListTile(
5d2e7931 481 title: const Text("LXC Container"),
598a93b4 482 trailing: Text(totalCTs.toString()),
bbc12f2a
TM
483 leading:
484 Icon(Renderers.getDefaultResourceIcon('lxc')),
d48d8e9d 485 onTap: () {
7969a2e0
TL
486 Provider.of<BehaviorSubject<int>>(context,
487 listen: false)
488 .add(1);
489 Provider.of<PveResourceBloc>(context,
490 listen: false)
d48d8e9d
TM
491 .events
492 .add(FilterResources(
493 typeFilter: BuiltSet.from(['lxc']),
494 ));
495 },
bbc12f2a 496 ),
bbc12f2a
TM
497 ListTile(
498 dense: true,
5d2e7931 499 title: const Text(
bbc12f2a
TM
500 "Online",
501 style: TextStyle(fontSize: 14),
502 ),
5d2e7931 503 leading: const Icon(Icons.play_circle_outline,
bbc12f2a
TM
504 color: Colors.green),
505 trailing: Text(onlineCTs.length.toString()),
d48d8e9d 506 onTap: () {
7969a2e0
TL
507 Provider.of<BehaviorSubject<int>>(context,
508 listen: false)
509 .add(1);
510 Provider.of<PveResourceBloc>(context,
511 listen: false)
d48d8e9d
TM
512 .events
513 .add(FilterResources(
514 typeFilter: BuiltSet.from(['lxc']),
515 statusFilter: BuiltSet.from(
516 [PveResourceStatusType.running]),
517 ));
518 },
bbc12f2a
TM
519 ),
520 ListTile(
521 dense: true,
5d2e7931 522 title: const Text(
bbc12f2a 523 "Offline",
c96ce032 524 style: TextStyle(
f5aa4476
TM
525 fontSize: 14,
526 ),
35e7d210 527 ),
5d2e7931 528 leading: const Icon(Icons.stop),
bbc12f2a 529 trailing: Text(offCTs.toString()),
d48d8e9d 530 onTap: () {
7969a2e0
TL
531 Provider.of<BehaviorSubject<int>>(context,
532 listen: false)
533 .add(1);
534 Provider.of<PveResourceBloc>(context,
535 listen: false)
d48d8e9d
TM
536 .events
537 .add(FilterResources(
538 typeFilter: BuiltSet.from(['lxc']),
539 statusFilter: BuiltSet.from(
540 [PveResourceStatusType.stopped]),
541 ));
542 },
bbc12f2a
TM
543 ),
544 ],
545 );
546 }),
547 ]);
548 }),
549 ]),
4dc5fbe2 550 bottomNavigationBar: const PveMobileBottomNavigationbar(),
3628aaea
TM
551 );
552 }
553}
554
555class PveNodeListTile extends StatelessWidget {
556 final String name;
5f9cc4f9 557 final bool online;
3628aaea 558 final String type;
598a93b4
TL
559 final String? level;
560 final String? ip;
3628aaea 561 const PveNodeListTile(
59df1bb3 562 {super.key,
598a93b4 563 required this.name,
5f9cc4f9 564 required this.online,
598a93b4 565 required this.type,
3628aaea 566 this.level,
59df1bb3 567 this.ip = ''});
3628aaea
TM
568 @override
569 Widget build(BuildContext context) {
570 return ListTile(
571 leading: Icon(
572 Renderers.getDefaultResourceIcon(type),
00a48038 573 ),
598a93b4 574 title: Text(name),
5f9cc4f9
DC
575 subtitle: Text(getNodeTileSubtitle(online, level, ip)),
576 trailing: Icon(Icons.power, color: online ? Colors.green : Colors.grey),
3628aaea
TM
577 onTap: () => Navigator.pushNamed(context, '/nodes/$name'),
578 );
579 }
580
598a93b4 581 String getNodeTileSubtitle(bool online, String? level, String? ip) {
3628aaea 582 if (online) {
59df1bb3 583 return '$ip - ${Renderers.renderSupportLevel(level)}';
3628aaea
TM
584 }
585 return 'offline';
586 }
587}
588
589class MobileResourceOverview extends StatelessWidget {
59df1bb3
TL
590 const MobileResourceOverview({super.key});
591
3628aaea
TM
592 @override
593 Widget build(BuildContext context) {
594 final rBloc = Provider.of<PveResourceBloc>(context);
595 return ProxmoxStreamBuilder<PveResourceBloc, PveResourceState>(
596 bloc: rBloc,
597 builder: (context, rstate) {
3628aaea 598 final fResources = rstate.filterResources.toList();
4e30c09d
TM
599 return GestureDetector(
600 onTap: () {
601 FocusScope.of(context).unfocus();
602 },
453eb819
DC
603 child: SafeArea(
604 child: Scaffold(
4e30c09d
TM
605 endDrawer: _MobileResourceFilterSheet(),
606 appBar: AppBar(
607 automaticallyImplyLeading: false,
9bb60c76 608 backgroundColor: Theme.of(context).colorScheme.primary,
4e30c09d
TM
609 elevation: 0,
610 title: AppbarSearchTextField(
d48d8e9d
TM
611 onChanged: (filter) =>
612 rBloc.events.add(FilterResources(nameFilter: filter)),
4e30c09d 613 ),
4dc5fbe2 614 actions: <Widget>[const AppBarFilterIconButton()],
3628aaea 615 ),
4e30c09d
TM
616 body: ListView.separated(
617 itemCount: fResources.length,
5d2e7931 618 separatorBuilder: (context, index) => const Divider(),
4e30c09d
TM
619 itemBuilder: (context, index) {
620 final resource = fResources[index];
621 var listWidget;
622 if (const ['lxc', 'qemu'].contains(resource.type)) {
623 listWidget = PveGuestListTile(resource: resource);
624 }
625 if (resource.type == 'node') {
626 listWidget = PveNodeListTile(
598a93b4 627 name: resource.node!,
4e30c09d
TM
628 online:
629 resource.getStatus() == PveResourceStatusType.running,
630 type: resource.type,
631 level: resource.level,
632 );
633 }
634 if (resource.type == 'storage') {
635 listWidget = PveStorageListeTile(
636 resource: resource,
637 );
638 }
639 if (listWidget != null) {
640 if (otherCategory(fResources, index)) {
641 return Column(
642 crossAxisAlignment: CrossAxisAlignment.start,
643 children: [
644 Padding(
645 padding: const EdgeInsets.all(12.0),
646 child: Text(
647 resource.type.toUpperCase(),
5d2e7931 648 style: const TextStyle(
4e30c09d
TM
649 fontSize: 18,
650 fontWeight: FontWeight.bold,
651 ),
6e210f1b
TM
652 ),
653 ),
4e30c09d
TM
654 listWidget,
655 ],
656 );
657 } else {
658 return listWidget;
659 }
7cf5af08 660 }
7cf5af08 661
5d2e7931 662 return const ListTile(
4e30c09d
TM
663 title: Text('Unkown resource type'),
664 );
665 },
666 ),
4dc5fbe2 667 bottomNavigationBar: const PveMobileBottomNavigationbar(),
453eb819 668 )),
3628aaea
TM
669 );
670 },
671 );
672 }
7cf5af08
TM
673
674 bool otherCategory(List<PveClusterResourcesModel> fResources, index) {
675 var previous;
676 if (index > 0) {
677 previous = fResources[index - 1];
678 }
679 final current = fResources[index];
680 return previous == null || previous.type != current.type;
681 }
682}
683
684class PveGuestListTile extends StatelessWidget {
685 const PveGuestListTile({
59df1bb3 686 super.key,
598a93b4 687 required this.resource,
59df1bb3 688 });
7cf5af08
TM
689
690 final PveClusterResourcesModel resource;
691
692 @override
693 Widget build(BuildContext context) {
910168d6 694 final status = resource.getStatus();
5b5b3f73 695
7cf5af08 696 return ListTile(
5b5b3f73
TM
697 leading: PveGuestIcon(
698 type: resource.type,
699 template: resource.template,
700 status: status,
7cf5af08
TM
701 ),
702 title: Text(resource.displayName),
703 subtitle: Row(
704 mainAxisAlignment: MainAxisAlignment.spaceBetween,
705 children: [
598a93b4 706 Text(resource.node!),
7cf5af08 707 StatusChip(
5b5b3f73 708 status: status,
7cf5af08
TM
709 fontzsize: 12,
710 ),
711 ],
712 ),
713 onTap: () {
714 if (['qemu', 'lxc'].contains(resource.type)) {
715 Navigator.pushNamed(
716 context, '/nodes/${resource.node}/${resource.id}');
717 }
718 },
719 );
720 }
721}
722
723class PveStorageListeTile extends StatelessWidget {
724 const PveStorageListeTile({
59df1bb3 725 super.key,
598a93b4 726 required this.resource,
59df1bb3 727 });
7cf5af08
TM
728
729 final PveClusterResourcesModel resource;
730
731 @override
732 Widget build(BuildContext context) {
733 final apiClient = Provider.of<ProxmoxApiClient>(context);
aae9f01d 734 final usedPercent = (resource.disk ?? 0.0) / (resource.maxdisk ?? 100.0);
7cf5af08
TM
735 return ListTile(
736 title: Text(resource.displayName),
737 subtitle: Column(
738 crossAxisAlignment: CrossAxisAlignment.start,
739 children: <Widget>[
740 Row(
741 mainAxisAlignment: MainAxisAlignment.spaceBetween,
742 children: [
598a93b4 743 Text(resource.node!),
7cf5af08
TM
744 StatusChip(
745 status: resource.getStatus(),
746 fontzsize: 12,
747 ),
748 ],
749 ),
aae9f01d
TM
750 if (resource.getStatus() == PveResourceStatusType.running &&
751 !(usedPercent.isNaN || usedPercent.isInfinite))
7cf5af08
TM
752 ProxmoxCapacityIndicator(
753 usedValue: Renderers.formatSize(resource.disk ?? 0),
754 totalValue: Renderers.formatSize(resource.maxdisk ?? 0),
aae9f01d
TM
755 usedPercent:
756 usedPercent.isNaN || usedPercent.isInfinite ? 0 : usedPercent,
7cf5af08
TM
757 icon: Icon(
758 Renderers.getDefaultResourceIcon(resource.type,
759 shared: resource.shared),
760 ),
761 ),
762 ],
763 ),
764 onTap: resource.getStatus() == PveResourceStatusType.running
765 ? () => Navigator.of(context).push(MaterialPageRoute(
766 builder: (context) => PveFileSelector(
767 fBloc: PveFileSelectorBloc(
768 apiClient: apiClient,
769 init: PveFileSelectorState.init(nodeID: resource.node)
770 .rebuild((b) => b..storageID = resource.storage)),
771 sBloc: PveStorageSelectorBloc(
772 apiClient: apiClient,
773 init: PveStorageSelectorState.init(nodeID: resource.node)
774 .rebuild((b) => b..storage = resource.storage),
775 )..events.add(LoadStoragesEvent()),
776 ),
777 ))
778 : null,
779 );
780 }
3628aaea
TM
781}
782
783class AppbarSearchTextField extends StatefulWidget {
598a93b4 784 final ValueChanged<String>? onChanged;
3628aaea 785
59df1bb3 786 const AppbarSearchTextField({super.key, this.onChanged});
3628aaea
TM
787 @override
788 _AppbarSearchTextFieldState createState() => _AppbarSearchTextFieldState();
789}
790
791class _AppbarSearchTextFieldState extends State<AppbarSearchTextField> {
9bb60c76 792 late TextEditingController _controller;
3628aaea
TM
793
794 void initState() {
795 super.initState();
796 _controller = TextEditingController();
797 }
798
799 void dispose() {
9bb60c76 800 _controller.dispose();
3628aaea
TM
801 super.dispose();
802 }
803
804 @override
805 Widget build(BuildContext context) {
806 return TextField(
807 decoration: InputDecoration(
9bb60c76
TL
808 prefixIcon: Icon(
809 Icons.search,
810 size: 20,
811 color: Theme.of(context).colorScheme.onSurface,
812 ),
813 suffixIcon: _controller.text.isNotEmpty
814 ? IconButton(
815 padding: EdgeInsets.zero,
816 iconSize: 20,
817 icon: Icon(
818 Icons.close,
819 color: Theme.of(context).colorScheme.onSurface,
820 ),
821 onPressed: () {
822 _controller.clear();
823 widget.onChanged!('');
824 FocusScope.of(context).unfocus();
825 },
826 )
827 : null,
5d2e7931 828 contentPadding: const EdgeInsets.fromLTRB(20, 5, 8, 5),
d6b19533
TL
829 prefixIconConstraints:
830 const BoxConstraints(minHeight: 32, minWidth: 32),
831 suffixIconConstraints:
832 const BoxConstraints(maxHeight: 32, maxWidth: 32),
9bb60c76
TL
833 //fillColor: Color(0xFFF1F2F4),
834 fillColor: Theme.of(context).colorScheme.surface,
835 filled: true,
836 isDense: true,
d6b19533
TL
837 enabledBorder: const OutlineInputBorder(
838 borderSide: BorderSide(color: Colors.white)),
9bb60c76 839 ),
5d2e7931 840 style: const TextStyle(fontSize: 20),
598a93b4 841 onChanged: (value) => widget.onChanged!(value),
3628aaea
TM
842 controller: _controller,
843 );
844 }
845}
846
847class _MobileResourceFilterSheet extends StatelessWidget {
848 @override
849 Widget build(BuildContext context) {
850 final rBloc = Provider.of<PveResourceBloc>(context);
851
852 return ProxmoxStreamBuilder<PveResourceBloc, PveResourceState>(
853 bloc: rBloc,
854 builder: (context, state) => Drawer(
855 child: SingleChildScrollView(
57d2bbd2
TM
856 child: Column(
857 crossAxisAlignment: CrossAxisAlignment.start,
858 children: <Widget>[
859 Padding(
860 padding: const EdgeInsets.fromLTRB(8.0, 20.0, 8.0, 0),
861 child: ListTile(
5d2e7931 862 title: const Text(
57d2bbd2 863 'Filter Results',
57d2bbd2 864 ),
d48d8e9d 865 trailing: rBloc.isFiltered
83b68bff 866 ? TextButton(
d48d8e9d
TM
867 onPressed: () => rBloc.events.add(ResetFilter()),
868 child: Text(
869 'Reset',
870 style: TextStyle(
58eadc41 871 color: Theme.of(context).colorScheme.secondary,
d48d8e9d
TM
872 ),
873 ),
874 )
875 : null,
3628aaea 876 ),
57d2bbd2 877 ),
5d2e7931 878 const Divider(
57d2bbd2
TM
879 indent: 0,
880 endIndent: 0,
881 ),
882 Padding(
883 padding: const EdgeInsets.fromLTRB(8.0, 0, 8.0, 0),
884 child: Column(
885 children: [
5d2e7931 886 const ListTile(
57d2bbd2
TM
887 title: Text(
888 'Type',
9bb60c76 889 style: TextStyle(fontWeight: FontWeight.bold),
57d2bbd2
TM
890 ),
891 ),
892 CheckboxListTile(
893 dense: true,
894 title: Text(
895 'Nodes',
9bb60c76
TL
896 style: TextStyle(
897 color: Theme.of(context)
898 .colorScheme
899 .onSurface
900 .withOpacity(0.75)),
57d2bbd2
TM
901 ),
902 value: state.typeFilter.contains('node'),
d48d8e9d 903 onChanged: (v) => rBloc.events.add(FilterResources(
598a93b4 904 typeFilter: addOrRemove(v!, 'node', state.typeFilter),
d48d8e9d 905 )),
57d2bbd2
TM
906 ),
907 CheckboxListTile(
908 dense: true,
909 title: Text(
910 'Qemu',
9bb60c76
TL
911 style: TextStyle(
912 color: Theme.of(context)
913 .colorScheme
914 .onSurface
915 .withOpacity(0.75)),
57d2bbd2
TM
916 ),
917 value: state.typeFilter.contains('qemu'),
d48d8e9d 918 onChanged: (v) => rBloc.events.add(FilterResources(
598a93b4 919 typeFilter: addOrRemove(v!, 'qemu', state.typeFilter),
d48d8e9d 920 )),
57d2bbd2
TM
921 ),
922 CheckboxListTile(
923 dense: true,
924 title: Text(
925 'LXC',
9bb60c76
TL
926 style: TextStyle(
927 color: Theme.of(context)
928 .colorScheme
929 .onSurface
930 .withOpacity(0.75)),
57d2bbd2
TM
931 ),
932 value: state.typeFilter.contains('lxc'),
d48d8e9d 933 onChanged: (v) => rBloc.events.add(FilterResources(
598a93b4 934 typeFilter: addOrRemove(v!, 'lxc', state.typeFilter),
d48d8e9d 935 )),
57d2bbd2
TM
936 ),
937 CheckboxListTile(
938 dense: true,
939 title: Text(
940 'Storage',
9bb60c76
TL
941 style: TextStyle(
942 color: Theme.of(context)
943 .colorScheme
944 .onSurface
945 .withOpacity(0.75)),
57d2bbd2
TM
946 ),
947 value: state.typeFilter.contains('storage'),
d48d8e9d 948 onChanged: (v) => rBloc.events.add(FilterResources(
598a93b4
TL
949 typeFilter:
950 addOrRemove(v!, 'storage', state.typeFilter),
d48d8e9d 951 )),
57d2bbd2
TM
952 ),
953 ],
3628aaea 954 ),
57d2bbd2 955 ),
d48d8e9d
TM
956 Padding(
957 padding: const EdgeInsets.fromLTRB(8.0, 0, 8.0, 0),
958 child: Column(
959 children: [
5d2e7931 960 const ListTile(
d48d8e9d
TM
961 title: Text(
962 'Status',
9bb60c76 963 style: TextStyle(fontWeight: FontWeight.bold),
d48d8e9d
TM
964 ),
965 ),
966 CheckboxListTile(
967 dense: true,
968 title: Text(
969 'Online',
9bb60c76
TL
970 style: TextStyle(
971 color: Theme.of(context)
972 .colorScheme
973 .onSurface
974 .withOpacity(0.75)),
d48d8e9d
TM
975 ),
976 value: state.statusFilter
977 .contains(PveResourceStatusType.running),
978 onChanged: (v) => rBloc.events.add(FilterResources(
598a93b4 979 statusFilter: addOrRemove(v!,
d48d8e9d
TM
980 PveResourceStatusType.running, state.statusFilter),
981 )),
982 ),
983 CheckboxListTile(
984 dense: true,
985 title: Text(
986 'Offline',
9bb60c76
TL
987 style: TextStyle(
988 color: Theme.of(context)
989 .colorScheme
990 .onSurface
991 .withOpacity(0.75)),
d48d8e9d
TM
992 ),
993 value: state.statusFilter
994 .contains(PveResourceStatusType.stopped),
995 onChanged: (v) => rBloc.events.add(FilterResources(
598a93b4 996 statusFilter: addOrRemove(v!,
d48d8e9d
TM
997 PveResourceStatusType.stopped, state.statusFilter),
998 )),
999 ),
1000 ],
1001 ),
1002 )
57d2bbd2 1003 ],
3628aaea
TM
1004 ),
1005 ),
00a48038
TM
1006 ),
1007 );
1008 }
3628aaea 1009
d48d8e9d 1010 BuiltSet<S> addOrRemove<S>(bool value, S element, BuiltSet<S> filter) {
3628aaea
TM
1011 if (value) {
1012 return filter.rebuild((b) => b..add(element));
1013 } else {
1014 return filter.rebuild((b) => b..remove(element));
1015 }
1016 }
1017}
1018
1019class AppBarFilterIconButton extends StatelessWidget {
59df1bb3
TL
1020 const AppBarFilterIconButton({super.key});
1021
3628aaea
TM
1022 @override
1023 Widget build(BuildContext context) {
d48d8e9d
TM
1024 final rBloc = Provider.of<PveResourceBloc>(context);
1025
1026 return ProxmoxStreamBuilder<PveResourceBloc, PveResourceState>(
1027 bloc: rBloc,
1028 builder: (context, state) {
1029 return IconButton(
1030 icon: rBloc.isFiltered
5d2e7931 1031 ? const Icon(
d48d8e9d
TM
1032 FontAwesomeIcons.filter,
1033 color: Colors.black,
1034 )
5d2e7931 1035 : const Icon(
d48d8e9d
TM
1036 FontAwesomeIcons.filter,
1037 color: Colors.grey,
1038 ),
1039 onPressed: () => Scaffold.of(context).openEndDrawer(),
1040 );
1041 });
3628aaea
TM
1042 }
1043}
5ad24bd7
TM
1044
1045class MobileAccessManagement extends StatelessWidget {
59df1bb3
TL
1046 const MobileAccessManagement({super.key});
1047
5ad24bd7
TM
1048 @override
1049 Widget build(BuildContext context) {
1050 final aBloc = Provider.of<PveAccessManagementBloc>(context);
1051 return DefaultTabController(
b6823e19 1052 length: 5,
5ad24bd7
TM
1053 child: Scaffold(
1054 appBar: AppBar(
5d2e7931 1055 title: const Text('Permissions'),
5ad24bd7
TM
1056 //backgroundColor: Colors.transparent,
1057 elevation: 0.0,
1058 automaticallyImplyLeading: false,
b876878b
TL
1059 bottom: TabBar(
1060 isScrollable: true,
1061 labelStyle:
1062 TextStyle(color: Theme.of(context).colorScheme.onPrimary),
1063 tabs: [
5d2e7931 1064 const Tab(
b876878b
TL
1065 text: 'Users',
1066 icon: Icon(Icons.person),
1067 ),
5d2e7931 1068 const Tab(
b876878b
TL
1069 text: 'API Tokens',
1070 icon: Icon(Icons.person_outline),
1071 ),
5d2e7931 1072 const Tab(
b876878b
TL
1073 text: 'Groups',
1074 icon: Icon(Icons.group),
1075 ),
5d2e7931 1076 const Tab(
b876878b
TL
1077 text: 'Roles',
1078 icon: Icon(Icons.lock_open),
1079 ),
5d2e7931 1080 const Tab(
b876878b
TL
1081 text: 'Domains',
1082 icon: Icon(Icons.domain),
1083 )
1084 ]),
5ad24bd7
TM
1085 ),
1086 body: ProxmoxStreamBuilder<PveAccessManagementBloc,
1087 PveAccessManagementState>(
1088 bloc: aBloc,
1089 builder: (context, aState) {
1090 return TabBarView(children: [
1091 ListView.builder(
1092 itemCount: aState.users.length,
1093 itemBuilder: (context, index) {
1094 final user = aState.users[index];
1095 return ListTile(
1096 title: Text(user.userid),
1097 subtitle: Text(user.email ?? ''),
1098 trailing: aState.apiUser == user.userid
5d2e7931 1099 ? const Icon(Icons.person_pin_circle)
5ad24bd7
TM
1100 : null,
1101 );
1102 }),
b6823e19
TM
1103 ListView.builder(
1104 itemCount: aState.tokens.length,
1105 itemBuilder: (context, index) {
1106 final token = aState.tokens[index];
1107 var expireDate = 'infinite';
1108 if (token.expire != null) {
598a93b4 1109 expireDate = DateFormat.yMd().format(token.expire!);
b6823e19
TM
1110 }
1111
1112 return ListTile(
1113 title: Text('${token.userid} ${token.tokenid}'),
1114 subtitle: Text('Expires: $expireDate'),
1115 );
1116 }),
5ad24bd7
TM
1117 ListView.builder(
1118 itemCount: aState.groups.length,
1119 itemBuilder: (context, index) {
1120 final group = aState.groups[index];
598a93b4
TL
1121 final users = (group.users?.isNotEmpty ?? false)
1122 ? group.users!.split(',')
1123 : [];
5ad24bd7
TM
1124 return ListTile(
1125 title: Text(group.groupid),
1126 subtitle: Text(group.comment ?? ''),
5d2e7931 1127 trailing: const Icon(Icons.arrow_right),
5ad24bd7 1128 onTap: () => showModalBottomSheet(
5d2e7931 1129 shape: const RoundedRectangleBorder(
5ad24bd7
TM
1130 borderRadius: BorderRadius.vertical(
1131 top: Radius.circular(10))),
1132 context: context,
1133 builder: (context) {
27a2bb4e 1134 return SizedBox(
5ad24bd7
TM
1135 height: MediaQuery.of(context).size.height * 0.5,
1136 child: Column(
1137 crossAxisAlignment: CrossAxisAlignment.start,
1138 children: <Widget>[
1139 Padding(
d6b19533
TL
1140 padding:
1141 const EdgeInsets.fromLTRB(0, 5, 0, 5),
5ad24bd7
TM
1142 child: Align(
1143 alignment: Alignment.topCenter,
1144 child: Container(
1145 width: 40,
1146 height: 3,
1147 color: Colors.black,
1148 ),
1149 ),
1150 ),
1151 ListTile(
1152 title:
1153 Text('Group members (${users.length})'),
1154 ),
5d2e7931 1155 const Divider(),
5ad24bd7
TM
1156 Expanded(
1157 child: Padding(
1158 padding: const EdgeInsets.all(14.0),
1159 child: ListView.builder(
1160 itemCount: users.length,
1161 itemBuilder: (context, index) =>
1162 ListTile(
1163 title: Text(users[index]),
1164 ),
1165 ),
1166 ),
1167 )
1168 ],
1169 ),
1170 );
1171 },
1172 ),
1173 );
1174 }),
1175 ListView.builder(
1176 itemCount: aState.roles.length,
1177 itemBuilder: (context, index) {
1178 final role = aState.roles[index];
1179 final perms = role.privs.split(',');
1180 return ListTile(
1181 title: Text(role.roleid),
598a93b4
TL
1182 subtitle: Text((role.special ?? false)
1183 ? 'Built in Role'
1184 : 'Custom'),
5d2e7931 1185 trailing: const Icon(Icons.arrow_right),
5ad24bd7 1186 onTap: () => showModalBottomSheet(
5d2e7931 1187 shape: const RoundedRectangleBorder(
5ad24bd7
TM
1188 borderRadius: BorderRadius.vertical(
1189 top: Radius.circular(10))),
1190 context: context,
1191 builder: (context) {
27a2bb4e 1192 return SizedBox(
5ad24bd7
TM
1193 height: MediaQuery.of(context).size.height * 0.5,
1194 child: Column(
1195 crossAxisAlignment: CrossAxisAlignment.start,
1196 children: <Widget>[
1197 Padding(
d6b19533
TL
1198 padding:
1199 const EdgeInsets.fromLTRB(0, 5, 0, 5),
5ad24bd7
TM
1200 child: Align(
1201 alignment: Alignment.topCenter,
1202 child: Container(
1203 width: 40,
1204 height: 3,
1205 color: Colors.black,
1206 ),
1207 ),
1208 ),
1209 ListTile(
1210 title: Text('Privileges (${perms.length})'),
1211 ),
5d2e7931 1212 const Divider(),
5ad24bd7
TM
1213 Expanded(
1214 child: Padding(
1215 padding: const EdgeInsets.all(14.0),
1216 child: ListView.builder(
1217 itemCount: perms.length,
1218 itemBuilder: (context, index) =>
1219 ListTile(
1220 title: Text(perms[index]),
1221 ),
1222 ),
1223 ),
1224 )
1225 ],
1226 ),
1227 );
1228 },
1229 ),
1230 );
1231 }),
1232 ListView.builder(
1233 itemCount: aState.domains.length,
1234 itemBuilder: (context, index) {
1235 final domain = aState.domains[index];
1236 return ListTile(
1237 title: Text(domain.realm),
1238 subtitle: Text(domain.comment ?? ''),
1239 trailing: domain.tfa?.isNotEmpty ?? false
5d2e7931 1240 ? const Icon(Icons.looks_two)
5ad24bd7
TM
1241 : null,
1242 );
1243 }),
1244 ]);
1245 }),
4dc5fbe2 1246 bottomNavigationBar: const PveMobileBottomNavigationbar(),
5ad24bd7
TM
1247 ),
1248 );
1249 }
1250}