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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cce69c3..b2b4f9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,11 @@ jobs: } while ($p.Count -lt 3) { $p += '0' } $version = '{0}.{1}.{2}.0' -f $p[0], $p[1], $p[2] + # 3 partes para o MSBuild: o Directory.Build.props faz $(Version).0 para chegar + # às 4 do assembly; passar 4 partes aqui produziria 5. + $msbuild = '{0}.{1}.{2}' -f $p[0], $p[1], $p[2] "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "msbuildVersion=$msbuild" | Out-File -FilePath $env:GITHUB_OUTPUT -Append Write-Host "Versão do pacote: $version" # Carimba a identidade do pacote no manifesto (só durante o build do CI; não é commitado): @@ -93,6 +97,7 @@ jobs: /p:AppxBundlePlatforms=x64 /p:UapAppxPackageBuildMode=StoreUpload /p:AppxPackageSigningEnabled=false + /p:Version=${{ steps.ver.outputs.msbuildVersion }} - name: Locate upload package id: pkg diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..532b205 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,33 @@ + + + + + 1.1.0 + + + $(ClefVersion) + + + $(Version).0 + $(Version).0 + + + diff --git a/ROADMAP.md b/ROADMAP.md index 1736c9c..97c9192 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -55,12 +55,12 @@ O app está publicado na Store (product `9MVZN1HVJ230`, gratuito) e o CI está v | # | Item | Tipo | Esforço | Status | |---|---|---|---|---| -| 18 | Virtualização da lista (alternativa/complemento à paginação) | ⚡ Perf | 🟡 | ⬜ | -| 19 | Painel de estatísticas / timeline | ✨ Feature | 🔴 | ⬜ | -| 20 | Acessibilidade (teclado, ARIA, foco) | ♿ A11y | 🟡 | ⬜ | -| 21 | Versionamento coerente + auto-update da versão standalone | 🧱 Débito | 🟡 | ⬜ | -| 22 | Internacionalização (i18n) | ✨ Feature | 🔴 | ⬜ | -| 23 | Higiene geral do código e nomenclatura | 🧱 Débito | 🟢 | 🟨 parcial (avisos `CS*` zerados) | +| 18 | Virtualização da lista (alternativa/complemento à paginação) | ⚡ Perf | 🟡 | ✅ (+ páginas de até 1000) | +| 19 | Painel de estatísticas / timeline | ✨ Feature | 🔴 | ✅ | +| 20 | Acessibilidade (teclado, ARIA, foco) | ♿ A11y | 🟡 | ✅ (anel de foco não verificado na tela) | +| 21 | Versionamento coerente + auto-update da versão standalone | 🧱 Débito | 🟡 | 🟨 versão unificada; auto-update pendente | +| 22 | Internacionalização (i18n) | ✨ Feature | 🔴 | ⬜ (depende de decisão de alcance) | +| 23 | Higiene geral do código e nomenclatura | 🧱 Débito | 🟢 | ✅ | --- diff --git a/publish-store-package.ps1 b/publish-store-package.ps1 index 8f50191..9717d18 100644 --- a/publish-store-package.ps1 +++ b/publish-store-package.ps1 @@ -38,8 +38,9 @@ #> [CmdletBinding()] param( - [Parameter(Mandatory = $true)] - [string]$Version, + # Sem Mandatory: omitir cai no do Directory.Build.props. Com Mandatory, + # o PowerShell pediria o valor no prompt e o fallback nunca seria alcançado. + [string]$Version = '', [string]$Platform = 'x64', [string]$Configuration = 'Release', @@ -60,6 +61,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}$') { @@ -72,6 +85,9 @@ if ($parts.Count -eq 4 -and $parts[3] -ne '0') { } while ($parts.Count -lt 3) { $parts += '0' } $fullVersion = '{0}.{1}.{2}.0' -f $parts[0], $parts[1], $parts[2] +# 3 partes para o MSBuild: o Directory.Build.props faz $(Version).0 para chegar às 4 +# do assembly; passar 4 partes aqui produziria 5. +$msbuildVersion = '{0}.{1}.{2}' -f $parts[0], $parts[1], $parts[2] Write-Host "Versão do pacote: $fullVersion" -ForegroundColor Cyan # ---------- Dica: versão já instalada/publicada ---------- @@ -154,6 +170,7 @@ try { /p:AppxBundlePlatforms=$Platform ` /p:UapAppxPackageBuildMode=StoreUpload ` /p:AppxPackageSigningEnabled=false ` + /p:Version=$msbuildVersion ` /v:minimal if ($LASTEXITCODE -ne 0) { throw "MSBuild falhou (exit $LASTEXITCODE)." } } 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/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/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/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..389b02a 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) {
@@ -9,29 +11,39 @@ 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. *@ + @* Height="@null" e não Height="null": o segundo passaria a STRING "null". *@ + + +
+
+
+ + @ev.Timestamp?.ToString("HH:mm:ss") +
+
@ev.Message
+
+ @if (!string.IsNullOrEmpty(ev.Exception)) + { + Exception + } + @ObterResumoProps(ev) +
-
- } + + } else { @@ -47,6 +59,35 @@ [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(); + + /// 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/Components/LogStatsPanel.razor b/src/Components/LogStatsPanel.razor new file mode 100644 index 0000000..1530d50 --- /dev/null +++ b/src/Components/LogStatsPanel.razor @@ -0,0 +1,167 @@ +@using ClefExplorer.Models +@using ClefExplorer.Helpers +@using Omni.Blazor.Models + +@* Visão agregada do conjunto FILTRADO — não do arquivo inteiro. Assim as estatísticas + respondem ao que está em tela: filtrou por período, os números acompanham. + + Tudo aqui é clicável e aplica o filtro correspondente: é o que transforma o painel + de "relatório" em ponto de partida da investigação. *@ + +
+ @if (Stats.Total == 0) + { +
+ +
+ } + else + { +
+ + @* --- Resumo -------------------------------------------------------- *@ + +
+
+ @Stats.Total.ToString("N0") + eventos +
+
+ @Stats.ErrorCount.ToString("N0") + erros e fatais +
+
+ @PercentualErro + taxa de erro +
+
+ +
+ @foreach (var nivel in Stats.ByLevel) + { + var n = nivel; + + } +
+
+ + @* --- Timeline ------------------------------------------------------ *@ + + @if (Stats.Timeline.Count > 0) + { + + } + + + @* --- Rankings ------------------------------------------------------ *@ + + @RenderRanking(Stats.TopSources, OnFilterText) + + + + @RenderRanking(Stats.TopMessages, OnFilterText) + + + @if (Stats.TopExceptions.Count > 0) + { + + @RenderRanking(Stats.TopExceptions, OnFilterText) + + } +
+ } +
+ +@code { + [Parameter] public LogStats Stats { get; set; } = new(); + + /// Clique num nível aplica o filtro rápido correspondente. + [Parameter] public EventCallback OnFilterLevel { get; set; } + + /// Clique numa origem/mensagem/exceção joga o texto na busca. + [Parameter] public EventCallback OnFilterText { get; set; } + + private int MaiorNivel => Stats.ByLevel.Count == 0 ? 1 : Stats.ByLevel.Max(e => e.Count); + + private string PercentualErro => + Stats.Total == 0 ? "0%" : (Stats.ErrorCount / (double)Stats.Total).ToString("P1"); + + private static string Largura(int valor, int maximo) => + $"width: {(maximo <= 0 ? 0 : valor * 100.0 / maximo).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%"; + + private RenderFragment RenderRanking(IReadOnlyList entradas, EventCallback aoClicar) => __builder => + { + if (entradas.Count == 0) + { +

Nada a destacar.

+ } + else + { + var maior = entradas.Max(e => e.Count); +
+ @foreach (var entrada in entradas) + { + var e = entrada; + + } +
+ } + }; + + /// Duas séries: o volume total e, sobreposto, quanto dele é erro. + private IEnumerable SerieTimeline => new[] + { + new ChartSeries + { + Title = "Eventos", + Type = ChartSeriesType.Column, + Points = Stats.Timeline.Select(b => new ChartDataPoint + { + Category = b.Start.ToString(FormatoDaFatia), + Value = b.Total, + }).ToList(), + }, + new ChartSeries + { + Title = "Erros", + Type = ChartSeriesType.Column, + Points = Stats.Timeline.Select(b => new ChartDataPoint + { + Category = b.Start.ToString(FormatoDaFatia), + Value = b.Errors, + }).ToList(), + }, + }; + + /// O eixo mostra só o que distingue as fatias: hora para períodos curtos, data para longos. + private string FormatoDaFatia => Stats.BucketSize >= TimeSpan.FromDays(1) ? "dd/MM" + : Stats.BucketSize >= TimeSpan.FromHours(1) ? "dd/MM HH'h'" + : "HH:mm"; + + private string RotuloFatia + { + get + { + var b = Stats.BucketSize; + if (b <= TimeSpan.Zero) return "—"; + if (b >= TimeSpan.FromDays(1)) return $"por {b.TotalDays:0.#} dia(s)"; + if (b >= TimeSpan.FromHours(1)) return $"por {b.TotalHours:0.#} hora(s)"; + if (b >= TimeSpan.FromMinutes(1)) return $"por {b.TotalMinutes:0.#} minuto(s)"; + return $"por {Math.Max(1, b.TotalSeconds):0.#} segundo(s)"; + } + } +} diff --git a/src/Components/LogToolbar.razor b/src/Components/LogToolbar.razor index eade214..b6d3e5d 100644 --- a/src/Components/LogToolbar.razor +++ b/src/Components/LogToolbar.razor @@ -23,8 +23,40 @@ 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) + { + + } + @* OmniPagination é zero-based; o app conta a partir de 1 (é o que aparece no + "X-Y de Z"). Sem a conversão, a página 1 era exibida como "2" e a última + ficava inalcançável. A tradução fica aqui, na fronteira com o componente. *@ + @@ -42,4 +74,15 @@ [Parameter] public EventCallback OnToggleTail { get; set; } [Parameter] public bool IsLoading { get; set; } [Parameter] public EventCallback OnCancelLoad { get; set; } + + [Parameter] public LogViewMode ViewMode { get; set; } = LogViewMode.List; + [Parameter] public EventCallback OnSetViewMode { get; set; } + + private ButtonVariant VarianteModo(LogViewMode modo) => + ViewMode == modo ? ButtonVariant.Primary : ButtonVariant.Ghost; + + /// 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..0980e98 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 @@ -10,6 +11,7 @@ @inject SettingsService SettingsService @inject DialogService Dialog @inject NotificationService Notifications +@inject UiPreferencesService UiPreferences @inject IJSRuntime JS
@@ -60,25 +62,33 @@ TailEnabled="@Store.TailEnabled" OnToggleTail="AlternarAoVivo" IsLoading="@Store.IsLoading" - OnCancelLoad="CancelarCarregamento" /> + OnCancelLoad="CancelarCarregamento" + DetailVisible="@(_selected != null)" + DetailAtBottom="@DetalheAbaixo" + OnToggleDetailPosition="AlternarPosicaoDetalhe" + ViewMode="@UiPreferences.Preferences.ViewMode" + OnSetViewMode="DefinirModoVisualizacao" />
- @* 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) { - - + +
- + @RenderEventos()
- +
@@ -88,10 +98,7 @@ else {
- + @RenderEventos()
}
@@ -101,7 +108,35 @@
+@* 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 (ModoEstatisticas) + { + + } + else if (ModoTabela) + { + + } + else + { + + } + }; + private ClefEvent? _selected; private bool _isBusy; private List _todosEventos = new(); @@ -499,6 +534,10 @@ _todosEventos = result; if (_pagina > UltimaPagina) _pagina = 1; AtualizarPagina(); + // Colunas e estatísticas derivam do conjunto, então mudam junto com ele. + // Só recalcula no modo que as usa — nos demais seria trabalho jogado fora. + if (ModoTabela) DescobrirColunas(); + if (ModoEstatisticas) CalcularEstatisticas(); _isBusy = false; StateHasChanged(); }); @@ -572,6 +611,92 @@ } } + /// 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; + private bool ModoEstatisticas => UiPreferences.Preferences.ViewMode == LogViewMode.Stats; + + private LogStats _stats = new(); + + /// + /// 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(); + + /// Troca o modo de visualização, persistindo a escolha. + private void DefinirModoVisualizacao(LogViewMode modo) + { + UiPreferences.SetViewMode(modo); + + // Colunas e estatísticas vêm do conjunto atual; só são calculadas no modo que + // as usa, para não gastar trabalho à toa nos demais. + if (ModoTabela) DescobrirColunas(); + if (ModoEstatisticas) CalcularEstatisticas(); + } + + /// + /// Agrega o conjunto FILTRADO inteiro — não a página. As estatísticas precisam refletir + /// tudo o que o filtro selecionou, senão responderiam sobre uma amostra arbitrária. + /// + private void CalcularEstatisticas() + { + try + { + _stats = LogStatistics.Compute(_todosEventos); + } + catch (Exception ex) + { + AppLog.Error("Falha ao calcular as estatísticas", ex); + _stats = new LogStats(); + } + } + + /// Clique num nível do painel: passa a filtrar só por ele. + private void FiltrarPorNivel(string nivel) + { + NiveisSelecionados = new HashSet(new[] { nivel }, StringComparer.OrdinalIgnoreCase); + } + + /// + /// 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. + /// + private void AlternarPosicaoDetalhe() => UiPreferences.ToggleDetailPanelPosition(); + /// Interrompe o carregamento em andamento, preservando o conteúdo anterior. private void CancelarCarregamento() { 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/Helpers/LogStatistics.cs b/src/Helpers/LogStatistics.cs new file mode 100644 index 0000000..bebee27 --- /dev/null +++ b/src/Helpers/LogStatistics.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ClefExplorer.Models; +using ClefExplorer.Services; +using Serilog.Events; + +namespace ClefExplorer.Helpers +{ + /// Um item de ranking (nível, mensagem, exceção, origem…) com sua contagem. + /// Valor usado para filtrar ao clicar. + /// Texto exibido. + /// Quantidade de eventos. + public sealed record StatEntry(string Key, string Label, int Count); + + /// Uma fatia do histograma temporal. + /// Início do intervalo. + /// Eventos no intervalo. + /// Quantos deles são Error/Fatal — é o que revela os picos. + public sealed record TimeBucket(DateTimeOffset Start, int Total, int Errors); + + /// Visão agregada do conjunto filtrado. + public sealed class LogStats + { + public int Total { get; init; } + public IReadOnlyList ByLevel { get; init; } = Array.Empty(); + public IReadOnlyList TopMessages { get; init; } = Array.Empty(); + public IReadOnlyList TopExceptions { get; init; } = Array.Empty(); + public IReadOnlyList TopSources { get; init; } = Array.Empty(); + public IReadOnlyList Timeline { get; init; } = Array.Empty(); + + /// Tamanho de cada fatia da timeline, para rotular o eixo. + public TimeSpan BucketSize { get; init; } + + public int ErrorCount => ByLevel + .Where(e => e.Key is "Error" or "Fatal") + .Sum(e => e.Count); + } + + /// + /// Calcula a visão agregada dos eventos filtrados: o que transforma o app de leitor em + /// analisador. Puro e sem dependência de UI, para poder ser testado direto. + /// + public static class LogStatistics + { + /// Quantas entradas cada ranking traz. + public const int DefaultTopCount = 8; + + /// Fatias desejadas na timeline. O tamanho de cada uma sai do período coberto. + public const int DefaultBuckets = 40; + + /// Ordem de exibição dos níveis: do mais grave ao mais verboso. + private static readonly string[] LevelOrder = LogFilter.AllLevels; + + public static LogStats Compute(IReadOnlyList events, int topCount = DefaultTopCount, int buckets = DefaultBuckets) + { + ArgumentNullException.ThrowIfNull(events); + // buckets = 0 dividiria por zero em MontarTimeline e criaria um vetor vazio, + // e um topCount não positivo devolveria rankings sempre vazios. Falhar aqui é + // mais claro do que a exceção que apareceria lá dentro. + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(topCount); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(buckets); + + if (events.Count == 0) return new LogStats(); + + var (timeline, bucketSize) = MontarTimeline(events, buckets); + + return new LogStats + { + Total = events.Count, + ByLevel = ContarPorNivel(events), + TopMessages = TopPor(events, MensagemAgrupavel, topCount), + TopExceptions = TopPor(events.Where(e => !string.IsNullOrEmpty(e.Exception)).ToList(), TipoDaExcecao, topCount), + TopSources = TopPor(events, OrigemDoEvento, topCount), + Timeline = timeline, + BucketSize = bucketSize, + }; + } + + // --- Níveis ------------------------------------------------------------------ + + private static IReadOnlyList ContarPorNivel(IReadOnlyList events) + { + var contagem = events + .GroupBy(e => e.Level ?? "Information", StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); + + // Ordem fixa por gravidade, e não por contagem: a lista fica estável entre + // filtragens, então o olho encontra "Error" sempre no mesmo lugar. + var ordenados = LevelOrder + .Where(contagem.ContainsKey) + .Select(nivel => new StatEntry(nivel, nivel, contagem[nivel])) + .ToList(); + + // Níveis fora da lista conhecida (log de terceiro com nome próprio) vão ao fim. + ordenados.AddRange(contagem + .Where(kv => !LevelOrder.Contains(kv.Key, StringComparer.OrdinalIgnoreCase)) + .OrderByDescending(kv => kv.Value) + .Select(kv => new StatEntry(kv.Key, kv.Key, kv.Value))); + + return ordenados; + } + + // --- Rankings ---------------------------------------------------------------- + + private static IReadOnlyList TopPor( + IReadOnlyList events, + Func seletor, + int topCount) + { + return events + .Select(seletor) + .Where(x => x is not null) + .Select(x => x!.Value) + .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .Select(g => new StatEntry(g.Key, g.First().Label, g.Count())) + .OrderByDescending(e => e.Count) + .ThenBy(e => e.Label, StringComparer.OrdinalIgnoreCase) + .Take(topCount) + .ToList(); + } + + /// + /// Agrupa pelo TEMPLATE, não pela mensagem renderizada: "Pedido 1 processado" e + /// "Pedido 2 processado" são a mesma ocorrência com parâmetros diferentes, e contá-las + /// separadamente esconderia justamente o que mais se repete. + /// + private static (string, string)? MensagemAgrupavel(ClefEvent e) + { + var chave = !string.IsNullOrWhiteSpace(e.MessageTemplate) ? e.MessageTemplate : e.Message; + return string.IsNullOrWhiteSpace(chave) ? null : (chave, chave); + } + + /// + /// Primeira linha da exceção — normalmente "Namespace.TipoException: mensagem". + /// O stack trace inteiro seria único por ocorrência e não agruparia nada. + /// + private static (string, string)? TipoDaExcecao(ClefEvent e) + { + if (string.IsNullOrWhiteSpace(e.Exception)) return null; + + var primeiraLinha = e.Exception + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault()? + .Trim(); + + return string.IsNullOrWhiteSpace(primeiraLinha) ? null : (primeiraLinha, primeiraLinha); + } + + /// + /// Origem do evento: o SourceContext (a classe que logou) quando existe; + /// senão o arquivo. É o que responde "de onde vem esse barulho todo". + /// + private static (string, string)? OrigemDoEvento(ClefEvent e) + { + if (e.Properties is not null + && e.Properties.TryGetValue("SourceContext", out var ctx) + && ctx is ScalarValue { Value: string s } + && !string.IsNullOrWhiteSpace(s)) + { + return (s, s); + } + + if (!string.IsNullOrEmpty(e.SourceFile)) + { + return (e.SourceFile, System.IO.Path.GetFileName(e.SourceFile)); + } + + return null; + } + + // --- Timeline ----------------------------------------------------------------- + + private static (IReadOnlyList Timeline, TimeSpan BucketSize) MontarTimeline( + IReadOnlyList events, int buckets) + { + var comHora = events.Where(e => e.Timestamp.HasValue).ToList(); + if (comHora.Count == 0) return (Array.Empty(), TimeSpan.Zero); + + var inicio = comHora.Min(e => e.Timestamp!.Value); + var fim = comHora.Max(e => e.Timestamp!.Value); + var periodo = fim - inicio; + + // Tudo no mesmo instante (ou um evento só): uma fatia basta. + if (periodo <= TimeSpan.Zero) + { + return (new[] { new TimeBucket(inicio, comHora.Count, ContarErros(comHora)) }, TimeSpan.FromSeconds(1)); + } + + var bucketSize = TimeSpan.FromTicks(Math.Max(1, periodo.Ticks / buckets)); + + var porFatia = new int[buckets]; + var errosPorFatia = new int[buckets]; + + foreach (var e in comHora) + { + var offset = (e.Timestamp!.Value - inicio).Ticks / bucketSize.Ticks; + // O evento mais recente cairia em `buckets`; encaixa na última fatia. + var indice = (int)Math.Min(offset, buckets - 1); + + porFatia[indice]++; + if (EhErro(e)) errosPorFatia[indice]++; + } + + var timeline = Enumerable.Range(0, buckets) + .Select(i => new TimeBucket(inicio + TimeSpan.FromTicks(bucketSize.Ticks * i), porFatia[i], errosPorFatia[i])) + .ToList(); + + return (timeline, bucketSize); + } + + private static int ContarErros(IEnumerable events) => events.Count(EhErro); + + private static bool EhErro(ClefEvent e) => + string.Equals(e.Level, "Error", StringComparison.OrdinalIgnoreCase) + || string.Equals(e.Level, "Fatal", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Models/UiEnums.cs b/src/Models/UiEnums.cs new file mode 100644 index 0000000..40c233c --- /dev/null +++ b/src/Models/UiEnums.cs @@ -0,0 +1,25 @@ +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, + + /// Visão agregada do conjunto filtrado: contagens, rankings e timeline. + Stats = 2, + } + + /// 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/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"); diff --git a/src/Services/UiPreferencesService.cs b/src/Services/UiPreferencesService.cs new file mode 100644 index 0000000..f96be69 --- /dev/null +++ b/src/Services/UiPreferencesService.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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; + + 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(); + } + + /// + /// 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; + } + + /// Define o modo de visualização e persiste a escolha. + public void SetViewMode(LogViewMode mode) + { + if (_preferences.ViewMode == mode) return; + + _preferences.ViewMode = mode; + Save(); + } + + /// 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..415487c 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