- 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:
@@ -10,6 +10,7 @@ using ClawdDotNet.App.Settings;
|
||||
using ClawdDotNet.Desktop.Services;
|
||||
using ClawdDotNet.Desktop.ViewModels;
|
||||
using ClawdDotNet.Desktop.Views;
|
||||
using ClawdDotNet.Web;
|
||||
|
||||
namespace ClawdDotNet.Desktop;
|
||||
|
||||
@@ -24,6 +25,9 @@ public partial class App : Application
|
||||
|
||||
private AppHost? _host;
|
||||
|
||||
/// <summary>Die Weboberflaeche, falls sie eingeschaltet ist. Sonst null.</summary>
|
||||
private WebUiHost? _web;
|
||||
|
||||
/// <summary>Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht.</summary>
|
||||
private string? _preselectedInstancePath;
|
||||
|
||||
@@ -44,6 +48,10 @@ public partial class App : Application
|
||||
|
||||
desktop.ShutdownRequested += async (_, _) =>
|
||||
{
|
||||
// Reihenfolge: erst die Weboberflaeche, dann der Aufbau. Andersherum
|
||||
// bediente Kestrel noch Anfragen gegen eine bereits geschlossene
|
||||
// Datenbank.
|
||||
await ShutdownWebAsync();
|
||||
if (_host is not null) await _host.DisposeAsync();
|
||||
};
|
||||
|
||||
@@ -110,6 +118,11 @@ public partial class App : Application
|
||||
HookErrorReporting(_host);
|
||||
HookLicenseWatch(_host, desktop);
|
||||
|
||||
// Nach dem Aufbau und vor dem Fenster: Die Weboberflaeche braucht den fertigen
|
||||
// AppHost, und ein belegter Port darf das Fenster nicht aufhalten — TryStart
|
||||
// gibt in jedem Fehlerfall null zurueck und schreibt den Grund ins Protokoll.
|
||||
_web = WebUiHost.TryStart(_host, _host.LoggerFactory);
|
||||
|
||||
var mainViewModel = new MainWindowViewModel(_host);
|
||||
|
||||
// Der Wechsel gehoert hierher und nicht ins Fenster: Er braucht den Aufbau und
|
||||
@@ -181,7 +194,7 @@ public partial class App : Application
|
||||
/// der Benutzer — eine Anwendung, die sich beim Start selbst beendet, um sich zu
|
||||
/// erneuern, ist genau dann im Weg, wenn man sie braucht.</para>
|
||||
/// </summary>
|
||||
private static async Task OfferUpdateAsync(AppHost host, IClassicDesktopStyleApplicationLifetime desktop)
|
||||
private async Task OfferUpdateAsync(AppHost host, IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
if (host.Deploymentcenter is null) return;
|
||||
|
||||
@@ -221,12 +234,17 @@ public partial class App : Application
|
||||
/// das tut es aber über <c>Environment.Exit</c> und übergeht damit Schritt 2
|
||||
/// vollständig.</para>
|
||||
/// </summary>
|
||||
private static async Task StartUpdateAsync(
|
||||
private async Task StartUpdateAsync(
|
||||
AppHost host, IClassicDesktopStyleApplicationLifetime desktop, string version)
|
||||
{
|
||||
host.Deploymentcenter!.AnnounceUpdate(version);
|
||||
|
||||
var settings = host.Settings.AppSettings;
|
||||
|
||||
// Vor dem Aufbau: Kestrel darf keine Anfrage mehr gegen eine geschlossene
|
||||
// Datenbank bedienen — und schon gar nicht, waehrend der update-agent
|
||||
// gleich die Dateien darunter austauscht.
|
||||
await ShutdownWebAsync();
|
||||
await host.DisposeAsync();
|
||||
|
||||
var problem = host.Deploymentcenter.StartUpdate(settings, AppHost.AppVersion);
|
||||
@@ -322,6 +340,8 @@ public partial class App : Application
|
||||
if (path is null) return;
|
||||
if (string.Equals(path, _host.InstancePath, StringComparison.OrdinalIgnoreCase)) return;
|
||||
|
||||
await ShutdownWebAsync();
|
||||
|
||||
var executable = Environment.ProcessPath;
|
||||
|
||||
if (executable is null)
|
||||
@@ -353,6 +373,21 @@ public partial class App : Application
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Faehrt die Weboberflaeche herunter, falls sie laeuft, und merkt sich, dass sie
|
||||
/// weg ist. Mehrfach aufrufbar: Update, Instanzwechsel und das gewoehnliche
|
||||
/// Beenden nehmen denselben Weg, und der letzte von ihnen soll nicht ins Leere
|
||||
/// greifen.
|
||||
/// </summary>
|
||||
private async Task ShutdownWebAsync()
|
||||
{
|
||||
if (_web is null) return;
|
||||
|
||||
var web = _web;
|
||||
_web = null;
|
||||
await web.DisposeAsync();
|
||||
}
|
||||
|
||||
private static string? ReadInstanceArgument(IReadOnlyList<string>? args)
|
||||
{
|
||||
if (args is null) return null;
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.App\ClawdDotNet.App.csproj" />
|
||||
|
||||
<!-- Die Weboberflaeche. Sie laeuft im selben Prozess und bekommt denselben AppHost;
|
||||
warum das so sein muss, steht in ClawdDotNet.Web/WebUiHost.cs.
|
||||
|
||||
Die Richtung des Verweises ist Absicht: Desktop kennt Web, Web kennt Desktop
|
||||
nicht. Wer hier umdreht, hat den Schnitt verletzt. -->
|
||||
<ProjectReference Include="..\ClawdDotNet.Web\ClawdDotNet.Web.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Native Debug-Symbole aus NuGet-Paketen nicht mitkopieren. libSkiaSharp.pdb ist
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user