SaraSort: Flet GUI for configuring SaraSortd

Flowchart of inputs/outputs with editable arrows, service page (status,
controls, autostart, version, logs), config editor (form + raw), templates,
tools (warnings, filename tester, failed files) and settings with
SaraFem-compatible export/import. AppImage build and a SaraSuite manifest
in Packaging/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-07 22:23:36 +02:00
commit f7ed23edf9
22 files changed
+3399

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
"""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, 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"Unit: {self.App.Config.ServiceName}"),
Note(f"Since: {Current.Since or '-'}"),
Note(f"PID: {Current.Pid or '-'}"),
Note(f"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