"""SaraSortd's service: status, start/stop/restart, start on login, its version, and its logs (its own log files, and the systemd journal or, where SaraSuite runs it, its service log; see Service.py).""" import asyncio from pathlib import Path import flet as ft import tomlkit from Core import ChipButton, ChipButtonStyle, DataHome, Heading, IsWindows, Note, Palette, RunBySaraSuite 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("Service log" if RunBySaraSuite else "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 RunBySaraSuite else 'Unit'}: {self.App.Config.ServiceName.removesuffix('.service') if RunBySaraSuite else self.App.Config.ServiceName}"), Note(f"Since: {Current.Since or '-'}"), Note(f"PID: {Current.Pid or '-'}"), Note(f"{'Autostart (Startup folder)' if IsWindows else 'Autostart (at login)' if RunBySaraSuite 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