diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 9e18bd0..d24e59b 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -254,6 +254,7 @@ public sealed class AppHost : IAsyncDisposable private readonly Lazy _serviceAdmin; private readonly Lazy _jobAdmin; private readonly Lazy _runs; + private readonly Lazy _settingsAdmin; public AppHost() { @@ -261,6 +262,7 @@ public sealed class AppHost : IAsyncDisposable _serviceAdmin = new Lazy(() => new ServiceAdministration(this)); _jobAdmin = new Lazy(() => new JobAdministration(this)); _runs = new Lazy(() => new RunCoordinator(this)); + _settingsAdmin = new Lazy(() => new SettingsAdministration(this)); } /// Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen. @@ -278,6 +280,9 @@ public sealed class AppHost : IAsyncDisposable /// „Jetzt ausführen" samt Verlauf. public RunCoordinator Runs => _runs.Value; + /// #764: The settings the WebUI reads and writes. + public SettingsAdministration SettingsAdmin => _settingsAdmin.Value; + /// Was der Aufrufer beim Start erfragen muss. public sealed class Callbacks { diff --git a/src/ClawdDotNet.App/Services/AgentAdministration.cs b/src/ClawdDotNet.App/Services/AgentAdministration.cs index b03cc8e..c185339 100644 --- a/src/ClawdDotNet.App/Services/AgentAdministration.cs +++ b/src/ClawdDotNet.App/Services/AgentAdministration.cs @@ -13,6 +13,26 @@ public sealed record NewAgent( string Description = "", string? UnitId = null); +/// +/// #764: What can be changed on an existing agent. Every field is optional — null keeps +/// the current value, so a form that only knows some of the fields cannot blank the others. +/// +public sealed record AgentUpdate( + string? DisplayName = null, + string? Model = null, + string? Description = null, + string? SystemPrompt = null, + string? Identity = null, + string? Soul = null, + string? PromptCaching = null, + int? MaxToolResultChars = null, + int? MaxSteps = null, + int? MaxContextTokens = null, + int? TimeoutSeconds = null, + decimal? DailyCostUsd = null, + long? DailyTokens = null, + string? WhenExhausted = null); + /// /// Agenten anlegen, entfernen, ihre Werkzeuge zulassen und einstellen. /// @@ -191,6 +211,80 @@ public sealed class AgentAdministration(AppHost host) + $"'{Path.GetFileName(archived)}'."); } + /// The values understands. + public static readonly IReadOnlyList PromptCachingModes = ["auto", "on", "off"]; + + /// The values understands. + public static readonly IReadOnlyList WhenExhaustedModes = ["expire", "catchUpOnce"]; + + /// + /// #764: Changes an agent's master data, limits and prompt texts — what the desktop agent page + /// edited directly on the configuration objects. + /// + /// Everything is checked before anything is written. A request with one bad value + /// changes nothing: half an update that says "failed" would leave the agent in a state nobody + /// asked for. + /// + /// The display name changes the name only, not the folder: the agent keeps living in + /// , which is where writes. + /// + public AdminResult Update(string agentId, AgentUpdate update) + { + if (Find(agentId) is not { } agent) + return AdminResult.Failure($"No agent with id '{agentId}'."); + + if (Validate(update) is { } problem) + return AdminResult.Failure(problem); + + if (update.DisplayName is { } name) agent.DisplayName = name.Trim(); + if (update.Model is { } model) agent.Model = model.Trim(); + if (update.Description is { } description) agent.Description = description; + if (update.SystemPrompt is { } systemPrompt) agent.SystemPrompt = systemPrompt; + if (update.Identity is { } identity) agent.Identity = identity; + if (update.Soul is { } soul) agent.Soul = soul; + if (update.PromptCaching is { } caching) agent.PromptCaching = caching.Trim().ToLowerInvariant(); + if (update.MaxToolResultChars is { } chars) agent.MaxToolResultChars = chars; + if (update.MaxSteps is { } steps) agent.LoopGuard.MaxSteps = steps; + if (update.MaxContextTokens is { } context) agent.LoopGuard.MaxContextTokens = context; + if (update.TimeoutSeconds is { } timeout) agent.LoopGuard.TimeoutSeconds = timeout; + if (update.DailyCostUsd is { } cost) agent.Budget.DailyCostUsd = cost; + if (update.DailyTokens is { } tokens) agent.Budget.DailyTokens = tokens; + if (update.WhenExhausted is { } when) + agent.Budget.WhenExhausted = WhenExhaustedModes.First(m => m.Equals(when.Trim(), StringComparison.OrdinalIgnoreCase)); + + PersistAgent(agent); + host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agent.AgentId)); + + return AdminResult.Success($"Agent '{agent.DisplayName}' saved."); + } + + private static string? Validate(AgentUpdate update) + { + if (update.DisplayName is { } name && name.Trim().Length == 0) + return "The display name must not be empty."; + if (update.Model is { } model && model.Trim().Length == 0) + return "The model must not be empty."; + if (update.PromptCaching is { } caching + && !PromptCachingModes.Contains(caching.Trim().ToLowerInvariant())) + return $"Prompt caching is one of {string.Join(", ", PromptCachingModes)}."; + if (update.WhenExhausted is { } when + && !WhenExhaustedModes.Any(m => m.Equals(when.Trim(), StringComparison.OrdinalIgnoreCase))) + return $"'When exhausted' is one of {string.Join(", ", WhenExhaustedModes)}."; + if (update.MaxToolResultChars is < 100) + return "A tool result needs at least 100 characters."; + if (update.MaxSteps is < 1 or > 500) + return "Steps must be between 1 and 500."; + if (update.MaxContextTokens is < 1000) + return "The context needs at least 1000 tokens."; + if (update.TimeoutSeconds is < 10 or > 86_400) + return "The time limit must be between 10 seconds and one day."; + if (update.DailyCostUsd is < 0) + return "A daily budget cannot be negative."; + if (update.DailyTokens is < 0) + return "A daily token budget cannot be negative."; + return null; + } + /// /// Lässt ein Werkzeug zu oder nimmt es weg. /// diff --git a/src/ClawdDotNet.App/Services/SettingsAdministration.cs b/src/ClawdDotNet.App/Services/SettingsAdministration.cs new file mode 100644 index 0000000..6c2afa0 --- /dev/null +++ b/src/ClawdDotNet.App/Services/SettingsAdministration.cs @@ -0,0 +1,210 @@ +namespace ClawdDotNet.App.Services; + +/// +/// #764: The settings of this instance and of the application, as the WebUI sees them — +/// without a single secret. For the OpenRouter key and the Deploymentcenter token there is +/// only whether one is set. +/// +public sealed record SettingsSnapshot( + // Instance — InstanceSettings.json, owned by this process. + string InstanceName, + string LogDirectory, + string EffectiveLogDirectory, + bool AutostartByManager, + bool OpenRouterApiKeySet, + decimal DailyCostUsd, + long DailyTokens, + bool AutoBackupEnabled, + string AutoBackupTime, + int BackupKeepCount, + // Application — settings.json, shared by every instance on this machine. + string MinimumLogLevel, + string OpenRouterBaseUrl, + int StatusCheckIntervalSeconds, + string DeploymentcenterUrl, + bool DeploymentcenterTokenSet, + string DeploymentcenterEnvironment, + bool ErrorReportingEnabled, + bool UpdateCheckEnabled, + string UpdateChannel); + +/// +/// #764: A change to the settings. null keeps a value. For the two secrets an empty +/// value also keeps the one there is: a form never gets them back, so it cannot send them +/// back either. +/// +public sealed record SettingsUpdate( + string? InstanceName = null, + string? LogDirectory = null, + bool? AutostartByManager = null, + string? OpenRouterApiKey = null, + decimal? DailyCostUsd = null, + long? DailyTokens = null, + bool? AutoBackupEnabled = null, + string? AutoBackupTime = null, + int? BackupKeepCount = null, + string? MinimumLogLevel = null, + string? OpenRouterBaseUrl = null, + int? StatusCheckIntervalSeconds = null, + string? DeploymentcenterUrl = null, + string? DeploymentcenterToken = null, + string? DeploymentcenterEnvironment = null, + bool? ErrorReportingEnabled = null, + bool? UpdateCheckEnabled = null, + string? UpdateChannel = null); + +/// +/// #764: Reads and writes what the desktop settings page edited, so the WebUI can do it. +/// +/// Two files, two owners. InstanceSettings.json belongs to this instance +/// process — the manager only reads it (autostart, backup schedule), which is why these +/// fields are written here and not through the manager. settings.json is the +/// application file every instance on the machine reads; a change there reaches the others +/// at their next start. +/// +/// Several values only take effect at the next start: the logging is set up, +/// the agent engine is built (or not, without a key) and the Deploymentcenter connection is +/// made while the instance starts. Pretending otherwise would be a save button that seems to +/// do nothing — so the answer says which ones. +/// +public sealed class SettingsAdministration(AppHost host) +{ + public static readonly IReadOnlyList LogLevels = ["Debug", "Info", "Warn", "Error"]; + public static readonly IReadOnlyList UpdateChannels = ["prod", "beta", "dev"]; + public static readonly IReadOnlyList Environments = ["production", "development", "staging"]; + + public SettingsSnapshot Snapshot() + { + var instance = host.Instance; + var app = host.Settings.AppSettings; + + return new SettingsSnapshot( + instance.InstanceName, + instance.LogDirectory, + host.LogDirectory, + instance.AutostartByManager, + !string.IsNullOrWhiteSpace(instance.OpenRouterApiKey), + instance.Budget.DailyCostUsd, + instance.Budget.DailyTokens, + instance.AutoBackupEnabled, + instance.AutoBackupTime, + instance.BackupKeepCount, + app.MinimumLogLevel, + app.OpenRouterBaseUrl, + app.StatusCheckIntervalSeconds, + app.DeploymentcenterUrl, + !string.IsNullOrWhiteSpace(app.DeploymentcenterToken), + app.DeploymentcenterEnvironment, + app.ErrorReportingEnabled, + app.UpdateCheckEnabled, + app.UpdateChannel); + } + + /// Checks everything first; a request with one bad value changes nothing. + public AdminResult Update(SettingsUpdate update) + { + if (Validate(update) is { } problem) + return AdminResult.Failure(problem); + + var instance = host.Instance; + var app = host.Settings.AppSettings; + var restart = new List(); + + void Note(bool changed, string what) + { + if (changed) restart.Add(what); + } + + // ─── Instance ─── + if (update.InstanceName is { } name) instance.InstanceName = name.Trim(); + if (update.LogDirectory is { } logDir) + { + Note(!string.Equals(instance.LogDirectory, logDir.Trim(), StringComparison.Ordinal), "log directory"); + instance.LogDirectory = logDir.Trim(); + } + if (update.AutostartByManager is { } autostart) instance.AutostartByManager = autostart; + if (!string.IsNullOrWhiteSpace(update.OpenRouterApiKey)) + { + instance.OpenRouterApiKey = update.OpenRouterApiKey.Trim(); + restart.Add("OpenRouter key"); + } + if (update.DailyCostUsd is { } cost) instance.Budget.DailyCostUsd = cost; + if (update.DailyTokens is { } tokens) instance.Budget.DailyTokens = tokens; + if (update.AutoBackupEnabled is { } backup) instance.AutoBackupEnabled = backup; + if (update.AutoBackupTime is { } time) instance.AutoBackupTime = TimeSpan.Parse(time.Trim()).ToString(@"hh\:mm"); + if (update.BackupKeepCount is { } keep) instance.BackupKeepCount = keep; + + // ─── Application ─── + var appChanged = false; + + void ChangeApp(bool differs, Action set, string? restartNote = null) + { + if (differs && restartNote is not null) restart.Add(restartNote); + set(); + appChanged = true; + } + + if (update.MinimumLogLevel is { } level) + { + var known = LogLevels.First(l => l.Equals(level.Trim(), StringComparison.OrdinalIgnoreCase)); + ChangeApp(app.MinimumLogLevel != known, () => app.MinimumLogLevel = known, "log level"); + } + if (update.OpenRouterBaseUrl?.Trim() is { } baseUrl) + ChangeApp(app.OpenRouterBaseUrl != baseUrl, () => app.OpenRouterBaseUrl = baseUrl, "OpenRouter address"); + if (update.StatusCheckIntervalSeconds is { } interval) + ChangeApp(app.StatusCheckIntervalSeconds != interval, () => app.StatusCheckIntervalSeconds = interval, "status check interval"); + if (update.DeploymentcenterUrl?.Trim() is { } dcUrl) + ChangeApp(app.DeploymentcenterUrl != dcUrl, () => app.DeploymentcenterUrl = dcUrl, "Deploymentcenter"); + if (update.DeploymentcenterEnvironment?.Trim() is { } environment) + ChangeApp(app.DeploymentcenterEnvironment != environment, () => app.DeploymentcenterEnvironment = environment, "Deploymentcenter"); + if (update.ErrorReportingEnabled is { } reporting) + ChangeApp(app.ErrorReportingEnabled != reporting, () => app.ErrorReportingEnabled = reporting, "Deploymentcenter"); + if (update.UpdateCheckEnabled is { } updateCheck) + ChangeApp(false, () => app.UpdateCheckEnabled = updateCheck); + if (update.UpdateChannel?.Trim() is { } channel) + ChangeApp(false, () => app.UpdateChannel = channel); + if (!string.IsNullOrWhiteSpace(update.DeploymentcenterToken)) + ChangeApp(true, () => app.DeploymentcenterToken = update.DeploymentcenterToken.Trim(), "Deploymentcenter"); + + if (appChanged) + host.Settings.Save(); + + host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings)); + + var pending = restart.Distinct().ToList(); + return AdminResult.Success(pending.Count == 0 + ? "Settings saved." + : $"Settings saved. Takes effect after the instance restarts: {string.Join(", ", pending)}."); + } + + private static string? Validate(SettingsUpdate update) + { + if (update.InstanceName is { } name && name.Trim().Length == 0) + return "The instance name must not be empty."; + if (update.DailyCostUsd is < 0 || update.DailyTokens is < 0) + return "A daily budget cannot be negative."; + if (update.AutoBackupTime is { } time + && (!TimeSpan.TryParse(time.Trim(), out var at) || at < TimeSpan.Zero || at >= TimeSpan.FromDays(1))) + return "The backup time is a time of day, e.g. 03:00."; + if (update.BackupKeepCount is < 0 or > 365) + return "Keep between 0 (all) and 365 backups."; + if (update.MinimumLogLevel is { } level + && !LogLevels.Any(l => l.Equals(level, StringComparison.OrdinalIgnoreCase))) + return $"The log level is one of {string.Join(", ", LogLevels)}."; + if (update.OpenRouterBaseUrl is { } baseUrl && !IsHttpUrl(baseUrl)) + return "The OpenRouter address must be an http(s) address."; + if (update.DeploymentcenterUrl is { } dcUrl && dcUrl.Trim().Length > 0 && !IsHttpUrl(dcUrl)) + return "The Deploymentcenter address must be an http(s) address."; + if (update.StatusCheckIntervalSeconds is < 10 or > 86_400) + return "The status check interval must be between 10 seconds and one day."; + if (update.DeploymentcenterEnvironment is { } env && !Environments.Contains(env.Trim())) + return $"The environment is one of {string.Join(", ", Environments)}."; + if (update.UpdateChannel is { } channel && !UpdateChannels.Contains(channel.Trim())) + return $"The update channel is one of {string.Join(", ", UpdateChannels)}."; + return null; + } + + private static bool IsHttpUrl(string value) + => Uri.TryCreate(value.Trim(), UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp); +} diff --git a/src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj b/src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj index 458bebc..6ed7615 100644 --- a/src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj +++ b/src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj @@ -22,4 +22,8 @@ + + + + diff --git a/src/ClawdDotNet.Licensing/ILicensePrompt.cs b/src/ClawdDotNet.Licensing/ILicensePrompt.cs index 4ba3b00..ca7236f 100644 --- a/src/ClawdDotNet.Licensing/ILicensePrompt.cs +++ b/src/ClawdDotNet.Licensing/ILicensePrompt.cs @@ -37,8 +37,13 @@ public interface ILicensePrompt /// Für den kopflosen Betrieb: fragt nicht, sondern lehnt ab. /// /// Ein Dienst ohne Sitzung kann keinen Schlüssel entgegennehmen. Statt zu blockieren -/// meldet er, was zu tun ist — der Schlüssel wird vorab über die Kommandozeile -/// hinterlegt (--license-set-key). +/// meldet er, was zu tun ist. +/// +/// #764: Here stood that the key is set beforehand on the command line +/// (--license-set-key). That switch was planned and never built — the log pointed +/// everyone who needed it at something that does not exist. The way in is the web +/// interface () or licenseKey in +/// manager.json, written by the installer. /// public sealed class NonInteractiveLicensePrompt(Action log) : ILicensePrompt { @@ -46,7 +51,7 @@ public sealed class NonInteractiveLicensePrompt(Action log) : ILicensePr { log($"Lizenz erforderlich, aber kein Eingabeweg vorhanden. Hardware-ID: {hardwareId}. " + (problem is null ? "" : $"Grund: {problem}. ") - + "Schlüssel mit '--license-set-key ' hinterlegen."); + + "Enter the key in the web interface (Settings > License) or as licenseKey in manager.json."); return Task.FromResult(null); } diff --git a/src/ClawdDotNet.Licensing/LicenseGate.cs b/src/ClawdDotNet.Licensing/LicenseGate.cs index 5df615a..db7407b 100644 --- a/src/ClawdDotNet.Licensing/LicenseGate.cs +++ b/src/ClawdDotNet.Licensing/LicenseGate.cs @@ -33,8 +33,21 @@ public sealed class LicenseGate private readonly string _serverUrl; private readonly string _appVersion; + /// Test seam: answers in place of the license server. + private readonly Func>? _validator; + private string? _hardwareId; + /// For tests: a gate whose server answers are given, not fetched. + internal LicenseGate( + ILicenseStore store, ILogger logger, ILicensePrompt prompt, string appVersion, + Func> validator) + : this(store, logger, prompt, appVersion) + { + _validator = validator; + _hardwareId = "test-hardware"; + } + /// /// Wo Schlüssel und Serveradresse liegen. Eine Schnittstelle und keine /// Einstellungsklasse, weil zwei Prozesse sie führen: die Anwendung in @@ -179,6 +192,67 @@ public sealed class LicenseGate return true; } + /// + /// #764: Takes a key entered in the WebUI — the way in for a service that never asks + /// (). + /// + /// Only a key the server has confirmed is stored. A refused key leaves the + /// current one in place: otherwise a typo would take a working installation down. A + /// check that could not be made (server unreachable) stores nothing either — the same as + /// the startup check, which also keeps an unconfirmed key only for the running + /// session. + /// + public async Task ApplyKeyAsync(string key, CancellationToken ct = default) + { + key = key.Trim(); + + if (key.Length == 0) + return new LicenseApplyResult(false, false, "Enter a license key."); + + var result = await ValidateAsync(key, ct).ConfigureAwait(false); + + if (!result.IsValid) + { + if (result.IsTransient) + { + _logger.LogWarning( + "License key not checkable ({Status}) - not stored.", result.Status); + return new LicenseApplyResult(false, true, + "The license server could not be reached. The key was not stored; try again later."); + } + + _logger.LogWarning( + "License key refused: {Status} - {Message}", result.Status, result.Message); + + // As in the startup check: no details on a clock rollback, a precise text would + // be a recipe. + return new LicenseApplyResult(false, false, result.Status == "clock_rollback" + ? "The license check could not be completed. Please contact support." + : DescribeProblem(result)); + } + + _store.LicenseKey = key; + + try + { + _store.Save(); + } + catch (Exception ex) + { + // Valid, and in force for this session - but the next start would not know it. + // That has to be said, not logged away. + _logger.LogWarning(ex, "License key could not be saved"); + return new LicenseApplyResult(true, false, + $"The license is valid but could not be saved ({ex.Message}). It applies until the next restart."); + } + + _logger.LogInformation("License key replaced via the web interface."); + + return new LicenseApplyResult(true, false, result.IsCached + ? $"License stored. The server was not reachable; valid offline until {Describe(result.CacheExpiresAt)}." + : "License stored and confirmed."); + } + private async Task ValidateAsync(string key, CancellationToken ct) { if (string.IsNullOrWhiteSpace(key)) @@ -193,6 +267,9 @@ public sealed class LicenseGate try { + if (_validator is not null) + return await _validator(key, ct).ConfigureAwait(false); + return await _client.ValidateAsync( LicenseInfo.ProductSlug, key, _serverUrl, _appVersion, ct); } @@ -246,3 +323,9 @@ public sealed class LicenseGate : result.Message }; } + +/// #764: The outcome of . +/// The key is now the installation's key. +/// Nothing was decided: the server could not be asked. +/// For the person who entered the key. +public sealed record LicenseApplyResult(bool Applied, bool Transient, string Message); diff --git a/src/ClawdDotNet.Manager.Web/Endpoints.cs b/src/ClawdDotNet.Manager.Web/Endpoints.cs index 872b5e7..5951586 100644 --- a/src/ClawdDotNet.Manager.Web/Endpoints.cs +++ b/src/ClawdDotNet.Manager.Web/Endpoints.cs @@ -74,6 +74,20 @@ public sealed record FtpSettingsUpdateDto( bool Enabled, string Host, int Port, string Username, string? Password, string RemoteDirectory, bool RequireTls, int KeepCount); +/// #764: The license as the web interface sees it — never the key, only whether one is set. +public sealed record LicenseStatusDto(bool Enforced, bool Holds, bool KeySet, string? HardwareId); + +/// #764: A license key entered in the web interface. +public sealed record LicenseKeyDto(string Key); + +/// #764: The manager's own settings — without the master token, only whether one is set. +public sealed record ManagerSettingsDto( + string ConfigFile, string InstancesDirectory, int WebUiPort, string WebUiBinding, + string DeploymentcenterUrl, bool MasterTokenSet, string UpdateChannel); + +/// #764: A change to the manager's settings. null keeps a value, an empty token too. +public sealed record ManagerSettingsUpdateDto(string? DeploymentcenterUrl, string? MasterToken, string? UpdateChannel); + /// /// Die HTTP-Fläche des Managers. /// @@ -368,6 +382,37 @@ internal static class ManagerEndpoints } }); + // ─── License and manager settings (#764) ─── + + verwaltung.MapGet("/license", (ManagerCore manager) => + Results.Ok(new LicenseStatusDto( + manager.LicenseEnforced, manager.LicenseHolds, + !string.IsNullOrWhiteSpace(manager.Settings.LicenseKey), manager.LicenseHardwareId))); + + // The key goes to the license server before it is stored; a refused key leaves the + // current one in place (LicenseGate.ApplyKeyAsync). + verwaltung.MapPut("/license", async (LicenseKeyDto input, ManagerCore manager, CancellationToken ct) => + { + var result = await manager.ApplyLicenseKeyAsync(input.Key ?? "", ct); + return result.Applied + ? Results.Ok(new OutcomeDto(true, result.Message)) + : Results.BadRequest(new OutcomeDto(false, result.Message)); + }); + + verwaltung.MapGet("/settings", (ManagerCore manager) => + { + var s = manager.Settings; + return Results.Ok(new ManagerSettingsDto( + manager.SettingsPath, s.InstancesDirectory, s.WebUiPort, s.WebUiBinding, + s.DeploymentcenterUrl, !string.IsNullOrWhiteSpace(s.DeploymentcenterMasterToken), s.UpdateChannel)); + }); + + verwaltung.MapPut("/settings", (ManagerSettingsUpdateDto input, ManagerCore manager) => + { + var (ok, message) = manager.UpdateSettings(input.DeploymentcenterUrl, input.MasterToken, input.UpdateChannel); + return ok ? Results.Ok(new OutcomeDto(true, message)) : Results.BadRequest(new OutcomeDto(false, message)); + }); + // ─── FTP-Upload der Sicherungen ─── verwaltung.MapGet("/sicherung/ftp", (ManagerBackups backups) => diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css index 29a7160..8b68fe8 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css @@ -1517,6 +1517,14 @@ button.klein { font-size: 18px; } +/* #764: Settings cards side by side where there is room, stacked on a phone. */ +.settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); + gap: 14px; + align-items: start; +} + /* ─── Responsive Optimierung (Handy / 375px) ─── */ @media (max-width: 768px) { diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index 37c7670..4e03d69 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -417,6 +417,39 @@ "agents.treatAsForeignReader": "Immer als Leser fremder Inhalte behandeln", "agents.treatAsForeignReaderHint": "Kann nur verschärfen. Die Stufe folgt aus den Werkzeugen. Diese Einstellung setzt sie für einen Agenten, dessen Eingaben auf anderem Weg von außen kommen.", "agents.foreignReaderSaved": "Berichtsweg von {name} gespeichert.", + "agents.edit": "Bearbeiten", + "agents.editTooltip": "Name, Modell, Grenzen, Budget und Prompttexte bearbeiten", + "agents.editTitle": "Agent bearbeiten — {name}", + "agents.saved": "Agent gespeichert.", + "agents.runNow": "Jetzt ausführen", + "agents.runNowTooltip": "Agent von Hand wecken, wie es ein geplanter Lauf täte", + "agents.runStarted": "{name} läuft.", + "agents.runFailed": "Lauf fehlgeschlagen: {error}", + "agents.limits": "Grenzen", + "agents.limitsValue": "{steps} Schritte · {context} Kontext-Tokens · {seconds} s", + "agents.budget": "Tagesbudget", + "agents.budgetValue": "{cost} · {tokens} Tokens", + "agents.unlimited": "unbegrenzt", + "agents.promptTextsHint": "Identity, Soul und Systemprompt liest das Modell vor jedem Lauf. Wer sie ändert, ändert, wie der Agent arbeitet.", + "agents.field.displayName": "Anzeigename", + "agents.field.model": "Modell", + "agents.field.description": "Beschreibung", + "agents.field.identity": "Identity", + "agents.field.soul": "Soul", + "agents.field.systemPrompt": "Systemprompt", + "agents.field.promptCaching": "Prompt-Caching", + "agents.field.maxSteps": "Max. Schritte je Lauf", + "agents.field.maxContextTokens": "Max. Kontext-Tokens", + "agents.field.maxToolResultChars": "Max. Zeichen je Werkzeugergebnis", + "agents.field.timeoutSeconds": "Zeitlimit je Lauf (Sekunden)", + "agents.field.dailyCostUsd": "Tagesbudget (USD, 0 = unbegrenzt)", + "agents.field.dailyTokens": "Tages-Tokens (0 = unbegrenzt)", + "agents.field.whenExhausted": "Wenn das Budget erschöpft ist", + "agents.option.auto": "Automatisch", + "agents.option.on": "An", + "agents.option.off": "Aus", + "agents.option.expire": "Lauf entfällt", + "agents.option.catchUpOnce": "Einmal nachholen", "tasks.title": "Aufgaben", "tasks.newTask": "Neue Aufgabe", @@ -558,34 +591,51 @@ "logs.empty": "Noch keine Protokolleinträge vorhanden.", "settings.title": "Einstellungen & Systemarchitektur", + "settings.license": "Lizenz", + "settings.licenseState": "Zustand", + "settings.licenseValid": "Gültig", + "settings.licenseInvalid": "Nicht gültig", + "settings.licenseMissing": "Kein Schlüssel", + "settings.licenseNoCheck": "Keine Lizenzprüfung", + "settings.licenseUnavailable": "Der Lizenzzustand ließ sich nicht lesen.", + "settings.licenseKeyHint": "Der Schlüssel wird vor dem Speichern beim Lizenzserver geprüft. Ein abgelehnter Schlüssel lässt den bisherigen stehen. Eine Lizenz gilt für alle Instanzen dieser Installation.", + "settings.hardwareId": "Hardware-ID", + "settings.secretSet": "Hinterlegt — leer lassen, um ihn zu behalten", + "settings.secretNotSet": "Nicht gesetzt", + "settings.managerCard": "Instanzmanager", + "settings.instancesDirectory": "Instanzverzeichnis", + "settings.webUi": "Weboberfläche", + "settings.dcUrl": "Deploymentcenter-Adresse", + "settings.dcMasterTokenHint": "Damit stellt der Manager jeder Instanz ein eigenes Melde-Token aus. Nur ein leeres Feld behält das gespeicherte Token.", + "settings.managerRestartHint": "Änderungen hier greifen, wenn der Instanzmanager neu startet. Port, Bindung und Zugangstoken der Weboberfläche stehen in manager.json — ein falscher Wert hier sperrte diese Seite aus.", + "settings.instanceStopped": "Instanz läuft nicht", + "settings.instanceStoppedHint": "Die Instanzdatei gehört dem Instanzprozess und wird nur bearbeitet, solange er läuft. Instanz starten, um ihre Einstellungen zu ändern.", + "settings.instanceCard": "Instanz", + "settings.logDirectory": "Protokollverzeichnis", + "settings.logDirectoryHint": "Leer nimmt die Vorgabe. Derzeit: {path}. Greift nach einem Neustart.", + "settings.instanceDailyCost": "Tagesbudget der Instanz (USD, 0 = unbegrenzt)", + "settings.instanceDailyTokens": "Tages-Tokens der Instanz (0 = unbegrenzt)", + "settings.budgetHint": "Über alle Agenten dieser Instanz. Agenten haben zusätzlich eigene Budgets.", + "settings.apiKeyHint": "Ohne Schlüssel startet die Agent-Engine nicht. Ein neuer Schlüssel greift nach einem Neustart der Instanz.", + "settings.applicationCard": "Anwendung (alle Instanzen)", + "settings.applicationHint": "settings.json auf dieser Maschine, von jeder Instanz beim Start gelesen.", + "settings.minimumLogLevel": "Mindest-Protokollstufe", + "settings.openRouterBaseUrl": "OpenRouter-Adresse", + "settings.statusCheckInterval": "Statusprüfung alle (Sekunden)", + "settings.dcToken": "Deploymentcenter-Token", + "settings.dcEnvironment": "Umgebung", + "settings.errorReporting": "Fehler an das Deploymentcenter melden", + "settings.on": "An", + "settings.off": "Aus", "settings.intro": "Übersicht der Konfiguration nach dem Instanzmanager-Konzept: Strikte Trennung zwischen globaler Manager-Ebene (Installation) und isolierter Instanz-Ebene.", "settings.managerLevel": "Instanzmanager", "settings.managerBadge": "Manager-Ebene · Global", "settings.managerIntro": "Gilt für alle Instanzen und die Weboberfläche. Gespeichert in manager.json im Anwendungs-Hauptverzeichnis.", - "settings.webUiAndNet": "Weboberfläche & Netzwerk", "settings.configFile": "Konfigurationsdatei", - "settings.webUiPort": "WebUI-Port", - "settings.webUiPortDefault": "7000 (Vorgabe, webUiPort)", - "settings.networkBinding": "Netzwerk-Bindung", - "settings.networkBindingValue": "loopback (127.0.0.1, webUiBinding)", - "settings.accessProtection": "Zugangsschutz", - "settings.tokenActive": "Token aktiv (≥ 20 Zeichen)", - "settings.licenseAndUpdates": "Lizenz & Updates", - "settings.licenseScope": "Lizenz-Geltung", - "settings.licenseScopeValue": "Installationsebene (E-M3: Beliebig viele Instanzen)", "settings.licenseKey": "Lizenzschlüssel", - "settings.licenseEncrypted": "●●●●-●●●●-●●●●-●●●● (verschlüsselt)", "settings.updateChannel": "Update-Kanal", - "settings.updateChannelValue": "prod (Offizielle Release-Versionen)", "settings.updateCheck": "Update-Prüfung", - "settings.updateCheckValue": "Zentral auf Manager-Ebene", - "settings.dcAndOversight": "Deploymentcenter & Aufsicht", "settings.dcMasterToken": "Master-Token", - "settings.dcMasterTokenValue": "●●●●●●●● (erzeugt Sub-Token je Instanz)", - "settings.desktopWindows": "Desktop-Fenster", - "settings.desktopWindowsValue": "Aktiv (jede Instanz hat ein eigenes Fenster)", - "settings.crashProtection": "Absturz-Schutz", - "settings.crashProtectionValue": "Max. 5 Neustarts / 10 Minuten", "settings.instanceSectionTitle": "Aktive Instanz: {name}", "settings.instanceSectionTitleNone": "Instanzbezogene Einstellungen", "settings.instanceBadge": "Instanz-Ebene · Isoliert", @@ -594,41 +644,20 @@ "settings.noInstanceSelected": "Keine Instanz ausgewählt", "settings.noInstancePrompt": "Bitte wähle oben im Kopfbereich eine aktive Instanz aus oder wechsle in den Bereich 'Instanzen', um eine Instanz zu starten oder auszuwählen.", "settings.toInstancesOverview": "Zur Instanzen-Übersicht", - "settings.identityAndProcess": "Identität & Prozess", "settings.instanceName": "Instanzname", - "settings.instanceId": "Instanz-ID", "settings.autostartByManager": "Autostart durch Manager", - "settings.autostartActive": "Aktiv (Am Laufen halten)", - "settings.autostartInactive": "Inaktiv (Manueller Start)", - "settings.communication": "Kommunikation", - "settings.communicationValue": "HTTP über Named Pipe (Windows) / Socket (Linux)", - "settings.isolatedPaths": "Isolierte Verzeichnisse", - "settings.logsPath": "Protokolle (Logs)", - "settings.backupsPath": "Datensicherungen", - "settings.stateDbPath": "Zustandsdatenbank", - "settings.stateDbValue": "Instances/{name}/state.db (exklusiv)", - "settings.workspacePath": "Arbeitsbereich (Shared)", "settings.openRouterCard": "Modellanbindung (OpenRouter)", "settings.openRouterStatus": "OpenRouter-Status", "settings.connected": "Verbunden", "settings.offline": "Offline", - "settings.statusMessage": "Statusmeldung", "settings.credits": "Guthaben / Credits", "settings.apiKey": "API-Schlüssel", - "settings.apiKeyMasked": "In InstanceSettings.json dieser Instanz maskiert hinterlegt", - "settings.openRouterNotRunning": "Instanz läuft nicht. Daten werden nach dem Start geladen.", "settings.openRouterNoKey": "Kein Schlüssel hinterlegt oder Dienst nicht erreichbar.", "settings.runtimeAndSubsystems": "Laufzeit & Teilsysteme", "settings.agentEngine": "Agent Engine", "settings.scheduler": "Scanner / Scheduler", "settings.stagingService": "Freigabestelle (Staging)", "settings.dcSubToken": "Deploymentcenter Sub-Token", - "settings.subsystemsLoading": "Laufzeitdaten werden abgefragt…", - "settings.subsystemsStopped": "Instanz ist beendet — Teilsysteme ruhen.", - "settings.save": "Einstellungen speichern", - "settings.discard": "Verwerfen", - "settings.saved": "Einstellungen erfolgreich gespeichert.", - "settings.saveError": "Fehler beim Speichern der Einstellungen: {error}", "info.title": "System & Status", "info.noInstanceSelected": "Keine Instanz ausgewählt", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index 04ef04e..98dd0d5 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -417,6 +417,39 @@ "agents.treatAsForeignReader": "Always treat as reading outside content", "agents.treatAsForeignReaderHint": "Can only tighten. The tools decide the tier; this adds it for an agent whose input comes from outside some other way.", "agents.foreignReaderSaved": "Reporting line of {name} saved.", + "agents.edit": "Edit", + "agents.editTooltip": "Edit name, model, limits, budget and prompt texts", + "agents.editTitle": "Edit agent — {name}", + "agents.saved": "Agent saved.", + "agents.runNow": "Run now", + "agents.runNowTooltip": "Wake the agent by hand, as a scheduled run would", + "agents.runStarted": "{name} is running.", + "agents.runFailed": "Run failed: {error}", + "agents.limits": "Limits", + "agents.limitsValue": "{steps} steps · {context} context tokens · {seconds} s", + "agents.budget": "Daily budget", + "agents.budgetValue": "{cost} · {tokens} tokens", + "agents.unlimited": "unlimited", + "agents.promptTextsHint": "Identity, Soul and the system prompt are what the model reads before every run. Changing them changes how the agent works.", + "agents.field.displayName": "Display name", + "agents.field.model": "Model", + "agents.field.description": "Description", + "agents.field.identity": "Identity", + "agents.field.soul": "Soul", + "agents.field.systemPrompt": "System prompt", + "agents.field.promptCaching": "Prompt caching", + "agents.field.maxSteps": "Max. steps per run", + "agents.field.maxContextTokens": "Max. context tokens", + "agents.field.maxToolResultChars": "Max. characters per tool result", + "agents.field.timeoutSeconds": "Time limit per run (seconds)", + "agents.field.dailyCostUsd": "Daily budget (USD, 0 = unlimited)", + "agents.field.dailyTokens": "Daily tokens (0 = unlimited)", + "agents.field.whenExhausted": "When the budget is exhausted", + "agents.option.auto": "Automatic", + "agents.option.on": "On", + "agents.option.off": "Off", + "agents.option.expire": "Skip the run", + "agents.option.catchUpOnce": "Catch up once", "tasks.title": "Tasks", "tasks.newTask": "New Task", @@ -558,34 +591,51 @@ "logs.empty": "No log messages yet.", "settings.title": "Settings & System Architecture", + "settings.license": "License", + "settings.licenseState": "State", + "settings.licenseValid": "Valid", + "settings.licenseInvalid": "Not valid", + "settings.licenseMissing": "No key", + "settings.licenseNoCheck": "No license check", + "settings.licenseUnavailable": "The license state could not be read.", + "settings.licenseKeyHint": "The key is checked with the license server before it is stored. A refused key leaves the current one in place. One license covers every instance of this installation.", + "settings.hardwareId": "Hardware ID", + "settings.secretSet": "Stored — leave empty to keep", + "settings.secretNotSet": "Not set", + "settings.managerCard": "Instance manager", + "settings.instancesDirectory": "Instances directory", + "settings.webUi": "Web interface", + "settings.dcUrl": "Deploymentcenter address", + "settings.dcMasterTokenHint": "Used to issue each instance its own reporting token. Only an empty field keeps the stored token.", + "settings.managerRestartHint": "Changes here take effect when the instance manager restarts. Port, binding and access token of the web interface are changed in manager.json — a wrong value here would lock this page out.", + "settings.instanceStopped": "Instance not running", + "settings.instanceStoppedHint": "The instance file belongs to the instance process and is only edited while it runs. Start the instance to change its settings.", + "settings.instanceCard": "Instance", + "settings.logDirectory": "Log directory", + "settings.logDirectoryHint": "Empty uses the default. Currently: {path}. Takes effect after a restart.", + "settings.instanceDailyCost": "Daily budget of the instance (USD, 0 = unlimited)", + "settings.instanceDailyTokens": "Daily tokens of the instance (0 = unlimited)", + "settings.budgetHint": "Across all agents of this instance. Agents also have their own budgets.", + "settings.apiKeyHint": "Without a key the agent engine does not start. A new key takes effect after the instance restarts.", + "settings.applicationCard": "Application (all instances)", + "settings.applicationHint": "settings.json on this machine, read by every instance when it starts.", + "settings.minimumLogLevel": "Minimum log level", + "settings.openRouterBaseUrl": "OpenRouter address", + "settings.statusCheckInterval": "Status check interval (seconds)", + "settings.dcToken": "Deploymentcenter token", + "settings.dcEnvironment": "Environment", + "settings.errorReporting": "Report errors to the Deploymentcenter", + "settings.on": "On", + "settings.off": "Off", "settings.intro": "Configuration overview following the instance manager concept: Strict separation between global manager level (installation) and isolated instance level.", "settings.managerLevel": "Instance Manager", "settings.managerBadge": "Manager Level · Global", "settings.managerIntro": "Applies to all instances and the WebUI. Stored in manager.json in the application root directory.", - "settings.webUiAndNet": "WebUI & Network", "settings.configFile": "Configuration File", - "settings.webUiPort": "WebUI Port", - "settings.webUiPortDefault": "7000 (default, webUiPort)", - "settings.networkBinding": "Network Binding", - "settings.networkBindingValue": "loopback (127.0.0.1, webUiBinding)", - "settings.accessProtection": "Access Protection", - "settings.tokenActive": "Token active (≥ 20 characters)", - "settings.licenseAndUpdates": "License & Updates", - "settings.licenseScope": "License Scope", - "settings.licenseScopeValue": "Installation level (E-M3: Unlimited instances)", "settings.licenseKey": "License Key", - "settings.licenseEncrypted": "●●●●-●●●●-●●●●-●●●● (encrypted)", "settings.updateChannel": "Update Channel", - "settings.updateChannelValue": "prod (Official release versions)", "settings.updateCheck": "Update Check", - "settings.updateCheckValue": "Central at manager level", - "settings.dcAndOversight": "Deploymentcenter & Oversight", "settings.dcMasterToken": "Master Token", - "settings.dcMasterTokenValue": "●●●●●●●● (generates sub-tokens per instance)", - "settings.desktopWindows": "Desktop Windows", - "settings.desktopWindowsValue": "Active (each instance has its own window)", - "settings.crashProtection": "Crash Protection", - "settings.crashProtectionValue": "Max 5 restarts / 10 minutes", "settings.instanceSectionTitle": "Active Instance: {name}", "settings.instanceSectionTitleNone": "Instance-Related Settings", "settings.instanceBadge": "Instance Level · Isolated", @@ -594,41 +644,20 @@ "settings.noInstanceSelected": "No instance selected", "settings.noInstancePrompt": "Please select an active instance in the header above or switch to 'Instances' to start or select an instance.", "settings.toInstancesOverview": "To Instances Overview", - "settings.identityAndProcess": "Identity & Process", "settings.instanceName": "Instance Name", - "settings.instanceId": "Instance ID", "settings.autostartByManager": "Autostart by Manager", - "settings.autostartActive": "Active (keep running)", - "settings.autostartInactive": "Inactive (manual start)", - "settings.communication": "Communication", - "settings.communicationValue": "HTTP over Named Pipe (Windows) / Socket (Linux)", - "settings.isolatedPaths": "Isolated Directories", - "settings.logsPath": "Logs", - "settings.backupsPath": "Backups", - "settings.stateDbPath": "State Database", - "settings.stateDbValue": "Instances/{name}/state.db (exclusive)", - "settings.workspacePath": "Shared Workspace", "settings.openRouterCard": "Model Integration (OpenRouter)", "settings.openRouterStatus": "OpenRouter Status", "settings.connected": "Connected", "settings.offline": "Offline", - "settings.statusMessage": "Status Message", "settings.credits": "Credits", "settings.apiKey": "API Key", - "settings.apiKeyMasked": "Masked in InstanceSettings.json of this instance", - "settings.openRouterNotRunning": "Instance is not running. Data will be loaded after start.", "settings.openRouterNoKey": "No key configured or service unreachable.", "settings.runtimeAndSubsystems": "Runtime & Subsystems", "settings.agentEngine": "Agent Engine", "settings.scheduler": "Scanner / Scheduler", "settings.stagingService": "Approvals (Staging)", "settings.dcSubToken": "Deploymentcenter Sub-Token", - "settings.subsystemsLoading": "Querying runtime data…", - "settings.subsystemsStopped": "Instance stopped — subsystems idle.", - "settings.save": "Save Settings", - "settings.discard": "Discard Changes", - "settings.saved": "Settings saved successfully.", - "settings.saveError": "Failed to save settings: {error}", "info.title": "System & Status", "info.noInstanceSelected": "No instance selected", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js index ea60ccc..707a0e1 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js @@ -186,7 +186,31 @@ async function rendereDetails(agentId) { erstelleElement("span", { text: ` ${t("agents.delete")}` }) ); - aktionen.append(zumChatBtn, loeschenBtn); + // #764: What the desktop agent page could do — edit the agent and wake it by hand. + const editButton = erstelleElement( + "button", + { + klasse: "schaltflaeche flach", + title: t("agents.editTooltip"), + onclick: () => openEditDialog(agent) + }, + erstelleIcon(PFADE.einstellungen, 14), + erstelleElement("span", { text: ` ${t("agents.edit")}` }) + ); + + const runButton = erstelleElement( + "button", + { + klasse: "schaltflaeche flach", + title: t("agents.runNowTooltip"), + disabled: Boolean(agent.isRunning), + onclick: () => runAgentNow(agent, runButton) + }, + erstelleIcon(PFADE.abspielen, 14), + erstelleElement("span", { text: ` ${t("agents.runNow")}` }) + ); + + aktionen.append(editButton, runButton, zumChatBtn, loeschenBtn); // Stammdaten-Karte const stammdatenListe = erstelleElement("dl", { klasse: "datenliste" }); @@ -194,7 +218,13 @@ async function rendereDetails(agentId) { [t("agents.name"), agent.displayName], [t("agents.id"), agent.agentId], [t("agents.model"), agent.model], - [t("agents.description"), agent.description || "—"] + [t("agents.description"), agent.description || "—"], + [t("agents.limits"), t("agents.limitsValue", { + steps: agent.maxSteps, + context: agent.maxContextTokens, + seconds: agent.timeoutSeconds + })], + [t("agents.budget"), formatBudget(agent)] ]; for (const [bez, wert] of felder) { @@ -433,6 +463,127 @@ function oeffneNeuerAgentDialog() { modal.oeffnen(); } +function formatBudget(agent) { + const cost = Number(agent.dailyCostUsd) > 0 ? `$${Number(agent.dailyCostUsd).toFixed(2)}` : t("agents.unlimited"); + const tokens = Number(agent.dailyTokens) > 0 ? Number(agent.dailyTokens).toLocaleString() : t("agents.unlimited"); + return t("agents.budgetValue", { cost, tokens }); +} + +async function runAgentNow(agent, button) { + button.disabled = true; + try { + const result = await api(`/agents/${encodeURIComponent(agent.agentId)}/run`, { method: "POST" }); + zeigeMeldung(result?.message ?? t("agents.runStarted", { name: agent.displayName }), "info"); + } catch (err) { + zeigeMeldung(t("agents.runFailed", { error: err.message }), "gefahr"); + } finally { + button.disabled = false; + } +} + +/** + * #764: Edits an agent — master data, limits, budget and the three prompt texts. The long + * texts are fetched for this agent only; the agent list does not carry them. + */ +async function openEditDialog(agent) { + let texts; + try { + texts = await api(`/agents/${encodeURIComponent(agent.agentId)}/texts`); + } catch (err) { + zeigeMeldung(t("agents.loadFailed", { error: err.message }), "gefahr"); + return; + } + + const form = erstelleElement("form", { klasse: "formular-raster", id: "agent-edit-form" }); + + const textInput = (value) => erstelleElement("input", { type: "text", value: value ?? "" }); + const numberInput = (value, min, step = "1") => + erstelleElement("input", { type: "number", min: String(min), step, value: String(value ?? "") }); + const textArea = (value, rows) => { + const area = erstelleElement("textarea", { rows: String(rows) }); + area.value = value ?? ""; + return area; + }; + const select = (options, value) => { + const el = erstelleElement("select", {}, + ...options.map((o) => erstelleElement("option", { value: o, text: t(`agents.option.${o}`) }))); + el.value = value; + return el; + }; + + const fields = { + displayName: textInput(agent.displayName), + model: textInput(agent.model), + description: textArea(agent.description, 2), + identity: textArea(texts.identity, 8), + soul: textArea(texts.soul, 8), + systemPrompt: textArea(texts.systemPrompt, 6), + promptCaching: select(["auto", "on", "off"], agent.promptCaching || "auto"), + maxSteps: numberInput(agent.maxSteps, 1), + maxContextTokens: numberInput(agent.maxContextTokens, 1000), + maxToolResultChars: numberInput(agent.maxToolResultChars, 100), + timeoutSeconds: numberInput(agent.timeoutSeconds, 10), + dailyCostUsd: numberInput(agent.dailyCostUsd, 0, "0.01"), + dailyTokens: numberInput(agent.dailyTokens, 0), + whenExhausted: select(["expire", "catchUpOnce"], agent.whenExhausted || "expire") + }; + fields.displayName.required = true; + fields.model.required = true; + + for (const [key, input] of Object.entries(fields)) { + form.append(erstelleElement("label", { text: t(`agents.field.${key}`) }), input); + } + form.append(erstelleElement("p", { klasse: "hinweis", text: t("agents.promptTextsHint") })); + + const cancelButton = erstelleElement("button", { + type: "button", + klasse: "schaltflaeche flach", + text: t("common.cancel"), + onclick: () => modal.schliessen() + }); + const saveButton = erstelleElement("button", { + type: "submit", + klasse: "schaltflaeche primaer", + text: t("common.save") + }); + + const modal = erstelleModal(t("agents.editTitle", { name: agent.displayName }), form, [cancelButton, saveButton], { klasse: "modal-breit" }); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const body = { + displayName: fields.displayName.value.trim(), + model: fields.model.value.trim(), + description: fields.description.value, + identity: fields.identity.value, + soul: fields.soul.value, + systemPrompt: fields.systemPrompt.value, + promptCaching: fields.promptCaching.value, + maxSteps: Number(fields.maxSteps.value), + maxContextTokens: Number(fields.maxContextTokens.value), + maxToolResultChars: Number(fields.maxToolResultChars.value), + timeoutSeconds: Number(fields.timeoutSeconds.value), + dailyCostUsd: Number(fields.dailyCostUsd.value), + dailyTokens: Number(fields.dailyTokens.value), + whenExhausted: fields.whenExhausted.value + }; + + saveButton.disabled = true; + try { + const result = await api(`/agents/${encodeURIComponent(agent.agentId)}`, { method: "PUT", body }); + modal.schliessen(); + zeigeMeldung(result?.message ?? t("agents.saved"), "erfolg"); + await ladeUndRendereListe(agent.agentId); + } catch (err) { + zeigeMeldung(t("agents.saveFailed", { error: err.message }), "gefahr"); + } finally { + saveButton.disabled = false; + } + }); + + modal.oeffnen(); +} + async function loescheAgent(agentId, name) { if (!confirm(t("agents.deleteConfirm", { name, id: agentId }))) return; diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js index 8c30211..a2e9087 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js @@ -1,6 +1,13 @@ -// ClawdDotNet — Einstellungen & Systemarchitektur +// ClawdDotNet — Settings (#764) // -// Strikte Trennung zwischen globaler Manager-Ebene (Installation) und isolierter Instanz-Ebene. +// Two levels, kept apart on purpose: +// - the instance manager (manager.json): license, Deploymentcenter master token, update channel; +// - the active instance (InstanceSettings.json, owned by the instance process) and the +// application file settings.json that every instance on this machine reads. +// +// Until #764 this page only showed fixed text — the values could be edited in the desktop window +// alone. Secrets (license key, tokens, OpenRouter key) are written here and never shown: the API +// returns only whether one is set, and an empty field keeps the stored value. import { api, holeAktiveInstanz } from "../modules/api.js"; import { t } from "../modules/i18n.js"; @@ -10,6 +17,7 @@ import { erstellePlakette, instanzZustandInfo, erstelleIcon, + zeigeMeldung, PFADE } from "../modules/dom.js"; import { erstelleMcpServerKarte } from "./mcp-servers.js"; @@ -21,240 +29,306 @@ export async function rendereEinstellungen() { const layout = erstelleElement("div", { klasse: "einstellungen-ansicht" }); - // Kopfbereich - const kopf = erstelleElement("div", { klasse: "ansicht-kopf" }); - const kopfText = erstelleElement("div"); - kopfText.append( + const head = erstelleElement("div", { klasse: "ansicht-kopf" }); + head.append(erstelleElement("div", {}, erstelleElement("h1", { text: t("settings.title") }), - erstelleElement("p", { - klasse: "hinweis", - text: t("settings.intro") - }) - ); - kopf.append(kopfText); - layout.append(kopf); + erstelleElement("p", { klasse: "hinweis", text: t("settings.intro") }))); + layout.append(head); - // ═══════════════════════════════════════════════════════════════ - // SEKTION 1: Instanzmanager (Globale Ebene / Installation) - // ═══════════════════════════════════════════════════════════════ - - const managerSektion = erstelleElement("section", { klasse: "einstellungen-sektion sektion-manager" }); - - const managerKopf = erstelleElement("div", { klasse: "sektion-titel-zeile" }); - const managerTitelLinks = erstelleElement("div", { klasse: "gruppe-horizontal" }); - managerTitelLinks.append( - erstelleIcon(PFADE.server, 20), - erstelleElement("h2", { text: t("settings.managerLevel") }), - erstellePlakette(t("settings.managerBadge"), "warnung") - ); - managerKopf.append(managerTitelLinks); - - const managerIntro = erstelleElement("p", { - klasse: "gedaempft text-klein", - text: t("settings.managerIntro") - }); - - const managerGitter = erstelleElement("div", { klasse: "info-gitter" }); - - // Karte 1: Weboberfläche & Netzwerk - const dlNetz = erstelleElement("dl", { klasse: "datenliste" }); - dlNetz.append( - erstelleElement("dt", { text: t("settings.configFile") }), - erstelleElement("dd", {}, erstelleElement("code", { text: "manager.json" })), - erstelleElement("dt", { text: t("settings.webUiPort") }), - erstelleElement("dd", { text: t("settings.webUiPortDefault") }), - erstelleElement("dt", { text: t("settings.networkBinding") }), - erstelleElement("dd", { text: t("settings.networkBindingValue") }), - erstelleElement("dt", { text: t("settings.accessProtection") }), - erstelleElement("dd", {}, erstellePlakette(t("settings.tokenActive"), "erfolg")) - ); - managerGitter.append(erstelleKarte(t("settings.webUiAndNet"), [dlNetz])); - - // Karte 2: Lizenz & Programm-Updates - const dlLizenz = erstelleElement("dl", { klasse: "datenliste" }); - dlLizenz.append( - erstelleElement("dt", { text: t("settings.licenseScope") }), - erstelleElement("dd", { text: t("settings.licenseScopeValue") }), - erstelleElement("dt", { text: t("settings.licenseKey") }), - erstelleElement("dd", {}, erstelleElement("code", { text: t("settings.licenseEncrypted") })), - erstelleElement("dt", { text: t("settings.updateChannel") }), - erstelleElement("dd", { text: t("settings.updateChannelValue") }), - erstelleElement("dt", { text: t("settings.updateCheck") }), - erstelleElement("dd", { text: t("settings.updateCheckValue") }) - ); - managerGitter.append(erstelleKarte(t("settings.licenseAndUpdates"), [dlLizenz])); - - // Karte 3: Deploymentcenter & Aufsicht - const dlAufsicht = erstelleElement("dl", { klasse: "datenliste" }); - dlAufsicht.append( - erstelleElement("dt", { text: "Deploymentcenter" }), - erstelleElement("dd", { text: "https://dc.mhdf.de" }), - erstelleElement("dt", { text: t("settings.dcMasterToken") }), - erstelleElement("dd", {}, erstelleElement("code", { text: t("settings.dcMasterTokenValue") })), - erstelleElement("dt", { text: t("settings.desktopWindows") }), - erstelleElement("dd", { text: t("settings.desktopWindowsValue") }), - erstelleElement("dt", { text: t("settings.crashProtection") }), - erstelleElement("dd", { text: t("settings.crashProtectionValue") }) - ); - managerGitter.append(erstelleKarte(t("settings.dcAndOversight"), [dlAufsicht])); - - managerSektion.append(managerKopf, managerIntro, managerGitter); - layout.append(managerSektion); - - // ═══════════════════════════════════════════════════════════════ - // SEKTION 2: Aktive Instanz (Instanz-Ebene) - // ═══════════════════════════════════════════════════════════════ - - const instanzSektion = erstelleElement("section", { klasse: "einstellungen-sektion sektion-instanz" }); - - const aktiveId = holeAktiveInstanz(); - let instanzenListe = []; - try { - instanzenListe = await api("/verwaltung/instanzen"); - } catch { - // Wenn Liste nicht abrufbar, weiter - } - - const aktiveInstanz = instanzenListe.find((i) => i.id === aktiveId); - const zInfo = aktiveInstanz ? instanzZustandInfo(aktiveInstanz.zustand) : { text: t("common.none"), art: "neutral" }; - - const instanzKopf = erstelleElement("div", { klasse: "sektion-titel-zeile" }); - const instanzTitelLinks = erstelleElement("div", { klasse: "gruppe-horizontal" }); - instanzTitelLinks.append( - erstelleIcon(PFADE.fenster, 20), - erstelleElement("h2", { - text: aktiveInstanz - ? t("settings.instanceSectionTitle", { name: aktiveInstanz.name }) - : t("settings.instanceSectionTitleNone") - }), - erstellePlakette(t("settings.instanceBadge"), "erfolg"), - aktiveInstanz ? erstellePlakette(zInfo.text, zInfo.art) : erstellePlakette(t("settings.noInstanceActive"), "neutral") - ); - instanzKopf.append(instanzTitelLinks); - - const instanzIntro = erstelleElement("p", { - klasse: "gedaempft text-klein", - text: t("settings.instanceIntro") - }); - - instanzSektion.append(instanzKopf, instanzIntro); - - if (!aktiveInstanz) { - const leerKarte = erstelleKarte(t("settings.noInstanceSelected"), [ - erstelleElement("p", { - klasse: "hinweis", - text: t("settings.noInstancePrompt") - }), - erstelleElement("a", { - href: "#/instanzen", - klasse: "schaltflaeche primaer klein", - text: t("settings.toInstancesOverview") - }) - ]); - instanzSektion.append(leerKarte); - } else { - // Daten von der laufenden Instanz abfragen (sofern sie läuft) - const [instanzInfo, instanzStatus] = await Promise.all([ - api("/info").catch(() => null), - api("/status").catch(() => null) - ]); - - const instanzGitter = erstelleElement("div", { klasse: "info-gitter" }); - - // Karte 1: Instanz-Identität & Autostart - const dlIdent = erstelleElement("dl", { klasse: "datenliste" }); - dlIdent.append( - erstelleElement("dt", { text: t("settings.instanceName") }), - erstelleElement("dd", {}, erstelleElement("strong", { text: aktiveInstanz.name })), - erstelleElement("dt", { text: t("settings.instanceId") }), - erstelleElement("dd", {}, erstelleElement("code", { text: aktiveInstanz.id })), - erstelleElement("dt", { text: t("settings.configFile") }), - erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/InstanceSettings.json` })), - erstelleElement("dt", { text: t("settings.autostartByManager") }), - erstelleElement("dd", {}, erstellePlakette( - aktiveInstanz.autostart ? t("settings.autostartActive") : t("settings.autostartInactive"), - aktiveInstanz.autostart ? "erfolg" : "neutral" - )), - erstelleElement("dt", { text: t("settings.communication") }), - erstelleElement("dd", { text: t("settings.communicationValue") }) - ); - instanzGitter.append(erstelleKarte(t("settings.identityAndProcess"), [dlIdent])); - - // Karte 2: Isolierte Verzeichnisse & Speicher - const dlPfade = erstelleElement("dl", { klasse: "datenliste" }); - dlPfade.append( - erstelleElement("dt", { text: t("settings.logsPath") }), - erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/Logs/` })), - erstelleElement("dt", { text: t("settings.backupsPath") }), - erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/Backups/` })), - erstelleElement("dt", { text: t("settings.stateDbPath") }), - erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/state.db (exklusiv)` })), - erstelleElement("dt", { text: t("settings.workspacePath") }), - erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/Agents/SharedWorkspace/` })) - ); - instanzGitter.append(erstelleKarte(t("settings.isolatedPaths"), [dlPfade])); - - // Karte 3: Modellanbindung & Budget (OpenRouter) - const dlModell = erstelleElement("dl", { klasse: "datenliste" }); - if (instanzStatus) { - dlModell.append( - erstelleElement("dt", { text: t("settings.openRouterStatus") }), - erstelleElement("dd", {}, erstellePlakette( - instanzStatus.isApiReachable ? t("settings.connected") : t("settings.offline"), - instanzStatus.isApiReachable ? "erfolg" : "gefahr" - )), - erstelleElement("dt", { text: t("settings.statusMessage") }), - erstelleElement("dd", { text: instanzStatus.statusText || "—" }), - erstelleElement("dt", { text: t("settings.credits") }), - erstelleElement("dd", {}, erstelleElement("strong", { text: instanzStatus.creditsText || "—" })), - erstelleElement("dt", { text: t("settings.apiKey") }), - erstelleElement("dd", { text: t("settings.apiKeyMasked") }) - ); - } else { - dlModell.append( - erstelleElement("dt", { text: "OpenRouter" }), - erstelleElement("dd", { - klasse: "gedaempft", - text: aktiveInstanz.zustand === "Running" - ? t("settings.openRouterNoKey") - : t("settings.openRouterNotRunning") - }) - ); - } - instanzGitter.append(erstelleKarte(t("settings.openRouterCard"), [dlModell])); - - // Karte 4: Teilsysteme der Instanz - const dlSysteme = erstelleElement("dl", { klasse: "datenliste" }); - if (instanzInfo) { - dlSysteme.append( - erstelleElement("dt", { text: t("settings.agentEngine") }), - erstelleElement("dd", {}, erstellePlakette(instanzInfo.engineActive ? "Aktiv" : "Aus", instanzInfo.engineActive ? "erfolg" : "neutral")), - erstelleElement("dt", { text: t("settings.scheduler") }), - erstelleElement("dd", {}, erstellePlakette(instanzInfo.schedulerActive ? "Aktiv" : "Aus", instanzInfo.schedulerActive ? "erfolg" : "neutral")), - erstelleElement("dt", { text: t("settings.stagingService") }), - erstelleElement("dd", {}, erstellePlakette(instanzInfo.stagingActive ? "Aktiv" : "Aus", instanzInfo.stagingActive ? "erfolg" : "neutral")), - erstelleElement("dt", { text: t("settings.dcSubToken") }), - erstelleElement("dd", {}, erstellePlakette(instanzInfo.deploymentcenterActive ? "Aktiv" : "Aus", instanzInfo.deploymentcenterActive ? "erfolg" : "neutral")) - ); - } else { - dlSysteme.append( - erstelleElement("dt", { text: t("info.subsystems") }), - erstelleElement("dd", { - klasse: "gedaempft", - text: aktiveInstanz.zustand === "Running" - ? t("settings.subsystemsLoading") - : t("settings.subsystemsStopped") - }) - ); - } - instanzGitter.append(erstelleKarte(t("settings.runtimeAndSubsystems"), [dlSysteme])); - - instanzSektion.append(instanzGitter); - - // #197: external MCP servers — only a running instance knows them. - if (instanzInfo) instanzSektion.append(erstelleMcpServerKarte()); - } - - layout.append(instanzSektion); + layout.append(await managerSection(), await instanceSection()); container.append(layout); } + +// ─── Building blocks ─── + +function section(kind, icon, title, badges, intro) { + const el = erstelleElement("section", { klasse: `einstellungen-sektion ${kind}` }); + el.append( + erstelleElement("div", { klasse: "sektion-titel-zeile" }, + erstelleElement("div", { klasse: "gruppe-horizontal" }, + erstelleIcon(icon, 20), erstelleElement("h2", { text: title }), ...badges)), + erstelleElement("p", { klasse: "gedaempft text-klein", text: intro })); + return el; +} + +function textInput(value, type = "text") { + return erstelleElement("input", { type, value: value ?? "" }); +} + +function numberInput(value, min, step = "1") { + return erstelleElement("input", { type: "number", min: String(min), step, value: String(value ?? 0) }); +} + +/** A secret is never shown; the placeholder says whether one is stored. */ +function secretInput(isSet) { + return erstelleElement("input", { + type: "password", + autocomplete: "new-password", + placeholder: isSet ? t("settings.secretSet") : t("settings.secretNotSet") + }); +} + +function selectInput(options, value) { + const el = erstelleElement("select", {}, ...options.map((o) => erstelleElement("option", { value: o, text: o }))); + el.value = value; + return el; +} + +function checkbox(checked) { + return erstelleElement("input", { type: "checkbox", checked: Boolean(checked) }); +} + +/** A form of labelled fields with a save button that sends what `collect` returns. */ +function settingsForm(rows, path, collect, afterSave) { + const form = erstelleElement("form", { klasse: "formular-raster" }); + for (const [label, input, hint] of rows) { + if (input.type === "checkbox") { + form.append(erstelleElement("label", { klasse: "gruppe-horizontal" }, input, erstelleElement("span", { text: label }))); + } else { + form.append(erstelleElement("label", { text: label }), input); + } + if (hint) form.append(erstelleElement("div", { klasse: "gedaempft text-klein", text: hint })); + } + + const save = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer klein", text: t("common.save") }); + form.append(erstelleElement("div", {}, save)); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + save.disabled = true; + try { + const result = await api(path, { method: "PUT", body: collect() }); + zeigeMeldung(result?.message ?? result?.meldung ?? t("common.saved"), "erfolg", 6000); + await afterSave?.(); + } catch (err) { + zeigeMeldung(err.message, "gefahr", 8000); + } finally { + save.disabled = false; + } + }); + + return form; +} + +function dataList(pairs) { + const dl = erstelleElement("dl", { klasse: "datenliste" }); + for (const [label, value] of pairs) { + dl.append(erstelleElement("dt", { text: label }), + value instanceof Node ? erstelleElement("dd", {}, value) : erstelleElement("dd", { text: value ?? "—" })); + } + return dl; +} + +async function refresh() { + await rendereEinstellungen(); +} + +// ─── Instance manager ─── + +async function managerSection() { + const el = section("sektion-manager", PFADE.server, t("settings.managerLevel"), + [erstellePlakette(t("settings.managerBadge"), "warnung")], t("settings.managerIntro")); + + const [license, manager] = await Promise.all([ + api("/verwaltung/license").catch(() => null), + api("/verwaltung/settings").catch(() => null) + ]); + + const grid = erstelleElement("div", { klasse: "settings-grid" }); + grid.append(licenseCard(license)); + if (manager) grid.append(managerCard(manager)); + el.append(grid); + return el; +} + +function licenseCard(license) { + if (!license) { + return erstelleKarte(t("settings.license"), [erstelleElement("p", { klasse: "gedaempft", text: t("settings.licenseUnavailable") })]); + } + + const state = !license.enforced + ? erstellePlakette(t("settings.licenseNoCheck"), "neutral") + : license.holds + ? erstellePlakette(t("settings.licenseValid"), "erfolg") + : erstellePlakette(license.keySet ? t("settings.licenseInvalid") : t("settings.licenseMissing"), "gefahr"); + + const key = secretInput(license.keySet); + const form = settingsForm( + [[t("settings.licenseKey"), key, t("settings.licenseKeyHint")]], + "/verwaltung/license", + () => ({ key: key.value }), + refresh); + + return erstelleKarte(t("settings.license"), [ + dataList([ + [t("settings.licenseState"), state], + [t("settings.hardwareId"), erstelleElement("code", { text: license.hardwareId ?? "—" })] + ]), + form + ]); +} + +function managerCard(manager) { + const dcUrl = textInput(manager.deploymentcenterUrl, "url"); + const masterToken = secretInput(manager.masterTokenSet); + const channel = selectInput(["prod", "beta", "dev"], manager.updateChannel); + + return erstelleKarte(t("settings.managerCard"), [ + dataList([ + [t("settings.configFile"), erstelleElement("code", { text: manager.configFile })], + [t("settings.instancesDirectory"), erstelleElement("code", { text: manager.instancesDirectory })], + [t("settings.webUi"), `${manager.webUiBinding} · ${manager.webUiPort}`] + ]), + settingsForm( + [ + [t("settings.dcUrl"), dcUrl], + [t("settings.dcMasterToken"), masterToken, t("settings.dcMasterTokenHint")], + [t("settings.updateChannel"), channel] + ], + "/verwaltung/settings", + () => ({ deploymentcenterUrl: dcUrl.value.trim(), masterToken: masterToken.value, updateChannel: channel.value }), + refresh), + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("settings.managerRestartHint") }) + ]); +} + +// ─── Active instance ─── + +async function instanceSection() { + const activeId = holeAktiveInstanz(); + const instances = await api("/verwaltung/instanzen").catch(() => []); + const active = instances.find((i) => i.id === activeId); + const state = active ? instanzZustandInfo(active.zustand) : null; + + const el = section("sektion-instanz", PFADE.fenster, + active ? t("settings.instanceSectionTitle", { name: active.name }) : t("settings.instanceSectionTitleNone"), + [erstellePlakette(t("settings.instanceBadge"), "erfolg"), + state ? erstellePlakette(state.text, state.art) : erstellePlakette(t("settings.noInstanceActive"), "neutral")], + t("settings.instanceIntro")); + + if (!active) { + el.append(erstelleKarte(t("settings.noInstanceSelected"), [ + erstelleElement("p", { klasse: "hinweis", text: t("settings.noInstancePrompt") }), + erstelleElement("a", { href: "#/instanzen", klasse: "schaltflaeche primaer klein", text: t("settings.toInstancesOverview") }) + ])); + return el; + } + + const [settings, status, info] = await Promise.all([ + api("/settings").catch(() => null), + api("/status").catch(() => null), + api("/info").catch(() => null) + ]); + + // The instance file belongs to the instance process: it is only written while it runs. + if (!settings) { + el.append(erstelleKarte(t("settings.instanceStopped"), [ + erstelleElement("p", { klasse: "hinweis", text: t("settings.instanceStoppedHint") }) + ])); + return el; + } + + const grid = erstelleElement("div", { klasse: "settings-grid" }); + grid.append(instanceCard(settings), openRouterCard(settings, status), applicationCard(settings)); + if (info) grid.append(subsystemsCard(info)); + el.append(grid); + + // #197: external MCP servers — only a running instance knows them. + if (info) el.append(erstelleMcpServerKarte()); + return el; +} + +function instanceCard(s) { + const name = textInput(s.instanceName); + const logDirectory = textInput(s.logDirectory); + const autostart = checkbox(s.autostartByManager); + const cost = numberInput(s.dailyCostUsd, 0, "0.01"); + const tokens = numberInput(s.dailyTokens, 0); + + return erstelleKarte(t("settings.instanceCard"), [settingsForm( + [ + [t("settings.instanceName"), name], + [t("settings.logDirectory"), logDirectory, t("settings.logDirectoryHint", { path: s.effectiveLogDirectory })], + [t("settings.autostartByManager"), autostart], + [t("settings.instanceDailyCost"), cost], + [t("settings.instanceDailyTokens"), tokens, t("settings.budgetHint")] + ], + "/settings", + () => ({ + instanceName: name.value.trim(), + logDirectory: logDirectory.value.trim(), + autostartByManager: autostart.checked, + dailyCostUsd: Number(cost.value), + dailyTokens: Number(tokens.value) + }), + refresh)]); +} + +function openRouterCard(s, status) { + const key = secretInput(s.openRouterApiKeySet); + const statusList = status + ? dataList([ + [t("settings.openRouterStatus"), erstellePlakette( + status.apiReachable ? t("settings.connected") : t("settings.offline"), + status.apiReachable ? "erfolg" : "gefahr")], + [t("settings.credits"), status.creditsText || "—"] + ]) + : erstelleElement("p", { klasse: "gedaempft", text: t("settings.openRouterNoKey") }); + + return erstelleKarte(t("settings.openRouterCard"), [ + statusList, + settingsForm([[t("settings.apiKey"), key, t("settings.apiKeyHint")]], "/settings", + () => ({ openRouterApiKey: key.value }), refresh) + ]); +} + +function applicationCard(s) { + const level = selectInput(["Debug", "Info", "Warn", "Error"], s.minimumLogLevel); + const baseUrl = textInput(s.openRouterBaseUrl, "url"); + const interval = numberInput(s.statusCheckIntervalSeconds, 10); + const dcUrl = textInput(s.deploymentcenterUrl, "url"); + const dcToken = secretInput(s.deploymentcenterTokenSet); + const environment = selectInput(["production", "development", "staging"], s.deploymentcenterEnvironment); + const reporting = checkbox(s.errorReportingEnabled); + const updateCheck = checkbox(s.updateCheckEnabled); + const channel = selectInput(["prod", "beta", "dev"], s.updateChannel); + + return erstelleKarte(t("settings.applicationCard"), [ + erstelleElement("p", { klasse: "hinweis", text: t("settings.applicationHint") }), + settingsForm( + [ + [t("settings.minimumLogLevel"), level], + [t("settings.openRouterBaseUrl"), baseUrl], + [t("settings.statusCheckInterval"), interval], + [t("settings.dcUrl"), dcUrl], + [t("settings.dcToken"), dcToken], + [t("settings.dcEnvironment"), environment], + [t("settings.errorReporting"), reporting], + [t("settings.updateCheck"), updateCheck], + [t("settings.updateChannel"), channel] + ], + "/settings", + () => ({ + minimumLogLevel: level.value, + openRouterBaseUrl: baseUrl.value.trim(), + statusCheckIntervalSeconds: Number(interval.value), + deploymentcenterUrl: dcUrl.value.trim(), + deploymentcenterToken: dcToken.value, + deploymentcenterEnvironment: environment.value, + errorReportingEnabled: reporting.checked, + updateCheckEnabled: updateCheck.checked, + updateChannel: channel.value + }), + refresh) + ]); +} + +function subsystemsCard(info) { + const badge = (on) => erstellePlakette(on ? t("settings.on") : t("settings.off"), on ? "erfolg" : "neutral"); + return erstelleKarte(t("settings.runtimeAndSubsystems"), [dataList([ + [t("settings.agentEngine"), badge(info.engineActive)], + [t("settings.scheduler"), badge(info.schedulerActive)], + [t("settings.stagingService"), badge(info.stagingActive)], + [t("settings.dcSubToken"), badge(info.deploymentcenterActive)] + ])]); +} diff --git a/src/ClawdDotNet.Manager/ManagerCore.cs b/src/ClawdDotNet.Manager/ManagerCore.cs index 5c63e48..2ebef3d 100644 --- a/src/ClawdDotNet.Manager/ManagerCore.cs +++ b/src/ClawdDotNet.Manager/ManagerCore.cs @@ -118,6 +118,9 @@ public sealed class ManagerCore : IAsyncDisposable public ManagerSettings Settings => _settings.Settings; + /// Where manager.json lives — shown in the web interface. + public string SettingsPath => _settings.SettingsPath; + // ─── Hochfahren ─── /// @@ -313,6 +316,73 @@ public sealed class ManagerCore : IAsyncDisposable /// betreiben: Der Weg vom Widerruf zum Anhalten ist die Zusage, nicht die Frage, wer /// ihn ausgelöst hat. /// + /// Whether a license check is configured at all — without one nothing is locked. + public bool LicenseEnforced => _license is not null; + + /// The hardware id the license server sees, for support; null without a license check. + public string? LicenseHardwareId => _license?.HardwareId; + + /// + /// #764: A license key from the web interface. The first install of a service has no other + /// way to get one: the manager never asks (NonInteractiveLicensePrompt). + /// + /// A confirmed key lifts the lock at once — the watch starts and the autostart + /// instances come up, as they would have at startup. Without that, a freshly licensed + /// manager would still refuse every start until someone restarted the service. + /// + /// The autostart runs without the request's token: whoever closes the browser right + /// after saving should not leave half the instances started. + /// + public async Task ApplyLicenseKeyAsync(string key, CancellationToken ct = default) + { + if (_license is null) + return new LicenseApplyResult(false, false, "This instance manager has no license check configured."); + + var result = await _license.ApplyKeyAsync(key, ct).ConfigureAwait(false); + if (!result.Applied) + return result; + + var wasLocked = !LicenseHolds; + LicenseHolds = true; + _watch?.Start(); + + if (wasLocked) + { + _logger.LogInformation("License confirmed - instances may start again."); + await StartAutostartInstancesAsync(CancellationToken.None).ConfigureAwait(false); + } + + return result; + } + + /// + /// #764: Changes the manager's own settings in manager.json that the web interface + /// offers. null keeps a value; an empty master token keeps the one there is — it is + /// never sent out, so a form cannot send it back. + /// + /// Everything here is read once, when the manager starts (license server, token + /// source, update check). The answer says so instead of pretending the change is live. + /// + /// Port, binding and the access token of the web interface are deliberately not + /// offered: a wrong value there locks out the very page it was set from. + /// + public (bool Ok, string Message) UpdateSettings(string? deploymentcenterUrl, string? masterToken, string? updateChannel) + { + if (deploymentcenterUrl is { } url && url.Trim().Length > 0 + && !(Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp))) + return (false, "The Deploymentcenter address must be an http(s) address."); + if (updateChannel is { } channel && channel.Trim() is not ("prod" or "beta" or "dev")) + return (false, "The update channel is one of prod, beta, dev."); + + var settings = _settings.Settings; + if (deploymentcenterUrl is not null) settings.DeploymentcenterUrl = deploymentcenterUrl.Trim(); + if (!string.IsNullOrWhiteSpace(masterToken)) settings.DeploymentcenterMasterToken = masterToken.Trim(); + if (updateChannel is not null) settings.UpdateChannel = updateChannel.Trim(); + _settings.Save(); + + return (true, "Saved. Takes effect when the instance manager restarts."); + } + public async Task HandleLicenseRevokedAsync(string grund) { LicenseHolds = false; diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs index 3cc9df1..d578beb 100644 --- a/src/ClawdDotNet.Web/Contracts/Dtos.cs +++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs @@ -57,7 +57,9 @@ public sealed record AgentDto( IReadOnlyList ForeignContentSources, bool TreatAsForeignReader, // #712 - AgentRuntimeDto Runtime); + AgentRuntimeDto Runtime, + // #764 + string WhenExhausted); /// #712: Where an agent's runs happen. public sealed record AgentRuntimeDto( @@ -132,6 +134,29 @@ public sealed record ServiceInputDto(string Name, string Type, int Port, string /// Ein neuer Agent. public sealed record NewAgentDto(string AgentId, string DisplayName, string Model, string Description = "", string? UnitId = null); +/// #764: A change to an agent. Fields left out (null) keep their value. +public sealed record AgentUpdateDto( + string? DisplayName = null, + string? Model = null, + string? Description = null, + string? SystemPrompt = null, + string? Identity = null, + string? Soul = null, + string? PromptCaching = null, + int? MaxToolResultChars = null, + int? MaxSteps = null, + int? MaxContextTokens = null, + int? TimeoutSeconds = null, + decimal? DailyCostUsd = null, + long? DailyTokens = null, + string? WhenExhausted = null); + +/// +/// #764: The long texts of an agent — fetched for one agent at a time, not in the agent list: +/// with fifty agents the list would carry every role description in the instance. +/// +public sealed record AgentTextsDto(string SystemPrompt, string Identity, string Soul); + /// Eine Aufgabe vom Taskboard. public sealed record TaskDto( string Id, @@ -419,3 +444,27 @@ public sealed record UnitCostDto( /// #699: A cost alert as it was raised. public sealed record CostAlertDto(string Period, string Scope, string Kind, string Message, DateTime RaisedAt); + +/// +/// #764: A change to the instance and application settings. null keeps a value; for +/// the OpenRouter key and the Deploymentcenter token an empty value does as well. +/// +public sealed record SettingsUpdateDto( + string? InstanceName = null, + string? LogDirectory = null, + bool? AutostartByManager = null, + string? OpenRouterApiKey = null, + decimal? DailyCostUsd = null, + long? DailyTokens = null, + bool? AutoBackupEnabled = null, + string? AutoBackupTime = null, + int? BackupKeepCount = null, + string? MinimumLogLevel = null, + string? OpenRouterBaseUrl = null, + int? StatusCheckIntervalSeconds = null, + string? DeploymentcenterUrl = null, + string? DeploymentcenterToken = null, + string? DeploymentcenterEnvironment = null, + bool? ErrorReportingEnabled = null, + bool? UpdateCheckEnabled = null, + string? UpdateChannel = null); diff --git a/src/ClawdDotNet.Web/Contracts/Mapping.cs b/src/ClawdDotNet.Web/Contracts/Mapping.cs index 4e8e639..8ecdfa5 100644 --- a/src/ClawdDotNet.Web/Contracts/Mapping.cs +++ b/src/ClawdDotNet.Web/Contracts/Mapping.cs @@ -45,7 +45,8 @@ internal static class Mapping .ToList(), tools is null ? [] : Core.Organisation.ReportingLine.TrustOf(agent, tools).Sources, agent.TreatAsForeignReader, - agent.Runtime.ToDto()); + agent.Runtime.ToDto(), + agent.Budget.MissPolicy == Core.Tasks.BudgetMissPolicy.CatchUpOnce ? "catchUpOnce" : "expire"); public static AgentRuntimeDto ToDto(this Core.Runtimes.AgentRuntimeConfig runtime) => new(Core.Runtimes.RuntimeKinds.Normalize(runtime.Kind), runtime.AllowedTools, runtime.MaxTurns, diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs index 41c3f5c..497c189 100644 --- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs @@ -34,6 +34,7 @@ internal static class ApiEndpoints api.MapOrganisation(); api.MapCases(); api.MapFileHistory(); + api.MapSettings(); api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto()); app.MapEvents(); @@ -62,6 +63,26 @@ internal static class ApiEndpoints public static IResult Unavailable(string reason) => Results.Json(new OutcomeDto(false, reason), statusCode: StatusCodes.Status503ServiceUnavailable); + // ─── Settings (#764) ─── + + /// + /// What the desktop settings page edited. The snapshot carries no secret — only whether + /// the OpenRouter key and the Deploymentcenter token are set (). + /// + private static void MapSettings(this RouteGroupBuilder api) + { + api.MapGet("/settings", (AppHost host) => Results.Ok(host.SettingsAdmin.Snapshot())); + + api.MapPut("/settings", (AppHost host, SettingsUpdateDto input) => + host.SettingsAdmin.Update(new SettingsUpdate( + input.InstanceName, input.LogDirectory, input.AutostartByManager, input.OpenRouterApiKey, + input.DailyCostUsd, input.DailyTokens, input.AutoBackupEnabled, input.AutoBackupTime, + input.BackupKeepCount, input.MinimumLogLevel, input.OpenRouterBaseUrl, + input.StatusCheckIntervalSeconds, input.DeploymentcenterUrl, input.DeploymentcenterToken, + input.DeploymentcenterEnvironment, input.ErrorReportingEnabled, input.UpdateCheckEnabled, + input.UpdateChannel)).ToResponse()); + } + // ─── Info und Zustand ─── private static void MapInfo(this RouteGroupBuilder api) @@ -136,6 +157,19 @@ internal static class ApiEndpoints agents.MapDelete("/{agentId}", (AppHost host, string agentId) => host.AgentAdmin.Remove(agentId).ToResponse()); + // #764: What the desktop agent page edited — master data, limits, budget, prompt texts. + agents.MapPut("/{agentId}", (AppHost host, string agentId, AgentUpdateDto input) => + host.AgentAdmin.Update(agentId, new AgentUpdate( + input.DisplayName, input.Model, input.Description, input.SystemPrompt, + input.Identity, input.Soul, input.PromptCaching, input.MaxToolResultChars, + input.MaxSteps, input.MaxContextTokens, input.TimeoutSeconds, + input.DailyCostUsd, input.DailyTokens, input.WhenExhausted)).ToResponse()); + + agents.MapGet("/{agentId}/texts", (AppHost host, string agentId) => + host.AgentAdmin.Find(agentId) is { } agent + ? Results.Ok(new AgentTextsDto(agent.SystemPrompt, agent.Identity, agent.Soul)) + : Results.NotFound(new OutcomeDto(false, $"No agent with id '{agentId}'."))); + // #712: the runtime — native, or foreign in the sandbox. agents.MapPut("/{agentId}/runtime", (AppHost host, string agentId, AgentRuntimeDto input) => host.AgentAdmin.SetRuntime(agentId, input.ToConfig()).ToResponse()); diff --git a/tests/ClawdDotNet.App.Tests/Services/AgentUpdateTests.cs b/tests/ClawdDotNet.App.Tests/Services/AgentUpdateTests.cs new file mode 100644 index 0000000..b3b1d9c --- /dev/null +++ b/tests/ClawdDotNet.App.Tests/Services/AgentUpdateTests.cs @@ -0,0 +1,101 @@ +using ClawdDotNet.App.Services; +using ClawdDotNet.Core.Config; +using Shouldly; + +namespace ClawdDotNet.App.Tests.Services; + +/// +/// #764: Editing an agent from the WebUI — what the desktop agent page did directly on the +/// configuration objects, now one checked call. +/// +public sealed class AgentUpdateTests +{ + [Fact] + public void Changes_survive_a_restart_including_identity_and_soul() + { + // Identity and Soul live in their own files; written only into AgentSettings.json they + // would be overwritten by the next load. + using var host = new TestHost(); + host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "old-model")).Ok.ShouldBeTrue(); + + var result = host.Host.AgentAdmin.Update("scout", new AgentUpdate( + DisplayName: "Scout II", Model: "new-model", SystemPrompt: "Be brief.", + Identity: "# Identity: changed", Soul: "# Soul: changed", PromptCaching: "OFF", + MaxSteps: 42, DailyTokens: 12_345, DailyCostUsd: 1.5m, WhenExhausted: "catchupOnce")); + + result.Ok.ShouldBeTrue(result.Message); + + var reloaded = host.Host.Directories + .LoadInstanceConfig(host.Host.InstancePath).Agents + .Single(a => a.AgentId == "scout"); + + reloaded.DisplayName.ShouldBe("Scout II"); + reloaded.Model.ShouldBe("new-model"); + reloaded.SystemPrompt.ShouldBe("Be brief."); + reloaded.Identity.ShouldBe("# Identity: changed"); + reloaded.Soul.ShouldBe("# Soul: changed"); + reloaded.PromptCaching.ShouldBe("off"); + reloaded.LoopGuard.MaxSteps.ShouldBe(42); + reloaded.Budget.DailyTokens.ShouldBe(12_345); + reloaded.Budget.DailyCostUsd.ShouldBe(1.5m); + reloaded.Budget.WhenExhausted.ShouldBe("catchUpOnce"); + } + + [Fact] + public void Fields_left_out_keep_their_value() + { + using var host = new TestHost(); + host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "model", "Watches the market")).Ok.ShouldBeTrue(); + host.Host.AgentAdmin.Update("scout", new AgentUpdate(Identity: "keep me")).Ok.ShouldBeTrue(); + + host.Host.AgentAdmin.Update("scout", new AgentUpdate(Model: "other")).Ok.ShouldBeTrue(); + + var agent = host.Host.AgentAdmin.Find("scout")!; + agent.Model.ShouldBe("other"); + agent.Description.ShouldBe("Watches the market"); + agent.Identity.ShouldBe("keep me"); + } + + [Theory] + [InlineData("display")] + [InlineData("caching")] + [InlineData("exhausted")] + [InlineData("steps")] + [InlineData("budget")] + public void One_bad_value_changes_nothing(string bad) + { + using var host = new TestHost(TestHost.Agent("scout", "Scout")); + var update = bad switch + { + "display" => new AgentUpdate(Model: "new-model", DisplayName: " "), + "caching" => new AgentUpdate(Model: "new-model", PromptCaching: "sometimes"), + "exhausted" => new AgentUpdate(Model: "new-model", WhenExhausted: "panic"), + "steps" => new AgentUpdate(Model: "new-model", MaxSteps: 0), + _ => new AgentUpdate(Model: "new-model", DailyCostUsd: -1m) + }; + + host.Host.AgentAdmin.Update("scout", update).Ok.ShouldBeFalse(); + + host.Host.AgentAdmin.Find("scout")!.Model.ShouldBe("anthropic/claude-sonnet-4-5"); + host.Changes.ShouldBeEmpty(); + } + + [Fact] + public void An_unknown_agent_is_refused() + { + using var host = new TestHost(); + + host.Host.AgentAdmin.Update("ghost", new AgentUpdate(Model: "x")).Ok.ShouldBeFalse(); + } + + [Fact] + public void A_change_is_announced() + { + // Without the event the other views (and a second browser) would keep the old values. + using var host = new TestHost(TestHost.Agent("scout")); + + host.Host.AgentAdmin.Update("scout", new AgentUpdate(Model: "x")).Ok.ShouldBeTrue(); + + host.Changes.ShouldHaveSingleItem().Kind.ShouldBe(InstanceChangeKind.Agents); + } +} diff --git a/tests/ClawdDotNet.App.Tests/Services/SettingsAdministrationTests.cs b/tests/ClawdDotNet.App.Tests/Services/SettingsAdministrationTests.cs new file mode 100644 index 0000000..363475c --- /dev/null +++ b/tests/ClawdDotNet.App.Tests/Services/SettingsAdministrationTests.cs @@ -0,0 +1,116 @@ +using ClawdDotNet.App.Services; +using ClawdDotNet.App.Settings; +using Shouldly; + +namespace ClawdDotNet.App.Tests.Services; + +/// +/// #764: The settings the WebUI reads and writes — what the desktop settings page edited. +/// +public sealed class SettingsAdministrationTests +{ + [Fact] + public void The_snapshot_never_carries_a_secret() + { + using var host = new TestHost(); + host.Host.Instance.OpenRouterApiKey = "sk-or-secret"; + host.Host.Settings.AppSettings.DeploymentcenterToken = "dc-secret"; + + var snapshot = host.Host.SettingsAdmin.Snapshot(); + + snapshot.OpenRouterApiKeySet.ShouldBeTrue(); + snapshot.DeploymentcenterTokenSet.ShouldBeTrue(); + System.Text.Json.JsonSerializer.Serialize(snapshot).ShouldNotContain("secret"); + } + + [Fact] + public void Instance_settings_survive_a_restart() + { + using var host = new TestHost(); + host.Host.Directories.SaveInstanceConfig(host.Host.InstancePath, host.Host.Instance); + + var result = host.Host.SettingsAdmin.Update(new SettingsUpdate( + InstanceName: "Renamed", AutostartByManager: true, DailyCostUsd: 2.5m, DailyTokens: 900, + AutoBackupEnabled: true, AutoBackupTime: "4:30", BackupKeepCount: 7)); + + result.Ok.ShouldBeTrue(result.Message); + + var reloaded = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath); + reloaded.InstanceName.ShouldBe("Renamed"); + reloaded.AutostartByManager.ShouldBeTrue(); + reloaded.Budget.DailyCostUsd.ShouldBe(2.5m); + reloaded.Budget.DailyTokens.ShouldBe(900); + reloaded.AutoBackupEnabled.ShouldBeTrue(); + reloaded.AutoBackupTime.ShouldBe("04:30"); + reloaded.BackupKeepCount.ShouldBe(7); + } + + [Fact] + public void An_empty_secret_keeps_the_one_there_is() + { + // The form never gets the key back, so it sends an empty field. That must not wipe it. + using var host = new TestHost(); + host.Host.Instance.OpenRouterApiKey = "sk-or-keep"; + host.Host.Settings.AppSettings.DeploymentcenterToken = "dc-keep"; + + host.Host.SettingsAdmin.Update(new SettingsUpdate(OpenRouterApiKey: "", DeploymentcenterToken: " ")).Ok.ShouldBeTrue(); + + host.Host.Instance.OpenRouterApiKey.ShouldBe("sk-or-keep"); + host.Host.Settings.AppSettings.DeploymentcenterToken.ShouldBe("dc-keep"); + } + + [Fact] + public void A_new_key_says_it_needs_a_restart() + { + // The engine is built at start; without saying so the save button would seem to do nothing. + using var host = new TestHost(); + + var result = host.Host.SettingsAdmin.Update(new SettingsUpdate(OpenRouterApiKey: "sk-or-new")); + + result.Ok.ShouldBeTrue(); + result.Message.ShouldContain("restart"); + host.Host.Instance.OpenRouterApiKey.ShouldBe("sk-or-new"); + } + + [Fact] + public void Application_settings_are_written_to_settings_json() + { + using var host = new TestHost(); + + host.Host.SettingsAdmin.Update(new SettingsUpdate( + MinimumLogLevel: "warn", UpdateChannel: "beta", ErrorReportingEnabled: false)).Ok.ShouldBeTrue(); + + var root = Path.GetDirectoryName(host.Host.Settings.SettingsPath)!; + var reloaded = new SettingsManager(root); + reloaded.Load(); + reloaded.AppSettings.MinimumLogLevel.ShouldBe("Warn"); + reloaded.AppSettings.UpdateChannel.ShouldBe("beta"); + reloaded.AppSettings.ErrorReportingEnabled.ShouldBeFalse(); + } + + [Theory] + [InlineData("time")] + [InlineData("level")] + [InlineData("url")] + [InlineData("channel")] + [InlineData("name")] + [InlineData("keep")] + public void One_bad_value_changes_nothing(string bad) + { + using var host = new TestHost(); + var update = bad switch + { + "time" => new SettingsUpdate(DailyTokens: 5, AutoBackupTime: "25:00"), + "level" => new SettingsUpdate(DailyTokens: 5, MinimumLogLevel: "Chatty"), + "url" => new SettingsUpdate(DailyTokens: 5, OpenRouterBaseUrl: "file:///etc/passwd"), + "channel" => new SettingsUpdate(DailyTokens: 5, UpdateChannel: "nightly"), + "name" => new SettingsUpdate(DailyTokens: 5, InstanceName: " "), + _ => new SettingsUpdate(DailyTokens: 5, BackupKeepCount: -1) + }; + + host.Host.SettingsAdmin.Update(update).Ok.ShouldBeFalse(); + + host.Host.Instance.Budget.DailyTokens.ShouldBe(0); + host.Changes.ShouldBeEmpty(); + } +} diff --git a/tests/ClawdDotNet.Manager.Tests/LicenseKeyEntryTests.cs b/tests/ClawdDotNet.Manager.Tests/LicenseKeyEntryTests.cs new file mode 100644 index 0000000..bfb343c --- /dev/null +++ b/tests/ClawdDotNet.Manager.Tests/LicenseKeyEntryTests.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Instances; +using ClawdDotNet.Licensing; +using Deploymentcenter.Client; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Manager.Tests; + +/// +/// #764: The license key entered in the web interface — the only way in for a manager that +/// runs as a service and never asks (). +/// +/// The license server is replaced by given answers. The promise checked here is not +/// "the server judges correctly" — that is the SDK's job — but "only a confirmed key is +/// stored, and a confirmed key lifts the lock without a restart". +/// +public sealed class LicenseKeyEntryTests : IAsyncDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "lic-" + Guid.NewGuid().ToString("N")[..8]); + + private readonly FakeLauncher _launcher = new(); + private readonly FakeChannel _channel = new(); + private readonly InstanceDirectoryManager _directories; + + /// What the "server" says, per key. + private readonly Dictionary _answers = new(); + + private static readonly SupervisionOptions Options = new( + ProbeInterval: TimeSpan.FromMilliseconds(50), + StartTimeout: TimeSpan.FromMilliseconds(600), + SilentChecksBeforeAlarm: 3, + StopGrace: TimeSpan.FromMilliseconds(300), + SilentKillGrace: TimeSpan.FromMilliseconds(300), + MaxDrainWait: TimeSpan.FromSeconds(2)); + + public LicenseKeyEntryTests() + { + Directory.CreateDirectory(_root); + _directories = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); + _channel.Launcher = _launcher; + + _answers["GOOD-KEY"] = new LicenseValidationResult { IsValid = true, Status = "valid" }; + _answers["REVOKED-KEY"] = new LicenseValidationResult { IsValid = false, Status = "revoked" }; + _answers["OFFLINE-KEY"] = new LicenseValidationResult { IsValid = false, Status = "server_unavailable", IsTransient = true }; + } + + public async ValueTask DisposeAsync() + { + await Task.CompletedTask; + try { Directory.Delete(_root, recursive: true); } + catch { /* A leftover test folder does not turn a run red. */ } + } + + private ManagerCore Manager() + { + var store = new ManagerSettingsStore(_root); + store.Load(); + + var gate = new LicenseGate( + new ManagerLicenseStore(store), NullLogger.Instance, + new NonInteractiveLicensePrompt(_ => { }), "0.0.0-test", + (key, _) => Task.FromResult(_answers.TryGetValue(key, out var answer) + ? answer + : new LicenseValidationResult { IsValid = false, Status = "not_found" })); + + var registry = new InstanceRegistry( + _directories, _launcher, _channel, NullLoggerFactory.Instance, Options); + + return new ManagerCore(store, registry, new FakeTokenSource(), NullLoggerFactory.Instance, license: gate); + } + + private string StoredKey() + { + var store = new ManagerSettingsStore(_root); + return store.Load().LicenseKey; + } + + private void AutostartInstance(string name) + { + var path = _directories.CreateInstance(name); + var file = Path.Combine(path, "InstanceSettings.json"); + var config = JsonSerializer.Deserialize(File.ReadAllText(file))!; + config.AutostartByManager = true; + File.WriteAllText(file, JsonSerializer.Serialize(config)); + _channel.AnswerFor[Path.GetFullPath(path)] = new InstanceIdentity("i-" + name, name, "0.1.2"); + } + + [Fact] + public async Task A_confirmed_key_is_stored_and_lifts_the_lock_at_once() + { + AutostartInstance("Alpha"); + + await using var manager = Manager(); + (await manager.StartAsync()).ShouldBeFalse(); // no key yet: locked + _launcher.Starts.ShouldBe(0); + + var result = await manager.ApplyLicenseKeyAsync(" GOOD-KEY "); + + result.Applied.ShouldBeTrue(result.Message); + manager.LicenseHolds.ShouldBeTrue(); + StoredKey().ShouldBe("GOOD-KEY"); + _launcher.Starts.ShouldBe(1); // the autostart instance came up + } + + [Fact] + public async Task A_refused_key_leaves_the_current_one_in_place() + { + await using (var first = Manager()) + (await first.ApplyLicenseKeyAsync("GOOD-KEY")).Applied.ShouldBeTrue(); + + await using var manager = Manager(); + var result = await manager.ApplyLicenseKeyAsync("REVOKED-KEY"); + + result.Applied.ShouldBeFalse(); + result.Transient.ShouldBeFalse(); + StoredKey().ShouldBe("GOOD-KEY"); + } + + [Fact] + public async Task An_unreachable_server_stores_nothing() + { + await using var manager = Manager(); + await manager.StartAsync(); + + var result = await manager.ApplyLicenseKeyAsync("OFFLINE-KEY"); + + result.Applied.ShouldBeFalse(); + result.Transient.ShouldBeTrue(); + manager.LicenseHolds.ShouldBeFalse(); + StoredKey().ShouldBeEmpty(); + } + + [Fact] + public async Task An_empty_key_is_not_sent_anywhere() + { + await using var manager = Manager(); + + (await manager.ApplyLicenseKeyAsync(" ")).Applied.ShouldBeFalse(); + } + + [Fact] + public async Task Without_a_license_check_nothing_is_stored() + { + var registry = new InstanceRegistry( + _directories, _launcher, _channel, NullLoggerFactory.Instance, Options); + await using var manager = new ManagerCore( + new ManagerSettingsStore(_root), registry, new FakeTokenSource(), NullLoggerFactory.Instance); + + (await manager.ApplyLicenseKeyAsync("GOOD-KEY")).Applied.ShouldBeFalse(); + StoredKey().ShouldBeEmpty(); + } +} diff --git a/tests/ClawdDotNet.Web.Tests/LanguagePackTests.cs b/tests/ClawdDotNet.Web.Tests/LanguagePackTests.cs new file mode 100644 index 0000000..bfac69c --- /dev/null +++ b/tests/ClawdDotNet.Web.Tests/LanguagePackTests.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Shouldly; + +namespace ClawdDotNet.Web.Tests; + +/// +/// #764 / #661: The WebUI texts come from language packs (wwwroot/i18n/en.json and +/// de.json). A key missing in one pack shows up as the bare key on screen — in the +/// language nobody tested in. So every run checks the packs, not a person after each change. +/// +public sealed partial class LanguagePackTests +{ + private static readonly string WwwRoot = Path.Combine( + FindRepositoryRoot(), "src", "ClawdDotNet.Manager.Web", "wwwroot"); + + private static string FindRepositoryRoot() + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent) + { + if (File.Exists(Path.Combine(dir.FullName, "ClawdDotNet.slnx"))) + return dir.FullName; + } + + throw new InvalidOperationException("Repository root not found above " + AppContext.BaseDirectory); + } + + private static Dictionary Pack(string language) + => JsonSerializer.Deserialize>( + File.ReadAllText(Path.Combine(WwwRoot, "i18n", language + ".json")))!; + + [GeneratedRegex(@"\{(\w+)\}")] + private static partial Regex Placeholder(); + + // t("key" … — but not erstelleElement("div": the t must stand on its own. + [GeneratedRegex(@"(? k != "trigger.instructionHint") + .Where(k => !Placeholder().Matches(en[k]).Select(m => m.Value).ToHashSet() + .SetEquals(Placeholder().Matches(de[k]).Select(m => m.Value))) + .ToList(); + + mismatches.ShouldBeEmpty(); + } + + [Fact] + public void Every_key_the_code_asks_for_exists() + { + var en = Pack("en"); + + var missing = Directory.EnumerateFiles(WwwRoot, "*.js", SearchOption.AllDirectories) + .Where(f => !f.Contains(Path.DirectorySeparatorChar + "vendor" + Path.DirectorySeparatorChar)) + .SelectMany(f => TranslateCall().Matches(File.ReadAllText(f)) + .Select(m => (File: Path.GetFileName(f), Key: m.Groups[1].Value))) + .Where(x => !en.ContainsKey(x.Key)) + // The example in the doc comment of i18n.js. + .Where(x => x.Key != "runs.count") + .Select(x => $"{x.File}: {x.Key}") + .Distinct() + .ToList(); + + missing.ShouldBeEmpty(); + } + + [Fact] + public void No_text_is_empty() + { + foreach (var language in new[] { "en", "de" }) + Pack(language).Where(p => string.IsNullOrWhiteSpace(p.Value)).Select(p => p.Key).ShouldBeEmpty(language); + } +}