[ADD] script todo: afficher la RAM de l'hôte dans le suivi d'installation
La barre montrait le CPU et le disque, jamais la mémoire. C'est pourtant la
seule des trois dont l'épuisement ne se voit nulle part ailleurs : une
compilation mobile s'est fait tuer par le noyau sur une VM de 12 Go sans swap
pendant que la barre affichait une charge tranquille et du disque de reste.
Lue dans /proc/meminfo, sans dépendance — ce suivi tourne sur l'hyperviseur,
donc sous Linux, d'où viennent déjà getloadavg et libvirt. « MemAvailable »
plutôt que « MemFree », presque nul dès que le cache travaille. Le swap
n'occupe la barre que s'il existe, et alors même à zéro : une machine qui
commence à échanger explique une lenteur. Au passage, « charge » et « libre »
s'affichaient en français dans une session anglaise.
--- EN ---
The bar showed CPU and disk, never memory. Yet memory is the one of the three
whose exhaustion shows up nowhere else: a mobile build was killed by the kernel
on a 12 GB VM with no swap while the bar displayed a quiet load and disk to
spare.
Read from /proc/meminfo, no dependency — this monitor runs on the hypervisor,
so on Linux, where getloadavg and libvirt already come from. "MemAvailable"
rather than "MemFree", near zero as soon as the cache is working. Swap takes
room in the bar only when it exists, and then even at zero: a machine starting
to swap explains a slowdown. Along the way, "load" and "free" were showing in
French in an English session.
Assisted-by: Claude Opus 5
2026-08-19 06:27:50 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
"""Barre de télémétrie du suivi d'installation : CPU, RAM, disque.
|
|
|
|
|
|
|
|
|
|
La RAM manquait, et son absence a coûté : une compilation mobile s'est fait
|
|
|
|
|
tuer par le noyau sur une VM de 12 Go sans swap, pendant que le suivi affichait
|
|
|
|
|
sereinement le CPU et le disque. L'épuisement mémoire ne se voit nulle part
|
|
|
|
|
ailleurs — le disque va bien, la charge CPU aussi, et la machine meurt.
|
|
|
|
|
|
|
|
|
|
Ce qui se vérifie ici : le chiffre lu est celui que le noyau dit pouvoir
|
|
|
|
|
rendre, le swap n'occupe la barre que s'il existe, et une lecture impossible
|
|
|
|
|
laisse la barre utile au lieu de la vider.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import sys
|
|
|
|
|
import tempfile
|
|
|
|
|
import unittest
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from unittest import mock
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "script/todo"))
|
|
|
|
|
import qemu_install_monitor as m # noqa: E402
|
|
|
|
|
|
|
|
|
|
GB = 1 << 30
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestHostMem(unittest.TestCase):
|
|
|
|
|
def test_it_reads_the_real_proc(self):
|
|
|
|
|
total, avail, sw_total, sw_free = m._host_mem()
|
|
|
|
|
self.assertGreater(total, 0, "aucune RAM lue dans /proc/meminfo")
|
|
|
|
|
self.assertLessEqual(avail, total)
|
|
|
|
|
self.assertLessEqual(sw_free, sw_total)
|
|
|
|
|
|
|
|
|
|
def test_it_reports_what_the_kernel_can_give_back(self):
|
|
|
|
|
"""« MemAvailable » et non « MemFree » : sur une machine qui travaille,
|
|
|
|
|
MemFree est presque nul parce que le cache occupe le reste, et
|
|
|
|
|
alarmerait pour rien."""
|
|
|
|
|
proc = (
|
|
|
|
|
"MemTotal: 12000000 kB\n"
|
|
|
|
|
"MemFree: 100000 kB\n"
|
|
|
|
|
"MemAvailable: 8000000 kB\n"
|
|
|
|
|
"SwapTotal: 0 kB\n"
|
|
|
|
|
"SwapFree: 0 kB\n"
|
|
|
|
|
)
|
|
|
|
|
with mock.patch("builtins.open", mock.mock_open(read_data=proc)):
|
|
|
|
|
total, avail, _, _ = m._host_mem()
|
|
|
|
|
self.assertEqual(avail, 8000000 * 1024)
|
|
|
|
|
self.assertEqual(total, 12000000 * 1024)
|
|
|
|
|
|
|
|
|
|
def test_an_unreadable_proc_gives_zeros_not_a_crash(self):
|
|
|
|
|
"""Le suivi tourne pendant une heure d'installation : il ne meurt pas
|
|
|
|
|
parce qu'une lecture a échoué."""
|
|
|
|
|
with mock.patch("builtins.open", side_effect=OSError):
|
|
|
|
|
self.assertEqual(m._host_mem(), (0, 0, 0, 0))
|
|
|
|
|
|
|
|
|
|
def test_a_malformed_line_is_not_fatal(self):
|
|
|
|
|
with mock.patch(
|
|
|
|
|
"builtins.open", mock.mock_open(read_data="MemTotal: beaucoup\n")
|
|
|
|
|
):
|
|
|
|
|
self.assertEqual(m._host_mem(), (0, 0, 0, 0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMemSegment(unittest.TestCase):
|
|
|
|
|
def test_the_used_share_is_total_minus_available(self):
|
|
|
|
|
got = m._mem_tele(12 * GB, 3 * GB, 0, 0)
|
|
|
|
|
self.assertIn("9.0G/12.0G", got)
|
|
|
|
|
self.assertIn("(75%)", got)
|
|
|
|
|
|
|
|
|
|
def test_the_available_figure_is_shown_as_such(self):
|
|
|
|
|
self.assertIn("3.0G", m._mem_tele(12 * GB, 3 * GB, 0, 0))
|
|
|
|
|
|
|
|
|
|
def test_swap_appears_only_when_the_machine_has_some(self):
|
|
|
|
|
"""Un « swap 0/0 » occuperait la barre pour ne rien dire. Mais dès
|
|
|
|
|
qu'il existe, il est montré même à zéro : une machine qui commence à
|
|
|
|
|
échanger explique une lenteur, et c'est ce qu'on cherche ici."""
|
|
|
|
|
self.assertNotIn("swap", m._mem_tele(12 * GB, 3 * GB, 0, 0))
|
|
|
|
|
self.assertIn(
|
|
|
|
|
"swap 0K/4.0G", m._mem_tele(12 * GB, 3 * GB, 4 * GB, 4 * GB)
|
|
|
|
|
)
|
|
|
|
|
self.assertIn(
|
|
|
|
|
"swap 1.0G/4.0G", m._mem_tele(12 * GB, 3 * GB, 4 * GB, 3 * GB)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_nothing_read_means_no_segment_not_a_zero_segment(self):
|
|
|
|
|
"""La barre garde alors le CPU et le disque, qui eux ont répondu."""
|
|
|
|
|
self.assertEqual(m._mem_tele(0, 0, 0, 0), "")
|
|
|
|
|
|
|
|
|
|
def test_more_available_than_total_does_not_show_negative_use(self):
|
|
|
|
|
"""Cas absurde mais possible entre deux lectures : on ne veut pas
|
|
|
|
|
« -1.0G » dans la barre."""
|
|
|
|
|
self.assertIn("0K/12.0G", m._mem_tele(12 * GB, 13 * GB, 0, 0))
|
|
|
|
|
|
|
|
|
|
|
[ADD] script todo: dire depuis quand un journal d'installation est muet
Le sablier ne distingue pas une installation qui travaille d'une qui est morte :
le marqueur de sortie manque dans les deux cas. Une session ssh emportée, et le
tableau de bord a affiché « ⏳ » pendant 54 minutes sans que rien ne cloche à
l'œil.
La colonne d'état porte maintenant le silence du journal — « ⏳ silence 48min ».
C'est un chiffre, pas un verdict. Le seuil de dix minutes vient d'une mesure :
le téléchargement d'Android Studio tient ~5 min sans une ligne, et l'étape
« APK debug » davantage, son détail partant dans le journal de la VM. Plus bas,
chaque installation deviendrait une alerte, et l'alerte cesserait d'être lue.
--- EN ---
The hourglass does not tell a working install from a dead one: the exit marker
is missing in both cases. An ssh session was reaped, and the dashboard showed
"⏳" for 54 minutes with nothing looking wrong.
The state column now carries the log's silence — "⏳ silent 48min". It is a
figure, not a verdict. The ten-minute threshold comes from a measurement: the
Android Studio download holds ~5 min without a line, and the "debug APK" step
longer, its detail going to the VM's own log. Any lower and every install would
become an alert, and the alert would stop being read.
Assisted-by: Claude Opus 5
2026-08-19 07:26:54 -04:00
|
|
|
class TestLogSilence(unittest.TestCase):
|
|
|
|
|
"""Une installation morte et une qui travaille portent le même sablier.
|
|
|
|
|
|
|
|
|
|
Vécu : une session ssh emportée, l'installation morte sans marqueur de
|
|
|
|
|
sortie, et le tableau de bord a montré « ⏳ » pendant 54 minutes. Le
|
|
|
|
|
marqueur manque dans les deux cas — seule la date d'écriture du journal
|
|
|
|
|
les sépare.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
|
|
|
self.log = Path(self.tmp.name) / "vm.log"
|
|
|
|
|
self.log.write_text("== installation ==\n")
|
|
|
|
|
|
|
|
|
|
def tearDown(self):
|
|
|
|
|
self.tmp.cleanup()
|
|
|
|
|
|
|
|
|
|
def test_a_fresh_log_is_not_silent(self):
|
|
|
|
|
self.assertLess(m.log_idle(str(self.log)), 5)
|
|
|
|
|
|
|
|
|
|
def test_an_old_log_reports_its_age(self):
|
|
|
|
|
import os
|
|
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
old = time.time() - 3000
|
|
|
|
|
os.utime(self.log, (old, old))
|
|
|
|
|
self.assertAlmostEqual(m.log_idle(str(self.log)), 3000, delta=5)
|
|
|
|
|
|
|
|
|
|
def test_a_missing_log_is_not_reported_as_silent(self):
|
|
|
|
|
"""-1 plutôt que 0 : « absent » n'est pas « à l'instant », et surtout
|
|
|
|
|
pas « silencieux depuis toujours »."""
|
|
|
|
|
self.assertEqual(m.log_idle("/nonexistent/erplibre.log"), -1.0)
|
|
|
|
|
self.assertEqual(m.state_mark("⏳", -1.0), "⏳")
|
|
|
|
|
|
|
|
|
|
def test_below_the_threshold_the_cell_stays_bare(self):
|
|
|
|
|
"""Plusieurs étapes sont muettes quelques minutes — un téléchargement
|
|
|
|
|
d'Android Studio, une compilation dont la sortie va ailleurs."""
|
|
|
|
|
self.assertEqual(m.state_mark("⏳", 0), "⏳")
|
|
|
|
|
self.assertEqual(m.state_mark("⏳", m.IDLE_HINT_SECS), "⏳")
|
|
|
|
|
|
|
|
|
|
def test_above_the_threshold_the_cell_says_how_long(self):
|
|
|
|
|
mark = m.state_mark("⏳", 2900)
|
|
|
|
|
self.assertNotEqual(mark, "⏳")
|
|
|
|
|
self.assertIn("48", mark)
|
|
|
|
|
|
|
|
|
|
def test_the_threshold_clears_the_longest_measured_silence(self):
|
|
|
|
|
"""Mesuré : le téléchargement d'Android Studio reste ~5 min sans une
|
|
|
|
|
ligne. Un seuil en dessous transformerait chaque installation en alerte,
|
|
|
|
|
et l'alerte cesserait d'être lue."""
|
|
|
|
|
studio_download = 5 * 60
|
|
|
|
|
self.assertGreater(m.IDLE_HINT_SECS, studio_download)
|
|
|
|
|
# Et pas si haut qu'une installation morte passe la demi-heure.
|
|
|
|
|
self.assertLessEqual(m.IDLE_HINT_SECS, 1200)
|
|
|
|
|
|
|
|
|
|
|
[ADD] script todo: afficher la RAM de l'hôte dans le suivi d'installation
La barre montrait le CPU et le disque, jamais la mémoire. C'est pourtant la
seule des trois dont l'épuisement ne se voit nulle part ailleurs : une
compilation mobile s'est fait tuer par le noyau sur une VM de 12 Go sans swap
pendant que la barre affichait une charge tranquille et du disque de reste.
Lue dans /proc/meminfo, sans dépendance — ce suivi tourne sur l'hyperviseur,
donc sous Linux, d'où viennent déjà getloadavg et libvirt. « MemAvailable »
plutôt que « MemFree », presque nul dès que le cache travaille. Le swap
n'occupe la barre que s'il existe, et alors même à zéro : une machine qui
commence à échanger explique une lenteur. Au passage, « charge » et « libre »
s'affichaient en français dans une session anglaise.
--- EN ---
The bar showed CPU and disk, never memory. Yet memory is the one of the three
whose exhaustion shows up nowhere else: a mobile build was killed by the kernel
on a 12 GB VM with no swap while the bar displayed a quiet load and disk to
spare.
Read from /proc/meminfo, no dependency — this monitor runs on the hypervisor,
so on Linux, where getloadavg and libvirt already come from. "MemAvailable"
rather than "MemFree", near zero as soon as the cache is working. Swap takes
room in the bar only when it exists, and then even at zero: a machine starting
to swap explains a slowdown. Along the way, "load" and "free" were showing in
French in an English session.
Assisted-by: Claude Opus 5
2026-08-19 06:27:50 -04:00
|
|
|
class TestTheRealBar(unittest.TestCase):
|
|
|
|
|
"""La barre telle que le suivi la construit, sans lancer la TUI."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
try:
|
|
|
|
|
import textual # noqa: F401
|
|
|
|
|
except ImportError:
|
|
|
|
|
self.skipTest("textual absent de ce venv")
|
|
|
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
|
|
|
log = Path(self.tmp.name) / "vm-a.log"
|
|
|
|
|
log.write_text("== installation ==\n")
|
|
|
|
|
manifest = Path(self.tmp.name) / "session.json"
|
|
|
|
|
manifest.write_text(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"branch": "develop",
|
|
|
|
|
"started": 0,
|
|
|
|
|
"vms": [
|
|
|
|
|
{
|
|
|
|
|
"name": "vm-a",
|
|
|
|
|
"ip": "192.168.123.2",
|
|
|
|
|
"log": str(log),
|
|
|
|
|
"ssh": "ssh erplibre@192.168.123.2",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
self.app = m.run_monitor(str(manifest), run_app=False)
|
|
|
|
|
|
|
|
|
|
def tearDown(self):
|
|
|
|
|
self.tmp.cleanup()
|
|
|
|
|
|
|
|
|
|
def test_the_bar_carries_the_three_resources(self):
|
|
|
|
|
bar = self.app._collect_tele()
|
|
|
|
|
self.assertIn("CPU", bar)
|
|
|
|
|
self.assertIn("RAM", bar)
|
|
|
|
|
self.assertIn("💽", bar)
|
|
|
|
|
|
|
|
|
|
def test_ram_sits_between_cpu_and_disk(self):
|
|
|
|
|
"""L'ordre est celui du coût : le CPU se voit ailleurs, la RAM nulle
|
|
|
|
|
part, le disque partout."""
|
|
|
|
|
bar = self.app._collect_tele()
|
|
|
|
|
self.assertLess(bar.index("CPU"), bar.index("RAM"))
|
|
|
|
|
self.assertLess(bar.index("RAM"), bar.index("💽"))
|
|
|
|
|
|
|
|
|
|
def test_the_bar_survives_a_mute_proc(self):
|
|
|
|
|
with mock.patch.object(m, "_host_mem", return_value=(0, 0, 0, 0)):
|
|
|
|
|
bar = self.app._collect_tele()
|
|
|
|
|
self.assertIn("CPU", bar)
|
|
|
|
|
self.assertIn("💽", bar)
|
|
|
|
|
self.assertNotIn("RAM", bar)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|