system.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  1. """System information API routes."""
  2. import asyncio
  3. import os
  4. import platform
  5. import time
  6. from collections.abc import Callable
  7. from datetime import datetime, timezone
  8. from pathlib import Path
  9. import psutil
  10. from fastapi import APIRouter, Depends
  11. from sqlalchemy import func, select
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
  14. from backend.app.core.config import APP_VERSION, settings
  15. from backend.app.core.database import get_db
  16. from backend.app.core.local_config import read_local_toml, read_ntp_gate
  17. from backend.app.core.permissions import Permission
  18. from backend.app.core.printer_scope import PrinterScope
  19. from backend.app.models.archive import PrintArchive
  20. from backend.app.models.filament import Filament
  21. from backend.app.models.print_log import PrintLogEntry
  22. from backend.app.models.printer import Printer
  23. from backend.app.models.project import Project
  24. from backend.app.models.smart_plug import SmartPlug
  25. from backend.app.models.user import User
  26. from backend.app.services.log_health import ScanResult, scan_logs
  27. from backend.app.services.log_reader import collect_sensitive_strings
  28. from backend.app.services.printer_manager import printer_manager
  29. router = APIRouter(prefix="/system", tags=["system"])
  30. STORAGE_USAGE_CACHE_SECONDS = 300
  31. _storage_usage_cache: dict | None = None
  32. _storage_usage_cache_ts: float | None = None
  33. _storage_usage_lock = asyncio.Lock()
  34. def get_directory_size(path: Path) -> int:
  35. """Calculate total size of a directory in bytes."""
  36. total = 0
  37. try:
  38. for entry in path.rglob("*"):
  39. if entry.is_file():
  40. total += entry.stat().st_size
  41. except (PermissionError, OSError):
  42. pass # Return partial total if directory traversal is interrupted
  43. return total
  44. def format_bytes(bytes_value: int) -> str:
  45. """Format bytes to human-readable string."""
  46. for unit in ["B", "KB", "MB", "GB", "TB"]:
  47. if bytes_value < 1024:
  48. return f"{bytes_value:.1f} {unit}"
  49. bytes_value /= 1024
  50. return f"{bytes_value:.1f} PB"
  51. def format_uptime(seconds: float) -> str:
  52. """Format uptime in seconds to human-readable string."""
  53. days = int(seconds // 86400)
  54. hours = int((seconds % 86400) // 3600)
  55. minutes = int((seconds % 3600) // 60)
  56. parts = []
  57. if days > 0:
  58. parts.append(f"{days}d")
  59. if hours > 0:
  60. parts.append(f"{hours}h")
  61. if minutes > 0:
  62. parts.append(f"{minutes}m")
  63. return " ".join(parts) if parts else "< 1m"
  64. def _is_under(path: Path, root: Path) -> bool:
  65. try:
  66. path.resolve().relative_to(root.resolve())
  67. return True
  68. except ValueError:
  69. return False
  70. def _get_database_paths() -> list[Path]:
  71. from backend.app.core.db_dialect import is_sqlite
  72. if not is_sqlite():
  73. return [] # PostgreSQL — no local DB files
  74. candidates = [settings.base_dir / "bambuddy.db", settings.base_dir / "bambutrack.db"]
  75. return [path for path in candidates if path.exists()]
  76. def _get_database_items() -> list[dict]:
  77. items: list[dict] = []
  78. for path in _get_database_paths():
  79. try:
  80. size = path.stat().st_size
  81. except OSError:
  82. continue
  83. items.append(
  84. {
  85. "name": path.name,
  86. "path": str(path),
  87. "bytes": size,
  88. "formatted": format_bytes(size),
  89. }
  90. )
  91. items.sort(key=lambda item: item["bytes"], reverse=True)
  92. return items
  93. def _get_app_dir() -> Path:
  94. return settings.static_dir.parent
  95. def _get_data_dirs() -> list[Path]:
  96. return [
  97. settings.archive_dir,
  98. settings.log_dir,
  99. settings.plate_calibration_dir,
  100. settings.base_dir / "virtual_printer",
  101. settings.base_dir / "firmware",
  102. ]
  103. def _is_system_path(path: Path) -> bool:
  104. app_dir = _get_app_dir()
  105. if not _is_under(path, app_dir):
  106. return False
  107. return all(not _is_under(path, data_dir) for data_dir in _get_data_dirs())
  108. def _get_storage_rules() -> list[tuple[str, str, Callable]]:
  109. base_dir = settings.base_dir
  110. archive_dir = settings.archive_dir
  111. library_dir = archive_dir / "library"
  112. virtual_printer_dir = base_dir / "virtual_printer"
  113. upload_dir = virtual_printer_dir / "uploads"
  114. db_paths = set(_get_database_paths())
  115. return [
  116. (
  117. "database",
  118. "Database",
  119. lambda path: path in db_paths,
  120. ),
  121. (
  122. "library_thumbnails",
  123. "Library Thumbnails",
  124. lambda path: _is_under(path, library_dir / "thumbnails"),
  125. ),
  126. (
  127. "library_files",
  128. "Library Files",
  129. lambda path: _is_under(path, library_dir / "files"),
  130. ),
  131. (
  132. "library_other",
  133. "Library Other",
  134. lambda path: _is_under(path, library_dir),
  135. ),
  136. (
  137. "archive_timelapses",
  138. "Timelapses",
  139. lambda path: _is_under(path, archive_dir) and "timelapse" in path.name.lower(),
  140. ),
  141. (
  142. "archive_thumbnails",
  143. "Thumbnails",
  144. lambda path: _is_under(path, archive_dir) and path.name.lower().startswith("thumbnail"),
  145. ),
  146. (
  147. "archive_files",
  148. "Archives",
  149. lambda path: _is_under(path, archive_dir),
  150. ),
  151. (
  152. "virtual_printer_upload_cache",
  153. "Virtual Printer Upload Cache",
  154. lambda path: _is_under(path, upload_dir / "cache"),
  155. ),
  156. (
  157. "virtual_printer_uploads",
  158. "Virtual Printer Uploads",
  159. lambda path: _is_under(path, upload_dir),
  160. ),
  161. (
  162. "virtual_printer_certs",
  163. "Virtual Printer Certs",
  164. lambda path: _is_under(path, virtual_printer_dir / "certs"),
  165. ),
  166. (
  167. "virtual_printer_other",
  168. "Virtual Printer Other",
  169. lambda path: _is_under(path, virtual_printer_dir),
  170. ),
  171. (
  172. "downloads",
  173. "Downloads",
  174. lambda path: _is_under(path, base_dir / "firmware"),
  175. ),
  176. (
  177. "plate_calibration",
  178. "Plate Calibration",
  179. lambda path: _is_under(path, settings.plate_calibration_dir),
  180. ),
  181. (
  182. "logs",
  183. "Logs",
  184. lambda path: _is_under(path, settings.log_dir),
  185. ),
  186. ]
  187. def _classify_file(path: Path, rules: list[tuple[str, str, Callable]]) -> tuple[str, str]:
  188. for key, label, matcher in rules:
  189. try:
  190. if matcher(path):
  191. return key, label
  192. except OSError:
  193. continue
  194. return "other_data", "Other"
  195. def _format_percentage(part: int, total: int) -> float:
  196. if total <= 0:
  197. return 0.0
  198. return round((part / total) * 100, 2)
  199. def _get_other_bucket(path: Path, base_dir: Path) -> str:
  200. try:
  201. relative = path.resolve().relative_to(base_dir.resolve())
  202. except ValueError:
  203. return path.parent.name or path.name
  204. parts = relative.parts
  205. return parts[0] if parts else path.name
  206. def _walk_files(roots: list[Path]) -> list[Path]:
  207. files: list[Path] = []
  208. stack = [root for root in roots if root.exists()]
  209. while stack:
  210. current = stack.pop()
  211. try:
  212. with os.scandir(current) as entries:
  213. for entry in entries:
  214. try:
  215. if entry.is_symlink():
  216. continue
  217. if entry.is_dir(follow_symlinks=False):
  218. stack.append(Path(entry.path))
  219. elif entry.is_file(follow_symlinks=False):
  220. files.append(Path(entry.path))
  221. except OSError:
  222. continue
  223. except OSError:
  224. continue
  225. return files
  226. def _scan_storage_usage() -> dict:
  227. base_dir = settings.base_dir
  228. rules = _get_storage_rules()
  229. roots = _get_data_dirs()
  230. seen_roots = set()
  231. unique_roots = []
  232. for root in roots:
  233. resolved = root.resolve()
  234. if resolved not in seen_roots:
  235. seen_roots.add(resolved)
  236. unique_roots.append(root)
  237. total_bytes = 0
  238. error_count = 0
  239. category_sizes: dict[str, dict] = {}
  240. other_breakdown: dict[tuple[str, str], int] = {}
  241. database_items = _get_database_items()
  242. files = _walk_files(unique_roots)
  243. for file_path in files:
  244. try:
  245. size = file_path.stat().st_size
  246. except OSError:
  247. error_count += 1
  248. continue
  249. total_bytes += size
  250. key, label = _classify_file(file_path, rules)
  251. if key not in category_sizes:
  252. category_sizes[key] = {"key": key, "label": label, "bytes": 0}
  253. category_sizes[key]["bytes"] += size
  254. if key == "other_data":
  255. bucket = _get_other_bucket(file_path, base_dir)
  256. kind = "system" if _is_system_path(file_path) else "data"
  257. other_breakdown[(bucket, kind)] = other_breakdown.get((bucket, kind), 0) + size
  258. for item in database_items:
  259. total_bytes += item["bytes"]
  260. key = "database"
  261. label = "Database"
  262. if key not in category_sizes:
  263. category_sizes[key] = {"key": key, "label": label, "bytes": 0}
  264. category_sizes[key]["bytes"] += item["bytes"]
  265. categories = []
  266. for item in category_sizes.values():
  267. bytes_value = item["bytes"]
  268. categories.append(
  269. {
  270. "key": item["key"],
  271. "label": item["label"],
  272. "bytes": bytes_value,
  273. "formatted": format_bytes(bytes_value),
  274. "percent_of_total": _format_percentage(bytes_value, total_bytes),
  275. }
  276. )
  277. categories.sort(key=lambda entry: entry["bytes"], reverse=True)
  278. other_items = []
  279. for (bucket, kind), size in other_breakdown.items():
  280. other_items.append(
  281. {
  282. "bucket": bucket,
  283. "label": bucket,
  284. "kind": kind,
  285. "deletable": kind != "system",
  286. "bytes": size,
  287. "formatted": format_bytes(size),
  288. "percent_of_total": _format_percentage(size, total_bytes),
  289. }
  290. )
  291. other_items.sort(key=lambda entry: entry["bytes"], reverse=True)
  292. return {
  293. "roots": [str(root) for root in unique_roots],
  294. "total_bytes": total_bytes,
  295. "total_formatted": format_bytes(total_bytes),
  296. "categories": categories,
  297. "other_breakdown": other_items,
  298. "scan_errors": error_count,
  299. }
  300. async def _get_storage_usage_cached(refresh: bool, max_age_seconds: int) -> dict:
  301. global _storage_usage_cache
  302. global _storage_usage_cache_ts
  303. now = time.time()
  304. if not refresh and _storage_usage_cache and _storage_usage_cache_ts is not None:
  305. age = now - _storage_usage_cache_ts
  306. if age < max_age_seconds:
  307. return {
  308. **_storage_usage_cache,
  309. "cache": {
  310. "hit": True,
  311. "age_seconds": round(age, 2),
  312. "max_age_seconds": max_age_seconds,
  313. },
  314. }
  315. async with _storage_usage_lock:
  316. now = time.time()
  317. if not refresh and _storage_usage_cache and _storage_usage_cache_ts is not None:
  318. age = now - _storage_usage_cache_ts
  319. if age < max_age_seconds:
  320. return {
  321. **_storage_usage_cache,
  322. "cache": {
  323. "hit": True,
  324. "age_seconds": round(age, 2),
  325. "max_age_seconds": max_age_seconds,
  326. },
  327. }
  328. snapshot = await asyncio.to_thread(_scan_storage_usage)
  329. _storage_usage_cache = {
  330. **snapshot,
  331. "generated_at": datetime.now(timezone.utc).isoformat(),
  332. }
  333. _storage_usage_cache_ts = time.time()
  334. return {
  335. **_storage_usage_cache,
  336. "cache": {
  337. "hit": False,
  338. "age_seconds": 0,
  339. "max_age_seconds": max_age_seconds,
  340. },
  341. }
  342. @router.get("/info")
  343. async def get_system_info(
  344. db: AsyncSession = Depends(get_db),
  345. _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
  346. printer_scope: PrinterScope = RequestPrinterScope,
  347. ):
  348. """Get comprehensive system information."""
  349. # Database stats
  350. archive_count = await db.scalar(select(func.count(PrintArchive.id)))
  351. # Printers are counted and listed as the caller's printer list shows them.
  352. printer_count_query = select(func.count(Printer.id))
  353. if (printer_clause := printer_scope.where_strict(Printer.id)) is not None:
  354. printer_count_query = printer_count_query.where(printer_clause)
  355. printer_count = await db.scalar(printer_count_query)
  356. filament_count = await db.scalar(select(func.count(Filament.id)))
  357. project_count = await db.scalar(select(func.count(Project.id)))
  358. smart_plug_count = await db.scalar(select(func.count(SmartPlug.id)))
  359. # Archive stats by status
  360. completed_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
  361. failed_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed"))
  362. printing_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "printing"))
  363. # System-wide totals aggregate per-run from ``print_log_entries`` so
  364. # reprints contribute each run and multi-plate sums are pulled from the
  365. # measured per-run actuals — same source the per-archive stats and the
  366. # project rollup use (#1593). Pre-fix this summed ``PrintArchive`` directly,
  367. # which under-reported the same way the project page did (3 reprints of
  368. # one file showed as one file's worth of filament/time).
  369. total_print_time = (
  370. await db.scalar(
  371. select(func.sum(PrintLogEntry.duration_seconds)).where(PrintLogEntry.duration_seconds.isnot(None))
  372. )
  373. or 0
  374. )
  375. total_filament = (
  376. await db.scalar(
  377. select(func.sum(PrintLogEntry.filament_used_grams)).where(PrintLogEntry.filament_used_grams.isnot(None))
  378. )
  379. or 0
  380. )
  381. # Connected printers
  382. connected_printers = []
  383. for printer_id, client in printer_manager._clients.items():
  384. if not printer_scope.allows(printer_id):
  385. continue
  386. state = client.state
  387. if state and state.connected:
  388. # Get printer name and model from database
  389. result = await db.execute(select(Printer.name, Printer.model).where(Printer.id == printer_id))
  390. row = result.first()
  391. name = row[0] if row else f"Printer {printer_id}"
  392. model = row[1] if row else "unknown"
  393. connected_printers.append(
  394. {
  395. "id": printer_id,
  396. "name": name,
  397. "state": state.state,
  398. "model": model,
  399. }
  400. )
  401. # Storage info
  402. archive_dir = settings.archive_dir
  403. archive_size = get_directory_size(archive_dir) if archive_dir.exists() else 0
  404. # Database info (engine type, version, size)
  405. from backend.app.core.db_dialect import is_postgres, is_sqlite
  406. db_engine_info: dict = {"engine": "unknown", "version": "unknown"}
  407. db_size = 0
  408. try:
  409. if is_postgres():
  410. from sqlalchemy import text
  411. result = await db.execute(text("SELECT version()"))
  412. pg_version_full = result.scalar() or "unknown"
  413. # e.g. "PostgreSQL 16.2 on x86_64..." → "PostgreSQL 16.2"
  414. pg_version = " ".join(pg_version_full.split()[:2])
  415. result = await db.execute(text("SELECT pg_database_size(current_database())"))
  416. db_size = result.scalar() or 0
  417. db_engine_info = {
  418. "engine": "PostgreSQL",
  419. "version": pg_version,
  420. }
  421. elif is_sqlite():
  422. from sqlalchemy import text
  423. result = await db.execute(text("SELECT sqlite_version()"))
  424. sqlite_ver = result.scalar() or "unknown"
  425. db_path = settings.base_dir / "bambuddy.db"
  426. db_size = db_path.stat().st_size if db_path.exists() else 0
  427. db_engine_info = {
  428. "engine": "SQLite",
  429. "version": f"SQLite {sqlite_ver}",
  430. }
  431. except Exception:
  432. pass
  433. # Disk usage
  434. disk = psutil.disk_usage(str(settings.base_dir))
  435. # System info
  436. memory = psutil.virtual_memory()
  437. # PID 1's create_time is the right uptime anchor in containerised installs
  438. # (Docker, LXC) — psutil.boot_time() reads /proc/stat:btime which on a
  439. # shared-kernel container is the host's boot time, not the container's
  440. # (#1690). On bare metal / VMs PID 1 is the host init, which starts at
  441. # boot, so the value matches psutil.boot_time() within a sub-second.
  442. try:
  443. boot_time = datetime.fromtimestamp(psutil.Process(1).create_time(), tz=timezone.utc)
  444. except (psutil.Error, OSError):
  445. boot_time = datetime.fromtimestamp(psutil.boot_time(), tz=timezone.utc)
  446. uptime_seconds = (datetime.now(timezone.utc) - boot_time).total_seconds()
  447. # Python and system info
  448. import sys
  449. return {
  450. "app": {
  451. "version": APP_VERSION,
  452. "base_dir": str(settings.base_dir),
  453. "archive_dir": str(archive_dir),
  454. },
  455. "database": {
  456. "engine": db_engine_info["engine"],
  457. "version": db_engine_info["version"],
  458. "archives": archive_count,
  459. "archives_completed": completed_count,
  460. "archives_failed": failed_count,
  461. "archives_printing": printing_count,
  462. "printers": printer_count,
  463. "filaments": filament_count,
  464. "projects": project_count,
  465. "smart_plugs": smart_plug_count,
  466. "total_print_time_seconds": total_print_time,
  467. "total_print_time_formatted": format_uptime(total_print_time),
  468. "total_filament_grams": round(total_filament, 1),
  469. "total_filament_kg": round(total_filament / 1000, 2),
  470. },
  471. "printers": {
  472. "total": printer_count,
  473. "connected": len(connected_printers),
  474. "connected_list": connected_printers,
  475. },
  476. "storage": {
  477. "archive_size_bytes": archive_size,
  478. "archive_size_formatted": format_bytes(archive_size),
  479. "database_size_bytes": db_size,
  480. "database_size_formatted": format_bytes(db_size),
  481. "disk_total_bytes": disk.total,
  482. "disk_total_formatted": format_bytes(disk.total),
  483. "disk_used_bytes": disk.used,
  484. "disk_used_formatted": format_bytes(disk.used),
  485. "disk_free_bytes": disk.free,
  486. "disk_free_formatted": format_bytes(disk.free),
  487. "disk_percent_used": disk.percent,
  488. },
  489. "system": {
  490. "platform": platform.system(),
  491. "platform_release": platform.release(),
  492. "platform_version": platform.version(),
  493. "architecture": platform.machine(),
  494. "hostname": platform.node(),
  495. "python_version": sys.version.split()[0],
  496. "uptime_seconds": uptime_seconds,
  497. "uptime_formatted": format_uptime(uptime_seconds),
  498. "boot_time": boot_time.isoformat(),
  499. },
  500. "memory": {
  501. "total_bytes": memory.total,
  502. "total_formatted": format_bytes(memory.total),
  503. "available_bytes": memory.available,
  504. "available_formatted": format_bytes(memory.available),
  505. "used_bytes": memory.used,
  506. "used_formatted": format_bytes(memory.used),
  507. "percent_used": memory.percent,
  508. },
  509. "cpu": {
  510. "count": psutil.cpu_count(),
  511. "count_logical": psutil.cpu_count(logical=True),
  512. "percent": psutil.cpu_percent(interval=0.1),
  513. },
  514. }
  515. @router.get("/storage-usage")
  516. async def get_storage_usage(
  517. refresh: bool = False,
  518. max_age_seconds: int = STORAGE_USAGE_CACHE_SECONDS,
  519. _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
  520. ):
  521. """Get storage usage breakdown for Bambuddy data directories."""
  522. max_age_seconds = max(0, min(max_age_seconds, 3600))
  523. return await _get_storage_usage_cached(refresh=refresh, max_age_seconds=max_age_seconds)
  524. @router.get("/health", response_model=ScanResult)
  525. async def get_system_health(
  526. db: AsyncSession = Depends(get_db),
  527. _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
  528. printer_scope: PrinterScope = RequestPrinterScope,
  529. ):
  530. """Scan the recent application log against the known-issue catalog.
  531. Powers the self-service triage surfaces (System page + bug reporter).
  532. Sample lines are sanitized before they leave the process.
  533. """
  534. sensitive_strings = await collect_sensitive_strings(db)
  535. result = await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)
  536. # A log line can be about any printer; a caller limited to some gets the
  537. # findings without them.
  538. if not printer_scope.is_unrestricted:
  539. for finding in result.findings:
  540. finding.sample = ""
  541. return result
  542. @router.get("/db-pool")
  543. async def get_db_pool(
  544. _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
  545. ):
  546. """Live database connection-pool gauges for large-farm diagnostics (#2572).
  547. Reports the resolved pool configuration plus current checked-out /
  548. checked-in / overflow counts. Deliberately takes no DB session — reading
  549. the pool's own counters must not itself consume a connection, so this stays
  550. truthful even when the pool is saturated. On a healthy install ``checked_out``
  551. sits well below ``config.pool_size + config.max_overflow``; sustained
  552. saturation points at connections held across slow I/O (see #2572).
  553. """
  554. from backend.app.core.database import get_pool_status
  555. return get_pool_status()
  556. @router.get("/appliance")
  557. async def get_appliance_defaults():
  558. """Expose appliance-set state for the SPA's bootstrap surface.
  559. Two file sources, both optional and silently degraded when absent:
  560. - ``/etc/bambuddy/local.toml`` — hostname / timezone / locale the
  561. firstboot wizard collected.
  562. - ``/run/bambuddy/time-synced`` — chrony NTP gate state. The RPi 5 has
  563. no battery-backed RTC, so on a fresh boot the clock is wrong until
  564. ntp-gate.sh writes "ok" (or "warning" if 3-minute timeout elapsed).
  565. A warning state means JWT expiries and TLS validity windows may be
  566. misaligned; the UI should surface this.
  567. No auth required — the frontend bootstrap reads this BEFORE auth might
  568. be set up, and the contents are user-set defaults plus a public sync
  569. flag (no secrets).
  570. """
  571. config = read_local_toml()
  572. return {
  573. "hostname": config.get("hostname"),
  574. "timezone": config.get("timezone"),
  575. "locale": config.get("locale"),
  576. "time_synced": read_ntp_gate(),
  577. }