feat: socle BDD (tâche 1.9 Phase 1-2) + moteur APT (tâche 2 SJ-0→3) + WIP capabilities/auth/Rust
Checkpoint multi-chantiers (arbre vert : tsc 0 erreur, 70 tests, build OK). - tâche 1.9 Phase 1 : schéma socle (machine_state/events/reports/raw_artifacts/ hardware/metrics + colonnes étendues) + wiring refresh/execute. Migration 0002. - tâche 1.9 Phase 2 : machine_credentials + machine_host_keys (non destructif, dual-read + backfill). Migration 0003. Fix séquence journal de migration. - tâche 2 : SJ-0 (types étendus rétro-compatibles, réducteur Docker, resolveTemplate), SJ-1 (update-analyze enrichi), SJ-2 (apply + diff dpkg + timeout inactivité SSH), SJ-3 (reboot vérifié boot_id). - WIP parallèle inclus : /api/capabilities, auth/apiTokens/apiClients, system metrics, scaffold app_rust, ajustements frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HttpUrl {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub base_path: String,
|
||||
}
|
||||
|
||||
impl HttpUrl {
|
||||
pub fn parse(server_url: &str) -> Result<Self, String> {
|
||||
let without_scheme = server_url
|
||||
.strip_prefix("http://")
|
||||
.ok_or_else(|| "ce premier client supporte seulement http://".to_string())?;
|
||||
|
||||
let (host_port, base_path) = match without_scheme.split_once('/') {
|
||||
Some((host_port, path)) => (host_port, format!("/{path}")),
|
||||
None => (without_scheme, String::new()),
|
||||
};
|
||||
|
||||
let (host, port) = match host_port.rsplit_once(':') {
|
||||
Some((host, port)) => {
|
||||
let parsed_port = port
|
||||
.parse::<u16>()
|
||||
.map_err(|_| "port serveur invalide".to_string())?;
|
||||
(host.to_string(), parsed_port)
|
||||
}
|
||||
None => (host_port.to_string(), 80),
|
||||
};
|
||||
|
||||
if host.is_empty() {
|
||||
return Err("hôte serveur manquant".to_string());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
host,
|
||||
port,
|
||||
base_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn path(&self, endpoint: &str) -> String {
|
||||
let base = self.base_path.trim_end_matches('/');
|
||||
let endpoint = endpoint.trim_start_matches('/');
|
||||
if base.is_empty() {
|
||||
format!("/{endpoint}")
|
||||
} else {
|
||||
format!("{base}/{endpoint}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ApiClient {
|
||||
server: HttpUrl,
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new(server_url: &str, token: Option<String>) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
server: HttpUrl::parse(server_url)?,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_capabilities(&self) -> Result<String, String> {
|
||||
self.get("/api/capabilities")
|
||||
}
|
||||
|
||||
pub fn get_system_status(&self) -> Result<String, String> {
|
||||
self.get("/api/system/status")
|
||||
}
|
||||
|
||||
pub fn get_system_metrics(&self) -> Result<String, String> {
|
||||
self.get("/api/system/metrics")
|
||||
}
|
||||
|
||||
pub fn get_machines(&self) -> Result<String, String> {
|
||||
self.get("/api/machines")
|
||||
}
|
||||
|
||||
fn get(&self, endpoint: &str) -> Result<String, String> {
|
||||
let path = self.server.path(endpoint);
|
||||
let mut request = format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: {}\r\nUser-Agent: system-update-gnome/0.1\r\nAccept: application/json\r\nConnection: close\r\n",
|
||||
self.server.host
|
||||
);
|
||||
|
||||
if let Some(token) = &self.token {
|
||||
request.push_str(&format!("Authorization: Bearer {token}\r\n"));
|
||||
}
|
||||
request.push_str("\r\n");
|
||||
|
||||
let mut stream = TcpStream::connect((&*self.server.host, self.server.port))
|
||||
.map_err(|err| format!("connexion serveur échouée: {err}"))?;
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.map_err(|err| format!("envoi requête échoué: {err}"))?;
|
||||
|
||||
let mut response = String::new();
|
||||
stream
|
||||
.read_to_string(&mut response)
|
||||
.map_err(|err| format!("lecture réponse échouée: {err}"))?;
|
||||
|
||||
split_http_response(&response)
|
||||
}
|
||||
}
|
||||
|
||||
fn split_http_response(response: &str) -> Result<String, String> {
|
||||
let (headers, body) = response
|
||||
.split_once("\r\n\r\n")
|
||||
.ok_or_else(|| "réponse HTTP invalide".to_string())?;
|
||||
let status_line = headers.lines().next().unwrap_or_default();
|
||||
if !status_line.contains(" 200 ") {
|
||||
return Err(format!("réponse serveur inattendue: {status_line}"));
|
||||
}
|
||||
Ok(body.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_default_http_port() {
|
||||
let url = HttpUrl::parse("http://localhost").expect("url");
|
||||
assert_eq!(url.host, "localhost");
|
||||
assert_eq!(url.port, 80);
|
||||
assert_eq!(url.path("/api/capabilities"), "/api/capabilities");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_explicit_http_port_and_base_path() {
|
||||
let url = HttpUrl::parse("http://10.0.0.80:8787/system-update").expect("url");
|
||||
assert_eq!(url.host, "10.0.0.80");
|
||||
assert_eq!(url.port, 8787);
|
||||
assert_eq!(
|
||||
url.path("/api/capabilities"),
|
||||
"/system-update/api/capabilities"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_https_until_tls_client_is_added() {
|
||||
assert!(HttpUrl::parse("https://10.0.0.80:8787").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_success_body() {
|
||||
let body = split_http_response(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"ok\":true}",
|
||||
)
|
||||
.expect("body");
|
||||
assert_eq!(body, "{\"ok\":true}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use crate::token_store::TokenSource;
|
||||
use std::env;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AppConfig {
|
||||
pub server_url: String,
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn from_args(args: &[String]) -> Result<(Self, Command), String> {
|
||||
let mut server_url = env::var("SYSTEM_UPDATE_SERVER")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:8787".to_string());
|
||||
let mut token = TokenSource::from_env().load();
|
||||
let mut command = None;
|
||||
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--server" => {
|
||||
i += 1;
|
||||
server_url = args
|
||||
.get(i)
|
||||
.ok_or_else(|| "--server attend une URL".to_string())?
|
||||
.clone();
|
||||
}
|
||||
"--token" => {
|
||||
i += 1;
|
||||
let raw_token = args
|
||||
.get(i)
|
||||
.ok_or_else(|| "--token attend une valeur".to_string())?
|
||||
.clone();
|
||||
token = TokenSource::CliArgument(raw_token).load();
|
||||
}
|
||||
"capabilities" => command = Some(Command::Capabilities),
|
||||
"status" => command = Some(Command::Status),
|
||||
"metrics" => command = Some(Command::Metrics),
|
||||
"machines" => command = Some(Command::Machines),
|
||||
"gui" => command = Some(Command::Gui),
|
||||
"help" | "--help" | "-h" => command = Some(Command::Help),
|
||||
other => return Err(format!("argument inconnu: {other}")),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
validate_server_url(&server_url)?;
|
||||
|
||||
Ok((Self { server_url, token }, command.unwrap_or(Command::Help)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Capabilities,
|
||||
Status,
|
||||
Metrics,
|
||||
Machines,
|
||||
Gui,
|
||||
Help,
|
||||
}
|
||||
|
||||
pub fn validate_server_url(url: &str) -> Result<(), String> {
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("l'URL serveur doit commencer par http:// ou https://".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_http_server_url() {
|
||||
assert!(validate_server_url("http://127.0.0.1:8787").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_scheme() {
|
||||
assert!(validate_server_url("127.0.0.1:8787").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_capabilities_command() {
|
||||
let args = vec![
|
||||
"system-update-gnome".to_string(),
|
||||
"--server".to_string(),
|
||||
"http://10.0.0.80:8787".to_string(),
|
||||
"capabilities".to_string(),
|
||||
];
|
||||
|
||||
let (config, command) = AppConfig::from_args(&args).expect("config");
|
||||
|
||||
assert_eq!(config.server_url, "http://10.0.0.80:8787");
|
||||
assert_eq!(command, Command::Capabilities);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_status_command() {
|
||||
let args = vec!["system-update-gnome".to_string(), "status".to_string()];
|
||||
let (_, command) = AppConfig::from_args(&args).expect("config");
|
||||
|
||||
assert_eq!(command, Command::Status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_gui_command() {
|
||||
let args = vec!["system-update-gnome".to_string(), "gui".to_string()];
|
||||
let (_, command) = AppConfig::from_args(&args).expect("config");
|
||||
|
||||
assert_eq!(command, Command::Gui);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_machines_command() {
|
||||
let args = vec!["system-update-gnome".to_string(), "machines".to_string()];
|
||||
let (_, command) = AppConfig::from_args(&args).expect("config");
|
||||
|
||||
assert_eq!(command, Command::Machines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
use crate::api::ApiClient;
|
||||
use crate::config::AppConfig;
|
||||
use adw::prelude::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
const APP_CSS: &str = r#"
|
||||
window { background: #28201b; color: #ead9b8; }
|
||||
.su-header { background: #241b17; border-bottom: 1px solid #4a3a2f; }
|
||||
.su-root { background: #28201b; }
|
||||
.su-sidebar {
|
||||
background: #30261f;
|
||||
border-right: 1px solid #5a4738;
|
||||
padding: 12px;
|
||||
}
|
||||
.su-terminal-pane {
|
||||
background: #181b1d;
|
||||
border-left: 1px solid #4a3a2f;
|
||||
}
|
||||
.su-terminal-head {
|
||||
background: #241b17;
|
||||
color: #bdae93;
|
||||
border-bottom: 1px solid #3a2c24;
|
||||
padding: 8px 10px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
.su-terminal-output {
|
||||
background: #181b1d;
|
||||
color: #f6e3b4;
|
||||
padding: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
.su-center { padding: 16px; }
|
||||
.su-title { font-size: 20px; font-weight: 700; color: #f1d8aa; }
|
||||
.su-muted { color: #bdae93; font-size: 12px; }
|
||||
.su-label {
|
||||
color: #bdae93;
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.su-card {
|
||||
background: #30261f;
|
||||
border: 1px solid #6a5544;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 6px 14px rgba(0,0,0,.22);
|
||||
}
|
||||
.su-dot-ok { background: #6ad13f; border-radius: 999px; min-width: 10px; min-height: 10px; }
|
||||
.su-dot-unknown { background: #928374; border-radius: 999px; min-width: 10px; min-height: 10px; }
|
||||
.su-machine-name { font-weight: 700; font-size: 15px; color: #f1e0bc; }
|
||||
.su-mono { font-family: monospace; color: #bdae93; font-size: 12px; }
|
||||
.su-taskbar {
|
||||
background: #241b17;
|
||||
border-top: 1px solid #5a4738;
|
||||
min-height: 28px;
|
||||
}
|
||||
.su-task-cell {
|
||||
padding: 5px 12px;
|
||||
border-right: 1px solid #4a3a2f;
|
||||
color: #bdae93;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
.su-task-mode { background: #d79921; color: #28201b; font-weight: 700; }
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Machine {
|
||||
name: String,
|
||||
hostname: String,
|
||||
port: u16,
|
||||
os_family: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
pub fn run(config: AppConfig) {
|
||||
let app = adw::Application::builder()
|
||||
.application_id("local.system-update.gnome")
|
||||
.build();
|
||||
|
||||
app.connect_activate(move |app| {
|
||||
build_window(app, config.clone());
|
||||
});
|
||||
|
||||
app.run_with_args::<&str>(&[]);
|
||||
}
|
||||
|
||||
fn build_window(app: &adw::Application, config: AppConfig) {
|
||||
install_css();
|
||||
|
||||
let server_entry = gtk::Entry::builder()
|
||||
.text(&config.server_url)
|
||||
.hexpand(true)
|
||||
.placeholder_text("http://10.0.1.137:8787")
|
||||
.build();
|
||||
|
||||
let terminal = gtk::TextView::builder()
|
||||
.editable(false)
|
||||
.monospace(true)
|
||||
.vexpand(true)
|
||||
.hexpand(true)
|
||||
.css_classes(["su-terminal-output"])
|
||||
.build();
|
||||
terminal.buffer().set_text(
|
||||
"Terminal API prêt.\nLes retours capabilities/status/metrics/machines apparaissent ici.",
|
||||
);
|
||||
|
||||
let machines_flow = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.column_spacing(12)
|
||||
.row_spacing(12)
|
||||
.max_children_per_line(3)
|
||||
.min_children_per_line(1)
|
||||
.homogeneous(false)
|
||||
.build();
|
||||
|
||||
let task_status = gtk::Label::new(Some("server 10.0.1.137:8787"));
|
||||
task_status.add_css_class("su-task-cell");
|
||||
let task_metrics = gtk::Label::new(Some("metrics --"));
|
||||
task_metrics.add_css_class("su-task-cell");
|
||||
|
||||
let add_button = gtk::Button::with_label("+ Ajouter");
|
||||
let refresh_button = gtk::Button::with_label("Refresh");
|
||||
let capabilities = gtk::Button::with_label("Capabilities");
|
||||
let status = gtk::Button::with_label("Status");
|
||||
let metrics = gtk::Button::with_label("Metrics");
|
||||
|
||||
let header = adw::HeaderBar::builder()
|
||||
.title_widget(>k::Label::new(Some("System Update")))
|
||||
.css_classes(["su-header"])
|
||||
.build();
|
||||
|
||||
let left = build_hermes_panel();
|
||||
let center = build_center_panel(&machines_flow, &refresh_button, &add_button);
|
||||
let right = build_terminal_panel(&terminal, &server_entry, &capabilities, &status, &metrics);
|
||||
let taskbar = build_taskbar(&task_status, &task_metrics);
|
||||
|
||||
let body = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
body.append(&left);
|
||||
body.append(¢er);
|
||||
body.append(&right);
|
||||
|
||||
let root = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.css_classes(["su-root"])
|
||||
.build();
|
||||
root.append(&header);
|
||||
root.append(&body);
|
||||
root.append(&taskbar);
|
||||
|
||||
let window = adw::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title("System Update")
|
||||
.default_width(1480)
|
||||
.default_height(760)
|
||||
.content(&root)
|
||||
.build();
|
||||
|
||||
wire_machine_refresh(
|
||||
&refresh_button,
|
||||
&server_entry,
|
||||
config.token.clone(),
|
||||
&terminal,
|
||||
&machines_flow,
|
||||
&task_status,
|
||||
);
|
||||
connect_action(
|
||||
&capabilities,
|
||||
&server_entry,
|
||||
config.token.clone(),
|
||||
&terminal,
|
||||
&task_status,
|
||||
Action::Capabilities,
|
||||
);
|
||||
connect_action(
|
||||
&status,
|
||||
&server_entry,
|
||||
config.token.clone(),
|
||||
&terminal,
|
||||
&task_status,
|
||||
Action::Status,
|
||||
);
|
||||
connect_action(
|
||||
&metrics,
|
||||
&server_entry,
|
||||
config.token,
|
||||
&terminal,
|
||||
&task_status,
|
||||
Action::Metrics,
|
||||
);
|
||||
|
||||
load_machines(&server_entry, None, &terminal, &machines_flow, &task_status);
|
||||
|
||||
window.present();
|
||||
}
|
||||
|
||||
fn install_css() {
|
||||
let provider = gtk::CssProvider::new();
|
||||
provider.load_from_data(APP_CSS);
|
||||
if let Some(display) = gtk::gdk::Display::default() {
|
||||
gtk::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&provider,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_hermes_panel() -> gtk::Box {
|
||||
let panel = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.width_request(225)
|
||||
.css_classes(["su-sidebar"])
|
||||
.spacing(12)
|
||||
.build();
|
||||
|
||||
let title = gtk::Label::new(Some("HERMES"));
|
||||
title.set_xalign(0.0);
|
||||
title.add_css_class("su-label");
|
||||
let text = gtk::Label::new(Some(
|
||||
"Copilote d'exploitation -- à venir.\nAnalyse des mises à jour, plans et rapports seront disponibles ici.",
|
||||
));
|
||||
text.set_wrap(true);
|
||||
text.set_xalign(0.0);
|
||||
text.add_css_class("su-muted");
|
||||
|
||||
panel.append(&title);
|
||||
panel.append(&text);
|
||||
panel
|
||||
}
|
||||
|
||||
fn build_center_panel(
|
||||
machines_flow: >k::FlowBox,
|
||||
refresh_button: >k::Button,
|
||||
add_button: >k::Button,
|
||||
) -> gtk::Box {
|
||||
let center = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.spacing(14)
|
||||
.css_classes(["su-center"])
|
||||
.build();
|
||||
|
||||
let title = gtk::Label::new(Some("Machines"));
|
||||
title.set_xalign(0.0);
|
||||
title.add_css_class("su-title");
|
||||
|
||||
let tools = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.build();
|
||||
tools.append(&title);
|
||||
tools.append(>k::Box::builder().hexpand(true).build());
|
||||
tools.append(refresh_button);
|
||||
tools.append(add_button);
|
||||
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.child(machines_flow)
|
||||
.build();
|
||||
|
||||
center.append(&tools);
|
||||
center.append(&scroll);
|
||||
center
|
||||
}
|
||||
|
||||
fn build_terminal_panel(
|
||||
terminal: >k::TextView,
|
||||
server_entry: >k::Entry,
|
||||
capabilities: >k::Button,
|
||||
status: >k::Button,
|
||||
metrics: >k::Button,
|
||||
) -> gtk::Box {
|
||||
let right = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.width_request(365)
|
||||
.css_classes(["su-terminal-pane"])
|
||||
.build();
|
||||
|
||||
let head = gtk::Label::new(Some("TERMINAL API"));
|
||||
head.set_xalign(0.0);
|
||||
head.add_css_class("su-terminal-head");
|
||||
|
||||
let controls = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.build();
|
||||
controls.append(server_entry);
|
||||
|
||||
let buttons = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(6)
|
||||
.build();
|
||||
buttons.append(capabilities);
|
||||
buttons.append(status);
|
||||
buttons.append(metrics);
|
||||
controls.append(&buttons);
|
||||
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.child(terminal)
|
||||
.build();
|
||||
|
||||
right.append(&head);
|
||||
right.append(&controls);
|
||||
right.append(&scroll);
|
||||
right
|
||||
}
|
||||
|
||||
fn build_taskbar(task_status: >k::Label, task_metrics: >k::Label) -> gtk::Box {
|
||||
let taskbar = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.css_classes(["su-taskbar"])
|
||||
.build();
|
||||
|
||||
let mode = gtk::Label::new(Some(" SYSTEM UPDATE "));
|
||||
mode.add_css_class("su-task-cell");
|
||||
mode.add_css_class("su-task-mode");
|
||||
let scope = gtk::Label::new(Some("machines"));
|
||||
scope.add_css_class("su-task-cell");
|
||||
let spacer = gtk::Box::builder().hexpand(true).build();
|
||||
|
||||
taskbar.append(&mode);
|
||||
taskbar.append(&scope);
|
||||
taskbar.append(task_status);
|
||||
taskbar.append(&spacer);
|
||||
taskbar.append(task_metrics);
|
||||
taskbar
|
||||
}
|
||||
|
||||
fn wire_machine_refresh(
|
||||
button: >k::Button,
|
||||
server_entry: >k::Entry,
|
||||
token: Option<String>,
|
||||
terminal: >k::TextView,
|
||||
machines_flow: >k::FlowBox,
|
||||
task_status: >k::Label,
|
||||
) {
|
||||
let server_entry = server_entry.clone();
|
||||
let terminal = terminal.clone();
|
||||
let machines_flow = machines_flow.clone();
|
||||
let task_status = task_status.clone();
|
||||
button.connect_clicked(move |_| {
|
||||
load_machines(
|
||||
&server_entry,
|
||||
token.clone(),
|
||||
&terminal,
|
||||
&machines_flow,
|
||||
&task_status,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn load_machines(
|
||||
server_entry: >k::Entry,
|
||||
token: Option<String>,
|
||||
terminal: >k::TextView,
|
||||
machines_flow: >k::FlowBox,
|
||||
task_status: >k::Label,
|
||||
) {
|
||||
let server_url = server_entry.text().to_string();
|
||||
let raw = ApiClient::new(&server_url, token).and_then(|client| client.get_machines());
|
||||
|
||||
while let Some(child) = machines_flow.first_child() {
|
||||
machines_flow.remove(&child);
|
||||
}
|
||||
|
||||
match raw {
|
||||
Ok(json) => {
|
||||
terminal.buffer().set_text(&json);
|
||||
match serde_json::from_str::<Vec<Machine>>(&json) {
|
||||
Ok(machines) => {
|
||||
for machine in &machines {
|
||||
machines_flow.insert(&machine_card(machine), -1);
|
||||
}
|
||||
task_status.set_text(&format!("{server_url} · {} machines", machines.len()));
|
||||
}
|
||||
Err(err) => {
|
||||
task_status.set_text("machines: JSON invalide");
|
||||
machines_flow
|
||||
.insert(&empty_card(&format!("JSON machines invalide: {err}")), -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
terminal.buffer().set_text(&format!("Erreur: {err}"));
|
||||
task_status.set_text("server erreur");
|
||||
machines_flow.insert(&empty_card(&err), -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn machine_card(machine: &Machine) -> gtk::Box {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.width_request(230)
|
||||
.css_classes(["su-card"])
|
||||
.build();
|
||||
|
||||
let row = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.build();
|
||||
let dot = gtk::Box::builder()
|
||||
.width_request(10)
|
||||
.height_request(10)
|
||||
.css_classes([if machine.status == "unknown" {
|
||||
"su-dot-unknown"
|
||||
} else {
|
||||
"su-dot-ok"
|
||||
}])
|
||||
.build();
|
||||
let name = gtk::Label::new(Some(&machine.name));
|
||||
name.set_xalign(0.0);
|
||||
name.add_css_class("su-machine-name");
|
||||
row.append(&dot);
|
||||
row.append(&name);
|
||||
|
||||
let host = gtk::Label::new(Some(&format!(
|
||||
"{}:{} · {}",
|
||||
machine.hostname, machine.port, machine.os_family
|
||||
)));
|
||||
host.set_xalign(0.0);
|
||||
host.add_css_class("su-mono");
|
||||
|
||||
let updates = gtk::Label::new(Some("UPDATES 0"));
|
||||
updates.set_xalign(0.0);
|
||||
updates.add_css_class("su-label");
|
||||
|
||||
let buttons = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(6)
|
||||
.build();
|
||||
buttons.append(>k::Button::with_label("Refresh"));
|
||||
buttons.append(>k::Button::with_label("Upgrade"));
|
||||
buttons.append(>k::Button::with_label("Reboot"));
|
||||
|
||||
card.append(&row);
|
||||
card.append(&host);
|
||||
card.append(&updates);
|
||||
card.append(&buttons);
|
||||
card
|
||||
}
|
||||
|
||||
fn empty_card(message: &str) -> gtk::Box {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.width_request(300)
|
||||
.css_classes(["su-card"])
|
||||
.build();
|
||||
let title = gtk::Label::new(Some("Aucune machine"));
|
||||
title.set_xalign(0.0);
|
||||
title.add_css_class("su-machine-name");
|
||||
let text = gtk::Label::new(Some(message));
|
||||
text.set_wrap(true);
|
||||
text.set_xalign(0.0);
|
||||
text.add_css_class("su-muted");
|
||||
card.append(&title);
|
||||
card.append(&text);
|
||||
card
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Action {
|
||||
Capabilities,
|
||||
Status,
|
||||
Metrics,
|
||||
}
|
||||
|
||||
fn connect_action(
|
||||
button: >k::Button,
|
||||
server_entry: >k::Entry,
|
||||
token: Option<String>,
|
||||
terminal: >k::TextView,
|
||||
task_status: >k::Label,
|
||||
action: Action,
|
||||
) {
|
||||
let server_entry = server_entry.clone();
|
||||
let terminal = terminal.clone();
|
||||
let task_status = task_status.clone();
|
||||
button.connect_clicked(move |_| {
|
||||
let server_url = server_entry.text().to_string();
|
||||
let body = ApiClient::new(&server_url, token.clone()).and_then(|client| match action {
|
||||
Action::Capabilities => client.get_capabilities(),
|
||||
Action::Status => client.get_system_status(),
|
||||
Action::Metrics => client.get_system_metrics(),
|
||||
});
|
||||
|
||||
match body {
|
||||
Ok(json) => {
|
||||
terminal.buffer().set_text(&json);
|
||||
task_status.set_text(&format!("{server_url} · ok"));
|
||||
}
|
||||
Err(err) => {
|
||||
terminal.buffer().set_text(&format!("Erreur: {err}"));
|
||||
task_status.set_text("server erreur");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
mod api;
|
||||
mod config;
|
||||
#[cfg(feature = "gui")]
|
||||
mod gui;
|
||||
mod token_store;
|
||||
|
||||
use api::ApiClient;
|
||||
use config::{AppConfig, Command};
|
||||
use std::env;
|
||||
use std::process::ExitCode;
|
||||
use token_store::keyring_identity;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
match run(&args) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
eprintln!("erreur: {err}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let (config, command) = AppConfig::from_args(args)?;
|
||||
|
||||
match command {
|
||||
Command::Capabilities => {
|
||||
let client = ApiClient::new(&config.server_url, config.token)?;
|
||||
let body = client.get_capabilities()?;
|
||||
println!("{body}");
|
||||
}
|
||||
Command::Status => {
|
||||
let client = ApiClient::new(&config.server_url, config.token)?;
|
||||
let body = client.get_system_status()?;
|
||||
println!("{body}");
|
||||
}
|
||||
Command::Metrics => {
|
||||
let client = ApiClient::new(&config.server_url, config.token)?;
|
||||
let body = client.get_system_metrics()?;
|
||||
println!("{body}");
|
||||
}
|
||||
Command::Machines => {
|
||||
let client = ApiClient::new(&config.server_url, config.token)?;
|
||||
let body = client.get_machines()?;
|
||||
println!("{body}");
|
||||
}
|
||||
Command::Gui => run_gui(config)?,
|
||||
Command::Help => print_help(),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
fn run_gui(config: AppConfig) -> Result<(), String> {
|
||||
gui::run(config);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "gui"))]
|
||||
fn run_gui(_config: AppConfig) -> Result<(), String> {
|
||||
Err(
|
||||
"l'interface graphique n'est pas compilée. Lance: cargo run --features gui -- gui"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
let (keyring_service, keyring_account) = keyring_identity();
|
||||
println!(
|
||||
"system-update-gnome\n\
|
||||
\n\
|
||||
Usage:\n\
|
||||
system-update-gnome --server http://127.0.0.1:8787 capabilities\n\
|
||||
system-update-gnome --server http://127.0.0.1:8787 status\n\
|
||||
system-update-gnome --server http://127.0.0.1:8787 metrics\n\
|
||||
system-update-gnome --server http://127.0.0.1:8787 machines\n\
|
||||
system-update-gnome --server http://127.0.0.1:8787 gui\n\
|
||||
\n\
|
||||
Variables:\n\
|
||||
SYSTEM_UPDATE_SERVER URL du backend, défaut http://127.0.0.1:8787\n\
|
||||
SYSTEM_UPDATE_TOKEN Token API optionnel\n\
|
||||
\n\
|
||||
Futur trousseau:\n\
|
||||
service: {keyring_service}\n\
|
||||
compte: {keyring_account}\n"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use std::env;
|
||||
|
||||
pub const KEYRING_SERVICE: &str = "system-update";
|
||||
pub const KEYRING_ACCOUNT: &str = "api-token";
|
||||
|
||||
pub fn keyring_identity() -> (&'static str, &'static str) {
|
||||
(KEYRING_SERVICE, KEYRING_ACCOUNT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TokenSource {
|
||||
CliArgument(String),
|
||||
Environment(Option<String>),
|
||||
}
|
||||
|
||||
impl TokenSource {
|
||||
pub fn from_env() -> Self {
|
||||
Self::Environment(env::var("SYSTEM_UPDATE_TOKEN").ok())
|
||||
}
|
||||
|
||||
pub fn load(self) -> Option<String> {
|
||||
match self {
|
||||
Self::CliArgument(token) => clean_token(token),
|
||||
Self::Environment(token) => token.and_then(clean_token),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_token(token: String) -> Option<String> {
|
||||
let trimmed = token.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn trims_cli_token() {
|
||||
assert_eq!(
|
||||
TokenSource::CliArgument(" su_token ".to_string()).load(),
|
||||
Some("su_token".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_empty_token() {
|
||||
assert_eq!(TokenSource::CliArgument(" ".to_string()).load(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_future_keyring_identity() {
|
||||
assert_eq!(keyring_identity(), ("system-update", "api-token"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user