Files
SaraSort/ServicePage.py
T
2026-10-09 14:16:50 +02:00

223 lines
9.6 KiB
Python

"""SaraSortd's service: status, start/stop/restart, start on login, its
version, and its logs (its own log files and the systemd journal)."""
import asyncio
from pathlib import Path
import flet as ft
import tomlkit
from Core import ChipButton, ChipButtonStyle, DataHome, Heading, IsWindows, Note, Palette
from Service import Control, Journal, LogFiles, ReadTail, Status
from Widgets import App
ReceiptPath = DataHome / "SaraSuite" / "Receipts" / "SaraSortd.toml"
def LineColor(Line: str) -> str:
Lower = Line.lower()
if "error" in Lower or "traceback" in Lower or "failed" in Lower:
return Palette.Red
if "sorted" in Lower:
return Palette.Green
if "started" in Lower or "stopped" in Lower:
return Palette.Mauve
if "checking" in Lower:
return Palette.Overlay0
return Palette.Text
class ServicePage(ft.Column):
def __init__(self, TheApp: App):
super().__init__(expand=True, spacing=0)
self.App = TheApp
self.Polling = False
self.Source = "Files" # or "Journal"
self.Dot = ft.Container(width=14, height=14, border_radius=7, bgcolor=Palette.Overlay0)
self.StateText = ft.Text("", size=16)
self.Details = ft.Column(spacing=2)
self.VersionInfo = ft.Column(spacing=2)
self.Autostart = ft.Switch(label="Start SaraSortd on login", on_change=self._ToggleAutostart)
self.StartButton = ChipButton("Start", lambda e: self._Do("start"), ft.Icons.PLAY_ARROW)
self.StopButton = ChipButton("Stop", lambda e: self._Do("stop"), ft.Icons.STOP, Accent=Palette.Red)
self.RestartButton = ChipButton("Restart", lambda e: self._Do("restart"), ft.Icons.RESTART_ALT)
self.FilesButton = ChipButton("Log files", lambda e: self._SetSource("Files"))
self.JournalButton = ChipButton("Journal", lambda e: self._SetSource("Journal"))
self.FilePicker = ft.Dropdown(label="Log file", dense=True, width=260, on_select=lambda e: self._LoadLog())
self.HideChecking = ft.Checkbox(label="Hide \"Checking\" lines", value=True, on_change=lambda e: self._LoadLog())
self.Follow = ft.Checkbox(label="Follow", value=True)
self.LogView = ft.ListView(expand=True, spacing=0, padding=8, auto_scroll=True)
StatusCard = ft.Container(
ft.Column([
ft.Row([self.Dot, self.StateText], spacing=10),
self.Details,
ft.Row([self.StartButton, self.StopButton, self.RestartButton], wrap=True),
self.Autostart,
], spacing=10),
padding=14, bgcolor=Palette.Mantle, border=ft.Border.all(1, Palette.Surface1), border_radius=6, expand=True,
)
VersionCard = ft.Container(
ft.Column([ft.Text("Version", color=Palette.Mauve), self.VersionInfo], spacing=8),
padding=14, bgcolor=Palette.Mantle, border=ft.Border.all(1, Palette.Surface1), border_radius=6, expand=True,
)
self.controls = [
ft.Container(
ft.Row([Heading("Service"), ft.Container(expand=True),
ChipButton("Reload", lambda e: self.Refresh(), ft.Icons.REFRESH)]),
padding=ft.Padding.only(left=16, right=16, top=12, bottom=8),
),
ft.Container(ft.Row([StatusCard, VersionCard], vertical_alignment=ft.CrossAxisAlignment.START),
padding=ft.Padding.symmetric(horizontal=16)),
ft.Container(
ft.Row([self.FilesButton, self.JournalButton, self.FilePicker, ft.Container(width=8),
self.HideChecking, self.Follow],
vertical_alignment=ft.CrossAxisAlignment.CENTER, scroll=ft.ScrollMode.AUTO),
padding=ft.Padding.only(left=16, right=16, top=14, bottom=6),
),
ft.Container(self.LogView, expand=True, bgcolor=Palette.Crust, margin=ft.Margin.only(left=16, right=16, bottom=16),
border=ft.Border.all(1, Palette.Surface1), border_radius=6),
]
# Status
def Refresh(self) -> None:
self._RefreshStatus()
self._RefreshVersion()
self._RefreshLogFiles()
self._LoadLog()
self._StyleSourceButtons()
self.update()
def _RefreshStatus(self) -> None:
Current = Status(self.App.Config.ServiceName)
if not Current.Found:
Color, Text = Palette.Overlay0, f"{self.App.Config.ServiceName} isn't installed"
elif Current.Running:
Color, Text = Palette.Green, f"Running ({Current.Sub})"
elif Current.Active == "failed":
Color, Text = Palette.Red, "Failed"
else:
Color, Text = Palette.Yellow, f"Stopped ({Current.Active})"
self.Dot.bgcolor = Color
self.StateText.value = Text
self.StateText.color = Color
self.Details.controls = [
Note(f"{'Service' if IsWindows else 'Unit'}: {self.App.Config.ServiceName}"),
Note(f"Since: {Current.Since or '-'}"),
Note(f"PID: {Current.Pid or '-'}"),
Note(f"{'Autostart (Startup folder)' if IsWindows else 'Unit file'}: {Current.Enabled}"),
]
self.StartButton.disabled = Current.Running or not Current.Found
self.StopButton.disabled = not Current.Running
self.RestartButton.disabled = not Current.Found
self.Autostart.value = Current.Enabled == "enabled"
self.Autostart.disabled = not Current.Found
def _RefreshVersion(self) -> None:
TheSetup, Error = self.App.LoadSetup()
Lines = []
Daemon = TheSetup.DaemonVersion() if TheSetup else None
Lines.append(ft.Text(f"SaraSortd {Daemon or '?'}", size=16))
Lines.append(Note(str(self.App.Config.SortdScriptPath)))
if TheSetup:
ConfVersion = str(TheSetup.Data.get("Version", "?"))
Mismatch = Daemon and ConfVersion.split(".")[0] != Daemon.split(".")[0]
Lines.append(ft.Text(f"GlobalConf.toml {ConfVersion}" + (" (major version differs!)" if Mismatch else ""),
color=Palette.Red if Mismatch else Palette.Text, size=13))
else:
Lines.append(ft.Text(Error, color=Palette.Red, size=12))
try:
Receipt = tomlkit.parse(ReceiptPath.read_text()).unwrap()
Lines.append(Note(f"Installed by SaraSuite: commit {str(Receipt.get('Commit', '?'))[:10]}"))
Lines.append(Note(f"Installed {Receipt.get('InstalledAt', '?')}, updated {Receipt.get('UpdatedAt', '?')}"))
except (OSError, tomlkit.exceptions.TOMLKitError):
Lines.append(Note("Not installed through SaraSuite (no receipt)."))
self.VersionInfo.controls = Lines
def _Do(self, Action: str) -> None:
Error = Control(self.App.Config.ServiceName, Action)
if Error:
self.App.Notify(Error, "error")
else:
self.App.Notify(f"SaraSortd: {Action} done.", "success")
self.Refresh()
def _ToggleAutostart(self, e) -> None:
Error = Control(self.App.Config.ServiceName, "enable" if self.Autostart.value else "disable")
if Error:
self.App.Notify(Error, "error")
self._RefreshStatus()
self.update()
# Logs
def _StyleSourceButtons(self) -> None:
self.FilesButton.style = ChipButtonStyle(Palette.Mauve if self.Source == "Files" else Palette.Surface1)
self.JournalButton.style = ChipButtonStyle(Palette.Mauve if self.Source == "Journal" else Palette.Surface1)
self.FilePicker.visible = self.Source == "Files"
def _SetSource(self, Source: str) -> None:
self.Source = Source
self._StyleSourceButtons()
self._LoadLog()
self.update()
def _LogDir(self) -> Path | None:
TheSetup, _ = self.App.LoadSetup()
return TheSetup.SpecialDir("LogDir") if TheSetup else None
def _RefreshLogFiles(self) -> None:
LogDir = self._LogDir()
Files = LogFiles(LogDir) if LogDir else []
Current = self.FilePicker.value
self.FilePicker.options = [ft.DropdownOption(key=str(FilePath), text=FilePath.name) for FilePath in Files]
Keys = [str(FilePath) for FilePath in Files]
if Current not in Keys:
self.FilePicker.value = Keys[0] if Keys else None
def _LoadLog(self) -> None:
Limit = self.App.Config.LogLines
if self.Source == "Journal":
Lines = Journal(self.App.Config.ServiceName, Limit)
elif self.FilePicker.value:
Lines = ReadTail(Path(self.FilePicker.value), Limit * 4)
else:
Lines = ["No log files yet (is Logging = 1 in GlobalConf.toml?)."]
if self.HideChecking.value:
Lines = [Line for Line in Lines if "Checking" not in Line]
Lines = Lines[-Limit:]
self.LogView.auto_scroll = bool(self.Follow.value)
self.LogView.controls = [
ft.Text(Line, size=12, color=LineColor(Line), selectable=True) for Line in Lines
]
if self.page:
self.LogView.update()
# Polling while the page is shown
def StartPolling(self) -> None:
if self.Polling:
return
self.Polling = True
self.App.Page.run_task(self._Poll)
def StopPolling(self) -> None:
self.Polling = False
async def _Poll(self) -> None:
while self.Polling:
await asyncio.sleep(max(1, int(self.App.Config.RefreshSeconds)))
if not self.Polling:
break
try:
self._RefreshStatus()
if self.Follow.value:
self._RefreshLogFiles()
self._LoadLog()
self.update()
except Exception: # the page may be gone mid-update
pass