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 @@
|
||||
target/
|
||||
Generated
+728
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+83
-5
@@ -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<string | null>(null);
|
||||
const [summary, setSummary] = useState<DashboardSummary>(EMPTY_SUMMARY);
|
||||
const [metrics, setMetrics] = useState<SystemMetrics | null>(null);
|
||||
const [theme, setTheme] = useState<Theme>(() => 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 (
|
||||
<div className="su-layout">
|
||||
<HermesPanel />
|
||||
<Dashboard onSelect={setSelected} />
|
||||
<TerminalPanel machineId={selected} />
|
||||
<div className="su-app">
|
||||
<header className="su-header">
|
||||
<div className="su-brand">
|
||||
<span className="su-brand-mark">SU</span>
|
||||
<div>
|
||||
<h1>System Update</h1>
|
||||
<span className="mono">dashboard SSH agentless</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="su-header-summary">
|
||||
<span>{summary.machines} machines</span>
|
||||
<span>{summary.updates} updates</span>
|
||||
<span>{summary.running} jobs</span>
|
||||
<span>{summary.errors} erreurs</span>
|
||||
</div>
|
||||
<div className="su-spacer" />
|
||||
<button className="interactive su-header-button" onClick={() => setTheme(nextTheme(theme))}>
|
||||
{theme === "dark" ? "Light" : "Dark"}
|
||||
</button>
|
||||
<button className="interactive su-header-button" onClick={() => setSettingsOpen(true)}>
|
||||
Paramètres
|
||||
</button>
|
||||
</header>
|
||||
<div className="su-row">
|
||||
<HermesPanel />
|
||||
<Dashboard onSelect={setSelected} onSummaryChange={setSummary} />
|
||||
<TerminalPanel machineId={selected} />
|
||||
</div>
|
||||
<footer className="su-statusbar">
|
||||
<span className="cell mode">SYSTEM UPDATE</span>
|
||||
<span className="cell">machines {summary.machines}</span>
|
||||
<span className="cell">apt {summary.updates}</span>
|
||||
<span className="cell">jobs {summary.running}</span>
|
||||
<span className="cell">ram {formatMb(metrics?.process.rssMb)}</span>
|
||||
<span className="cell">heap {formatMb(metrics?.process.heapUsedMb)}</span>
|
||||
<span className="cell">load {formatLoad(metrics?.host.loadAverage1m)}</span>
|
||||
<span className="cell">terminal {selected ?? "none"}</span>
|
||||
<span className="cell clock">{new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||
</footer>
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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) : "--";
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
ok: "var(--ok)", updates_available: "var(--warn)", error: "var(--err)",
|
||||
running: "var(--info)", unknown: "var(--ink-4)",
|
||||
const STATUS_LED: Record<MachineStatus, "ok" | "warn" | "err" | "info" | "off"> = {
|
||||
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<MachineStatus, string> = {
|
||||
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 (
|
||||
<div className="glass" style={{ padding: 16, borderRadius: 10 }} onClick={() => onSelect(machine.id)}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: 999, background: STATUS_COLOR[machine.status] }} />
|
||||
<strong>{machine.name}</strong>
|
||||
<article
|
||||
className={`machine-tile glass ${expanded ? "machine-tile-expanded" : ""}`}
|
||||
onClick={() => onSelect(machine.id)}
|
||||
>
|
||||
<header className="machine-tile-head">
|
||||
<div className="machine-title-row">
|
||||
<StatusLed status={STATUS_LED[machine.status]} size={10} pulse={machine.status === "running"} />
|
||||
<div className="machine-title-text">
|
||||
<strong>{machine.name}</strong>
|
||||
<span className="mono">{machine.hostname}:{machine.port} · {machine.osFamily}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`machine-status-pill ${isError ? "machine-status-danger" : ""}`}>
|
||||
{STATUS_TEXT[machine.status]}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="machine-summary">
|
||||
<Metric label="Updates" value={packageCount.toString()} tone={packageCount > 0 ? "warn" : "ok"} />
|
||||
<Metric label="Reboot" value="-" />
|
||||
<Metric label="Dernier check" value={formatDate(machine.lastCheckedAt)} />
|
||||
</div>
|
||||
<div className="mono" style={{ color: "var(--ink-3)", fontSize: 12 }}>
|
||||
{machine.hostname}:{machine.port} · {machine.osFamily}
|
||||
|
||||
{isError && (
|
||||
<div className="machine-alert">
|
||||
<Icon name="alert" size={14} style={undefined} />
|
||||
<span>État machine à vérifier avant toute action sensible.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="machine-actions" onClick={(event) => event.stopPropagation()}>
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
label="Update + analyse"
|
||||
active={false}
|
||||
danger={false}
|
||||
primary={false}
|
||||
onClick={() => onRefresh(machine.id)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="upgrade"
|
||||
label="Upgrade système"
|
||||
active={false}
|
||||
danger={false}
|
||||
primary={packageCount > 0}
|
||||
onClick={() => onUpgrade(machine.id)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="power"
|
||||
label="Reboot"
|
||||
active={false}
|
||||
danger
|
||||
primary={false}
|
||||
onClick={() => onReboot(machine.id)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="terminal"
|
||||
label="Ouvrir les logs machine"
|
||||
active={false}
|
||||
danger={false}
|
||||
primary={false}
|
||||
onClick={() => onSelect(machine.id)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ margin: "10px 0", fontSize: 13 }}>
|
||||
<span className="label">UPDATES</span>{" "}
|
||||
<span className="mono">{packageCount}</span>
|
||||
|
||||
<div className="machine-sections" onClick={(event) => event.stopPropagation()}>
|
||||
<SectionToggle
|
||||
icon="docker"
|
||||
title="Docker"
|
||||
open={dockerOpen}
|
||||
onToggle={() => setDockerOpen((value) => !value)}
|
||||
/>
|
||||
{dockerOpen && <DockerSection />}
|
||||
|
||||
<SectionToggle
|
||||
icon="script"
|
||||
title="Post-install"
|
||||
open={postOpen}
|
||||
onToggle={() => setPostOpen((value) => !value)}
|
||||
/>
|
||||
{postOpen && <PostInstallSection />}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }} onClick={(e) => e.stopPropagation()}>
|
||||
<button className="interactive" onClick={() => onRefresh(machine.id)}>Refresh</button>
|
||||
<button className="interactive" onClick={() => onUpgrade(machine.id)}>Upgrade</button>
|
||||
<button className="interactive" onClick={() => onReboot(machine.id)}>Reboot</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value, tone }: { label: string; value: string; tone?: "ok" | "warn" }) {
|
||||
return (
|
||||
<div className="machine-metric">
|
||||
<span className="label">{label}</span>
|
||||
<span className={`mono ${tone === "warn" ? "machine-metric-warn" : tone === "ok" ? "machine-metric-ok" : ""}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionToggle({
|
||||
icon,
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
icon: string;
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className="machine-section-toggle interactive" onClick={onToggle}>
|
||||
<span className="machine-section-title">
|
||||
<Icon name={icon} size={14} style={undefined} />
|
||||
<span>{title}</span>
|
||||
</span>
|
||||
<Icon name={open ? "chevD" : "chevR"} size={12} style={undefined} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DockerSection() {
|
||||
return (
|
||||
<div className="machine-section-body">
|
||||
<div className="machine-section-row">
|
||||
<span className="mono">Docker non scanné</span>
|
||||
<Button icon="cog" size="sm" onClick={() => undefined}>Paramètres</Button>
|
||||
</div>
|
||||
<div className="machine-placeholder">
|
||||
Roots compose, stacks, upgrades image et prune seront affichés ici dès que le backend Docker sera disponible.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostInstallSection() {
|
||||
return (
|
||||
<div className="machine-section-body">
|
||||
<label className="machine-check-row">
|
||||
<input type="checkbox" />
|
||||
<span>Profil network tools</span>
|
||||
</label>
|
||||
<label className="machine-check-row">
|
||||
<input type="checkbox" />
|
||||
<span>Profil partage Samba/NFS</span>
|
||||
</label>
|
||||
<div className="machine-placeholder">
|
||||
Les champs dynamiques seront dépliés ici selon les profils sélectionnés.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
+23
-3
@@ -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<unknown> {
|
||||
const text = await res.text();
|
||||
if (!text.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { error: text };
|
||||
}
|
||||
}
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<T>;
|
||||
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<SystemMetrics>("/system/metrics"),
|
||||
listMachines: () => req<MachineView[]>("/machines"),
|
||||
createMachine: (body: unknown) => req<MachineView>("/machines", { method: "POST", body: JSON.stringify(body) }),
|
||||
refresh: (id: string) => req<UpdateSnapshot>(`/machines/${id}/refresh`, { method: "POST" }),
|
||||
|
||||
@@ -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<MachineView[]>([]);
|
||||
const [counts, setCounts] = useState<Record<string, number>>({});
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<DashboardSummary>(() => ({
|
||||
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 (
|
||||
<main className="su-center">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0 }}>Machines</h2>
|
||||
<button className="interactive" onClick={() => setAdding(true)}>+ Ajouter</button>
|
||||
<div className="su-dashboard-head">
|
||||
<div>
|
||||
<h2>Machines</h2>
|
||||
<p>{summary.machines} machines · {summary.updates} updates · {summary.errors} erreurs</p>
|
||||
</div>
|
||||
<button className="interactive su-add-button" onClick={() => setAdding(true)}>+ Ajouter</button>
|
||||
</div>
|
||||
{machines.length === 0 && <p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « + Ajouter ».</p>}
|
||||
{error && <p style={{ color: "var(--err)" }}>{error}</p>}
|
||||
{!error && loading && <p style={{ color: "var(--ink-3)" }}>Chargement des machines…</p>}
|
||||
{!error && !loading && machines.length === 0 && <p style={{ color: "var(--ink-3)" }}>Aucune machine. Clique sur « + Ajouter ».</p>}
|
||||
<div className="su-tiles">
|
||||
{machines.map((m) => (
|
||||
<MachineTile
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// client/src/panels/SettingsModal.tsx
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../components/ui-kit.js";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => 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<SettingsTab>("appearance");
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="settings-backdrop" onClick={onClose}>
|
||||
<section className="settings-modal glass-strong" onClick={(event) => event.stopPropagation()}>
|
||||
<header className="settings-head">
|
||||
<div>
|
||||
<span className="label">PARAMÈTRES</span>
|
||||
<h2>System Update</h2>
|
||||
</div>
|
||||
<button className="interactive settings-close" onClick={onClose} aria-label="Fermer">
|
||||
<Icon name="close" size={14} style={undefined} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="settings-body">
|
||||
<nav className="settings-nav">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`interactive settings-nav-item ${active === tab.id ? "active" : ""}`}
|
||||
onClick={() => setActive(tab.id)}
|
||||
>
|
||||
<Icon name={tab.icon} size={14} style={undefined} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="settings-content">
|
||||
{active === "appearance" && <AppearanceSettings />}
|
||||
{active === "tiles" && <TileSettings />}
|
||||
{active === "layout" && <LayoutSettings />}
|
||||
{active === "docker" && <DockerSettings />}
|
||||
{active === "scripts" && <ScriptsSettings />}
|
||||
{active === "hermes" && <HermesSettings />}
|
||||
{active === "terminal" && <TerminalSettings />}
|
||||
{active === "retention" && <RetentionSettings />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="settings-footer">
|
||||
<span className="mono">settings backend pending</span>
|
||||
<button className="interactive settings-secondary" onClick={onClose}>Fermer</button>
|
||||
<button className="interactive settings-primary" onClick={onClose}>Sauvegarder</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceSettings() {
|
||||
return (
|
||||
<SettingsSection title="Apparence">
|
||||
<Field label="Thème">
|
||||
<select className="su-field" defaultValue="system">
|
||||
<option value="system">Système</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Zoom UI">
|
||||
<input className="su-field" type="number" min="80" max="130" defaultValue="100" />
|
||||
</Field>
|
||||
<Field label="Densité">
|
||||
<select className="su-field" defaultValue="compact">
|
||||
<option value="compact">Compact</option>
|
||||
<option value="comfortable">Confort</option>
|
||||
</select>
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function TileSettings() {
|
||||
return (
|
||||
<SettingsSection title="Tuiles machine">
|
||||
<Field label="Largeur minimale">
|
||||
<input className="su-field" type="number" min="240" max="420" defaultValue="280" />
|
||||
</Field>
|
||||
<Field label="Sections ouvertes par défaut">
|
||||
<div className="settings-checks">
|
||||
<Check label="Docker" />
|
||||
<Check label="Post-install" />
|
||||
<Check label="Hardware" />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Mode erreur">
|
||||
<select className="su-field" defaultValue="expanded">
|
||||
<option value="badge">Badge</option>
|
||||
<option value="expanded">Alerte visible</option>
|
||||
</select>
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutSettings() {
|
||||
return (
|
||||
<SettingsSection title="Volets">
|
||||
<Field label="Hermes largeur">
|
||||
<input className="su-field" type="number" min="200" max="300" defaultValue="240" />
|
||||
</Field>
|
||||
<Field label="Terminal largeur">
|
||||
<input className="su-field" type="number" min="320" max="460" defaultValue="380" />
|
||||
</Field>
|
||||
<Field label="Mobile">
|
||||
<select className="su-field" defaultValue="tabs">
|
||||
<option value="tabs">Onglets</option>
|
||||
<option value="bottom">Barre basse</option>
|
||||
</select>
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function DockerSettings() {
|
||||
return (
|
||||
<SettingsSection title="Docker">
|
||||
<Field label="Roots Compose">
|
||||
<textarea className="su-field settings-textarea" defaultValue={"/home/gilles/docker\n/opt/docker"} />
|
||||
</Field>
|
||||
<Field label="Prune">
|
||||
<select className="su-field" defaultValue="safe">
|
||||
<option value="safe">Images inutilisées</option>
|
||||
<option value="aggressive">Agressif avec validation</option>
|
||||
</select>
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ScriptsSettings() {
|
||||
return (
|
||||
<SettingsSection title="Scripts">
|
||||
<Field label="Catalogue">
|
||||
<select className="su-field" defaultValue="local">
|
||||
<option value="local">Scripts locaux</option>
|
||||
<option value="shared">Scripts partagés</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Profils visibles">
|
||||
<div className="settings-checks">
|
||||
<Check label="Network" checked />
|
||||
<Check label="Dev tools" checked />
|
||||
<Check label="Domotique" />
|
||||
</div>
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function HermesSettings() {
|
||||
return (
|
||||
<SettingsSection title="Hermes">
|
||||
<Field label="Endpoint">
|
||||
<input className="su-field" defaultValue="http://10.0.0.80:8000" />
|
||||
</Field>
|
||||
<Field label="Contexte max">
|
||||
<input className="su-field" type="number" min="1000" max="64000" defaultValue="12000" />
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalSettings() {
|
||||
return (
|
||||
<SettingsSection title="Terminal">
|
||||
<Field label="Mode">
|
||||
<select className="su-field" defaultValue="logs">
|
||||
<option value="logs">Logs actions</option>
|
||||
<option value="ssh">SSH interactif</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Police">
|
||||
<input className="su-field" type="number" min="10" max="18" defaultValue="12" />
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function RetentionSettings() {
|
||||
return (
|
||||
<SettingsSection title="Nettoyage">
|
||||
<Field label="Logs bruts">
|
||||
<input className="su-field" type="number" min="7" max="365" defaultValue="90" />
|
||||
</Field>
|
||||
<Field label="Rapports">
|
||||
<input className="su-field" type="number" min="30" max="730" defaultValue="365" />
|
||||
</Field>
|
||||
<Field label="Messages importants">
|
||||
<select className="su-field" defaultValue="keep">
|
||||
<option value="keep">Conserver non acquittés</option>
|
||||
<option value="archive">Archiver</option>
|
||||
</select>
|
||||
</Field>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>{title}</h3>
|
||||
<div className="settings-fields">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="settings-field">
|
||||
<span className="label">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Check({ label, checked }: { label: string; checked?: boolean }) {
|
||||
return (
|
||||
<label className="settings-check">
|
||||
<input type="checkbox" defaultChecked={checked} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -18,11 +18,31 @@ export function TerminalPanel({ machineId }: { machineId: string | null }) {
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(ref.current);
|
||||
fit.fit();
|
||||
const fitTerminal = () => {
|
||||
try { fit.fit(); } catch { /* xterm peut être entre deux cycles de layout. */ }
|
||||
};
|
||||
const frame = window.requestAnimationFrame(fitTerminal);
|
||||
const resizeObserver = new ResizeObserver(fitTerminal);
|
||||
resizeObserver.observe(ref.current);
|
||||
term.writeln(machineId ? `# flux ${machineId}` : "# sélectionne une machine");
|
||||
const disconnect = machineId ? connectOutput(machineId, (c) => term.write(c)) : () => {};
|
||||
return () => { disconnect(); term.dispose(); };
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
resizeObserver.disconnect();
|
||||
disconnect();
|
||||
term.dispose();
|
||||
};
|
||||
}, [machineId]);
|
||||
|
||||
return <div className="su-terminal" ref={ref} style={{ padding: 6 }} />;
|
||||
return (
|
||||
<aside className="su-terminal-wrap">
|
||||
<div className="su-terminal-head">
|
||||
<span className="label">TERMINAL</span>
|
||||
<span className="mono" style={{ color: "var(--ink-3)", fontSize: 12 }}>
|
||||
{machineId ?? "aucune machine"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="su-terminal" ref={ref} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
+301
-7
@@ -8,9 +8,9 @@ body {
|
||||
color: var(--ink-1);
|
||||
}
|
||||
|
||||
/* Ossature : header / rangée 3 volets / status bar */
|
||||
.su-app { display: flex; flex-direction: column; height: 100vh; }
|
||||
.su-row { flex: 1; display: flex; min-height: 0; }
|
||||
/* Ossature : rangée 3 volets */
|
||||
.su-app { display: flex; flex-direction: column; width: 100%; height: 100vh; overflow: hidden; }
|
||||
.su-row { flex: 1; display: flex; width: 100%; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
|
||||
.su-header {
|
||||
height: 52px; flex: 0 0 52px;
|
||||
@@ -21,14 +21,154 @@ body {
|
||||
}
|
||||
.su-header h1 { font-size: 15px; margin: 0; font-weight: 600; }
|
||||
.su-spacer { flex: 1; }
|
||||
.su-brand { display: flex; align-items: center; gap: 10px; min-width: 210px; }
|
||||
.su-brand-mark {
|
||||
width: 30px; height: 30px; border-radius: 8px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: var(--accent); color: var(--bg-1);
|
||||
font-weight: 800; font-family: var(--font-mono); font-size: 12px;
|
||||
}
|
||||
.su-brand .mono { display: block; color: var(--ink-3); font-size: 11px; margin-top: 2px; }
|
||||
.su-header-summary { display: flex; gap: 8px; flex-wrap: wrap; color: var(--ink-3); font-size: 12px; }
|
||||
.su-header-summary span,
|
||||
.su-header-button {
|
||||
border: 1px solid var(--border-1);
|
||||
background: var(--bg-3);
|
||||
color: var(--ink-2);
|
||||
border-radius: 8px;
|
||||
padding: 6px 9px;
|
||||
}
|
||||
.su-header-button { font-family: var(--font-ui); }
|
||||
|
||||
.su-hermes { width: 280px; min-width: 200px; background: var(--bg-2); border-right: 1px solid var(--border-1); padding: 14px; overflow: auto; }
|
||||
.su-center { flex: 1; overflow: auto; padding: 18px; }
|
||||
.su-terminal-wrap { width: 360px; min-width: 320px; display: flex; flex-direction: column; background: var(--bg-0); border-left: 1px solid var(--border-1); }
|
||||
.su-hermes { flex: 0 0 clamp(220px, 15vw, 280px); min-width: 0; background: var(--bg-2); border-right: 1px solid var(--border-1); padding: 14px; overflow: auto; }
|
||||
.su-center { flex: 1 1 auto; min-width: 0; overflow: auto; padding: 18px; }
|
||||
.su-terminal-wrap { flex: 0 0 clamp(320px, 28vw, 440px); min-width: 0; display: flex; flex-direction: column; background: var(--bg-0); border-left: 1px solid var(--border-1); overflow: hidden; }
|
||||
.su-terminal-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border-1); }
|
||||
.su-terminal { flex: 1; min-height: 0; padding: 6px; }
|
||||
.su-terminal { flex: 1; min-width: 0; min-height: 0; padding: 6px; overflow: hidden; }
|
||||
.su-terminal .xterm { height: 100%; }
|
||||
|
||||
.su-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
|
||||
.su-dashboard-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.su-dashboard-head h2 { margin: 0; font-size: 22px; }
|
||||
.su-dashboard-head p { margin: 4px 0 0; color: var(--ink-3); font-size: 12px; }
|
||||
.su-add-button {
|
||||
background: var(--bg-3);
|
||||
color: var(--ink-1);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.machine-tile {
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.machine-tile-expanded { grid-column: span 2; }
|
||||
.machine-tile-head,
|
||||
.machine-title-row,
|
||||
.machine-actions,
|
||||
.machine-section-toggle,
|
||||
.machine-section-row,
|
||||
.machine-check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.machine-tile-head { justify-content: space-between; gap: 12px; min-width: 0; }
|
||||
.machine-title-row { gap: 9px; min-width: 0; }
|
||||
.machine-title-text { display: flex; flex-direction: column; min-width: 0; }
|
||||
.machine-title-text strong { color: var(--ink-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.machine-title-text .mono { color: var(--ink-3); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.machine-status-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--border-2);
|
||||
color: var(--ink-3);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.machine-status-danger { color: var(--err); border-color: var(--err); background: rgba(251, 73, 52, 0.08); }
|
||||
.machine-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.machine-metric {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border-1);
|
||||
}
|
||||
.machine-metric .mono { display: block; margin-top: 3px; font-size: 13px; color: var(--ink-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.machine-metric-warn { color: var(--warn) !important; }
|
||||
.machine-metric-ok { color: var(--ok) !important; }
|
||||
.machine-alert {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--err);
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--err);
|
||||
border-radius: 8px;
|
||||
background: rgba(251, 73, 52, 0.08);
|
||||
}
|
||||
.machine-actions { gap: 7px; flex-wrap: wrap; }
|
||||
.machine-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--border-1);
|
||||
padding-top: 10px;
|
||||
}
|
||||
.machine-section-toggle {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-1);
|
||||
background: var(--bg-2);
|
||||
color: var(--ink-2);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.machine-section-title { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.machine-section-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--border-1);
|
||||
}
|
||||
.machine-section-row { justify-content: space-between; gap: 8px; }
|
||||
.machine-placeholder { color: var(--ink-3); font-size: 12px; line-height: 1.35; }
|
||||
.machine-check-row { gap: 8px; color: var(--ink-2); font-size: 13px; }
|
||||
.machine-check-row input { accent-color: var(--accent); }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.machine-tile-expanded { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.su-hermes { flex-basis: 220px; }
|
||||
.su-terminal-wrap { flex-basis: 320px; }
|
||||
.su-header-summary { display: none; }
|
||||
}
|
||||
|
||||
/* Status bar style tmux */
|
||||
.su-statusbar {
|
||||
@@ -51,3 +191,157 @@ body {
|
||||
outline: none;
|
||||
}
|
||||
.su-field:focus { border-color: var(--accent-soft); }
|
||||
|
||||
.settings-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.settings-modal {
|
||||
width: min(920px, 96vw);
|
||||
max-height: min(720px, 92vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-1);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.settings-head h2 { margin: 2px 0 0; font-size: 18px; }
|
||||
.settings-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: var(--bg-2);
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.settings-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
.settings-nav {
|
||||
flex: 0 0 210px;
|
||||
padding: 12px;
|
||||
border-right: 1px solid var(--border-1);
|
||||
background: var(--bg-2);
|
||||
overflow: auto;
|
||||
}
|
||||
.settings-nav-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
font-family: var(--font-ui);
|
||||
text-align: left;
|
||||
}
|
||||
.settings-nav-item.active {
|
||||
background: var(--accent-tint);
|
||||
border-color: var(--accent-soft);
|
||||
color: var(--ink-1);
|
||||
}
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
}
|
||||
.settings-section h3 { margin: 0 0 14px; font-size: 18px; }
|
||||
.settings-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.settings-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
.settings-field .su-field {
|
||||
width: 100%;
|
||||
}
|
||||
.settings-textarea {
|
||||
min-height: 96px;
|
||||
resize: vertical;
|
||||
}
|
||||
.settings-checks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: var(--bg-1);
|
||||
}
|
||||
.settings-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--ink-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
.settings-check input { accent-color: var(--accent); }
|
||||
.settings-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-1);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.settings-footer .mono {
|
||||
margin-right: auto;
|
||||
color: var(--ink-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.settings-primary,
|
||||
.settings-secondary {
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-family: var(--font-ui);
|
||||
border: 1px solid var(--border-2);
|
||||
}
|
||||
.settings-primary {
|
||||
background: var(--accent);
|
||||
color: var(--bg-1);
|
||||
border-color: var(--accent-soft);
|
||||
}
|
||||
.settings-secondary {
|
||||
background: var(--bg-3);
|
||||
color: var(--ink-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.settings-body { flex-direction: column; }
|
||||
.settings-nav {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border-1);
|
||||
}
|
||||
.settings-nav-item { flex: 0 0 auto; width: auto; }
|
||||
}
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"check": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"api-client:create": "tsx server/cli/createApiClient.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// server/auth/apiAuth.test.ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { apiAuthInternals } from "./apiAuth.js";
|
||||
|
||||
describe("apiAuthInternals", () => {
|
||||
it("extrait un token bearer", () => {
|
||||
expect(apiAuthInternals.extractBearerToken("Bearer su_token")).toBe("su_token");
|
||||
});
|
||||
|
||||
it("accepte bearer sans sensibilité à la casse", () => {
|
||||
expect(apiAuthInternals.extractBearerToken("bearer su_token")).toBe("su_token");
|
||||
});
|
||||
|
||||
it("rejette un header absent ou mal formé", () => {
|
||||
expect(apiAuthInternals.extractBearerToken(null)).toBeNull();
|
||||
expect(apiAuthInternals.extractBearerToken("Basic abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// server/auth/apiAuth.ts
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import type { ApiClientScope, ApiClientView } from "@shared/types.js";
|
||||
import { authenticateApiToken, hasApiScope } from "../services/apiClients.js";
|
||||
|
||||
export interface ApiAuthVariables {
|
||||
apiClient: ApiClientView;
|
||||
}
|
||||
|
||||
export function extractBearerToken(authorization: string | null | undefined): string | null {
|
||||
if (!authorization) return null;
|
||||
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
export function requireApiScope(required: ApiClientScope): MiddlewareHandler<{
|
||||
Variables: ApiAuthVariables;
|
||||
}> {
|
||||
return async (c, next) => {
|
||||
const token = extractBearerToken(c.req.header("Authorization"));
|
||||
if (!token) return c.json({ error: "Token API manquant" }, 401);
|
||||
|
||||
const client = authenticateApiToken(token);
|
||||
if (!client) return c.json({ error: "Token API invalide ou révoqué" }, 401);
|
||||
if (!hasApiScope(client.scopes, required)) {
|
||||
return c.json({ error: "Scope API insuffisant" }, 403);
|
||||
}
|
||||
|
||||
c.set("apiClient", client);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
export const apiAuthInternals = { extractBearerToken };
|
||||
@@ -0,0 +1,28 @@
|
||||
// server/cli/createApiClient.test.ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createApiClientCliInternals,
|
||||
parseCreateApiClientArgs,
|
||||
} from "./createApiClient.js";
|
||||
|
||||
describe("createApiClient CLI", () => {
|
||||
it("parse un nom et des scopes", () => {
|
||||
expect(
|
||||
parseCreateApiClientArgs(["--name", "App Rust", "--scopes", "read,operate,read"]),
|
||||
).toEqual({
|
||||
name: "App Rust",
|
||||
scopes: ["read", "operate"],
|
||||
});
|
||||
});
|
||||
|
||||
it("utilise read par défaut", () => {
|
||||
expect(parseCreateApiClientArgs(["--name", "Hermes"])).toEqual({
|
||||
name: "Hermes",
|
||||
scopes: ["read"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejette un scope invalide", () => {
|
||||
expect(() => createApiClientCliInternals.parseScopes("read,root")).toThrow("Scope invalide");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// server/cli/createApiClient.ts
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { ApiClientScope } from "@shared/types.js";
|
||||
import { runMigrations } from "../db/migrate.js";
|
||||
import { createApiClient } from "../services/apiClients.js";
|
||||
|
||||
export interface CreateApiClientCliOptions {
|
||||
name: string;
|
||||
scopes: ApiClientScope[];
|
||||
}
|
||||
|
||||
const ALLOWED_SCOPES: ApiClientScope[] = ["read", "operate", "admin", "debug"];
|
||||
|
||||
export function parseCreateApiClientArgs(args: string[]): CreateApiClientCliOptions {
|
||||
let name = "";
|
||||
let scopes: ApiClientScope[] = ["read"];
|
||||
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === "--name") {
|
||||
i += 1;
|
||||
name = args[i] ?? "";
|
||||
} else if (arg === "--scopes") {
|
||||
i += 1;
|
||||
scopes = parseScopes(args[i] ?? "");
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
throw new Error(helpText());
|
||||
} else {
|
||||
throw new Error(`Argument inconnu: ${arg}\n\n${helpText()}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!name.trim()) throw new Error(`--name est obligatoire\n\n${helpText()}`);
|
||||
return { name: name.trim(), scopes };
|
||||
}
|
||||
|
||||
function parseScopes(raw: string): ApiClientScope[] {
|
||||
const scopes = raw
|
||||
.split(",")
|
||||
.map((scope) => scope.trim())
|
||||
.filter(Boolean) as ApiClientScope[];
|
||||
|
||||
if (scopes.length === 0) return ["read"];
|
||||
for (const scope of scopes) {
|
||||
if (!ALLOWED_SCOPES.includes(scope)) {
|
||||
throw new Error(`Scope invalide: ${scope}. Scopes valides: ${ALLOWED_SCOPES.join(", ")}`);
|
||||
}
|
||||
}
|
||||
return [...new Set(scopes)];
|
||||
}
|
||||
|
||||
function helpText(): string {
|
||||
return [
|
||||
"Usage:",
|
||||
" pnpm api-client:create -- --name \"App Rust\" --scopes read,operate",
|
||||
"",
|
||||
"Variables requises:",
|
||||
" SU_MASTER_KEY clé hex 64 caractères",
|
||||
" SU_DB_PATH chemin SQLite optionnel",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseCreateApiClientArgs(process.argv.slice(2));
|
||||
runMigrations();
|
||||
const created = createApiClient(options);
|
||||
console.log(JSON.stringify(created, null, 2));
|
||||
}
|
||||
|
||||
const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
|
||||
if (import.meta.url === entrypoint) {
|
||||
main().catch((err) => {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
export const createApiClientCliInternals = { parseScopes, helpText };
|
||||
@@ -0,0 +1,25 @@
|
||||
// server/crypto/apiTokens.test.ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateApiToken, hashApiToken, tokenPrefix, verifyApiToken } from "./apiTokens.js";
|
||||
|
||||
const PEPPER = "b".repeat(64);
|
||||
|
||||
describe("apiTokens", () => {
|
||||
it("génère un token préfixé non trivial", () => {
|
||||
const token = generateApiToken();
|
||||
expect(token).toMatch(/^su_[A-Za-z0-9_-]{40,}$/);
|
||||
});
|
||||
|
||||
it("calcule un préfixe court affichable", () => {
|
||||
expect(tokenPrefix("su_abcdefghijklmnopqrstuvwxyz")).toBe("su_abcdefghi");
|
||||
});
|
||||
|
||||
it("vérifie un token par HMAC sans stocker le token brut", () => {
|
||||
const token = "su_test_token";
|
||||
const hash = hashApiToken(token, PEPPER);
|
||||
|
||||
expect(hash).not.toContain(token);
|
||||
expect(verifyApiToken(token, hash, PEPPER)).toBe(true);
|
||||
expect(verifyApiToken("su_other_token", hash, PEPPER)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// server/crypto/apiTokens.ts
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const TOKEN_BYTES = 32;
|
||||
const TOKEN_PREFIX_LENGTH = 12;
|
||||
|
||||
export function generateApiToken(): string {
|
||||
return `su_${randomBytes(TOKEN_BYTES).toString("base64url")}`;
|
||||
}
|
||||
|
||||
export function tokenPrefix(token: string): string {
|
||||
return token.slice(0, TOKEN_PREFIX_LENGTH);
|
||||
}
|
||||
|
||||
export function hashApiToken(token: string, pepperHex: string): string {
|
||||
const pepper = Buffer.from(pepperHex, "hex");
|
||||
return createHmac("sha256", pepper).update(token).digest("base64url");
|
||||
}
|
||||
|
||||
export function verifyApiToken(token: string, expectedHash: string, pepperHex: string): boolean {
|
||||
const actual = Buffer.from(hashApiToken(token, pepperHex));
|
||||
const expected = Buffer.from(expectedHash);
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
// server/db/migrate.ts
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { db } from "./client.js";
|
||||
import { backfillCredentials } from "../services/credentials.js";
|
||||
|
||||
export function runMigrations(): void {
|
||||
migrate(db, { migrationsFolder: "./server/db/migrations" });
|
||||
const n = backfillCredentials();
|
||||
if (n > 0) console.log(`[migrate] backfill credentials: ${n} machine(s)`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE `api_clients` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`token_prefix` text NOT NULL,
|
||||
`token_hash` text NOT NULL,
|
||||
`scopes_json` text NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`last_used_at` text,
|
||||
`revoked_at` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `api_clients_token_hash_unique` ON `api_clients` (`token_hash`);
|
||||
@@ -0,0 +1,150 @@
|
||||
CREATE TABLE `important_messages` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`machine_id` text,
|
||||
`source` text NOT NULL,
|
||||
`category` text NOT NULL,
|
||||
`severity` text NOT NULL,
|
||||
`package_name` text,
|
||||
`component` text,
|
||||
`message` text NOT NULL,
|
||||
`raw_line_ref` text,
|
||||
`snapshot_id` text,
|
||||
`execution_id` text,
|
||||
`first_seen_at` text NOT NULL,
|
||||
`last_seen_at` text NOT NULL,
|
||||
`acknowledged` integer DEFAULT 0 NOT NULL,
|
||||
`acknowledged_at` text,
|
||||
`acknowledged_by` text,
|
||||
`payload_json` text,
|
||||
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `machine_events` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`machine_id` text,
|
||||
`event_type` text NOT NULL,
|
||||
`severity` text NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`actor_type` text,
|
||||
`actor_id` text,
|
||||
`snapshot_id` text,
|
||||
`execution_id` text,
|
||||
`job_id` text,
|
||||
`message` text,
|
||||
`payload_json` text,
|
||||
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `machine_hardware` (
|
||||
`machine_id` text PRIMARY KEY NOT NULL,
|
||||
`probe_snapshot_id` text,
|
||||
`cpu_model` text,
|
||||
`cpu_cores` integer,
|
||||
`memory_bytes` integer,
|
||||
`gpus_json` text,
|
||||
`disks_json` text,
|
||||
`network_json` text,
|
||||
`firmware_json` text,
|
||||
`driver_json` text,
|
||||
`warnings_json` text,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `machine_metrics_latest` (
|
||||
`machine_id` text PRIMARY KEY NOT NULL,
|
||||
`snapshot_id` text,
|
||||
`collected_at` text NOT NULL,
|
||||
`cpu_load1` real,
|
||||
`cpu_load5` real,
|
||||
`cpu_cores` integer,
|
||||
`memory_total_bytes` integer,
|
||||
`memory_used_bytes` integer,
|
||||
`memory_available_bytes` integer,
|
||||
`memory_used_percent` real,
|
||||
`filesystems_json` text,
|
||||
`root_used_percent` real,
|
||||
`warnings_json` text,
|
||||
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `machine_state` (
|
||||
`machine_id` text PRIMARY KEY NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`apt_status` text,
|
||||
`apt_updates_count` integer DEFAULT 0 NOT NULL,
|
||||
`apt_reboot_required` integer DEFAULT 0 NOT NULL,
|
||||
`apt_last_analyze_at` text,
|
||||
`docker_status` text,
|
||||
`docker_installed` integer DEFAULT 0 NOT NULL,
|
||||
`docker_stacks_count` integer DEFAULT 0 NOT NULL,
|
||||
`docker_updates_count` integer DEFAULT 0 NOT NULL,
|
||||
`docker_prune_available` integer DEFAULT 0 NOT NULL,
|
||||
`post_install_status` text,
|
||||
`metrics_last_collected_at` text,
|
||||
`cpu_load1` real,
|
||||
`memory_used_percent` real,
|
||||
`root_used_percent` real,
|
||||
`disk_warnings_count` integer DEFAULT 0 NOT NULL,
|
||||
`hardware_warnings_count` integer DEFAULT 0 NOT NULL,
|
||||
`running_job_id` text,
|
||||
`last_error_kind` text,
|
||||
`last_error_message` text,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `raw_artifacts` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`machine_id` text,
|
||||
`kind` text NOT NULL,
|
||||
`path` text NOT NULL,
|
||||
`bytes` integer,
|
||||
`sha256` text,
|
||||
`created_at` text NOT NULL,
|
||||
`expires_at` text,
|
||||
`pinned` integer DEFAULT 0 NOT NULL,
|
||||
`redacted` integer DEFAULT 1 NOT NULL,
|
||||
`retention_policy` text,
|
||||
`deleted_at` text,
|
||||
`delete_reason` text,
|
||||
`metadata_json` text,
|
||||
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `reports` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`machine_id` text,
|
||||
`execution_id` text,
|
||||
`kind` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`path` text NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`pinned` integer DEFAULT 0 NOT NULL,
|
||||
`summary_json` text,
|
||||
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `executions` ADD `schema_version` integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `executions` ADD `request_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `executions` ADD `job_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `executions` ADD `important_json` text;--> statement-breakpoint
|
||||
ALTER TABLE `executions` ADD `report_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `executions` ADD `exit_code` integer;--> statement-breakpoint
|
||||
ALTER TABLE `executions` ADD `error_kind` text;--> statement-breakpoint
|
||||
ALTER TABLE `executions` ADD `error_message` text;--> statement-breakpoint
|
||||
ALTER TABLE `machines` ADD `os_version` text;--> statement-breakpoint
|
||||
ALTER TABLE `machines` ADD `os_codename` text;--> statement-breakpoint
|
||||
ALTER TABLE `machines` ADD `arch` text;--> statement-breakpoint
|
||||
ALTER TABLE `machines` ADD `machine_kind` text;--> statement-breakpoint
|
||||
ALTER TABLE `machines` ADD `virtualization` text;--> statement-breakpoint
|
||||
ALTER TABLE `machines` ADD `hardware_profile` text;--> statement-breakpoint
|
||||
ALTER TABLE `machines` ADD `last_seen_at` text;--> statement-breakpoint
|
||||
ALTER TABLE `machines` ADD `updated_at` text;--> statement-breakpoint
|
||||
ALTER TABLE `machines` ADD `deleted_at` text;--> statement-breakpoint
|
||||
ALTER TABLE `snapshots` ADD `kind` text DEFAULT 'apt_update_analyze' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `snapshots` ADD `schema_version` integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `snapshots` ADD `important_json` text;--> statement-breakpoint
|
||||
ALTER TABLE `snapshots` ADD `raw_log_path` text;--> statement-breakpoint
|
||||
ALTER TABLE `snapshots` ADD `raw_artifact_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `snapshots` ADD `source_job_id` text;
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE `machine_credentials` (
|
||||
`machine_id` text PRIMARY KEY NOT NULL,
|
||||
`auth_method` text NOT NULL,
|
||||
`enc_password` text,
|
||||
`enc_sudo_password` text,
|
||||
`enc_private_key` text,
|
||||
`enc_key_passphrase` text,
|
||||
`sudo_mode` text NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
`last_test_at` text,
|
||||
`status` text,
|
||||
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `machine_host_keys` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`machine_id` text,
|
||||
`hostname` text NOT NULL,
|
||||
`port` integer NOT NULL,
|
||||
`key_type` text,
|
||||
`fingerprint_sha256` text NOT NULL,
|
||||
`public_key` text,
|
||||
`status` text NOT NULL,
|
||||
`first_seen_at` text NOT NULL,
|
||||
`last_seen_at` text NOT NULL,
|
||||
FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,27 @@
|
||||
"when": 1780599514478,
|
||||
"tag": "0000_brainy_dakota_north",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1780669000000,
|
||||
"tag": "0001_api_clients",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1780669100000,
|
||||
"tag": "0002_reflective_lifeguard",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1780669200000,
|
||||
"tag": "0003_magical_psylocke",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// server/db/schema.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
|
||||
function freshMigratedDb() {
|
||||
const sqlite = new Database(":memory:");
|
||||
const db = drizzle(sqlite);
|
||||
migrate(db, { migrationsFolder: "./server/db/migrations" });
|
||||
return sqlite;
|
||||
}
|
||||
|
||||
function tableNames(sqlite: Database.Database): string[] {
|
||||
return sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r: any) => r.name);
|
||||
}
|
||||
function columnNames(sqlite: Database.Database, table: string): string[] {
|
||||
return sqlite.prepare(`PRAGMA table_info(${table})`).all().map((r: any) => r.name);
|
||||
}
|
||||
|
||||
describe("schéma Phase 1", () => {
|
||||
it("crée les tables socle", () => {
|
||||
const sqlite = freshMigratedDb();
|
||||
const tables = tableNames(sqlite);
|
||||
for (const t of [
|
||||
"machines", "snapshots", "executions",
|
||||
"machine_state", "machine_hardware", "machine_metrics_latest",
|
||||
"machine_events", "important_messages", "reports", "raw_artifacts",
|
||||
]) {
|
||||
expect(tables, `table ${t}`).toContain(t);
|
||||
}
|
||||
});
|
||||
|
||||
it("ajoute les colonnes étendues sans casser l'existant", () => {
|
||||
const sqlite = freshMigratedDb();
|
||||
expect(columnNames(sqlite, "machines")).toEqual(
|
||||
expect.arrayContaining(["machine_kind", "virtualization", "hardware_profile", "os_version", "updated_at"]),
|
||||
);
|
||||
expect(columnNames(sqlite, "snapshots")).toEqual(
|
||||
expect.arrayContaining(["kind", "schema_version", "important_json"]),
|
||||
);
|
||||
expect(columnNames(sqlite, "executions")).toEqual(
|
||||
expect.arrayContaining(["schema_version", "error_kind", "error_message", "exit_code"]),
|
||||
);
|
||||
// colonnes jalon 1 conservées
|
||||
expect(columnNames(sqlite, "snapshots")).toContain("checked_at");
|
||||
expect(columnNames(sqlite, "machines")).toContain("enc_password");
|
||||
});
|
||||
});
|
||||
|
||||
describe("schéma Phase 2", () => {
|
||||
it("crée les tables de credentials Phase 2", () => {
|
||||
const sqlite = freshMigratedDb();
|
||||
const tables = tableNames(sqlite);
|
||||
expect(tables).toEqual(expect.arrayContaining(["machine_credentials", "machine_host_keys"]));
|
||||
// machines conserve ses colonnes secrets legacy (fallback)
|
||||
expect(columnNames(sqlite, "machines")).toContain("enc_password");
|
||||
});
|
||||
});
|
||||
+190
-1
@@ -1,5 +1,5 @@
|
||||
// server/db/schema.ts
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
import { sqliteTable, text, integer, real, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const machines = sqliteTable("machines", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -7,6 +7,12 @@ export const machines = sqliteTable("machines", {
|
||||
hostname: text("hostname").notNull(),
|
||||
port: integer("port").notNull().default(22),
|
||||
osFamily: text("os_family").notNull().default("unknown"),
|
||||
osVersion: text("os_version"),
|
||||
osCodename: text("os_codename"),
|
||||
arch: text("arch"),
|
||||
machineKind: text("machine_kind"), // physical | vm | proxmox_host | lxc | raspberry_pi | workstation | unknown
|
||||
virtualization: text("virtualization"), // none | qemu | kvm | lxc | docker | vmware | ...
|
||||
hardwareProfile: text("hardware_profile"), // generic_vm | baremetal_server | raspberry_pi | gpu_server | proxmox_host | ...
|
||||
username: text("username").notNull(),
|
||||
encPassword: text("enc_password").notNull(),
|
||||
encSudoPassword: text("enc_sudo_password"),
|
||||
@@ -14,15 +20,24 @@ export const machines = sqliteTable("machines", {
|
||||
aptProxyUrl: text("apt_proxy_url"),
|
||||
status: text("status").notNull().default("unknown"),
|
||||
lastCheckedAt: text("last_checked_at"),
|
||||
lastSeenAt: text("last_seen_at"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at"),
|
||||
deletedAt: text("deleted_at"),
|
||||
});
|
||||
|
||||
export const snapshots = sqliteTable("snapshots", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").notNull().default("apt_update_analyze"),
|
||||
schemaVersion: integer("schema_version").notNull().default(1),
|
||||
checkedAt: text("checked_at").notNull(),
|
||||
status: text("status").notNull(),
|
||||
payloadJson: text("payload_json").notNull(),
|
||||
importantJson: text("important_json"),
|
||||
rawLogPath: text("raw_log_path"),
|
||||
rawArtifactId: text("raw_artifact_id"),
|
||||
sourceJobId: text("source_job_id"),
|
||||
});
|
||||
|
||||
export const executions = sqliteTable("executions", {
|
||||
@@ -30,10 +45,184 @@ export const executions = sqliteTable("executions", {
|
||||
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
|
||||
action: text("action").notNull(),
|
||||
mode: text("mode").notNull().default("manual"),
|
||||
schemaVersion: integer("schema_version").notNull().default(1),
|
||||
startedAt: text("started_at").notNull(),
|
||||
finishedAt: text("finished_at"),
|
||||
status: text("status").notNull(),
|
||||
requestId: text("request_id"),
|
||||
jobId: text("job_id"),
|
||||
resultJson: text("result_json"),
|
||||
importantJson: text("important_json"),
|
||||
reportPath: text("report_path"),
|
||||
rawLogPath: text("raw_log_path"),
|
||||
reportId: text("report_id"),
|
||||
exitCode: integer("exit_code"),
|
||||
errorKind: text("error_kind"),
|
||||
errorMessage: text("error_message"),
|
||||
});
|
||||
|
||||
export const machineState = sqliteTable("machine_state", {
|
||||
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
|
||||
status: text("status").notNull(),
|
||||
aptStatus: text("apt_status"),
|
||||
aptUpdatesCount: integer("apt_updates_count").notNull().default(0),
|
||||
aptRebootRequired: integer("apt_reboot_required").notNull().default(0),
|
||||
aptLastAnalyzeAt: text("apt_last_analyze_at"),
|
||||
dockerStatus: text("docker_status"),
|
||||
dockerInstalled: integer("docker_installed").notNull().default(0),
|
||||
dockerStacksCount: integer("docker_stacks_count").notNull().default(0),
|
||||
dockerUpdatesCount: integer("docker_updates_count").notNull().default(0),
|
||||
dockerPruneAvailable: integer("docker_prune_available").notNull().default(0),
|
||||
postInstallStatus: text("post_install_status"),
|
||||
metricsLastCollectedAt: text("metrics_last_collected_at"),
|
||||
cpuLoad1: real("cpu_load1"),
|
||||
memoryUsedPercent: real("memory_used_percent"),
|
||||
rootUsedPercent: real("root_used_percent"),
|
||||
diskWarningsCount: integer("disk_warnings_count").notNull().default(0),
|
||||
hardwareWarningsCount: integer("hardware_warnings_count").notNull().default(0),
|
||||
runningJobId: text("running_job_id"),
|
||||
lastErrorKind: text("last_error_kind"),
|
||||
lastErrorMessage: text("last_error_message"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const machineHardware = sqliteTable("machine_hardware", {
|
||||
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
|
||||
probeSnapshotId: text("probe_snapshot_id"),
|
||||
cpuModel: text("cpu_model"),
|
||||
cpuCores: integer("cpu_cores"),
|
||||
memoryBytes: integer("memory_bytes"),
|
||||
gpusJson: text("gpus_json"),
|
||||
disksJson: text("disks_json"),
|
||||
networkJson: text("network_json"),
|
||||
firmwareJson: text("firmware_json"),
|
||||
driverJson: text("driver_json"),
|
||||
warningsJson: text("warnings_json"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const machineMetricsLatest = sqliteTable("machine_metrics_latest", {
|
||||
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
|
||||
snapshotId: text("snapshot_id"),
|
||||
collectedAt: text("collected_at").notNull(),
|
||||
cpuLoad1: real("cpu_load1"),
|
||||
cpuLoad5: real("cpu_load5"),
|
||||
cpuCores: integer("cpu_cores"),
|
||||
memoryTotalBytes: integer("memory_total_bytes"),
|
||||
memoryUsedBytes: integer("memory_used_bytes"),
|
||||
memoryAvailableBytes: integer("memory_available_bytes"),
|
||||
memoryUsedPercent: real("memory_used_percent"),
|
||||
filesystemsJson: text("filesystems_json"),
|
||||
rootUsedPercent: real("root_used_percent"),
|
||||
warningsJson: text("warnings_json"),
|
||||
});
|
||||
|
||||
export const machineEvents = sqliteTable("machine_events", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
|
||||
eventType: text("event_type").notNull(),
|
||||
severity: text("severity").notNull(), // info | warning | error
|
||||
createdAt: text("created_at").notNull(),
|
||||
actorType: text("actor_type"), // user | system | schedule | hermes
|
||||
actorId: text("actor_id"),
|
||||
snapshotId: text("snapshot_id"),
|
||||
executionId: text("execution_id"),
|
||||
jobId: text("job_id"),
|
||||
message: text("message"),
|
||||
payloadJson: text("payload_json"),
|
||||
});
|
||||
|
||||
export const importantMessages = sqliteTable("important_messages", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
|
||||
source: text("source").notNull(), // apt | docker | post_install | ssh | system
|
||||
category: text("category").notNull(), // error | warning | future_major_change | ...
|
||||
severity: text("severity").notNull(),
|
||||
packageName: text("package_name"),
|
||||
component: text("component"),
|
||||
message: text("message").notNull(),
|
||||
rawLineRef: text("raw_line_ref"),
|
||||
snapshotId: text("snapshot_id"),
|
||||
executionId: text("execution_id"),
|
||||
firstSeenAt: text("first_seen_at").notNull(),
|
||||
lastSeenAt: text("last_seen_at").notNull(),
|
||||
acknowledged: integer("acknowledged").notNull().default(0),
|
||||
acknowledgedAt: text("acknowledged_at"),
|
||||
acknowledgedBy: text("acknowledged_by"),
|
||||
payloadJson: text("payload_json"),
|
||||
});
|
||||
|
||||
export const reports = sqliteTable("reports", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
|
||||
executionId: text("execution_id"),
|
||||
kind: text("kind").notNull(), // machine | global | cleanup | hermes
|
||||
title: text("title").notNull(),
|
||||
path: text("path").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
pinned: integer("pinned").notNull().default(0),
|
||||
summaryJson: text("summary_json"),
|
||||
});
|
||||
|
||||
export const rawArtifacts = sqliteTable("raw_artifacts", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").notNull(), // raw_log | rendered_template | export | screenshot
|
||||
path: text("path").notNull(),
|
||||
bytes: integer("bytes"),
|
||||
sha256: text("sha256"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
expiresAt: text("expires_at"),
|
||||
pinned: integer("pinned").notNull().default(0),
|
||||
redacted: integer("redacted").notNull().default(1),
|
||||
retentionPolicy: text("retention_policy"), // default | failed | pinned | short
|
||||
deletedAt: text("deleted_at"),
|
||||
deleteReason: text("delete_reason"),
|
||||
metadataJson: text("metadata_json"),
|
||||
});
|
||||
|
||||
// --- Préexistant (WIP api_clients) : NE PAS supprimer ---
|
||||
export const apiClients = sqliteTable(
|
||||
"api_clients",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
tokenPrefix: text("token_prefix").notNull(),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
scopesJson: text("scopes_json").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
revokedAt: text("revoked_at"),
|
||||
},
|
||||
(table) => ({
|
||||
tokenHashIdx: uniqueIndex("api_clients_token_hash_unique").on(table.tokenHash),
|
||||
}),
|
||||
);
|
||||
|
||||
// --- Phase 2 : credentials isolés (non destructif) ---
|
||||
export const machineCredentials = sqliteTable("machine_credentials", {
|
||||
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
|
||||
authMethod: text("auth_method").notNull(), // password | ssh_key
|
||||
encPassword: text("enc_password"),
|
||||
encSudoPassword: text("enc_sudo_password"),
|
||||
encPrivateKey: text("enc_private_key"),
|
||||
encKeyPassphrase: text("enc_key_passphrase"),
|
||||
sudoMode: text("sudo_mode").notNull(), // same_as_ssh | separate | none
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
lastTestAt: text("last_test_at"),
|
||||
status: text("status"), // ok | error | unknown
|
||||
});
|
||||
|
||||
export const machineHostKeys = sqliteTable("machine_host_keys", {
|
||||
id: text("id").primaryKey(),
|
||||
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
|
||||
hostname: text("hostname").notNull(),
|
||||
port: integer("port").notNull(),
|
||||
keyType: text("key_type"),
|
||||
fingerprintSha256: text("fingerprint_sha256").notNull(),
|
||||
publicKey: text("public_key"),
|
||||
status: text("status").notNull(), // approved | changed | rejected | unknown
|
||||
firstSeenAt: text("first_seen_at").notNull(),
|
||||
lastSeenAt: text("last_seen_at").notNull(),
|
||||
});
|
||||
|
||||
@@ -14,6 +14,10 @@ env.requireMasterKey();
|
||||
runMigrations();
|
||||
|
||||
const app = new Hono();
|
||||
app.onError((err, c) => {
|
||||
console.error("[api]", err.message);
|
||||
return c.json({ error: err.message || "Erreur serveur" }, 500);
|
||||
});
|
||||
app.route("/api", api);
|
||||
app.get("/health", (c) => c.json({ ok: true }));
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
import { Hono } from "hono";
|
||||
import { machinesRoutes } from "./machines.js";
|
||||
import { actionsRoutes } from "./actions.js";
|
||||
import { getServerCapabilities } from "../services/capabilities.js";
|
||||
import { getSystemMetrics, getSystemStatus } from "../services/system.js";
|
||||
|
||||
export const api = new Hono();
|
||||
api.get("/capabilities", (c) => c.json(getServerCapabilities()));
|
||||
api.get("/system/status", (c) => c.json(getSystemStatus()));
|
||||
api.get("/system/metrics", (c) => c.json(getSystemMetrics()));
|
||||
api.route("/machines", machinesRoutes);
|
||||
api.route("/machines", actionsRoutes);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
===SU:APT_UPDATE===
|
||||
Hit:1 http://deb.debian.org/debian bookworm InRelease
|
||||
Reading package lists...
|
||||
===SU:APT_SIM_UPGRADE===
|
||||
Reading package lists...
|
||||
Building dependency tree...
|
||||
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
|
||||
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
|
||||
===SU:APT_SIM_DISTUPGRADE===
|
||||
Reading package lists...
|
||||
Inst libc6 [2.31-13] (2.31-13+deb11u5 Debian:11.6/stable [amd64])
|
||||
Inst newdep (1.0.0 Debian:11.6/stable [all])
|
||||
Remv oldpkg [3.2-1]
|
||||
Conf libc6 (2.31-13+deb11u5 Debian:11.6/stable [amd64])
|
||||
===SU:APT_HELD===
|
||||
frozenpkg
|
||||
===SU:REBOOT===
|
||||
REBOOT_REQUIRED=1
|
||||
PKG=linux-image-amd64
|
||||
===SU:EXIT=0===
|
||||
@@ -0,0 +1,64 @@
|
||||
// server/services/apiClients.test.ts
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: {},
|
||||
schema: { apiClients: {} },
|
||||
}));
|
||||
|
||||
vi.mock("../env.js", () => ({ env: { requireMasterKey: vi.fn() } }));
|
||||
|
||||
import { apiClientInternals } from "./apiClients.js";
|
||||
|
||||
describe("apiClientInternals", () => {
|
||||
it("retombe sur read quand aucun scope n'est fourni", () => {
|
||||
expect(apiClientInternals.normalizeScopes([])).toEqual(["read"]);
|
||||
});
|
||||
|
||||
it("déduplique les scopes en gardant l'ordre", () => {
|
||||
expect(apiClientInternals.normalizeScopes(["read", "operate", "read"])).toEqual([
|
||||
"read",
|
||||
"operate",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejette un scope inconnu", () => {
|
||||
expect(() => apiClientInternals.normalizeScopes(["root" as never])).toThrow(
|
||||
"Scope API inconnu: root",
|
||||
);
|
||||
});
|
||||
|
||||
it("convertit une ligne DB en vue sans token hash", () => {
|
||||
const view = apiClientInternals.toView({
|
||||
id: "client_1",
|
||||
name: "App locale",
|
||||
tokenPrefix: "su_abcdefghi",
|
||||
tokenHash: "hash-secret",
|
||||
scopesJson: '["read","operate"]',
|
||||
createdAt: "2026-06-05T08:00:00.000Z",
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
expect(view).toEqual({
|
||||
id: "client_1",
|
||||
name: "App locale",
|
||||
tokenPrefix: "su_abcdefghi",
|
||||
scopes: ["read", "operate"],
|
||||
createdAt: "2026-06-05T08:00:00.000Z",
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
});
|
||||
expect(JSON.stringify(view)).not.toContain("hash-secret");
|
||||
});
|
||||
|
||||
it("applique les scopes par capacité", () => {
|
||||
expect(apiClientInternals.hasApiScope(["read"], "read")).toBe(true);
|
||||
expect(apiClientInternals.hasApiScope(["read"], "operate")).toBe(false);
|
||||
expect(apiClientInternals.hasApiScope(["operate"], "read")).toBe(true);
|
||||
expect(apiClientInternals.hasApiScope(["operate"], "operate")).toBe(true);
|
||||
expect(apiClientInternals.hasApiScope(["debug"], "debug")).toBe(true);
|
||||
expect(apiClientInternals.hasApiScope(["admin"], "debug")).toBe(true);
|
||||
expect(apiClientInternals.hasApiScope(["admin"], "admin")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// server/services/apiClients.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ApiClientScope, ApiClientView, CreatedApiClient } from "@shared/types.js";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { env } from "../env.js";
|
||||
import {
|
||||
generateApiToken,
|
||||
hashApiToken,
|
||||
tokenPrefix,
|
||||
verifyApiToken,
|
||||
} from "../crypto/apiTokens.js";
|
||||
|
||||
type ApiClientRow = typeof schema.apiClients.$inferSelect;
|
||||
|
||||
const ALLOWED_SCOPES = new Set<ApiClientScope>(["read", "operate", "admin", "debug"]);
|
||||
|
||||
export interface CreateApiClientInput {
|
||||
name: string;
|
||||
scopes: ApiClientScope[];
|
||||
}
|
||||
|
||||
function normalizeScopes(scopes: ApiClientScope[]): ApiClientScope[] {
|
||||
const unique = [...new Set(scopes)];
|
||||
if (unique.length === 0) return ["read"];
|
||||
for (const scope of unique) {
|
||||
if (!ALLOWED_SCOPES.has(scope)) throw new Error(`Scope API inconnu: ${scope}`);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function scopesFromJson(json: string): ApiClientScope[] {
|
||||
const parsed = JSON.parse(json) as ApiClientScope[];
|
||||
return normalizeScopes(parsed);
|
||||
}
|
||||
|
||||
function toView(row: ApiClientRow): ApiClientView {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
tokenPrefix: row.tokenPrefix,
|
||||
scopes: scopesFromJson(row.scopesJson),
|
||||
createdAt: row.createdAt,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
revokedAt: row.revokedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function createApiClient(input: CreateApiClientInput, now = new Date()): CreatedApiClient {
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error("Le nom du client API est obligatoire");
|
||||
|
||||
const scopes = normalizeScopes(input.scopes);
|
||||
const token = generateApiToken();
|
||||
const pepper = env.requireMasterKey();
|
||||
const row: ApiClientRow = {
|
||||
id: randomUUID(),
|
||||
name,
|
||||
tokenPrefix: tokenPrefix(token),
|
||||
tokenHash: hashApiToken(token, pepper),
|
||||
scopesJson: JSON.stringify(scopes),
|
||||
createdAt: now.toISOString(),
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
};
|
||||
|
||||
db.insert(schema.apiClients).values(row).run();
|
||||
return { client: toView(row), token };
|
||||
}
|
||||
|
||||
export function listApiClients(): ApiClientView[] {
|
||||
return db.select().from(schema.apiClients).all().map(toView);
|
||||
}
|
||||
|
||||
export function revokeApiClient(id: string, now = new Date()): ApiClientView | null {
|
||||
const existing = db.select().from(schema.apiClients).where(eq(schema.apiClients.id, id)).get();
|
||||
if (!existing) return null;
|
||||
|
||||
const revokedAt = now.toISOString();
|
||||
db.update(schema.apiClients).set({ revokedAt }).where(eq(schema.apiClients.id, id)).run();
|
||||
return toView({ ...existing, revokedAt });
|
||||
}
|
||||
|
||||
export function authenticateApiToken(token: string, now = new Date()): ApiClientView | null {
|
||||
const pepper = env.requireMasterKey();
|
||||
const tokenHash = hashApiToken(token, pepper);
|
||||
const row = db
|
||||
.select()
|
||||
.from(schema.apiClients)
|
||||
.where(eq(schema.apiClients.tokenHash, tokenHash))
|
||||
.get();
|
||||
|
||||
if (!row || row.revokedAt) return null;
|
||||
if (!verifyApiToken(token, row.tokenHash, pepper)) return null;
|
||||
|
||||
const lastUsedAt = now.toISOString();
|
||||
db.update(schema.apiClients)
|
||||
.set({ lastUsedAt })
|
||||
.where(eq(schema.apiClients.id, row.id))
|
||||
.run();
|
||||
|
||||
return toView({ ...row, lastUsedAt });
|
||||
}
|
||||
|
||||
export function hasApiScope(scopes: ApiClientScope[], required: ApiClientScope): boolean {
|
||||
if (scopes.includes("admin")) return true;
|
||||
if (required === "read") return scopes.length > 0;
|
||||
if (required === "operate") return scopes.includes("operate");
|
||||
if (required === "debug") return scopes.includes("debug");
|
||||
return false;
|
||||
}
|
||||
|
||||
export const apiClientInternals = {
|
||||
normalizeScopes,
|
||||
scopesFromJson,
|
||||
toView,
|
||||
hasApiScope,
|
||||
};
|
||||
@@ -2,10 +2,17 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseAptSimulate, parseRebootRequired } from "./aptParse.js";
|
||||
import { parseAptSimulate, parseRebootRequired, parseAptRemovals, parseHeld, parseRebootDetail, buildAptSnapshotDetail, parseDpkgList, buildAptExecutionResult } from "./aptParse.js";
|
||||
|
||||
const raw = readFileSync(fileURLToPath(new URL("./__fixtures__/apt-simulate.txt", import.meta.url)), "utf8");
|
||||
|
||||
const ua = readFileSync(fileURLToPath(new URL("./__fixtures__/apt-update-analyze.txt", import.meta.url)), "utf8");
|
||||
function section(rawInput: string, start: string, end: string): string {
|
||||
const s = rawInput.indexOf(start); if (s === -1) return "";
|
||||
const from = s + start.length; const e = rawInput.indexOf(end, from);
|
||||
return rawInput.slice(from, e === -1 ? undefined : e).trim();
|
||||
}
|
||||
|
||||
describe("parseAptSimulate", () => {
|
||||
it("extrait les paquets upgradables avec versions et origine", () => {
|
||||
const pkgs = parseAptSimulate(raw);
|
||||
@@ -28,3 +35,74 @@ describe("parseRebootRequired", () => {
|
||||
expect(parseRebootRequired("rien")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAptRemovals", () => {
|
||||
it("extrait les suppressions Remv", () => {
|
||||
expect(parseAptRemovals("Remv oldpkg [3.2-1]\nInst x [1] (2 Y [amd64])"))
|
||||
.toEqual([{ name: "oldpkg", currentVersion: "3.2-1" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHeld", () => {
|
||||
it("liste les paquets retenus non vides", () => {
|
||||
expect(parseHeld("frozenpkg\n\n other ")).toEqual(["frozenpkg", "other"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRebootDetail", () => {
|
||||
it("lit le flag et les paquets reboot", () => {
|
||||
expect(parseRebootDetail("REBOOT_REQUIRED=1\nPKG=linux-image-amd64\nPKG=foo"))
|
||||
.toEqual({ rebootRequired: true, pkgs: ["linux-image-amd64", "foo"] });
|
||||
expect(parseRebootDetail("REBOOT_REQUIRED=0")).toEqual({ rebootRequired: false, pkgs: [] });
|
||||
});
|
||||
});
|
||||
|
||||
const BEFORE = "libc6\t2.31-13\tamd64\noldpkg\t3.2-1\tamd64\nstable\t1.0\tamd64";
|
||||
const AFTER = "libc6\t2.31-14\tamd64\nnewpkg\t1.0.0\tall\nstable\t1.0\tamd64";
|
||||
|
||||
describe("parseDpkgList", () => {
|
||||
it("indexe par package:arch", () => {
|
||||
const m = parseDpkgList("libc6\t2.31-13\tamd64");
|
||||
expect(m["libc6:amd64"]).toEqual({ version: "2.31-13", arch: "amd64" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAptExecutionResult", () => {
|
||||
it("calcule le diff réel before/after", () => {
|
||||
const r = buildAptExecutionResult(BEFORE, AFTER, "REBOOT_REQUIRED=1");
|
||||
expect(r.applied.find((c) => c.name === "libc6")).toMatchObject({ operation: "upgraded", fromVersion: "2.31-13", toVersion: "2.31-14" });
|
||||
expect(r.installed.map((c) => c.name)).toEqual(["newpkg"]);
|
||||
expect(r.removed.map((c) => c.name)).toEqual(["oldpkg"]);
|
||||
expect(r.applied.some((c) => c.name === "stable")).toBe(false); // unchanged exclu
|
||||
expect(r.rebootRequiredAfterRun).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAptSnapshotDetail", () => {
|
||||
it("construit le détail enrichi depuis les sections", () => {
|
||||
const detail = buildAptSnapshotDetail({
|
||||
upgradeSim: section(ua, "===SU:APT_SIM_UPGRADE===", "===SU:APT_SIM_DISTUPGRADE==="),
|
||||
distUpgradeSim: section(ua, "===SU:APT_SIM_DISTUPGRADE===", "===SU:APT_HELD==="),
|
||||
heldRaw: section(ua, "===SU:APT_HELD===", "===SU:REBOOT==="),
|
||||
rebootRaw: section(ua, "===SU:REBOOT===", "===SU:EXIT"),
|
||||
updateFailed: false,
|
||||
});
|
||||
expect(detail.enabled).toBe(true);
|
||||
expect(detail.count).toBe(2); // 2 Inst en dist-upgrade
|
||||
expect(detail.upgradeCount).toBe(1); // 1 Inst en upgrade
|
||||
expect(detail.distUpgradeCount).toBe(2);
|
||||
expect(detail.rebootRequired).toBe(true);
|
||||
expect(detail.rebootPkgs).toEqual(["linux-image-amd64"]);
|
||||
expect(detail.held).toEqual(["frozenpkg"]);
|
||||
expect(detail.removed?.map((r) => r.name)).toEqual(["oldpkg"]);
|
||||
expect(detail.installed?.map((p) => p.name)).toEqual(["newdep"]);
|
||||
expect(detail.status).toBe("warning"); // car removed + held non vides
|
||||
});
|
||||
|
||||
it("status=updates_available sans removed/held, error si update échoue", () => {
|
||||
const ok = buildAptSnapshotDetail({ upgradeSim: "Inst a [1] (2 Y [amd64])", distUpgradeSim: "Inst a [1] (2 Y [amd64])", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: false });
|
||||
expect(ok.status).toBe("updates_available");
|
||||
const err = buildAptSnapshotDetail({ upgradeSim: "", distUpgradeSim: "", heldRaw: "", rebootRaw: "REBOOT_REQUIRED=0", updateFailed: true });
|
||||
expect(err.status).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
+115
-1
@@ -1,5 +1,5 @@
|
||||
// server/services/aptParse.ts
|
||||
import type { AptPackage } from "@shared/types.js";
|
||||
import type { AptPackage, AptSnapshotDetail, SnapshotStatus, AptChange, AptExecutionResult } from "@shared/types.js";
|
||||
|
||||
// Exemple de ligne:
|
||||
// Inst pve-manager [8.4-1] (8.4-3 Proxmox VE:8.x [amd64])
|
||||
@@ -24,3 +24,117 @@ export function parseAptSimulate(raw: string): AptPackage[] {
|
||||
export function parseRebootRequired(raw: string): boolean {
|
||||
return /REBOOT_REQUIRED=1/.test(raw);
|
||||
}
|
||||
|
||||
const REMV_RE = /^Remv (\S+)(?: \[([^\]]+)\])?/;
|
||||
export function parseAptRemovals(raw: string): { name: string; currentVersion: string | null }[] {
|
||||
const out: { name: string; currentVersion: string | null }[] = [];
|
||||
for (const line of raw.split("\n")) {
|
||||
const m = REMV_RE.exec(line.trimEnd());
|
||||
if (m) out.push({ name: m[1]!, currentVersion: m[2] ?? null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseHeld(raw: string): string[] {
|
||||
return raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
||||
}
|
||||
|
||||
export function parseRebootDetail(raw: string): { rebootRequired: boolean; pkgs: string[] } {
|
||||
const rebootRequired = /REBOOT_REQUIRED=1/.test(raw);
|
||||
const pkgs: string[] = [];
|
||||
for (const line of raw.split("\n")) {
|
||||
const m = /^PKG=(.+)$/.exec(line.trim());
|
||||
if (m) pkgs.push(m[1]!.trim());
|
||||
}
|
||||
return { rebootRequired, pkgs };
|
||||
}
|
||||
|
||||
export interface AptSections {
|
||||
upgradeSim: string;
|
||||
distUpgradeSim: string;
|
||||
heldRaw: string;
|
||||
rebootRaw: string;
|
||||
updateFailed: boolean;
|
||||
}
|
||||
|
||||
export function parseDpkgList(raw: string): Record<string, { version: string; arch: string }> {
|
||||
const out: Record<string, { version: string; arch: string }> = {};
|
||||
for (const line of raw.split("\n")) {
|
||||
const parts = line.split("\t");
|
||||
if (parts.length < 3) continue;
|
||||
const [name, version, arch] = [parts[0]!.trim(), parts[1]!.trim(), parts[2]!.trim()];
|
||||
if (!name) continue;
|
||||
out[`${name}:${arch}`] = { version, arch };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Diff dpkg réel before/after → AptExecutionResult (planned/held vides : portés par le snapshot). */
|
||||
export function buildAptExecutionResult(beforeRaw: string, afterRaw: string, rebootRaw: string): AptExecutionResult {
|
||||
const before = parseDpkgList(beforeRaw);
|
||||
const after = parseDpkgList(afterRaw);
|
||||
const applied: AptChange[] = [];
|
||||
const installed: AptChange[] = [];
|
||||
const removed: AptChange[] = [];
|
||||
|
||||
for (const key of Object.keys(after)) {
|
||||
const [name] = key.split(":");
|
||||
const a = after[key]!;
|
||||
const b = before[key];
|
||||
if (!b) {
|
||||
const change: AptChange = { name: name!, arch: a.arch, fromVersion: null, toVersion: a.version, operation: "installed" };
|
||||
installed.push(change); applied.push(change);
|
||||
} else if (b.version !== a.version) {
|
||||
applied.push({ name: name!, arch: a.arch, fromVersion: b.version, toVersion: a.version, operation: "upgraded" });
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(before)) {
|
||||
if (!after[key]) {
|
||||
const [name] = key.split(":");
|
||||
const b = before[key]!;
|
||||
const change: AptChange = { name: name!, arch: b.arch, fromVersion: b.version, toVersion: null, operation: "removed" };
|
||||
removed.push(change); applied.push(change);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
planned: [],
|
||||
applied,
|
||||
installed,
|
||||
removed,
|
||||
held: [],
|
||||
rebootRequiredAfterRun: /REBOOT_REQUIRED=1/.test(rebootRaw),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAptSnapshotDetail(s: AptSections): AptSnapshotDetail {
|
||||
const upgradePkgs = parseAptSimulate(s.upgradeSim);
|
||||
const distPkgs = parseAptSimulate(s.distUpgradeSim);
|
||||
const installed: AptPackage[] = distPkgs
|
||||
.filter((p) => p.currentVersion === null)
|
||||
.map((p) => ({ ...p, operation: "install" as const }));
|
||||
const removed: AptPackage[] = parseAptRemovals(s.distUpgradeSim).map((r) => ({
|
||||
name: r.name, currentVersion: r.currentVersion, targetVersion: "", origin: null, operation: "remove" as const,
|
||||
}));
|
||||
const held = parseHeld(s.heldRaw);
|
||||
const { rebootRequired, pkgs: rebootPkgs } = parseRebootDetail(s.rebootRaw);
|
||||
|
||||
let status: SnapshotStatus = "ok";
|
||||
if (s.updateFailed) status = "error";
|
||||
else if (removed.length > 0 || held.length > 0) status = "warning";
|
||||
else if (distPkgs.length > 0) status = "updates_available";
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
count: distPkgs.length,
|
||||
rebootRequired,
|
||||
packages: distPkgs,
|
||||
status,
|
||||
upgradeCount: upgradePkgs.length,
|
||||
distUpgradeCount: distPkgs.length,
|
||||
installed,
|
||||
removed,
|
||||
held,
|
||||
rebootPkgs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// server/services/capabilities.test.ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getServerCapabilities } from "./capabilities.js";
|
||||
|
||||
describe("getServerCapabilities", () => {
|
||||
it("publie un contrat stable sans annoncer les fonctions futures non implémentées", () => {
|
||||
const caps = getServerCapabilities(new Date("2026-06-05T08:00:00.000Z"));
|
||||
|
||||
expect(caps).toMatchObject({
|
||||
app: "system_update",
|
||||
apiVersion: "1",
|
||||
generatedAt: "2026-06-05T08:00:00.000Z",
|
||||
features: {
|
||||
machines: true,
|
||||
actions: true,
|
||||
terminalOutput: true,
|
||||
docker: false,
|
||||
hermes: false,
|
||||
interactiveSsh: false,
|
||||
authTokens: false,
|
||||
},
|
||||
endpoints: {
|
||||
capabilities: "GET /api/capabilities",
|
||||
systemStatus: "GET /api/system/status",
|
||||
systemMetrics: "GET /api/system/metrics",
|
||||
machines: "GET /api/machines",
|
||||
terminalOutputWs: "WS /api/ws/machines/:id/output",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// server/services/capabilities.ts
|
||||
import type { ServerCapabilities } from "@shared/types.js";
|
||||
|
||||
export function getServerCapabilities(now = new Date()): ServerCapabilities {
|
||||
return {
|
||||
app: "system_update",
|
||||
apiVersion: "1",
|
||||
generatedAt: now.toISOString(),
|
||||
features: {
|
||||
machines: true,
|
||||
machineSnapshots: true,
|
||||
actions: true,
|
||||
aptFullUpgrade: true,
|
||||
reboot: true,
|
||||
reports: true,
|
||||
terminalOutput: true,
|
||||
interactiveSsh: false,
|
||||
docker: false,
|
||||
postInstall: false,
|
||||
hermes: false,
|
||||
settings: false,
|
||||
scheduledJobs: false,
|
||||
authTokens: false,
|
||||
},
|
||||
endpoints: {
|
||||
capabilities: "GET /api/capabilities",
|
||||
systemStatus: "GET /api/system/status",
|
||||
systemMetrics: "GET /api/system/metrics",
|
||||
machines: "GET /api/machines",
|
||||
machineSnapshot: "GET /api/machines/:id/snapshot",
|
||||
machineRefresh: "POST /api/machines/:id/refresh",
|
||||
machineActions: "POST /api/machines/:id/actions",
|
||||
machineExecutions: "GET /api/machines/:id/executions",
|
||||
executionReport: "GET /api/machines/:id/executions/:execId/report",
|
||||
terminalOutputWs: "WS /api/ws/machines/:id/output",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveCreds } from "./credentials.js";
|
||||
|
||||
describe("resolveCreds", () => {
|
||||
it("préfère la ligne machine_credentials", () => {
|
||||
const out = resolveCreds(
|
||||
{ encPassword: "M_PWD", encSudoPassword: null }, // machines (legacy)
|
||||
{ encPassword: "C_PWD", encSudoPassword: "C_SUDO" }, // machine_credentials
|
||||
);
|
||||
expect(out).toEqual({ encPassword: "C_PWD", encSudoPassword: "C_SUDO" });
|
||||
});
|
||||
it("retombe sur machines si pas de credentials", () => {
|
||||
const out = resolveCreds({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" }, null);
|
||||
expect(out).toEqual({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// server/services/credentials.ts
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.js";
|
||||
|
||||
interface EncPair { encPassword: string | null; encSudoPassword: string | null; }
|
||||
|
||||
/** Résout la source des secrets : machine_credentials prioritaire, sinon legacy machines (fonction pure). */
|
||||
export function resolveCreds(legacy: EncPair, creds: EncPair | null): EncPair {
|
||||
if (creds && creds.encPassword) return { encPassword: creds.encPassword, encSudoPassword: creds.encSudoPassword };
|
||||
return { encPassword: legacy.encPassword, encSudoPassword: legacy.encSudoPassword };
|
||||
}
|
||||
|
||||
/** Écrit (insert/replace) la ligne machine_credentials pour une machine (secrets déjà chiffrés). */
|
||||
export function writeCredentials(input: {
|
||||
machineId: string;
|
||||
encPassword: string | null;
|
||||
encSudoPassword: string | null;
|
||||
}): void {
|
||||
const now = new Date().toISOString();
|
||||
db.insert(schema.machineCredentials)
|
||||
.values({
|
||||
machineId: input.machineId,
|
||||
authMethod: "password",
|
||||
encPassword: input.encPassword,
|
||||
encSudoPassword: input.encSudoPassword,
|
||||
sudoMode: input.encSudoPassword ? "separate" : "same_as_ssh",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: "unknown",
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.machineCredentials.machineId,
|
||||
set: { encPassword: input.encPassword, encSudoPassword: input.encSudoPassword, updatedAt: now },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
/** Lit la ligne machine_credentials (ou null). */
|
||||
export function readCredentials(machineId: string): EncPair | null {
|
||||
const row = db.select().from(schema.machineCredentials)
|
||||
.where(eq(schema.machineCredentials.machineId, machineId)).get();
|
||||
return row ? { encPassword: row.encPassword, encSudoPassword: row.encSudoPassword } : null;
|
||||
}
|
||||
|
||||
/** Backfill idempotent : crée une ligne machine_credentials pour chaque machine qui n'en a pas. */
|
||||
export function backfillCredentials(): number {
|
||||
const machines = db.select().from(schema.machines).all();
|
||||
let created = 0;
|
||||
for (const m of machines) {
|
||||
if (readCredentials(m.id)) continue;
|
||||
writeCredentials({ machineId: m.id, encPassword: m.encPassword, encSudoPassword: m.encSudoPassword });
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
+102
-7
@@ -1,7 +1,7 @@
|
||||
// server/services/execute.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, writeFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { env } from "../env.js";
|
||||
@@ -9,15 +9,22 @@ import { getMachineRow, getCreds } from "./machines.js";
|
||||
import { renderTemplate } from "../templates/render.js";
|
||||
import { reduceAptLines } from "../templates/aptReduce.js";
|
||||
import { runScriptSudo } from "../ssh/client.js";
|
||||
import { parseRebootRequired } from "./aptParse.js";
|
||||
import { parseRebootRequired, buildAptExecutionResult } from "./aptParse.js";
|
||||
import { parseBootIdBefore, verifyReboot } from "./rebootVerify.js";
|
||||
import type { RebootResult } from "@shared/types.js";
|
||||
import { extractSection } from "./refresh.js";
|
||||
import { buildReportMarkdown } from "./report.js";
|
||||
import { outputHub } from "../ws/outputHub.js";
|
||||
import type { ActionType, ExecutionResult, ExecutionStatus } from "@shared/types.js";
|
||||
import { upsertMachineState, recordEvent } from "./machineState.js";
|
||||
import type { ActionType, AptExecutionResult, ExecutionResult, ExecutionStatus } from "@shared/types.js";
|
||||
|
||||
const TEMPLATE_FOR: Record<ActionType, string> = {
|
||||
const TEMPLATE_FOR: Partial<Record<ActionType, string>> = {
|
||||
apt_full_upgrade: "apt/full-upgrade.sh.tpl",
|
||||
apt_upgrade: "apt/upgrade.sh.tpl",
|
||||
apt_autoremove: "apt/autoremove.sh.tpl",
|
||||
apt_clean: "apt/clean.sh.tpl",
|
||||
reboot: "apt/reboot.sh.tpl",
|
||||
reboot_verified: "apt/reboot.sh.tpl",
|
||||
};
|
||||
|
||||
export async function runAction(machineId: string, action: ActionType): Promise<ExecutionResult> {
|
||||
@@ -31,9 +38,14 @@ export async function runAction(machineId: string, action: ActionType): Promise<
|
||||
db.insert(schema.executions).values({
|
||||
id: executionId, machineId, action, mode: "manual", startedAt, status: "running",
|
||||
}).run();
|
||||
upsertMachineState(machineId, { status: "running", runningJobId: executionId });
|
||||
|
||||
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
|
||||
const script = renderTemplate(TEMPLATE_FOR[action], { aptProxy: proxy });
|
||||
const rel = TEMPLATE_FOR[action];
|
||||
if (!rel) throw new Error("Action sans template: " + action);
|
||||
const script = renderTemplate(rel, { aptProxy: proxy });
|
||||
|
||||
const inactivity = action === "reboot" ? 0 : 600000;
|
||||
|
||||
let raw = "";
|
||||
let status: ExecutionStatus = "ok";
|
||||
@@ -41,7 +53,7 @@ export async function runAction(machineId: string, action: ActionType): Promise<
|
||||
const res = await runScriptSudo(getCreds(m), script, (c) => {
|
||||
raw += c;
|
||||
outputHub.publish(machineId, c);
|
||||
});
|
||||
}, inactivity);
|
||||
raw = res.stdout;
|
||||
if (/===SU:EXIT=\d+===/.test(raw) && !/===SU:EXIT=0===/.test(raw)) {
|
||||
status = "error";
|
||||
@@ -51,9 +63,41 @@ export async function runAction(machineId: string, action: ActionType): Promise<
|
||||
raw += `\n[ERREUR] ${(err as Error).message}\n`;
|
||||
}
|
||||
|
||||
// Vérification réseau du reboot (nouvelle action reboot_verified, jalon SJ-3).
|
||||
let rebootResult: RebootResult | undefined;
|
||||
if (action === "reboot_verified") {
|
||||
const beforeBootId = parseBootIdBefore(raw);
|
||||
outputHub.publish(machineId, "\n[reboot] attente du redémarrage...\n");
|
||||
rebootResult = await verifyReboot(getCreds(m), { beforeBootId, requestedAt: startedAt });
|
||||
if (rebootResult.status !== "ok") status = "error";
|
||||
if (rebootResult.status === "ok") {
|
||||
recordEvent({
|
||||
machineId,
|
||||
eventType: "reboot_verified",
|
||||
severity: "info",
|
||||
executionId,
|
||||
message: `Reboot vérifié en ${rebootResult.waitedSeconds}s (boot_id changé)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
const rebootRequired = parseRebootRequired(extractSection(raw, "===SU:REBOOT===", "===SU:EXIT") || raw);
|
||||
|
||||
// Diff dpkg réel (si le template a émis DPKG_BEFORE + DPKG_AFTER).
|
||||
let aptResult: AptExecutionResult | undefined;
|
||||
if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) {
|
||||
const afterBeforeMarker =
|
||||
raw.includes("===SU:APT_FULLUPGRADE===") ? "===SU:APT_FULLUPGRADE===" :
|
||||
raw.includes("===SU:APT_UPGRADE===") ? "===SU:APT_UPGRADE===" :
|
||||
"===SU:APT_AUTOREMOVE===";
|
||||
aptResult = buildAptExecutionResult(
|
||||
extractSection(raw, "===SU:DPKG_BEFORE===", afterBeforeMarker),
|
||||
extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="),
|
||||
extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
|
||||
);
|
||||
}
|
||||
|
||||
// Archivage log brut + rapport.
|
||||
const dir = join(env.reportsDir, machineId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
@@ -66,15 +110,66 @@ export async function runAction(machineId: string, action: ActionType): Promise<
|
||||
rebootRequiredAfterRun: rebootRequired,
|
||||
importantLogLines: reduceAptLines(raw),
|
||||
rawLogRef: rawLogPath, reportRef: reportPath,
|
||||
...(aptResult ? { apt: aptResult } : {}),
|
||||
...(rebootResult ? { reboot: rebootResult } : {}),
|
||||
};
|
||||
writeFileSync(reportPath, buildReportMarkdown(result, m.name), "utf8");
|
||||
|
||||
const reportId = randomUUID();
|
||||
const exitMatch = /===SU:EXIT=(\d+)===/.exec(raw);
|
||||
db.update(schema.executions).set({
|
||||
finishedAt, status, resultJson: JSON.stringify(result), reportPath, rawLogPath,
|
||||
finishedAt,
|
||||
status,
|
||||
schemaVersion: 1,
|
||||
resultJson: JSON.stringify(result),
|
||||
importantJson: JSON.stringify(result.importantLogLines),
|
||||
reportPath,
|
||||
rawLogPath,
|
||||
reportId,
|
||||
exitCode: exitMatch ? Number(exitMatch[1]) : null,
|
||||
errorKind: status === "error" ? "execution_failed" : null,
|
||||
errorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
|
||||
}).where(eq(schema.executions.id, executionId)).run();
|
||||
db.update(schema.machines).set({ status: status === "error" ? "error" : "unknown" })
|
||||
.where(eq(schema.machines.id, machineId)).run();
|
||||
|
||||
db.insert(schema.reports).values({
|
||||
id: reportId,
|
||||
machineId,
|
||||
executionId,
|
||||
kind: "machine",
|
||||
title: `${m.name} — ${action}`,
|
||||
path: reportPath,
|
||||
createdAt: finishedAt,
|
||||
}).run();
|
||||
|
||||
db.insert(schema.rawArtifacts).values({
|
||||
id: randomUUID(),
|
||||
machineId,
|
||||
kind: "raw_log",
|
||||
path: rawLogPath,
|
||||
bytes: statSync(rawLogPath).size,
|
||||
createdAt: finishedAt,
|
||||
retentionPolicy: status === "error" ? "failed" : "default",
|
||||
}).run();
|
||||
|
||||
upsertMachineState(machineId, {
|
||||
status: status === "error" ? "error" : "unknown",
|
||||
runningJobId: null,
|
||||
lastErrorKind: status === "error" ? "execution_failed" : null,
|
||||
lastErrorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null,
|
||||
});
|
||||
|
||||
const execSeverity: "info" | "warning" | "error" =
|
||||
status === "error" ? "error" : (status as string) === "warning" ? "warning" : "info";
|
||||
recordEvent({
|
||||
machineId,
|
||||
eventType: `action_${action}`,
|
||||
severity: execSeverity,
|
||||
executionId,
|
||||
message: `Action ${action} : ${status}`,
|
||||
});
|
||||
|
||||
outputHub.publish(machineId, `\n===SU:DONE status=${status}===\n`);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// server/services/machineState.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { deriveAptState } from "./machineState.js";
|
||||
import type { UpdateSnapshot } from "@shared/types.js";
|
||||
|
||||
const snap: UpdateSnapshot = {
|
||||
machineId: "m1", hostname: "h", os: { family: "debian", version: "12" },
|
||||
checkedAt: "2026-06-05T10:00:00Z", status: "updates_available",
|
||||
apt: { enabled: true, count: 3, rebootRequired: true, packages: [] },
|
||||
};
|
||||
|
||||
describe("deriveAptState", () => {
|
||||
it("dérive le bloc APT de machine_state depuis un snapshot", () => {
|
||||
expect(deriveAptState(snap)).toEqual({
|
||||
status: "updates_available",
|
||||
aptStatus: "updates_available",
|
||||
aptUpdatesCount: 3,
|
||||
aptRebootRequired: 1,
|
||||
aptLastAnalyzeAt: "2026-06-05T10:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("met rebootRequired à 0 quand absent", () => {
|
||||
const s = { ...snap, status: "ok" as const, apt: { ...snap.apt, count: 0, rebootRequired: false } };
|
||||
expect(deriveAptState(s)).toMatchObject({ aptUpdatesCount: 0, aptRebootRequired: 0, status: "ok" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// server/services/machineState.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import type { UpdateSnapshot } from "@shared/types.js";
|
||||
|
||||
export interface AptDerivedState {
|
||||
status: string;
|
||||
aptStatus: string;
|
||||
aptUpdatesCount: number;
|
||||
aptRebootRequired: number;
|
||||
aptLastAnalyzeAt: string;
|
||||
}
|
||||
|
||||
/** Dérive le bloc APT de l'état courant depuis un snapshot (fonction pure). */
|
||||
export function deriveAptState(snapshot: UpdateSnapshot): AptDerivedState {
|
||||
return {
|
||||
status: snapshot.status,
|
||||
aptStatus: snapshot.status,
|
||||
aptUpdatesCount: snapshot.apt.count,
|
||||
aptRebootRequired: snapshot.apt.rebootRequired ? 1 : 0,
|
||||
aptLastAnalyzeAt: snapshot.checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
type MachineStateInsert = typeof schema.machineState.$inferInsert;
|
||||
|
||||
/** Insère ou met à jour les champs fournis de machine_state pour une machine. */
|
||||
export function upsertMachineState(
|
||||
machineId: string,
|
||||
fields: Partial<Omit<MachineStateInsert, "machineId" | "updatedAt" | "status">> & { status: string },
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
db.insert(schema.machineState)
|
||||
.values({ machineId, updatedAt: now, ...fields })
|
||||
.onConflictDoUpdate({
|
||||
target: schema.machineState.machineId,
|
||||
set: { ...fields, updatedAt: now },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
/** Ajoute une ligne à la timeline machine_events. */
|
||||
export function recordEvent(input: {
|
||||
machineId: string;
|
||||
eventType: string;
|
||||
severity: "info" | "warning" | "error";
|
||||
actorType?: string;
|
||||
snapshotId?: string;
|
||||
executionId?: string;
|
||||
message?: string;
|
||||
}): void {
|
||||
db.insert(schema.machineEvents).values({
|
||||
id: randomUUID(),
|
||||
machineId: input.machineId,
|
||||
eventType: input.eventType,
|
||||
severity: input.severity,
|
||||
createdAt: new Date().toISOString(),
|
||||
actorType: input.actorType ?? "system",
|
||||
snapshotId: input.snapshotId,
|
||||
executionId: input.executionId,
|
||||
message: input.message,
|
||||
}).run();
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { encryptSecret, decryptSecret } from "../crypto/secrets.js";
|
||||
import { env } from "../env.js";
|
||||
import { runPlain, type SshCreds } from "../ssh/client.js";
|
||||
import type { MachineView, OsFamily } from "@shared/types.js";
|
||||
import { writeCredentials, readCredentials, resolveCreds } from "./credentials.js";
|
||||
|
||||
export interface CreateMachineInput {
|
||||
name: string;
|
||||
@@ -37,12 +38,17 @@ function toView(m: MachineRow): MachineView {
|
||||
|
||||
export function getCreds(m: MachineRow): SshCreds {
|
||||
const key = env.requireMasterKey();
|
||||
const { encPassword, encSudoPassword } = resolveCreds(
|
||||
{ encPassword: m.encPassword, encSudoPassword: m.encSudoPassword },
|
||||
readCredentials(m.id),
|
||||
);
|
||||
if (!encPassword) throw new Error("Aucun secret pour cette machine");
|
||||
return {
|
||||
hostname: m.hostname,
|
||||
port: m.port,
|
||||
username: m.username,
|
||||
password: decryptSecret(m.encPassword, key),
|
||||
sudoPassword: m.encSudoPassword ? decryptSecret(m.encSudoPassword, key) : null,
|
||||
password: decryptSecret(encPassword, key),
|
||||
sudoPassword: encSudoPassword ? decryptSecret(encSudoPassword, key) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,12 +88,19 @@ export async function createMachine(input: CreateMachineInput): Promise<MachineV
|
||||
};
|
||||
const os = await testConnection(creds); // lève si la connexion échoue
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const row: MachineRow = {
|
||||
id,
|
||||
name: input.name,
|
||||
hostname: input.hostname,
|
||||
port: input.port,
|
||||
osFamily: os.family,
|
||||
osVersion: os.version || null,
|
||||
osCodename: null,
|
||||
arch: null,
|
||||
machineKind: null,
|
||||
virtualization: null,
|
||||
hardwareProfile: null,
|
||||
username: input.username,
|
||||
encPassword: encryptSecret(input.password, key),
|
||||
encSudoPassword: input.sudoPassword ? encryptSecret(input.sudoPassword, key) : null,
|
||||
@@ -95,9 +108,13 @@ export async function createMachine(input: CreateMachineInput): Promise<MachineV
|
||||
aptProxyUrl: input.aptProxyUrl ?? null,
|
||||
status: "unknown",
|
||||
lastCheckedAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastSeenAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
};
|
||||
db.insert(schema.machines).values(row).run();
|
||||
writeCredentials({ machineId: id, encPassword: row.encPassword, encSudoPassword: row.encSudoPassword });
|
||||
return toView(row);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { classifyReboot, parseBootIdBefore } from "./rebootVerify.js";
|
||||
|
||||
describe("parseBootIdBefore", () => {
|
||||
it("extrait le boot_id de la sortie du template", () => {
|
||||
const raw = "===SU:BOOT_ID_BEFORE===\nabcd-1234\n===SU:REBOOT_NOW===\nreboot planifié";
|
||||
expect(parseBootIdBefore(raw)).toBe("abcd-1234");
|
||||
});
|
||||
it("retourne null si absent", () => {
|
||||
expect(parseBootIdBefore("rien")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyReboot", () => {
|
||||
it("ok si la machine revient avec un boot_id différent", () => {
|
||||
expect(classifyReboot({ beforeBootId: "A", afterBootId: "B", wentDown: true, cameBack: true }).status).toBe("ok");
|
||||
});
|
||||
it("boot_id_unchanged si même boot_id", () => {
|
||||
expect(classifyReboot({ beforeBootId: "A", afterBootId: "A", wentDown: true, cameBack: true }).status).toBe("boot_id_unchanged");
|
||||
});
|
||||
it("ssh_never_went_down si la coupure n'a pas été observée", () => {
|
||||
expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: false, cameBack: false }).status).toBe("ssh_never_went_down");
|
||||
});
|
||||
it("machine_did_not_return si coupure mais pas de retour", () => {
|
||||
expect(classifyReboot({ beforeBootId: "A", afterBootId: null, wentDown: true, cameBack: false }).status).toBe("machine_did_not_return");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// server/services/rebootVerify.ts
|
||||
import { runPlain, type SshCreds } from "../ssh/client.js";
|
||||
import type { RebootResult } from "@shared/types.js";
|
||||
|
||||
export function parseBootIdBefore(raw: string): string | null {
|
||||
const s = raw.indexOf("===SU:BOOT_ID_BEFORE===");
|
||||
if (s === -1) return null;
|
||||
const from = s + "===SU:BOOT_ID_BEFORE===".length;
|
||||
const e = raw.indexOf("===SU:REBOOT_NOW===", from);
|
||||
const id = raw.slice(from, e === -1 ? undefined : e).trim();
|
||||
return id || null;
|
||||
}
|
||||
|
||||
export interface RebootSignals {
|
||||
beforeBootId: string | null;
|
||||
afterBootId: string | null;
|
||||
wentDown: boolean;
|
||||
cameBack: boolean;
|
||||
}
|
||||
|
||||
/** Détermine le statut d'un reboot vérifié (fonction pure). */
|
||||
export function classifyReboot(s: RebootSignals): { status: RebootResult["status"] } {
|
||||
if (!s.wentDown) return { status: "ssh_never_went_down" };
|
||||
if (!s.cameBack || s.afterBootId === null) return { status: "machine_did_not_return" };
|
||||
if (s.beforeBootId !== null && s.afterBootId === s.beforeBootId) return { status: "boot_id_unchanged" };
|
||||
return { status: "ok" };
|
||||
}
|
||||
|
||||
async function readBootId(creds: SshCreds): Promise<string | null> {
|
||||
try {
|
||||
const res = await runPlain(creds, "cat /proc/sys/kernel/random/boot_id");
|
||||
const id = res.stdout.trim();
|
||||
return id || null;
|
||||
} catch {
|
||||
return null; // connexion impossible (machine down)
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export interface VerifyOptions {
|
||||
beforeBootId: string | null;
|
||||
requestedAt: string;
|
||||
downTimeoutMs?: number; // détection de la coupure
|
||||
upTimeoutMs?: number; // attente du retour
|
||||
pollMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestration : attend la coupure SSH (machine qui reboote) puis le retour,
|
||||
* relit le boot_id, et classe le résultat. Réseau ; non testé unitairement.
|
||||
*/
|
||||
export async function verifyReboot(creds: SshCreds, opt: VerifyOptions): Promise<RebootResult> {
|
||||
const downTimeoutMs = opt.downTimeoutMs ?? 60000;
|
||||
const upTimeoutMs = opt.upTimeoutMs ?? 600000;
|
||||
const pollMs = opt.pollMs ?? 5000;
|
||||
const t0 = Date.now();
|
||||
|
||||
// Phase A : attendre que la machine devienne injoignable.
|
||||
let wentDown = false;
|
||||
let sshWentDownAt: string | null = null;
|
||||
while (Date.now() - t0 < downTimeoutMs) {
|
||||
const id = await readBootId(creds);
|
||||
if (id === null) { wentDown = true; sshWentDownAt = new Date().toISOString(); break; }
|
||||
await sleep(pollMs);
|
||||
}
|
||||
|
||||
// Phase B : attendre le retour (seulement si on a vu la coupure).
|
||||
let cameBack = false;
|
||||
let sshCameBackAt: string | null = null;
|
||||
let afterBootId: string | null = null;
|
||||
if (wentDown) {
|
||||
const tB = Date.now();
|
||||
while (Date.now() - tB < upTimeoutMs) {
|
||||
const id = await readBootId(creds);
|
||||
if (id !== null) { cameBack = true; sshCameBackAt = new Date().toISOString(); afterBootId = id; break; }
|
||||
await sleep(pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
const { status } = classifyReboot({ beforeBootId: opt.beforeBootId, afterBootId, wentDown, cameBack });
|
||||
const waitedSeconds = Math.round((Date.now() - t0) / 1000);
|
||||
return {
|
||||
beforeBootId: opt.beforeBootId,
|
||||
afterBootId,
|
||||
requestedAt: opt.requestedAt,
|
||||
sshWentDownAt,
|
||||
sshCameBackAt,
|
||||
waitedSeconds,
|
||||
status,
|
||||
lastRebootDurationSeconds: status === "ok" ? waitedSeconds : undefined,
|
||||
nextRecommendedWaitSeconds: status === "ok" ? Math.round(waitedSeconds * 1.5) + 30 : undefined,
|
||||
};
|
||||
}
|
||||
+35
-12
@@ -3,12 +3,13 @@ import { randomUUID } from "node:crypto";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { getMachineRow, getCreds } from "./machines.js";
|
||||
import { renderTemplate } from "../templates/render.js";
|
||||
import { renderTemplate, resolveTemplate } from "../templates/render.js";
|
||||
import { reduceAptLines } from "../templates/aptReduce.js";
|
||||
import { runScriptSudo } from "../ssh/client.js";
|
||||
import { parseAptSimulate, parseRebootRequired } from "./aptParse.js";
|
||||
import { buildAptSnapshotDetail } from "./aptParse.js";
|
||||
import { outputHub } from "../ws/outputHub.js";
|
||||
import type { UpdateSnapshot, MachineStatus } from "@shared/types.js";
|
||||
import { deriveAptState, upsertMachineState, recordEvent } from "./machineState.js";
|
||||
import type { UpdateSnapshot, MachineStatus, AptSnapshotDetail } from "@shared/types.js";
|
||||
|
||||
/** Extrait la section entre deux marqueurs ===SU:X=== d'une sortie de script. */
|
||||
export function extractSection(raw: string, start: string, end: string): string {
|
||||
@@ -27,7 +28,7 @@ export async function refreshMachine(machineId: string): Promise<UpdateSnapshot>
|
||||
outputHub.clear(machineId);
|
||||
|
||||
const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null;
|
||||
const script = renderTemplate("apt/check.sh.tpl", { aptProxy: proxy });
|
||||
const script = renderTemplate(resolveTemplate("update-analyze", m.osFamily), { aptProxy: proxy });
|
||||
|
||||
let raw = "";
|
||||
try {
|
||||
@@ -41,32 +42,54 @@ export async function refreshMachine(machineId: string): Promise<UpdateSnapshot>
|
||||
throw err;
|
||||
}
|
||||
|
||||
const simulate = extractSection(raw, "===SU:SIMULATE===", "===SU:REBOOT===");
|
||||
const rebootSection = extractSection(raw, "===SU:REBOOT===", "===SU:END===");
|
||||
const packages = parseAptSimulate(simulate);
|
||||
const rebootRequired = parseRebootRequired(rebootSection);
|
||||
const status: MachineStatus = packages.length > 0 ? "updates_available" : "ok";
|
||||
const updateExit = /===SU:EXIT=(\d+)===/.exec(raw);
|
||||
const detail: AptSnapshotDetail = buildAptSnapshotDetail({
|
||||
upgradeSim: extractSection(raw, "===SU:APT_SIM_UPGRADE===", "===SU:APT_SIM_DISTUPGRADE==="),
|
||||
distUpgradeSim: extractSection(raw, "===SU:APT_SIM_DISTUPGRADE===", "===SU:APT_HELD==="),
|
||||
heldRaw: extractSection(raw, "===SU:APT_HELD===", "===SU:REBOOT==="),
|
||||
rebootRaw: extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"),
|
||||
updateFailed: updateExit ? Number(updateExit[1]) !== 0 : false,
|
||||
});
|
||||
|
||||
// MachineStatus n'a pas "warning" : warning => updates_available côté machine.
|
||||
const status: MachineStatus =
|
||||
detail.status === "error" ? "error" : detail.count > 0 || detail.status === "warning" ? "updates_available" : "ok";
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
const snapshot: UpdateSnapshot = {
|
||||
machineId,
|
||||
hostname: m.hostname,
|
||||
os: { family: m.osFamily as UpdateSnapshot["os"]["family"], version: "" },
|
||||
os: { family: m.osFamily as UpdateSnapshot["os"]["family"], version: m.osVersion ?? "" },
|
||||
checkedAt,
|
||||
status,
|
||||
apt: { enabled: true, count: packages.length, rebootRequired, packages },
|
||||
apt: detail,
|
||||
schemaVersion: 1,
|
||||
kind: "apt_update_analyze",
|
||||
rawHints: { logImportantLines: reduceAptLines(raw) },
|
||||
};
|
||||
|
||||
const snapshotId = randomUUID();
|
||||
db.insert(schema.snapshots).values({
|
||||
id: randomUUID(),
|
||||
id: snapshotId,
|
||||
machineId,
|
||||
kind: "apt_update_analyze",
|
||||
schemaVersion: 1,
|
||||
checkedAt,
|
||||
status,
|
||||
payloadJson: JSON.stringify(snapshot),
|
||||
importantJson: JSON.stringify(snapshot.rawHints?.logImportantLines ?? []),
|
||||
}).run();
|
||||
db.update(schema.machines).set({ status, lastCheckedAt: checkedAt }).where(eq(schema.machines.id, machineId)).run();
|
||||
|
||||
upsertMachineState(machineId, deriveAptState(snapshot));
|
||||
recordEvent({
|
||||
machineId,
|
||||
eventType: "apt_refresh",
|
||||
severity: "info",
|
||||
snapshotId,
|
||||
message: `Refresh APT : ${snapshot.apt.count} mise(s) à jour`,
|
||||
});
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// server/services/system.test.ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSystemMetrics, getSystemStatus, systemInternals } from "./system.js";
|
||||
|
||||
describe("system service", () => {
|
||||
it("publie un status serveur stable", () => {
|
||||
const status = getSystemStatus(new Date("2026-06-05T10:00:00.000Z"));
|
||||
|
||||
expect(status).toMatchObject({
|
||||
app: "system_update",
|
||||
version: "0.1.0",
|
||||
apiVersion: "1",
|
||||
serverTime: "2026-06-05T10:00:00.000Z",
|
||||
});
|
||||
expect(status.uptimeSeconds).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("publie des métriques serveur sans secret", () => {
|
||||
const metrics = getSystemMetrics(new Date("2026-06-05T10:00:00.000Z"));
|
||||
|
||||
expect(metrics.collectedAt).toBe("2026-06-05T10:00:00.000Z");
|
||||
expect(metrics.process.rssMb).toBeGreaterThan(0);
|
||||
expect(metrics.host.totalMemoryMb).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(metrics)).not.toContain("password");
|
||||
});
|
||||
|
||||
it("arrondit à deux décimales", () => {
|
||||
expect(systemInternals.round2(12.345)).toBe(12.35);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// server/services/system.ts
|
||||
import os from "node:os";
|
||||
import type { SystemMetrics, SystemStatus } from "@shared/types.js";
|
||||
|
||||
const APP_VERSION = "0.1.0";
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
export function getSystemStatus(now = new Date()): SystemStatus {
|
||||
return {
|
||||
app: "system_update",
|
||||
version: APP_VERSION,
|
||||
apiVersion: "1",
|
||||
serverTime: now.toISOString(),
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSystemMetrics(now = new Date()): SystemMetrics {
|
||||
const memory = process.memoryUsage();
|
||||
const load = os.loadavg();
|
||||
|
||||
return {
|
||||
collectedAt: now.toISOString(),
|
||||
process: {
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
rssMb: round2(memory.rss / MB),
|
||||
heapUsedMb: round2(memory.heapUsed / MB),
|
||||
heapTotalMb: round2(memory.heapTotal / MB),
|
||||
},
|
||||
host: {
|
||||
loadAverage1m: round2(load[0] ?? 0),
|
||||
loadAverage5m: round2(load[1] ?? 0),
|
||||
loadAverage15m: round2(load[2] ?? 0),
|
||||
totalMemoryMb: round2(os.totalmem() / MB),
|
||||
freeMemoryMb: round2(os.freemem() / MB),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const systemInternals = { round2 };
|
||||
+24
-2
@@ -44,17 +44,20 @@ export async function runPlain(creds: SshCreds, command: string): Promise<RunRes
|
||||
* Exécute un script shell sous sudo. Le script est encodé en base64 pour éviter
|
||||
* tout problème de quoting; le mot de passe sudo est poussé sur stdin (sudo -S -p '').
|
||||
* `onData` reçoit chaque chunk de sortie pour le streaming live.
|
||||
* `inactivityTimeoutMs` (défaut 0 = désactivé) : si aucune sortie n'est reçue pendant
|
||||
* cette durée, la connexion est fermée et une erreur `human_interaction_required` est levée.
|
||||
*/
|
||||
export async function runScriptSudo(
|
||||
creds: SshCreds,
|
||||
script: string,
|
||||
onData: (chunk: string) => void,
|
||||
inactivityTimeoutMs = 0,
|
||||
): Promise<RunResult> {
|
||||
const conn = await connect(creds);
|
||||
try {
|
||||
const b64 = Buffer.from(script, "utf8").toString("base64");
|
||||
const cmd = `sudo -S -p '' sh -c "$(printf '%s' '${b64}' | base64 -d)"`;
|
||||
return await execStream(conn, cmd, (creds.sudoPassword ?? creds.password) + "\n", onData);
|
||||
return await execStream(conn, cmd, (creds.sudoPassword ?? creds.password) + "\n", onData, inactivityTimeoutMs);
|
||||
} finally {
|
||||
conn.end();
|
||||
}
|
||||
@@ -65,26 +68,45 @@ function execStream(
|
||||
command: string,
|
||||
stdinData: string | null,
|
||||
onData: (chunk: string) => void,
|
||||
inactivityTimeoutMs = 0,
|
||||
): Promise<RunResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
conn.exec(command, { pty: false }, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let stdout = "";
|
||||
let code = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const arm = () => {
|
||||
if (!inactivityTimeoutMs) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
stream.close();
|
||||
conn.end();
|
||||
reject(new Error(`human_interaction_required: aucune sortie depuis ${Math.round(inactivityTimeoutMs / 1000)}s`));
|
||||
}, inactivityTimeoutMs);
|
||||
};
|
||||
arm();
|
||||
|
||||
if (stdinData) {
|
||||
stream.write(stdinData);
|
||||
}
|
||||
stream
|
||||
.on("close", (c: number) => resolve({ stdout, code: c ?? code }))
|
||||
.on("close", (c: number) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, code: c ?? code });
|
||||
})
|
||||
.on("data", (d: Buffer) => {
|
||||
const s = d.toString("utf8");
|
||||
stdout += s;
|
||||
onData(s);
|
||||
arm();
|
||||
});
|
||||
stream.stderr.on("data", (d: Buffer) => {
|
||||
const s = d.toString("utf8");
|
||||
stdout += s;
|
||||
onData(s);
|
||||
arm();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// server/templates/aptReduce.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { reduceAptLines } from "./aptReduce.js";
|
||||
import { reduceAptLines, reduceLines } from "./aptReduce.js";
|
||||
|
||||
describe("reduceAptLines", () => {
|
||||
it("ne garde que les lignes utiles", () => {
|
||||
@@ -26,4 +26,22 @@ describe("reduceAptLines", () => {
|
||||
it("retourne un tableau vide si rien d'utile", () => {
|
||||
expect(reduceAptLines("Reading package lists...\nDone")).toEqual([]);
|
||||
});
|
||||
|
||||
it("garde aussi les lignes Docker utiles", () => {
|
||||
const raw = [
|
||||
"Pulling jellyfin ...",
|
||||
"Status: Downloaded newer image for jellyfin/jellyfin:latest",
|
||||
"Recreating jellyfin ...",
|
||||
"Started jellyfin",
|
||||
"blabla inutile",
|
||||
"Total reclaimed space: 1.2GB",
|
||||
].join("\n");
|
||||
expect(reduceLines(raw)).toEqual([
|
||||
"Pulling jellyfin ...",
|
||||
"Status: Downloaded newer image for jellyfin/jellyfin:latest",
|
||||
"Recreating jellyfin ...",
|
||||
"Started jellyfin",
|
||||
"Total reclaimed space: 1.2GB",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
// server/templates/aptReduce.ts
|
||||
const PREFIXES = ["Inst ", "Conf ", "Remv ", "Err ", "E:", "W:", "dpkg:"];
|
||||
const CONTAINS = ["reboot-required", "REBOOT_REQUIRED"];
|
||||
const PREFIXES = [
|
||||
// APT / dpkg (jalon 1)
|
||||
"Inst ", "Conf ", "Remv ", "Err ", "E:", "W:", "dpkg:",
|
||||
// Docker (SJ-0)
|
||||
"Pulling", "Digest", "Status", "Downloaded newer image", "Recreating", "Started", "Error",
|
||||
];
|
||||
const CONTAINS = [
|
||||
"reboot-required", "REBOOT_REQUIRED",
|
||||
"deleted", "Total reclaimed space",
|
||||
];
|
||||
|
||||
/** Garde uniquement les lignes informatives d'une sortie APT brute. */
|
||||
export function reduceAptLines(raw: string): string[] {
|
||||
/** Garde uniquement les lignes informatives (APT + Docker) d'une sortie brute. */
|
||||
export function reduceLines(raw: string): string[] {
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((l) => l.trimEnd())
|
||||
.filter((l) => PREFIXES.some((p) => l.startsWith(p)) || CONTAINS.some((c) => l.includes(c)));
|
||||
}
|
||||
|
||||
/** Alias rétro-compatible (jalon 1) : même comportement, conserve les imports existants. */
|
||||
export const reduceAptLines = reduceLines;
|
||||
|
||||
@@ -14,4 +14,10 @@ describe("renderTemplate", () => {
|
||||
const out = renderTemplate("apt/check.sh.tpl", { aptProxy: "http://cache:3142" });
|
||||
expect(out).toContain('http_proxy="http://cache:3142"');
|
||||
});
|
||||
|
||||
it("rend update-analyze.sh.tpl avec les sections attendues", () => {
|
||||
const out = renderTemplate("apt/update-analyze.sh.tpl", { aptProxy: null });
|
||||
expect(out).toContain("===SU:APT_SIM_DISTUPGRADE===");
|
||||
expect(out).toContain("apt-mark showhold");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// server/templates/render.ts
|
||||
import Mustache from "mustache";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const TEMPLATES_ROOT = resolve(process.cwd(), "templates");
|
||||
@@ -14,3 +14,23 @@ export function renderTemplate(relPath: string, vars: TemplateVars): string {
|
||||
// Mustache échappe le HTML par défaut; on désactive (ce sont des scripts shell).
|
||||
return Mustache.render(tpl, vars, {}, { escape: (s) => s });
|
||||
}
|
||||
|
||||
/** Existence par défaut d'un template relatif à templates/. */
|
||||
function defaultExists(rel: string): boolean {
|
||||
return existsSync(resolve(TEMPLATES_ROOT, rel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout le chemin de template le plus spécifique pour (action, OS) :
|
||||
* `<osFamily>/<action>.sh.tpl` s'il existe, sinon fallback base `apt/<action>.sh.tpl`.
|
||||
* `exists` est injectable pour les tests.
|
||||
*/
|
||||
export function resolveTemplate(
|
||||
action: string,
|
||||
osFamily: string,
|
||||
exists: (rel: string) => boolean = defaultExists,
|
||||
): string {
|
||||
const specific = `${osFamily}/${action}.sh.tpl`;
|
||||
if (osFamily !== "unknown" && osFamily !== "apt" && exists(specific)) return specific;
|
||||
return `apt/${action}.sh.tpl`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveTemplate } from "./render.js";
|
||||
|
||||
describe("resolveTemplate", () => {
|
||||
it("retombe sur apt/ quand aucun dossier OS spécifique n'existe (fonction exists fournie)", () => {
|
||||
const noneExist = () => false;
|
||||
expect(resolveTemplate("full-upgrade", "proxmox", noneExist)).toBe("apt/full-upgrade.sh.tpl");
|
||||
expect(resolveTemplate("update-analyze", "debian", noneExist)).toBe("apt/update-analyze.sh.tpl");
|
||||
});
|
||||
|
||||
it("choisit le template OS spécifique quand il existe", () => {
|
||||
const proxmoxExists = (rel: string) => rel === "proxmox/full-upgrade.sh.tpl";
|
||||
expect(resolveTemplate("full-upgrade", "proxmox", proxmoxExists)).toBe("proxmox/full-upgrade.sh.tpl");
|
||||
});
|
||||
|
||||
it("unknown retombe toujours sur apt/", () => {
|
||||
const all = () => true;
|
||||
expect(resolveTemplate("clean", "unknown", all)).toBe("apt/clean.sh.tpl");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { UpdateSnapshot, ExecutionResult } from "./types.js";
|
||||
|
||||
describe("rétro-compatibilité des contrats", () => {
|
||||
it("un snapshot jalon 1 (sans blocs optionnels) reste valide", () => {
|
||||
const snap: UpdateSnapshot = {
|
||||
machineId: "m1", hostname: "h", os: { family: "debian", version: "12" },
|
||||
checkedAt: "2026-06-05T10:00:00Z", status: "ok",
|
||||
apt: { enabled: true, count: 0, rebootRequired: false, packages: [] },
|
||||
};
|
||||
expect(snap.apt.count).toBe(0);
|
||||
});
|
||||
|
||||
it("une exécution jalon 1 (mode manual, sans blocs) reste valide", () => {
|
||||
const exec: ExecutionResult = {
|
||||
executionId: "e1", machineId: "m1", startedAt: "a", finishedAt: "b",
|
||||
mode: "manual", action: "apt_full_upgrade", status: "ok",
|
||||
rebootRequiredAfterRun: false, importantLogLines: [], rawLogRef: "r", reportRef: "rr",
|
||||
};
|
||||
expect(exec.action).toBe("apt_full_upgrade");
|
||||
});
|
||||
|
||||
it("accepte les nouveaux blocs optionnels", () => {
|
||||
const snap: UpdateSnapshot = {
|
||||
machineId: "m1", hostname: "h", os: { family: "proxmox", version: "8" },
|
||||
checkedAt: "t", status: "updates_available",
|
||||
apt: { enabled: true, count: 1, rebootRequired: false, packages: [], status: "updates_available" },
|
||||
schemaVersion: 1, kind: "apt_update_analyze", machineKind: "proxmox_host",
|
||||
docker: { enabled: false, installed: false, count: 0, stacks: [] },
|
||||
errors: [],
|
||||
};
|
||||
expect(snap.docker?.installed).toBe(false);
|
||||
});
|
||||
});
|
||||
+222
-10
@@ -1,15 +1,143 @@
|
||||
// shared/types.ts
|
||||
export type OsFamily = "debian" | "ubuntu" | "unknown";
|
||||
export type OsFamily = "debian" | "ubuntu" | "proxmox" | "raspbian" | "unknown";
|
||||
export type MachineStatus = "unknown" | "ok" | "updates_available" | "error" | "running";
|
||||
export type AptProxyMode = "direct" | "runtime";
|
||||
export type ActionType = "apt_full_upgrade" | "reboot";
|
||||
export type AptProxyMode = "direct" | "runtime" | "persistent";
|
||||
export type ActionType =
|
||||
| "apt_full_upgrade" | "reboot"
|
||||
| "apt_update_analyze" | "apt_upgrade" | "apt_dist_upgrade"
|
||||
| "apt_autoremove" | "apt_clean" | "reboot_verified"
|
||||
| "docker_scan" | "docker_inspect_current" | "docker_pull_check"
|
||||
| "docker_compose_apply" | "docker_prune_images" | "docker_compose_down"
|
||||
| "machine_probe" | "post_install";
|
||||
export type ExecutionStatus = "ok" | "warning" | "error";
|
||||
export type ApiClientScope = "read" | "operate" | "admin" | "debug";
|
||||
export type MachineKind =
|
||||
| "physical" | "vm" | "proxmox_host" | "lxc"
|
||||
| "raspberry_pi" | "workstation" | "unknown";
|
||||
export type SnapshotStatus = "ok" | "updates_available" | "warning" | "error";
|
||||
|
||||
export interface ServerCapabilities {
|
||||
app: "system_update";
|
||||
apiVersion: "1";
|
||||
generatedAt: string;
|
||||
features: {
|
||||
machines: boolean;
|
||||
machineSnapshots: boolean;
|
||||
actions: boolean;
|
||||
aptFullUpgrade: boolean;
|
||||
reboot: boolean;
|
||||
reports: boolean;
|
||||
terminalOutput: boolean;
|
||||
interactiveSsh: boolean;
|
||||
docker: boolean;
|
||||
postInstall: boolean;
|
||||
hermes: boolean;
|
||||
settings: boolean;
|
||||
scheduledJobs: boolean;
|
||||
authTokens: boolean;
|
||||
};
|
||||
endpoints: {
|
||||
capabilities: string;
|
||||
systemStatus: string;
|
||||
systemMetrics: string;
|
||||
machines: string;
|
||||
machineSnapshot: string;
|
||||
machineRefresh: string;
|
||||
machineActions: string;
|
||||
machineExecutions: string;
|
||||
executionReport: string;
|
||||
terminalOutputWs: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
app: "system_update";
|
||||
version: string;
|
||||
apiVersion: "1";
|
||||
serverTime: string;
|
||||
uptimeSeconds: number;
|
||||
}
|
||||
|
||||
export interface SystemMetrics {
|
||||
collectedAt: string;
|
||||
process: {
|
||||
uptimeSeconds: number;
|
||||
rssMb: number;
|
||||
heapUsedMb: number;
|
||||
heapTotalMb: number;
|
||||
};
|
||||
host: {
|
||||
loadAverage1m: number;
|
||||
loadAverage5m: number;
|
||||
loadAverage15m: number;
|
||||
totalMemoryMb: number;
|
||||
freeMemoryMb: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AptPackage {
|
||||
name: string;
|
||||
currentVersion: string | null;
|
||||
targetVersion: string;
|
||||
origin: string | null;
|
||||
arch?: string;
|
||||
operation?: "upgrade" | "install" | "remove" | "hold";
|
||||
severityHint?: "normal" | "security";
|
||||
}
|
||||
|
||||
export interface AptSnapshotDetail {
|
||||
enabled: boolean;
|
||||
count: number;
|
||||
rebootRequired: boolean;
|
||||
packages: AptPackage[];
|
||||
status?: SnapshotStatus;
|
||||
upgradeCount?: number;
|
||||
distUpgradeCount?: number;
|
||||
installed?: AptPackage[];
|
||||
removed?: AptPackage[];
|
||||
held?: string[];
|
||||
rebootPkgs?: string[];
|
||||
}
|
||||
|
||||
export interface DockerSnapshotService {
|
||||
serviceName: string;
|
||||
image: string;
|
||||
currentImageId?: string | null;
|
||||
currentDigest?: string | null;
|
||||
candidateImageId?: string | null;
|
||||
candidateDigest?: string | null;
|
||||
currentVersion?: string | null;
|
||||
candidateVersion?: string | null;
|
||||
sourceUrl?: string | null;
|
||||
status?: "up_to_date" | "updates_available" | "warning" | "error";
|
||||
}
|
||||
|
||||
export interface DockerSnapshotStack {
|
||||
name: string;
|
||||
workingDir: string;
|
||||
composeFiles: string[];
|
||||
projectName?: string | null;
|
||||
status: "candidate" | "enabled" | "ignored" | "error";
|
||||
detectedBy?: "root_scan" | "label" | "manual";
|
||||
services: DockerSnapshotService[];
|
||||
}
|
||||
|
||||
export interface DockerSnapshot {
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
count: number;
|
||||
declaredRoots?: string[];
|
||||
stacks: DockerSnapshotStack[];
|
||||
status?: SnapshotStatus;
|
||||
}
|
||||
|
||||
export interface SnapshotError {
|
||||
source: "apt" | "docker" | "post_install" | "ssh" | "system";
|
||||
kind: string;
|
||||
severity: "info" | "warning" | "error";
|
||||
message: string;
|
||||
remediation?: string;
|
||||
importantLines?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateSnapshot {
|
||||
@@ -18,27 +146,95 @@ export interface UpdateSnapshot {
|
||||
os: { family: OsFamily; version: string };
|
||||
checkedAt: string; // ISO 8601
|
||||
status: MachineStatus;
|
||||
apt: {
|
||||
enabled: boolean;
|
||||
count: number;
|
||||
rebootRequired: boolean;
|
||||
packages: AptPackage[];
|
||||
};
|
||||
apt: AptSnapshotDetail;
|
||||
schemaVersion?: number;
|
||||
kind?: "apt_update_analyze" | "docker_scan" | "reboot_check" | "combined";
|
||||
machineKind?: MachineKind;
|
||||
docker?: DockerSnapshot;
|
||||
errors?: SnapshotError[];
|
||||
rawHints?: { logImportantLines: string[] };
|
||||
}
|
||||
|
||||
export interface AptChange {
|
||||
name: string;
|
||||
arch?: string;
|
||||
fromVersion: string | null;
|
||||
toVersion: string | null;
|
||||
operation: "upgraded" | "installed" | "removed" | "unchanged";
|
||||
origin?: string | null;
|
||||
}
|
||||
|
||||
export interface AptExecutionResult {
|
||||
planned: AptPackage[];
|
||||
applied: AptChange[];
|
||||
installed: AptChange[];
|
||||
removed: AptChange[];
|
||||
held: string[];
|
||||
errors?: SnapshotError[];
|
||||
rebootRequiredAfterRun: boolean;
|
||||
}
|
||||
|
||||
export interface DockerImageChange {
|
||||
stack: string;
|
||||
serviceName?: string;
|
||||
imageRef?: string;
|
||||
fromImageId?: string | null;
|
||||
toImageId?: string | null;
|
||||
fromDigest?: string | null;
|
||||
toDigest?: string | null;
|
||||
operation: "pulled" | "recreated" | "pruned";
|
||||
}
|
||||
|
||||
export interface DockerExecutionResult {
|
||||
pull?: { changes: DockerImageChange[]; errors?: SnapshotError[] };
|
||||
up?: { recreated: string[]; running: string[]; exited: string[]; errors?: SnapshotError[] };
|
||||
prune?: { imagesDeleted: string[]; bytesReclaimed: number; errors?: SnapshotError[] };
|
||||
errors?: SnapshotError[];
|
||||
}
|
||||
|
||||
export interface RebootResult {
|
||||
beforeBootId: string | null;
|
||||
afterBootId: string | null;
|
||||
requestedAt: string;
|
||||
sshWentDownAt: string | null;
|
||||
sshCameBackAt: string | null;
|
||||
waitedSeconds: number;
|
||||
status: "ok" | "reboot_command_failed" | "ssh_never_went_down"
|
||||
| "machine_did_not_return" | "boot_id_unchanged" | "timeout";
|
||||
lastRebootDurationSeconds?: number;
|
||||
nextRecommendedWaitSeconds?: number;
|
||||
errors?: SnapshotError[];
|
||||
}
|
||||
|
||||
export interface PostInstallResult {
|
||||
profilesRun: string[];
|
||||
variablesUsed: Record<string, string | number | boolean>;
|
||||
filesModified: string[];
|
||||
packagesInstalled: string[];
|
||||
servicesEnabled: string[];
|
||||
rebootsRequested: boolean;
|
||||
networkChange?: { oldEndpoint: string | null; newEndpoint: string | null; reconnectHost: string | null };
|
||||
errors?: SnapshotError[];
|
||||
}
|
||||
|
||||
export interface ExecutionResult {
|
||||
executionId: string;
|
||||
machineId: string;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
mode: "manual";
|
||||
mode: "manual" | "scheduled" | "hermes_requested";
|
||||
action: ActionType;
|
||||
status: ExecutionStatus;
|
||||
rebootRequiredAfterRun: boolean;
|
||||
importantLogLines: string[];
|
||||
rawLogRef: string;
|
||||
reportRef: string;
|
||||
schemaVersion?: number;
|
||||
apt?: AptExecutionResult;
|
||||
docker?: DockerExecutionResult;
|
||||
reboot?: RebootResult;
|
||||
postInstall?: PostInstallResult;
|
||||
errors?: SnapshotError[];
|
||||
}
|
||||
|
||||
/** Vue machine renvoyée par l'API — NE CONTIENT JAMAIS de secret. */
|
||||
@@ -54,3 +250,19 @@ export interface MachineView {
|
||||
status: MachineStatus;
|
||||
lastCheckedAt: string | null;
|
||||
}
|
||||
|
||||
/** Client API local/Hermes — ne contient jamais le token brut. */
|
||||
export interface ApiClientView {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
scopes: ApiClientScope[];
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CreatedApiClient {
|
||||
client: ApiClientView;
|
||||
token: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
echo "===SU:APT_SIM_AUTOREMOVE==="
|
||||
apt-get -s -y autoremove 2>&1
|
||||
echo "===SU:DPKG_BEFORE==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:APT_AUTOREMOVE==="
|
||||
apt-get -y autoremove 2>&1
|
||||
CODE=$?
|
||||
echo "===SU:DPKG_AFTER==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:REBOOT==="
|
||||
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
echo "===SU:APT_CLEAN==="
|
||||
BEFORE=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
|
||||
apt-get clean 2>&1
|
||||
AFTER=$(du -sb /var/cache/apt/archives 2>/dev/null | awk '{print $1}')
|
||||
echo "FREED_BYTES=$((BEFORE - AFTER))"
|
||||
echo "===SU:EXIT=0==="
|
||||
@@ -3,9 +3,13 @@ export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
|
||||
{{/aptProxy}}
|
||||
echo "===SU:UPGRADE==="
|
||||
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold full-upgrade 2>&1
|
||||
echo "===SU:DPKG_BEFORE==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:APT_FULLUPGRADE==="
|
||||
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold dist-upgrade 2>&1
|
||||
CODE=$?
|
||||
echo "===SU:DPKG_AFTER==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:REBOOT==="
|
||||
if [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
|
||||
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
echo "===SU:BOOT_ID_BEFORE==="
|
||||
cat /proc/sys/kernel/random/boot_id 2>/dev/null
|
||||
echo "===SU:REBOOT_NOW==="
|
||||
# Reboot différé pour laisser le canal SSH se fermer proprement.
|
||||
nohup sh -c 'sleep 2; reboot' >/dev/null 2>&1 &
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
# Refresh index + simulations upgrade/dist-upgrade + held + reboot-check.
|
||||
# Exécuté entier sous sudo par la couche SSH. Non destructif.
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
|
||||
{{/aptProxy}}
|
||||
|
||||
echo "===SU:APT_UPDATE==="
|
||||
apt-get update -qq 2>&1
|
||||
UPD=$?
|
||||
|
||||
echo "===SU:APT_SIM_UPGRADE==="
|
||||
apt-get -s -y upgrade 2>&1
|
||||
|
||||
echo "===SU:APT_SIM_DISTUPGRADE==="
|
||||
apt-get -s -y dist-upgrade 2>&1
|
||||
|
||||
echo "===SU:APT_HELD==="
|
||||
apt-mark showhold 2>/dev/null
|
||||
|
||||
echo "===SU:REBOOT==="
|
||||
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then
|
||||
echo "REBOOT_REQUIRED=1"
|
||||
[ -f /var/run/reboot-required.pkgs ] && sed 's/^/PKG=/' /var/run/reboot-required.pkgs
|
||||
else
|
||||
echo "REBOOT_REQUIRED=0"
|
||||
fi
|
||||
|
||||
echo "===SU:EXIT=${UPD}==="
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
export LC_ALL=C
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
{{#aptProxy}}export http_proxy="{{aptProxy}}"; export https_proxy="{{aptProxy}}"
|
||||
{{/aptProxy}}
|
||||
echo "===SU:DPKG_BEFORE==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:APT_UPGRADE==="
|
||||
apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade 2>&1
|
||||
CODE=$?
|
||||
echo "===SU:DPKG_AFTER==="
|
||||
dpkg-query -W -f='${binary:Package}\t${Version}\t${Architecture}\n' 2>/dev/null
|
||||
echo "===SU:REBOOT==="
|
||||
if [ -f /run/reboot-required ] || [ -f /var/run/reboot-required ]; then echo "REBOOT_REQUIRED=1"; else echo "REBOOT_REQUIRED=0"; fi
|
||||
echo "===SU:EXIT=${CODE}==="
|
||||
Reference in New Issue
Block a user