- 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.
399 lines
12 KiB
C#
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
|
|
};
|
|
}
|
|
|
|
}
|