Sicherungspunkt vor dem Aufraeumen. Buendelt die Arbeit, die seit dem Abschluss der Avalonia-Portierung im Arbeitsverzeichnis lag. Oberflaeche - Entwurf aus Mockup/ umgesetzt: Theme.axaml (Farben je Thema, Barlow als mitgelieferte Schrift), Icons.axaml (Symbolgeometrien), Shell.axaml (eigene ControlThemes statt Fluent umzufaerben). - Neue Steuerelemente StrokeIcon und BlueprintFrame, Seiten fuer Token-Verbrauch und Agenten-Chats, Werkzeug-Einstellungen als Seite statt eigenem Fenster, Texteditor-Fenster. - ThemeManager mit hellem und dunklem Thema; die beiden Pinsel-Konverter entfallen, weil ein fester Farbwert den Themenwechsel nicht ueberlebt. Rocket.Chat - Neues Tool-Projekt (Client, Konfiguration, Workspace-Dateien) nach der Bauform des Telegram-Tools: rocketchat_poll als Tool-Job, geweckt wird nur, wenn wirklich etwas anliegt. - send_file ist freigabepflichtig, send_message bewusst nicht: Der Raum ist Arbeitsraum, der Schutz sitzt an der Raum-Allowlist. - Konzept-Doc um die Messung gegen die echte Instanz 8.7 ergaenzt; drei Annahmen waren falsch und sind korrigiert. Deploymentcenter - DC6 (Update anwenden) und DC7 (Erstinstallation ueber setup.json) erledigt, DC3 fuer win-x64/dev; deploy/publish.py als Release-Strecke. - AppHost.DisposeAsync gegen doppeltes Herunterfahren gesperrt - sonst ueberschreibt eine zweite Abmeldung den Wartungszustand am Watchdog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
550 lines
17 KiB
C#
550 lines
17 KiB
C#
using System.Collections.ObjectModel;
|
|
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;
|
|
|
|
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 JobHistoryService? _jobHistoryService;
|
|
private readonly ILogger? _logger;
|
|
private readonly Dictionary<string, DateTime> _jobLastRunTimes = new();
|
|
|
|
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)
|
|
{
|
|
_jobHistoryService = new JobHistoryService(host.InstancePath);
|
|
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Tasks");
|
|
|
|
EnsureBuiltInServices();
|
|
RefreshJobList();
|
|
RefreshServiceList();
|
|
RefreshJobHistory();
|
|
}
|
|
else
|
|
{
|
|
StatusText = "Entwurfsmodus";
|
|
}
|
|
}
|
|
|
|
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()
|
|
{
|
|
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")
|
|
: "—";
|
|
|
|
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"
|
|
));
|
|
}
|
|
|
|
// 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")
|
|
: "—";
|
|
|
|
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"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void RefreshServiceList()
|
|
{
|
|
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
|
|
));
|
|
}
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void RefreshJobHistory()
|
|
{
|
|
JobHistoryEntries.Clear();
|
|
if (_jobHistoryService is null) return;
|
|
|
|
foreach (var entry in _jobHistoryService.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 agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == result.AgentId);
|
|
if (agent is null) return;
|
|
|
|
if (result.IsToolJob)
|
|
{
|
|
agent.ToolJobs.Add(new ToolJobConfig
|
|
{
|
|
ToolName = result.ToolName,
|
|
JobTypeId = result.JobTypeId,
|
|
Cron = result.CronExpression,
|
|
RunOnStart = result.RunOnStart,
|
|
Enabled = true
|
|
});
|
|
|
|
_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();
|
|
}
|
|
|
|
[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;
|
|
|
|
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();
|
|
RefreshJobList();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void RemoveJob()
|
|
{
|
|
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;
|
|
}
|
|
|
|
SaveInstanceConfig();
|
|
RefreshJobList();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task RunJobNowAsync()
|
|
{
|
|
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;
|
|
|
|
IsRunningJob = true;
|
|
StatusText = $"Führe Job für '{agent.DisplayName}' aus…";
|
|
|
|
try
|
|
{
|
|
if (entry.JobType == "Tool Job")
|
|
{
|
|
if (_host.Scanner is null)
|
|
{
|
|
StatusText = "Scanner ist nicht aktiv.";
|
|
return;
|
|
}
|
|
|
|
var jobConfig = agent.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId);
|
|
if (jobConfig is null) return;
|
|
|
|
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).";
|
|
}
|
|
|
|
RefreshJobList();
|
|
RefreshJobHistory();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusText = $"Fehler beim Ausführen: {ex.Message}";
|
|
_logger?.LogError(ex, "Job-Ausführung fehlgeschlagen");
|
|
}
|
|
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;
|
|
|
|
var svcConfig = new ServiceConfig
|
|
{
|
|
Name = result.ServiceName,
|
|
Type = result.ServiceType,
|
|
Port = result.ServicePort,
|
|
Enabled = true,
|
|
Description = result.ServiceDescription
|
|
};
|
|
|
|
_host.Instance.Services.Add(svcConfig);
|
|
SaveInstanceConfig();
|
|
RefreshServiceList();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void RemoveService()
|
|
{
|
|
if (_host is null || SelectedService is null) return;
|
|
|
|
_host.Instance.Services.RemoveAll(s => s.ServiceId == SelectedService.ServiceId);
|
|
SaveInstanceConfig();
|
|
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;
|
|
|
|
if (entry.JobType != "Tool Job")
|
|
{
|
|
StatusText = "Nur Tool-Jobs lassen sich abschalten. "
|
|
+ "Ein Agent-Wakeup wird über die Agenten-Seite entfernt.";
|
|
return;
|
|
}
|
|
|
|
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>
|
|
/// 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;
|
|
var service = _host.Instance.Services.FirstOrDefault(s => s.ServiceId == id);
|
|
if (service is null) return;
|
|
|
|
service.Enabled = !service.Enabled;
|
|
SaveInstanceConfig();
|
|
RefreshServiceList();
|
|
|
|
SelectedService = ServiceEntries.FirstOrDefault(s => s.ServiceId == id);
|
|
|
|
StatusText = service.Enabled
|
|
? $"Dienst '{service.Name}' aktiviert."
|
|
: $"Dienst '{service.Name}' deaktiviert.";
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void SelectTab(string? tab)
|
|
{
|
|
SelectedTab = tab switch
|
|
{
|
|
"Services" => TasksTab.Services,
|
|
"History" => TasksTab.History,
|
|
_ => TasksTab.Jobs
|
|
};
|
|
}
|
|
|
|
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";
|
|
}
|
|
}
|
|
}
|