using System.Collections.ObjectModel; using Avalonia.Threading; using ClawdDotNet.App; using ClawdDotNet.App.Models; using ClawdDotNet.App.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Desktop.ViewModels; public sealed record JobDisplayItemViewModel( string JobType, string JobId, string AgentId, string AgentName, string ToolName, string CronExpression, string TaskMessage, bool RunOnStart, string NextRun, string LastRun, string LastStatus, string Status ); /// Die drei Ansichten der Aufgaben-Seite. public enum TasksTab { Jobs, Services, History } public sealed record ServiceDisplayItemViewModel( string ServiceId, string Name, string Type, int Port, string Status, string Description ); /// /// Ansichtsmodell für die Aufgaben-Seite (Jobs, Dienste, Verlauf). /// public sealed partial class TasksPageViewModel : PageViewModel { private readonly AppHost? _host; private readonly ILogger? _logger; public ObservableCollection JobEntries { get; } = []; public ObservableCollection ServiceEntries { get; } = []; public ObservableCollection JobHistoryEntries { get; } = []; [ObservableProperty] private JobDisplayItemViewModel? _selectedJob; [ObservableProperty] private ServiceDisplayItemViewModel? _selectedService; [ObservableProperty] private string _statusText = "Bereit"; [ObservableProperty] private bool _isRunningJob; /// /// Der Entwurf ersetzt die Registerkarten durch eine Segmentleiste. Fachlich ist das /// dasselbe: eine von drei Ansichten. Die drei Kennzeichen darunter gibt es, weil /// eine Bindung „Ist dieser Wert gleich Services?" in AXAML sonst jedes Mal einen /// Konverter mit Parameter bräuchte. /// [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsJobsTab))] [NotifyPropertyChangedFor(nameof(IsServicesTab))] [NotifyPropertyChangedFor(nameof(IsHistoryTab))] private TasksTab _selectedTab = TasksTab.Jobs; public bool IsJobsTab => SelectedTab == TasksTab.Jobs; public bool IsServicesTab => SelectedTab == TasksTab.Services; public bool IsHistoryTab => SelectedTab == TasksTab.History; public event Func>? RequestAddJobDialog; public event Func>? RequestAddServiceDialog; public TasksPageViewModel() : this(null) { } public TasksPageViewModel(AppHost? host) : base("Aufgaben") { _host = host; if (host is not null) { _logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Tasks"); host.ServiceAdmin.EnsureBuiltIns(); RefreshJobList(); RefreshServiceList(); RefreshJobHistory(); // Auch die Weboberflaeche legt Jobs und Dienste an. Das Ereignis kommt auf // dem Faden des Verursachers, deshalb ueber den Dispatcher. // // Kein Abmelden: Diese Seite lebt so lange wie die Anwendung. host.InstanceChanged += change => Dispatcher.UIThread.Post(() => { switch (change.Kind) { case InstanceChangeKind.Jobs: case InstanceChangeKind.Agents: // Auch bei Agenten: Verschwindet ein Agent, verschwinden seine // geplanten Laeufe mit ihm. RefreshJobList(); break; case InstanceChangeKind.Services: RefreshServiceList(); break; } }); } else { StatusText = "Entwurfsmodus"; } } [RelayCommand] private void RefreshJobList() { var previous = SelectedJob?.JobId; JobEntries.Clear(); if (_host is null) return; foreach (var job in _host.JobAdmin.List()) JobEntries.Add(ToDisplayItem(job, _host.Runs.LastRun(job.JobId))); SelectedJob = JobEntries.FirstOrDefault(j => j.JobId == previous); } /// /// Formt einen geplanten Lauf für die Anzeige um. /// /// Die Fachschicht liefert Zeitpunkte als ; wie sie /// aussehen und was an ihrer Stelle steht, wenn es keinen gibt, entscheidet die /// Anzeige. Deshalb steht das Gedankenstrich-Zeichen hier und nicht dort. /// private static JobDisplayItemViewModel ToDisplayItem(JobSummary job, DateTime? lastRun) { var isToolJob = job.Kind == JobKind.ToolJob; var nextRun = !job.CronValid ? "Ungültig" : job.NextRunUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "—"; return new JobDisplayItemViewModel( isToolJob ? "Tool Job" : "Agent Wakeup", job.JobId, job.AgentId, job.AgentName, isToolJob ? job.ToolName : "—", job.Cron, job.Message, job.RunOnStart, nextRun, lastRun?.ToString("yyyy-MM-dd HH:mm:ss") ?? "—", "—", job.Enabled ? "Aktiv" : isToolJob ? "Deaktiviert" : "Inaktiv"); } [RelayCommand] private void RefreshServiceList() { var previous = SelectedService?.ServiceId; ServiceEntries.Clear(); if (_host is null) return; foreach (var svc in _host.Instance.Services) { ServiceEntries.Add(new ServiceDisplayItemViewModel( svc.ServiceId, svc.Name, svc.Type, svc.Port, svc.Enabled ? "Bereit" : "Deaktiviert", svc.Description )); } SelectedService = ServiceEntries.FirstOrDefault(s => s.ServiceId == previous); } [RelayCommand] private void RefreshJobHistory() { JobHistoryEntries.Clear(); if (_host is null) return; foreach (var entry in _host.Runs.History.GetAll().Take(200)) JobHistoryEntries.Add(entry); } [RelayCommand] private async Task AddJobAsync() { if (_host is null || RequestAddJobDialog is null) return; var vm = new AddJobViewModel(_host.Instance.Agents); var result = await RequestAddJobDialog(vm); if (result is null) return; var outcome = _host.JobAdmin.Add(ToDefinition(result)); StatusText = outcome.Message; if (outcome.Ok) _logger?.LogInformation("Geplanter Lauf hinzugefügt: Agent={Agent}, Cron={Cron}", result.AgentId, result.CronExpression); RefreshJobList(); } /// Übersetzt das Ergebnis des Dialogs in das, womit die Fachschicht arbeitet. private static JobDefinition ToDefinition(AddJobResultData result) => new( result.AgentId, result.IsToolJob ? JobKind.ToolJob : JobKind.AgentWakeup, result.ToolName, result.JobTypeId, result.CronExpression, result.TaskMessage, result.RunOnStart); [RelayCommand] private async Task EditJobAsync() { if (_host is null || SelectedJob is null || RequestAddJobDialog is null) return; var entry = SelectedJob; var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId); if (agent is null) return; var isToolJob = entry.JobType == "Tool Job"; var vm = new AddJobViewModel(_host.Instance.Agents) { SelectedAgent = agent, IsToolJob = isToolJob, SelectedToolName = isToolJob ? entry.ToolName : "FileRW", JobTypeId = isToolJob ? entry.TaskMessage : "PollJob", CronExpression = entry.CronExpression, TaskMessage = isToolJob ? "" : entry.TaskMessage, RunOnStart = entry.RunOnStart }; var result = await RequestAddJobDialog(vm); if (result is null) return; StatusText = _host.JobAdmin.Update(entry.JobId, ToDefinition(result)).Message; RefreshJobList(); } [RelayCommand] private void RemoveJob() { if (_host is null || SelectedJob is null) return; var entry = SelectedJob; StatusText = _host.JobAdmin.Remove( entry.AgentId, entry.JobId, entry.JobType == "Tool Job" ? JobKind.ToolJob : JobKind.AgentWakeup).Message; RefreshJobList(); } [RelayCommand] private async Task RunJobNowAsync() { if (_host is null || SelectedJob is null) return; var entry = SelectedJob; var isToolJob = entry.JobType == "Tool Job"; IsRunningJob = true; StatusText = $"Führe Job für '{entry.AgentName}' aus…"; try { var outcome = isToolJob ? await _host.Runs.RunToolJobAsync(entry.AgentId, entry.JobId, CancellationToken.None) : await _host.Runs.RunAgentAsync(entry.AgentId, entry.TaskMessage, CancellationToken.None); StatusText = outcome.Message; if (!outcome.Ok) _logger?.LogWarning("Job-Ausführung fehlgeschlagen: {Grund}", outcome.Message); RefreshJobList(); RefreshJobHistory(); } finally { IsRunningJob = false; } } [RelayCommand] private async Task AddServiceAsync() { if (_host is null || RequestAddServiceDialog is null) return; var vm = new AddServiceViewModel(); var result = await RequestAddServiceDialog(vm); if (result is null) return; StatusText = _host.ServiceAdmin.Add(new NewService( result.ServiceName, result.ServiceType, result.ServicePort, result.ServiceDescription)).Message; RefreshServiceList(); } [RelayCommand] private void RemoveService() { if (_host is null || SelectedService is null) return; StatusText = _host.ServiceAdmin.Remove(SelectedService.ServiceId).Message; RefreshServiceList(); } /// /// Schaltet einen Job an oder ab, ohne ihn zu entfernen — im Entwurf die neue /// Schaltfläche „Aktivieren/Deaktivieren" in der Werkzeugleiste. /// /// Gilt nur für Tool-Jobs: SchedulerConfig hat keinen solchen Schalter, /// ein Agent-Wakeup existiert oder existiert nicht. Ihn hier zu ergänzen wäre keine /// Oberflächenarbeit — der Scanner müsste den Schalter erst einmal lesen, sonst liefe /// ein „deaktivierter" Wakeup munter weiter. /// [RelayCommand] private void ToggleJobEnabled() { if (_host is null || SelectedJob is null) return; var entry = SelectedJob; StatusText = _host.JobAdmin .SetEnabled(entry.AgentId, entry.JobId, enabled: entry.Status != "Aktiv").Message; RefreshJobList(); // Die Auswahl geht beim Neuaufbau der Liste verloren — sie wieder zu setzen // erspart es, für zwei Schaltvorgänge zweimal dieselbe Zeile anzuklicken. SelectedJob = JobEntries.FirstOrDefault(j => j.JobId == entry.JobId); } /// /// Aktiviert bzw. deaktiviert einen Dienst. /// /// Bewusst nicht „Start"/„Stop" genannt, auch wenn der Entwurf das so zeigt: /// Umgelegt wird das Kennzeichen Enabled in der Instanzkonfiguration. Ein /// laufender Dienstprozess wird davon nicht angehalten — eine Schaltfläche „Stop", /// nach der etwas weiterläuft, wäre schlimmer als gar keine. /// [RelayCommand] private void ToggleServiceEnabled() { if (_host is null || SelectedService is null) return; var id = SelectedService.ServiceId; StatusText = _host.ServiceAdmin .SetEnabled(id, enabled: SelectedService.Status != "Bereit").Message; RefreshServiceList(); SelectedService = ServiceEntries.FirstOrDefault(s => s.ServiceId == id); } [RelayCommand] private void SelectTab(string? tab) { SelectedTab = tab switch { "Services" => TasksTab.Services, "History" => TasksTab.History, _ => TasksTab.Jobs }; } }