]> git.proxmox.com Git - pve-installer.git/blob - proxmox-tui-installer/src/main.rs
tui: add preliminary summary dialog
[pve-installer.git] / proxmox-tui-installer / src / main.rs
1 #![forbid(unsafe_code)]
2
3 mod views;
4
5 use crate::views::DiskSizeFormInputView;
6 use cursive::{
7 event::Event,
8 view::{Finder, Nameable, Resizable, ViewWrapper},
9 views::{
10 Button, Checkbox, Dialog, DummyView, EditView, LinearLayout, PaddedView, Panel,
11 ResizedView, ScrollView, SelectView, TextView,
12 },
13 Cursive, View,
14 };
15 use std::{
16 fmt, iter,
17 net::{IpAddr, Ipv4Addr},
18 };
19 use views::{CidrAddressEditView, FormInputView, TableView, TableViewItem};
20
21 // TextView::center() seems to garble the first two lines, so fix it manually here.
22 const LOGO: &str = r#"
23 ____ _ __ _____
24 / __ \_________ _ ______ ___ ____ _ __ | | / / ____/
25 / /_/ / ___/ __ \| |/_/ __ `__ \/ __ \| |/_/ | | / / __/
26 / ____/ / / /_/ /> </ / / / / / /_/ /> < | |/ / /___
27 /_/ /_/ \____/_/|_/_/ /_/ /_/\____/_/|_| |___/_____/
28 "#;
29
30 const TITLE: &str = "Proxmox VE Installer";
31
32 struct InstallerView {
33 view: ResizedView<LinearLayout>,
34 }
35
36 impl InstallerView {
37 pub fn new<T: View>(view: T, next_cb: Box<dyn Fn(&mut Cursive)>) -> Self {
38 let inner = LinearLayout::vertical().child(view).child(PaddedView::lrtb(
39 1,
40 1,
41 1,
42 0,
43 LinearLayout::horizontal()
44 .child(abort_install_button())
45 .child(DummyView.full_width())
46 .child(Button::new("Previous", switch_to_prev_screen))
47 .child(DummyView)
48 .child(Button::new("Next", next_cb)),
49 ));
50
51 Self::with_raw(inner)
52 }
53
54 pub fn with_raw<T: View>(view: T) -> Self {
55 let inner = LinearLayout::vertical()
56 .child(PaddedView::lrtb(1, 1, 0, 1, TextView::new(LOGO).center()))
57 .child(Dialog::around(view).title(TITLE));
58
59 Self {
60 // Limit the maximum to something reasonable, such that it won't get spread out much
61 // depending on the screen.
62 view: ResizedView::with_max_size((120, 40), inner),
63 }
64 }
65 }
66
67 impl ViewWrapper for InstallerView {
68 cursive::wrap_impl!(self.view: ResizedView<LinearLayout>);
69 }
70
71 #[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
72 enum FsType {
73 #[default]
74 Ext4,
75 Xfs,
76 }
77
78 impl fmt::Display for FsType {
79 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
80 let s = match self {
81 FsType::Ext4 => "ext4",
82 FsType::Xfs => "XFS",
83 };
84 write!(f, "{s}")
85 }
86 }
87
88 const FS_TYPES: &[FsType] = &[FsType::Ext4, FsType::Xfs];
89
90 #[derive(Clone, Debug)]
91 struct LvmBootdiskOptions {
92 disk: Disk,
93 total_size: u64,
94 swap_size: u64,
95 max_root_size: u64,
96 max_data_size: u64,
97 min_lvm_free: u64,
98 }
99
100 impl LvmBootdiskOptions {
101 fn defaults_from(disk: &Disk) -> Self {
102 let min_lvm_free = if disk.size > 128 * 1024 * 1024 {
103 16 * 1024 * 1024
104 } else {
105 disk.size / 8
106 };
107
108 Self {
109 disk: disk.clone(),
110 total_size: disk.size,
111 swap_size: 4 * 1024 * 1024, // TODO: value from installed memory
112 max_root_size: 0,
113 max_data_size: 0,
114 min_lvm_free,
115 }
116 }
117 }
118
119 #[derive(Clone, Debug)]
120 enum AdvancedBootdiskOptions {
121 Lvm(LvmBootdiskOptions),
122 }
123
124 impl AdvancedBootdiskOptions {
125 fn selected_disks(&self) -> impl Iterator<Item = &Disk> {
126 match self {
127 AdvancedBootdiskOptions::Lvm(LvmBootdiskOptions { disk, .. }) => iter::once(disk),
128 }
129 }
130 }
131
132 #[derive(Clone, Debug)]
133 struct Disk {
134 path: String,
135 size: u64,
136 }
137
138 impl fmt::Display for Disk {
139 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140 // TODO: Format sizes properly with `proxmox-human-byte` once merged
141 // https://lists.proxmox.com/pipermail/pbs-devel/2023-May/006125.html
142 write!(f, "{} ({} B)", self.path, self.size)
143 }
144 }
145
146 #[derive(Clone, Debug)]
147 struct BootdiskOptions {
148 disks: Vec<Disk>,
149 fstype: FsType,
150 advanced: AdvancedBootdiskOptions,
151 }
152
153 #[derive(Clone, Debug)]
154 struct TimezoneOptions {
155 timezone: String,
156 kb_layout: String,
157 }
158
159 impl Default for TimezoneOptions {
160 fn default() -> Self {
161 Self {
162 timezone: "Europe/Vienna".to_owned(),
163 kb_layout: "en_US".to_owned(),
164 }
165 }
166 }
167
168 #[derive(Clone, Debug)]
169 struct PasswordOptions {
170 email: String,
171 root_password: String,
172 }
173
174 impl Default for PasswordOptions {
175 fn default() -> Self {
176 Self {
177 email: "mail@example.invalid".to_owned(),
178 root_password: String::new(),
179 }
180 }
181 }
182
183 #[derive(Clone, Debug)]
184 struct NetworkOptions {
185 ifname: String,
186 fqdn: String,
187 ip_addr: IpAddr,
188 cidr_mask: usize,
189 gateway: IpAddr,
190 dns_server: IpAddr,
191 }
192
193 impl Default for NetworkOptions {
194 fn default() -> Self {
195 // TODO: Retrieve automatically
196 Self {
197 ifname: String::new(),
198 fqdn: "pve.example.invalid".to_owned(),
199 ip_addr: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
200 cidr_mask: 0,
201 gateway: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
202 dns_server: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
203 }
204 }
205 }
206
207 #[derive(Clone, Debug)]
208 struct InstallerOptions {
209 bootdisk: BootdiskOptions,
210 timezone: TimezoneOptions,
211 password: PasswordOptions,
212 network: NetworkOptions,
213 }
214
215 impl InstallerOptions {
216 fn to_summary(&self) -> Vec<SummaryOption> {
217 vec![
218 SummaryOption::new("Bootdisk filesystem", self.bootdisk.fstype.to_string()),
219 SummaryOption::new(
220 "Bootdisks",
221 self.bootdisk
222 .advanced
223 .selected_disks()
224 .map(|d| d.path.as_str())
225 .collect::<Vec<&str>>()
226 .join(", "),
227 ),
228 SummaryOption::new("Timezone", &self.timezone.timezone),
229 SummaryOption::new("Keyboard layout", &self.timezone.kb_layout),
230 SummaryOption::new("Administator email:", &self.password.email),
231 SummaryOption::new("Management interface:", &self.network.ifname),
232 SummaryOption::new("Hostname:", &self.network.fqdn),
233 SummaryOption::new(
234 "Host IP (CIDR):",
235 format!("{}/{}", self.network.ip_addr, self.network.cidr_mask),
236 ),
237 SummaryOption::new("Gateway", self.network.gateway.to_string()),
238 SummaryOption::new("DNS:", self.network.dns_server.to_string()),
239 ]
240 }
241 }
242
243 fn main() {
244 let mut siv = cursive::termion();
245
246 siv.clear_global_callbacks(Event::CtrlChar('c'));
247 siv.set_on_pre_event(Event::CtrlChar('c'), trigger_abort_install_dialog);
248
249 let disks = vec![Disk {
250 path: "/dev/vda".to_owned(),
251 size: 17179869184,
252 }];
253 siv.set_user_data(InstallerOptions {
254 bootdisk: BootdiskOptions {
255 disks: disks.clone(),
256 fstype: FsType::default(),
257 advanced: AdvancedBootdiskOptions::Lvm(LvmBootdiskOptions::defaults_from(&disks[0])),
258 },
259 timezone: TimezoneOptions::default(),
260 password: PasswordOptions::default(),
261 network: NetworkOptions::default(),
262 });
263
264 add_next_screen(&license_dialog)(&mut siv);
265 siv.run();
266 }
267
268 fn add_next_screen(
269 constructor: &dyn Fn(&mut Cursive) -> InstallerView,
270 ) -> Box<dyn Fn(&mut Cursive) + '_> {
271 Box::new(|siv: &mut Cursive| {
272 let v = constructor(siv);
273 siv.add_active_screen();
274 siv.screen_mut().add_layer(v);
275 })
276 }
277
278 fn switch_to_prev_screen(siv: &mut Cursive) {
279 let id = siv.active_screen().saturating_sub(1);
280 siv.set_screen(id);
281 }
282
283 fn yes_no_dialog(
284 siv: &mut Cursive,
285 title: &str,
286 text: &str,
287 callback: &'static dyn Fn(&mut Cursive),
288 ) {
289 siv.add_layer(
290 Dialog::around(TextView::new(text))
291 .title(title)
292 .dismiss_button("No")
293 .button("Yes", callback),
294 )
295 }
296
297 fn trigger_abort_install_dialog(siv: &mut Cursive) {
298 #[cfg(debug_assertions)]
299 siv.quit();
300
301 #[cfg(not(debug_assertions))]
302 yes_no_dialog(
303 siv,
304 "Abort installation?",
305 "Are you sure you want to abort the installation?",
306 &Cursive::quit,
307 )
308 }
309
310 fn abort_install_button() -> Button {
311 Button::new("Abort", trigger_abort_install_dialog)
312 }
313
314 fn get_eula() -> String {
315 // TODO: properly using info from Proxmox::Install::Env::setup()
316 std::fs::read_to_string("/cdrom/EULA")
317 .unwrap_or_else(|_| "< Debug build - ignoring non-existing EULA >".to_owned())
318 }
319
320 fn license_dialog(_: &mut Cursive) -> InstallerView {
321 let inner = LinearLayout::vertical()
322 .child(PaddedView::lrtb(
323 0,
324 0,
325 1,
326 0,
327 TextView::new("END USER LICENSE AGREEMENT (EULA)").center(),
328 ))
329 .child(Panel::new(ScrollView::new(
330 TextView::new(get_eula()).center(),
331 )))
332 .child(PaddedView::lrtb(
333 1,
334 1,
335 1,
336 0,
337 LinearLayout::horizontal()
338 .child(abort_install_button())
339 .child(DummyView.full_width())
340 .child(Button::new("I agree", add_next_screen(&bootdisk_dialog))),
341 ));
342
343 InstallerView::with_raw(inner)
344 }
345
346 fn bootdisk_dialog(siv: &mut Cursive) -> InstallerView {
347 let options = siv
348 .user_data::<InstallerOptions>()
349 .map(|o| o.clone())
350 .unwrap()
351 .bootdisk;
352
353 let AdvancedBootdiskOptions::Lvm(advanced) = options.advanced;
354
355 let fstype_select = LinearLayout::horizontal()
356 .child(TextView::new("Filesystem: "))
357 .child(DummyView.full_width())
358 .child(
359 SelectView::new()
360 .popup()
361 .with_all(FS_TYPES.iter().map(|t| (t.to_string(), t)))
362 .selected(
363 FS_TYPES
364 .iter()
365 .position(|t| *t == options.fstype)
366 .unwrap_or_default(),
367 )
368 .on_submit({
369 let disks = options.disks.clone();
370 let advanced = advanced.clone();
371 move |siv, fstype: &FsType| {
372 let view = match fstype {
373 FsType::Ext4 | FsType::Xfs => {
374 LvmBootdiskOptionsView::new(&disks, &advanced)
375 }
376 };
377
378 siv.call_on_name("bootdisk-options", |v: &mut LinearLayout| {
379 v.clear();
380 v.add_child(view);
381 });
382 }
383 })
384 .with_name("fstype")
385 .full_width(),
386 );
387
388 let inner = LinearLayout::vertical()
389 .child(fstype_select)
390 .child(DummyView)
391 .child(
392 LinearLayout::horizontal()
393 .child(LvmBootdiskOptionsView::new(&options.disks, &advanced))
394 .with_name("bootdisk-options"),
395 );
396
397 InstallerView::new(
398 inner,
399 Box::new(|siv| {
400 let options = siv
401 .call_on_name("bootdisk-options", |v: &mut LinearLayout| {
402 v.get_child_mut(0)?
403 .downcast_mut::<LvmBootdiskOptionsView>()?
404 .get_values()
405 .map(AdvancedBootdiskOptions::Lvm)
406 })
407 .flatten();
408
409 if let Some(options) = options {
410 siv.with_user_data(|opts: &mut InstallerOptions| {
411 opts.bootdisk.advanced = options;
412 });
413
414 add_next_screen(&timezone_dialog)(siv)
415 } else {
416 siv.add_layer(Dialog::info("Invalid values"));
417 }
418 }),
419 )
420 }
421
422 struct LvmBootdiskOptionsView {
423 view: LinearLayout,
424 }
425
426 impl LvmBootdiskOptionsView {
427 fn new(disks: &[Disk], options: &LvmBootdiskOptions) -> Self {
428 let view = LinearLayout::vertical()
429 .child(FormInputView::new(
430 "Target harddisk",
431 SelectView::new()
432 .popup()
433 .with_all(disks.iter().map(|d| (d.to_string(), d.clone())))
434 .with_name("bootdisk-disk"),
435 ))
436 .child(DiskSizeFormInputView::new("Total size").content(options.total_size))
437 .child(DiskSizeFormInputView::new("Swap size").content(options.swap_size))
438 .child(
439 DiskSizeFormInputView::new("Maximum root volume size")
440 .content(options.max_root_size),
441 )
442 .child(
443 DiskSizeFormInputView::new("Maximum data volume size")
444 .content(options.max_data_size),
445 )
446 .child(
447 DiskSizeFormInputView::new("Minimum free LVM space").content(options.min_lvm_free),
448 );
449
450 Self { view }
451 }
452
453 fn get_values(&mut self) -> Option<LvmBootdiskOptions> {
454 let disk = self
455 .view
456 .call_on_name("bootdisk-disk", |view: &mut SelectView<Disk>| {
457 view.selection()
458 })?
459 .map(|d| (*d).clone())?;
460
461 let mut get_disksize_value = |i| {
462 self.view
463 .get_child_mut(i)?
464 .downcast_mut::<DiskSizeFormInputView>()?
465 .get_content()
466 };
467
468 Some(LvmBootdiskOptions {
469 disk,
470 total_size: get_disksize_value(1)?,
471 swap_size: get_disksize_value(2)?,
472 max_root_size: get_disksize_value(3)?,
473 max_data_size: get_disksize_value(4)?,
474 min_lvm_free: get_disksize_value(5)?,
475 })
476 }
477 }
478
479 impl ViewWrapper for LvmBootdiskOptionsView {
480 cursive::wrap_impl!(self.view: LinearLayout);
481 }
482
483 fn timezone_dialog(siv: &mut Cursive) -> InstallerView {
484 let options = siv
485 .user_data::<InstallerOptions>()
486 .map(|o| o.timezone.clone())
487 .unwrap_or_default();
488
489 let inner = LinearLayout::vertical()
490 .child(FormInputView::new(
491 "Country",
492 EditView::new().content("Austria"),
493 ))
494 .child(FormInputView::new(
495 "Timezone",
496 EditView::new()
497 .content(options.timezone)
498 .with_name("timezone-tzname"),
499 ))
500 .child(FormInputView::new(
501 "Keyboard layout",
502 EditView::new()
503 .content(options.kb_layout)
504 .with_name("timezone-kblayout"),
505 ));
506
507 InstallerView::new(
508 inner,
509 Box::new(|siv| {
510 let timezone = siv.call_on_name("timezone-tzname", |v: &mut EditView| {
511 (*v.get_content()).clone()
512 });
513
514 let kb_layout = siv.call_on_name("timezone-kblayout", |v: &mut EditView| {
515 (*v.get_content()).clone()
516 });
517
518 if let (Some(timezone), Some(kb_layout)) = (timezone, kb_layout) {
519 siv.with_user_data(|opts: &mut InstallerOptions| {
520 opts.timezone = TimezoneOptions {
521 timezone,
522 kb_layout,
523 };
524 });
525
526 add_next_screen(&password_dialog)(siv);
527 } else {
528 siv.add_layer(Dialog::info("Invalid values"));
529 }
530 }),
531 )
532 }
533
534 fn password_dialog(siv: &mut Cursive) -> InstallerView {
535 let options = siv
536 .user_data::<InstallerOptions>()
537 .map(|o| o.password.clone())
538 .unwrap_or_default();
539
540 let inner = LinearLayout::vertical()
541 .child(FormInputView::new(
542 "Root password",
543 EditView::new()
544 .secret()
545 .with_name("password-dialog-root-pw"),
546 ))
547 .child(FormInputView::new(
548 "Confirm root password",
549 EditView::new()
550 .secret()
551 .with_name("password-dialog-root-pw-confirm"),
552 ))
553 .child(FormInputView::new(
554 "Administator email",
555 EditView::new()
556 .content(options.email)
557 .with_name("password-dialog-email"),
558 ));
559
560 InstallerView::new(
561 inner,
562 Box::new(|siv| {
563 // TODO: password validation
564 add_next_screen(&network_dialog)(siv)
565 }),
566 )
567 }
568
569 fn network_dialog(siv: &mut Cursive) -> InstallerView {
570 let options = siv
571 .user_data::<InstallerOptions>()
572 .map(|o| o.network.clone())
573 .unwrap_or_default();
574
575 let inner = LinearLayout::vertical()
576 .child(FormInputView::new(
577 "Management interface",
578 SelectView::new().popup().with_all_str(vec!["eth0"]),
579 ))
580 .child(FormInputView::new(
581 "Hostname (FQDN)",
582 EditView::new().content(options.fqdn),
583 ))
584 .child(FormInputView::new(
585 "IP address (CIDR)",
586 CidrAddressEditView::new().content(options.ip_addr, options.cidr_mask),
587 ))
588 .child(FormInputView::new(
589 "Gateway address",
590 EditView::new().content(options.gateway.to_string()),
591 ))
592 .child(FormInputView::new(
593 "DNS server address",
594 EditView::new().content(options.dns_server.to_string()),
595 ));
596
597 InstallerView::new(
598 inner,
599 Box::new(|siv| {
600 add_next_screen(&summary_dialog)(siv);
601 }),
602 )
603 }
604
605 struct SummaryOption {
606 name: &'static str,
607 value: String,
608 }
609
610 impl SummaryOption {
611 pub fn new<S: Into<String>>(name: &'static str, value: S) -> Self {
612 Self {
613 name,
614 value: value.into(),
615 }
616 }
617 }
618
619 impl TableViewItem for SummaryOption {
620 fn get_column(&self, name: &str) -> String {
621 match name {
622 "name" => self.name.to_owned(),
623 "value" => self.value.clone(),
624 _ => unreachable!(),
625 }
626 }
627 }
628
629 fn summary_dialog(siv: &mut Cursive) -> InstallerView {
630 let options = siv
631 .user_data::<InstallerOptions>()
632 .map(|o| o.clone())
633 .unwrap();
634
635 let inner = LinearLayout::vertical()
636 .child(PaddedView::lrtb(
637 0,
638 0,
639 1,
640 2,
641 TableView::new()
642 .columns(&[
643 ("name".to_owned(), "Option".to_owned()),
644 ("value".to_owned(), "Selected value".to_owned()),
645 ])
646 .items(options.to_summary()),
647 ))
648 .child(
649 LinearLayout::horizontal()
650 .child(DummyView.full_width())
651 .child(Checkbox::new().with_name("reboot-after-install"))
652 .child(
653 TextView::new(" Automatically reboot after successful installation").no_wrap(),
654 )
655 .child(DummyView.full_width()),
656 )
657 .child(PaddedView::lrtb(
658 1,
659 1,
660 1,
661 0,
662 LinearLayout::horizontal()
663 .child(abort_install_button())
664 .child(DummyView.full_width())
665 .child(Button::new("Previous", switch_to_prev_screen))
666 .child(DummyView)
667 .child(Button::new("Install", |_| {})),
668 ));
669
670 InstallerView::with_raw(inner)
671 }