[Feat] Split Daemon & TUI

This commit is contained in:
Alex Emmet 2026-07-20 23:40:33 +02:00
commit 36a70e82a0
35 changed files with 970 additions and 239 deletions

56
Cargo.lock generated
View file

@ -2085,6 +2085,14 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "iota"
version = "0.1.0"
dependencies = [
"iota-cli",
"tokio",
]
[[package]] [[package]]
name = "iota-auth" name = "iota-auth"
version = "0.1.0" version = "0.1.0"
@ -2151,6 +2159,7 @@ dependencies = [
"hkdf 0.12.4", "hkdf 0.12.4",
"hyper", "hyper",
"hyper-util", "hyper-util",
"iota-ipc",
"iota-logger", "iota-logger",
"iota-state", "iota-state",
"iota-storage", "iota-storage",
@ -2178,7 +2187,6 @@ dependencies = [
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",
"tungstenite", "tungstenite",
"uuid",
"walkdir", "walkdir",
"warp", "warp",
"x448", "x448",
@ -2195,27 +2203,41 @@ dependencies = [
] ]
[[package]] [[package]]
name = "iota-core" name = "iota-daemon"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"dashmap", "iota-daemon-lib",
"iota-cli", "iota-ipc",
"iota-logger", "iota-logger",
"iota-state", "iota-state",
"iota-storage", "iota-storage",
"iota-terms",
"iota-updater",
"iota-util",
"json",
"mtp",
"omikron-connector", "omikron-connector",
"once_cell",
"pnet",
"ratatui",
"reqwest",
"tokio", "tokio",
"web-server", "web-server",
"web-ui", ]
[[package]]
name = "iota-daemon-lib"
version = "0.1.0"
dependencies = [
"iota-ipc",
"iota-logger",
"iota-state",
"iota-storage",
"iota-util",
"mtp",
"omikron-connector",
"sysinfo",
"tokio",
]
[[package]]
name = "iota-ipc"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"tokio",
] ]
[[package]] [[package]]
@ -2228,6 +2250,7 @@ dependencies = [
"mtp", "mtp",
"once_cell", "once_cell",
"ratatui", "ratatui",
"tokio",
] ]
[[package]] [[package]]
@ -2238,6 +2261,7 @@ dependencies = [
"json", "json",
"mtp", "mtp",
"once_cell", "once_cell",
"serde",
"sysinfo", "sysinfo",
"tokio", "tokio",
] ]
@ -4805,9 +4829,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.0" version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",

View file

@ -9,7 +9,10 @@ members = [
"iota-terms", "iota-terms",
"iota-state", "iota-state",
"iota-cli", "iota-cli",
"iota-core", "iota",
"iota-daemon",
"iota-daemon-lib",
"iota-ipc",
"omikron-connector", "omikron-connector",
"web-server", "web-server",
"web-ui", "web-ui",

43
flake.lock generated
View file

@ -5,11 +5,11 @@
"nixpkgs-lib": "nixpkgs-lib" "nixpkgs-lib": "nixpkgs-lib"
}, },
"locked": { "locked": {
"lastModified": 1778716662, "lastModified": 1782949081,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -20,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1780243769, "lastModified": 1784497964,
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=", "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c", "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -36,11 +36,11 @@
}, },
"nixpkgs-lib": { "nixpkgs-lib": {
"locked": { "locked": {
"lastModified": 1777168982, "lastModified": 1782614948,
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=", "narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixpkgs.lib", "repo": "nixpkgs.lib",
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14", "rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -53,8 +53,7 @@
"inputs": { "inputs": {
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay", "rust-overlay": "rust-overlay"
"ttp": "ttp"
} }
}, },
"rust-overlay": { "rust-overlay": {
@ -64,11 +63,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1780543271, "lastModified": 1784526465,
"narHash": "sha256-oPJ7eJN1sM37v92Rp/eyQL7/rUm0BOvXEBAoq/zN0cM=", "narHash": "sha256-L37teKC6oINWG4PGZLIqbphMWvSQ0PEz+aWxAk+rIDw=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "c30ca201c5093540cf792f6982f81ba1aa0f3514", "rev": "58c6334db52d51fc5dd8877c90b01f00cf8a696b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -76,22 +75,6 @@
"repo": "rust-overlay", "repo": "rust-overlay",
"type": "github" "type": "github"
} }
},
"ttp": {
"flake": false,
"locked": {
"lastModified": 1780494955,
"narHash": "sha256-i2VRRF6yNips3c4JHgfvmvMxb0HTkTCn69lmsKZLHRw=",
"ref": "refs/heads/main",
"rev": "23438fa8f884e6ad0d32ca1004c0dedcce0cc8d2",
"revCount": 125,
"type": "git",
"url": "https://git.methanium.net/tensamin/ttp.git"
},
"original": {
"type": "git",
"url": "https://git.methanium.net/tensamin/ttp.git"
}
} }
}, },
"root": "root", "root": "root",

View file

