Instanzmanager Stufe 4: eine Adresse, ein Zugang, alle Instanzen (#528, E-M5)
Der Managerprozess. ClawdDotNet.Instanzmanager ist eine Konsolenanwendung: Lizenz pruefen, uebernehmen was laeuft, Weboberflaeche aufhaengen, warten. Beim Herunterfahren beendet er keine Instanz. Zwei neue Projekte. ClawdDotNet.Manager.Web traegt die Weboberflaeche - ohne jeden Verweis auf ClawdDotNet.Web, denn dort haengt App und daran sechzehn Werkzeugprojekte. ClawdDotNet.ManagerApp ist der Einstiegspunkt. wwwroot ist aus der Instanz in den Manager gezogen. DER SCHNITT DER HTTP-FLAECHE: Unter /api/verwaltung steht, was den Manager angeht - auflisten, anlegen, starten, anhalten. Alles andere unter /api geht unveraendert an eine Instanz. Er leitet weiter, er implementiert nicht. Welche Instanz gemeint ist, sagt die Kopfzeile X-Instanz; laeuft genau eine, ist sie gemeint, und laufen mehrere, wird nachgefragt statt geraten - eine Anfrage an die falsche Instanz ist schlimmer als eine abgelehnte. Der zusammengefuehrte Ereignisstrom haelt je laufender Instanz eine Zuleitung und schreibt an jedes Ereignis, von wem es kommt. Ergaenzt statt verpackt: Ein Umschlag waere einfacher gewesen, haette aber jede Ansicht der Weboberflaeche zum Umschreiben gezwungen. ABNAHME gegen laufende Prozesse: 401 ohne Token, Oberflaeche ohne Token 200, Instanz ueber den Manager gestartet und angehalten, /api/info und /api/status unverfaelscht durchgereicht, /events/logs mit "instanz":"Instance-Test" an jeder Zeile. Manager abgeschossen: Instanz laeuft weiter. Manager neu gestartet: "1 Instanzen bekannt, 1 laufen bereits" - uebernommen. E-M6 damit in beide Richtungen belegt. DREI DEFEKTE, die erst der Lauf gezeigt hat: 1. Der Manager hing an einer Konsolenabfrage nach dem Lizenzschluessel. Ein Test auf Console.IsInputRedirected reicht dafuer NICHT: Er beantwortet "ist die Eingabe umgeleitet", nicht "kann jemand antworten" - ein Prozess ganz ohne Konsole, der gefaehrlichste Fall, ist nicht umgeleitet. Der Manager fragt jetzt gar nicht. 2. Eine unlesbare manager.json wurde stillschweigend durch Vorgaben ersetzt: falsches Instanzverzeichnis, anderer Port, Lizenzfrage - und nichts sagte, woran es lag. LoadProblem meldet den Grund jetzt. 3. Der Instanzprozess erbte CLAWD_CONFIG_DIR vom Manager und legte sich in dessen Konfigurationsverzeichnis eine eigene Settings.json an. Eine Umgebungsvariable vererbt sich, ein Argument nicht - der Manager nimmt sein Verzeichnis jetzt ueber --config. SharedLoggerProvider ist nach Core gezogen: Es gibt jetzt zwei Webhosts, die dasselbe brauchen, und sie duerfen einander nicht kennen. NOCH OFFEN in dieser Stufe: Die Weboberflaeche kennt noch keine Instanzen - Instanzauswahl, X-Instanz und die Verwaltungsansicht sind Arbeit an wwwroot. Und webServerPort steht noch in der Instanz. Build ohne Warnungen, 775 Tests gruen, 8 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ClawdDotNet.Manager.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Der Torwächter der Weboberfläche: an die Daten kommt nur, wer das Token hat.
|
||||
///
|
||||
/// <para><b>Warum der Manager einen eigenen hat und nicht den der Instanz.</b> Die Instanz
|
||||
/// hört seit Stufe 2 nur noch auf ihrem Socket, und dort ist die Grenze das Dateisystem —
|
||||
/// ihr Torwächter kennt deshalb eine Ausnahme für lokale Verbindungen. Hier gibt es die
|
||||
/// nicht: Alles unter <c>/api</c> und <c>/events</c> kommt über TCP herein, und alles davon
|
||||
/// braucht das Token. Eine Regel weniger ist hier eine Regel, die nicht falsch angewandt
|
||||
/// werden kann.</para>
|
||||
///
|
||||
/// <para><b>Was ohne Token ausgeliefert wird, ist die leere Hülle</b> — HTML, CSS und
|
||||
/// JavaScript. Das ist kein Zugeständnis, sondern die einzige Bauform, die funktioniert:
|
||||
/// Ein Browser, der eine Adresse anspringt, kann keine Kopfzeile mitschicken; wäre die
|
||||
/// Seite selbst gesperrt, gäbe es keinen Ort, an dem man das Token je eingeben könnte.
|
||||
/// In den Dateien steht nichts, was jemand nicht wissen dürfte.</para>
|
||||
///
|
||||
/// <para><b>Warum der Vergleich in fester Zeit läuft:</b> Ein gewöhnlicher
|
||||
/// Zeichenkettenvergleich bricht beim ersten Unterschied ab. Wer die Antwortzeit misst,
|
||||
/// rät das Token Zeichen für Zeichen statt in einem Rutsch — bei einem Dienst, der
|
||||
/// Agenten mit Dateizugriff steuert, ist das kein theoretischer Einwand.</para>
|
||||
/// </summary>
|
||||
public sealed class AccessToken(byte[] expected)
|
||||
{
|
||||
/// <summary>Kürzer als das hier darf ein Token nicht sein.</summary>
|
||||
public const int MinimumLength = 20;
|
||||
|
||||
public static AccessToken For(string token)
|
||||
=> new(Encoding.UTF8.GetBytes(token));
|
||||
|
||||
/// <summary>Ob dieser Pfad ein Token braucht: alles unter <c>/api</c> und <c>/events</c>.</summary>
|
||||
public static bool NeedsToken(PathString path)
|
||||
=> path.StartsWithSegments("/api") || path.StartsWithSegments("/events");
|
||||
|
||||
/// <summary>
|
||||
/// Holt das Token aus der Anfrage.
|
||||
///
|
||||
/// <para><c>Authorization: Bearer …</c> ist der Weg. Ein Cookie wäre bequemer und
|
||||
/// genau deshalb falsch: Der Browser schickt es bei jeder Anfrage automatisch mit,
|
||||
/// auch bei einer, die eine fremde Seite ausgelöst hat. Eine Kopfzeile, die
|
||||
/// JavaScript setzen muss, wird nicht von selbst mitgeschickt — damit ist CSRF hier
|
||||
/// keine eigene Baustelle.</para>
|
||||
///
|
||||
/// <para>Die Ausnahme ist der Ereignisstrom: <c>EventSource</c> im Browser kann keine
|
||||
/// Kopfzeilen setzen. Dafür — und nur dafür — wird das Token auch aus der
|
||||
/// Abfragezeichenkette gelesen. Eine bewusst in Kauf genommene Schwäche: Adressen mit
|
||||
/// Abfragezeichenkette landen in Protokollen. Vertretbar, weil die Weboberfläche in
|
||||
/// der Vorgabe nur auf Loopback hört; wer sie ins Netz stellt, hat einen Reverse Proxy
|
||||
/// davor und entscheidet dort, was protokolliert wird.</para>
|
||||
/// </summary>
|
||||
public static string? Read(HttpRequest request)
|
||||
{
|
||||
var header = request.Headers.Authorization.ToString();
|
||||
|
||||
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return header["Bearer ".Length..].Trim();
|
||||
|
||||
if (request.Path.StartsWithSegments("/events")
|
||||
&& request.Query.TryGetValue("token", out var fromQuery))
|
||||
return fromQuery.ToString();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool Matches(string? candidate)
|
||||
{
|
||||
if (string.IsNullOrEmpty(candidate)) return false;
|
||||
|
||||
// FixedTimeEquals gibt bei ungleicher Laenge false zurueck, ohne zu werfen. Dass
|
||||
// dabei die Laenge durchscheint, ist unbedenklich: Sie ist kein Geheimnis,
|
||||
// sondern eine feste Zahl. Geheim ist der Inhalt, und der wird in fester Zeit
|
||||
// verglichen.
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(candidate), expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Manager.Web</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Die EINE Weboberflaeche fuer alle Instanzen (#528, E-M5).
|
||||
|
||||
Bewusst Microsoft.NET.Sdk und nicht Microsoft.NET.Sdk.Web: Dieses Projekt ist eine
|
||||
Bibliothek, die in einem fremden Prozess einen Kestrel hochfaehrt. ASP.NET Core
|
||||
kommt als Framework-Referenz und damit ohne NuGet-Paket.
|
||||
|
||||
KEIN Verweis auf ClawdDotNet.Web: Dort haengt ClawdDotNet.App dran und daran
|
||||
sechzehn Werkzeugprojekte. Der Manager spricht mit den Instanzen ueber ihren
|
||||
Socket, nicht ueber geteilte Objekte - und genau deshalb leitet er weiter, statt
|
||||
Fachlogik zu haben. -->
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Manager\ClawdDotNet.Manager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ClawdDotNet.Manager.Web.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Die Oberflaeche als gewoehnliche Dateien neben der Programmdatei, nicht
|
||||
eingebettet: Wer am HTML arbeitet, sieht seine Aenderung nach einem Neuladen im
|
||||
Browser statt nach einem Neubau. -->
|
||||
<ItemGroup>
|
||||
<Content Include="wwwroot\**" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,154 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Manager.Web;
|
||||
|
||||
/// <summary>Eine Instanz, wie die Weboberfläche sie sieht.</summary>
|
||||
public sealed record InstanceDto(string Id, string Name, string Zustand, int? Prozess);
|
||||
|
||||
/// <summary>Was beim Anlegen mitkommt.</summary>
|
||||
public sealed record NewInstanceDto(string Name);
|
||||
|
||||
/// <summary>Antwort auf etwas, das gelingen oder scheitern kann.</summary>
|
||||
public sealed record OutcomeDto(bool Ok, string Meldung);
|
||||
|
||||
/// <summary>
|
||||
/// Die HTTP-Fläche des Managers.
|
||||
///
|
||||
/// <para><b>Zwei Sorten, und der Unterschied ist die ganze Architektur:</b> Unter
|
||||
/// <c>/api/verwaltung</c> steht, was den Manager selbst angeht — welche Instanzen es gibt,
|
||||
/// anlegen, starten, anhalten. Alles andere unter <c>/api</c> wird unverändert an eine
|
||||
/// Instanz weitergereicht.</para>
|
||||
///
|
||||
/// <para>Wer hier einen Endpunkt ergänzt, der etwas über Agenten, Aufgaben oder
|
||||
/// Freigaben weiß, hat Fachlogik in den Manager gelegt — die gehört in die Instanz, wo
|
||||
/// die Avalonia-Oberfläche sie auch findet.</para>
|
||||
/// </summary>
|
||||
internal static class ManagerEndpoints
|
||||
{
|
||||
// ─── Was den Manager selbst angeht ───
|
||||
|
||||
public static void MapVerwaltung(this WebApplication app)
|
||||
{
|
||||
var verwaltung = app.MapGroup("/api/verwaltung");
|
||||
|
||||
verwaltung.MapGet("/instanzen", (InstanceRegistry registry) =>
|
||||
registry.Instances.Select(Map).ToList());
|
||||
|
||||
verwaltung.MapPost("/instanzen", async (
|
||||
NewInstanceDto neu, ManagerCore manager, CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(neu.Name))
|
||||
return Results.BadRequest(new OutcomeDto(false, "Eine Instanz braucht einen Namen."));
|
||||
|
||||
try
|
||||
{
|
||||
var pfad = await manager.CreateInstanceAsync(neu.Name.Trim(), ct);
|
||||
|
||||
return Results.Ok(new OutcomeDto(
|
||||
true, $"Instanz '{neu.Name.Trim()}' angelegt: {pfad}"));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// Gibt es schon. Das ist eine Antwort auf eine Frage und keine Stoerung
|
||||
// des Servers - deshalb 400 und nicht 500.
|
||||
return Results.BadRequest(new OutcomeDto(false, ex.Message));
|
||||
}
|
||||
});
|
||||
|
||||
verwaltung.MapPost("/instanzen/{id}/start", async (
|
||||
string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) =>
|
||||
{
|
||||
if (Find(registry, id) is not { } instanz)
|
||||
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
|
||||
|
||||
var ergebnis = await manager.StartInstanceAsync(instanz.Directory, ct);
|
||||
|
||||
return ergebnis switch
|
||||
{
|
||||
StartRefusal.None =>
|
||||
Results.Ok(new OutcomeDto(true, $"Instanz '{instanz.Name}' läuft.")),
|
||||
|
||||
// 402: Der Grund liegt nicht an der Anfrage, sondern an der Lizenz.
|
||||
StartRefusal.NoLicense => Results.Json(
|
||||
new OutcomeDto(false, "Ohne gültige Lizenz startet keine Instanz."),
|
||||
statusCode: StatusCodes.Status402PaymentRequired),
|
||||
|
||||
_ => Results.Json(
|
||||
new OutcomeDto(false,
|
||||
$"Instanz '{instanz.Name}' ist nicht hochgekommen. "
|
||||
+ "Der Grund steht im Protokoll des Managers."),
|
||||
statusCode: StatusCodes.Status500InternalServerError)
|
||||
};
|
||||
});
|
||||
|
||||
verwaltung.MapPost("/instanzen/{id}/stop", async (
|
||||
string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) =>
|
||||
{
|
||||
if (Find(registry, id) is not { } instanz)
|
||||
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
|
||||
|
||||
var beendet = await manager.StopInstanceAsync(instanz.Directory, ct);
|
||||
|
||||
return beendet
|
||||
? Results.Ok(new OutcomeDto(true, $"Instanz '{instanz.Name}' beendet."))
|
||||
: Results.Json(
|
||||
new OutcomeDto(false,
|
||||
$"Instanz '{instanz.Name}' hört nicht und ist von hier aus nicht zu beenden."),
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Der Ereignisstrom ───
|
||||
|
||||
public static void MapEvents(this WebApplication app)
|
||||
{
|
||||
var logger = app.Services
|
||||
.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("ClawdDotNet.Manager.Web");
|
||||
|
||||
// Zwei Stroeme, dieselbe Zusammenfuehrung: die Kernereignisse und das Protokoll.
|
||||
// Beide gewinnen dadurch, dass an jeder Zeile steht, aus welcher Instanz sie kommt.
|
||||
app.MapGet("/events", (HttpContext context, InstanceRegistry registry, CancellationToken ct)
|
||||
=> MergedEventStream.StreamAsync(context, registry, "/events", logger, ct));
|
||||
|
||||
app.MapGet("/events/logs", (HttpContext context, InstanceRegistry registry, CancellationToken ct)
|
||||
=> MergedEventStream.StreamAsync(
|
||||
context, registry, "/events/logs" + context.Request.QueryString, logger, ct));
|
||||
}
|
||||
|
||||
// ─── Alles andere: durchreichen ───
|
||||
|
||||
public static void MapForwarding(this WebApplication app)
|
||||
{
|
||||
app.Map("/api/{**pfad}", async (
|
||||
HttpContext context, InstanceRegistry registry, InstanceForwarding forwarding,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var (instanz, problem) = InstanceRouting.Resolve(registry, context);
|
||||
|
||||
if (instanz is null)
|
||||
{
|
||||
// 409 und nicht 404: Der Pfad ist in Ordnung, nur ist unklar oder
|
||||
// unmoeglich, an wen er gehen soll.
|
||||
context.Response.StatusCode = StatusCodes.Status409Conflict;
|
||||
await context.Response.WriteAsJsonAsync(new OutcomeDto(false, problem!), ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await forwarding.ForwardAsync(context, instanz, context.Request.Path, ct);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Kleinkram ───
|
||||
|
||||
private static InstanceDto Map(ManagedInstance instanz) => new(
|
||||
InstanceRouting.IdOf(instanz), instanz.Name, instanz.Condition.ToString(), instanz.ProcessId);
|
||||
|
||||
private static ManagedInstance? Find(InstanceRegistry registry, string id)
|
||||
=> registry.Instances.FirstOrDefault(
|
||||
i => string.Equals(InstanceRouting.IdOf(i), id, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Manager.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Reicht eine Anfrage an den Socket der gemeinten Instanz durch.
|
||||
///
|
||||
/// <para><b>Er leitet weiter, er implementiert nicht</b> (E-M5). Diese Klasse kennt
|
||||
/// Kennungen und Pfade, sonst nichts — keinen Agenten, keine Aufgabe, kein Taskboard. Wer
|
||||
/// hier anfängt, eine Antwort umzuformen oder eine Regel anzuwenden, hat Fachlogik in den
|
||||
/// Manager gelegt, und die gehört in die Instanz.</para>
|
||||
///
|
||||
/// <para><b>Deshalb bleibt das Protokoll HTTP</b> (E-M1). Weil auf beiden Seiten HTTP
|
||||
/// steht, ist Weiterleiten eine Weiterleitung und keine Übersetzung: Die Endpunkte, die
|
||||
/// Übertragungstypen und der Ereignisstrom aus #494 gelten unverändert weiter, es ändert
|
||||
/// sich nur die Bindung.</para>
|
||||
/// </summary>
|
||||
internal sealed class InstanceForwarding(ILogger logger) : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Je Instanz ein Client, und der bleibt.
|
||||
///
|
||||
/// <para>Ein <c>HttpClient</c> je Anfrage wäre hier besonders teuer: Jeder brächte
|
||||
/// einen eigenen Verbindungsaufbau zum Socket mit, und die Weboberfläche fragt viel
|
||||
/// und oft.</para>
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, HttpClient> _clients =
|
||||
new(ClawdDotNet.Core.Storage.PathBoundary.Comparer);
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Kopfzeilen, die nicht weitergereicht werden.
|
||||
///
|
||||
/// <para><c>Authorization</c>, weil der Socket kein Token kennt und keines bekommen
|
||||
/// soll — die Grenze steht dort am Dateisystem. <c>Host</c>, weil die Gegenstelle
|
||||
/// keinen Hostnamen hat. Die Übertragungskopfzeilen, weil sie die Verbindung
|
||||
/// beschreiben und nicht den Inhalt: Sie einfach mitzunehmen ergibt eine Antwort, die
|
||||
/// doppelt gestückelt ist.</para>
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> NotForwarded = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Authorization", "Host", "Connection", "Transfer-Encoding",
|
||||
"Keep-Alive", "Upgrade", "Proxy-Authorization", "TE", "Trailer",
|
||||
InstanceRouting.HeaderName
|
||||
};
|
||||
|
||||
public async Task ForwardAsync(
|
||||
HttpContext context, ManagedInstance ziel, string pfad, CancellationToken ct)
|
||||
{
|
||||
var client = ClientFor(ziel.Directory);
|
||||
|
||||
using var anfrage = new HttpRequestMessage(
|
||||
new HttpMethod(context.Request.Method),
|
||||
pfad + context.Request.QueryString);
|
||||
|
||||
if (context.Request.ContentLength is > 0 || context.Request.Headers.ContentLength is null)
|
||||
{
|
||||
// Den Rumpf stroemen lassen statt ihn zu puffern: Eine hochgeladene Datei
|
||||
// muss nicht erst vollstaendig im Manager liegen, bevor die Instanz sie sieht.
|
||||
anfrage.Content = new StreamContent(context.Request.Body);
|
||||
|
||||
foreach (var (name, werte) in context.Request.Headers)
|
||||
{
|
||||
if (name.StartsWith("Content-", StringComparison.OrdinalIgnoreCase))
|
||||
anfrage.Content.Headers.TryAddWithoutValidation(name, werte.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (name, werte) in context.Request.Headers)
|
||||
{
|
||||
if (NotForwarded.Contains(name)) continue;
|
||||
if (name.StartsWith("Content-", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
anfrage.Headers.TryAddWithoutValidation(name, werte.ToArray());
|
||||
}
|
||||
|
||||
HttpResponseMessage antwort;
|
||||
|
||||
try
|
||||
{
|
||||
antwort = await client.SendAsync(
|
||||
anfrage, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// Die Instanz antwortet nicht. 502 und nicht 500: Der Fehler liegt nicht hier,
|
||||
// und das gehoert in die Antwort - sonst sucht jemand im Manager nach einem
|
||||
// Problem, das in der Instanz steckt oder darin, dass sie gar nicht laeuft.
|
||||
logger.LogWarning(ex,
|
||||
"Instanz {Name} war für {Methode} {Pfad} nicht erreichbar.",
|
||||
ziel.Name, context.Request.Method, pfad);
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
await context.Response.WriteAsJsonAsync(
|
||||
new { fehler = $"Die Instanz '{ziel.Name}' antwortet nicht." }, ct);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
using (antwort)
|
||||
{
|
||||
context.Response.StatusCode = (int)antwort.StatusCode;
|
||||
|
||||
foreach (var (name, werte) in antwort.Headers)
|
||||
{
|
||||
if (!NotForwarded.Contains(name))
|
||||
context.Response.Headers[name] = werte.ToArray();
|
||||
}
|
||||
|
||||
foreach (var (name, werte) in antwort.Content.Headers)
|
||||
{
|
||||
if (!NotForwarded.Contains(name))
|
||||
context.Response.Headers[name] = werte.ToArray();
|
||||
}
|
||||
|
||||
// Kestrel bestimmt selbst, wie er stueckelt.
|
||||
context.Response.Headers.Remove("transfer-encoding");
|
||||
|
||||
await antwort.Content.CopyToAsync(context.Response.Body, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient ClientFor(string instanceDirectory)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_clients.TryGetValue(instanceDirectory, out var vorhanden))
|
||||
return vorhanden;
|
||||
|
||||
var client = InstanceEndpoint.CreateClient(instanceDirectory);
|
||||
|
||||
// Grosszuegig: Hinter einer weitergeleiteten Anfrage kann ein Agentenlauf
|
||||
// stecken, und der dauert, so lange er dauert. Der Ereignisstrom laeuft
|
||||
// ohnehin nicht hierueber.
|
||||
client.Timeout = TimeSpan.FromMinutes(10);
|
||||
|
||||
_clients[instanceDirectory] = client;
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var client in _clients.Values) client.Dispose();
|
||||
_clients.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ClawdDotNet.Manager.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Welche Instanz eine Anfrage meint.
|
||||
///
|
||||
/// <para><b>Der Ordnername ist die Kennung.</b> Er ist eindeutig — zwei Instanzen können
|
||||
/// nicht im selben Verzeichnis liegen —, er ist stabil, und er ist lesbar. Die
|
||||
/// Instanzkennung aus der Konfiguration wäre ebenfalls eindeutig, aber man müsste jede
|
||||
/// Instanzdatei lesen, um von ihr auf ein Verzeichnis zu kommen; der Manager hat den Pfad
|
||||
/// ohnehin in der Hand.</para>
|
||||
///
|
||||
/// <para><b>Und wenn nichts dabeisteht?</b> Läuft genau eine Instanz, ist sie gemeint —
|
||||
/// alles andere wäre Förmlichkeit gegenüber dem Normalfall. Laufen mehrere, wird
|
||||
/// nachgefragt statt geraten: Eine Anfrage an die falsche Instanz ist schlimmer als eine
|
||||
/// abgelehnte.</para>
|
||||
/// </summary>
|
||||
internal static class InstanceRouting
|
||||
{
|
||||
/// <summary>Die Kopfzeile, mit der die Weboberfläche die Instanz benennt.</summary>
|
||||
public const string HeaderName = "X-Instanz";
|
||||
|
||||
public static string IdOf(ManagedInstance instance) => Path.GetFileName(instance.Directory);
|
||||
|
||||
/// <summary>Die gemeinte Instanz, oder eine Begründung, warum keine bestimmbar ist.</summary>
|
||||
public static (ManagedInstance? Instance, string? Problem) Resolve(
|
||||
InstanceRegistry registry, HttpContext context)
|
||||
{
|
||||
var alle = registry.Instances;
|
||||
|
||||
if (context.Request.Headers.TryGetValue(HeaderName, out var genannt)
|
||||
&& !string.IsNullOrWhiteSpace(genannt))
|
||||
{
|
||||
var id = genannt.ToString().Trim();
|
||||
|
||||
var treffer = alle.FirstOrDefault(
|
||||
i => string.Equals(IdOf(i), id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return treffer is null
|
||||
? (null, $"Es gibt keine Instanz '{id}'.")
|
||||
: (treffer, null);
|
||||
}
|
||||
|
||||
var laufend = alle.Where(i => i.Condition == InstanceCondition.Running).ToList();
|
||||
|
||||
return laufend.Count switch
|
||||
{
|
||||
1 => (laufend[0], null),
|
||||
0 => (null, "Es läuft keine Instanz."),
|
||||
_ => (null, $"Es laufen {laufend.Count} Instanzen. "
|
||||
+ $"Welche gemeint ist, gehört in die Kopfzeile {HeaderName}.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using ClawdDotNet.Core.Logging;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Manager.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Die eine Weboberfläche für alle Instanzen (#528, E-M5).
|
||||
///
|
||||
/// <para><b>Eine Adresse, ein Zugang, alle Instanzen.</b> Der Manager besitzt Port, Zugang
|
||||
/// und Rahmen; Anfragen zu Chat, Agenten, Aufgaben und Freigaben reicht er über den Socket
|
||||
/// an die betroffene Instanz weiter. Vorher hatte jede Instanz ihren eigenen Port, und
|
||||
/// zwei Instanzen stritten sich stillschweigend um die Vorbelegung 7001.</para>
|
||||
///
|
||||
/// <para><b>Er leitet weiter, er implementiert nicht.</b> Unter <c>/api/verwaltung</c>
|
||||
/// steht, was den Manager selbst angeht: welche Instanzen es gibt, anlegen, starten,
|
||||
/// anhalten. Alles andere unter <c>/api</c> geht unverändert an eine Instanz. Wer hier
|
||||
/// einen Endpunkt ergänzt, der etwas über Agenten oder Aufgaben weiß, hat Fachlogik in
|
||||
/// den Manager gelegt.</para>
|
||||
///
|
||||
/// <para><b>Zwei Dinge, die er nicht kann und nicht können soll:</b> Er fasst keine
|
||||
/// <c>state.db</c> an — die gehört genau einem Prozess, und prozessübergreifend gilt die
|
||||
/// Schreibsperre von <c>SqliteStorage</c> nicht. Und er führt keine Werkzeuge aus;
|
||||
/// Telegram, Rocket.Chat, Mail und FTP laufen ausschließlich in den Instanzen.</para>
|
||||
/// </summary>
|
||||
public sealed class ManagerWebHost : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication _app;
|
||||
private readonly ILogger _logger;
|
||||
private readonly InstanceForwarding _forwarding;
|
||||
|
||||
private ManagerWebHost(
|
||||
WebApplication app, ILogger logger, InstanceForwarding forwarding, string url)
|
||||
{
|
||||
_app = app;
|
||||
_logger = logger;
|
||||
_forwarding = forwarding;
|
||||
Url = url;
|
||||
}
|
||||
|
||||
/// <summary>Die Adresse, unter der die Oberfläche hört.</summary>
|
||||
public string Url { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Fährt die Weboberfläche hoch.
|
||||
///
|
||||
/// <para><b>Gibt in jedem Fehlerfall <c>null</c> zurück und wirft nicht.</b> Ein
|
||||
/// Manager, der nicht anläuft, weil ein Port belegt ist, wäre genau dann im Weg, wenn
|
||||
/// man ihn braucht: Er beaufsichtigt dann auch keine Instanzen mehr. Was schiefging,
|
||||
/// steht im Protokoll.</para>
|
||||
/// </summary>
|
||||
public static ManagerWebHost? TryStart(ManagerCore manager, ILoggerFactory loggerFactory)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web");
|
||||
var settings = manager.Settings;
|
||||
|
||||
var token = settings.WebUiToken?.Trim() ?? "";
|
||||
|
||||
if (token.Length < AccessToken.MinimumLength)
|
||||
{
|
||||
// Keine halben Sachen: Ein Netzwerkdienst, der Agenten mit Datei-, Mail- und
|
||||
// FTP-Zugriff steuert, ist nichts, was mit einem schwachen oder gar keinem
|
||||
// Token offen stehen darf.
|
||||
logger.LogWarning(
|
||||
"Weboberfläche nicht gestartet: Es fehlt ein Zugangstoken von mindestens "
|
||||
+ "{Mindestlänge} Zeichen (webUiToken in manager.json).",
|
||||
AccessToken.MinimumLength);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var loopback = !string.Equals(settings.WebUiBinding, "any", StringComparison.OrdinalIgnoreCase);
|
||||
var host = loopback ? "127.0.0.1" : "0.0.0.0";
|
||||
var url = $"http://{host}:{settings.WebUiPort}";
|
||||
|
||||
var forwarding = new InstanceForwarding(logger);
|
||||
|
||||
try
|
||||
{
|
||||
var app = Build(manager, loggerFactory, url, token, forwarding);
|
||||
|
||||
app.StartAsync().GetAwaiter().GetResult();
|
||||
|
||||
var anzeige = loopback ? url : url.Replace("0.0.0.0", Environment.MachineName);
|
||||
|
||||
logger.LogInformation(
|
||||
"Weboberfläche hört auf {Url} ({Bindung})",
|
||||
anzeige, loopback ? "nur lokal" : "alle Adressen");
|
||||
|
||||
return new ManagerWebHost(app, logger, forwarding, anzeige);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
forwarding.Dispose();
|
||||
logger.LogWarning(ex, "Weboberfläche konnte nicht gestartet werden ({Url})", url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static WebApplication Build(
|
||||
ManagerCore manager,
|
||||
ILoggerFactory loggerFactory,
|
||||
string url,
|
||||
string token,
|
||||
InstanceForwarding forwarding)
|
||||
{
|
||||
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
|
||||
{
|
||||
ContentRootPath = AppContext.BaseDirectory,
|
||||
EnvironmentName = Environments.Production,
|
||||
ApplicationName = typeof(ManagerWebHost).Assembly.GetName().Name
|
||||
});
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddProvider(new SharedLoggerProvider(loggerFactory));
|
||||
|
||||
// ASP.NET protokolliert auf Information jede einzelne Anfrage - auch jede
|
||||
// Schriftart und jedes Stylesheet. Ab Warnung ist es das, was man wissen will.
|
||||
builder.Logging.AddFilter("Microsoft", Microsoft.Extensions.Logging.LogLevel.Warning);
|
||||
builder.Logging.AddFilter("System", Microsoft.Extensions.Logging.LogLevel.Warning);
|
||||
|
||||
builder.WebHost.UseUrls(url);
|
||||
|
||||
builder.Services.AddSingleton(manager);
|
||||
builder.Services.AddSingleton(manager.Instances);
|
||||
builder.Services.AddSingleton(AccessToken.For(token));
|
||||
builder.Services.AddSingleton(forwarding);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.Use(ReportUnhandled(loggerFactory));
|
||||
app.Use(CheckToken);
|
||||
|
||||
MapStaticFiles(app);
|
||||
|
||||
app.MapVerwaltung();
|
||||
app.MapEvents();
|
||||
app.MapForwarding();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static Func<HttpContext, RequestDelegate, Task> ReportUnhandled(
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web");
|
||||
|
||||
return async (context, next) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await next(context);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Der Browser hat aufgelegt, oder der Manager faehrt herunter. Bewusst
|
||||
// ohne Bedingung auf RequestAborted: Beim Herunterfahren bricht
|
||||
// ApplicationStopping ab, und RequestAborted ist dabei nicht gesetzt.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Unbehandelter Fehler in {Pfad}", context.Request.Path);
|
||||
|
||||
if (context.Response.HasStarted) return;
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await context.Response.WriteAsJsonAsync(new { fehler = "Interner Fehler." });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task CheckToken(HttpContext context, RequestDelegate next)
|
||||
{
|
||||
if (!AccessToken.NeedsToken(context.Request.Path))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var gate = context.RequestServices.GetRequiredService<AccessToken>();
|
||||
|
||||
if (!gate.Matches(AccessToken.Read(context.Request)))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
await context.Response.WriteAsJsonAsync(new { fehler = "Kein gültiges Token." });
|
||||
return;
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
|
||||
private static void MapStaticFiles(WebApplication app)
|
||||
{
|
||||
var webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
|
||||
|
||||
if (!Directory.Exists(webRoot))
|
||||
{
|
||||
app.Logger.LogWarning(
|
||||
"Kein wwwroot unter {Pfad} — es läuft nur die API.", webRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
var files = new PhysicalFileProvider(webRoot);
|
||||
|
||||
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files });
|
||||
app.UseStaticFiles(new StaticFileOptions { FileProvider = files });
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _app.StopAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Weboberfläche ließ sich nicht sauber beenden");
|
||||
}
|
||||
|
||||
_forwarding.Dispose();
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Channels;
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Manager.Web;
|
||||
|
||||
/// <summary>
|
||||
/// N Instanzen, ein Ereignisstrom zum Browser.
|
||||
///
|
||||
/// <para><b>Die eine Stelle, die wirklich neu gedacht werden musste</b> (Konzept,
|
||||
/// Abschnitt 8). Alles andere an der Weboberfläche ließ sich weiterleiten; ein
|
||||
/// Ereignisstrom nicht. Der Browser hält eine Verbindung, die Instanzen halten je eine,
|
||||
/// und dazwischen muss jemand zusammenführen — und an jedes Ereignis schreiben, von wem
|
||||
/// es kommt. Ohne das wäre in einem Fenster mit drei Instanzen nicht zu erkennen, welcher
|
||||
/// Agent gerade spricht.</para>
|
||||
///
|
||||
/// <para><b>Ergänzt statt verpackt.</b> Es wäre einfacher, jedes Ereignis in einen
|
||||
/// Umschlag zu stecken (<c>{"instanz":…,"daten":…}</c>) — dann müsste aber jede Ansicht
|
||||
/// der Weboberfläche umgeschrieben werden. Stattdessen bekommt das vorhandene Objekt ein
|
||||
/// Feld <c>instanz</c> dazu; alles, was heute Felder liest, liest sie weiter.</para>
|
||||
///
|
||||
/// <para><b>Eine tote Instanz reißt den Strom nicht ab.</b> Fällt eine weg, endet ihre
|
||||
/// Zuleitung, und die anderen laufen weiter. Der Browser merkt davon nichts — was er
|
||||
/// merken soll, sagt ihm die Aufsicht als eigenes Ereignis.</para>
|
||||
/// </summary>
|
||||
internal static class MergedEventStream
|
||||
{
|
||||
/// <summary>
|
||||
/// Wie viele Ereignisse ein langsamer Browser hinter sich herziehen darf, bevor die
|
||||
/// ältesten wegfallen.
|
||||
///
|
||||
/// <para>Begrenzt und verwerfend, nicht unbegrenzt: Ein Tab, der im Hintergrund
|
||||
/// eingefroren ist, würde sonst den Speicher des Managers füllen. Ein verlorener
|
||||
/// Zwischenstand ist der bessere Verlust — die Seite lädt den Verlauf beim Verbinden
|
||||
/// ohnehin neu.</para>
|
||||
/// </summary>
|
||||
private const int QueueDepth = 512;
|
||||
|
||||
public static async Task StreamAsync(
|
||||
HttpContext context,
|
||||
InstanceRegistry registry,
|
||||
string instancePath,
|
||||
ILogger logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var queue = Channel.CreateBounded<string>(new BoundedChannelOptions(QueueDepth)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true
|
||||
});
|
||||
|
||||
var laufend = registry.Instances
|
||||
.Where(i => i.Condition == InstanceCondition.Running)
|
||||
.ToList();
|
||||
|
||||
using var leben = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
|
||||
var zuleitungen = laufend
|
||||
.Select(instanz => PumpAsync(instanz, instancePath, queue.Writer, logger, leben.Token))
|
||||
.ToList();
|
||||
|
||||
PrepareResponse(context);
|
||||
|
||||
// Sofort etwas schicken: Erst damit steht die Verbindung fuer den Browser, und
|
||||
// onopen feuert. Ohne das haengt die Seite im Ungewissen, bis das erste Ereignis
|
||||
// von selbst kommt - was Stunden dauern kann.
|
||||
await context.Response.WriteAsync(
|
||||
$": bereit ({laufend.Count} Instanzen)\n\n", ct).ConfigureAwait(false);
|
||||
|
||||
await context.Response.Body.FlushAsync(ct).ConfigureAwait(false);
|
||||
|
||||
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
|
||||
|
||||
try
|
||||
{
|
||||
var naechstes = queue.Reader.ReadAsync(ct).AsTask();
|
||||
var schlag = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||
|
||||
while (true)
|
||||
{
|
||||
var fertig = await Task.WhenAny(naechstes, schlag).ConfigureAwait(false);
|
||||
|
||||
if (fertig == naechstes)
|
||||
{
|
||||
await context.Response.WriteAsync(await naechstes, ct).ConfigureAwait(false);
|
||||
naechstes = queue.Reader.ReadAsync(ct).AsTask();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!await schlag) break;
|
||||
|
||||
// Ein Kommentar haelt die Verbindung offen, ohne ein Ereignis
|
||||
// vorzutaeuschen. Ohne ihn kappt so mancher Proxy nach einer Minute.
|
||||
await context.Response.WriteAsync(": still\n\n", ct).ConfigureAwait(false);
|
||||
schlag = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||
}
|
||||
|
||||
await context.Response.Body.FlushAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Der Browser hat aufgelegt, oder der Manager faehrt herunter.
|
||||
}
|
||||
catch (ChannelClosedException)
|
||||
{
|
||||
// Der Strom wird abgeraeumt.
|
||||
}
|
||||
finally
|
||||
{
|
||||
await leben.CancelAsync().ConfigureAwait(false);
|
||||
queue.Writer.TryComplete();
|
||||
|
||||
try { await Task.WhenAll(zuleitungen).ConfigureAwait(false); }
|
||||
catch { /* Abgebrochene Zuleitungen sind der Normalfall beim Aufraeumen. */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine Zuleitung: hört am Socket einer Instanz und schiebt weiter, was kommt.
|
||||
/// </summary>
|
||||
private static async Task PumpAsync(
|
||||
ManagedInstance instanz,
|
||||
string instancePath,
|
||||
ChannelWriter<string> ziel,
|
||||
ILogger logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var id = InstanceRouting.IdOf(instanz);
|
||||
|
||||
try
|
||||
{
|
||||
using var client = InstanceEndpoint.CreateClient(instanz.Directory);
|
||||
|
||||
// Kein Zeitlimit: Ein Ereignisstrom endet nie von selbst - das ist sein Zweck.
|
||||
client.Timeout = Timeout.InfiniteTimeSpan;
|
||||
|
||||
using var antwort = await client.GetAsync(
|
||||
instancePath, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
|
||||
antwort.EnsureSuccessStatusCode();
|
||||
|
||||
await using var strom = await antwort.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
using var leser = new StreamReader(strom, Encoding.UTF8);
|
||||
|
||||
string? art = null;
|
||||
|
||||
while (await leser.ReadLineAsync(ct).ConfigureAwait(false) is { } zeile)
|
||||
{
|
||||
if (zeile.StartsWith(": ", StringComparison.Ordinal))
|
||||
continue; // Herzschlag der Instanz. Der Manager hat einen eigenen.
|
||||
|
||||
if (zeile.StartsWith("event: ", StringComparison.Ordinal))
|
||||
{
|
||||
art = zeile["event: ".Length..];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!zeile.StartsWith("data: ", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
ziel.TryWrite(Format(art ?? "message", zeile["data: ".Length..], id, instanz.Name));
|
||||
art = null;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Der Strom wird abgeraeumt oder der Browser hat aufgelegt.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Eine Instanz, die waehrend des Zuhoerens weggeht, ist kein Fehler des
|
||||
// Managers - und sie darf die anderen Zuleitungen nicht mitnehmen.
|
||||
logger.LogInformation(
|
||||
"Ereigniszuleitung von Instanz {Name} beendet: {Grund}", instanz.Name, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hängt <c>instanz</c> und <c>instanzName</c> an das Ereignis.
|
||||
///
|
||||
/// <para>Ist der Inhalt kein Objekt — oder gar kein JSON —, wird er unverändert
|
||||
/// weitergereicht. Ein Ereignis zu verschlucken, weil sein Inhalt unerwartet aussieht,
|
||||
/// wäre die schlechtere Antwort: Der Manager leitet weiter, er urteilt nicht.</para>
|
||||
/// </summary>
|
||||
private static string Format(string art, string daten, string id, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (JsonNode.Parse(daten) is JsonObject objekt)
|
||||
{
|
||||
objekt["instanz"] = id;
|
||||
objekt["instanzName"] = name;
|
||||
return $"event: {art}\ndata: {objekt.ToJsonString()}\n\n";
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
// Siehe oben.
|
||||
}
|
||||
|
||||
return $"event: {art}\ndata: {daten}\n\n";
|
||||
}
|
||||
|
||||
private static void PrepareResponse(HttpContext context)
|
||||
{
|
||||
context.Response.Headers.ContentType = "text/event-stream";
|
||||
context.Response.Headers.CacheControl = "no-cache";
|
||||
|
||||
// Ohne das puffert ein vorgelagerter nginx den Strom und liefert ihn erst, wenn
|
||||
// er voll ist - aus einem Ereignisstrom wird dann eine Ereignissammlung.
|
||||
context.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
// ClawdDotNet — WebUI Haupteinstiegspunkt
|
||||
//
|
||||
// Initialisiert Authentifizierung, Router, Ereignisstrom, Farbschema und Kopfzeile.
|
||||
|
||||
import { api, hatToken, loescheToken, beiUnberechtigt } from "./modules/api.js";
|
||||
import { verbindeStrom, trenneStrom, beiVerbindungsStatus, abonnieren } from "./modules/events.js";
|
||||
import { initialisiereTheme, wechsleTheme } from "./modules/theme.js";
|
||||
import { registriereRoute, initialisiereRouter, navigiere } from "./modules/router.js";
|
||||
import { initialisiereLogin, zeigeAnmeldung, versteckeAnmeldung } from "./views/login.js";
|
||||
|
||||
// Ansichten
|
||||
import { rendereChat } from "./views/chat.js";
|
||||
import { rendereAgenten } from "./views/agents.js";
|
||||
import { rendereAufgaben } from "./views/tasks.js";
|
||||
import { rendereDienste } from "./views/services.js";
|
||||
import { rendereStaging, holeWartendeAnzahl } from "./views/staging.js";
|
||||
import { rendereVerbrauch } from "./views/usage.js";
|
||||
import { rendereProtokoll, trenneLogStream } from "./views/logs.js";
|
||||
import { rendereInfo } from "./views/info.js";
|
||||
import { rendereSicherung, rendereEinstellungen, rendereAgentenChats } from "./views/placeholders.js";
|
||||
|
||||
// ─── 1. Routen registrieren ───
|
||||
|
||||
registriereRoute("/chat", () => { trenneLogStream(); return rendereChat(); });
|
||||
registriereRoute("/chat/:agentId", (p) => { trenneLogStream(); return rendereChat(p); });
|
||||
registriereRoute("/agenten", () => { trenneLogStream(); return rendereAgenten(); });
|
||||
registriereRoute("/agenten/:agentId", (p) => { trenneLogStream(); return rendereAgenten(p); });
|
||||
registriereRoute("/aufgaben", () => { trenneLogStream(); return rendereAufgaben(); });
|
||||
registriereRoute("/dienste", () => { trenneLogStream(); return rendereDienste(); });
|
||||
registriereRoute("/staging", () => { trenneLogStream(); return rendereStaging(); });
|
||||
registriereRoute("/verbrauch", () => { trenneLogStream(); return rendereVerbrauch(); });
|
||||
registriereRoute("/agenten-chats", () => { trenneLogStream(); return rendereAgentenChats(); });
|
||||
registriereRoute("/protokoll", () => rendereProtokoll());
|
||||
registriereRoute("/sicherung", () => { trenneLogStream(); return rendereSicherung(); });
|
||||
registriereRoute("/einstellungen", () => { trenneLogStream(); return rendereEinstellungen(); });
|
||||
registriereRoute("/info", () => { trenneLogStream(); return rendereInfo(); });
|
||||
|
||||
// ─── 2. DOM-Verdrahtung ───
|
||||
|
||||
const seitenleiste = document.getElementById("seitenleiste");
|
||||
const sidebarToggle = document.getElementById("sidebar-toggle");
|
||||
const mobilMenueBtn = document.getElementById("mobil-menue-btn");
|
||||
const seitenleisteBackdrop = document.getElementById("seitenleiste-backdrop");
|
||||
const themeSchalter = document.getElementById("theme-schalter");
|
||||
const abmeldenBtn = document.getElementById("abmelden-btn");
|
||||
const verbindungPunkt = document.getElementById("verbindung-punkt");
|
||||
const kopfInstanz = document.getElementById("kopf-instanz");
|
||||
const kopfOpenRouter = document.getElementById("kopf-openrouter-status");
|
||||
|
||||
// Seitenleiste kollabieren (Desktop)
|
||||
sidebarToggle?.addEventListener("click", () => {
|
||||
seitenleiste?.classList.toggle("kollabiert");
|
||||
});
|
||||
|
||||
// Mobil-Menü öffnen
|
||||
mobilMenueBtn?.addEventListener("click", () => {
|
||||
seitenleiste?.classList.add("mobil-offen");
|
||||
if (seitenleisteBackdrop) seitenleisteBackdrop.hidden = false;
|
||||
});
|
||||
|
||||
// Mobil-Menü schließen bei Klick auf Backdrop oder Link
|
||||
function schliesseMobilMenue() {
|
||||
seitenleiste?.classList.remove("mobil-offen");
|
||||
if (seitenleisteBackdrop) seitenleisteBackdrop.hidden = true;
|
||||
}
|
||||
|
||||
seitenleisteBackdrop?.addEventListener("click", schliesseMobilMenue);
|
||||
document.querySelectorAll(".nav-link").forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
if (window.innerWidth <= 768) schliesseMobilMenue();
|
||||
});
|
||||
});
|
||||
|
||||
// Farbschema umschalten
|
||||
themeSchalter?.addEventListener("click", () => {
|
||||
wechsleTheme();
|
||||
});
|
||||
|
||||
// Abmelden
|
||||
abmeldenBtn?.addEventListener("click", () => {
|
||||
loescheToken();
|
||||
trenneStrom();
|
||||
trenneLogStream();
|
||||
zeigeAnmeldung();
|
||||
});
|
||||
|
||||
// Bei unberechtigtem API-Aufruf (401) sofort zur Anmeldung springen
|
||||
beiUnberechtigt(() => {
|
||||
trenneStrom();
|
||||
trenneLogStream();
|
||||
zeigeAnmeldung();
|
||||
});
|
||||
|
||||
// Verbindungsstatus des Ereignisstroms
|
||||
beiVerbindungsStatus((istOffen) => {
|
||||
if (!verbindungPunkt) return;
|
||||
if (istOffen) {
|
||||
verbindungPunkt.classList.add("offen");
|
||||
verbindungPunkt.title = "Ereignisstrom: verbunden";
|
||||
} else {
|
||||
verbindungPunkt.classList.remove("offen");
|
||||
verbindungPunkt.title = "Ereignisstrom: getrennt";
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 3. Start & Initialisierung ───
|
||||
|
||||
initialisiereTheme();
|
||||
initialisiereLogin(starteHauptAnwendung);
|
||||
|
||||
async function aktualisiereKopfStatus() {
|
||||
try {
|
||||
const status = await api("/status");
|
||||
if (kopfOpenRouter && status) {
|
||||
kopfOpenRouter.textContent = status.creditsText
|
||||
? `OpenRouter: ${status.creditsText}`
|
||||
: (status.isApiReachable ? "OpenRouter: OK" : "OpenRouter: offline");
|
||||
}
|
||||
} catch {
|
||||
if (kopfOpenRouter) kopfOpenRouter.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function aktualisiereStagingZaehler() {
|
||||
const anzahl = await holeWartendeAnzahl();
|
||||
const badge = document.getElementById("staging-badge");
|
||||
if (badge) {
|
||||
if (anzahl > 0) {
|
||||
badge.textContent = String(anzahl);
|
||||
badge.hidden = false;
|
||||
} else {
|
||||
badge.hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function starteHauptAnwendung(info) {
|
||||
versteckeAnmeldung();
|
||||
|
||||
if (kopfInstanz && info) {
|
||||
kopfInstanz.textContent = `${info.instanceName} (${info.instanceId})`;
|
||||
}
|
||||
|
||||
// Ereignisstrom verbinden
|
||||
verbindeStrom();
|
||||
|
||||
// Status & Zähler laden
|
||||
await aktualisiereKopfStatus();
|
||||
await aktualisiereStagingZaehler();
|
||||
|
||||
// Ereignisse für Kopf-Updates
|
||||
abonnieren("status", () => {
|
||||
aktualisiereKopfStatus();
|
||||
});
|
||||
|
||||
abonnieren("instance", (daten) => {
|
||||
if (daten.kind === "Settings") {
|
||||
aktualisiereKopfStatus();
|
||||
}
|
||||
});
|
||||
|
||||
// Router initialisieren
|
||||
initialisiereRouter();
|
||||
}
|
||||
|
||||
// Prüfe bestehende Session
|
||||
if (hatToken()) {
|
||||
api("/info")
|
||||
.then((info) => {
|
||||
starteHauptAnwendung(info);
|
||||
})
|
||||
.catch(() => {
|
||||
loescheToken();
|
||||
zeigeAnmeldung();
|
||||
});
|
||||
} else {
|
||||
zeigeAnmeldung();
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,172 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ClawdDotNet</title>
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ─── Anmelde-Ansicht ─── -->
|
||||
<div id="anmeldung">
|
||||
<div class="karte anmelde-kasten">
|
||||
<div class="karte-kopf">
|
||||
<h2>ClawdDotNet</h2>
|
||||
</div>
|
||||
<p class="hinweis">
|
||||
Bitte gib das Zugangstoken der Instanz ein (<code>webUiToken</code> in der Instanzdatei).
|
||||
</p>
|
||||
<form id="anmeldeformular" class="formular-raster">
|
||||
<input type="password" id="token-eingabe" placeholder="Zugangstoken (mind. 20 Zeichen)" autocomplete="current-password" required>
|
||||
<button type="submit" class="primaer">Anmelden</button>
|
||||
<p id="anmeldefehler" class="fehler" hidden></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Hauptanwendung ─── -->
|
||||
<div id="anwendung" hidden>
|
||||
<div id="seitenleiste-backdrop" class="seitenleiste-backdrop" hidden></div>
|
||||
|
||||
<!-- Seitenleiste / Navigation -->
|
||||
<aside id="seitenleiste" class="seitenleiste">
|
||||
<div class="seitenleiste-kopf">
|
||||
<span class="marke">ClawdDotNet</span>
|
||||
<button id="sidebar-toggle" class="schaltflaeche-icon flach" title="Seitenleiste ein-/ausklappen" type="button">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 18l-6-6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="seitenleiste-inhalt">
|
||||
<div class="nav-gruppe">
|
||||
<div class="nav-gruppe-titel">Arbeiten</div>
|
||||
<a href="#/chat" class="nav-link" title="Chat">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
|
||||
</svg>
|
||||
<span class="nav-label">Chat</span>
|
||||
</a>
|
||||
<a href="#/agenten" class="nav-link" title="Agenten">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
<span class="nav-label">Agenten</span>
|
||||
</a>
|
||||
<a href="#/aufgaben" class="nav-link" title="Aufgaben">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
||||
</svg>
|
||||
<span class="nav-label">Aufgaben</span>
|
||||
</a>
|
||||
<a href="#/dienste" class="nav-link" title="Dienste">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M4 14a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6z M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4z M12 6v6 M8 8h.01 M16 8h.01"/>
|
||||
</svg>
|
||||
<span class="nav-label">Dienste</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-gruppe">
|
||||
<div class="nav-gruppe-titel">Analyse</div>
|
||||
<a href="#/verbrauch" class="nav-link" title="Tokennutzung">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7"/>
|
||||
</svg>
|
||||
<span class="nav-label">Tokennutzung</span>
|
||||
</a>
|
||||
<a href="#/agenten-chats" class="nav-link" title="Agenten-Chats">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M8 12h.01M12 12h.01M16 12h.01 M21 12c0 4.4-4 8-9 8-1.3 0-2.6-.2-3.7-.7L3 20l1-4.2C3.4 14.6 3 13.3 3 12c0-4.4 4-8 9-8s9 3.6 9 8Z"/>
|
||||
</svg>
|
||||
<span class="nav-label">Agenten-Chats</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-gruppe">
|
||||
<div class="nav-gruppe-titel">System</div>
|
||||
<a href="#/staging" class="nav-link" title="Freigaben">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z M9 12l2 2 4-4"/>
|
||||
</svg>
|
||||
<span class="nav-label">Freigaben</span>
|
||||
<span id="staging-badge" class="nav-badge" hidden>0</span>
|
||||
</a>
|
||||
<a href="#/protokoll" class="nav-link" title="Protokoll">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20 M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z M8 7h8 M8 11h6"/>
|
||||
</svg>
|
||||
<span class="nav-label">Protokoll</span>
|
||||
</a>
|
||||
<a href="#/sicherung" class="nav-link" title="Sicherung">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z M17 21v-8H7v8 M7 3v5h8"/>
|
||||
</svg>
|
||||
<span class="nav-label">Sicherung</span>
|
||||
</a>
|
||||
<a href="#/einstellungen" class="nav-link" title="Einstellungen">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<span class="nav-label">Einstellungen</span>
|
||||
</a>
|
||||
<a href="#/info" class="nav-link" title="Info">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
<span class="nav-label">Info</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Hauptinhalt -->
|
||||
<div class="hauptbereich">
|
||||
<header class="kopfzeile">
|
||||
<div class="kopfzeile-links">
|
||||
<button id="mobil-menue-btn" class="schaltflaeche-icon flach" title="Menü öffnen" type="button">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span id="kopf-instanz" class="gedaempft"></span>
|
||||
</div>
|
||||
|
||||
<div class="kopfzeile-rechts">
|
||||
<span id="kopf-openrouter-status" class="gedaempft text-klein"></span>
|
||||
<span id="verbindung-punkt" class="status-punkt" title="Ereignisstrom: getrennt"></span>
|
||||
<button id="theme-schalter" class="schaltflaeche-icon flach" title="Farbschema umschalten" type="button">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="abmelden-btn" class="schaltflaeche flach klein" title="Abmelden" type="button">
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="inhalt-scroll">
|
||||
<div id="ansicht-inhalt"></div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,115 @@
|
||||
// ClawdDotNet — API-Zugriffsschicht
|
||||
//
|
||||
// Verwaltet das Zugangstoken im sessionStorage und kapselt fetch-Aufrufe.
|
||||
|
||||
export const TOKEN_SCHLUESSEL = "clawd.token";
|
||||
|
||||
let token = sessionStorage.getItem(TOKEN_SCHLUESSEL) ?? "";
|
||||
const unberechtigtCallbacks = new Set();
|
||||
|
||||
/**
|
||||
* Gibt das aktuell gespeicherte Token zurück.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function holeToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert ein neues Token im Speicher und sessionStorage.
|
||||
* @param {string} neuesToken
|
||||
*/
|
||||
export function setzeToken(neuesToken) {
|
||||
token = (neuesToken || "").trim();
|
||||
if (token) {
|
||||
sessionStorage.setItem(TOKEN_SCHLUESSEL, token);
|
||||
} else {
|
||||
sessionStorage.removeItem(TOKEN_SCHLUESSEL);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht das Token (Abmeldung).
|
||||
*/
|
||||
export function loescheToken() {
|
||||
token = "";
|
||||
sessionStorage.removeItem(TOKEN_SCHLUESSEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Token im Speicher liegt.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hatToken() {
|
||||
return Boolean(token && token.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert einen Rückruf für den Fall einer 401-Ablehnung (sofortiger Login-Sprung).
|
||||
* @param {() => void} callback
|
||||
*/
|
||||
export function beiUnberechtigt(callback) {
|
||||
unberechtigtCallbacks.add(callback);
|
||||
return () => unberechtigtCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
function meldeUnberechtigt() {
|
||||
loescheToken();
|
||||
for (const cb of unberechtigtCallbacks) {
|
||||
try {
|
||||
cb();
|
||||
} catch (err) {
|
||||
console.error("Fehler im Unberechtigt-Callback:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt einen Aufruf gegen die API aus.
|
||||
* @param {string} pfad Pfad relativ zu /api, z.B. "/info" oder "/agents"
|
||||
* @param {RequestInit} [optionen={}]
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function api(pfad, optionen = {}) {
|
||||
const kopfzeilen = {
|
||||
...(optionen.headers ?? {}),
|
||||
Authorization: `Bearer ${token}`
|
||||
};
|
||||
|
||||
if (optionen.body && typeof optionen.body === "object" && !(optionen.body instanceof FormData)) {
|
||||
kopfzeilen["Content-Type"] = "application/json";
|
||||
optionen.body = JSON.stringify(optionen.body);
|
||||
}
|
||||
|
||||
const antwort = await fetch(`/api${pfad}`, {
|
||||
...optionen,
|
||||
headers: kopfzeilen
|
||||
});
|
||||
|
||||
if (antwort.status === 401) {
|
||||
meldeUnberechtigt();
|
||||
const fehler = new Error("Kein gültiges Token. Bitte neu anmelden.");
|
||||
fehler.abgelehnt = true;
|
||||
fehler.status = 401;
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
if (!antwort.ok) {
|
||||
let meldung = `HTTP ${antwort.status}`;
|
||||
try {
|
||||
const rumpf = await antwort.json();
|
||||
meldung = rumpf?.message ?? rumpf?.fehler ?? rumpf?.detail ?? meldung;
|
||||
} catch {
|
||||
// Wenn kein JSON geliefert wird, bleibt es beim HTTP-Code
|
||||
}
|
||||
const fehler = new Error(meldung);
|
||||
fehler.status = antwort.status;
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
if (antwort.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return antwort.json();
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// ClawdDotNet — Sichere DOM-Helferfunktionen
|
||||
//
|
||||
// Leitfaden-Regel 3.1: Fremdtext ist nie HTML. Kein innerHTML im gesamten Baum.
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/**
|
||||
* Erzeugt ein HTML-Element sicher ohne innerHTML.
|
||||
* @param {string} tag
|
||||
* @param {object} [eigenschaften]
|
||||
* @param {...(Node|string|null|undefined)} kinder
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function erstelleElement(tag, eigenschaften = {}, ...kinder) {
|
||||
const el = document.createElement(tag);
|
||||
|
||||
for (const [schluessel, wert] of Object.entries(eigenschaften)) {
|
||||
if (wert === undefined || wert === null) continue;
|
||||
|
||||
if (schluessel === "klasse" || schluessel === "className") {
|
||||
el.className = wert;
|
||||
} else if (schluessel === "text") {
|
||||
el.textContent = wert;
|
||||
} else if (schluessel === "stil" || schluessel === "style") {
|
||||
if (typeof wert === "string") {
|
||||
el.style.cssText = wert;
|
||||
} else {
|
||||
Object.assign(el.style, wert);
|
||||
}
|
||||
} else if (schluessel.startsWith("on") && typeof wert === "function") {
|
||||
const eventName = schluessel.slice(2).toLowerCase();
|
||||
el.addEventListener(eventName, wert);
|
||||
} else if (schluessel === "dataset" && typeof wert === "object") {
|
||||
Object.assign(el.dataset, wert);
|
||||
} else if (typeof wert === "boolean") {
|
||||
if (wert) el.setAttribute(schluessel, "");
|
||||
else el.removeAttribute(schluessel);
|
||||
} else {
|
||||
el.setAttribute(schluessel, String(wert));
|
||||
}
|
||||
}
|
||||
|
||||
for (const kind of kinder) {
|
||||
if (kind === null || kind === undefined) continue;
|
||||
if (typeof kind === "string" || typeof kind === "number") {
|
||||
el.append(document.createTextNode(String(kind)));
|
||||
} else if (kind instanceof Node) {
|
||||
el.append(kind);
|
||||
}
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt ein SVG-Icon sicher über den SVG-Namensraum ohne innerHTML.
|
||||
* @param {string} pfadDaten SVG path d attribute
|
||||
* @param {number} [groesse=16]
|
||||
* @param {string} [klasse=""]
|
||||
* @returns {SVGSVGElement}
|
||||
*/
|
||||
export function erstelleIcon(pfadDaten, groesse = 16, klasse = "") {
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("width", String(groesse));
|
||||
svg.setAttribute("height", String(groesse));
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", "currentColor");
|
||||
svg.setAttribute("stroke-width", "1.75");
|
||||
svg.setAttribute("stroke-linecap", "round");
|
||||
svg.setAttribute("stroke-linejoin", "round");
|
||||
if (klasse) svg.setAttribute("class", `icon ${klasse}`);
|
||||
else svg.setAttribute("class", "icon");
|
||||
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("d", pfadDaten);
|
||||
svg.appendChild(path);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Häufig genutzte SVG-Pfade (Lucide-kompatibel, 24x24).
|
||||
*/
|
||||
export const PFADE = {
|
||||
chat: "M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z",
|
||||
agenten: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75",
|
||||
aufgaben: "M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11",
|
||||
dienste: "M4 14a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6z M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4z M12 6v6 M8 8h.01 M16 8h.01",
|
||||
verbrauch: "M3 3v18h18 M7 15v4 M11 11v8 M15 7v12 M19 12v7",
|
||||
staging: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z M9 12l2 2 4-4",
|
||||
protokoll: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20 M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z M8 7h8 M8 11h6",
|
||||
sicherung: "M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z M17 21v-8H7v8 M7 3v5h8",
|
||||
einstellungen: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
|
||||
info: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z M12 16v-4 M12 8h.01",
|
||||
plus: "M12 5v14 M5 12h14",
|
||||
muelleimer: "M3 6h18 M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2 M10 11v6 M14 11v6",
|
||||
abspielen: "M5 3l14 9-14 9V3z",
|
||||
abbrechen: "M18 6L6 18 M6 6l12 12",
|
||||
senden: "M22 2L11 13 M22 2l-7 20-4-9-9-4 20-7z",
|
||||
leeren: "M19 7l-.867 12.142A2 2 0 0 1 16.138 21H7.862a2 2 0 0 1-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v3M4 7h16",
|
||||
aktualisieren: "M21.5 2v6h-6 M2.5 22v-6h6 M2 11.5a10 10 0 0 1 18.8-4.3L21.5 8 M22 12.5a10 10 0 0 1-18.8 4.2L2.5 16",
|
||||
sonne: "M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0z",
|
||||
mond: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z",
|
||||
menue: "M3 12h18 M3 6h18 M3 18h18",
|
||||
schliessen: "M18 6L6 18 M6 6l12 12",
|
||||
pfeilLinks: "M19 12H5 M12 19l-7-7 7-7",
|
||||
pfeilRechts: "M5 12h14 M12 5l7 7-7 7",
|
||||
pfeilUnten: "M6 9l6 6 6-6",
|
||||
haken: "M20 6L9 17l-5-5",
|
||||
ausrufezeichen: "M12 8v4 M12 16h.01 M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z",
|
||||
ausloggen: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4 M16 17l5-5-5-5 M21 12H9"
|
||||
};
|
||||
|
||||
/**
|
||||
* Erzeugt eine Statusplakette (Badge).
|
||||
* @param {string} text
|
||||
* @param {"erfolg"|"warnung"|"gefahr"|"neutral"|"laufend"} art
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function erstellePlakette(text, art = "neutral") {
|
||||
return erstelleElement("span", { klasse: `plakette plakette-${art}`, text });
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Karte mit optionalem Blueprint-Rahmen.
|
||||
* @param {string} [titel]
|
||||
* @param {HTMLElement[]} [elemente]
|
||||
* @param {object} [optionen]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function erstelleKarte(titel, elemente = [], optionen = {}) {
|
||||
const card = erstelleElement("div", {
|
||||
klasse: `karte ${optionen.klasse ?? ""}`.trim()
|
||||
});
|
||||
|
||||
if (titel) {
|
||||
const kopf = erstelleElement("div", { klasse: "karte-kopf" });
|
||||
const h = erstelleElement("h2", { text: titel });
|
||||
kopf.append(h);
|
||||
if (optionen.aktionen) kopf.append(optionen.aktionen);
|
||||
card.append(kopf);
|
||||
}
|
||||
|
||||
for (const el of elemente) {
|
||||
if (el) card.append(el);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt einen standardisierten Modaldialog.
|
||||
* @param {string} titel
|
||||
* @param {HTMLElement} inhalt
|
||||
* @param {HTMLElement[]} [aktionen]
|
||||
* @returns {{ dialog: HTMLElement, oeffnen: () => void, schliessen: () => void }}
|
||||
*/
|
||||
export function erstelleModal(titel, inhalt, aktionen = []) {
|
||||
const overlay = erstelleElement("div", { klasse: "modal-overlay", hidden: true });
|
||||
const box = erstelleElement("div", { klasse: "modal-box karte" });
|
||||
|
||||
const kopf = erstelleElement("div", { klasse: "modal-kopf" });
|
||||
const h = erstelleElement("h2", { text: titel });
|
||||
const schliessenBtn = erstelleElement(
|
||||
"button",
|
||||
{ klasse: "schaltflaeche-icon flach", title: "Schließen", type: "button" },
|
||||
erstelleIcon(PFADE.schliessen, 16)
|
||||
);
|
||||
kopf.append(h, schliessenBtn);
|
||||
|
||||
const koerper = erstelleElement("div", { klasse: "modal-koerper" }, inhalt);
|
||||
|
||||
const fuss = erstelleElement("div", { klasse: "modal-fuss" });
|
||||
for (const aktion of aktionen) fuss.append(aktion);
|
||||
|
||||
box.append(kopf, koerper, fuss);
|
||||
overlay.append(box);
|
||||
document.body.append(overlay);
|
||||
|
||||
const schliessen = () => {
|
||||
overlay.hidden = true;
|
||||
overlay.classList.remove("sichtbar");
|
||||
document.removeEventListener("keydown", tasteHandler);
|
||||
};
|
||||
|
||||
const oeffnen = () => {
|
||||
overlay.hidden = false;
|
||||
overlay.classList.add("sichtbar");
|
||||
document.addEventListener("keydown", tasteHandler);
|
||||
const erstesInput = box.querySelector("input, select, textarea, button:not(.schaltflaeche-icon)");
|
||||
erstesInput?.focus();
|
||||
};
|
||||
|
||||
const tasteHandler = (e) => {
|
||||
if (e.key === "Escape") schliessen();
|
||||
};
|
||||
|
||||
schliessenBtn.addEventListener("click", schliessen);
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) schliessen();
|
||||
});
|
||||
|
||||
return { dialog: overlay, oeffnen, schliessen };
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert Zeitangaben (z.B. "14:32:05").
|
||||
* @param {string|Date|number|null} wert
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatiereZeit(wert) {
|
||||
if (!wert) return "—";
|
||||
const d = wert instanceof Date ? wert : new Date(wert);
|
||||
if (isNaN(d.getTime())) return String(wert);
|
||||
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert Datum und Uhrzeit.
|
||||
* @param {string|Date|number|null} wert
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatiereDatumZeit(wert) {
|
||||
if (!wert) return "—";
|
||||
const d = wert instanceof Date ? wert : new Date(wert);
|
||||
if (isNaN(d.getTime())) return String(wert);
|
||||
return d.toLocaleString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert Tokenzahlen mit Tausendertrennzeichen.
|
||||
* @param {number|null} zahl
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatiereTokens(zahl) {
|
||||
if (zahl === null || zahl === undefined) return "0";
|
||||
return new Intl.NumberFormat("de-DE").format(zahl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert Kosten (USD / EUR).
|
||||
* @param {number|null} kosten
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatiereKosten(kosten) {
|
||||
if (kosten === null || kosten === undefined) return "$0.00";
|
||||
return "$" + Number(kosten).toFixed(4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt ein kurzes Benachrichtigungsbanner oben im Fenster.
|
||||
* @param {string} text
|
||||
* @param {"info"|"erfolg"|"warnung"|"gefahr"} [art="info"]
|
||||
* @param {number} [dauerMs=4000]
|
||||
*/
|
||||
export function zeigeMeldung(text, art = "info", dauerMs = 4000) {
|
||||
let container = document.getElementById("toast-container");
|
||||
if (!container) {
|
||||
container = erstelleElement("div", { id: "toast-container", klasse: "toast-container" });
|
||||
document.body.append(container);
|
||||
}
|
||||
|
||||
const toast = erstelleElement("div", { klasse: `toast toast-${art}` });
|
||||
const iconArt = art === "erfolg" ? PFADE.haken : art === "warnung" || art === "gefahr" ? PFADE.ausrufezeichen : PFADE.info;
|
||||
toast.append(erstelleIcon(iconArt, 16), erstelleElement("span", { text }));
|
||||
|
||||
container.append(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add("ausblenden");
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, dauerMs);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// ClawdDotNet — Ereignisstrom (Server-Sent Events)
|
||||
//
|
||||
// Hört auf /events und verteilt anwendungsintern Ereignisse (chat, step, run, instance, status).
|
||||
|
||||
import { holeToken } from "./api.js";
|
||||
|
||||
let strom = null;
|
||||
const abonnenten = new Map();
|
||||
const verbindungsStatusCallbacks = new Set();
|
||||
let verbunden = false;
|
||||
|
||||
/**
|
||||
* Registriert einen Rückruf für Änderungen des Verbindungsstatus (offen / getrennt).
|
||||
* @param {(status: boolean) => void} callback
|
||||
*/
|
||||
export function beiVerbindungsStatus(callback) {
|
||||
verbindungsStatusCallbacks.add(callback);
|
||||
callback(verbunden);
|
||||
return () => verbindungsStatusCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
function meldeStatus(istOffen) {
|
||||
if (verbunden === istOffen) return;
|
||||
verbunden = istOffen;
|
||||
for (const cb of verbindungsStatusCallbacks) {
|
||||
try {
|
||||
cb(verbunden);
|
||||
} catch (err) {
|
||||
console.error("Fehler im Verbindungsstatus-Callback:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abonniert ein bestimmtes Server-Ereignis.
|
||||
* @param {string} ereignis Name des Ereignisses ("chat", "step", "run", "instance", "status")
|
||||
* @param {(daten: any) => void} callback
|
||||
* @returns {() => void} Abmeldefunktion
|
||||
*/
|
||||
export function abonnieren(ereignis, callback) {
|
||||
if (!abonnenten.has(ereignis)) {
|
||||
abonnenten.set(ereignis, new Set());
|
||||
}
|
||||
abonnenten.get(ereignis).add(callback);
|
||||
|
||||
return () => {
|
||||
const s = abonnenten.get(ereignis);
|
||||
if (s) {
|
||||
s.delete(callback);
|
||||
if (s.size === 0) abonnenten.delete(ereignis);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function verteileEreignis(ereignis, rohdaten) {
|
||||
const gruppe = abonnenten.get(ereignis);
|
||||
if (!gruppe || gruppe.size === 0) return;
|
||||
|
||||
let daten = rohdaten;
|
||||
try {
|
||||
daten = JSON.parse(rohdaten);
|
||||
} catch {
|
||||
// Falls Rohdaten kein JSON waren, unverändert übergeben
|
||||
}
|
||||
|
||||
for (const cb of gruppe) {
|
||||
try {
|
||||
cb(daten);
|
||||
} catch (err) {
|
||||
console.error(`Fehler bei Verarbeitung von ${ereignis}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den Ereignisstrom, falls ein Token vorliegt.
|
||||
*/
|
||||
export function verbindeStrom() {
|
||||
trenneStrom();
|
||||
|
||||
const token = holeToken();
|
||||
if (!token) return;
|
||||
|
||||
// EventSource kann keine Header setzen: Token wird über die Query übergeben (siehe TokenGate.cs)
|
||||
strom = new EventSource(`/events?token=${encodeURIComponent(token)}`);
|
||||
|
||||
strom.onopen = () => {
|
||||
meldeStatus(true);
|
||||
};
|
||||
|
||||
// EventSource verbindet automatisch wieder bei Abbruch.
|
||||
strom.onerror = () => {
|
||||
meldeStatus(false);
|
||||
};
|
||||
|
||||
const bekannteEreignisse = ["chat", "step", "run", "instance", "status"];
|
||||
for (const art of bekannteEreignisse) {
|
||||
strom.addEventListener(art, (e) => verteileEreignis(art, e.data));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schließt die bestehende Verbindung zum Ereignisstrom.
|
||||
*/
|
||||
export function trenneStrom() {
|
||||
if (strom) {
|
||||
strom.close();
|
||||
strom = null;
|
||||
}
|
||||
meldeStatus(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt an, ob der Ereignisstrom aktuell aktiv verbunden ist.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function istVerbunden() {
|
||||
return verbunden;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// ClawdDotNet — Hash-basierter Router
|
||||
//
|
||||
// Leitfaden-Regel 3.2: Navigation über #. Kein history.pushState, keine Serverrouten.
|
||||
|
||||
const routen = new Map();
|
||||
let aktiverPfad = "";
|
||||
let navigationsListener = new Set();
|
||||
|
||||
/**
|
||||
* Registriert einen Handler für eine Route.
|
||||
* @param {string} muster z.B. "/chat", "/chat/:agentId", "/agenten"
|
||||
* @param {(parameter: object) => Promise<void>|void} handler
|
||||
*/
|
||||
export function registriereRoute(muster, handler) {
|
||||
routen.set(muster, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert einen allgemeinen Listener, der bei jedem Routenwechsel aufgerufen wird.
|
||||
* @param {(pfad: string, parameter: object) => void} listener
|
||||
*/
|
||||
export function beiRoutenwechsel(listener) {
|
||||
navigationsListener.add(listener);
|
||||
return () => navigationsListener.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigiert zu einem bestimmten Hash.
|
||||
* @param {string} hash z.B. "#/agenten" oder "/agenten"
|
||||
*/
|
||||
export function navigiere(hash) {
|
||||
const bereinigt = hash.startsWith("#") ? hash : `#${hash}`;
|
||||
if (window.location.hash !== bereinigt) {
|
||||
window.location.hash = bereinigt;
|
||||
} else {
|
||||
fuehreRouteAus();
|
||||
}
|
||||
}
|
||||
|
||||
function parseHash() {
|
||||
const hash = window.location.hash || "#/chat";
|
||||
const pfad = hash.startsWith("#") ? hash.slice(1) : hash;
|
||||
return pfad.startsWith("/") ? pfad : `/${pfad}`;
|
||||
}
|
||||
|
||||
function findePassendeRoute(pfad) {
|
||||
const pfadTeile = pfad.split("/").filter(Boolean);
|
||||
|
||||
for (const [muster, handler] of routen.entries()) {
|
||||
const musterTeile = muster.split("/").filter(Boolean);
|
||||
if (pfadTeile.length !== musterTeile.length) continue;
|
||||
|
||||
const params = {};
|
||||
let passt = true;
|
||||
|
||||
for (let i = 0; i < musterTeile.length; i++) {
|
||||
if (musterTeile[i].startsWith(":")) {
|
||||
const paramName = musterTeile[i].slice(1);
|
||||
params[paramName] = decodeURIComponent(pfadTeile[i]);
|
||||
} else if (musterTeile[i] !== pfadTeile[i]) {
|
||||
passt = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (passt) {
|
||||
return { handler, params, muster };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Erste Route oder 404
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fuehreRouteAus() {
|
||||
const pfad = parseHash();
|
||||
aktiverPfad = pfad;
|
||||
|
||||
aktualisiereAktivenNavEintrag(pfad);
|
||||
|
||||
const match = findePassendeRoute(pfad);
|
||||
if (match) {
|
||||
for (const listener of navigationsListener) {
|
||||
listener(pfad, match.params);
|
||||
}
|
||||
await match.handler(match.params);
|
||||
} else if (routen.has("/chat")) {
|
||||
navigiere("#/chat");
|
||||
}
|
||||
}
|
||||
|
||||
function aktualisiereAktivenNavEintrag(pfad) {
|
||||
const basisPfad = "/" + pfad.split("/").filter(Boolean)[0];
|
||||
const navLinks = document.querySelectorAll(".nav-link[href]");
|
||||
|
||||
for (const link of navLinks) {
|
||||
const href = link.getAttribute("href") || "";
|
||||
const zielPfad = href.startsWith("#") ? href.slice(1) : href;
|
||||
const zielBasis = "/" + zielPfad.split("/").filter(Boolean)[0];
|
||||
|
||||
if (basisPfad === zielBasis) {
|
||||
link.classList.add("aktiv");
|
||||
} else {
|
||||
link.classList.remove("aktiv");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisiert den Router und startet die Überwachung.
|
||||
*/
|
||||
export function initialisiereRouter() {
|
||||
window.addEventListener("hashchange", fuehreRouteAus);
|
||||
fuehreRouteAus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den aktuellen Pfad zurück.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function holeAktuellenPfad() {
|
||||
return aktiverPfad;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// ClawdDotNet — Theme-Verwaltung (Hell / Dunkel)
|
||||
//
|
||||
// Farbschema nach Theme.axaml / Mockup. Unterstützt system, hell, dunkel.
|
||||
|
||||
const THEME_SCHLUESSEL = "clawd.theme";
|
||||
|
||||
let aktuellerModus = localStorage.getItem(THEME_SCHLUESSEL) || "system";
|
||||
|
||||
const systemQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
function wendeThemeAn() {
|
||||
const istDunkel =
|
||||
aktuellerModus === "dunkel" ||
|
||||
(aktuellerModus === "system" && systemQuery.matches);
|
||||
|
||||
if (istDunkel) {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
}
|
||||
}
|
||||
|
||||
systemQuery.addEventListener("change", () => {
|
||||
if (aktuellerModus === "system") {
|
||||
wendeThemeAn();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialisiert das Theme beim Start.
|
||||
*/
|
||||
export function initialisiereTheme() {
|
||||
wendeThemeAn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schaltet zwischen Hell und Dunkel um.
|
||||
* @returns {string} Neues aktives Theme
|
||||
*/
|
||||
export function wechsleTheme() {
|
||||
const istJetztDunkel = document.documentElement.getAttribute("data-theme") === "dark";
|
||||
aktuellerModus = istJetztDunkel ? "hell" : "dunkel";
|
||||
localStorage.setItem(THEME_SCHLUESSEL, aktuellerModus);
|
||||
wendeThemeAn();
|
||||
return aktuellerModus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt das aktuell gewählte Theme zurück.
|
||||
* @returns {"system"|"hell"|"dunkel"}
|
||||
*/
|
||||
export function holeAktuellesTheme() {
|
||||
return aktuellerModus;
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
// ClawdDotNet — Agenten-Verwaltung
|
||||
//
|
||||
// Übersicht, Anlegen, Löschen von Agenten, Werkzeug-Schalter und maskierte Einstellungen.
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import { abonnieren } from "../modules/events.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleIcon,
|
||||
PFADE,
|
||||
erstellePlakette,
|
||||
erstelleKarte,
|
||||
erstelleModal,
|
||||
zeigeMeldung
|
||||
} from "../modules/dom.js";
|
||||
|
||||
let gewaehlterAgentId = null;
|
||||
let agenten = [];
|
||||
let entladeEreignisse = [];
|
||||
|
||||
export async function rendereAgenten(parameter = {}) {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
entladeEreignisse.forEach((c) => c());
|
||||
entladeEreignisse = [];
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "agenten-ansicht" });
|
||||
|
||||
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
|
||||
kopfZeile.append(
|
||||
erstelleElement("h1", { text: "Agenten" }),
|
||||
erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche primaer",
|
||||
onclick: oeffneNeuerAgentDialog
|
||||
},
|
||||
erstelleIcon(PFADE.plus, 16),
|
||||
erstelleElement("span", { text: " Neuer Agent" })
|
||||
)
|
||||
);
|
||||
|
||||
const raster = erstelleElement("div", { klasse: "agenten-raster" });
|
||||
const listeContainer = erstelleElement("div", { klasse: "agenten-liste-bereich" });
|
||||
const detailContainer = erstelleElement("div", { klasse: "agenten-detail-bereich" });
|
||||
|
||||
raster.append(listeContainer, detailContainer);
|
||||
layout.append(kopfZeile, raster);
|
||||
container.append(layout);
|
||||
|
||||
await ladeUndRendereListe(parameter.agentId);
|
||||
|
||||
entladeEreignisse.push(
|
||||
abonnieren("instance", (daten) => {
|
||||
if (daten.kind === "Agents") {
|
||||
ladeUndRendereListe(gewaehlterAgentId);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function ladeUndRendereListe(zielId = null) {
|
||||
const listeContainer = document.querySelector(".agenten-liste-bereich");
|
||||
if (!listeContainer) return;
|
||||
|
||||
listeContainer.replaceChildren(
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Agenten…" })
|
||||
);
|
||||
|
||||
try {
|
||||
agenten = await api("/agents");
|
||||
listeContainer.replaceChildren();
|
||||
|
||||
if (!agenten || agenten.length === 0) {
|
||||
listeContainer.append(
|
||||
erstelleElement("div", {
|
||||
klasse: "hinweis-zentriert",
|
||||
text: "Keine Agenten eingerichtet."
|
||||
})
|
||||
);
|
||||
rendereDetails(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const auswahlId = zielId && agenten.some((a) => a.agentId === zielId)
|
||||
? zielId
|
||||
: agenten[0].agentId;
|
||||
|
||||
for (const a of agenten) {
|
||||
const eintrag = erstelleElement("div", {
|
||||
klasse: `agenten-karte ${a.agentId === auswahlId ? "aktiv" : ""}`,
|
||||
onclick: () => {
|
||||
document.querySelectorAll(".agenten-karte").forEach((k) => k.classList.remove("aktiv"));
|
||||
eintrag.classList.add("aktiv");
|
||||
rendereDetails(a.agentId);
|
||||
}
|
||||
});
|
||||
|
||||
const top = erstelleElement("div", { klasse: "agent-karte-top" });
|
||||
const name = erstelleElement("strong", { text: a.displayName });
|
||||
const status = a.isRunning
|
||||
? erstellePlakette("Beschäftigt", "warnung")
|
||||
: erstellePlakette("Bereit", "neutral");
|
||||
top.append(name, status);
|
||||
|
||||
const meta = erstelleElement("div", { klasse: "agent-karte-meta" });
|
||||
meta.append(
|
||||
erstelleElement("span", { klasse: "gedaempft", text: `ID: ${a.agentId}` }),
|
||||
erstelleElement("span", { klasse: "gedaempft", text: ` · Modell: ${a.model}` })
|
||||
);
|
||||
|
||||
const werkzeugeZaehler = a.tools ? a.tools.filter((t) => t.enabled).length : 0;
|
||||
const werkzeugeText = erstelleElement("div", {
|
||||
klasse: "agent-karte-werkzeuge gedaempft",
|
||||
text: `${werkzeugeZaehler} Werkzeuge aktiv`
|
||||
});
|
||||
|
||||
eintrag.append(top, meta, werkzeugeText);
|
||||
listeContainer.append(eintrag);
|
||||
}
|
||||
|
||||
rendereDetails(auswahlId);
|
||||
} catch (err) {
|
||||
listeContainer.replaceChildren(
|
||||
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function rendereDetails(agentId) {
|
||||
gewaehlterAgentId = agentId;
|
||||
const detailContainer = document.querySelector(".agenten-detail-bereich");
|
||||
if (!detailContainer) return;
|
||||
detailContainer.replaceChildren();
|
||||
|
||||
if (!agentId) {
|
||||
detailContainer.append(
|
||||
erstelleKarte("Agent-Details", [
|
||||
erstelleElement("p", { klasse: "gedaempft", text: "Wähle links einen Agenten aus." })
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = agenten.find((a) => a.agentId === agentId);
|
||||
if (!agent) return;
|
||||
|
||||
// Kopf-Aktionen (Chatten, Löschen)
|
||||
const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
|
||||
const zumChatBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach",
|
||||
title: "Mit diesem Agenten chatten",
|
||||
onclick: () => {
|
||||
window.location.hash = `#/chat/${encodeURIComponent(agent.agentId)}`;
|
||||
}
|
||||
},
|
||||
erstelleIcon(PFADE.chat, 14),
|
||||
erstelleElement("span", { text: " Chatten" })
|
||||
);
|
||||
|
||||
const loeschenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche gefahr flach",
|
||||
title: "Diesen Agenten löschen",
|
||||
onclick: () => loescheAgent(agent.agentId, agent.displayName)
|
||||
},
|
||||
erstelleIcon(PFADE.muelleimer, 14),
|
||||
erstelleElement("span", { text: " Löschen" })
|
||||
);
|
||||
|
||||
aktionen.append(zumChatBtn, loeschenBtn);
|
||||
|
||||
// Stammdaten-Karte
|
||||
const stammdatenListe = erstelleElement("dl", { klasse: "datenliste" });
|
||||
const felder = [
|
||||
["Name", agent.displayName],
|
||||
["Kennung (ID)", agent.agentId],
|
||||
["Modell", agent.model],
|
||||
["Beschreibung", agent.description || "—"]
|
||||
];
|
||||
|
||||
for (const [bez, wert] of felder) {
|
||||
stammdatenListe.append(
|
||||
erstelleElement("dt", { text: bez }),
|
||||
erstelleElement("dd", { text: wert })
|
||||
);
|
||||
}
|
||||
|
||||
const basisKarte = erstelleKarte(agent.displayName, [stammdatenListe], { aktionen });
|
||||
|
||||
// Werkzeuge-Karte
|
||||
const werkzeugeListe = erstelleElement("div", { klasse: "werkzeuge-liste" });
|
||||
|
||||
if (!agent.tools || agent.tools.length === 0) {
|
||||
werkzeugeListe.append(
|
||||
erstelleElement("p", { klasse: "gedaempft", text: "Keine Werkzeuge für diesen Agenten konfiguriert." })
|
||||
);
|
||||
} else {
|
||||
for (const tool of agent.tools) {
|
||||
const zeile = erstelleElement("div", { klasse: "werkzeug-zeile" });
|
||||
|
||||
const links = erstelleElement("div", { klasse: "werkzeug-info" });
|
||||
const toolTitel = erstelleElement("strong", { text: tool.toolName });
|
||||
const toolDesc = erstelleElement("div", {
|
||||
klasse: "werkzeug-beschreibung gedaempft",
|
||||
text: tool.description || ""
|
||||
});
|
||||
links.append(toolTitel, toolDesc);
|
||||
|
||||
const rechts = erstelleElement("div", { klasse: "werkzeug-aktionen" });
|
||||
|
||||
const einstellungenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach klein",
|
||||
title: "Werkzeug-Einstellungen einsehen",
|
||||
onclick: () => zeigeWerkzeugEinstellungen(agent.agentId, tool.toolName)
|
||||
},
|
||||
erstelleIcon(PFADE.einstellungen, 14),
|
||||
erstelleElement("span", { text: " Konfiguration" })
|
||||
);
|
||||
|
||||
const toggleLabel = erstelleElement("label", { klasse: "schalter" });
|
||||
const toggleInput = erstelleElement("input", {
|
||||
type: "checkbox",
|
||||
checked: Boolean(tool.enabled),
|
||||
onchange: async (e) => {
|
||||
const aktiv = e.target.checked;
|
||||
try {
|
||||
await api(
|
||||
`/agents/${encodeURIComponent(agent.agentId)}/tools/${encodeURIComponent(tool.toolName)}?enabled=${aktiv}`,
|
||||
{ method: "PUT" }
|
||||
);
|
||||
tool.enabled = aktiv;
|
||||
zeigeMeldung(`Werkzeug '${tool.toolName}' ${aktiv ? "aktiviert" : "deaktiviert"}.`, "info");
|
||||
} catch (err) {
|
||||
e.target.checked = !aktiv;
|
||||
zeigeMeldung(`Änderung fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
}
|
||||
}
|
||||
});
|
||||
const toggleSpan = erstelleElement("span", { klasse: "schalter-regler" });
|
||||
toggleLabel.append(toggleInput, toggleSpan);
|
||||
|
||||
rechts.append(einstellungenBtn, toggleLabel);
|
||||
zeile.append(links, rechts);
|
||||
werkzeugeListe.append(zeile);
|
||||
}
|
||||
}
|
||||
|
||||
const werkzeugeKarte = erstelleKarte("Verfügbare Werkzeuge", [werkzeugeListe]);
|
||||
|
||||
detailContainer.append(basisKarte, werkzeugeKarte);
|
||||
}
|
||||
|
||||
function oeffneNeuerAgentDialog() {
|
||||
const formular = erstelleElement("form", { klasse: "formular-raster" });
|
||||
|
||||
const idFeld = erstelleElement("input", {
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "z.B. scout",
|
||||
id: "neu-agent-id"
|
||||
});
|
||||
|
||||
const nameFeld = erstelleElement("input", {
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "z.B. Scout Agent",
|
||||
id: "neu-agent-name"
|
||||
});
|
||||
|
||||
const modellFeld = erstelleElement("input", {
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "z.B. anthropic/claude-3.5-sonnet",
|
||||
value: "anthropic/claude-3.5-sonnet",
|
||||
id: "neu-agent-modell"
|
||||
});
|
||||
|
||||
const descFeld = erstelleElement("textarea", {
|
||||
rows: "3",
|
||||
placeholder: "Optionale Beschreibung des Agenten…",
|
||||
id: "neu-agent-desc"
|
||||
});
|
||||
|
||||
formular.append(
|
||||
erstelleElement("label", { text: "Agent-ID (eindeutiger Schlüssel):" }),
|
||||
idFeld,
|
||||
erstelleElement("label", { text: "Anzeigename:" }),
|
||||
nameFeld,
|
||||
erstelleElement("label", { text: "Modell (OpenRouter):" }),
|
||||
modellFeld,
|
||||
erstelleElement("label", { text: "Beschreibung:" }),
|
||||
descFeld
|
||||
);
|
||||
|
||||
const abbrechenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
klasse: "schaltflaeche flach",
|
||||
onclick: () => modal.schliessen()
|
||||
},
|
||||
erstelleElement("span", { text: "Abbrechen" })
|
||||
);
|
||||
|
||||
const speichernBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
type: "submit",
|
||||
klasse: "schaltflaeche primaer"
|
||||
},
|
||||
erstelleElement("span", { text: "Agent anlegen" })
|
||||
);
|
||||
|
||||
const modal = erstelleModal("Neuen Agenten anlegen", formular, [abbrechenBtn, speichernBtn]);
|
||||
|
||||
formular.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
speichernBtn.disabled = true;
|
||||
|
||||
try {
|
||||
await api("/agents", {
|
||||
method: "POST",
|
||||
body: {
|
||||
agentId: idFeld.value.trim(),
|
||||
displayName: nameFeld.value.trim(),
|
||||
model: modellFeld.value.trim(),
|
||||
description: descFeld.value.trim()
|
||||
}
|
||||
});
|
||||
modal.schliessen();
|
||||
zeigeMeldung(`Agent '${nameFeld.value.trim()}' wurde erfolgreich angelegt.`, "erfolg");
|
||||
await ladeUndRendereListe(idFeld.value.trim());
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Fehler beim Anlegen: ${err.message}`, "gefahr");
|
||||
} finally {
|
||||
speichernBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
async function loescheAgent(agentId, name) {
|
||||
if (!confirm(`Soll der Agent '${name}' (${agentId}) wirklich gelöscht werden?`)) return;
|
||||
|
||||
try {
|
||||
await api(`/agents/${encodeURIComponent(agentId)}`, { method: "DELETE" });
|
||||
zeigeMeldung(`Agent '${name}' wurde gelöscht.`, "info");
|
||||
await ladeUndRendereListe();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
}
|
||||
}
|
||||
|
||||
async function zeigeWerkzeugEinstellungen(agentId, toolName) {
|
||||
const box = erstelleElement("div", { klasse: "einstellungen-box" });
|
||||
box.append(erstelleElement("p", { klasse: "gedaempft", text: "Lade Konfiguration…" }));
|
||||
|
||||
const schliessenBtn = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche flach",
|
||||
onclick: () => modal.schliessen(),
|
||||
text: "Schließen"
|
||||
});
|
||||
|
||||
const modal = erstelleModal(
|
||||
`Einstellungen: ${toolName} (${agentId})`,
|
||||
box,
|
||||
[schliessenBtn]
|
||||
);
|
||||
modal.oeffnen();
|
||||
|
||||
try {
|
||||
const daten = await api(
|
||||
`/agents/${encodeURIComponent(agentId)}/tools/${encodeURIComponent(toolName)}/settings`
|
||||
);
|
||||
box.replaceChildren();
|
||||
|
||||
const hinweis = erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: "Geheime Werte (Passwörter, Schlüssel) werden vom Server maskiert ausgegeben."
|
||||
});
|
||||
box.append(hinweis);
|
||||
|
||||
if (!daten || Object.keys(daten).length === 0) {
|
||||
box.append(
|
||||
erstelleElement("p", { klasse: "gedaempft", text: "Keine individuellen Einstellungen hinterlegt." })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const dl = erstelleElement("dl", { klasse: "datenliste" });
|
||||
for (const [schluessel, wert] of Object.entries(daten)) {
|
||||
const dt = erstelleElement("dt", { text: schluessel });
|
||||
const dd = erstelleElement("dd", { text: typeof wert === "object" ? JSON.stringify(wert) : String(wert) });
|
||||
dl.append(dt, dd);
|
||||
}
|
||||
box.append(dl);
|
||||
} catch (err) {
|
||||
box.replaceChildren(
|
||||
erstelleElement("p", { klasse: "fehler", text: `Laden fehlgeschlagen: ${err.message}` })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
// ClawdDotNet — Chat-Ansicht
|
||||
//
|
||||
// Leitfaden: Sicherer Chatverlauf (strikt textContent), Echtzeit-Ereignisse über SSE.
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import { abonnieren } from "../modules/events.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleIcon,
|
||||
PFADE,
|
||||
formatiereZeit,
|
||||
erstellePlakette,
|
||||
zeigeMeldung
|
||||
} from "../modules/dom.js";
|
||||
|
||||
let aktiverAgentId = null;
|
||||
let agentenListe = [];
|
||||
let nachrichtenContainer = null;
|
||||
let schrittBox = null;
|
||||
let eingabeTextarea = null;
|
||||
let sendenButton = null;
|
||||
let abbrechenButton = null;
|
||||
let agentenSelector = null;
|
||||
let statusPlakette = null;
|
||||
let entladeEreignisse = [];
|
||||
|
||||
export async function rendereChat(parameter = {}) {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
// Alte Ereignisse aufräumen
|
||||
entladeEreignisse.forEach((cleanup) => cleanup());
|
||||
entladeEreignisse = [];
|
||||
|
||||
const chatShell = erstelleElement("div", { klasse: "chat-ansicht" });
|
||||
|
||||
// 1. Kopfleiste der Chat-Ansicht
|
||||
const kopfleiste = erstelleElement("div", { klasse: "chat-kopf" });
|
||||
|
||||
const agentenWahl = erstelleElement("div", { klasse: "chat-agenten-wahl" });
|
||||
agentenSelector = erstelleElement("select", {
|
||||
klasse: "eingabe-select",
|
||||
onchange: (e) => waehleAgent(e.target.value)
|
||||
});
|
||||
|
||||
statusPlakette = erstellePlakette("Lädt…", "neutral");
|
||||
agentenWahl.append(erstelleElement("label", { text: "Agent: " }), agentenSelector, statusPlakette);
|
||||
|
||||
const aktionen = erstelleElement("div", { klasse: "chat-aktionen" });
|
||||
|
||||
const weckenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach",
|
||||
title: "Agenten manuell ausführen / wecken",
|
||||
onclick: weckeAktivenAgent
|
||||
},
|
||||
erstelleIcon(PFADE.abspielen, 14),
|
||||
erstelleElement("span", { text: " Wecken" })
|
||||
);
|
||||
|
||||
const leerenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach",
|
||||
title: "Chatverlauf dieses Agenten leeren",
|
||||
onclick: leereChatverlauf
|
||||
},
|
||||
erstelleIcon(PFADE.leeren, 14),
|
||||
erstelleElement("span", { text: " Verlauf leeren" })
|
||||
);
|
||||
|
||||
abbrechenButton = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche gefahr flach",
|
||||
title: "Laufenden Lauf abbrechen",
|
||||
hidden: true,
|
||||
onclick: brecheChatAb
|
||||
},
|
||||
erstelleIcon(PFADE.abbrechen, 14),
|
||||
erstelleElement("span", { text: " Abbrechen" })
|
||||
);
|
||||
|
||||
aktionen.append(weckenBtn, leerenBtn, abbrechenButton);
|
||||
kopfleiste.append(agentenWahl, aktionen);
|
||||
|
||||
// 2. Nachrichtenverlauf & Schrittanzeige
|
||||
nachrichtenContainer = erstelleElement("div", { klasse: "chat-verlauf" });
|
||||
|
||||
schrittBox = erstelleElement("div", { klasse: "chat-schritt-box", hidden: true });
|
||||
const schrittIcon = erstelleIcon(PFADE.aktualisieren, 14, "rotierend");
|
||||
const schrittText = erstelleElement("span", { klasse: "schritt-text", text: "" });
|
||||
schrittBox.append(schrittIcon, schrittText);
|
||||
|
||||
// 3. Eingabebereich
|
||||
const eingabeBereich = erstelleElement("div", { klasse: "chat-eingabe-bereich" });
|
||||
|
||||
eingabeTextarea = erstelleElement("textarea", {
|
||||
klasse: "chat-eingabe",
|
||||
placeholder: "Nachricht an den Agenten eingeben (Enter zum Senden, Umschalt+Enter für neue Zeile)…",
|
||||
rows: "3",
|
||||
onkeydown: (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendeNachricht();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sendenButton = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche primaer",
|
||||
title: "Nachricht senden",
|
||||
onclick: sendeNachricht
|
||||
},
|
||||
erstelleIcon(PFADE.senden, 16),
|
||||
erstelleElement("span", { text: "Senden" })
|
||||
);
|
||||
|
||||
eingabeBereich.append(eingabeTextarea, sendenButton);
|
||||
|
||||
chatShell.append(kopfleiste, nachrichtenContainer, schrittBox, eingabeBereich);
|
||||
container.append(chatShell);
|
||||
|
||||
// Lade Agenten und initialisiere
|
||||
await ladeAgenten(parameter.agentId);
|
||||
|
||||
// SSE-Abonnements
|
||||
entladeEreignisse.push(
|
||||
abonnieren("chat", (daten) => {
|
||||
if (daten.agentId === aktiverAgentId) {
|
||||
fuegeNachrichtEin(daten);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
entladeEreignisse.push(
|
||||
abonnieren("step", (daten) => {
|
||||
if (daten.agentId === aktiverAgentId) {
|
||||
zeigeSchritt(daten.text);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
entladeEreignisse.push(
|
||||
abonnieren("run", (daten) => {
|
||||
versteckeSchritt();
|
||||
aktualisiereLaufStatus(false);
|
||||
})
|
||||
);
|
||||
|
||||
entladeEreignisse.push(
|
||||
abonnieren("instance", (daten) => {
|
||||
if (daten.kind === "Agents") {
|
||||
ladeAgenten(aktiverAgentId);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function ladeAgenten(gewuenschteId = null) {
|
||||
try {
|
||||
agentenListe = await api("/agents");
|
||||
agentenSelector.replaceChildren();
|
||||
|
||||
if (!agentenListe || agentenListe.length === 0) {
|
||||
const opt = erstelleElement("option", { text: "Keine Agenten vorhanden", value: "" });
|
||||
agentenSelector.append(opt);
|
||||
statusPlakette.replaceWith(erstellePlakette("Keine Agenten", "warnung"));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const a of agentenListe) {
|
||||
const opt = erstelleElement("option", {
|
||||
value: a.agentId,
|
||||
text: `${a.displayName} (${a.agentId})`
|
||||
});
|
||||
agentenSelector.append(opt);
|
||||
}
|
||||
|
||||
const zielId = gewuenschteId && agentenListe.some((a) => a.agentId === gewuenschteId)
|
||||
? gewuenschteId
|
||||
: agentenListe[0].agentId;
|
||||
|
||||
agentenSelector.value = zielId;
|
||||
await waehleAgent(zielId);
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Fehler beim Laden der Agenten: ${err.message}`, "gefahr");
|
||||
}
|
||||
}
|
||||
|
||||
async function waehleAgent(agentId) {
|
||||
aktiverAgentId = agentId;
|
||||
const agent = agentenListe.find((a) => a.agentId === agentId);
|
||||
aktualisiereLaufStatus(agent?.isRunning ?? false);
|
||||
|
||||
window.location.hash = `#/chat/${encodeURIComponent(agentId)}`;
|
||||
|
||||
await ladeChatVerlauf(agentId);
|
||||
}
|
||||
|
||||
function aktualisiereLaufStatus(laeuft) {
|
||||
if (statusPlakette) {
|
||||
const neuePlakette = laeuft
|
||||
? erstellePlakette("Beschäftigt", "warnung")
|
||||
: erstellePlakette("Bereit", "erfolg");
|
||||
statusPlakette.replaceWith(neuePlakette);
|
||||
statusPlakette = neuePlakette;
|
||||
}
|
||||
|
||||
if (abbrechenButton) {
|
||||
abbrechenButton.hidden = !laeuft;
|
||||
}
|
||||
}
|
||||
|
||||
async function ladeChatVerlauf(agentId) {
|
||||
if (!nachrichtenContainer) return;
|
||||
nachrichtenContainer.replaceChildren(
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Verlauf…" })
|
||||
);
|
||||
|
||||
try {
|
||||
const verlauf = await api(`/agents/${encodeURIComponent(agentId)}/chat`);
|
||||
nachrichtenContainer.replaceChildren();
|
||||
|
||||
if (!verlauf || verlauf.length === 0) {
|
||||
nachrichtenContainer.append(
|
||||
erstelleElement("div", {
|
||||
klasse: "hinweis-zentriert",
|
||||
text: "Noch keine Nachrichten im Verlauf vorhanden."
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const eintrag of verlauf) {
|
||||
fuegeNachrichtEin(eintrag);
|
||||
}
|
||||
|
||||
scrolleNachUnten();
|
||||
} catch (err) {
|
||||
nachrichtenContainer.replaceChildren(
|
||||
erstelleElement("div", {
|
||||
klasse: "fehler-zentriert",
|
||||
text: `Verlauf konnte nicht geladen werden: ${err.message}`
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function fuegeNachrichtEin(eintrag) {
|
||||
// Falls aktuell der Hinweis angezeigt wird, entfernen
|
||||
const platzhalter = nachrichtenContainer.querySelector(".hinweis-zentriert");
|
||||
if (platzhalter) platzhalter.remove();
|
||||
|
||||
const rolle = (eintrag.role || "user").toLowerCase();
|
||||
const istUser = rolle === "user";
|
||||
const istSystem = rolle === "system";
|
||||
const istTool = rolle === "tool";
|
||||
|
||||
let rollenKlasse = "nachricht-benutzer";
|
||||
let rollenTitel = "Benutzer";
|
||||
|
||||
if (istSystem) {
|
||||
rollenKlasse = "nachricht-system";
|
||||
rollenTitel = "System";
|
||||
} else if (istTool) {
|
||||
rollenKlasse = "nachricht-werkzeug";
|
||||
rollenTitel = "Werkzeug";
|
||||
} else if (!istUser) {
|
||||
rollenKlasse = "nachricht-agent";
|
||||
rollenTitel = agentenListe.find((a) => a.agentId === aktiverAgentId)?.displayName || "Agent";
|
||||
}
|
||||
|
||||
const zeile = erstelleElement("div", { klasse: `chat-zeile ${istUser ? "zeile-benutzer" : "zeile-fremd"}` });
|
||||
const nachrichtCard = erstelleElement("div", { klasse: `nachricht-blase ${rollenKlasse}` });
|
||||
|
||||
const kopf = erstelleElement("div", { klasse: "nachricht-meta" });
|
||||
const autor = erstelleElement("strong", { text: rollenTitel });
|
||||
const zeit = erstelleElement("span", {
|
||||
klasse: "nachricht-zeit",
|
||||
text: formatiereZeit(eintrag.timestamp)
|
||||
});
|
||||
|
||||
kopf.append(autor);
|
||||
if (eintrag.source) {
|
||||
const quelleBadge = erstelleElement("span", { klasse: "quelle-tag", text: eintrag.source });
|
||||
kopf.append(quelleBadge);
|
||||
}
|
||||
kopf.append(zeit);
|
||||
|
||||
// WICHTIG (Regel 3.1): Fremdtext ist NIE HTML! Strikt textContent!
|
||||
const textKörper = erstelleElement("div", {
|
||||
klasse: "nachricht-text",
|
||||
text: eintrag.content || ""
|
||||
});
|
||||
|
||||
nachrichtCard.append(kopf, textKörper);
|
||||
zeile.append(nachrichtCard);
|
||||
nachrichtenContainer.append(zeile);
|
||||
|
||||
scrolleNachUnten();
|
||||
}
|
||||
|
||||
function zeigeSchritt(text) {
|
||||
if (!schrittBox) return;
|
||||
const span = schrittBox.querySelector(".schritt-text");
|
||||
if (span) span.textContent = text;
|
||||
schrittBox.hidden = false;
|
||||
aktualisiereLaufStatus(true);
|
||||
}
|
||||
|
||||
function versteckeSchritt() {
|
||||
if (schrittBox) schrittBox.hidden = true;
|
||||
}
|
||||
|
||||
function scrolleNachUnten() {
|
||||
if (nachrichtenContainer) {
|
||||
nachrichtenContainer.scrollTop = nachrichtenContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendeNachricht() {
|
||||
if (!aktiverAgentId || !eingabeTextarea) return;
|
||||
const nachricht = eingabeTextarea.value.trim();
|
||||
if (!nachricht) return;
|
||||
|
||||
eingabeTextarea.value = "";
|
||||
sendenButton.disabled = true;
|
||||
aktualisiereLaufStatus(true);
|
||||
|
||||
try {
|
||||
await api(`/agents/${encodeURIComponent(aktiverAgentId)}/chat`, {
|
||||
method: "POST",
|
||||
body: { message: nachricht }
|
||||
});
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Nachricht konnte nicht gesendet werden: ${err.message}`, "gefahr");
|
||||
aktualisiereLaufStatus(false);
|
||||
} finally {
|
||||
sendenButton.disabled = false;
|
||||
eingabeTextarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function brecheChatAb() {
|
||||
if (!aktiverAgentId) return;
|
||||
try {
|
||||
await api(`/agents/${encodeURIComponent(aktiverAgentId)}/chat/abort`, {
|
||||
method: "POST"
|
||||
});
|
||||
versteckeSchritt();
|
||||
aktualisiereLaufStatus(false);
|
||||
zeigeMeldung("Lauf wurde abgebrochen.", "warnung");
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Abbruch fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
}
|
||||
}
|
||||
|
||||
async function weckeAktivenAgent() {
|
||||
if (!aktiverAgentId) return;
|
||||
try {
|
||||
await api(`/agents/${encodeURIComponent(aktiverAgentId)}/run`, {
|
||||
method: "POST"
|
||||
});
|
||||
aktualisiereLaufStatus(true);
|
||||
zeigeMeldung("Agent wurde manuell gestartet.", "info");
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Starten fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
}
|
||||
}
|
||||
|
||||
async function leereChatverlauf() {
|
||||
if (!aktiverAgentId) return;
|
||||
if (!confirm("Soll der gesamte Chatverlauf dieses Agenten unwiderruflich gelöscht werden?")) return;
|
||||
|
||||
try {
|
||||
await api(`/agents/${encodeURIComponent(aktiverAgentId)}/chat`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
nachrichtenContainer.replaceChildren(
|
||||
erstelleElement("div", {
|
||||
klasse: "hinweis-zentriert",
|
||||
text: "Verlauf wurde geleert."
|
||||
})
|
||||
);
|
||||
zeigeMeldung("Chatverlauf geleert.", "info");
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Leeren fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// ClawdDotNet — System-Info & Status
|
||||
//
|
||||
// Zeigt Anwendungsversion, Instanzkennung, Teilsysteme und OpenRouter-Guthaben an.
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleKarte,
|
||||
erstellePlakette
|
||||
} from "../modules/dom.js";
|
||||
|
||||
export async function rendereInfo() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "info-ansicht" });
|
||||
layout.append(erstelleElement("h1", { text: "System & Status" }));
|
||||
|
||||
const gitter = erstelleElement("div", { klasse: "info-gitter" });
|
||||
layout.append(gitter);
|
||||
container.append(layout);
|
||||
|
||||
gitter.append(
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Systemdaten…" })
|
||||
);
|
||||
|
||||
try {
|
||||
const [info, status] = await Promise.all([
|
||||
api("/info").catch(() => null),
|
||||
api("/status").catch(() => null)
|
||||
]);
|
||||
|
||||
gitter.replaceChildren();
|
||||
|
||||
// 1. Instanz & Version
|
||||
if (info) {
|
||||
const dlInstanz = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dlInstanz.append(
|
||||
erstelleElement("dt", { text: "Instanzname" }),
|
||||
erstelleElement("dd", {}, erstelleElement("strong", { text: info.instanceName })),
|
||||
erstelleElement("dt", { text: "Instanz-ID" }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: info.instanceId })),
|
||||
erstelleElement("dt", { text: "Version" }),
|
||||
erstelleElement("dd", { text: info.appVersion }),
|
||||
erstelleElement("dt", { text: "Build" }),
|
||||
erstelleElement("dd", { text: info.buildSummary })
|
||||
);
|
||||
gitter.append(erstelleKarte("Instanz & Programm", [dlInstanz]));
|
||||
|
||||
// 2. Teilsysteme
|
||||
const dlSysteme = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dlSysteme.append(
|
||||
erstelleElement("dt", { text: "Agent Engine" }),
|
||||
erstelleElement("dd", {}, erstellePlakette(info.engineActive ? "Aktiv" : "Aus", info.engineActive ? "erfolg" : "neutral")),
|
||||
erstelleElement("dt", { text: "Scanner / Scheduler" }),
|
||||
erstelleElement("dd", {}, erstellePlakette(info.schedulerActive ? "Aktiv" : "Aus", info.schedulerActive ? "erfolg" : "neutral")),
|
||||
erstelleElement("dt", { text: "Freigabestelle (Staging)" }),
|
||||
erstelleElement("dd", {}, erstellePlakette(info.stagingActive ? "Aktiv" : "Aus", info.stagingActive ? "erfolg" : "neutral")),
|
||||
erstelleElement("dt", { text: "Deploymentcenter" }),
|
||||
erstelleElement("dd", {}, erstellePlakette(info.deploymentcenterActive ? "Verbunden" : "Nicht konfiguriert", info.deploymentcenterActive ? "erfolg" : "neutral"))
|
||||
);
|
||||
gitter.append(erstelleKarte("Dienste & Teilsysteme", [dlSysteme]));
|
||||
}
|
||||
|
||||
// 3. OpenRouter / API-Status
|
||||
const dlStatus = erstelleElement("dl", { klasse: "datenliste" });
|
||||
if (status) {
|
||||
dlStatus.append(
|
||||
erstelleElement("dt", { text: "API-Erreichbarkeit" }),
|
||||
erstelleElement("dd", {}, erstellePlakette(status.isApiReachable ? "Verbunden" : "Getrennt", status.isApiReachable ? "erfolg" : "gefahr")),
|
||||
erstelleElement("dt", { text: "Statusmeldung" }),
|
||||
erstelleElement("dd", { text: status.statusText || "—" }),
|
||||
erstelleElement("dt", { text: "Guthaben / Credits" }),
|
||||
erstelleElement("dd", {}, erstelleElement("strong", { text: status.creditsText || "—" }))
|
||||
);
|
||||
} else {
|
||||
dlStatus.append(
|
||||
erstelleElement("dt", { text: "OpenRouter" }),
|
||||
erstelleElement("dd", { klasse: "gedaempft", text: "Kein API-Schlüssel oder Dienst nicht erreichbar." })
|
||||
);
|
||||
}
|
||||
gitter.append(erstelleKarte("Modellanbindung (OpenRouter)", [dlStatus]));
|
||||
|
||||
} catch (err) {
|
||||
gitter.replaceChildren(
|
||||
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// ClawdDotNet — Anmeldeseite
|
||||
//
|
||||
// Leitfaden: Ohne Token soll das UI sofort auf die Login- bzw. Tokeneingabe springen.
|
||||
|
||||
import { setzeToken, loescheToken, api } from "../modules/api.js";
|
||||
import { erstelleElement, PFADE, erstelleIcon } from "../modules/dom.js";
|
||||
|
||||
let loginCallback = null;
|
||||
|
||||
export function initialisiereLogin(onErfolg) {
|
||||
loginCallback = onErfolg;
|
||||
const formular = document.getElementById("anmeldeformular");
|
||||
const fehlerBox = document.getElementById("anmeldefehler");
|
||||
|
||||
if (!formular) return;
|
||||
|
||||
formular.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const tokenInput = document.getElementById("token-eingabe");
|
||||
const token = tokenInput?.value.trim() || "";
|
||||
|
||||
if (!token) return;
|
||||
|
||||
if (fehlerBox) {
|
||||
fehlerBox.hidden = true;
|
||||
fehlerBox.textContent = "";
|
||||
}
|
||||
|
||||
setzeToken(token);
|
||||
|
||||
try {
|
||||
// Teste das Token gegen /api/info
|
||||
const info = await api("/info");
|
||||
versteckeAnmeldung();
|
||||
if (loginCallback) loginCallback(info);
|
||||
} catch (err) {
|
||||
loescheToken();
|
||||
if (fehlerBox) {
|
||||
fehlerBox.textContent = err.abgelehnt
|
||||
? "Token ungültig oder kürzer als 20 Zeichen."
|
||||
: `Anmeldefehler: ${err.message}`;
|
||||
fehlerBox.hidden = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function zeigeAnmeldung() {
|
||||
const anmeldung = document.getElementById("anmeldung");
|
||||
const anwendung = document.getElementById("anwendung");
|
||||
const fehlerBox = document.getElementById("anmeldefehler");
|
||||
const tokenInput = document.getElementById("token-eingabe");
|
||||
|
||||
if (anmeldung) anmeldung.hidden = false;
|
||||
if (anwendung) anwendung.hidden = true;
|
||||
if (fehlerBox) {
|
||||
fehlerBox.hidden = true;
|
||||
fehlerBox.textContent = "";
|
||||
}
|
||||
if (tokenInput) {
|
||||
tokenInput.value = "";
|
||||
tokenInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function versteckeAnmeldung() {
|
||||
const anmeldung = document.getElementById("anmeldung");
|
||||
const anwendung = document.getElementById("anwendung");
|
||||
if (anmeldung) anmeldung.hidden = true;
|
||||
if (anwendung) anwendung.hidden = false;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// ClawdDotNet — Live-Protokollansicht (Logs)
|
||||
//
|
||||
// Fortlaufender Stream über /events/logs, Modul- und Level-Filterung, Autoscroll, strikt textContent.
|
||||
|
||||
import { api, holeToken } from "../modules/api.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleIcon,
|
||||
PFADE,
|
||||
erstelleKarte,
|
||||
erstellePlakette
|
||||
} from "../modules/dom.js";
|
||||
|
||||
let logStream = null;
|
||||
let modulFilter = "";
|
||||
let levelFilter = "All";
|
||||
let autoScroll = true;
|
||||
let logContainer = null;
|
||||
let streamStatusPlakette = null;
|
||||
let pauseButton = null;
|
||||
let istPausiert = false;
|
||||
|
||||
export async function rendereProtokoll() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
trenneLogStream();
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "protokoll-ansicht" });
|
||||
|
||||
// Kopfleiste mit Filtern und Steuerungen
|
||||
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
|
||||
const titelGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
titelGruppe.append(erstelleElement("h1", { text: "Protokoll" }));
|
||||
streamStatusPlakette = erstellePlakette("Verbinde…", "neutral");
|
||||
titelGruppe.append(streamStatusPlakette);
|
||||
|
||||
const steuerungGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
|
||||
// Modul-Auswahl
|
||||
const modulSelect = erstelleElement("select", {
|
||||
klasse: "eingabe-select",
|
||||
onchange: (e) => {
|
||||
modulFilter = e.target.value;
|
||||
starteLogStream();
|
||||
}
|
||||
});
|
||||
modulSelect.append(erstelleElement("option", { value: "", text: "Alle Module" }));
|
||||
|
||||
// Level-Auswahl
|
||||
const levelSelect = erstelleElement("select", {
|
||||
klasse: "eingabe-select",
|
||||
onchange: (e) => {
|
||||
levelFilter = e.target.value;
|
||||
starteLogStream();
|
||||
}
|
||||
});
|
||||
const levelOptionen = [
|
||||
["All", "Alle Stufen"],
|
||||
["Debug", "Debug"],
|
||||
["Information", "Info"],
|
||||
["Warning", "Warnung"],
|
||||
["Error", "Fehler"],
|
||||
["Critical", "Kritisch"]
|
||||
];
|
||||
for (const [val, label] of levelOptionen) {
|
||||
levelSelect.append(erstelleElement("option", { value: val, text: label }));
|
||||
}
|
||||
|
||||
// Autoscroll-Checkbox
|
||||
const autoScrollLabel = erstelleElement("label", { klasse: "checkbox-label gedaempft" });
|
||||
const autoScrollInput = erstelleElement("input", {
|
||||
type: "checkbox",
|
||||
checked: true,
|
||||
onchange: (e) => {
|
||||
autoScroll = e.target.checked;
|
||||
}
|
||||
});
|
||||
autoScrollLabel.append(autoScrollInput, erstelleElement("span", { text: " Auto-Scroll" }));
|
||||
|
||||
// Pause / Fortsetzen
|
||||
pauseButton = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach",
|
||||
onclick: togglePause
|
||||
},
|
||||
erstelleIcon(PFADE.abbrechen, 14),
|
||||
erstelleElement("span", { text: " Anhalten" })
|
||||
);
|
||||
|
||||
// Leeren
|
||||
const leerenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach",
|
||||
title: "Anzeige leeren",
|
||||
onclick: () => logContainer?.replaceChildren()
|
||||
},
|
||||
erstelleIcon(PFADE.leeren, 14),
|
||||
erstelleElement("span", { text: " Leeren" })
|
||||
);
|
||||
|
||||
steuerungGruppe.append(modulSelect, levelSelect, autoScrollLabel, pauseButton, leerenBtn);
|
||||
kopfZeile.append(titelGruppe, steuerungGruppe);
|
||||
|
||||
logContainer = erstelleElement("div", { klasse: "protokoll-strom code-block" });
|
||||
layout.append(kopfZeile, logContainer);
|
||||
container.append(layout);
|
||||
|
||||
// Lade verfügbare Module
|
||||
try {
|
||||
const module = await api("/logs/modules");
|
||||
if (Array.isArray(module)) {
|
||||
for (const m of module) {
|
||||
modulSelect.append(erstelleElement("option", { value: m, text: m }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Nicht kritisch, Filter bleibt auf "Alle Module"
|
||||
}
|
||||
|
||||
starteLogStream();
|
||||
}
|
||||
|
||||
function starteLogStream() {
|
||||
trenneLogStream();
|
||||
if (istPausiert) return;
|
||||
|
||||
const token = holeToken();
|
||||
if (!token) return;
|
||||
|
||||
let url = `/events/logs?token=${encodeURIComponent(token)}`;
|
||||
if (modulFilter) url += `&module=${encodeURIComponent(modulFilter)}`;
|
||||
if (levelFilter) url += `&level=${encodeURIComponent(levelFilter)}`;
|
||||
|
||||
logStream = new EventSource(url);
|
||||
|
||||
logStream.onopen = () => {
|
||||
aktualisiereStatus(true);
|
||||
};
|
||||
|
||||
logStream.onerror = () => {
|
||||
aktualisiereStatus(false);
|
||||
};
|
||||
|
||||
logStream.addEventListener("log", (e) => {
|
||||
if (istPausiert) return;
|
||||
try {
|
||||
const zeile = JSON.parse(e.data);
|
||||
fuegeLogZeileEin(zeile);
|
||||
} catch {
|
||||
fuegeLogZeileEin({ text: e.data });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fuegeLogZeileEin(eintrag) {
|
||||
if (!logContainer) return;
|
||||
|
||||
const zeile = erstelleElement("div", { klasse: "log-zeile" });
|
||||
|
||||
if (eintrag.level) {
|
||||
const lvl = String(eintrag.level).toLowerCase();
|
||||
const badgeArt =
|
||||
lvl.includes("err") || lvl.includes("crit")
|
||||
? "gefahr"
|
||||
: lvl.includes("warn")
|
||||
? "warnung"
|
||||
: lvl.includes("info")
|
||||
? "erfolg"
|
||||
: "neutral";
|
||||
|
||||
zeile.append(erstelleElement("span", { klasse: `log-tag tag-${badgeArt}`, text: eintrag.level }));
|
||||
}
|
||||
|
||||
if (eintrag.module) {
|
||||
zeile.append(erstelleElement("span", { klasse: "log-modul", text: `[${eintrag.module}] ` }));
|
||||
}
|
||||
|
||||
// Wichtig: textContent, kein innerHTML!
|
||||
const textSpan = erstelleElement("span", { klasse: "log-text", text: eintrag.text || "" });
|
||||
zeile.append(textSpan);
|
||||
|
||||
logContainer.append(zeile);
|
||||
|
||||
// Begrenze auf 300 Zeilen im DOM
|
||||
while (logContainer.children.length > 300) {
|
||||
logContainer.firstElementChild?.remove();
|
||||
}
|
||||
|
||||
if (autoScroll) {
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
istPausiert = !istPausiert;
|
||||
if (istPausiert) {
|
||||
trenneLogStream();
|
||||
pauseButton.replaceChildren(erstelleIcon(PFADE.abspielen, 14), erstelleElement("span", { text: " Fortsetzen" }));
|
||||
aktualisiereStatus(false, "Pausiert");
|
||||
} else {
|
||||
pauseButton.replaceChildren(erstelleIcon(PFADE.abbrechen, 14), erstelleElement("span", { text: " Anhalten" }));
|
||||
starteLogStream();
|
||||
}
|
||||
}
|
||||
|
||||
function aktualisiereStatus(offen, text = null) {
|
||||
if (!streamStatusPlakette) return;
|
||||
const t = text || (offen ? "Live" : "Getrennt");
|
||||
const art = offen ? "erfolg" : "neutral";
|
||||
const neuePlakette = erstellePlakette(t, art);
|
||||
streamStatusPlakette.replaceWith(neuePlakette);
|
||||
streamStatusPlakette = neuePlakette;
|
||||
}
|
||||
|
||||
export function trenneLogStream() {
|
||||
if (logStream) {
|
||||
logStream.close();
|
||||
logStream = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// ClawdDotNet — Platzhalter-Ansichten für noch nicht freigegebene Kern-Endpunkte
|
||||
//
|
||||
// Leitfaden Abschnitt 7: Sicherung, Einstellungen und Agenten-Chats (Analyse).
|
||||
|
||||
import { erstelleElement, erstelleKarte, erstelleIcon, PFADE } from "../modules/dom.js";
|
||||
|
||||
export function rendereSicherung() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "platzhalter-ansicht" });
|
||||
layout.append(erstelleElement("h1", { text: "Datensicherung & Wiederherstellung" }));
|
||||
|
||||
const karte = erstelleKarte("In Vorbereitung", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: "Die API-Endpunkte für die automatisierte Sicherung und Rücksicherung über das WebUI befinden sich in Arbeit."
|
||||
}),
|
||||
erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: "Bitte verwende für die Sicherung von Instanzen aktuell die Desktop-Oberfläche von ClawdDotNet."
|
||||
})
|
||||
]);
|
||||
|
||||
layout.append(karte);
|
||||
container.append(layout);
|
||||
}
|
||||
|
||||
export function rendereEinstellungen() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "platzhalter-ansicht" });
|
||||
layout.append(erstelleElement("h1", { text: "Instanz-Einstellungen" }));
|
||||
|
||||
const karte = erstelleKarte("In Vorbereitung", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: "Die Konfiguration der Instanz (Port, Token, Logging, Budget) erfolgt über die Datei 'InstanceSettings.json' bzw. über den Einstellungsdialog im Desktop-Fenster."
|
||||
}),
|
||||
erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: "Ein dedizierter Web-Endpunkt zum Schreiben sensibler Zugangsdaten ist für einen späteren Meilenstein vorgesehen."
|
||||
})
|
||||
]);
|
||||
|
||||
layout.append(karte);
|
||||
container.append(layout);
|
||||
}
|
||||
|
||||
export function rendereAgentenChats() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "platzhalter-ansicht" });
|
||||
layout.append(erstelleElement("h1", { text: "Agenten-Kommunikation" }));
|
||||
|
||||
const karte = erstelleKarte("Analyse: Interne Agenten-Chats", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: "Die Protokollierung und Auswertung der direkten Kommunikation zwischen zwei Agenten (A2A) wird mit Vorhaben #208 bereitgestellt."
|
||||
}),
|
||||
erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: "Direkte Chats mit Agenten können im Bereich 'Chat' geführt werden."
|
||||
})
|
||||
]);
|
||||
|
||||
layout.append(karte);
|
||||
container.append(layout);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// ClawdDotNet — Hintergrunddienste
|
||||
//
|
||||
// Übersicht und Verwaltung der Instanz-Dienste (Port, Protokoll, Status, Ein-/Ausschalten).
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import { abonnieren } from "../modules/events.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleIcon,
|
||||
PFADE,
|
||||
erstelleKarte,
|
||||
erstelleModal,
|
||||
zeigeMeldung
|
||||
} from "../modules/dom.js";
|
||||
|
||||
let services = [];
|
||||
let entladeEreignisse = [];
|
||||
|
||||
export async function rendereDienste() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
entladeEreignisse.forEach((c) => c());
|
||||
entladeEreignisse = [];
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "dienste-ansicht" });
|
||||
|
||||
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
|
||||
kopfZeile.append(
|
||||
erstelleElement("h1", { text: "Dienste" }),
|
||||
erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche primaer",
|
||||
onclick: oeffneNeuerDienstDialog
|
||||
},
|
||||
erstelleIcon(PFADE.plus, 16),
|
||||
erstelleElement("span", { text: " Neuer Dienst" })
|
||||
)
|
||||
);
|
||||
|
||||
const tabelleBereich = erstelleElement("div", { id: "dienste-liste-bereich" });
|
||||
layout.append(kopfZeile, tabelleBereich);
|
||||
container.append(layout);
|
||||
|
||||
await ladeDienste();
|
||||
|
||||
entladeEreignisse.push(
|
||||
abonnieren("instance", (daten) => {
|
||||
if (daten.kind === "Services") {
|
||||
ladeDienste();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function ladeDienste() {
|
||||
const container = document.getElementById("dienste-liste-bereich");
|
||||
if (!container) return;
|
||||
|
||||
container.replaceChildren(
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Dienste…" })
|
||||
);
|
||||
|
||||
try {
|
||||
services = await api("/services");
|
||||
container.replaceChildren();
|
||||
|
||||
if (!services || services.length === 0) {
|
||||
container.append(
|
||||
erstelleKarte("Keine Dienste", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: "Für diese Instanz sind noch keine Hintergrunddienste eingerichtet."
|
||||
})
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
|
||||
const thead = erstelleElement("thead");
|
||||
thead.append(
|
||||
erstelleElement("tr", {},
|
||||
erstelleElement("th", { text: "Aktiv" }),
|
||||
erstelleElement("th", { text: "Dienstname" }),
|
||||
erstelleElement("th", { text: "Typ" }),
|
||||
erstelleElement("th", { text: "Port" }),
|
||||
erstelleElement("th", { text: "Beschreibung" }),
|
||||
erstelleElement("th", { text: "Aktionen", stil: "text-align: right;" })
|
||||
)
|
||||
);
|
||||
|
||||
const tbody = erstelleElement("tbody");
|
||||
|
||||
for (const s of services) {
|
||||
const tr = erstelleElement("tr");
|
||||
|
||||
// Schalter
|
||||
const tdStatus = erstelleElement("td");
|
||||
const schalterLabel = erstelleElement("label", { klasse: "schalter" });
|
||||
const schalterInput = erstelleElement("input", {
|
||||
type: "checkbox",
|
||||
checked: Boolean(s.enabled),
|
||||
onchange: async (e) => {
|
||||
const aktiv = e.target.checked;
|
||||
try {
|
||||
await api(`/services/${encodeURIComponent(s.id)}/enabled?enabled=${aktiv}`, {
|
||||
method: "PUT"
|
||||
});
|
||||
s.enabled = aktiv;
|
||||
zeigeMeldung(`Dienst '${s.name}' ${aktiv ? "aktiviert" : "deaktiviert"}.`, "info");
|
||||
} catch (err) {
|
||||
e.target.checked = !aktiv;
|
||||
zeigeMeldung(`Fehler: ${err.message}`, "gefahr");
|
||||
}
|
||||
}
|
||||
});
|
||||
schalterLabel.append(schalterInput, erstelleElement("span", { klasse: "schalter-regler" }));
|
||||
tdStatus.append(schalterLabel);
|
||||
|
||||
const tdName = erstelleElement("td", {}, erstelleElement("strong", { text: s.name }));
|
||||
const tdTyp = erstelleElement("td", { text: s.type || "—" });
|
||||
const tdPort = erstelleElement("td", {}, s.port ? erstelleElement("code", { text: String(s.port) }) : erstelleElement("span", { text: "—" }));
|
||||
const tdDesc = erstelleElement("td", { klasse: "gedaempft", text: s.description || "—" });
|
||||
|
||||
const tdAktionen = erstelleElement("td", { stil: "text-align: right;" });
|
||||
const loeschenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche gefahr flach klein",
|
||||
title: "Dienst löschen",
|
||||
onclick: () => loescheDienst(s)
|
||||
},
|
||||
erstelleIcon(PFADE.muelleimer, 14)
|
||||
);
|
||||
tdAktionen.append(loeschenBtn);
|
||||
|
||||
tr.append(tdStatus, tdName, tdTyp, tdPort, tdDesc, tdAktionen);
|
||||
tbody.append(tr);
|
||||
}
|
||||
|
||||
tabelle.append(thead, tbody);
|
||||
container.append(tabelle);
|
||||
} catch (err) {
|
||||
container.replaceChildren(
|
||||
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loescheDienst(s) {
|
||||
if (!confirm(`Soll der Dienst '${s.name}' (${s.id}) gelöscht werden?`)) return;
|
||||
|
||||
try {
|
||||
await api(`/services/${encodeURIComponent(s.id)}`, { method: "DELETE" });
|
||||
zeigeMeldung(`Dienst '${s.name}' wurde gelöscht.`, "info");
|
||||
await ladeDienste();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
}
|
||||
}
|
||||
|
||||
function oeffneNeuerDienstDialog() {
|
||||
const formular = erstelleElement("form", { klasse: "formular-raster" });
|
||||
|
||||
const nameFeld = erstelleElement("input", {
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "z.B. Webhook-Empfänger"
|
||||
});
|
||||
|
||||
const typFeld = erstelleElement("input", {
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "z.B. HTTP, TCP, Socket"
|
||||
});
|
||||
|
||||
const portFeld = erstelleElement("input", {
|
||||
type: "number",
|
||||
min: "1",
|
||||
max: "65535",
|
||||
placeholder: "z.B. 8081 (optional)"
|
||||
});
|
||||
|
||||
const descFeld = erstelleElement("textarea", {
|
||||
rows: "2",
|
||||
placeholder: "Beschreibung des Dienstes…"
|
||||
});
|
||||
|
||||
formular.append(
|
||||
erstelleElement("label", { text: "Dienstname:" }),
|
||||
nameFeld,
|
||||
erstelleElement("label", { text: "Diensttyp:" }),
|
||||
typFeld,
|
||||
erstelleElement("label", { text: "Port (optional):" }),
|
||||
portFeld,
|
||||
erstelleElement("label", { text: "Beschreibung:" }),
|
||||
descFeld
|
||||
);
|
||||
|
||||
const abbrechenBtn = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche flach",
|
||||
onclick: () => modal.schliessen(),
|
||||
text: "Abbrechen"
|
||||
});
|
||||
|
||||
const anlegenBtn = erstelleElement("button", {
|
||||
type: "submit",
|
||||
klasse: "schaltflaeche primaer",
|
||||
text: "Dienst anlegen"
|
||||
});
|
||||
|
||||
const modal = erstelleModal("Neuen Hintergrunddienst anlegen", formular, [abbrechenBtn, anlegenBtn]);
|
||||
|
||||
formular.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
anlegenBtn.disabled = true;
|
||||
|
||||
try {
|
||||
await api("/services", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: nameFeld.value.trim(),
|
||||
type: typFeld.value.trim(),
|
||||
port: portFeld.value ? parseInt(portFeld.value, 10) : 0,
|
||||
description: descFeld.value.trim()
|
||||
}
|
||||
});
|
||||
modal.schliessen();
|
||||
zeigeMeldung(`Dienst '${nameFeld.value.trim()}' angelegt.`, "erfolg");
|
||||
await ladeDienste();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Fehler beim Anlegen: ${err.message}`, "gefahr");
|
||||
} finally {
|
||||
anlegenBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
modal.oeffnen();
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// ClawdDotNet — Freigabestelle (Staging)
|
||||
//
|
||||
// Überprüfung und Genehmigung / Ablehnung von sensiblen Werkzeug-Aufrufen.
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleIcon,
|
||||
PFADE,
|
||||
erstelleKarte,
|
||||
erstelleModal,
|
||||
formatiereDatumZeit,
|
||||
zeigeMeldung
|
||||
} from "../modules/dom.js";
|
||||
|
||||
let wartendeAufrufe = [];
|
||||
|
||||
export async function holeWartendeAnzahl() {
|
||||
try {
|
||||
const liste = await api("/staging");
|
||||
return Array.isArray(liste) ? liste.length : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function rendereStaging() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "staging-ansicht" });
|
||||
|
||||
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
|
||||
kopfZeile.append(
|
||||
erstelleElement("h1", { text: "Freigabestelle" }),
|
||||
erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach",
|
||||
onclick: ladeFreigaben
|
||||
},
|
||||
erstelleIcon(PFADE.aktualisieren, 16),
|
||||
erstelleElement("span", { text: " Aktualisieren" })
|
||||
)
|
||||
);
|
||||
|
||||
const listeBereich = erstelleElement("div", { id: "staging-liste-bereich" });
|
||||
layout.append(kopfZeile, listeBereich);
|
||||
container.append(layout);
|
||||
|
||||
await ladeFreigaben();
|
||||
}
|
||||
|
||||
async function ladeFreigaben() {
|
||||
const container = document.getElementById("staging-liste-bereich");
|
||||
if (!container) return;
|
||||
|
||||
container.replaceChildren(
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Freigaben…" })
|
||||
);
|
||||
|
||||
try {
|
||||
wartendeAufrufe = await api("/staging");
|
||||
container.replaceChildren();
|
||||
|
||||
// Aktualisiere Badge in Sidebar
|
||||
aktualisiereStagingBadge(wartendeAufrufe.length);
|
||||
|
||||
if (!wartendeAufrufe || wartendeAufrufe.length === 0) {
|
||||
container.append(
|
||||
erstelleKarte("Keine offenen Freigaben", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: "Aktuell warten keine Werkzeug-Aufrufe auf eine manuelle Bestätigung."
|
||||
})
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const anfrage of wartendeAufrufe) {
|
||||
const kopfAktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
|
||||
const freigebenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche primaer klein",
|
||||
onclick: () => bestaetigeFreigabe(anfrage.id)
|
||||
},
|
||||
erstelleIcon(PFADE.haken, 14),
|
||||
erstelleElement("span", { text: " Freigeben" })
|
||||
);
|
||||
|
||||
const ablehnenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche gefahr flach klein",
|
||||
onclick: () => oeffneAblehnenDialog(anfrage.id)
|
||||
},
|
||||
erstelleIcon(PFADE.abbrechen, 14),
|
||||
erstelleElement("span", { text: " Ablehnen" })
|
||||
);
|
||||
|
||||
kopfAktionen.append(freigebenBtn, ablehnenBtn);
|
||||
|
||||
const datenliste = erstelleElement("dl", { klasse: "datenliste" });
|
||||
datenliste.append(
|
||||
erstelleElement("dt", { text: "Aufruf-ID" }),
|
||||
erstelleElement("dd", { text: String(anfrage.id) }),
|
||||
erstelleElement("dt", { text: "Agent" }),
|
||||
erstelleElement("dd", { text: anfrage.agentId || "—" }),
|
||||
erstelleElement("dt", { text: "Werkzeug" }),
|
||||
erstelleElement("dd", { text: `${anfrage.toolName} → ${anfrage.methodName}` }),
|
||||
erstelleElement("dt", { text: "Zeitpunkt" }),
|
||||
erstelleElement("dd", { text: formatiereDatumZeit(anfrage.requestedAt) })
|
||||
);
|
||||
|
||||
const argBox = erstelleElement("div", { klasse: "staging-argumente" });
|
||||
argBox.append(erstelleElement("strong", { text: "Eingefrorene Argumente:" }));
|
||||
|
||||
// Strikt textContent! Argumente können Fremdtext sein
|
||||
const pre = erstelleElement("pre", {
|
||||
klasse: "code-block",
|
||||
text: JSON.stringify(anfrage.arguments, null, 2)
|
||||
});
|
||||
argBox.append(pre);
|
||||
|
||||
const karte = erstelleKarte(`${anfrage.toolName}.${anfrage.methodName}`, [datenliste, argBox], {
|
||||
aktionen: kopfAktionen
|
||||
});
|
||||
|
||||
container.append(karte);
|
||||
}
|
||||
} catch (err) {
|
||||
container.replaceChildren(
|
||||
erstelleKarte("Freigabestelle nicht aktiv", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: err.message || "In dieser Instanz ist keine Freigabestelle eingerichtet."
|
||||
})
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function aktualisiereStagingBadge(anzahl) {
|
||||
const badge = document.getElementById("staging-badge");
|
||||
if (!badge) return;
|
||||
if (anzahl > 0) {
|
||||
badge.textContent = String(anzahl);
|
||||
badge.hidden = false;
|
||||
} else {
|
||||
badge.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function bestaetigeFreigabe(id) {
|
||||
try {
|
||||
const res = await api(`/staging/${id}/approve`, { method: "POST" });
|
||||
zeigeMeldung(`Aufruf freigegeben: ${res?.message ?? "Erfolgreich ausgeführt"}`, "erfolg");
|
||||
await ladeFreigaben();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Freigabe fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
}
|
||||
}
|
||||
|
||||
function oeffneAblehnenDialog(id) {
|
||||
const formular = erstelleElement("form", { klasse: "formular-raster" });
|
||||
const grundInput = erstelleElement("input", {
|
||||
type: "text",
|
||||
placeholder: "Begründung für die Ablehnung an den Agenten…"
|
||||
});
|
||||
|
||||
formular.append(
|
||||
erstelleElement("label", { text: "Begründung (optional):" }),
|
||||
grundInput
|
||||
);
|
||||
|
||||
const abbrechenBtn = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche flach",
|
||||
onclick: () => modal.schliessen(),
|
||||
text: "Abbrechen"
|
||||
});
|
||||
|
||||
const ablehnenBtn = erstelleElement("button", {
|
||||
type: "submit",
|
||||
klasse: "schaltflaeche gefahr",
|
||||
text: "Aufruf ablehnen"
|
||||
});
|
||||
|
||||
const modal = erstelleModal("Werkzeug-Aufruf ablehnen", formular, [abbrechenBtn, ablehnenBtn]);
|
||||
|
||||
formular.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
ablehnenBtn.disabled = true;
|
||||
|
||||
try {
|
||||
await api(`/staging/${id}/reject`, {
|
||||
method: "POST",
|
||||
body: { reason: grundInput.value.trim() }
|
||||
});
|
||||
modal.schliessen();
|
||||
zeigeMeldung("Aufruf wurde abgelehnt.", "info");
|
||||
await ladeFreigaben();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Ablehnen fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
} finally {
|
||||
ablehnenBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
modal.oeffnen();
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
// ClawdDotNet — Aufgaben & Geplante Läufe
|
||||
//
|
||||
// Zwei Bereiche: Taskboard (Aufgaben) und Geplante Läufe (Jobs mit Cron & Sofortausführung).
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import { abonnieren } from "../modules/events.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleIcon,
|
||||
PFADE,
|
||||
erstellePlakette,
|
||||
erstelleKarte,
|
||||
erstelleModal,
|
||||
formatiereDatumZeit,
|
||||
zeigeMeldung
|
||||
} from "../modules/dom.js";
|
||||
|
||||
let aktiverReiter = "jobs"; // "jobs" oder "taskboard"
|
||||
let jobsListe = [];
|
||||
let entladeEreignisse = [];
|
||||
|
||||
export async function rendereAufgaben() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
entladeEreignisse.forEach((c) => c());
|
||||
entladeEreignisse = [];
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "aufgaben-ansicht" });
|
||||
|
||||
// Kopf mit Reiter-Umschaltung und Aktionen
|
||||
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
|
||||
const titel = erstelleElement("h1", { text: "Aufgaben & Jobs" });
|
||||
|
||||
const reiterLeiste = erstelleElement("div", { klasse: "reiter-leiste" });
|
||||
const reiterJobs = erstelleElement("button", {
|
||||
klasse: `reiter-schaltflaeche ${aktiverReiter === "jobs" ? "aktiv" : ""}`,
|
||||
text: "Geplante Läufe (Jobs)",
|
||||
onclick: () => wechsleReiter("jobs")
|
||||
});
|
||||
const reiterTaskboard = erstelleElement("button", {
|
||||
klasse: `reiter-schaltflaeche ${aktiverReiter === "taskboard" ? "aktiv" : ""}`,
|
||||
text: "Taskboard",
|
||||
onclick: () => wechsleReiter("taskboard")
|
||||
});
|
||||
reiterLeiste.append(reiterJobs, reiterTaskboard);
|
||||
|
||||
const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
const neuerJobBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche primaer",
|
||||
onclick: oeffneNeuerJobDialog
|
||||
},
|
||||
erstelleIcon(PFADE.plus, 16),
|
||||
erstelleElement("span", { text: " Neuer Job" })
|
||||
);
|
||||
aktionen.append(neuerJobBtn);
|
||||
|
||||
kopfZeile.append(titel, reiterLeiste, aktionen);
|
||||
|
||||
const inhaltBereich = erstelleElement("div", { id: "aufgaben-bereich-inhalt" });
|
||||
layout.append(kopfZeile, inhaltBereich);
|
||||
container.append(layout);
|
||||
|
||||
await ladeAktivenBereich();
|
||||
|
||||
entladeEreignisse.push(
|
||||
abonnieren("instance", (daten) => {
|
||||
if (daten.kind === "Jobs" && aktiverReiter === "jobs") {
|
||||
ladeAktivenBereich();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function wechsleReiter(reiter) {
|
||||
aktiverReiter = reiter;
|
||||
document.querySelectorAll(".reiter-schaltflaeche").forEach((r) => r.classList.remove("aktiv"));
|
||||
if (reiter === "jobs") {
|
||||
document.querySelector(".reiter-schaltflaeche:first-child")?.classList.add("aktiv");
|
||||
} else {
|
||||
document.querySelector(".reiter-schaltflaeche:last-child")?.classList.add("aktiv");
|
||||
}
|
||||
ladeAktivenBereich();
|
||||
}
|
||||
|
||||
async function ladeAktivenBereich() {
|
||||
const container = document.getElementById("aufgaben-bereich-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
if (aktiverReiter === "jobs") {
|
||||
await rendereJobs(container);
|
||||
} else {
|
||||
await rendereTaskboard(container);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Jobs / Geplante Läufe ───
|
||||
|
||||
async function rendereJobs(container) {
|
||||
container.replaceChildren(
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade geplante Läufe…" })
|
||||
);
|
||||
|
||||
try {
|
||||
jobsListe = await api("/jobs");
|
||||
container.replaceChildren();
|
||||
|
||||
if (!jobsListe || jobsListe.length === 0) {
|
||||
container.append(
|
||||
erstelleKarte("Keine geplanten Läufe", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: "Es sind noch keine regelmäßigen Agenten-Läufe oder Tool-Jobs eingerichtet."
|
||||
})
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
|
||||
const thead = erstelleElement("thead");
|
||||
const trKopf = erstelleElement("tr");
|
||||
trKopf.append(
|
||||
erstelleElement("th", { text: "Status" }),
|
||||
erstelleElement("th", { text: "Kennung / Art" }),
|
||||
erstelleElement("th", { text: "Agent" }),
|
||||
erstelleElement("th", { text: "Cron-Muster" }),
|
||||
erstelleElement("th", { text: "Nächster Lauf (UTC)" }),
|
||||
erstelleElement("th", { text: "Letzter Lauf (Lokal)" }),
|
||||
erstelleElement("th", { text: "Aktionen", stil: "text-align: right;" })
|
||||
);
|
||||
thead.append(trKopf);
|
||||
|
||||
const tbody = erstelleElement("tbody");
|
||||
|
||||
for (const job of jobsListe) {
|
||||
const tr = erstelleElement("tr");
|
||||
|
||||
// Schalter Ein/Aus
|
||||
const tdStatus = erstelleElement("td");
|
||||
const schalterLabel = erstelleElement("label", { klasse: "schalter" });
|
||||
const schalterInput = erstelleElement("input", {
|
||||
type: "checkbox",
|
||||
checked: Boolean(job.enabled),
|
||||
onchange: async (e) => {
|
||||
const neuAktiv = e.target.checked;
|
||||
try {
|
||||
await api(
|
||||
`/jobs/${encodeURIComponent(job.jobId)}/enabled?agentId=${encodeURIComponent(job.agentId)}&enabled=${neuAktiv}`,
|
||||
{ method: "PUT" }
|
||||
);
|
||||
job.enabled = neuAktiv;
|
||||
zeigeMeldung(`Job '${job.jobId}' ${neuAktiv ? "aktiviert" : "pausiert"}.`, "info");
|
||||
} catch (err) {
|
||||
e.target.checked = !neuAktiv;
|
||||
zeigeMeldung(`Fehler: ${err.message}`, "gefahr");
|
||||
}
|
||||
}
|
||||
});
|
||||
schalterLabel.append(schalterInput, erstelleElement("span", { klasse: "schalter-regler" }));
|
||||
tdStatus.append(schalterLabel);
|
||||
|
||||
// Name / Art
|
||||
const tdName = erstelleElement("td");
|
||||
tdName.append(
|
||||
erstelleElement("strong", { text: job.jobId }),
|
||||
erstelleElement("div", {
|
||||
klasse: "gedaempft text-klein",
|
||||
text: job.kind === "toolJob" ? `Werkzeug (${job.toolName || ""})` : "Agent-Aufruf"
|
||||
})
|
||||
);
|
||||
|
||||
// Agent
|
||||
const tdAgent = erstelleElement("td", { text: job.agentId });
|
||||
|
||||
// Cron
|
||||
const tdCron = erstelleElement("td");
|
||||
tdCron.append(erstelleElement("code", { text: job.cron }));
|
||||
|
||||
// Zeiten
|
||||
const tdNext = erstelleElement("td", {
|
||||
text: job.nextRunUtc ? formatiereDatumZeit(job.nextRunUtc) : "—"
|
||||
});
|
||||
const tdLast = erstelleElement("td", {
|
||||
text: job.lastRunLocal ? formatiereDatumZeit(job.lastRunLocal) : "—"
|
||||
});
|
||||
|
||||
// Aktionen (Sofortlauf, Löschen)
|
||||
const tdAktionen = erstelleElement("td", { stil: "text-align: right;" });
|
||||
const aktionenGruppe = erstelleElement("div", { klasse: "gruppe-horizontal", stil: "justify-content: flex-end;" });
|
||||
|
||||
const ausfuehrenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach klein",
|
||||
title: "Job jetzt sofort manuell ausführen",
|
||||
onclick: () => fuehreJobAus(job.jobId)
|
||||
},
|
||||
erstelleIcon(PFADE.abspielen, 14),
|
||||
erstelleElement("span", { text: " Start" })
|
||||
);
|
||||
|
||||
const loeschenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche gefahr flach klein",
|
||||
title: "Job löschen",
|
||||
onclick: () => loescheJob(job)
|
||||
},
|
||||
erstelleIcon(PFADE.muelleimer, 14)
|
||||
);
|
||||
|
||||
aktionenGruppe.append(ausfuehrenBtn, loeschenBtn);
|
||||
tdAktionen.append(aktionenGruppe);
|
||||
|
||||
tr.append(tdStatus, tdName, tdAgent, tdCron, tdNext, tdLast, tdAktionen);
|
||||
tbody.append(tr);
|
||||
}
|
||||
|
||||
tabelle.append(thead, tbody);
|
||||
container.append(tabelle);
|
||||
} catch (err) {
|
||||
container.replaceChildren(
|
||||
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function fuehreJobAus(jobId) {
|
||||
try {
|
||||
const res = await api(`/jobs/${encodeURIComponent(jobId)}/run`, { method: "POST" });
|
||||
zeigeMeldung(`Job '${jobId}' angestoßen: ${res?.message ?? "Erfolgreich"}`, "erfolg");
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Ausführung fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
}
|
||||
}
|
||||
|
||||
async function loescheJob(job) {
|
||||
if (!confirm(`Soll der geplante Job '${job.jobId}' gelöscht werden?`)) return;
|
||||
|
||||
try {
|
||||
await api(
|
||||
`/jobs/${encodeURIComponent(job.jobId)}?agentId=${encodeURIComponent(job.agentId)}&kind=${encodeURIComponent(job.kind)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
zeigeMeldung(`Job '${job.jobId}' wurde gelöscht.`, "info");
|
||||
await ladeAktivenBereich();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
}
|
||||
}
|
||||
|
||||
async function oeffneNeuerJobDialog() {
|
||||
let agenten = [];
|
||||
try {
|
||||
agenten = await api("/agents");
|
||||
} catch {}
|
||||
|
||||
const formular = erstelleElement("form", { klasse: "formular-raster" });
|
||||
|
||||
const agentSelect = erstelleElement("select", { required: true });
|
||||
for (const a of agenten) {
|
||||
agentSelect.append(erstelleElement("option", { value: a.agentId, text: `${a.displayName} (${a.agentId})` }));
|
||||
}
|
||||
|
||||
const kindSelect = erstelleElement("select");
|
||||
kindSelect.append(
|
||||
erstelleElement("option", { value: "agentWakeup", text: "Agent Wakeup (Regelmäßiger Aufruf)" }),
|
||||
erstelleElement("option", { value: "toolJob", text: "Tool Job (Werkzeug-Ausführung)" })
|
||||
);
|
||||
|
||||
const cronInput = erstelleElement("input", {
|
||||
type: "text",
|
||||
required: true,
|
||||
value: "0 * * * *",
|
||||
placeholder: "z.B. 0 * * * * (stündlich)"
|
||||
});
|
||||
|
||||
const taskMsgInput = erstelleElement("textarea", {
|
||||
rows: "2",
|
||||
placeholder: "Optionale Start-Nachricht / Anweisung an den Agenten…"
|
||||
});
|
||||
|
||||
formular.append(
|
||||
erstelleElement("label", { text: "Ziel-Agent:" }),
|
||||
agentSelect,
|
||||
erstelleElement("label", { text: "Art des Jobs:" }),
|
||||
kindSelect,
|
||||
erstelleElement("label", { text: "Cron-Ausdruck:" }),
|
||||
cronInput,
|
||||
erstelleElement("label", { text: "Aufgaben-Nachricht:" }),
|
||||
taskMsgInput
|
||||
);
|
||||
|
||||
const abbrechenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
klasse: "schaltflaeche flach",
|
||||
onclick: () => modal.schliessen(),
|
||||
text: "Abbrechen"
|
||||
}
|
||||
);
|
||||
|
||||
const anlegenBtn = erstelleElement("button", {
|
||||
type: "submit",
|
||||
klasse: "schaltflaeche primaer",
|
||||
text: "Job erstellen"
|
||||
});
|
||||
|
||||
const modal = erstelleModal("Neuen geplanten Job anlegen", formular, [abbrechenBtn, anlegenBtn]);
|
||||
|
||||
formular.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
anlegenBtn.disabled = true;
|
||||
|
||||
try {
|
||||
await api("/jobs", {
|
||||
method: "POST",
|
||||
body: {
|
||||
agentId: agentSelect.value,
|
||||
kind: kindSelect.value,
|
||||
cron: cronInput.value.trim(),
|
||||
taskMessage: taskMsgInput.value.trim(),
|
||||
runOnStart: false
|
||||
}
|
||||
});
|
||||
modal.schliessen();
|
||||
zeigeMeldung("Geplanter Job wurde erstellt.", "erfolg");
|
||||
await ladeAktivenBereich();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Fehler beim Erstellen: ${err.message}`, "gefahr");
|
||||
} finally {
|
||||
anlegenBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
// ─── Taskboard ───
|
||||
|
||||
async function rendereTaskboard(container) {
|
||||
container.replaceChildren(
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Taskboard…" })
|
||||
);
|
||||
|
||||
try {
|
||||
const tasks = await api("/tasks?limit=50");
|
||||
container.replaceChildren();
|
||||
|
||||
if (!tasks || tasks.length === 0) {
|
||||
container.append(
|
||||
erstelleKarte("Taskboard leer", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: "Aktuell liegen keine offenen Aufgaben im gemeinsamen Arbeitsbereich."
|
||||
})
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const raster = erstelleElement("div", { klasse: "taskboard-raster" });
|
||||
|
||||
for (const task of tasks) {
|
||||
const karte = erstelleKarte(task.title || `Aufgabe ${task.id}`, [
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: `ID: ${task.id} · Agent: ${task.agentId || "—"}` }),
|
||||
erstelleElement("div", {
|
||||
klasse: "task-status-zeile",
|
||||
stil: "margin-top: 8px;"
|
||||
}, erstellePlakette(task.status || "Offen", task.status === "Done" ? "erfolg" : "neutral")),
|
||||
erstelleElement("div", {
|
||||
klasse: "gedaempft text-klein",
|
||||
stil: "margin-top: 8px;",
|
||||
text: `Erstellt: ${formatiereDatumZeit(task.createdAt)}`
|
||||
})
|
||||
]);
|
||||
raster.append(karte);
|
||||
}
|
||||
|
||||
container.append(raster);
|
||||
} catch (err) {
|
||||
container.replaceChildren(
|
||||
erstelleKarte("Taskboard nicht verfügbar", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: err.message || "In dieser Instanz ist kein Taskboard aktiv (kein gemeinsamer Arbeitsbereich)."
|
||||
})
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// ClawdDotNet — Token-Verbrauch & Analyse
|
||||
//
|
||||
// Diagramme (CSS/DOM-Balken ohne externe Bibliothek), Statistiken und Läufetabelle.
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleKarte,
|
||||
formatiereDatumZeit,
|
||||
formatiereTokens,
|
||||
formatiereKosten
|
||||
} from "../modules/dom.js";
|
||||
|
||||
export async function rendereVerbrauch() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "verbrauch-ansicht" });
|
||||
layout.append(erstelleElement("h1", { text: "Token-Verbrauch & Analyse" }));
|
||||
|
||||
const inhaltBereich = erstelleElement("div", { id: "verbrauch-inhalt-bereich" });
|
||||
layout.append(inhaltBereich);
|
||||
container.append(layout);
|
||||
|
||||
await ladeVerbrauch();
|
||||
}
|
||||
|
||||
async function ladeVerbrauch() {
|
||||
const container = document.getElementById("verbrauch-inhalt-bereich");
|
||||
if (!container) return;
|
||||
|
||||
container.replaceChildren(
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Verbrauchsdaten…" })
|
||||
);
|
||||
|
||||
try {
|
||||
const laeufe = await api("/usage?limit=100");
|
||||
container.replaceChildren();
|
||||
|
||||
if (!laeufe || laeufe.length === 0) {
|
||||
container.append(
|
||||
erstelleKarte("Keine Daten", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: "Noch keine Token-Verbrauchsdaten aufgezeichnet."
|
||||
})
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Statistiken berechnen
|
||||
let summeTokens = 0;
|
||||
let summePrompt = 0;
|
||||
let summeCompletion = 0;
|
||||
let summeKosten = 0;
|
||||
const nachAgent = new Map();
|
||||
const nachModell = new Map();
|
||||
|
||||
for (const l of laeufe) {
|
||||
summeTokens += l.totalTokens || 0;
|
||||
summePrompt += l.promptTokens || 0;
|
||||
summeCompletion += l.completionTokens || 0;
|
||||
summeKosten += Number(l.cost || 0);
|
||||
|
||||
const agentKey = l.agentId || "Unbekannt";
|
||||
nachAgent.set(agentKey, (nachAgent.get(agentKey) || 0) + (l.totalTokens || 0));
|
||||
|
||||
const modellKey = l.model || "Unbekannt";
|
||||
nachModell.set(modellKey, (nachModell.get(modellKey) || 0) + (l.totalTokens || 0));
|
||||
}
|
||||
|
||||
// 1. Kacheln (Statistiken)
|
||||
const kachelGitter = erstelleElement("div", { klasse: "kachel-gitter" });
|
||||
|
||||
kachelGitter.append(
|
||||
erstelleStatKachel("Gesamtkosten (100 Läufe)", formatiereKosten(summeKosten)),
|
||||
erstelleStatKachel("Tokens gesamt", formatiereTokens(summeTokens)),
|
||||
erstelleStatKachel("Eingabe-Tokens (Prompt)", formatiereTokens(summePrompt)),
|
||||
erstelleStatKachel("Ausgabe-Tokens (Completion)", formatiereTokens(summeCompletion))
|
||||
);
|
||||
|
||||
// 2. Balkendiagramme (rein CSS/DOM)
|
||||
const diagrammGitter = erstelleElement("div", { klasse: "diagramm-gitter" });
|
||||
|
||||
const agentBalken = erstelleBalkenDiagramm("Tokens nach Agent", nachAgent, summeTokens);
|
||||
const modellBalken = erstelleBalkenDiagramm("Tokens nach Modell", nachModell, summeTokens);
|
||||
diagrammGitter.append(agentBalken, modellBalken);
|
||||
|
||||
// 3. Läufe-Tabelle
|
||||
const tabelleKarte = erstelleElement("div", { klasse: "karte" });
|
||||
tabelleKarte.append(erstelleElement("h2", { text: "Letzte Agenten-Läufe" }));
|
||||
|
||||
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
|
||||
const thead = erstelleElement("thead");
|
||||
thead.append(
|
||||
erstelleElement("tr", {},
|
||||
erstelleElement("th", { text: "Zeitpunkt" }),
|
||||
erstelleElement("th", { text: "Agent" }),
|
||||
erstelleElement("th", { text: "Modell" }),
|
||||
erstelleElement("th", { text: "Prompt", stil: "text-align: right;" }),
|
||||
erstelleElement("th", { text: "Completion", stil: "text-align: right;" }),
|
||||
erstelleElement("th", { text: "Gesamt", stil: "text-align: right;" }),
|
||||
erstelleElement("th", { text: "Kosten", stil: "text-align: right;" })
|
||||
)
|
||||
);
|
||||
|
||||
const tbody = erstelleElement("tbody");
|
||||
|
||||
for (const lauf of laeufe) {
|
||||
const tr = erstelleElement("tr");
|
||||
tr.append(
|
||||
erstelleElement("td", { text: formatiereDatumZeit(lauf.timestamp) }),
|
||||
erstelleElement("td", {}, erstelleElement("strong", { text: lauf.agentId })),
|
||||
erstelleElement("td", { klasse: "gedaempft text-klein", text: lauf.model }),
|
||||
erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.promptTokens) }),
|
||||
erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.completionTokens) }),
|
||||
erstelleElement("td", { stil: "text-align: right;", text: formatiereTokens(lauf.totalTokens) }),
|
||||
erstelleElement("td", { stil: "text-align: right;", text: formatiereKosten(lauf.cost) })
|
||||
);
|
||||
tbody.append(tr);
|
||||
}
|
||||
|
||||
tabelle.append(thead, tbody);
|
||||
tabelleKarte.append(tabelle);
|
||||
|
||||
container.append(kachelGitter, diagrammGitter, tabelleKarte);
|
||||
} catch (err) {
|
||||
container.replaceChildren(
|
||||
erstelleKarte("Verbrauchsdaten nicht verfügbar", [
|
||||
erstelleElement("p", { klasse: "gedaempft", text: err.message })
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function erstelleStatKachel(titel, wert) {
|
||||
const kachel = erstelleElement("div", { klasse: "stat-kachel karte" });
|
||||
kachel.append(
|
||||
erstelleElement("div", { klasse: "stat-titel gedaempft", text: titel }),
|
||||
erstelleElement("div", { klasse: "stat-wert", text: wert })
|
||||
);
|
||||
return kachel;
|
||||
}
|
||||
|
||||
function erstelleBalkenDiagramm(titel, datenMap, gesamt) {
|
||||
const karte = erstelleKarte(titel);
|
||||
const container = erstelleElement("div", { klasse: "balken-liste" });
|
||||
|
||||
const sortiert = [...datenMap.entries()].sort((a, b) => b[1] - a[1]);
|
||||
|
||||
for (const [name, wert] of sortiert) {
|
||||
const prozent = gesamt > 0 ? ((wert / gesamt) * 100).toFixed(1) : "0";
|
||||
|
||||
const zeile = erstelleElement("div", { klasse: "balken-zeile" });
|
||||
const meta = erstelleElement("div", { klasse: "balken-meta" });
|
||||
meta.append(
|
||||
erstelleElement("span", { klasse: "balken-name", text: name }),
|
||||
erstelleElement("span", { klasse: "balken-wert gedaempft", text: `${formatiereTokens(wert)} (${prozent}%)` })
|
||||
);
|
||||
|
||||
const spur = erstelleElement("div", { klasse: "balken-spur" });
|
||||
const fuellung = erstelleElement("div", {
|
||||
klasse: "balken-fuellung",
|
||||
stil: `width: ${Math.max(1, Number(prozent))}%;`
|
||||
});
|
||||
spur.append(fuellung);
|
||||
|
||||
zeile.append(meta, spur);
|
||||
container.append(zeile);
|
||||
}
|
||||
|
||||
karte.append(container);
|
||||
return karte;
|
||||
}
|
||||
Reference in New Issue
Block a user