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.
This commit is contained in:
Richard
2026-09-07 21:56:02 +02:00
parent cffd6f4b77
commit 1e9cb789c8
64 changed files with 10543 additions and 554 deletions
@@ -3,10 +3,8 @@ using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Models;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClawdDotNet.Core.Scheduling;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Desktop.ViewModels;
@@ -49,9 +47,7 @@ public sealed record ServiceDisplayItemViewModel(
public sealed partial class TasksPageViewModel : PageViewModel
{
private readonly AppHost? _host;
private readonly JobHistoryService? _jobHistoryService;
private readonly ILogger? _logger;
private readonly Dictionary<string, DateTime> _jobLastRunTimes = new();
public ObservableCollection<JobDisplayItemViewModel> JobEntries { get; } = [];
public ObservableCollection<ServiceDisplayItemViewModel> ServiceEntries { get; } = [];
@@ -96,13 +92,33 @@ public sealed partial class TasksPageViewModel : PageViewModel
if (host is not null)
{
_jobHistoryService = new JobHistoryService(host.InstancePath);
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Tasks");
EnsureBuiltInServices();
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
{
@@ -110,93 +126,57 @@ public sealed partial class TasksPageViewModel : PageViewModel
}
}
private void SaveInstanceConfig()
{
if (_host is null) return;
_host.Directories.SaveInstanceConfig(_host.InstancePath, _host.Instance);
}
private void EnsureBuiltInServices()
{
if (_host is null) return;
var defaults = BuiltInServices.CreateDefaults();
var changed = false;
foreach (var def in defaults)
{
if (_host.Instance.Services.Any(s => s.ServiceId == def.ServiceId))
continue;
_host.Instance.Services.Insert(0, def);
changed = true;
}
if (changed)
SaveInstanceConfig();
}
[RelayCommand]
private void RefreshJobList()
{
var previous = SelectedJob?.JobId;
JobEntries.Clear();
if (_host is null) return;
foreach (var agent in _host.Instance.Agents)
{
// Agent Wakeup Jobs
if (agent.Scheduler is not null)
{
var nextRun = CalculateNextRun(agent.Scheduler.Cron);
var agentLastRun = _jobLastRunTimes.TryGetValue($"agent_{agent.AgentId}", out var agentLastTime)
? agentLastTime.ToString("yyyy-MM-dd HH:mm:ss")
: "—";
foreach (var job in _host.JobAdmin.List())
JobEntries.Add(ToDisplayItem(job, _host.Runs.LastRun(job.JobId)));
JobEntries.Add(new JobDisplayItemViewModel(
"Agent Wakeup",
$"agent_{agent.AgentId}",
agent.AgentId,
agent.DisplayName,
"—",
agent.Scheduler.Cron,
agent.Scheduler.TaskMessage,
agent.Scheduler.RunOnStart,
nextRun,
agentLastRun,
"—",
_host.Scanner is not null ? "Aktiv" : "Inaktiv"
));
}
SelectedJob = JobEntries.FirstOrDefault(j => j.JobId == previous);
}
// Tool Jobs
foreach (var toolJob in agent.ToolJobs)
{
var nextRun = CalculateNextRun(toolJob.Cron);
var toolLastRun = _jobLastRunTimes.TryGetValue(toolJob.JobId, out var lastTime)
? lastTime.ToString("yyyy-MM-dd HH:mm:ss")
: "—";
/// <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;
JobEntries.Add(new JobDisplayItemViewModel(
"Tool Job",
toolJob.JobId,
agent.AgentId,
agent.DisplayName,
toolJob.ToolName,
toolJob.Cron,
toolJob.JobTypeId,
toolJob.RunOnStart,
nextRun,
toolLastRun,
"—",
toolJob.Enabled ? "Aktiv" : "Deaktiviert"
));
}
}
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;
@@ -211,15 +191,17 @@ public sealed partial class TasksPageViewModel : PageViewModel
svc.Description
));
}
SelectedService = ServiceEntries.FirstOrDefault(s => s.ServiceId == previous);
}
[RelayCommand]
private void RefreshJobHistory()
{
JobHistoryEntries.Clear();
if (_jobHistoryService is null) return;
if (_host is null) return;
foreach (var entry in _jobHistoryService.GetAll().Take(200))
foreach (var entry in _host.Runs.History.GetAll().Take(200))
JobHistoryEntries.Add(entry);
}
@@ -232,40 +214,27 @@ public sealed partial class TasksPageViewModel : PageViewModel
var result = await RequestAddJobDialog(vm);
if (result is null) return;
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == result.AgentId);
if (agent is null) return;
var outcome = _host.JobAdmin.Add(ToDefinition(result));
StatusText = outcome.Message;
if (result.IsToolJob)
{
agent.ToolJobs.Add(new ToolJobConfig
{
ToolName = result.ToolName,
JobTypeId = result.JobTypeId,
Cron = result.CronExpression,
RunOnStart = result.RunOnStart,
Enabled = true
});
if (outcome.Ok)
_logger?.LogInformation("Geplanter Lauf hinzugefügt: Agent={Agent}, Cron={Cron}",
result.AgentId, result.CronExpression);
_logger?.LogInformation("Tool-Job hinzugefügt: Agent={Agent}, Tool={Tool}, Cron={Cron}",
agent.DisplayName, result.ToolName, result.CronExpression);
}
else
{
agent.Scheduler = new SchedulerConfig
{
Cron = result.CronExpression,
RunOnStart = result.RunOnStart,
TaskMessage = result.TaskMessage
};
_logger?.LogInformation("Agent-Wakeup-Job hinzugefügt: Agent={Agent}, Cron={Cron}",
agent.DisplayName, result.CronExpression);
}
SaveInstanceConfig();
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()
{
@@ -290,28 +259,7 @@ public sealed partial class TasksPageViewModel : PageViewModel
var result = await RequestAddJobDialog(vm);
if (result is null) return;
if (isToolJob)
{
var toolJob = agent.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId);
if (toolJob is not null)
{
toolJob.Cron = result.CronExpression;
toolJob.RunOnStart = result.RunOnStart;
toolJob.JobTypeId = result.JobTypeId;
toolJob.ToolName = result.ToolName;
}
}
else
{
if (agent.Scheduler is not null)
{
agent.Scheduler.Cron = result.CronExpression;
agent.Scheduler.TaskMessage = result.TaskMessage;
agent.Scheduler.RunOnStart = result.RunOnStart;
}
}
SaveInstanceConfig();
StatusText = _host.JobAdmin.Update(entry.JobId, ToDefinition(result)).Message;
RefreshJobList();
}
@@ -321,19 +269,12 @@ public sealed partial class TasksPageViewModel : PageViewModel
if (_host is null || SelectedJob is null) return;
var entry = SelectedJob;
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
if (agent is null) return;
if (entry.JobType == "Tool Job")
{
agent.ToolJobs.RemoveAll(j => j.JobId == entry.JobId);
}
else
{
agent.Scheduler = null;
}
StatusText = _host.JobAdmin.Remove(
entry.AgentId,
entry.JobId,
entry.JobType == "Tool Job" ? JobKind.ToolJob : JobKind.AgentWakeup).Message;
SaveInstanceConfig();
RefreshJobList();
}
@@ -343,77 +284,25 @@ public sealed partial class TasksPageViewModel : PageViewModel
if (_host is null || SelectedJob 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";
IsRunningJob = true;
StatusText = $"Führe Job für '{agent.DisplayName}' aus…";
StatusText = $"Führe Job für '{entry.AgentName}' aus…";
try
{
if (entry.JobType == "Tool Job")
{
if (_host.Scanner is null)
{
StatusText = "Scanner ist nicht aktiv.";
return;
}
var outcome = isToolJob
? await _host.Runs.RunToolJobAsync(entry.AgentId, entry.JobId, CancellationToken.None)
: await _host.Runs.RunAgentAsync(entry.AgentId, entry.TaskMessage, CancellationToken.None);
var jobConfig = agent.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId);
if (jobConfig is null) return;
StatusText = outcome.Message;
var ran = await _host.Scanner.RunTaskNowAsync(
$"tj-{agent.AgentId}-{jobConfig.JobId}", CancellationToken.None);
_jobLastRunTimes[jobConfig.JobId] = DateTime.Now;
var info = ran ? "Ausgeführt" : "Nicht bereit";
_jobHistoryService?.Add(new JobHistoryEntry
{
JobName = jobConfig.JobTypeId,
Agent = agent.DisplayName,
Time = DateTime.Now,
JobDescription = $"Tool Job: {jobConfig.ToolName}",
Info = info,
Status = "Manual"
});
StatusText = $"Tool Job '{jobConfig.JobTypeId}' abgeschlossen ({info}).";
}
else
{
if (_host.Engine is null)
{
StatusText = "Agent-Engine ist nicht aktiv (kein API-Key).";
return;
}
var taskMessage = agent.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.";
var result = await _host.Engine.RunAsync(agent, taskMessage, _host.Instance.InstanceId, CancellationToken.None);
_jobLastRunTimes[$"agent_{agent.AgentId}"] = DateTime.Now;
_jobHistoryService?.Add(new JobHistoryEntry
{
JobName = "Agent Wakeup",
Agent = agent.DisplayName,
Time = DateTime.Now,
JobDescription = taskMessage,
Info = $"{result.Status} ({result.StepCount} Steps, {result.TokensUsed:N0} Tokens)",
Status = "Manual"
});
StatusText = $"Agent '{agent.DisplayName}' abgeschlossen: {result.Status} ({result.TokensUsed:N0} Tokens).";
}
if (!outcome.Ok)
_logger?.LogWarning("Job-Ausführung fehlgeschlagen: {Grund}", outcome.Message);
RefreshJobList();
RefreshJobHistory();
}
catch (Exception ex)
{
StatusText = $"Fehler beim Ausführen: {ex.Message}";
_logger?.LogError(ex, "Job-Ausführung fehlgeschlagen");
}
finally
{
IsRunningJob = false;
@@ -429,17 +318,12 @@ public sealed partial class TasksPageViewModel : PageViewModel
var result = await RequestAddServiceDialog(vm);
if (result is null) return;
var svcConfig = new ServiceConfig
{
Name = result.ServiceName,
Type = result.ServiceType,
Port = result.ServicePort,
Enabled = true,
Description = result.ServiceDescription
};
StatusText = _host.ServiceAdmin.Add(new NewService(
result.ServiceName,
result.ServiceType,
result.ServicePort,
result.ServiceDescription)).Message;
_host.Instance.Services.Add(svcConfig);
SaveInstanceConfig();
RefreshServiceList();
}
@@ -448,8 +332,7 @@ public sealed partial class TasksPageViewModel : PageViewModel
{
if (_host is null || SelectedService is null) return;
_host.Instance.Services.RemoveAll(s => s.ServiceId == SelectedService.ServiceId);
SaveInstanceConfig();
StatusText = _host.ServiceAdmin.Remove(SelectedService.ServiceId).Message;
RefreshServiceList();
}
@@ -469,28 +352,14 @@ public sealed partial class TasksPageViewModel : PageViewModel
var entry = SelectedJob;
if (entry.JobType != "Tool Job")
{
StatusText = "Nur Tool-Jobs lassen sich abschalten. "
+ "Ein Agent-Wakeup wird über die Agenten-Seite entfernt.";
return;
}
StatusText = _host.JobAdmin
.SetEnabled(entry.AgentId, entry.JobId, enabled: entry.Status != "Aktiv").Message;
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
var job = agent?.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId);
if (job is null) return;
job.Enabled = !job.Enabled;
SaveInstanceConfig();
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);
StatusText = job.Enabled
? $"Job '{entry.TaskMessage}' aktiviert."
: $"Job '{entry.TaskMessage}' deaktiviert.";
}
/// <summary>
@@ -507,18 +376,12 @@ public sealed partial class TasksPageViewModel : PageViewModel
if (_host is null || SelectedService is null) return;
var id = SelectedService.ServiceId;
var service = _host.Instance.Services.FirstOrDefault(s => s.ServiceId == id);
if (service is null) return;
service.Enabled = !service.Enabled;
SaveInstanceConfig();
StatusText = _host.ServiceAdmin
.SetEnabled(id, enabled: SelectedService.Status != "Bereit").Message;
RefreshServiceList();
SelectedService = ServiceEntries.FirstOrDefault(s => s.ServiceId == id);
StatusText = service.Enabled
? $"Dienst '{service.Name}' aktiviert."
: $"Dienst '{service.Name}' deaktiviert.";
}
[RelayCommand]
@@ -532,18 +395,4 @@ public sealed partial class TasksPageViewModel : PageViewModel
};
}
private static string CalculateNextRun(string? cronExpr)
{
if (string.IsNullOrWhiteSpace(cronExpr)) return "—";
try
{
var cron = CronExpression.Parse(cronExpr);
var next = cron.GetNextOccurrence(DateTime.UtcNow);
return next?.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "—";
}
catch
{
return "Ungültig";
}
}
}