@ -8,10 +8,6 @@
url = "github:oxalica/rust-overlay"; url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
ttp = {
url = "git+https://git.methanium.net/tensamin/ttp.git";
flake = false;
};
}; };
outputs = inputs @ { outputs = inputs @ {
@ -19,7 +15,6 @@
nixpkgs, nixpkgs,
flake-parts, flake-parts,
rust-overlay, rust-overlay,
ttp,
... ...
}: }:
flake-parts.lib.mkFlake {inherit inputs;} { flake-parts.lib.mkFlake {inherit inputs;} {
@ -47,9 +42,10 @@
packages = { packages = {
default = self'.packages.iota; default = self'.packages.iota;
iota = pkgs.rustPlatform.buildRustPackage { iota = pkgs.rustPlatform.buildRustPackage {
pname = "iota"; pname = "iota-daemon";
version = "0.1.0"; version = "0.1.0";
src = ./.; src = ./.;
cargoBuildFlags = ["-p" "iota-daemon"];
cargoLock = { cargoLock = {
lockFile = ./Cargo.lock; lockFile = ./Cargo.lock;
allowBuiltinFetchGit = true; allowBuiltinFetchGit = true;
@ -57,15 +53,9 @@
nativeBuildInputs = with pkgs; [cmake perl pkg-config]; nativeBuildInputs = with pkgs; [cmake perl pkg-config];
buildInputs = with pkgs; [openssl sqlite]; buildInputs = with pkgs; [openssl sqlite];
dontUseCmakeConfigure = true; dontUseCmakeConfigure = true;
preConfigure = ''
if [ -d ../cargo-vendor-dir/ttp-core-0.1.0 ]; then
cp ${ttp}/ttp-codec.json ../cargo-vendor-dir/ttp-codec.json
fi
'';
postInstall = '' postInstall = ''
mv $out/bin/iota-core $out/bin/iota
for f in $out/bin/*; do for f in $out/bin/*; do
if [ "$(basename "$f")" != "iota" ]; then if [ "$(basename "$f")" != "iota-daemon" ]; then
rm "$f" rm "$f"
fi fi
done done
@ -95,8 +85,7 @@
then cfg.settingsFile then cfg.settingsFile
else pkgs.writeText "iota-config.json" (builtins.toJSON cfg.settings); else pkgs.writeText "iota-config.json" (builtins.toJSON cfg.settings);
descriptionText = "Tensamin Iota"; descriptionText = "Tensamin Iota daemon";
#+ lib.optionalString cfg.useTmux " (attach TUI: tmux -S ${cfg.dataDir}/tmux.sock attach -t iota)";
in { in {
options.services.iota = { options.services.iota = {
enable = lib.mkEnableOption "Enable the Iota service."; enable = lib.mkEnableOption "Enable the Iota service.";
@ -132,12 +121,6 @@
description = "Whether to open the firewall for ports used by Iota."; description = "Whether to open the firewall for ports used by Iota.";
}; };
ttpBind = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = "IP address to bind the TTP/QUIC server to.";
};
bindAddress = lib.mkOption { bindAddress = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "0.0.0.0"; default = "0.0.0.0";
@ -150,12 +133,6 @@
description = "The Iota package to use."; description = "The Iota package to use.";
}; };
useTmux = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to run Iota inside a tmux session for shared TUI access.";
};
settings = lib.mkOption { settings = lib.mkOption {
type = lib.types.attrs; type = lib.types.attrs;
default = {}; default = {};
@ -181,19 +158,7 @@
users.groups.iota = {}; users.groups.iota = {};
systemd.services.iota = let systemd.services.iota = {
iotaTmuxCmd = pkgs.writeShellScript "iota-tmux-cmd" ''
mkdir -p ${cfg.dataDir}
echo "[$(date)] Running Iota..."
${cfg.package}/bin/iota
status=$?
echo ""
echo "[$(date)] Iota exited with status: $status"
echo "Press any key to exit..."
read -r -n 1
exit $status
'';
in {
description = descriptionText; description = descriptionText;
wantedBy = ["multi-user.target"]; wantedBy = ["multi-user.target"];
after = ["network.target"]; after = ["network.target"];
@ -205,29 +170,7 @@
Group = "iota"; Group = "iota";
WorkingDirectory = cfg.dataDir; WorkingDirectory = cfg.dataDir;
ExecStart = ExecStart = "${cfg.package}/bin/iota-daemon";
if cfg.useTmux
then
pkgs.writeShellScript "iota-start" ''
set -e
export TMUX_TMPDIR=${cfg.dataDir}
${pkgs.coreutils}/bin/mkdir -p ${cfg.dataDir}
${pkgs.coreutils}/bin/chown iota:iota ${cfg.dataDir}
echo "[iota-start] Creating tmux session..."
if ! ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock new-session -d -s iota "${iotaTmuxCmd}"; then
echo "[iota-start] ERROR: tmux new-session failed"
exit 1
fi
echo "[iota-start] tmux session created, waiting..."
echo "[iota-start] Run 'tmux -S ${cfg.dataDir}/tmux.sock attach -t iota' to attach to the tmux session."
while ${pkgs.tmux}/bin/tmux -S ${cfg.dataDir}/tmux.sock has-session -t iota 2>/dev/null; do
sleep 2
done
echo "[iota-start] tmux session ended"
''
else "${cfg.package}/bin/iota";
ExecStartPre = [ ExecStartPre = [
("+" ("+"
@ -245,6 +188,8 @@
Restart = "always"; Restart = "always";
RestartSec = "5s"; RestartSec = "5s";
RuntimeDirectory = "iota";
RuntimeDirectoryMode = "0750";
AmbientCapabilities = ["CAP_NET_BIND_SERVICE"]; AmbientCapabilities = ["CAP_NET_BIND_SERVICE"];
CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"]; CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"];
@ -262,8 +207,8 @@
LockPersonality = true; LockPersonality = true;
MemoryDenyWriteExecute = true; MemoryDenyWriteExecute = true;
Environment = [ Environment = [
"TTP_BIND=${cfg.ttpBind}"
"BIND_ADDRESS=${cfg.bindAddress}" "BIND_ADDRESS=${cfg.bindAddress}"
"IOTA_SOCKET=/run/iota/iota.sock"
]; ];
} }
// lib.optionalAttrs (cfg.environmentFiles != []) { // lib.optionalAttrs (cfg.environmentFiles != []) {

View file

@ -3,16 +3,26 @@ name = "iota-cli"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[features]
legacy-commands = [
"dep:iota-logger",
"dep:iota-storage",
"dep:iota-util",
"dep:mtp",
"dep:omikron-connector",
]
[dependencies] [dependencies]
iota-logger = { path = "../iota-logger" } iota-logger = { path = "../iota-logger", optional = true }
iota-state = { path = "../iota-state" } iota-state = { path = "../iota-state" }
iota-storage = { path = "../iota-storage" } iota-storage = { path = "../iota-storage", optional = true }
iota-terms = { path = "../iota-terms" } iota-terms = { path = "../iota-terms" }
iota-util = { path = "../iota-util" } iota-util = { path = "../iota-util", optional = true }
omikron-connector = { path = "../omikron-connector" } iota-ipc = { path = "../iota-ipc" }
omikron-connector = { path = "../omikron-connector", optional = true }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", optional = true }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
actix-web = { version = "4", features = ["rustls-0_23"] } actix-web = { version = "4", features = ["rustls-0_23"] }
actix-web-actors = "4" actix-web-actors = "4"
@ -56,7 +66,6 @@ sysinfo = "0.38.3"
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
tokio-tungstenite = { version = "*", features = ["native-tls"] } tokio-tungstenite = { version = "*", features = ["native-tls"] }
tungstenite = "*" tungstenite = "*"
uuid = { version = "*", features = ["v4"] }
walkdir = "2.5.0" walkdir = "2.5.0"
warp = "*" warp = "*"
x448 = { version = "*" } x448 = { version = "*" }

View file

@ -1,10 +1,17 @@
use crossterm::event::{KeyCode, KeyEvent}; use crossterm::event::{KeyCode, KeyEvent};
use iota_logger::{log, log_command, log_cv}; #[cfg(feature = "legacy-commands")]
use iota_logger::{log, log_cv};
#[cfg(feature = "legacy-commands")]
use iota_state::{ACTIVE_TASKS, RELOAD, SHUTDOWN}; use iota_state::{ACTIVE_TASKS, RELOAD, SHUTDOWN};
#[cfg(feature = "legacy-commands")]
use iota_storage::users::{user_manager, user_profile::UserProfile}; use iota_storage::users::{user_manager, user_profile::UserProfile};
#[cfg(feature = "legacy-commands")]
use iota_storage::util::config_util::modify_config; use iota_storage::util::config_util::modify_config;
#[cfg(feature = "legacy-commands")]
use iota_util::file_util; use iota_util::file_util;
#[cfg(feature = "legacy-commands")]
use mtp::codec::{CommunicationType, CommunicationValue}; use mtp::codec::{CommunicationType, CommunicationValue};
#[cfg(feature = "legacy-commands")]
use omikron_connector::omikron_connection::OMIKRON_CONNECTION; use omikron_connector::omikron_connection::OMIKRON_CONNECTION;
use ratatui::{ use ratatui::{
Frame, Frame,
@ -13,7 +20,6 @@ use ratatui::{
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Paragraph}, widgets::{Block, Borders, Paragraph},
}; };
use uuid::Uuid;
use std::{ use std::{
any::Any, any::Any,
@ -25,11 +31,12 @@ use tokio::time::Instant;
use crate::{ use crate::{
elements::elements::{Element, InteractableElement, JoinableElement}, elements::elements::{Element, InteractableElement, JoinableElement},
interaction_result::InteractionResult, interaction_result::InteractionResult,
ui::FPS, ipc_client::IpcClient,
util::borders::draw_block_joins, util::borders::draw_block_joins,
}; };
pub struct ConsoleCard { pub struct ConsoleCard {
ipc: Arc<IpcClient>,
focused: bool, focused: bool,
pub title: String, pub title: String,
pub content: String, pub content: String,
@ -44,8 +51,9 @@ pub struct ConsoleCard {
} }
impl ConsoleCard { impl ConsoleCard {
pub fn new(title: &str, content: &str) -> Self { pub fn new(title: &str, content: &str, ipc: Arc<IpcClient>) -> Self {
ConsoleCard { ConsoleCard {
ipc,
focused: false, focused: false,
title: title.to_string(), title: title.to_string(),
content: content.to_string(), content: content.to_string(),
@ -290,22 +298,17 @@ impl InteractableElement for ConsoleCard {
match key.code { match key.code {
KeyCode::Enter => { KeyCode::Enter => {
if self.content.is_empty() { if self.content.is_empty() {
log!("");
return InteractionResult::Handled; return InteractionResult::Handled;
} }
let command = self.content.clone(); let command = self.content.clone();
let id = Uuid::new_v4(); let ipc = self.ipc.clone();
let id = id.to_string(); let seq = std::time::SystemTime::now()
let id = id.split_at(8).0; .duration_since(std::time::UNIX_EPOCH)
let task_id = format!("command_{}_{}", command, id); .unwrap_or_default()
ACTIVE_TASKS.insert(task_id.clone()); .as_millis() as u64;
log_command!("{}", command);
tokio::spawn(async move { tokio::spawn(async move {
run_command(&command).await; let _ = ipc.send_command(seq, command).await;
ACTIVE_TASKS.remove(&task_id);
}); });
self.content.clear(); self.content.clear();
@ -379,6 +382,7 @@ impl InteractableElement for ConsoleCard {
} }
} }
#[cfg(feature = "legacy-commands")]
pub async fn run_command(command: &str) { pub async fn run_command(command: &str) {
let parts = command.split(" ").collect::<Vec<&str>>(); let parts = command.split(" ").collect::<Vec<&str>>();
@ -503,6 +507,7 @@ pub async fn run_command(command: &str) {
} }
} }
#[cfg(feature = "legacy-commands")]
pub async fn ping(time: u64) { pub async fn ping(time: u64) {
let conn = OMIKRON_CONNECTION.clone(); let conn = OMIKRON_CONNECTION.clone();

View file

@ -1,7 +1,7 @@
use std::{any::Any, sync::Arc}; use std::{any::Any, sync::Arc};
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use iota_state::APP_STATE; use iota_state::ClientState;
use ratatui::{ use ratatui::{
Frame, Frame,
layout::Rect, layout::Rect,
@ -34,11 +34,29 @@ impl GRAPHS {
} }
} }
pub fn get_graph(&self) -> Vec<(f64, f64)> { pub fn get_graph(&self, state: &ClientState) -> Vec<(f64, f64)> {
match self { match self {
GRAPHS::Ram => APP_STATE.lock().unwrap().with_width(28).ram.clone(), GRAPHS::Ram => state
GRAPHS::Cpu => APP_STATE.lock().unwrap().with_width(28).cpu.clone(), .app
GRAPHS::Ping => APP_STATE.lock().unwrap().with_width(28).ping.clone(), .lock()
.unwrap_or_else(|error| error.into_inner())
.with_width(28)
.ram
.clone(),
GRAPHS::Cpu => state
.app
.lock()
.unwrap_or_else(|error| error.into_inner())
.with_width(28)
.cpu
.clone(),
GRAPHS::Ping => state
.app
.lock()
.unwrap_or_else(|error| error.into_inner())
.with_width(28)
.ping
.clone(),
} }
} }
@ -54,6 +72,7 @@ impl GRAPHS {
#[allow(unused)] #[allow(unused)]
pub struct GraphCard { pub struct GraphCard {
ui: Arc<UI>, ui: Arc<UI>,
state: ClientState,
graph_type: GRAPHS, graph_type: GRAPHS,
focused: bool, focused: bool,
@ -66,9 +85,10 @@ pub struct GraphCard {
} }
impl GraphCard { impl GraphCard {
pub fn new(ui: Arc<UI>, graph_type: GRAPHS, title: String) -> Self { pub fn new(ui: Arc<UI>, state: ClientState, graph_type: GRAPHS, title: String) -> Self {
Self { Self {
ui, ui,
state,
graph_type, graph_type,
focused: false, focused: false,
title, title,
@ -93,7 +113,7 @@ impl Element for GraphCard {
fn render(&self, f: &mut Frame, r: Rect) { fn render(&self, f: &mut Frame, r: Rect) {
if self.open { if self.open {
let graph = self.graph_type.get_graph(); let graph = self.graph_type.get_graph(&self.state);
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);

View file

@ -1,9 +1,8 @@
use crate::app_state::APP_STATE;
use crate::elements::elements::{Element, InteractableElement, JoinableElement}; use crate::elements::elements::{Element, InteractableElement, JoinableElement};
use crate::interaction_result::InteractionResult; use crate::interaction_result::InteractionResult;
use crate::util::borders::draw_block_joins; use crate::util::borders::draw_block_joins;
use crossterm::event::{KeyCode, KeyEvent}; use crossterm::event::{KeyCode, KeyEvent};
use iota_logger::PrintType; use iota_state::{ClientState, UiLogEntry};
use ratatui::{ use ratatui::{
Frame, Frame,
layout::Rect, layout::Rect,
@ -12,60 +11,9 @@ use ratatui::{
widgets::{Block, Borders, Paragraph}, widgets::{Block, Borders, Paragraph},
}; };
use std::any::Any; use std::any::Any;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Clone, Debug)]
pub struct UiLogEntry {
pub timestamp_ms: u128,
pub sender: PrintType,
pub message: String,
pub is_error: bool,
}
impl UiLogEntry {
pub fn format_timestamp(&self) -> String {
let secs = (self.timestamp_ms / 1000) as i64;
let hours = (secs / 3600) % 24;
let minutes = (secs / 60) % 60;
let seconds = secs % 60;
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
}
}
impl From<LogEntry> for UiLogEntry {
fn from(entry: LogEntry) -> Self {
Self {
timestamp_ms: entry.timestamp_ms,
sender: entry.sender,
message: entry.message,
is_error: entry.is_error,
}
}
}
#[derive(Clone, Debug)]
pub struct LogEntry {
pub timestamp_ms: u128,
pub sender: PrintType,
pub message: String,
pub is_error: bool,
}
impl LogEntry {
pub fn new(sender: PrintType, message: String, is_error: bool) -> Self {
Self {
timestamp_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis(),
sender,
message,
is_error,
}
}
}
pub struct LogCard { pub struct LogCard {
state: ClientState,
focused: bool, focused: bool,
selected: bool, selected: bool,
scroll_offset: usize, scroll_offset: usize,
@ -76,8 +24,9 @@ pub struct LogCard {
} }
impl LogCard { impl LogCard {
pub fn new() -> Self { pub fn new(state: ClientState) -> Self {
Self { Self {
state,
focused: false, focused: false,
selected: false, selected: false,
scroll_offset: 0, scroll_offset: 0,
@ -89,21 +38,17 @@ impl LogCard {
} }
fn get_logs(&self) -> Vec<UiLogEntry> { fn get_logs(&self) -> Vec<UiLogEntry> {
let state = APP_STATE.lock().unwrap(); let state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state state
.get_logs() .get_logs()
.iter() .iter()
.map(|e| UiLogEntry { .map(|e| UiLogEntry {
timestamp_ms: e.timestamp_ms, timestamp_ms: e.timestamp_ms,
sender: match e.sender.as_str() { sender: e.sender.clone(),
"Call" => PrintType::Call,
"Client" => PrintType::Client,
"Iota" => PrintType::Iota,
"Omikron" => PrintType::Omikron,
"Omega" => PrintType::Omega,
"Command" => PrintType::Command,
_ => PrintType::General,
},
message: e.message.clone(), message: e.message.clone(),
is_error: e.is_error, is_error: e.is_error,
}) })
@ -196,12 +141,24 @@ impl LogCard {
line.push_str(&timestamp); line.push_str(&timestamp);
} }
result.push((line, entry.sender.prefix_color(), entry.is_error)); result.push((line, Self::sender_color(&entry.sender), entry.is_error));
} }
result result
} }
fn sender_color(sender: &str) -> Color {
match sender {
"Call" => Color::Magenta,
"Client" => Color::Green,
"Iota" => Color::Yellow,
"Omikron" => Color::Blue,
"Omega" => Color::Cyan,
"Command" => Color::LightGreen,
_ => Color::LightCyan,
}
}
fn build_all_lines( fn build_all_lines(
&self, &self,
entries: Vec<UiLogEntry>, entries: Vec<UiLogEntry>,

View file

@ -1,14 +1,12 @@
use crate::ui::UI; use crate::ui::UI;
use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
use iota_state::{RELOAD, SHUTDOWN, UNIQUE};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration; use std::time::Duration;
pub fn setup_input_handler(ui: Arc<UI>) { pub fn setup_input_handler(ui: Arc<UI>) {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
if *SHUTDOWN.read().await { if ui.is_shutdown() {
break; break;
} }
@ -27,7 +25,6 @@ pub fn setup_input_handler(ui: Arc<UI>) {
match event_result { match event_result {
Ok(Some(key_event)) => { Ok(Some(key_event)) => {
handle_input(key_event, ui.clone()).await; handle_input(key_event, ui.clone()).await;
UNIQUE.store(true, Ordering::Relaxed);
} }
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
@ -43,11 +40,11 @@ pub async fn handle_input(key: KeyEvent, ui: Arc<UI>) {
match (key.code, key.modifiers) { match (key.code, key.modifiers) {
(crossterm::event::KeyCode::Char('q'), KeyModifiers::CONTROL) (crossterm::event::KeyCode::Char('q'), KeyModifiers::CONTROL)
| (crossterm::event::KeyCode::Char('c'), KeyModifiers::CONTROL) => { | (crossterm::event::KeyCode::Char('c'), KeyModifiers::CONTROL) => {
*SHUTDOWN.write().await = true; ui.request_shutdown();
} }
(crossterm::event::KeyCode::Char('r'), KeyModifiers::CONTROL) => { (crossterm::event::KeyCode::Char('r'), KeyModifiers::CONTROL) => {
*RELOAD.write().await = true; let _ = ui.send_restart().await;
*SHUTDOWN.write().await = true; ui.request_shutdown();
} }
_ => { _ => {
ui.handle_input(key).await; ui.handle_input(key).await;

View file

@ -0,0 +1,83 @@
use iota_ipc::{ClientMessage, DaemonMessage, read_msg, write_msg};
use iota_state::{ClientState, UiLogEntry};
use std::io::Result;
use std::path::Path;
use std::sync::Arc;
use tokio::net::UnixStream;
use tokio::net::unix::OwnedWriteHalf;
use tokio::sync::Mutex;
/* The TUI owns this cache. IPC updates replace daemon snapshots and append
* logs, so rendering never reaches into daemon-owned storage or connections. */
pub struct IpcClient {
state: ClientState,
writer: Mutex<OwnedWriteHalf>,
}
impl IpcClient {
pub async fn connect(path: impl AsRef<Path>) -> Result<Arc<Self>> {
let stream = UnixStream::connect(path).await?;
let (mut reader, writer) = stream.into_split();
let client = Arc::new(Self {
state: ClientState::new(),
writer: Mutex::new(writer),
});
let reader_client = client.clone();
tokio::spawn(async move {
while let Ok(message) = read_msg::<_, DaemonMessage>(&mut reader).await {
reader_client.apply(message).await;
}
});
client.send(ClientMessage::Subscribe).await?;
Ok(client)
}
pub fn state(&self) -> ClientState {
self.state.clone()
}
pub async fn send_command(&self, seq: u64, line: String) -> Result<()> {
self.send(ClientMessage::Command { seq, line }).await
}
async fn send(&self, message: ClientMessage) -> Result<()> {
let mut writer = self.writer.lock().await;
write_msg(&mut *writer, &message).await
}
async fn apply(&self, message: DaemonMessage) {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
match message {
DaemonMessage::LogEntry(entry) => state.push_log(UiLogEntry {
timestamp_ms: entry.timestamp_ms,
sender: entry.sender,
message: entry.message,
is_error: entry.is_error,
}),
DaemonMessage::StateUpdate(snapshot) => {
state.cpu = snapshot.cpu;
state.ram = snapshot.ram;
state.ping = snapshot.ping;
state.net_up = snapshot.net_up;
state.net_down = snapshot.net_down;
state.sys_info = snapshot.sys_info;
}
DaemonMessage::CommandResult {
success, message, ..
} => state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Command".into(),
message,
is_error: !success,
}),
DaemonMessage::Pong { .. } => {}
}
}
}

View file

@ -18,5 +18,6 @@ pub mod util {
} }
pub mod app_state; pub mod app_state;
pub mod input_handler; pub mod input_handler;
pub mod ipc_client;
pub mod interaction_result; pub mod interaction_result;
pub mod ui; pub mod ui;

View file

@ -36,22 +36,23 @@ impl MainScreen {
vec![Some(1), Some(4)], vec![Some(1), Some(4)],
]; ];
let mut log_card = LogCard::new(); let state = ui.client_state();
let mut log_card = LogCard::new(state.clone());
log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT)); log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT));
let mut console_card = ConsoleCard::new("Console", ""); let mut console_card = ConsoleCard::new("Console", "", ui.ipc());
console_card.set_joins(Borders::TOP); console_card.set_joins(Borders::TOP);
elements.push(Box::new(log_card)); elements.push(Box::new(log_card));
elements.push(Box::new(console_card)); elements.push(Box::new(console_card));
let mut ram_graph = GraphCard::new(ui.clone(), GRAPHS::Ram, "RAM".into()); let mut ram_graph = GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into());
ram_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT)); ram_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
elements.push(Box::new(ram_graph)); elements.push(Box::new(ram_graph));
let mut cpu_graph = GraphCard::new(ui.clone(), GRAPHS::Cpu, "CPU".into()); let mut cpu_graph = GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into());
cpu_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT)); cpu_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
cpu_graph.set_joins(Borders::TOP); cpu_graph.set_joins(Borders::TOP);
elements.push(Box::new(cpu_graph)); elements.push(Box::new(cpu_graph));
let mut ping_graph = GraphCard::new(ui.clone(), GRAPHS::Ping, "Ping".into()); let mut ping_graph = GraphCard::new(ui.clone(), state, GRAPHS::Ping, "Ping".into());
ping_graph.set_joins(Borders::TOP); ping_graph.set_joins(Borders::TOP);
elements.push(Box::new(ping_graph)); elements.push(Box::new(ping_graph));

