Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6b4c144b73 |
|||
|
009173a97d |
|||
|
6a535099bb |
60 changed files with 5915 additions and 402 deletions
115
Cargo.lock
generated
115
Cargo.lock
generated
|
|
@ -327,6 +327,56 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.104"
|
version = "1.0.104"
|
||||||
|
|
@ -682,6 +732,46 @@ dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "client"
|
name = "client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -749,6 +839,12 @@ version = "0.5.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
|
|
@ -2089,12 +2185,16 @@ dependencies = [
|
||||||
name = "iota"
|
name = "iota"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"clap",
|
||||||
"iota-cli",
|
"iota-cli",
|
||||||
"iota-core",
|
"iota-core",
|
||||||
"iota-installer",
|
"iota-installer",
|
||||||
"iota-ipc",
|
"iota-ipc",
|
||||||
"iota-paths",
|
"iota-paths",
|
||||||
"iota-process-manager",
|
"iota-process-manager",
|
||||||
|
"iota-terms",
|
||||||
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
]
|
]
|
||||||
|
|
@ -2251,6 +2351,7 @@ dependencies = [
|
||||||
"iota-paths",
|
"iota-paths",
|
||||||
"iota-state",
|
"iota-state",
|
||||||
"iota-storage",
|
"iota-storage",
|
||||||
|
"iota-terms",
|
||||||
"iota-util",
|
"iota-util",
|
||||||
"omikron-connector",
|
"omikron-connector",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
@ -2268,10 +2369,12 @@ dependencies = [
|
||||||
"iota-logger",
|
"iota-logger",
|
||||||
"iota-state",
|
"iota-state",
|
||||||
"iota-storage",
|
"iota-storage",
|
||||||
|
"iota-updater",
|
||||||
"iota-util",
|
"iota-util",
|
||||||
"libc",
|
"libc",
|
||||||
"mtp",
|
"mtp",
|
||||||
"omikron-connector",
|
"omikron-connector",
|
||||||
|
"serde_yaml",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
@ -2465,6 +2568,12 @@ dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
|
|
@ -3175,6 +3284,12 @@ version = "1.21.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opaque-debug"
|
name = "opaque-debug"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -28,6 +28,27 @@ theme: surface
|
||||||
|
|
||||||
An invalid `ui.yaml` value is reported and Iota falls back to ANSI so the TUI can still start.
|
An invalid `ui.yaml` value is reported and Iota falls back to ANSI so the TUI can still start.
|
||||||
|
|
||||||
|
## Accepting terms without the TUI
|
||||||
|
|
||||||
|
Iota services do not start until the required agreements have been accepted for
|
||||||
|
the deployment. Use the terminal flow to read each current document and type
|
||||||
|
the document-specific acceptance phrase:
|
||||||
|
|
||||||
|
```text
|
||||||
|
iota terms accept
|
||||||
|
```
|
||||||
|
|
||||||
|
For a system-managed daemon, accept its deployment-scoped terms as an account
|
||||||
|
that can write the system Iota state directory (normally via `sudo`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
sudo iota terms accept --system
|
||||||
|
```
|
||||||
|
|
||||||
|
`iota terms status` reports the stored state, and `iota terms show eula`,
|
||||||
|
`iota terms show tos`, or `iota terms show privacy` displays an individual
|
||||||
|
document without accepting it.
|
||||||
|
|
||||||
# Linux daemon installation
|
# Linux daemon installation
|
||||||
|
|
||||||
The system-managed daemon runs as the dedicated `iota` account and listens on
|
The system-managed daemon runs as the dedicated `iota` account and listens on
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,12 @@ impl ClientConnection {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cv.is_type(CommunicationType::ClientStateAck) {
|
||||||
|
self.send_message(&message_handlers::handle_client_state_ack(&cv))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ************************************************ //
|
// ************************************************ //
|
||||||
// Direct messages //
|
// Direct messages //
|
||||||
// ************************************************ //
|
// ************************************************ //
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Alignment, Rect},
|
layout::{Alignment, Rect},
|
||||||
text::Span,
|
text::Span,
|
||||||
widgets::{Block, Borders, Paragraph},
|
widgets::Paragraph,
|
||||||
};
|
};
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -39,9 +39,15 @@ pub fn render_button(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(Span::styled(button.label, style))
|
Paragraph::new(Span::styled(
|
||||||
.alignment(Alignment::Center)
|
if button.focused {
|
||||||
.block(Block::default().borders(Borders::ALL)),
|
format!("› {}", button.label)
|
||||||
|
} else {
|
||||||
|
button.label.to_owned()
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
62
iota-cli/src/controls/header.rs
Normal file
62
iota-cli/src/controls/header.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
use crate::theme::ResolvedTheme;
|
||||||
|
use crate::{
|
||||||
|
controls::button::{ActionButton, ButtonIntent, render_button},
|
||||||
|
screens::screens::{AppAction, HitMap},
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
text::Span,
|
||||||
|
widgets::Paragraph,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Shared application bar. The brand cell is deliberately an action so it is
|
||||||
|
/// a reliable way home from every screen.
|
||||||
|
pub fn render_header(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
title: &str,
|
||||||
|
theme: &ResolvedTheme,
|
||||||
|
hits: &mut HitMap,
|
||||||
|
focused_action: Option<usize>,
|
||||||
|
) {
|
||||||
|
let cells = Layout::horizontal([
|
||||||
|
Constraint::Min(28),
|
||||||
|
Constraint::Length(12),
|
||||||
|
Constraint::Length(12),
|
||||||
|
Constraint::Length(12),
|
||||||
|
Constraint::Length(8),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Span::styled(format!(" {title}"), theme.surfaces.toolbar)),
|
||||||
|
cells[0],
|
||||||
|
);
|
||||||
|
hits.register(cells[0], AppAction::OpenMain);
|
||||||
|
for (index, (area, label, action)) in [
|
||||||
|
(cells[1], "Overview", AppAction::OpenOverview),
|
||||||
|
(cells[2], "Users", AppAction::OpenUsers),
|
||||||
|
(cells[3], "Settings", AppAction::OpenSettings),
|
||||||
|
(cells[4], "Quit", AppAction::Quit),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
render_button(
|
||||||
|
frame,
|
||||||
|
area,
|
||||||
|
ActionButton {
|
||||||
|
label,
|
||||||
|
intent: if action == AppAction::Quit {
|
||||||
|
ButtonIntent::Destructive
|
||||||
|
} else {
|
||||||
|
ButtonIntent::Neutral
|
||||||
|
},
|
||||||
|
focused: focused_action == Some(index),
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
theme,
|
||||||
|
);
|
||||||
|
hits.register(area, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,5 +2,8 @@ pub mod action;
|
||||||
pub mod button;
|
pub mod button;
|
||||||
pub mod checkbox_group;
|
pub mod checkbox_group;
|
||||||
pub mod choice;
|
pub mod choice;
|
||||||
|
pub mod header;
|
||||||
pub mod navigation;
|
pub mod navigation;
|
||||||
|
pub mod panel;
|
||||||
pub mod radio_group;
|
pub mod radio_group;
|
||||||
|
pub mod scroll;
|
||||||
|
|
|
||||||
60
iota-cli/src/controls/panel.rs
Normal file
60
iota-cli/src/controls/panel.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
use crate::theme::{ChromeMode, ResolvedTheme};
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::Rect,
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Draw a conventional outlined panel or a filled surface from the same call
|
||||||
|
/// site. Screens can migrate without embedding theme branches in layouts.
|
||||||
|
pub fn render_panel(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
title: &str,
|
||||||
|
focused: bool,
|
||||||
|
theme: &ResolvedTheme,
|
||||||
|
) -> Rect {
|
||||||
|
match theme.chrome {
|
||||||
|
ChromeMode::Bordered => {
|
||||||
|
let block = Block::default()
|
||||||
|
.title(title)
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(if focused {
|
||||||
|
theme.borders.focused
|
||||||
|
} else {
|
||||||
|
theme.borders.normal
|
||||||
|
});
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
ChromeMode::Surfaces => {
|
||||||
|
frame.render_widget(
|
||||||
|
Block::default().style(if focused {
|
||||||
|
theme.surfaces.panel_focused
|
||||||
|
} else {
|
||||||
|
theme.surfaces.panel
|
||||||
|
}),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
let header = Rect {
|
||||||
|
x: area.x,
|
||||||
|
y: area.y,
|
||||||
|
width: area.width,
|
||||||
|
height: area.height.min(1),
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!(" {title}")).style(theme.surfaces.panel_alternate),
|
||||||
|
header,
|
||||||
|
);
|
||||||
|
// Surface panels use a single header row. A one-cell inset keeps
|
||||||
|
// compact controls such as the console usable at height three.
|
||||||
|
Rect {
|
||||||
|
x: area.x.saturating_add(1),
|
||||||
|
y: area.y.saturating_add(1),
|
||||||
|
width: area.width.saturating_sub(2),
|
||||||
|
height: area.height.saturating_sub(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
iota-cli/src/controls/scroll.rs
Normal file
53
iota-cli/src/controls/scroll.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::Rect,
|
||||||
|
widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Reusable viewport policy for long, vertically stacked terminal content.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct ScrollOptions {
|
||||||
|
pub show_scrollbar: bool,
|
||||||
|
pub render_partial_components: bool,
|
||||||
|
}
|
||||||
|
impl Default for ScrollOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
show_scrollbar: true,
|
||||||
|
render_partial_components: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct ScrollField {
|
||||||
|
pub offset: u16,
|
||||||
|
pub options: ScrollOptions,
|
||||||
|
}
|
||||||
|
impl ScrollField {
|
||||||
|
pub fn up(&mut self, amount: u16) {
|
||||||
|
self.offset = self.offset.saturating_sub(amount);
|
||||||
|
}
|
||||||
|
pub fn down(&mut self, amount: u16, content_height: u16, viewport_height: u16) {
|
||||||
|
self.offset = (self.offset.saturating_add(amount))
|
||||||
|
.min(content_height.saturating_sub(viewport_height));
|
||||||
|
}
|
||||||
|
pub fn render(
|
||||||
|
&self,
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
content: Paragraph<'_>,
|
||||||
|
content_height: u16,
|
||||||
|
) {
|
||||||
|
frame.render_widget(content.scroll((self.offset, 0)), area);
|
||||||
|
if self.options.show_scrollbar && content_height > area.height {
|
||||||
|
let mut state =
|
||||||
|
ScrollbarState::new(content_height as usize).position(self.offset as usize);
|
||||||
|
frame.render_stateful_widget(
|
||||||
|
Scrollbar::new(ScrollbarOrientation::VerticalRight),
|
||||||
|
area,
|
||||||
|
&mut state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
|
|
@ -35,6 +35,10 @@ pub struct ConsoleCard {
|
||||||
last_swap: Arc<Mutex<Instant>>,
|
last_swap: Arc<Mutex<Instant>>,
|
||||||
pending_restore: Arc<Mutex<Option<String>>>,
|
pending_restore: Arc<Mutex<Option<String>>>,
|
||||||
pending_confirmation: Option<String>,
|
pending_confirmation: Option<String>,
|
||||||
|
history: Vec<String>,
|
||||||
|
history_index: Option<usize>,
|
||||||
|
history_draft: String,
|
||||||
|
message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConsoleCard {
|
impl ConsoleCard {
|
||||||
|
|
@ -51,6 +55,10 @@ impl ConsoleCard {
|
||||||
last_swap: Arc::new(Mutex::new(Instant::now())),
|
last_swap: Arc::new(Mutex::new(Instant::now())),
|
||||||
pending_restore: Arc::new(Mutex::new(None)),
|
pending_restore: Arc::new(Mutex::new(None)),
|
||||||
pending_confirmation: None,
|
pending_confirmation: None,
|
||||||
|
history: Vec::new(),
|
||||||
|
history_index: None,
|
||||||
|
history_draft: String::new(),
|
||||||
|
message: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,6 +159,9 @@ impl ConsoleCard {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Span<'static>> {
|
fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Span<'static>> {
|
||||||
|
if let Some(message) = &self.message {
|
||||||
|
return vec![Span::styled(message.clone(), theme.console.error)];
|
||||||
|
}
|
||||||
if let Some(command) = &self.pending_confirmation {
|
if let Some(command) = &self.pending_confirmation {
|
||||||
return vec![Span::styled(
|
return vec![Span::styled(
|
||||||
format!("Confirm `{command}`? [y/N]"),
|
format!("Confirm `{command}`? [y/N]"),
|
||||||
|
|
@ -163,11 +174,12 @@ impl ConsoleCard {
|
||||||
fn is_destructive(command: &str) -> bool {
|
fn is_destructive(command: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
command.trim_start_matches('/').trim(),
|
command.trim_start_matches('/').trim(),
|
||||||
"restart" | "reload" | "stop" | "shutdown" | "regenerate keys"
|
"restart" | "reload" | "stop" | "shutdown" | "regenerate keys" | "identity rotate"
|
||||||
) || command
|
) || command
|
||||||
.trim_start_matches('/')
|
.trim_start_matches('/')
|
||||||
.trim_start()
|
.trim_start()
|
||||||
.starts_with("user remove ")
|
.split_once(" remove ")
|
||||||
|
.is_some_and(|(noun, _)| matches!(noun, "user" | "users"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch_command(&self, command: String) {
|
fn dispatch_command(&self, command: String) {
|
||||||
|
|
@ -214,6 +226,69 @@ impl ConsoleCard {
|
||||||
self.content.insert(idx, c);
|
self.content.insert(idx, c);
|
||||||
self.cursor_position += 1;
|
self.cursor_position += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn handle_paste(&mut self, text: &str) {
|
||||||
|
let sanitized = text.replace(['\r', '\n'], " ");
|
||||||
|
let index = self.byte_index();
|
||||||
|
self.content.insert_str(index, &sanitized);
|
||||||
|
self.cursor_position += sanitized.chars().count();
|
||||||
|
self.message = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_editor(&mut self, value: String) {
|
||||||
|
self.content = value;
|
||||||
|
self.cursor_position = self.content.chars().count();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history_previous(&mut self) {
|
||||||
|
if self.history.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let index = match self.history_index {
|
||||||
|
None => {
|
||||||
|
self.history_draft = self.content.clone();
|
||||||
|
self.history.len() - 1
|
||||||
|
}
|
||||||
|
Some(index) => index.saturating_sub(1),
|
||||||
|
};
|
||||||
|
self.history_index = Some(index);
|
||||||
|
self.set_editor(self.history[index].clone());
|
||||||
|
self.message = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history_next(&mut self) {
|
||||||
|
let Some(index) = self.history_index else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if index + 1 < self.history.len() {
|
||||||
|
self.history_index = Some(index + 1);
|
||||||
|
self.set_editor(self.history[index + 1].clone());
|
||||||
|
} else {
|
||||||
|
self.history_index = None;
|
||||||
|
let draft = std::mem::take(&mut self.history_draft);
|
||||||
|
self.set_editor(draft);
|
||||||
|
}
|
||||||
|
self.message = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn complete(&mut self) -> bool {
|
||||||
|
let completions = iota_ipc::text_commands::completions(&self.content);
|
||||||
|
if completions.len() == 1 {
|
||||||
|
let leading_slash = self.content.starts_with('/');
|
||||||
|
self.set_editor(format!(
|
||||||
|
"{}{}",
|
||||||
|
if leading_slash { "/" } else { "" },
|
||||||
|
completions[0]
|
||||||
|
));
|
||||||
|
self.message = None;
|
||||||
|
true
|
||||||
|
} else if completions.len() > 1 {
|
||||||
|
self.message = Some(format!("Matches: {}", completions.join(", ")));
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Element for ConsoleCard {
|
impl Element for ConsoleCard {
|
||||||
|
|
@ -226,6 +301,21 @@ impl Element for ConsoleCard {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
|
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
|
||||||
|
if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||||
|
let inner = crate::controls::panel::render_panel(
|
||||||
|
f,
|
||||||
|
r,
|
||||||
|
&self.title,
|
||||||
|
self.focused,
|
||||||
|
context.theme,
|
||||||
|
);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(self.render_cursor_spans(context.theme)))
|
||||||
|
.style(context.theme.console.text),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(self.borders)
|
.borders(self.borders)
|
||||||
.title(self.title.clone())
|
.title(self.title.clone())
|
||||||
|
|
@ -304,6 +394,7 @@ impl InteractableElement for ConsoleCard {
|
||||||
if let Some(restored) = self.pending_restore.lock().unwrap().take() {
|
if let Some(restored) = self.pending_restore.lock().unwrap().take() {
|
||||||
self.content = restored;
|
self.content = restored;
|
||||||
self.cursor_position = self.content.chars().count();
|
self.cursor_position = self.content.chars().count();
|
||||||
|
self.message = Some("Command failed; restored for retry.".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(command) = self.pending_confirmation.take() {
|
if let Some(command) = self.pending_confirmation.take() {
|
||||||
|
|
@ -320,6 +411,22 @@ impl InteractableElement for ConsoleCard {
|
||||||
}
|
}
|
||||||
|
|
||||||
let command = self.content.clone();
|
let command = self.content.clone();
|
||||||
|
if let Some(error) = iota_ipc::text_commands::validation_error(&command) {
|
||||||
|
self.message = Some(error);
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
if command.trim_start_matches('/').trim() == "help" {
|
||||||
|
self.message = Some(format!(
|
||||||
|
"Commands: {}",
|
||||||
|
iota_ipc::text_commands::COMMANDS.join(", ")
|
||||||
|
));
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
if self.history.last() != Some(&command) {
|
||||||
|
self.history.push(command.clone());
|
||||||
|
}
|
||||||
|
self.history_index = None;
|
||||||
|
self.history_draft.clear();
|
||||||
self.content.clear();
|
self.content.clear();
|
||||||
self.cursor_position = 0;
|
self.cursor_position = 0;
|
||||||
if Self::is_destructive(&command) {
|
if Self::is_destructive(&command) {
|
||||||
|
|
@ -330,10 +437,12 @@ impl InteractableElement for ConsoleCard {
|
||||||
InteractionResult::Handled
|
InteractionResult::Handled
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
|
self.message = None;
|
||||||
self.delete_at_cursor();
|
self.delete_at_cursor();
|
||||||
InteractionResult::Handled
|
InteractionResult::Handled
|
||||||
}
|
}
|
||||||
KeyCode::Delete => {
|
KeyCode::Delete => {
|
||||||
|
self.message = None;
|
||||||
let len = self.content.chars().count();
|
let len = self.content.chars().count();
|
||||||
if self.cursor_position < len {
|
if self.cursor_position < len {
|
||||||
let start = self.byte_index();
|
let start = self.byte_index();
|
||||||
|
|
@ -363,15 +472,36 @@ impl InteractableElement for ConsoleCard {
|
||||||
self.cursor_position = self.content.chars().count();
|
self.cursor_position = self.content.chars().count();
|
||||||
InteractionResult::Handled
|
InteractionResult::Handled
|
||||||
}
|
}
|
||||||
KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled,
|
KeyCode::Up => {
|
||||||
_ => {
|
self.history_previous();
|
||||||
if let Some(c) = key.code.as_char() {
|
InteractionResult::Handled
|
||||||
self.insert_at_cursor(c);
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
self.history_next();
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Tab if !self.content.is_empty() => {
|
||||||
|
if self.complete() {
|
||||||
InteractionResult::Handled
|
InteractionResult::Handled
|
||||||
} else {
|
} else {
|
||||||
InteractionResult::Unhandled
|
self.message = Some("No command completion.".into());
|
||||||
|
InteractionResult::Handled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled,
|
||||||
|
_ => {
|
||||||
|
if !key
|
||||||
|
.modifiers
|
||||||
|
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||||
|
{
|
||||||
|
if let Some(c) = key.code.as_char() {
|
||||||
|
self.insert_at_cursor(c);
|
||||||
|
self.message = None;
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InteractionResult::Unhandled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,15 +34,24 @@ impl GRAPHS {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_graph(&self, state: &ClientState) -> Vec<(f64, f64)> {
|
pub fn get_graph(&self, state: &ClientState, sample_width: usize) -> Vec<(f64, f64)> {
|
||||||
let state = match state.app.try_lock() {
|
let state = match state.app.try_lock() {
|
||||||
Ok(state) => state,
|
Ok(state) => state,
|
||||||
Err(_) => return Vec::new(),
|
Err(_) => return Vec::new(),
|
||||||
};
|
};
|
||||||
match self {
|
match self {
|
||||||
GRAPHS::Ram => state.with_width(28).ram.clone(),
|
GRAPHS::Ram => state
|
||||||
GRAPHS::Cpu => state.with_width(28).cpu.clone(),
|
.with_width(sample_width.min(u16::MAX as usize) as u16)
|
||||||
GRAPHS::Ping => state.with_width(28).ping.clone(),
|
.ram
|
||||||
|
.clone(),
|
||||||
|
GRAPHS::Cpu => state
|
||||||
|
.with_width(sample_width.min(u16::MAX as usize) as u16)
|
||||||
|
.cpu
|
||||||
|
.clone(),
|
||||||
|
GRAPHS::Ping => state
|
||||||
|
.with_width(sample_width.min(u16::MAX as usize) as u16)
|
||||||
|
.ping
|
||||||
|
.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,6 +78,7 @@ pub struct GraphCard {
|
||||||
joins: Borders,
|
joins: Borders,
|
||||||
|
|
||||||
open: bool,
|
open: bool,
|
||||||
|
sample_width: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GraphCard {
|
impl GraphCard {
|
||||||
|
|
@ -82,12 +92,17 @@ impl GraphCard {
|
||||||
borders: Borders::ALL,
|
borders: Borders::ALL,
|
||||||
joins: Borders::NONE,
|
joins: Borders::NONE,
|
||||||
open: true,
|
open: true,
|
||||||
|
sample_width: 28,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_open(&mut self, open: bool) {
|
pub fn set_open(&mut self, open: bool) {
|
||||||
self.open = open;
|
self.open = open;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_sample_width(&mut self, sample_width: usize) {
|
||||||
|
self.sample_width = sample_width.max(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
impl Element for GraphCard {
|
impl Element for GraphCard {
|
||||||
fn as_any(&self) -> &dyn Any {
|
fn as_any(&self) -> &dyn Any {
|
||||||
|
|
@ -100,7 +115,24 @@ impl Element for GraphCard {
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
|
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
|
||||||
if self.open {
|
if self.open {
|
||||||
let graph = self.graph_type.get_graph(&self.state);
|
let graph = self.graph_type.get_graph(&self.state, self.sample_width);
|
||||||
|
if graph.is_empty() {
|
||||||
|
let block = Block::default()
|
||||||
|
.title(format!(" {} ", self.title))
|
||||||
|
.borders(self.borders)
|
||||||
|
.border_style(if self.focused {
|
||||||
|
context.theme.graphs.focused_border
|
||||||
|
} else {
|
||||||
|
context.theme.graphs.border
|
||||||
|
});
|
||||||
|
f.render_widget(
|
||||||
|
ratatui::widgets::Paragraph::new("No metric samples yet.")
|
||||||
|
.style(context.theme.text.muted)
|
||||||
|
.block(block),
|
||||||
|
r,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let unit = self.graph_type.get_unit();
|
let unit = self.graph_type.get_unit();
|
||||||
let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0);
|
let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0);
|
||||||
let max_x = graph.last().map(|(x, _)| *x).unwrap_or(100.0);
|
let max_x = graph.last().map(|(x, _)| *x).unwrap_or(100.0);
|
||||||
|
|
@ -117,16 +149,34 @@ impl Element for GraphCard {
|
||||||
GRAPHS::Ping => (max_y * 1.2).max(10.0),
|
GRAPHS::Ping => (max_y * 1.2).max(10.0),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let surface = matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces);
|
||||||
|
let title = format!(
|
||||||
|
"{}: {}{} {}min/{}max",
|
||||||
|
self.title,
|
||||||
|
graph.last().unwrap_or(&(0.0, 0.0)).1 as i64,
|
||||||
|
unit,
|
||||||
|
min_y as i64,
|
||||||
|
max_y as i64
|
||||||
|
);
|
||||||
|
let plot_area = if surface {
|
||||||
|
crate::controls::panel::render_panel(f, r, &title, self.focused, context.theme)
|
||||||
|
} else {
|
||||||
|
r
|
||||||
|
};
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title(format!(
|
.title(if surface {
|
||||||
"{}:─{}{}─{}min/{}max",
|
String::new()
|
||||||
self.title,
|
} else {
|
||||||
graph.last().unwrap_or(&(0.0, 0.0)).1 as i64,
|
format!(
|
||||||
unit,
|
"{}:─{}{}─{}min/{}max",
|
||||||
min_y as i64,
|
self.title,
|
||||||
max_y as i64,
|
graph.last().unwrap_or(&(0.0, 0.0)).1 as i64,
|
||||||
))
|
unit,
|
||||||
.borders(self.borders)
|
min_y as i64,
|
||||||
|
max_y as i64,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.borders(if surface { Borders::NONE } else { self.borders })
|
||||||
.border_style(if self.focused {
|
.border_style(if self.focused {
|
||||||
context.theme.graphs.focused_border
|
context.theme.graphs.focused_border
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -148,7 +198,7 @@ impl Element for GraphCard {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
f.render_widget(canvas, r);
|
f.render_widget(canvas, plot_area);
|
||||||
} else {
|
} else {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title("")
|
.title("")
|
||||||
|
|
@ -160,17 +210,19 @@ impl Element for GraphCard {
|
||||||
});
|
});
|
||||||
f.render_widget(block, r);
|
f.render_widget(block, r);
|
||||||
}
|
}
|
||||||
draw_block_joins(
|
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||||
f,
|
draw_block_joins(
|
||||||
r,
|
f,
|
||||||
self.borders,
|
r,
|
||||||
self.joins,
|
self.borders,
|
||||||
if self.focused {
|
self.joins,
|
||||||
context.theme.borders.focused
|
if self.focused {
|
||||||
} else {
|
context.theme.borders.focused
|
||||||
context.theme.borders.normal
|
} else {
|
||||||
},
|
context.theme.borders.normal
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::elements::elements::{Element, InteractableElement, JoinableElement};
|
use crate::elements::elements::{Element, InteractableElement, JoinableElement};
|
||||||
use crate::util::borders::draw_block_joins;
|
use crate::util::borders::draw_block_joins;
|
||||||
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
|
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use iota_state::{ClientState, UiLogEntry};
|
use iota_state::{ClientState, UiLogEntry};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
|
|
@ -10,7 +10,10 @@ use ratatui::{
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Paragraph},
|
widgets::{Block, Borders, Paragraph},
|
||||||
};
|
};
|
||||||
use std::any::Any;
|
use std::{
|
||||||
|
any::Any,
|
||||||
|
sync::atomic::{AtomicUsize, Ordering},
|
||||||
|
};
|
||||||
use unicode_width::UnicodeWidthChar;
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
|
|
@ -55,8 +58,11 @@ pub struct LogCard {
|
||||||
focused: bool,
|
focused: bool,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
scroll_offset: usize,
|
scroll_offset: usize,
|
||||||
last_total_lines: usize,
|
last_total_lines: AtomicUsize,
|
||||||
last_visible_height: usize,
|
last_visible_height: AtomicUsize,
|
||||||
|
last_width: AtomicUsize,
|
||||||
|
filter: String,
|
||||||
|
filtering: bool,
|
||||||
pub borders: Borders,
|
pub borders: Borders,
|
||||||
pub joins: Borders,
|
pub joins: Borders,
|
||||||
}
|
}
|
||||||
|
|
@ -68,8 +74,11 @@ impl LogCard {
|
||||||
focused: false,
|
focused: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
scroll_offset: 0,
|
scroll_offset: 0,
|
||||||
last_total_lines: 0,
|
last_total_lines: AtomicUsize::new(0),
|
||||||
last_visible_height: 10,
|
last_visible_height: AtomicUsize::new(1),
|
||||||
|
last_width: AtomicUsize::new(1),
|
||||||
|
filter: String::new(),
|
||||||
|
filtering: false,
|
||||||
borders: Borders::ALL,
|
borders: Borders::ALL,
|
||||||
joins: Borders::NONE,
|
joins: Borders::NONE,
|
||||||
}
|
}
|
||||||
|
|
@ -80,9 +89,15 @@ impl LogCard {
|
||||||
Ok(state) => state,
|
Ok(state) => state,
|
||||||
Err(_) => return Vec::new(),
|
Err(_) => return Vec::new(),
|
||||||
};
|
};
|
||||||
|
let needle = self.filter.to_ascii_lowercase();
|
||||||
state
|
state
|
||||||
.get_logs()
|
.get_logs()
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|entry| {
|
||||||
|
needle.is_empty()
|
||||||
|
|| entry.sender.to_ascii_lowercase().contains(&needle)
|
||||||
|
|| entry.message.to_ascii_lowercase().contains(&needle)
|
||||||
|
})
|
||||||
.map(|e| UiLogEntry {
|
.map(|e| UiLogEntry {
|
||||||
timestamp_ms: e.timestamp_ms,
|
timestamp_ms: e.timestamp_ms,
|
||||||
sender: e.sender.clone(),
|
sender: e.sender.clone(),
|
||||||
|
|
@ -214,11 +229,13 @@ impl LogCard {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_title_hints(&self) -> (bool, bool) {
|
fn get_title_hints(&self) -> (bool, bool) {
|
||||||
if self.last_total_lines == 0 || self.last_total_lines <= self.last_visible_height {
|
let total_lines = self.last_total_lines.load(Ordering::Relaxed);
|
||||||
|
let visible_height = self.last_visible_height.load(Ordering::Relaxed);
|
||||||
|
if total_lines == 0 || total_lines <= visible_height {
|
||||||
return (false, false);
|
return (false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let max_offset = self.last_total_lines - self.last_visible_height;
|
let max_offset = total_lines - visible_height;
|
||||||
let can_scroll_up = self.scroll_offset < max_offset;
|
let can_scroll_up = self.scroll_offset < max_offset;
|
||||||
let can_scroll_down = self.scroll_offset > 0;
|
let can_scroll_down = self.scroll_offset > 0;
|
||||||
|
|
||||||
|
|
@ -226,6 +243,12 @@ impl LogCard {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_title(&self) -> String {
|
fn build_title(&self) -> String {
|
||||||
|
if self.filtering {
|
||||||
|
return format!("Logs filter: {}_", self.filter);
|
||||||
|
}
|
||||||
|
if !self.filter.is_empty() {
|
||||||
|
return format!("Logs [filter: {}]", self.filter);
|
||||||
|
}
|
||||||
if !self.focused {
|
if !self.focused {
|
||||||
return "Logs".to_string();
|
return "Logs".to_string();
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +273,8 @@ impl LogCard {
|
||||||
fn scroll_up(&mut self) {
|
fn scroll_up(&mut self) {
|
||||||
let max_offset = self
|
let max_offset = self
|
||||||
.last_total_lines
|
.last_total_lines
|
||||||
.saturating_sub(self.last_visible_height);
|
.load(Ordering::Relaxed)
|
||||||
|
.saturating_sub(self.last_visible_height.load(Ordering::Relaxed));
|
||||||
self.scroll_offset = (self.scroll_offset + 1).min(max_offset);
|
self.scroll_offset = (self.scroll_offset + 1).min(max_offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,17 +321,27 @@ impl Element for LogCard {
|
||||||
fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
|
fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
|
||||||
let entries = self.get_logs();
|
let entries = self.get_logs();
|
||||||
|
|
||||||
let block = Block::default()
|
let inner_area = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||||
.title(self.build_title())
|
crate::controls::panel::render_panel(
|
||||||
.borders(self.borders)
|
f,
|
||||||
.border_style(if self.focused {
|
area,
|
||||||
context.theme.logs.focused_border
|
&self.build_title(),
|
||||||
} else {
|
self.focused,
|
||||||
context.theme.logs.border
|
context.theme,
|
||||||
});
|
)
|
||||||
|
} else {
|
||||||
let inner_area = block.inner(area);
|
let block = Block::default()
|
||||||
f.render_widget(block, area);
|
.title(self.build_title())
|
||||||
|
.borders(self.borders)
|
||||||
|
.border_style(if self.focused {
|
||||||
|
context.theme.logs.focused_border
|
||||||
|
} else {
|
||||||
|
context.theme.logs.border
|
||||||
|
});
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
inner
|
||||||
|
};
|
||||||
|
|
||||||
if inner_area.width == 0 || inner_area.height == 0 {
|
if inner_area.width == 0 || inner_area.height == 0 {
|
||||||
draw_block_joins(
|
draw_block_joins(
|
||||||
|
|
@ -327,6 +361,11 @@ impl Element for LogCard {
|
||||||
let all_lines = self.build_all_lines(entries, inner_area.width as usize);
|
let all_lines = self.build_all_lines(entries, inner_area.width as usize);
|
||||||
let total_lines = all_lines.len();
|
let total_lines = all_lines.len();
|
||||||
let visible_height = inner_area.height as usize;
|
let visible_height = inner_area.height as usize;
|
||||||
|
self.last_width
|
||||||
|
.store(inner_area.width as usize, Ordering::Relaxed);
|
||||||
|
self.last_total_lines.store(total_lines, Ordering::Relaxed);
|
||||||
|
self.last_visible_height
|
||||||
|
.store(visible_height, Ordering::Relaxed);
|
||||||
|
|
||||||
let (start, end) = self.calculate_view_window(total_lines, visible_height);
|
let (start, end) = self.calculate_view_window(total_lines, visible_height);
|
||||||
let visible_lines = &all_lines[start..end];
|
let visible_lines = &all_lines[start..end];
|
||||||
|
|
@ -337,6 +376,11 @@ impl Element for LogCard {
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
|
|
||||||
let (prefix, rest) = Self::split_line_prefix(line);
|
let (prefix, rest) = Self::split_line_prefix(line);
|
||||||
|
let prefix = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
prefix
|
||||||
|
};
|
||||||
|
|
||||||
if !prefix.is_empty() {
|
if !prefix.is_empty() {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
|
|
@ -377,17 +421,19 @@ impl Element for LogCard {
|
||||||
f.render_widget(Paragraph::new(line.clone()), line_area);
|
f.render_widget(Paragraph::new(line.clone()), line_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
draw_block_joins(
|
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||||
f,
|
draw_block_joins(
|
||||||
area,
|
f,
|
||||||
self.borders,
|
area,
|
||||||
self.joins,
|
self.borders,
|
||||||
if self.focused {
|
self.joins,
|
||||||
context.theme.borders.focused
|
if self.focused {
|
||||||
} else {
|
context.theme.borders.focused
|
||||||
context.theme.borders.normal
|
} else {
|
||||||
},
|
context.theme.borders.normal
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,14 +481,44 @@ impl InteractableElement for LogCard {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn interact(&mut self, key: KeyEvent) -> InteractionResult {
|
fn interact(&mut self, key: KeyEvent) -> InteractionResult {
|
||||||
|
if self.filtering {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.filtering = false;
|
||||||
|
self.filter.clear();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => self.filtering = false,
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.filter.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c)
|
||||||
|
if !key
|
||||||
|
.modifiers
|
||||||
|
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
|
||||||
|
{
|
||||||
|
self.filter.push(c);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
let entries = self.get_logs();
|
let entries = self.get_logs();
|
||||||
let estimated_width = 80usize;
|
let width = self.last_width.load(Ordering::Relaxed).max(1);
|
||||||
let all_lines = self.build_all_lines(entries, estimated_width);
|
let all_lines = self.build_all_lines(entries, width);
|
||||||
|
|
||||||
self.last_total_lines = all_lines.len();
|
self.last_total_lines
|
||||||
let visible_height = self.last_visible_height.max(1);
|
.store(all_lines.len(), Ordering::Relaxed);
|
||||||
|
let total_lines = all_lines.len();
|
||||||
|
let visible_height = self.last_visible_height.load(Ordering::Relaxed).max(1);
|
||||||
|
|
||||||
match key.code {
|
match key.code {
|
||||||
|
KeyCode::Char('/') => {
|
||||||
|
self.filtering = true;
|
||||||
|
self.filter.clear();
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||||
self.selected = !self.selected;
|
self.selected = !self.selected;
|
||||||
InteractionResult::Handled
|
InteractionResult::Handled
|
||||||
|
|
@ -476,8 +552,8 @@ impl InteractableElement for LogCard {
|
||||||
InteractionResult::Handled
|
InteractionResult::Handled
|
||||||
}
|
}
|
||||||
KeyCode::Home => {
|
KeyCode::Home => {
|
||||||
if self.last_total_lines > visible_height {
|
if total_lines > visible_height {
|
||||||
self.scroll_offset = self.last_total_lines - visible_height;
|
self.scroll_offset = total_lines - visible_height;
|
||||||
}
|
}
|
||||||
InteractionResult::Handled
|
InteractionResult::Handled
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::ui::UI;
|
use crate::{screens::screens::UiEvent, ui::UI};
|
||||||
use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
|
use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
@ -27,8 +27,9 @@ pub fn setup_input_handler(ui: Arc<UI>) -> JoinHandle<Result<(), String>> {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
event = rx.recv() => match event {
|
event = rx.recv() => match event {
|
||||||
Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await,
|
Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await,
|
||||||
Some(Event::Resize(_, _)) => ui.invalidate(),
|
Some(Event::Mouse(mouse)) => ui.clone().handle_event(UiEvent::Mouse(mouse)).await,
|
||||||
Some(Event::Paste(text)) => ui.handle_paste(text).await,
|
Some(Event::Resize(width, height)) => ui.clone().handle_event(UiEvent::Resize(width, height)).await,
|
||||||
|
Some(Event::Paste(text)) => ui.clone().handle_event(UiEvent::Paste(text)).await,
|
||||||
Some(_) => {},
|
Some(_) => {},
|
||||||
None => break,
|
None => break,
|
||||||
},
|
},
|
||||||
|
|
@ -55,6 +56,6 @@ pub async fn handle_input(key: KeyEvent, ui: Arc<UI>) {
|
||||||
{
|
{
|
||||||
ui.request_shutdown();
|
ui.request_shutdown();
|
||||||
} else {
|
} else {
|
||||||
ui.handle_input(key).await;
|
ui.handle_event(UiEvent::Key(key)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use std::fmt::{Debug, Formatter};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
use crate::screens::screens::Screen;
|
use crate::screens::screens::{Screen, UiEvent};
|
||||||
|
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub enum InteractionResult {
|
pub enum InteractionResult {
|
||||||
|
|
@ -13,6 +13,9 @@ pub enum InteractionResult {
|
||||||
OpenFutureScreen {
|
OpenFutureScreen {
|
||||||
screen: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>>,
|
screen: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>>,
|
||||||
},
|
},
|
||||||
|
AppTask {
|
||||||
|
task: Pin<Box<dyn Future<Output = UiEvent> + Send>>,
|
||||||
|
},
|
||||||
Handled,
|
Handled,
|
||||||
Unhandled,
|
Unhandled,
|
||||||
}
|
}
|
||||||
|
|
@ -22,6 +25,7 @@ impl Debug for InteractionResult {
|
||||||
match self {
|
match self {
|
||||||
InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"),
|
InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"),
|
||||||
InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"),
|
InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"),
|
||||||
|
InteractionResult::AppTask { task: _ } => write!(f, "AppTask"),
|
||||||
InteractionResult::CloseScreen => write!(f, "CloseScreen"),
|
InteractionResult::CloseScreen => write!(f, "CloseScreen"),
|
||||||
InteractionResult::Handled => write!(f, "Handled"),
|
InteractionResult::Handled => write!(f, "Handled"),
|
||||||
InteractionResult::Unhandled => write!(f, "Unhandled"),
|
InteractionResult::Unhandled => write!(f, "Unhandled"),
|
||||||
|
|
@ -36,6 +40,9 @@ impl PartialEq for InteractionResult {
|
||||||
InteractionResult::OpenScreen { screen: _ },
|
InteractionResult::OpenScreen { screen: _ },
|
||||||
InteractionResult::OpenScreen { screen: _ },
|
InteractionResult::OpenScreen { screen: _ },
|
||||||
) => true,
|
) => true,
|
||||||
|
(InteractionResult::AppTask { task: _ }, InteractionResult::AppTask { task: _ }) => {
|
||||||
|
true
|
||||||
|
}
|
||||||
(
|
(
|
||||||
InteractionResult::OpenFutureScreen { screen: _ },
|
InteractionResult::OpenFutureScreen { screen: _ },
|
||||||
InteractionResult::OpenFutureScreen { screen: _ },
|
InteractionResult::OpenFutureScreen { screen: _ },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use iota_ipc::{
|
use iota_ipc::{
|
||||||
ClientMessage, DaemonMessage, HelloAck, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION,
|
ClientMessage, DaemonMessage, HelloAck, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION,
|
||||||
RequestEnvelope, ResponseResult, read_msg, write_msg,
|
RequestEnvelope, ResponsePayload, ResponseResult, read_msg, write_msg,
|
||||||
};
|
};
|
||||||
use iota_state::{ClientState, UiLogEntry};
|
use iota_state::{ClientState, UiLogEntry};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
@ -443,6 +443,115 @@ impl IpcClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_payload(payload: &ResponsePayload) -> String {
|
||||||
|
match payload {
|
||||||
|
ResponsePayload::Status(status) => {
|
||||||
|
let mut msg = format!("Phase: {}", status.phase);
|
||||||
|
if !status.tasks.is_empty() {
|
||||||
|
msg.push_str(&format!(", Tasks: {}", status.tasks.join(", ")));
|
||||||
|
}
|
||||||
|
if let Some(reason) = &status.degraded_reason {
|
||||||
|
msg.push_str(&format!(", Degraded: {reason}"));
|
||||||
|
}
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
ResponsePayload::Tasks(tasks) => {
|
||||||
|
if tasks.is_empty() {
|
||||||
|
"No active tasks.".into()
|
||||||
|
} else {
|
||||||
|
tasks
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.name.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponsePayload::Users(users) => {
|
||||||
|
if users.is_empty() {
|
||||||
|
"No users.".into()
|
||||||
|
} else {
|
||||||
|
users
|
||||||
|
.iter()
|
||||||
|
.map(|u| format!("{} ({})", u.username, u.user_id))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponsePayload::UserCreated { user_id, username } => {
|
||||||
|
format!("Created user {} ({})", username, user_id)
|
||||||
|
}
|
||||||
|
ResponsePayload::UserRemoved { user_id } => {
|
||||||
|
format!("Removed user {}", user_id)
|
||||||
|
}
|
||||||
|
ResponsePayload::Acknowledged { message } => message.clone(),
|
||||||
|
ResponsePayload::DaemonStatus(status) => status.formatted.clone(),
|
||||||
|
ResponsePayload::Config(config) => config.yaml.clone(),
|
||||||
|
ResponsePayload::OmikronStatus(status) => {
|
||||||
|
let mut msg = format!("Connected: {}", status.connected);
|
||||||
|
if let Some(id) = status.iota_id {
|
||||||
|
msg.push_str(&format!("\nIota ID: {}", id));
|
||||||
|
}
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
ResponsePayload::Components(components) => {
|
||||||
|
if components.is_empty() {
|
||||||
|
"No component health data available.".into()
|
||||||
|
} else {
|
||||||
|
components
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
let status_str = match c.status {
|
||||||
|
iota_ipc::HealthStatus::Healthy => "healthy",
|
||||||
|
iota_ipc::HealthStatus::Degraded => "degraded",
|
||||||
|
iota_ipc::HealthStatus::Failed => "failed",
|
||||||
|
};
|
||||||
|
format!("{:?}: {}", c.id, status_str)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponsePayload::UserDetail(user) => {
|
||||||
|
let mut msg = format!("User: {} ({})", user.username, user.user_id);
|
||||||
|
if let Some(ref name) = user.display_name {
|
||||||
|
msg.push_str(&format!("\nDisplay Name: {name}"));
|
||||||
|
}
|
||||||
|
msg.push_str(&format!("\nCreated At: {}", user.created_at));
|
||||||
|
if !user.trusted_apps.is_empty() {
|
||||||
|
msg.push_str(&format!("\nTrusted Apps: {}", user.trusted_apps.join(", ")));
|
||||||
|
}
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
ResponsePayload::LogEntries(logs) => logs
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| {
|
||||||
|
let level = if e.is_error { "ERR" } else { "INF" };
|
||||||
|
format!("[{}] {level} {}: {}", e.timestamp_ms, e.sender, e.message)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n"),
|
||||||
|
ResponsePayload::UpdateStatus(status) => {
|
||||||
|
if status.available {
|
||||||
|
"Update available.".into()
|
||||||
|
} else {
|
||||||
|
"Up to date.".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponsePayload::Communities(communities) => {
|
||||||
|
if communities.is_empty() {
|
||||||
|
"No communities.".into()
|
||||||
|
} else {
|
||||||
|
communities
|
||||||
|
.iter()
|
||||||
|
.map(|c| format!("{} ({})", c.title, c.name))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn format_error(code: &iota_ipc::IpcErrorCode) -> &'static str {
|
fn format_error(code: &iota_ipc::IpcErrorCode) -> &'static str {
|
||||||
match code {
|
match code {
|
||||||
iota_ipc::IpcErrorCode::InvalidRequest => "The command is not valid.",
|
iota_ipc::IpcErrorCode::InvalidRequest => "The command is not valid.",
|
||||||
|
|
@ -496,29 +605,9 @@ impl IpcClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a legacy console command string into a typed request.
|
/// Parse a legacy console command string into a typed request.
|
||||||
|
/// Delegates to the shared parser in iota-ipc.
|
||||||
pub fn parse_console_command(line: &str) -> Option<LocalRequest> {
|
pub fn parse_console_command(line: &str) -> Option<LocalRequest> {
|
||||||
let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect();
|
iota_ipc::text_commands::parse(line)
|
||||||
match parts.as_slice() {
|
|
||||||
["help"] => None,
|
|
||||||
["tasks"] => Some(LocalRequest::ListTasks),
|
|
||||||
["user", "add", username] => Some(LocalRequest::CreateUser {
|
|
||||||
username: username.to_string(),
|
|
||||||
}),
|
|
||||||
["user", "remove", user_id_str] => {
|
|
||||||
let user_id = user_id_str.parse::<i64>().ok()?;
|
|
||||||
Some(LocalRequest::RemoveUser { user_id })
|
|
||||||
}
|
|
||||||
["user", "list"] => Some(LocalRequest::ListUsers),
|
|
||||||
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
|
|
||||||
["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity),
|
|
||||||
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
|
|
||||||
intent: iota_ipc::ExitIntent::Restart,
|
|
||||||
}),
|
|
||||||
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
|
|
||||||
intent: iota_ipc::ExitIntent::Stop,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Legacy command interface: parse text command, send as typed request.
|
/// Legacy command interface: parse text command, send as typed request.
|
||||||
|
|
@ -559,7 +648,7 @@ impl IpcClient {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let mut state = self.state.app.lock().await;
|
let mut state = self.state.app.lock().await;
|
||||||
let message = match &result {
|
let message = match &result {
|
||||||
ResponseResult::Ok(msg) => msg.clone(),
|
ResponseResult::Ok(payload) => Self::format_payload(payload),
|
||||||
ResponseResult::Error(code) => Self::format_error(code).into(),
|
ResponseResult::Error(code) => Self::format_error(code).into(),
|
||||||
};
|
};
|
||||||
state.push_log(UiLogEntry {
|
state.push_log(UiLogEntry {
|
||||||
|
|
@ -595,8 +684,8 @@ impl IpcClient {
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_millis(),
|
.as_millis(),
|
||||||
sender: "Console".into(),
|
sender: "Console".into(),
|
||||||
message: if trimmed == "help" {
|
message: if trimmed == "help" {
|
||||||
"Commands: status, tasks, ping, user add <name>, user remove <id>, user list, reconnect, regenerate keys, restart, stop"
|
"Commands: status, tasks, ping, user add <name>, user remove <id>, user list, users, reconnect, regenerate keys, restart, stop, config get, config reload, omikron status, components"
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
format!("Unknown command: {}", line)
|
format!("Unknown command: {}", line)
|
||||||
|
|
@ -695,7 +784,7 @@ impl IpcClient {
|
||||||
} else {
|
} else {
|
||||||
let mut state = self.state.app.lock().await;
|
let mut state = self.state.app.lock().await;
|
||||||
let message = match &response.result {
|
let message = match &response.result {
|
||||||
ResponseResult::Ok(msg) => msg.clone(),
|
ResponseResult::Ok(payload) => Self::format_payload(payload),
|
||||||
ResponseResult::Error(code) => Self::format_error(code).into(),
|
ResponseResult::Error(code) => Self::format_error(code).into(),
|
||||||
};
|
};
|
||||||
state.push_log(UiLogEntry {
|
state.push_log(UiLogEntry {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,13 @@ pub mod screens {
|
||||||
pub mod daemon_setup;
|
pub mod daemon_setup;
|
||||||
pub mod main_screen;
|
pub mod main_screen;
|
||||||
pub mod md_viewer;
|
pub mod md_viewer;
|
||||||
|
pub mod metrics;
|
||||||
|
pub mod overview;
|
||||||
pub mod screens;
|
pub mod screens;
|
||||||
|
pub mod settings;
|
||||||
pub mod terms_checker;
|
pub mod terms_checker;
|
||||||
pub mod terms_updater;
|
pub mod terms_updater;
|
||||||
|
pub mod users;
|
||||||
}
|
}
|
||||||
pub mod util {
|
pub mod util {
|
||||||
pub mod borders;
|
pub mod borders;
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ use crate::{
|
||||||
},
|
},
|
||||||
interaction_result::InteractionResult,
|
interaction_result::InteractionResult,
|
||||||
render_context::RenderContext,
|
render_context::RenderContext,
|
||||||
screens::screens::Screen,
|
screens::screens::{HitMap, Screen, UiEvent},
|
||||||
};
|
};
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::KeyCode;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
|
|
@ -29,7 +29,13 @@ impl Screen for DaemonStartingScreen {
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) {
|
fn render(
|
||||||
|
&self,
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
context: &RenderContext<'_>,
|
||||||
|
_hits: &mut HitMap,
|
||||||
|
) {
|
||||||
let popup = crate::layout::fit::centered_rect(
|
let popup = crate::layout::fit::centered_rect(
|
||||||
area,
|
area,
|
||||||
crate::layout::fit::RequiredSize {
|
crate::layout::fit::RequiredSize {
|
||||||
|
|
@ -51,7 +57,7 @@ impl Screen for DaemonStartingScreen {
|
||||||
popup,
|
popup,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
fn handle_input(&mut self, _: KeyEvent) -> InteractionResult {
|
fn handle_event(&mut self, _: UiEvent) -> InteractionResult {
|
||||||
InteractionResult::Handled
|
InteractionResult::Handled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +197,13 @@ impl Screen for DaemonSetupScreen {
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) {
|
fn render(
|
||||||
|
&self,
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
context: &RenderContext<'_>,
|
||||||
|
_hits: &mut HitMap,
|
||||||
|
) {
|
||||||
let popup = crate::layout::fit::centered_rect(
|
let popup = crate::layout::fit::centered_rect(
|
||||||
area,
|
area,
|
||||||
crate::layout::fit::RequiredSize {
|
crate::layout::fit::RequiredSize {
|
||||||
|
|
@ -266,7 +278,10 @@ impl Screen for DaemonSetupScreen {
|
||||||
context.theme,
|
context.theme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
|
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
||||||
|
let UiEvent::Key(event) = event else {
|
||||||
|
return InteractionResult::Unhandled;
|
||||||
|
};
|
||||||
match event.code {
|
match event.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
self.complete(DaemonSetupDecision::Exit);
|
self.complete(DaemonSetupDecision::Exit);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
|
controls::button::{ActionButton, ButtonIntent, render_button},
|
||||||
elements::{
|
elements::{
|
||||||
console_card::ConsoleCard,
|
console_card::ConsoleCard,
|
||||||
elements::{InteractableElement, JoinableElement},
|
elements::{InteractableElement, JoinableElement},
|
||||||
|
|
@ -8,19 +9,28 @@ use crate::{
|
||||||
interaction_result::InteractionResult,
|
interaction_result::InteractionResult,
|
||||||
ipc_client::{DaemonStatus, IpcConnectionState},
|
ipc_client::{DaemonStatus, IpcConnectionState},
|
||||||
render_context::RenderContext,
|
render_context::RenderContext,
|
||||||
screens::screens::{NavDirection, Screen},
|
screens::{
|
||||||
|
overview::OverviewScreen,
|
||||||
|
screens::{AppAction, HitMap, KeyHint, NavDirection, Screen, UiEvent},
|
||||||
|
},
|
||||||
ui::UI,
|
ui::UI,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Constraint, Layout, Margin, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
widgets::{Block, Borders},
|
widgets::Borders,
|
||||||
};
|
};
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
|
||||||
use std::{any::Any, sync::Arc};
|
use std::{
|
||||||
|
any::Any,
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicU16, Ordering},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
pub struct MainScreen {
|
pub struct MainScreen {
|
||||||
elements: Vec<Box<dyn InteractableElement>>,
|
elements: Vec<Box<dyn InteractableElement>>,
|
||||||
|
|
@ -29,9 +39,38 @@ pub struct MainScreen {
|
||||||
graphs_open: bool,
|
graphs_open: bool,
|
||||||
connection_status_rx: watch::Receiver<IpcConnectionState>,
|
connection_status_rx: watch::Receiver<IpcConnectionState>,
|
||||||
daemon_status_rx: watch::Receiver<DaemonStatus>,
|
daemon_status_rx: watch::Receiver<DaemonStatus>,
|
||||||
|
layout_width: AtomicU16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MainScreen {
|
impl MainScreen {
|
||||||
|
pub fn connection_status(&self) -> watch::Receiver<IpcConnectionState> {
|
||||||
|
self.connection_status_rx.clone()
|
||||||
|
}
|
||||||
|
pub fn daemon_status(&self) -> watch::Receiver<DaemonStatus> {
|
||||||
|
self.daemon_status_rx.clone()
|
||||||
|
}
|
||||||
|
fn status_summary(&self) -> String {
|
||||||
|
let connection = match self.connection_status_rx.borrow().clone() {
|
||||||
|
IpcConnectionState::Connected => "[OK] Connected".to_owned(),
|
||||||
|
IpcConnectionState::Connecting => "[..] Connecting".to_owned(),
|
||||||
|
IpcConnectionState::Reconnecting { .. } => "[WARN] Reconnecting".to_owned(),
|
||||||
|
IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => {
|
||||||
|
"[FAIL] Failed".to_owned()
|
||||||
|
}
|
||||||
|
IpcConnectionState::Disconnected => "[WARN] Disconnected".to_owned(),
|
||||||
|
};
|
||||||
|
let daemon = self.daemon_status_rx.borrow().clone();
|
||||||
|
let version = if daemon.version.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" v{}", daemon.version)
|
||||||
|
};
|
||||||
|
let ready = daemon
|
||||||
|
.startup_phase
|
||||||
|
.map(|phase| format!(" {:?}", phase))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("IOTA{version} {connection}{ready}")
|
||||||
|
}
|
||||||
pub async fn new(ui: Arc<UI>) -> Self {
|
pub async fn new(ui: Arc<UI>) -> Self {
|
||||||
let mut elements: Vec<Box<dyn InteractableElement>> = Vec::new();
|
let mut elements: Vec<Box<dyn InteractableElement>> = Vec::new();
|
||||||
|
|
||||||
|
|
@ -80,6 +119,7 @@ impl MainScreen {
|
||||||
graphs_open,
|
graphs_open,
|
||||||
connection_status_rx,
|
connection_status_rx,
|
||||||
daemon_status_rx,
|
daemon_status_rx,
|
||||||
|
layout_width: AtomicU16::new(0),
|
||||||
};
|
};
|
||||||
screen.focus_current();
|
screen.focus_current();
|
||||||
screen
|
screen
|
||||||
|
|
@ -172,6 +212,9 @@ impl MainScreen {
|
||||||
let mut seen: Vec<Option<usize>> = Vec::new();
|
let mut seen: Vec<Option<usize>> = Vec::new();
|
||||||
for (y, row) in self.nav_grid.iter().enumerate() {
|
for (y, row) in self.nav_grid.iter().enumerate() {
|
||||||
for (x, elem_opt) in row.iter().enumerate() {
|
for (x, elem_opt) in row.iter().enumerate() {
|
||||||
|
if x == 1 && (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if elem_opt.is_some() && !seen.contains(elem_opt) {
|
if elem_opt.is_some() && !seen.contains(elem_opt) {
|
||||||
seen.push(*elem_opt);
|
seen.push(*elem_opt);
|
||||||
positions.push((y, x));
|
positions.push((y, x));
|
||||||
|
|
@ -209,7 +252,8 @@ impl Screen for MainScreen {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) {
|
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) {
|
||||||
|
self.layout_width.store(rect.width, Ordering::Relaxed);
|
||||||
// A watch Ref blocks senders until it is dropped. Rendering may do
|
// A watch Ref blocks senders until it is dropped. Rendering may do
|
||||||
// terminal I/O, so retain only owned snapshots for the whole frame.
|
// terminal I/O, so retain only owned snapshots for the whole frame.
|
||||||
let status = self.connection_status_rx.borrow().clone();
|
let status = self.connection_status_rx.borrow().clone();
|
||||||
|
|
@ -242,25 +286,15 @@ impl Screen for MainScreen {
|
||||||
} else {
|
} else {
|
||||||
format!(" v{}", daemon.version)
|
format!(" v{}", daemon.version)
|
||||||
};
|
};
|
||||||
let main_block = Block::default()
|
let _ = (status_text, readiness, health, version);
|
||||||
.title(format!(
|
f.render_widget(
|
||||||
"Iota{version} [{status_text}; {readiness}{health}]"
|
ratatui::widgets::Block::default().style(context.theme.surfaces.canvas),
|
||||||
))
|
rect,
|
||||||
.borders(Borders::ALL)
|
);
|
||||||
.border_style(context.theme.borders.normal)
|
let inner = rect;
|
||||||
.title_style(context.theme.borders.title);
|
|
||||||
f.render_widget(main_block, rect);
|
|
||||||
|
|
||||||
let inner = rect.inner(Margin {
|
let metrics_visible = self.graphs_open && inner.width >= 70;
|
||||||
vertical: 1,
|
let graphs_width = if metrics_visible { 30 } else { 0 };
|
||||||
horizontal: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
let graphs_width = if self.graphs_open && inner.width >= 70 {
|
|
||||||
30
|
|
||||||
} else {
|
|
||||||
2
|
|
||||||
};
|
|
||||||
let main_width = inner.width.saturating_sub(graphs_width);
|
let main_width = inner.width.saturating_sub(graphs_width);
|
||||||
|
|
||||||
let horizontal_chunks = Layout::default()
|
let horizontal_chunks = Layout::default()
|
||||||
|
|
@ -273,9 +307,39 @@ impl Screen for MainScreen {
|
||||||
|
|
||||||
let left_area = horizontal_chunks[0];
|
let left_area = horizontal_chunks[0];
|
||||||
let right_area = horizontal_chunks[1];
|
let right_area = horizontal_chunks[1];
|
||||||
|
hits.register(left_area, AppAction::FocusLogs);
|
||||||
|
if metrics_visible {
|
||||||
|
hits.register(right_area, AppAction::FocusMetrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
if inner.width >= 70 {
|
||||||
|
let metrics_button = Rect {
|
||||||
|
x: right_area.x,
|
||||||
|
y: right_area.y,
|
||||||
|
width: right_area.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
render_button(
|
||||||
|
f,
|
||||||
|
metrics_button,
|
||||||
|
ActionButton {
|
||||||
|
label: if self.graphs_open {
|
||||||
|
"Hide metrics"
|
||||||
|
} else {
|
||||||
|
"Show metrics"
|
||||||
|
},
|
||||||
|
intent: ButtonIntent::Neutral,
|
||||||
|
focused: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
context.theme,
|
||||||
|
);
|
||||||
|
hits.register(metrics_button, AppAction::ToggleMetrics);
|
||||||
|
}
|
||||||
|
|
||||||
let left_rows =
|
let left_rows =
|
||||||
Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area);
|
Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area);
|
||||||
|
hits.register(left_rows[1], AppAction::FocusConsole);
|
||||||
|
|
||||||
if let Some(log) = self.elements.get(0) {
|
if let Some(log) = self.elements.get(0) {
|
||||||
log.as_element().render(f, left_rows[0], context);
|
log.as_element().render(f, left_rows[0], context);
|
||||||
|
|
@ -291,7 +355,7 @@ impl Screen for MainScreen {
|
||||||
.filter(|el| el.as_any().is::<GraphCard>())
|
.filter(|el| el.as_any().is::<GraphCard>())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !graph_elements.is_empty() {
|
if metrics_visible && !graph_elements.is_empty() {
|
||||||
let graph_chunks = Layout::vertical(
|
let graph_chunks = Layout::vertical(
|
||||||
graph_elements
|
graph_elements
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -306,7 +370,40 @@ impl Screen for MainScreen {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
|
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
||||||
|
if let UiEvent::Paste(text) = &event {
|
||||||
|
if self.selected_coords == (2, 0) {
|
||||||
|
if let Some(console) = self
|
||||||
|
.elements
|
||||||
|
.get_mut(1)
|
||||||
|
.and_then(|element| element.as_any_mut().downcast_mut::<ConsoleCard>())
|
||||||
|
{
|
||||||
|
console.handle_paste(text);
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return InteractionResult::Unhandled;
|
||||||
|
}
|
||||||
|
if let UiEvent::Resize(width, _) = &event {
|
||||||
|
self.layout_width.store(*width, Ordering::Relaxed);
|
||||||
|
if *width < 70 && self.selected_coords.1 == 1 {
|
||||||
|
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
|
||||||
|
self.selected_coords = (0, 0);
|
||||||
|
self.focus_current();
|
||||||
|
}
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
let UiEvent::Key(event) = event else {
|
||||||
|
return InteractionResult::Unhandled;
|
||||||
|
};
|
||||||
|
// A focused console consumes text and cursor keys before dashboard
|
||||||
|
// shortcuts; commands such as `users` must remain typeable.
|
||||||
|
if self.selected_coords == (2, 0) && !matches!(event.code, KeyCode::Tab | KeyCode::BackTab)
|
||||||
|
{
|
||||||
|
if let Some(console) = self.elements.get_mut(1) {
|
||||||
|
return console.interact(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
match event.code {
|
match event.code {
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
self.navigate_focus(true);
|
self.navigate_focus(true);
|
||||||
|
|
@ -316,6 +413,27 @@ impl Screen for MainScreen {
|
||||||
self.navigate_focus(false);
|
self.navigate_focus(false);
|
||||||
return InteractionResult::Handled;
|
return InteractionResult::Handled;
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('o') | KeyCode::Char('O') => {
|
||||||
|
let conn_rx = self.connection_status_rx.clone();
|
||||||
|
let daemon_rx = self.daemon_status_rx.clone();
|
||||||
|
return InteractionResult::OpenScreen {
|
||||||
|
screen: Box::new(OverviewScreen::new(conn_rx, daemon_rx)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
KeyCode::Char('u') | KeyCode::Char('U') => {
|
||||||
|
return InteractionResult::AppTask {
|
||||||
|
task: Box::pin(async {
|
||||||
|
UiEvent::App(crate::screens::screens::AppEvent::OpenUsers)
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
KeyCode::Char('m') | KeyCode::Char('M') => {
|
||||||
|
return InteractionResult::AppTask {
|
||||||
|
task: Box::pin(async {
|
||||||
|
UiEvent::App(crate::screens::screens::AppEvent::OpenMetrics)
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => {
|
KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => {
|
||||||
self.graphs_open = !self.graphs_open;
|
self.graphs_open = !self.graphs_open;
|
||||||
for element in self.elements.iter_mut() {
|
for element in self.elements.iter_mut() {
|
||||||
|
|
@ -347,4 +465,109 @@ impl Screen for MainScreen {
|
||||||
|
|
||||||
InteractionResult::Handled
|
InteractionResult::Handled
|
||||||
}
|
}
|
||||||
|
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
|
||||||
|
match action {
|
||||||
|
AppAction::ToggleMetrics => {
|
||||||
|
self.graphs_open = !self.graphs_open;
|
||||||
|
for element in &mut self.elements {
|
||||||
|
if let Some(graph) = element.as_any_mut().downcast_mut::<GraphCard>() {
|
||||||
|
graph.set_open(self.graphs_open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
AppAction::OpenOverview => {
|
||||||
|
self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('o'))))
|
||||||
|
}
|
||||||
|
AppAction::OpenUsers => {
|
||||||
|
self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('u'))))
|
||||||
|
}
|
||||||
|
AppAction::FocusLogs => {
|
||||||
|
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
|
||||||
|
self.selected_coords = (0, 0);
|
||||||
|
self.focus_current();
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
AppAction::FocusConsole => {
|
||||||
|
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
|
||||||
|
self.selected_coords = (2, 0);
|
||||||
|
self.focus_current();
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
AppAction::FocusMetrics => {
|
||||||
|
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
|
||||||
|
self.selected_coords = (0, 1);
|
||||||
|
self.focus_current();
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
_ => InteractionResult::Unhandled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn app_title(&self) -> String {
|
||||||
|
self.status_summary()
|
||||||
|
}
|
||||||
|
fn key_hints(&self) -> Vec<KeyHint> {
|
||||||
|
if self.selected_coords == (2, 0) {
|
||||||
|
vec![
|
||||||
|
KeyHint {
|
||||||
|
keys: "Enter",
|
||||||
|
action: "Send",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "Up/Down",
|
||||||
|
action: "History",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "Tab",
|
||||||
|
action: "Complete",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "F6",
|
||||||
|
action: "Header",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} else if self.selected_coords == (0, 0) {
|
||||||
|
vec![
|
||||||
|
KeyHint {
|
||||||
|
keys: "J/K",
|
||||||
|
action: "Scroll logs",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "Enter",
|
||||||
|
action: "Lock scroll",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "/",
|
||||||
|
action: "Filter",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "M",
|
||||||
|
action: "Metrics screen",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "Tab",
|
||||||
|
action: "Next panel",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "F6",
|
||||||
|
action: "Header",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
KeyHint {
|
||||||
|
keys: "Enter",
|
||||||
|
action: "Toggle metrics",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "Tab",
|
||||||
|
action: "Next panel",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "F6",
|
||||||
|
action: "Header",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEvent};
|
use crossterm::event::{self, Event, KeyCode};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
DefaultTerminal,
|
DefaultTerminal,
|
||||||
prelude::*,
|
prelude::*,
|
||||||
|
|
@ -10,7 +10,7 @@ use std::{any::Any, time::Duration};
|
||||||
use crate::{
|
use crate::{
|
||||||
interaction_result::InteractionResult,
|
interaction_result::InteractionResult,
|
||||||
render_context::RenderContext,
|
render_context::RenderContext,
|
||||||
screens::screens::Screen,
|
screens::screens::{HitMap, Screen, UiEvent},
|
||||||
theme::{ResolvedTheme, TextSemantics, ThemeName},
|
theme::{ResolvedTheme, TextSemantics, ThemeName},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -29,11 +29,14 @@ impl Screen for FileViewer {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) {
|
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
|
||||||
self.draw(f, rect, context.theme);
|
self.draw(f, rect, context.theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
|
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
||||||
|
let UiEvent::Key(event) = event else {
|
||||||
|
return InteractionResult::Unhandled;
|
||||||
|
};
|
||||||
match event.code {
|
match event.code {
|
||||||
KeyCode::Char('q') | KeyCode::Esc => {
|
KeyCode::Char('q') | KeyCode::Esc => {
|
||||||
return InteractionResult::CloseScreen;
|
return InteractionResult::CloseScreen;
|
||||||
|
|
|
||||||
136
iota-cli/src/screens/metrics.rs
Normal file
136
iota-cli/src/screens/metrics.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
use std::any::Any;
|
||||||
|
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
elements::{
|
||||||
|
elements::Element,
|
||||||
|
graph_card::{GRAPHS, GraphCard},
|
||||||
|
},
|
||||||
|
interaction_result::InteractionResult,
|
||||||
|
render_context::RenderContext,
|
||||||
|
screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent},
|
||||||
|
ui::UI,
|
||||||
|
};
|
||||||
|
|
||||||
|
const RANGES: &[(usize, &str)] = &[(30, "Recent"), (120, "Medium"), (300, "Long")];
|
||||||
|
|
||||||
|
pub struct MetricsScreen {
|
||||||
|
graphs: Vec<GraphCard>,
|
||||||
|
range_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MetricsScreen {
|
||||||
|
pub async fn new(ui: std::sync::Arc<UI>) -> Option<Self> {
|
||||||
|
let state = ui.client_state().await?;
|
||||||
|
let mut screen = Self {
|
||||||
|
graphs: vec![
|
||||||
|
GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into()),
|
||||||
|
GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into()),
|
||||||
|
GraphCard::new(ui, state, GRAPHS::Ping, "Ping".into()),
|
||||||
|
],
|
||||||
|
range_index: 0,
|
||||||
|
};
|
||||||
|
screen.apply_range();
|
||||||
|
Some(screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_range(&mut self) {
|
||||||
|
let width = RANGES[self.range_index].0;
|
||||||
|
for graph in &mut self.graphs {
|
||||||
|
graph.set_sample_width(width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn change_range(&mut self, delta: isize) {
|
||||||
|
self.range_index =
|
||||||
|
(self.range_index as isize + delta).clamp(0, RANGES.len() as isize - 1) as usize;
|
||||||
|
self.apply_range();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Screen for MetricsScreen {
|
||||||
|
fn as_any(&self) -> &dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
&self,
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
context: &RenderContext<'_>,
|
||||||
|
hits: &mut HitMap,
|
||||||
|
) {
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" Metrics ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(context.theme.borders.normal);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
let rows = Layout::vertical([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Ratio(1, 3),
|
||||||
|
Constraint::Ratio(1, 3),
|
||||||
|
Constraint::Ratio(1, 3),
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!(
|
||||||
|
"Range: {} ({} samples) Left/Right to change",
|
||||||
|
RANGES[self.range_index].1, RANGES[self.range_index].0
|
||||||
|
))
|
||||||
|
.style(context.theme.text.heading),
|
||||||
|
rows[0],
|
||||||
|
);
|
||||||
|
for (graph, graph_area) in self.graphs.iter().zip(rows[1..].iter()) {
|
||||||
|
graph.render(frame, *graph_area, context);
|
||||||
|
}
|
||||||
|
hits.register(rows[0], AppAction::OpenMetrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
||||||
|
let UiEvent::Key(key) = event else {
|
||||||
|
return InteractionResult::Unhandled;
|
||||||
|
};
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Left => {
|
||||||
|
self.change_range(-1);
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
self.change_range(1);
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
|
||||||
|
InteractionResult::CloseScreen
|
||||||
|
}
|
||||||
|
_ => InteractionResult::Unhandled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_hints(&self) -> Vec<KeyHint> {
|
||||||
|
vec![
|
||||||
|
KeyHint {
|
||||||
|
keys: "Left/Right",
|
||||||
|
action: "Range",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "Esc/B",
|
||||||
|
action: "Back",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "F6",
|
||||||
|
action: "Header",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
291
iota-cli/src/screens/overview.rs
Normal file
291
iota-cli/src/screens/overview.rs
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
use crate::{
|
||||||
|
controls::button::{ActionButton, ButtonIntent, render_button},
|
||||||
|
interaction_result::InteractionResult,
|
||||||
|
ipc_client::{DaemonStatus, IpcConnectionState},
|
||||||
|
render_context::RenderContext,
|
||||||
|
screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent},
|
||||||
|
};
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::Rect,
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph, Wrap},
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
any::Any,
|
||||||
|
sync::atomic::{AtomicUsize, Ordering},
|
||||||
|
};
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
pub struct OverviewScreen {
|
||||||
|
connection_rx: watch::Receiver<IpcConnectionState>,
|
||||||
|
daemon_rx: watch::Receiver<DaemonStatus>,
|
||||||
|
_focus: Focus,
|
||||||
|
scroll_offset: usize,
|
||||||
|
content_height: AtomicUsize,
|
||||||
|
viewport_height: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Focus {
|
||||||
|
Back,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OverviewScreen {
|
||||||
|
pub fn new(
|
||||||
|
connection_rx: watch::Receiver<IpcConnectionState>,
|
||||||
|
daemon_rx: watch::Receiver<DaemonStatus>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
connection_rx,
|
||||||
|
daemon_rx,
|
||||||
|
_focus: Focus::Back,
|
||||||
|
scroll_offset: 0,
|
||||||
|
content_height: AtomicUsize::new(0),
|
||||||
|
viewport_height: AtomicUsize::new(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_lines(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Line<'static>> {
|
||||||
|
let conn = self.connection_rx.borrow().clone();
|
||||||
|
let daemon = self.daemon_rx.borrow().clone();
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
lines.push(Line::from(Span::styled("Connection", theme.text.heading)));
|
||||||
|
lines.push(Line::from(format!(" State: {}", connection_label(&conn))));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
lines.push(Line::from(Span::styled("Daemon", theme.text.heading)));
|
||||||
|
lines.push(Line::from(format!(
|
||||||
|
" Version: {}",
|
||||||
|
version_or_unknown(&daemon.version)
|
||||||
|
)));
|
||||||
|
lines.push(Line::from(format!(
|
||||||
|
" Instance: {}",
|
||||||
|
truncate_id(&daemon.instance_id)
|
||||||
|
)));
|
||||||
|
|
||||||
|
let phase = daemon
|
||||||
|
.startup_phase
|
||||||
|
.map(|p| format!("{:?}", p))
|
||||||
|
.unwrap_or_else(|| "Unknown".into());
|
||||||
|
lines.push(Line::from(format!(" Phase: {}", phase)));
|
||||||
|
|
||||||
|
let lifecycle = daemon
|
||||||
|
.lifecycle
|
||||||
|
.map(|l| format!("{:?}", l))
|
||||||
|
.unwrap_or_else(|| "Unknown".into());
|
||||||
|
lines.push(Line::from(format!(" Lifecycle: {}", lifecycle)));
|
||||||
|
|
||||||
|
let health = match daemon.health {
|
||||||
|
iota_ipc::HealthStatus::Healthy => "[OK] Healthy",
|
||||||
|
iota_ipc::HealthStatus::Degraded => "[WARN] Degraded",
|
||||||
|
iota_ipc::HealthStatus::Failed => "[FAIL] Failed",
|
||||||
|
};
|
||||||
|
lines.push(Line::from(format!(" Health: {health}")));
|
||||||
|
|
||||||
|
if let Some(ref reason) = daemon.degraded_reason {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" Degraded: {reason}"),
|
||||||
|
theme.status.warning,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode = daemon
|
||||||
|
.deployment_mode
|
||||||
|
.map(|m| format!("{:?}", m))
|
||||||
|
.unwrap_or_else(|| "Unknown".into());
|
||||||
|
lines.push(Line::from(format!(" Deployment: {mode}")));
|
||||||
|
|
||||||
|
let supervisor = daemon
|
||||||
|
.supervisor
|
||||||
|
.map(|s| format!("{:?}", s))
|
||||||
|
.unwrap_or_else(|| "Unknown".into());
|
||||||
|
lines.push(Line::from(format!(" Supervisor: {supervisor}")));
|
||||||
|
|
||||||
|
if !daemon.components.is_empty() {
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from(Span::styled("Components", theme.text.heading)));
|
||||||
|
for (id, health) in &daemon.components {
|
||||||
|
let status_str = match health.status {
|
||||||
|
iota_ipc::HealthStatus::Healthy => "[OK] healthy",
|
||||||
|
iota_ipc::HealthStatus::Degraded => "[WARN] degraded",
|
||||||
|
iota_ipc::HealthStatus::Failed => "[FAIL] failed",
|
||||||
|
};
|
||||||
|
let suffix = health
|
||||||
|
.message
|
||||||
|
.as_deref()
|
||||||
|
.map(|m| format!(" ({m})"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
lines.push(Line::from(format!(" {:?}: {}{}", id, status_str, suffix)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"Press Esc or B to return to the dashboard",
|
||||||
|
theme.text.muted,
|
||||||
|
)));
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connection_label(conn: &IpcConnectionState) -> String {
|
||||||
|
match conn {
|
||||||
|
IpcConnectionState::Connected => "Connected".into(),
|
||||||
|
IpcConnectionState::Connecting => "Connecting...".into(),
|
||||||
|
IpcConnectionState::Reconnecting { attempt } => {
|
||||||
|
format!("Reconnecting (attempt {attempt})...")
|
||||||
|
}
|
||||||
|
IpcConnectionState::Incompatible { message } => {
|
||||||
|
format!("Incompatible: {message}")
|
||||||
|
}
|
||||||
|
IpcConnectionState::Failed { message } => format!("Failed: {message}"),
|
||||||
|
IpcConnectionState::Disconnected => "Disconnected".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_or_unknown(v: &str) -> String {
|
||||||
|
if v.is_empty() {
|
||||||
|
"Unknown".into()
|
||||||
|
} else {
|
||||||
|
v.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_id(id: &str) -> String {
|
||||||
|
if id.len() > 8 {
|
||||||
|
format!("{}…", &id[..8])
|
||||||
|
} else {
|
||||||
|
id.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Screen for OverviewScreen {
|
||||||
|
fn as_any(&self) -> &dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" Overview ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(context.theme.borders.normal)
|
||||||
|
.title_style(context.theme.borders.title);
|
||||||
|
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||||
|
crate::controls::panel::render_panel(f, rect, "Overview", false, context.theme)
|
||||||
|
} else {
|
||||||
|
let inner = block.inner(rect);
|
||||||
|
f.render_widget(block, rect);
|
||||||
|
inner
|
||||||
|
};
|
||||||
|
let rows = ratatui::layout::Layout::vertical([
|
||||||
|
ratatui::layout::Constraint::Min(1),
|
||||||
|
ratatui::layout::Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
let lines = self.build_lines(context.theme);
|
||||||
|
self.content_height.store(lines.len(), Ordering::Relaxed);
|
||||||
|
self.viewport_height
|
||||||
|
.store(rows[0].height as usize, Ordering::Relaxed);
|
||||||
|
let par = Paragraph::new(lines)
|
||||||
|
.wrap(Wrap { trim: true })
|
||||||
|
.scroll((self.scroll_offset as u16, 0));
|
||||||
|
f.render_widget(par, rows[0]);
|
||||||
|
render_button(
|
||||||
|
f,
|
||||||
|
rows[1],
|
||||||
|
ActionButton {
|
||||||
|
label: "Back",
|
||||||
|
intent: ButtonIntent::Cancel,
|
||||||
|
focused: self._focus == Focus::Back,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
context.theme,
|
||||||
|
);
|
||||||
|
_hits.register(rows[1], AppAction::Back);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
||||||
|
let UiEvent::Key(event) = event else {
|
||||||
|
return InteractionResult::Unhandled;
|
||||||
|
};
|
||||||
|
match event.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
|
||||||
|
InteractionResult::CloseScreen
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
let max = self
|
||||||
|
.content_height
|
||||||
|
.load(Ordering::Relaxed)
|
||||||
|
.saturating_sub(self.viewport_height.load(Ordering::Relaxed));
|
||||||
|
self.scroll_offset = self.scroll_offset.saturating_add(1).min(max);
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::PageDown => {
|
||||||
|
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
|
||||||
|
let max = self
|
||||||
|
.content_height
|
||||||
|
.load(Ordering::Relaxed)
|
||||||
|
.saturating_sub(page);
|
||||||
|
self.scroll_offset = self.scroll_offset.saturating_add(page).min(max);
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::PageUp => {
|
||||||
|
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
|
||||||
|
self.scroll_offset = self.scroll_offset.saturating_sub(page);
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Home => {
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::End => {
|
||||||
|
self.scroll_offset = self
|
||||||
|
.content_height
|
||||||
|
.load(Ordering::Relaxed)
|
||||||
|
.saturating_sub(self.viewport_height.load(Ordering::Relaxed));
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
_ => InteractionResult::Unhandled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
|
||||||
|
if action == AppAction::Back {
|
||||||
|
InteractionResult::CloseScreen
|
||||||
|
} else {
|
||||||
|
InteractionResult::Unhandled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn key_hints(&self) -> Vec<KeyHint> {
|
||||||
|
vec![
|
||||||
|
KeyHint {
|
||||||
|
keys: "Up/Down",
|
||||||
|
action: "Scroll",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "PgUp/PgDn",
|
||||||
|
action: "Page",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "Esc/B",
|
||||||
|
action: "Back",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "F6",
|
||||||
|
action: "Header",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,103 @@
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
|
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::{KeyEvent, MouseEvent};
|
||||||
use ratatui::{Frame, layout::Rect};
|
use ratatui::{Frame, layout::Rect};
|
||||||
|
|
||||||
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
|
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
|
||||||
|
|
||||||
|
/// All terminal input that can affect the UI. Keeping this as one type makes
|
||||||
|
/// it impossible for screens to accidentally ignore a newly supported event.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum UiEvent {
|
||||||
|
Key(KeyEvent),
|
||||||
|
Mouse(MouseEvent),
|
||||||
|
Paste(String),
|
||||||
|
Resize(u16, u16),
|
||||||
|
App(AppEvent),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completion of background UI work. Keeping it in the regular event stream
|
||||||
|
/// gives screens an explicit success/failure path instead of detached tasks.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum AppEvent {
|
||||||
|
OpenUsers,
|
||||||
|
OpenMetrics,
|
||||||
|
ApplyTheme {
|
||||||
|
theme: crate::theme::ThemeName,
|
||||||
|
persist: bool,
|
||||||
|
},
|
||||||
|
SaveSettings {
|
||||||
|
theme: crate::theme::ThemeName,
|
||||||
|
color: crate::theme::TerminalPolicy,
|
||||||
|
unicode: crate::theme::TerminalPolicy,
|
||||||
|
},
|
||||||
|
ThemeSaved(Result<(), String>),
|
||||||
|
UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>),
|
||||||
|
UserCreated(Result<crate::screens::users::UserEntry, String>),
|
||||||
|
UserRemoved {
|
||||||
|
user_id: i64,
|
||||||
|
result: Result<(), String>,
|
||||||
|
},
|
||||||
|
RegenerateKeysRequested,
|
||||||
|
KeysRegenerated(Result<(), String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AppAction {
|
||||||
|
OpenOverview,
|
||||||
|
OpenUsers,
|
||||||
|
OpenSettings,
|
||||||
|
OpenMetrics,
|
||||||
|
ToggleMetrics,
|
||||||
|
AddUser,
|
||||||
|
RemoveUser,
|
||||||
|
Back,
|
||||||
|
Quit,
|
||||||
|
FocusLogs,
|
||||||
|
FocusConsole,
|
||||||
|
FocusMetrics,
|
||||||
|
OpenMain,
|
||||||
|
SelectUser(usize),
|
||||||
|
ConfirmDialog,
|
||||||
|
CancelDialog,
|
||||||
|
RegenerateKeys,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct KeyHint {
|
||||||
|
pub keys: &'static str,
|
||||||
|
pub action: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct HitRegion {
|
||||||
|
pub area: Rect,
|
||||||
|
pub action: AppAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct HitMap {
|
||||||
|
regions: Vec<HitRegion>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HitMap {
|
||||||
|
pub fn register(&mut self, area: Rect, action: AppAction) {
|
||||||
|
self.regions.push(HitRegion { area, action });
|
||||||
|
}
|
||||||
|
pub fn action_at(&self, column: u16, row: u16) -> Option<AppAction> {
|
||||||
|
self.regions
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|region| {
|
||||||
|
column >= region.area.x
|
||||||
|
&& column < region.area.x.saturating_add(region.area.width)
|
||||||
|
&& row >= region.area.y
|
||||||
|
&& row < region.area.y.saturating_add(region.area.height)
|
||||||
|
})
|
||||||
|
.map(|region| region.action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum NavDirection {
|
pub enum NavDirection {
|
||||||
Up,
|
Up,
|
||||||
|
|
@ -20,6 +113,32 @@ pub trait Screen: Send + Sync + Any {
|
||||||
fn as_any(&self) -> &dyn Any;
|
fn as_any(&self) -> &dyn Any;
|
||||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>);
|
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap);
|
||||||
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult;
|
fn handle_event(&mut self, event: UiEvent) -> InteractionResult;
|
||||||
|
fn handle_action(&mut self, _action: AppAction) -> InteractionResult {
|
||||||
|
InteractionResult::Unhandled
|
||||||
|
}
|
||||||
|
fn app_title(&self) -> String {
|
||||||
|
"IOTA".to_owned()
|
||||||
|
}
|
||||||
|
fn key_hints(&self) -> Vec<KeyHint> {
|
||||||
|
vec![
|
||||||
|
KeyHint {
|
||||||
|
keys: "Tab",
|
||||||
|
action: "Move focus",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "Enter",
|
||||||
|
action: "Activate",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "Esc",
|
||||||
|
action: "Back",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "F6",
|
||||||
|
action: "Header",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
415
iota-cli/src/screens/settings.rs
Normal file
415
iota-cli/src/screens/settings.rs
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
use std::any::Any;
|
||||||
|
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
controls::button::{ActionButton, ButtonIntent, render_button},
|
||||||
|
interaction_result::InteractionResult,
|
||||||
|
render_context::RenderContext,
|
||||||
|
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
|
||||||
|
theme::{TerminalPolicy, ThemeName, UiConfig},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Focus {
|
||||||
|
Theme,
|
||||||
|
RegenerateKeys,
|
||||||
|
Back,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Dialog {
|
||||||
|
ConfirmRegenerateKeys,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SettingsScreen {
|
||||||
|
selected: usize,
|
||||||
|
saved: ThemeName,
|
||||||
|
message: String,
|
||||||
|
color: TerminalPolicy,
|
||||||
|
unicode: TerminalPolicy,
|
||||||
|
focus: Focus,
|
||||||
|
dialog: Option<Dialog>,
|
||||||
|
pending: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsScreen {
|
||||||
|
pub fn new(current: ThemeName) -> Self {
|
||||||
|
let selected = ThemeName::ALL
|
||||||
|
.iter()
|
||||||
|
.position(|theme| *theme == current)
|
||||||
|
.unwrap_or(0);
|
||||||
|
Self {
|
||||||
|
selected,
|
||||||
|
saved: current,
|
||||||
|
message: "Left/Right previews. Enter saves.".into(),
|
||||||
|
color: UiConfig::load()
|
||||||
|
.map(|config| config.color)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
unicode: UiConfig::load()
|
||||||
|
.map(|config| config.unicode)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
focus: Focus::Theme,
|
||||||
|
dialog: None,
|
||||||
|
pending: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_theme(&self) -> ThemeName {
|
||||||
|
ThemeName::ALL[self.selected]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(&self, persist: bool) -> InteractionResult {
|
||||||
|
let theme = self.selected_theme();
|
||||||
|
InteractionResult::AppTask {
|
||||||
|
task: Box::pin(async move { UiEvent::App(AppEvent::ApplyTheme { theme, persist }) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_policy(policy: TerminalPolicy) -> TerminalPolicy {
|
||||||
|
match policy {
|
||||||
|
TerminalPolicy::Auto => TerminalPolicy::Always,
|
||||||
|
TerminalPolicy::Always => TerminalPolicy::Never,
|
||||||
|
TerminalPolicy::Never => TerminalPolicy::Auto,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_focus(&mut self) {
|
||||||
|
self.focus = match self.focus {
|
||||||
|
Focus::Theme => Focus::RegenerateKeys,
|
||||||
|
Focus::RegenerateKeys => Focus::Back,
|
||||||
|
Focus::Back => Focus::Theme,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prev_focus(&mut self) {
|
||||||
|
self.focus = match self.focus {
|
||||||
|
Focus::Theme => Focus::Back,
|
||||||
|
Focus::Back => Focus::RegenerateKeys,
|
||||||
|
Focus::RegenerateKeys => Focus::Theme,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate(&mut self) -> InteractionResult {
|
||||||
|
if self.pending {
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
if let Some(dialog) = self.dialog.take() {
|
||||||
|
match dialog {
|
||||||
|
Dialog::ConfirmRegenerateKeys => {
|
||||||
|
self.pending = true;
|
||||||
|
self.message = "Regenerating keys…".into();
|
||||||
|
return InteractionResult::AppTask {
|
||||||
|
task: Box::pin(async { UiEvent::App(AppEvent::RegenerateKeysRequested) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match self.focus {
|
||||||
|
Focus::Theme => {
|
||||||
|
self.message = "Saving theme…".into();
|
||||||
|
let theme = self.selected_theme();
|
||||||
|
let color = self.color;
|
||||||
|
let unicode = self.unicode;
|
||||||
|
InteractionResult::AppTask {
|
||||||
|
task: Box::pin(async move {
|
||||||
|
UiEvent::App(AppEvent::SaveSettings {
|
||||||
|
theme,
|
||||||
|
color,
|
||||||
|
unicode,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Focus::RegenerateKeys => {
|
||||||
|
self.dialog = Some(Dialog::ConfirmRegenerateKeys);
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
Focus::Back => InteractionResult::CloseScreen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Screen for SettingsScreen {
|
||||||
|
fn as_any(&self) -> &dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
&self,
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
context: &RenderContext<'_>,
|
||||||
|
hits: &mut HitMap,
|
||||||
|
) {
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" Settings ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(context.theme.borders.focused);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
let rows = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!(
|
||||||
|
"Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)",
|
||||||
|
self.selected_theme(),
|
||||||
|
if self.selected_theme() == self.saved {
|
||||||
|
" [saved]"
|
||||||
|
} else {
|
||||||
|
" [preview]"
|
||||||
|
},
|
||||||
|
self.color,
|
||||||
|
self.unicode
|
||||||
|
))
|
||||||
|
.style(context.theme.text.heading),
|
||||||
|
rows[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
let bottom_rows =
|
||||||
|
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(rows[1]);
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(Span::styled(&self.message, context.theme.text.normal)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from("Preview"),
|
||||||
|
Line::from("[OK] Healthy"),
|
||||||
|
Line::from("[WARN] Degraded"),
|
||||||
|
Line::from("[FAIL] Failed"),
|
||||||
|
Line::from("> Focused action <"),
|
||||||
|
];
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(lines).style(context.theme.text.normal),
|
||||||
|
bottom_rows[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
let buttons_area = Layout::horizontal([
|
||||||
|
Constraint::Percentage(33),
|
||||||
|
Constraint::Percentage(34),
|
||||||
|
Constraint::Percentage(33),
|
||||||
|
])
|
||||||
|
.split(bottom_rows[1]);
|
||||||
|
|
||||||
|
render_button(
|
||||||
|
frame,
|
||||||
|
buttons_area[0],
|
||||||
|
ActionButton {
|
||||||
|
label: "Back",
|
||||||
|
intent: ButtonIntent::Cancel,
|
||||||
|
focused: self.focus == Focus::Back && self.dialog.is_none(),
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
context.theme,
|
||||||
|
);
|
||||||
|
hits.register(buttons_area[0], AppAction::Back);
|
||||||
|
|
||||||
|
render_button(
|
||||||
|
frame,
|
||||||
|
buttons_area[1],
|
||||||
|
ActionButton {
|
||||||
|
label: "Regenerate Keys",
|
||||||
|
intent: ButtonIntent::Destructive,
|
||||||
|
focused: self.focus == Focus::RegenerateKeys && self.dialog.is_none(),
|
||||||
|
enabled: !self.pending,
|
||||||
|
},
|
||||||
|
context.theme,
|
||||||
|
);
|
||||||
|
hits.register(buttons_area[1], AppAction::RegenerateKeys);
|
||||||
|
|
||||||
|
if self.dialog.is_some() {
|
||||||
|
frame.render_widget(Block::default().style(context.theme.surfaces.overlay), area);
|
||||||
|
let popup = crate::layout::fit::centered_rect(
|
||||||
|
area,
|
||||||
|
crate::layout::fit::RequiredSize {
|
||||||
|
width: 42,
|
||||||
|
height: 7,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" Confirm ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(context.theme.borders.focused)
|
||||||
|
.style(context.theme.surfaces.overlay);
|
||||||
|
let popup_inner = block.inner(popup);
|
||||||
|
frame.render_widget(block, popup);
|
||||||
|
let dialog_rows =
|
||||||
|
Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new("Regenerate the identity key pair?\nThis will rotate keys and reconnect to Omikron.").style(context.theme.text.normal),
|
||||||
|
dialog_rows[0],
|
||||||
|
);
|
||||||
|
let dialog_buttons =
|
||||||
|
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
|
.split(dialog_rows[1]);
|
||||||
|
render_button(
|
||||||
|
frame,
|
||||||
|
dialog_buttons[0],
|
||||||
|
ActionButton {
|
||||||
|
label: "Cancel",
|
||||||
|
intent: ButtonIntent::Cancel,
|
||||||
|
focused: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
context.theme,
|
||||||
|
);
|
||||||
|
render_button(
|
||||||
|
frame,
|
||||||
|
dialog_buttons[1],
|
||||||
|
ActionButton {
|
||||||
|
label: "Regenerate",
|
||||||
|
intent: ButtonIntent::Destructive,
|
||||||
|
focused: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
context.theme,
|
||||||
|
);
|
||||||
|
hits.register(dialog_buttons[0], AppAction::CancelDialog);
|
||||||
|
hits.register(dialog_buttons[1], AppAction::ConfirmDialog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
||||||
|
let event = match event {
|
||||||
|
UiEvent::App(AppEvent::ThemeSaved(result)) => {
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
self.saved = self.selected_theme();
|
||||||
|
self.message = "Theme saved to ui.yaml.".into();
|
||||||
|
}
|
||||||
|
Err(error) => self.message = error,
|
||||||
|
}
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
UiEvent::App(AppEvent::KeysRegenerated(result)) => {
|
||||||
|
self.pending = false;
|
||||||
|
self.dialog = None;
|
||||||
|
match result {
|
||||||
|
Ok(()) => self.message = "Keys regenerated successfully.".into(),
|
||||||
|
Err(error) => self.message = error,
|
||||||
|
}
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
event => event,
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.dialog.is_some() {
|
||||||
|
let UiEvent::Key(key) = event else {
|
||||||
|
return InteractionResult::Unhandled;
|
||||||
|
};
|
||||||
|
return match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.dialog = None;
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Enter => self.activate(),
|
||||||
|
_ => InteractionResult::Handled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let UiEvent::Key(key) = event else {
|
||||||
|
return InteractionResult::Unhandled;
|
||||||
|
};
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Left => {
|
||||||
|
if self.focus == Focus::Theme {
|
||||||
|
self.selected = self.selected.saturating_sub(1);
|
||||||
|
self.apply(false)
|
||||||
|
} else {
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
if self.focus == Focus::Theme {
|
||||||
|
self.selected = (self.selected + 1).min(ThemeName::ALL.len() - 1);
|
||||||
|
self.apply(false)
|
||||||
|
} else {
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
|
||||||
|
KeyCode::Tab => {
|
||||||
|
self.next_focus();
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::BackTab => {
|
||||||
|
self.prev_focus();
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Char('c') | KeyCode::Char('C') => {
|
||||||
|
self.color = Self::next_policy(self.color);
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Char('u') | KeyCode::Char('U') => {
|
||||||
|
self.unicode = Self::next_policy(self.unicode);
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
|
||||||
|
InteractionResult::CloseScreen
|
||||||
|
}
|
||||||
|
_ => InteractionResult::Unhandled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
|
||||||
|
match action {
|
||||||
|
AppAction::Back => InteractionResult::CloseScreen,
|
||||||
|
AppAction::RegenerateKeys => {
|
||||||
|
self.focus = Focus::RegenerateKeys;
|
||||||
|
self.activate()
|
||||||
|
}
|
||||||
|
AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(),
|
||||||
|
AppAction::CancelDialog if self.dialog.is_some() => {
|
||||||
|
self.dialog = None;
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
_ => InteractionResult::Unhandled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_hints(&self) -> Vec<KeyHint> {
|
||||||
|
if self.dialog.is_some() {
|
||||||
|
vec![
|
||||||
|
KeyHint {
|
||||||
|
keys: "Enter",
|
||||||
|
action: "Confirm",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "Esc",
|
||||||
|
action: "Cancel",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
KeyHint {
|
||||||
|
keys: "Left/Right",
|
||||||
|
action: "Preview theme",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "Enter",
|
||||||
|
action: "Save/Activate",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "Tab",
|
||||||
|
action: "Move focus",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "C/U",
|
||||||
|
action: "Color/Unicode",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "Esc/B",
|
||||||
|
action: "Back",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,13 @@ use crate::{
|
||||||
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
||||||
interaction_result::InteractionResult,
|
interaction_result::InteractionResult,
|
||||||
render_context::RenderContext,
|
render_context::RenderContext,
|
||||||
screens::{md_viewer::FileViewer, screens::Screen},
|
screens::{
|
||||||
|
md_viewer::FileViewer,
|
||||||
|
screens::{HitMap, Screen, UiEvent},
|
||||||
|
},
|
||||||
util::{buttons::draw_buttons, terms_focus::Focus},
|
util::{buttons::draw_buttons, terms_focus::Focus},
|
||||||
};
|
};
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::KeyCode;
|
||||||
use iota_terms::{TermsType, get_link, get_terms};
|
use iota_terms::{TermsType, get_link, get_terms};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
|
|
@ -53,7 +56,7 @@ impl Screen for TermsCheckerScreen {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) {
|
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
|
||||||
let mut needed_height = 5;
|
let mut needed_height = 5;
|
||||||
|
|
||||||
if size.height < 6 || size.width < 27 {
|
if size.height < 6 || size.width < 27 {
|
||||||
|
|
@ -270,7 +273,10 @@ impl Screen for TermsCheckerScreen {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
|
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
||||||
|
let UiEvent::Key(event) = event else {
|
||||||
|
return InteractionResult::Unhandled;
|
||||||
|
};
|
||||||
let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel];
|
let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel];
|
||||||
|
|
||||||
if self.eula {
|
if self.eula {
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,14 @@ use crate::{
|
||||||
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
||||||
interaction_result::InteractionResult,
|
interaction_result::InteractionResult,
|
||||||
render_context::RenderContext,
|
render_context::RenderContext,
|
||||||
screens::{md_viewer::FileViewer, screens::Screen},
|
screens::{
|
||||||
|
md_viewer::FileViewer,
|
||||||
|
screens::{HitMap, Screen, UiEvent},
|
||||||
|
},
|
||||||
util::{buttons::draw_buttons, terms_focus::Focus},
|
util::{buttons::draw_buttons, terms_focus::Focus},
|
||||||
};
|
};
|
||||||
use chrono::{Local, TimeZone, Utc};
|
use chrono::{Local, TimeZone, Utc};
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::KeyCode;
|
||||||
use iota_terms::{Doc, TermsType, get_newest_link, get_terms};
|
use iota_terms::{Doc, TermsType, get_newest_link, get_terms};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
|
|
@ -121,7 +124,7 @@ impl Screen for TermsUpdaterScreen {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) {
|
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
|
||||||
let checkbox = |label, selected, focused, enabled| {
|
let checkbox = |label, selected, focused, enabled| {
|
||||||
render_choice_line(
|
render_choice_line(
|
||||||
label,
|
label,
|
||||||
|
|
@ -593,7 +596,10 @@ impl Screen for TermsUpdaterScreen {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
|
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
||||||
|
let UiEvent::Key(event) = event else {
|
||||||
|
return InteractionResult::Unhandled;
|
||||||
|
};
|
||||||
let mut possible_states = Vec::new();
|
let mut possible_states = Vec::new();
|
||||||
|
|
||||||
if self.eula_needed {
|
if self.eula_needed {
|
||||||
|
|
|
||||||
717
iota-cli/src/screens/users.rs
Normal file
717
iota-cli/src/screens/users.rs
Normal file
|
|
@ -0,0 +1,717 @@
|
||||||
|
use crate::{
|
||||||
|
controls::{
|
||||||
|
button::{ActionButton, ButtonIntent, render_button},
|
||||||
|
choice::{ChoiceKind, render_choice_line},
|
||||||
|
},
|
||||||
|
interaction_result::InteractionResult,
|
||||||
|
ipc_client::IpcClient,
|
||||||
|
render_context::RenderContext,
|
||||||
|
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
|
||||||
|
};
|
||||||
|
use crossterm::event::{KeyCode, KeyModifiers};
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
any::Any,
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct UserEntry {
|
||||||
|
pub user_id: i64,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Focus {
|
||||||
|
List,
|
||||||
|
AddButton,
|
||||||
|
RemoveButton,
|
||||||
|
Back,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
enum Dialog {
|
||||||
|
Add { username: String },
|
||||||
|
Remove { user: UserEntry },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UsersScreen {
|
||||||
|
users: Vec<UserEntry>,
|
||||||
|
focused_index: usize,
|
||||||
|
focus: Focus,
|
||||||
|
ipc: Arc<IpcClient>,
|
||||||
|
message: Option<String>,
|
||||||
|
dialog: Option<Dialog>,
|
||||||
|
pending_dialog: Option<Dialog>,
|
||||||
|
loading: bool,
|
||||||
|
pending: bool,
|
||||||
|
scroll_offset: usize,
|
||||||
|
viewport_height: AtomicUsize,
|
||||||
|
filter: String,
|
||||||
|
filtering: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsersScreen {
|
||||||
|
pub fn new(ipc: Arc<IpcClient>, users: Vec<UserEntry>) -> Self {
|
||||||
|
Self {
|
||||||
|
users,
|
||||||
|
focused_index: 0,
|
||||||
|
focus: Focus::List,
|
||||||
|
ipc,
|
||||||
|
message: None,
|
||||||
|
dialog: None,
|
||||||
|
pending_dialog: None,
|
||||||
|
loading: false,
|
||||||
|
pending: false,
|
||||||
|
scroll_offset: 0,
|
||||||
|
viewport_height: AtomicUsize::new(1),
|
||||||
|
filter: String::new(),
|
||||||
|
filtering: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn loading(ipc: Arc<IpcClient>) -> Self {
|
||||||
|
let mut screen = Self::new(ipc, Vec::new());
|
||||||
|
screen.loading = true;
|
||||||
|
screen.message = Some("Loading users…".into());
|
||||||
|
screen
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_user_list(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
|
||||||
|
let visible_indices = self.filtered_indices();
|
||||||
|
let title = if self.filter.is_empty() {
|
||||||
|
format!("Users ({})", self.users.len())
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Users ({}/{}) filter: {}",
|
||||||
|
visible_indices.len(),
|
||||||
|
self.users.len(),
|
||||||
|
self.filter
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||||
|
crate::controls::panel::render_panel(
|
||||||
|
f,
|
||||||
|
area,
|
||||||
|
&title,
|
||||||
|
self.focus == Focus::List,
|
||||||
|
context.theme,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let block = Block::default()
|
||||||
|
.title(format!(" {title} "))
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(context.theme.borders.normal);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
inner
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.loading {
|
||||||
|
f.render_widget(Paragraph::new("Loading users…"), inner);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if visible_indices.is_empty() {
|
||||||
|
let par = Paragraph::new(if self.users.is_empty() {
|
||||||
|
"No users found."
|
||||||
|
} else {
|
||||||
|
"No users match the filter."
|
||||||
|
});
|
||||||
|
f.render_widget(par, inner);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
self.viewport_height
|
||||||
|
.store(inner.height as usize, Ordering::Relaxed);
|
||||||
|
let labels: Vec<(usize, String)> = visible_indices
|
||||||
|
.iter()
|
||||||
|
.skip(self.scroll_offset)
|
||||||
|
.take(inner.height as usize)
|
||||||
|
.map(|user_index| {
|
||||||
|
let user = &self.users[*user_index];
|
||||||
|
(
|
||||||
|
*user_index,
|
||||||
|
format!("{:>6} {}", user.user_id, user.username),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (user_index, label) in &labels {
|
||||||
|
let visual = crate::controls::choice::ChoiceVisualState {
|
||||||
|
selected: false,
|
||||||
|
focused: self.focus == Focus::List && *user_index == self.focused_index,
|
||||||
|
enabled: !self.loading && !self.pending,
|
||||||
|
};
|
||||||
|
lines.push(render_choice_line(
|
||||||
|
&label,
|
||||||
|
ChoiceKind::Radio,
|
||||||
|
visual,
|
||||||
|
context.theme,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let par = Paragraph::new(lines);
|
||||||
|
f.render_widget(par, inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_actions(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
|
||||||
|
let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(area);
|
||||||
|
|
||||||
|
if let Some(msg) = &self.message {
|
||||||
|
let par = Paragraph::new(Line::from(Span::styled(
|
||||||
|
msg.as_str(),
|
||||||
|
context.theme.text.muted,
|
||||||
|
)));
|
||||||
|
f.render_widget(par, rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let buttons_area = Layout::horizontal([
|
||||||
|
Constraint::Percentage(33),
|
||||||
|
Constraint::Percentage(33),
|
||||||
|
Constraint::Percentage(34),
|
||||||
|
])
|
||||||
|
.split(rows[1]);
|
||||||
|
|
||||||
|
render_button(
|
||||||
|
f,
|
||||||
|
buttons_area[0],
|
||||||
|
ActionButton {
|
||||||
|
label: "Back",
|
||||||
|
intent: ButtonIntent::Cancel,
|
||||||
|
focused: self.focus == Focus::Back,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
context.theme,
|
||||||
|
);
|
||||||
|
render_button(
|
||||||
|
f,
|
||||||
|
buttons_area[1],
|
||||||
|
ActionButton {
|
||||||
|
label: "Add",
|
||||||
|
intent: ButtonIntent::Primary,
|
||||||
|
focused: self.focus == Focus::AddButton,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
context.theme,
|
||||||
|
);
|
||||||
|
render_button(
|
||||||
|
f,
|
||||||
|
buttons_area[2],
|
||||||
|
ActionButton {
|
||||||
|
label: "Remove",
|
||||||
|
intent: ButtonIntent::Destructive,
|
||||||
|
focused: self.focus == Focus::RemoveButton,
|
||||||
|
enabled: !self.loading && !self.pending && !self.users.is_empty(),
|
||||||
|
},
|
||||||
|
context.theme,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate(&mut self) -> InteractionResult {
|
||||||
|
if self.loading || self.pending {
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
if let Some(dialog) = self.dialog.take() {
|
||||||
|
match dialog {
|
||||||
|
Dialog::Add { username } if !username.trim().is_empty() => {
|
||||||
|
let name = username.trim().to_owned();
|
||||||
|
self.pending_dialog = Some(Dialog::Add { username });
|
||||||
|
self.pending = true;
|
||||||
|
self.message = Some("Creating user…".into());
|
||||||
|
let ipc = self.ipc.clone();
|
||||||
|
return InteractionResult::AppTask {
|
||||||
|
task: Box::pin(async move {
|
||||||
|
let result = match ipc.send_request(iota_ipc::LocalRequest::CreateUser { username: name }).await {
|
||||||
|
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username }),
|
||||||
|
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot create user: {error}")),
|
||||||
|
Ok(_) => Err("Daemon returned an unexpected response while creating the user.".into()),
|
||||||
|
Err(error) => Err(format!("Cannot create user: {error}")),
|
||||||
|
};
|
||||||
|
UiEvent::App(AppEvent::UserCreated(result))
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Dialog::Remove { user } => {
|
||||||
|
self.pending_dialog = Some(Dialog::Remove { user: user.clone() });
|
||||||
|
let ipc = self.ipc.clone();
|
||||||
|
let id = user.user_id;
|
||||||
|
self.pending = true;
|
||||||
|
self.message = Some(format!("Removing {}…", user.username));
|
||||||
|
return InteractionResult::AppTask {
|
||||||
|
task: Box::pin(async move {
|
||||||
|
let result = match ipc.send_request(iota_ipc::LocalRequest::RemoveUser { user_id: id }).await {
|
||||||
|
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserRemoved { .. })) => Ok(()),
|
||||||
|
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot remove user: {error}")),
|
||||||
|
Ok(_) => Err("Daemon returned an unexpected response while removing the user.".into()),
|
||||||
|
Err(error) => Err(format!("Cannot remove user: {error}")),
|
||||||
|
};
|
||||||
|
UiEvent::App(AppEvent::UserRemoved {
|
||||||
|
user_id: id,
|
||||||
|
result,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Dialog::Add { .. } => self.message = Some("A username is required.".into()),
|
||||||
|
}
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
match self.focus {
|
||||||
|
Focus::Back => InteractionResult::CloseScreen,
|
||||||
|
Focus::AddButton => {
|
||||||
|
self.dialog = Some(Dialog::Add {
|
||||||
|
username: String::new(),
|
||||||
|
});
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
Focus::RemoveButton => {
|
||||||
|
if let Some(user) = self.users.get(self.focused_index) {
|
||||||
|
self.dialog = Some(Dialog::Remove { user: user.clone() });
|
||||||
|
}
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
Focus::List => InteractionResult::Handled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_focus(&mut self) {
|
||||||
|
self.focus = match self.focus {
|
||||||
|
Focus::List => Focus::AddButton,
|
||||||
|
Focus::AddButton => Focus::RemoveButton,
|
||||||
|
Focus::RemoveButton => Focus::Back,
|
||||||
|
Focus::Back => Focus::List,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prev_focus(&mut self) {
|
||||||
|
self.focus = match self.focus {
|
||||||
|
Focus::List => Focus::Back,
|
||||||
|
Focus::Back => Focus::RemoveButton,
|
||||||
|
Focus::RemoveButton => Focus::AddButton,
|
||||||
|
Focus::AddButton => Focus::List,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keep_focused_user_visible(&mut self) {
|
||||||
|
let indices = self.filtered_indices();
|
||||||
|
let Some(position) = indices
|
||||||
|
.iter()
|
||||||
|
.position(|index| *index == self.focused_index)
|
||||||
|
else {
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let height = self.viewport_height.load(Ordering::Relaxed).max(1);
|
||||||
|
if position < self.scroll_offset {
|
||||||
|
self.scroll_offset = position;
|
||||||
|
} else if position >= self.scroll_offset + height {
|
||||||
|
self.scroll_offset = position + 1 - height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_user_focus(&mut self, index: usize) {
|
||||||
|
if !self.users.is_empty() {
|
||||||
|
self.focused_index = index.min(self.users.len() - 1);
|
||||||
|
self.keep_focused_user_visible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filtered_indices(&self) -> Vec<usize> {
|
||||||
|
let needle = self.filter.to_ascii_lowercase();
|
||||||
|
self.users
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, user)| {
|
||||||
|
needle.is_empty()
|
||||||
|
|| user.username.to_ascii_lowercase().contains(&needle)
|
||||||
|
|| user.user_id.to_string().contains(&needle)
|
||||||
|
})
|
||||||
|
.map(|(index, _)| index)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_visible(&mut self, delta: isize) {
|
||||||
|
let indices = self.filtered_indices();
|
||||||
|
if indices.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let current = indices
|
||||||
|
.iter()
|
||||||
|
.position(|index| *index == self.focused_index)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let next = (current as isize + delta).clamp(0, indices.len() as isize - 1) as usize;
|
||||||
|
self.move_user_focus(indices[next]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_focus_to_filter(&mut self) {
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
if let Some(index) = self.filtered_indices().first().copied() {
|
||||||
|
self.focused_index = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Screen for UsersScreen {
|
||||||
|
fn as_any(&self) -> &dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) {
|
||||||
|
let outer_block = Block::default()
|
||||||
|
.title(" Users ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(context.theme.borders.normal)
|
||||||
|
.title_style(context.theme.borders.title);
|
||||||
|
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||||
|
crate::controls::panel::render_panel(f, rect, "Users", false, context.theme)
|
||||||
|
} else {
|
||||||
|
let inner = outer_block.inner(rect);
|
||||||
|
f.render_widget(outer_block, rect);
|
||||||
|
inner
|
||||||
|
};
|
||||||
|
|
||||||
|
let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(inner);
|
||||||
|
|
||||||
|
self.render_user_list(f, chunks[0], context);
|
||||||
|
self.render_actions(f, chunks[1], context);
|
||||||
|
if let Some(dialog) = &self.dialog {
|
||||||
|
f.render_widget(Block::default().style(context.theme.surfaces.overlay), rect);
|
||||||
|
let popup = crate::layout::fit::centered_rect(
|
||||||
|
rect,
|
||||||
|
crate::layout::fit::RequiredSize {
|
||||||
|
width: 42,
|
||||||
|
height: 7,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let text = match dialog {
|
||||||
|
Dialog::Add { username } => {
|
||||||
|
format!("Add user\nUsername: {username}")
|
||||||
|
}
|
||||||
|
Dialog::Remove { user } => format!(
|
||||||
|
"Remove user {} (ID {})?\nThis removes the local user record.",
|
||||||
|
user.username, user.user_id
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" Confirm ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(context.theme.borders.focused)
|
||||||
|
.style(context.theme.surfaces.overlay);
|
||||||
|
let popup_inner = block.inner(popup);
|
||||||
|
f.render_widget(block, popup);
|
||||||
|
let dialog_rows =
|
||||||
|
Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(text).style(context.theme.text.normal),
|
||||||
|
dialog_rows[0],
|
||||||
|
);
|
||||||
|
let dialog_buttons =
|
||||||
|
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
|
.split(dialog_rows[1]);
|
||||||
|
render_button(
|
||||||
|
f,
|
||||||
|
dialog_buttons[0],
|
||||||
|
ActionButton {
|
||||||
|
label: "Cancel",
|
||||||
|
intent: ButtonIntent::Cancel,
|
||||||
|
focused: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
context.theme,
|
||||||
|
);
|
||||||
|
render_button(
|
||||||
|
f,
|
||||||
|
dialog_buttons[1],
|
||||||
|
ActionButton {
|
||||||
|
label: match dialog {
|
||||||
|
Dialog::Add { .. } => "Create",
|
||||||
|
Dialog::Remove { .. } => "Remove",
|
||||||
|
},
|
||||||
|
intent: match dialog {
|
||||||
|
Dialog::Add { .. } => ButtonIntent::Primary,
|
||||||
|
Dialog::Remove { .. } => ButtonIntent::Destructive,
|
||||||
|
},
|
||||||
|
focused: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
context.theme,
|
||||||
|
);
|
||||||
|
hits.register(dialog_buttons[0], AppAction::CancelDialog);
|
||||||
|
hits.register(dialog_buttons[1], AppAction::ConfirmDialog);
|
||||||
|
}
|
||||||
|
let buttons = Layout::horizontal([
|
||||||
|
Constraint::Percentage(33),
|
||||||
|
Constraint::Percentage(33),
|
||||||
|
Constraint::Percentage(34),
|
||||||
|
])
|
||||||
|
.split(chunks[1]);
|
||||||
|
if self.dialog.is_none() {
|
||||||
|
hits.register(buttons[0], AppAction::Back);
|
||||||
|
}
|
||||||
|
if self.dialog.is_none() && !self.loading && !self.pending {
|
||||||
|
hits.register(buttons[1], AppAction::AddUser);
|
||||||
|
}
|
||||||
|
if self.dialog.is_none() && !self.loading && !self.pending && !self.users.is_empty() {
|
||||||
|
hits.register(buttons[2], AppAction::RemoveUser);
|
||||||
|
}
|
||||||
|
if self.dialog.is_none() {
|
||||||
|
let list_height = chunks[0].height.saturating_sub(2) as usize;
|
||||||
|
let filtered_indices = self.filtered_indices();
|
||||||
|
for visible in 0..list_height {
|
||||||
|
let position = self.scroll_offset + visible;
|
||||||
|
let Some(index) = filtered_indices.get(position).copied() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
hits.register(
|
||||||
|
Rect {
|
||||||
|
x: chunks[0].x.saturating_add(1),
|
||||||
|
y: chunks[0].y.saturating_add(1 + visible as u16),
|
||||||
|
width: chunks[0].width.saturating_sub(2),
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
AppAction::SelectUser(index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
||||||
|
let event = match event {
|
||||||
|
UiEvent::App(AppEvent::UsersLoaded(result)) => {
|
||||||
|
self.loading = false;
|
||||||
|
match result {
|
||||||
|
Ok(users) => {
|
||||||
|
self.users = users;
|
||||||
|
self.message = None;
|
||||||
|
}
|
||||||
|
Err(error) => self.message = Some(error),
|
||||||
|
}
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
UiEvent::App(AppEvent::UserCreated(result)) => {
|
||||||
|
self.pending = false;
|
||||||
|
match result {
|
||||||
|
Ok(user) => {
|
||||||
|
self.pending_dialog = None;
|
||||||
|
self.focused_index = self.users.len();
|
||||||
|
self.users.push(user.clone());
|
||||||
|
self.message = Some(format!(
|
||||||
|
"Created user {} ({}).",
|
||||||
|
user.username, user.user_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.dialog = self.pending_dialog.take();
|
||||||
|
self.message = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
UiEvent::App(AppEvent::UserRemoved { user_id, result }) => {
|
||||||
|
self.pending = false;
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
self.pending_dialog = None;
|
||||||
|
self.users.retain(|user| user.user_id != user_id);
|
||||||
|
self.focused_index =
|
||||||
|
self.focused_index.min(self.users.len().saturating_sub(1));
|
||||||
|
self.message = Some(format!("Removed user {user_id}."));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.dialog = self.pending_dialog.take();
|
||||||
|
self.message = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
UiEvent::Paste(text) if matches!(self.dialog, Some(Dialog::Add { .. })) => {
|
||||||
|
if let Some(Dialog::Add { username }) = self.dialog.as_mut() {
|
||||||
|
username.push_str(&text.replace(['\r', '\n'], " "));
|
||||||
|
}
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
UiEvent::Key(event) => event,
|
||||||
|
_ => return InteractionResult::Unhandled,
|
||||||
|
};
|
||||||
|
if self.filtering && self.dialog.is_none() {
|
||||||
|
match event.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.filtering = false;
|
||||||
|
self.filter.clear();
|
||||||
|
self.reset_focus_to_filter();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => self.filtering = false,
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.filter.pop();
|
||||||
|
self.reset_focus_to_filter();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c)
|
||||||
|
if !event
|
||||||
|
.modifiers
|
||||||
|
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
|
||||||
|
{
|
||||||
|
self.filter.push(c);
|
||||||
|
self.reset_focus_to_filter();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
if let Some(Dialog::Add { username }) = self.dialog.as_mut() {
|
||||||
|
match event.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.dialog = None;
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => return self.activate(),
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
username.pop();
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
KeyCode::Char(c)
|
||||||
|
if !c.is_control()
|
||||||
|
&& !event
|
||||||
|
.modifiers
|
||||||
|
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
|
||||||
|
{
|
||||||
|
username.push(c);
|
||||||
|
return InteractionResult::Handled;
|
||||||
|
}
|
||||||
|
_ => return InteractionResult::Handled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.dialog.is_some() {
|
||||||
|
return match event.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.dialog = None;
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Enter => self.activate(),
|
||||||
|
_ => InteractionResult::Handled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match event.code {
|
||||||
|
KeyCode::Esc => InteractionResult::CloseScreen,
|
||||||
|
KeyCode::Char('/') if self.focus == Focus::List => {
|
||||||
|
self.filtering = true;
|
||||||
|
self.filter.clear();
|
||||||
|
self.reset_focus_to_filter();
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Tab => {
|
||||||
|
self.next_focus();
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::BackTab => {
|
||||||
|
self.prev_focus();
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
if self.focus == Focus::List {
|
||||||
|
self.move_visible(1);
|
||||||
|
}
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
if self.focus == Focus::List {
|
||||||
|
self.move_visible(-1);
|
||||||
|
}
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::PageDown if self.focus == Focus::List && !self.users.is_empty() => {
|
||||||
|
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
|
||||||
|
self.move_visible(page as isize);
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::PageUp if self.focus == Focus::List && !self.users.is_empty() => {
|
||||||
|
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
|
||||||
|
self.move_visible(-(page as isize));
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Home if self.focus == Focus::List => {
|
||||||
|
if let Some(index) = self.filtered_indices().first().copied() {
|
||||||
|
self.move_user_focus(index);
|
||||||
|
}
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::End if self.focus == Focus::List && !self.users.is_empty() => {
|
||||||
|
if let Some(index) = self.filtered_indices().last().copied() {
|
||||||
|
self.move_user_focus(index);
|
||||||
|
}
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
|
||||||
|
_ => InteractionResult::Unhandled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
|
||||||
|
match action {
|
||||||
|
AppAction::Back => InteractionResult::CloseScreen,
|
||||||
|
AppAction::AddUser => {
|
||||||
|
self.focus = Focus::AddButton;
|
||||||
|
self.activate()
|
||||||
|
}
|
||||||
|
AppAction::RemoveUser => {
|
||||||
|
self.focus = Focus::RemoveButton;
|
||||||
|
self.activate()
|
||||||
|
}
|
||||||
|
AppAction::SelectUser(index) if self.dialog.is_none() => {
|
||||||
|
self.focus = Focus::List;
|
||||||
|
self.move_user_focus(index);
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(),
|
||||||
|
AppAction::CancelDialog if self.dialog.is_some() => {
|
||||||
|
self.dialog = None;
|
||||||
|
InteractionResult::Handled
|
||||||
|
}
|
||||||
|
_ => InteractionResult::Unhandled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn key_hints(&self) -> Vec<KeyHint> {
|
||||||
|
if self.dialog.is_some() {
|
||||||
|
vec![
|
||||||
|
KeyHint {
|
||||||
|
keys: "Enter",
|
||||||
|
action: "Confirm",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "Esc",
|
||||||
|
action: "Cancel",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
KeyHint {
|
||||||
|
keys: "Up/Down",
|
||||||
|
action: "Select user",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "PgUp/PgDn",
|
||||||
|
action: "Page",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "/",
|
||||||
|
action: "Filter",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "Tab",
|
||||||
|
action: "Move focus",
|
||||||
|
},
|
||||||
|
KeyHint {
|
||||||
|
keys: "F6",
|
||||||
|
action: "Header",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,19 @@ pub struct UiConfig {
|
||||||
/// Whether opening the interactive UI should launch a locally installed daemon.
|
/// Whether opening the interactive UI should launch a locally installed daemon.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub daemon_start_policy: DaemonStartPolicy,
|
pub daemon_start_policy: DaemonStartPolicy,
|
||||||
|
#[serde(default)]
|
||||||
|
pub color: TerminalPolicy,
|
||||||
|
#[serde(default)]
|
||||||
|
pub unicode: TerminalPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TerminalPolicy {
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
Always,
|
||||||
|
Never,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,81 @@ mod model;
|
||||||
mod name;
|
mod name;
|
||||||
mod presets;
|
mod presets;
|
||||||
|
|
||||||
pub use config::{DaemonStartPolicy, UiConfig};
|
pub use config::{DaemonStartPolicy, TerminalPolicy, UiConfig};
|
||||||
pub use model::*;
|
pub use model::*;
|
||||||
pub use name::ThemeName;
|
pub use name::ThemeName;
|
||||||
|
|
||||||
pub fn resolve(name: ThemeName) -> ResolvedTheme {
|
pub fn resolve(name: ThemeName) -> ResolvedTheme {
|
||||||
presets::resolve(name)
|
presets::resolve(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_with_capabilities(
|
||||||
|
name: ThemeName,
|
||||||
|
color_enabled: bool,
|
||||||
|
unicode_enabled: bool,
|
||||||
|
) -> ResolvedTheme {
|
||||||
|
let mut theme = if color_enabled {
|
||||||
|
presets::resolve(name)
|
||||||
|
} else {
|
||||||
|
presets::resolve(ThemeName::Monospace)
|
||||||
|
};
|
||||||
|
theme.name = name;
|
||||||
|
theme.unicode = unicode_enabled;
|
||||||
|
if !unicode_enabled {
|
||||||
|
if matches!(theme.console.cursor, CursorPresentation::Character { .. }) {
|
||||||
|
theme.console.cursor = CursorPresentation::Character {
|
||||||
|
glyph: "|",
|
||||||
|
style: theme.console.text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
theme
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a theme against the terminal's color depth. Surface uses RGB
|
||||||
|
/// colors, so a portable ANSI preset is selected when truecolor is absent.
|
||||||
|
pub fn resolve_with_terminal_profile(
|
||||||
|
name: ThemeName,
|
||||||
|
color_enabled: bool,
|
||||||
|
unicode_enabled: bool,
|
||||||
|
truecolor_enabled: bool,
|
||||||
|
) -> ResolvedTheme {
|
||||||
|
let effective = if color_enabled && !truecolor_enabled && matches!(name, ThemeName::Surface) {
|
||||||
|
ThemeName::Ansi
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
};
|
||||||
|
resolve_with_capabilities(effective, color_enabled, unicode_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ratatui::style::Color;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_color_policy_removes_palette_dependencies() {
|
||||||
|
let theme = resolve_with_capabilities(ThemeName::Surface, false, true);
|
||||||
|
assert_eq!(theme.name, ThemeName::Surface);
|
||||||
|
assert_eq!(theme.status.error.fg, None);
|
||||||
|
assert_eq!(theme.surfaces.panel.bg, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ascii_policy_replaces_character_cursor() {
|
||||||
|
let theme = resolve_with_capabilities(ThemeName::Monospace, false, false);
|
||||||
|
assert!(!theme.unicode);
|
||||||
|
match theme.console.cursor {
|
||||||
|
CursorPresentation::Character { glyph, .. } => assert_eq!(glyph, "|"),
|
||||||
|
CursorPresentation::StyledCell(_) => panic!("expected an ASCII character cursor"),
|
||||||
|
}
|
||||||
|
assert_ne!(theme.graphs.ram, Color::Blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn surface_uses_ansi_fallback_without_truecolor() {
|
||||||
|
let theme = resolve_with_terminal_profile(ThemeName::Surface, true, true, false);
|
||||||
|
assert_eq!(theme.name, ThemeName::Ansi);
|
||||||
|
assert_eq!(theme.surfaces.panel.bg, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,22 @@ pub struct BorderStyles {
|
||||||
pub title: Style,
|
pub title: Style,
|
||||||
}
|
}
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SurfaceStyles {
|
||||||
|
pub canvas: Style,
|
||||||
|
pub toolbar: Style,
|
||||||
|
pub panel: Style,
|
||||||
|
pub panel_alternate: Style,
|
||||||
|
pub panel_focused: Style,
|
||||||
|
pub panel_selected: Style,
|
||||||
|
pub footer: Style,
|
||||||
|
pub overlay: Style,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ChromeMode {
|
||||||
|
Bordered,
|
||||||
|
Surfaces,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
pub struct ChoiceItemStyle {
|
pub struct ChoiceItemStyle {
|
||||||
pub marker: Style,
|
pub marker: Style,
|
||||||
pub label: Style,
|
pub label: Style,
|
||||||
|
|
@ -119,6 +135,9 @@ pub struct TextSemantics {
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ResolvedTheme {
|
pub struct ResolvedTheme {
|
||||||
pub name: ThemeName,
|
pub name: ThemeName,
|
||||||
|
pub unicode: bool,
|
||||||
|
pub surfaces: SurfaceStyles,
|
||||||
|
pub chrome: ChromeMode,
|
||||||
pub text: TextStyles,
|
pub text: TextStyles,
|
||||||
pub status: StatusStyles,
|
pub status: StatusStyles,
|
||||||
pub choices: ChoiceStyles,
|
pub choices: ChoiceStyles,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use super::{
|
use super::{
|
||||||
BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ConsoleStyles, CursorPresentation,
|
BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ChromeMode, ConsoleStyles,
|
||||||
GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, TextStyles,
|
CursorPresentation, GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme,
|
||||||
ThemeName,
|
StatusStyles, SurfaceStyles, TextStyles, ThemeName,
|
||||||
};
|
};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
|
||||||
|
|
@ -45,6 +45,18 @@ fn base(
|
||||||
};
|
};
|
||||||
ResolvedTheme {
|
ResolvedTheme {
|
||||||
name,
|
name,
|
||||||
|
unicode: true,
|
||||||
|
surfaces: SurfaceStyles {
|
||||||
|
canvas: Style::default(),
|
||||||
|
toolbar: Style::default(),
|
||||||
|
panel: Style::default(),
|
||||||
|
panel_alternate: Style::default(),
|
||||||
|
panel_focused: focused,
|
||||||
|
panel_selected: selected,
|
||||||
|
footer: Style::default(),
|
||||||
|
overlay: Style::default(),
|
||||||
|
},
|
||||||
|
chrome: ChromeMode::Bordered,
|
||||||
text: TextStyles {
|
text: TextStyles {
|
||||||
normal,
|
normal,
|
||||||
muted,
|
muted,
|
||||||
|
|
@ -295,6 +307,17 @@ pub fn resolve(name: ThemeName) -> ResolvedTheme {
|
||||||
);
|
);
|
||||||
theme.console.cursor =
|
theme.console.cursor =
|
||||||
CursorPresentation::StyledCell(plain.fg(Color::Black).bg(Color::Yellow));
|
CursorPresentation::StyledCell(plain.fg(Color::Black).bg(Color::Yellow));
|
||||||
|
theme.chrome = ChromeMode::Surfaces;
|
||||||
|
theme.surfaces = SurfaceStyles {
|
||||||
|
canvas: plain.bg(Color::Black),
|
||||||
|
toolbar: plain.fg(Color::White).bg(Color::DarkGray),
|
||||||
|
panel: plain.fg(Color::White).bg(Color::Rgb(30, 35, 45)),
|
||||||
|
panel_alternate: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)),
|
||||||
|
panel_focused: plain.fg(Color::White).bg(Color::Rgb(48, 58, 78)),
|
||||||
|
panel_selected: selected,
|
||||||
|
footer: plain.fg(Color::DarkGray).bg(Color::Black),
|
||||||
|
overlay: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)),
|
||||||
|
};
|
||||||
theme
|
theme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,28 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
|
controls::header::render_header,
|
||||||
input_handler::setup_input_handler,
|
input_handler::setup_input_handler,
|
||||||
interaction_result::InteractionResult,
|
interaction_result::InteractionResult,
|
||||||
ipc_client::IpcClient,
|
ipc_client::IpcClient,
|
||||||
render_context::RenderContext,
|
render_context::RenderContext,
|
||||||
screens::screens::Screen,
|
screens::{
|
||||||
|
main_screen::MainScreen,
|
||||||
|
metrics::MetricsScreen,
|
||||||
|
overview::OverviewScreen,
|
||||||
|
screens::{AppAction, AppEvent, HitMap, Screen, UiEvent},
|
||||||
|
settings::SettingsScreen,
|
||||||
|
users::{UserEntry, UsersScreen},
|
||||||
|
},
|
||||||
theme::{self, ResolvedTheme, ThemeName},
|
theme::{self, ResolvedTheme, ThemeName},
|
||||||
};
|
};
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::{
|
||||||
|
DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, MouseEventKind,
|
||||||
|
};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
use ratatui::{
|
||||||
|
Terminal,
|
||||||
|
backend::CrosstermBackend,
|
||||||
|
layout::{Constraint, Layout},
|
||||||
|
};
|
||||||
use std::{
|
use std::{
|
||||||
io,
|
io,
|
||||||
io::Stdout,
|
io::Stdout,
|
||||||
|
|
@ -18,7 +32,7 @@ use std::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use tokio::sync::{Notify, RwLock};
|
use tokio::sync::{Notify, RwLock, mpsc};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
|
@ -35,6 +49,10 @@ pub struct UI {
|
||||||
theme: RwLock<Arc<ResolvedTheme>>,
|
theme: RwLock<Arc<ResolvedTheme>>,
|
||||||
pub(crate) invalidation: Notify,
|
pub(crate) invalidation: Notify,
|
||||||
failure: Arc<Mutex<Option<String>>>,
|
failure: Arc<Mutex<Option<String>>>,
|
||||||
|
hits: Mutex<HitMap>,
|
||||||
|
app_event_tx: mpsc::UnboundedSender<UiEvent>,
|
||||||
|
app_event_rx: Mutex<Option<mpsc::UnboundedReceiver<UiEvent>>>,
|
||||||
|
header_focus: Mutex<Option<usize>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_tui(ipc: Arc<IpcClient>) -> io::Result<TuiSession> {
|
pub fn start_tui(ipc: Arc<IpcClient>) -> io::Result<TuiSession> {
|
||||||
|
|
@ -55,6 +73,24 @@ pub fn start_bootstrap_tui_with_theme(theme: ResolvedTheme) -> io::Result<TuiSes
|
||||||
|
|
||||||
fn start_session(ui: UI) -> io::Result<TuiSession> {
|
fn start_session(ui: UI) -> io::Result<TuiSession> {
|
||||||
let ui = Arc::new(ui);
|
let ui = Arc::new(ui);
|
||||||
|
let mut app_event_rx = ui
|
||||||
|
.app_event_rx
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| io::Error::other("application event queue poisoned"))?
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| io::Error::other("application event queue already started"))?;
|
||||||
|
let app_ui = ui.clone();
|
||||||
|
let app_event_task = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = app_ui.cancellation.cancelled() => break,
|
||||||
|
event = app_event_rx.recv() => match event {
|
||||||
|
Some(event) => app_ui.clone().handle_event(event).await,
|
||||||
|
None => break,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
let uic = ui.clone();
|
let uic = ui.clone();
|
||||||
let renderer_task = tokio::spawn(async move {
|
let renderer_task = tokio::spawn(async move {
|
||||||
let cancellation = uic.cancellation_token();
|
let cancellation = uic.cancellation_token();
|
||||||
|
|
@ -62,7 +98,6 @@ fn start_session(ui: UI) -> io::Result<TuiSession> {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = cancellation.cancelled() => break Ok(()),
|
_ = cancellation.cancelled() => break Ok(()),
|
||||||
_ = uic.invalidation.notified() => { if !uic.is_shutdown() { uic.render().await?; } },
|
_ = uic.invalidation.notified() => { if !uic.is_shutdown() { uic.render().await?; } },
|
||||||
_ = tokio::time::sleep(std::time::Duration::from_millis(250)) => { if !uic.is_shutdown() { uic.render().await?; } },
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(error) = &result {
|
if let Err(error) = &result {
|
||||||
|
|
@ -101,6 +136,7 @@ fn start_session(ui: UI) -> io::Result<TuiSession> {
|
||||||
ui,
|
ui,
|
||||||
renderer_task,
|
renderer_task,
|
||||||
input_task,
|
input_task,
|
||||||
|
app_event_task,
|
||||||
signal_task,
|
signal_task,
|
||||||
restored: AtomicBool::new(false),
|
restored: AtomicBool::new(false),
|
||||||
previous_hook,
|
previous_hook,
|
||||||
|
|
@ -111,6 +147,7 @@ pub struct TuiSession {
|
||||||
ui: Arc<UI>,
|
ui: Arc<UI>,
|
||||||
renderer_task: JoinHandle<io::Result<()>>,
|
renderer_task: JoinHandle<io::Result<()>>,
|
||||||
input_task: JoinHandle<Result<(), String>>,
|
input_task: JoinHandle<Result<(), String>>,
|
||||||
|
app_event_task: JoinHandle<()>,
|
||||||
signal_task: Option<JoinHandle<()>>,
|
signal_task: Option<JoinHandle<()>>,
|
||||||
restored: AtomicBool,
|
restored: AtomicBool,
|
||||||
previous_hook: Arc<Mutex<Option<Box<dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static>>>>,
|
previous_hook: Arc<Mutex<Option<Box<dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static>>>>,
|
||||||
|
|
@ -129,6 +166,7 @@ impl TuiSession {
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.renderer_task).await;
|
tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.renderer_task).await;
|
||||||
let input =
|
let input =
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.input_task).await;
|
tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.input_task).await;
|
||||||
|
self.app_event_task.abort();
|
||||||
if renderer.is_err() {
|
if renderer.is_err() {
|
||||||
self.renderer_task.abort();
|
self.renderer_task.abort();
|
||||||
}
|
}
|
||||||
|
|
@ -154,6 +192,7 @@ impl TuiSession {
|
||||||
}
|
}
|
||||||
fn restore_terminal_once(&self) {
|
fn restore_terminal_once(&self) {
|
||||||
if !self.restored.swap(true, Ordering::AcqRel) {
|
if !self.restored.swap(true, Ordering::AcqRel) {
|
||||||
|
let _ = crossterm::execute!(io::stdout(), DisableMouseCapture);
|
||||||
ratatui::restore();
|
ratatui::restore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -168,6 +207,7 @@ impl Drop for TuiSession {
|
||||||
self.ui.request_shutdown();
|
self.ui.request_shutdown();
|
||||||
self.renderer_task.abort();
|
self.renderer_task.abort();
|
||||||
self.input_task.abort();
|
self.input_task.abort();
|
||||||
|
self.app_event_task.abort();
|
||||||
if let Some(task) = self.signal_task.as_ref() {
|
if let Some(task) = self.signal_task.as_ref() {
|
||||||
task.abort();
|
task.abort();
|
||||||
}
|
}
|
||||||
|
|
@ -182,6 +222,8 @@ impl UI {
|
||||||
theme: ResolvedTheme,
|
theme: ResolvedTheme,
|
||||||
) -> io::Result<Self> {
|
) -> io::Result<Self> {
|
||||||
let terminal = ratatui::try_init()?;
|
let terminal = ratatui::try_init()?;
|
||||||
|
crossterm::execute!(io::stdout(), EnableMouseCapture)?;
|
||||||
|
let (app_event_tx, app_event_rx) = mpsc::unbounded_channel();
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
ipc: RwLock::new(ipc),
|
ipc: RwLock::new(ipc),
|
||||||
shutdown_on_empty,
|
shutdown_on_empty,
|
||||||
|
|
@ -191,6 +233,10 @@ impl UI {
|
||||||
theme: RwLock::new(Arc::new(theme)),
|
theme: RwLock::new(Arc::new(theme)),
|
||||||
invalidation: Notify::new(),
|
invalidation: Notify::new(),
|
||||||
failure: Arc::new(Mutex::new(None)),
|
failure: Arc::new(Mutex::new(None)),
|
||||||
|
hits: Mutex::new(HitMap::default()),
|
||||||
|
app_event_tx,
|
||||||
|
app_event_rx: Mutex::new(Some(app_event_rx)),
|
||||||
|
header_focus: Mutex::new(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,10 +274,6 @@ impl UI {
|
||||||
pub fn failure(&self) -> Option<String> {
|
pub fn failure(&self) -> Option<String> {
|
||||||
self.failure.lock().ok().and_then(|f| f.clone())
|
self.failure.lock().ok().and_then(|f| f.clone())
|
||||||
}
|
}
|
||||||
pub async fn handle_paste(&self, _text: String) {
|
|
||||||
self.invalidate();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lets bootstrap operations race their work against Ctrl+C without
|
/// Lets bootstrap operations race their work against Ctrl+C without
|
||||||
/// blocking the input task or leaving the terminal in raw mode.
|
/// blocking the input task or leaving the terminal in raw mode.
|
||||||
pub async fn wait_for_shutdown(&self) {
|
pub async fn wait_for_shutdown(&self) {
|
||||||
|
|
@ -259,10 +301,166 @@ impl UI {
|
||||||
self.invalidate();
|
self.invalidate();
|
||||||
}
|
}
|
||||||
pub async fn handle_input(self: Arc<Self>, key_event: KeyEvent) {
|
pub async fn handle_input(self: Arc<Self>, key_event: KeyEvent) {
|
||||||
|
self.handle_event(UiEvent::Key(key_event)).await;
|
||||||
|
}
|
||||||
|
pub async fn handle_event(self: Arc<Self>, event: UiEvent) {
|
||||||
|
if matches!(&event, UiEvent::App(AppEvent::OpenUsers)) {
|
||||||
|
self.open_users().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if matches!(&event, UiEvent::App(AppEvent::OpenMetrics)) {
|
||||||
|
if let Some(screen) = MetricsScreen::new(self.clone()).await {
|
||||||
|
self.set_screen(Box::new(screen)).await;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let UiEvent::App(AppEvent::ApplyTheme { theme, persist }) = &event {
|
||||||
|
self.set_theme(theme::resolve(*theme)).await;
|
||||||
|
if *persist {
|
||||||
|
let mut config = theme::UiConfig::load().unwrap_or_default();
|
||||||
|
config.theme = *theme;
|
||||||
|
let result = config
|
||||||
|
.save()
|
||||||
|
.map_err(|error| format!("Could not save UI settings: {error}"));
|
||||||
|
let _ = self
|
||||||
|
.app_event_tx
|
||||||
|
.send(UiEvent::App(AppEvent::ThemeSaved(result)));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let UiEvent::App(AppEvent::SaveSettings {
|
||||||
|
theme,
|
||||||
|
color,
|
||||||
|
unicode,
|
||||||
|
}) = &event
|
||||||
|
{
|
||||||
|
self.set_theme(theme::resolve(*theme)).await;
|
||||||
|
let mut config = theme::UiConfig::load().unwrap_or_default();
|
||||||
|
config.theme = *theme;
|
||||||
|
config.color = *color;
|
||||||
|
config.unicode = *unicode;
|
||||||
|
let result = config
|
||||||
|
.save()
|
||||||
|
.map_err(|error| format!("Could not save UI settings: {error}"));
|
||||||
|
let _ = self
|
||||||
|
.app_event_tx
|
||||||
|
.send(UiEvent::App(AppEvent::ThemeSaved(result)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if matches!(&event, UiEvent::App(AppEvent::RegenerateKeysRequested)) {
|
||||||
|
let Some(ipc) = self.ipc().await else {
|
||||||
|
let _ = self
|
||||||
|
.app_event_tx
|
||||||
|
.send(UiEvent::App(AppEvent::KeysRegenerated(Err(
|
||||||
|
"Not connected to daemon.".into(),
|
||||||
|
))));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let sender = self.app_event_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = match ipc
|
||||||
|
.send_request(iota_ipc::LocalRequest::RotateIotaIdentity)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(iota_ipc::ResponseResult::Ok(_)) => Ok(()),
|
||||||
|
Ok(iota_ipc::ResponseResult::Error(error)) => {
|
||||||
|
Err(format!("Cannot regenerate keys: {error}"))
|
||||||
|
}
|
||||||
|
Err(error) => Err(format!("Cannot regenerate keys: {error}")),
|
||||||
|
};
|
||||||
|
let _ = sender.send(UiEvent::App(AppEvent::KeysRegenerated(result)));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let UiEvent::Key(key) = &event {
|
||||||
|
let header_is_focused = self
|
||||||
|
.header_focus
|
||||||
|
.lock()
|
||||||
|
.map(|focus| focus.is_some())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if key.code == KeyCode::F(6) {
|
||||||
|
if let Ok(mut focus) = self.header_focus.lock() {
|
||||||
|
*focus = if focus.is_some() { None } else { Some(0) };
|
||||||
|
}
|
||||||
|
self.invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if header_is_focused {
|
||||||
|
let mut action = None;
|
||||||
|
if let Ok(mut focus) = self.header_focus.lock() {
|
||||||
|
let index = focus.unwrap_or(0);
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Left | KeyCode::BackTab => *focus = Some((index + 3) % 4),
|
||||||
|
KeyCode::Right | KeyCode::Tab => *focus = Some((index + 1) % 4),
|
||||||
|
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||||
|
action = Some(
|
||||||
|
[
|
||||||
|
AppAction::OpenOverview,
|
||||||
|
AppAction::OpenUsers,
|
||||||
|
AppAction::OpenSettings,
|
||||||
|
AppAction::Quit,
|
||||||
|
][index],
|
||||||
|
);
|
||||||
|
*focus = None;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => *focus = None,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(action) = action {
|
||||||
|
self.dispatch_action(action).await;
|
||||||
|
} else {
|
||||||
|
self.invalidate();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let UiEvent::Mouse(mouse) = &event {
|
||||||
|
if matches!(
|
||||||
|
mouse.kind,
|
||||||
|
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
|
||||||
|
) {
|
||||||
|
let action = self
|
||||||
|
.hits
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|hits| hits.action_at(mouse.column, mouse.row));
|
||||||
|
if action == Some(AppAction::FocusLogs) {
|
||||||
|
self.dispatch_action(AppAction::FocusLogs).await;
|
||||||
|
let key = if matches!(mouse.kind, MouseEventKind::ScrollUp) {
|
||||||
|
KeyCode::Up
|
||||||
|
} else {
|
||||||
|
KeyCode::Down
|
||||||
|
};
|
||||||
|
// Log scrolling is a local, handled interaction; route it
|
||||||
|
// directly rather than recursively constructing another
|
||||||
|
// async UI event future.
|
||||||
|
if let Some(screen) = self.screen_stack.write().await.last_mut() {
|
||||||
|
let _ = screen.handle_event(UiEvent::Key(KeyEvent::from(key)));
|
||||||
|
}
|
||||||
|
self.invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches!(
|
||||||
|
mouse.kind,
|
||||||
|
MouseEventKind::Down(crossterm::event::MouseButton::Left)
|
||||||
|
) {
|
||||||
|
if let Some(action) = self
|
||||||
|
.hits
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|hits| hits.action_at(mouse.column, mouse.row))
|
||||||
|
{
|
||||||
|
self.dispatch_action(action).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let result = {
|
let result = {
|
||||||
let mut stack = self.screen_stack.write().await;
|
let mut stack = self.screen_stack.write().await;
|
||||||
if let Some(screen) = stack.last_mut() {
|
if let Some(screen) = stack.last_mut() {
|
||||||
screen.handle_input(key_event)
|
screen.handle_event(event)
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -278,6 +476,13 @@ impl UI {
|
||||||
_ = ui.cancellation.cancelled() => return,
|
_ = ui.cancellation.cancelled() => return,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
InteractionResult::AppTask { task } => {
|
||||||
|
let sender = self.app_event_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let event = task.await;
|
||||||
|
let _ = sender.send(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
InteractionResult::CloseScreen => {
|
InteractionResult::CloseScreen => {
|
||||||
let mut stack = self.screen_stack.write().await;
|
let mut stack = self.screen_stack.write().await;
|
||||||
stack.pop();
|
stack.pop();
|
||||||
|
|
@ -292,6 +497,81 @@ impl UI {
|
||||||
self.invalidate();
|
self.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn dispatch_action(self: &Arc<Self>, action: AppAction) {
|
||||||
|
match action {
|
||||||
|
AppAction::Quit => self.request_shutdown(),
|
||||||
|
AppAction::OpenMain => {
|
||||||
|
let mut stack = self.screen_stack.write().await;
|
||||||
|
if stack.len() > 1 {
|
||||||
|
stack.truncate(1);
|
||||||
|
}
|
||||||
|
drop(stack);
|
||||||
|
self.invalidate();
|
||||||
|
}
|
||||||
|
AppAction::OpenOverview => {
|
||||||
|
let status = {
|
||||||
|
let stack = self.screen_stack.read().await;
|
||||||
|
stack
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find_map(|s| s.as_any().downcast_ref::<MainScreen>())
|
||||||
|
.map(|main| (main.connection_status(), main.daemon_status()))
|
||||||
|
};
|
||||||
|
if let Some((connection, daemon)) = status {
|
||||||
|
self.set_screen(Box::new(OverviewScreen::new(connection, daemon)))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppAction::OpenUsers => self.open_users().await,
|
||||||
|
AppAction::OpenSettings => {
|
||||||
|
let current = self.theme_name().await;
|
||||||
|
self.set_screen(Box::new(SettingsScreen::new(current)))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
AppAction::OpenMetrics => {
|
||||||
|
if let Some(screen) = MetricsScreen::new(self.clone()).await {
|
||||||
|
self.set_screen(Box::new(screen)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
action => {
|
||||||
|
let result = {
|
||||||
|
let mut stack = self.screen_stack.write().await;
|
||||||
|
stack.last_mut().map(|screen| screen.handle_action(action))
|
||||||
|
};
|
||||||
|
if matches!(result, Some(InteractionResult::CloseScreen)) {
|
||||||
|
let mut stack = self.screen_stack.write().await;
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
self.invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn open_users(self: &Arc<Self>) {
|
||||||
|
let Some(ipc) = self.ipc().await else { return };
|
||||||
|
self.set_screen(Box::new(UsersScreen::loading(ipc.clone())))
|
||||||
|
.await;
|
||||||
|
let sender = self.app_event_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await {
|
||||||
|
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => {
|
||||||
|
Ok(users
|
||||||
|
.into_iter()
|
||||||
|
.map(|u| UserEntry {
|
||||||
|
user_id: u.user_id,
|
||||||
|
username: u.username,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
Ok(iota_ipc::ResponseResult::Error(error)) => {
|
||||||
|
Err(format!("Cannot load users: {error}"))
|
||||||
|
}
|
||||||
|
Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()),
|
||||||
|
Err(error) => Err(format!("Cannot load users: {error}")),
|
||||||
|
};
|
||||||
|
let _ = sender.send(UiEvent::App(AppEvent::UsersLoaded(result)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn render(&self) -> io::Result<()> {
|
pub async fn render(&self) -> io::Result<()> {
|
||||||
let theme = self.theme.read().await.clone();
|
let theme = self.theme.read().await.clone();
|
||||||
let context = RenderContext {
|
let context = RenderContext {
|
||||||
|
|
@ -304,9 +584,59 @@ impl UI {
|
||||||
.terminal
|
.terminal
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| io::Error::other("terminal mutex poisoned"))?;
|
.map_err(|_| io::Error::other("terminal mutex poisoned"))?;
|
||||||
|
let mut hits = HitMap::default();
|
||||||
terminal.draw(|f| {
|
terminal.draw(|f| {
|
||||||
screen.render(f, f.area(), &context);
|
let rows = Layout::vertical([
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Min(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(f.area());
|
||||||
|
let header_title = self
|
||||||
|
.screen_stack
|
||||||
|
.try_read()
|
||||||
|
.ok()
|
||||||
|
.and_then(|stack| {
|
||||||
|
stack
|
||||||
|
.iter()
|
||||||
|
.find_map(|item| item.as_any().downcast_ref::<MainScreen>())
|
||||||
|
.map(|main| main.app_title())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| screen.app_title());
|
||||||
|
let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus);
|
||||||
|
render_header(
|
||||||
|
f,
|
||||||
|
rows[0],
|
||||||
|
&header_title,
|
||||||
|
context.theme,
|
||||||
|
&mut hits,
|
||||||
|
header_focus,
|
||||||
|
);
|
||||||
|
let hints = if header_focus.is_some() {
|
||||||
|
" Left/Right: choose Enter: activate Esc/F6: screen".to_owned()
|
||||||
|
} else {
|
||||||
|
screen
|
||||||
|
.key_hints()
|
||||||
|
.into_iter()
|
||||||
|
.map(|hint| format!("{}: {}", hint.keys, hint.action))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
ratatui::widgets::Paragraph::new(format!(" {hints}")).style(
|
||||||
|
context
|
||||||
|
.theme
|
||||||
|
.surfaces
|
||||||
|
.footer
|
||||||
|
.patch(context.theme.text.muted),
|
||||||
|
),
|
||||||
|
rows[2],
|
||||||
|
);
|
||||||
|
screen.render(f, rows[1], &context, &mut hits);
|
||||||
})?;
|
})?;
|
||||||
|
if let Ok(mut current) = self.hits.lock() {
|
||||||
|
*current = hits;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
iota-cli/tests/settings_snapshot.rs
Normal file
76
iota-cli/tests/settings_snapshot.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
use iota_cli::{
|
||||||
|
interaction_result::InteractionResult,
|
||||||
|
render_context::RenderContext,
|
||||||
|
screens::{
|
||||||
|
screens::{AppEvent, HitMap, Screen, UiEvent},
|
||||||
|
settings::SettingsScreen,
|
||||||
|
},
|
||||||
|
theme::{ThemeName, resolve},
|
||||||
|
};
|
||||||
|
use ratatui::{Terminal, backend::TestBackend};
|
||||||
|
|
||||||
|
fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
|
||||||
|
terminal
|
||||||
|
.backend()
|
||||||
|
.buffer()
|
||||||
|
.content()
|
||||||
|
.iter()
|
||||||
|
.map(|cell| cell.symbol())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn settings_preview_and_save_emit_typed_application_events() {
|
||||||
|
let mut screen = SettingsScreen::new(ThemeName::Ansi);
|
||||||
|
let preview = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Right)));
|
||||||
|
let InteractionResult::AppTask { task } = preview else {
|
||||||
|
panic!("theme preview should emit an application task");
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
task.await,
|
||||||
|
UiEvent::App(AppEvent::ApplyTheme {
|
||||||
|
theme: ThemeName::Surface,
|
||||||
|
persist: false
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
let save = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Enter)));
|
||||||
|
let InteractionResult::AppTask { task } = save else {
|
||||||
|
panic!("theme save should emit an application task");
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
task.await,
|
||||||
|
UiEvent::App(AppEvent::SaveSettings {
|
||||||
|
theme: ThemeName::Surface,
|
||||||
|
color: _,
|
||||||
|
unicode: _
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_is_readable_in_every_theme_and_layout() {
|
||||||
|
for theme_name in ThemeName::ALL {
|
||||||
|
for (width, height) in [(42, 12), (72, 20), (100, 28)] {
|
||||||
|
let theme = resolve(theme_name);
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||||
|
let screen = SettingsScreen::new(theme_name);
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
screen.render(
|
||||||
|
frame,
|
||||||
|
frame.area(),
|
||||||
|
&RenderContext { theme: &theme },
|
||||||
|
&mut HitMap::default(),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let rendered = buffer_text(&terminal);
|
||||||
|
assert!(rendered.contains("Settings"));
|
||||||
|
assert!(rendered.contains("Theme:"));
|
||||||
|
assert!(rendered.contains("[OK] Healthy"));
|
||||||
|
assert!(rendered.contains("[FAIL] Failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -108,6 +108,10 @@ fn stored_message_value(
|
||||||
partner_id: i64,
|
partner_id: i64,
|
||||||
) -> DataValue {
|
) -> DataValue {
|
||||||
let mut fields = vec![
|
let mut fields = vec![
|
||||||
|
(
|
||||||
|
DataType::MessageId,
|
||||||
|
DataValue::SignedNumber(message.id as i128),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
DataType::SendTime,
|
DataType::SendTime,
|
||||||
DataValue::SignedNumber(message.message_time as i128),
|
DataValue::SignedNumber(message.message_time as i128),
|
||||||
|
|
@ -261,56 +265,160 @@ pub fn handle_delete_app(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
.with_receiver(sender_id as u64)
|
.with_receiver(sender_id as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue {
|
fn contact_value(contact: &iota_storage::users::contact::Contact) -> DataValue {
|
||||||
let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64;
|
let mut fields = vec![(
|
||||||
|
DataType::UserId,
|
||||||
let contacts = chats_util::get_users(user_id);
|
DataValue::SignedNumber(contact.user_id as i128),
|
||||||
let mut contacts_array = Vec::new();
|
)];
|
||||||
|
if let Some(name) = &contact.user_name {
|
||||||
for (i, contact) in contacts.iter().enumerate() {
|
fields.push((DataType::Username, DataValue::Str(name.clone())));
|
||||||
let mut contact_container = Vec::new();
|
|
||||||
contact_container.push((
|
|
||||||
DataType::UserId,
|
|
||||||
DataValue::SignedNumber(contact.user_id as i128),
|
|
||||||
));
|
|
||||||
contact_container.push((
|
|
||||||
DataType::LastMessageAt,
|
|
||||||
DataValue::SignedNumber(contact.last_message_at.unwrap_or(0) as i128),
|
|
||||||
));
|
|
||||||
|
|
||||||
if let Some(ref name) = contact.user_name {
|
|
||||||
contact_container.push((DataType::Username, DataValue::Str(name.clone())));
|
|
||||||
}
|
|
||||||
|
|
||||||
let amount = if i < 10 { 20 } else { 1 };
|
|
||||||
let messages = chat_files::get_messages(user_id, contact.user_id, 0, amount);
|
|
||||||
|
|
||||||
let mut msg_array = Vec::new();
|
|
||||||
for m in &messages {
|
|
||||||
msg_array.push(stored_message_value(m, user_id, contact.user_id));
|
|
||||||
|
|
||||||
if msg_array.len() == 1 {
|
|
||||||
let sender_id = if m.sent_by_self {
|
|
||||||
user_id
|
|
||||||
} else {
|
|
||||||
contact.user_id
|
|
||||||
};
|
|
||||||
let mut last_msg = Vec::new();
|
|
||||||
last_msg.push((DataType::Content, DataValue::Str(m.content.clone())));
|
|
||||||
last_msg.push((
|
|
||||||
DataType::SenderId,
|
|
||||||
DataValue::SignedNumber(sender_id as i128),
|
|
||||||
));
|
|
||||||
contact_container.push((DataType::LastMessage, typed_container(last_msg)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
contact_container.push((DataType::Messages, DataValue::Array(msg_array)));
|
|
||||||
contacts_array.push(typed_container(contact_container));
|
|
||||||
}
|
}
|
||||||
|
if let Some(last_message_at) = contact.last_message_at {
|
||||||
|
fields.push((
|
||||||
|
DataType::LastMessageAt,
|
||||||
|
DataValue::SignedNumber(last_message_at as i128),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
typed_container(fields)
|
||||||
|
}
|
||||||
|
|
||||||
CommunicationValue::new(CommunicationType::ClientConnected)
|
fn sync_error(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
|
error_response(cv, CommunicationType::ErrorInvalidData).add_typed_default(
|
||||||
|
DataType::SessionId,
|
||||||
|
cv.get_data(DataType::SessionId).clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sender is authenticated by MTP; a UserId embedded by a client is never trusted here.
|
||||||
|
pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
|
use iota_storage::util::sync::{self, CACHE_SCHEMA_VERSION};
|
||||||
|
let user_id = match i64::try_from(cv.get_sender()) {
|
||||||
|
Ok(id) if id > 0 => id,
|
||||||
|
_ => return sync_error(cv),
|
||||||
|
};
|
||||||
|
let session_id = match data_i64(cv, DataType::SessionId) {
|
||||||
|
Some(id) if id > 0 => id,
|
||||||
|
_ => return sync_error(cv),
|
||||||
|
};
|
||||||
|
let reported_version = match data_i64(cv, DataType::VersionNumber) {
|
||||||
|
Some(version) if version >= 0 => version,
|
||||||
|
_ => return sync_error(cv),
|
||||||
|
};
|
||||||
|
let cache_valid = cv.get_data(DataType::CacheValid).as_bool().unwrap_or(false);
|
||||||
|
let schema = data_i64(cv, DataType::CacheSchemaVersion).unwrap_or(0);
|
||||||
|
let head = match sync::head(user_id) {
|
||||||
|
Ok(version) => version,
|
||||||
|
Err(_) => return sync_error(cv),
|
||||||
|
};
|
||||||
|
let known_session = sync::has_session(user_id, session_id).unwrap_or(false);
|
||||||
|
let full = !cache_valid
|
||||||
|
|| reported_version == 0
|
||||||
|
|| !known_session
|
||||||
|
|| reported_version > head
|
||||||
|
|| schema != CACHE_SCHEMA_VERSION;
|
||||||
|
let (contacts, messages, deleted_messages, deleted_contacts, mode) = if full {
|
||||||
|
(
|
||||||
|
chats_util::get_users(user_id),
|
||||||
|
chat_files::get_all_messages(user_id),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
"full",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
match sync::delta(user_id, reported_version, head) {
|
||||||
|
Ok(delta) => (
|
||||||
|
chats_util::get_users_by_ids(user_id, &delta.contact_upserts),
|
||||||
|
chat_files::get_messages_by_ids(user_id, &delta.message_upserts),
|
||||||
|
delta.deleted_message_ids,
|
||||||
|
delta.deleted_contact_ids,
|
||||||
|
"delta",
|
||||||
|
),
|
||||||
|
Err(_) => (
|
||||||
|
chats_util::get_users(user_id),
|
||||||
|
chat_files::get_all_messages(user_id),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
"full",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let all_contact_ids = chats_util::get_users(user_id)
|
||||||
|
.into_iter()
|
||||||
|
.map(|contact| DataValue::SignedNumber(contact.user_id as i128))
|
||||||
|
.collect();
|
||||||
|
let message_values = messages
|
||||||
|
.iter()
|
||||||
|
.map(|message| stored_message_value(message, user_id, message.external_user))
|
||||||
|
.collect();
|
||||||
|
CommunicationValue::new(CommunicationType::ClientStateSync)
|
||||||
.with_id(cv.get_id())
|
.with_id(cv.get_id())
|
||||||
.add_typed_default(DataType::Contacts, DataValue::Array(contacts_array))
|
.with_receiver(cv.get_sender())
|
||||||
|
.add_typed_default(
|
||||||
|
DataType::SessionId,
|
||||||
|
DataValue::SignedNumber(session_id as i128),
|
||||||
|
)
|
||||||
|
.add_typed_default(
|
||||||
|
DataType::VersionNumber,
|
||||||
|
DataValue::SignedNumber(head as i128),
|
||||||
|
)
|
||||||
|
.add_typed_default(
|
||||||
|
DataType::CacheSchemaVersion,
|
||||||
|
DataValue::SignedNumber(CACHE_SCHEMA_VERSION as i128),
|
||||||
|
)
|
||||||
|
.add_typed_default(DataType::SyncMode, DataValue::Str(mode.into()))
|
||||||
|
.add_typed_default(
|
||||||
|
DataType::Contacts,
|
||||||
|
DataValue::Array(contacts.iter().map(contact_value).collect()),
|
||||||
|
)
|
||||||
|
.add_typed_default(DataType::Messages, DataValue::Array(message_values))
|
||||||
|
.add_typed_default(
|
||||||
|
DataType::DeletedMessageIds,
|
||||||
|
DataValue::Array(
|
||||||
|
deleted_messages
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| DataValue::SignedNumber(id as i128))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add_typed_default(
|
||||||
|
DataType::DeletedContactIds,
|
||||||
|
DataValue::Array(
|
||||||
|
deleted_contacts
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| DataValue::SignedNumber(id as i128))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add_typed_default(DataType::UserIds, DataValue::Array(all_contact_ids))
|
||||||
|
.add_typed_default(DataType::Calls, DataValue::Array(Vec::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_client_state_ack(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
|
use iota_storage::util::sync::{self, CACHE_SCHEMA_VERSION};
|
||||||
|
let user_id = match i64::try_from(cv.get_sender()) {
|
||||||
|
Ok(id) if id > 0 => id,
|
||||||
|
_ => return sync_error(cv),
|
||||||
|
};
|
||||||
|
let session_id = match data_i64(cv, DataType::SessionId) {
|
||||||
|
Some(id) if id > 0 => id,
|
||||||
|
_ => return sync_error(cv),
|
||||||
|
};
|
||||||
|
let version = match data_i64(cv, DataType::VersionNumber) {
|
||||||
|
Some(version) if version >= 0 => version,
|
||||||
|
_ => return sync_error(cv),
|
||||||
|
};
|
||||||
|
if sync::acknowledge(user_id, session_id, version, CACHE_SCHEMA_VERSION).is_err() {
|
||||||
|
return sync_error(cv);
|
||||||
|
}
|
||||||
|
success_response(cv)
|
||||||
|
.add_typed_default(
|
||||||
|
DataType::SessionId,
|
||||||
|
DataValue::SignedNumber(session_id as i128),
|
||||||
|
)
|
||||||
|
.add_typed_default(
|
||||||
|
DataType::VersionNumber,
|
||||||
|
DataValue::SignedNumber(version as i128),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_message_state(cv: &CommunicationValue) {
|
pub fn handle_message_state(cv: &CommunicationValue) {
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@ iota-ipc = { path = "../iota-ipc" }
|
||||||
iota-logger = { path = "../iota-logger" }
|
iota-logger = { path = "../iota-logger" }
|
||||||
iota-state = { path = "../iota-state" }
|
iota-state = { path = "../iota-state" }
|
||||||
iota-storage = { path = "../iota-storage" }
|
iota-storage = { path = "../iota-storage" }
|
||||||
|
iota-updater = { path = "../iota-updater" }
|
||||||
iota-util = { path = "../iota-util" }
|
iota-util = { path = "../iota-util" }
|
||||||
omikron-connector = { path = "../omikron-connector" }
|
omikron-connector = { path = "../omikron-connector" }
|
||||||
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
|
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.1.0"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
sysinfo = "0.38.3"
|
sysinfo = "0.38.3"
|
||||||
|
serde_yaml = "0.9"
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { version = "1.50.0", features = ["full"] }
|
||||||
tokio-util = { version = "0.7", features = ["rt"] }
|
tokio-util = { version = "0.7", features = ["rt"] }
|
||||||
uuid = { version = "*", features = ["v4"] }
|
uuid = { version = "*", features = ["v4"] }
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
|
use crate::log_buffer::LogBuffer;
|
||||||
use crate::{DaemonRuntime, DaemonServices};
|
use crate::{DaemonRuntime, DaemonServices};
|
||||||
use iota_ipc::{ExitIntent, IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult};
|
use iota_ipc::{
|
||||||
|
CommunitySummary, ComponentStatusResponse, ConfigResponse, ExitIntent, IpcErrorCode,
|
||||||
|
LocalRequest, LogEntriesResponse, OmikronStatusResponse, ResponseEnvelope, ResponsePayload,
|
||||||
|
ResponseResult, StatusResponse, TaskSummary, UpdateStatusResponse, UserDetailResponse,
|
||||||
|
UserSummary,
|
||||||
|
};
|
||||||
use iota_logger::{log, log_command};
|
use iota_logger::{log, log_command};
|
||||||
use iota_storage::users::user_manager;
|
use iota_storage::users::user_manager;
|
||||||
use iota_storage::util::config_util::modify_config;
|
use iota_storage::util::config_util::{self, modify_config};
|
||||||
use mtp::codec::{CommunicationType, CommunicationValue};
|
use mtp::codec::{CommunicationType, CommunicationValue};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::daemon_state::{ShutdownReason, StartupPhase};
|
use crate::daemon_state::{ShutdownReason, StartupPhase};
|
||||||
|
|
@ -13,11 +19,20 @@ use crate::daemon_state::{ShutdownReason, StartupPhase};
|
||||||
pub struct CommandRouter {
|
pub struct CommandRouter {
|
||||||
runtime: Arc<DaemonRuntime>,
|
runtime: Arc<DaemonRuntime>,
|
||||||
services: Arc<DaemonServices>,
|
services: Arc<DaemonServices>,
|
||||||
|
log_buffer: Arc<Mutex<LogBuffer>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommandRouter {
|
impl CommandRouter {
|
||||||
pub fn new(runtime: Arc<DaemonRuntime>, services: Arc<DaemonServices>) -> Self {
|
pub fn new(
|
||||||
Self { runtime, services }
|
runtime: Arc<DaemonRuntime>,
|
||||||
|
services: Arc<DaemonServices>,
|
||||||
|
log_buffer: Arc<Mutex<LogBuffer>>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
runtime,
|
||||||
|
services,
|
||||||
|
log_buffer,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope {
|
pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope {
|
||||||
|
|
@ -26,36 +41,15 @@ impl CommandRouter {
|
||||||
ResponseEnvelope { request_id, result }
|
ResponseEnvelope { request_id, result }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a legacy console command string into a typed request.
|
|
||||||
pub fn parse_console_command(line: &str) -> Option<LocalRequest> {
|
|
||||||
let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect();
|
|
||||||
match parts.as_slice() {
|
|
||||||
["help"] => None,
|
|
||||||
["tasks"] => Some(LocalRequest::ListTasks),
|
|
||||||
["ping", _] | ["ping"] => None,
|
|
||||||
["user", "add", username] => Some(LocalRequest::CreateUser {
|
|
||||||
username: username.to_string(),
|
|
||||||
}),
|
|
||||||
["user", "remove", username] => {
|
|
||||||
let user = user_manager::get_user_by_username(username)?;
|
|
||||||
Some(LocalRequest::RemoveUser {
|
|
||||||
user_id: user.user_id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
["user", "list"] => Some(LocalRequest::ListUsers),
|
|
||||||
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
|
|
||||||
["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity),
|
|
||||||
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
|
|
||||||
intent: ExitIntent::Restart,
|
|
||||||
}),
|
|
||||||
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
|
|
||||||
intent: ExitIntent::Stop,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(&self, request: LocalRequest) -> ResponseResult {
|
async fn execute(&self, request: LocalRequest) -> ResponseResult {
|
||||||
|
if !self.services.active
|
||||||
|
&& !matches!(
|
||||||
|
request,
|
||||||
|
LocalRequest::GetStatus | LocalRequest::GetDaemonStatus
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return ResponseResult::Error(IpcErrorCode::Unauthorized);
|
||||||
|
}
|
||||||
let needs_omikron = matches!(
|
let needs_omikron = matches!(
|
||||||
request,
|
request,
|
||||||
LocalRequest::CreateUser { .. }
|
LocalRequest::CreateUser { .. }
|
||||||
|
|
@ -83,28 +77,33 @@ impl CommandRouter {
|
||||||
.iter()
|
.iter()
|
||||||
.map(|task| task.to_string())
|
.map(|task| task.to_string())
|
||||||
.collect();
|
.collect();
|
||||||
let mut info = format!("Phase: {:?}, Tasks: {}", phase, tasks.join(", "));
|
ResponseResult::Ok(ResponsePayload::Status(StatusResponse {
|
||||||
if let Some(reason) = degraded {
|
phase: format!("{:?}", phase),
|
||||||
info.push_str(&format!(", Degraded: {}", reason));
|
tasks: tasks.clone(),
|
||||||
}
|
degraded_reason: degraded,
|
||||||
ResponseResult::Ok(info)
|
}))
|
||||||
}
|
}
|
||||||
LocalRequest::ListTasks => {
|
LocalRequest::ListTasks => {
|
||||||
let tasks: Vec<String> = self
|
let tasks: Vec<TaskSummary> = self
|
||||||
.runtime
|
.runtime
|
||||||
.state
|
.state
|
||||||
.active_tasks
|
.active_tasks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|task| task.to_string())
|
.map(|task| TaskSummary {
|
||||||
|
name: task.to_string(),
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
ResponseResult::Ok(tasks.join(", "))
|
ResponseResult::Ok(ResponsePayload::Tasks(tasks))
|
||||||
}
|
}
|
||||||
LocalRequest::ListUsers => {
|
LocalRequest::ListUsers => {
|
||||||
let users: Vec<String> = user_manager::get_users()
|
let users: Vec<UserSummary> = user_manager::get_users()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|user| format!("{} ({})", user.username, user.user_id))
|
.map(|user| UserSummary {
|
||||||
|
user_id: user.user_id,
|
||||||
|
username: user.username,
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
ResponseResult::Ok(users.join("\n"))
|
ResponseResult::Ok(ResponsePayload::Users(users))
|
||||||
}
|
}
|
||||||
LocalRequest::CreateUser { username } => {
|
LocalRequest::CreateUser { username } => {
|
||||||
match omikron_connector::user_ops::create_user(
|
match omikron_connector::user_ops::create_user(
|
||||||
|
|
@ -113,7 +112,10 @@ impl CommandRouter {
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
(Some(user), _) => ResponseResult::Ok(format!("Created user {}", user.user_id)),
|
(Some(user), _) => ResponseResult::Ok(ResponsePayload::UserCreated {
|
||||||
|
user_id: user.user_id,
|
||||||
|
username: user.username,
|
||||||
|
}),
|
||||||
_ => ResponseResult::Error(IpcErrorCode::StorageFailure),
|
_ => ResponseResult::Error(IpcErrorCode::StorageFailure),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -128,10 +130,12 @@ impl CommandRouter {
|
||||||
return ResponseResult::Error(IpcErrorCode::OmikronUnavailable);
|
return ResponseResult::Error(IpcErrorCode::OmikronUnavailable);
|
||||||
}
|
}
|
||||||
user_manager::remove_user(user.user_id);
|
user_manager::remove_user(user.user_id);
|
||||||
ResponseResult::Ok(format!("Removed user {}", user.user_id))
|
ResponseResult::Ok(ResponsePayload::UserRemoved { user_id })
|
||||||
}
|
}
|
||||||
LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await {
|
LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await {
|
||||||
Ok(()) => ResponseResult::Ok("Reconnected to Omikron server".into()),
|
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||||
|
message: "Reconnected to Omikron server".into(),
|
||||||
|
}),
|
||||||
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
|
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
|
||||||
},
|
},
|
||||||
LocalRequest::RotateIotaIdentity => {
|
LocalRequest::RotateIotaIdentity => {
|
||||||
|
|
@ -141,9 +145,9 @@ impl CommandRouter {
|
||||||
config.iota_id = None;
|
config.iota_id = None;
|
||||||
});
|
});
|
||||||
match self.services.omikron.reconnect().await {
|
match self.services.omikron.reconnect().await {
|
||||||
Ok(()) => ResponseResult::Ok(
|
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||||
"Key pair regenerated and Omikron reconnection requested".into(),
|
message: "Key pair regenerated and Omikron reconnection requested".into(),
|
||||||
),
|
}),
|
||||||
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
|
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -160,18 +164,117 @@ impl CommandRouter {
|
||||||
ExitIntent::Stop => ShutdownReason::Stop,
|
ExitIntent::Stop => ShutdownReason::Stop,
|
||||||
ExitIntent::Restart => ShutdownReason::Restart,
|
ExitIntent::Restart => ShutdownReason::Restart,
|
||||||
});
|
});
|
||||||
ResponseResult::Ok("process exit accepted".into())
|
ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||||
}
|
message: "process exit accepted".into(),
|
||||||
LocalRequest::GetDaemonStatus => {
|
})
|
||||||
ResponseResult::Ok(format!("{:?}", self.runtime.snapshot()))
|
|
||||||
}
|
}
|
||||||
|
LocalRequest::GetDaemonStatus => ResponseResult::Ok(ResponsePayload::DaemonStatus(
|
||||||
|
iota_ipc::DaemonStatusResponse {
|
||||||
|
formatted: format!("{:?}", self.runtime.snapshot()),
|
||||||
|
},
|
||||||
|
)),
|
||||||
LocalRequest::RestartDaemon => {
|
LocalRequest::RestartDaemon => {
|
||||||
self.runtime.shutdown(ShutdownReason::Restart);
|
self.runtime.shutdown(ShutdownReason::Restart);
|
||||||
ResponseResult::Ok("Daemon restart requested".into())
|
ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||||
|
message: "Daemon restart requested".into(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
LocalRequest::StopDaemon => {
|
LocalRequest::StopDaemon => {
|
||||||
self.runtime.shutdown(ShutdownReason::Stop);
|
self.runtime.shutdown(ShutdownReason::Stop);
|
||||||
ResponseResult::Ok("Daemon shutdown requested".into())
|
ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||||
|
message: "Daemon shutdown requested".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
LocalRequest::GetConfig => {
|
||||||
|
let cfg = config_util::CONFIG.load();
|
||||||
|
let yaml = serde_yaml::to_string(&**cfg).unwrap_or_default();
|
||||||
|
ResponseResult::Ok(ResponsePayload::Config(ConfigResponse { yaml }))
|
||||||
|
}
|
||||||
|
LocalRequest::SetConfig { key, value } => {
|
||||||
|
match config_util::modify_config_value(&key, &value) {
|
||||||
|
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||||
|
message: format!("Set {key} = {value}"),
|
||||||
|
}),
|
||||||
|
Err(_e) => ResponseResult::Error(IpcErrorCode::InvalidRequest),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LocalRequest::ReloadConfig => {
|
||||||
|
config_util::load_config();
|
||||||
|
ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||||
|
message: "Configuration reloaded".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
LocalRequest::GetOmikronStatus => {
|
||||||
|
let connected = self.services.omikron.is_connected().await;
|
||||||
|
let iota_id = config_util::CONFIG.load().iota_id;
|
||||||
|
ResponseResult::Ok(ResponsePayload::OmikronStatus(OmikronStatusResponse {
|
||||||
|
connected,
|
||||||
|
iota_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
LocalRequest::ListComponents => {
|
||||||
|
let snapshot = self.runtime.snapshot();
|
||||||
|
let components: Vec<ComponentStatusResponse> = snapshot
|
||||||
|
.components
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, health)| ComponentStatusResponse {
|
||||||
|
id,
|
||||||
|
status: health.status,
|
||||||
|
message: health.message,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
ResponseResult::Ok(ResponsePayload::Components(components))
|
||||||
|
}
|
||||||
|
LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) {
|
||||||
|
Some(user) => ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse {
|
||||||
|
user_id: user.user_id,
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
|
created_at: user.created_at,
|
||||||
|
trusted_apps: user.trusted_apps.keys().cloned().collect(),
|
||||||
|
})),
|
||||||
|
None => ResponseResult::Error(IpcErrorCode::NotFound),
|
||||||
|
},
|
||||||
|
LocalRequest::ImportUser { username } => {
|
||||||
|
match user_manager::load_from_tu(&username).await {
|
||||||
|
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
|
||||||
|
message: format!("Imported user {username}"),
|
||||||
|
}),
|
||||||
|
Err(()) => ResponseResult::Error(IpcErrorCode::StorageFailure),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LocalRequest::GetLogs { limit } => {
|
||||||
|
let entries = if let Ok(buf) = self.log_buffer.lock() {
|
||||||
|
buf.recent(limit)
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
ResponseResult::Ok(ResponsePayload::LogEntries(LogEntriesResponse { entries }))
|
||||||
|
}
|
||||||
|
LocalRequest::CheckUpdate => match iota_updater::check_update().await {
|
||||||
|
Ok(available) => {
|
||||||
|
ResponseResult::Ok(ResponsePayload::UpdateStatus(UpdateStatusResponse {
|
||||||
|
available,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Err(_e) => ResponseResult::Error(IpcErrorCode::InternalFailure),
|
||||||
|
},
|
||||||
|
LocalRequest::ListCommunities => {
|
||||||
|
let iota_id = config_util::CONFIG
|
||||||
|
.load()
|
||||||
|
.iota_id
|
||||||
|
.map(|id| id as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let stored =
|
||||||
|
iota_storage::util::communities_util::CommunitiesUtil::get_communities(iota_id);
|
||||||
|
let summaries: Vec<CommunitySummary> = stored
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| CommunitySummary {
|
||||||
|
name: c.address,
|
||||||
|
title: c.title,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
ResponseResult::Ok(ResponsePayload::Communities(summaries))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use crate::deployment::from_environment;
|
use crate::deployment::from_environment;
|
||||||
|
use crate::log_buffer::LogBuffer;
|
||||||
use crate::{CommandRouter, DaemonRuntime, DaemonServices};
|
use crate::{CommandRouter, DaemonRuntime, DaemonServices};
|
||||||
use iota_ipc::{
|
use iota_ipc::{
|
||||||
ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg,
|
ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg,
|
||||||
|
|
@ -8,7 +9,7 @@ use iota_logger::log;
|
||||||
use std::io::Result;
|
use std::io::Result;
|
||||||
use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
|
use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
use std::{env, fs::File, os::fd::FromRawFd, os::unix::net::UnixListener as StdUnixListener};
|
use std::{env, fs::File, os::fd::FromRawFd, os::unix::net::UnixListener as StdUnixListener};
|
||||||
use tokio::net::{UnixListener, UnixStream};
|
use tokio::net::{UnixListener, UnixStream};
|
||||||
use tokio::sync::{broadcast, mpsc, watch};
|
use tokio::sync::{broadcast, mpsc, watch};
|
||||||
|
|
@ -22,11 +23,25 @@ const CLIENT_CHANNEL_SIZE: usize = 256;
|
||||||
const MAX_HANDSHAKE_RETRIES: u32 = 1;
|
const MAX_HANDSHAKE_RETRIES: u32 = 1;
|
||||||
const CLIENT_IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
const CLIENT_IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// Minimum metric subscription interval to prevent excessive update rates.
|
||||||
|
const MIN_METRIC_INTERVAL_MS: u64 = 100;
|
||||||
|
/// Maximum metric subscription interval.
|
||||||
|
const MAX_METRIC_INTERVAL_MS: u64 = 60_000;
|
||||||
|
/// Default metric interval if the client does not specify one.
|
||||||
|
const DEFAULT_METRIC_INTERVAL_MS: u64 = 500;
|
||||||
|
|
||||||
|
/// Per-client subscription state.
|
||||||
|
struct ClientSubscription {
|
||||||
|
log_classes: Vec<String>,
|
||||||
|
metric_interval_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct IpcServer {
|
pub struct IpcServer {
|
||||||
listener: UnixListener,
|
listener: UnixListener,
|
||||||
runtime: Arc<DaemonRuntime>,
|
runtime: Arc<DaemonRuntime>,
|
||||||
services: Arc<DaemonServices>,
|
services: Arc<DaemonServices>,
|
||||||
log_tx: broadcast::Sender<DaemonMessage>,
|
log_tx: broadcast::Sender<DaemonMessage>,
|
||||||
|
log_buffer: Arc<Mutex<LogBuffer>>,
|
||||||
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
|
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
|
||||||
instance_id: String,
|
instance_id: String,
|
||||||
_instance_lock: File,
|
_instance_lock: File,
|
||||||
|
|
@ -38,6 +53,7 @@ impl IpcServer {
|
||||||
runtime: Arc<DaemonRuntime>,
|
runtime: Arc<DaemonRuntime>,
|
||||||
services: Arc<DaemonServices>,
|
services: Arc<DaemonServices>,
|
||||||
log_tx: broadcast::Sender<DaemonMessage>,
|
log_tx: broadcast::Sender<DaemonMessage>,
|
||||||
|
log_buffer: Arc<Mutex<LogBuffer>>,
|
||||||
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
|
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let path = path.into();
|
let path = path.into();
|
||||||
|
|
@ -90,6 +106,7 @@ impl IpcServer {
|
||||||
runtime,
|
runtime,
|
||||||
services,
|
services,
|
||||||
log_tx,
|
log_tx,
|
||||||
|
log_buffer,
|
||||||
state_rx,
|
state_rx,
|
||||||
instance_id: Uuid::new_v4().to_string(),
|
instance_id: Uuid::new_v4().to_string(),
|
||||||
_instance_lock: lock,
|
_instance_lock: lock,
|
||||||
|
|
@ -101,6 +118,7 @@ impl IpcServer {
|
||||||
runtime,
|
runtime,
|
||||||
services,
|
services,
|
||||||
log_tx,
|
log_tx,
|
||||||
|
log_buffer,
|
||||||
state_rx,
|
state_rx,
|
||||||
instance_id: Uuid::new_v4().to_string(),
|
instance_id: Uuid::new_v4().to_string(),
|
||||||
_instance_lock: File::options().read(true).open("/dev/null")?,
|
_instance_lock: File::options().read(true).open("/dev/null")?,
|
||||||
|
|
@ -114,11 +132,20 @@ impl IpcServer {
|
||||||
let runtime = self.runtime.clone();
|
let runtime = self.runtime.clone();
|
||||||
let services = self.services.clone();
|
let services = self.services.clone();
|
||||||
let log_tx = self.log_tx.clone();
|
let log_tx = self.log_tx.clone();
|
||||||
|
let log_buffer = self.log_buffer.clone();
|
||||||
let state_rx = self.state_rx.clone();
|
let state_rx = self.state_rx.clone();
|
||||||
let instance_id = self.instance_id.clone();
|
let instance_id = self.instance_id.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(error) =
|
if let Err(error) = handle_client(
|
||||||
handle_client(stream, runtime, services, log_tx, state_rx, instance_id).await
|
stream,
|
||||||
|
runtime,
|
||||||
|
services,
|
||||||
|
log_tx,
|
||||||
|
log_buffer,
|
||||||
|
state_rx,
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
eprintln!("IPC client error: {error}");
|
eprintln!("IPC client error: {error}");
|
||||||
}
|
}
|
||||||
|
|
@ -252,6 +279,7 @@ async fn handle_client(
|
||||||
runtime: Arc<DaemonRuntime>,
|
runtime: Arc<DaemonRuntime>,
|
||||||
services: Arc<DaemonServices>,
|
services: Arc<DaemonServices>,
|
||||||
log_tx: broadcast::Sender<DaemonMessage>,
|
log_tx: broadcast::Sender<DaemonMessage>,
|
||||||
|
log_buffer: Arc<Mutex<LogBuffer>>,
|
||||||
mut state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
|
mut state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
|
||||||
instance_id: String,
|
instance_id: String,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
|
@ -337,12 +365,18 @@ async fn handle_client(
|
||||||
|
|
||||||
// --- Writer task: merge directed responses + shared log events ---
|
// --- Writer task: merge directed responses + shared log events ---
|
||||||
let mut log_rx = log_tx.subscribe();
|
let mut log_rx = log_tx.subscribe();
|
||||||
|
let (sub_tx, mut sub_rx) = tokio::sync::watch::channel(ClientSubscription {
|
||||||
|
log_classes: Vec::new(),
|
||||||
|
metric_interval_ms: DEFAULT_METRIC_INTERVAL_MS,
|
||||||
|
});
|
||||||
let writer_task = {
|
let writer_task = {
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
let session_cancellation = session_cancellation.clone();
|
let session_cancellation = session_cancellation.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut directed_rx = directed_rx;
|
let mut directed_rx = directed_rx;
|
||||||
|
let mut last_metric_sent = tokio::time::Instant::now();
|
||||||
loop {
|
loop {
|
||||||
|
let metric_interval = sub_rx.borrow().metric_interval_ms;
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
// Directed messages (responses to this client's requests)
|
// Directed messages (responses to this client's requests)
|
||||||
msg = directed_rx.recv() => {
|
msg = directed_rx.recv() => {
|
||||||
|
|
@ -360,9 +394,35 @@ async fn handle_client(
|
||||||
// Shared log events
|
// Shared log events
|
||||||
result = log_rx.recv() => {
|
result = log_rx.recv() => {
|
||||||
match result {
|
match result {
|
||||||
|
Ok(DaemonMessage::LogEntry(entry)) => {
|
||||||
|
// Filter by subscribed log classes
|
||||||
|
let log_classes = sub_rx.borrow().log_classes.clone();
|
||||||
|
if log_classes.is_empty()
|
||||||
|
|| log_classes.iter().any(|c| entry.sender == *c)
|
||||||
|
{
|
||||||
|
if let Err(error) = write_client_message(&mut writer, &DaemonMessage::LogEntry(entry)).await {
|
||||||
|
eprintln!("IPC client writer stopped while sending log message: {error}");
|
||||||
|
session_cancellation.cancel();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(DaemonMessage::MetricSample(sample)) => {
|
||||||
|
// Rate-limit metric samples based on subscription interval
|
||||||
|
let now = tokio::time::Instant::now();
|
||||||
|
if now.duration_since(last_metric_sent) >= std::time::Duration::from_millis(metric_interval) {
|
||||||
|
last_metric_sent = now;
|
||||||
|
if let Err(error) = write_client_message(&mut writer, &DaemonMessage::MetricSample(sample)).await {
|
||||||
|
eprintln!("IPC client writer stopped while sending metric sample: {error}");
|
||||||
|
session_cancellation.cancel();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(message) => {
|
Ok(message) => {
|
||||||
|
// Forward other broadcast messages as-is
|
||||||
if let Err(error) = write_client_message(&mut writer, &message).await {
|
if let Err(error) = write_client_message(&mut writer, &message).await {
|
||||||
eprintln!("IPC client writer stopped while sending log message: {error}");
|
eprintln!("IPC client writer stopped while sending broadcast message: {error}");
|
||||||
session_cancellation.cancel();
|
session_cancellation.cancel();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -389,13 +449,14 @@ async fn handle_client(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ = sub_rx.changed() => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Reader loop ---
|
// --- Reader loop ---
|
||||||
let router = CommandRouter::new(runtime.clone(), services);
|
let router = CommandRouter::new(runtime.clone(), services, log_buffer);
|
||||||
loop {
|
loop {
|
||||||
let message = tokio::select! {
|
let message = tokio::select! {
|
||||||
_ = session_cancellation.cancelled() => break,
|
_ = session_cancellation.cancelled() => break,
|
||||||
|
|
@ -442,7 +503,17 @@ async fn handle_client(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(ClientMessage::Subscribe { .. }) => {
|
Ok(ClientMessage::Subscribe {
|
||||||
|
log_classes,
|
||||||
|
metric_interval_ms,
|
||||||
|
}) => {
|
||||||
|
let interval = metric_interval_ms
|
||||||
|
.unwrap_or(DEFAULT_METRIC_INTERVAL_MS)
|
||||||
|
.clamp(MIN_METRIC_INTERVAL_MS, MAX_METRIC_INTERVAL_MS);
|
||||||
|
let _ = sub_tx.send(ClientSubscription {
|
||||||
|
log_classes,
|
||||||
|
metric_interval_ms: interval,
|
||||||
|
});
|
||||||
let snapshot = DaemonMessage::StateUpdate(runtime.snapshot());
|
let snapshot = DaemonMessage::StateUpdate(runtime.snapshot());
|
||||||
let _ = directed_tx.send(snapshot).await;
|
let _ = directed_tx.send(snapshot).await;
|
||||||
let _ = directed_tx.send(DaemonMessage::Subscribed).await;
|
let _ = directed_tx.send(DaemonMessage::Subscribed).await;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ pub mod daemon_state;
|
||||||
pub mod deployment;
|
pub mod deployment;
|
||||||
pub mod ipc_server;
|
pub mod ipc_server;
|
||||||
pub mod log_broadcaster;
|
pub mod log_broadcaster;
|
||||||
|
pub mod log_buffer;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod task_registry;
|
pub mod task_registry;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,27 @@
|
||||||
|
use crate::log_buffer::LogBuffer;
|
||||||
use iota_ipc::{DaemonMessage, LogEntry};
|
use iota_ipc::{DaemonMessage, LogEntry};
|
||||||
use iota_logger::subscribe;
|
use iota_logger::subscribe;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
/* The daemon adapts logger output to the wire protocol so the logger stays
|
/* The daemon adapts logger output to the wire protocol so the logger stays
|
||||||
* independent from both the socket implementation and TUI state. */
|
* independent from both the socket implementation and TUI state. */
|
||||||
pub fn spawn(message_tx: broadcast::Sender<DaemonMessage>) {
|
pub fn spawn(message_tx: broadcast::Sender<DaemonMessage>, buffer: Arc<Mutex<LogBuffer>>) {
|
||||||
let Some(mut logs) = subscribe() else {
|
let Some(mut logs) = subscribe() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(entry) = logs.recv().await {
|
while let Ok(entry) = logs.recv().await {
|
||||||
let _ = message_tx.send(DaemonMessage::LogEntry(LogEntry {
|
let entry = LogEntry {
|
||||||
timestamp_ms: entry.timestamp_ms,
|
timestamp_ms: entry.timestamp_ms,
|
||||||
sender: entry.sender,
|
sender: entry.sender,
|
||||||
message: entry.message,
|
message: entry.message,
|
||||||
is_error: entry.is_error,
|
is_error: entry.is_error,
|
||||||
}));
|
};
|
||||||
|
if let Ok(mut buf) = buffer.lock() {
|
||||||
|
buf.push(entry.clone());
|
||||||
|
}
|
||||||
|
let _ = message_tx.send(DaemonMessage::LogEntry(entry));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
36
iota-daemon-lib/src/log_buffer.rs
Normal file
36
iota-daemon-lib/src/log_buffer.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
use iota_ipc::LogEntry;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
pub struct LogBuffer {
|
||||||
|
entries: VecDeque<LogEntry>,
|
||||||
|
capacity: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogBuffer {
|
||||||
|
pub fn new(capacity: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: VecDeque::with_capacity(capacity),
|
||||||
|
capacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, entry: LogEntry) {
|
||||||
|
if self.entries.len() == self.capacity {
|
||||||
|
self.entries.pop_front();
|
||||||
|
}
|
||||||
|
self.entries.push_back(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recent(&self, limit: usize) -> Vec<LogEntry> {
|
||||||
|
let _len = self.entries.len();
|
||||||
|
self.entries
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(limit)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
use omikron_connector::{OmikronClient, OmikronConnection};
|
use async_trait::async_trait;
|
||||||
|
use mtp::codec::CommunicationValue;
|
||||||
|
use omikron_connector::{OmikronClient, OmikronConnection, OmikronError};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct UserService;
|
pub struct UserService;
|
||||||
|
|
@ -10,6 +13,7 @@ pub struct DaemonServices {
|
||||||
pub omikron: Arc<dyn OmikronClient>,
|
pub omikron: Arc<dyn OmikronClient>,
|
||||||
pub users: Arc<UserService>,
|
pub users: Arc<UserService>,
|
||||||
pub config: Arc<ConfigService>,
|
pub config: Arc<ConfigService>,
|
||||||
|
pub active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DaemonServices {
|
impl DaemonServices {
|
||||||
|
|
@ -18,6 +22,46 @@ impl DaemonServices {
|
||||||
omikron,
|
omikron,
|
||||||
users: Arc::new(UserService),
|
users: Arc::new(UserService),
|
||||||
config: Arc::new(ConfigService),
|
config: Arc::new(ConfigService),
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Services used while the daemon is awaiting terms acceptance. They can
|
||||||
|
/// never initiate a connection; the command router exposes status only.
|
||||||
|
pub fn inactive() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
omikron: Arc::new(InactiveOmikron),
|
||||||
|
users: Arc::new(UserService),
|
||||||
|
config: Arc::new(ConfigService),
|
||||||
|
active: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct InactiveOmikron;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl OmikronClient for InactiveOmikron {
|
||||||
|
async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> {
|
||||||
|
Err(OmikronError::Disconnected(
|
||||||
|
"terms have not been accepted".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
async fn await_response(
|
||||||
|
&self,
|
||||||
|
_: &CommunicationValue,
|
||||||
|
_: Duration,
|
||||||
|
) -> Result<CommunicationValue, OmikronError> {
|
||||||
|
Err(OmikronError::Disconnected(
|
||||||
|
"terms have not been accepted".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
async fn reconnect(&self) -> Result<(), OmikronError> {
|
||||||
|
Err(OmikronError::Disconnected(
|
||||||
|
"terms have not been accepted".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
async fn is_connected(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use iota_daemon_lib::log_buffer::LogBuffer;
|
||||||
use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices};
|
use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices};
|
||||||
use iota_ipc::{LocalRequest, ResponseResult};
|
use iota_ipc::{LocalRequest, ResponseResult};
|
||||||
use mtp::codec::CommunicationValue;
|
use mtp::codec::CommunicationValue;
|
||||||
use omikron_connector::{OmikronClient, OmikronError};
|
use omikron_connector::{OmikronClient, OmikronError};
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
Arc,
|
Arc, Mutex,
|
||||||
atomic::{AtomicUsize, Ordering},
|
atomic::{AtomicUsize, Ordering},
|
||||||
};
|
};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
@ -43,7 +44,11 @@ async fn reconnect_uses_the_injected_client() {
|
||||||
users: Default::default(),
|
users: Default::default(),
|
||||||
config: Default::default(),
|
config: Default::default(),
|
||||||
});
|
});
|
||||||
let router = CommandRouter::new(Arc::new(DaemonRuntime::new()), services);
|
let router = CommandRouter::new(
|
||||||
|
Arc::new(DaemonRuntime::new()),
|
||||||
|
services,
|
||||||
|
Arc::new(Mutex::new(LogBuffer::new(100))),
|
||||||
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
router.route(1, LocalRequest::ReconnectOmikron).await.result,
|
router.route(1, LocalRequest::ReconnectOmikron).await.result,
|
||||||
ResponseResult::Ok(_)
|
ResponseResult::Ok(_)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ iota-state = { path = "../iota-state" }
|
||||||
iota-paths = { path = "../iota-paths" }
|
iota-paths = { path = "../iota-paths" }
|
||||||
iota-storage = { path = "../iota-storage" }
|
iota-storage = { path = "../iota-storage" }
|
||||||
iota-util = { path = "../iota-util" }
|
iota-util = { path = "../iota-util" }
|
||||||
|
iota-terms = { path = "../iota-terms" }
|
||||||
omikron-connector = { path = "../omikron-connector" }
|
omikron-connector = { path = "../omikron-connector" }
|
||||||
web-server = { path = "../web-server" }
|
web-server = { path = "../web-server" }
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { version = "1.50.0", features = ["full"] }
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
use iota_daemon_lib::{
|
use iota_daemon_lib::{
|
||||||
DaemonRuntime, DaemonServices, IpcServer, ShutdownReason, StartupPhase, log_broadcaster,
|
DaemonRuntime, DaemonServices, IpcServer, ShutdownReason, StartupPhase, log_broadcaster,
|
||||||
|
log_buffer::LogBuffer,
|
||||||
};
|
};
|
||||||
use iota_logger::{self as logger, log};
|
use iota_logger::{self as logger, log};
|
||||||
use iota_storage::users::user_manager;
|
use iota_storage::users::user_manager;
|
||||||
use iota_storage::util::config_util::CONFIG;
|
use iota_storage::util::config_util::CONFIG;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::{broadcast, watch};
|
use tokio::sync::{broadcast, watch};
|
||||||
#[tokio::main(flavor = "multi_thread")]
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
|
|
@ -21,6 +22,61 @@ async fn main() -> ExitCode {
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Bind a deliberately dormant IPC daemon before terms are accepted. This
|
||||||
|
// makes socket activation and `iota terms accept --system` usable, while
|
||||||
|
// the router exposes status only and the inactive service cannot connect.
|
||||||
|
if !iota_terms::consent::load(&paths.state_dir).has_all_required() {
|
||||||
|
let socket = match &paths.ipc_endpoint {
|
||||||
|
iota_paths::IpcEndpoint::UnixSocket(path) => path.clone(),
|
||||||
|
iota_paths::IpcEndpoint::WindowsPipe(_) => {
|
||||||
|
eprintln!("Windows named-pipe daemon transport is not implemented yet");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let runtime = Arc::new(DaemonRuntime::new());
|
||||||
|
let (log_tx, _) = broadcast::channel(64);
|
||||||
|
let log_buffer = Arc::new(Mutex::new(LogBuffer::new(64)));
|
||||||
|
let (_, state_rx) = watch::channel(runtime.snapshot());
|
||||||
|
let server = match IpcServer::bind(
|
||||||
|
socket,
|
||||||
|
runtime.clone(),
|
||||||
|
DaemonServices::inactive(),
|
||||||
|
log_tx,
|
||||||
|
log_buffer,
|
||||||
|
state_rx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(server) => server,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Cannot bind dormant daemon IPC socket: {error}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = server.serve().await;
|
||||||
|
});
|
||||||
|
eprintln!(
|
||||||
|
"Iota daemon is awaiting terms acceptance. Run `iota terms accept{}` in an interactive terminal.",
|
||||||
|
if paths.scope == iota_paths::Scope::System {
|
||||||
|
" --system"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(1)) => {
|
||||||
|
if iota_terms::consent::load(&paths.state_dir).has_all_required() {
|
||||||
|
// systemd restarts this daemon; a locally-launched daemon can
|
||||||
|
// simply be started again after accepting the documents.
|
||||||
|
return ExitCode::from(75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = tokio::signal::ctrl_c() => return ExitCode::SUCCESS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Err(error) = paths.migrate_legacy_layout() {
|
if let Err(error) = paths.migrate_legacy_layout() {
|
||||||
eprintln!("Cannot migrate legacy Iota layout: {error}");
|
eprintln!("Cannot migrate legacy Iota layout: {error}");
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
|
|
@ -41,7 +97,8 @@ async fn main() -> ExitCode {
|
||||||
let runtime = Arc::new(DaemonRuntime::new());
|
let runtime = Arc::new(DaemonRuntime::new());
|
||||||
// --- IPC infrastructure ---
|
// --- IPC infrastructure ---
|
||||||
let (log_tx, _) = broadcast::channel(512);
|
let (log_tx, _) = broadcast::channel(512);
|
||||||
log_broadcaster::spawn(log_tx.clone());
|
let log_buffer = Arc::new(Mutex::new(LogBuffer::new(1024)));
|
||||||
|
log_broadcaster::spawn(log_tx.clone(), log_buffer.clone());
|
||||||
let (state_tx, state_rx) = watch::channel(runtime.snapshot());
|
let (state_tx, state_rx) = watch::channel(runtime.snapshot());
|
||||||
|
|
||||||
runtime.set_startup_phase(StartupPhase::LoadingUsers);
|
runtime.set_startup_phase(StartupPhase::LoadingUsers);
|
||||||
|
|
@ -101,6 +158,7 @@ async fn main() -> ExitCode {
|
||||||
runtime.clone(),
|
runtime.clone(),
|
||||||
services,
|
services,
|
||||||
log_tx.clone(),
|
log_tx.clone(),
|
||||||
|
log_buffer.clone(),
|
||||||
state_rx,
|
state_rx,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -128,6 +186,21 @@ async fn main() -> ExitCode {
|
||||||
.await;
|
.await;
|
||||||
log!("iota-daemon IPC server ready");
|
log!("iota-daemon IPC server ready");
|
||||||
|
|
||||||
|
log!(
|
||||||
|
"iota-daemon paths (scope={:?}): config={} state={} storage={} identity={} cache={} log={} asset={} ipc={}",
|
||||||
|
paths.scope,
|
||||||
|
paths.config_file.display(),
|
||||||
|
paths.state_dir.display(),
|
||||||
|
paths.storage_dir.display(),
|
||||||
|
paths.identity_dir.display(),
|
||||||
|
paths.cache_dir.display(),
|
||||||
|
paths.log_dir.display(),
|
||||||
|
paths.asset_dir.display(),
|
||||||
|
match &paths.ipc_endpoint {
|
||||||
|
iota_paths::IpcEndpoint::UnixSocket(p) => p.display().to_string(),
|
||||||
|
iota_paths::IpcEndpoint::WindowsPipe(n) => n.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
runtime.set_startup_phase(StartupPhase::StartingServices);
|
runtime.set_startup_phase(StartupPhase::StartingServices);
|
||||||
|
|
||||||
// --- System monitor ---
|
// --- System monitor ---
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
|
pub mod text_commands;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
|
||||||
pub use protocol::{
|
pub use protocol::{
|
||||||
ClientMessage, ComponentHealth, ComponentId, ConnectionStatus, DaemonMessage, DeploymentMode,
|
ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse,
|
||||||
|
ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode,
|
||||||
ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest,
|
ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest,
|
||||||
LogEntry, MetricSample, RequestEnvelope, ResponseEnvelope, ResponseResult, StartupPhase,
|
LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, RequestEnvelope,
|
||||||
StateSnapshot, SupervisorKind,
|
ResponseEnvelope, ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse,
|
||||||
|
SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary,
|
||||||
};
|
};
|
||||||
pub use transport::{read_msg, write_msg};
|
pub use transport::{read_msg, write_msg};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,25 @@ pub enum LocalRequest {
|
||||||
RestartDaemon,
|
RestartDaemon,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
StopDaemon,
|
StopDaemon,
|
||||||
|
GetConfig,
|
||||||
|
SetConfig {
|
||||||
|
key: String,
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
|
ReloadConfig,
|
||||||
|
GetOmikronStatus,
|
||||||
|
ListComponents,
|
||||||
|
GetUser {
|
||||||
|
user_id: i64,
|
||||||
|
},
|
||||||
|
ImportUser {
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
GetLogs {
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
CheckUpdate,
|
||||||
|
ListCommunities,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
|
||||||
|
|
@ -107,10 +126,95 @@ pub struct ResponseEnvelope {
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
||||||
pub enum ResponseResult {
|
pub enum ResponseResult {
|
||||||
Ok(String),
|
Ok(ResponsePayload),
|
||||||
Error(IpcErrorCode),
|
Error(IpcErrorCode),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
||||||
|
pub enum ResponsePayload {
|
||||||
|
Status(StatusResponse),
|
||||||
|
Tasks(Vec<TaskSummary>),
|
||||||
|
Users(Vec<UserSummary>),
|
||||||
|
UserCreated { user_id: i64, username: String },
|
||||||
|
UserRemoved { user_id: i64 },
|
||||||
|
Acknowledged { message: String },
|
||||||
|
DaemonStatus(DaemonStatusResponse),
|
||||||
|
Config(ConfigResponse),
|
||||||
|
OmikronStatus(OmikronStatusResponse),
|
||||||
|
Components(Vec<ComponentStatusResponse>),
|
||||||
|
UserDetail(UserDetailResponse),
|
||||||
|
LogEntries(LogEntriesResponse),
|
||||||
|
UpdateStatus(UpdateStatusResponse),
|
||||||
|
Communities(Vec<CommunitySummary>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ConfigResponse {
|
||||||
|
pub yaml: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct OmikronStatusResponse {
|
||||||
|
pub connected: bool,
|
||||||
|
pub iota_id: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ComponentStatusResponse {
|
||||||
|
pub id: ComponentId,
|
||||||
|
pub status: HealthStatus,
|
||||||
|
pub message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct UserDetailResponse {
|
||||||
|
pub user_id: i64,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub trusted_apps: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct LogEntriesResponse {
|
||||||
|
pub entries: Vec<LogEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct UpdateStatusResponse {
|
||||||
|
pub available: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct CommunitySummary {
|
||||||
|
pub name: String,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct StatusResponse {
|
||||||
|
pub phase: String,
|
||||||
|
pub tasks: Vec<String>,
|
||||||
|
pub degraded_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct TaskSummary {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct UserSummary {
|
||||||
|
pub user_id: i64,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct DaemonStatusResponse {
|
||||||
|
pub formatted: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum IpcErrorCode {
|
pub enum IpcErrorCode {
|
||||||
|
|
@ -128,6 +232,43 @@ pub enum IpcErrorCode {
|
||||||
InternalFailure,
|
InternalFailure,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for IpcErrorCode {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::InvalidRequest => "the daemon rejected the request",
|
||||||
|
Self::NotFound => "the requested resource was not found",
|
||||||
|
Self::Conflict => "the request conflicts with current daemon state",
|
||||||
|
Self::StorageFailure => "the daemon could not access local storage",
|
||||||
|
Self::OmikronUnavailable => "Omikron is unavailable",
|
||||||
|
Self::UnsupportedVersion => "the client and daemon protocol versions are incompatible",
|
||||||
|
Self::NotReady => "the daemon is not ready yet",
|
||||||
|
Self::Disconnected => "the daemon connection was lost",
|
||||||
|
Self::Timeout => "the daemon did not respond in time",
|
||||||
|
Self::Cancelled => "the daemon cancelled the request",
|
||||||
|
Self::Unauthorized => "the daemon denied this operation",
|
||||||
|
Self::InternalFailure => "the daemon encountered an internal failure",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod error_tests {
|
||||||
|
use super::IpcErrorCode;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_codes_have_operator_facing_messages() {
|
||||||
|
assert_eq!(
|
||||||
|
IpcErrorCode::NotReady.to_string(),
|
||||||
|
"the daemon is not ready yet"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!IpcErrorCode::InternalFailure
|
||||||
|
.to_string()
|
||||||
|
.contains("InternalFailure")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
||||||
pub enum LifecycleEvent {
|
pub enum LifecycleEvent {
|
||||||
|
|
|
||||||
324
iota-ipc/src/text_commands.rs
Normal file
324
iota-ipc/src/text_commands.rs
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
use crate::LocalRequest;
|
||||||
|
|
||||||
|
pub const COMMANDS: &[&str] = &[
|
||||||
|
"status",
|
||||||
|
"tasks",
|
||||||
|
"users list",
|
||||||
|
"users show ",
|
||||||
|
"users add ",
|
||||||
|
"users remove ",
|
||||||
|
"users import ",
|
||||||
|
"omikron status",
|
||||||
|
"reconnect",
|
||||||
|
"identity rotate",
|
||||||
|
"daemon status",
|
||||||
|
"config get",
|
||||||
|
"config set ",
|
||||||
|
"config reload",
|
||||||
|
"components",
|
||||||
|
"logs",
|
||||||
|
"update check",
|
||||||
|
"community list",
|
||||||
|
"restart",
|
||||||
|
"stop",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn completions(prefix: &str) -> Vec<&'static str> {
|
||||||
|
let normalized = prefix.trim_start_matches('/');
|
||||||
|
COMMANDS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|command| command.starts_with(normalized))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validation_error(line: &str) -> Option<String> {
|
||||||
|
let normalized = line.trim_start_matches('/').trim();
|
||||||
|
if normalized == "help" || parse(normalized).is_some() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(format!(
|
||||||
|
"Unknown command `{normalized}`. Use /help or Tab completion."
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a text command string into a typed IPC request.
|
||||||
|
///
|
||||||
|
/// Both the CLI console and the TUI command palette use this single parser.
|
||||||
|
/// Commands are case-insensitive and support an optional leading `/`.
|
||||||
|
pub fn parse(line: &str) -> Option<LocalRequest> {
|
||||||
|
let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect();
|
||||||
|
match parts.as_slice() {
|
||||||
|
["help"] => None,
|
||||||
|
["status"] => Some(LocalRequest::GetStatus),
|
||||||
|
["tasks"] => Some(LocalRequest::ListTasks),
|
||||||
|
["users"] | ["user", "list"] | ["users", "list"] => Some(LocalRequest::ListUsers),
|
||||||
|
["user" | "users", "show", id_str] => {
|
||||||
|
let user_id = id_str.parse::<i64>().ok()?;
|
||||||
|
Some(LocalRequest::GetUser { user_id })
|
||||||
|
}
|
||||||
|
["user" | "users", "add", username] => Some(LocalRequest::CreateUser {
|
||||||
|
username: username.to_string(),
|
||||||
|
}),
|
||||||
|
["user" | "users", "remove", id_str] => {
|
||||||
|
let user_id = id_str.parse::<i64>().ok()?;
|
||||||
|
Some(LocalRequest::RemoveUser { user_id })
|
||||||
|
}
|
||||||
|
["user" | "users", "import", username] => Some(LocalRequest::ImportUser {
|
||||||
|
username: username.to_string(),
|
||||||
|
}),
|
||||||
|
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
|
||||||
|
["regenerate", "keys"] | ["identity", "rotate"] => Some(LocalRequest::RotateIotaIdentity),
|
||||||
|
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
|
||||||
|
intent: crate::ExitIntent::Restart,
|
||||||
|
}),
|
||||||
|
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
|
||||||
|
intent: crate::ExitIntent::Stop,
|
||||||
|
}),
|
||||||
|
["daemon", "status"] => Some(LocalRequest::GetDaemonStatus),
|
||||||
|
["config", "get"] => Some(LocalRequest::GetConfig),
|
||||||
|
["config", "set", key, value] => Some(LocalRequest::SetConfig {
|
||||||
|
key: key.to_string(),
|
||||||
|
value: value.to_string(),
|
||||||
|
}),
|
||||||
|
["config", "reload"] => Some(LocalRequest::ReloadConfig),
|
||||||
|
["omikron", "status"] => Some(LocalRequest::GetOmikronStatus),
|
||||||
|
["components"] => Some(LocalRequest::ListComponents),
|
||||||
|
["logs"] => Some(LocalRequest::GetLogs { limit: 100 }),
|
||||||
|
["update", "check"] => Some(LocalRequest::CheckUpdate),
|
||||||
|
["community", "list"] | ["communities"] => Some(LocalRequest::ListCommunities),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_status() {
|
||||||
|
assert!(matches!(parse("status"), Some(LocalRequest::GetStatus)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_tasks() {
|
||||||
|
assert!(matches!(parse("tasks"), Some(LocalRequest::ListTasks)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_user_list_shortcuts() {
|
||||||
|
assert!(matches!(parse("users"), Some(LocalRequest::ListUsers)));
|
||||||
|
assert!(matches!(parse("user list"), Some(LocalRequest::ListUsers)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_user_add() {
|
||||||
|
let req = parse("user add alice").unwrap();
|
||||||
|
match req {
|
||||||
|
LocalRequest::CreateUser { username } => assert_eq!(username, "alice"),
|
||||||
|
_ => panic!("expected CreateUser"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_the_headless_cli_user_vocabulary() {
|
||||||
|
assert!(matches!(parse("users list"), Some(LocalRequest::ListUsers)));
|
||||||
|
assert!(matches!(
|
||||||
|
parse("users add alice"),
|
||||||
|
Some(LocalRequest::CreateUser { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse("users remove 42"),
|
||||||
|
Some(LocalRequest::RemoveUser { user_id: 42 })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse("identity rotate"),
|
||||||
|
Some(LocalRequest::RotateIotaIdentity)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_user_remove_by_id() {
|
||||||
|
let req = parse("user remove 42").unwrap();
|
||||||
|
match req {
|
||||||
|
LocalRequest::RemoveUser { user_id } => assert_eq!(user_id, 42),
|
||||||
|
_ => panic!("expected RemoveUser"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_remove_requires_numeric_id() {
|
||||||
|
assert!(parse("user remove alice").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_reconnect() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse("reconnect"),
|
||||||
|
Some(LocalRequest::ReconnectOmikron)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_regenerate_keys() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse("regenerate keys"),
|
||||||
|
Some(LocalRequest::RotateIotaIdentity)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_restart_aliases() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse("restart"),
|
||||||
|
Some(LocalRequest::RequestProcessExit { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse("reload"),
|
||||||
|
Some(LocalRequest::RequestProcessExit { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_stop_aliases() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse("stop"),
|
||||||
|
Some(LocalRequest::RequestProcessExit { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse("shutdown"),
|
||||||
|
Some(LocalRequest::RequestProcessExit { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_daemon_status() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse("daemon status"),
|
||||||
|
Some(LocalRequest::GetDaemonStatus)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_config_get() {
|
||||||
|
assert!(matches!(parse("config get"), Some(LocalRequest::GetConfig)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_config_reload() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse("config reload"),
|
||||||
|
Some(LocalRequest::ReloadConfig)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_omikron_status() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse("omikron status"),
|
||||||
|
Some(LocalRequest::GetOmikronStatus)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_components() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse("components"),
|
||||||
|
Some(LocalRequest::ListComponents)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_users_show() {
|
||||||
|
let req = parse("users show 42").unwrap();
|
||||||
|
match req {
|
||||||
|
LocalRequest::GetUser { user_id } => assert_eq!(user_id, 42),
|
||||||
|
_ => panic!("expected GetUser"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_show_requires_numeric_id() {
|
||||||
|
assert!(parse("users show alice").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_config_set() {
|
||||||
|
let req = parse("config set port 8080").unwrap();
|
||||||
|
match req {
|
||||||
|
LocalRequest::SetConfig { key, value } => {
|
||||||
|
assert_eq!(key, "port");
|
||||||
|
assert_eq!(value, "8080");
|
||||||
|
}
|
||||||
|
_ => panic!("expected SetConfig"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_logs() {
|
||||||
|
assert!(matches!(parse("logs"), Some(LocalRequest::GetLogs { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_update_check() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse("update check"),
|
||||||
|
Some(LocalRequest::CheckUpdate)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_community_list() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse("community list"),
|
||||||
|
Some(LocalRequest::ListCommunities)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_communities_alias() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse("communities"),
|
||||||
|
Some(LocalRequest::ListCommunities)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_users_import() {
|
||||||
|
let req = parse("users import alice").unwrap();
|
||||||
|
match req {
|
||||||
|
LocalRequest::ImportUser { username } => assert_eq!(username, "alice"),
|
||||||
|
_ => panic!("expected ImportUser"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_with_slash_prefix() {
|
||||||
|
assert!(matches!(parse("/status"), Some(LocalRequest::GetStatus)));
|
||||||
|
assert!(matches!(parse("/tasks"), Some(LocalRequest::ListTasks)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_returns_none() {
|
||||||
|
assert!(parse("nonexistent").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_is_prefix_based_and_deterministic() {
|
||||||
|
assert_eq!(completions("identity r"), vec!["identity rotate"]);
|
||||||
|
assert_eq!(completions("/users a"), vec!["users add "]);
|
||||||
|
assert!(completions("definitely-unknown").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_distinguishes_help_and_unknown_commands() {
|
||||||
|
assert_eq!(validation_error("/help"), None);
|
||||||
|
assert!(validation_error("status").is_none());
|
||||||
|
assert!(
|
||||||
|
validation_error("statuz")
|
||||||
|
.unwrap()
|
||||||
|
.contains("Unknown command")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use crate::storage_error::StorageError;
|
use crate::storage_error::StorageError;
|
||||||
use crate::util::db;
|
use crate::util::db;
|
||||||
|
use crate::util::sync::{self, EntityType, Operation};
|
||||||
use iota_logger::log;
|
use iota_logger::log;
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
|
|
||||||
|
|
@ -46,6 +47,7 @@ impl MessageState {
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct StoredMessage {
|
pub struct StoredMessage {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
|
pub external_user: i64,
|
||||||
pub message_time: i64,
|
pub message_time: i64,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub edited: bool,
|
pub edited: bool,
|
||||||
|
|
@ -150,7 +152,8 @@ fn update_message_content(
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_millis() as i64;
|
.as_millis() as i64;
|
||||||
|
|
||||||
conn.execute(
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO message_edits (message_id, content_before, content_after, edited_at, edited_by)
|
INSERT INTO message_edits (message_id, content_before, content_after, edited_at, edited_by)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
|
@ -158,7 +161,7 @@ fn update_message_content(
|
||||||
params![msg_id, old_content, new_content, now, editor_id],
|
params![msg_id, old_content, new_content, now, editor_id],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
conn.execute(
|
tx.execute(
|
||||||
r#"
|
r#"
|
||||||
UPDATE messages
|
UPDATE messages
|
||||||
SET content = ?1, edited_count = edited_count + 1
|
SET content = ?1, edited_count = edited_count + 1
|
||||||
|
|
@ -166,6 +169,14 @@ fn update_message_content(
|
||||||
"#,
|
"#,
|
||||||
params![new_content, msg_id],
|
params![new_content, msg_id],
|
||||||
)?;
|
)?;
|
||||||
|
sync::record_event(
|
||||||
|
&tx,
|
||||||
|
storage_owner,
|
||||||
|
EntityType::Message,
|
||||||
|
msg_id,
|
||||||
|
Operation::Upsert,
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|
@ -187,15 +198,24 @@ pub fn hard_delete_message(
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
conn.execute(
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute(
|
||||||
"DELETE FROM message_edits WHERE message_id = ?1",
|
"DELETE FROM message_edits WHERE message_id = ?1",
|
||||||
params![msg_id],
|
params![msg_id],
|
||||||
)?;
|
)?;
|
||||||
conn.execute(
|
tx.execute(
|
||||||
"DELETE FROM reactions WHERE message_id = ?1",
|
"DELETE FROM reactions WHERE message_id = ?1",
|
||||||
params![msg_id],
|
params![msg_id],
|
||||||
)?;
|
)?;
|
||||||
conn.execute("DELETE FROM messages WHERE id = ?1", params![msg_id])?;
|
tx.execute("DELETE FROM messages WHERE id = ?1", params![msg_id])?;
|
||||||
|
sync::record_event(
|
||||||
|
&tx,
|
||||||
|
storage_owner,
|
||||||
|
EntityType::Message,
|
||||||
|
msg_id,
|
||||||
|
Operation::Delete,
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -261,7 +281,8 @@ pub fn flag_deleted_by_external(
|
||||||
message_time: i64,
|
message_time: i64,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
db::with_db(|conn| {
|
db::with_db(|conn| {
|
||||||
let affected = conn.execute(
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
let affected = tx.execute(
|
||||||
r#"
|
r#"
|
||||||
UPDATE messages
|
UPDATE messages
|
||||||
SET deleted_by_external = 1
|
SET deleted_by_external = 1
|
||||||
|
|
@ -272,6 +293,15 @@ pub fn flag_deleted_by_external(
|
||||||
if affected == 0 {
|
if affected == 0 {
|
||||||
return Err(StorageError::Other("Message not found".into()));
|
return Err(StorageError::Other("Message not found".into()));
|
||||||
}
|
}
|
||||||
|
let msg_id: i64 = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 ORDER BY id DESC LIMIT 1", params![storage_owner, external_user, message_time], |r| r.get(0))?;
|
||||||
|
sync::record_event(
|
||||||
|
&tx,
|
||||||
|
storage_owner,
|
||||||
|
EntityType::Message,
|
||||||
|
msg_id,
|
||||||
|
Operation::Delete,
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -297,10 +327,19 @@ pub fn delete_edit_history(
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
conn.execute(
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute(
|
||||||
"DELETE FROM message_edits WHERE message_id = ?1",
|
"DELETE FROM message_edits WHERE message_id = ?1",
|
||||||
params![msg_id],
|
params![msg_id],
|
||||||
)?;
|
)?;
|
||||||
|
sync::record_event(
|
||||||
|
&tx,
|
||||||
|
storage_owner,
|
||||||
|
EntityType::Message,
|
||||||
|
msg_id,
|
||||||
|
Operation::Upsert,
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -328,13 +367,22 @@ pub fn add_reaction(
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_millis() as i64;
|
.as_millis() as i64;
|
||||||
|
|
||||||
conn.execute(
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute(
|
||||||
r#"
|
r#"
|
||||||
INSERT OR IGNORE INTO reactions (message_id, user_id, reaction, created_at)
|
INSERT OR IGNORE INTO reactions (message_id, user_id, reaction, created_at)
|
||||||
VALUES (?1, ?2, ?3, ?4)
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
"#,
|
"#,
|
||||||
params![msg_id, user_id, reaction, now],
|
params![msg_id, user_id, reaction, now],
|
||||||
)?;
|
)?;
|
||||||
|
sync::record_event(
|
||||||
|
&tx,
|
||||||
|
storage_owner,
|
||||||
|
EntityType::Message,
|
||||||
|
msg_id,
|
||||||
|
Operation::Upsert,
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -357,10 +405,19 @@ pub fn remove_reaction(
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
conn.execute(
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute(
|
||||||
"DELETE FROM reactions WHERE message_id = ?1 AND user_id = ?2 AND reaction = ?3",
|
"DELETE FROM reactions WHERE message_id = ?1 AND user_id = ?2 AND reaction = ?3",
|
||||||
params![msg_id, user_id, reaction],
|
params![msg_id, user_id, reaction],
|
||||||
)?;
|
)?;
|
||||||
|
sync::record_event(
|
||||||
|
&tx,
|
||||||
|
storage_owner,
|
||||||
|
EntityType::Message,
|
||||||
|
msg_id,
|
||||||
|
Operation::Upsert,
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -383,7 +440,8 @@ pub fn add_message(
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = db::with_db(|conn| {
|
if let Err(e) = db::with_db(|conn| {
|
||||||
conn.execute(
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO messages (
|
INSERT INTO messages (
|
||||||
storage_owner, external_user, message_time, content,
|
storage_owner, external_user, message_time, content,
|
||||||
|
|
@ -405,6 +463,15 @@ pub fn add_message(
|
||||||
reply_to,
|
reply_to,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
let msg_id = tx.last_insert_rowid();
|
||||||
|
sync::record_event(
|
||||||
|
&tx,
|
||||||
|
storage_owner,
|
||||||
|
EntityType::Message,
|
||||||
|
msg_id,
|
||||||
|
Operation::Upsert,
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}) {
|
}) {
|
||||||
log!("Failed to insert message into sqlite: {}", e);
|
log!("Failed to insert message into sqlite: {}", e);
|
||||||
|
|
@ -447,7 +514,8 @@ pub fn change_message_state(
|
||||||
.as_str()
|
.as_str()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
conn.execute(
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute(
|
||||||
r#"
|
r#"
|
||||||
UPDATE messages
|
UPDATE messages
|
||||||
SET message_state = ?1
|
SET message_state = ?1
|
||||||
|
|
@ -459,6 +527,13 @@ pub fn change_message_state(
|
||||||
"#,
|
"#,
|
||||||
params![upgraded, storage_owner, external_user, timestamp],
|
params![upgraded, storage_owner, external_user, timestamp],
|
||||||
)?;
|
)?;
|
||||||
|
let msg_id: i64 = tx.query_row(
|
||||||
|
"SELECT id FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 ORDER BY id DESC LIMIT 1",
|
||||||
|
params![storage_owner, external_user, timestamp],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
sync::record_event(&tx, storage_owner, EntityType::Message, msg_id, Operation::Upsert)?;
|
||||||
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.map_err(|e: StorageError| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
|
.map_err(|e: StorageError| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
|
||||||
|
|
@ -532,6 +607,7 @@ pub fn get_messages(
|
||||||
|row| {
|
|row| {
|
||||||
Ok(StoredMessage {
|
Ok(StoredMessage {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
|
external_user,
|
||||||
message_time: row.get(1)?,
|
message_time: row.get(1)?,
|
||||||
content: row.get(2)?,
|
content: row.get(2)?,
|
||||||
sent_by_self: row.get::<_, i64>(3)? != 0,
|
sent_by_self: row.get::<_, i64>(3)? != 0,
|
||||||
|
|
@ -568,6 +644,69 @@ pub fn get_messages(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_messages_by_ids(storage_owner: i64, ids: &[i64]) -> Vec<StoredMessage> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let wanted: std::collections::HashSet<i64> = ids.iter().copied().collect();
|
||||||
|
// A journal id uniquely identifies a row. Load all messages for this owner and retain only
|
||||||
|
// those ids; this keeps reaction hydration identical to normal message loading.
|
||||||
|
match db::with_db(|conn| {
|
||||||
|
let mut stmt = conn.prepare("SELECT id, message_time, content, sent_by_self, message_state, height, reply_to, edited_count, external_user FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0")?;
|
||||||
|
let rows = stmt.query_map([storage_owner], |row| {
|
||||||
|
let external_user: i64 = row.get(8)?;
|
||||||
|
Ok(StoredMessage {
|
||||||
|
id: row.get(0)?,
|
||||||
|
external_user,
|
||||||
|
message_time: row.get(1)?,
|
||||||
|
content: row.get(2)?,
|
||||||
|
sent_by_self: row.get::<_, i64>(3)? != 0,
|
||||||
|
message_state: row.get(4)?,
|
||||||
|
height: row.get(5).unwrap_or(0),
|
||||||
|
reply_to: row.get(6).ok().flatten(),
|
||||||
|
edited: row.get::<_, i64>(7).unwrap_or(0) > 0,
|
||||||
|
reactions: Vec::new(),
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let message = row?;
|
||||||
|
if wanted.contains(&message.id) {
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reaction_map = load_reactions(conn, &messages.iter().map(|m| m.id).collect::<Vec<_>>());
|
||||||
|
for message in &mut messages {
|
||||||
|
message.reactions = reaction_map.get(&message.id).cloned().unwrap_or_default();
|
||||||
|
}
|
||||||
|
Ok(messages)
|
||||||
|
}) {
|
||||||
|
Ok(messages) => messages,
|
||||||
|
Err(e) => {
|
||||||
|
log!("Failed to query messages by id: {}", e);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_all_messages(storage_owner: i64) -> Vec<StoredMessage> {
|
||||||
|
let ids = match db::with_db(|conn| {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0",
|
||||||
|
)?;
|
||||||
|
Ok(stmt
|
||||||
|
.query_map([storage_owner], |row| row.get::<_, i64>(0))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?)
|
||||||
|
}) {
|
||||||
|
Ok(ids) => ids,
|
||||||
|
Err(e) => {
|
||||||
|
log!("Failed to query all messages: {}", e);
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
get_messages_by_ids(storage_owner, &ids)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::MessageState;
|
use super::MessageState;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
use crate::users::contact::Contact;
|
use crate::users::contact::Contact;
|
||||||
use crate::util::db;
|
use crate::util::db;
|
||||||
|
use crate::util::sync::{self, EntityType, Operation};
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
|
|
||||||
pub fn mod_user(storage_owner: i64, contact: &Contact) {
|
pub fn mod_user(storage_owner: i64, contact: &Contact) {
|
||||||
if let Err(e) = db::with_db(|conn| {
|
if let Err(e) = db::with_db(|conn| {
|
||||||
conn.execute(
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
tx.execute(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO contacts (storage_owner, user_id, user_name, last_message_at)
|
INSERT INTO contacts (storage_owner, user_id, user_name, last_message_at)
|
||||||
VALUES (?1, ?2, ?3, ?4)
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
|
@ -19,12 +21,31 @@ pub fn mod_user(storage_owner: i64, contact: &Contact) {
|
||||||
contact.last_message_at,
|
contact.last_message_at,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
sync::record_event(
|
||||||
|
&tx,
|
||||||
|
storage_owner,
|
||||||
|
EntityType::Contact,
|
||||||
|
contact.user_id,
|
||||||
|
Operation::Upsert,
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}) {
|
}) {
|
||||||
eprintln!("Failed to mod_user: {}", e);
|
eprintln!("Failed to mod_user: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_users_by_ids(storage_owner: i64, ids: &[i64]) -> Vec<Contact> {
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let wanted: std::collections::HashSet<i64> = ids.iter().copied().collect();
|
||||||
|
get_users(storage_owner)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|contact| wanted.contains(&contact.user_id))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_user(storage_owner: i64, user_id: i64) -> Option<Contact> {
|
pub fn get_user(storage_owner: i64, user_id: i64) -> Option<Contact> {
|
||||||
match db::with_db(|conn| {
|
match db::with_db(|conn| {
|
||||||
match conn.query_row(
|
match conn.query_row(
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,57 @@ pub fn modify_config(f: impl FnOnce(&mut IotaConfig)) {
|
||||||
CONFIG.store(Arc::new(cfg));
|
CONFIG.store(Arc::new(cfg));
|
||||||
save_config();
|
save_config();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn modify_config_value(key: &str, value: &str) -> Result<(), &'static str> {
|
||||||
|
match key {
|
||||||
|
"iota_id" => {
|
||||||
|
let parsed: u64 = value.parse().map_err(|_| "invalid iota_id")?;
|
||||||
|
modify_config(|cfg| cfg.iota_id = Some(parsed));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"port" => {
|
||||||
|
let parsed: u16 = value.parse().map_err(|_| "invalid port")?;
|
||||||
|
modify_config(|cfg| cfg.port = parsed);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"omikron_host" => {
|
||||||
|
let host = value.to_string();
|
||||||
|
modify_config(|cfg| cfg.omikron_host = Some(host));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"omikron_port" => {
|
||||||
|
let parsed: u16 = value.parse().map_err(|_| "invalid omikron_port")?;
|
||||||
|
modify_config(|cfg| cfg.omikron_port = Some(parsed));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"read_receipts_enabled" => {
|
||||||
|
let parsed: bool = value.parse().map_err(|_| "invalid boolean")?;
|
||||||
|
modify_config(|cfg| cfg.read_receipts_enabled = parsed);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"web.mode" => {
|
||||||
|
let mode = match value {
|
||||||
|
"disabled" => WebMode::Disabled,
|
||||||
|
"loopback" => WebMode::Loopback,
|
||||||
|
"network" => WebMode::Network,
|
||||||
|
_ => return Err("invalid web.mode; use disabled, loopback, or network"),
|
||||||
|
};
|
||||||
|
modify_config(|cfg| cfg.web.mode = mode);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"web.port" => {
|
||||||
|
let parsed: u16 = value.parse().map_err(|_| "invalid web.port")?;
|
||||||
|
modify_config(|cfg| cfg.web.port = parsed);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
"web.bind" => {
|
||||||
|
let bind = value.to_string();
|
||||||
|
modify_config(|cfg| cfg.web.bind = bind);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err("unknown config key"),
|
||||||
|
}
|
||||||
|
}
|
||||||
static CONFIG_PATH: OnceLock<PathBuf> = OnceLock::new();
|
static CONFIG_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
pub fn configure_config_path(path: PathBuf) {
|
pub fn configure_config_path(path: PathBuf) {
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,37 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> {
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if current_version < 6 {
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_heads (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
version INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_events (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id INTEGER NOT NULL,
|
||||||
|
operation TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, version)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_events_user_version
|
||||||
|
ON sync_events (user_id, version);
|
||||||
|
CREATE TABLE IF NOT EXISTS client_sync_state (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
session_id INTEGER NOT NULL,
|
||||||
|
acknowledged_version INTEGER NOT NULL,
|
||||||
|
cache_schema_version INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, session_id)
|
||||||
|
);
|
||||||
|
PRAGMA user_version = 6;
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,7 +320,7 @@ mod tests {
|
||||||
run_migrations_on_connection(&conn)?;
|
run_migrations_on_connection(&conn)?;
|
||||||
|
|
||||||
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
||||||
assert_eq!(version, 5);
|
assert_eq!(version, 6);
|
||||||
for column in ["height", "reply_to", "edited_count", "deleted_by_external"] {
|
for column in ["height", "reply_to", "edited_count", "deleted_by_external"] {
|
||||||
let mut statement =
|
let mut statement =
|
||||||
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
|
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
|
||||||
|
|
@ -298,4 +329,23 @@ mod tests {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrates_version_five_once_and_is_idempotent() -> Result<(), StorageError> {
|
||||||
|
let conn = Connection::open_in_memory()?;
|
||||||
|
conn.execute_batch("PRAGMA user_version = 5;")?;
|
||||||
|
run_migrations_on_connection(&conn)?;
|
||||||
|
run_migrations_on_connection(&conn)?;
|
||||||
|
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
||||||
|
assert_eq!(version, 6);
|
||||||
|
for table in ["sync_heads", "sync_events", "client_sync_state"] {
|
||||||
|
let exists: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||||
|
[table],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
assert_eq!(exists, 1);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,4 @@ pub mod config_util;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod e2ee_storage;
|
pub mod e2ee_storage;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod sync;
|
||||||
|
|
|
||||||
160
iota-storage/src/util/sync.rs
Normal file
160
iota-storage/src/util/sync.rs
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
//! Durable per-user state journal used by device cache synchronization.
|
||||||
|
use crate::storage_error::StorageError;
|
||||||
|
use crate::util::db;
|
||||||
|
use rusqlite::{Transaction, params};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
pub const CACHE_SCHEMA_VERSION: i64 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum EntityType {
|
||||||
|
Message,
|
||||||
|
Contact,
|
||||||
|
}
|
||||||
|
impl EntityType {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Message => "message",
|
||||||
|
Self::Contact => "contact",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Operation {
|
||||||
|
Upsert,
|
||||||
|
Delete,
|
||||||
|
}
|
||||||
|
impl Operation {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Upsert => "upsert",
|
||||||
|
Self::Delete => "delete",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct Delta {
|
||||||
|
pub message_upserts: Vec<i64>,
|
||||||
|
pub deleted_message_ids: Vec<i64>,
|
||||||
|
pub contact_upserts: Vec<i64>,
|
||||||
|
pub deleted_contact_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_millis() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_event(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
user_id: i64,
|
||||||
|
entity: EntityType,
|
||||||
|
entity_id: i64,
|
||||||
|
operation: Operation,
|
||||||
|
) -> Result<i64, StorageError> {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO sync_heads (user_id, version) VALUES (?1, 0) ON CONFLICT(user_id) DO NOTHING",
|
||||||
|
[user_id],
|
||||||
|
)?;
|
||||||
|
let previous: i64 = tx.query_row(
|
||||||
|
"SELECT version FROM sync_heads WHERE user_id = ?1",
|
||||||
|
[user_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
let version = previous
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or_else(|| StorageError::Other("sync version overflow".into()))?;
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE sync_heads SET version = ?2 WHERE user_id = ?1",
|
||||||
|
params![user_id, version],
|
||||||
|
)?;
|
||||||
|
tx.execute("INSERT INTO sync_events (user_id, version, entity_type, entity_id, operation, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![user_id, version, entity.as_str(), entity_id, operation.as_str(), now_millis()])?;
|
||||||
|
Ok(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn head(user_id: i64) -> Result<i64, StorageError> {
|
||||||
|
db::with_db(|conn| {
|
||||||
|
Ok(conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT version FROM sync_heads WHERE user_id = ?1",
|
||||||
|
[user_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_session(user_id: i64, session_id: i64) -> Result<bool, StorageError> {
|
||||||
|
db::with_db(|conn| {
|
||||||
|
Ok(conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM client_sync_state WHERE user_id = ?1 AND session_id = ?2",
|
||||||
|
params![user_id, session_id],
|
||||||
|
|_| Ok(()),
|
||||||
|
)
|
||||||
|
.is_ok())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn acknowledge(
|
||||||
|
user_id: i64,
|
||||||
|
session_id: i64,
|
||||||
|
version: i64,
|
||||||
|
cache_schema_version: i64,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
|
if user_id <= 0 || session_id <= 0 || version < 0 {
|
||||||
|
return Err(StorageError::Other("invalid sync acknowledgement".into()));
|
||||||
|
}
|
||||||
|
db::with_db(|conn| {
|
||||||
|
let head = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT version FROM sync_heads WHERE user_id = ?1",
|
||||||
|
[user_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0);
|
||||||
|
if version > head {
|
||||||
|
return Err(StorageError::Other(
|
||||||
|
"acknowledgement is ahead of head".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
conn.execute("INSERT INTO client_sync_state (user_id, session_id, acknowledged_version, cache_schema_version, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(user_id, session_id) DO UPDATE SET acknowledged_version = MAX(acknowledged_version, excluded.acknowledged_version), cache_schema_version = excluded.cache_schema_version, updated_at = excluded.updated_at", params![user_id, session_id, version, cache_schema_version, now_millis()])?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the final operation for each entity after `from_version`.
|
||||||
|
pub fn delta(user_id: i64, from_version: i64, captured_head: i64) -> Result<Delta, StorageError> {
|
||||||
|
if from_version < 0 || from_version > captured_head {
|
||||||
|
return Err(StorageError::Other("invalid sync cursor".into()));
|
||||||
|
}
|
||||||
|
db::with_db(|conn| {
|
||||||
|
let mut stmt = conn.prepare("SELECT entity_type, entity_id, operation FROM sync_events WHERE user_id = ?1 AND version > ?2 AND version <= ?3 ORDER BY version ASC")?;
|
||||||
|
let mut final_events = BTreeMap::<(String, i64), String>::new();
|
||||||
|
for row in stmt.query_map(params![user_id, from_version, captured_head], |r| {
|
||||||
|
Ok((
|
||||||
|
r.get::<_, String>(0)?,
|
||||||
|
r.get::<_, i64>(1)?,
|
||||||
|
r.get::<_, String>(2)?,
|
||||||
|
))
|
||||||
|
})? {
|
||||||
|
let (kind, id, operation) = row?;
|
||||||
|
final_events.insert((kind, id), operation);
|
||||||
|
}
|
||||||
|
let mut out = Delta::default();
|
||||||
|
for ((kind, id), operation) in final_events {
|
||||||
|
match (kind.as_str(), operation.as_str()) {
|
||||||
|
("message", "delete") => out.deleted_message_ids.push(id),
|
||||||
|
("message", _) => out.message_upserts.push(id),
|
||||||
|
("contact", "delete") => out.deleted_contact_ids.push(id),
|
||||||
|
("contact", _) => out.contact_upserts.push(id),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
})
|
||||||
|
}
|
||||||
89
iota-terms/src/consent.rs
Normal file
89
iota-terms/src/consent.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
//! Durable, deployment-scoped consent records.
|
||||||
|
//!
|
||||||
|
//! This deliberately contains no UI code. Both the terminal client and the
|
||||||
|
//! daemon use the same record so a UI-local decision can never start services.
|
||||||
|
|
||||||
|
use crate::{Doc, TermsType as Type};
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const FILE_NAME: &str = "terms-consent-v1";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct ConsentRecord {
|
||||||
|
pub eula: Option<(String, String)>,
|
||||||
|
pub tos: Option<(String, String)>,
|
||||||
|
pub privacy: Option<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConsentRecord {
|
||||||
|
pub fn has_all_required(&self) -> bool {
|
||||||
|
self.eula.is_some() && self.tos.is_some() && self.privacy.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn accepts(&self, eula: &Doc, tos: &Doc, privacy: &Doc) -> bool {
|
||||||
|
matches_doc(&self.eula, eula)
|
||||||
|
&& matches_doc(&self.tos, tos)
|
||||||
|
&& matches_doc(&self.privacy, privacy)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn accept(&mut self, doc: &Doc) {
|
||||||
|
let value = Some((doc.get_version(), doc.get_hash()));
|
||||||
|
match doc.doc_type {
|
||||||
|
Type::EULA => self.eula = value,
|
||||||
|
Type::TOS => self.tos = value,
|
||||||
|
Type::PP => self.privacy = value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_doc(value: &Option<(String, String)>, doc: &Doc) -> bool {
|
||||||
|
matches!(value, Some((version, hash)) if version == &doc.get_version() && hash == &doc.get_hash())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(state_dir: &Path) -> ConsentRecord {
|
||||||
|
let Ok(text) = fs::read_to_string(state_dir.join(FILE_NAME)) else {
|
||||||
|
return ConsentRecord::default();
|
||||||
|
};
|
||||||
|
let mut record = ConsentRecord::default();
|
||||||
|
for line in text.lines() {
|
||||||
|
let Some((key, value)) = line.split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some((version, hash)) = value.split_once(':') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = Some((version.to_owned(), hash.to_owned()));
|
||||||
|
match key {
|
||||||
|
"eula" => record.eula = value,
|
||||||
|
"tos" => record.tos = value,
|
||||||
|
"privacy" => record.privacy = value,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(state_dir: &Path, record: &ConsentRecord) -> io::Result<()> {
|
||||||
|
fs::create_dir_all(state_dir)?;
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let mut text = format!("# Iota terms consent record; accepted_at_unix={timestamp}\n");
|
||||||
|
for (name, value) in [
|
||||||
|
("eula", &record.eula),
|
||||||
|
("tos", &record.tos),
|
||||||
|
("privacy", &record.privacy),
|
||||||
|
] {
|
||||||
|
if let Some((version, hash)) = value {
|
||||||
|
text.push_str(&format!("{name}={version}:{hash}\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let path = state_dir.join(FILE_NAME);
|
||||||
|
let temporary = state_dir.join(format!(".{FILE_NAME}.{}.tmp", std::process::id()));
|
||||||
|
fs::write(&temporary, text)?;
|
||||||
|
fs::rename(temporary, path)
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod consent;
|
||||||
pub mod terms_getter;
|
pub mod terms_getter;
|
||||||
|
|
||||||
pub use terms_getter::Type as TermsType;
|
pub use terms_getter::Type as TermsType;
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,9 @@ iota-installer = { path = "../iota-installer" }
|
||||||
iota-core = { path = "../iota-core" }
|
iota-core = { path = "../iota-core" }
|
||||||
iota-process-manager = { path = "../iota-process-manager" }
|
iota-process-manager = { path = "../iota-process-manager" }
|
||||||
iota-paths = { path = "../iota-paths" }
|
iota-paths = { path = "../iota-paths" }
|
||||||
|
iota-terms = { path = "../iota-terms" }
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { version = "1.50.0", features = ["full"] }
|
||||||
tokio-util = { version = "0.7", features = ["rt"] }
|
tokio-util = { version = "0.7", features = ["rt"] }
|
||||||
|
serde_json = "1"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,246 @@
|
||||||
|
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind};
|
||||||
use iota_cli::theme::ThemeName;
|
use iota_cli::theme::ThemeName;
|
||||||
|
use iota_terms::TermsType;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct CliInvocation {
|
pub struct CliInvocation {
|
||||||
pub theme_override: Option<ThemeName>,
|
pub theme_override: Option<ThemeName>,
|
||||||
|
pub output: OutputFormat,
|
||||||
|
pub color: CapabilityPolicy,
|
||||||
|
pub unicode: CapabilityPolicy,
|
||||||
pub command: Command,
|
pub command: Command,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||||
|
pub enum CapabilityPolicy {
|
||||||
|
Auto,
|
||||||
|
Always,
|
||||||
|
Never,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||||
|
pub enum OutputFormat {
|
||||||
|
Text,
|
||||||
|
Json,
|
||||||
|
Yaml,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||||
|
enum CliTheme {
|
||||||
|
Monospace,
|
||||||
|
Binary,
|
||||||
|
Ansi,
|
||||||
|
Surface,
|
||||||
|
}
|
||||||
|
impl From<CliTheme> for ThemeName {
|
||||||
|
fn from(value: CliTheme) -> Self {
|
||||||
|
match value {
|
||||||
|
CliTheme::Monospace => Self::Monospace,
|
||||||
|
CliTheme::Binary => Self::Binary,
|
||||||
|
CliTheme::Ansi => Self::Ansi,
|
||||||
|
CliTheme::Surface => Self::Surface,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "iota",
|
||||||
|
version,
|
||||||
|
about = "Iota operator console",
|
||||||
|
arg_required_else_help = false
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
#[arg(long, global = true, value_enum)]
|
||||||
|
theme: Option<CliTheme>,
|
||||||
|
#[arg(long, global = true, value_enum, default_value_t = OutputFormat::Text)]
|
||||||
|
output: OutputFormat,
|
||||||
|
#[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)]
|
||||||
|
color: CapabilityPolicy,
|
||||||
|
#[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)]
|
||||||
|
unicode: CapabilityPolicy,
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
no_color: bool,
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<CliCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum CliCommand {
|
||||||
|
Status,
|
||||||
|
Tasks,
|
||||||
|
Users(UsersArgs),
|
||||||
|
Omikron(OmikronArgs),
|
||||||
|
Identity(IdentityArgs),
|
||||||
|
Daemon(DaemonArgs),
|
||||||
|
Config(ConfigArgs),
|
||||||
|
Terms(TermsArgs),
|
||||||
|
RegenerateKeys {
|
||||||
|
#[arg(long)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
|
Components,
|
||||||
|
Logs {
|
||||||
|
#[arg(long, default_value_t = 100)]
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
Update(UpdateArgs),
|
||||||
|
Community(CommunityArgs),
|
||||||
|
Completions {
|
||||||
|
shell: String,
|
||||||
|
},
|
||||||
|
Man,
|
||||||
|
}
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct UsersArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: UsersAction,
|
||||||
|
}
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum UsersAction {
|
||||||
|
List,
|
||||||
|
Show {
|
||||||
|
user_id: i64,
|
||||||
|
},
|
||||||
|
Add {
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
Remove {
|
||||||
|
user_id: i64,
|
||||||
|
#[arg(long)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
|
Import {
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct OmikronArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: OmikronAction,
|
||||||
|
}
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum OmikronAction {
|
||||||
|
Reconnect,
|
||||||
|
Status,
|
||||||
|
}
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct IdentityArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: IdentityAction,
|
||||||
|
}
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum IdentityAction {
|
||||||
|
Rotate {
|
||||||
|
#[arg(long)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct ConfigArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: ConfigAction,
|
||||||
|
}
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum ConfigAction {
|
||||||
|
Get,
|
||||||
|
Set { key: String, value: String },
|
||||||
|
Reload,
|
||||||
|
}
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct DaemonArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: DaemonAction,
|
||||||
|
}
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum DaemonAction {
|
||||||
|
Restart {
|
||||||
|
#[arg(long)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
|
Stop {
|
||||||
|
#[arg(long)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
|
Enable {
|
||||||
|
#[arg(long, value_parser = ["socket", "always-on"])]
|
||||||
|
mode: String,
|
||||||
|
},
|
||||||
|
DisableStartup,
|
||||||
|
Status,
|
||||||
|
StartupStatus,
|
||||||
|
Start,
|
||||||
|
RestartService,
|
||||||
|
StopService,
|
||||||
|
Install {
|
||||||
|
#[arg(long)]
|
||||||
|
bundle: String,
|
||||||
|
#[arg(long)]
|
||||||
|
operator: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct UpdateArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: UpdateAction,
|
||||||
|
}
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum UpdateAction {
|
||||||
|
Check,
|
||||||
|
}
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct CommunityArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: CommunityAction,
|
||||||
|
}
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum CommunityAction {
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct TermsArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: TermsAction,
|
||||||
|
}
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum TermsAction {
|
||||||
|
Status {
|
||||||
|
#[arg(long)]
|
||||||
|
system: bool,
|
||||||
|
},
|
||||||
|
Show {
|
||||||
|
document: TermsDocument,
|
||||||
|
},
|
||||||
|
Accept {
|
||||||
|
#[arg(long)]
|
||||||
|
system: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||||
|
enum TermsDocument {
|
||||||
|
Eula,
|
||||||
|
Tos,
|
||||||
|
Privacy,
|
||||||
|
}
|
||||||
|
impl From<TermsDocument> for TermsType {
|
||||||
|
fn from(value: TermsDocument) -> Self {
|
||||||
|
match value {
|
||||||
|
TermsDocument::Eula => TermsType::EULA,
|
||||||
|
TermsDocument::Tos => TermsType::TOS,
|
||||||
|
TermsDocument::Privacy => TermsType::PP,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
Dashboard,
|
Dashboard,
|
||||||
Help,
|
Help,
|
||||||
|
Version,
|
||||||
|
Completions {
|
||||||
|
shell: String,
|
||||||
|
},
|
||||||
|
ManPage,
|
||||||
Install {
|
Install {
|
||||||
bundle: String,
|
bundle: String,
|
||||||
operator: Option<String>,
|
operator: Option<String>,
|
||||||
|
|
@ -17,95 +248,188 @@ pub enum Command {
|
||||||
Status,
|
Status,
|
||||||
Tasks,
|
Tasks,
|
||||||
UsersList,
|
UsersList,
|
||||||
|
UsersShow {
|
||||||
|
user_id: i64,
|
||||||
|
},
|
||||||
|
UsersAdd {
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
UsersRemove {
|
||||||
|
user_id: i64,
|
||||||
|
confirmed: bool,
|
||||||
|
},
|
||||||
|
UsersImport {
|
||||||
|
username: String,
|
||||||
|
},
|
||||||
|
OmikronReconnect,
|
||||||
|
IdentityRotate {
|
||||||
|
confirmed: bool,
|
||||||
|
},
|
||||||
DaemonRestart {
|
DaemonRestart {
|
||||||
confirmed: bool,
|
confirmed: bool,
|
||||||
},
|
},
|
||||||
DaemonStop {
|
DaemonStop {
|
||||||
confirmed: bool,
|
confirmed: bool,
|
||||||
},
|
},
|
||||||
DaemonStopProcess,
|
|
||||||
DaemonEnable {
|
DaemonEnable {
|
||||||
mode: String,
|
mode: String,
|
||||||
},
|
},
|
||||||
DaemonDisableStartup,
|
DaemonDisableStartup,
|
||||||
DaemonDaemonStatus,
|
DaemonDaemonStatus,
|
||||||
|
DaemonStartupStatus,
|
||||||
|
DaemonStart,
|
||||||
|
DaemonRestartService,
|
||||||
|
DaemonStopService,
|
||||||
|
ConfigGet,
|
||||||
|
ConfigSet {
|
||||||
|
key: String,
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
|
ConfigReload,
|
||||||
|
OmikronStatus,
|
||||||
|
RegenerateKeys {
|
||||||
|
confirmed: bool,
|
||||||
|
},
|
||||||
|
Components,
|
||||||
|
Logs {
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
UpdateCheck,
|
||||||
|
CommunityList,
|
||||||
|
TermsStatus {
|
||||||
|
system: bool,
|
||||||
|
},
|
||||||
|
TermsShow {
|
||||||
|
document: TermsType,
|
||||||
|
},
|
||||||
|
TermsAccept {
|
||||||
|
system: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
impl CliInvocation {
|
impl CliInvocation {
|
||||||
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
|
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
|
||||||
let mut theme_override = None;
|
let args = args.into_iter().collect::<Vec<_>>();
|
||||||
let mut command = Vec::new();
|
if args.as_slice() == ["help"] {
|
||||||
let mut args = args.into_iter();
|
return Ok(Self::special(Command::Help));
|
||||||
while let Some(argument) = args.next() {
|
|
||||||
if argument == "--theme" {
|
|
||||||
let value = args.next().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"--theme requires a value ({})",
|
|
||||||
ThemeName::supported_names()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
theme_override = Some(value.parse()?);
|
|
||||||
} else if let Some(value) = argument.strip_prefix("--theme=") {
|
|
||||||
theme_override = Some(value.parse()?);
|
|
||||||
} else {
|
|
||||||
command.push(argument);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let command = match command.as_slice() {
|
let parsed =
|
||||||
[] => Command::Dashboard,
|
Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| {
|
||||||
[help] if help == "help" || help == "--help" => Command::Help,
|
match error.kind() {
|
||||||
[status] if status == "status" => Command::Status,
|
ErrorKind::DisplayHelp => return "__help__".to_owned(),
|
||||||
[tasks] if tasks == "tasks" => Command::Tasks,
|
ErrorKind::DisplayVersion => return "__version__".to_owned(),
|
||||||
[noun, verb] if noun == "users" && verb == "list" => Command::UsersList,
|
_ => error.to_string(),
|
||||||
[noun, verb, flag] if noun == "daemon" && verb == "restart" => Command::DaemonRestart {
|
|
||||||
confirmed: flag == "--yes",
|
|
||||||
},
|
|
||||||
[noun, verb] if noun == "daemon" && verb == "restart" => {
|
|
||||||
Command::DaemonRestart { confirmed: false }
|
|
||||||
}
|
|
||||||
[noun, verb, flag] if noun == "daemon" && verb == "stop" => Command::DaemonStop {
|
|
||||||
confirmed: flag == "--yes",
|
|
||||||
},
|
|
||||||
[noun, verb] if noun == "daemon" && verb == "stop" => {
|
|
||||||
Command::DaemonStop { confirmed: false }
|
|
||||||
}
|
|
||||||
[noun, verb] if noun == "daemon" && verb == "stop-process" => {
|
|
||||||
Command::DaemonStopProcess
|
|
||||||
}
|
|
||||||
[noun, verb] if noun == "daemon" && verb == "disable-startup" => {
|
|
||||||
Command::DaemonDisableStartup
|
|
||||||
}
|
|
||||||
[noun, verb] if noun == "daemon" && verb == "status" => Command::DaemonDaemonStatus,
|
|
||||||
[noun, verb, flag, mode]
|
|
||||||
if noun == "daemon" && verb == "enable" && flag == "--mode" =>
|
|
||||||
{
|
|
||||||
Command::DaemonEnable { mode: mode.clone() }
|
|
||||||
}
|
|
||||||
[noun, verb, bundle_flag, bundle]
|
|
||||||
if noun == "daemon" && verb == "install" && bundle_flag == "--bundle" =>
|
|
||||||
{
|
|
||||||
Command::Install {
|
|
||||||
bundle: bundle.clone(),
|
|
||||||
operator: None,
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
[noun, verb, bundle_flag, bundle, operator_flag, operator]
|
let parsed = match parsed {
|
||||||
if noun == "daemon"
|
Ok(parsed) => parsed,
|
||||||
&& verb == "install"
|
Err(marker) if marker == "__help__" => return Ok(Self::special(Command::Help)),
|
||||||
&& bundle_flag == "--bundle"
|
Err(marker) if marker == "__version__" => return Ok(Self::special(Command::Version)),
|
||||||
&& operator_flag == "--operator" =>
|
Err(error) => return Err(error),
|
||||||
{
|
};
|
||||||
Command::Install {
|
let command = match parsed.command {
|
||||||
bundle: bundle.clone(),
|
None => Command::Dashboard,
|
||||||
operator: Some(operator.clone()),
|
Some(CliCommand::Status) => Command::Status,
|
||||||
}
|
Some(CliCommand::Tasks) => Command::Tasks,
|
||||||
}
|
Some(CliCommand::Components) => Command::Components,
|
||||||
_ => return Err("Unknown command. Run `iota --help`.".into()),
|
Some(CliCommand::Completions { shell }) => Command::Completions { shell },
|
||||||
|
Some(CliCommand::Man) => Command::ManPage,
|
||||||
|
Some(CliCommand::Users(users)) => match users.action {
|
||||||
|
UsersAction::List => Command::UsersList,
|
||||||
|
UsersAction::Show { user_id } => Command::UsersShow { user_id },
|
||||||
|
UsersAction::Add { username } => Command::UsersAdd { username },
|
||||||
|
UsersAction::Remove { user_id, yes } => Command::UsersRemove {
|
||||||
|
user_id,
|
||||||
|
confirmed: yes,
|
||||||
|
},
|
||||||
|
UsersAction::Import { username } => Command::UsersImport { username },
|
||||||
|
},
|
||||||
|
Some(CliCommand::Omikron(omikron)) => match omikron.action {
|
||||||
|
OmikronAction::Reconnect => Command::OmikronReconnect,
|
||||||
|
OmikronAction::Status => Command::OmikronStatus,
|
||||||
|
},
|
||||||
|
Some(CliCommand::Identity(identity)) => match identity.action {
|
||||||
|
IdentityAction::Rotate { yes } => Command::IdentityRotate { confirmed: yes },
|
||||||
|
},
|
||||||
|
Some(CliCommand::Config(config)) => match config.action {
|
||||||
|
ConfigAction::Get => Command::ConfigGet,
|
||||||
|
ConfigAction::Set { key, value } => Command::ConfigSet { key, value },
|
||||||
|
ConfigAction::Reload => Command::ConfigReload,
|
||||||
|
},
|
||||||
|
Some(CliCommand::RegenerateKeys { yes }) => Command::RegenerateKeys { confirmed: yes },
|
||||||
|
Some(CliCommand::Logs { limit }) => Command::Logs { limit },
|
||||||
|
Some(CliCommand::Update(update)) => match update.action {
|
||||||
|
UpdateAction::Check => Command::UpdateCheck,
|
||||||
|
},
|
||||||
|
Some(CliCommand::Community(community)) => match community.action {
|
||||||
|
CommunityAction::List => Command::CommunityList,
|
||||||
|
},
|
||||||
|
Some(CliCommand::Terms(terms)) => match terms.action {
|
||||||
|
TermsAction::Status { system } => Command::TermsStatus { system },
|
||||||
|
TermsAction::Show { document } => Command::TermsShow {
|
||||||
|
document: document.into(),
|
||||||
|
},
|
||||||
|
TermsAction::Accept { system } => Command::TermsAccept { system },
|
||||||
|
},
|
||||||
|
Some(CliCommand::Daemon(daemon)) => match daemon.action {
|
||||||
|
DaemonAction::Restart { yes } => Command::DaemonRestart { confirmed: yes },
|
||||||
|
DaemonAction::Stop { yes } => Command::DaemonStop { confirmed: yes },
|
||||||
|
DaemonAction::Enable { mode } => Command::DaemonEnable { mode },
|
||||||
|
DaemonAction::DisableStartup => Command::DaemonDisableStartup,
|
||||||
|
DaemonAction::Status => Command::DaemonDaemonStatus,
|
||||||
|
DaemonAction::StartupStatus => Command::DaemonStartupStatus,
|
||||||
|
DaemonAction::Start => Command::DaemonStart,
|
||||||
|
DaemonAction::RestartService => Command::DaemonRestartService,
|
||||||
|
DaemonAction::StopService => Command::DaemonStopService,
|
||||||
|
DaemonAction::Install { bundle, operator } => Command::Install { bundle, operator },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
theme_override,
|
theme_override: parsed.theme.map(Into::into),
|
||||||
|
output: parsed.output,
|
||||||
|
color: if parsed.no_color {
|
||||||
|
CapabilityPolicy::Never
|
||||||
|
} else {
|
||||||
|
parsed.color
|
||||||
|
},
|
||||||
|
unicode: parsed.unicode,
|
||||||
command,
|
command,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn special(command: Command) -> Self {
|
||||||
|
Self {
|
||||||
|
theme_override: None,
|
||||||
|
output: OutputFormat::Text,
|
||||||
|
color: CapabilityPolicy::Auto,
|
||||||
|
unicode: CapabilityPolicy::Auto,
|
||||||
|
command,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn help_text() -> String {
|
||||||
|
Cli::command().render_long_help().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn command_paths() -> Vec<String> {
|
||||||
|
fn collect(command: &clap::Command, prefix: &str, paths: &mut Vec<String>) {
|
||||||
|
for subcommand in command.get_subcommands() {
|
||||||
|
let path = if prefix.is_empty() {
|
||||||
|
subcommand.get_name().to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{prefix} {}", subcommand.get_name())
|
||||||
|
};
|
||||||
|
if subcommand.get_subcommands().next().is_some() {
|
||||||
|
collect(subcommand, &path, paths);
|
||||||
|
} else {
|
||||||
|
paths.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let command = Cli::command();
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
collect(&command, "", &mut paths);
|
||||||
|
paths
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -126,6 +450,52 @@ mod tests {
|
||||||
assert!(error.contains(ThemeName::supported_names()));
|
assert!(error.contains(ThemeName::supported_names()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_structured_output_as_a_global_option() {
|
||||||
|
let invocation =
|
||||||
|
CliInvocation::parse(["users".into(), "list".into(), "--output=json".into()]).unwrap();
|
||||||
|
assert_eq!(invocation.output, OutputFormat::Json);
|
||||||
|
assert_eq!(invocation.command, Command::UsersList);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_terminal_capability_overrides() {
|
||||||
|
let invocation =
|
||||||
|
CliInvocation::parse(["--color=never".into(), "--unicode".into(), "always".into()])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(invocation.color, CapabilityPolicy::Never);
|
||||||
|
assert_eq!(invocation.unicode, CapabilityPolicy::Always);
|
||||||
|
assert_eq!(invocation.command, Command::Dashboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_color_is_a_compatible_alias() {
|
||||||
|
let invocation = CliInvocation::parse(["--no-color".into()]).unwrap();
|
||||||
|
assert_eq!(invocation.color, CapabilityPolicy::Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supports_standard_help_and_version_flags() {
|
||||||
|
assert_eq!(
|
||||||
|
CliInvocation::parse(["-h".into()]).unwrap().command,
|
||||||
|
Command::Help
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
CliInvocation::parse(["--version".into()]).unwrap().command,
|
||||||
|
Command::Version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_schema_drives_help_and_completion_paths() {
|
||||||
|
let paths = CliInvocation::command_paths();
|
||||||
|
assert!(paths.contains(&"users remove".to_owned()));
|
||||||
|
assert!(paths.contains(&"daemon install".to_owned()));
|
||||||
|
let help = CliInvocation::help_text();
|
||||||
|
assert!(help.contains("users"));
|
||||||
|
assert!(help.contains("--output"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_install_operator_without_raw_slice_matching() {
|
fn parses_install_operator_without_raw_slice_matching() {
|
||||||
let invocation = CliInvocation::parse([
|
let invocation = CliInvocation::parse([
|
||||||
|
|
@ -151,4 +521,92 @@ mod tests {
|
||||||
let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap();
|
let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap();
|
||||||
assert_eq!(invocation.command, Command::DaemonStop { confirmed: false });
|
assert_eq!(invocation.command, Command::DaemonStop { confirmed: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_users_add() {
|
||||||
|
let invocation =
|
||||||
|
CliInvocation::parse(["users".into(), "add".into(), "alice".into()]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
invocation.command,
|
||||||
|
Command::UsersAdd {
|
||||||
|
username: "alice".into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_malformed_users_add_shape() {
|
||||||
|
assert!(
|
||||||
|
CliInvocation::parse([
|
||||||
|
"users".into(),
|
||||||
|
"incorrect".into(),
|
||||||
|
"add".into(),
|
||||||
|
"alice".into()
|
||||||
|
])
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_destructive_option() {
|
||||||
|
assert!(CliInvocation::parse(["daemon".into(), "stop".into(), "--later".into()]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_users_remove_without_confirmation() {
|
||||||
|
let invocation =
|
||||||
|
CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
invocation.command,
|
||||||
|
Command::UsersRemove {
|
||||||
|
user_id: 42,
|
||||||
|
confirmed: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_users_remove_with_confirmation() {
|
||||||
|
let invocation =
|
||||||
|
CliInvocation::parse(["users".into(), "remove".into(), "42".into(), "--yes".into()])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
invocation.command,
|
||||||
|
Command::UsersRemove {
|
||||||
|
user_id: 42,
|
||||||
|
confirmed: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_omikron_reconnect() {
|
||||||
|
let invocation = CliInvocation::parse(["omikron".into(), "reconnect".into()]).unwrap();
|
||||||
|
assert_eq!(invocation.command, Command::OmikronReconnect);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_identity_rotate_requires_yes() {
|
||||||
|
let invocation = CliInvocation::parse(["identity".into(), "rotate".into()]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
invocation.command,
|
||||||
|
Command::IdentityRotate { confirmed: false }
|
||||||
|
);
|
||||||
|
let invocation =
|
||||||
|
CliInvocation::parse(["identity".into(), "rotate".into(), "--yes".into()]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
invocation.command,
|
||||||
|
Command::IdentityRotate { confirmed: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_daemon_ping_until_protocol_supports_a_ping_contract() {
|
||||||
|
assert!(CliInvocation::parse(["daemon".into(), "ping".into()]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_daemon_diagnostics_until_protocol_supports_diagnostics() {
|
||||||
|
assert!(CliInvocation::parse(["daemon".into(), "diagnostics".into()]).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,19 +65,23 @@ pub async fn run(
|
||||||
endpoints: &DaemonEndpoints,
|
endpoints: &DaemonEndpoints,
|
||||||
caps: Capabilities,
|
caps: Capabilities,
|
||||||
) -> Result<ConnectionContext, StartupError> {
|
) -> Result<ConnectionContext, StartupError> {
|
||||||
// Try connecting to an already-running daemon before starting a new one.
|
// The initial dashboard probe may have raced a daemon that was still
|
||||||
|
// accepting connections. Re-check both endpoints before offering setup:
|
||||||
|
// a system-managed daemon normally listens on a different socket from a
|
||||||
|
// locally launched one.
|
||||||
if let Ok(ipc) = IpcClient::connect(&endpoints.local).await {
|
if let Ok(ipc) = IpcClient::connect(&endpoints.local).await {
|
||||||
return Ok(ConnectionContext { ipc });
|
return Ok(ConnectionContext { ipc });
|
||||||
}
|
}
|
||||||
|
if endpoints.system != endpoints.local {
|
||||||
|
if let Ok(ipc) = IpcClient::connect(&endpoints.system).await {
|
||||||
|
return Ok(ConnectionContext { ipc });
|
||||||
|
}
|
||||||
|
}
|
||||||
let options = caps.options();
|
let options = caps.options();
|
||||||
if !options.iter().any(|o| o.enabled) {
|
if !options.iter().any(|o| o.enabled) {
|
||||||
let _ = show(
|
return Err(StartupError::Other(
|
||||||
ui,
|
"No running daemon could be reached, and no daemon launch method is available.".into(),
|
||||||
options,
|
));
|
||||||
"Daemon cannot be started. Correct the reported problem, then Retry, or Exit.",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
return Err(StartupError::Cancelled);
|
|
||||||
}
|
}
|
||||||
if UiConfig::load()
|
if UiConfig::load()
|
||||||
.map(|c| c.daemon_start_policy == iota_cli::theme::DaemonStartPolicy::WithUi)
|
.map(|c| c.daemon_start_policy == iota_cli::theme::DaemonStartPolicy::WithUi)
|
||||||
|
|
|
||||||
387
iota/src/main.rs
387
iota/src/main.rs
|
|
@ -2,7 +2,7 @@ use iota_cli::{
|
||||||
ipc_client::IpcClient, screens::main_screen::MainScreen, theme,
|
ipc_client::IpcClient, screens::main_screen::MainScreen, theme,
|
||||||
ui::start_bootstrap_tui_with_theme,
|
ui::start_bootstrap_tui_with_theme,
|
||||||
};
|
};
|
||||||
use iota_ipc::{LocalRequest, ResponseResult};
|
use iota_ipc::{LocalRequest, ResponsePayload, ResponseResult};
|
||||||
use iota_process_manager::detect;
|
use iota_process_manager::detect;
|
||||||
use std::{path::Path, process::ExitCode, sync::Arc};
|
use std::{path::Path, process::ExitCode, sync::Arc};
|
||||||
|
|
||||||
|
|
@ -10,8 +10,9 @@ mod cli_args;
|
||||||
mod daemon_setup_flow;
|
mod daemon_setup_flow;
|
||||||
mod local_daemon;
|
mod local_daemon;
|
||||||
mod startup_error;
|
mod startup_error;
|
||||||
|
mod terms;
|
||||||
|
|
||||||
use cli_args::{CliInvocation, Command};
|
use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat};
|
||||||
use startup_error::StartupError;
|
use startup_error::StartupError;
|
||||||
|
|
||||||
#[tokio::main(flavor = "multi_thread")]
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
|
|
@ -30,6 +31,13 @@ async fn main() -> ExitCode {
|
||||||
async fn run() -> Result<(), StartupError> {
|
async fn run() -> Result<(), StartupError> {
|
||||||
let invocation =
|
let invocation =
|
||||||
CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?;
|
CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?;
|
||||||
|
let CliInvocation {
|
||||||
|
theme_override,
|
||||||
|
output,
|
||||||
|
color,
|
||||||
|
unicode,
|
||||||
|
command,
|
||||||
|
} = invocation;
|
||||||
let local_endpoint = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
|
let local_endpoint = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
|
||||||
.map_err(|error| StartupError::Other(format!("Cannot resolve user IPC path: {error}")))?
|
.map_err(|error| StartupError::Other(format!("Cannot resolve user IPC path: {error}")))?
|
||||||
.ipc_endpoint
|
.ipc_endpoint
|
||||||
|
|
@ -57,11 +65,23 @@ async fn run() -> Result<(), StartupError> {
|
||||||
system: system_endpoint,
|
system: system_endpoint,
|
||||||
};
|
};
|
||||||
|
|
||||||
match invocation.command {
|
match command {
|
||||||
Command::Help => {
|
Command::Help => {
|
||||||
print_help();
|
print_help();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
Command::Version => {
|
||||||
|
println!("iota {}", env!("CARGO_PKG_VERSION"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Command::Completions { shell } => print_completions(&shell),
|
||||||
|
Command::ManPage => {
|
||||||
|
print_man_page();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Command::TermsStatus { system } => terms::run(terms::TermsCommand::Status { system }).await,
|
||||||
|
Command::TermsShow { document } => terms::run(terms::TermsCommand::Show { document }).await,
|
||||||
|
Command::TermsAccept { system } => terms::run(terms::TermsCommand::Accept { system }).await,
|
||||||
Command::Install { bundle, operator } => {
|
Command::Install { bundle, operator } => {
|
||||||
iota_installer::install_linux_bundle_with_operator(
|
iota_installer::install_linux_bundle_with_operator(
|
||||||
Path::new(&bundle),
|
Path::new(&bundle),
|
||||||
|
|
@ -72,24 +92,31 @@ async fn run() -> Result<(), StartupError> {
|
||||||
command => {
|
command => {
|
||||||
if matches!(
|
if matches!(
|
||||||
command,
|
command,
|
||||||
Command::DaemonEnable { .. } | Command::DaemonDisableStartup
|
Command::DaemonEnable { .. }
|
||||||
|
| Command::DaemonDisableStartup
|
||||||
|
| Command::DaemonStartupStatus
|
||||||
|
| Command::DaemonStart
|
||||||
|
| Command::DaemonRestartService
|
||||||
|
| Command::DaemonStopService
|
||||||
) {
|
) {
|
||||||
return run_startup_command(command).await;
|
return run_startup_command(command).await;
|
||||||
}
|
}
|
||||||
if !matches!(command, Command::Dashboard) {
|
if !matches!(command, Command::Dashboard) {
|
||||||
match iota_core::consent_state::non_interactive_consent() {
|
let state_dir = iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
|
||||||
iota_core::consent_state::NonInteractiveConsent::Accepted => {}
|
.map_err(|error| {
|
||||||
iota_core::consent_state::NonInteractiveConsent::RequiresInteractiveAcceptance => {
|
StartupError::Other(format!("Cannot resolve consent storage: {error}"))
|
||||||
return Err(StartupError::Consent("Run `iota` in an interactive terminal to review and accept the required terms.".into()));
|
})?
|
||||||
}
|
.state_dir;
|
||||||
|
if !iota_terms::consent::load(&state_dir).has_all_required() {
|
||||||
|
return Err(StartupError::Consent("Run `iota terms accept` in an interactive terminal to review and accept the required terms.".into()));
|
||||||
}
|
}
|
||||||
let ipc = tokio::select! {
|
let ipc = tokio::select! {
|
||||||
result = connect_available(&endpoints) => result?,
|
result = connect_available(&endpoints) => result?,
|
||||||
_ = tokio::signal::ctrl_c() => return Err(StartupError::Cancelled),
|
_ = tokio::signal::ctrl_c() => return Err(StartupError::Cancelled),
|
||||||
};
|
};
|
||||||
return run_command(ipc, command).await;
|
return run_command(ipc, command, output).await;
|
||||||
}
|
}
|
||||||
run_dashboard(invocation.theme_override, endpoints).await
|
run_dashboard(theme_override, color, unicode, endpoints).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +145,42 @@ async fn run_startup_command(command: Command) -> Result<(), StartupError> {
|
||||||
.disable_startup()
|
.disable_startup()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| StartupError::Other(e.to_string()))?,
|
.map_err(|e| StartupError::Other(e.to_string()))?,
|
||||||
|
Command::DaemonStartupStatus => {
|
||||||
|
let status = manager
|
||||||
|
.iota_startup_status()
|
||||||
|
.await
|
||||||
|
.map_err(|e| StartupError::Other(e.to_string()))?;
|
||||||
|
println!("service active: {}", status.service.active);
|
||||||
|
println!("service enabled: {}", status.service.enabled);
|
||||||
|
println!("socket active: {}", status.socket.active);
|
||||||
|
println!("socket enabled: {}", status.socket.enabled);
|
||||||
|
println!("detected mode: {:?}", status.detected);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Command::DaemonStart => {
|
||||||
|
let status = manager
|
||||||
|
.process_action(iota_process_manager::ProcessAction::Start)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StartupError::Other(e.to_string()))?;
|
||||||
|
println!("Daemon started. detected mode: {:?}", status.detected);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Command::DaemonRestartService => {
|
||||||
|
let status = manager
|
||||||
|
.process_action(iota_process_manager::ProcessAction::Restart)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StartupError::Other(e.to_string()))?;
|
||||||
|
println!("Daemon restarted. detected mode: {:?}", status.detected);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Command::DaemonStopService => {
|
||||||
|
let status = manager
|
||||||
|
.process_action(iota_process_manager::ProcessAction::Stop)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StartupError::Other(e.to_string()))?;
|
||||||
|
println!("Daemon stopped. detected mode: {:?}", status.detected);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
println!("deployment status: {:?}", status.detected);
|
println!("deployment status: {:?}", status.detected);
|
||||||
|
|
@ -149,6 +212,8 @@ async fn connect_available(
|
||||||
|
|
||||||
async fn run_dashboard(
|
async fn run_dashboard(
|
||||||
theme_override: Option<theme::ThemeName>,
|
theme_override: Option<theme::ThemeName>,
|
||||||
|
color_policy: CapabilityPolicy,
|
||||||
|
unicode_policy: CapabilityPolicy,
|
||||||
endpoints: daemon_setup_flow::DaemonEndpoints,
|
endpoints: daemon_setup_flow::DaemonEndpoints,
|
||||||
) -> Result<(), StartupError> {
|
) -> Result<(), StartupError> {
|
||||||
use std::io::IsTerminal;
|
use std::io::IsTerminal;
|
||||||
|
|
@ -162,9 +227,54 @@ async fn run_dashboard(
|
||||||
"TERM=dumb does not support the interactive dashboard".into(),
|
"TERM=dumb does not support the interactive dashboard".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let session = start_bootstrap_tui_with_theme(theme::resolve(theme::UiConfig::resolve_theme(
|
let stored_terminal = theme::UiConfig::load().unwrap_or_default();
|
||||||
theme_override,
|
let color_policy = match color_policy {
|
||||||
)))
|
CapabilityPolicy::Auto => match stored_terminal.color {
|
||||||
|
theme::TerminalPolicy::Auto => CapabilityPolicy::Auto,
|
||||||
|
theme::TerminalPolicy::Always => CapabilityPolicy::Always,
|
||||||
|
theme::TerminalPolicy::Never => CapabilityPolicy::Never,
|
||||||
|
},
|
||||||
|
policy => policy,
|
||||||
|
};
|
||||||
|
let unicode_policy = match unicode_policy {
|
||||||
|
CapabilityPolicy::Auto => match stored_terminal.unicode {
|
||||||
|
theme::TerminalPolicy::Auto => CapabilityPolicy::Auto,
|
||||||
|
theme::TerminalPolicy::Always => CapabilityPolicy::Always,
|
||||||
|
theme::TerminalPolicy::Never => CapabilityPolicy::Never,
|
||||||
|
},
|
||||||
|
policy => policy,
|
||||||
|
};
|
||||||
|
let color_enabled = match color_policy {
|
||||||
|
CapabilityPolicy::Always => true,
|
||||||
|
CapabilityPolicy::Never => false,
|
||||||
|
CapabilityPolicy::Auto => {
|
||||||
|
std::env::var_os("NO_COLOR").is_none() && std::env::var("TERM").as_deref() != Ok("dumb")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let unicode_enabled = match unicode_policy {
|
||||||
|
CapabilityPolicy::Always => true,
|
||||||
|
CapabilityPolicy::Never => false,
|
||||||
|
CapabilityPolicy::Auto => std::env::var("LC_ALL")
|
||||||
|
.or_else(|_| std::env::var("LC_CTYPE"))
|
||||||
|
.or_else(|_| std::env::var("LANG"))
|
||||||
|
.map(|locale| {
|
||||||
|
let locale = locale.to_ascii_lowercase();
|
||||||
|
locale.contains("utf-8") || locale.contains("utf8")
|
||||||
|
})
|
||||||
|
.unwrap_or(false),
|
||||||
|
};
|
||||||
|
let truecolor_enabled = std::env::var("COLORTERM")
|
||||||
|
.map(|value| {
|
||||||
|
let value = value.to_ascii_lowercase();
|
||||||
|
value.contains("truecolor") || value.contains("24bit")
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
let session = start_bootstrap_tui_with_theme(theme::resolve_with_terminal_profile(
|
||||||
|
theme::UiConfig::resolve_theme(theme_override),
|
||||||
|
color_enabled,
|
||||||
|
unicode_enabled,
|
||||||
|
truecolor_enabled,
|
||||||
|
))
|
||||||
.map_err(|error| StartupError::Terminal(error.to_string()))?;
|
.map_err(|error| StartupError::Terminal(error.to_string()))?;
|
||||||
let ui = session.ui();
|
let ui = session.ui();
|
||||||
let result = async {
|
let result = async {
|
||||||
|
|
@ -173,6 +283,7 @@ async fn run_dashboard(
|
||||||
if consent != (true, true) {
|
if consent != (true, true) {
|
||||||
return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into()));
|
return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into()));
|
||||||
}
|
}
|
||||||
|
persist_dashboard_consent().await?;
|
||||||
let initial = tokio::select! {
|
let initial = tokio::select! {
|
||||||
result = connect_available(&endpoints) => result,
|
result = connect_available(&endpoints) => result,
|
||||||
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
|
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
|
||||||
|
|
@ -223,6 +334,20 @@ async fn run_dashboard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn persist_dashboard_consent() -> Result<(), StartupError> {
|
||||||
|
let docs = iota_terms::get_current_docs().await.ok_or_else(|| {
|
||||||
|
StartupError::Consent("Could not verify the current agreements after acceptance.".into())
|
||||||
|
})?;
|
||||||
|
let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
|
||||||
|
.map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}")))?;
|
||||||
|
let mut record = iota_terms::consent::load(&paths.state_dir);
|
||||||
|
for document in [&docs.0, &docs.1, &docs.2] {
|
||||||
|
record.accept(document);
|
||||||
|
}
|
||||||
|
iota_terms::consent::save(&paths.state_dir, &record)
|
||||||
|
.map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError {
|
fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError {
|
||||||
use iota_process_manager::ProcessManagerErrorKind::*;
|
use iota_process_manager::ProcessManagerErrorKind::*;
|
||||||
match error.kind() {
|
match error.kind() {
|
||||||
|
|
@ -258,32 +383,115 @@ fn writable_socket_path(path: &Path) -> Result<(), StartupError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!("{}", CliInvocation::help_text());
|
||||||
"Iota operator console\n\nUsage:\n iota [--theme <name>] Open the dashboard\n iota daemon install --bundle <release.zip> [--operator USER]\n iota status Print daemon readiness and tasks\n iota tasks Print active tasks\n iota users list List users\n iota daemon restart --yes\n iota daemon stop --yes\n\nRun the dashboard in an interactive terminal to review required terms."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_command(ipc: Arc<IpcClient>, command: Command) -> Result<(), StartupError> {
|
fn print_completions(shell: &str) -> Result<(), StartupError> {
|
||||||
|
let command_paths = CliInvocation::command_paths();
|
||||||
|
let words = command_paths
|
||||||
|
.iter()
|
||||||
|
.flat_map(|command| command.split_whitespace())
|
||||||
|
.collect::<std::collections::BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
match shell {
|
||||||
|
"bash" => println!(
|
||||||
|
"_iota() {{ local words='{} --help --version --theme --output --color --unicode --yes --mode --bundle --operator'; COMPREPLY=( $(compgen -W \"$words\" -- \"${{COMP_WORDS[COMP_CWORD]}}\") ); }}\ncomplete -F _iota iota",
|
||||||
|
words
|
||||||
|
),
|
||||||
|
"zsh" => println!(
|
||||||
|
"#compdef iota\n_arguments '1:command:({})' '*::argument:->args'",
|
||||||
|
words
|
||||||
|
),
|
||||||
|
"fish" => {
|
||||||
|
for command in words.split_whitespace() {
|
||||||
|
println!("complete -c iota -f -a '{command}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(StartupError::InvalidCommand(
|
||||||
|
"completion shell must be bash, zsh, or fish".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_man_page() {
|
||||||
|
println!(".TH IOTA 1");
|
||||||
|
println!(".SH NAME\n iota \\- Iota operator console");
|
||||||
|
println!(".SH SYNOPSIS\n.B iota\n[global options] [command]");
|
||||||
|
println!(".SH COMMANDS");
|
||||||
|
for command in CliInvocation::command_paths() {
|
||||||
|
println!(".TP\n.B {command}");
|
||||||
|
}
|
||||||
|
println!(".SH GLOBAL OPTIONS");
|
||||||
|
println!(".TP\n.B --output text|json|yaml");
|
||||||
|
println!(".TP\n.B --color auto|always|never");
|
||||||
|
println!(".TP\n.B --unicode auto|always|never");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_command(
|
||||||
|
ipc: Arc<IpcClient>,
|
||||||
|
command: Command,
|
||||||
|
output: OutputFormat,
|
||||||
|
) -> Result<(), StartupError> {
|
||||||
let request = match command {
|
let request = match command {
|
||||||
Command::Status => LocalRequest::GetStatus,
|
Command::Status => LocalRequest::GetStatus,
|
||||||
Command::Tasks => LocalRequest::ListTasks,
|
Command::Tasks => LocalRequest::ListTasks,
|
||||||
Command::UsersList => LocalRequest::ListUsers,
|
Command::UsersList => LocalRequest::ListUsers,
|
||||||
|
Command::UsersShow { user_id } => LocalRequest::GetUser { user_id },
|
||||||
|
Command::UsersAdd { username } => LocalRequest::CreateUser { username },
|
||||||
|
Command::UsersRemove {
|
||||||
|
user_id,
|
||||||
|
confirmed: true,
|
||||||
|
} => LocalRequest::RemoveUser { user_id },
|
||||||
|
Command::UsersImport { username } => LocalRequest::ImportUser { username },
|
||||||
|
Command::OmikronReconnect => LocalRequest::ReconnectOmikron,
|
||||||
|
Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity,
|
||||||
|
Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity,
|
||||||
Command::DaemonRestart { confirmed: true } => LocalRequest::RequestProcessExit {
|
Command::DaemonRestart { confirmed: true } => LocalRequest::RequestProcessExit {
|
||||||
intent: iota_ipc::ExitIntent::Restart,
|
intent: iota_ipc::ExitIntent::Restart,
|
||||||
},
|
},
|
||||||
Command::DaemonStop { confirmed: true } => LocalRequest::RequestProcessExit {
|
Command::DaemonStop { confirmed: true } => LocalRequest::RequestProcessExit {
|
||||||
intent: iota_ipc::ExitIntent::Stop,
|
intent: iota_ipc::ExitIntent::Stop,
|
||||||
},
|
},
|
||||||
Command::DaemonStopProcess => LocalRequest::RequestProcessExit {
|
|
||||||
intent: iota_ipc::ExitIntent::Stop,
|
|
||||||
},
|
|
||||||
Command::DaemonDaemonStatus => LocalRequest::GetDaemonStatus,
|
Command::DaemonDaemonStatus => LocalRequest::GetDaemonStatus,
|
||||||
Command::DaemonRestart { confirmed: false } | Command::DaemonStop { confirmed: false } => {
|
Command::OmikronStatus => LocalRequest::GetOmikronStatus,
|
||||||
|
Command::ConfigGet => LocalRequest::GetConfig,
|
||||||
|
Command::ConfigSet { key, value } => LocalRequest::SetConfig { key, value },
|
||||||
|
Command::ConfigReload => LocalRequest::ReloadConfig,
|
||||||
|
Command::Components => LocalRequest::ListComponents,
|
||||||
|
Command::Logs { limit } => LocalRequest::GetLogs { limit },
|
||||||
|
Command::UpdateCheck => LocalRequest::CheckUpdate,
|
||||||
|
Command::CommunityList => LocalRequest::ListCommunities,
|
||||||
|
Command::UsersRemove {
|
||||||
|
confirmed: false, ..
|
||||||
|
}
|
||||||
|
| Command::IdentityRotate { confirmed: false }
|
||||||
|
| Command::RegenerateKeys { confirmed: false }
|
||||||
|
| Command::DaemonRestart { confirmed: false }
|
||||||
|
| Command::DaemonStop { confirmed: false } => {
|
||||||
return Err(StartupError::InvalidCommand(
|
return Err(StartupError::InvalidCommand(
|
||||||
"Refusing destructive command without --yes.".into(),
|
"Refusing destructive command without --yes.".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
_ => {
|
Command::Dashboard
|
||||||
|
| Command::Help
|
||||||
|
| Command::Version
|
||||||
|
| Command::Completions { .. }
|
||||||
|
| Command::ManPage
|
||||||
|
| Command::Install { .. }
|
||||||
|
| Command::TermsStatus { .. }
|
||||||
|
| Command::TermsShow { .. }
|
||||||
|
| Command::TermsAccept { .. }
|
||||||
|
| Command::DaemonEnable { .. }
|
||||||
|
| Command::DaemonDisableStartup
|
||||||
|
| Command::DaemonStartupStatus
|
||||||
|
| Command::DaemonStart
|
||||||
|
| Command::DaemonRestartService
|
||||||
|
| Command::DaemonStopService => {
|
||||||
return Err(StartupError::InvalidCommand(
|
return Err(StartupError::InvalidCommand(
|
||||||
"Command cannot be run headlessly.".into(),
|
"Command cannot be run headlessly.".into(),
|
||||||
));
|
));
|
||||||
|
|
@ -294,12 +502,139 @@ async fn run_command(ipc: Arc<IpcClient>, command: Command) -> Result<(), Startu
|
||||||
.await
|
.await
|
||||||
.map_err(|e| StartupError::Other(e.to_string()))?
|
.map_err(|e| StartupError::Other(e.to_string()))?
|
||||||
{
|
{
|
||||||
ResponseResult::Ok(message) => {
|
ResponseResult::Ok(payload) => {
|
||||||
println!("{message}");
|
if !matches!(output, OutputFormat::Text) {
|
||||||
|
return render_structured(&payload, output);
|
||||||
|
}
|
||||||
|
match payload {
|
||||||
|
ResponsePayload::Status(status) => {
|
||||||
|
print!("Phase: {}", status.phase);
|
||||||
|
if !status.tasks.is_empty() {
|
||||||
|
print!(", Tasks: {}", status.tasks.join(", "));
|
||||||
|
}
|
||||||
|
if let Some(reason) = status.degraded_reason {
|
||||||
|
print!(", Degraded: {}", reason);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
ResponsePayload::Tasks(tasks) => {
|
||||||
|
if tasks.is_empty() {
|
||||||
|
println!("No active tasks.");
|
||||||
|
} else {
|
||||||
|
for task in &tasks {
|
||||||
|
println!("{}", task.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponsePayload::Users(users) => {
|
||||||
|
if users.is_empty() {
|
||||||
|
println!("No users.");
|
||||||
|
} else {
|
||||||
|
for user in &users {
|
||||||
|
println!("{} ({})", user.username, user.user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponsePayload::UserCreated { user_id, username } => {
|
||||||
|
println!("Created user {} ({})", username, user_id);
|
||||||
|
}
|
||||||
|
ResponsePayload::UserRemoved { user_id } => {
|
||||||
|
println!("Removed user {}", user_id);
|
||||||
|
}
|
||||||
|
ResponsePayload::Acknowledged { message } => {
|
||||||
|
println!("{}", message);
|
||||||
|
}
|
||||||
|
ResponsePayload::DaemonStatus(status) => {
|
||||||
|
println!("{}", status.formatted);
|
||||||
|
}
|
||||||
|
ResponsePayload::Config(config) => {
|
||||||
|
println!("{}", config.yaml);
|
||||||
|
}
|
||||||
|
ResponsePayload::OmikronStatus(status) => {
|
||||||
|
println!("Connected: {}", status.connected);
|
||||||
|
if let Some(id) = status.iota_id {
|
||||||
|
println!("Iota ID: {}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponsePayload::Components(components) => {
|
||||||
|
if components.is_empty() {
|
||||||
|
println!("No component health data available.");
|
||||||
|
} else {
|
||||||
|
for comp in &components {
|
||||||
|
let status_str = match comp.status {
|
||||||
|
iota_ipc::HealthStatus::Healthy => "healthy",
|
||||||
|
iota_ipc::HealthStatus::Degraded => "degraded",
|
||||||
|
iota_ipc::HealthStatus::Failed => "failed",
|
||||||
|
};
|
||||||
|
let suffix = comp
|
||||||
|
.message
|
||||||
|
.as_deref()
|
||||||
|
.map(|m| format!(" ({m})"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!("{:?}: {}{}", comp.id, status_str, suffix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponsePayload::UserDetail(user) => {
|
||||||
|
println!("User: {} ({})", user.username, user.user_id);
|
||||||
|
if let Some(ref name) = user.display_name {
|
||||||
|
println!("Display Name: {name}");
|
||||||
|
}
|
||||||
|
println!("Created At: {}", user.created_at);
|
||||||
|
if !user.trusted_apps.is_empty() {
|
||||||
|
println!("Trusted Apps: {}", user.trusted_apps.join(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponsePayload::LogEntries(logs) => {
|
||||||
|
for entry in &logs.entries {
|
||||||
|
let ts = entry.timestamp_ms;
|
||||||
|
let level = if entry.is_error { "ERR" } else { "INF" };
|
||||||
|
println!("[{ts}] {level} {}: {}", entry.sender, entry.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponsePayload::UpdateStatus(status) => {
|
||||||
|
if status.available {
|
||||||
|
println!("Update available.");
|
||||||
|
} else {
|
||||||
|
println!("Up to date.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponsePayload::Communities(communities) => {
|
||||||
|
if communities.is_empty() {
|
||||||
|
println!("No communities.");
|
||||||
|
} else {
|
||||||
|
for c in &communities {
|
||||||
|
println!("{} ({})", c.title, c.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
ResponseResult::Error(code) => Err(StartupError::Other(format!(
|
ResponseResult::Error(code) => Err(StartupError::Other(format!(
|
||||||
"Daemon request failed: {code:?}"
|
"Daemon request failed: {code}"
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The IPC payload is the versioned, tagged schema used by headless clients.
|
||||||
|
/// Text remains an operator-oriented presentation; JSON and YAML must never
|
||||||
|
/// require consumers to parse it.
|
||||||
|
fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result<(), StartupError> {
|
||||||
|
match output {
|
||||||
|
OutputFormat::Json => println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(payload).map_err(|error| StartupError::Other(format!(
|
||||||
|
"Cannot encode JSON output: {error}"
|
||||||
|
)))?
|
||||||
|
),
|
||||||
|
OutputFormat::Yaml => print!(
|
||||||
|
"{}",
|
||||||
|
serde_yaml::to_string(payload).map_err(|error| StartupError::Other(format!(
|
||||||
|
"Cannot encode YAML output: {error}"
|
||||||
|
)))?
|
||||||
|
),
|
||||||
|
OutputFormat::Text => unreachable!(),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
118
iota/src/terms.rs
Normal file
118
iota/src/terms.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
use crate::startup_error::StartupError;
|
||||||
|
use iota_terms::{Doc, TermsType, consent, get_current_docs, get_terms};
|
||||||
|
use std::io::{self, IsTerminal, Write};
|
||||||
|
|
||||||
|
pub enum TermsCommand {
|
||||||
|
Status { system: bool },
|
||||||
|
Show { document: TermsType },
|
||||||
|
Accept { system: bool },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_dir(system: bool) -> Result<std::path::PathBuf, StartupError> {
|
||||||
|
let scope = if system {
|
||||||
|
iota_paths::Scope::System
|
||||||
|
} else {
|
||||||
|
iota_paths::Scope::User
|
||||||
|
};
|
||||||
|
iota_paths::IotaPaths::resolve(scope)
|
||||||
|
.map(|paths| paths.state_dir)
|
||||||
|
.map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(command: TermsCommand) -> Result<(), StartupError> {
|
||||||
|
match command {
|
||||||
|
TermsCommand::Status { system } => {
|
||||||
|
let record = consent::load(&state_dir(system)?);
|
||||||
|
println!(
|
||||||
|
"EULA: {}",
|
||||||
|
if record.eula.is_some() {
|
||||||
|
"accepted"
|
||||||
|
} else {
|
||||||
|
"not accepted"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Terms of Service: {}",
|
||||||
|
if record.tos.is_some() {
|
||||||
|
"accepted"
|
||||||
|
} else {
|
||||||
|
"not accepted"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Privacy Policy: {}",
|
||||||
|
if record.privacy.is_some() {
|
||||||
|
"accepted"
|
||||||
|
} else {
|
||||||
|
"not accepted"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
TermsCommand::Show { document } => {
|
||||||
|
let text = get_terms(document).await.ok_or_else(|| {
|
||||||
|
StartupError::Consent("Could not fetch the requested terms document.".into())
|
||||||
|
})?;
|
||||||
|
print!("{text}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
TermsCommand::Accept { system } => accept(system).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn accept(system: bool) -> Result<(), StartupError> {
|
||||||
|
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
|
||||||
|
return Err(StartupError::Consent("`iota terms accept` requires an interactive terminal so the documents can be reviewed.".into()));
|
||||||
|
}
|
||||||
|
let documents = get_current_docs().await.ok_or_else(|| {
|
||||||
|
StartupError::Consent(
|
||||||
|
"Could not fetch the current agreements from the legal endpoint.".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut record = consent::load(&state_dir(system)?);
|
||||||
|
for document in [&documents.0, &documents.1, &documents.2] {
|
||||||
|
let text = get_terms(document.doc_type).await.ok_or_else(|| {
|
||||||
|
StartupError::Consent(format!(
|
||||||
|
"Could not fetch {}.",
|
||||||
|
document.doc_type.to_string()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
println!(
|
||||||
|
"\n===== {} =====\nVersion: {}\nDocument hash: {}\n",
|
||||||
|
document.doc_type.to_string(),
|
||||||
|
document.get_version(),
|
||||||
|
document.get_hash()
|
||||||
|
);
|
||||||
|
print!("{text}\n");
|
||||||
|
if !confirm(document)? {
|
||||||
|
return Err(StartupError::Consent(
|
||||||
|
"No terms were accepted. Iota remains inactive.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
record.accept(document);
|
||||||
|
}
|
||||||
|
consent::save(&state_dir(system)?, &record)
|
||||||
|
.map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))?;
|
||||||
|
println!("Terms accepted. Start Iota again to enable services.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm(document: &Doc) -> Result<bool, StartupError> {
|
||||||
|
let hash = document.get_hash();
|
||||||
|
let prefix = hash.get(..10).unwrap_or(&hash);
|
||||||
|
let expected = format!(
|
||||||
|
"ACCEPT {} {} {}",
|
||||||
|
document.doc_type.to_str().to_ascii_uppercase(),
|
||||||
|
document.get_version(),
|
||||||
|
prefix
|
||||||
|
);
|
||||||
|
print!("To accept this exact document, type:\n{expected}\n> ");
|
||||||
|
io::stdout()
|
||||||
|
.flush()
|
||||||
|
.map_err(|error| StartupError::Consent(error.to_string()))?;
|
||||||
|
let mut response = String::new();
|
||||||
|
io::stdin()
|
||||||
|
.read_line(&mut response)
|
||||||
|
.map_err(|error| StartupError::Consent(error.to_string()))?;
|
||||||
|
Ok(response.trim() == expected)
|
||||||
|
}
|
||||||
|
|
@ -889,6 +889,7 @@ impl OmikronConnection {
|
||||||
dispatch!(CreateApp, handle_create_app);
|
dispatch!(CreateApp, handle_create_app);
|
||||||
dispatch!(DeleteApp, handle_delete_app);
|
dispatch!(DeleteApp, handle_delete_app);
|
||||||
dispatch!(ClientConnected, handle_client_connected);
|
dispatch!(ClientConnected, handle_client_connected);
|
||||||
|
dispatch!(ClientStateAck, handle_client_state_ack);
|
||||||
dispatch!(MessageState, handle_message_state);
|
dispatch!(MessageState, handle_message_state);
|
||||||
dispatch!(MessageSend, handle_message_send);
|
dispatch!(MessageSend, handle_message_send);
|
||||||
dispatch!(MessageEdit, handle_message_edit);
|
dispatch!(MessageEdit, handle_message_edit);
|
||||||
|
|
@ -1166,6 +1167,12 @@ impl OmikronConnection {
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_client_state_ack(self: Arc<Self>, cv: &CommunicationValue) {
|
||||||
|
let _ = self
|
||||||
|
.send_message(&message_handlers::handle_client_state_ack(cv))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_message_state(self: Arc<Self>, cv: &CommunicationValue) {
|
async fn handle_message_state(self: Arc<Self>, cv: &CommunicationValue) {
|
||||||
message_handlers::handle_message_state(cv);
|
message_handlers::handle_message_state(cv);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,9 @@ type_maps:
|
||||||
MessageReactionRemove: 147
|
MessageReactionRemove: 147
|
||||||
MessageReactionLive: 148
|
MessageReactionLive: 148
|
||||||
MessageDeleteLive: 150
|
MessageDeleteLive: 150
|
||||||
|
ClientStateSync: 151
|
||||||
|
ClientStateAck: 152
|
||||||
|
StateSubscribe: 153
|
||||||
DataTypes:
|
DataTypes:
|
||||||
ErrorType: 32
|
ErrorType: 32
|
||||||
ErrorProtocol: 33
|
ErrorProtocol: 33
|
||||||
|
|
@ -267,3 +270,9 @@ type_maps:
|
||||||
Reactions: 156
|
Reactions: 156
|
||||||
Reaction: 157
|
Reaction: 157
|
||||||
ReplyId: 158
|
ReplyId: 158
|
||||||
|
CacheValid: 159
|
||||||
|
CacheSchemaVersion: 160
|
||||||
|
SyncMode: 161
|
||||||
|
MessageId: 162
|
||||||
|
DeletedMessageIds: 163
|
||||||
|
DeletedContactIds: 164
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue