@trismegisto/spine 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +56 -0
- package/README.md +207 -0
- package/fesm2022/trismegisto-spine-audit.mjs +1299 -0
- package/fesm2022/trismegisto-spine-audit.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-audit.component-CCiz-q8o.mjs +37 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-audit.component-CCiz-q8o.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-client-picker.component-Z0wCANF6.mjs +51 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-client-picker.component-Z0wCANF6.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-client.component-BYkUOwWL.mjs +595 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-client.component-BYkUOwWL.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-groups.component-CqIVe2LU.mjs +357 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service-groups.component-CqIVe2LU.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service.component-B5tyJ2zY.mjs +477 -0
- package/fesm2022/trismegisto-spine-auto-service-auto-service.component-B5tyJ2zY.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-auto-service-home.component-vl3BPFnk.mjs +238 -0
- package/fesm2022/trismegisto-spine-auto-service-home.component-vl3BPFnk.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-auto-service.mjs +154 -0
- package/fesm2022/trismegisto-spine-auto-service.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-graphql.mjs +347 -0
- package/fesm2022/trismegisto-spine-graphql.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-layout.mjs +2848 -0
- package/fesm2022/trismegisto-spine-layout.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-modal.mjs +141 -0
- package/fesm2022/trismegisto-spine-modal.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-notification.mjs +122 -0
- package/fesm2022/trismegisto-spine-notification.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-routing.mjs +398 -0
- package/fesm2022/trismegisto-spine-routing.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-shell.mjs +409 -0
- package/fesm2022/trismegisto-spine-shell.mjs.map +1 -0
- package/fesm2022/trismegisto-spine-side-panel.mjs +142 -0
- package/fesm2022/trismegisto-spine-side-panel.mjs.map +1 -0
- package/fesm2022/trismegisto-spine.mjs +2574 -0
- package/fesm2022/trismegisto-spine.mjs.map +1 -0
- package/package.json +94 -0
- package/types/trismegisto-spine-audit.d.ts +948 -0
- package/types/trismegisto-spine-audit.d.ts.map +1 -0
- package/types/trismegisto-spine-auto-service.d.ts +110 -0
- package/types/trismegisto-spine-auto-service.d.ts.map +1 -0
- package/types/trismegisto-spine-graphql.d.ts +72 -0
- package/types/trismegisto-spine-graphql.d.ts.map +1 -0
- package/types/trismegisto-spine-layout.d.ts +1054 -0
- package/types/trismegisto-spine-layout.d.ts.map +1 -0
- package/types/trismegisto-spine-modal.d.ts +68 -0
- package/types/trismegisto-spine-modal.d.ts.map +1 -0
- package/types/trismegisto-spine-notification.d.ts +62 -0
- package/types/trismegisto-spine-notification.d.ts.map +1 -0
- package/types/trismegisto-spine-routing.d.ts +205 -0
- package/types/trismegisto-spine-routing.d.ts.map +1 -0
- package/types/trismegisto-spine-shell.d.ts +143 -0
- package/types/trismegisto-spine-shell.d.ts.map +1 -0
- package/types/trismegisto-spine-side-panel.d.ts +66 -0
- package/types/trismegisto-spine-side-panel.d.ts.map +1 -0
- package/types/trismegisto-spine.d.ts +1161 -0
- package/types/trismegisto-spine.d.ts.map +1 -0
|
@@ -0,0 +1,1299 @@
|
|
|
1
|
+
import { HttpParams, HttpClient, HttpHeaders } from '@angular/common/http';
|
|
2
|
+
import * as i0 from '@angular/core';
|
|
3
|
+
import { inject, Injectable, input, output, computed, ChangeDetectionStrategy, Component, signal, effect, untracked } from '@angular/core';
|
|
4
|
+
import { firstValueFrom } from 'rxjs';
|
|
5
|
+
import { AuthorizationService } from '@orfeu/angular/core';
|
|
6
|
+
import { IconButtonComponent, SpinnerComponent, AlertComponent, ButtonComponent, DataGridCellDirective, DataGridComponent, DrawerComponent, FilterBarComponent, ProgressComponent, SegmentedControlComponent, StatCardComponent, StateScreenComponent, SwitchComponent, TagComponent } from '@trismegisto/angular';
|
|
7
|
+
import { formatMessage, pluralMessage } from '@trismegisto/primitives';
|
|
8
|
+
import { Router } from '@angular/router';
|
|
9
|
+
import { SPINE_MESSAGES, SPINE_ADMIN_API } from '@trismegisto/spine';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Leitura da auditoria (`audit.events`) do argos admin-server, nos dois escopos:
|
|
13
|
+
* - `admin` → `GET {adminApi}/admin/audit/*` (painel central, todos os apps);
|
|
14
|
+
* - `client` → `GET {adminApi}/auto-service/:clientId/audit/*` (só os apps do projeto do client; o
|
|
15
|
+
* servidor recorta, `app_id` de fora → 403).
|
|
16
|
+
* Endpoints: `actions`, `events`, `events/:id`, `traces/:traceId`, `stats` e `tops/:kind` (rankings paginados).
|
|
17
|
+
* Unidade: a leitura padrão é por AÇÃO (uma linha por trace: o evento raiz + agregados dos spans);
|
|
18
|
+
* `unit=events` nas agregações mantém a contagem por evento (registro).
|
|
19
|
+
* Formatos e filtros fixados pelo contrato do admin-server; tudo aqui é TypeScript puro (tipos +
|
|
20
|
+
* montagem da consulta).
|
|
21
|
+
*/
|
|
22
|
+
const AUDIT_METHODS = ['read', 'write', 'delete', 'stream'];
|
|
23
|
+
const AUDIT_OUTCOMES = ['success', 'failure', 'denied'];
|
|
24
|
+
const AUDIT_UNITS = ['actions', 'events'];
|
|
25
|
+
const AUDIT_TOP_KINDS = ['apps', 'operations', 'users'];
|
|
26
|
+
/** Itens por página dos cards de ranking (o servidor aceita até 50). */
|
|
27
|
+
const AUDIT_TOP_PAGE_SIZE = 5;
|
|
28
|
+
const trimSlash = (url) => (url ?? '').replace(/\/+$/, '');
|
|
29
|
+
/**
|
|
30
|
+
* Base dos endpoints da auditoria para o escopo (sem barra no fim). `null` quando falta o necessário
|
|
31
|
+
* (sem `adminApi`, ou escopo `client` sem `clientId`).
|
|
32
|
+
*/
|
|
33
|
+
function auditApiBase(adminApi, scope, clientId) {
|
|
34
|
+
const api = trimSlash(adminApi);
|
|
35
|
+
if (!api)
|
|
36
|
+
return null;
|
|
37
|
+
if (scope === 'admin')
|
|
38
|
+
return `${api}/admin/audit`;
|
|
39
|
+
return clientId ? `${api}/auto-service/${encodeURIComponent(clientId)}/audit` : null;
|
|
40
|
+
}
|
|
41
|
+
/** Lista de apps para o filtro: todos (`admin`, `GET /admin/apps`) ou os do projeto do client. */
|
|
42
|
+
function auditAppsUrl(adminApi, scope, clientId) {
|
|
43
|
+
const api = trimSlash(adminApi);
|
|
44
|
+
if (!api)
|
|
45
|
+
return null;
|
|
46
|
+
if (scope === 'admin')
|
|
47
|
+
return `${api}/admin/apps`;
|
|
48
|
+
return clientId ? `${api}/auto-service/${encodeURIComponent(clientId)}/apps` : null;
|
|
49
|
+
}
|
|
50
|
+
/** Monta a query string: vazio/`null` some, lista repete a chave (`app_id=a&app_id=b`). */
|
|
51
|
+
function auditParams(query, page = {}) {
|
|
52
|
+
let params = new HttpParams();
|
|
53
|
+
for (const [key, value] of Object.entries(query)) {
|
|
54
|
+
if (value === undefined || value === null || value === '')
|
|
55
|
+
continue;
|
|
56
|
+
if (Array.isArray(value))
|
|
57
|
+
for (const item of value)
|
|
58
|
+
params = params.append(key, String(item));
|
|
59
|
+
else
|
|
60
|
+
params = params.set(key, String(value));
|
|
61
|
+
}
|
|
62
|
+
if (page.limit)
|
|
63
|
+
params = params.set('limit', String(page.limit));
|
|
64
|
+
if (page.cursor)
|
|
65
|
+
params = params.set('cursor', page.cursor);
|
|
66
|
+
if (page.offset !== undefined)
|
|
67
|
+
params = params.set('offset', String(page.offset));
|
|
68
|
+
return params;
|
|
69
|
+
}
|
|
70
|
+
const HOUR = 60 * 60 * 1000;
|
|
71
|
+
const AUDIT_PERIOD_MS = {
|
|
72
|
+
'1h': HOUR, '6h': 6 * HOUR, '24h': 24 * HOUR, '7d': 7 * 24 * HOUR, '30d': 30 * 24 * HOUR,
|
|
73
|
+
};
|
|
74
|
+
/** Janela máxima aceita pelo servidor (acima: 400 `range_too_large`). */
|
|
75
|
+
const AUDIT_MAX_WINDOW_MS = 31 * 24 * HOUR;
|
|
76
|
+
function localDay(iso, endOfDay) {
|
|
77
|
+
const [y, m, d] = iso.split('-').map(Number);
|
|
78
|
+
return endOfDay ? new Date(y, m - 1, d, 23, 59, 59, 999) : new Date(y, m - 1, d, 0, 0, 0, 0);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Período + filtros da tela → consulta do contrato. As datas da faixa (dia local inteiro) vencem a
|
|
82
|
+
* escolha rápida; o fim nunca passa de `now`. A janela vira `from`/`to` absolutos, então as páginas
|
|
83
|
+
* seguintes (cursor) e o resumo enxergam exatamente o mesmo intervalo.
|
|
84
|
+
*/
|
|
85
|
+
function buildAuditQuery(period, filters, now) {
|
|
86
|
+
let from;
|
|
87
|
+
let to;
|
|
88
|
+
const dates = filters.dates;
|
|
89
|
+
if (dates && (dates.start || dates.end)) {
|
|
90
|
+
to = dates.end ? new Date(Math.min(localDay(dates.end, true).getTime(), now.getTime())) : now;
|
|
91
|
+
from = dates.start ? localDay(dates.start, false) : new Date(to.getTime() - AUDIT_PERIOD_MS['24h']);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
to = now;
|
|
95
|
+
from = new Date(now.getTime() - AUDIT_PERIOD_MS[period]);
|
|
96
|
+
}
|
|
97
|
+
if (to.getTime() - from.getTime() > AUDIT_MAX_WINDOW_MS)
|
|
98
|
+
return { ok: false, reason: 'range_too_large' };
|
|
99
|
+
const text = (v) => String(v ?? '').trim() || undefined;
|
|
100
|
+
const apps = (Array.isArray(filters.app) ? filters.app : filters.app ? [filters.app] : []).filter(Boolean);
|
|
101
|
+
const status = text(filters.status);
|
|
102
|
+
const query = {
|
|
103
|
+
from: from.toISOString(),
|
|
104
|
+
to: to.toISOString(),
|
|
105
|
+
app_id: apps.length ? apps : undefined,
|
|
106
|
+
user: text(filters.user),
|
|
107
|
+
user_id: text(filters.user_id),
|
|
108
|
+
operation: text(filters.operation),
|
|
109
|
+
method: text(filters.method),
|
|
110
|
+
level: text(filters.level),
|
|
111
|
+
outcome: text(filters.outcome),
|
|
112
|
+
permission_result: text(filters.permission_result),
|
|
113
|
+
actor_type: text(filters.actor_type),
|
|
114
|
+
// Status que não é número vai como está: o servidor responde 400 `invalid_filter` e a tela explica.
|
|
115
|
+
status: status === undefined ? undefined : /^\d+$/.test(status) ? Number(status) : status,
|
|
116
|
+
trace_id: text(filters.trace_id)?.toLowerCase(),
|
|
117
|
+
entry: typeof filters.entry === 'boolean' ? filters.entry : undefined,
|
|
118
|
+
};
|
|
119
|
+
for (const key of Object.keys(query))
|
|
120
|
+
if (query[key] === undefined)
|
|
121
|
+
delete query[key];
|
|
122
|
+
return { ok: true, query };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Lê as datas do servidor, que vêm em ISO UTC com MICROssegundos (`2026-10-05T13:18:40.564281Z`): o
|
|
126
|
+
* formato de data do ECMAScript só garante 3 casas, então a fração é cortada em milissegundos antes.
|
|
127
|
+
*/
|
|
128
|
+
function parseAuditDate(iso) {
|
|
129
|
+
if (!iso)
|
|
130
|
+
return null;
|
|
131
|
+
const d = new Date(iso.replace(/(\.\d{3})\d+/, '$1'));
|
|
132
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
133
|
+
}
|
|
134
|
+
/** Maior `limit` aceito por `GET .../audit/events`. */
|
|
135
|
+
const AUDIT_MAX_PAGE_SIZE = 200;
|
|
136
|
+
/**
|
|
137
|
+
* Paginação numerada ("página N de M") sobre a lista por cursor (keyset) do servidor, que não tem
|
|
138
|
+
* OFFSET. Guarda o cursor de cada posição já vista (posição 0 = sem cursor); para abrir uma página
|
|
139
|
+
* nunca vista, anda a partir da posição conhecida mais próxima em saltos de até 200 (o cursor só
|
|
140
|
+
* depende do último evento lido, não do `limit`). Vale para UMA consulta (`from`/`to` + filtros
|
|
141
|
+
* fixos): consulta nova = pager novo.
|
|
142
|
+
*/
|
|
143
|
+
class AuditCursorPager {
|
|
144
|
+
constructor() {
|
|
145
|
+
this.cursors = new Map();
|
|
146
|
+
/** Quantos eventos a consulta tem, quando a lista já chegou ao fim (`next_cursor` nulo). */
|
|
147
|
+
this.endAt = null;
|
|
148
|
+
}
|
|
149
|
+
/** Total exato, conhecido depois de alguma página chegar ao fim da lista. */
|
|
150
|
+
get end() {
|
|
151
|
+
return this.endAt;
|
|
152
|
+
}
|
|
153
|
+
async page(offset, size, fetch, retry = true) {
|
|
154
|
+
size = Math.max(1, Math.min(AUDIT_MAX_PAGE_SIZE, Math.floor(size)));
|
|
155
|
+
offset = Math.max(0, Math.floor(offset / size) * size);
|
|
156
|
+
if (this.endAt !== null && offset > 0 && offset >= this.endAt)
|
|
157
|
+
offset = Math.floor((this.endAt - 1) / size) * size;
|
|
158
|
+
let at = 0;
|
|
159
|
+
for (const known of this.cursors.keys())
|
|
160
|
+
if (known <= offset && known > at)
|
|
161
|
+
at = known;
|
|
162
|
+
let cursor = at ? this.cursors.get(at) : null;
|
|
163
|
+
while (at < offset) {
|
|
164
|
+
const res = await fetch(cursor, Math.min(AUDIT_MAX_PAGE_SIZE, offset - at));
|
|
165
|
+
const items = res.items ?? [];
|
|
166
|
+
if (!res.next_cursor || !items.length) {
|
|
167
|
+
// A lista acabou antes da página pedida: vai para a última que existe.
|
|
168
|
+
this.endAt = at + items.length;
|
|
169
|
+
if (!retry)
|
|
170
|
+
return { items: [], offset: at, hasMore: false };
|
|
171
|
+
return this.page(this.endAt ? Math.floor((this.endAt - 1) / size) * size : 0, size, fetch, false);
|
|
172
|
+
}
|
|
173
|
+
at += items.length;
|
|
174
|
+
cursor = res.next_cursor;
|
|
175
|
+
this.cursors.set(at, cursor);
|
|
176
|
+
}
|
|
177
|
+
const res = await fetch(cursor, size);
|
|
178
|
+
const items = res.items ?? [];
|
|
179
|
+
const hasMore = !!res.next_cursor && items.length > 0;
|
|
180
|
+
if (hasMore)
|
|
181
|
+
this.cursors.set(offset + items.length, res.next_cursor);
|
|
182
|
+
else
|
|
183
|
+
this.endAt = offset + items.length;
|
|
184
|
+
return { items, offset, hasMore };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Total do rodapé: o exato quando a lista já chegou ao fim; senão o do resumo (`stats.total`, mesma
|
|
189
|
+
* consulta), nunca menor do que o que já se sabe existir (página atual + "tem mais").
|
|
190
|
+
*/
|
|
191
|
+
function auditPagerTotal(statsTotal, end, loaded) {
|
|
192
|
+
if (end !== null)
|
|
193
|
+
return end;
|
|
194
|
+
const seen = loaded ? loaded.offset + loaded.items.length + (loaded.hasMore ? 1 : 0) : 0;
|
|
195
|
+
return Math.max(statsTotal ?? 0, seen);
|
|
196
|
+
}
|
|
197
|
+
/** Pior resultado (denied > failure > success) — o `outcome` de uma ação. */
|
|
198
|
+
function worstOutcome(outcomes) {
|
|
199
|
+
if (outcomes.includes('denied'))
|
|
200
|
+
return 'denied';
|
|
201
|
+
if (outcomes.includes('failure'))
|
|
202
|
+
return 'failure';
|
|
203
|
+
return 'success';
|
|
204
|
+
}
|
|
205
|
+
/** Largura mínima da barra (%), para um span de 0 ms continuar visível. */
|
|
206
|
+
const MIN_SPAN_WIDTH = 0.8;
|
|
207
|
+
/**
|
|
208
|
+
* Spans de um trace → linha do tempo: ordem em árvore (pai antes dos filhos, irmãos por início), com
|
|
209
|
+
* profundidade pela cadeia parent_span_id → span_id. Span cujo pai não está na lista (de fora do escopo,
|
|
210
|
+
* fora da janela) vira raiz. Fim do span = início + duration_ms (sem duração = instante).
|
|
211
|
+
*/
|
|
212
|
+
function buildSpanTimeline(items) {
|
|
213
|
+
const start = (e) => parseAuditDate(e.occurred_at)?.getTime() ?? 0;
|
|
214
|
+
const list = [...items].sort((a, b) => start(a) - start(b) || a.event_id.localeCompare(b.event_id));
|
|
215
|
+
if (!list.length)
|
|
216
|
+
return { rows: [], totalMs: 0 };
|
|
217
|
+
const t0 = Math.min(...list.map(start));
|
|
218
|
+
const t1 = Math.max(...list.map((e) => start(e) + Math.max(0, e.duration_ms ?? 0)));
|
|
219
|
+
const total = Math.max(1, t1 - t0);
|
|
220
|
+
const spans = new Set(list.map((e) => e.span_id?.trim()).filter(Boolean));
|
|
221
|
+
const children = new Map();
|
|
222
|
+
const roots = [];
|
|
223
|
+
for (const e of list) {
|
|
224
|
+
const parent = e.parent_span_id?.trim();
|
|
225
|
+
if (parent && spans.has(parent) && parent !== e.span_id?.trim()) {
|
|
226
|
+
const bucket = children.get(parent) ?? [];
|
|
227
|
+
bucket.push(e);
|
|
228
|
+
children.set(parent, bucket);
|
|
229
|
+
}
|
|
230
|
+
else
|
|
231
|
+
roots.push(e);
|
|
232
|
+
}
|
|
233
|
+
const rows = [];
|
|
234
|
+
const seen = new Set();
|
|
235
|
+
const visit = (e, depth) => {
|
|
236
|
+
if (seen.has(e.event_id))
|
|
237
|
+
return;
|
|
238
|
+
seen.add(e.event_id);
|
|
239
|
+
const offsetMs = start(e) - t0;
|
|
240
|
+
const left = (offsetMs / total) * 100;
|
|
241
|
+
const width = Math.min(100 - left, Math.max(MIN_SPAN_WIDTH, ((e.duration_ms ?? 0) / total) * 100));
|
|
242
|
+
rows.push({ event: e, depth, offsetMs, left, width: Math.max(0, width) });
|
|
243
|
+
const span = e.span_id?.trim();
|
|
244
|
+
for (const child of (span && children.get(span)) || [])
|
|
245
|
+
visit(child, depth + 1);
|
|
246
|
+
};
|
|
247
|
+
for (const r of roots)
|
|
248
|
+
visit(r, 0);
|
|
249
|
+
// Ciclo (dado corrompido): o que sobrou entra como raiz.
|
|
250
|
+
for (const e of list)
|
|
251
|
+
visit(e, 0);
|
|
252
|
+
return { rows, totalMs: t1 - t0 };
|
|
253
|
+
}
|
|
254
|
+
/** Código de erro do envelope do admin-server (`{ error, detail }`), quando houver. */
|
|
255
|
+
function auditErrorCode(err) {
|
|
256
|
+
const e = err;
|
|
257
|
+
return {
|
|
258
|
+
status: e?.status ?? 0,
|
|
259
|
+
code: typeof e?.error?.error === 'string' ? e.error.error : '',
|
|
260
|
+
detail: typeof e?.error?.detail === 'string' ? e.error.detail : '',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* HTTP da auditoria (só leitura), igual nos dois escopos: cada método recebe a `base` já resolvida
|
|
266
|
+
* por `auditApiBase` (`{adminApi}/admin/audit` ou `{adminApi}/auto-service/:clientId/audit`). O
|
|
267
|
+
* recorte do escopo `client` é do servidor — nada aqui decide o que a pessoa pode ver.
|
|
268
|
+
*/
|
|
269
|
+
class SpineAuditService {
|
|
270
|
+
constructor() {
|
|
271
|
+
this.http = inject(HttpClient);
|
|
272
|
+
this.auth = inject(AuthorizationService);
|
|
273
|
+
}
|
|
274
|
+
get headers() {
|
|
275
|
+
return new HttpHeaders({ 'x-access-token': this.auth.getToken() ?? '' });
|
|
276
|
+
}
|
|
277
|
+
events(base, query, page = {}) {
|
|
278
|
+
return firstValueFrom(this.http.get(`${base}/events`, { headers: this.headers, params: auditParams(query, page) }));
|
|
279
|
+
}
|
|
280
|
+
/** Uma linha por AÇÃO (raiz do trace + agregados dos spans), mesmos filtros/cursor de `events`. */
|
|
281
|
+
actions(base, query, page = {}) {
|
|
282
|
+
return firstValueFrom(this.http.get(`${base}/actions`, { headers: this.headers, params: auditParams(query, page) }));
|
|
283
|
+
}
|
|
284
|
+
event(base, eventId, occurredAt) {
|
|
285
|
+
const params = new HttpParams().set('occurred_at', occurredAt);
|
|
286
|
+
return firstValueFrom(this.http.get(`${base}/events/${encodeURIComponent(eventId)}`, { headers: this.headers, params }));
|
|
287
|
+
}
|
|
288
|
+
/** `occurredAt` (opcional) = o do evento de origem: o servidor limita a busca da cadeia a ±1 dia dele. */
|
|
289
|
+
trace(base, traceId, occurredAt) {
|
|
290
|
+
const params = occurredAt ? new HttpParams().set('occurred_at', occurredAt) : undefined;
|
|
291
|
+
return firstValueFrom(this.http.get(`${base}/traces/${encodeURIComponent(traceId)}`, { headers: this.headers, params }));
|
|
292
|
+
}
|
|
293
|
+
/** Resumo (KPIs + linha do tempo). `tops: false` pula os rankings — a tela os pagina por `tops()`. */
|
|
294
|
+
stats(base, query, opts = {}) {
|
|
295
|
+
let params = auditParams(query);
|
|
296
|
+
if (opts.tops === false)
|
|
297
|
+
params = params.set('tops', 'false');
|
|
298
|
+
return firstValueFrom(this.http.get(`${base}/stats`, { headers: this.headers, params }));
|
|
299
|
+
}
|
|
300
|
+
/** Um ranking paginado (`apps` | `operations` | `users`) com os mesmos filtros/janela do resumo. */
|
|
301
|
+
tops(base, kind, query, page) {
|
|
302
|
+
return firstValueFrom(this.http.get(`${base}/tops/${kind}`, { headers: this.headers, params: auditParams(query, page) }));
|
|
303
|
+
}
|
|
304
|
+
/** Apps para o filtro (`{ apps: [{ id, name }] }` — `/admin/apps` ou `/auto-service/:clientId/apps`). */
|
|
305
|
+
apps(url) {
|
|
306
|
+
return firstValueFrom(this.http.get(url, { headers: this.headers }));
|
|
307
|
+
}
|
|
308
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineAuditService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
309
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineAuditService, providedIn: 'root' }); }
|
|
310
|
+
}
|
|
311
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineAuditService, decorators: [{
|
|
312
|
+
type: Injectable,
|
|
313
|
+
args: [{ providedIn: 'root' }]
|
|
314
|
+
}] });
|
|
315
|
+
|
|
316
|
+
let nextId = 0;
|
|
317
|
+
/**
|
|
318
|
+
* Card de ranking da Auditoria ("Operações", "Usuários", "Por aplicação") com paginação compacta
|
|
319
|
+
* no servidor: `x–y de N` + anterior/próxima. Durante a troca de página as linhas atuais continuam
|
|
320
|
+
* (esmaecidas, `aria-busy`), sem esqueleto. A barra é proporcional ao `max` (o 1º item da 1ª página),
|
|
321
|
+
* então as páginas seguintes não "enchem" de novo. Clicar num item emite `picked` (vira filtro).
|
|
322
|
+
*/
|
|
323
|
+
class SpineAuditTopCardComponent {
|
|
324
|
+
constructor() {
|
|
325
|
+
this.heading = input.required(...(ngDevMode ? [{ debugName: "heading" }] : /* istanbul ignore next */ []));
|
|
326
|
+
this.rows = input.required(...(ngDevMode ? [{ debugName: "rows" }] : /* istanbul ignore next */ []));
|
|
327
|
+
/** Quantos itens o ranking tem no total (todas as páginas). */
|
|
328
|
+
this.total = input(0, ...(ngDevMode ? [{ debugName: "total" }] : /* istanbul ignore next */ []));
|
|
329
|
+
this.offset = input(0, ...(ngDevMode ? [{ debugName: "offset" }] : /* istanbul ignore next */ []));
|
|
330
|
+
this.pageSize = input(5, ...(ngDevMode ? [{ debugName: "pageSize" }] : /* istanbul ignore next */ []));
|
|
331
|
+
/** Referência da barra (o maior total do ranking); 0 = o maior da página. */
|
|
332
|
+
this.max = input(0, ...(ngDevMode ? [{ debugName: "max" }] : /* istanbul ignore next */ []));
|
|
333
|
+
this.loading = input(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
|
|
334
|
+
this.failed = input(false, ...(ngDevMode ? [{ debugName: "failed" }] : /* istanbul ignore next */ []));
|
|
335
|
+
this.picked = output();
|
|
336
|
+
/** Novo `offset` pedido (anterior/próxima). */
|
|
337
|
+
this.offsetChange = output();
|
|
338
|
+
this.messages = inject(SPINE_MESSAGES);
|
|
339
|
+
this.text = this.messages.autoServiceAudit;
|
|
340
|
+
this.locale = this.messages.meta.locale;
|
|
341
|
+
this.fmt = formatMessage;
|
|
342
|
+
this.headingId = `spine-audit-top-${nextId++}`;
|
|
343
|
+
this.reference = computed(() => Math.max(1, this.max(), ...this.rows().map((r) => r.total)), ...(ngDevMode ? [{ debugName: "reference" }] : /* istanbul ignore next */ []));
|
|
344
|
+
this.range = computed(() => {
|
|
345
|
+
const from = Math.min(this.total(), this.offset() + 1);
|
|
346
|
+
const to = Math.min(this.total(), this.offset() + Math.max(this.rows().length, 1));
|
|
347
|
+
return formatMessage(this.text.topsRange, { from: this.number(from), to: this.number(to), total: this.number(this.total()) });
|
|
348
|
+
}, ...(ngDevMode ? [{ debugName: "range" }] : /* istanbul ignore next */ []));
|
|
349
|
+
}
|
|
350
|
+
go(direction) {
|
|
351
|
+
const next = Math.max(0, this.offset() + direction * this.pageSize());
|
|
352
|
+
if (next !== this.offset() && next < this.total())
|
|
353
|
+
this.offsetChange.emit(next);
|
|
354
|
+
}
|
|
355
|
+
/** Fatia (em % da própria barra) de falhas/negados dentro do total da linha. */
|
|
356
|
+
share(part, total) {
|
|
357
|
+
return total > 0 ? Math.min(100, (part / total) * 100) : 0;
|
|
358
|
+
}
|
|
359
|
+
bar(total) {
|
|
360
|
+
return Math.max(2, Math.round((total / this.reference()) * 100));
|
|
361
|
+
}
|
|
362
|
+
number(n) {
|
|
363
|
+
return new Intl.NumberFormat(this.locale).format(n ?? 0);
|
|
364
|
+
}
|
|
365
|
+
plural(message, count) {
|
|
366
|
+
return pluralMessage(message, count, this.locale);
|
|
367
|
+
}
|
|
368
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineAuditTopCardComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
369
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: SpineAuditTopCardComponent, isStandalone: true, selector: "spine-audit-top-card", inputs: { heading: { classPropertyName: "heading", publicName: "heading", isSignal: true, isRequired: true, transformFunction: null }, rows: { classPropertyName: "rows", publicName: "rows", isSignal: true, isRequired: true, transformFunction: null }, total: { classPropertyName: "total", publicName: "total", isSignal: true, isRequired: false, transformFunction: null }, offset: { classPropertyName: "offset", publicName: "offset", isSignal: true, isRequired: false, transformFunction: null }, pageSize: { classPropertyName: "pageSize", publicName: "pageSize", isSignal: true, isRequired: false, transformFunction: null }, max: { classPropertyName: "max", publicName: "max", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, failed: { classPropertyName: "failed", publicName: "failed", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { picked: "picked", offsetChange: "offsetChange" }, ngImport: i0, template: `
|
|
370
|
+
<section class="card card-block top" [attr.aria-labelledby]="headingId" [attr.aria-busy]="loading()">
|
|
371
|
+
<div class="top__head">
|
|
372
|
+
<h3 class="top__title" [id]="headingId">{{ heading() }}</h3>
|
|
373
|
+
@if (loading() && rows().length) {
|
|
374
|
+
<hm-spinner size="small" [label]="text.updating" />
|
|
375
|
+
}
|
|
376
|
+
</div>
|
|
377
|
+
@if (rows().length) {
|
|
378
|
+
<ol class="rank" [class.rank--busy]="loading()" [attr.start]="offset() + 1">
|
|
379
|
+
@for (row of rows(); track row.key) {
|
|
380
|
+
<li>
|
|
381
|
+
<button type="button" class="rank__item" [attr.title]="row.hint || fmt(text.filterBy, { name: row.name })"
|
|
382
|
+
[disabled]="row.disabled" (click)="picked.emit(row)">
|
|
383
|
+
<span class="rank__name">
|
|
384
|
+
{{ row.name }}
|
|
385
|
+
@if (row.suffix) { <span class="muted"> · {{ row.suffix }}</span> }
|
|
386
|
+
</span>
|
|
387
|
+
<span class="rank__value">
|
|
388
|
+
{{ number(row.total) }}
|
|
389
|
+
@if (row.failures) { <span class="rank__fail"> · {{ plural(text.failureCount, row.failures) }}</span> }
|
|
390
|
+
@if (row.denied) { <span class="rank__denied"> · {{ plural(text.deniedCount, row.denied) }}</span> }
|
|
391
|
+
</span>
|
|
392
|
+
<span class="rank__track" aria-hidden="true">
|
|
393
|
+
<!-- A barra é a própria linha (sempre cheia): sucesso, falhas e negados na proporção dela. -->
|
|
394
|
+
<span class="rank__bar">
|
|
395
|
+
@if (row.failures) { <span class="rank__seg rank__seg--fail" [style.width.%]="share(row.failures, row.total)"></span> }
|
|
396
|
+
@if (row.denied) { <span class="rank__seg rank__seg--denied" [style.width.%]="share(row.denied, row.total)"></span> }
|
|
397
|
+
</span>
|
|
398
|
+
</span>
|
|
399
|
+
</button>
|
|
400
|
+
</li>
|
|
401
|
+
}
|
|
402
|
+
</ol>
|
|
403
|
+
@if (failed()) {
|
|
404
|
+
<p class="muted top__error" role="status">{{ text.topsError }}</p>
|
|
405
|
+
}
|
|
406
|
+
} @else {
|
|
407
|
+
<p class="muted">{{ loading() ? text.loading : failed() ? text.topsError : text.noTop }}</p>
|
|
408
|
+
}
|
|
409
|
+
@if (total() > 0) {
|
|
410
|
+
<div class="top__pager">
|
|
411
|
+
<span class="top__range" role="status">{{ range() }}</span>
|
|
412
|
+
@if (total() > pageSize()) {
|
|
413
|
+
<span class="top__buttons">
|
|
414
|
+
<hm-icon-button [label]="fmt(text.topsPrevious, { name: heading() })" icon="angle" iconDirection="left"
|
|
415
|
+
emphasis="subtle" size="small" [disabled]="offset() <= 0 || loading()" (clicked)="go(-1)" />
|
|
416
|
+
<hm-icon-button [label]="fmt(text.topsNext, { name: heading() })" icon="angle" iconDirection="right"
|
|
417
|
+
emphasis="subtle" size="small" [disabled]="offset() + pageSize() >= total() || loading()" (clicked)="go(1)" />
|
|
418
|
+
</span>
|
|
419
|
+
}
|
|
420
|
+
</div>
|
|
421
|
+
}
|
|
422
|
+
</section>
|
|
423
|
+
`, isInline: true, styles: [":host{display:block;min-inline-size:0}.card{border:1px solid var(--hm-border);border-radius:var(--hm-radius-control);background:var(--hm-surface)}.card-block{padding:var(--hm-space-sm, 8px)}.muted{color:var(--hm-muted)}.top{display:flex;flex-direction:column;block-size:100%;box-sizing:border-box}.top__head{display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-sm, 8px);margin:0 0 var(--hm-space-sm, 8px);min-block-size:24px}.top__title{margin:0;font-size:14px;font-weight:600;color:var(--hm-text)}.top__error{margin:var(--hm-space-xs, 4px) 0 0;font-size:12px}.top__pager{display:flex;align-items:center;justify-content:flex-end;gap:var(--hm-space-xs, 4px);margin-block-start:auto;padding-block-start:var(--hm-space-sm, 8px)}.top__range{font-size:12px;color:var(--hm-muted);font-variant-numeric:tabular-nums}.top__buttons{display:inline-flex;gap:2px}.rank{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:2px;transition:opacity .12s ease}.rank--busy{opacity:.55}.rank__item{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px var(--hm-space-sm, 8px);inline-size:100%;padding:6px 8px;border:0;border-radius:var(--hm-radius-control);background:transparent;color:var(--hm-text);font:inherit;font-size:13px;text-align:start;cursor:pointer}.rank__item:hover:not(:disabled){background:color-mix(in srgb,var(--hm-action) 8%,transparent)}.rank__item:focus-visible{outline:2px solid var(--hm-action);outline-offset:1px}.rank__item:disabled{cursor:default}.rank__name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rank__value{font-variant-numeric:tabular-nums;white-space:nowrap}.rank__fail{color:var(--hm-danger)}.rank__denied{color:var(--hm-warning, #c98a00)}.rank__track{grid-column:1/-1;display:block;block-size:4px;border-radius:2px;background:color-mix(in srgb,var(--hm-text) 8%,transparent);overflow:hidden}.rank__bar{display:flex;justify-content:flex-end;inline-size:100%;block-size:100%;background:var(--hm-success)}.rank__seg{display:block;block-size:100%}.rank__seg--fail{background:var(--hm-danger)}.rank__seg--denied{background:var(--hm-warning)}@media(prefers-reduced-motion:reduce){.rank{transition:none}}\n"], dependencies: [{ kind: "component", type: IconButtonComponent, selector: "hm-icon-button", inputs: ["label", "icon", "solid", "iconDirection", "emphasis", "intent", "size", "disabled", "toggle", "pressed", "keyShortcuts"], outputs: ["pressedChange", "clicked"] }, { kind: "component", type: SpinnerComponent, selector: "hm-spinner", inputs: ["size", "inverse", "label", "labelVisible", "decorative", "done", "doneLabel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
424
|
+
}
|
|
425
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineAuditTopCardComponent, decorators: [{
|
|
426
|
+
type: Component,
|
|
427
|
+
args: [{ selector: 'spine-audit-top-card', imports: [IconButtonComponent, SpinnerComponent], template: `
|
|
428
|
+
<section class="card card-block top" [attr.aria-labelledby]="headingId" [attr.aria-busy]="loading()">
|
|
429
|
+
<div class="top__head">
|
|
430
|
+
<h3 class="top__title" [id]="headingId">{{ heading() }}</h3>
|
|
431
|
+
@if (loading() && rows().length) {
|
|
432
|
+
<hm-spinner size="small" [label]="text.updating" />
|
|
433
|
+
}
|
|
434
|
+
</div>
|
|
435
|
+
@if (rows().length) {
|
|
436
|
+
<ol class="rank" [class.rank--busy]="loading()" [attr.start]="offset() + 1">
|
|
437
|
+
@for (row of rows(); track row.key) {
|
|
438
|
+
<li>
|
|
439
|
+
<button type="button" class="rank__item" [attr.title]="row.hint || fmt(text.filterBy, { name: row.name })"
|
|
440
|
+
[disabled]="row.disabled" (click)="picked.emit(row)">
|
|
441
|
+
<span class="rank__name">
|
|
442
|
+
{{ row.name }}
|
|
443
|
+
@if (row.suffix) { <span class="muted"> · {{ row.suffix }}</span> }
|
|
444
|
+
</span>
|
|
445
|
+
<span class="rank__value">
|
|
446
|
+
{{ number(row.total) }}
|
|
447
|
+
@if (row.failures) { <span class="rank__fail"> · {{ plural(text.failureCount, row.failures) }}</span> }
|
|
448
|
+
@if (row.denied) { <span class="rank__denied"> · {{ plural(text.deniedCount, row.denied) }}</span> }
|
|
449
|
+
</span>
|
|
450
|
+
<span class="rank__track" aria-hidden="true">
|
|
451
|
+
<!-- A barra é a própria linha (sempre cheia): sucesso, falhas e negados na proporção dela. -->
|
|
452
|
+
<span class="rank__bar">
|
|
453
|
+
@if (row.failures) { <span class="rank__seg rank__seg--fail" [style.width.%]="share(row.failures, row.total)"></span> }
|
|
454
|
+
@if (row.denied) { <span class="rank__seg rank__seg--denied" [style.width.%]="share(row.denied, row.total)"></span> }
|
|
455
|
+
</span>
|
|
456
|
+
</span>
|
|
457
|
+
</button>
|
|
458
|
+
</li>
|
|
459
|
+
}
|
|
460
|
+
</ol>
|
|
461
|
+
@if (failed()) {
|
|
462
|
+
<p class="muted top__error" role="status">{{ text.topsError }}</p>
|
|
463
|
+
}
|
|
464
|
+
} @else {
|
|
465
|
+
<p class="muted">{{ loading() ? text.loading : failed() ? text.topsError : text.noTop }}</p>
|
|
466
|
+
}
|
|
467
|
+
@if (total() > 0) {
|
|
468
|
+
<div class="top__pager">
|
|
469
|
+
<span class="top__range" role="status">{{ range() }}</span>
|
|
470
|
+
@if (total() > pageSize()) {
|
|
471
|
+
<span class="top__buttons">
|
|
472
|
+
<hm-icon-button [label]="fmt(text.topsPrevious, { name: heading() })" icon="angle" iconDirection="left"
|
|
473
|
+
emphasis="subtle" size="small" [disabled]="offset() <= 0 || loading()" (clicked)="go(-1)" />
|
|
474
|
+
<hm-icon-button [label]="fmt(text.topsNext, { name: heading() })" icon="angle" iconDirection="right"
|
|
475
|
+
emphasis="subtle" size="small" [disabled]="offset() + pageSize() >= total() || loading()" (clicked)="go(1)" />
|
|
476
|
+
</span>
|
|
477
|
+
}
|
|
478
|
+
</div>
|
|
479
|
+
}
|
|
480
|
+
</section>
|
|
481
|
+
`, changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{display:block;min-inline-size:0}.card{border:1px solid var(--hm-border);border-radius:var(--hm-radius-control);background:var(--hm-surface)}.card-block{padding:var(--hm-space-sm, 8px)}.muted{color:var(--hm-muted)}.top{display:flex;flex-direction:column;block-size:100%;box-sizing:border-box}.top__head{display:flex;align-items:center;justify-content:space-between;gap:var(--hm-space-sm, 8px);margin:0 0 var(--hm-space-sm, 8px);min-block-size:24px}.top__title{margin:0;font-size:14px;font-weight:600;color:var(--hm-text)}.top__error{margin:var(--hm-space-xs, 4px) 0 0;font-size:12px}.top__pager{display:flex;align-items:center;justify-content:flex-end;gap:var(--hm-space-xs, 4px);margin-block-start:auto;padding-block-start:var(--hm-space-sm, 8px)}.top__range{font-size:12px;color:var(--hm-muted);font-variant-numeric:tabular-nums}.top__buttons{display:inline-flex;gap:2px}.rank{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:2px;transition:opacity .12s ease}.rank--busy{opacity:.55}.rank__item{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px var(--hm-space-sm, 8px);inline-size:100%;padding:6px 8px;border:0;border-radius:var(--hm-radius-control);background:transparent;color:var(--hm-text);font:inherit;font-size:13px;text-align:start;cursor:pointer}.rank__item:hover:not(:disabled){background:color-mix(in srgb,var(--hm-action) 8%,transparent)}.rank__item:focus-visible{outline:2px solid var(--hm-action);outline-offset:1px}.rank__item:disabled{cursor:default}.rank__name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rank__value{font-variant-numeric:tabular-nums;white-space:nowrap}.rank__fail{color:var(--hm-danger)}.rank__denied{color:var(--hm-warning, #c98a00)}.rank__track{grid-column:1/-1;display:block;block-size:4px;border-radius:2px;background:color-mix(in srgb,var(--hm-text) 8%,transparent);overflow:hidden}.rank__bar{display:flex;justify-content:flex-end;inline-size:100%;block-size:100%;background:var(--hm-success)}.rank__seg{display:block;block-size:100%}.rank__seg--fail{background:var(--hm-danger)}.rank__seg--denied{background:var(--hm-warning)}@media(prefers-reduced-motion:reduce){.rank{transition:none}}\n"] }]
|
|
482
|
+
}], propDecorators: { heading: [{ type: i0.Input, args: [{ isSignal: true, alias: "heading", required: true }] }], rows: [{ type: i0.Input, args: [{ isSignal: true, alias: "rows", required: true }] }], total: [{ type: i0.Input, args: [{ isSignal: true, alias: "total", required: false }] }], offset: [{ type: i0.Input, args: [{ isSignal: true, alias: "offset", required: false }] }], pageSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "pageSize", required: false }] }], max: [{ type: i0.Input, args: [{ isSignal: true, alias: "max", required: false }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], failed: [{ type: i0.Input, args: [{ isSignal: true, alias: "failed", required: false }] }], picked: [{ type: i0.Output, args: ["picked"] }], offsetChange: [{ type: i0.Output, args: ["offsetChange"] }] } });
|
|
483
|
+
|
|
484
|
+
/** Eventos por página da tabela, de saída (o servidor aceita até 200 — `AUDIT_MAX_PAGE_SIZE`). */
|
|
485
|
+
const AUDIT_PAGE_SIZE = 10;
|
|
486
|
+
/** Opções do seletor de eventos por página (todas dentro do `limit` máximo do servidor). */
|
|
487
|
+
const AUDIT_PAGE_SIZES = [10, 25, 50, 100, 200];
|
|
488
|
+
/** Intervalo da atualização automática (opcional, desligada por padrão). */
|
|
489
|
+
const AUDIT_AUTO_REFRESH_MS = 30_000;
|
|
490
|
+
const NO_VALUE = '—';
|
|
491
|
+
const LEVELS = ['public', 'private', 'protected', 'optional'];
|
|
492
|
+
const PERMISSIONS = ['allowed', 'forbidden', 'unauthorized', 'public'];
|
|
493
|
+
const ACTORS = ['user', 'app_token', 'api_token', 'public', 'unauthenticated'];
|
|
494
|
+
const EMPTY_TOP = { items: [], total: 0, offset: 0, max: 0, loading: false, failed: false };
|
|
495
|
+
/**
|
|
496
|
+
* A tela de Auditoria — a MESMA no auto-serviço e no painel central do argos admin. Só muda o escopo:
|
|
497
|
+
* - `scope="client"` + `clientId`: `{adminApi}/auto-service/:clientId/audit/*`, apps do projeto do client
|
|
498
|
+
* (recorte feito no servidor);
|
|
499
|
+
* - `scope="admin"`: `{adminApi}/admin/audit/*`, todos os apps (guard `admin:audit:*` no servidor).
|
|
500
|
+
* `adminApi` vem do input ou do `SPINE_ADMIN_API` (que o `provideAutoService` já fornece).
|
|
501
|
+
*
|
|
502
|
+
* Unidade: por padrão a tela lê AÇÕES (`GET .../actions`, uma linha por trace, coluna "Spans", detalhe
|
|
503
|
+
* com os spans numa linha do tempo); o alternador "Ações | Eventos" volta à leitura por registro. KPIs,
|
|
504
|
+
* linha do tempo e rankings seguem a unidade escolhida (`unit=` nas agregações).
|
|
505
|
+
*
|
|
506
|
+
* Conteúdo: período rápido (ou datas da faixa de filtros, que vencem), filtros, KPIs (falhas/negados
|
|
507
|
+
* clicáveis viram filtro), linha do tempo, 3 rankings paginados no servidor (`tops/:kind`), a tabela
|
|
508
|
+
* paginada no servidor (cursor) e o detalhe/cadeia (trace) de um evento.
|
|
509
|
+
*
|
|
510
|
+
* Recarga sem piscar: trocar de página (ou atualizar) mantém as linhas atuais à vista com um indicador
|
|
511
|
+
* por cima — a grade só mostra esqueleto na 1ª carga. Toda resposta carrega um número de sequência e a
|
|
512
|
+
* que chega fora de ordem (página/consulta já trocada) é descartada.
|
|
513
|
+
*
|
|
514
|
+
* Importe só de código carregado sob demanda (rota lazy, `import()`): o ponto `@trismegisto/spine/audit`
|
|
515
|
+
* existe para a tela não entrar no bundle inicial de quem só usa o resto do spine.
|
|
516
|
+
*/
|
|
517
|
+
class SpineAuditComponent {
|
|
518
|
+
constructor() {
|
|
519
|
+
/** `admin` = painel central (todos os apps); `client` = apps do projeto do `clientId`. */
|
|
520
|
+
this.scope = input.required(...(ngDevMode ? [{ debugName: "scope" }] : /* istanbul ignore next */ []));
|
|
521
|
+
/** Obrigatório com `scope="client"`. */
|
|
522
|
+
this.clientId = input(null, ...(ngDevMode ? [{ debugName: "clientId" }] : /* istanbul ignore next */ []));
|
|
523
|
+
/** Base do argos admin-server; sem ele, vale o `SPINE_ADMIN_API`. */
|
|
524
|
+
this.adminApi = input(null, ...(ngDevMode ? [{ debugName: "adminApi" }] : /* istanbul ignore next */ []));
|
|
525
|
+
this.service = inject(SpineAuditService);
|
|
526
|
+
this.tokenApi = inject(SPINE_ADMIN_API, { optional: true });
|
|
527
|
+
this.messages = inject(SPINE_MESSAGES);
|
|
528
|
+
this.text = this.messages.autoServiceAudit;
|
|
529
|
+
this.locale = this.messages.meta.locale;
|
|
530
|
+
this.fmt = formatMessage;
|
|
531
|
+
/** Base dos endpoints do escopo; muda → tudo recomeça (filtros, página, detalhe). */
|
|
532
|
+
this.base = computed(() => auditApiBase(this.adminApi() || this.tokenApi, this.scope(), this.clientId()), ...(ngDevMode ? [{ debugName: "base" }] : /* istanbul ignore next */ []));
|
|
533
|
+
this.appsUrl = computed(() => auditAppsUrl(this.adminApi() || this.tokenApi, this.scope(), this.clientId()), ...(ngDevMode ? [{ debugName: "appsUrl" }] : /* istanbul ignore next */ []));
|
|
534
|
+
this.period = signal('24h', ...(ngDevMode ? [{ debugName: "period" }] : /* istanbul ignore next */ []));
|
|
535
|
+
/** Ações (uma linha por trace) ou eventos (cada registro). */
|
|
536
|
+
this.unit = signal('actions', ...(ngDevMode ? [{ debugName: "unit" }] : /* istanbul ignore next */ []));
|
|
537
|
+
this.byActions = computed(() => this.unit() === 'actions', ...(ngDevMode ? [{ debugName: "byActions" }] : /* istanbul ignore next */ []));
|
|
538
|
+
this.unitOptions = [
|
|
539
|
+
{ value: 'actions', label: this.text.unitActions },
|
|
540
|
+
{ value: 'events', label: this.text.unitEvents },
|
|
541
|
+
];
|
|
542
|
+
/** Textos que mudam com a unidade. */
|
|
543
|
+
this.unitText = computed(() => {
|
|
544
|
+
const t = this.text;
|
|
545
|
+
return this.byActions()
|
|
546
|
+
? {
|
|
547
|
+
kpiTotal: t.kpiTotalActions, kpiTotalDescription: t.kpiTotalActionsDescription, timelineSummary: t.timelineSummaryActions,
|
|
548
|
+
bucketSummary: t.bucketSummaryActions, timelineEmpty: t.timelineEmptyActions, caption: t.actionsCaption,
|
|
549
|
+
pageItems: t.pageItemsActions, pageError: t.pageErrorActions, emptyTitle: t.emptyTitleActions,
|
|
550
|
+
}
|
|
551
|
+
: {
|
|
552
|
+
kpiTotal: t.kpiTotal, kpiTotalDescription: t.kpiTotalDescription, timelineSummary: t.timelineSummary,
|
|
553
|
+
bucketSummary: t.bucketSummary, timelineEmpty: t.timelineEmpty, caption: t.eventsCaption,
|
|
554
|
+
pageItems: t.pageItems, pageError: t.pageError, emptyTitle: t.emptyTitle,
|
|
555
|
+
};
|
|
556
|
+
}, ...(ngDevMode ? [{ debugName: "unitText" }] : /* istanbul ignore next */ []));
|
|
557
|
+
this.filterValue = signal({}, ...(ngDevMode ? [{ debugName: "filterValue" }] : /* istanbul ignore next */ []));
|
|
558
|
+
this.apps = signal([], ...(ngDevMode ? [{ debugName: "apps" }] : /* istanbul ignore next */ []));
|
|
559
|
+
this.stats = signal(null, ...(ngDevMode ? [{ debugName: "stats" }] : /* istanbul ignore next */ []));
|
|
560
|
+
this.events = signal([], ...(ngDevMode ? [{ debugName: "events" }] : /* istanbul ignore next */ []));
|
|
561
|
+
this.loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
|
|
562
|
+
this.pageLoading = signal(false, ...(ngDevMode ? [{ debugName: "pageLoading" }] : /* istanbul ignore next */ []));
|
|
563
|
+
this.pageFailed = signal(false, ...(ngDevMode ? [{ debugName: "pageFailed" }] : /* istanbul ignore next */ []));
|
|
564
|
+
this.pageState = signal({ page: 1, size: AUDIT_PAGE_SIZE }, ...(ngDevMode ? [{ debugName: "pageState" }] : /* istanbul ignore next */ []));
|
|
565
|
+
this.pageSizes = AUDIT_PAGE_SIZES;
|
|
566
|
+
/** A página mostrada (posição + "tem mais") e o total exato quando a lista já chegou ao fim. */
|
|
567
|
+
this.loaded = signal(null, ...(ngDevMode ? [{ debugName: "loaded" }] : /* istanbul ignore next */ []));
|
|
568
|
+
this.listEnd = signal(null, ...(ngDevMode ? [{ debugName: "listEnd" }] : /* istanbul ignore next */ []));
|
|
569
|
+
/** Total do rodapé da tabela. */
|
|
570
|
+
this.total = computed(() => auditPagerTotal(this.stats()?.total, this.listEnd(), this.loaded()), ...(ngDevMode ? [{ debugName: "total" }] : /* istanbul ignore next */ []));
|
|
571
|
+
this.errorKind = signal(null, ...(ngDevMode ? [{ debugName: "errorKind" }] : /* istanbul ignore next */ []));
|
|
572
|
+
this.errorDetail = signal('', ...(ngDevMode ? [{ debugName: "errorDetail" }] : /* istanbul ignore next */ []));
|
|
573
|
+
this.lastUpdated = signal(null, ...(ngDevMode ? [{ debugName: "lastUpdated" }] : /* istanbul ignore next */ []));
|
|
574
|
+
this.autoRefresh = signal(false, ...(ngDevMode ? [{ debugName: "autoRefresh" }] : /* istanbul ignore next */ []));
|
|
575
|
+
/** Esqueleto da grade só quando ainda não há linha nenhuma para mostrar (1ª carga). */
|
|
576
|
+
this.gridSkeleton = computed(() => this.loading() && this.events().length === 0, ...(ngDevMode ? [{ debugName: "gridSkeleton" }] : /* istanbul ignore next */ []));
|
|
577
|
+
/** Recarga com linhas à vista: elas ficam, com o indicador por cima. */
|
|
578
|
+
this.gridBusy = computed(() => (this.loading() || this.pageLoading()) && this.events().length > 0, ...(ngDevMode ? [{ debugName: "gridBusy" }] : /* istanbul ignore next */ []));
|
|
579
|
+
this.tops = {
|
|
580
|
+
apps: signal(EMPTY_TOP),
|
|
581
|
+
operations: signal(EMPTY_TOP),
|
|
582
|
+
users: signal(EMPTY_TOP),
|
|
583
|
+
};
|
|
584
|
+
this.topPageSize = AUDIT_TOP_PAGE_SIZE;
|
|
585
|
+
this.detailOpen = signal(false, ...(ngDevMode ? [{ debugName: "detailOpen" }] : /* istanbul ignore next */ []));
|
|
586
|
+
this.selected = signal(null, ...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
|
|
587
|
+
this.detailLoading = signal(false, ...(ngDevMode ? [{ debugName: "detailLoading" }] : /* istanbul ignore next */ []));
|
|
588
|
+
this.detailFailed = signal(false, ...(ngDevMode ? [{ debugName: "detailFailed" }] : /* istanbul ignore next */ []));
|
|
589
|
+
this.trace = signal(null, ...(ngDevMode ? [{ debugName: "trace" }] : /* istanbul ignore next */ []));
|
|
590
|
+
/** A ação aberta (linha da tabela por ações): agregados que o detalhe do evento raiz não traz. */
|
|
591
|
+
this.selectedAction = signal(null, ...(ngDevMode ? [{ debugName: "selectedAction" }] : /* istanbul ignore next */ []));
|
|
592
|
+
/** O detalhe mostra a ação (o raiz dela), não um span qualquer da cadeia. */
|
|
593
|
+
this.showingAction = computed(() => {
|
|
594
|
+
const a = this.selectedAction();
|
|
595
|
+
return !!a && a.event_id === this.selected()?.event_id;
|
|
596
|
+
}, ...(ngDevMode ? [{ debugName: "showingAction" }] : /* istanbul ignore next */ []));
|
|
597
|
+
/** Resultado do cabeçalho do detalhe: o da ação (pior dos spans) ou o do evento. */
|
|
598
|
+
this.headOutcome = computed(() => {
|
|
599
|
+
if (!this.showingAction())
|
|
600
|
+
return this.selected()?.outcome;
|
|
601
|
+
const spans = this.trace()?.items;
|
|
602
|
+
if (spans?.length)
|
|
603
|
+
return worstOutcome(spans.map((s) => s.outcome));
|
|
604
|
+
return this.selectedAction().outcome ?? this.selected()?.outcome;
|
|
605
|
+
}, ...(ngDevMode ? [{ debugName: "headOutcome" }] : /* istanbul ignore next */ []));
|
|
606
|
+
/** Os spans da cadeia numa linha do tempo (hierarquia por parent_span_id). */
|
|
607
|
+
this.spanTimeline = computed(() => {
|
|
608
|
+
const tr = this.trace();
|
|
609
|
+
return tr ? buildSpanTimeline(tr.items) : null;
|
|
610
|
+
}, ...(ngDevMode ? [{ debugName: "spanTimeline" }] : /* istanbul ignore next */ []));
|
|
611
|
+
this.traceLoading = signal(false, ...(ngDevMode ? [{ debugName: "traceLoading" }] : /* istanbul ignore next */ []));
|
|
612
|
+
this.traceFailed = signal(false, ...(ngDevMode ? [{ debugName: "traceFailed" }] : /* istanbul ignore next */ []));
|
|
613
|
+
/** Consulta da última carga — a troca de página usa a mesma (mesma janela absoluta). */
|
|
614
|
+
this.query = null;
|
|
615
|
+
/** Cursores da consulta atual (consulta nova = pager novo). */
|
|
616
|
+
this.pager = new AuditCursorPager();
|
|
617
|
+
/** Descartam respostas velhas (consulta/página/ranking/detalhe já trocados). */
|
|
618
|
+
this.seq = 0;
|
|
619
|
+
this.pageSeq = 0;
|
|
620
|
+
this.detailSeq = 0;
|
|
621
|
+
this.topSeq = { apps: 0, operations: 0, users: 0 };
|
|
622
|
+
this.periodOptions = [
|
|
623
|
+
{ value: '1h', label: this.text.periodLastHour },
|
|
624
|
+
{ value: '6h', label: this.text.period6h },
|
|
625
|
+
{ value: '24h', label: this.text.period24h },
|
|
626
|
+
{ value: '7d', label: this.text.period7d },
|
|
627
|
+
{ value: '30d', label: this.text.period30d },
|
|
628
|
+
];
|
|
629
|
+
/** Há mais de um app à vista: coluna, filtro e ranking "por aplicação" aparecem. */
|
|
630
|
+
this.multiApp = computed(() => this.apps().length > 1 || this.tops.apps().total > 1, ...(ngDevMode ? [{ debugName: "multiApp" }] : /* istanbul ignore next */ []));
|
|
631
|
+
/** As datas da faixa estão valendo (a escolha rápida fica sem efeito). */
|
|
632
|
+
this.customDates = computed(() => {
|
|
633
|
+
const dates = this.filterValue().dates;
|
|
634
|
+
return !!dates && !!(dates.start || dates.end);
|
|
635
|
+
}, ...(ngDevMode ? [{ debugName: "customDates" }] : /* istanbul ignore next */ []));
|
|
636
|
+
this.hasFilters = computed(() => Object.keys(this.filterValue()).length > 0, ...(ngDevMode ? [{ debugName: "hasFilters" }] : /* istanbul ignore next */ []));
|
|
637
|
+
this.selectedOutcome = computed(() => this.filterValue().outcome ?? null, ...(ngDevMode ? [{ debugName: "selectedOutcome" }] : /* istanbul ignore next */ []));
|
|
638
|
+
this.storageKey = computed(() => `spine-audit-${this.scope()}`, ...(ngDevMode ? [{ debugName: "storageKey" }] : /* istanbul ignore next */ []));
|
|
639
|
+
this.filterFields = computed(() => {
|
|
640
|
+
const t = this.text;
|
|
641
|
+
const options = (values, label) => values.map((v) => ({ value: v, label: label(v) }));
|
|
642
|
+
const fields = [{ key: 'dates', label: t.filterDates, type: 'date-range' }];
|
|
643
|
+
if (this.multiApp()) {
|
|
644
|
+
fields.push({ key: 'app', label: t.filterApp, type: 'multiselect', options: this.apps().map((a) => ({ value: a.id, label: a.name })) });
|
|
645
|
+
}
|
|
646
|
+
fields.push({ key: 'user', label: t.filterUser, type: 'text', placeholder: t.userPlaceholder }, { key: 'operation', label: t.filterOperation, type: 'text', placeholder: t.operationPlaceholder }, { key: 'method', label: t.filterMethod, type: 'select', options: options(AUDIT_METHODS, (m) => this.methodLabel(m)) }, { key: 'outcome', label: t.filterOutcome, type: 'select', options: options(AUDIT_OUTCOMES, (o) => this.outcomeLabel(o)) });
|
|
647
|
+
// Por ação, "só ações do usuário" não se aplica (toda linha já é uma ação).
|
|
648
|
+
if (!this.byActions())
|
|
649
|
+
fields.push({ key: 'entry', label: t.filterEntry, type: 'boolean' });
|
|
650
|
+
fields.push({ key: 'level', label: t.filterLevel, type: 'select', options: options(LEVELS, (l) => this.levelLabel(l)) }, { key: 'permission_result', label: t.filterPermission, type: 'select', options: options(PERMISSIONS, (p) => this.permissionLabel(p)) }, { key: 'actor_type', label: t.filterActor, type: 'select', options: options(ACTORS, (a) => this.actorLabel(a)) }, { key: 'status', label: t.filterStatus, type: 'text', placeholder: t.statusPlaceholder }, { key: 'trace_id', label: t.filterTrace, type: 'text', placeholder: t.tracePlaceholder }, { key: 'user_id', label: t.filterUserId, type: 'text', placeholder: t.userIdPlaceholder });
|
|
651
|
+
return fields;
|
|
652
|
+
}, ...(ngDevMode ? [{ debugName: "filterFields" }] : /* istanbul ignore next */ []));
|
|
653
|
+
this.errorSituation = computed(() => {
|
|
654
|
+
const kind = this.errorKind();
|
|
655
|
+
return kind === 'forbidden' ? 'forbidden' : kind === 'range' || kind === 'invalid' ? 'attention' : 'error';
|
|
656
|
+
}, ...(ngDevMode ? [{ debugName: "errorSituation" }] : /* istanbul ignore next */ []));
|
|
657
|
+
this.errorText = computed(() => {
|
|
658
|
+
switch (this.errorKind()) {
|
|
659
|
+
case 'forbidden': return this.scope() === 'admin' ? this.text.forbiddenAdmin : this.text.forbidden;
|
|
660
|
+
case 'range': return this.text.rangeTooLarge;
|
|
661
|
+
case 'invalid': return formatMessage(this.text.invalidFilter, { detail: this.errorDetail() }).trim();
|
|
662
|
+
default: return this.text.errorText;
|
|
663
|
+
}
|
|
664
|
+
}, ...(ngDevMode ? [{ debugName: "errorText" }] : /* istanbul ignore next */ []));
|
|
665
|
+
this.empty = computed(() => !this.loading() && !this.errorKind() && !!this.stats() && this.stats().total === 0 && this.events().length === 0, ...(ngDevMode ? [{ debugName: "empty" }] : /* istanbul ignore next */ []));
|
|
666
|
+
this.columns = computed(() => {
|
|
667
|
+
const t = this.text;
|
|
668
|
+
const cols = [
|
|
669
|
+
{ key: 'occurred_at', title: t.colWhen, value: (e) => this.dateTime(e.occurred_at), initialWidth: '11rem' },
|
|
670
|
+
];
|
|
671
|
+
if (this.multiApp())
|
|
672
|
+
cols.push({ key: 'app', title: t.colApp, value: (e) => e.app_name ?? NO_VALUE });
|
|
673
|
+
cols.push({ key: 'user', title: t.colUser, value: (e) => this.userName(e) },
|
|
674
|
+
// Só o identificador; a descrição fica na dica da célula e no detalhe do evento.
|
|
675
|
+
{ key: 'operation', title: t.colOperation, value: (e) => this.operationName(e) });
|
|
676
|
+
if (this.byActions()) {
|
|
677
|
+
cols.push({ key: 'spans', title: t.colSpans, value: (e) => this.number(e.span_count ?? 1), align: 'end', initialWidth: '5rem' });
|
|
678
|
+
}
|
|
679
|
+
cols.push({ key: 'method', title: t.colMethod, value: (e) => this.methodLabel(e.operation_method), initialWidth: '8rem' }, { key: 'outcome', title: t.colOutcome, value: (e) => this.outcomeLabel(e.outcome), initialWidth: '8rem' }, { key: 'status', title: t.colStatus, value: (e) => (e.status == null ? NO_VALUE : String(e.status)), align: 'end', initialWidth: '5rem' }, { key: 'duration', title: t.colDuration, value: (e) => this.duration(e.duration_ms), align: 'end', initialWidth: '7rem' });
|
|
680
|
+
return cols.map((c) => ({ sortable: false, filterable: false, ...c }));
|
|
681
|
+
}, ...(ngDevMode ? [{ debugName: "columns" }] : /* istanbul ignore next */ []));
|
|
682
|
+
this.rowId = (e) => e.event_id;
|
|
683
|
+
this.timelineBars = computed(() => {
|
|
684
|
+
const buckets = this.stats()?.timeline ?? [];
|
|
685
|
+
const max = Math.max(1, ...buckets.map((b) => b.total));
|
|
686
|
+
return buckets.map((b) => {
|
|
687
|
+
const failures = Math.min(b.failures, b.total);
|
|
688
|
+
const denied = Math.min(b.denied, b.total - failures);
|
|
689
|
+
return {
|
|
690
|
+
key: b.bucket,
|
|
691
|
+
summary: formatMessage(this.unitText().bucketSummary, {
|
|
692
|
+
time: this.bucketTime(b.bucket), total: this.number(b.total), failures: this.number(b.failures), denied: this.number(b.denied),
|
|
693
|
+
}),
|
|
694
|
+
ok: ((b.total - failures - denied) / max) * 100,
|
|
695
|
+
fail: (failures / max) * 100,
|
|
696
|
+
denied: (denied / max) * 100,
|
|
697
|
+
};
|
|
698
|
+
});
|
|
699
|
+
}, ...(ngDevMode ? [{ debugName: "timelineBars" }] : /* istanbul ignore next */ []));
|
|
700
|
+
this.timelineEdges = computed(() => {
|
|
701
|
+
const buckets = this.stats()?.timeline ?? [];
|
|
702
|
+
if (!buckets.length)
|
|
703
|
+
return null;
|
|
704
|
+
return { from: this.bucketTime(buckets[0].bucket), to: this.bucketTime(buckets[buckets.length - 1].bucket) };
|
|
705
|
+
}, ...(ngDevMode ? [{ debugName: "timelineEdges" }] : /* istanbul ignore next */ []));
|
|
706
|
+
this.hasTimeline = computed(() => (this.stats()?.timeline ?? []).some((b) => b.total > 0), ...(ngDevMode ? [{ debugName: "hasTimeline" }] : /* istanbul ignore next */ []));
|
|
707
|
+
this.topOperationRows = computed(() => this.tops.operations().items.map((o) => ({
|
|
708
|
+
key: `${o.app_id ?? ''}|${o.operation_identifier ?? ''}`,
|
|
709
|
+
name: o.operation_identifier || o.operation_label || NO_VALUE,
|
|
710
|
+
suffix: this.multiApp() ? o.app_name : null,
|
|
711
|
+
hint: o.operation_label,
|
|
712
|
+
total: o.total, failures: o.failures, denied: o.denied,
|
|
713
|
+
disabled: !o.operation_identifier,
|
|
714
|
+
})), ...(ngDevMode ? [{ debugName: "topOperationRows" }] : /* istanbul ignore next */ []));
|
|
715
|
+
this.topUserRows = computed(() => this.tops.users().items.map((u) => ({
|
|
716
|
+
key: u.user_id ?? u.user_name ?? '',
|
|
717
|
+
name: this.userName({ user_name: u.user_name ?? null, user_id: u.user_id ?? null }),
|
|
718
|
+
total: u.total, failures: u.failures, denied: u.denied,
|
|
719
|
+
disabled: !u.user_name && !u.user_id,
|
|
720
|
+
})), ...(ngDevMode ? [{ debugName: "topUserRows" }] : /* istanbul ignore next */ []));
|
|
721
|
+
this.topAppRows = computed(() => this.tops.apps().items.map((a) => ({
|
|
722
|
+
key: a.app_id ?? '',
|
|
723
|
+
name: a.app_name || a.app_id || NO_VALUE,
|
|
724
|
+
total: a.total, failures: a.failures, denied: a.denied,
|
|
725
|
+
disabled: !a.app_id,
|
|
726
|
+
})), ...(ngDevMode ? [{ debugName: "topAppRows" }] : /* istanbul ignore next */ []));
|
|
727
|
+
this.detailFields = computed(() => {
|
|
728
|
+
const e = this.selected();
|
|
729
|
+
if (!e)
|
|
730
|
+
return [];
|
|
731
|
+
const t = this.text;
|
|
732
|
+
const fields = [
|
|
733
|
+
{ label: t.fieldWhen, value: this.dateTime(e.occurred_at) },
|
|
734
|
+
{ label: t.fieldOutcome, value: this.outcomeLabel(e.outcome) },
|
|
735
|
+
{ label: t.fieldOrigin, value: e.entry ? t.originEntry : t.originInternal },
|
|
736
|
+
e.app_name ? { label: t.fieldApp, value: e.app_name } : null,
|
|
737
|
+
{ label: t.fieldUser, value: this.userName(e) },
|
|
738
|
+
e.actor_type ? { label: t.fieldActor, value: this.actorLabel(e.actor_type) } : null,
|
|
739
|
+
e.client_ip ? { label: t.fieldIp, value: e.client_ip, code: true } : null,
|
|
740
|
+
e.operation_identifier ? { label: t.fieldIdentifier, value: e.operation_identifier, code: true } : null,
|
|
741
|
+
e.operation_label ? { label: t.fieldOperation, value: e.operation_label } : null,
|
|
742
|
+
e.operation_method ? { label: t.fieldMethod, value: this.methodLabel(e.operation_method) } : null,
|
|
743
|
+
e.operation_level ? { label: t.fieldLevel, value: this.levelLabel(e.operation_level) } : null,
|
|
744
|
+
e.permission_result ? { label: t.fieldPermission, value: this.permissionLabel(e.permission_result) } : null,
|
|
745
|
+
e.status != null ? { label: t.fieldStatus, value: String(e.status) } : null,
|
|
746
|
+
e.duration_ms != null ? { label: t.fieldDuration, value: this.duration(e.duration_ms) } : null,
|
|
747
|
+
e.route_name || e.route_id ? { label: t.fieldRoute, value: e.route_name ?? e.route_id ?? '' } : null,
|
|
748
|
+
e.target_type || e.target_id ? { label: t.fieldTarget, value: [e.target_type, e.target_id].filter(Boolean).join(' · '), code: true } : null,
|
|
749
|
+
e.message ? { label: t.fieldMessage, value: e.message } : null,
|
|
750
|
+
e.trace_id ? { label: t.fieldTrace, value: e.trace_id, code: true } : null,
|
|
751
|
+
{ label: t.fieldEventId, value: e.event_id, code: true },
|
|
752
|
+
e.received_at ? { label: t.fieldReceivedAt, value: this.dateTime(e.received_at) } : null,
|
|
753
|
+
e.service_instance ? { label: t.fieldInstance, value: e.service_instance, code: true } : null,
|
|
754
|
+
e.sdk ? { label: t.fieldSdk, value: e.sdk, code: true } : null,
|
|
755
|
+
];
|
|
756
|
+
return fields.filter((f) => !!f);
|
|
757
|
+
}, ...(ngDevMode ? [{ debugName: "detailFields" }] : /* istanbul ignore next */ []));
|
|
758
|
+
this.detailsJson = computed(() => {
|
|
759
|
+
const details = this.selected()?.details;
|
|
760
|
+
if (details === undefined || details === null)
|
|
761
|
+
return '';
|
|
762
|
+
if (typeof details === 'object' && Object.keys(details).length === 0)
|
|
763
|
+
return '';
|
|
764
|
+
try {
|
|
765
|
+
return typeof details === 'string' ? details : JSON.stringify(details, null, 2);
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
return String(details);
|
|
769
|
+
}
|
|
770
|
+
}, ...(ngDevMode ? [{ debugName: "detailsJson" }] : /* istanbul ignore next */ []));
|
|
771
|
+
this.router = inject(Router, { optional: true });
|
|
772
|
+
this.urlConsumed = false;
|
|
773
|
+
// Escopo/client novo (ou o primeiro): zera filtros/detalhe e carrega de novo. `untracked` porque a
|
|
774
|
+
// carga lê `period`/`filterValue` — essas mudanças recarregam pelos próprios handlers.
|
|
775
|
+
effect(() => {
|
|
776
|
+
const base = this.base();
|
|
777
|
+
const appsUrl = this.appsUrl();
|
|
778
|
+
untracked(() => {
|
|
779
|
+
// 1ª carga: filtros e evento vindos da URL (links do dashboard: `?from&to&app_id&event_id…`).
|
|
780
|
+
const fromUrl = this.urlConsumed ? null : this.readUrlState();
|
|
781
|
+
this.urlConsumed = true;
|
|
782
|
+
this.filterValue.set(fromUrl?.filters ?? {});
|
|
783
|
+
this.firstPage();
|
|
784
|
+
this.closeDetail();
|
|
785
|
+
this.events.set([]);
|
|
786
|
+
this.loaded.set(null);
|
|
787
|
+
this.listEnd.set(null);
|
|
788
|
+
this.stats.set(null);
|
|
789
|
+
for (const kind of AUDIT_TOP_KINDS)
|
|
790
|
+
this.tops[kind].set(EMPTY_TOP);
|
|
791
|
+
void this.loadApps(appsUrl);
|
|
792
|
+
if (base) {
|
|
793
|
+
void this.reload().then(() => {
|
|
794
|
+
if (fromUrl?.event) {
|
|
795
|
+
if (this.byActions())
|
|
796
|
+
void this.openAction({ ...fromUrl.event, span_count: 0 });
|
|
797
|
+
else
|
|
798
|
+
void this.openEvent(fromUrl.event);
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
// Atualização automática (opcional): só quando nada está carregando.
|
|
805
|
+
effect((onCleanup) => {
|
|
806
|
+
if (!this.autoRefresh())
|
|
807
|
+
return;
|
|
808
|
+
const timer = setInterval(() => {
|
|
809
|
+
if (!this.loading() && !this.pageLoading())
|
|
810
|
+
void this.reload();
|
|
811
|
+
}, AUDIT_AUTO_REFRESH_MS);
|
|
812
|
+
onCleanup(() => clearInterval(timer));
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
/** Filtros/evento da query string (`from`, `to` ISO; `app_id` repetível; demais filtros pelo nome). */
|
|
816
|
+
readUrlState() {
|
|
817
|
+
if (!this.router)
|
|
818
|
+
return null;
|
|
819
|
+
const q = this.router.parseUrl(this.router.url).queryParamMap;
|
|
820
|
+
if (!q.keys.length)
|
|
821
|
+
return null;
|
|
822
|
+
const filters = {};
|
|
823
|
+
const day = (iso) => (iso ? iso.slice(0, 10) : null);
|
|
824
|
+
if (q.has('from') || q.has('to'))
|
|
825
|
+
filters['dates'] = { start: day(q.get('from')), end: day(q.get('to')) };
|
|
826
|
+
const apps = q.getAll('app_id');
|
|
827
|
+
if (apps.length)
|
|
828
|
+
filters['app'] = apps;
|
|
829
|
+
for (const key of ['user', 'operation', 'method', 'outcome', 'level', 'permission_result', 'actor_type', 'status', 'trace_id', 'user_id']) {
|
|
830
|
+
const v = q.get(key);
|
|
831
|
+
if (v)
|
|
832
|
+
filters[key] = v;
|
|
833
|
+
}
|
|
834
|
+
if (q.get('entry') === 'true')
|
|
835
|
+
filters['entry'] = true;
|
|
836
|
+
const unit = q.get('unit');
|
|
837
|
+
if (unit === 'actions' || unit === 'events')
|
|
838
|
+
this.unit.set(unit);
|
|
839
|
+
const eventId = q.get('event_id');
|
|
840
|
+
const occurredAt = q.get('occurred_at');
|
|
841
|
+
const event = eventId && occurredAt ? { event_id: eventId, occurred_at: occurredAt, trace_id: q.get('trace_id') ?? undefined } : null;
|
|
842
|
+
return { filters: filters, event: event };
|
|
843
|
+
}
|
|
844
|
+
// ---- ações ------------------------------------------------------------------------------------
|
|
845
|
+
setPeriod(value) {
|
|
846
|
+
if (value === this.period())
|
|
847
|
+
return;
|
|
848
|
+
this.period.set(value);
|
|
849
|
+
this.firstPage();
|
|
850
|
+
void this.reload();
|
|
851
|
+
}
|
|
852
|
+
setUnit(value) {
|
|
853
|
+
if (value === this.unit() || (value !== 'actions' && value !== 'events'))
|
|
854
|
+
return;
|
|
855
|
+
this.unit.set(value);
|
|
856
|
+
this.closeDetail();
|
|
857
|
+
this.firstPage();
|
|
858
|
+
void this.reload();
|
|
859
|
+
}
|
|
860
|
+
setFilters(value) {
|
|
861
|
+
this.filterValue.set(value);
|
|
862
|
+
this.firstPage();
|
|
863
|
+
void this.reload();
|
|
864
|
+
}
|
|
865
|
+
firstPage() {
|
|
866
|
+
this.pageState.update((s) => ({ ...s, page: 1 }));
|
|
867
|
+
}
|
|
868
|
+
clearFilters() {
|
|
869
|
+
this.setFilters({});
|
|
870
|
+
}
|
|
871
|
+
/** Clique num item de ranking ou atalho: vira filtro (soma aos que já estão). */
|
|
872
|
+
filterBy(key, value) {
|
|
873
|
+
if (!value || (Array.isArray(value) && !value.length))
|
|
874
|
+
return;
|
|
875
|
+
this.setFilters({ ...this.filterValue(), [key]: value });
|
|
876
|
+
}
|
|
877
|
+
pickTop(kind, row) {
|
|
878
|
+
if (kind === 'apps') {
|
|
879
|
+
this.filterBy('app', [row.key]);
|
|
880
|
+
}
|
|
881
|
+
else if (kind === 'operations') {
|
|
882
|
+
this.filterBy('operation', row.key.slice(row.key.indexOf('|') + 1));
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
const user = this.tops.users().items.find((u) => (u.user_id ?? u.user_name ?? '') === row.key);
|
|
886
|
+
if (user?.user_name)
|
|
887
|
+
this.filterBy('user', user.user_name);
|
|
888
|
+
else if (user?.user_id)
|
|
889
|
+
this.filterBy('user_id', user.user_id);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
/** KPI "Falhas"/"Negados" clicável: liga/desliga o filtro de resultado. */
|
|
893
|
+
toggleOutcome(outcome, on) {
|
|
894
|
+
const next = { ...this.filterValue() };
|
|
895
|
+
if (on)
|
|
896
|
+
next['outcome'] = outcome;
|
|
897
|
+
else
|
|
898
|
+
delete next['outcome'];
|
|
899
|
+
this.setFilters(next);
|
|
900
|
+
}
|
|
901
|
+
/** Recarrega resumo, rankings e a página atual com a janela recalculada (`now` novo, cursores descartados). */
|
|
902
|
+
async reload() {
|
|
903
|
+
const base = this.base();
|
|
904
|
+
const seq = ++this.seq;
|
|
905
|
+
const pageSeq = ++this.pageSeq;
|
|
906
|
+
this.pager = new AuditCursorPager();
|
|
907
|
+
this.pageLoading.set(false);
|
|
908
|
+
this.pageFailed.set(false);
|
|
909
|
+
this.errorKind.set(null);
|
|
910
|
+
this.errorDetail.set('');
|
|
911
|
+
if (!base)
|
|
912
|
+
return;
|
|
913
|
+
const built = buildAuditQuery(this.period(), this.filterValue(), new Date());
|
|
914
|
+
if (!built.ok) {
|
|
915
|
+
this.query = null;
|
|
916
|
+
this.clearData();
|
|
917
|
+
this.errorKind.set('range');
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
// Por ação, `entry` não se aplica; `unit` faz stats/tops contarem na unidade escolhida.
|
|
921
|
+
const query = { ...built.query, unit: this.unit() };
|
|
922
|
+
if (this.byActions())
|
|
923
|
+
delete query.entry;
|
|
924
|
+
this.query = query;
|
|
925
|
+
this.loading.set(true);
|
|
926
|
+
for (const kind of AUDIT_TOP_KINDS)
|
|
927
|
+
void this.loadTop(kind, 0);
|
|
928
|
+
try {
|
|
929
|
+
const pager = this.pager;
|
|
930
|
+
const { page, size } = this.pageState();
|
|
931
|
+
const [stats, result] = await Promise.all([
|
|
932
|
+
this.service.stats(base, query, { tops: false }),
|
|
933
|
+
pager.page((page - 1) * size, size, this.fetcher(base, query)),
|
|
934
|
+
]);
|
|
935
|
+
if (seq !== this.seq)
|
|
936
|
+
return;
|
|
937
|
+
this.stats.set(stats);
|
|
938
|
+
// Uma troca de página feita durante a recarga (mesma consulta) vence a página desta carga.
|
|
939
|
+
if (pageSeq === this.pageSeq)
|
|
940
|
+
this.showPage(result, size, pager);
|
|
941
|
+
this.lastUpdated.set(new Date());
|
|
942
|
+
}
|
|
943
|
+
catch (err) {
|
|
944
|
+
if (seq !== this.seq)
|
|
945
|
+
return;
|
|
946
|
+
this.clearData();
|
|
947
|
+
this.setError(err);
|
|
948
|
+
}
|
|
949
|
+
finally {
|
|
950
|
+
if (seq === this.seq)
|
|
951
|
+
this.loading.set(false);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
/** Rodapé da tabela mudou de página ou de itens por página: busca só aquela página, mesma consulta. */
|
|
955
|
+
async changePage(state) {
|
|
956
|
+
this.pageState.set(state);
|
|
957
|
+
const query = this.query;
|
|
958
|
+
const base = this.base();
|
|
959
|
+
if (!query || !base)
|
|
960
|
+
return;
|
|
961
|
+
const seq = this.seq;
|
|
962
|
+
const pageSeq = ++this.pageSeq;
|
|
963
|
+
const pager = this.pager;
|
|
964
|
+
const current = () => seq === this.seq && pageSeq === this.pageSeq;
|
|
965
|
+
this.pageLoading.set(true);
|
|
966
|
+
this.pageFailed.set(false);
|
|
967
|
+
try {
|
|
968
|
+
const result = await pager.page((state.page - 1) * state.size, state.size, this.fetcher(base, query));
|
|
969
|
+
if (current())
|
|
970
|
+
this.showPage(result, state.size, pager);
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
if (current())
|
|
974
|
+
this.pageFailed.set(true);
|
|
975
|
+
}
|
|
976
|
+
finally {
|
|
977
|
+
if (current())
|
|
978
|
+
this.pageLoading.set(false);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
/** Card de ranking pediu outra página (mesma consulta da última carga). */
|
|
982
|
+
async loadTop(kind, offset) {
|
|
983
|
+
const base = this.base();
|
|
984
|
+
const query = this.query;
|
|
985
|
+
if (!base || !query)
|
|
986
|
+
return;
|
|
987
|
+
const seq = ++this.topSeq[kind];
|
|
988
|
+
const state = this.tops[kind];
|
|
989
|
+
state.update((s) => ({ ...s, loading: true, failed: false }));
|
|
990
|
+
try {
|
|
991
|
+
const page = await this.service.tops(base, kind, query, { offset, limit: AUDIT_TOP_PAGE_SIZE });
|
|
992
|
+
if (seq !== this.topSeq[kind])
|
|
993
|
+
return;
|
|
994
|
+
const items = page.items ?? [];
|
|
995
|
+
state.update((s) => ({
|
|
996
|
+
items, total: page.total ?? items.length, offset, loading: false, failed: false,
|
|
997
|
+
max: offset === 0 ? (items[0]?.total ?? 0) : s.max,
|
|
998
|
+
}));
|
|
999
|
+
}
|
|
1000
|
+
catch {
|
|
1001
|
+
if (seq === this.topSeq[kind])
|
|
1002
|
+
state.update((s) => ({ ...s, loading: false, failed: true }));
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
fetcher(base, query) {
|
|
1006
|
+
return query.unit === 'events'
|
|
1007
|
+
? (cursor, limit) => this.service.events(base, query, { limit, cursor })
|
|
1008
|
+
: (cursor, limit) => this.service.actions(base, query, { limit, cursor });
|
|
1009
|
+
}
|
|
1010
|
+
showPage(result, size, pager) {
|
|
1011
|
+
this.events.set(result.items);
|
|
1012
|
+
this.loaded.set(result);
|
|
1013
|
+
this.listEnd.set(pager.end);
|
|
1014
|
+
// Pedido além do fim (o total encolheu): o pager devolveu a última página que existe.
|
|
1015
|
+
const page = Math.floor(result.offset / size) + 1;
|
|
1016
|
+
if (page !== this.pageState().page || size !== this.pageState().size)
|
|
1017
|
+
this.pageState.set({ page, size });
|
|
1018
|
+
}
|
|
1019
|
+
clearData() {
|
|
1020
|
+
this.stats.set(null);
|
|
1021
|
+
this.events.set([]);
|
|
1022
|
+
this.loaded.set(null);
|
|
1023
|
+
this.listEnd.set(null);
|
|
1024
|
+
for (const kind of AUDIT_TOP_KINDS) {
|
|
1025
|
+
this.topSeq[kind]++;
|
|
1026
|
+
this.tops[kind].set(EMPTY_TOP);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
/** Clique numa linha da tabela: por ação abre o raiz e já carrega os spans; por evento, só o evento. */
|
|
1030
|
+
openRow(row) {
|
|
1031
|
+
if (this.byActions())
|
|
1032
|
+
void this.openAction(row);
|
|
1033
|
+
else
|
|
1034
|
+
void this.openEvent(row);
|
|
1035
|
+
}
|
|
1036
|
+
/** Abre uma ação: o detalhe do evento raiz + a linha do tempo dos spans (trace) carregada junto. */
|
|
1037
|
+
async openAction(action) {
|
|
1038
|
+
const opening = this.openEvent(action);
|
|
1039
|
+
this.selectedAction.set(action);
|
|
1040
|
+
if (action.trace_id)
|
|
1041
|
+
void this.showTrace();
|
|
1042
|
+
await opening;
|
|
1043
|
+
}
|
|
1044
|
+
/** Abre o detalhe com o resumo da linha e completa com o evento inteiro. Da cadeia, mantém a cadeia. */
|
|
1045
|
+
async openEvent(event, keepTrace = false) {
|
|
1046
|
+
const base = this.base();
|
|
1047
|
+
if (!base)
|
|
1048
|
+
return;
|
|
1049
|
+
const seq = ++this.detailSeq;
|
|
1050
|
+
const sameTrace = keepTrace && !!event.trace_id && this.trace() !== null && this.selected()?.trace_id === event.trace_id;
|
|
1051
|
+
if (!sameTrace)
|
|
1052
|
+
this.selectedAction.set(null);
|
|
1053
|
+
this.selected.set(event);
|
|
1054
|
+
this.detailOpen.set(true);
|
|
1055
|
+
this.detailFailed.set(false);
|
|
1056
|
+
if (!sameTrace) {
|
|
1057
|
+
this.trace.set(null);
|
|
1058
|
+
this.traceFailed.set(false);
|
|
1059
|
+
}
|
|
1060
|
+
this.detailLoading.set(true);
|
|
1061
|
+
try {
|
|
1062
|
+
const detail = await this.service.event(base, event.event_id, event.occurred_at);
|
|
1063
|
+
if (seq === this.detailSeq)
|
|
1064
|
+
this.selected.set({ ...event, ...detail });
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
if (seq === this.detailSeq)
|
|
1068
|
+
this.detailFailed.set(true);
|
|
1069
|
+
}
|
|
1070
|
+
finally {
|
|
1071
|
+
if (seq === this.detailSeq)
|
|
1072
|
+
this.detailLoading.set(false);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
async showTrace() {
|
|
1076
|
+
const base = this.base();
|
|
1077
|
+
const origin = this.selected();
|
|
1078
|
+
const traceId = origin?.trace_id;
|
|
1079
|
+
if (!base || !traceId)
|
|
1080
|
+
return;
|
|
1081
|
+
const seq = this.detailSeq;
|
|
1082
|
+
this.traceLoading.set(true);
|
|
1083
|
+
this.traceFailed.set(false);
|
|
1084
|
+
try {
|
|
1085
|
+
const trace = await this.service.trace(base, traceId, origin.occurred_at);
|
|
1086
|
+
if (seq === this.detailSeq)
|
|
1087
|
+
this.trace.set({ items: trace.items ?? [], hidden_count: trace.hidden_count ?? 0 });
|
|
1088
|
+
}
|
|
1089
|
+
catch {
|
|
1090
|
+
if (seq === this.detailSeq)
|
|
1091
|
+
this.traceFailed.set(true);
|
|
1092
|
+
}
|
|
1093
|
+
finally {
|
|
1094
|
+
if (seq === this.detailSeq)
|
|
1095
|
+
this.traceLoading.set(false);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
/** "Filtrar a tabela por este trace" (do detalhe): fecha o detalhe e aplica o filtro. */
|
|
1099
|
+
filterByTrace(traceId) {
|
|
1100
|
+
if (!traceId)
|
|
1101
|
+
return;
|
|
1102
|
+
this.closeDetail();
|
|
1103
|
+
this.filterBy('trace_id', traceId);
|
|
1104
|
+
}
|
|
1105
|
+
onDetailOpenChange(open) {
|
|
1106
|
+
if (!open)
|
|
1107
|
+
this.closeDetail();
|
|
1108
|
+
}
|
|
1109
|
+
closeDetail() {
|
|
1110
|
+
this.detailSeq++;
|
|
1111
|
+
this.detailOpen.set(false);
|
|
1112
|
+
this.detailLoading.set(false);
|
|
1113
|
+
this.selectedAction.set(null);
|
|
1114
|
+
this.trace.set(null);
|
|
1115
|
+
this.traceLoading.set(false);
|
|
1116
|
+
this.traceFailed.set(false);
|
|
1117
|
+
}
|
|
1118
|
+
async loadApps(url) {
|
|
1119
|
+
if (!url) {
|
|
1120
|
+
this.apps.set([]);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
try {
|
|
1124
|
+
const res = await this.service.apps(url);
|
|
1125
|
+
if (url !== this.appsUrl())
|
|
1126
|
+
return;
|
|
1127
|
+
const apps = (res.apps ?? []).map((a) => ({ id: a.id, name: a.name }));
|
|
1128
|
+
this.apps.set(apps.sort((a, b) => a.name.localeCompare(b.name, this.locale)));
|
|
1129
|
+
}
|
|
1130
|
+
catch {
|
|
1131
|
+
// Sem a lista (ex.: sem permissão de listar apps), o filtro de aplicação some; o resto não depende dela.
|
|
1132
|
+
if (url === this.appsUrl())
|
|
1133
|
+
this.apps.set([]);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
setError(err) {
|
|
1137
|
+
const { status, code, detail } = auditErrorCode(err);
|
|
1138
|
+
if (code === 'range_too_large')
|
|
1139
|
+
this.errorKind.set('range');
|
|
1140
|
+
else if (status === 403)
|
|
1141
|
+
this.errorKind.set('forbidden');
|
|
1142
|
+
else if (status === 400 && (code === 'invalid_filter' || code === 'invalid_cursor')) {
|
|
1143
|
+
this.errorKind.set('invalid');
|
|
1144
|
+
this.errorDetail.set(detail);
|
|
1145
|
+
}
|
|
1146
|
+
else
|
|
1147
|
+
this.errorKind.set('generic');
|
|
1148
|
+
}
|
|
1149
|
+
// ---- formatação -------------------------------------------------------------------------------
|
|
1150
|
+
number(n) {
|
|
1151
|
+
return new Intl.NumberFormat(this.locale).format(n ?? 0);
|
|
1152
|
+
}
|
|
1153
|
+
plural(message, count) {
|
|
1154
|
+
return pluralMessage(message, count, this.locale);
|
|
1155
|
+
}
|
|
1156
|
+
share(part) {
|
|
1157
|
+
const total = this.stats()?.total ?? 0;
|
|
1158
|
+
const ratio = total > 0 ? part / total : 0;
|
|
1159
|
+
return formatMessage(this.text.kpiShare, {
|
|
1160
|
+
percent: new Intl.NumberFormat(this.locale, { style: 'percent', maximumFractionDigits: 1 }).format(ratio),
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
dateTime(iso) {
|
|
1164
|
+
if (!iso)
|
|
1165
|
+
return NO_VALUE;
|
|
1166
|
+
const d = parseAuditDate(iso);
|
|
1167
|
+
return d ? new Intl.DateTimeFormat(this.locale, { dateStyle: 'short', timeStyle: 'medium' }).format(d) : iso;
|
|
1168
|
+
}
|
|
1169
|
+
time(iso) {
|
|
1170
|
+
const d = parseAuditDate(iso);
|
|
1171
|
+
return d ? new Intl.DateTimeFormat(this.locale, { timeStyle: 'medium' }).format(d) : iso;
|
|
1172
|
+
}
|
|
1173
|
+
clock(d) {
|
|
1174
|
+
return new Intl.DateTimeFormat(this.locale, { timeStyle: 'medium' }).format(d);
|
|
1175
|
+
}
|
|
1176
|
+
/** Rótulo do intervalo da linha do tempo: hora no dia, dia+hora até 3 dias, só o dia acima. */
|
|
1177
|
+
bucketTime(iso) {
|
|
1178
|
+
const d = parseAuditDate(iso);
|
|
1179
|
+
if (!d)
|
|
1180
|
+
return iso;
|
|
1181
|
+
const buckets = this.stats()?.timeline ?? [];
|
|
1182
|
+
const edge = (i) => parseAuditDate(buckets[i].bucket)?.getTime() ?? 0;
|
|
1183
|
+
const span = buckets.length > 1 ? edge(buckets.length - 1) - edge(0) : 0;
|
|
1184
|
+
const day = 24 * 60 * 60 * 1000;
|
|
1185
|
+
const opts = span <= day ? { hour: '2-digit', minute: '2-digit' }
|
|
1186
|
+
: span <= 3 * day ? { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }
|
|
1187
|
+
: { day: '2-digit', month: '2-digit' };
|
|
1188
|
+
return new Intl.DateTimeFormat(this.locale, opts).format(d);
|
|
1189
|
+
}
|
|
1190
|
+
duration(ms) {
|
|
1191
|
+
return ms == null ? NO_VALUE : formatMessage(this.text.durationMs, { ms: this.number(Math.round(ms)) });
|
|
1192
|
+
}
|
|
1193
|
+
userName(e) {
|
|
1194
|
+
return e.user_name || e.user_id || this.text.anonymous;
|
|
1195
|
+
}
|
|
1196
|
+
/** Rótulo acessível de uma linha da linha do tempo dos spans. */
|
|
1197
|
+
spanAria(row) {
|
|
1198
|
+
return formatMessage(this.text.spanRow, {
|
|
1199
|
+
app: row.event.app_name ?? NO_VALUE,
|
|
1200
|
+
operation: this.operationName(row.event),
|
|
1201
|
+
outcome: this.outcomeLabel(row.event.outcome),
|
|
1202
|
+
offset: this.duration(row.offsetMs),
|
|
1203
|
+
duration: this.duration(row.event.duration_ms),
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
/** Dica da célula "Spans": aplicações envolvidas (+ spans de fora do escopo). */
|
|
1207
|
+
spansHint(a) {
|
|
1208
|
+
const parts = [];
|
|
1209
|
+
if (a.apps?.length)
|
|
1210
|
+
parts.push(formatMessage(this.text.spansApps, { apps: a.apps.join(', ') }));
|
|
1211
|
+
if (a.hidden_count)
|
|
1212
|
+
parts.push(this.plural(this.text.spansHidden, a.hidden_count));
|
|
1213
|
+
return parts.join(' · ');
|
|
1214
|
+
}
|
|
1215
|
+
/** Nome da operação = o identificador (a descrição é complemento: dica e detalhe). */
|
|
1216
|
+
operationName(e) {
|
|
1217
|
+
return e.operation_identifier || e.operation_label || NO_VALUE;
|
|
1218
|
+
}
|
|
1219
|
+
methodLabel(method) {
|
|
1220
|
+
const t = this.text;
|
|
1221
|
+
switch (method) {
|
|
1222
|
+
case 'read': return t.methodRead;
|
|
1223
|
+
case 'write': return t.methodWrite;
|
|
1224
|
+
case 'delete': return t.methodDelete;
|
|
1225
|
+
case 'stream': return t.methodStream;
|
|
1226
|
+
default: return method || NO_VALUE;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
levelLabel(level) {
|
|
1230
|
+
const t = this.text;
|
|
1231
|
+
switch (level) {
|
|
1232
|
+
case 'public': return t.levelPublic;
|
|
1233
|
+
case 'private': return t.levelPrivate;
|
|
1234
|
+
case 'protected': return t.levelProtected;
|
|
1235
|
+
case 'optional': return t.levelOptional;
|
|
1236
|
+
default: return level || NO_VALUE;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
permissionLabel(result) {
|
|
1240
|
+
const t = this.text;
|
|
1241
|
+
switch (result) {
|
|
1242
|
+
case 'allowed': return t.permissionAllowed;
|
|
1243
|
+
case 'forbidden': return t.permissionForbidden;
|
|
1244
|
+
case 'unauthorized': return t.permissionUnauthorized;
|
|
1245
|
+
case 'public': return t.permissionPublic;
|
|
1246
|
+
default: return result || NO_VALUE;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
actorLabel(actor) {
|
|
1250
|
+
const t = this.text;
|
|
1251
|
+
switch (actor) {
|
|
1252
|
+
case 'user': return t.actorUser;
|
|
1253
|
+
case 'app_token': return t.actorAppToken;
|
|
1254
|
+
case 'api_token': return t.actorApiToken;
|
|
1255
|
+
case 'public': return t.actorPublic;
|
|
1256
|
+
case 'unauthenticated': return t.actorUnauthenticated;
|
|
1257
|
+
default: return actor || NO_VALUE;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
outcomeLabel(outcome) {
|
|
1261
|
+
const t = this.text;
|
|
1262
|
+
switch (outcome) {
|
|
1263
|
+
case 'success': return t.outcomeSuccess;
|
|
1264
|
+
case 'failure': return t.outcomeFailure;
|
|
1265
|
+
case 'denied': return t.outcomeDenied;
|
|
1266
|
+
default: return outcome || NO_VALUE;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
outcomeColor(outcome) {
|
|
1270
|
+
return outcome === 'success' ? 'success' : outcome === 'failure' ? 'danger' : outcome === 'denied' ? 'warning' : 'neutral';
|
|
1271
|
+
}
|
|
1272
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineAuditComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1273
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.24", type: SpineAuditComponent, isStandalone: true, selector: "spine-audit", inputs: { scope: { classPropertyName: "scope", publicName: "scope", isSignal: true, isRequired: true, transformFunction: null }, clientId: { classPropertyName: "clientId", publicName: "clientId", isSignal: true, isRequired: false, transformFunction: null }, adminApi: { classPropertyName: "adminApi", publicName: "adminApi", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<div class=\"audit\" [attr.aria-busy]=\"loading() || pageLoading()\">\n <!-- Per\u00EDodo (escolha r\u00E1pida; as datas da faixa de filtros vencem) + atualizar. -->\n <div class=\"audit__toolbar\">\n <!-- Unidade: por a\u00E7\u00E3o (uma linha por trace, padr\u00E3o) ou por evento (cada registro). -->\n <hm-segmented-control\n [label]=\"text.unitLabel\"\n [options]=\"unitOptions\"\n [value]=\"unit()\"\n (valueChange)=\"setUnit($event)\"\n size=\"small\"\n emphasis=\"action\"\n />\n <hm-segmented-control\n [label]=\"text.period\"\n [options]=\"periodOptions\"\n [value]=\"period()\"\n (valueChange)=\"setPeriod($event)\"\n size=\"small\"\n emphasis=\"action\"\n />\n @if (customDates()) {\n <span class=\"muted audit__period-note\">{{ text.periodFromFilters }}</span>\n }\n <span class=\"audit__toolbar-end\">\n @if (lastUpdated(); as at) {\n <span class=\"muted audit__updated\" aria-live=\"polite\">{{ fmt(text.updatedAt, { time: clock(at) }) }}</span>\n }\n <hm-switch [label]=\"text.autoRefresh\" [value]=\"autoRefresh()\" (valueChange)=\"autoRefresh.set($event)\" />\n <hm-button emphasis=\"medium\" size=\"small\" icon=\"refresh\" [loading]=\"loading()\" (clicked)=\"reload()\">{{ text.refresh }}</hm-button>\n </span>\n </div>\n\n <hm-filter-bar\n class=\"audit__filters\"\n [label]=\"text.filters\"\n [filters]=\"filterFields()\"\n [value]=\"filterValue()\"\n (valueChange)=\"setFilters($event)\"\n [savedFilters]=\"true\"\n [storageKey]=\"storageKey()\"\n />\n\n @if (errorKind()) {\n <hm-state-screen\n class=\"audit__state\"\n [situation]=\"errorSituation()\"\n [heading]=\"text.errorTitle\"\n [description]=\"errorText()\"\n [action]=\"errorKind() === 'generic' ? text.retry : (hasFilters() ? text.clearFilters : '')\"\n actionIcon=\"refresh\"\n (actionTriggered)=\"errorKind() === 'generic' ? reload() : clearFilters()\"\n />\n } @else if (empty()) {\n <hm-state-screen\n class=\"audit__state\"\n situation=\"empty\"\n [heading]=\"unitText().emptyTitle\"\n [description]=\"text.emptyText\"\n [action]=\"hasFilters() ? text.clearFilters : ''\"\n (actionTriggered)=\"clearFilters()\"\n />\n } @else {\n <!-- KPIs: falhas/negados s\u00E3o filtros (altern\u00E2ncia). -->\n <section class=\"audit__kpis\" [attr.aria-label]=\"text.kpis\">\n <hm-stat-card\n icon=\"list\"\n [label]=\"unitText().kpiTotal\"\n [value]=\"number(stats()?.total ?? 0)\"\n [description]=\"unitText().kpiTotalDescription\"\n [loading]=\"loading() && !stats()\"\n />\n <hm-stat-card\n icon=\"error-standard\"\n [label]=\"text.kpiFailures\"\n [value]=\"number(stats()?.by_outcome?.failure ?? 0)\"\n [intent]=\"(stats()?.by_outcome?.failure ?? 0) > 0 ? 'danger' : 'neutral'\"\n [description]=\"fmt(text.kpiFilterHint, { share: share(stats()?.by_outcome?.failure ?? 0) })\"\n [loading]=\"loading() && !stats()\"\n selectable\n [selected]=\"selectedOutcome() === 'failure'\"\n (selectedChange)=\"toggleOutcome('failure', $event)\"\n />\n <hm-stat-card\n icon=\"ban\"\n [label]=\"text.kpiDenied\"\n [value]=\"number(stats()?.by_outcome?.denied ?? 0)\"\n [intent]=\"(stats()?.by_outcome?.denied ?? 0) > 0 ? 'warning' : 'neutral'\"\n [description]=\"fmt(text.kpiFilterHint, { share: share(stats()?.by_outcome?.denied ?? 0) })\"\n [loading]=\"loading() && !stats()\"\n selectable\n [selected]=\"selectedOutcome() === 'denied'\"\n (selectedChange)=\"toggleOutcome('denied', $event)\"\n />\n </section>\n\n <!-- Linha do tempo: barras empilhadas em CSS (sucesso embaixo, falha, negado). Cada barra tem\n o resumo em texto (title + aria-label); o gr\u00E1fico n\u00E3o \u00E9 a \u00FAnica forma de ler o n\u00FAmero. -->\n <section class=\"card card-block audit__timeline\" aria-labelledby=\"audit-timeline-title\">\n <h3 class=\"audit__section-title\" id=\"audit-timeline-title\">{{ text.timeline }}</h3>\n @if (hasTimeline()) {\n <ol class=\"timeline\" [attr.aria-label]=\"fmt(unitText().timelineSummary, { from: timelineEdges()?.from ?? '', to: timelineEdges()?.to ?? '' })\">\n @for (bar of timelineBars(); track bar.key) {\n <li class=\"timeline__bar\" [attr.title]=\"bar.summary\" [attr.aria-label]=\"bar.summary\">\n <span class=\"timeline__seg timeline__seg--denied\" [style.height.%]=\"bar.denied\"></span>\n <span class=\"timeline__seg timeline__seg--fail\" [style.height.%]=\"bar.fail\"></span>\n <span class=\"timeline__seg timeline__seg--ok\" [style.height.%]=\"bar.ok\"></span>\n </li>\n }\n </ol>\n <div class=\"timeline__axis\" aria-hidden=\"true\">\n <span>{{ timelineEdges()?.from }}</span>\n <span>{{ timelineEdges()?.to }}</span>\n </div>\n <ul class=\"timeline__legend\">\n <li><span class=\"swatch swatch--ok\"></span>{{ text.outcomeSuccess }}</li>\n <li><span class=\"swatch swatch--fail\"></span>{{ text.outcomeFailure }}</li>\n <li><span class=\"swatch swatch--denied\"></span>{{ text.outcomeDenied }}</li>\n </ul>\n } @else {\n <p class=\"muted\">{{ loading() ? text.loading : unitText().timelineEmpty }}</p>\n }\n </section>\n\n <!-- Rankings paginados no servidor (tops/:kind). Clicar num item vira filtro. -->\n <div class=\"audit__tops\" [class.audit__tops--three]=\"multiApp()\">\n @if (multiApp()) {\n <spine-audit-top-card\n class=\"audit__top audit__top--apps\"\n [heading]=\"text.topApps\"\n [rows]=\"topAppRows()\"\n [total]=\"tops.apps().total\"\n [offset]=\"tops.apps().offset\"\n [max]=\"tops.apps().max\"\n [pageSize]=\"topPageSize\"\n [loading]=\"tops.apps().loading\"\n [failed]=\"tops.apps().failed\"\n (offsetChange)=\"loadTop('apps', $event)\"\n (picked)=\"pickTop('apps', $event)\"\n />\n }\n <spine-audit-top-card\n class=\"audit__top audit__top--operations\"\n [heading]=\"text.topOperations\"\n [rows]=\"topOperationRows()\"\n [total]=\"tops.operations().total\"\n [offset]=\"tops.operations().offset\"\n [max]=\"tops.operations().max\"\n [pageSize]=\"topPageSize\"\n [loading]=\"tops.operations().loading\"\n [failed]=\"tops.operations().failed\"\n (offsetChange)=\"loadTop('operations', $event)\"\n (picked)=\"pickTop('operations', $event)\"\n />\n <spine-audit-top-card\n class=\"audit__top audit__top--users\"\n [heading]=\"text.topUsers\"\n [rows]=\"topUserRows()\"\n [total]=\"tops.users().total\"\n [offset]=\"tops.users().offset\"\n [max]=\"tops.users().max\"\n [pageSize]=\"topPageSize\"\n [loading]=\"tops.users().loading\"\n [failed]=\"tops.users().failed\"\n (offsetChange)=\"loadTop('users', $event)\"\n (picked)=\"pickTop('users', $event)\"\n />\n </div>\n\n <!-- Tabela: pagina\u00E7\u00E3o no servidor. A grade n\u00E3o corta nada ([paginated]=false): mostra a p\u00E1gina que veio\n (limit = itens por p\u00E1gina) e o rodap\u00E9 usa o total do resumo (mesma consulta). Esqueleto s\u00F3 na 1\u00AA\n carga; nas seguintes as linhas ficam e o indicador aparece por cima (sem piscar). -->\n <div class=\"audit__grid\" [class.audit__grid--busy]=\"gridBusy()\" [attr.aria-busy]=\"gridBusy()\">\n @if (gridBusy()) {\n <hm-progress class=\"audit__grid-progress\" indeterminate [label]=\"text.updating\" labelHidden />\n }\n <hm-data-grid\n [caption]=\"unitText().caption\"\n [columns]=\"columns()\"\n [rows]=\"events()\"\n [rowId]=\"rowId\"\n [loading]=\"gridSkeleton()\"\n [filterable]=\"false\"\n [paginated]=\"false\"\n [totalItems]=\"total()\"\n [pageState]=\"pageState()\"\n (pageStateChange)=\"changePage($event)\"\n [pageSizes]=\"pageSizes\"\n [itemsLabel]=\"unitText().pageItems\"\n [pageSizeLabel]=\"text.pageSize\"\n remoteSort\n rowClickable\n (rowClicked)=\"openRow($event)\"\n >\n <ng-template hmCell=\"operation\" let-item>\n <span class=\"audit__op\" [attr.title]=\"item.operation_label || null\">{{ operationName(item) }}</span>\n </ng-template>\n <ng-template hmCell=\"spans\" let-item>\n <span class=\"audit__spans\" [attr.title]=\"spansHint(item) || null\">\n {{ number(item.span_count ?? 1) }}\n @if (item.orphan) {\n <hm-tag class=\"audit__orphan\" [name]=\"text.orphanTag\" color=\"neutral\" />\n }\n </span>\n </ng-template>\n <ng-template hmCell=\"outcome\" let-item>\n <hm-tag [name]=\"outcomeLabel(item.outcome)\" [color]=\"outcomeColor(item.outcome)\" />\n </ng-template>\n </hm-data-grid>\n </div>\n\n @if (pageFailed()) {\n <hm-alert class=\"audit__page-error\" variant=\"error\" size=\"small\">{{ unitText().pageError }}</hm-alert>\n }\n }\n</div>\n\n<!-- Detalhe do evento + a cadeia (trace). -->\n<hm-drawer [open]=\"detailOpen()\" (openChange)=\"onDetailOpenChange($event)\" [heading]=\"showingAction() ? text.actionDetailHeading : text.detailHeading\">\n @if (selected(); as ev) {\n <div class=\"detail\">\n <div class=\"detail__head\">\n <strong class=\"detail__title\">{{ operationName(ev) }}</strong>\n <hm-tag [name]=\"outcomeLabel(headOutcome())\" [color]=\"outcomeColor(headOutcome())\" />\n </div>\n @if (showingAction() && selectedAction()?.orphan) {\n <hm-alert class=\"detail__orphan\" variant=\"info\" size=\"small\">{{ text.orphanNote }}</hm-alert>\n }\n @if (ev.operation_label && ev.operation_identifier) {\n <p class=\"muted detail__description\">{{ ev.operation_label }}</p>\n }\n\n @if (detailLoading()) {\n <p class=\"muted\" role=\"status\">{{ text.detailLoading }}</p>\n }\n @if (detailFailed()) {\n <hm-alert variant=\"warning\" size=\"small\">{{ text.detailError }}</hm-alert>\n }\n\n <dl class=\"detail__list\">\n @for (field of detailFields(); track field.label) {\n <div class=\"detail__row\">\n <dt>{{ field.label }}</dt>\n <dd>\n @if (field.code) { <code>{{ field.value }}</code> } @else { {{ field.value }} }\n </dd>\n </div>\n }\n </dl>\n\n @if (detailsJson()) {\n <h3 class=\"audit__section-title\">{{ text.fieldDetails }}</h3>\n <pre class=\"detail__json\">{{ detailsJson() }}</pre>\n }\n\n <!-- Por a\u00E7\u00E3o, os spans sobem para logo abaixo do cabe\u00E7alho (order no CSS); por evento, ficam no fim. -->\n @if (ev.trace_id) {\n <div class=\"detail__chain\" [class.detail__chain--first]=\"showingAction()\">\n <div class=\"detail__actions\">\n @if (!trace() && !traceLoading()) {\n <hm-button emphasis=\"medium\" size=\"small\" icon=\"tree-view\" (clicked)=\"showTrace()\">{{ text.viewTrace }}</hm-button>\n }\n <hm-button emphasis=\"minimal\" size=\"small\" icon=\"filter\" (clicked)=\"filterByTrace(ev.trace_id)\">{{ text.filterByTrace }}</hm-button>\n </div>\n @if (traceLoading()) {\n <p class=\"muted\" role=\"status\">{{ text.traceLoading }}</p>\n }\n @if (traceFailed()) {\n <hm-alert variant=\"error\" size=\"small\">{{ text.traceError }}</hm-alert>\n }\n @if (trace(); as tr) {\n <section class=\"trace\" aria-labelledby=\"audit-trace-title\">\n <h3 class=\"audit__section-title\" id=\"audit-trace-title\">{{ showingAction() ? text.spansHeading : text.traceHeading }}</h3>\n @if (tr.hidden_count) {\n <hm-alert class=\"trace__hidden\" variant=\"info\" size=\"small\">{{ plural(text.traceHidden, tr.hidden_count) }}</hm-alert>\n }\n @if (!tr.items.length) {\n <p class=\"muted\">{{ text.traceEmpty }}</p>\n } @else if (spanTimeline(); as tl) {\n <p class=\"muted trace__summary\">{{ fmt(text.spansSummary, { count: number(tl.rows.length), duration: duration(tl.totalMs) }) }}</p>\n <!-- Linha do tempo: cada span \u00E9 uma linha (recuo = profundidade pela cadeia parent_span_id) com a\n barra posicionada no tempo, proporcional \u00E0 dura\u00E7\u00E3o. O texto da linha (e o aria-label) traz\n tudo o que a barra mostra. -->\n <ol class=\"spans\">\n @for (row of tl.rows; track row.event.event_id) {\n <li class=\"spans__item\" [class.spans__item--current]=\"row.event.event_id === ev.event_id\">\n <button type=\"button\" class=\"spans__link\"\n [attr.aria-current]=\"row.event.event_id === ev.event_id ? 'true' : null\"\n [attr.aria-label]=\"spanAria(row)\"\n (click)=\"openEvent(row.event, true)\">\n <span class=\"spans__head\" [style.padding-inline-start.rem]=\"row.depth * 1\">\n <span class=\"spans__app\">{{ row.event.app_name ?? '' }}</span>\n <code class=\"spans__op\">{{ operationName(row.event) }}</code>\n </span>\n <hm-tag class=\"spans__tag\" [name]=\"outcomeLabel(row.event.outcome)\" [color]=\"outcomeColor(row.event.outcome)\" />\n <span class=\"spans__dur\">{{ duration(row.event.duration_ms) }}</span>\n <span class=\"spans__track\" aria-hidden=\"true\">\n <span class=\"spans__bar spans__bar--{{ row.event.outcome }}\" [style.left.%]=\"row.left\" [style.width.%]=\"row.width\"></span>\n </span>\n </button>\n </li>\n }\n </ol>\n }\n </section>\n }\n </div>\n }\n </div>\n }\n</hm-drawer>\n", styles: [".card{border:1px solid var(--hm-border);border-radius:var(--hm-radius-control);background:var(--hm-surface)}.card-block{padding:var(--hm-space-sm, 8px)}.muted{color:var(--hm-muted)}:host{display:block}.audit{display:flex;flex-direction:column;gap:var(--hm-space-md, 16px)}.audit__toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:var(--hm-space-sm, 8px)}.audit__period-note{font-size:13px}.audit__toolbar-end{display:inline-flex;align-items:center;flex-wrap:wrap;gap:var(--hm-space-sm, 8px) var(--hm-space-md, 16px);margin-inline-start:auto}.audit__updated{font-size:12px;font-variant-numeric:tabular-nums}.audit__state{min-block-size:320px}.audit__section-title{margin:0 0 var(--hm-space-sm, 8px);font-size:14px;font-weight:600;color:var(--hm-text)}.audit__kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--hm-space-sm, 8px);align-items:stretch}.timeline{display:flex;align-items:flex-end;gap:2px;block-size:120px;margin:0;padding:0;list-style:none;border-block-end:1px solid var(--hm-border)}.timeline__bar{flex:1 1 0;min-inline-size:2px;block-size:100%;display:flex;flex-direction:column;justify-content:flex-end;border-radius:2px 2px 0 0;overflow:hidden}.timeline__bar:hover{background:color-mix(in srgb,var(--hm-text) 6%,transparent)}.timeline__seg{display:block;inline-size:100%}.timeline__seg--ok,.swatch--ok{background:color-mix(in srgb,var(--hm-action) 55%,transparent)}.timeline__seg--fail,.swatch--fail{background:var(--hm-danger)}.timeline__seg--denied,.swatch--denied{background:var(--hm-warning, #c98a00)}.timeline__axis{display:flex;justify-content:space-between;margin-block-start:4px;font-size:12px;color:var(--hm-muted)}.timeline__legend{display:flex;flex-wrap:wrap;gap:var(--hm-space-sm, 8px) var(--hm-space-md, 16px);margin:var(--hm-space-sm, 8px) 0 0;padding:0;list-style:none;font-size:12px;color:var(--hm-muted)}.timeline__legend li{display:inline-flex;align-items:center;gap:6px}.swatch{display:inline-block;inline-size:10px;block-size:10px;border-radius:2px}.audit__tops{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:var(--hm-space-sm, 8px);align-items:stretch}.audit__grid{position:relative;display:block}.audit__grid-progress{position:absolute;inset-inline:0;inset-block-start:0;z-index:2;pointer-events:none}.audit__grid--busy hm-data-grid{opacity:.6;transition:opacity .12s ease}.audit__op{font-family:var(--hm-font-mono, ui-monospace, monospace);font-size:12px}.audit__page-error{display:block}@media(prefers-reduced-motion:reduce){.audit__grid--busy hm-data-grid{transition:none}}.detail{display:flex;flex-direction:column;gap:var(--hm-space-sm, 8px)}.detail__head{display:flex;align-items:center;gap:var(--hm-space-sm, 8px);flex-wrap:wrap}.detail__title{font-size:16px}.detail__list{display:grid;grid-template-columns:minmax(8rem,max-content) minmax(0,1fr);gap:6px var(--hm-space-md, 16px);margin:0}.detail__row{display:contents}.detail__row dt{color:var(--hm-muted);font-size:13px}.detail__row dd{margin:0;font-size:13px;overflow-wrap:anywhere}.detail__json{margin:0;padding:var(--hm-space-sm, 8px);max-block-size:280px;overflow:auto;border:1px solid var(--hm-border);border-radius:var(--hm-radius-control);background:color-mix(in srgb,var(--hm-text) 4%,transparent);font-size:12px}.detail__description{margin:0;font-size:13px}.detail__actions{display:flex;flex-wrap:wrap;gap:var(--hm-space-sm, 8px)}.trace{margin-block-start:var(--hm-space-sm, 8px)}.trace__hidden{margin-block-end:var(--hm-space-sm, 8px)}.detail__head,.detail__description,.detail__orphan{order:-3}.detail__chain{display:flex;flex-direction:column;gap:var(--hm-space-sm, 8px)}.detail__chain--first{order:-2}.trace__summary{margin:0 0 var(--hm-space-xs, 4px);font-size:13px}.spans{margin:0;padding:0;list-style:none;border-block-start:1px solid var(--hm-border)}.spans__item{border-block-end:1px solid var(--hm-border)}.spans__item--current .spans__link{background:color-mix(in srgb,var(--hm-action) 10%,transparent);box-shadow:inset 2px 0 0 var(--hm-action)}.spans__link{display:grid;grid-template-columns:minmax(0,1fr) auto auto;grid-template-areas:\"head tag dur\" \"track track track\";align-items:center;gap:4px var(--hm-space-sm, 8px);inline-size:100%;padding:6px 8px;border:0;background:transparent;color:var(--hm-text);font:inherit;font-size:13px;text-align:start;cursor:pointer}.spans__link:hover{background:color-mix(in srgb,var(--hm-action) 6%,transparent)}.spans__link:focus-visible{outline:2px solid var(--hm-action);outline-offset:-2px}.spans__head{grid-area:head;display:flex;flex-direction:column;min-inline-size:0}.spans__app{color:var(--hm-muted);font-size:12px}.spans__op{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--hm-font-mono, ui-monospace, monospace);font-size:12px}.spans__tag{grid-area:tag}.spans__dur{grid-area:dur;min-inline-size:4.5rem;text-align:end;font-variant-numeric:tabular-nums;color:var(--hm-muted)}.spans__track{grid-area:track;position:relative;block-size:6px;border-radius:3px;background:color-mix(in srgb,var(--hm-text) 6%,transparent)}.spans__bar{position:absolute;inset-block:0;border-radius:3px;background:color-mix(in srgb,var(--hm-action) 70%,transparent)}.spans__bar--failure{background:var(--hm-danger)}.spans__bar--denied{background:var(--hm-warning, #c98a00)}.audit__spans{display:inline-flex;align-items:center;gap:6px;font-variant-numeric:tabular-nums}\n"], dependencies: [{ kind: "component", type: AlertComponent, selector: "hm-alert", inputs: ["variant", "appearance", "heading", "closable", "size", "showIcon", "link", "linkLabel", "linkTarget", "dismissKey", "duration"], outputs: ["closed"] }, { kind: "component", type: ButtonComponent, selector: "hm-button", inputs: ["emphasis", "intent", "size", "type", "icon", "iconDirection", "iconEnd", "disabled", "loading", "busy", "loadingState", "block", "link", "newTab", "caret", "inMenu", "toggle", "pressed"], outputs: ["loadingStateChange", "pressedChange", "clicked"] }, { kind: "directive", type: DataGridCellDirective, selector: "ng-template[hmCell]", inputs: ["hmCell"] }, { kind: "component", type: DataGridComponent, selector: "hm-data-grid", inputs: ["columns", "rows", "rowId", "caption", "rowActions", "loading", "error", "emptyMessage", "noResultsMessage", "filterable", "paginated", "pageState", "pagination", "totalItems", "sort", "selectable", "selectionMode", "selection", "rowSelectable", "rowSelectableReason", "rowDetailDisabled", "rowDetailHidden", "virtual", "rowHeight", "pinnedColumns", "columnState", "pageSizes", "fillHeight", "maxHeight", "bordered", "rowState", "rowClickable", "remoteSearch", "remoteSort", "itemsLabel", "pageSizeLabel", "exportable", "exportFileName", "groupBy", "aggregates", "aggregateFormatter", "persistKey", "persistStorage", "advancedFilter", "filterQuery", "columnManager", "columnOrder", "searchTerm"], outputs: ["pageStateChange", "sortChange", "selectionChange", "pinnedColumnsChange", "columnStateChange", "actionTriggered", "detailToggled", "rowClicked", "clearFiltersRequested", "filteredChange", "searchTermChanged", "groupByChange", "filterQueryChange", "columnOrderChange", "searchTermChange", "detailPaneToggled"] }, { kind: "component", type: DrawerComponent, selector: "hm-drawer", inputs: ["open", "heading", "size", "position", "pinned", "closeLabel", "closable", "beforeClose"], outputs: ["openChange", "closed"] }, { kind: "component", type: FilterBarComponent, selector: "hm-filter-bar", inputs: ["filters", "value", "label", "savedFilters", "storageKey", "storage", "applyDefault"], outputs: ["valueChange", "savedChange", "savedApplied"] }, { kind: "component", type: ProgressComponent, selector: "hm-progress", inputs: ["value", "max", "label", "labelHidden", "showValue", "indeterminate", "intent", "size", "hint"] }, { kind: "component", type: SegmentedControlComponent, selector: "hm-segmented-control", inputs: ["options", "value", "label", "size", "emphasis"], outputs: ["valueChange"] }, { kind: "component", type: SpineAuditTopCardComponent, selector: "spine-audit-top-card", inputs: ["heading", "rows", "total", "offset", "pageSize", "max", "loading", "failed"], outputs: ["picked", "offsetChange"] }, { kind: "component", type: StatCardComponent, selector: "hm-stat-card", inputs: ["icon", "label", "value", "intent", "fill", "description", "details", "trend", "hint", "size", "selectable", "selected", "loading"], outputs: ["selectedChange"] }, { kind: "component", type: StateScreenComponent, selector: "hm-state-screen", inputs: ["heading", "description", "situation", "action", "actionIcon"], outputs: ["actionTriggered"] }, { kind: "component", type: SwitchComponent, selector: "hm-switch", inputs: ["value", "label", "hint", "help", "labelPosition", "loading"], outputs: ["valueChange"] }, { kind: "component", type: TagComponent, selector: "hm-tag", inputs: ["name", "color", "compact"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
1274
|
+
}
|
|
1275
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.24", ngImport: i0, type: SpineAuditComponent, decorators: [{
|
|
1276
|
+
type: Component,
|
|
1277
|
+
args: [{ selector: 'spine-audit', imports: [
|
|
1278
|
+
AlertComponent, ButtonComponent, DataGridCellDirective, DataGridComponent, DrawerComponent, FilterBarComponent,
|
|
1279
|
+
ProgressComponent, SegmentedControlComponent, SpineAuditTopCardComponent, StatCardComponent, StateScreenComponent,
|
|
1280
|
+
SwitchComponent, TagComponent,
|
|
1281
|
+
], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"audit\" [attr.aria-busy]=\"loading() || pageLoading()\">\n <!-- Per\u00EDodo (escolha r\u00E1pida; as datas da faixa de filtros vencem) + atualizar. -->\n <div class=\"audit__toolbar\">\n <!-- Unidade: por a\u00E7\u00E3o (uma linha por trace, padr\u00E3o) ou por evento (cada registro). -->\n <hm-segmented-control\n [label]=\"text.unitLabel\"\n [options]=\"unitOptions\"\n [value]=\"unit()\"\n (valueChange)=\"setUnit($event)\"\n size=\"small\"\n emphasis=\"action\"\n />\n <hm-segmented-control\n [label]=\"text.period\"\n [options]=\"periodOptions\"\n [value]=\"period()\"\n (valueChange)=\"setPeriod($event)\"\n size=\"small\"\n emphasis=\"action\"\n />\n @if (customDates()) {\n <span class=\"muted audit__period-note\">{{ text.periodFromFilters }}</span>\n }\n <span class=\"audit__toolbar-end\">\n @if (lastUpdated(); as at) {\n <span class=\"muted audit__updated\" aria-live=\"polite\">{{ fmt(text.updatedAt, { time: clock(at) }) }}</span>\n }\n <hm-switch [label]=\"text.autoRefresh\" [value]=\"autoRefresh()\" (valueChange)=\"autoRefresh.set($event)\" />\n <hm-button emphasis=\"medium\" size=\"small\" icon=\"refresh\" [loading]=\"loading()\" (clicked)=\"reload()\">{{ text.refresh }}</hm-button>\n </span>\n </div>\n\n <hm-filter-bar\n class=\"audit__filters\"\n [label]=\"text.filters\"\n [filters]=\"filterFields()\"\n [value]=\"filterValue()\"\n (valueChange)=\"setFilters($event)\"\n [savedFilters]=\"true\"\n [storageKey]=\"storageKey()\"\n />\n\n @if (errorKind()) {\n <hm-state-screen\n class=\"audit__state\"\n [situation]=\"errorSituation()\"\n [heading]=\"text.errorTitle\"\n [description]=\"errorText()\"\n [action]=\"errorKind() === 'generic' ? text.retry : (hasFilters() ? text.clearFilters : '')\"\n actionIcon=\"refresh\"\n (actionTriggered)=\"errorKind() === 'generic' ? reload() : clearFilters()\"\n />\n } @else if (empty()) {\n <hm-state-screen\n class=\"audit__state\"\n situation=\"empty\"\n [heading]=\"unitText().emptyTitle\"\n [description]=\"text.emptyText\"\n [action]=\"hasFilters() ? text.clearFilters : ''\"\n (actionTriggered)=\"clearFilters()\"\n />\n } @else {\n <!-- KPIs: falhas/negados s\u00E3o filtros (altern\u00E2ncia). -->\n <section class=\"audit__kpis\" [attr.aria-label]=\"text.kpis\">\n <hm-stat-card\n icon=\"list\"\n [label]=\"unitText().kpiTotal\"\n [value]=\"number(stats()?.total ?? 0)\"\n [description]=\"unitText().kpiTotalDescription\"\n [loading]=\"loading() && !stats()\"\n />\n <hm-stat-card\n icon=\"error-standard\"\n [label]=\"text.kpiFailures\"\n [value]=\"number(stats()?.by_outcome?.failure ?? 0)\"\n [intent]=\"(stats()?.by_outcome?.failure ?? 0) > 0 ? 'danger' : 'neutral'\"\n [description]=\"fmt(text.kpiFilterHint, { share: share(stats()?.by_outcome?.failure ?? 0) })\"\n [loading]=\"loading() && !stats()\"\n selectable\n [selected]=\"selectedOutcome() === 'failure'\"\n (selectedChange)=\"toggleOutcome('failure', $event)\"\n />\n <hm-stat-card\n icon=\"ban\"\n [label]=\"text.kpiDenied\"\n [value]=\"number(stats()?.by_outcome?.denied ?? 0)\"\n [intent]=\"(stats()?.by_outcome?.denied ?? 0) > 0 ? 'warning' : 'neutral'\"\n [description]=\"fmt(text.kpiFilterHint, { share: share(stats()?.by_outcome?.denied ?? 0) })\"\n [loading]=\"loading() && !stats()\"\n selectable\n [selected]=\"selectedOutcome() === 'denied'\"\n (selectedChange)=\"toggleOutcome('denied', $event)\"\n />\n </section>\n\n <!-- Linha do tempo: barras empilhadas em CSS (sucesso embaixo, falha, negado). Cada barra tem\n o resumo em texto (title + aria-label); o gr\u00E1fico n\u00E3o \u00E9 a \u00FAnica forma de ler o n\u00FAmero. -->\n <section class=\"card card-block audit__timeline\" aria-labelledby=\"audit-timeline-title\">\n <h3 class=\"audit__section-title\" id=\"audit-timeline-title\">{{ text.timeline }}</h3>\n @if (hasTimeline()) {\n <ol class=\"timeline\" [attr.aria-label]=\"fmt(unitText().timelineSummary, { from: timelineEdges()?.from ?? '', to: timelineEdges()?.to ?? '' })\">\n @for (bar of timelineBars(); track bar.key) {\n <li class=\"timeline__bar\" [attr.title]=\"bar.summary\" [attr.aria-label]=\"bar.summary\">\n <span class=\"timeline__seg timeline__seg--denied\" [style.height.%]=\"bar.denied\"></span>\n <span class=\"timeline__seg timeline__seg--fail\" [style.height.%]=\"bar.fail\"></span>\n <span class=\"timeline__seg timeline__seg--ok\" [style.height.%]=\"bar.ok\"></span>\n </li>\n }\n </ol>\n <div class=\"timeline__axis\" aria-hidden=\"true\">\n <span>{{ timelineEdges()?.from }}</span>\n <span>{{ timelineEdges()?.to }}</span>\n </div>\n <ul class=\"timeline__legend\">\n <li><span class=\"swatch swatch--ok\"></span>{{ text.outcomeSuccess }}</li>\n <li><span class=\"swatch swatch--fail\"></span>{{ text.outcomeFailure }}</li>\n <li><span class=\"swatch swatch--denied\"></span>{{ text.outcomeDenied }}</li>\n </ul>\n } @else {\n <p class=\"muted\">{{ loading() ? text.loading : unitText().timelineEmpty }}</p>\n }\n </section>\n\n <!-- Rankings paginados no servidor (tops/:kind). Clicar num item vira filtro. -->\n <div class=\"audit__tops\" [class.audit__tops--three]=\"multiApp()\">\n @if (multiApp()) {\n <spine-audit-top-card\n class=\"audit__top audit__top--apps\"\n [heading]=\"text.topApps\"\n [rows]=\"topAppRows()\"\n [total]=\"tops.apps().total\"\n [offset]=\"tops.apps().offset\"\n [max]=\"tops.apps().max\"\n [pageSize]=\"topPageSize\"\n [loading]=\"tops.apps().loading\"\n [failed]=\"tops.apps().failed\"\n (offsetChange)=\"loadTop('apps', $event)\"\n (picked)=\"pickTop('apps', $event)\"\n />\n }\n <spine-audit-top-card\n class=\"audit__top audit__top--operations\"\n [heading]=\"text.topOperations\"\n [rows]=\"topOperationRows()\"\n [total]=\"tops.operations().total\"\n [offset]=\"tops.operations().offset\"\n [max]=\"tops.operations().max\"\n [pageSize]=\"topPageSize\"\n [loading]=\"tops.operations().loading\"\n [failed]=\"tops.operations().failed\"\n (offsetChange)=\"loadTop('operations', $event)\"\n (picked)=\"pickTop('operations', $event)\"\n />\n <spine-audit-top-card\n class=\"audit__top audit__top--users\"\n [heading]=\"text.topUsers\"\n [rows]=\"topUserRows()\"\n [total]=\"tops.users().total\"\n [offset]=\"tops.users().offset\"\n [max]=\"tops.users().max\"\n [pageSize]=\"topPageSize\"\n [loading]=\"tops.users().loading\"\n [failed]=\"tops.users().failed\"\n (offsetChange)=\"loadTop('users', $event)\"\n (picked)=\"pickTop('users', $event)\"\n />\n </div>\n\n <!-- Tabela: pagina\u00E7\u00E3o no servidor. A grade n\u00E3o corta nada ([paginated]=false): mostra a p\u00E1gina que veio\n (limit = itens por p\u00E1gina) e o rodap\u00E9 usa o total do resumo (mesma consulta). Esqueleto s\u00F3 na 1\u00AA\n carga; nas seguintes as linhas ficam e o indicador aparece por cima (sem piscar). -->\n <div class=\"audit__grid\" [class.audit__grid--busy]=\"gridBusy()\" [attr.aria-busy]=\"gridBusy()\">\n @if (gridBusy()) {\n <hm-progress class=\"audit__grid-progress\" indeterminate [label]=\"text.updating\" labelHidden />\n }\n <hm-data-grid\n [caption]=\"unitText().caption\"\n [columns]=\"columns()\"\n [rows]=\"events()\"\n [rowId]=\"rowId\"\n [loading]=\"gridSkeleton()\"\n [filterable]=\"false\"\n [paginated]=\"false\"\n [totalItems]=\"total()\"\n [pageState]=\"pageState()\"\n (pageStateChange)=\"changePage($event)\"\n [pageSizes]=\"pageSizes\"\n [itemsLabel]=\"unitText().pageItems\"\n [pageSizeLabel]=\"text.pageSize\"\n remoteSort\n rowClickable\n (rowClicked)=\"openRow($event)\"\n >\n <ng-template hmCell=\"operation\" let-item>\n <span class=\"audit__op\" [attr.title]=\"item.operation_label || null\">{{ operationName(item) }}</span>\n </ng-template>\n <ng-template hmCell=\"spans\" let-item>\n <span class=\"audit__spans\" [attr.title]=\"spansHint(item) || null\">\n {{ number(item.span_count ?? 1) }}\n @if (item.orphan) {\n <hm-tag class=\"audit__orphan\" [name]=\"text.orphanTag\" color=\"neutral\" />\n }\n </span>\n </ng-template>\n <ng-template hmCell=\"outcome\" let-item>\n <hm-tag [name]=\"outcomeLabel(item.outcome)\" [color]=\"outcomeColor(item.outcome)\" />\n </ng-template>\n </hm-data-grid>\n </div>\n\n @if (pageFailed()) {\n <hm-alert class=\"audit__page-error\" variant=\"error\" size=\"small\">{{ unitText().pageError }}</hm-alert>\n }\n }\n</div>\n\n<!-- Detalhe do evento + a cadeia (trace). -->\n<hm-drawer [open]=\"detailOpen()\" (openChange)=\"onDetailOpenChange($event)\" [heading]=\"showingAction() ? text.actionDetailHeading : text.detailHeading\">\n @if (selected(); as ev) {\n <div class=\"detail\">\n <div class=\"detail__head\">\n <strong class=\"detail__title\">{{ operationName(ev) }}</strong>\n <hm-tag [name]=\"outcomeLabel(headOutcome())\" [color]=\"outcomeColor(headOutcome())\" />\n </div>\n @if (showingAction() && selectedAction()?.orphan) {\n <hm-alert class=\"detail__orphan\" variant=\"info\" size=\"small\">{{ text.orphanNote }}</hm-alert>\n }\n @if (ev.operation_label && ev.operation_identifier) {\n <p class=\"muted detail__description\">{{ ev.operation_label }}</p>\n }\n\n @if (detailLoading()) {\n <p class=\"muted\" role=\"status\">{{ text.detailLoading }}</p>\n }\n @if (detailFailed()) {\n <hm-alert variant=\"warning\" size=\"small\">{{ text.detailError }}</hm-alert>\n }\n\n <dl class=\"detail__list\">\n @for (field of detailFields(); track field.label) {\n <div class=\"detail__row\">\n <dt>{{ field.label }}</dt>\n <dd>\n @if (field.code) { <code>{{ field.value }}</code> } @else { {{ field.value }} }\n </dd>\n </div>\n }\n </dl>\n\n @if (detailsJson()) {\n <h3 class=\"audit__section-title\">{{ text.fieldDetails }}</h3>\n <pre class=\"detail__json\">{{ detailsJson() }}</pre>\n }\n\n <!-- Por a\u00E7\u00E3o, os spans sobem para logo abaixo do cabe\u00E7alho (order no CSS); por evento, ficam no fim. -->\n @if (ev.trace_id) {\n <div class=\"detail__chain\" [class.detail__chain--first]=\"showingAction()\">\n <div class=\"detail__actions\">\n @if (!trace() && !traceLoading()) {\n <hm-button emphasis=\"medium\" size=\"small\" icon=\"tree-view\" (clicked)=\"showTrace()\">{{ text.viewTrace }}</hm-button>\n }\n <hm-button emphasis=\"minimal\" size=\"small\" icon=\"filter\" (clicked)=\"filterByTrace(ev.trace_id)\">{{ text.filterByTrace }}</hm-button>\n </div>\n @if (traceLoading()) {\n <p class=\"muted\" role=\"status\">{{ text.traceLoading }}</p>\n }\n @if (traceFailed()) {\n <hm-alert variant=\"error\" size=\"small\">{{ text.traceError }}</hm-alert>\n }\n @if (trace(); as tr) {\n <section class=\"trace\" aria-labelledby=\"audit-trace-title\">\n <h3 class=\"audit__section-title\" id=\"audit-trace-title\">{{ showingAction() ? text.spansHeading : text.traceHeading }}</h3>\n @if (tr.hidden_count) {\n <hm-alert class=\"trace__hidden\" variant=\"info\" size=\"small\">{{ plural(text.traceHidden, tr.hidden_count) }}</hm-alert>\n }\n @if (!tr.items.length) {\n <p class=\"muted\">{{ text.traceEmpty }}</p>\n } @else if (spanTimeline(); as tl) {\n <p class=\"muted trace__summary\">{{ fmt(text.spansSummary, { count: number(tl.rows.length), duration: duration(tl.totalMs) }) }}</p>\n <!-- Linha do tempo: cada span \u00E9 uma linha (recuo = profundidade pela cadeia parent_span_id) com a\n barra posicionada no tempo, proporcional \u00E0 dura\u00E7\u00E3o. O texto da linha (e o aria-label) traz\n tudo o que a barra mostra. -->\n <ol class=\"spans\">\n @for (row of tl.rows; track row.event.event_id) {\n <li class=\"spans__item\" [class.spans__item--current]=\"row.event.event_id === ev.event_id\">\n <button type=\"button\" class=\"spans__link\"\n [attr.aria-current]=\"row.event.event_id === ev.event_id ? 'true' : null\"\n [attr.aria-label]=\"spanAria(row)\"\n (click)=\"openEvent(row.event, true)\">\n <span class=\"spans__head\" [style.padding-inline-start.rem]=\"row.depth * 1\">\n <span class=\"spans__app\">{{ row.event.app_name ?? '' }}</span>\n <code class=\"spans__op\">{{ operationName(row.event) }}</code>\n </span>\n <hm-tag class=\"spans__tag\" [name]=\"outcomeLabel(row.event.outcome)\" [color]=\"outcomeColor(row.event.outcome)\" />\n <span class=\"spans__dur\">{{ duration(row.event.duration_ms) }}</span>\n <span class=\"spans__track\" aria-hidden=\"true\">\n <span class=\"spans__bar spans__bar--{{ row.event.outcome }}\" [style.left.%]=\"row.left\" [style.width.%]=\"row.width\"></span>\n </span>\n </button>\n </li>\n }\n </ol>\n }\n </section>\n }\n </div>\n }\n </div>\n }\n</hm-drawer>\n", styles: [".card{border:1px solid var(--hm-border);border-radius:var(--hm-radius-control);background:var(--hm-surface)}.card-block{padding:var(--hm-space-sm, 8px)}.muted{color:var(--hm-muted)}:host{display:block}.audit{display:flex;flex-direction:column;gap:var(--hm-space-md, 16px)}.audit__toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:var(--hm-space-sm, 8px)}.audit__period-note{font-size:13px}.audit__toolbar-end{display:inline-flex;align-items:center;flex-wrap:wrap;gap:var(--hm-space-sm, 8px) var(--hm-space-md, 16px);margin-inline-start:auto}.audit__updated{font-size:12px;font-variant-numeric:tabular-nums}.audit__state{min-block-size:320px}.audit__section-title{margin:0 0 var(--hm-space-sm, 8px);font-size:14px;font-weight:600;color:var(--hm-text)}.audit__kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--hm-space-sm, 8px);align-items:stretch}.timeline{display:flex;align-items:flex-end;gap:2px;block-size:120px;margin:0;padding:0;list-style:none;border-block-end:1px solid var(--hm-border)}.timeline__bar{flex:1 1 0;min-inline-size:2px;block-size:100%;display:flex;flex-direction:column;justify-content:flex-end;border-radius:2px 2px 0 0;overflow:hidden}.timeline__bar:hover{background:color-mix(in srgb,var(--hm-text) 6%,transparent)}.timeline__seg{display:block;inline-size:100%}.timeline__seg--ok,.swatch--ok{background:color-mix(in srgb,var(--hm-action) 55%,transparent)}.timeline__seg--fail,.swatch--fail{background:var(--hm-danger)}.timeline__seg--denied,.swatch--denied{background:var(--hm-warning, #c98a00)}.timeline__axis{display:flex;justify-content:space-between;margin-block-start:4px;font-size:12px;color:var(--hm-muted)}.timeline__legend{display:flex;flex-wrap:wrap;gap:var(--hm-space-sm, 8px) var(--hm-space-md, 16px);margin:var(--hm-space-sm, 8px) 0 0;padding:0;list-style:none;font-size:12px;color:var(--hm-muted)}.timeline__legend li{display:inline-flex;align-items:center;gap:6px}.swatch{display:inline-block;inline-size:10px;block-size:10px;border-radius:2px}.audit__tops{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:var(--hm-space-sm, 8px);align-items:stretch}.audit__grid{position:relative;display:block}.audit__grid-progress{position:absolute;inset-inline:0;inset-block-start:0;z-index:2;pointer-events:none}.audit__grid--busy hm-data-grid{opacity:.6;transition:opacity .12s ease}.audit__op{font-family:var(--hm-font-mono, ui-monospace, monospace);font-size:12px}.audit__page-error{display:block}@media(prefers-reduced-motion:reduce){.audit__grid--busy hm-data-grid{transition:none}}.detail{display:flex;flex-direction:column;gap:var(--hm-space-sm, 8px)}.detail__head{display:flex;align-items:center;gap:var(--hm-space-sm, 8px);flex-wrap:wrap}.detail__title{font-size:16px}.detail__list{display:grid;grid-template-columns:minmax(8rem,max-content) minmax(0,1fr);gap:6px var(--hm-space-md, 16px);margin:0}.detail__row{display:contents}.detail__row dt{color:var(--hm-muted);font-size:13px}.detail__row dd{margin:0;font-size:13px;overflow-wrap:anywhere}.detail__json{margin:0;padding:var(--hm-space-sm, 8px);max-block-size:280px;overflow:auto;border:1px solid var(--hm-border);border-radius:var(--hm-radius-control);background:color-mix(in srgb,var(--hm-text) 4%,transparent);font-size:12px}.detail__description{margin:0;font-size:13px}.detail__actions{display:flex;flex-wrap:wrap;gap:var(--hm-space-sm, 8px)}.trace{margin-block-start:var(--hm-space-sm, 8px)}.trace__hidden{margin-block-end:var(--hm-space-sm, 8px)}.detail__head,.detail__description,.detail__orphan{order:-3}.detail__chain{display:flex;flex-direction:column;gap:var(--hm-space-sm, 8px)}.detail__chain--first{order:-2}.trace__summary{margin:0 0 var(--hm-space-xs, 4px);font-size:13px}.spans{margin:0;padding:0;list-style:none;border-block-start:1px solid var(--hm-border)}.spans__item{border-block-end:1px solid var(--hm-border)}.spans__item--current .spans__link{background:color-mix(in srgb,var(--hm-action) 10%,transparent);box-shadow:inset 2px 0 0 var(--hm-action)}.spans__link{display:grid;grid-template-columns:minmax(0,1fr) auto auto;grid-template-areas:\"head tag dur\" \"track track track\";align-items:center;gap:4px var(--hm-space-sm, 8px);inline-size:100%;padding:6px 8px;border:0;background:transparent;color:var(--hm-text);font:inherit;font-size:13px;text-align:start;cursor:pointer}.spans__link:hover{background:color-mix(in srgb,var(--hm-action) 6%,transparent)}.spans__link:focus-visible{outline:2px solid var(--hm-action);outline-offset:-2px}.spans__head{grid-area:head;display:flex;flex-direction:column;min-inline-size:0}.spans__app{color:var(--hm-muted);font-size:12px}.spans__op{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--hm-font-mono, ui-monospace, monospace);font-size:12px}.spans__tag{grid-area:tag}.spans__dur{grid-area:dur;min-inline-size:4.5rem;text-align:end;font-variant-numeric:tabular-nums;color:var(--hm-muted)}.spans__track{grid-area:track;position:relative;block-size:6px;border-radius:3px;background:color-mix(in srgb,var(--hm-text) 6%,transparent)}.spans__bar{position:absolute;inset-block:0;border-radius:3px;background:color-mix(in srgb,var(--hm-action) 70%,transparent)}.spans__bar--failure{background:var(--hm-danger)}.spans__bar--denied{background:var(--hm-warning, #c98a00)}.audit__spans{display:inline-flex;align-items:center;gap:6px;font-variant-numeric:tabular-nums}\n"] }]
|
|
1282
|
+
}], ctorParameters: () => [], propDecorators: { scope: [{ type: i0.Input, args: [{ isSignal: true, alias: "scope", required: true }] }], clientId: [{ type: i0.Input, args: [{ isSignal: true, alias: "clientId", required: false }] }], adminApi: [{ type: i0.Input, args: [{ isSignal: true, alias: "adminApi", required: false }] }] } });
|
|
1283
|
+
|
|
1284
|
+
/*
|
|
1285
|
+
* @trismegisto/spine/audit — a tela de Auditoria (leitura de `audit.events` do argos admin-server), a mesma
|
|
1286
|
+
* para o painel central (`scope="admin"`) e para o auto-serviço (`scope="client"` + `clientId`).
|
|
1287
|
+
*
|
|
1288
|
+
* Este ponto de entrada EXPORTA o componente: importe-o só de código carregado sob demanda (rota lazy,
|
|
1289
|
+
* `import('@trismegisto/spine/audit')`). O `@trismegisto/spine/auto-service` só o alcança por `import()`
|
|
1290
|
+
* dentro dos chunks das telas dele — `scripts/check-lazy.mjs` falha se o arquivo do ponto `auto-service`
|
|
1291
|
+
* passar a importá-lo estaticamente.
|
|
1292
|
+
*/
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* Generated bundle index. Do not edit.
|
|
1296
|
+
*/
|
|
1297
|
+
|
|
1298
|
+
export { AUDIT_AUTO_REFRESH_MS, AUDIT_MAX_PAGE_SIZE, AUDIT_MAX_WINDOW_MS, AUDIT_METHODS, AUDIT_OUTCOMES, AUDIT_PAGE_SIZE, AUDIT_PAGE_SIZES, AUDIT_PERIOD_MS, AUDIT_TOP_KINDS, AUDIT_TOP_PAGE_SIZE, AUDIT_UNITS, AuditCursorPager, SpineAuditComponent, SpineAuditService, SpineAuditTopCardComponent, auditApiBase, auditAppsUrl, auditErrorCode, auditPagerTotal, auditParams, buildAuditQuery, buildSpanTimeline, parseAuditDate, worstOutcome };
|
|
1299
|
+
//# sourceMappingURL=trismegisto-spine-audit.mjs.map
|