diff --git a/src/OpenClaw.Shared/IOperatorGatewayClient.cs b/src/OpenClaw.Shared/IOperatorGatewayClient.cs index 08ea6a7c1..328309196 100644 --- a/src/OpenClaw.Shared/IOperatorGatewayClient.cs +++ b/src/OpenClaw.Shared/IOperatorGatewayClient.cs @@ -87,6 +87,8 @@ public interface IOperatorGatewayClient Task RequestNodePairListAsync(); Task NodePairApproveAsync(string requestId); Task NodePairRejectAsync(string requestId); + Task NodePairRemoveAsync(string nodeId); + Task NodeRenameAsync(string nodeId, string displayName); Task RequestDevicePairListAsync(); Task DevicePairApproveAsync(string requestId); Task DevicePairRejectAsync(string requestId); diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index d38d0fb08..4606da22a 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -491,6 +491,32 @@ public class GatewayNodeInfo public List DisabledCommands { get; set; } = new(); public Dictionary Permissions { get; set; } = new(StringComparer.OrdinalIgnoreCase); + // Identity / hardware (from gateway NodeListNode schema) + public string? Version { get; set; } + public string? CoreVersion { get; set; } + public string? UiVersion { get; set; } + public string? ClientId { get; set; } + public string? ClientMode { get; set; } + public string? DeviceFamily { get; set; } + public string? ModelIdentifier { get; set; } + public string? RemoteIp { get; set; } + public string? PathEnv { get; set; } + + // Timestamps and state + public DateTime? ConnectedAt { get; set; } + public DateTime? ApprovedAt { get; set; } + public string? LastSeenReason { get; set; } + + // True when the node is in the gateway's paired set (regardless of current + // connection state). Distinct from IsOnline — a paired node can be offline. + public bool IsPaired { get; set; } + + // True when the gateway provided an explicit displayName/name/label. + // False when the parser had to fall back to shortId or nodeId. UI surfaces + // (e.g. the rename dialog) use this to distinguish "the user gave this + // node a name" from "we showed the id because there was nothing better". + public bool HasExplicitDisplayName { get; set; } + public string ShortId => NodeId.Length <= 12 ? NodeId : NodeId[..12] + "…"; public string DisplayText @@ -522,6 +548,26 @@ public string DetailText private static string FormatAge(DateTime timestampUtc) => ModelFormatting.FormatAge(timestampUtc); } +/// +/// Result of a node.rename request to the gateway. +/// +/// True when the gateway accepted the rename and persisted it. +/// Node id the gateway returned; same as the requested id on success. +/// Updated display name as persisted by the gateway. +/// Gateway-supplied or transport-derived error description; null on success. +public sealed record NodeRenameResult( + bool Success, + string? NodeId = null, + string? DisplayName = null, + string? ErrorMessage = null); + +/// +/// Result of a node.pair.remove request to the gateway. +/// +/// True when the gateway accepted the removal. +/// Gateway-supplied or transport-derived error description; null on success. +public sealed record NodeForgetResult(bool Success, string? ErrorMessage = null); + public enum GatewayDiagnosticSeverity { Info, @@ -1490,18 +1536,28 @@ private static string BuildLocalTunnelUrl(int localPort) => } /// Shared display-formatting helpers used by model classes. -internal static class ModelFormatting +public static class ModelFormatting { /// - /// Formats a UTC timestamp as a human-readable age string (e.g. "just now", "5m ago", "2h ago", "3d ago"). + /// Formats a UTC timestamp as a human-readable age string. + /// Examples: "just now", "5m ago", "12h ago", "3d ago", "2026-03-12". + /// Public so all UI surfaces share one canonical formatter — divergent + /// thresholds between callers can otherwise show the same timestamp as + /// "1d ago" in one place and "36h ago" in another for the same node. /// - internal static string FormatAge(DateTime timestampUtc) + public static string FormatAge(DateTime timestampUtc) { var delta = DateTime.UtcNow - timestampUtc; + // Clock skew between gateway host and local machine can produce + // timestamps slightly in the future. Treat those as "just now". + if (delta < TimeSpan.Zero) return "just now"; if (delta.TotalSeconds < 60) return "just now"; if (delta.TotalMinutes < 60) return $"{(int)delta.TotalMinutes}m ago"; if (delta.TotalHours < 48) return $"{(int)delta.TotalHours}h ago"; - return $"{(int)delta.TotalDays}d ago"; + if (delta.TotalDays < 30) return $"{(int)delta.TotalDays}d ago"; + // For very old timestamps the relative form loses meaning; show + // an absolute local date instead. + return timestampUtc.ToLocalTime().ToString("yyyy-MM-dd"); } /// diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 08f70e59e..c786be0fd 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -571,6 +571,101 @@ public Task NodePairRejectAsync(string requestId) return TrySendTrackedRequestAsync("node.pair.reject", new { requestId }); } + /// + /// Removes a paired node from the gateway and waits for the gateway's + /// application-level response. Returns Success=true only when the + /// gateway confirms the removal — Success=false on transport failure, + /// missing scope, unknown nodeId, or any server-side rejection. The + /// gateway also broadcasts node.pair.resolved with + /// decision="removed" after success, which the broadcast handler + /// turns into a node.list + node.pair.list refresh. + /// + public async Task NodePairRemoveAsync(string nodeId) + { + if (string.IsNullOrWhiteSpace(nodeId)) + return new NodeForgetResult(false, "nodeId required"); + if (!IsConnected) + return new NodeForgetResult(false, "Gateway connection is not open"); + + try + { + // SendWizardRequestAsync awaits the matching ack frame and + // throws InvalidOperationException when the gateway responds + // with ok=false, so callers see a real failure result on + // rejection (missing scope, unknown nodeId) rather than a + // false success the moment the WS frame is sent. + await SendWizardRequestAsync("node.pair.remove", new { nodeId }); + return new NodeForgetResult(true); + } + catch (InvalidOperationException ex) + { + // Gateway business error (e.g. "missing scope: operator.pairing", + // "unknown nodeId"). Surface this verbatim so the user sees an + // actionable message. + _logger.Warn($"node.pair.remove rejected: {ex.Message}"); + return new NodeForgetResult(false, ex.Message); + } + catch (Exception ex) + { + // Transport / timeout / unexpected exception. Don't leak raw + // exception text into the UI — return null so the caller uses + // its localized fallback string. + _logger.Warn($"node.pair.remove failed: {ex.Message}"); + return new NodeForgetResult(false, ErrorMessage: null); + } + } + + /// + /// Renames the display name of a paired node. Awaits the gateway's + /// response, so callers can rely on + /// before refreshing UI state. The gateway does not broadcast a rename, + /// so callers should follow a successful rename with + /// to pick up the new value. + /// + public async Task NodeRenameAsync(string nodeId, string displayName) + { + if (string.IsNullOrWhiteSpace(nodeId)) + return new NodeRenameResult(false, ErrorMessage: "nodeId required"); + var trimmed = displayName?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(trimmed)) + return new NodeRenameResult(false, ErrorMessage: "displayName required"); + if (!IsConnected) + return new NodeRenameResult(false, ErrorMessage: "Gateway connection is not open"); + + try + { + var response = await SendWizardRequestAsync( + "node.rename", + new { nodeId, displayName = trimmed }); + + var returnedNodeId = response.ValueKind == JsonValueKind.Object && + response.TryGetProperty("nodeId", out var idEl) + ? idEl.GetString() + : nodeId; + var returnedDisplayName = response.ValueKind == JsonValueKind.Object && + response.TryGetProperty("displayName", out var nameEl) + ? nameEl.GetString() ?? trimmed + : trimmed; + return new NodeRenameResult(true, returnedNodeId, returnedDisplayName); + } + catch (InvalidOperationException ex) + { + // Gateway business error (e.g. "missing scope: operator.pairing", + // "unknown nodeId"). Surface this verbatim so the user sees an + // actionable message. + _logger.Warn($"node.rename rejected: {ex.Message}"); + return new NodeRenameResult(false, ErrorMessage: ex.Message); + } + catch (Exception ex) + { + // Transport / timeout / unexpected exception. Don't leak raw + // exception text into the UI — return null so the caller uses + // its localized fallback string. + _logger.Warn($"node.rename failed: {ex.Message}"); + return new NodeRenameResult(false, ErrorMessage: null); + } + } + public async Task RequestDevicePairListAsync() { if (_devicePairListUnsupported) return; @@ -1815,8 +1910,13 @@ private void HandleEvent(JsonElement root) break; case "node.pair.requested": case "node.pair.resolved": - // Refresh node pair list when pairing state changes + // Refresh node pair list when pairing state changes. Also + // refresh node.list because resolved decisions (in particular + // "removed") drop the node from the gateway's known set, so + // any UI mirroring node.list would otherwise show stale data + // until the next poll. _ = RequestNodePairListAsync(); + _ = RequestNodesAsync(); break; case "device.pair.requested": case "device.pair.resolved": @@ -2395,40 +2495,73 @@ private void ParseNodeList(JsonElement nodesPayload) "unknown"); var connected = GetOptionalBool(nodeElement, "connected"); var online = GetOptionalBool(nodeElement, "online"); + var paired = GetOptionalBool(nodeElement, "paired"); var capabilities = GetStringArray(nodeElement, "caps"); if (capabilities.Length == 0) capabilities = GetStringArray(nodeElement, "capabilities"); var commands = GetStringArray(nodeElement, "declaredCommands"); if (commands.Length == 0) commands = GetStringArray(nodeElement, "commands"); + var disabledCommands = GetStringArray(nodeElement, "disabledCommands"); var permissions = GetBoolDictionary(nodeElement, "permissions"); + var clientMode = GetString(nodeElement, "clientMode"); + + // Distinguish "user gave this node a name" from "we fell back + // to the id". The rename dialog uses this so it can prefill + // empty when the node has no explicit name (rather than + // pre-seeding the textbox with the id, which would otherwise + // get persisted as the new display name on Enter). + var explicitName = FirstNonEmpty( + GetString(nodeElement, "displayName"), + GetString(nodeElement, "name"), + GetString(nodeElement, "label")); + buffer[count++] = new GatewayNodeInfo { NodeId = nodeId!, - DisplayName = FirstNonEmpty( - GetString(nodeElement, "displayName"), - GetString(nodeElement, "name"), - GetString(nodeElement, "label"), - GetString(nodeElement, "shortId"), - nodeId)!, + DisplayName = !string.IsNullOrWhiteSpace(explicitName) + ? explicitName! + : FirstNonEmpty(GetString(nodeElement, "shortId"), nodeId)!, + HasExplicitDisplayName = !string.IsNullOrWhiteSpace(explicitName), Mode = FirstNonEmpty( GetString(nodeElement, "mode"), - GetString(nodeElement, "clientMode"), + clientMode, "node")!, Status = status!, Platform = FirstNonEmpty( GetString(nodeElement, "platform"), GetString(nodeElement, "os")), - LastSeen = ParseUnixTimestampMs(nodeElement, "lastSeenAt") ?? + // Gateway NodeListNode wire schema uses *Ms suffix; older + // fallbacks kept for compatibility with mocks/tests. + // ConnectedAt is parsed independently below — do NOT fall + // back to it here, otherwise the UI shows the same value + // twice as both "Connected Xm ago" and "Seen Xm ago". + LastSeen = ParseUnixTimestampMs(nodeElement, "lastSeenAtMs") ?? + ParseUnixTimestampMs(nodeElement, "lastSeenAt") ?? ParseUnixTimestampMs(nodeElement, "lastSeen") ?? - ParseUnixTimestampMs(nodeElement, "updatedAt") ?? - ParseUnixTimestampMs(nodeElement, "connectedAt"), + ParseUnixTimestampMs(nodeElement, "updatedAt"), + ConnectedAt = ParseUnixTimestampMs(nodeElement, "connectedAtMs") ?? + ParseUnixTimestampMs(nodeElement, "connectedAt"), + ApprovedAt = ParseUnixTimestampMs(nodeElement, "approvedAtMs") ?? + ParseUnixTimestampMs(nodeElement, "approvedAt"), + LastSeenReason = GetString(nodeElement, "lastSeenReason"), Capabilities = capabilities.ToList(), Commands = commands.ToList(), + DisabledCommands = disabledCommands.ToList(), Permissions = permissions, CapabilityCount = capabilities.Length, CommandCount = commands.Length, + Version = GetString(nodeElement, "version"), + CoreVersion = GetString(nodeElement, "coreVersion"), + UiVersion = GetString(nodeElement, "uiVersion"), + ClientId = GetString(nodeElement, "clientId"), + ClientMode = clientMode, + DeviceFamily = GetString(nodeElement, "deviceFamily"), + ModelIdentifier = GetString(nodeElement, "modelIdentifier"), + RemoteIp = GetString(nodeElement, "remoteIp"), + PathEnv = GetString(nodeElement, "pathEnv"), + IsPaired = paired ?? false, IsOnline = online ?? connected ?? status is "ok" or "online" or "connected" or "ready" or "active" }; } diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index ccc0cfcdf..43f4ceffb 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -1383,18 +1383,21 @@ private void BuildTrayMenuPopup(TrayMenuWindow menu) } // ── Connected Devices with inline permission toggles ── - if (_lastNodes.Length > 0) + // Only show currently-connected nodes; offline/stale paired nodes + // remain visible on the full Nodes page where they can be renamed + // or forgotten. + var connectedNodes = _lastNodes.Where(n => n.IsOnline).ToArray(); + if (connectedNodes.Length > 0) { menu.AddSeparator(); - var onlineCount = _lastNodes.Count(n => n.IsOnline); - var totalCaps = _lastNodes.Sum(n => n.CapabilityCount); - var deviceSummaryRight = $"{onlineCount} online · {totalCaps} caps"; + var totalCaps = connectedNodes.Sum(n => n.CapabilityCount); + var deviceSummaryRight = $"{connectedNodes.Length} online · {totalCaps} caps"; menu.AddCustomElement(BuildSectionHeader("Devices", deviceSummaryRight)); var currentHost = Environment.MachineName; - foreach (var node in _lastNodes.Take(5)) + foreach (var node in connectedNodes.Take(5)) { var card = BuildDeviceCard(node); var flyoutItems = BuildDeviceFlyoutItems(node); diff --git a/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs index 032cb7a9c..750826f75 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs @@ -4,9 +4,12 @@ using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Shapes; using OpenClaw.Shared; +using OpenClawTray.Helpers; using OpenClawTray.Windows; using System; using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using WinDataTransfer = global::Windows.ApplicationModel.DataTransfer; using WinColor = global::Windows.UI.Color; @@ -15,6 +18,12 @@ namespace OpenClawTray.Pages; public sealed partial class NodesPage : Page { private HubWindow? _hub; + // Page-wide guard for ContentDialog reentrancy. WinUI 3 only permits one + // ContentDialog per XamlRoot at a time, so a per-node guard is not enough + // (Rename on node A and Forget on node B in quick succession would + // otherwise throw inside the second ShowAsync). Match the convention used + // by SandboxPage's _confirmDialogOpen field. + private bool _dialogOpen; public NodesPage() { @@ -48,24 +57,14 @@ public void UpdateNodes(GatewayNodeInfo[] nodes) } EmptyState.Visibility = Visibility.Collapsed; - var vms = new List(); - foreach (var n in nodes) - { - vms.Add(new NodeViewModel - { - Name = string.IsNullOrWhiteSpace(n.DisplayName) ? n.ShortId : n.DisplayName, - DeviceId = n.NodeId, - Platform = n.Platform ?? "unknown", - IsOnline = n.IsOnline, - Capabilities = Array.Empty(), - Commands = Array.Empty(), - }); - } - RenderNodes(vms); + // Render straight from the gateway model: Capabilities, Commands + // and Permissions are already populated by ParseNodeList and the + // card layout consumes them as-is. + RenderNodes(nodes); }); } - private void RenderNodes(List nodes) + private void RenderNodes(IReadOnlyList nodes) { NodesList.Children.Clear(); if (nodes.Count == 0) @@ -76,125 +75,665 @@ private void RenderNodes(List nodes) EmptyState.Visibility = Visibility.Collapsed; - foreach (var vm in nodes) + foreach (var node in nodes) { - var card = new Border + NodesList.Children.Add(BuildNodeCard(node)); + } + } + + private Border BuildNodeCard(GatewayNodeInfo node) + { + var card = new Border + { + Background = (Brush)Application.Current.Resources["CardBackgroundFillColorDefaultBrush"], + BorderBrush = (Brush)Application.Current.Resources["CardStrokeColorDefaultBrush"], + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + }; + + var expander = new Expander + { + IsExpanded = node.IsOnline, + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch, + Header = BuildCardHeader(node), + Content = BuildCardDetails(node), + }; + + card.Child = expander; + return card; + } + + private static Grid BuildCardHeader(GatewayNodeInfo node) + { + // Header is purely informational: dot · name · platform badge · + // status caption. Actions live in the body footer (Win11 Settings + // pattern: see Settings → Accounts → Email & accounts → Manage / + // Remove buttons at the bottom). + // + // Use a Grid (not a horizontal StackPanel) so a long display name + // ellipsizes at the available width instead of pushing the platform + // badge and caption off the right edge. + var header = new Grid + { + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Center, + ColumnSpacing = 10, + }; + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // dot + header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // name (ellipsizes) + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // platform badge + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // status caption + + var dot = new Ellipse + { + Width = 10, + Height = 10, + VerticalAlignment = VerticalAlignment.Center, + Fill = node.IsOnline ? new SolidColorBrush(Colors.LimeGreen) : new SolidColorBrush(Colors.Gray), + }; + Grid.SetColumn(dot, 0); + header.Children.Add(dot); + + var nameLabel = string.IsNullOrWhiteSpace(node.DisplayName) ? node.ShortId : node.DisplayName; + var nameText = new TextBlock + { + Text = nameLabel, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + TextWrapping = TextWrapping.NoWrap, + }; + ToolTipService.SetToolTip(nameText, nameLabel); + Grid.SetColumn(nameText, 1); + header.Children.Add(nameText); + + var platform = node.Platform ?? "unknown"; + var platformBadge = new Border + { + Background = new SolidColorBrush(GetPlatformColor(platform)), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(8, 2, 8, 2), + VerticalAlignment = VerticalAlignment.Center, + }; + platformBadge.Child = new TextBlock + { + Text = platform, + FontSize = 11, + Foreground = new SolidColorBrush(Colors.White), + }; + Grid.SetColumn(platformBadge, 2); + header.Children.Add(platformBadge); + + var detailText = new TextBlock + { + Text = node.DetailText, + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(detailText, 3); + header.Children.Add(detailText); + + return header; + } + + private StackPanel BuildCardDetails(GatewayNodeInfo node) + { + var stack = new StackPanel { Spacing = 10 }; + + // Short identity row + stack.Children.Add(BuildIdentityRow(node)); + + // Optional one-line fact rows. Each helper returns null when the + // backing data is empty so we don't render empty labels. + AppendIfNotNull(stack, BuildVersionRow(node)); + AppendIfNotNull(stack, BuildHardwareRow(node)); + AppendIfNotNull(stack, BuildNetworkRow(node)); + AppendIfNotNull(stack, BuildTimestampsRow(node)); + AppendIfNotNull(stack, BuildCapabilitiesSection(node)); + AppendIfNotNull(stack, BuildCommandsSection(node)); + AppendIfNotNull(stack, BuildPermissionsSection(node)); + AppendIfNotNull(stack, BuildPathEnvSection(node)); + + // Actions footer — separator line then right-aligned Rename/Forget. + // This mirrors how Win11 Settings places "Manage" / "Remove" actions + // at the bottom of an account or device card: they're visually + // separated from the informational content so destructive actions + // are deliberate, not accidental. + stack.Children.Add(BuildActionFooter(node)); + + return stack; + } + + private static void AppendIfNotNull(StackPanel stack, UIElement? element) + { + if (element != null) stack.Children.Add(element); + } + + private StackPanel BuildActionFooter(GatewayNodeInfo node) + { + var clientAvailable = _hub?.GatewayClient != null; + + var footer = new StackPanel + { + Spacing = 8, + Margin = new Thickness(0, 8, 0, 0), + }; + + // Subtle 1px divider above the actions. Uses the same stroke colour + // the card itself uses for its border so the line feels native. + footer.Children.Add(new Border + { + Height = 1, + Background = (Brush)Application.Current.Resources["CardStrokeColorDefaultBrush"], + HorizontalAlignment = HorizontalAlignment.Stretch, + }); + + var actions = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + HorizontalAlignment = HorizontalAlignment.Right, + }; + + var renameBtn = new Button + { + Content = LocalizationHelper.GetString("NodesPage_Action_Rename"), + IsEnabled = clientAvailable, + MinWidth = 96, + }; + renameBtn.Click += async (s, e) => + { + try { await OnRenameClickedAsync(node); } + catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Rename click failed: {ex}"); } + }; + actions.Children.Add(renameBtn); + + var forgetBtn = new Button + { + Content = LocalizationHelper.GetString("NodesPage_Action_Forget"), + IsEnabled = clientAvailable, + MinWidth = 96, + // Critical-coloured text marks destructive intent without + // turning the whole button red — destructive primary buttons + // are reserved for the confirmation dialog. + Foreground = (Brush)Application.Current.Resources["SystemFillColorCriticalBrush"], + }; + forgetBtn.Click += async (s, e) => + { + try { await OnForgetClickedAsync(node); } + catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Forget click failed: {ex}"); } + }; + actions.Children.Add(forgetBtn); + + footer.Children.Add(actions); + return footer; + } + + private Grid BuildIdentityRow(GatewayNodeInfo node) + { + // Use a Grid so a long node id (full GUIDs are 36+ chars) ellipsizes + // instead of pushing the copy button off-screen or out of the card. + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var idText = new TextBlock + { + Text = node.NodeId, + FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas, monospace"), + // Body text style so the id sits at the same visual weight as + // the rest of the card body (Version / Hardware / Network rows). + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.NoWrap, + TextTrimming = TextTrimming.CharacterEllipsis, + Margin = new Thickness(0, 0, 8, 0), + }; + ToolTipService.SetToolTip(idText, node.NodeId); + Grid.SetColumn(idText, 0); + grid.Children.Add(idText); + + var copyBtn = new Button + { + Content = "📋", + Padding = new Thickness(6, 2, 6, 2), + Tag = node.NodeId, + VerticalAlignment = VerticalAlignment.Center, + }; + copyBtn.Click += OnCopyDeviceId; + Grid.SetColumn(copyBtn, 1); + grid.Children.Add(copyBtn); + return grid; + } + + private static FrameworkElement? BuildVersionRow(GatewayNodeInfo node) + { + var parts = new List(3); + if (!string.IsNullOrWhiteSpace(node.Version)) parts.Add(node.Version!); + if (!string.IsNullOrWhiteSpace(node.CoreVersion)) parts.Add($"core {node.CoreVersion}"); + if (!string.IsNullOrWhiteSpace(node.UiVersion)) parts.Add($"ui {node.UiVersion}"); + if (parts.Count == 0) return null; + return MakeLabeledRow( + LocalizationHelper.GetString("NodesPage_Label_Version"), + string.Join(" · ", parts)); + } + + private static FrameworkElement? BuildHardwareRow(GatewayNodeInfo node) + { + var parts = new List(2); + if (!string.IsNullOrWhiteSpace(node.DeviceFamily)) parts.Add(node.DeviceFamily!); + if (!string.IsNullOrWhiteSpace(node.ModelIdentifier)) parts.Add(node.ModelIdentifier!); + if (parts.Count == 0) return null; + return MakeLabeledRow( + LocalizationHelper.GetString("NodesPage_Label_Hardware"), + string.Join(" · ", parts)); + } + + private static FrameworkElement? BuildNetworkRow(GatewayNodeInfo node) + { + var parts = new List(3); + if (!string.IsNullOrWhiteSpace(node.RemoteIp)) parts.Add(node.RemoteIp!); + if (!string.IsNullOrWhiteSpace(node.ClientId)) parts.Add(node.ClientId!); + if (!string.IsNullOrWhiteSpace(node.ClientMode)) parts.Add(node.ClientMode!); + if (parts.Count == 0) return null; + return MakeLabeledRow( + LocalizationHelper.GetString("NodesPage_Label_Network"), + string.Join(" · ", parts)); + } + + private static FrameworkElement? BuildTimestampsRow(GatewayNodeInfo node) + { + var parts = new List(3); + if (node.ApprovedAt.HasValue) + parts.Add($"{LocalizationHelper.GetString("NodesPage_Label_Approved")} {FormatAge(node.ApprovedAt.Value)}"); + if (node.ConnectedAt.HasValue) + parts.Add($"{LocalizationHelper.GetString("NodesPage_Label_Connected")} {FormatAge(node.ConnectedAt.Value)}"); + if (node.LastSeen.HasValue) + { + var label = $"{LocalizationHelper.GetString("NodesPage_Label_LastSeen")} {FormatAge(node.LastSeen.Value)}"; + if (!string.IsNullOrWhiteSpace(node.LastSeenReason)) + label += $" ({node.LastSeenReason})"; + parts.Add(label); + } + if (parts.Count == 0) return null; + return MakeSingleLine(string.Join(" · ", parts)); + } + + private static StackPanel? BuildCapabilitiesSection(GatewayNodeInfo node) + { + if (node.Capabilities is not { Count: > 0 } caps) return null; + var section = new StackPanel { Spacing = 4 }; + section.Children.Add(new TextBlock + { + Text = LocalizationHelper.GetString("NodesPage_Label_Capabilities"), + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + }); + var wrap = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4 }; + foreach (var cap in caps) + { + var badge = new Border { Background = (Brush)Application.Current.Resources["CardBackgroundFillColorDefaultBrush"], BorderBrush = (Brush)Application.Current.Resources["CardStrokeColorDefaultBrush"], BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(16), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(8, 3, 8, 3), }; + badge.Child = new TextBlock + { + Text = cap, + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + }; + wrap.Children.Add(badge); + } + section.Children.Add(wrap); + return section; + } - var stack = new StackPanel { Spacing = 8 }; - - // Header: name + online dot - var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; - header.Children.Add(new Ellipse + private static Expander? BuildCommandsSection(GatewayNodeInfo node) + { + if (node.Commands is not { Count: > 0 } cmds) return null; + var disabled = new HashSet(node.DisabledCommands ?? new List(), StringComparer.OrdinalIgnoreCase); + var expander = new Expander + { + Header = string.Format(LocalizationHelper.GetString("NodesPage_Commands_Header"), cmds.Count), + IsExpanded = false, + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch, + }; + var stack = new StackPanel { Spacing = 2 }; + foreach (var cmd in cmds) + { + var isDisabled = disabled.Contains(cmd); + stack.Children.Add(new TextBlock { - Width = 10, Height = 10, - VerticalAlignment = VerticalAlignment.Center, - Fill = vm.IsOnline ? new SolidColorBrush(Colors.LimeGreen) : new SolidColorBrush(Colors.Gray), + Text = isDisabled + ? $" • {cmd}{LocalizationHelper.GetString("NodesPage_Command_DisabledSuffix")}" + : $" • {cmd}", + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources[ + isDisabled ? "TextFillColorTertiaryBrush" : "TextFillColorSecondaryBrush"], }); - header.Children.Add(new TextBlock + } + expander.Content = stack; + return expander; + } + + private static StackPanel? BuildPermissionsSection(GatewayNodeInfo node) + { + if (node.Permissions is not { Count: > 0 } perms) return null; + var section = new StackPanel { Spacing = 4 }; + section.Children.Add(new TextBlock + { + Text = LocalizationHelper.GetString("NodesPage_Label_Permissions"), + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + }); + foreach (var kv in perms) + { + section.Children.Add(new TextBlock { - Text = vm.Name, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - VerticalAlignment = VerticalAlignment.Center, + Text = $" {kv.Key}: {(kv.Value ? "✅" : "❌")}", + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], }); - stack.Children.Add(header); + } + return section; + } - // Platform badge - var platformBadge = new Border + private static Expander? BuildPathEnvSection(GatewayNodeInfo node) + { + if (string.IsNullOrWhiteSpace(node.PathEnv)) return null; + // PATH can contain usernames, network shares, build tool locations. + // Keep it collapsed by default so it doesn't reveal those at a glance. + var expander = new Expander + { + Header = LocalizationHelper.GetString("NodesPage_Label_PathEnv"), + IsExpanded = false, + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch, + }; + expander.Content = new TextBlock + { + Text = node.PathEnv, + FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas, monospace"), + // PATH lines are very dense; staying at Caption keeps long + // entries readable without dominating the card. + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + TextWrapping = TextWrapping.Wrap, + IsTextSelectionEnabled = true, + }; + return expander; + } + + private static Grid MakeLabeledRow(string label, string value) + { + // Use a Grid with star value column so long values wrap properly. A + // horizontal StackPanel gives children unbounded width, which makes + // TextWrapping=Wrap a no-op and lets long network/hardware strings + // overflow the card. + var grid = new Grid { ColumnSpacing = 6 }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var labelText = new TextBlock + { + Text = label, + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + VerticalAlignment = VerticalAlignment.Top, + }; + Grid.SetColumn(labelText, 0); + grid.Children.Add(labelText); + + var valueText = new TextBlock + { + Text = value, + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], + TextWrapping = TextWrapping.Wrap, + IsTextSelectionEnabled = true, + }; + Grid.SetColumn(valueText, 1); + grid.Children.Add(valueText); + return grid; + } + + private static TextBlock MakeSingleLine(string value) + { + // No wrapping container needed: a TextBlock placed directly in a + // vertical StackPanel gets the full width and wraps cleanly. + return new TextBlock + { + Text = value, + Style = (Style)Application.Current.Resources["BodyTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorTertiaryBrush"], + TextWrapping = TextWrapping.Wrap, + }; + } + + private static string FormatAge(DateTime utc) + { + // Single canonical age formatter shared with GatewayNodeInfo.DetailText + // (so a node never shows "1d ago" in one place and "36h ago" in + // another for the same timestamp). + return ModelFormatting.FormatAge(utc); + } + + private async Task OnRenameClickedAsync(GatewayNodeInfo node) + { + if (_hub?.GatewayClient is not { } client) return; + if (_dialogOpen) return; + _dialogOpen = true; + try + { + var input = new TextBox { - Background = new SolidColorBrush(GetPlatformColor(vm.Platform)), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(8, 2, 8, 2), - HorizontalAlignment = HorizontalAlignment.Left, + // Pre-fill ONLY when the gateway gave us an explicit display + // name. If we fell back to the id (HasExplicitDisplayName= + // false), seeding the textbox with the id would cause Enter + // to persist that id as the new display name. + Text = node.HasExplicitDisplayName ? node.DisplayName : string.Empty, + MaxLength = 64, + AcceptsReturn = false, + SelectionStart = 0, + PlaceholderText = LocalizationHelper.GetString("NodesPage_Rename_Placeholder"), }; - platformBadge.Child = new TextBlock + // Focus + select-all only after the TextBox is actually attached + // to the visual tree; calling these before Loaded is a no-op. + input.Loaded += (_, _) => { - Text = vm.Platform, - FontSize = 11, - Foreground = new SolidColorBrush(Colors.White), + input.Focus(FocusState.Programmatic); + input.SelectAll(); }; - stack.Children.Add(platformBadge); - // Device ID with copy button - var idRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; - var shortId = vm.DeviceId.Length > 16 ? vm.DeviceId[..16] + "…" : vm.DeviceId; - idRow.Children.Add(new TextBlock + var errorBlock = new TextBlock { - Text = shortId, - Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + Foreground = (Brush)Application.Current.Resources["SystemFillColorCriticalBrush"], FontSize = 12, - VerticalAlignment = VerticalAlignment.Center, + Visibility = Visibility.Collapsed, + }; + + var content = new StackPanel { Spacing = 8 }; + content.Children.Add(new TextBlock + { + Text = string.Format( + LocalizationHelper.GetString("NodesPage_Rename_Body"), + string.IsNullOrWhiteSpace(node.DisplayName) ? node.ShortId : node.DisplayName), + TextWrapping = TextWrapping.Wrap, }); - var copyBtn = new Button + content.Children.Add(input); + content.Children.Add(errorBlock); + + var dialog = new ContentDialog { - Content = "📋", - Padding = new Thickness(4, 2, 4, 2), - Tag = vm.DeviceId, - FontSize = 11, + Title = LocalizationHelper.GetString("NodesPage_Rename_Title"), + Content = content, + PrimaryButtonText = LocalizationHelper.GetString("NodesPage_Rename_Primary"), + CloseButtonText = LocalizationHelper.GetString("NodesPage_Common_Cancel"), + // Rename is non-destructive — Enter should confirm. (Forget + // is destructive and intentionally keeps Close as default.) + DefaultButton = ContentDialogButton.Primary, + XamlRoot = this.XamlRoot, }; - copyBtn.Click += OnCopyDeviceId; - idRow.Children.Add(copyBtn); - stack.Children.Add(idRow); - // Capabilities as tags - if (vm.Capabilities.Length > 0) + dialog.PrimaryButtonClick += async (s, args) => { - stack.Children.Add(new TextBlock - { - Text = "Capabilities", - FontSize = 12, - Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], - Margin = new Thickness(0, 4, 0, 0), - }); - var capWrap = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4 }; - foreach (var cap in vm.Capabilities) + var deferral = args.GetDeferral(); + try { - var badge = new Border + var newName = input.Text?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(newName)) + { + errorBlock.Text = LocalizationHelper.GetString("NodesPage_Rename_Error_Empty"); + errorBlock.Visibility = Visibility.Visible; + args.Cancel = true; + return; + } + // Don't short-circuit on "name unchanged" — our local + // node.DisplayName might be stale (another operator may + // have renamed in the interim). Always send to the + // gateway and let it decide. + + s.IsPrimaryButtonEnabled = false; + input.IsEnabled = false; + errorBlock.Visibility = Visibility.Collapsed; + + var result = await client.NodeRenameAsync(node.NodeId, newName); + if (!result.Success) { - Background = (Brush)Application.Current.Resources["CardBackgroundFillColorDefaultBrush"], - BorderBrush = (Brush)Application.Current.Resources["CardStrokeColorDefaultBrush"], - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(6, 2, 6, 2), - }; - badge.Child = new TextBlock { Text = cap, FontSize = 11 }; - capWrap.Children.Add(badge); + errorBlock.Text = result.ErrorMessage ?? LocalizationHelper.GetString("NodesPage_Rename_Error_Generic"); + errorBlock.Visibility = Visibility.Visible; + s.IsPrimaryButtonEnabled = true; + input.IsEnabled = true; + args.Cancel = true; + return; + } + + // Gateway does not broadcast rename; trigger an explicit + // list refresh so this card reflects the new display name + // (and any other state that changed in the meantime). + _ = client.RequestNodesAsync(); } - stack.Children.Add(capWrap); - } + finally + { + deferral.Complete(); + } + }; + + await dialog.ShowAsync(); + } + finally + { + _dialogOpen = false; + } + } - // Commands (collapsed by default in an Expander) - if (vm.Commands.Length > 0) + private async Task OnForgetClickedAsync(GatewayNodeInfo node) + { + if (_hub?.GatewayClient is not { } client) return; + if (_dialogOpen) return; + _dialogOpen = true; + try + { + var body = new StackPanel { Spacing = 8 }; + body.Children.Add(new TextBlock { - var expander = new Expander - { - Header = $"Commands ({vm.Commands.Length})", - IsExpanded = false, - HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Stretch, - }; - var cmdStack = new StackPanel { Spacing = 2 }; - foreach (var cmd in vm.Commands) + Text = LocalizationHelper.GetString("NodesPage_Forget_Body"), + TextWrapping = TextWrapping.Wrap, + }); + // Surface the identity prominently so the user is forgetting the + // node they think they're forgetting. + var identity = new StackPanel { Spacing = 2, Margin = new Thickness(0, 4, 0, 4) }; + identity.Children.Add(new TextBlock + { + Text = string.IsNullOrWhiteSpace(node.DisplayName) ? node.ShortId : node.DisplayName, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + }); + var subtitle = new List(2); + if (!string.IsNullOrWhiteSpace(node.Platform)) subtitle.Add(node.Platform!); + subtitle.Add(node.ShortId); + identity.Children.Add(new TextBlock + { + Text = string.Join(" · ", subtitle), + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + FontSize = 12, + }); + body.Children.Add(identity); + body.Children.Add(new TextBlock + { + Text = LocalizationHelper.GetString("NodesPage_Forget_Warning"), + TextWrapping = TextWrapping.Wrap, + FontSize = 12, + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + }); + + var errorBlock = new TextBlock + { + Foreground = (Brush)Application.Current.Resources["SystemFillColorCriticalBrush"], + FontSize = 12, + Visibility = Visibility.Collapsed, + TextWrapping = TextWrapping.Wrap, + }; + body.Children.Add(errorBlock); + + var dialog = new ContentDialog + { + Title = LocalizationHelper.GetString("NodesPage_Forget_Title"), + Content = body, + PrimaryButtonText = LocalizationHelper.GetString("NodesPage_Forget_Primary"), + CloseButtonText = LocalizationHelper.GetString("NodesPage_Common_Cancel"), + // Cancel is the default so pressing Enter does NOT confirm a + // destructive action. Leaving the primary button at its + // default style (no accent fill) keeps the destructive label + // visually muted — Cancel remains the recommended action. + DefaultButton = ContentDialogButton.Close, + XamlRoot = this.XamlRoot, + }; + + // Use the deferral pattern so we can keep the dialog open and + // surface an inline error when NodePairRemoveAsync reports + // failure (disconnected, missing scope, unknown nodeId). On + // success the dialog closes and the gateway's + // node.pair.resolved broadcast triggers a node.list refresh. + dialog.PrimaryButtonClick += async (s, args) => + { + var deferral = args.GetDeferral(); + try { - cmdStack.Children.Add(new TextBlock + s.IsPrimaryButtonEnabled = false; + errorBlock.Visibility = Visibility.Collapsed; + + var result = await client.NodePairRemoveAsync(node.NodeId); + if (!result.Success) { - Text = $" • {cmd}", - FontSize = 12, - Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], - }); + // Surface the actual gateway error message when we + // have one (e.g. "missing scope: operator.pairing"), + // falling back to a generic message for unrecognised + // failures so non-English locales never see a raw + // English string from the server. + errorBlock.Text = result.ErrorMessage + ?? LocalizationHelper.GetString("NodesPage_Forget_Error_Generic"); + errorBlock.Visibility = Visibility.Visible; + s.IsPrimaryButtonEnabled = true; + args.Cancel = true; + } + // On success: dialog closes; gateway's node.pair.resolved + // broadcast triggers a node.list refresh. } - expander.Content = cmdStack; - stack.Children.Add(expander); - } + finally + { + deferral.Complete(); + } + }; - card.Child = stack; - NodesList.Children.Add(card); + await dialog.ShowAsync(); + } + finally + { + _dialogOpen = false; } } @@ -223,16 +762,6 @@ private void OnCopyDeviceId(object sender, RoutedEventArgs e) _ => WinColor.FromArgb(255, 128, 128, 128), }; - public class NodeViewModel - { - public string Name { get; set; } = ""; - public string DeviceId { get; set; } = ""; - public string Platform { get; set; } = ""; - public bool IsOnline { get; set; } - public string[] Capabilities { get; set; } = Array.Empty(); - public string[] Commands { get; set; } = Array.Empty(); - } - public void UpdatePairingRequests(PairingListInfo data) { PairingList.Children.Clear(); diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index b56819566..6923d1c17 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -1925,6 +1925,84 @@ On your gateway host (Mac/Linux), run: 🌐 Connected Clients + + Rename + + + Forget + + + More options + + + Version + + + Hardware + + + Network + + + Approved + + + Connected + + + Seen + + + Capabilities + + + Permissions + + + PATH + + + Commands ({0}) + + + (disabled) + + + Rename node + + + Pick a new display name for {0}. + + + Display name + + + Rename + + + Display name cannot be empty. + + + Rename failed. + + + Forget node? + + + This will remove the gateway's record of the node below. The node will need to re-pair before it can reconnect. + + + Active operator and chat sessions targeting this node will lose access immediately. + + + Forget node + + + Could not forget node — gateway not reachable or permission denied. + + + Cancel + 🔐 Permissions diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 7aea33289..8d52d0270 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -1926,6 +1926,84 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : 🌐 Clients connectés + + Renommer + + + Oublier + + + Plus d'options + + + Version + + + Hardware + + + Réseau + + + Approuvé + + + Connecté + + + Vu + + + Capacités + + + Autorisations + + + PATH + + + Commandes ({0}) + + + (désactivée) + + + Renommer le nœud + + + Choisissez un nouveau nom d'affichage pour {0}. + + + Nom d'affichage + + + Renommer + + + Le nom d'affichage ne peut pas être vide. + + + Échec du renommage. + + + Oublier le nœud ? + + + Cela supprimera l'enregistrement du nœud ci-dessous dans la passerelle. Le nœud devra se réappairer pour pouvoir se reconnecter. + + + Les sessions opérateur et de chat actives qui ciblent ce nœud perdront l'accès immédiatement. + + + Oublier le nœud + + + Impossible d'oublier le nœud — passerelle inaccessible ou autorisation refusée. + + + Annuler + 🔐 Autorisations diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 91ce6bf82..335dd0331 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -1927,6 +1927,84 @@ Voer op uw gateway-host (Mac/Linux) uit: 🌐 Verbonden clients + + Hernoemen + + + Vergeten + + + Meer opties + + + Version + + + Hardware + + + Netwerk + + + Goedgekeurd + + + Verbonden + + + Gezien + + + Mogelijkheden + + + Machtigingen + + + PATH + + + Opdrachten ({0}) + + + (uitgeschakeld) + + + Knooppunt hernoemen + + + Kies een nieuwe weergavenaam voor {0}. + + + Weergavenaam + + + Hernoemen + + + Weergavenaam mag niet leeg zijn. + + + Hernoemen mislukt. + + + Knooppunt vergeten? + + + Dit verwijdert de registratie van het onderstaande knooppunt bij de gateway. Het knooppunt moet opnieuw worden gekoppeld voordat het opnieuw verbinding kan maken. + + + Actieve operator- en chatsessies die op dit knooppunt zijn gericht, verliezen onmiddellijk toegang. + + + Knooppunt vergeten + + + Knooppunt vergeten mislukt — gateway niet bereikbaar of toestemming geweigerd. + + + Annuleren + 🔐 Machtigingen diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 5d624c0e4..db06908e9 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -1926,6 +1926,84 @@ 🌐 已连接的客户端 + + 重命名 + + + 忘记 + + + 更多选项 + + + Version + + + Hardware + + + 网络 + + + 已批准 + + + 已连接 + + + 上次见到 + + + 功能 + + + 权限 + + + PATH + + + 命令 ({0}) + + + (已禁用) + + + 重命名节点 + + + 为 {0} 选择新的显示名称。 + + + 显示名称 + + + 重命名 + + + 显示名称不能为空。 + + + 重命名失败。 + + + 忘记节点? + + + 这将从网关中删除下面节点的记录。节点需要重新配对才能再次连接。 + + + 针对该节点的活动操作员和聊天会话将立即失去访问权限。 + + + 忘记节点 + + + 无法忘记节点 — 网关不可达或权限被拒绝。 + + + 取消 + 🔐 权限 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index c761108b9..a57708652 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -1926,6 +1926,84 @@ 🌐 已連線的用戶端 + + 重新命名 + + + 忘記 + + + 更多選項 + + + Version + + + Hardware + + + 網路 + + + 已核准 + + + 已連線 + + + 上次看到 + + + 功能 + + + 權限 + + + PATH + + + 命令 ({0}) + + + (已停用) + + + 重新命名節點 + + + 為 {0} 選擇新的顯示名稱。 + + + 顯示名稱 + + + 重新命名 + + + 顯示名稱不能為空。 + + + 重新命名失敗。 + + + 忘記節點? + + + 這將從閘道中刪除下面節點的記錄。節點需要重新配對才能再次連線。 + + + 針對該節點的作用中操作員和聊天工作階段將立即失去存取權。 + + + 忘記節點 + + + 無法忘記節點 — 閘道無法連線或權限遭拒。 + + + 取消 + 🔐 權限 diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index d971cc59b..35f49d49e 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -1288,6 +1288,202 @@ public void ParseNodeListPayload_SkipsItemsWithNoNodeId() Assert.Equal("valid-node", nodes[0].NodeId); } + [Fact] + public void ParseNodeListPayload_PopulatesAllNodeListNodeFields() + { + // Mirrors the full NodeListNode schema from openclaw/openclaw + // src/shared/node-list-types.ts so we don't lose data the gateway + // already sends. Uses the production *Ms timestamp names. + var helper = new GatewayClientTestHelper(); + var nodes = helper.ParseNodeListPayload(""" + { + "nodes": [ + { + "nodeId": "node-rich", + "displayName": "Rich Node", + "platform": "windows", + "mode": "node", + "status": "connected", + "version": "v2026.5.7", + "coreVersion": "1.2.3", + "uiVersion": "4.5.6", + "clientId": "client-abc", + "clientMode": "operator-node", + "remoteIp": "192.168.1.42", + "deviceFamily": "desktop", + "modelIdentifier": "Surface-Pro-X", + "pathEnv": "C:\\Windows;C:\\tools", + "caps": ["camera", "screen"], + "commands": ["system.run"], + "disabledCommands": ["camera.recordVideo"], + "permissions": { "screen.record": true, "camera.snap": false }, + "paired": true, + "connected": true, + "connectedAtMs": 1739760000000, + "lastSeenAtMs": 1739760123456, + "lastSeenReason": "heartbeat", + "approvedAtMs": 1739700000000 + } + ] + } + """); + + Assert.Single(nodes); + var n = nodes[0]; + Assert.Equal("node-rich", n.NodeId); + Assert.Equal("Rich Node", n.DisplayName); + Assert.Equal("v2026.5.7", n.Version); + Assert.Equal("1.2.3", n.CoreVersion); + Assert.Equal("4.5.6", n.UiVersion); + Assert.Equal("client-abc", n.ClientId); + Assert.Equal("operator-node", n.ClientMode); + Assert.True(n.HasExplicitDisplayName); + Assert.Equal("192.168.1.42", n.RemoteIp); + Assert.Equal("desktop", n.DeviceFamily); + Assert.Equal("Surface-Pro-X", n.ModelIdentifier); + Assert.Equal("C:\\Windows;C:\\tools", n.PathEnv); + Assert.Equal(["camera", "screen"], n.Capabilities); + Assert.Equal(["system.run"], n.Commands); + Assert.Equal(["camera.recordVideo"], n.DisabledCommands); + Assert.True(n.IsPaired); + Assert.True(n.IsOnline); + Assert.True(n.Permissions["screen.record"]); + Assert.False(n.Permissions["camera.snap"]); + Assert.Equal("heartbeat", n.LastSeenReason); + + // Timestamps come from *Ms wire names + Assert.NotNull(n.ConnectedAt); + Assert.Equal( + DateTimeOffset.FromUnixTimeMilliseconds(1739760000000).UtcDateTime, + n.ConnectedAt!.Value); + Assert.NotNull(n.ApprovedAt); + Assert.Equal( + DateTimeOffset.FromUnixTimeMilliseconds(1739700000000).UtcDateTime, + n.ApprovedAt!.Value); + Assert.NotNull(n.LastSeen); + Assert.Equal( + DateTimeOffset.FromUnixTimeMilliseconds(1739760123456).UtcDateTime, + n.LastSeen!.Value); + } + + [Fact] + public void ParseNodeListPayload_AcceptsLegacyLastSeenAtWireName() + { + // Older mocks / non-gateway producers may emit lastSeenAt (no Ms suffix). + // The parser keeps that path as a fallback so existing fixtures keep + // working after we add the *Ms primary names. + var helper = new GatewayClientTestHelper(); + var nodes = helper.ParseNodeListPayload(""" + { + "nodes": [ + { + "nodeId": "legacy", + "status": "connected", + "lastSeenAt": 1739760000000 + } + ] + } + """); + + Assert.Single(nodes); + Assert.NotNull(nodes[0].LastSeen); + Assert.Equal( + DateTimeOffset.FromUnixTimeMilliseconds(1739760000000).UtcDateTime, + nodes[0].LastSeen!.Value); + } + + [Fact] + public void ParseNodeListPayload_DefaultsForMinimalPayload() + { + // A node entry with only nodeId must still parse without throwing + // and the new optional fields must default to null / false / empty. + var helper = new GatewayClientTestHelper(); + var nodes = helper.ParseNodeListPayload(""" + { + "nodes": [ { "nodeId": "bare" } ] + } + """); + + Assert.Single(nodes); + var n = nodes[0]; + Assert.Null(n.Version); + Assert.Null(n.CoreVersion); + Assert.Null(n.UiVersion); + Assert.Null(n.ClientId); + Assert.Null(n.ClientMode); + Assert.Null(n.DeviceFamily); + Assert.Null(n.ModelIdentifier); + Assert.Null(n.RemoteIp); + Assert.Null(n.PathEnv); + Assert.Null(n.ConnectedAt); + Assert.Null(n.ApprovedAt); + Assert.Null(n.LastSeen); + Assert.Null(n.LastSeenReason); + Assert.False(n.IsPaired); + Assert.False(n.HasExplicitDisplayName); + Assert.Empty(n.DisabledCommands); + } + + [Fact] + public async Task NodeRenameAsync_RejectsEmptyNodeId_WithoutHittingTransport() + { + var logger = new TestLogger(); + var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + + var result = await client.NodeRenameAsync("", "New Name"); + + Assert.False(result.Success); + Assert.Equal("nodeId required", result.ErrorMessage); + } + + [Fact] + public async Task NodeRenameAsync_RejectsEmptyDisplayName_WithoutHittingTransport() + { + var logger = new TestLogger(); + var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + + var result = await client.NodeRenameAsync("node-1", " "); + + Assert.False(result.Success); + Assert.Equal("displayName required", result.ErrorMessage); + } + + [Fact] + public async Task NodeRenameAsync_ReturnsErrorWhenNotConnected() + { + var logger = new TestLogger(); + var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + + var result = await client.NodeRenameAsync("node-1", "Pretty Name"); + + Assert.False(result.Success); + Assert.Equal("Gateway connection is not open", result.ErrorMessage); + } + + [Fact] + public async Task NodePairRemoveAsync_ReturnsFailureForEmptyNodeId() + { + var logger = new TestLogger(); + var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + + var result = await client.NodePairRemoveAsync(""); + + Assert.False(result.Success); + Assert.Equal("nodeId required", result.ErrorMessage); + } + + [Fact] + public async Task NodePairRemoveAsync_ReturnsFailureWhenNotConnected() + { + var logger = new TestLogger(); + var client = new OpenClawGatewayClient("http://test:8080", "my-token", logger); + + var result = await client.NodePairRemoveAsync("node-1"); + + Assert.False(result.Success); + Assert.Equal("Gateway connection is not open", result.ErrorMessage); + } + [Fact] public void Constructor_InitializesWithProvidedValues() { diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs index 824a99f44..ebf2dd11e 100644 --- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs @@ -36,6 +36,14 @@ public class LocalizationValidationTests // Sample IDs / brand identifiers — same across locales. "VoiceSettingsPage_ElevenLabsVoiceIdBox.PlaceholderText", "VoiceSettingsPage_ElevenLabsModelBox.PlaceholderText", + // NodesPage detail labels — "Version", "Hardware", and "PATH" are + // technical loanwords that read the same in every supported locale + // in this app's audience. Translating them adds no clarity and + // mixing scripts in a single detail row reads worse than keeping + // the English label. + "NodesPage_Label_Version", + "NodesPage_Label_Hardware", + "NodesPage_Label_PathEnv", }; private static readonly string[] RequiredRuntimeOnboardingKeys = diff --git a/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs b/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs index 8237757c2..8e09af30b 100644 --- a/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs +++ b/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs @@ -181,6 +181,10 @@ public void SetPreferStructuredCategories(bool value) { } public Task RequestNodePairListAsync() => Task.CompletedTask; public Task NodePairApproveAsync(string requestId) => Task.FromResult(false); public Task NodePairRejectAsync(string requestId) => Task.FromResult(false); + public Task NodePairRemoveAsync(string nodeId) => + Task.FromResult(new NodeForgetResult(false, "stub")); + public Task NodeRenameAsync(string nodeId, string displayName) => + Task.FromResult(new NodeRenameResult(false, ErrorMessage: "stub")); public Task RequestDevicePairListAsync() => Task.CompletedTask; public Task DevicePairApproveAsync(string requestId) => Task.FromResult(false); public Task DevicePairRejectAsync(string requestId) => Task.FromResult(false);