Files
ClawdDotNet/src/ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs
T
Richard 1e9cb789c8 Weboberflaeche hinzugefuegt und Fachlogik nach App herausgeloest (#493, #494)
- ClawdDotNet.Web: Kestrel im Prozess der Anwendung, Token-Absicherung (TokenGate mit FixedTimeEquals), Endpunkte unter /api, Ereignisstrom unter /events (Server-Sent Events) und fortlaufendes Log-Streaming.

- Weboberflaeche (wwwroot/): Vollstaendiges Single-Page-Frontend mit nativem HTML5, CSS3 und ES-Modulen ohne Buildschritt (strikt textContent, #-Routing, sofortiger Login-Sprung bei fehlendem Token, Light/Dark-Theme, lokale Barlow-Schriften).

- ClawdDotNet.App: Fachlogik aus den Avalonia-ViewModels in Dienste herausgeloest (AgentAdministration, JobAdministration, ServiceAdministration, RunCoordinator) und ueber InstanceChange synchronisiert.

- Tests: ClawdDotNet.Web.Tests und ClawdDotNet.App.Tests hinzugefuegt, alle 684 Tests erfolgreich.
2026-09-07 21:56:02 +02:00

399 lines
12 KiB
C#

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
);
/// <summary>Die drei Ansichten der Aufgaben-Seite.</summary>
public enum TasksTab
{
Jobs,
Services,
History
}
public sealed record ServiceDisplayItemViewModel(
string ServiceId,
string Name,
string Type,
int Port,
string Status,
string Description
);
/// <summary>
/// Ansichtsmodell für die Aufgaben-Seite (Jobs, Dienste, Verlauf).
/// </summary>
public sealed partial class TasksPageViewModel : PageViewModel
{
private readonly AppHost? _host;
private readonly ILogger? _logger;
public ObservableCollection<JobDisplayItemViewModel> JobEntries { get; } = [];
public ObservableCollection<ServiceDisplayItemViewModel> ServiceEntries { get; } = [];
public ObservableCollection<JobHistoryEntry> JobHistoryEntries { get; } = [];
[ObservableProperty]
private JobDisplayItemViewModel? _selectedJob;
[ObservableProperty]
private ServiceDisplayItemViewModel? _selectedService;
[ObservableProperty]
private string _statusText = "Bereit";
[ObservableProperty]
private bool _isRunningJob;
/// <summary>
/// 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.
/// </summary>
[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<AddJobViewModel, Task<AddJobResultData?>>? RequestAddJobDialog;
public event Func<AddServiceViewModel, Task<AddServiceResultData?>>? 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);
}
/// <summary>
/// Formt einen geplanten Lauf für die Anzeige um.
///
/// <para>Die Fachschicht liefert Zeitpunkte als <see cref="DateTime"/>; 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.</para>
/// </summary>
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();
}
/// <summary>Übersetzt das Ergebnis des Dialogs in das, womit die Fachschicht arbeitet.</summary>
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();
}
/// <summary>
/// Schaltet einen Job an oder ab, ohne ihn zu entfernen — im Entwurf die neue
/// Schaltfläche „Aktivieren/Deaktivieren" in der Werkzeugleiste.
///
/// <para>Gilt nur für Tool-Jobs: <c>SchedulerConfig</c> 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.</para>
/// </summary>
[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);
}
/// <summary>
/// Aktiviert bzw. deaktiviert einen Dienst.
///
/// <para>Bewusst nicht „Start"/„Stop" genannt, auch wenn der Entwurf das so zeigt:
/// Umgelegt wird das Kennzeichen <c>Enabled</c> 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.</para>
/// </summary>
[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
};
}
}