- 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:
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user