View file

@ -1,15 +1,17 @@
use crate::{ use crate::{
input_handler::setup_input_handler, interaction_result::InteractionResult, input_handler::setup_input_handler, interaction_result::InteractionResult,
screens::screens::Screen, ipc_client::IpcClient, screens::screens::Screen,
}; };
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use iota_state::{ACTIVE_TASKS, SHUTDOWN, UNIQUE};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use ratatui::{Terminal, backend::CrosstermBackend, init}; use ratatui::{Terminal, backend::CrosstermBackend, init};
use std::{ use std::{
collections::VecDeque, collections::VecDeque,
io::Stdout, io::Stdout,
sync::{Arc, Mutex, atomic::Ordering}, sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::Duration, time::Duration,
}; };
use tokio::{sync::RwLock, time::Instant}; use tokio::{sync::RwLock, time::Instant};
@ -19,14 +21,15 @@ use tokio::{sync::RwLock, time::Instant};
pub static FPS: Lazy<RwLock<(f64, f64)>> = Lazy::new(|| RwLock::new((0.0, 0.0))); pub static FPS: Lazy<RwLock<(f64, f64)>> = Lazy::new(|| RwLock::new((0.0, 0.0)));
pub struct UI { pub struct UI {
ipc: Arc<IpcClient>,
shutdown: AtomicBool,
pub terminal: Arc<Mutex<Terminal<CrosstermBackend<Stdout>>>>, pub terminal: Arc<Mutex<Terminal<CrosstermBackend<Stdout>>>>,
screen_stack: Arc<RwLock<Vec<Box<dyn Screen>>>>, screen_stack: Arc<RwLock<Vec<Box<dyn Screen>>>>,
} }
pub fn start_tui() -> Arc<UI> { pub fn start_tui(ipc: Arc<IpcClient>) -> Arc<UI> {
let ui = Arc::new(UI::new()); let ui = Arc::new(UI::new(ipc));
let uic = ui.clone(); let uic = ui.clone();
ACTIVE_TASKS.insert("UI Renderer".to_string());
tokio::spawn(async move { tokio::spawn(async move {
let mut last_render = Instant::now(); let mut last_render = Instant::now();
@ -39,11 +42,11 @@ pub fn start_tui() -> Arc<UI> {
let mut skipped = 0; let mut skipped = 0;
loop { loop {
if *SHUTDOWN.read().await { if uic.is_shutdown() {
break; break;
} }
if skipped > 5 || UNIQUE.load(Ordering::Relaxed) { if skipped > 5 {
uic.render().await; uic.render().await;
skip_samples.push_back(skipped); skip_samples.push_back(skipped);
@ -88,27 +91,47 @@ pub fn start_tui() -> Arc<UI> {
*FPS.write().await = (avg_fps, avg_skips_percentage); *FPS.write().await = (avg_fps, avg_skips_percentage);
last_render = Instant::now(); last_render = Instant::now();
UNIQUE.store(false, Ordering::Relaxed);
} else { } else {
skipped += 1; skipped += 1;
} }
tokio::time::sleep(Duration::from_millis(16)).await; tokio::time::sleep(Duration::from_millis(16)).await;
} }
ACTIVE_TASKS.remove("UI Renderer");
ratatui::restore(); ratatui::restore();
}); });
setup_input_handler(ui.clone()); setup_input_handler(ui.clone());
ui ui
} }
impl UI { impl UI {
pub fn new() -> Self { pub fn new(ipc: Arc<IpcClient>) -> Self {
let terminal = init(); let terminal = init();
Self { Self {
ipc,
shutdown: AtomicBool::new(false),
terminal: Arc::new(Mutex::new(terminal)), terminal: Arc::new(Mutex::new(terminal)),
screen_stack: Arc::new(RwLock::new(Vec::new())), screen_stack: Arc::new(RwLock::new(Vec::new())),
} }
} }
pub fn ipc(&self) -> Arc<IpcClient> {
self.ipc.clone()
}
pub fn client_state(&self) -> iota_state::ClientState {
self.ipc.state()
}
pub fn is_shutdown(&self) -> bool {
self.shutdown.load(Ordering::Relaxed)
}
pub fn request_shutdown(&self) {
self.shutdown.store(true, Ordering::Relaxed);
}
pub async fn send_restart(&self) -> std::io::Result<()> {
self.ipc.send_command(0, "restart".into()).await
}
pub async fn set_screen(&self, screen: Box<dyn Screen>) { pub async fn set_screen(&self, screen: Box<dyn Screen>) {
self.screen_stack.write().await.push(screen); self.screen_stack.write().await.push(screen);
} }
@ -140,7 +163,7 @@ impl UI {
stack.pop(); stack.pop();
if stack.is_empty() { if stack.is_empty() {
*SHUTDOWN.write().await = true; self.request_shutdown();
} }
} }
InteractionResult::Handled => {} InteractionResult::Handled => {}

View file

@ -6,7 +6,7 @@ use tokio::time::{Duration, sleep};
use iota_state::{ACTIVE_TASKS, APP_STATE, AppState, RELOAD, SHUTDOWN}; use iota_state::{ACTIVE_TASKS, APP_STATE, AppState, RELOAD, SHUTDOWN};
use iota_cli::screens::main_screen::MainScreen; use iota_cli::screens::main_screen::MainScreen;
use iota_cli::ui::start_tui; use iota_cli::{ipc_client::IpcClient, ui::start_tui};
use iota_logger::{self as logger, language_creator}; use iota_logger::{self as logger, language_creator};
use iota_logger::{log, log_t}; use iota_logger::{log, log_t};
use iota_storage::users::user_manager; use iota_storage::users::user_manager;
@ -22,7 +22,10 @@ async fn main() {
*RELOAD.write().await = false; *RELOAD.write().await = false;
*SHUTDOWN.write().await = false; *SHUTDOWN.write().await = false;
let ui = start_tui(); let ipc = IpcClient::connect("/run/iota/iota.sock")
.await
.expect("iota-daemon must be running before starting iota-core");
let ui = start_tui(ipc);
let (eula, tos_pp) = match consent_state::check(ui.clone()).await { let (eula, tos_pp) = match consent_state::check(ui.clone()).await {
Ok(v) => v, Ok(v) => v,

View file

@ -0,0 +1,15 @@
[package]
name = "iota-daemon-lib"
version = "0.1.0"
edition = "2024"
[dependencies]
iota-ipc = { path = "../iota-ipc" }
iota-logger = { path = "../iota-logger" }
iota-state = { path = "../iota-state" }
iota-storage = { path = "../iota-storage" }
iota-util = { path = "../iota-util" }
omikron-connector = { path = "../omikron-connector" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
sysinfo = "0.38.3"
tokio = { version = "1.50.0", features = ["full"] }

View file

@ -0,0 +1,114 @@
use crate::DaemonRuntime;
use iota_ipc::DaemonMessage;
use iota_logger::{log, log_command};
use iota_storage::users::user_manager;
use iota_storage::util::config_util::modify_config;
use mtp::codec::{CommunicationType, CommunicationValue};
use omikron_connector::omikron_connection::OMIKRON_CONNECTION;
use std::sync::Arc;
use std::time::Duration;
#[derive(Clone)]
pub struct CommandRouter {
runtime: Arc<DaemonRuntime>,
}
impl CommandRouter {
pub fn new(runtime: Arc<DaemonRuntime>) -> Self {
Self { runtime }
}
pub async fn route(&self, seq: u64, line: String) -> DaemonMessage {
log_command!("{}", line);
let result = self.execute(&line).await;
DaemonMessage::CommandResult {
seq,
success: result.is_ok(),
message: result.unwrap_or_else(|error| error),
}
}
async fn execute(&self, line: &str) -> Result<String, String> {
let parts = line
.trim_start_matches('/')
.split_whitespace()
.collect::<Vec<_>>();
match parts.as_slice() {
["tasks"] => Ok(self
.runtime
.state
.active_tasks
.iter()
.map(|task| task.to_string())
.collect::<Vec<_>>()
.join(", ")),
["help"] => Ok(
"Available commands: tasks, ping, user, reconnect, regenerate, reload, shutdown"
.into(),
),
["ping"] => self.ping(20).await,
["ping", seconds] => self.ping(seconds.parse::<u64>().unwrap_or(20)).await,
["user", "add", username] => {
let (user, _) = omikron_connector::user_ops::create_user(username).await;
user.map(|user| format!("Created user {}", user.user_id))
.ok_or_else(|| "User creation failed".into())
}
["user", "remove", username] => {
let user = user_manager::get_user_by_username(username)
.ok_or_else(|| "Username does not exist".to_string())?;
let message = CommunicationValue::new(CommunicationType::DeleteUser)
.with_sender(user.user_id as u64);
OMIKRON_CONNECTION
.send_message(&message)
.await
.map_err(|error| error.to_string())?;
user_manager::remove_user(user.user_id);
Ok(format!("Removed user {}", user.user_id))
}
["user", "list"] => Ok(user_manager::get_users()
.into_iter()
.map(|user| format!("{} ({})", user.username, user.user_id))
.collect::<Vec<_>>()
.join("\n")),
["reconnect"] => {
OMIKRON_CONNECTION.reconnect().await;
Ok("Reconnected to Omikron server".into())
}
["regenerate", "keys"] => {
modify_config(|config| {
config.public_key = None;
config.private_key = None;
config.iota_id = None;
});
OMIKRON_CONNECTION.reconnect().await;
Ok("Key pair regenerated and Omikron reconnection requested".into())
}
["reload"] | ["restart"] => {
*self.runtime.state.reload.write().await = true;
*self.runtime.state.shutdown.write().await = true;
Ok("Daemon restart requested".into())
}
["shutdown"] | ["stop"] => {
*self.runtime.state.shutdown.write().await = true;
Ok("Daemon shutdown requested".into())
}
_ => Err("Unknown command".into()),
}
}
async fn ping(&self, seconds: u64) -> Result<String, String> {
let response = OMIKRON_CONNECTION
.await_response(
&CommunicationValue::new(CommunicationType::Ping),
Some(Duration::from_secs(seconds)),
)
.await;
match response {
Ok(value) => {
log!("{}", iota_logger::format_cv(&value));
Ok("Ping response received".into())
}
Err(error) => Err(format!("Ping error: {error:?}")),
}
}
}

View file

@ -0,0 +1,72 @@
use iota_ipc::StateSnapshot;
use iota_state::DaemonState;
use std::sync::Arc;
use std::time::Duration;
use sysinfo::{RefreshKind, System};
/* This wrapper exposes daemon state as IPC-safe snapshots while preserving a
* single owned state instance for all daemon subsystems. */
#[derive(Clone, Default)]
pub struct DaemonRuntime {
pub state: Arc<DaemonState>,
}
impl DaemonRuntime {
pub fn new() -> Self {
Self {
state: Arc::new(DaemonState::new()),
}
}
pub fn snapshot(&self) -> StateSnapshot {
let state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
StateSnapshot {
cpu: state.cpu.clone(),
ram: state.ram.clone(),
ping: state.ping.clone(),
net_up: state.net_up.clone(),
net_down: state.net_down.clone(),
sys_info: state.sys_info.clone(),
}
}
pub fn spawn_system_monitor(&self) {
let runtime = self.clone();
tokio::spawn(async move {
runtime.state.active_tasks.insert("System monitor".into());
let mut system = System::new_with_specifics(RefreshKind::everything());
let mut counter = 0.0;
loop {
if *runtime.state.shutdown.read().await {
break;
}
system.refresh_cpu_all();
system.refresh_memory();
let cpu = system.global_cpu_usage() as f64;
let total_memory = system.total_memory();
let ram = if total_memory == 0 {
0.0
} else {
system.used_memory() as f64 / total_memory as f64 * 100.0
};
{
let mut state = runtime
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.push_cpu((counter, cpu));
state.push_ram((counter, ram));
state.sys_info = format!("CPU: {cpu:.1}% RAM: {ram:.1}%");
}
counter += 1.0;
tokio::time::sleep(Duration::from_millis(500)).await;
}
runtime.state.active_tasks.remove("System monitor");
});
}
}

View file

@ -0,0 +1,113 @@
use crate::{CommandRouter, DaemonRuntime};
use iota_ipc::{ClientMessage, DaemonMessage, read_msg, write_msg};
use std::io::Result;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::{env, os::fd::FromRawFd};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::broadcast;
pub struct IpcServer {
path: PathBuf,
runtime: Arc<DaemonRuntime>,
messages: broadcast::Sender<DaemonMessage>,
}
impl IpcServer {
pub fn new(
path: impl Into<PathBuf>,
runtime: Arc<DaemonRuntime>,
messages: broadcast::Sender<DaemonMessage>,
) -> Self {
Self {
path: path.into(),
runtime,
messages,
}
}
pub async fn run(self) -> Result<()> {
if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let listener = match activated_listener()? {
Some(listener) => listener,
None => {
remove_stale_socket(&self.path).await?;
UnixListener::bind(&self.path)?
}
};
loop {
let (stream, _) = listener.accept().await?;
let runtime = self.runtime.clone();
let messages = self.messages.clone();
tokio::spawn(async move {
let _ = handle_client(stream, runtime, messages).await;
});
}
}
}
/* systemd hands the first socket-activated file descriptor to the service as
* descriptor 3. Manual launches continue to bind the configured socket path. */
fn activated_listener() -> Result<Option<UnixListener>> {
let listen_fds = env::var("LISTEN_FDS")
.ok()
.and_then(|value| value.parse::<u32>().ok());
let listen_pid = env::var("LISTEN_PID")
.ok()
.and_then(|value| value.parse::<u32>().ok());
if listen_fds != Some(1) || listen_pid != Some(std::process::id()) {
return Ok(None);
}
let listener = unsafe { std::os::unix::net::UnixListener::from_raw_fd(3) };
UnixListener::from_std(listener).map(Some)
}
async fn remove_stale_socket(path: &Path) -> Result<()> {
match tokio::fs::symlink_metadata(path).await {
Ok(_) => tokio::fs::remove_file(path).await,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
async fn handle_client(
stream: UnixStream,
runtime: Arc<DaemonRuntime>,
messages: broadcast::Sender<DaemonMessage>,
) -> Result<()> {
let (mut reader, mut writer) = stream.into_split();
let mut outgoing = messages.subscribe();
let initial = DaemonMessage::StateUpdate(runtime.snapshot());
write_msg(&mut writer, &initial).await?;
let writer_task = tokio::spawn(async move {
while let Ok(message) = outgoing.recv().await {
if write_msg(&mut writer, &message).await.is_err() {
break;
}
}
});
let router = CommandRouter::new(runtime.clone());
loop {
match read_msg::<_, ClientMessage>(&mut reader).await {
Ok(ClientMessage::Command { seq, line }) => {
let result = router.route(seq, line).await;
let _ = messages.send(result);
}
Ok(ClientMessage::Subscribe) => {
let _ = messages.send(DaemonMessage::StateUpdate(runtime.snapshot()));
}
Ok(ClientMessage::Ping { seq }) => {
let _ = messages.send(DaemonMessage::Pong { seq });
}
Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(error) => {
writer_task.abort();
return Err(error);
}
}
}
writer_task.abort();
Ok(())
}

View file

@ -0,0 +1,8 @@
pub mod command_router;
pub mod daemon_state;
pub mod ipc_server;
pub mod log_broadcaster;
pub use command_router::CommandRouter;
pub use daemon_state::DaemonRuntime;
pub use ipc_server::IpcServer;

View file

@ -0,0 +1,21 @@
use iota_ipc::{DaemonMessage, LogEntry};
use iota_logger::subscribe;
use tokio::sync::broadcast;
/* The daemon adapts logger output to the wire protocol so the logger stays
* independent from both the socket implementation and TUI state. */
pub fn spawn(message_tx: broadcast::Sender<DaemonMessage>) {
let Some(mut logs) = subscribe() else {
return;
};
tokio::spawn(async move {
while let Ok(entry) = logs.recv().await {
let _ = message_tx.send(DaemonMessage::LogEntry(LogEntry {
timestamp_ms: entry.timestamp_ms,
sender: entry.sender,
message: entry.message,
is_error: entry.is_error,
}));
}
});
}

14
iota-daemon/Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "iota-daemon"
version = "0.1.0"
edition = "2024"
[dependencies]
iota-daemon-lib = { path = "../iota-daemon-lib" }
iota-ipc = { path = "../iota-ipc" }
iota-logger = { path = "../iota-logger" }
iota-state = { path = "../iota-state" }
iota-storage = { path = "../iota-storage" }
omikron-connector = { path = "../omikron-connector" }
web-server = { path = "../web-server" }
tokio = { version = "1.50.0", features = ["full"] }

58
iota-daemon/src/main.rs Normal file
View file

@ -0,0 +1,58 @@
use iota_daemon_lib::{DaemonRuntime, IpcServer, log_broadcaster};
use iota_logger::{self as logger, log, log_t};
use iota_storage::users::user_manager;
use iota_storage::util::config_util::CONFIG;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::broadcast;
fn socket_path() -> PathBuf {
std::env::var_os("IOTA_SOCKET")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/run/iota/iota.sock"))
}
#[tokio::main(flavor = "multi_thread")]
async fn main() {
logger::startup();
iota_storage::util::config_util::load_config();
if user_manager::load_users().await.is_err() {
log_t!("user_load_failed");
}
let runtime = Arc::new(DaemonRuntime::new());
runtime.spawn_system_monitor();
let (messages, _) = broadcast::channel(512);
log_broadcaster::spawn(messages.clone());
let state_updates = runtime.clone();
let state_messages = messages.clone();
tokio::spawn(async move {
loop {
if *state_updates.state.shutdown.read().await {
break;
}
let _ = state_messages.send(iota_ipc::DaemonMessage::StateUpdate(
state_updates.snapshot(),
));
tokio::time::sleep(Duration::from_millis(500)).await;
}
});
let port = CONFIG.load().port;
if !web_server::start(port).await {
log!("Failed to start the MTP web server on port {}", port);
}
let _ = omikron_connector::omikron_connection::get_omikron_connection().await;
let server = IpcServer::new(socket_path(), runtime.clone(), messages);
tokio::spawn(async move {
if let Err(error) = server.run().await {
eprintln!("iota-daemon IPC server failed: {error}");
}
});
log!("iota-daemon started");
while !*runtime.state.shutdown.read().await {
tokio::time::sleep(Duration::from_millis(250)).await;
}
log!("iota-daemon stopping");
}

9
iota-ipc/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "iota-ipc"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1.53.1", features = ["io-util", "macros", "rt"] }

5
iota-ipc/src/lib.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod protocol;
pub mod transport;
pub use protocol::{ClientMessage, DaemonMessage, LogEntry, StateSnapshot};
pub use transport::{read_msg, write_msg};

42
iota-ipc/src/protocol.rs Normal file
View file

@ -0,0 +1,42 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum ClientMessage {
Command { seq: u64, line: String },
Subscribe,
Ping { seq: u64 },
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum DaemonMessage {
LogEntry(LogEntry),
StateUpdate(StateSnapshot),
CommandResult {
seq: u64,
success: bool,
message: String,
},
Pong {
seq: u64,
},
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct LogEntry {
pub timestamp_ms: u128,
pub sender: String,
pub message: String,
pub is_error: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct StateSnapshot {
pub cpu: Vec<(f64, f64)>,
pub ram: Vec<(f64, f64)>,
pub ping: Vec<(f64, f64)>,
pub net_up: Vec<(f64, f64)>,
pub net_down: Vec<(f64, f64)>,
pub sys_info: String,
}

59
iota-ipc/src/transport.rs Normal file
View file

@ -0,0 +1,59 @@
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::io::{Error, ErrorKind, Result};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
const MAX_MESSAGE_SIZE: usize = 1024 * 1024;
/* Length-prefixing preserves message boundaries on a byte stream and bounds
* allocations before JSON is deserialized. */
pub async fn write_msg<W, T>(writer: &mut W, message: &T) -> Result<()>
where
W: AsyncWrite + Unpin,
T: Serialize,
{
let payload =
serde_json::to_vec(message).map_err(|error| Error::new(ErrorKind::InvalidData, error))?;
let len = u32::try_from(payload.len())
.map_err(|_| Error::new(ErrorKind::InvalidData, "IPC message is too large"))?;
writer.write_u32(len).await?;
writer.write_all(&payload).await?;
writer.flush().await
}
pub async fn read_msg<R, T>(reader: &mut R) -> Result<T>
where
R: AsyncRead + Unpin,
T: DeserializeOwned,
{
let len = reader.read_u32().await? as usize;
if len > MAX_MESSAGE_SIZE {
return Err(Error::new(
ErrorKind::InvalidData,
"IPC message exceeds limit",
));
}
let mut payload = vec![0; len];
reader.read_exact(&mut payload).await?;
serde_json::from_slice(&payload).map_err(|error| Error::new(ErrorKind::InvalidData, error))
}
#[cfg(test)]
mod tests {
use super::{read_msg, write_msg};
use crate::protocol::ClientMessage;
#[tokio::test]
async fn round_trips_framed_messages() {
let (mut writer, mut reader) = tokio::io::duplex(1024);
let message = ClientMessage::Command {
seq: 4,
line: "help".into(),
};
write_msg(&mut writer, &message)
.await
.expect("write succeeds");
let received: ClientMessage = read_msg(&mut reader).await.expect("read succeeds");
assert!(matches!(received, ClientMessage::Command { seq: 4, line } if line == "help"));
}
}

View file

@ -12,3 +12,4 @@ mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
ratatui = "0.30.0" ratatui = "0.30.0"
json = "0.12.4" json = "0.12.4"
once_cell = "1.21.4" once_cell = "1.21.4"
tokio = { version = "1.50.0", features = ["sync"] }

View file

@ -10,11 +10,13 @@ use std::{
use mtp::codec::{CommunicationValue, DataTypeId, DataValue, Version}; use mtp::codec::{CommunicationValue, DataTypeId, DataValue, Version};
use ratatui::style::Color; use ratatui::style::Color;
use iota_state::{APP_STATE, UNIQUE, UiLogEntry}; use iota_state::{UNIQUE, UiLogEntry};
use tokio::sync::broadcast;
pub mod language_creator; pub mod language_creator;
pub mod language_manager; pub mod language_manager;
static LOGGER: OnceLock<mpsc::Sender<LogMessage>> = OnceLock::new(); static LOGGER: OnceLock<mpsc::Sender<LogMessage>> = OnceLock::new();
static LOG_BROADCASTER: OnceLock<broadcast::Sender<UiLogEntry>> = OnceLock::new();
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
#[allow(unused)] #[allow(unused)]
@ -51,9 +53,15 @@ struct LogMessage {
message: Option<String>, message: Option<String>,
} }
/* The logger owns file persistence while consumers receive rendered entries
* through a process-local broadcast subscription. */
pub fn startup() { pub fn startup() {
let (tx, rx) = mpsc::channel::<LogMessage>(); let (tx, rx) = mpsc::channel::<LogMessage>();
LOGGER.set(tx).expect("Logger already initialized"); if LOGGER.set(tx).is_err() {
return;
}
let (broadcast_tx, _) = broadcast::channel(512);
let _ = LOG_BROADCASTER.set(broadcast_tx.clone());
thread::spawn(move || { thread::spawn(move || {
let working_dir = iota_util::file_util::get_directory(); let working_dir = iota_util::file_util::get_directory();
@ -106,12 +114,15 @@ pub fn startup() {
is_error: msg.is_error, is_error: msg.is_error,
}; };
let mut state = APP_STATE.lock().unwrap(); let _ = broadcast_tx.send(entry);
state.push_log(entry);
} }
}); });
} }
pub fn subscribe() -> Option<broadcast::Receiver<UiLogEntry>> {
LOG_BROADCASTER.get().map(broadcast::Sender::subscribe)
}
fn format_timestamp_inline(timestamp_ms: u128) -> String { fn format_timestamp_inline(timestamp_ms: u128) -> String {
let secs = (timestamp_ms / 1000) as i64; let secs = (timestamp_ms / 1000) as i64;
let hours = (secs / 3600) % 24; let hours = (secs / 3600) % 24;

View file

@ -3,6 +3,10 @@ name = "iota-state"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[features]
default = ["legacy-globals"]
legacy-globals = []
[dependencies] [dependencies]
dashmap = "6.1.0" dashmap = "6.1.0"
once_cell = "1.21.3" once_cell = "1.21.3"
@ -10,3 +14,4 @@ tokio = { version = "1.50.0", features = ["full"] }
json = "*" json = "*"
sysinfo = "0.38.3" sysinfo = "0.38.3"
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
serde = { version = "1", features = ["derive"] }

View file

@ -1,13 +1,67 @@
use dashmap::DashSet; use dashmap::DashSet;
use json::{JsonValue, object}; use json::{JsonValue, object};
#[cfg(feature = "legacy-globals")]
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::{Arc, LazyLock, Mutex, atomic::AtomicBool}; #[cfg(feature = "legacy-globals")]
use std::sync::LazyLock;
use std::sync::{Arc, Mutex, atomic::AtomicBool};
#[cfg(feature = "legacy-globals")]
use std::thread; use std::thread;
#[cfg(feature = "legacy-globals")]
use std::time::Duration; use std::time::Duration;
#[cfg(feature = "legacy-globals")]
use sysinfo::{RefreshKind, System}; use sysinfo::{RefreshKind, System};
use tokio::sync::RwLock; use tokio::sync::RwLock;
/* Process-owned daemon state and TUI-local state must be separate because IPC,
* rather than shared memory, is the boundary between the two binaries. */
#[derive(Clone)]
pub struct DaemonState {
pub app: Arc<Mutex<AppState>>,
pub shutdown: Arc<RwLock<bool>>,
pub reload: Arc<RwLock<bool>>,
pub active_tasks: Arc<DashSet<String>>,
}
impl DaemonState {
pub fn new() -> Self {
Self {
app: Arc::new(Mutex::new(AppState::new())),
shutdown: Arc::new(RwLock::new(false)),
reload: Arc::new(RwLock::new(false)),
active_tasks: Arc::new(DashSet::new()),
}
}
}
impl Default for DaemonState {
fn default() -> Self {
Self::new()
}
}
/* The TUI keeps only the daemon data it renders. This state is never shared
* with the daemon and is populated from daemon IPC messages. */
#[derive(Clone)]
pub struct ClientState {
pub app: Arc<Mutex<AppState>>,
}
impl ClientState {
pub fn new() -> Self {
Self {
app: Arc::new(Mutex::new(AppState::new())),
}
}
}
impl Default for ClientState {
fn default() -> Self {
Self::new()
}
}
pub const MAX_POINTS: usize = 1000; pub const MAX_POINTS: usize = 1000;
pub const MAX_LOGS: usize = 100; pub const MAX_LOGS: usize = 100;
@ -148,13 +202,22 @@ impl AppState {
} }
} }
#[cfg(feature = "legacy-globals")]
#[deprecated(note = "use DaemonState or ClientState")]
pub static APP_STATE: LazyLock<Arc<Mutex<AppState>>> = pub static APP_STATE: LazyLock<Arc<Mutex<AppState>>> =
LazyLock::new(|| Arc::new(Mutex::new(AppState::new()))); LazyLock::new(|| Arc::new(Mutex::new(AppState::new())));
#[cfg(feature = "legacy-globals")]
#[deprecated(note = "use DaemonState")]
pub static SHUTDOWN: Lazy<RwLock<bool>> = Lazy::new(|| RwLock::new(false)); pub static SHUTDOWN: Lazy<RwLock<bool>> = Lazy::new(|| RwLock::new(false));
#[cfg(feature = "legacy-globals")]
#[deprecated(note = "use DaemonState")]
pub static RELOAD: Lazy<RwLock<bool>> = Lazy::new(|| RwLock::new(true)); pub static RELOAD: Lazy<RwLock<bool>> = Lazy::new(|| RwLock::new(true));
#[cfg(feature = "legacy-globals")]
#[deprecated(note = "use DaemonState")]
pub static ACTIVE_TASKS: Lazy<DashSet<String>> = Lazy::new(|| DashSet::new()); pub static ACTIVE_TASKS: Lazy<DashSet<String>> = Lazy::new(|| DashSet::new());
#[cfg(feature = "legacy-globals")]
pub fn setup() { pub fn setup() {
ACTIVE_TASKS.insert("System info loader".to_string()); ACTIVE_TASKS.insert("System info loader".to_string());
tokio::spawn(async move { tokio::spawn(async move {

View file

@ -42,10 +42,12 @@ pub fn decrypt_challenge(encrypted: &str, keyring: &Keyring) -> Result<String, S
String::from_utf8(pt).map_err(|e| format!("utf8 decode error: {}", e)) String::from_utf8(pt).map_err(|e| format!("utf8 decode error: {}", e))
} }
pub fn export(data: &[u8], format: DataFormat) -> String { pub fn export(data: &[u8], format: DataFormat) -> Result<String, String> {
match format { match format {
DataFormat::Raw => String::from_utf8_lossy(data).to_string(), DataFormat::Raw => {
DataFormat::Base64 => STANDARD.encode(data), String::from_utf8(data.to_vec()).map_err(|error| format!("utf8 decode error: {error}"))
DataFormat::Hex => hex::encode(data), }
DataFormat::Base64 => Ok(STANDARD.encode(data)),
DataFormat::Hex => Ok(hex::encode(data)),
} }
} }

8
iota/Cargo.toml Normal file
View file

@ -0,0 +1,8 @@
[package]
name = "iota"
version = "0.1.0"
edition = "2024"
[dependencies]
iota-cli = { path = "../iota-cli" }
tokio = { version = "1.50.0", features = ["full"] }

29
iota/src/main.rs Normal file
View file

@ -0,0 +1,29 @@
use iota_cli::{ipc_client::IpcClient, screens::main_screen::MainScreen, ui::start_tui};
use std::path::PathBuf;
fn socket_path() -> PathBuf {
std::env::var_os("IOTA_SOCKET")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/run/iota/iota.sock"))
}
#[tokio::main(flavor = "multi_thread")]
async fn main() {
let path = socket_path();
let ipc = match IpcClient::connect(&path).await {
Ok(client) => client,
Err(error) => {
eprintln!(
"Cannot connect to iota-daemon at {}: {error}",
path.display()
);
std::process::exit(1);
}
};
let ui = start_tui(ipc);
ui.set_screen(Box::new(MainScreen::new(ui.clone()).await))
.await;
while !ui.is_shutdown() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}

View file

@ -0,0 +1,16 @@
[Unit]
Description=Tensamin Iota daemon
After=network-online.target
Wants=network-online.target
Requires=iota-daemon.socket
[Service]
Type=simple
ExecStart=/usr/bin/iota-daemon
Restart=on-failure
RuntimeDirectory=iota
RuntimeDirectoryMode=0750
Environment=IOTA_SOCKET=/run/iota/iota.sock
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,12 @@
[Unit]
Description=Tensamin Iota daemon IPC socket
[Socket]
ListenStream=/run/iota/iota.sock
SocketMode=0660
SocketUser=iota
SocketGroup=iota
RemoveOnStop=true
[Install]
WantedBy=sockets.target