using System.Collections.ObjectModel; using System.Globalization; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DodoSSH.Client.Domain; using DodoSSH.Client.Session; using DodoSSH.Client.Sync; namespace DodoSSH.Client.Shell.ViewModels; /// Which log the screen is showing. internal enum LogSection { /// Connections that were made. Connections, /// Changes made to keychain items. Activity, } /// One connection, as a row. internal sealed class ConnectionLogRowViewModel(VaultItem entry, bool isLive) { internal Guid EntityId => entry.EntityId; internal string HostLabel => entry.Secret.HostLabel; internal string Address => entry.Secret.Address; /// When it started, in the reader's own conventions. /// /// The user's locale, unlike the transfers screen's deliberately invariant UTC column — and the /// difference is the reason each is right. There, two panes are read against one another and a /// sortable, unambiguous format wins; here there is one column and it answers "when was I on that /// machine", which is a question about the reader's own day. InvariantGlobalization is false in /// the client csproj precisely so this works. /// internal string Started => entry.Secret.StartedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture); /// /// How long it lasted, or that it has not finished. /// /// /// "still open" and not a dash. A dash reads as "nothing was recorded", and the two are opposite /// facts — one is an entry the log is missing, the other is a connection that is happening now. A live /// session has no entry at all until it closes, so this state comes from the workspace rather than from /// the vault; see . /// internal string Duration => isLive ? "still open" : Humanise(entry.Secret.Duration); internal bool IsLive => isLive; internal string Outcome => entry.Secret.Outcome switch { ConnectionOutcome.Failed => "failed", ConnectionOutcome.Refused => "host key refused", _ => string.Empty, }; internal bool HasOutcome => Outcome.Length > 0; /// Whether this was a terminal or the file browser. internal string Kind => entry.Secret.Kind is ConnectionKind.Sftp ? "files" : "terminal"; /// The keychain host this was, if it was one. /// /// Null for a connection made to a typed address, and that is a real distinction rather than missing /// data — see VaultViewModel.ConnectManuallyAsync. It is what lets the Connections screen offer /// the right thing when one of these rows is tapped: a keychain host has a connect bar with its own /// authentication behind it, and an address has only the box it was typed into. /// internal Guid? HostId => entry.Secret.HostId; internal string DeviceName => entry.Secret.DeviceName; /// /// Rounded to whole units and never to more than two of them. A connection log is read to answer "about /// how long was I on that machine", and "1h 4m" answers it where "1:04:37.482" makes the reader do the /// rounding themselves. /// private static string Humanise(TimeSpan duration) { if (duration < TimeSpan.FromMinutes(1)) { return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalSeconds}s"); } if (duration < TimeSpan.FromHours(1)) { return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalMinutes}m"); } return string.Create( CultureInfo.CurrentCulture, $"{(int)duration.TotalHours}h {duration.Minutes}m"); } } /// One keychain change, as a row. internal sealed class ActivityLogRowViewModel(VaultItem entry) { internal Guid EntityId => entry.EntityId; internal string ItemLabel => entry.Secret.ItemLabel; internal string ItemKind => entry.Secret.ItemKind; internal string Operation => entry.Secret.Operation switch { ActivityOperation.Created => "created", ActivityOperation.Deleted => "deleted", _ => "changed", }; /// ── v5b ── Whether the WHAT chip should draw in its "created" colour. internal bool IsCreated => entry.Secret.Operation is ActivityOperation.Created; /// ── v5b ── Whether the WHAT chip should draw in its "deleted" colour. internal bool IsDeleted => entry.Secret.Operation is ActivityOperation.Deleted; /// internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture); /// Which fields changed. Never what they changed to. internal string ChangedFields => entry.Secret.ChangedFields; internal bool HasChangedFields => ChangedFields.Length > 0; internal string DeviceName => entry.Secret.DeviceName; } /// /// What has been connected to, and what has been changed. /// /// /// /// A wrapper over the vault, as the pins and snippets screens are. What is its own is the two lists, the /// section switch and one thing neither log knows: which connections are happening now. An entry is /// written once, when a connection closes, so a live session is not in the vault at all — it is in the /// workspace, and this screen is where the two are put side by side. /// /// /// Read on demand rather than kept in step. Unlike the host list, a log is not something a background /// sync has to keep fresh on screen — nobody is waiting for their own connection from an hour ago to appear /// — and reading two full logs on every pass would decrypt thousands of entries a minute for a screen /// nobody is looking at. /// /// internal sealed partial class LogsViewModel : ObservableObject { private readonly VaultSession session; private readonly Func> live; /// The open vault, which holds both logs. /// /// The connections that are open right now. A function rather than a list, because tabs open and close /// while this screen is showing and it is not told about either. /// internal LogsViewModel(VaultSession session, Func> live) { this.session = session; this.live = live; } /// Connections, newest first, with anything still open at the top. internal ObservableCollection Connections { get; } = []; /// Keychain changes, newest first. internal ObservableCollection Activity { get; } = []; /// /// Settable, and the markup binds two buttons to a command rather than a selector's selection — the same /// idiom the keychain screen's categories use, and for the same reason: a selection binding moves before /// a command can refuse it. /// [ObservableProperty] private LogSection section; [ObservableProperty] private bool isBusy; [ObservableProperty] private string status = string.Empty; internal bool ShowsConnections => Section is LogSection.Connections; internal bool ShowsActivity => Section is LogSection.Activity; internal bool HasConnections => Connections.Count > 0; internal bool HasActivity => Activity.Count > 0; internal string EmptyMessage => Section is LogSection.Connections ? "Nothing here yet. A connection is recorded when it closes, so an open terminal appears at the " + "top and gets its line when you close the tab." : "Nothing here yet. Adding, editing or deleting anything in the keychain is recorded here — the " + "names of the fields that changed, never their contents."; /// /// ── v5b ── The mono sentence Logs.dc.html prints beside REFRESH: what one row of the showing log /// actually records. /// /// /// Per section rather than one line for the screen, and drawn from the same fact this type's own header /// remarks already state: an entry is written once, at close, for a connection; and once per write, per /// device, for an activity row. Not itself — that is a refresh outcome, cleared to /// empty on success — so is the one the header actually binds, showing an /// error over this fact when there is one to show. /// internal string SectionFact => Section is LogSection.Connections ? "an entry is written once, when a connection closes" : "one row per write, per device"; /// What the header's own status line shows: an error, if refreshing just produced one, else the fact. internal string HeaderStatusLine => Status.Length > 0 ? Status : SectionFact; /// Shows one of the two logs. [RelayCommand] private void ShowSection(LogSection section) => Section = section; /// Re-reads both logs. [RelayCommand] private async Task RefreshAsync(CancellationToken cancellationToken) { if (IsBusy) { return; } IsBusy = true; try { await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = string.Empty; } catch (OperationCanceledException) { // Leaving the screen. } catch (Exception exception) when (exception is not OutOfMemoryException) { Status = exception.Message; } finally { IsBusy = false; } } /// Reads both logs into the lists. internal async Task ReloadAsync(CancellationToken cancellationToken) { await ReloadConnectionsAsync(cancellationToken).ConfigureAwait(true); var activity = await session.ActivityLog .ListAsync(session.ActiveVaultId, cancellationToken) .ConfigureAwait(true); Activity.Clear(); foreach (var entry in activity.Items.OrderByDescending(item => item.Secret.At)) { Activity.Add(new ActivityLogRowViewModel(entry)); } OnPropertyChanged(nameof(HasActivity)); } /// Reads the connection log alone. /// /// Split out for the Connections screen, which offers the most recent of these as a way back to a /// machine and has no use at all for the keychain's activity. Reading both there would double the /// decryption for a list nobody on that screen is looking at — and this list is already the expensive /// one, which is why the whole thing is read on demand rather than kept in step. See the remark on the /// type. /// internal async Task ReloadConnectionsAsync(CancellationToken cancellationToken) { var connections = await session.ConnectionLog .ListAsync(session.ActiveVaultId, cancellationToken) .ConfigureAwait(true); Connections.Clear(); // The live ones first and above everything, because they are the only rows in this list that are // still changing. They carry no entity id — there is no vault item for them yet — which is why they // are built from a different source and marked as live rather than merged into the same shape. foreach (var open in live()) { Connections.Add(new ConnectionLogRowViewModel( new VaultItem( Guid.Empty, new ConnectionLogSecret { HostLabel = open.HostLabel, Address = open.Address, StartedAt = open.StartedAt, DeviceName = open.DeviceName, }, Version: 0, HasUnsyncedChanges: false, IsBlocked: false, IsReadOnly: false), isLive: true)); } foreach (var entry in connections.Items.OrderByDescending(item => item.Secret.StartedAt)) { Connections.Add(new ConnectionLogRowViewModel(entry, isLive: false)); } OnPropertyChanged(nameof(HasConnections)); } partial void OnSectionChanged(LogSection value) { OnPropertyChanged(nameof(ShowsConnections)); OnPropertyChanged(nameof(ShowsActivity)); OnPropertyChanged(nameof(EmptyMessage)); OnPropertyChanged(nameof(SectionFact)); OnPropertyChanged(nameof(HeaderStatusLine)); } partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(HeaderStatusLine)); } /// A connection that is open right now. /// What the host is called. /// The address as dialled. /// When it opened. /// This machine. /// /// Supplied by the shell, which owns the tabs. It is deliberately not read out of the vault: a connection /// that is still running has no entry there, because an entry is written once and at close — which is what /// keeps a synced log from needing a merge. /// internal sealed record LiveConnection( string HostLabel, string Address, DateTimeOffset StartedAt, string DeviceName);