edit_resource_screen.dart 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. import 'package:flutter/material.dart';
  2. import '../data/payload.dart';
  3. import '../design.dart';
  4. import '../models/inventory.dart';
  5. import '../state/inventory_store.dart';
  6. class EditResourceScreen extends StatefulWidget {
  7. const EditResourceScreen({
  8. super.key,
  9. required this.store,
  10. required this.resource,
  11. });
  12. final InventoryStore store;
  13. final Resource resource;
  14. @override
  15. State<EditResourceScreen> createState() => _EditResourceScreenState();
  16. }
  17. class _EditResourceScreenState extends State<EditResourceScreen> {
  18. late final TextEditingController _model;
  19. late final TextEditingController _description;
  20. late final TextEditingController _ramSize;
  21. late final TextEditingController _ramMts;
  22. late final TextEditingController _os;
  23. late final TextEditingController _cores;
  24. late final TextEditingController _systemRam;
  25. late final TextEditingController _ip;
  26. late final TextEditingController _port;
  27. late final TextEditingController _protocol;
  28. late final TextEditingController _url;
  29. late final TextEditingController _speed;
  30. late final TextEditingController _va;
  31. late final TextEditingController _notes;
  32. late final TextEditingController _newTag;
  33. late bool? _ipmi;
  34. late bool? _managed;
  35. late bool? _poe;
  36. late String? _type;
  37. late List<String> _tags;
  38. late Map<String, String> _labels;
  39. late List<String> _runsOn;
  40. late final Map<TextEditingController, String> _initialText;
  41. String? _error;
  42. static const _systemTypes = [
  43. 'baremetal',
  44. 'hypervisor',
  45. 'vm',
  46. 'container',
  47. 'embedded',
  48. 'cloud',
  49. 'cluster',
  50. 'other',
  51. ];
  52. @override
  53. void initState() {
  54. super.initState();
  55. final r = widget.resource;
  56. _model = TextEditingController(text: r.model ?? '');
  57. _description = TextEditingController(text: r.description ?? '');
  58. _ramSize = TextEditingController(
  59. text: r.ram?.size != null ? r.ram!.size!.toStringAsFixed(0) : '',
  60. );
  61. _ramMts = TextEditingController(text: r.ram?.mts?.toString() ?? '');
  62. _os = TextEditingController(text: r.os ?? '');
  63. _cores = TextEditingController(text: r.cores?.toString() ?? '');
  64. _systemRam = TextEditingController(
  65. text: r.ramGb != null ? r.ramGb!.toStringAsFixed(0) : '',
  66. );
  67. _ip = TextEditingController(text: r.ip ?? r.network?.ip ?? '');
  68. _port = TextEditingController(text: r.network?.port?.toString() ?? '');
  69. _protocol = TextEditingController(text: r.network?.protocol ?? '');
  70. _url = TextEditingController(text: r.network?.url ?? '');
  71. _speed = TextEditingController(
  72. text: r.speed != null ? r.speed!.toStringAsFixed(0) : '',
  73. );
  74. _va = TextEditingController(text: r.va?.toString() ?? '');
  75. _notes = TextEditingController(text: r.notes ?? '');
  76. _newTag = TextEditingController();
  77. _ipmi = r.ipmi;
  78. _managed = r.managed;
  79. _poe = r.poe;
  80. _type = r.type;
  81. _tags = List.of(r.tags);
  82. _labels = Map.of(r.labels);
  83. _runsOn = List.of(r.runsOn);
  84. _initialText = {
  85. for (final controller in _trackedControllers) controller: controller.text,
  86. };
  87. for (final controller in _trackedControllers) {
  88. controller.addListener(_onTrackedFieldChanged);
  89. }
  90. }
  91. List<TextEditingController> get _trackedControllers => [
  92. _model,
  93. _description,
  94. _ramSize,
  95. _ramMts,
  96. _os,
  97. _cores,
  98. _systemRam,
  99. _ip,
  100. _port,
  101. _protocol,
  102. _url,
  103. _speed,
  104. _va,
  105. _notes,
  106. ];
  107. void _onTrackedFieldChanged() {
  108. if (mounted) setState(() {});
  109. }
  110. bool get _hasUnsavedChanges {
  111. final r = widget.resource;
  112. return _trackedControllers.any(
  113. (controller) => controller.text != _initialText[controller],
  114. ) ||
  115. _ipmi != r.ipmi ||
  116. _managed != r.managed ||
  117. _poe != r.poe ||
  118. _type != r.type ||
  119. !_listEquals(_tags, r.tags) ||
  120. !_mapsEqual(_labels, r.labels) ||
  121. !_listEquals(_runsOn, r.runsOn);
  122. }
  123. @override
  124. void dispose() {
  125. for (final controller in _trackedControllers) {
  126. controller.removeListener(_onTrackedFieldChanged);
  127. }
  128. for (final controller in [
  129. _model,
  130. _description,
  131. _ramSize,
  132. _ramMts,
  133. _os,
  134. _cores,
  135. _systemRam,
  136. _ip,
  137. _port,
  138. _protocol,
  139. _url,
  140. _speed,
  141. _va,
  142. _notes,
  143. _newTag,
  144. _newLabelKey,
  145. _newLabelValue,
  146. ]) {
  147. controller.dispose();
  148. }
  149. super.dispose();
  150. }
  151. Map<String, Object?> _scalarFields() {
  152. final r = widget.resource;
  153. final fields = <String, Object?>{};
  154. void maybeString(
  155. String key,
  156. TextEditingController controller,
  157. String? original,
  158. ) {
  159. final value = controller.text.trim();
  160. if (value.isEmpty) return;
  161. if (value != (original ?? '')) fields[key] = value;
  162. }
  163. void maybeInt(String key, TextEditingController controller, int? original) {
  164. final value = int.tryParse(controller.text.trim());
  165. if (value == null || value == original) return;
  166. fields[key] = value;
  167. }
  168. void maybeDouble(
  169. String key,
  170. TextEditingController controller,
  171. double? original,
  172. ) {
  173. final value = double.tryParse(controller.text.trim());
  174. if (value == null || value == original) return;
  175. fields[key] = value;
  176. }
  177. switch (r.kind) {
  178. case 'Server':
  179. maybeString('model', _model, r.model);
  180. final serverRam = _ramPatch();
  181. if (serverRam != null) fields['ram'] = serverRam;
  182. if (_ipmi != r.ipmi && _ipmi != null) fields['ipmi'] = _ipmi;
  183. case 'Desktop' || 'Laptop':
  184. maybeString('model', _model, r.model);
  185. final portableRam = _ramPatch();
  186. if (portableRam != null) fields['ram'] = portableRam;
  187. case 'Switch' || 'Router' || 'Firewall':
  188. maybeString('model', _model, r.model);
  189. if (_managed != r.managed && _managed != null) {
  190. fields['managed'] = _managed;
  191. }
  192. if (_poe != r.poe && _poe != null) fields['poe'] = _poe;
  193. case 'AccessPoint':
  194. maybeString('model', _model, r.model);
  195. maybeDouble('speed', _speed, r.speed);
  196. case 'Ups':
  197. maybeString('model', _model, r.model);
  198. maybeInt('va', _va, r.va);
  199. case 'Other':
  200. maybeString('model', _model, r.model);
  201. maybeString('description', _description, r.description);
  202. case 'System':
  203. if (_type != null && _type != r.type) fields['type'] = _type;
  204. maybeString('os', _os, r.os);
  205. maybeInt('cores', _cores, r.cores);
  206. maybeDouble('ram', _systemRam, r.ramGb);
  207. maybeString('ip', _ip, r.ip);
  208. if (!_listEquals(_runsOn, r.runsOn)) fields['runsOn'] = _runsOn;
  209. case 'Service':
  210. final network = <String, Object?>{};
  211. maybeNetwork(network);
  212. if (network.isNotEmpty) fields['network'] = network;
  213. if (!_listEquals(_runsOn, r.runsOn)) fields['runsOn'] = _runsOn;
  214. }
  215. return fields;
  216. }
  217. Map<String, Object?>? _ramPatch() {
  218. final original = widget.resource.ram;
  219. final size = double.tryParse(_ramSize.text.trim());
  220. final mts = int.tryParse(_ramMts.text.trim());
  221. final ram = <String, Object?>{};
  222. if (size != null && size != original?.size) ram['size'] = size;
  223. if (mts != null && mts != original?.mts) ram['mts'] = mts;
  224. return ram.isEmpty ? null : ram;
  225. }
  226. void maybeNetwork(Map<String, Object?> network) {
  227. final original = widget.resource.network;
  228. final ip = _ip.text.trim();
  229. final port = int.tryParse(_port.text.trim());
  230. final protocol = _protocol.text.trim();
  231. final url = _url.text.trim();
  232. if (ip.isNotEmpty && ip != original?.ip) network['ip'] = ip;
  233. if (port != null && port != original?.port) network['port'] = port;
  234. if (protocol.isNotEmpty && protocol != original?.protocol) {
  235. network['protocol'] = protocol;
  236. }
  237. if (url.isNotEmpty && url != original?.url) network['url'] = url;
  238. }
  239. static bool _listEquals(List<String> a, List<String> b) {
  240. if (a.length != b.length) return false;
  241. for (var i = 0; i < a.length; i++) {
  242. if (a[i] != b[i]) return false;
  243. }
  244. return true;
  245. }
  246. Future<void> _save() async {
  247. setState(() => _error = null);
  248. final fields = _scalarFields();
  249. final tagsChanged = !_listEquals(_tags, widget.resource.tags);
  250. final labelsChanged = !_mapsEqual(_labels, widget.resource.labels);
  251. final notesChanged = _notes.text != (widget.resource.notes ?? '');
  252. if (fields.isEmpty && !tagsChanged && !labelsChanged && !notesChanged) {
  253. Navigator.of(context).pop();
  254. return;
  255. }
  256. try {
  257. await widget.store.applyPatch(
  258. resourcePatch(
  259. kind: widget.resource.kind,
  260. name: widget.resource.name,
  261. fields: fields,
  262. tags: tagsChanged ? _tags : null,
  263. labels: labelsChanged ? _labels : null,
  264. notes: notesChanged ? _notes.text : null,
  265. ),
  266. );
  267. if (!mounted) return;
  268. Navigator.of(context).pop();
  269. } catch (e) {
  270. setState(() => _error = e.toString());
  271. }
  272. }
  273. static bool _mapsEqual(Map<String, String> a, Map<String, String> b) {
  274. if (a.length != b.length) return false;
  275. for (final entry in a.entries) {
  276. if (b[entry.key] != entry.value) return false;
  277. }
  278. return true;
  279. }
  280. @override
  281. Widget build(BuildContext context) {
  282. final r = widget.resource;
  283. return Scaffold(
  284. appBar: AppBar(
  285. backgroundColor: ScribeColors.canvas,
  286. elevation: 0,
  287. scrolledUnderElevation: 0,
  288. title: Column(
  289. crossAxisAlignment: CrossAxisAlignment.start,
  290. mainAxisAlignment: MainAxisAlignment.center,
  291. children: [
  292. Text(
  293. 'Edit ${r.name}',
  294. maxLines: 1,
  295. overflow: TextOverflow.ellipsis,
  296. ),
  297. if (_hasUnsavedChanges) ...[
  298. const SizedBox(height: 2),
  299. Text(
  300. 'Not saved',
  301. style: Theme.of(
  302. context,
  303. ).textTheme.labelSmall?.copyWith(color: ScribeColors.danger),
  304. ),
  305. ],
  306. ],
  307. ),
  308. ),
  309. body: ScribeSheet(
  310. children: [
  311. if (_hasScalars) ...[
  312. SurfacePanel(
  313. child: Column(
  314. crossAxisAlignment: CrossAxisAlignment.stretch,
  315. children: [
  316. const SectionLabel('Details'),
  317. const SizedBox(height: ScribeSpacing.relatedGap),
  318. ..._scalarInputs(),
  319. ],
  320. ),
  321. ),
  322. ],
  323. _tagsPanel(),
  324. _labelsPanel(),
  325. SurfacePanel(
  326. child: Column(
  327. crossAxisAlignment: CrossAxisAlignment.stretch,
  328. children: [
  329. const SectionLabel('Notes'),
  330. const SizedBox(height: ScribeSpacing.relatedGap),
  331. TextField(
  332. controller: _notes,
  333. minLines: 3,
  334. maxLines: 10,
  335. decoration: const InputDecoration(
  336. hintText: 'Markdown, as in the web UI',
  337. ),
  338. ),
  339. ],
  340. ),
  341. ),
  342. if (_error != null) InlineNotice(message: _error!, isError: true),
  343. ScribeAction(
  344. label: 'Save',
  345. busy: widget.store.busy,
  346. onPressed: _save,
  347. ),
  348. const Text(
  349. 'Merge semantics: empty fields are left untouched on the server. '
  350. 'Deleting a resource or clearing all tags is done from the web UI.',
  351. style: ScribeType.metadata,
  352. ),
  353. ],
  354. ),
  355. );
  356. }
  357. bool get _hasScalars => const {
  358. 'Server',
  359. 'Desktop',
  360. 'Laptop',
  361. 'Switch',
  362. 'Router',
  363. 'Firewall',
  364. 'AccessPoint',
  365. 'Ups',
  366. 'Other',
  367. 'System',
  368. 'Service',
  369. }.contains(widget.resource.kind);
  370. List<Widget> _scalarInputs() {
  371. switch (widget.resource.kind) {
  372. case 'Server':
  373. return [
  374. _field(_ramSize, 'RAM (GB)', TextInputType.number),
  375. const SizedBox(height: ScribeSpacing.relatedGap),
  376. _field(_ramMts, 'RAM speed (MT/s)', TextInputType.number),
  377. const SizedBox(height: ScribeSpacing.relatedGap),
  378. _triSwitch('IPMI', _ipmi, (v) => setState(() => _ipmi = v)),
  379. ];
  380. case 'Desktop' || 'Laptop':
  381. return [
  382. _field(_model, 'Model'),
  383. const SizedBox(height: ScribeSpacing.relatedGap),
  384. _field(_ramSize, 'RAM (GB)', TextInputType.number),
  385. const SizedBox(height: ScribeSpacing.relatedGap),
  386. _field(_ramMts, 'RAM speed (MT/s)', TextInputType.number),
  387. ];
  388. case 'Switch' || 'Router' || 'Firewall':
  389. return [
  390. _field(_model, 'Model'),
  391. const SizedBox(height: ScribeSpacing.relatedGap),
  392. _triSwitch('Managed', _managed, (v) => setState(() => _managed = v)),
  393. const SizedBox(height: ScribeSpacing.relatedGap),
  394. _triSwitch('PoE', _poe, (v) => setState(() => _poe = v)),
  395. ];
  396. case 'AccessPoint':
  397. return [
  398. _field(_model, 'Model'),
  399. const SizedBox(height: ScribeSpacing.relatedGap),
  400. _field(_speed, 'Speed (Gbps)', TextInputType.number),
  401. ];
  402. case 'Ups':
  403. return [
  404. _field(_model, 'Model'),
  405. const SizedBox(height: ScribeSpacing.relatedGap),
  406. _field(_va, 'VA', TextInputType.number),
  407. ];
  408. case 'Other':
  409. return [
  410. _field(_model, 'Model'),
  411. const SizedBox(height: ScribeSpacing.relatedGap),
  412. _field(_description, 'Description'),
  413. ];
  414. case 'System':
  415. return [
  416. DropdownButtonFormField<String>(
  417. initialValue: _type,
  418. decoration: const InputDecoration(labelText: 'Type'),
  419. items: [
  420. for (final t in _systemTypes)
  421. DropdownMenuItem(value: t, child: Text(t)),
  422. ],
  423. onChanged: (v) => setState(() => _type = v),
  424. ),
  425. const SizedBox(height: ScribeSpacing.relatedGap),
  426. _field(_os, 'OS'),
  427. const SizedBox(height: ScribeSpacing.relatedGap),
  428. _field(_cores, 'Cores', TextInputType.number),
  429. const SizedBox(height: ScribeSpacing.relatedGap),
  430. _field(_systemRam, 'RAM (GB)', TextInputType.number),
  431. const SizedBox(height: ScribeSpacing.relatedGap),
  432. _field(_ip, 'IP'),
  433. const SizedBox(height: ScribeSpacing.relatedGap),
  434. _runsOnPicker(),
  435. ];
  436. case 'Service':
  437. return [
  438. _field(_ip, 'IP'),
  439. const SizedBox(height: ScribeSpacing.relatedGap),
  440. _field(_port, 'Port', TextInputType.number),
  441. const SizedBox(height: ScribeSpacing.relatedGap),
  442. _field(_protocol, 'Protocol'),
  443. const SizedBox(height: ScribeSpacing.relatedGap),
  444. _field(_url, 'URL'),
  445. const SizedBox(height: ScribeSpacing.relatedGap),
  446. _runsOnPicker(),
  447. ];
  448. default:
  449. return [];
  450. }
  451. }
  452. Widget _runsOnPicker() {
  453. final candidates = (widget.store.document?.resources ?? []).where((c) {
  454. if (c.name == widget.resource.name) return false;
  455. if (widget.resource.kind == 'System') {
  456. return c.isHardware || c.kind == 'System';
  457. }
  458. return c.kind == 'System';
  459. }).toList();
  460. return Column(
  461. crossAxisAlignment: CrossAxisAlignment.start,
  462. children: [
  463. const SectionLabel('Runs on'),
  464. const SizedBox(height: 8),
  465. for (final candidate in candidates)
  466. CheckboxListTile(
  467. contentPadding: EdgeInsets.zero,
  468. controlAffinity: ListTileControlAffinity.leading,
  469. value: _runsOn.contains(candidate.name),
  470. title: Text(candidate.name, style: ScribeType.body),
  471. onChanged: (checked) => setState(() {
  472. if (checked == true) {
  473. _runsOn = [candidate.name];
  474. } else {
  475. _runsOn = [];
  476. }
  477. }),
  478. ),
  479. ],
  480. );
  481. }
  482. Widget _tagsPanel() {
  483. return SurfacePanel(
  484. child: Column(
  485. crossAxisAlignment: CrossAxisAlignment.stretch,
  486. children: [
  487. const SectionLabel('Tags'),
  488. const SizedBox(height: 8),
  489. Wrap(
  490. spacing: 8,
  491. runSpacing: 8,
  492. children: [
  493. for (final tag in _tags)
  494. InputChip(
  495. label: Text(tag),
  496. onDeleted: () => setState(() => _tags.remove(tag)),
  497. ),
  498. ],
  499. ),
  500. const SizedBox(height: ScribeSpacing.relatedGap),
  501. Row(
  502. children: [
  503. Expanded(
  504. child: TextField(
  505. controller: _newTag,
  506. decoration: const InputDecoration(hintText: 'Add tag'),
  507. onSubmitted: (_) => _addTag(),
  508. ),
  509. ),
  510. const SizedBox(width: 8),
  511. IconButton(
  512. tooltip: 'Add tag',
  513. onPressed: _addTag,
  514. icon: const Icon(Icons.add_circle),
  515. color: ScribeColors.accent,
  516. ),
  517. ],
  518. ),
  519. ],
  520. ),
  521. );
  522. }
  523. void _addTag() {
  524. final tag = _newTag.text.trim();
  525. if (tag.isEmpty || _tags.contains(tag)) return;
  526. setState(() {
  527. _tags.add(tag);
  528. _newTag.clear();
  529. });
  530. }
  531. Widget _labelsPanel() {
  532. return SurfacePanel(
  533. child: Column(
  534. crossAxisAlignment: CrossAxisAlignment.stretch,
  535. children: [
  536. const SectionLabel('Labels'),
  537. const SizedBox(height: 8),
  538. for (final entry in _labels.entries)
  539. Row(
  540. children: [
  541. Expanded(
  542. child: Text(
  543. '${entry.key}: ${entry.value}',
  544. style: ScribeType.body,
  545. ),
  546. ),
  547. IconButton(
  548. tooltip: 'Remove label ${entry.key}',
  549. icon: const Icon(Icons.close),
  550. color: ScribeColors.tertiaryText,
  551. onPressed: () => setState(() => _labels.remove(entry.key)),
  552. ),
  553. ],
  554. ),
  555. const SizedBox(height: ScribeSpacing.relatedGap),
  556. _labelAdder(),
  557. ],
  558. ),
  559. );
  560. }
  561. final TextEditingController _newLabelKey = TextEditingController();
  562. final TextEditingController _newLabelValue = TextEditingController();
  563. Widget _labelAdder() {
  564. return Row(
  565. children: [
  566. Expanded(
  567. child: TextField(
  568. controller: _newLabelKey,
  569. decoration: const InputDecoration(hintText: 'Key'),
  570. ),
  571. ),
  572. const SizedBox(width: 8),
  573. Expanded(
  574. child: TextField(
  575. controller: _newLabelValue,
  576. decoration: const InputDecoration(hintText: 'Value'),
  577. ),
  578. ),
  579. const SizedBox(width: 8),
  580. IconButton(
  581. tooltip: 'Add label',
  582. icon: const Icon(Icons.add_circle),
  583. color: ScribeColors.accent,
  584. onPressed: () {
  585. final key = _newLabelKey.text.trim();
  586. final value = _newLabelValue.text.trim();
  587. if (key.isEmpty || value.isEmpty) return;
  588. setState(() {
  589. _labels[key] = value;
  590. _newLabelKey.clear();
  591. _newLabelValue.clear();
  592. });
  593. },
  594. ),
  595. ],
  596. );
  597. }
  598. Widget _field(
  599. TextEditingController controller,
  600. String label, [
  601. TextInputType type = TextInputType.text,
  602. ]) {
  603. return TextField(
  604. controller: controller,
  605. keyboardType: type,
  606. decoration: InputDecoration(labelText: label),
  607. );
  608. }
  609. Widget _triSwitch(String label, bool? value, ValueChanged<bool?> onChanged) {
  610. return SwitchListTile(
  611. contentPadding: EdgeInsets.zero,
  612. title: Text(label, style: ScribeType.body),
  613. value: value ?? false,
  614. onChanged: onChanged,
  615. activeThumbColor: ScribeColors.accent,
  616. );
  617. }
  618. }