From 08919752e3f1a21161ab98558411335c5b88649c Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Fri, 5 Jun 2026 19:50:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20socle=20BDD=20(t=C3=A2che=201.9=20Phase?= =?UTF-8?q?=201-2)=20+=20moteur=20APT=20(t=C3=A2che=202=20SJ-0=E2=86=923)?= =?UTF-8?q?=20+=20WIP=20capabilities/auth/Rust?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app_rust/system-update-gnome/.gitignore | 1 + app_rust/system-update-gnome/Cargo.lock | 728 ++++++++ app_rust/system-update-gnome/Cargo.toml | 16 + app_rust/system-update-gnome/README.md | 56 + .../system-update-gnome/docs/token-storage.md | 33 + app_rust/system-update-gnome/src/api.rs | 157 ++ app_rust/system-update-gnome/src/config.rs | 122 ++ app_rust/system-update-gnome/src/gui.rs | 517 ++++++ app_rust/system-update-gnome/src/main.rs | 89 + .../system-update-gnome/src/token_store.rs | 59 + client/src/App.tsx | 88 +- client/src/components/ui-kit.tsx | 11 + .../src/features/machines/AddMachineModal.tsx | 7 +- client/src/features/machines/MachineTile.tsx | 203 ++- client/src/lib/api.ts | 26 +- client/src/panels/Dashboard.tsx | 66 +- client/src/panels/SettingsModal.tsx | 259 +++ client/src/panels/TerminalPanel.tsx | 26 +- client/src/styles/app.css | 308 +++- package.json | 3 +- server/auth/apiAuth.test.ts | 18 + server/auth/apiAuth.ts | 34 + server/cli/createApiClient.test.ts | 28 + server/cli/createApiClient.ts | 78 + server/crypto/apiTokens.test.ts | 25 + server/crypto/apiTokens.ts | 24 + server/db/migrate.ts | 3 + server/db/migrations/0001_api_clients.sql | 12 + .../migrations/0002_reflective_lifeguard.sql | 150 ++ .../db/migrations/0003_magical_psylocke.sql | 28 + server/db/migrations/meta/0002_snapshot.json | 1388 +++++++++++++++ server/db/migrations/meta/0003_snapshot.json | 1583 +++++++++++++++++ server/db/migrations/meta/_journal.json | 21 + server/db/schema.test.ts | 59 + server/db/schema.ts | 191 +- server/index.ts | 4 + server/routes/index.ts | 5 + .../__fixtures__/apt-update-analyze.txt | 20 + server/services/apiClients.test.ts | 64 + server/services/apiClients.ts | 118 ++ server/services/aptParse.test.ts | 80 +- server/services/aptParse.ts | 116 +- server/services/capabilities.test.ts | 31 + server/services/capabilities.ts | 38 + server/services/credentials.test.ts | 16 + server/services/credentials.ts | 55 + server/services/execute.ts | 109 +- server/services/machineState.test.ts | 27 + server/services/machineState.ts | 63 + server/services/machines.ts | 23 +- server/services/rebootVerify.test.ts | 27 + server/services/rebootVerify.ts | 94 + server/services/refresh.ts | 47 +- server/services/system.test.ts | 30 + server/services/system.ts | 44 + server/ssh/client.ts | 26 +- server/templates/aptReduce.test.ts | 20 +- server/templates/aptReduce.ts | 19 +- server/templates/render.test.ts | 6 + server/templates/render.ts | 22 +- server/templates/resolveTemplate.test.ts | 20 + shared/types.test.ts | 34 + shared/types.ts | 232 ++- templates/apt/autoremove.sh.tpl | 15 + templates/apt/clean.sh.tpl | 8 + templates/apt/full-upgrade.sh.tpl | 10 +- templates/apt/reboot.sh.tpl | 2 + templates/apt/update-analyze.sh.tpl | 30 + templates/apt/upgrade.sh.tpl | 15 + 69 files changed, 7785 insertions(+), 102 deletions(-) create mode 100644 app_rust/system-update-gnome/.gitignore create mode 100644 app_rust/system-update-gnome/Cargo.lock create mode 100644 app_rust/system-update-gnome/Cargo.toml create mode 100644 app_rust/system-update-gnome/README.md create mode 100644 app_rust/system-update-gnome/docs/token-storage.md create mode 100644 app_rust/system-update-gnome/src/api.rs create mode 100644 app_rust/system-update-gnome/src/config.rs create mode 100644 app_rust/system-update-gnome/src/gui.rs create mode 100644 app_rust/system-update-gnome/src/main.rs create mode 100644 app_rust/system-update-gnome/src/token_store.rs create mode 100644 client/src/panels/SettingsModal.tsx create mode 100644 server/auth/apiAuth.test.ts create mode 100644 server/auth/apiAuth.ts create mode 100644 server/cli/createApiClient.test.ts create mode 100644 server/cli/createApiClient.ts create mode 100644 server/crypto/apiTokens.test.ts create mode 100644 server/crypto/apiTokens.ts create mode 100644 server/db/migrations/0001_api_clients.sql create mode 100644 server/db/migrations/0002_reflective_lifeguard.sql create mode 100644 server/db/migrations/0003_magical_psylocke.sql create mode 100644 server/db/migrations/meta/0002_snapshot.json create mode 100644 server/db/migrations/meta/0003_snapshot.json create mode 100644 server/db/schema.test.ts create mode 100644 server/services/__fixtures__/apt-update-analyze.txt create mode 100644 server/services/apiClients.test.ts create mode 100644 server/services/apiClients.ts create mode 100644 server/services/capabilities.test.ts create mode 100644 server/services/capabilities.ts create mode 100644 server/services/credentials.test.ts create mode 100644 server/services/credentials.ts create mode 100644 server/services/machineState.test.ts create mode 100644 server/services/machineState.ts create mode 100644 server/services/rebootVerify.test.ts create mode 100644 server/services/rebootVerify.ts create mode 100644 server/services/system.test.ts create mode 100644 server/services/system.ts create mode 100644 server/templates/resolveTemplate.test.ts create mode 100644 shared/types.test.ts create mode 100644 templates/apt/autoremove.sh.tpl create mode 100644 templates/apt/clean.sh.tpl create mode 100644 templates/apt/update-analyze.sh.tpl create mode 100644 templates/apt/upgrade.sh.tpl diff --git a/app_rust/system-update-gnome/.gitignore b/app_rust/system-update-gnome/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/app_rust/system-update-gnome/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/app_rust/system-update-gnome/Cargo.lock b/app_rust/system-update-gnome/Cargo.lock new file mode 100644 index 0000000..12f5428 --- /dev/null +++ b/app_rust/system-update-gnome/Cargo.lock @@ -0,0 +1,728 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" + +[[package]] +name = "cairo-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cfg-expr" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gio" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys", +] + +[[package]] +name = "glib" +version = "0.22.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libadwaita" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0da4e27b20d3e71f830e5b0f0188d22c257986bf421c02cfde777fe07932a4" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "libc", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaee067051c5d3c058d050d167688b80b67de1950cfca77730549aa761fc5d7d" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "pango" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "system-update-gnome" +version = "0.1.0" +dependencies = [ + "gtk4", + "libadwaita", + "serde", + "serde_json", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/app_rust/system-update-gnome/Cargo.toml b/app_rust/system-update-gnome/Cargo.toml new file mode 100644 index 0000000..66f4d1a --- /dev/null +++ b/app_rust/system-update-gnome/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "system-update-gnome" +version = "0.1.0" +edition = "2024" +description = "Client local Rust/GNOME pour le backend system_update" +license = "UNLICENSED" + +[dependencies] +adw = { package = "libadwaita", version = "0.9", features = ["v1_6"], optional = true } +gtk = { package = "gtk4", version = "0.11", features = ["v4_6"], optional = true } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[features] +default = [] +gui = ["dep:adw", "dep:gtk"] diff --git a/app_rust/system-update-gnome/README.md b/app_rust/system-update-gnome/README.md new file mode 100644 index 0000000..c9b0659 --- /dev/null +++ b/app_rust/system-update-gnome/README.md @@ -0,0 +1,56 @@ +# system-update-gnome + +Scaffold de l'application locale Rust/GNOME pour `system_update`. + +Ce sous-dossier est volontairement dédié au développement de l'application Rust : + +- backend/webapp : racine du projet, `server/`, `client/`, `shared/` ; +- application Rust/GNOME : `app_rust/system-update-gnome/` ; +- artefacts Cargo : `app_rust/system-update-gnome/target/`, ignoré par Git. + +État actuel : + +- client CLI minimal ; +- aucune dépendance externe pour garder le premier build vérifiable sans accès réseau ; +- test de connexion HTTP vers `GET /api/capabilities` ; +- stratégie token séparée dans `src/token_store.rs` ; +- première UI GTK/libadwaita derrière la feature Cargo `gui`. + +Exemples : + +```bash +cargo run -- --server http://127.0.0.1:8787 capabilities +cargo run -- --server http://127.0.0.1:8787 status +cargo run -- --server http://127.0.0.1:8787 metrics +cargo run -- --server http://127.0.0.1:8787 machines +SYSTEM_UPDATE_SERVER=http://127.0.0.1:8787 cargo run -- capabilities +``` + +Interface graphique : + +```bash +cargo run --features gui -- gui +``` + +Avec un serveur précis : + +```bash +cargo run --features gui -- --server http://10.0.1.137:8787 gui +``` + +Pré-requis pour le futur incrément GTK/libadwaita : + +```bash +sudo apt install libgtk-4-dev libadwaita-1-dev +``` + +Règles : + +- l'application ne fait pas de SSH direct ; +- les credentials machines restent côté backend ; +- les actions passent par l'API du serveur ; +- le token local sera stocké via trousseau système dans un incrément suivant. + +Voir aussi : + +- `docs/token-storage.md`. diff --git a/app_rust/system-update-gnome/docs/token-storage.md b/app_rust/system-update-gnome/docs/token-storage.md new file mode 100644 index 0000000..fcea496 --- /dev/null +++ b/app_rust/system-update-gnome/docs/token-storage.md @@ -0,0 +1,33 @@ +# Stockage du token API + +Objectif : l'application locale ne doit pas stocker le token API en clair dans un fichier de configuration. + +État actuel du scaffold : + +- `--token` : accepté pour test manuel ponctuel ; +- `SYSTEM_UPDATE_TOKEN` : accepté pour développement ; +- trousseau système : prévu, pas encore activé dans `Cargo.toml`. + +Identité prévue dans le trousseau : + +- service : `system-update` ; +- compte : `api-token`. + +Choix technique à finaliser : + +- la documentation actuelle de `keyring` indique que Linux dispose de plusieurs magasins possibles, dont keyutils et Secret Service ; +- pour une app GNOME, Secret Service est le choix naturel ; +- l'écosystème `keyring` 4.x sépare davantage les composants de librairie, donc l'ajout Cargo doit être fait après choix précis entre `keyring-core` + store Secret Service ou une version `keyring` 3.x encore centrée librairie. + +Références : + +- https://docs.rs/keyring/latest/keyring/ +- https://docs.rs/crate/keyring/latest +- https://github.com/open-source-cooperative/keyring-rs/wiki/Keyring-Core + +Règle de sécurité : + +- token jamais loggé ; +- token jamais écrit dans les rapports ; +- token affiché uniquement sous forme de préfixe côté backend ; +- révocation possible côté backend via `api_clients.revoked_at`. diff --git a/app_rust/system-update-gnome/src/api.rs b/app_rust/system-update-gnome/src/api.rs new file mode 100644 index 0000000..4f203a1 --- /dev/null +++ b/app_rust/system-update-gnome/src/api.rs @@ -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 { + 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::() + .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, +} + +impl ApiClient { + pub fn new(server_url: &str, token: Option) -> Result { + Ok(Self { + server: HttpUrl::parse(server_url)?, + token, + }) + } + + pub fn get_capabilities(&self) -> Result { + self.get("/api/capabilities") + } + + pub fn get_system_status(&self) -> Result { + self.get("/api/system/status") + } + + pub fn get_system_metrics(&self) -> Result { + self.get("/api/system/metrics") + } + + pub fn get_machines(&self) -> Result { + self.get("/api/machines") + } + + fn get(&self, endpoint: &str) -> Result { + 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 { + 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}"); + } +} diff --git a/app_rust/system-update-gnome/src/config.rs b/app_rust/system-update-gnome/src/config.rs new file mode 100644 index 0000000..db61a87 --- /dev/null +++ b/app_rust/system-update-gnome/src/config.rs @@ -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, +} + +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); + } +} diff --git a/app_rust/system-update-gnome/src/gui.rs b/app_rust/system-update-gnome/src/gui.rs new file mode 100644 index 0000000..9cea52b --- /dev/null +++ b/app_rust/system-update-gnome/src/gui.rs @@ -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, + 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, + 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::>(&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, + 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"); + } + } + }); +} diff --git a/app_rust/system-update-gnome/src/main.rs b/app_rust/system-update-gnome/src/main.rs new file mode 100644 index 0000000..65c5b61 --- /dev/null +++ b/app_rust/system-update-gnome/src/main.rs @@ -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 = 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" + ); +} diff --git a/app_rust/system-update-gnome/src/token_store.rs b/app_rust/system-update-gnome/src/token_store.rs new file mode 100644 index 0000000..5d47e38 --- /dev/null +++ b/app_rust/system-update-gnome/src/token_store.rs @@ -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), +} + +impl TokenSource { + pub fn from_env() -> Self { + Self::Environment(env::var("SYSTEM_UPDATE_TOKEN").ok()) + } + + pub fn load(self) -> Option { + match self { + Self::CliArgument(token) => clean_token(token), + Self::Environment(token) => token.and_then(clean_token), + } + } +} + +fn clean_token(token: String) -> Option { + 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")); + } +} diff --git a/client/src/App.tsx b/client/src/App.tsx index 267b527..7d01889 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,16 +1,94 @@ // client/src/App.tsx -import { useState } from "react"; +import { useEffect, useState } from "react"; +import type { SystemMetrics } from "@shared/types.js"; +import { api } from "./lib/api.js"; +import type { DashboardSummary } from "./panels/Dashboard.js"; import { HermesPanel } from "./panels/HermesPanel.js"; import { Dashboard } from "./panels/Dashboard.js"; import { TerminalPanel } from "./panels/TerminalPanel.js"; +import { SettingsModal } from "./panels/SettingsModal.js"; +import { applyTheme, getInitialTheme, nextTheme, type Theme } from "./lib/theme.js"; + +const EMPTY_SUMMARY: DashboardSummary = { machines: 0, updates: 0, errors: 0, running: 0 }; export function App() { const [selected, setSelected] = useState(null); + const [summary, setSummary] = useState(EMPTY_SUMMARY); + const [metrics, setMetrics] = useState(null); + const [theme, setTheme] = useState(() => getInitialTheme()); + const [settingsOpen, setSettingsOpen] = useState(false); + + useEffect(() => { + applyTheme(theme); + }, [theme]); + + useEffect(() => { + let cancelled = false; + async function loadMetrics() { + try { + const next = await api.systemMetrics(); + if (!cancelled) setMetrics(next); + } catch { + if (!cancelled) setMetrics(null); + } + } + void loadMetrics(); + const timer = window.setInterval(loadMetrics, 10_000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, []); + return ( -
- - - +
+
+
+ SU +
+

System Update

+ dashboard SSH agentless +
+
+
+ {summary.machines} machines + {summary.updates} updates + {summary.running} jobs + {summary.errors} erreurs +
+
+ + +
+
+ + + +
+
+ SYSTEM UPDATE + machines {summary.machines} + apt {summary.updates} + jobs {summary.running} + ram {formatMb(metrics?.process.rssMb)} + heap {formatMb(metrics?.process.heapUsedMb)} + load {formatLoad(metrics?.host.loadAverage1m)} + terminal {selected ?? "none"} + {new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })} +
+ setSettingsOpen(false)} />
); } + +function formatMb(value: number | undefined): string { + return typeof value === "number" ? `${Math.round(value)}M` : "--"; +} + +function formatLoad(value: number | undefined): string { + return typeof value === "number" ? value.toFixed(2) : "--"; +} diff --git a/client/src/components/ui-kit.tsx b/client/src/components/ui-kit.tsx index f03e606..5a177bf 100644 --- a/client/src/components/ui-kit.tsx +++ b/client/src/components/ui-kit.tsx @@ -46,6 +46,17 @@ const ICON_MAP = { filter: 'filter', download: 'download', folder: 'folder', + docker: 'boxes-stacked', + package: 'box-open', + script: 'file-code', + shield: 'shield-halved', + key: 'key', + locked: 'lock', + logs: 'file-lines', + report: 'clipboard-list', + copy: 'copy', + collapse: 'down-left-and-up-right-to-center', + upgrade: 'cloud-arrow-down', node: 'circle-nodes', user: 'user', }; diff --git a/client/src/features/machines/AddMachineModal.tsx b/client/src/features/machines/AddMachineModal.tsx index b570645..4619342 100644 --- a/client/src/features/machines/AddMachineModal.tsx +++ b/client/src/features/machines/AddMachineModal.tsx @@ -1,5 +1,6 @@ // client/src/features/machines/AddMachineModal.tsx import { useState } from "react"; +import { api } from "../../lib/api.js"; interface Props { onClose: () => void; onCreated: () => void; } @@ -12,11 +13,7 @@ export function AddMachineModal({ onClose, onCreated }: Props) { async function submit() { setBusy(true); setError(null); try { - const res = await fetch("/api/machines", { - method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null }), - }); - if (!res.ok) throw new Error((await res.json()).error ?? "Échec"); + await api.createMachine({ ...form, port: Number(form.port), sudoPassword: form.sudoPassword || null }); onCreated(); onClose(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } } diff --git a/client/src/features/machines/MachineTile.tsx b/client/src/features/machines/MachineTile.tsx index 412982e..c14cc07 100644 --- a/client/src/features/machines/MachineTile.tsx +++ b/client/src/features/machines/MachineTile.tsx @@ -1,5 +1,7 @@ // client/src/features/machines/MachineTile.tsx -import type { MachineView } from "@shared/types.js"; +import { useState } from "react"; +import type { MachineStatus, MachineView } from "@shared/types.js"; +import { Button, Icon, IconButton, StatusLed } from "../../components/ui-kit.js"; interface Props { machine: MachineView; @@ -10,30 +12,195 @@ interface Props { onReboot: (id: string) => void; } -const STATUS_COLOR: Record = { - ok: "var(--ok)", updates_available: "var(--warn)", error: "var(--err)", - running: "var(--info)", unknown: "var(--ink-4)", +const STATUS_LED: Record = { + ok: "ok", + updates_available: "warn", + error: "err", + running: "info", + unknown: "off", }; -export function MachineTile({ machine, packageCount, onSelect, onRefresh, onUpgrade, onReboot }: Props) { +const STATUS_TEXT: Record = { + ok: "OK", + updates_available: "Updates", + error: "Erreur", + running: "Action en cours", + unknown: "Inconnu", +}; + +export function MachineTile({ + machine, + packageCount, + onSelect, + onRefresh, + onUpgrade, + onReboot, +}: Props) { + const [dockerOpen, setDockerOpen] = useState(false); + const [postOpen, setPostOpen] = useState(false); + const expanded = dockerOpen || postOpen; + const isError = machine.status === "error" || machine.status === "unknown"; + return ( -
onSelect(machine.id)}> -
- - {machine.name} +
onSelect(machine.id)} + > +
+
+ +
+ {machine.name} + {machine.hostname}:{machine.port} · {machine.osFamily} +
+
+ + {STATUS_TEXT[machine.status]} + +
+ +
+ 0 ? "warn" : "ok"} /> + +
-
- {machine.hostname}:{machine.port} · {machine.osFamily} + + {isError && ( +
+ + État machine à vérifier avant toute action sensible. +
+ )} + +
event.stopPropagation()}> + onRefresh(machine.id)} + /> + 0} + onClick={() => onUpgrade(machine.id)} + /> + onReboot(machine.id)} + /> + onSelect(machine.id)} + />
-
- UPDATES{" "} - {packageCount} + +
event.stopPropagation()}> + setDockerOpen((value) => !value)} + /> + {dockerOpen && } + + setPostOpen((value) => !value)} + /> + {postOpen && }
-
e.stopPropagation()}> - - - +
+ ); +} + +function Metric({ label, value, tone }: { label: string; value: string; tone?: "ok" | "warn" }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +function SectionToggle({ + icon, + title, + open, + onToggle, +}: { + icon: string; + title: string; + open: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +function DockerSection() { + return ( +
+
+ Docker non scanné + +
+
+ Roots compose, stacks, upgrades image et prune seront affichés ici dès que le backend Docker sera disponible.
); } + +function PostInstallSection() { + return ( +
+ + +
+ Les champs dynamiques seront dépliés ici selon les profils sélectionnés. +
+
+ ); +} + +function formatDate(value: string | null): string { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + return date.toLocaleString("fr-FR", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 6b3012c..20e7150 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1,16 +1,36 @@ // client/src/lib/api.ts -import type { MachineView, UpdateSnapshot, ActionType } from "@shared/types.js"; +import type { ActionType, MachineView, SystemMetrics, UpdateSnapshot } from "@shared/types.js"; + +async function readJsonBody(res: Response): Promise { + const text = await res.text(); + if (!text.trim()) return null; + try { + return JSON.parse(text); + } catch { + return { error: text }; + } +} async function req(path: string, init?: RequestInit): Promise { const res = await fetch(`/api${path}`, { headers: { "content-type": "application/json" }, ...init, }); - if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText); - return res.json() as Promise; + const body = await readJsonBody(res); + if (!res.ok) { + const apiUnavailable = res.status >= 500 && body === null; + const error = apiUnavailable + ? "API indisponible: le serveur backend ne répond pas." + : body && typeof body === "object" && "error" in body + ? String(body.error) + : res.statusText; + throw new Error(error || "Erreur API"); + } + return body as T; } export const api = { + systemMetrics: () => req("/system/metrics"), listMachines: () => req("/machines"), createMachine: (body: unknown) => req("/machines", { method: "POST", body: JSON.stringify(body) }), refresh: (id: string) => req(`/machines/${id}/refresh`, { method: "POST" }), diff --git a/client/src/panels/Dashboard.tsx b/client/src/panels/Dashboard.tsx index 79be700..33e9962 100644 --- a/client/src/panels/Dashboard.tsx +++ b/client/src/panels/Dashboard.tsx @@ -1,35 +1,73 @@ // client/src/panels/Dashboard.tsx -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { MachineView } from "@shared/types.js"; import { api } from "../lib/api.js"; import { MachineTile } from "../features/machines/MachineTile.js"; import { AddMachineModal } from "../features/machines/AddMachineModal.js"; +import { sumUpdates } from "../lib/stats.js"; -interface Props { onSelect: (id: string) => void; } +export interface DashboardSummary { + machines: number; + updates: number; + errors: number; + running: number; +} -export function Dashboard({ onSelect }: Props) { +interface Props { + onSelect: (id: string) => void; + onSummaryChange?: (summary: DashboardSummary) => void; +} + +export function Dashboard({ onSelect, onSummaryChange }: Props) { const [machines, setMachines] = useState([]); const [counts, setCounts] = useState>({}); const [adding, setAdding] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); async function load() { - const ms = await api.listMachines(); - setMachines(ms); - const entries = await Promise.all(ms.map(async (m) => { - try { return [m.id, (await api.snapshot(m.id)).apt.count] as const; } - catch { return [m.id, 0] as const; } - })); - setCounts(Object.fromEntries(entries)); + setError(null); + try { + const ms = await api.listMachines(); + setMachines(ms); + const entries = await Promise.all(ms.map(async (m) => { + try { return [m.id, (await api.snapshot(m.id)).apt.count] as const; } + catch { return [m.id, 0] as const; } + })); + setCounts(Object.fromEntries(entries)); + } catch (err) { + setMachines([]); + setCounts({}); + setError((err as Error).message); + } finally { + setLoading(false); + } } useEffect(() => { void load(); }, []); + const summary = useMemo(() => ({ + machines: machines.length, + updates: sumUpdates(counts), + errors: machines.filter((m) => m.status === "error").length, + running: machines.filter((m) => m.status === "running").length, + }), [machines, counts]); + + useEffect(() => { + onSummaryChange?.(summary); + }, [onSummaryChange, summary]); + return (
-
-

Machines

- +
+
+

Machines

+

{summary.machines} machines · {summary.updates} updates · {summary.errors} erreurs

+
+
- {machines.length === 0 &&

Aucune machine. Clique sur « + Ajouter ».

} + {error &&

{error}

} + {!error && loading &&

Chargement des machines…

} + {!error && !loading && machines.length === 0 &&

Aucune machine. Clique sur « + Ajouter ».

}
{machines.map((m) => ( void; +} + +type SettingsTab = + | "appearance" + | "tiles" + | "layout" + | "docker" + | "scripts" + | "hermes" + | "terminal" + | "retention"; + +const TABS: Array<{ id: SettingsTab; label: string; icon: string }> = [ + { id: "appearance", label: "Apparence", icon: "cog" }, + { id: "tiles", label: "Tuiles", icon: "grid" }, + { id: "layout", label: "Volets", icon: "collapse" }, + { id: "docker", label: "Docker", icon: "docker" }, + { id: "scripts", label: "Scripts", icon: "script" }, + { id: "hermes", label: "Hermes", icon: "node" }, + { id: "terminal", label: "Terminal", icon: "terminal" }, + { id: "retention", label: "Nettoyage", icon: "logs" }, +]; + +export function SettingsModal({ open, onClose }: Props) { + const [active, setActive] = useState("appearance"); + + if (!open) return null; + + return ( +
+
event.stopPropagation()}> +
+
+ PARAMÈTRES +

System Update

+
+ +
+ +
+ + +
+ {active === "appearance" && } + {active === "tiles" && } + {active === "layout" && } + {active === "docker" && } + {active === "scripts" && } + {active === "hermes" && } + {active === "terminal" && } + {active === "retention" && } +
+
+ +
+ settings backend pending + + +
+
+
+ ); +} + +function AppearanceSettings() { + return ( + + + + + + + + + + + + ); +} + +function TileSettings() { + return ( + + + + + +
+ + + +
+
+ + + +
+ ); +} + +function LayoutSettings() { + return ( + + + + + + + + + + + + ); +} + +function DockerSettings() { + return ( + + +