[Imp] UI & UX
This commit is contained in:
parent
a97092d653
commit
7399cf8fc3
18 changed files with 1635 additions and 193 deletions
|
|
@ -1,5 +1,5 @@
|
|||
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind};
|
||||
use iota_cli::theme::ThemeName;
|
||||
use iota_cli::theme::{CliOutputFormat, ThemeName, UiConfig};
|
||||
use iota_terms::TermsType;
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -23,6 +23,7 @@ pub enum OutputFormat {
|
|||
Text,
|
||||
Json,
|
||||
Yaml,
|
||||
Table,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
|
|
@ -43,6 +44,17 @@ impl From<CliTheme> for ThemeName {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<CliOutputFormat> for OutputFormat {
|
||||
fn from(value: CliOutputFormat) -> Self {
|
||||
match value {
|
||||
CliOutputFormat::Text => OutputFormat::Text,
|
||||
CliOutputFormat::Json => OutputFormat::Json,
|
||||
CliOutputFormat::Yaml => OutputFormat::Yaml,
|
||||
CliOutputFormat::Table => OutputFormat::Table,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "iota",
|
||||
|
|
@ -312,6 +324,11 @@ impl CliInvocation {
|
|||
if args.as_slice() == ["help"] {
|
||||
return Ok(Self::special(Command::Help));
|
||||
}
|
||||
|
||||
let config = UiConfig::load_or_default();
|
||||
let config_output: OutputFormat = config.cli_output.into();
|
||||
let require_confirmation = config.resolve_cli_require_confirmation();
|
||||
|
||||
let parsed =
|
||||
Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| {
|
||||
match error.kind() {
|
||||
|
|
@ -326,6 +343,20 @@ impl CliInvocation {
|
|||
Err(marker) if marker == "__version__" => return Ok(Self::special(Command::Version)),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
|
||||
let output = if parsed.output == OutputFormat::Text {
|
||||
config_output
|
||||
} else {
|
||||
parsed.output
|
||||
};
|
||||
|
||||
let resolve_confirmed = |yes_flag: bool| -> bool {
|
||||
if yes_flag {
|
||||
return true;
|
||||
}
|
||||
!require_confirmation
|
||||
};
|
||||
|
||||
let command = match parsed.command {
|
||||
None => Command::Dashboard,
|
||||
Some(CliCommand::Status) => Command::Status,
|
||||
|
|
@ -339,7 +370,7 @@ impl CliInvocation {
|
|||
UsersAction::Add { username } => Command::UsersAdd { username },
|
||||
UsersAction::Remove { user_id, yes } => Command::UsersRemove {
|
||||
user_id,
|
||||
confirmed: yes,
|
||||
confirmed: resolve_confirmed(yes),
|
||||
},
|
||||
UsersAction::Import { username } => Command::UsersImport { username },
|
||||
},
|
||||
|
|
@ -348,14 +379,18 @@ impl CliInvocation {
|
|||
OmikronAction::Status => Command::OmikronStatus,
|
||||
},
|
||||
Some(CliCommand::Identity(identity)) => match identity.action {
|
||||
IdentityAction::Rotate { yes } => Command::IdentityRotate { confirmed: yes },
|
||||
IdentityAction::Rotate { yes } => Command::IdentityRotate {
|
||||
confirmed: resolve_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::RegenerateKeys { yes }) => Command::RegenerateKeys {
|
||||
confirmed: resolve_confirmed(yes),
|
||||
},
|
||||
Some(CliCommand::Logs { limit }) => Command::Logs { limit },
|
||||
Some(CliCommand::Update(update)) => match update.action {
|
||||
UpdateAction::Check => Command::UpdateCheck,
|
||||
|
|
@ -371,8 +406,12 @@ impl CliInvocation {
|
|||
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::Restart { yes } => Command::DaemonRestart {
|
||||
confirmed: resolve_confirmed(yes),
|
||||
},
|
||||
DaemonAction::Stop { yes } => Command::DaemonStop {
|
||||
confirmed: resolve_confirmed(yes),
|
||||
},
|
||||
DaemonAction::Enable { mode } => Command::DaemonEnable { mode },
|
||||
DaemonAction::DisableStartup => Command::DaemonDisableStartup,
|
||||
DaemonAction::Status => Command::DaemonDaemonStatus,
|
||||
|
|
@ -385,7 +424,7 @@ impl CliInvocation {
|
|||
};
|
||||
Ok(Self {
|
||||
theme_override: parsed.theme.map(Into::into),
|
||||
output: parsed.output,
|
||||
output,
|
||||
color: if parsed.no_color {
|
||||
CapabilityPolicy::Never
|
||||
} else {
|
||||
|
|
@ -519,7 +558,12 @@ mod tests {
|
|||
#[test]
|
||||
fn parses_unconfirmed_destructive_commands_explicitly() {
|
||||
let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap();
|
||||
assert_eq!(invocation.command, Command::DaemonStop { confirmed: false });
|
||||
assert_eq!(
|
||||
invocation.command,
|
||||
Command::DaemonStop {
|
||||
confirmed: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -560,7 +604,7 @@ mod tests {
|
|||
invocation.command,
|
||||
Command::UsersRemove {
|
||||
user_id: 42,
|
||||
confirmed: false,
|
||||
confirmed: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -590,7 +634,7 @@ mod tests {
|
|||
let invocation = CliInvocation::parse(["identity".into(), "rotate".into()]).unwrap();
|
||||
assert_eq!(
|
||||
invocation.command,
|
||||
Command::IdentityRotate { confirmed: false }
|
||||
Command::IdentityRotate { confirmed: true }
|
||||
);
|
||||
let invocation =
|
||||
CliInvocation::parse(["identity".into(), "rotate".into(), "--yes".into()]).unwrap();
|
||||
|
|
|
|||
105
iota/src/cli_color.rs
Normal file
105
iota/src/cli_color.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
use std::env;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ColorConfig {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for ColorConfig {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorConfig {
|
||||
pub fn new() -> Self {
|
||||
let enabled = env::var("NO_COLOR").is_err()
|
||||
&& env::var("TERM")
|
||||
.map(|t| t != "dumb")
|
||||
.unwrap_or(true);
|
||||
Self { enabled }
|
||||
}
|
||||
|
||||
pub fn colorize(&self, text: &str, style: Style) -> String {
|
||||
if !self.enabled {
|
||||
return text.to_string();
|
||||
}
|
||||
format!("{}{}\x1b[0m", style.prefix(), text)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Style {
|
||||
pub fg: Option<u8>,
|
||||
pub bg: Option<u8>,
|
||||
pub bold: bool,
|
||||
}
|
||||
|
||||
impl Style {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
fg: None,
|
||||
bg: None,
|
||||
bold: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn fg(mut self, color: u8) -> Self {
|
||||
self.fg = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn bold(mut self) -> Self {
|
||||
self.bold = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn prefix(&self) -> String {
|
||||
let mut codes = Vec::new();
|
||||
if self.bold {
|
||||
codes.push("1".to_string());
|
||||
}
|
||||
if let Some(fg) = self.fg {
|
||||
codes.push(format!("3{}", fg));
|
||||
}
|
||||
if let Some(bg) = self.bg {
|
||||
codes.push(format!("4{}", bg));
|
||||
}
|
||||
if codes.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\x1b[{}m", codes.join(";"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const SUCCESS: Style = Style::new().fg(2);
|
||||
pub const WARNING: Style = Style::new().fg(3);
|
||||
pub const ERROR: Style = Style::new().fg(1);
|
||||
pub const INFO: Style = Style::new().fg(4);
|
||||
pub const MUTED: Style = Style::new().fg(8);
|
||||
pub const HEADING: Style = Style::new().bold();
|
||||
|
||||
pub fn success(config: &ColorConfig, text: &str) -> String {
|
||||
config.colorize(text, SUCCESS)
|
||||
}
|
||||
|
||||
pub fn warning(config: &ColorConfig, text: &str) -> String {
|
||||
config.colorize(text, WARNING)
|
||||
}
|
||||
|
||||
pub fn error(config: &ColorConfig, text: &str) -> String {
|
||||
config.colorize(text, ERROR)
|
||||
}
|
||||
|
||||
pub fn info(config: &ColorConfig, text: &str) -> String {
|
||||
config.colorize(text, INFO)
|
||||
}
|
||||
|
||||
pub fn muted(config: &ColorConfig, text: &str) -> String {
|
||||
config.colorize(text, MUTED)
|
||||
}
|
||||
|
||||
pub fn heading(config: &ColorConfig, text: &str) -> String {
|
||||
config.colorize(text, HEADING)
|
||||
}
|
||||
367
iota/src/main.rs
367
iota/src/main.rs
|
|
@ -7,12 +7,14 @@ use iota_process_manager::detect;
|
|||
use std::{path::Path, process::ExitCode, sync::Arc};
|
||||
|
||||
mod cli_args;
|
||||
mod cli_color;
|
||||
mod daemon_setup_flow;
|
||||
mod local_daemon;
|
||||
mod startup_error;
|
||||
mod terms;
|
||||
|
||||
use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat};
|
||||
use cli_color::ColorConfig;
|
||||
use startup_error::StartupError;
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
|
|
@ -21,7 +23,7 @@ async fn main() -> ExitCode {
|
|||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
if !matches!(error, StartupError::Cancelled) {
|
||||
eprintln!("{error}");
|
||||
startup_error::print_error(&error);
|
||||
}
|
||||
startup_error::exit_code(&error)
|
||||
}
|
||||
|
|
@ -383,7 +385,93 @@ fn writable_socket_path(path: &Path) -> Result<(), StartupError> {
|
|||
}
|
||||
|
||||
fn print_help() {
|
||||
println!("{}", CliInvocation::help_text());
|
||||
let color = cli_color::ColorConfig::new();
|
||||
println!(
|
||||
"{}",
|
||||
cli_color::heading(&color, "Iota Operator Console")
|
||||
);
|
||||
println!();
|
||||
println!("Usage: iota [OPTIONS] [COMMAND]");
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
cli_color::info(&color, "Commands:")
|
||||
);
|
||||
println!(" (no command) Launch the interactive dashboard");
|
||||
println!(" status Show daemon status");
|
||||
println!(" tasks List active tasks");
|
||||
println!(" users list List all users");
|
||||
println!(" users show <ID> Show user details");
|
||||
println!(" users add <NAME> Create a new user");
|
||||
println!(" users remove <ID> Remove a user (requires --yes)");
|
||||
println!(" omikron status Show Omikron connection status");
|
||||
println!(" omikron reconnect Reconnect to Omikron");
|
||||
println!(" identity rotate Rotate identity keys (requires --yes)");
|
||||
println!(" config get Show current configuration");
|
||||
println!(" config set <KEY> <VAL> Set a configuration value");
|
||||
println!(" config reload Reload configuration");
|
||||
println!(" components Show component health");
|
||||
println!(" logs [--limit N] Show recent log entries");
|
||||
println!(" update check Check for updates");
|
||||
println!(" community list List communities");
|
||||
println!(" terms status Show terms acceptance status");
|
||||
println!(" terms show <DOC> Show a terms document");
|
||||
println!(" terms accept Accept required terms");
|
||||
println!(" daemon restart Restart the daemon (requires --yes)");
|
||||
println!(" daemon stop Stop the daemon (requires --yes)");
|
||||
println!(" daemon enable Enable daemon at startup");
|
||||
println!(" daemon disable-startup Disable daemon at startup");
|
||||
println!(" daemon startup-status Show startup configuration");
|
||||
println!(" daemon start Start the daemon");
|
||||
println!(" daemon restart-service Restart the daemon service");
|
||||
println!(" daemon stop-service Stop the daemon service");
|
||||
println!(" daemon install Install from a bundle");
|
||||
println!(" help Show this help message");
|
||||
println!(" completions <SHELL> Generate shell completions");
|
||||
println!(" man Show the man page");
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
cli_color::info(&color, "Options:")
|
||||
);
|
||||
println!(" --theme <THEME> Theme: monospace, binary, ansi, surface");
|
||||
println!(" --output <FORMAT> Output format: text, json, yaml, table");
|
||||
println!(" --color <WHEN> Color: auto, always, never");
|
||||
println!(" --unicode <WHEN> Unicode: auto, always, never");
|
||||
println!(" --no-color Disable colored output");
|
||||
println!(" --yes, -y Confirm destructive operations");
|
||||
println!(" -h, --help Show help");
|
||||
println!(" -V, --version Show version");
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
cli_color::info(&color, "Examples:")
|
||||
);
|
||||
println!(" iota Launch the interactive dashboard");
|
||||
println!(" iota status Show daemon status");
|
||||
println!(" iota users list --output=json List users in JSON format");
|
||||
println!(" iota users add alice Create a user named 'alice'");
|
||||
println!(" iota users remove 42 --yes Remove user 42");
|
||||
println!(" iota config get --output=yaml Show config in YAML format");
|
||||
println!(" iota logs --limit 50 Show last 50 log entries");
|
||||
println!(" iota completions bash Generate bash completions");
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
cli_color::info(&color, "Exit Codes:")
|
||||
);
|
||||
println!(" 0 Success");
|
||||
println!(" 1 General error");
|
||||
println!(" 2 Invalid command or arguments");
|
||||
println!(" 130 Interrupted (Ctrl+C)");
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
cli_color::muted(&color, "Environment Variables:")
|
||||
);
|
||||
println!(" NO_COLOR Disable colored output when set");
|
||||
println!(" TERM Terminal type (dumb disables colors)");
|
||||
println!(" IOTA_THEME Default theme override");
|
||||
}
|
||||
|
||||
fn print_completions(shell: &str) -> Result<(), StartupError> {
|
||||
|
|
@ -422,14 +510,47 @@ 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 DESCRIPTION");
|
||||
println!("Iota is the operator console for managing Iota daemon instances.");
|
||||
println!("It provides both an interactive dashboard and headless CLI commands.");
|
||||
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 --output text|json|yaml|table");
|
||||
println!("Set the output format for headless commands.");
|
||||
println!(".TP\n.B --color auto|always|never");
|
||||
println!("Control colored output.");
|
||||
println!(".TP\n.B --unicode auto|always|never");
|
||||
println!("Control Unicode character rendering.");
|
||||
println!(".TP\n.B --yes, -y");
|
||||
println!("Confirm destructive operations without prompting.");
|
||||
println!(".SH EXIT CODES");
|
||||
println!(".TP\n.B 0");
|
||||
println!("Success");
|
||||
println!(".TP\n.B 1");
|
||||
println!("General error");
|
||||
println!(".TP\n.B 2");
|
||||
println!("Invalid command or arguments");
|
||||
println!(".TP\n.B 130");
|
||||
println!("Interrupted (Ctrl+C)");
|
||||
println!(".SH EXAMPLES");
|
||||
println!(".TP\n.B iota");
|
||||
println!("Launch the interactive dashboard");
|
||||
println!(".TP\n.B iota status");
|
||||
println!("Show daemon status");
|
||||
println!(".TP\n.B iota users list --output=json");
|
||||
println!("List users in JSON format");
|
||||
println!(".TP\n.B iota users add alice");
|
||||
println!("Create a user named 'alice'");
|
||||
println!(".SH ENVIRONMENT");
|
||||
println!(".TP\n.B NO_COLOR");
|
||||
println!("Disable colored output when set");
|
||||
println!(".TP\n.B TERM");
|
||||
println!("Terminal type (dumb disables colors)");
|
||||
println!(".TP\n.B IOTA_THEME");
|
||||
println!("Default theme override");
|
||||
}
|
||||
|
||||
async fn run_command(
|
||||
|
|
@ -437,6 +558,7 @@ async fn run_command(
|
|||
command: Command,
|
||||
output: OutputFormat,
|
||||
) -> Result<(), StartupError> {
|
||||
let color = ColorConfig::new();
|
||||
let request = match command {
|
||||
Command::Status => LocalRequest::GetStatus,
|
||||
Command::Tasks => LocalRequest::ListTasks,
|
||||
|
|
@ -508,18 +630,31 @@ async fn run_command(
|
|||
}
|
||||
match payload {
|
||||
ResponsePayload::Status(status) => {
|
||||
print!("Phase: {}", status.phase);
|
||||
let phase_color = if status.degraded_reason.is_some() {
|
||||
cli_color::WARNING
|
||||
} else {
|
||||
cli_color::SUCCESS
|
||||
};
|
||||
print!(
|
||||
"{} {}",
|
||||
cli_color::info(&color, "Phase:"),
|
||||
color.colorize(&status.phase, phase_color)
|
||||
);
|
||||
if !status.tasks.is_empty() {
|
||||
print!(", Tasks: {}", status.tasks.join(", "));
|
||||
print!(
|
||||
", {} {}",
|
||||
cli_color::info(&color, "Tasks:"),
|
||||
status.tasks.join(", ")
|
||||
);
|
||||
}
|
||||
if let Some(reason) = status.degraded_reason {
|
||||
print!(", Degraded: {}", reason);
|
||||
print!(", {}: {}", cli_color::warning(&color, "Degraded"), reason);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
ResponsePayload::Tasks(tasks) => {
|
||||
if tasks.is_empty() {
|
||||
println!("No active tasks.");
|
||||
println!("{}", cli_color::muted(&color, "No active tasks."));
|
||||
} else {
|
||||
for task in &tasks {
|
||||
println!("{}", task.name);
|
||||
|
|
@ -528,18 +663,31 @@ async fn run_command(
|
|||
}
|
||||
ResponsePayload::Users(users) => {
|
||||
if users.is_empty() {
|
||||
println!("No users.");
|
||||
println!("{}", cli_color::muted(&color, "No users."));
|
||||
} else {
|
||||
for user in &users {
|
||||
println!("{} ({})", user.username, user.user_id);
|
||||
println!(
|
||||
"{} ({})",
|
||||
cli_color::heading(&color, &user.username),
|
||||
user.user_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponsePayload::UserCreated { user_id, username } => {
|
||||
println!("Created user {} ({})", username, user_id);
|
||||
println!(
|
||||
"{} {} ({})",
|
||||
cli_color::success(&color, "Created user"),
|
||||
cli_color::heading(&color, &username),
|
||||
user_id
|
||||
);
|
||||
}
|
||||
ResponsePayload::UserRemoved { user_id } => {
|
||||
println!("Removed user {}", user_id);
|
||||
println!(
|
||||
"{} {}",
|
||||
cli_color::warning(&color, "Removed user"),
|
||||
user_id
|
||||
);
|
||||
}
|
||||
ResponsePayload::Acknowledged { message } => {
|
||||
println!("{}", message);
|
||||
|
|
@ -551,32 +699,57 @@ async fn run_command(
|
|||
println!("{}", config.yaml);
|
||||
}
|
||||
ResponsePayload::OmikronStatus(status) => {
|
||||
println!("Connected: {}", status.connected);
|
||||
println!(
|
||||
"{}: {}",
|
||||
cli_color::info(&color, "Connected"),
|
||||
status.connected
|
||||
);
|
||||
if let Some(id) = status.iota_id {
|
||||
println!("Iota ID: {}", id);
|
||||
println!(
|
||||
"{}: {}",
|
||||
cli_color::info(&color, "Iota ID"),
|
||||
id
|
||||
);
|
||||
}
|
||||
}
|
||||
ResponsePayload::Components(components) => {
|
||||
if components.is_empty() {
|
||||
println!("No component health data available.");
|
||||
println!(
|
||||
"{}",
|
||||
cli_color::muted(&color, "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 (status_str, style) = match comp.status {
|
||||
iota_ipc::HealthStatus::Healthy => {
|
||||
("healthy", cli_color::SUCCESS)
|
||||
}
|
||||
iota_ipc::HealthStatus::Degraded => {
|
||||
("degraded", cli_color::WARNING)
|
||||
}
|
||||
iota_ipc::HealthStatus::Failed => ("failed", cli_color::ERROR),
|
||||
};
|
||||
let suffix = comp
|
||||
.message
|
||||
.as_deref()
|
||||
.map(|m| format!(" ({m})"))
|
||||
.unwrap_or_default();
|
||||
println!("{:?}: {}{}", comp.id, status_str, suffix);
|
||||
println!(
|
||||
"{:?}: {}{}",
|
||||
comp.id,
|
||||
color.colorize(status_str, style),
|
||||
suffix
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponsePayload::UserDetail(user) => {
|
||||
println!("User: {} ({})", user.username, user.user_id);
|
||||
println!(
|
||||
"{}: {} ({})",
|
||||
cli_color::info(&color, "User"),
|
||||
cli_color::heading(&color, &user.username),
|
||||
user.user_id
|
||||
);
|
||||
if let Some(ref name) = user.display_name {
|
||||
println!("Display Name: {name}");
|
||||
}
|
||||
|
|
@ -588,23 +761,42 @@ async fn run_command(
|
|||
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);
|
||||
let (level, style) = if entry.is_error {
|
||||
("ERR", cli_color::ERROR)
|
||||
} else {
|
||||
("INF", cli_color::INFO)
|
||||
};
|
||||
println!(
|
||||
"[{ts}] {} {}: {}",
|
||||
color.colorize(level, style),
|
||||
entry.sender,
|
||||
entry.message
|
||||
);
|
||||
}
|
||||
}
|
||||
ResponsePayload::UpdateStatus(status) => {
|
||||
if status.available {
|
||||
println!("Update available.");
|
||||
println!(
|
||||
"{}",
|
||||
cli_color::success(&color, "Update available.")
|
||||
);
|
||||
} else {
|
||||
println!("Up to date.");
|
||||
println!(
|
||||
"{}",
|
||||
cli_color::info(&color, "Up to date.")
|
||||
);
|
||||
}
|
||||
}
|
||||
ResponsePayload::Communities(communities) => {
|
||||
if communities.is_empty() {
|
||||
println!("No communities.");
|
||||
println!("{}", cli_color::muted(&color, "No communities."));
|
||||
} else {
|
||||
for c in &communities {
|
||||
println!("{} ({})", c.title, c.name);
|
||||
println!(
|
||||
"{} ({})",
|
||||
cli_color::heading(&color, &c.title),
|
||||
c.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -634,7 +826,130 @@ fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result<
|
|||
"Cannot encode YAML output: {error}"
|
||||
)))?
|
||||
),
|
||||
OutputFormat::Table => render_table(payload),
|
||||
OutputFormat::Text => unreachable!(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_table(payload: &ResponsePayload) {
|
||||
match payload {
|
||||
ResponsePayload::Users(users) => {
|
||||
if users.is_empty() {
|
||||
println!("No users.");
|
||||
return;
|
||||
}
|
||||
println!("{:<8} {}", "ID", "USERNAME");
|
||||
println!("{:<8} {}", "--------", "--------");
|
||||
for user in users {
|
||||
println!("{:<8} {}", user.user_id, user.username);
|
||||
}
|
||||
}
|
||||
ResponsePayload::Tasks(tasks) => {
|
||||
if tasks.is_empty() {
|
||||
println!("No active tasks.");
|
||||
return;
|
||||
}
|
||||
println!("{}", "NAME");
|
||||
println!("{}", "--------");
|
||||
for task in tasks {
|
||||
println!("{}", task.name);
|
||||
}
|
||||
}
|
||||
ResponsePayload::Components(components) => {
|
||||
if components.is_empty() {
|
||||
println!("No component health data available.");
|
||||
return;
|
||||
}
|
||||
println!("{:<20} {:<10} {}", "COMPONENT", "STATUS", "MESSAGE");
|
||||
println!("{:<20} {:<10} {}", "--------", "--------", "--------");
|
||||
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 message = comp.message.as_deref().unwrap_or("-");
|
||||
println!("{:<20} {:<10} {}", format!("{:?}", comp.id), status_str, message);
|
||||
}
|
||||
}
|
||||
ResponsePayload::Communities(communities) => {
|
||||
if communities.is_empty() {
|
||||
println!("No communities.");
|
||||
return;
|
||||
}
|
||||
println!("{:<20} {}", "NAME", "TITLE");
|
||||
println!("{:<20} {}", "--------", "--------");
|
||||
for c in communities {
|
||||
println!("{:<20} {}", c.name, c.title);
|
||||
}
|
||||
}
|
||||
ResponsePayload::LogEntries(logs) => {
|
||||
if logs.entries.is_empty() {
|
||||
println!("No log entries.");
|
||||
return;
|
||||
}
|
||||
println!("{:<20} {:<6} {:<12} {}", "TIMESTAMP", "LEVEL", "SENDER", "MESSAGE");
|
||||
println!("{:<20} {:<6} {:<12} {}", "--------", "--------", "--------", "--------");
|
||||
for entry in &logs.entries {
|
||||
let level = if entry.is_error { "ERR" } else { "INF" };
|
||||
println!(
|
||||
"{:<20} {:<6} {:<12} {}",
|
||||
entry.timestamp_ms, level, entry.sender, entry.message
|
||||
);
|
||||
}
|
||||
}
|
||||
ResponsePayload::Status(status) => {
|
||||
println!("{:<15} {}", "Field", "Value");
|
||||
println!("{:<15} {}", "--------", "--------");
|
||||
println!("{:<15} {}", "Phase", status.phase);
|
||||
if !status.tasks.is_empty() {
|
||||
println!("{:<15} {}", "Tasks", status.tasks.join(", "));
|
||||
}
|
||||
if let Some(reason) = &status.degraded_reason {
|
||||
println!("{:<15} {}", "Degraded", reason);
|
||||
}
|
||||
}
|
||||
ResponsePayload::DaemonStatus(status) => {
|
||||
println!("{}", status.formatted);
|
||||
}
|
||||
ResponsePayload::Config(config) => {
|
||||
println!("{}", config.yaml);
|
||||
}
|
||||
ResponsePayload::OmikronStatus(status) => {
|
||||
println!("{:<15} {}", "Field", "Value");
|
||||
println!("{:<15} {}", "--------", "--------");
|
||||
println!("{:<15} {}", "Connected", status.connected);
|
||||
if let Some(id) = &status.iota_id {
|
||||
println!("{:<15} {}", "Iota ID", id);
|
||||
}
|
||||
}
|
||||
ResponsePayload::UpdateStatus(status) => {
|
||||
println!("{:<15} {}", "Field", "Value");
|
||||
println!("{:<15} {}", "--------", "--------");
|
||||
println!("{:<15} {}", "Available", status.available);
|
||||
}
|
||||
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::UserDetail(user) => {
|
||||
println!("{:<15} {}", "Field", "Value");
|
||||
println!("{:<15} {}", "--------", "--------");
|
||||
println!("{:<15} {}", "Username", user.username);
|
||||
println!("{:<15} {}", "User ID", user.user_id);
|
||||
if let Some(ref name) = user.display_name {
|
||||
println!("{:<15} {}", "Display Name", name);
|
||||
}
|
||||
println!("{:<15} {}", "Created At", user.created_at);
|
||||
if !user.trusted_apps.is_empty() {
|
||||
println!("{:<15} {}", "Trusted Apps", user.trusted_apps.join(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,52 @@ impl StartupError {
|
|||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn suggestion(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::DaemonExecutableMissing(_) => {
|
||||
Some("Install the daemon with `iota daemon install` or ensure it is in your PATH.")
|
||||
}
|
||||
Self::LocalSocketNotWritable(_, _) => {
|
||||
Some("Check permissions on the parent directory or run as your user (not root).")
|
||||
}
|
||||
Self::SystemManagerUnavailable => {
|
||||
Some("Install systemd or another supported process manager.")
|
||||
}
|
||||
Self::SystemPermissionDenied(_) => {
|
||||
Some("Run with appropriate privileges or use a user-level daemon instead.")
|
||||
}
|
||||
Self::SocketPermissionDenied(_) => {
|
||||
Some(
|
||||
"Check file permissions on the socket or ensure the daemon is running as your user.",
|
||||
)
|
||||
}
|
||||
Self::IpcTimedOut(_) => {
|
||||
Some(
|
||||
"The daemon may be starting up. Wait a moment and try again, or check daemon logs.",
|
||||
)
|
||||
}
|
||||
Self::ProtocolMismatch { .. } => {
|
||||
Some("Update your CLI or daemon to match versions.")
|
||||
}
|
||||
Self::DaemonExited { .. } => {
|
||||
Some("Restart the daemon with `iota daemon restart`.")
|
||||
}
|
||||
Self::IpcBindUnavailable(_) => {
|
||||
Some("Another instance may be running. Stop it first or use a different socket path.")
|
||||
}
|
||||
Self::Terminal(_) => {
|
||||
Some("Use a terminal that supports interactive mode, or run commands headlessly.")
|
||||
}
|
||||
Self::Consent(_) => {
|
||||
Some("Run `iota terms accept` in an interactive terminal to review and accept terms.")
|
||||
}
|
||||
Self::InvalidCommand(_) => {
|
||||
Some("Run `iota --help` to see available commands.")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl fmt::Display for StartupError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
|
|
@ -75,6 +121,18 @@ pub fn exit_code(error: &StartupError) -> ExitCode {
|
|||
ExitCode::from(error.exit_code())
|
||||
}
|
||||
|
||||
pub fn print_error(error: &StartupError) {
|
||||
let color = crate::cli_color::ColorConfig::new();
|
||||
eprintln!("{} {}", crate::cli_color::error(&color, "error:"), error);
|
||||
if let Some(suggestion) = error.suggestion() {
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
crate::cli_color::info(&color, "hint:"),
|
||||
suggestion
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -89,4 +147,10 @@ mod tests {
|
|||
assert!(error.to_string().contains("authorization"));
|
||||
assert!(error.to_string().contains("administrator"));
|
||||
}
|
||||
#[test]
|
||||
fn most_errors_have_suggestions() {
|
||||
assert!(StartupError::DaemonExecutableMissing(PathBuf::from("iota-daemon")).suggestion().is_some());
|
||||
assert!(StartupError::IpcTimedOut(PathBuf::from("/tmp/iota.sock")).suggestion().is_some());
|
||||
assert!(StartupError::Cancelled.suggestion().is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue