From 3d3d714f3e6cc781225913842657eacaf647e4e5 Mon Sep 17 00:00:00 2001 From: Anderson Fernandes do Nascimento Date: Tue, 28 Jul 2026 11:42:37 -0300 Subject: [PATCH 1/7] Permite abrir o painel de detalhes abaixo da lista MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O detalhe so abria a direita. Um botao na toolbar passa a alternar entre a direita e abaixo: a direita funciona melhor em telas largas, abaixo e melhor para stack traces longos e telas estreitas. O icone aponta para ONDE o painel vai (seta para baixo / para a direita), e nao onde ele esta — a lib nao tem icones de painel, e a seta remove a ambiguidade. O botao so aparece quando ha um detalhe aberto para reposicionar. O splitter recebe @key na orientacao: ele mede e fixa os tamanhos dos panes na inicializacao, entao reaproveitar a instancia deixaria as larguras da disposicao anterior aplicadas na nova. A preferencia e persistida em ui.json — e nao no settings.json de proposito: o SettingsService dispara Changed ao salvar e o LogStore reage recarregando todos os arquivos, o que seria absurdo para uma preferencia visual. O enum e gravado por nome (Bottom), ja que o arquivo e editavel a mao. Validado no app: alterna nos dois sentidos e a escolha sobrevive a reabertura. 6 testes novos, incluindo arquivo corrompido e valor desconhecido caindo no padrao. Co-Authored-By: Claude Opus 5 --- src/Components/LogToolbar.razor | 17 ++++ src/Components/LogViewer.razor | 27 +++++- src/Models/DetailPanelPosition.cs | 12 +++ src/Program.cs | 1 + src/Services/UiPreferencesService.cs | 83 +++++++++++++++++++ test/ClefExplorer.Tests/UiPreferencesTests.cs | 76 +++++++++++++++++ 6 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 src/Models/DetailPanelPosition.cs create mode 100644 src/Services/UiPreferencesService.cs create mode 100644 test/ClefExplorer.Tests/UiPreferencesTests.cs diff --git a/src/Components/LogToolbar.razor b/src/Components/LogToolbar.razor index eade214..82e2c34 100644 --- a/src/Components/LogToolbar.razor +++ b/src/Components/LogToolbar.razor @@ -23,6 +23,18 @@ Disabled="@(TotalCount == 0)" Title="Exporta os eventos filtrados (não apenas a página atual)" OnClick="OnExport" /> + @* Só faz sentido quando há um detalhe aberto para reposicionar. O ícone aponta + para ONDE o painel vai, e não onde ele está — a lib não tem ícones de painel, + e a seta remove a ambiguidade. *@ + @if (DetailVisible) + { + + } Há um evento selecionado (o painel de detalhes está aberto). + [Parameter] public bool DetailVisible { get; set; } + [Parameter] public bool DetailAtBottom { get; set; } + [Parameter] public EventCallback OnToggleDetailPosition { get; set; } } diff --git a/src/Components/LogViewer.razor b/src/Components/LogViewer.razor index 5f4adf0..4413837 100644 --- a/src/Components/LogViewer.razor +++ b/src/Components/LogViewer.razor @@ -10,6 +10,7 @@ @inject SettingsService SettingsService @inject DialogService Dialog @inject NotificationService Notifications +@inject UiPreferencesService UiPreferences @inject IJSRuntime JS
@@ -60,7 +61,10 @@ TailEnabled="@Store.TailEnabled" OnToggleTail="AlternarAoVivo" IsLoading="@Store.IsLoading" - OnCancelLoad="CancelarCarregamento" /> + OnCancelLoad="CancelarCarregamento" + DetailVisible="@(_selected != null)" + DetailAtBottom="@DetalheAbaixo" + OnToggleDetailPosition="AlternarPosicaoDetalhe" />
@* Sem seleção: lista ocupa todo o espaço (sem splitter, evitando a @@ -69,8 +73,14 @@ tem largura, então a medição funciona. *@ @if (_selected != null) { - - + @* @key força a recriação do splitter ao trocar a orientação: + ele mede e fixa os tamanhos dos panes na inicialização, então + reaproveitar a instância deixaria as larguras da disposição + anterior aplicadas na nova. *@ + +
- +
@@ -572,6 +582,15 @@ } } + /// Painel de detalhes abaixo da lista (em vez de à direita). + private bool DetalheAbaixo => UiPreferences.Preferences.DetailPanelPosition == DetailPanelPosition.Bottom; + + /// + /// Alterna entre detalhe à direita e abaixo. À direita funciona melhor em telas largas; + /// abaixo, para stack traces longos e telas estreitas. A escolha é persistida. + /// + private void AlternarPosicaoDetalhe() => UiPreferences.ToggleDetailPanelPosition(); + /// Interrompe o carregamento em andamento, preservando o conteúdo anterior. private void CancelarCarregamento() { diff --git a/src/Models/DetailPanelPosition.cs b/src/Models/DetailPanelPosition.cs new file mode 100644 index 0000000..d196e7f --- /dev/null +++ b/src/Models/DetailPanelPosition.cs @@ -0,0 +1,12 @@ +namespace ClefExplorer.Models +{ + /// Onde o painel de detalhes aparece em relação à lista de eventos. + public enum DetailPanelPosition + { + /// Ao lado da lista (padrão). Bom para mensagens curtas e telas largas. + Right = 0, + + /// Abaixo da lista. Bom para stack traces longos e telas estreitas. + Bottom = 1, + } +} diff --git a/src/Program.cs b/src/Program.cs index b778634..c75e131 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -35,6 +35,7 @@ static void Main(string[] args) services.AddOmniComponents(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Services/UiPreferencesService.cs b/src/Services/UiPreferencesService.cs new file mode 100644 index 0000000..fdda999 --- /dev/null +++ b/src/Services/UiPreferencesService.cs @@ -0,0 +1,83 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using ClefExplorer.Models; + +namespace ClefExplorer.Services +{ + /// Preferências de layout da interface, preservadas entre execuções. + public class UiPreferences + { + public DetailPanelPosition DetailPanelPosition { get; set; } = DetailPanelPosition.Right; + } + + /// + /// Persiste preferências de interface em ui.json. + /// + /// Ficam fora do settings.json de propósito: o + /// dispara Changed ao salvar, e o reage a esse evento + /// recarregando todos os arquivos. Uma preferência puramente visual não pode custar um + /// recarregamento completo dos logs. + /// + public class UiPreferencesService + { + private const string FileName = "ui.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + // Grava "Right"/"Bottom" em vez de 0/1: o arquivo é editável à mão e um número + // não diria nada a quem o abrisse. + Converters = { new JsonStringEnumConverter() }, + }; + + private readonly AppStorage _storage; + private UiPreferences _preferences; + + public UiPreferencesService(AppStorage storage) + { + _storage = storage; + _preferences = Load(); + } + + public UiPreferences Preferences => _preferences; + + private UiPreferences Load() + { + try + { + var json = _storage.ReadText(FileName); + return json is null ? new UiPreferences() : JsonSerializer.Deserialize(json, JsonOptions) ?? new UiPreferences(); + } + catch (Exception ex) + { + // Preferência visual: cair no padrão é aceitável, sem incomodar o usuário. + AppLog.Warning("Não foi possível ler as preferências de interface", ex); + return new UiPreferences(); + } + } + + public void Save() + { + try + { + _storage.WriteText(FileName, JsonSerializer.Serialize(_preferences, JsonOptions)); + } + catch (Exception ex) + { + AppLog.Warning("Não foi possível salvar as preferências de interface", ex); + } + } + + /// Alterna a posição do painel de detalhes e persiste a escolha. + public DetailPanelPosition ToggleDetailPanelPosition() + { + _preferences.DetailPanelPosition = _preferences.DetailPanelPosition == DetailPanelPosition.Right + ? DetailPanelPosition.Bottom + : DetailPanelPosition.Right; + + Save(); + return _preferences.DetailPanelPosition; + } + } +} diff --git a/test/ClefExplorer.Tests/UiPreferencesTests.cs b/test/ClefExplorer.Tests/UiPreferencesTests.cs new file mode 100644 index 0000000..2f40515 --- /dev/null +++ b/test/ClefExplorer.Tests/UiPreferencesTests.cs @@ -0,0 +1,76 @@ +using ClefExplorer.Models; +using ClefExplorer.Services; + +namespace ClefExplorer.Tests; + +/// +/// Preferências de layout da interface. Ficam em ui.json, e não no +/// settings.json, porque salvar as configurações dispara um recarregamento de todos +/// os arquivos de log — inaceitável para uma preferência puramente visual. +/// +public class UiPreferencesTests : IDisposable +{ + private readonly string _root; + + public UiPreferencesTests() + { + _root = Path.Combine(Path.GetTempPath(), "ClefExplorerTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { /* limpeza best-effort */ } + } + + private UiPreferencesService NewService() => new(new AppStorage(_root, legacyFolder: null)); + + [Fact] + public void Detail_panel_starts_on_the_right() + { + Assert.Equal(DetailPanelPosition.Right, NewService().Preferences.DetailPanelPosition); + } + + [Fact] + public void Toggling_moves_the_panel_to_the_bottom_and_back() + { + var service = NewService(); + + Assert.Equal(DetailPanelPosition.Bottom, service.ToggleDetailPanelPosition()); + Assert.Equal(DetailPanelPosition.Right, service.ToggleDetailPanelPosition()); + } + + [Fact] + public void The_chosen_position_survives_a_restart() + { + NewService().ToggleDetailPanelPosition(); + + Assert.Equal(DetailPanelPosition.Bottom, NewService().Preferences.DetailPanelPosition); + } + + [Fact] + public void The_position_is_written_by_name_not_by_number() + { + // O arquivo é editável à mão: "Bottom" diz algo, "1" não. + NewService().ToggleDetailPanelPosition(); + + var json = File.ReadAllText(Path.Combine(_root, "ui.json")); + Assert.Contains("\"Bottom\"", json); + } + + [Fact] + public void A_corrupt_file_falls_back_to_the_default_without_throwing() + { + File.WriteAllText(Path.Combine(_root, "ui.json"), "não é json"); + + Assert.Equal(DetailPanelPosition.Right, NewService().Preferences.DetailPanelPosition); + } + + [Fact] + public void An_unknown_position_falls_back_to_the_default() + { + File.WriteAllText(Path.Combine(_root, "ui.json"), """{"DetailPanelPosition":"Diagonal"}"""); + + Assert.Equal(DetailPanelPosition.Right, NewService().Preferences.DetailPanelPosition); + } +} From 32abc80cd976ba7d62ab3e1d16d0324d83e2de59 Mon Sep 17 00:00:00 2001 From: Anderson Fernandes do Nascimento Date: Tue, 28 Jul 2026 12:10:54 -0300 Subject: [PATCH 2/7] Adiciona visao em tabela (OmniDataGrid) com colunas derivadas do log Alem da lista, um botao na toolbar alterna para uma tabela com ordenacao, agrupamento (arrastando a coluna para a faixa acima), filtro por coluna e redimensionamento. A escolha e persistida. Colunas derivadas do CONTEUDO: logs CLEF carregam propriedades estruturadas que variam conforme a aplicacao que os gerou, entao fixa-las na mao nao funcionaria. O LogColumnDiscovery amostra os eventos carregados, conta a frequencia de cada propriedade e oferece como coluna as que aparecem o suficiente, com o nome ja legivel - "MachineName" vira "Machine Name". O desempate por nome mantem a ordem estavel entre aberturas. O seletor de colunas e proprio, e nao o embutido (AllowColumnVisibility=false). Motivo: a coluna aplica a visibilidade no proprio OnInitialized, entao toda vez que o grid e recriado - o que acontece ao abrir/fechar o detalhe, ja que o layout troca de splitter - as escolhas do menu interno se perdiam. Mantendo o conjunto do lado do app, ele sobrevive a recriacao e ainda persiste entre execucoes. Tentei estabilizar a arvore mantendo o splitter sempre montado com o pane do detalhe colapsado, mas isso reintroduz a race de medicao do splitter aninhado (o conteudo mede 0px e some). Por isso a renderizacao condicional foi mantida e o problema foi resolvido pelo lado do estado. 19 testes novos: descoberta (frequencia, corte de raras, exclusao das que ja tem coluna fixa, teto, estabilidade, amostragem), rotulo legivel e valor da celula (sem as aspas que o ScalarValue adiciona). Co-Authored-By: Claude Opus 5 --- src/Components/LogGrid.razor | 177 +++++++++++++++++ src/Components/LogToolbar.razor | 11 ++ src/Components/LogViewer.razor | 103 ++++++++-- src/Helpers/LogColumnDiscovery.cs | 128 ++++++++++++ src/Helpers/LogGridColumns.cs | 36 ++++ .../{DetailPanelPosition.cs => UiEnums.cs} | 10 + src/Services/UiPreferencesService.cs | 30 +++ src/wwwroot/css/app.css | 53 +++++ .../LogColumnDiscoveryTests.cs | 182 ++++++++++++++++++ 9 files changed, 713 insertions(+), 17 deletions(-) create mode 100644 src/Components/LogGrid.razor create mode 100644 src/Helpers/LogColumnDiscovery.cs create mode 100644 src/Helpers/LogGridColumns.cs rename src/Models/{DetailPanelPosition.cs => UiEnums.cs} (53%) create mode 100644 test/ClefExplorer.Tests/LogColumnDiscoveryTests.cs diff --git a/src/Components/LogGrid.razor b/src/Components/LogGrid.razor new file mode 100644 index 0000000..68f5e93 --- /dev/null +++ b/src/Components/LogGrid.razor @@ -0,0 +1,177 @@ +@using ClefExplorer.Models +@using ClefExplorer.Helpers + +@* Visão em tabela dos eventos: ordenar, agrupar (arrastando a coluna para a faixa + acima), filtrar por coluna e redimensionar. + + Além das colunas fixas, as colunas de propriedade são DESCOBERTAS a partir do + conteúdo dos logs carregados — cada aplicação emite um conjunto próprio + (SourceContext, RequestId, MachineName…), então fixá-las na mão não funcionaria. + + O seletor de colunas é NOSSO, e não o embutido (AllowColumnVisibility): a coluna + aplica a visibilidade no próprio OnInitialized, então toda vez que o grid é + recriado — o que acontece ao abrir/fechar o painel de detalhes, já que o layout + troca de splitter — as escolhas do menu interno se perdiam. Mantendo o conjunto + aqui, ele sobrevive à recriação e ainda é persistido entre execuções. *@ + +
+ + + + + + + +
+ @foreach (var col in TodasAsColunas) + { + var c = col; + + } +
+
+
+
+ + + + + + + + + + + @* Só o nome do arquivo na célula; o caminho completo fica no title. *@ + + + + + + + + + @* Colunas derivadas do conteúdo dos logs carregados. *@ + @foreach (var coluna in Colunas) + { + var c = coluna; + + } + +
+
+ +@code { + [Parameter] public IReadOnlyList Eventos { get; set; } = Array.Empty(); + [Parameter] public ClefEvent? SelectedEvent { get; set; } + [Parameter] public EventCallback OnSelect { get; set; } + + /// Colunas descobertas no conteúdo dos logs carregados. + [Parameter] public IReadOnlyList Colunas { get; set; } = Array.Empty(); + + /// Chaves das colunas visíveis (fixas e descobertas). + [Parameter] public HashSet VisibleColumns { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + [Parameter] public EventCallback> VisibleColumnsChanged { get; set; } + + private bool _colunasOpen; + + /// Fixas primeiro, depois as descobertas — a mesma ordem da tabela. + private IEnumerable<(string Key, string Title)> TodasAsColunas => + LogGridColumns.Fixed.Concat(Colunas.Select(c => (c.Key, c.Title))); + + private async Task Alternar(string key, bool visivel) + { + var novo = new HashSet(VisibleColumns, StringComparer.OrdinalIgnoreCase); + if (visivel) novo.Add(key); else novo.Remove(key); + + VisibleColumns = novo; + await VisibleColumnsChanged.InvokeAsync(novo); + } + + private static string NomeArquivo(ClefEvent e) => + string.IsNullOrEmpty(e.SourceFile) ? string.Empty : Path.GetFileName(e.SourceFile); + + /// Destaca a linha selecionada e as de erro, como na lista. + private string LinhaCss(ClefEvent e) + { + var classes = new List(2); + + if (ReferenceEquals(e, SelectedEvent)) classes.Add("is-selected"); + + if (string.Equals(e.Level, "Error", StringComparison.OrdinalIgnoreCase) + || string.Equals(e.Level, "Fatal", StringComparison.OrdinalIgnoreCase)) + { + classes.Add("clef-grid-row-error"); + } + + return string.Join(' ', classes); + } +} diff --git a/src/Components/LogToolbar.razor b/src/Components/LogToolbar.razor index 82e2c34..d072cae 100644 --- a/src/Components/LogToolbar.razor +++ b/src/Components/LogToolbar.razor @@ -23,6 +23,13 @@ Disabled="@(TotalCount == 0)" Title="Exporta os eventos filtrados (não apenas a página atual)" OnClick="OnExport" /> + + @* Só faz sentido quando há um detalhe aberto para reposicionar. O ícone aponta para ONDE o painel vai, e não onde ele está — a lib não tem ícones de painel, e a seta remove a ambiguidade. *@ @@ -55,6 +62,10 @@ [Parameter] public bool IsLoading { get; set; } [Parameter] public EventCallback OnCancelLoad { get; set; } + /// Exibindo em tabela (em vez da lista). + [Parameter] public bool GridMode { get; set; } + [Parameter] public EventCallback OnToggleViewMode { get; set; } + /// Há um evento selecionado (o painel de detalhes está aberto). [Parameter] public bool DetailVisible { get; set; } [Parameter] public bool DetailAtBottom { get; set; } diff --git a/src/Components/LogViewer.razor b/src/Components/LogViewer.razor index 4413837..c1e4820 100644 --- a/src/Components/LogViewer.razor +++ b/src/Components/LogViewer.razor @@ -1,6 +1,7 @@ @using System.Threading @using ClefExplorer.Models @using ClefExplorer.Services +@using ClefExplorer.Helpers @implements IDisposable @inject LogStore Store @@ -64,28 +65,27 @@ OnCancelLoad="CancelarCarregamento" DetailVisible="@(_selected != null)" DetailAtBottom="@DetalheAbaixo" - OnToggleDetailPosition="AlternarPosicaoDetalhe" /> + OnToggleDetailPosition="AlternarPosicaoDetalhe" + GridMode="@ModoTabela" + OnToggleViewMode="AlternarModoVisualizacao" />
- @* Sem seleção: lista ocupa todo o espaço (sem splitter, evitando a - race de medição do OmniSplitter aninhado no estado de 1 pane). - Com seleção: splitter lista|detalhe — criado quando o pane pai já - tem largura, então a medição funciona. *@ + @* Sem seleção: o miolo ocupa todo o espaço, SEM splitter. Manter o + splitter sempre montado (com o pane do detalhe colapsado) foi + tentado e reintroduz a race de medição: o splitter aninhado mede + 0px e o conteúdo some. + + O @key na orientação: o splitter mede e fixa os tamanhos dos panes + na inicialização, então reaproveitar a instância deixaria as + medidas da disposição anterior aplicadas na nova. *@ @if (_selected != null) { - @* @key força a recriação do splitter ao trocar a orientação: - ele mede e fixa os tamanhos dos panes na inicialização, então - reaproveitar a instância deixaria as larguras da disposição - anterior aplicadas na nova. *@
- + @RenderEventos()
@@ -98,10 +98,7 @@ else {
- + @RenderEventos()
}
@@ -111,7 +108,29 @@
+@* Lista ou tabela conforme a preferência — o resto do layout (splitter, detalhe) é o + mesmo nos dois modos, então só o miolo troca. *@ @code { + private RenderFragment RenderEventos() => __builder => + { + if (ModoTabela) + { + + } + else + { + + } + }; + private ClefEvent? _selected; private bool _isBusy; private List _todosEventos = new(); @@ -509,6 +528,9 @@ _todosEventos = result; if (_pagina > UltimaPagina) _pagina = 1; AtualizarPagina(); + // As colunas vêm do conteúdo, então mudam quando o conjunto muda. + // Só recalcula no modo tabela — na lista seria trabalho jogado fora. + if (ModoTabela) DescobrirColunas(); _isBusy = false; StateHasChanged(); }); @@ -585,6 +607,53 @@ /// Painel de detalhes abaixo da lista (em vez de à direita). private bool DetalheAbaixo => UiPreferences.Preferences.DetailPanelPosition == DetailPanelPosition.Bottom; + private bool ModoTabela => UiPreferences.Preferences.ViewMode == LogViewMode.Grid; + + /// + /// Colunas visíveis na tabela. Vazio (primeira abertura) cai no conjunto padrão — o + /// usuário nunca vê uma tabela sem colunas. + /// + private HashSet ColunasVisiveis + { + get + { + var salvas = UiPreferences.Preferences.GridVisibleColumns; + return new HashSet( + salvas.Count > 0 ? salvas : LogGridColumns.Defaults, + StringComparer.OrdinalIgnoreCase); + } + } + + private void SalvarColunasVisiveis(HashSet colunas) => + UiPreferences.SetGridVisibleColumns(colunas); + + private IReadOnlyList _colunasDescobertas = Array.Empty(); + + /// Alterna entre lista e tabela, persistindo a escolha. + private void AlternarModoVisualizacao() + { + UiPreferences.ToggleViewMode(); + if (ModoTabela) DescobrirColunas(); + } + + /// + /// Deriva as colunas de propriedade do conteúdo carregado. Roda sobre o resultado + /// filtrado inteiro (não a página), para uma propriedade que só aparece em eventos de + /// outras páginas não ficar de fora. + /// + private void DescobrirColunas() + { + try + { + _colunasDescobertas = LogColumnDiscovery.Discover(_todosEventos); + } + catch (Exception ex) + { + AppLog.Warning("Falha ao descobrir colunas a partir do conteúdo dos logs", ex); + _colunasDescobertas = Array.Empty(); + } + } + /// /// Alterna entre detalhe à direita e abaixo. À direita funciona melhor em telas largas; /// abaixo, para stack traces longos e telas estreitas. A escolha é persistida. diff --git a/src/Helpers/LogColumnDiscovery.cs b/src/Helpers/LogColumnDiscovery.cs new file mode 100644 index 0000000..844bbcd --- /dev/null +++ b/src/Helpers/LogColumnDiscovery.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ClefExplorer.Models; +using Serilog.Events; + +namespace ClefExplorer.Helpers +{ + /// Uma coluna derivada de uma propriedade estruturada do log. + /// Nome da propriedade no evento (ex.: SourceContext). + /// Rótulo exibido no cabeçalho. + /// Fração dos eventos amostrados que possuem a propriedade (0..1). + public sealed record DiscoveredColumn(string Key, string Title, double Frequency); + + /// + /// Deriva colunas do CONTEÚDO dos logs. + /// + /// Logs CLEF carregam propriedades estruturadas (SourceContext, + /// RequestId, MachineName…) que variam conforme a aplicação que os + /// gerou. Em vez de fixar uma lista, amostramos os eventos carregados e oferecemos como + /// coluna as propriedades que aparecem com frequência — o usuário liga/desliga cada uma + /// pelo menu de colunas. + /// + public static class LogColumnDiscovery + { + /// Quantos eventos são inspecionados. Amostra basta e evita varrer milhões de linhas. + public const int DefaultSampleSize = 2_000; + + /// Máximo de colunas sugeridas, para o menu não virar uma lista interminável. + public const int DefaultMaxColumns = 15; + + /// Fração mínima de eventos com a propriedade para ela virar coluna. + public const double DefaultMinFrequency = 0.05; + + /// + /// Propriedades que já têm coluna própria ou que não agregam nada numa tabela. + /// Comparação sem diferenciar maiúsculas, como o resto do tratamento de propriedades. + /// + private static readonly HashSet Excluded = new(StringComparer.OrdinalIgnoreCase) + { + // Já exibidas em colunas fixas. + "SourceFile", "Level", "Message", "MessageTemplate", "Timestamp", "Exception", + // Ruído do Serilog: o template renderizado já está na coluna Mensagem. + "SourceContextTemplate", + }; + + public static IReadOnlyList Discover( + IEnumerable events, + int sampleSize = DefaultSampleSize, + int maxColumns = DefaultMaxColumns, + double minFrequency = DefaultMinFrequency) + { + ArgumentNullException.ThrowIfNull(events); + + var contagem = new Dictionary(StringComparer.OrdinalIgnoreCase); + var amostrados = 0; + + foreach (var ev in events.Take(sampleSize)) + { + amostrados++; + if (ev.Properties is null) continue; + + foreach (var chave in ev.Properties.Keys) + { + if (string.IsNullOrWhiteSpace(chave) || Excluded.Contains(chave)) continue; + contagem[chave] = contagem.GetValueOrDefault(chave) + 1; + } + } + + if (amostrados == 0) return Array.Empty(); + + return contagem + .Select(p => new DiscoveredColumn(p.Key, Humanize(p.Key), p.Value / (double)amostrados)) + .Where(c => c.Frequency >= minFrequency) + // Mais frequentes primeiro; nome como desempate, para a ordem ser estável + // entre carregamentos (senão as colunas dançariam a cada abertura). + .OrderByDescending(c => c.Frequency) + .ThenBy(c => c.Key, StringComparer.OrdinalIgnoreCase) + .Take(maxColumns) + .ToList(); + } + + /// + /// "RequestId" → "Request Id". Nomes de propriedade vêm em PascalCase do código que + /// emitiu o log; separá-los deixa o cabeçalho legível. + /// + public static string Humanize(string key) + { + if (string.IsNullOrEmpty(key)) return key; + + var sb = new System.Text.StringBuilder(key.Length + 4); + for (var i = 0; i < key.Length; i++) + { + var c = key[i]; + var anterior = i > 0 ? key[i - 1] : '\0'; + var proximo = i + 1 < key.Length ? key[i + 1] : '\0'; + + // Espaço antes de uma maiúscula que inicia palavra — inclusive no fim de uma + // sigla ("HTTPRequest" → "HTTP Request"). + var iniciaPalavra = char.IsUpper(c) + && i > 0 + && (!char.IsUpper(anterior) || (char.IsUpper(anterior) && char.IsLower(proximo))); + + if (iniciaPalavra) sb.Append(' '); + sb.Append(c); + } + + return sb.ToString(); + } + + /// + /// Texto da célula para uma propriedade estruturada. + /// envolve strings em aspas — indesejado numa + /// tabela, onde a coluna já dá o contexto. + /// + public static string FormatValue(ClefEvent ev, string key) + { + if (ev.Properties is null || !ev.Properties.TryGetValue(key, out var valor)) return string.Empty; + + return valor switch + { + null => string.Empty, + ScalarValue s => s.Value?.ToString() ?? string.Empty, + _ => valor.ToString(), + }; + } + } +} diff --git a/src/Helpers/LogGridColumns.cs b/src/Helpers/LogGridColumns.cs new file mode 100644 index 0000000..c97b8f3 --- /dev/null +++ b/src/Helpers/LogGridColumns.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; + +namespace ClefExplorer.Helpers +{ + /// + /// Colunas fixas da visão em tabela. As demais são descobertas em tempo de execução + /// pelo , a partir do conteúdo dos logs carregados. + /// + public static class LogGridColumns + { + public const string Timestamp = "Timestamp"; + public const string Level = "Level"; + public const string Message = "Message"; + public const string SourceFile = "SourceFile"; + public const string Exception = "Exception"; + + /// Colunas fixas, na ordem em que aparecem na tabela. + public static readonly IReadOnlyList<(string Key, string Title)> Fixed = new[] + { + (Timestamp, "Data/hora"), + (Level, "Nível"), + (Message, "Mensagem"), + (SourceFile, "Arquivo"), + (Exception, "Exceção"), + }; + + /// + /// Visíveis na primeira abertura. Exceção fica de fora: é útil, mas polui a tabela + /// para quem só quer ler as mensagens — e está a um clique de distância. + /// + public static IReadOnlyList Defaults { get; } = new[] + { + Timestamp, Level, Message, SourceFile, + }; + } +} diff --git a/src/Models/DetailPanelPosition.cs b/src/Models/UiEnums.cs similarity index 53% rename from src/Models/DetailPanelPosition.cs rename to src/Models/UiEnums.cs index d196e7f..9dc8d00 100644 --- a/src/Models/DetailPanelPosition.cs +++ b/src/Models/UiEnums.cs @@ -1,5 +1,15 @@ namespace ClefExplorer.Models { + /// Como os eventos são apresentados. + public enum LogViewMode + { + /// Lista compacta (padrão): uma linha por evento, com a mensagem em destaque. + List = 0, + + /// Tabela com colunas: permite ordenar, agrupar e escolher as colunas exibidas. + Grid = 1, + } + /// Onde o painel de detalhes aparece em relação à lista de eventos. public enum DetailPanelPosition { diff --git a/src/Services/UiPreferencesService.cs b/src/Services/UiPreferencesService.cs index fdda999..6c64a23 100644 --- a/src/Services/UiPreferencesService.cs +++ b/src/Services/UiPreferencesService.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using ClefExplorer.Models; @@ -9,6 +11,16 @@ namespace ClefExplorer.Services public class UiPreferences { public DetailPanelPosition DetailPanelPosition { get; set; } = DetailPanelPosition.Right; + + public LogViewMode ViewMode { get; set; } = LogViewMode.List; + + /// + /// Colunas visíveis no modo tabela, por chave (fixas e descobertas). Guardamos por + /// nome, e não por posição: as colunas disponíveis vêm do conteúdo dos logs + /// carregados, então mudam conforme os arquivos abertos. Lista vazia = ainda não + /// escolhido, usa o padrão. + /// + public List GridVisibleColumns { get; set; } = new(); } /// @@ -79,5 +91,23 @@ public DetailPanelPosition ToggleDetailPanelPosition() Save(); return _preferences.DetailPanelPosition; } + + /// Alterna entre lista e tabela e persiste a escolha. + public LogViewMode ToggleViewMode() + { + _preferences.ViewMode = _preferences.ViewMode == LogViewMode.List + ? LogViewMode.Grid + : LogViewMode.List; + + Save(); + return _preferences.ViewMode; + } + + /// Grava quais colunas ficam visíveis no modo tabela. + public void SetGridVisibleColumns(IEnumerable keys) + { + _preferences.GridVisibleColumns = keys.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + Save(); + } } } diff --git a/src/wwwroot/css/app.css b/src/wwwroot/css/app.css index dd2919a..b82b505 100644 --- a/src/wwwroot/css/app.css +++ b/src/wwwroot/css/app.css @@ -607,3 +607,56 @@ span.omni-tree-text.clef-tree-backup { font-weight: 600; } .clef-path-list::-webkit-scrollbar-thumb:hover { background: var(--omni-fg-soft); } + +/* ---- Visão em tabela (OmniDataGrid) --------------------------------------- */ +.clef-grid { + height: 100%; + display: flex; + flex-direction: column; + min-height: 0; + background: var(--omni-bg); +} + +/* Embed="true" faz o grid preencher o pai; garante o mesmo aqui. */ +.clef-grid > .omni-datagrid { + flex: 1 1 auto; + min-height: 0; +} + +/* Linha do evento selecionado: mesma leitura da lista. */ +.clef-grid .omni-datagrid tbody tr.is-selected { + background: color-mix(in oklab, var(--omni-accent) 14%, var(--omni-bg)); +} + +/* Barra à esquerda em erros/fatais, como na lista — cor, e não só o badge, + para o erro saltar mesmo com a coluna Nível oculta. */ +.clef-grid .omni-datagrid tbody tr.clef-grid-row-error td:first-child { + box-shadow: inset 3px 0 0 0 color-mix(in oklab, var(--omni-danger) 80%, var(--omni-fg)); +} + +.clef-grid .clef-grid-exc { + color: color-mix(in oklab, var(--omni-danger) 80%, var(--omni-fg)); +} + +/* Seletor de colunas da tabela (próprio, não o embutido do grid). */ +.clef-col-chooser { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 320px; + overflow-y: auto; +} + +.clef-col-item { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 8px; + border-radius: var(--omni-radius); + font-size: 13px; + color: var(--omni-fg); + cursor: pointer; +} + +.clef-col-item:hover { background: var(--omni-bg-sunken); } +.clef-col-item input { cursor: pointer; } diff --git a/test/ClefExplorer.Tests/LogColumnDiscoveryTests.cs b/test/ClefExplorer.Tests/LogColumnDiscoveryTests.cs new file mode 100644 index 0000000..40f4ecc --- /dev/null +++ b/test/ClefExplorer.Tests/LogColumnDiscoveryTests.cs @@ -0,0 +1,182 @@ +using ClefExplorer.Helpers; +using ClefExplorer.Models; +using Serilog.Events; + +namespace ClefExplorer.Tests; + +/// +/// Descoberta de colunas a partir do conteúdo dos logs. Cada aplicação emite um conjunto +/// próprio de propriedades estruturadas, então as colunas não podem ser fixadas na mão. +/// +public class LogColumnDiscoveryTests +{ + private static ClefEvent Event(params (string Key, object Value)[] props) + { + var ev = new ClefEvent + { + Level = "Information", + Timestamp = DateTimeOffset.UtcNow, + Properties = new Dictionary(StringComparer.OrdinalIgnoreCase), + }; + + foreach (var (key, value) in props) + { + ev.Properties![key] = new ScalarValue(value); + } + + return ev; + } + + // --- Descoberta -------------------------------------------------------------- + + [Fact] + public void Discovers_properties_present_in_the_events() + { + var eventos = new[] + { + Event(("SourceContext", "Api.Pedido"), ("RequestId", "req-1")), + Event(("SourceContext", "Api.Pagamento"), ("RequestId", "req-2")), + }; + + var colunas = LogColumnDiscovery.Discover(eventos); + + Assert.Contains(colunas, c => c.Key == "SourceContext"); + Assert.Contains(colunas, c => c.Key == "RequestId"); + } + + [Fact] + public void Orders_by_frequency_so_the_most_useful_come_first() + { + var eventos = new[] + { + Event(("Comum", 1), ("Raro", 1)), + Event(("Comum", 2)), + Event(("Comum", 3)), + }; + + var colunas = LogColumnDiscovery.Discover(eventos); + + Assert.Equal("Comum", colunas[0].Key); + } + + [Fact] + public void Ignores_properties_that_are_too_rare_to_be_worth_a_column() + { + // 1 evento em 100 com a propriedade: abaixo do mínimo de 5%. + var eventos = Enumerable.Range(0, 100) + .Select(i => i == 0 ? Event(("QuaseNunca", 1), ("Sempre", 1)) : Event(("Sempre", 1))) + .ToArray(); + + var colunas = LogColumnDiscovery.Discover(eventos); + + Assert.Contains(colunas, c => c.Key == "Sempre"); + Assert.DoesNotContain(colunas, c => c.Key == "QuaseNunca"); + } + + [Fact] + public void Skips_properties_that_already_have_a_fixed_column() + { + var eventos = new[] { Event(("SourceFile", @"C:\logs\a.clef"), ("Level", "Error"), ("Util", 1)) }; + + var colunas = LogColumnDiscovery.Discover(eventos); + + Assert.DoesNotContain(colunas, c => c.Key == "SourceFile"); + Assert.DoesNotContain(colunas, c => c.Key == "Level"); + Assert.Contains(colunas, c => c.Key == "Util"); + } + + [Fact] + public void Caps_the_number_of_columns_so_the_menu_stays_usable() + { + var muitas = Enumerable.Range(0, 50).Select(i => ($"Prop{i}", (object)i)).ToArray(); + + var colunas = LogColumnDiscovery.Discover(new[] { Event(muitas) }, maxColumns: 5); + + Assert.Equal(5, colunas.Count); + } + + [Fact] + public void The_order_is_stable_between_runs() + { + // Sem desempate estável, as colunas dançariam a cada abertura do arquivo. + var eventos = new[] { Event(("Bbb", 1), ("Aaa", 1), ("Ccc", 1)) }; + + var primeira = LogColumnDiscovery.Discover(eventos).Select(c => c.Key); + var segunda = LogColumnDiscovery.Discover(eventos).Select(c => c.Key); + + Assert.Equal(primeira, segunda); + } + + [Fact] + public void Only_the_sample_is_inspected() + { + // A propriedade só existe além da amostra: não deve virar coluna. + var eventos = Enumerable.Range(0, 10) + .Select(i => i < 5 ? Event(("Cedo", 1)) : Event(("Tarde", 1))) + .ToArray(); + + var colunas = LogColumnDiscovery.Discover(eventos, sampleSize: 5); + + Assert.Contains(colunas, c => c.Key == "Cedo"); + Assert.DoesNotContain(colunas, c => c.Key == "Tarde"); + } + + [Fact] + public void An_empty_set_yields_no_columns() + { + Assert.Empty(LogColumnDiscovery.Discover(Array.Empty())); + } + + [Fact] + public void Events_without_properties_do_not_break_discovery() + { + var eventos = new[] { new ClefEvent { Level = "Information" }, Event(("Util", 1)) }; + + var colunas = LogColumnDiscovery.Discover(eventos); + + Assert.Contains(colunas, c => c.Key == "Util"); + } + + // --- Rótulo ------------------------------------------------------------------- + + [Theory] + [InlineData("RequestId", "Request Id")] + [InlineData("SourceContext", "Source Context")] + [InlineData("MachineName", "Machine Name")] + [InlineData("Id", "Id")] + [InlineData("HTTPRequest", "HTTP Request")] // sigla seguida de palavra + [InlineData("threadId", "thread Id")] // já começa minúsculo + [InlineData("", "")] + public void Property_names_become_readable_headers(string key, string expected) + { + Assert.Equal(expected, LogColumnDiscovery.Humanize(key)); + } + + // --- Valor da célula ---------------------------------------------------------- + + [Fact] + public void Scalar_strings_are_shown_without_the_quotes_serilog_adds() + { + var ev = Event(("SourceContext", "Api.Pedido")); + + Assert.Equal("Api.Pedido", LogColumnDiscovery.FormatValue(ev, "SourceContext")); + } + + [Fact] + public void Numbers_are_shown_as_is() + { + Assert.Equal("42", LogColumnDiscovery.FormatValue(Event(("PedidoId", 42)), "PedidoId")); + } + + [Fact] + public void A_missing_property_yields_an_empty_cell() + { + Assert.Equal(string.Empty, LogColumnDiscovery.FormatValue(Event(("Outra", 1)), "Inexistente")); + } + + [Fact] + public void An_event_without_properties_yields_an_empty_cell() + { + Assert.Equal(string.Empty, LogColumnDiscovery.FormatValue(new ClefEvent(), "Qualquer")); + } +} From 24638a827aa9bd378fc00aff2cf26377f71f794c Mon Sep 17 00:00:00 2001 From: Anderson Fernandes do Nascimento Date: Tue, 28 Jul 2026 12:19:01 -0300 Subject: [PATCH 3/7] CI roda em PR para qualquer branch, nao so para a main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Com 'pull_request: branches: [main]', PRs empilhados — que apontam para outra branch de feature — ficavam sem CI: aconteceu no #5 e no #7, onde o gate de testes so passou a valer depois de disparar o workflow na mao. Removido o filtro de base; o gatilho de push continua restrito a main. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6e4d4a..87ab815 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,10 @@ name: CI on: push: branches: [main] + # Sem filtro de base: roda em PR para QUALQUER branch. Com "branches: [main]" os + # PRs empilhados (que apontam para outra branch de feature) ficavam sem CI, e o + # gate de testes só valia para o último PR da fila. pull_request: - branches: [main] workflow_dispatch: permissions: From 938b2c10953002ab091f2b56b41f56af173d568b Mon Sep 17 00:00:00 2001 From: Anderson Fernandes do Nascimento Date: Tue, 28 Jul 2026 12:36:14 -0300 Subject: [PATCH 4/7] Unifica a versao numa fonte unica e limpa residuos (roadmap P4.21/P4.23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A versao vivia solta no csproj e ficou em 1.0.0 enquanto a Store ja estava em 1.0.2 — o titulo da janela, que le a versao do assembly, mostrava um numero diferente do publicado. Directory.Build.props passa a ser a fonte unica: - o csproj herda Version/FileVersion/AssemblyVersion (verificado: o assembly agora sai 1.1.0.0); - publish-store-package.ps1 usa esse valor quando -Version nao e informado; - release.yml passa /p:Version com a versao da tag, entao o exe dentro do pacote carrega a mesma versao carimbada no manifesto — antes o manifesto ia com a versao da tag e o assembly ficava em 1.0.0. Higiene: - o tipo de arquivo registrado virou 'Clef Log File'; 'Reader Log File' era sobra de copy/paste e divergia do rotulo do Package.appxmanifest; - RuntimeIdentifiers fica so em win-x64: e o que o CI publica, o que o pacote da Store contem e o que o README anuncia. Declarar win-x86 sugeria um suporte que nao existe. Nao suprimi o aviso MSB3277 remanescente: ele vem da unificacao de WindowsBase pelo proprio pacote WebView2 e mascarar conflitos de assembly com NoWarn e pior do que conviver com o ruido. Os avisos CS do nosso codigo seguem zerados. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 1 + Directory.Build.props | 27 ++++++++++++++++++++++++++ publish-store-package.ps1 | 14 ++++++++++++- src/ClefExplorer.csproj | 8 ++++---- src/Services/FileAssociationService.cs | 4 +++- 5 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 Directory.Build.props diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cce69c3..46d4107 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,6 +93,7 @@ jobs: /p:AppxBundlePlatforms=x64 /p:UapAppxPackageBuildMode=StoreUpload /p:AppxPackageSigningEnabled=false + /p:Version=${{ steps.ver.outputs.version }} - name: Locate upload package id: pkg diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..077b652 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,27 @@ + + + + + 1.1.0 + + $(ClefVersion) + + $(ClefVersion).0 + $(ClefVersion).0 + + + diff --git a/publish-store-package.ps1 b/publish-store-package.ps1 index 8f50191..0b55679 100644 --- a/publish-store-package.ps1 +++ b/publish-store-package.ps1 @@ -39,7 +39,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$Version, + [string]$Version = '', [string]$Platform = 'x64', [string]$Configuration = 'Release', @@ -60,6 +60,18 @@ foreach ($p in @($srcProj, $pkgProj, $manifest)) { if (-not (Test-Path $p)) { throw "Não encontrei: $p (rode o script da raiz do repositório)." } } +# ---------- Versão: parâmetro, ou a do Directory.Build.props ---------- +if ([string]::IsNullOrWhiteSpace($Version)) { + $propsPath = Join-Path $root 'Directory.Build.props' + if (Test-Path $propsPath) { + $Version = ([xml](Get-Content $propsPath)).Project.PropertyGroup.ClefVersion + } + if ([string]::IsNullOrWhiteSpace($Version)) { + throw "Informe -Version ou defina em Directory.Build.props." + } + Write-Host "Versão herdada do Directory.Build.props: $Version" -ForegroundColor DarkGray +} + # ---------- Normaliza a versão para x.y.z.0 ---------- $v = $Version.Trim().TrimStart('v', 'V') if ($v -notmatch '^\d+\.\d+(\.\d+){0,2}$') { diff --git a/src/ClefExplorer.csproj b/src/ClefExplorer.csproj index aa1fd79..3a7d63b 100644 --- a/src/ClefExplorer.csproj +++ b/src/ClefExplorer.csproj @@ -11,7 +11,9 @@ true true - win-x64;win-x86 + + win-x64 true true app.ico @@ -24,9 +26,7 @@ https://github.com/afernandes/ClefExplorer https://github.com/afernandes/ClefExplorer.git log;serilog;clef;viewer;blazor;winforms;structured-logging - 1.0.0 - 1.0.0.0 - 1.0.0.0 + diff --git a/src/Services/FileAssociationService.cs b/src/Services/FileAssociationService.cs index 0c32768..bb0c038 100644 --- a/src/Services/FileAssociationService.cs +++ b/src/Services/FileAssociationService.cs @@ -101,7 +101,9 @@ private void RegisterAppId(string exePath) using var key = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{AppId}"); if (key != null) { - key.SetValue(null, "Reader Log File"); + // Mesmo rótulo declarado no Package.appxmanifest, para o tipo de arquivo + // aparecer igual na versão instalada e na avulsa. + key.SetValue(null, "Clef Log File"); key.SetValue("Icon", $"\"{exePath}\",0"); using var shell = key.CreateSubKey("shell"); From 1259ae5b77746b539f9cafe83a492452b212bf1a Mon Sep 17 00:00:00 2001 From: Anderson Fernandes do Nascimento Date: Tue, 28 Jul 2026 12:42:13 -0300 Subject: [PATCH 5/7] Virtualiza a lista e corrige a paginacao off-by-one (roadmap P4.18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lista montava um no no DOM por evento da pagina. O roadmap pedia para medir antes: com paginas de ate 100 a virtualizacao renderia pouco, entao o ganho de verdade vinha junto com paginas maiores. A lista passa a usar OmniVirtualize (Height=null: o pai ja limita a altura e faz o scroll; sem altura limitada o Virtualize renderiza tudo) e o seletor ganha 250/500/1000 itens por pagina. Verificado com 5000 eventos: pagina de 1000 rola sem travar. Correcao encontrada durante esse teste: OmniPagination e zero-based e o app conta a partir de 1 — que e o numero mostrado no "X-Y de Z". Sem conversao, a pagina 1 aparecia como "2 / 100" e a ultima pagina ficava inalcancavel, porque o clamp do app ia ate UltimaPagina enquanto o componente esperava UltimaPagina - 1. A traducao ficou na fronteira, dentro da LogToolbar. Bug meu, introduzido na migracao do P2 ao trocar os botoes manuais pelo componente. Verificado: "1-50 de 5000" com "1 / 100", e o proximo leva a "51-100" / "2 / 100". Co-Authored-By: Claude Opus 5 --- src/Components/LogHeader.razor | 4 ++- src/Components/LogList.razor | 53 ++++++++++++++++++++------------- src/Components/LogToolbar.razor | 7 +++-- 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/Components/LogHeader.razor b/src/Components/LogHeader.razor index 6bc245e..af99720 100644 --- a/src/Components/LogHeader.razor +++ b/src/Components/LogHeader.razor @@ -42,7 +42,9 @@ [Parameter] public EventCallback OnReload { get; set; } - private readonly int[] _qtdOptions = { 30, 50, 100 }; + // Páginas grandes só passaram a fazer sentido depois que a lista virou virtualizada: + // antes, 1000 itens significavam 1000 nós no DOM. + private readonly int[] _qtdOptions = { 30, 50, 100, 250, 500, 1000 }; private static string FormatQtd(int v) => $"{v} itens"; diff --git a/src/Components/LogList.razor b/src/Components/LogList.razor index 633497a..350022e 100644 --- a/src/Components/LogList.razor +++ b/src/Components/LogList.razor @@ -9,29 +9,33 @@ Carregando logs... } - else if (Eventos != null && Eventos.Any()) + else if (_itens.Count > 0) { - @foreach (var e in Eventos) - { - var ev = e; - var selected = ReferenceEquals(SelectedEvent, ev); -
-
-
- - @ev.Timestamp?.ToString("HH:mm:ss") -
-
@ev.Message
-
- @if (!string.IsNullOrEmpty(ev.Exception)) - { - Exception - } - @ObterResumoProps(ev) + @* Virtualizado: só as linhas visíveis (mais um pequeno overscan) vão ao DOM. + Height=null porque o pai (.clef-list) já limita a altura e faz o scroll — + sem altura limitada o Virtualize renderizaria tudo. É o que permite páginas + grandes (500/1000) sem a UI travar. *@ + + +
+
+
+ + @ev.Timestamp?.ToString("HH:mm:ss") +
+
@ev.Message
+
+ @if (!string.IsNullOrEmpty(ev.Exception)) + { + Exception + } + @ObterResumoProps(ev) +
-
- } + + } else { @@ -47,6 +51,15 @@ [Parameter] public ClefEvent? SelectedEvent { get; set; } [Parameter] public EventCallback OnSelect { get; set; } + /// + /// O OmniVirtualize exige ICollection: materializa uma vez por mudança de + /// parâmetros, em vez de a cada render. + /// + private ICollection _itens = Array.Empty(); + + protected override void OnParametersSet() => + _itens = Eventos as ICollection ?? Eventos?.ToList() ?? (ICollection)Array.Empty(); + private string ObterResumoProps(ClefEvent e) { if (e.Properties == null) return ""; diff --git a/src/Components/LogToolbar.razor b/src/Components/LogToolbar.razor index d072cae..f5bb27e 100644 --- a/src/Components/LogToolbar.razor +++ b/src/Components/LogToolbar.razor @@ -42,8 +42,11 @@ Title="@(DetailAtBottom ? "Mover o painel de detalhes para a direita" : "Mover o painel de detalhes para baixo")" OnClick="OnToggleDetailPosition" /> } - From a4e1b79832f3bce36a4808a9b41d3120eeb3c89a Mon Sep 17 00:00:00 2001 From: Anderson Fernandes do Nascimento Date: Tue, 28 Jul 2026 12:46:36 -0300 Subject: [PATCH 6/7] Melhora a acessibilidade dos elementos proprios (roadmap P4.20) Nao houve passe de a11y depois da migracao. Os componentes da Omni ja vem tratados, mas os elementos clef-* proprios nao. Lista de eventos: era uma pilha de div com @onclick - nao recebia foco, nao respondia ao teclado e o leitor de tela nao a anunciava como lista selecionavel. Agora e role=listbox com itens role=option, tabindex e aria-selected, ativaveis por Enter/Espaco. Cada item ganhou um aria-label montado com nivel, hora, mensagem e a marca de excecao: sem ele o item seria lido como textos soltos e o nivel, que visualmente e um badge colorido, se perderia. Itens de grupo viraram button de verdade em vez de div com @onclick - foco, teclado e semantica de graca. Nao ha nada interativo aninhado, entao a troca e segura; o CSS ganhou o reset de aparencia correspondente. Indicador de foco: nenhum elemento clef-* tinha. Adicionado via :focus-visible, que mostra o anel so na navegacao por teclado, sem poluir o clique de mouse. O contraste ja tinha sido tratado na migracao (WCAG AA medido no DOM nos dois temas), entao ficou de fora deste passe. Nao consegui confirmar o anel de foco na tela: a maquina bloqueou durante o teste. As mudancas sao estruturais e compilam, mas a verificacao visual fica pendente. Co-Authored-By: Claude Opus 5 --- src/Components/LogGroupManager.razor | 10 +++++++-- src/Components/LogList.razor | 31 ++++++++++++++++++++++++++-- src/wwwroot/css/app.css | 19 +++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/Components/LogGroupManager.razor b/src/Components/LogGroupManager.razor index 7743a66..e25eee3 100644 --- a/src/Components/LogGroupManager.razor +++ b/src/Components/LogGroupManager.razor @@ -11,10 +11,16 @@ @foreach (var group in GroupService.Groups) { var g = group; -
+ @*
+ }
diff --git a/src/Components/LogList.razor b/src/Components/LogList.razor index 350022e..ee91237 100644 --- a/src/Components/LogList.razor +++ b/src/Components/LogList.razor @@ -1,7 +1,9 @@ @using ClefExplorer.Models @using ClefExplorer.Helpers -
+@* role=listbox + option: os itens são
por causa do layout, mas precisam ser + anunciados como uma lista de opções selecionáveis e alcançáveis pelo teclado. *@ +
@if (IsBusy) {
@@ -18,7 +20,12 @@
+ role="option" + tabindex="0" + aria-selected="@ReferenceEquals(SelectedEvent, ev).ToString().ToLowerInvariant()" + aria-label="@RotuloAcessivel(ev)" + @onclick="@(() => OnSelect.InvokeAsync(ev))" + @onkeydown="@(e => AoTeclar(e, ev))">
@@ -60,6 +67,26 @@ protected override void OnParametersSet() => _itens = Eventos as ICollection ?? Eventos?.ToList() ?? (ICollection)Array.Empty(); + /// Enter/Espaço ativam o item, como num botão. + private async Task AoTeclar(KeyboardEventArgs e, ClefEvent ev) + { + if (e.Key is "Enter" or " ") + { + await OnSelect.InvokeAsync(ev); + } + } + + /// + /// Nome anunciado pelo leitor de tela. Sem ele, o item seria lido como um amontoado de + /// textos soltos — e o nível, que visualmente é um badge colorido, se perderia. + /// + private static string RotuloAcessivel(ClefEvent e) + { + var hora = e.Timestamp?.ToString("HH:mm:ss") ?? ""; + var excecao = string.IsNullOrEmpty(e.Exception) ? "" : ", com exceção"; + return $"{e.Level} às {hora}: {e.Message}{excecao}"; + } + private string ObterResumoProps(ClefEvent e) { if (e.Properties == null) return ""; diff --git a/src/wwwroot/css/app.css b/src/wwwroot/css/app.css index b82b505..3a9226f 100644 --- a/src/wwwroot/css/app.css +++ b/src/wwwroot/css/app.css @@ -552,15 +552,21 @@ span.omni-tree-text.clef-tree-backup { font-weight: 600; } flex-direction: column; gap: 8px; } +/* É um