diff --git a/.gitignore b/.gitignore index ce89292..32d2304 100644 --- a/.gitignore +++ b/.gitignore @@ -416,3 +416,6 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# Capturas do Playwright MCP durante a inspeção da UI — depuração, nunca fonte. +.playwright-mcp/ diff --git a/Directory.Build.props b/Directory.Build.props index 532b205..43a43e7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -16,7 +16,7 @@ tempo de empacotamento pelo script e pelo workflow, ambos a partir deste valor. --> - 1.1.0 + 1.2.0 diff --git a/README.md b/README.md index 27309ad..2d40456 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Utilize a barra lateral esquerda para: - **Windows Forms** (Host nativo) - **Blazor Hybrid** (Interface de usuário web dentro do desktop) - **Microsoft.AspNetCore.Components.WebView.WindowsForms** -- **Serilog** & **Serilog.Formatting.Compact.Reader** (Parsing de logs) +- **Serilog** (renderização de message templates; o parsing CLEF é próprio, sobre `Utf8JsonReader`) - **Omni.Blazor** (Biblioteca de componentes / design system - pacote NuGet `AndersonN.Omni.Blazor`) ## 📄 Licença diff --git a/src/ClefExplorer.csproj b/src/ClefExplorer.csproj index 3a7d63b..e463b05 100644 --- a/src/ClefExplorer.csproj +++ b/src/ClefExplorer.csproj @@ -30,12 +30,17 @@ - - - - - - + + + + + + + diff --git a/src/Components/LogFileTree.razor b/src/Components/LogFileTree.razor index dc8742d..f6e32d2 100644 --- a/src/Components/LogFileTree.razor +++ b/src/Components/LogFileTree.razor @@ -1,44 +1,75 @@ @using System.IO @using System.Text.RegularExpressions +@using ClefExplorer.Helpers -@if (RootNodes.Any()) +@inject ContextMenuService ContextMenu +@inject ExploradorArquivos Explorador +@inject NotificationService Notifications +@inject IJSRuntime JS + +@if (!RootNodes.Any()) +{ +
+ Nenhum arquivo carregado +
+} +else if (_raizesVisiveis.Count == 0) +{ +
+ Nenhum arquivo corresponde à busca +
+} +else { - } -else -{ -
- Nenhum arquivo carregado -
-} @code { [Parameter] public IEnumerable Files { get; set; } = Enumerable.Empty(); [Parameter] public IEnumerable CheckedFiles { get; set; } = Enumerable.Empty(); [Parameter] public EventCallback> OnSelectionChanged { get; set; } + /// Termo digitado no campo de busca da barra lateral; vazio mostra tudo. + [Parameter] public string? Filtro { get; set; } + private List RootNodes { get; set; } = new(); private List _checkedNodes = new(); private List? _lastFiles; + private HashSet _visiveis = new(); + private List _raizesVisiveis = new(); + private string? _ultimoFiltro; + protected override void OnParametersSet() { var currentFiles = Files?.OrderBy(f => f).ToList() ?? new List(); @@ -57,8 +88,25 @@ else BuildTree(); _checkedNodes = ComputeChecked(currentChecked); } + + if (filesChanged || Filtro != _ultimoFiltro) + { + _ultimoFiltro = Filtro; + AplicarFiltro(); + } + } + + // ─── Busca na árvore ──────────────────────────────────────────────────────── + + private void AplicarFiltro() + { + _visiveis = FiltroArvoreArquivos.Visiveis(RootNodes, Filtro); + _raizesVisiveis = RootNodes.Where(_visiveis.Contains).ToList(); } + private List FilhosVisiveis(FileTreeNode node) => + node.Children.Where(_visiveis.Contains).ToList(); + private static string NodeIcon(FileTreeNode node) { if (node.IsBackup) return "archive"; @@ -111,6 +159,103 @@ else await OnSelectionChanged.InvokeAsync(set); } + // ─── Menu de contexto ─────────────────────────────────────────────────────── + + private void AbrirMenu(MouseEventArgs e, FileTreeNode? node) + { + if (node is null) return; + ContextMenu.Open(e, ConstruirMenu(node)); + } + + private List ConstruirMenu(FileTreeNode node) + { + var caminhos = node.CaminhosDescendentes().ToList(); + var arquivo = node.FullPath; + var itens = new List(); + + // Um nó de agrupamento não tem pasta própria: a hierarquia vem do NOME do + // arquivo, então não há o que revelar no Explorer. + if (!string.IsNullOrEmpty(arquivo)) + { + itens.Add(new ContextMenuItem + { + Text = "Mostrar no Explorer", + Icon = "folder", + OnClick = () => MostrarNoExplorer(arquivo), + }); + } + + if (caminhos.Count > 0) + { + itens.Add(new ContextMenuItem + { + Text = caminhos.Count == 1 ? "Copiar caminho" : $"Copiar {caminhos.Count} caminhos", + Icon = "copy", + OnClick = () => CopiarCaminhos(caminhos), + }); + + itens.Add(ContextMenuItem.Separator()); + + // Com dezenas de arquivos carregados, isolar um deles pelo checkbox significa + // desmarcar todos os outros a mão. + itens.Add(new ContextMenuItem + { + Text = caminhos.Count == 1 ? "Exibir somente este" : $"Exibir somente estes {caminhos.Count}", + Icon = "filter", + OnClick = () => ExibirSomente(caminhos), + }); + } + + itens.Add(new ContextMenuItem + { + Text = "Exibir todos os arquivos", + Icon = "list", + OnClick = ExibirTodos, + }); + + return itens; + } + + private Task MostrarNoExplorer(string caminho) + { + if (!Explorador.Revelar(caminho)) + { + Notifications.Warning("Arquivo não encontrado", caminho); + } + return Task.CompletedTask; + } + + private async Task CopiarCaminhos(IReadOnlyList caminhos) + { + try + { + await JS.InvokeVoidAsync("navigator.clipboard.writeText", string.Join(Environment.NewLine, caminhos)); + Notifications.Info(caminhos.Count == 1 ? "Caminho copiado" : $"{caminhos.Count} caminhos copiados"); + } + catch (Exception ex) + { + AppLog.Warning("Não foi possível copiar para a área de transferência", ex); + Notifications.Warning("Não foi possível copiar o caminho"); + } + } + + private Task ExibirSomente(IEnumerable caminhos) => + AplicarSelecao(new HashSet(caminhos, StringComparer.OrdinalIgnoreCase)); + + private Task ExibirTodos() => + AplicarSelecao(new HashSet(_lastFiles ?? new List(), StringComparer.OrdinalIgnoreCase)); + + /// + /// Troca a marcação inteira de uma vez. É a exceção à regra de que o OmniTree é a fonte + /// única durante a interação: aqui quem manda é uma ação explícita do usuário, e o + /// estado precisa ser reescrito nos dois lados (checkboxes e arquivos visíveis). + /// + private async Task AplicarSelecao(HashSet caminhos) + { + _checkedNodes = ComputeChecked(caminhos); + await OnSelectionChanged.InvokeAsync(caminhos); + } + // ─── Construção da árvore virtual (lógica preservada da versão Bootstrap) ─── private void BuildTree() @@ -347,12 +492,4 @@ else } } - public class FileTreeNode - { - public string Name { get; set; } = ""; - public string? FullPath { get; set; } - public List Children { get; set; } = new(); - public FileTreeNode? Parent { get; set; } - public bool IsBackup { get; set; } - } } diff --git a/src/Components/LogGrid.razor b/src/Components/LogGrid.razor index 68f5e93..01b33c9 100644 --- a/src/Components/LogGrid.razor +++ b/src/Components/LogGrid.razor @@ -12,123 +12,217 @@ 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; - - } - -
+ aqui, ele sobrevive à recriação e ainda é persistido entre execuções. + + As colunas entram e saem por @if, e NÃO pelo parâmetro Visible: na 0.2.0 a coluna + lê Visible só no OnInitialized, então alterá-lo depois não mexia em nada — marcar + e desmarcar no seletor não fazia a coluna aparecer nem sumir. Declarando de fato + só as colunas escolhidas, a que sai é descartada e se desregistra do grid. + (A biblioteca já foi corrigida para reagir a Visible; quando o app subir de versão + dá para voltar ao parâmetro, que evita recriar a coluna a cada troca.) *@ + +
+ @* O grid não recebia nenhum sinal de trabalho em andamento: com a tabela ainda vazia, + a primeira filtragem de um log grande deixava a área central em "Nenhum registro + encontrado" — que é a resposta errada para "ainda estou filtrando". *@ + @if (IsBusy) + { +
+ + Carregando logs... +
+ } + else + { + @* Virtualize + sem paginação: o grid recebe o conjunto filtrado inteiro, então + ordenar, agrupar e filtrar valem sobre TODO o log. Só as linhas visíveis vão ao + DOM, e a rolagem substitui o paginador. *@ + + + + + + + + @* Em três seções: as fixas, as propriedades recorrentes do log e os + campos de message template. Numa lista única, os campos de template + (que podem ser dezenas) afogariam o resto. *@ +
+ @foreach (var col in LogGridColumns.Fixed) + { + @ItemDeColuna(col.Key, col.Title) + } + + @if (ColunasDePropriedade.Count > 0) + { +
Propriedades
+ @foreach (var col in ColunasDePropriedade) + { + @ItemDeColuna(col.Key, col.Title) + } + } + + @if (ColunasDeTemplate.Count > 0) + { +
Campos da mensagem
+ @foreach (var col in ColunasDeTemplate) + { + @ItemDeColuna(col.Key, col.Title) + } + } +
+
+
+
+ + + @* Agrupa em Ano › Mês › Dia. Sem a hierarquia a chave seria o INSTANTE EXATO, + que tem milissegundos e não se repete: cada evento viraria um grupo de um + item — medido sobre os 314.973 eventos de C:\TOTVSPDV\Logs, o resultado é o + teto do MaxGroups (10.000 grupos) e um aviso de corte no lugar da árvore. + Ver a página /showcase/datagrid-date-grouping do Forneria.Demo. + + MaxGroups e AutoCollapseGroupsThreshold ficam nos defaults da biblioteca + (10.000 e 100). Não é omissão: no mesmo conjunto, agrupar por data custa + 147 ms e achata 314.988 linhas, das quais ~110 chegam ao DOM — a lista + achatada é entregue à virtualização, e é o teto do MaxGroups que segura o + caso degenerado de uma coluna quase-única. Baixar o auto-colapso não teria + efeito aqui: ele conta os grupos de PRIMEIRO nível, e esta hierarquia começa + pelo ano, quase sempre um só. *@ + @if (VisibleColumns.Contains(LogGridColumns.Timestamp)) + { + + } + + @if (VisibleColumns.Contains(LogGridColumns.Level)) + { + + + + } + + @* O VALOR da coluna é o template (@mt), não a mensagem formatada: agrupar pelo + texto renderizado daria um grupo por linha, já que os parâmetros mudam a cada + evento. Pelo template, "Intervalo otimizado para {ProviderKey}" junta todas as + ocorrências — e os parâmetros viram colunas próprias (ver + LogColumnDiscovery.ColumnSource.TemplateField) para agrupar e filtrar por eles. + + Os três seletores têm papéis distintos aqui: + Property → chave do agrupamento, ordenação e filtro (o template); + TextSelector → rótulo do cabeçalho de grupo, que o grid tira do texto da + célula do PRIMEIRO item — com a mensagem formatada, o grupo + se chamaria por uma de suas linhas; + Template → o que a célula mostra de fato: a mensagem renderizada. + + Efeito colateral assumido: o filtro DESTA coluna procura no template; + procurar por um valor ("RacClient") se faz na coluna do campo ou na busca + global. Sem @mt (mensagem literal), tudo cai na própria mensagem. *@ + @if (VisibleColumns.Contains(LogGridColumns.Message)) + { + + + + } + + @* Só o nome do arquivo na célula; o caminho completo fica no title. *@ + @if (VisibleColumns.Contains(LogGridColumns.SourceFile)) + { + + + + } + + @if (VisibleColumns.Contains(LogGridColumns.Exception)) + { + + + + } + + @* Colunas derivadas do conteúdo dos logs carregados. + + Property entrega o valor TIPADO (número como número) e TextSelector cuida do + que aparece na célula. Com o texto nos dois papéis, a ordenação comparava + string e "591" vinha antes de "78". *@ + @foreach (var coluna in Colunas.Where(c => VisibleColumns.Contains(c.Key))) + { + var c = coluna; + + } + +
+ }
@code { + /// Filtragem em andamento sem nada ainda para mostrar. + [Parameter] public bool IsBusy { get; set; } + [Parameter] public IReadOnlyList Eventos { get; set; } = Array.Empty(); [Parameter] public ClefEvent? SelectedEvent { get; set; } [Parameter] public EventCallback OnSelect { get; set; } @@ -143,9 +237,23 @@ 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))); + /// Propriedades recorrentes do log (SourceContext, Application…). + private IReadOnlyList ColunasDePropriedade => + Colunas.Where(c => c.Source == ColumnSource.Property).ToList(); + + /// Parâmetros dos message templates ({ProviderKey}, {Interval}…). + private IReadOnlyList ColunasDeTemplate => + Colunas.Where(c => c.Source == ColumnSource.TemplateField).ToList(); + + private RenderFragment ItemDeColuna(string key, string title) => __builder => + { + + }; private async Task Alternar(string key, bool visivel) { @@ -159,6 +267,14 @@ private static string NomeArquivo(ClefEvent e) => string.IsNullOrEmpty(e.SourceFile) ? string.Empty : Path.GetFileName(e.SourceFile); + /// + /// Identidade da mensagem para agrupar, ordenar e filtrar: o template quando existe, + /// senão a própria mensagem (logs sem @mt, ou linhas em que o template É a + /// mensagem por não ter parâmetro). + /// + private static string TemplateOuMensagem(ClefEvent e) => + !string.IsNullOrEmpty(e.MessageTemplate) ? e.MessageTemplate : e.Message ?? string.Empty; + /// Destaca a linha selecionada e as de erro, como na lista. private string LinhaCss(ClefEvent e) { diff --git a/src/Components/LogHeader.razor b/src/Components/LogHeader.razor index af99720..577dfdb 100644 --- a/src/Components/LogHeader.razor +++ b/src/Components/LogHeader.razor @@ -17,12 +17,9 @@ @onclick="ToggleRegex">.*
+ @* O seletor de "itens por página" saiu junto com a paginação: lista e tabela + virtualizam o conjunto filtrado inteiro, e a rolagem faz a navegação. *@
-
@@ -31,9 +28,6 @@ [Parameter] public string TextoPesquisa { get; set; } = string.Empty; [Parameter] public EventCallback TextoPesquisaChanged { get; set; } - [Parameter] public int QuantidadeRegistros { get; set; } - [Parameter] public EventCallback QuantidadeRegistrosChanged { get; set; } - [Parameter] public bool UsarRegex { get; set; } [Parameter] public EventCallback UsarRegexChanged { get; set; } @@ -42,12 +36,6 @@ [Parameter] public EventCallback OnReload { get; set; } - // 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"; - private async Task OnSearchChanged(string? v) { TextoPesquisa = v ?? string.Empty; @@ -60,9 +48,4 @@ await UsarRegexChanged.InvokeAsync(UsarRegex); } - private async Task OnQtdChanged(int v) - { - QuantidadeRegistros = v; - await QuantidadeRegistrosChanged.InvokeAsync(v); - } } diff --git a/src/Components/LogSidebar.razor b/src/Components/LogSidebar.razor index 7598a90..b02226a 100644 --- a/src/Components/LogSidebar.razor +++ b/src/Components/LogSidebar.razor @@ -1,4 +1,5 @@ @using System.IO +@using ClefExplorer.Helpers @using ClefExplorer.Models