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
@@ -1,5 +1,7 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -77,12 +79,11 @@ public sealed partial class AgentItemViewModel : ObservableObject
public ObservableCollection<ToolItemViewModel> AvailableTools { get; } = [];
public static readonly List<string> KnownTools =
[
"FileRW", "Telegram", "Mail", "Database", "FTP",
"DirectAPI", "WebFetch", "WebMonitor", "AgentComm",
"SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard"
];
/// <summary>
/// Die Namensliste steht in der Anwendungsschicht — die Weboberfläche braucht
/// dieselbe, und zwei gepflegte Listen wären eine zu viel.
/// </summary>
public static IReadOnlyList<string> KnownTools => AgentAdministration.KnownTools;
public AgentItemViewModel(AgentConfig config, IReadOnlyDictionary<string, string>? descriptions = null)
{
@@ -217,6 +218,18 @@ public sealed partial class AgentsPageViewModel : PageViewModel
{
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Agents");
ReloadAgents();
// Seit es eine zweite Oberflaeche gibt, ist diese Liste nicht mehr die
// einzige Quelle von Aenderungen: Ein im Browser angelegter Agent stuende
// sonst in der Datei und nicht im Fenster. 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 =>
{
if (change.Kind == InstanceChangeKind.Agents)
Dispatcher.UIThread.Post(ReloadAgents);
};
}
else
{
@@ -231,23 +244,34 @@ public sealed partial class AgentsPageViewModel : PageViewModel
/// Beschreibung eben leer.
/// </summary>
private IReadOnlyDictionary<string, string> ToolDescriptions()
=> _host?.Tools.GetAll()
.GroupBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().Description, StringComparer.OrdinalIgnoreCase)
?? new Dictionary<string, string>();
=> _host?.AgentAdmin.ToolDescriptions() ?? new Dictionary<string, string>();
/// <summary>
/// Baut die Liste neu auf und behaelt die Auswahl, sofern es sie noch gibt.
///
/// <para>Die Auswahl zu behalten ist kein Feinschliff: Der Aufbau laeuft jetzt auch,
/// wenn die Weboberflaeche etwas geaendert hat. Waere die Auswahl danach jedesmal
/// weg, spraenge dem Benutzer der Cursor aus der Zeile, waehrend er arbeitet.</para>
/// </summary>
[RelayCommand]
private void ReloadAgents()
{
if (_host is null)
{
Agents.Clear();
return;
}
var previous = SelectedAgent?.AgentId;
Agents.Clear();
if (_host is null) return;
var descriptions = ToolDescriptions();
foreach (var agent in _host.Instance.Agents)
Agents.Add(new AgentItemViewModel(agent, descriptions));
SelectedAgent = Agents.FirstOrDefault();
SelectedAgent = Agents.FirstOrDefault(a => a.AgentId == previous) ?? Agents.FirstOrDefault();
}
partial void OnSelectedAgentChanged(AgentItemViewModel? value)
@@ -282,28 +306,22 @@ public sealed partial class AgentsPageViewModel : PageViewModel
var result = await RequestAddAgentDialog(vm);
if (result is null) return;
if (_host.Instance.Agents.Any(a => a.AgentId.Equals(result.AgentId, StringComparison.OrdinalIgnoreCase)))
{
StatusText = $"Ein Agent mit der ID '{result.AgentId}' existiert bereits.";
return;
}
// Erst die offenen Änderungen der Liste sichern, dann anlegen: Der Aufruf
// schreibt die Instanzdatei, und was hier noch nicht übernommen war, ginge sonst
// verloren.
ApplyPendingEdits();
var newConfig = new AgentConfig
{
AgentId = result.AgentId,
DisplayName = result.DisplayName,
Model = result.Model,
Description = result.Description
};
var outcome = _host.AgentAdmin.Add(
new NewAgent(result.AgentId, result.DisplayName, result.Model, result.Description));
_host.Instance.Agents.Add(newConfig);
SaveAgents();
StatusText = outcome.Message;
if (!outcome.Ok) return;
if (_host.AgentAdmin.Find(result.AgentId) is not { } newConfig) return;
var item = new AgentItemViewModel(newConfig, ToolDescriptions());
Agents.Add(item);
SelectedAgent = item;
StatusText = $"Agent '{result.DisplayName}' angelegt.";
}
[RelayCommand]
@@ -311,14 +329,15 @@ public sealed partial class AgentsPageViewModel : PageViewModel
{
if (_host is null || SelectedAgent is null) return;
var agentId = SelectedAgent.AgentId;
_host.Instance.Agents.RemoveAll(a => a.AgentId == agentId);
var removed = SelectedAgent;
ApplyPendingEdits();
SaveAgents();
Agents.Remove(SelectedAgent);
var outcome = _host.AgentAdmin.Remove(removed.AgentId);
StatusText = outcome.Message;
if (!outcome.Ok) return;
Agents.Remove(removed);
SelectedAgent = Agents.FirstOrDefault();
StatusText = $"Agent '{agentId}' entfernt.";
}
/// <summary>
@@ -338,23 +357,19 @@ public sealed partial class AgentsPageViewModel : PageViewModel
}
var config = SelectedAgent.Config;
var message = config.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.";
IsRunning = true;
StatusText = $"Führe '{config.DisplayName}' aus…";
try
{
var result = await _host.Engine.RunAsync(
config, message, _host.Instance.InstanceId, CancellationToken.None);
var outcome = await _host.Runs.RunAgentAsync(
config.AgentId, config.Scheduler?.TaskMessage, CancellationToken.None);
StatusText = $"'{config.DisplayName}' abgeschlossen: {result.Status} "
+ $"({result.StepCount} Schritte, {result.TokensUsed:N0} Tokens).";
}
catch (Exception ex)
{
StatusText = $"Lauf fehlgeschlagen: {ex.Message}";
_logger?.LogError(ex, "Handlauf des Agenten fehlgeschlagen");
StatusText = outcome.Message;
if (!outcome.Ok)
_logger?.LogWarning("Handlauf des Agenten fehlgeschlagen: {Grund}", outcome.Message);
}
finally
{
@@ -420,19 +435,19 @@ public sealed partial class AgentsPageViewModel : PageViewModel
[RelayCommand]
private void SaveToolSettings()
{
if (SelectedAgent is null || SelectedTool is null || ToolSettings is null) return;
if (_host is null || SelectedAgent is null || SelectedTool is null || ToolSettings is null) return;
ToolSettings.ConfirmCommand.Execute(null);
if (ToolSettings.Result is not { } config) return;
SelectedAgent.Config.Tools[SelectedTool.Name] = config;
// Einstellungen zu schreiben heißt, das Werkzeug zu wollen.
// Einstellungen zu schreiben heißt, das Werkzeug zu wollen — das Ankreuzfeld
// zieht nach, damit Liste und Konfiguration nicht auseinanderlaufen.
SelectedTool.IsEnabled = true;
SaveAgents();
StatusText = $"Einstellungen für '{SelectedTool.Name}' gespeichert.";
ApplyPendingEdits();
StatusText = _host.AgentAdmin
.SetToolSettings(SelectedAgent.AgentId, SelectedTool.Name, config).Message;
}
[RelayCommand]
@@ -440,14 +455,27 @@ public sealed partial class AgentsPageViewModel : PageViewModel
{
if (_host is null) return;
foreach (var agentVm in Agents)
agentVm.ApplyChanges();
ApplyPendingEdits();
_host.AgentAdmin.Save();
_host.Directories.SaveInstanceConfig(_host.InstancePath, _host.Instance);
_logger?.LogInformation("Agenten-Konfiguration gespeichert.");
StatusText = "Agenten-Konfiguration gespeichert.";
}
/// <summary>
/// Überträgt, was in den Feldern der Seite steht, in die Konfigurationsobjekte.
///
/// <para>Die Ansichtsmodelle halten eigene Eigenschaften und schreiben sie erst bei
/// <c>ApplyChanges</c> zurück. Jeder Aufruf, der gleich die Instanzdatei schreibt,
/// muss vorher hier durch — sonst verschwindet eine gerade getippte Änderung, weil
/// jemand nebenbei einen Agenten angelegt hat.</para>
/// </summary>
private void ApplyPendingEdits()
{
foreach (var agentVm in Agents)
agentVm.ApplyChanges();
}
// ─── Teilerstellungen ───
//
// Die drei Teiler der Seite sollen den Neustart ueberleben. Gespeichert wird ein
@@ -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";
}
}
}