This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch feat/explore in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit e3b96c2415bd144f632a6939222c6e4ac152f8af Author: Wu Sheng <[email protected]> AuthorDate: Tue Jun 23 23:57:55 2026 +0800 feat(explore): Log inspect (raw source) + trace-inspect review fixes Log inspect — raw logs: - New ExploreLogView: cross-layer raw-log query with the optional Pick/Type/blank entity, keywords/tags/trace-id/time conditions, a full-width form, the shared LogStreamPanel, and a row-click popout (LogDetailPopout, built on the shared Modal) — also adopted by the per-layer Logs tab, replacing its bespoke popout. - BFF: fetchLogs extracted from the per-layer logs route; the explore route's log branch resolves the entity + SECOND-precision window and runs OAP queryLogs. Validated against the demo. Trace-inspect review fixes (adversarial review): - loadEndpoints() resets the picked endpoint on service change (was a silent OAP endpoint/service scope mismatch). - runQuery() clears the prior result before the await + shows "Reading data…" (cascade-clear-then-load). - RBAC: Trace/Log inspect gate on explore:read (the route's verb), now registered in verbs.ts. - TraceDistribution brush works again — the fat click hit-targets no longer swallow drag-start (click-vs-drag disambiguation). - Trace ID condition hidden for the zipkin source (unsupported there). - Native resolved-query echo includes the duration + tag filters. --- CHANGELOG.md | 5 + apps/bff/src/http/query/explore.ts | 73 ++- apps/bff/src/http/query/log.ts | 138 ++++-- apps/bff/src/rbac/verbs.ts | 2 + .../features/operate/explore/ExploreLogView.vue | 533 ++++++++++++++++++++ .../src/features/operate/explore/ExploreView.vue | 32 +- apps/ui/src/i18n/locales/de.json | 34 +- apps/ui/src/i18n/locales/en.json | 34 +- apps/ui/src/i18n/locales/es.json | 34 +- apps/ui/src/i18n/locales/fr.json | 34 +- apps/ui/src/i18n/locales/ja.json | 34 +- apps/ui/src/i18n/locales/ko.json | 34 +- apps/ui/src/i18n/locales/pt.json | 34 +- apps/ui/src/i18n/locales/zh-CN.json | 34 +- apps/ui/src/layer/logs/LayerLogsView.vue | 536 +-------------------- apps/ui/src/render/widgets/LogDetailPopout.vue | 186 +++++++ apps/ui/src/render/widgets/LogStreamPanel.vue | 233 +++++++++ apps/ui/src/render/widgets/TraceDistribution.vue | 48 +- apps/ui/src/shell/AppSidebar.vue | 4 +- apps/ui/src/shell/router/index.ts | 7 +- apps/ui/src/utils/logRow.ts | 25 + 21 files changed, 1477 insertions(+), 617 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e43cb5..97979eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ The version line is shared by every package in the monorepo (apps + shared packa - **Shareable trace links are unified.** Native and Zipkin traces both open from a single `?traceId=` link under the layer's trace tab; the viewer auto-selects native vs Zipkin by the trace-ID shape, so `/layer/<layer>/trace?traceId=…` always opens the right one. - **Trace filters are searchable, on-theme dropdowns.** The native Service / Instance / Endpoint pickers and the Zipkin Service / Remote service / Span name pickers use a dark type-to-filter dropdown that reopens correctly after a pick. +### Logs + +- **Log inspect uses the full width.** The cross-layer Log inspect form (Target + Keywords / Tags / Trace ID / Time / Limit conditions) now spans the whole page instead of sharing a two-column strip with empty space. +- **Clicking a log row opens a centered popout.** Both the cross-layer Log inspect and the per-layer Logs tab now open the same full-payload popout on row click — format-aware pretty-print (JSON pretty-printed by content type), the tags table, service / instance / endpoint / time meta, a copy button, and the trace link. Escape or the close button dismisses it. + ## 0.7.0 ### Browser errors & source maps diff --git a/apps/bff/src/http/query/explore.ts b/apps/bff/src/http/query/explore.ts index 7cc3ed1..e108d0c 100644 --- a/apps/bff/src/http/query/explore.ts +++ b/apps/bff/src/http/query/explore.ts @@ -35,15 +35,17 @@ import type { ExploreResponse, ExploreWindow, FetchLike, + LogTagFilter, ZipkinTraceListResponse, } from '@skywalking-horizon-ui/api-client'; import type { ConfigSource } from '../../config/loader.js'; import type { SessionStore } from '../../user/sessions.js'; import { requireAuth } from '../../user/middleware.js'; import { buildOapOpts } from '../../client/graphql.js'; -import { getServerOffsetMinutes } from '../../util/window.js'; +import { fmtSecond, getServerOffsetMinutes } from '../../util/window.js'; import { buildEndpointId, buildInstanceId, buildServiceId } from '../../util/entityId.js'; import { fetchNativeList, type TraceListBody } from './trace.js'; +import { fetchLogs } from './log.js'; import { zipkinFetchTraces } from '../../client/zipkin.js'; export interface ExploreRouteDeps { @@ -88,6 +90,21 @@ function zipkinWindow(w: ExploreWindow): { endTs: number; lookback: number } { return { endTs: Date.now(), lookback: Math.max(1, w.windowMinutes ?? 30) * 60_000 }; } +const DEFAULT_LOG_WINDOW_MIN = 30; +const MAX_LOG_WINDOW_MIN = 60 * 24 * 7; // 1 week guard, mirrors the per-layer route +/** Log queries are SECOND-precision RECORD reads (same as the per-layer + * Logs tab) — MINUTE rounding would chop off the most recent lines. + * Explicit epoch-ms bounds win; otherwise the rolling minutes preset + * anchors at "now". Both formatted OAP-local via the cached offset. */ +function logWindowSecond(w: ExploreWindow, offsetMinutes: number): { start: string; end: string } { + if (typeof w.startMs === 'number' && typeof w.endMs === 'number' && w.endMs > w.startMs) { + return { start: fmtSecond(w.startMs, offsetMinutes), end: fmtSecond(w.endMs, offsetMinutes) }; + } + const m = Math.max(1, Math.min(MAX_LOG_WINDOW_MIN, Math.round(w.windowMinutes ?? DEFAULT_LOG_WINDOW_MIN))); + const endMs = Date.now(); + return { start: fmtSecond(endMs - m * 60_000, offsetMinutes), end: fmtSecond(endMs, offsetMinutes) }; +} + export function registerExploreRoutes(app: FastifyInstance, deps: ExploreRouteDeps): void { const auth = requireAuth(deps); @@ -138,6 +155,9 @@ export function registerExploreRoutes(app: FastifyInstance, deps: ExploreRouteDe ...(body.traceId ? { traceId: body.traceId } : {}), traceState: body.traceState ?? 'ALL', queryOrder: body.queryOrder ?? 'BY_START_TIME', + ...(typeof body.minTraceDuration === 'number' ? { minTraceDuration: body.minTraceDuration } : {}), + ...(typeof body.maxTraceDuration === 'number' ? { maxTraceDuration: body.maxTraceDuration } : {}), + ...(body.tags && body.tags.length ? { tags: body.tags } : {}), ...win, }, }; @@ -206,8 +226,55 @@ export function registerExploreRoutes(app: FastifyInstance, deps: ExploreRouteDe } satisfies ExploreResponse); } - // kind === 'log' — raw + browser, added in the log increment. - return reply.code(501).send({ error: 'log_not_implemented' }); + // kind === 'log'. `browser` (BROWSER-layer JS errors) lands in the + // next increment; default + only supported source today is `raw`. + const logSource = body.logSource ?? 'raw'; + if (logSource === 'raw') { + // Entity optional — no service means "all services in the window" + // (OAP's LogQueryCondition.serviceId is nullable). + const ids = body.entity ? resolveNativeEntity(body.entity) : {}; + const win = logWindowSecond(body.window ?? {}, offset); + const keywords = (body.keywordsOfContent ?? []).filter((k) => k.length > 0); + const tags = (body.tags ?? []) as LogTagFilter[]; + const maxLogs = deps.config.current.performance.limits.maxPageSize.logs; + const paging = { + pageNum: Math.max(1, Math.round(body.pageNum ?? 1)), + pageSize: Math.min(maxLogs, Math.max(1, Math.round(body.pageSize ?? 50))), + }; + const scope = { + serviceId: ids.serviceId, + serviceInstanceId: ids.instanceId, + endpointId: ids.endpointId, + traceId: body.relatedTraceId, + keywordsOfContent: keywords, + tags, + }; + const logs = await fetchLogs(opts, scope, win, paging, !!req.coldStage); + const resolved: ExploreResolved = { + kind: 'log', + source: 'raw', + entityId: ids.serviceId, + condition: { + ...(ids.serviceId ? { serviceId: ids.serviceId } : {}), + ...(ids.instanceId ? { serviceInstanceId: ids.instanceId } : {}), + ...(ids.endpointId ? { endpointId: ids.endpointId } : {}), + ...(body.relatedTraceId ? { traceId: body.relatedTraceId } : {}), + ...(keywords.length > 0 ? { keywordsOfContent: keywords } : {}), + ...(tags.length > 0 ? { tags } : {}), + ...win, + }, + }; + return reply.send({ + kind: 'log', + logSource: 'raw', + generatedAt, + logs, + resolved, + } satisfies ExploreResponse); + } + + // kind === 'log' && logSource === 'browser' — next increment. + return reply.code(501).send({ error: 'browser_log_not_implemented' }); }, ); } diff --git a/apps/bff/src/http/query/log.ts b/apps/bff/src/http/query/log.ts index 74cc6cf..9d693eb 100644 --- a/apps/bff/src/http/query/log.ts +++ b/apps/bff/src/http/query/log.ts @@ -36,6 +36,7 @@ import type { LogKeyValue, LogQueryRequest, LogRow, + LogTagFilter, LogsResponse, } from '@skywalking-horizon-ui/api-client'; import type { ConfigSource } from '../../config/loader.js'; @@ -143,6 +144,79 @@ interface OapLogRow { tags?: LogKeyValue[] | null; } +function mapLogRow(r: OapLogRow): LogRow { + return { + serviceName: r.serviceName ?? null, + serviceId: r.serviceId ?? null, + serviceInstanceName: r.serviceInstanceName ?? null, + serviceInstanceId: r.serviceInstanceId ?? null, + endpointName: r.endpointName ?? null, + endpointId: r.endpointId ?? null, + traceId: r.traceId ?? null, + timestamp: r.timestamp, + contentType: r.contentType, + content: r.content, + tags: r.tags ?? [], + }; +} + +/** Entity ids the log query scopes by — all PRE-RESOLVED by the caller + * (no `listServices(layer)` lookup, no name → id resolution). The + * per-layer route resolves a name first; explore forwards ids it + * already minted. */ +export interface LogFetchScope { + serviceId?: string | null; + serviceInstanceId?: string | null; + endpointId?: string | null; + traceId?: string | null; + keywordsOfContent?: string[]; + tags?: LogTagFilter[]; +} + +/** Run OAP's `queryLogs(LogQueryCondition)` for a pre-resolved scope + + * SECOND-precision window + page, and map the rows to {@link LogRow}. + * Shared by the per-layer Logs route and the cross-layer Log inspect + * branch. Soft-fails to `reachable: false` on any OAP error. */ +export async function fetchLogs( + opts: GraphqlOptions, + scope: LogFetchScope, + window: { start: string; end: string }, + paging: { pageNum: number; pageSize: number }, + coldStage: boolean, +): Promise<LogsResponse> { + const condition = { + ...(scope.serviceId ? { serviceId: scope.serviceId } : {}), + ...(scope.serviceInstanceId ? { serviceInstanceId: scope.serviceInstanceId } : {}), + ...(scope.endpointId ? { endpointId: scope.endpointId } : {}), + ...(scope.traceId ? { relatedTrace: { traceId: scope.traceId } } : {}), + ...(scope.keywordsOfContent && scope.keywordsOfContent.length > 0 + ? { keywordsOfContent: scope.keywordsOfContent } + : {}), + ...(scope.tags && scope.tags.length > 0 ? { tags: scope.tags } : {}), + queryDuration: { + start: window.start, + end: window.end, + step: 'SECOND', + ...(coldStage ? { coldStage: true } : {}), + }, + paging, + }; + try { + const env = await graphqlPost<{ data: { logs: OapLogRow[] } }>(opts, QUERY_LOGS, { condition }); + const logs = (env.data?.logs ?? []).map(mapLogRow); + return { generatedAt: Date.now(), query: {}, total: logs.length, logs, reachable: true }; + } catch (err) { + return { + generatedAt: Date.now(), + query: {}, + total: 0, + logs: [], + reachable: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + /** * Resolve a service argument to an OAP service id. The arg can be * either a name (`mesh-svr::songs.sample-services`) or an id @@ -211,56 +285,26 @@ export function registerLogRoute(app: FastifyInstance, deps: LogRouteDeps): void } satisfies LogsResponse); } } - const condition = { - ...(serviceId ? { serviceId } : {}), - ...(body.serviceInstanceId ? { serviceInstanceId: body.serviceInstanceId } : {}), - ...(body.endpointId ? { endpointId: body.endpointId } : {}), - ...(body.traceId ? { relatedTrace: { traceId: body.traceId } } : {}), - ...(body.keywordsOfContent && body.keywordsOfContent.length > 0 - ? { keywordsOfContent: body.keywordsOfContent } - : {}), - ...(body.tags && body.tags.length > 0 ? { tags: body.tags } : {}), - queryDuration: withColdStage(req, { start: window.start, end: window.end, step: 'SECOND' }), - paging: { + const res = await fetchLogs( + opts, + { + serviceId, + serviceInstanceId: body.serviceInstanceId, + endpointId: body.endpointId, + traceId: body.traceId, + keywordsOfContent: body.keywordsOfContent, + tags: body.tags, + }, + window, + { pageNum: Math.max(1, Math.round(body.page ?? 1)), pageSize: clampPageSize(body.pageSize, 50, deps.config.current.performance.limits.maxPageSize.logs), }, - }; - - try { - const env = await graphqlPost<{ - data: { logs: OapLogRow[] }; - }>(opts, QUERY_LOGS, { condition }); - const logs: LogRow[] = (env.data?.logs ?? []).map((r) => ({ - serviceName: r.serviceName ?? null, - serviceId: r.serviceId ?? null, - serviceInstanceName: r.serviceInstanceName ?? null, - serviceInstanceId: r.serviceInstanceId ?? null, - endpointName: r.endpointName ?? null, - endpointId: r.endpointId ?? null, - traceId: r.traceId ?? null, - timestamp: r.timestamp, - contentType: r.contentType, - content: r.content, - tags: r.tags ?? [], - })); - return reply.send({ - generatedAt: Date.now(), - query: body, - total: logs.length, - logs, - reachable: true, - } satisfies LogsResponse); - } catch (err) { - return reply.send({ - generatedAt: Date.now(), - query: body, - total: 0, - logs: [], - reachable: false, - error: err instanceof Error ? err.message : String(err), - } satisfies LogsResponse); - } + !!req.coldStage, + ); + // Echo the operator's query (the shared helper returns an empty + // echo since it's entity-agnostic). + return reply.send({ ...res, query: body } satisfies LogsResponse); }, ); diff --git a/apps/bff/src/rbac/verbs.ts b/apps/bff/src/rbac/verbs.ts index 43ce4a8..d1d2bc4 100644 --- a/apps/bff/src/rbac/verbs.ts +++ b/apps/bff/src/rbac/verbs.ts @@ -29,6 +29,8 @@ export const VERBS = { tracesRead: 'traces:read', logsRead: 'logs:read', browserErrorsRead: 'browser-errors:read', + /** Cross-layer Trace/Log inspect (the /api/explore/query route). */ + exploreRead: 'explore:read', topologyRead: 'topology:read', profileRead: 'profile:read', infra3dRead: 'infra-3d:read', diff --git a/apps/ui/src/features/operate/explore/ExploreLogView.vue b/apps/ui/src/features/operate/explore/ExploreLogView.vue new file mode 100644 index 0000000..464dd2f --- /dev/null +++ b/apps/ui/src/features/operate/explore/ExploreLogView.vue @@ -0,0 +1,533 @@ +<!-- + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<!-- + Log inspect — cross-layer raw-log query power-tool. The Log sibling of + ExploreView (Trace inspect): same dark-dense spine, same OPTIONAL + entity (pick a layer-filtered service, type its name + the real flag, + or leave it blank to query every service). One query → one full-width + log stream, rendered with the shared LogStreamPanel; clicking a row + opens the shared LogDetailPopout with the full payload. The + resolved-query panel surfaces the exact condition the BFF ran. + + This pass implements Log · raw. The Browser source (BROWSER-layer JS + errors) lands next on the same spine — its toggle is present but + disabled. +--> +<script setup lang="ts"> +import { computed, reactive, ref, watch } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { bffClient } from '@/api/client'; +import type { + ExploreEntity, + ExploreRequest, + ExploreResolved, + ExploreWindow, + LogRow, + LogsResponse, +} from '@/api/client'; +import { useLayers } from '@/shell/useLayers'; +import { useTracePopout } from '@/layer/traces/useTracePopout'; +import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue'; +import LogStreamPanel from '@/render/widgets/LogStreamPanel.vue'; +import LogDetailPopout from '@/render/widgets/LogDetailPopout.vue'; +import { logRowKey } from '@/utils/logRow'; + +const { t } = useI18n(); +const { availableLayers } = useLayers(); +const { openTrace } = useTracePopout(); + +// ── source (browser-error log source lands in the next increment) ───── +type LogSource = 'raw' | 'browser'; +const logSource = ref<LogSource>('raw'); + +// ── entity (OPTIONAL): pick (layer-filtered) vs type (name + real) ──── +type EntityMode = 'pick' | 'type'; +const entityMode = ref<EntityMode>('pick'); + +const pickLayer = ref<string>(''); +const pickServiceId = ref<string>(''); +const pickInstanceId = ref<string>(''); +const pickEndpointId = ref<string>(''); +const services = ref<Array<{ id: string; name: string; normal: boolean | null }>>([]); +const instances = ref<Array<{ id: string; name: string }>>([]); +const endpoints = ref<Array<{ id: string; name: string }>>([]); +const servicesLoading = ref(false); + +const pickServiceName = computed( + () => services.value.find((s) => s.id === pickServiceId.value)?.name ?? '', +); + +async function loadServices(): Promise<void> { + services.value = []; + instances.value = []; + endpoints.value = []; + pickServiceId.value = ''; + pickInstanceId.value = ''; + pickEndpointId.value = ''; + if (!pickLayer.value) return; + servicesLoading.value = true; + try { + const res = await bffClient.layer.services(pickLayer.value); + services.value = res.reachable ? res.services : []; + } catch { + services.value = []; + } finally { + servicesLoading.value = false; + } +} + +async function loadInstances(): Promise<void> { + instances.value = []; + pickInstanceId.value = ''; + const name = pickServiceName.value; + if (!pickLayer.value || !name) return; + try { + const res = await bffClient.layer.instances(pickLayer.value, name); + instances.value = res.reachable ? res.instances : []; + } catch { + instances.value = []; + } +} + +async function loadEndpoints(): Promise<void> { + const name = pickServiceName.value; + if (!pickLayer.value || !name) { + endpoints.value = []; + return; + } + try { + const res = await bffClient.layer.endpoints(pickLayer.value, name, '', 50); + endpoints.value = res.reachable ? res.endpoints : []; + } catch { + endpoints.value = []; + } +} + +watch(pickLayer, () => void loadServices()); +watch(pickServiceId, () => { + void loadInstances(); + void loadEndpoints(); +}); + +const layerOptions = computed(() => availableLayers.value.map((l) => ({ value: l.key, label: l.name || l.key }))); +const serviceOptions = computed(() => + services.value.map((s) => ({ value: s.id, label: s.name, hint: s.normal === false ? 'virtual' : undefined })), +); +const instanceOptions = computed(() => [ + { value: '', label: t('All instances') }, + ...instances.value.map((i) => ({ value: i.id, label: i.name })), +]); +const endpointOptions = computed(() => [ + { value: '', label: t('All endpoints') }, + ...endpoints.value.map((e) => ({ value: e.id, label: e.name })), +]); +const instanceSel = computed<string>({ get: () => pickInstanceId.value, set: (v) => (pickInstanceId.value = v ?? '') }); +const endpointSel = computed<string>({ get: () => pickEndpointId.value, set: (v) => (pickEndpointId.value = v ?? '') }); + +const typeService = ref<string>(''); +const typeReal = ref<boolean>(true); +const typeInstance = ref<string>(''); +const typeEndpoint = ref<string>(''); + +/** Seed the Type form from the current Pick selection — pick to discover, + * then tweak the name/flag by hand. */ +function seedTypeFromPick(): void { + if (!pickServiceName.value) return; + typeService.value = pickServiceName.value; + typeReal.value = services.value.find((s) => s.id === pickServiceId.value)?.normal !== false; + typeInstance.value = instances.value.find((i) => i.id === pickInstanceId.value)?.name ?? ''; + typeEndpoint.value = endpoints.value.find((e) => e.id === pickEndpointId.value)?.name ?? ''; + entityMode.value = 'type'; +} + +function currentEntity(): ExploreEntity | null { + if (entityMode.value === 'pick') { + if (!pickServiceId.value) return null; + return { + mode: 'pick', + serviceId: pickServiceId.value, + instanceId: pickInstanceId.value || undefined, + endpointId: pickEndpointId.value || undefined, + }; + } + const name = typeService.value.trim(); + if (!name) return null; + return { + mode: 'type', + serviceName: name, + isReal: typeReal.value, + instanceName: typeInstance.value.trim() || undefined, + endpointName: typeEndpoint.value.trim() || undefined, + }; +} + +// ── log conditions ──────────────────────────────────────────────────── +const cond = reactive({ + keywords: '' as string, + tags: '' as string, + traceId: '' as string, + windowMinutes: 30, + limit: 50, +}); +const WINDOWS = [15, 30, 60, 180, 360, 720, 1440]; +const LIMITS = [20, 50, 100, 200]; + +const CUSTOM_RANGE_SENTINEL = -1; +const customStart = ref<string | null>(null); +const customEnd = ref<string | null>(null); +const isCustomRange = computed(() => cond.windowMinutes === CUSTOM_RANGE_SENTINEL); +function fmtLocalInput(d: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} +watch(isCustomRange, (custom) => { + if (custom) { + if (customStart.value && customEnd.value) return; + const end = new Date(); + const start = new Date(end.getTime() - 30 * 60_000); + customStart.value = fmtLocalInput(start); + customEnd.value = fmtLocalInput(end); + } else { + customStart.value = null; + customEnd.value = null; + } +}); + +/** Free-text keywords → OAP's content-keyword list. Split on whitespace + * and commas; each token is AND-joined server-side. */ +function parseKeywords(s: string): string[] { + return s + .split(/[\s,]+/) + .map((p) => p.trim()) + .filter(Boolean); +} +function parseTags(s: string): Array<{ key: string; value: string }> { + return s + .split(',') + .map((p) => p.trim()) + .filter(Boolean) + .map((p) => { + const i = p.indexOf('='); + return i < 0 ? { key: p, value: '' } : { key: p.slice(0, i).trim(), value: p.slice(i + 1).trim() }; + }) + .filter((kv) => kv.key); +} + +// ── run + result ────────────────────────────────────────────────────── +const running = ref(false); +const hasQueried = ref(false); +const errorMsg = ref<string | null>(null); +const logsResp = ref<LogsResponse | null>(null); +const resolved = ref<ExploreResolved | null>(null); +const showResolved = ref(false); + +const canRun = computed(() => logSource.value === 'raw'); + +/** Explicit epoch-ms bounds in Custom mode (datetime-local strings are + * browser-local; `Date.parse` reads them as local), else rolling. */ +function resolveWindow(): ExploreWindow { + if (isCustomRange.value) { + if (customStart.value && customEnd.value) { + const startMs = Date.parse(customStart.value); + const endMs = Date.parse(customEnd.value); + if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs) { + return { startMs, endMs }; + } + } + return { windowMinutes: 30 }; + } + return { windowMinutes: cond.windowMinutes }; +} + +function buildRequest(): ExploreRequest { + const entity = currentEntity(); + const keywords = parseKeywords(cond.keywords); + const tags = parseTags(cond.tags); + return { + kind: 'log', + logSource: 'raw', + ...(entity ? { entity } : {}), + window: resolveWindow(), + pageSize: cond.limit, + ...(keywords.length > 0 ? { keywordsOfContent: keywords } : {}), + ...(tags.length > 0 ? { tags } : {}), + ...(cond.traceId.trim() ? { relatedTraceId: cond.traceId.trim() } : {}), + }; +} + +async function runQuery(): Promise<void> { + running.value = true; + hasQueried.value = true; + errorMsg.value = null; + closeDetail(); + // Cascade-clear: drop the prior result before the new query lands so + // operators never read stale rows as the new state. + logsResp.value = null; + const req = buildRequest(); + try { + const res = await bffClient.explore.query(req); + if (res.kind === 'log' && res.logSource === 'raw') { + logsResp.value = res.logs; + resolved.value = res.resolved; + if (!res.logs.reachable) errorMsg.value = res.logs.error ?? t('OAP unreachable'); + } + } catch (e) { + errorMsg.value = e instanceof Error ? e.message : String(e); + logsResp.value = null; + } finally { + running.value = false; + } +} + +const rows = computed<LogRow[]>(() => (hasQueried.value ? logsResp.value?.logs ?? [] : [])); + +// ── detail — clicking a row opens the shared full-payload popout. The +// stream stays full-width; the popout owns its own Escape / close + +// format-aware pretty-print + copy + tag table. +const selectedKey = ref<string | null>(null); +const selectedRow = ref<LogRow | null>(null); + +function openRow(payload: { row: LogRow; key: string }): void { + selectedKey.value = payload.key; + selectedRow.value = payload.row; +} +function closeDetail(): void { + selectedKey.value = null; + selectedRow.value = null; +} +function jumpToTrace(traceId: string, ts: number): void { + openTrace(traceId, ts); +} + +// Drop the selection across result changes — a row that's no longer in +// the new result closes the popout. +watch(rows, (next) => { + if (selectedKey.value == null) return; + const stillThere = next.some((r, idx) => logRowKey(r, idx) === selectedKey.value); + if (!stillThere) closeDetail(); +}); +</script> + +<template> + <div class="iq"> + <header class="iq-head"> + <h1>{{ t('Log inspect') }}</h1> + <p class="iq-sub">{{ t('Query any service across layers — pick it, type its name, or leave it blank.') }}</p> + </header> + + <div class="iq-bar"> + <span class="iq-bar-l">{{ t('Source') }}</span> + <div class="seg"> + <button :class="{ on: logSource === 'raw' }" @click="logSource = 'raw'">{{ t('Raw') }}</button> + <button disabled :title="t('coming next')">{{ t('Browser') }}</button> + </div> + </div> + + <!-- Logs have no distribution square — the form spans the full width + (a 3-column condition grid reads well across it). --> + <div class="iq-form"> + <div class="iq-target"> + <div class="iq-target-h"> + <span>{{ t('Target') }} <small class="dim">{{ t('optional — blank queries all services') }}</small></span> + <div class="seg sm"> + <button :class="{ on: entityMode === 'pick' }" @click="entityMode = 'pick'">{{ t('Pick') }}</button> + <button :class="{ on: entityMode === 'type' }" @click="entityMode = 'type'">{{ t('Type') }}</button> + </div> + <button v-if="entityMode === 'pick'" class="iq-link" :disabled="!pickServiceId" @click="seedTypeFromPick"> + {{ t('→ edit as text') }} + </button> + </div> + + <div class="iq-grid" v-if="entityMode === 'pick'"> + <label class="cf"> + <span>{{ t('Layer') }}</span> + <TypeaheadSelect v-model="pickLayer" :aria-label="t('Layer')" :options="layerOptions" :placeholder="t('Any layer')" class="cf-tas" /> + </label> + <label class="cf"> + <span>{{ t('Service') }}</span> + <TypeaheadSelect + v-model="pickServiceId" :aria-label="t('Service')" :options="serviceOptions" :disabled="!pickLayer || servicesLoading" + :placeholder="servicesLoading ? t('Reading…') : t('Any service')" class="cf-tas" + /> + </label> + <label class="cf"> + <span>{{ t('Instance') }}</span> + <TypeaheadSelect v-model="instanceSel" :aria-label="t('Instance')" :options="instanceOptions" :disabled="!pickServiceId" :placeholder="t('All instances')" class="cf-tas" /> + </label> + <label class="cf"> + <span>{{ t('Endpoint') }}</span> + <TypeaheadSelect v-model="endpointSel" :aria-label="t('Endpoint')" :options="endpointOptions" :disabled="!pickServiceId" :placeholder="t('All endpoints')" class="cf-tas" /> + </label> + </div> + + <div class="iq-grid" v-else> + <label class="cf"> + <span>{{ t('Service name') }}</span> + <input v-model="typeService" class="cf-input mono" type="text" :placeholder="t('e.g. agent::checkout')" /> + </label> + <label class="cf cf-chk"> + <span>{{ t('Real') }}</span> + <span class="iq-chk"><input v-model="typeReal" type="checkbox" /> <small class="dim">{{ t('off = virtual / peer') }}</small></span> + </label> + <label class="cf"> + <span>{{ t('Instance') }}</span> + <input v-model="typeInstance" class="cf-input" type="text" :placeholder="t('optional')" /> + </label> + <label class="cf"> + <span>{{ t('Endpoint') }}</span> + <input v-model="typeEndpoint" class="cf-input" type="text" :placeholder="t('optional')" /> + </label> + </div> + </div> + + <div class="iq-conditions"> + <div class="iq-conditions-h"> + <span class="kicker iq-cond-kicker">Logs</span> + <button v-if="resolved" class="iq-resolved-tog" @click="showResolved = !showResolved"> + {{ showResolved ? '▾' : '▸' }} {{ t('Resolved query') }} + <span class="dim">{{ resolved.source }}</span> + </button> + <button class="iq-run" :disabled="!canRun || running" @click="runQuery"> + {{ running ? t('Running…') : t('Run query') }} + </button> + </div> + <div class="iq-grid"> + <label class="cf cf-wide"> + <span>{{ t('Keywords') }}</span> + <input v-model="cond.keywords" class="cf-input mono" type="text" :placeholder="t('space- or comma-separated, AND-joined')" /> + </label> + <label class="cf cf-wide"> + <span>{{ t('Tags') }}</span> + <input v-model="cond.tags" class="cf-input mono" type="text" :placeholder="t('level=ERROR, …')" /> + </label> + <label class="cf cf-wide"> + <span>{{ t('Trace ID') }}</span> + <input v-model="cond.traceId" class="cf-input mono" type="text" :placeholder="t('paste a trace id')" /> + </label> + <label class="cf iq-time" :class="{ 'cf-wide': isCustomRange }"> + <span>{{ t('Time') }}</span> + <template v-if="isCustomRange"> + <span class="cf-range"> + <input v-model="customStart" type="datetime-local" class="cf-input cf-range-num" /> + <span class="cf-range-sep">–</span> + <input v-model="customEnd" type="datetime-local" class="cf-input cf-range-num" /> + <button class="iq-range-reset" type="button" :title="t('Back to presets')" @click="cond.windowMinutes = 30">×</button> + </span> + </template> + <select v-else v-model.number="cond.windowMinutes" class="cf-input"> + <option v-for="w in WINDOWS" :key="w" :value="w">{{ w < 60 ? `${w}m` : `${w / 60}h` }}</option> + <option :value="CUSTOM_RANGE_SENTINEL">{{ t('Custom…') }}</option> + </select> + </label> + <label class="cf"> + <span>{{ t('Limit') }}</span> + <select v-model.number="cond.limit" class="cf-input"> + <option v-for="l in LIMITS" :key="l" :value="l">{{ l }}</option> + </select> + </label> + </div> + <pre v-if="resolved && showResolved" class="iq-resolved-body">{{ JSON.stringify(resolved.condition, null, 2) }}</pre> + </div> + </div> + + <div class="iq-result"> + <div v-if="!hasQueried" class="iq-empty">{{ t('Run a query — name a service or leave it blank.') }}</div> + <div v-else-if="running && rows.length === 0" class="iq-empty">{{ t('Reading data…') }}</div> + <div v-else-if="errorMsg" class="iq-err">{{ errorMsg }}</div> + <div v-else-if="rows.length === 0" class="iq-empty">{{ t('No logs in this window.') }}</div> + + <article v-else class="iq-list-card sw-card"> + <header class="iq-list-head"> + <h4>Logs</h4> + <span class="hint">{{ rows.length }} {{ t('logs') }}</span> + </header> + <div class="iq-stream-scroll"> + <LogStreamPanel :rows="rows" :selected-key="selectedKey" @select="openRow" @jump-trace="jumpToTrace($event.traceId, $event.ts)" /> + </div> + </article> + </div> + + <!-- Row click → shared full-payload popout (format-aware pretty-print + + copy + tag table + trace link). Escape / × closes it. --> + <LogDetailPopout :row="selectedRow" @close="closeDetail" @jump-trace="jumpToTrace($event.traceId, $event.ts)" /> + </div> +</template> + +<style scoped> +/* Mirrors ExploreView's `.iq-*` vocab — see the comment there for the + viewport-anchored min-height rationale. */ +.iq { display: flex; flex-direction: column; gap: 10px; min-height: calc(100vh - 52px); padding: 14px 16px; } +.iq-head h1 { font-size: 16px; margin: 0; } +.iq-sub { color: var(--sw-fg-3); font-size: 12px; margin: 2px 0 0; } +.iq-bar { display: flex; align-items: center; gap: 10px; } +.iq-bar-l { font-size: 11px; color: var(--sw-fg-3); font-weight: 500; } +.seg { display: inline-flex; border: 1px solid var(--sw-line-2); border-radius: 5px; overflow: hidden; } +.seg button { background: var(--sw-bg-2); color: var(--sw-fg-2); border: none; padding: 4px 14px; font: inherit; font-size: 12px; cursor: pointer; } +.seg button:disabled { opacity: 0.45; cursor: not-allowed; } +.seg.sm button { padding: 3px 10px; } +.seg button + button { border-left: 1px solid var(--sw-line-2); } +.seg button.on { background: var(--sw-accent); color: #fff; } +.iq-target { border: 1px solid var(--sw-line); border-radius: 6px; padding: 8px 10px; } +.iq-target-h { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; font-size: 11px; color: var(--sw-fg-2); font-weight: 600; } +.iq-target-h .dim { color: var(--sw-fg-4); font-weight: 400; } +.iq-link { background: none; border: none; color: var(--sw-accent); font-size: 11px; cursor: pointer; padding: 0; margin-left: auto; } +.iq-link:disabled { color: var(--sw-fg-4); cursor: not-allowed; } +.iq-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 10px; } +@media (max-width: 900px) { .iq-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 560px) { .iq-grid { grid-template-columns: 1fr; } } +.cf { display: flex; flex-direction: column; gap: 3px; font-size: 11px; color: var(--sw-fg-3); font-weight: 500; min-width: 0; } +.cf.cf-wide { grid-column: span 2; } +.cf.iq-time { grid-column-start: 1; } +.cf.iq-time.cf-wide { grid-column: 1 / span 2; } +.cf.cf-chk { justify-content: flex-end; } +.cf small { font-weight: 400; font-size: 9.5px; margin-left: 4px; font-style: italic; } +.iq-chk { display: inline-flex; align-items: center; gap: 6px; height: 28px; } +.iq-chk .dim { color: var(--sw-fg-4); } +.cf-input { + height: 28px; padding: 0 8px; background: var(--sw-bg-2); border: 1px solid var(--sw-line-2); + border-radius: 4px; color: var(--sw-fg-0); font: inherit; font-size: 11px; width: 100%; box-sizing: border-box; +} +.cf-input.mono { font-family: var(--sw-mono); } +.cf-input:disabled { opacity: 0.5; cursor: not-allowed; } +.cf-tas { display: block; width: 100%; } +.cf-tas :deep(.tas__trigger) { width: 100%; max-width: none; min-width: 0; height: 28px; padding: 0 8px; font-size: 11px; background: var(--sw-bg-2); border-radius: 4px; } +.cf-range { display: flex; align-items: center; gap: 4px; } +.cf-range-num { flex: 1; min-width: 0; } +.cf-range-sep { color: var(--sw-fg-3); font-size: 12px; flex: 0 0 auto; } +.iq-conditions-h { display: flex; align-items: center; gap: 12px; } +.iq-cond-kicker { margin-right: auto; } +.iq-run { background: var(--sw-accent); color: #fff; border: none; border-radius: 4px; padding: 5px 18px; font: inherit; font-size: 12px; cursor: pointer; height: 28px; order: 2; } +.iq-run:disabled { opacity: 0.5; cursor: not-allowed; } +.iq-resolved-tog { background: none; border: none; color: var(--sw-fg-3); font-size: 11px; cursor: pointer; } +.iq-resolved-tog .dim { color: var(--sw-fg-4); margin-left: 6px; } +.iq-resolved-body { margin: 0; padding: 8px 12px; font-family: var(--sw-mono); font-size: 11px; color: var(--sw-fg-2); background: var(--sw-bg-0); overflow: auto; max-height: 160px; border: 1px solid var(--sw-line); border-radius: 5px; } +.kicker { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--sw-accent); font-weight: 600; } + +.iq-form { display: flex; flex-direction: column; gap: 10px; min-width: 0; } +.iq-conditions { display: flex; flex-direction: column; gap: 10px; min-width: 0; } + +.iq-result { flex: 1; min-height: 0; display: flex; flex-direction: column; } +.iq-result > .iq-list-card { flex: 1; } +.iq-empty, .iq-err { padding: 24px; text-align: center; color: var(--sw-fg-3); font-size: 12px; } +.iq-err { color: var(--sw-err); } +.iq-list-card { padding: 0; display: flex; flex-direction: column; min-height: 0; max-height: calc(100vh - 80px); overflow: hidden; } +.iq-list-head { display: flex; align-items: baseline; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--sw-line); flex: 0 0 auto; } +.iq-list-head h4 { margin: 0; font-size: 12px; font-weight: 600; color: var(--sw-fg-0); } +.iq-list-head .hint { margin-left: auto; font-size: 10.5px; color: var(--sw-fg-3); } +.iq-stream-scroll { flex: 1; overflow-y: auto; min-height: 0; } +.mono { font-family: var(--sw-mono); } +</style> diff --git a/apps/ui/src/features/operate/explore/ExploreView.vue b/apps/ui/src/features/operate/explore/ExploreView.vue index dc0c466..682621c 100644 --- a/apps/ui/src/features/operate/explore/ExploreView.vue +++ b/apps/ui/src/features/operate/explore/ExploreView.vue @@ -15,8 +15,8 @@ limitations under the License. --> <!-- - Trace inspect / Log inspect — cross-layer trace/log query power-tools - (the `kind` prop picks which; two sidebar menus, one component). + Trace inspect — cross-layer trace query power-tool. (Log inspect is the + sibling ExploreLogView; both share the same `.iq-*` spine.) Layer-less by design: the entity is OPTIONAL. Name a service by PICKING it (a layer-filtered dropdown), TYPING it (service name + the real/ @@ -25,8 +25,7 @@ with the same waterfall the per-layer Traces tab uses; the resolved- query panel surfaces the exact condition the BFF ran. - This pass implements Trace · native. Trace · zipkin and the Log kinds - (raw / browser-error) land next on the same spine. + Source switches between SkyWalking-native and Zipkin traces. --> <script setup lang="ts"> import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'; @@ -53,13 +52,10 @@ import ZipkinTraceDetailCard from '@/render/widgets/ZipkinTraceDetailCard.vue'; import TraceDistribution from '@/render/widgets/TraceDistribution.vue'; import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue'; -const props = defineProps<{ kind: 'trace' | 'log' }>(); const { t } = useI18n(); const { availableLayers } = useLayers(); -const title = computed(() => (props.kind === 'trace' ? t('Trace inspect') : t('Log inspect'))); - -// ── source (zipkin / log sources land in the next increments) ───────── +// ── source (native / zipkin) ────────────────────────────────────────── type TraceSource = 'native' | 'zipkin'; const traceSource = ref<TraceSource>('native'); @@ -113,6 +109,7 @@ async function loadInstances(): Promise<void> { } async function loadEndpoints(): Promise<void> { + pickEndpointId.value = ''; const name = pickServiceName.value; if (!pickLayer.value || !name) { endpoints.value = []; @@ -314,8 +311,6 @@ const zipkinRows = ref<ZipkinTraceListRow[]>([]); const resolved = ref<ExploreResolved | null>(null); const showResolved = ref(false); -const canRun = computed(() => props.kind === 'trace'); - /** Build the window the request carries: explicit epoch-ms bounds in * Custom mode (datetime-local strings are browser-local; `Date.parse` * reads them as local), else the rolling minutes preset. */ @@ -376,6 +371,9 @@ async function runQuery(): Promise<void> { errorMsg.value = null; closeDetail(); brushedKeys.value = []; + native.value = null; + zipkinRows.value = []; + resolved.value = null; const zipkin = traceSource.value === 'zipkin'; const req = zipkin ? buildZipkinRequest() : buildNativeRequest(); try { @@ -500,14 +498,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true) <template> <div class="iq"> <header class="iq-head"> - <h1>{{ title }}</h1> + <h1>{{ t('Trace inspect') }}</h1> <p class="iq-sub">{{ t('Query any service across layers — pick it, type its name, or leave it blank.') }}</p> </header> - <div v-if="props.kind === 'log'" class="iq-coming">{{ t('Log inspect — coming in the next pass.') }}</div> - - <template v-else> - <div class="iq-bar"> + <div class="iq-bar"> <span class="iq-bar-l">{{ t('Source') }}</span> <div class="seg"> <button :class="{ on: traceSource === 'native' }" @click="traceSource = 'native'">{{ t('Native') }}</button> @@ -595,12 +590,12 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true) {{ showResolved ? '▾' : '▸' }} {{ t('Resolved query') }} <span class="dim">{{ resolved.source }}{{ resolved.backend ? ` · ${resolved.backend}` : '' }}</span> </button> - <button class="iq-run" :disabled="!canRun || running" @click="runQuery"> + <button class="iq-run" :disabled="running" @click="runQuery"> {{ running ? t('Running…') : t('Run query') }} </button> </div> <div class="iq-grid"> - <label class="cf"> + <label v-if="!isZipkin" class="cf"> <span>{{ t('Trace ID') }}</span> <input v-model="cond.traceId" class="cf-input mono" type="text" :placeholder="t('paste a trace id')" /> </label> @@ -693,6 +688,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true) <div class="iq-result"> <div v-if="!hasQueried" class="iq-empty">{{ t('Run a query — name a service or leave it blank.') }}</div> + <div v-else-if="running && rows.length === 0" class="iq-empty">{{ t('Reading data…') }}</div> <div v-else-if="errorMsg" class="iq-err">{{ errorMsg }}</div> <div v-else-if="rows.length === 0" class="iq-empty">{{ t('No traces in this window.') }}</div> @@ -750,7 +746,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true) </div> </section> </div> - </template> </div> </template> @@ -764,7 +759,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true) .iq { display: flex; flex-direction: column; gap: 10px; min-height: calc(100vh - 52px); padding: 14px 16px; } .iq-head h1 { font-size: 16px; margin: 0; } .iq-sub { color: var(--sw-fg-3); font-size: 12px; margin: 2px 0 0; } -.iq-coming { padding: 40px; text-align: center; color: var(--sw-fg-3); font-size: 13px; } .iq-bar { display: flex; align-items: center; gap: 10px; } .iq-bar-l { font-size: 11px; color: var(--sw-fg-3); font-weight: 500; } .seg { display: inline-flex; border: 1px solid var(--sw-line-2); border-radius: 5px; overflow: hidden; } diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json index 98a0799..773280d 100644 --- a/apps/ui/src/i18n/locales/de.json +++ b/apps/ui/src/i18n/locales/de.json @@ -1450,5 +1450,37 @@ "Use this to clear a stuck apply or coax laggard nodes to re-confirm the schema.": "Damit lässt sich ein hängendes Anwenden lösen oder säumige Nodes zur erneuten Bestätigung des Schemas bewegen.", "Apply status is no longer tracked — reload to see the stored rule.": "Apply-Status wird nicht mehr verfolgt — neu laden, um die gespeicherte Regel anzuzeigen.", "An ACTIVE rule is inactivated first, then reverted.": "Eine ACTIVE-Regel wird zuerst inaktiviert und dann zurückgesetzt.", - "rule is ACTIVE — inactivate first, then revert to bundled": "Regel ist ACTIVE — zuerst inaktivieren, dann auf Bundled zurücksetzen" + "rule is ACTIVE — inactivate first, then revert to bundled": "Regel ist ACTIVE — zuerst inaktivieren, dann auf Bundled zurücksetzen", + "All endpoints": "Alle Endpoints", + "All instances": "Alle Instanzen", + "Any layer": "Beliebiger Layer", + "Any service": "Beliebiger Service", + "Browser": "Browser", + "Copy": "Kopieren", + "Key": "Schlüssel", + "Keywords": "Schlüsselwörter", + "Log entry": "Log-Eintrag", + "Log inspect": "Log-Inspektion", + "No logs in this window.": "Keine Logs in diesem Zeitfenster.", + "Pick": "Auswählen", + "Query any service across layers — pick it, type its name, or leave it blank.": "Beliebigen Service über Layer hinweg abfragen — auswählen, Namen eingeben oder leer lassen.", + "Raw": "Roh", + "Reading…": "Lädt…", + "Real": "Real", + "Resolved query": "Aufgelöste Abfrage", + "Run a query — name a service or leave it blank.": "Abfrage ausführen — einen Service benennen oder leer lassen.", + "Running…": "Läuft…", + "Service name": "Service-Name", + "Time": "Zeit", + "Value": "Wert", + "coming next": "demnächst", + "e.g. agent::checkout": "z. B. agent::checkout", + "level=ERROR, …": "level=ERROR, …", + "logs": "Logs", + "off = virtual / peer": "aus = virtuell / peer", + "optional": "optional", + "optional — blank queries all services": "optional — leer fragt alle Services ab", + "paste a trace id": "Trace-ID einfügen", + "space- or comma-separated, AND-joined": "durch Leerzeichen oder Komma getrennt, mit AND verknüpft", + "→ edit as text": "→ als Text bearbeiten" } diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json index 72e8054..74cee77 100644 --- a/apps/ui/src/i18n/locales/en.json +++ b/apps/ui/src/i18n/locales/en.json @@ -1457,5 +1457,37 @@ "server send": "server send", "wire send": "wire send", "wire receive": "wire receive", - "error": "error" + "error": "error", + "All endpoints": "All endpoints", + "All instances": "All instances", + "Any layer": "Any layer", + "Any service": "Any service", + "Browser": "Browser", + "Copy": "Copy", + "Key": "Key", + "Keywords": "Keywords", + "Log entry": "Log entry", + "Log inspect": "Log inspect", + "No logs in this window.": "No logs in this window.", + "Pick": "Pick", + "Query any service across layers — pick it, type its name, or leave it blank.": "Query any service across layers — pick it, type its name, or leave it blank.", + "Raw": "Raw", + "Reading…": "Reading…", + "Real": "Real", + "Resolved query": "Resolved query", + "Run a query — name a service or leave it blank.": "Run a query — name a service or leave it blank.", + "Running…": "Running…", + "Service name": "Service name", + "Time": "Time", + "Value": "Value", + "coming next": "coming next", + "e.g. agent::checkout": "e.g. agent::checkout", + "level=ERROR, …": "level=ERROR, …", + "logs": "logs", + "off = virtual / peer": "off = virtual / peer", + "optional": "optional", + "optional — blank queries all services": "optional — blank queries all services", + "paste a trace id": "paste a trace id", + "space- or comma-separated, AND-joined": "space- or comma-separated, AND-joined", + "→ edit as text": "→ edit as text" } diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json index 3193901..5b94171 100644 --- a/apps/ui/src/i18n/locales/es.json +++ b/apps/ui/src/i18n/locales/es.json @@ -1450,5 +1450,37 @@ "Use this to clear a stuck apply or coax laggard nodes to re-confirm the schema.": "Úsalo para desatascar una aplicación bloqueada o para que los nodos rezagados vuelvan a confirmar el esquema.", "Apply status is no longer tracked — reload to see the stored rule.": "El estado de la aplicación ya no se rastrea — recarga para ver la regla almacenada.", "An ACTIVE rule is inactivated first, then reverted.": "Una regla ACTIVE se inactiva primero y luego se revierte.", - "rule is ACTIVE — inactivate first, then revert to bundled": "la regla está ACTIVE — inactívala primero y luego revierte a la bundled" + "rule is ACTIVE — inactivate first, then revert to bundled": "la regla está ACTIVE — inactívala primero y luego revierte a la bundled", + "All endpoints": "Todos los endpoints", + "All instances": "Todas las instancias", + "Any layer": "Cualquier Layer", + "Any service": "Cualquier Service", + "Browser": "Browser", + "Copy": "Copiar", + "Key": "Clave", + "Keywords": "Palabras clave", + "Log entry": "Entrada de log", + "Log inspect": "Inspección de logs", + "No logs in this window.": "No hay logs en esta ventana.", + "Pick": "Elegir", + "Query any service across layers — pick it, type its name, or leave it blank.": "Consulta cualquier Service entre Layers: selecciónalo, escribe su nombre o déjalo en blanco.", + "Raw": "Sin procesar", + "Reading…": "Leyendo…", + "Real": "Real", + "Resolved query": "Consulta resuelta", + "Run a query — name a service or leave it blank.": "Ejecuta una consulta: nombra un Service o déjalo en blanco.", + "Running…": "Ejecutando…", + "Service name": "Nombre del Service", + "Time": "Tiempo", + "Value": "Valor", + "coming next": "próximamente", + "e.g. agent::checkout": "p. ej. agent::checkout", + "level=ERROR, …": "level=ERROR, …", + "logs": "logs", + "off = virtual / peer": "desactivado = virtual / peer", + "optional": "opcional", + "optional — blank queries all services": "opcional: en blanco consulta todos los Services", + "paste a trace id": "pega un Trace ID", + "space- or comma-separated, AND-joined": "separadas por espacio o coma, unidas con AND", + "→ edit as text": "→ editar como texto" } diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json index 030b322..8e34963 100644 --- a/apps/ui/src/i18n/locales/fr.json +++ b/apps/ui/src/i18n/locales/fr.json @@ -1450,5 +1450,37 @@ "Use this to clear a stuck apply or coax laggard nodes to re-confirm the schema.": "À utiliser pour débloquer une application figée ou inciter les nœuds en retard à reconfirmer le schéma.", "Apply status is no longer tracked — reload to see the stored rule.": "L’état de l’application n’est plus suivi — rechargez pour voir la règle enregistrée.", "An ACTIVE rule is inactivated first, then reverted.": "Une règle ACTIVE est d’abord désactivée, puis rétablie.", - "rule is ACTIVE — inactivate first, then revert to bundled": "la règle est ACTIVE — désactivez-la d’abord, puis rétablissez la version bundled" + "rule is ACTIVE — inactivate first, then revert to bundled": "la règle est ACTIVE — désactivez-la d’abord, puis rétablissez la version bundled", + "All endpoints": "Tous les endpoints", + "All instances": "Toutes les instances", + "Any layer": "N'importe quel Layer", + "Any service": "N'importe quel Service", + "Browser": "Browser", + "Copy": "Copier", + "Key": "Clé", + "Keywords": "Mots-clés", + "Log entry": "Entrée de log", + "Log inspect": "Inspection des logs", + "No logs in this window.": "Aucun log dans cette fenêtre.", + "Pick": "Choisir", + "Query any service across layers — pick it, type its name, or leave it blank.": "Interrogez n'importe quel Service entre les Layers — sélectionnez-le, saisissez son nom ou laissez vide.", + "Raw": "Brut", + "Reading…": "Lecture…", + "Real": "Réel", + "Resolved query": "Requête résolue", + "Run a query — name a service or leave it blank.": "Lancez une requête — nommez un Service ou laissez vide.", + "Running…": "En cours…", + "Service name": "Nom du Service", + "Time": "Temps", + "Value": "Valeur", + "coming next": "à venir", + "e.g. agent::checkout": "p. ex. agent::checkout", + "level=ERROR, …": "level=ERROR, …", + "logs": "logs", + "off = virtual / peer": "désactivé = virtuel / peer", + "optional": "facultatif", + "optional — blank queries all services": "facultatif — vide interroge tous les Services", + "paste a trace id": "collez un Trace ID", + "space- or comma-separated, AND-joined": "séparés par un espace ou une virgule, joints par AND", + "→ edit as text": "→ éditer en texte" } diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json index 7bb58cb..ed9e529 100644 --- a/apps/ui/src/i18n/locales/ja.json +++ b/apps/ui/src/i18n/locales/ja.json @@ -1450,5 +1450,37 @@ "Use this to clear a stuck apply or coax laggard nodes to re-confirm the schema.": "停止した適用の解消や、追従が遅れているノードにスキーマの再確認を促すために使用します。", "Apply status is no longer tracked — reload to see the stored rule.": "適用ステータスは追跡されていません — 再読み込みして保存済みのルールを確認してください。", "An ACTIVE rule is inactivated first, then reverted.": "ACTIVE のルールは、まず無効化してから元に戻します。", - "rule is ACTIVE — inactivate first, then revert to bundled": "ルールは ACTIVE です — 先に無効化してから bundled に戻してください" + "rule is ACTIVE — inactivate first, then revert to bundled": "ルールは ACTIVE です — 先に無効化してから bundled に戻してください", + "All endpoints": "すべてのエンドポイント", + "All instances": "すべてのインスタンス", + "Any layer": "任意の Layer", + "Any service": "任意の Service", + "Browser": "Browser", + "Copy": "コピー", + "Key": "キー", + "Keywords": "キーワード", + "Log entry": "ログエントリ", + "Log inspect": "ログ調査", + "No logs in this window.": "この時間範囲にログはありません。", + "Pick": "選択", + "Query any service across layers — pick it, type its name, or leave it blank.": "Layer をまたいで任意の Service を検索 — 選択するか、名前を入力するか、空のままにします。", + "Raw": "生ログ", + "Reading…": "読み込み中…", + "Real": "実体", + "Resolved query": "解決済みクエリ", + "Run a query — name a service or leave it blank.": "クエリを実行 — Service を指定するか、空のままにします。", + "Running…": "実行中…", + "Service name": "Service 名", + "Time": "時間", + "Value": "値", + "coming next": "近日対応", + "e.g. agent::checkout": "例:agent::checkout", + "level=ERROR, …": "level=ERROR, …", + "logs": "件のログ", + "off = virtual / peer": "オフ = 仮想 / peer", + "optional": "任意", + "optional — blank queries all services": "任意 — 空の場合はすべての Service を検索", + "paste a trace id": "Trace ID を貼り付け", + "space- or comma-separated, AND-joined": "スペースまたはカンマ区切り、AND 結合", + "→ edit as text": "→ テキストで編集" } diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json index dc67187..590be15 100644 --- a/apps/ui/src/i18n/locales/ko.json +++ b/apps/ui/src/i18n/locales/ko.json @@ -1450,5 +1450,37 @@ "Use this to clear a stuck apply or coax laggard nodes to re-confirm the schema.": "멈춘 적용을 정리하거나 뒤처진 노드가 스키마를 다시 확인하도록 유도할 때 사용하세요.", "Apply status is no longer tracked — reload to see the stored rule.": "적용 상태가 더 이상 추적되지 않습니다 — 다시 로드하여 저장된 규칙을 확인하세요.", "An ACTIVE rule is inactivated first, then reverted.": "ACTIVE 규칙은 먼저 비활성화한 뒤 되돌립니다.", - "rule is ACTIVE — inactivate first, then revert to bundled": "규칙이 ACTIVE 상태입니다 — 먼저 비활성화한 뒤 bundled로 되돌리세요" + "rule is ACTIVE — inactivate first, then revert to bundled": "규칙이 ACTIVE 상태입니다 — 먼저 비활성화한 뒤 bundled로 되돌리세요", + "All endpoints": "모든 엔드포인트", + "All instances": "모든 인스턴스", + "Any layer": "모든 Layer", + "Any service": "모든 Service", + "Browser": "Browser", + "Copy": "복사", + "Key": "키", + "Keywords": "키워드", + "Log entry": "로그 항목", + "Log inspect": "로그 조회", + "No logs in this window.": "이 기간에 로그가 없습니다.", + "Pick": "선택", + "Query any service across layers — pick it, type its name, or leave it blank.": "Layer 전반에서 임의의 Service를 조회 — 선택하거나 이름을 입력하거나 비워 둡니다.", + "Raw": "원본", + "Reading…": "읽는 중…", + "Real": "실제", + "Resolved query": "해석된 쿼리", + "Run a query — name a service or leave it blank.": "쿼리 실행 — Service를 지정하거나 비워 둡니다.", + "Running…": "실행 중…", + "Service name": "Service 이름", + "Time": "시간", + "Value": "값", + "coming next": "곧 제공", + "e.g. agent::checkout": "예: agent::checkout", + "level=ERROR, …": "level=ERROR, …", + "logs": "개 로그", + "off = virtual / peer": "꺼짐 = 가상 / peer", + "optional": "선택", + "optional — blank queries all services": "선택 — 비워 두면 모든 Service 조회", + "paste a trace id": "Trace ID 붙여넣기", + "space- or comma-separated, AND-joined": "공백 또는 쉼표로 구분, AND 결합", + "→ edit as text": "→ 텍스트로 편집" } diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json index 7d163eb..38d7de1 100644 --- a/apps/ui/src/i18n/locales/pt.json +++ b/apps/ui/src/i18n/locales/pt.json @@ -1450,5 +1450,37 @@ "Use this to clear a stuck apply or coax laggard nodes to re-confirm the schema.": "Use isto para destravar uma aplicação presa ou induzir nodes atrasados a reconfirmar o schema.", "Apply status is no longer tracked — reload to see the stored rule.": "O status da aplicação não é mais rastreado — recarregue para ver a regra armazenada.", "An ACTIVE rule is inactivated first, then reverted.": "Uma regra ACTIVE é inativada primeiro e depois revertida.", - "rule is ACTIVE — inactivate first, then revert to bundled": "a regra está ACTIVE — inative-a primeiro e depois reverta para a bundled" + "rule is ACTIVE — inactivate first, then revert to bundled": "a regra está ACTIVE — inative-a primeiro e depois reverta para a bundled", + "All endpoints": "Todos os endpoints", + "All instances": "Todas as instâncias", + "Any layer": "Qualquer Layer", + "Any service": "Qualquer Service", + "Browser": "Browser", + "Copy": "Copiar", + "Key": "Chave", + "Keywords": "Palavras-chave", + "Log entry": "Entrada de log", + "Log inspect": "Inspeção de logs", + "No logs in this window.": "Nenhum log nesta janela.", + "Pick": "Escolher", + "Query any service across layers — pick it, type its name, or leave it blank.": "Consulte qualquer Service entre Layers — selecione, digite o nome ou deixe em branco.", + "Raw": "Brutos", + "Reading…": "Lendo…", + "Real": "Real", + "Resolved query": "Consulta resolvida", + "Run a query — name a service or leave it blank.": "Execute uma consulta — nomeie um Service ou deixe em branco.", + "Running…": "Executando…", + "Service name": "Nome do Service", + "Time": "Tempo", + "Value": "Valor", + "coming next": "em breve", + "e.g. agent::checkout": "ex. agent::checkout", + "level=ERROR, …": "level=ERROR, …", + "logs": "logs", + "off = virtual / peer": "desligado = virtual / peer", + "optional": "opcional", + "optional — blank queries all services": "opcional — em branco consulta todos os Services", + "paste a trace id": "cole um Trace ID", + "space- or comma-separated, AND-joined": "separadas por espaço ou vírgula, unidas com AND", + "→ edit as text": "→ editar como texto" } diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json index 80fd191..8df8bef 100644 --- a/apps/ui/src/i18n/locales/zh-CN.json +++ b/apps/ui/src/i18n/locales/zh-CN.json @@ -1450,5 +1450,37 @@ "Use this to clear a stuck apply or coax laggard nodes to re-confirm the schema.": "用于清除卡住的应用,或促使滞后的节点重新确认 schema。", "Apply status is no longer tracked — reload to see the stored rule.": "应用状态已不再被跟踪 —— 重新加载以查看已存储的规则。", "An ACTIVE rule is inactivated first, then reverted.": "ACTIVE 规则会先被停用,然后再回退。", - "rule is ACTIVE — inactivate first, then revert to bundled": "规则处于 ACTIVE 状态 —— 请先停用,然后再回退到 bundled" + "rule is ACTIVE — inactivate first, then revert to bundled": "规则处于 ACTIVE 状态 —— 请先停用,然后再回退到 bundled", + "All endpoints": "所有端点", + "All instances": "所有实例", + "Any layer": "任意 Layer", + "Any service": "任意 Service", + "Browser": "Browser", + "Copy": "复制", + "Key": "键", + "Keywords": "关键字", + "Log entry": "日志条目", + "Log inspect": "日志查询", + "No logs in this window.": "此时间范围内没有日志。", + "Pick": "选择", + "Query any service across layers — pick it, type its name, or leave it blank.": "跨 Layer 查询任意 Service —— 选择、输入名称,或留空。", + "Raw": "原始日志", + "Reading…": "读取中…", + "Real": "真实", + "Resolved query": "解析后的查询", + "Run a query — name a service or leave it blank.": "执行查询 —— 指定一个 Service 或留空。", + "Running…": "执行中…", + "Service name": "Service 名称", + "Time": "时间", + "Value": "值", + "coming next": "即将推出", + "e.g. agent::checkout": "例如 agent::checkout", + "level=ERROR, …": "level=ERROR, …", + "logs": "条日志", + "off = virtual / peer": "关闭 = 虚拟 / peer", + "optional": "可选", + "optional — blank queries all services": "可选 —— 留空则查询所有 Service", + "paste a trace id": "粘贴 Trace ID", + "space- or comma-separated, AND-joined": "以空格或逗号分隔,按 AND 组合", + "→ edit as text": "→ 改为文本编辑" } diff --git a/apps/ui/src/layer/logs/LayerLogsView.vue b/apps/ui/src/layer/logs/LayerLogsView.vue index 6d21655..fd91562 100644 --- a/apps/ui/src/layer/logs/LayerLogsView.vue +++ b/apps/ui/src/layer/logs/LayerLogsView.vue @@ -40,7 +40,8 @@ import { useSelectedEndpoint } from '@/layer/useSelectedEndpoint'; import { useLayerServiceName } from '@/layer/useLayerServiceName'; import { useSetupStore } from '@/state/setup'; import { useTracePopout } from '@/layer/traces/useTracePopout'; -import { parseServiceName } from '@/utils/serviceName'; +import LogStreamPanel from '@/render/widgets/LogStreamPanel.vue'; +import LogDetailPopout from '@/render/widgets/LogDetailPopout.vue'; const route = useRoute(); const layerKey = computed(() => String(route.params.layerKey ?? '')); @@ -455,149 +456,12 @@ const levelFacet = computed<Record<Level, number>>(() => { // reflect it — no client-side narrowing needed. const filteredLogs = computed<LogRow[]>(() => logs.value); -// Row keys for `<template v-for>`. Inline expand is gone — click -// now opens the full-canvas popout via `onRowClick(r)`. -function rowKey(r: LogRow, idx: number): string { - return `${r.timestamp}-${r.traceId ?? ''}-${idx}`; -} - -function fmtTime(ts: number): string { - const d = new Date(ts); - const pad = (n: number) => String(n).padStart(2, '0'); - return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`; -} -function fmtDate(ts: number): string { - // Numeric MM-DD in the operator's browser-local TZ. Matches the - // topbar's compact date chip (`05-12 03h → 06h`) and stays - // locale-independent — `toLocaleDateString` without an explicit - // locale picks up the system locale (e.g. `5月08日` on zh-CN), which - // looked inconsistent next to the rest of the dense English UI. - const d = new Date(ts); - const pad = (n: number) => String(n).padStart(2, '0'); - return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; -} -/** - * Render a one-line inline preview. Length is NOT capped here — the - * row's CSS (`.lg-content-body { white-space: nowrap; overflow: - * hidden; text-overflow: ellipsis }`) clips at the actual visible - * width, so wider viewports surface more of the payload while narrow - * ones still get a clean ellipsis. The slicing-at-220-chars heuristic - * I had before always cut at the same offset regardless of width. - * - * - JSON: re-serialize to the tightest single-line form so the row - * reads as `{"level":"error","msg":"…"}` and not the raw - * whitespace-laden source. - * - YAML: collapse newlines into a single space so the row still - * carries the keys (`apiVersion: v1 kind: Pod spec: containers: - * - name: nginx`). Indentation chars stay, which preserves a - * visual sense of the hierarchy even on one line. The popout is - * the right place to read the proper multi-line structure. - * - Text: whitespace collapsed, same as before. - */ -function summariseContent(r: LogRow): string { - if (!r.content) return ''; - const fmt = detectFormat(r); - if (fmt === 'json') { - try { - return JSON.stringify(JSON.parse(r.content)); - } catch { - /* fall through to the plain compaction below */ - } - } - if (fmt === 'yaml') { - // Replace newlines with a single space — keeps the indentation - // characters that sit at the start of each line, which gives the - // operator a visual cue of nesting depth even when flattened. - return r.content.replace(/\n+/g, ' ').trim(); - } - // Plain text — collapse any runs of whitespace. - return r.content.replace(/\s+/g, ' ').trim(); -} -/** With the new flattening rule above, every payload has a usable - * inline preview — JSON, YAML, and multi-line text all flatten to - * one line cleanly. We no longer need a hidden-payload affordance. - * Returns false unconditionally; kept so the template doesn't have - * to change. */ -function hasHiddenPayload(_r: LogRow): boolean { - return false; -} -function tryPrettyJson(content: string): string { - try { - return JSON.stringify(JSON.parse(content), null, 2); - } catch { - return content; - } -} - -/** - * Detect the rendered format of a log payload. OAP only labels - * JSON / TEXT, so we sniff for YAML markers (leading `---`, top-level - * `key:` mappings) and JSON-ness as a fallback so operators get the - * right pretty-printer in the popout. The detection is best-effort — - * an ambiguous payload falls back to TEXT. - */ -type LogFormat = 'json' | 'yaml' | 'text'; -function detectFormat(r: LogRow): LogFormat { - if (r.contentType === 'application/json') return 'json'; - const trimmed = r.content?.trim() ?? ''; - if (!trimmed) return 'text'; - // JSON sniff — content-type missing but body parses cleanly. - if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || - (trimmed.startsWith('[') && trimmed.endsWith(']'))) { - try { JSON.parse(trimmed); return 'json'; } catch { /* fallthrough */ } - } - // YAML sniff — document marker or 2+ top-level `key:` mappings. - if (trimmed.startsWith('---') || trimmed.startsWith('apiVersion:')) return 'yaml'; - const lines = trimmed.split('\n'); - if (lines.length >= 2) { - const topLevelMaps = lines.filter((l) => /^[A-Za-z_][\w.-]*\s*:\s*(\S|$)/.test(l)).length; - if (topLevelMaps >= 2) return 'yaml'; - } - return 'text'; -} -function prettyForFormat(r: LogRow, fmt: LogFormat): string { - if (fmt === 'json') return tryPrettyJson(r.content); - return r.content; -} - -// ── Log payload popout — operator-triggered modal for inspecting a -// single row's full payload at maximum size, with format-aware -// pretty-print + a copy button + key/value tag table. The inline -// expand below the row stays as a quick peek; the popout is the -// "give me the whole thing" view. +// ── Log payload popout — a row click opens the shared LogDetailPopout +// (format-aware pretty-print + copy + key/value tag table + trace link). +// The popout owns its own Escape / close + format detection. const popoutRow = ref<LogRow | null>(null); -function openPopout(r: LogRow): void { popoutRow.value = r; } -function closePopout(): void { popoutRow.value = null; } -async function copyPopout(): Promise<void> { - if (!popoutRow.value) return; - try { - await navigator.clipboard.writeText(popoutRow.value.content); - } catch { - /* clipboard may be blocked; silently no-op */ - } -} -// ESC closes the popout. Bound on the window so it works whether or -// not the popout has keyboard focus (clicking the modal sometimes -// drops focus into the underlying row). -function onGlobalKeydown(ev: KeyboardEvent): void { - if (ev.key === 'Escape' && popoutRow.value) { - ev.preventDefault(); - closePopout(); - } -} -if (typeof window !== 'undefined') { - window.addEventListener('keydown', onGlobalKeydown); -} - -/** - * Row click → open popout. The inline expand is intentionally gone — - * for multi-line / YAML / JSON payloads it rendered as a cramped strip - * inside the row band; the popout has the full canvas. Trace-id chip - * and group decoder still propagate stop-events so their own clicks - * don't bubble. - */ function onRowClick(r: LogRow): void { - openPopout(r); + popoutRow.value = r; } // Custom hover tooltip state for the density bar. Native browser @@ -862,48 +726,16 @@ function jumpToTrace(traceId: string, ts?: number): void { <div v-if="filteredLogs.length === 0" class="lg-empty"> {{ logs.length === 0 ? 'No logs returned for this scope.' : 'No logs match the active filters.' }} </div> - <div v-else class="lg-stream"> - <template v-for="(r, idx) in filteredLogs" :key="rowKey(r, idx)"> - <!-- Row click → open the popout. Inline expand removed: - YAML / JSON / multi-line text rendered cramped inside - the row band and reads as truncated. The popout has - the full canvas + format-aware pretty-print. --> - <div class="lg-row" :class="`lv-${levelOf(r)}`" @click="onRowClick(r)"> - <span class="lg-time mono">{{ fmtTime(r.timestamp) }}</span> - <span class="lg-date mono dim">{{ fmtDate(r.timestamp) }}</span> - <span class="lg-lvl" :style="{ color: LEVEL_COLOR[levelOf(r)] }">{{ levelOf(r) }}</span> - <span class="lg-svc mono dim"> - <span - v-if="r.serviceName && parseServiceName(r.serviceName).group" - class="lg-svc-group" - >{{ parseServiceName(r.serviceName).group }}</span> - {{ r.serviceName ? parseServiceName(r.serviceName).base : '—' }} - </span> - <!-- The trace-link slot is ALWAYS rendered, even when the - row has no traceId — its 60px grid column is fixed, - and skipping the slot collapses every subsequent - column leftward (the 1fr content cell ends up sized - at 60px, truncating the preview to two characters). --> - <span v-if="r.traceId" class="lg-trace mono" @click.stop="jumpToTrace(r.traceId!, r.timestamp)">↗ trace</span> - <span v-else class="lg-trace-spacer" aria-hidden="true"></span> - <!-- Format chip + flat content preview. Chip is always - rendered so the operator can tell at-a-glance which - rows are JSON / YAML / plain text. Preview is single - line, length-capped, and trimmed-with-ellipsis via - `.lg-content` CSS so even when JSON contains long - strings the row stays one line. --> - <span class="lg-content mono"> - <span class="lg-fmt-chip" :class="`fmt-${detectFormat(r)}`">{{ detectFormat(r).toUpperCase() }}</span> - <span class="lg-content-body"> - <template v-if="hasHiddenPayload(r)"> - <em class="lg-content-hint">click to view</em> - </template> - <template v-else>{{ summariseContent(r) }}</template> - </span> - </span> - </div> - </template> - </div> + <!-- Row click → open the full-payload popout. The dense row + rendering is the shared `LogStreamPanel` (same markup the + cross-layer Log inspect uses); the popout + density bar + + facets stay in this view. --> + <LogStreamPanel + v-else + :rows="filteredLogs" + @select="onRowClick($event.row)" + @jump-trace="jumpToTrace($event.traceId, $event.ts)" + /> <div class="lg-pager"> <span class="hint">page {{ page }} · showing {{ filteredLogs.length }} of {{ total }} total</span> <div class="lg-pager-ctrls"> @@ -919,58 +751,13 @@ function jumpToTrace(traceId: string, ts?: number): void { </div> </section> - <!-- Full-payload popout. Triggered from a row's expanded panel. - Format-aware pretty-print + copy button + tag table. Escape - or backdrop click closes. --> - <div v-if="popoutRow" class="lg-popout-backdrop" @click.self="closePopout"> - <article class="lg-popout sw-card"> - <header class="lg-popout-head"> - <div> - <span class="kicker">Log entry</span> - <span class="lg-popout-time mono">{{ fmtTime(popoutRow.timestamp) }} · {{ fmtDate(popoutRow.timestamp) }}</span> - <span class="lg-popout-fmt">{{ detectFormat(popoutRow).toUpperCase() }}</span> - </div> - <div class="lg-popout-ctrls"> - <button class="sw-btn small" type="button" @click="copyPopout">Copy</button> - <button - v-if="popoutRow.traceId" - class="sw-btn small" - type="button" - @click="jumpToTrace(popoutRow.traceId!, popoutRow.timestamp)" - >↗ trace</button> - <button class="sw-btn small ghost" type="button" @click="closePopout">×</button> - </div> - </header> - <div class="lg-popout-meta"> - <span v-if="popoutRow.serviceName" class="lg-meta">service <code>{{ popoutRow.serviceName }}</code></span> - <span v-if="popoutRow.serviceInstanceName" class="lg-meta">instance <code>{{ popoutRow.serviceInstanceName }}</code></span> - <span v-if="popoutRow.endpointName" class="lg-meta">endpoint <code>{{ popoutRow.endpointName }}</code></span> - <span v-if="popoutRow.traceId" class="lg-meta">trace <code class="mono">{{ popoutRow.traceId }}</code></span> - </div> - <div class="lg-popout-split"> - <pre - v-if="detectFormat(popoutRow) === 'json'" - class="lg-popout-body json" - >{{ prettyForFormat(popoutRow, 'json') }}</pre> - <pre - v-else-if="detectFormat(popoutRow) === 'yaml'" - class="lg-popout-body yaml" - >{{ popoutRow.content }}</pre> - <pre v-else class="lg-popout-body text">{{ popoutRow.content }}</pre> - <aside v-if="popoutRow.tags.length > 0" class="lg-popout-tags"> - <table class="lg-popout-tag-tbl"> - <thead><tr><th>Key</th><th>Value</th></tr></thead> - <tbody> - <tr v-for="t in popoutRow.tags" :key="`${t.key}=${t.value}`"> - <td class="mono">{{ t.key }}</td> - <td class="mono">{{ t.value }}</td> - </tr> - </tbody> - </table> - </aside> - </div> - </article> - </div> + <!-- Full-payload popout. Format-aware pretty-print + copy button + + tag table + trace link. Escape or backdrop click closes. --> + <LogDetailPopout + :row="popoutRow" + @close="popoutRow = null" + @jump-trace="jumpToTrace($event.traceId, $event.ts)" + /> </div> </template> @@ -1119,21 +906,6 @@ function jumpToTrace(traceId: string, ts?: number): void { .cf-combo-item.on { background: var(--sw-accent-soft); color: var(--sw-accent-2); font-weight: 600; } .cf-combo-empty { padding: 6px 8px; font-size: 10.5px; color: var(--sw-fg-3); } -/* Group-prefix chip in a log row's service column. Decodes - `<group>::<base>` so the eye lands on the base name first. */ -.lg-svc-group { - display: inline-block; - padding: 0 5px; - margin-right: 4px; - background: var(--sw-bg-2); - border: 1px solid var(--sw-line-2); - border-radius: 3px; - font-size: 9.5px; - font-weight: 600; - letter-spacing: 0.04em; - color: var(--sw-fg-2); - text-transform: uppercase; -} .f-field { display: inline-flex; align-items: baseline; @@ -1299,149 +1071,12 @@ function jumpToTrace(traceId: string, ts?: number): void { .lg-density-axis .t-tick:first-child { text-align: left; } .lg-density-axis .t-tick:last-child { text-align: right; } -/* Row-content hint when the inline preview is suppressed (multi-line, - YAML, JSON). Italic gray so it reads as "this is a placeholder, not - the actual content". */ -.lg-content-hint { - color: var(--sw-fg-3); - font-style: italic; - font-size: 10.5px; - letter-spacing: 0.04em; -} -.lg-row { cursor: pointer; } .lg-empty { padding: 32px; text-align: center; color: var(--sw-fg-3); font-size: 11.5px; } -.lg-stream { - flex: 1; - overflow-y: auto; - font-size: 11.5px; -} -.lg-row { - display: grid; - grid-template-columns: 80px 60px 56px 140px 60px 1fr; - gap: 10px; - align-items: center; - padding: 4px 12px; - border-bottom: 1px solid var(--sw-line); - cursor: pointer; -} -.lg-row:hover { background: var(--sw-bg-2); } -.lg-row.lv-error { box-shadow: inset 3px 0 0 var(--sw-err); } -.lg-row.lv-warn { box-shadow: inset 3px 0 0 var(--sw-warn); } -.lg-time { font-family: var(--sw-mono); color: var(--sw-fg-1); } -.lg-date { font-size: 10px; } -.lg-lvl { - font-size: 9.5px; - text-transform: uppercase; - letter-spacing: 0.06em; - font-weight: 700; -} -.lg-svc { font-size: 10.5px; } -.lg-trace { - font-size: 10px; - color: var(--sw-accent-2); - cursor: pointer; - padding: 1px 5px; - background: var(--sw-accent-soft); - border: 1px solid var(--sw-accent-line); - border-radius: 3px; -} -.lg-trace:hover { color: var(--sw-fg-0); } -.lg-content { - font-family: var(--sw-mono); - font-size: 11px; - color: var(--sw-fg-1); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; -} -/* Format chip — small uppercase tag rendered before the preview. Per- - format color keeps JSON / YAML / TEXT visually distinct without - adding chrome. Tabular-nums + monospace so the three chips align. */ -.lg-fmt-chip { - flex: 0 0 auto; - display: inline-block; - padding: 0 5px; - height: 14px; - line-height: 14px; - font-size: 9px; - font-weight: 700; - letter-spacing: 0.04em; - border-radius: 3px; - text-transform: uppercase; - font-family: var(--sw-mono); -} -.lg-fmt-chip.fmt-json { background: var(--sw-info-soft); color: var(--sw-info); } -.lg-fmt-chip.fmt-yaml { background: rgba(251, 191, 36, 0.18); color: #fbbf24; } -.lg-fmt-chip.fmt-text { background: var(--sw-bg-3); color: var(--sw-fg-2); } -.lg-content-body { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.lg-expand { - padding: 10px 14px 14px 28px; - border-bottom: 1px solid var(--sw-line); - background: var(--sw-bg-2); -} -.lg-payload { - margin: 0 0 8px; - padding: 8px 10px; - background: var(--sw-bg-1); - border: 1px solid var(--sw-line); - border-radius: 4px; - font-family: var(--sw-mono); - font-size: 11px; - color: var(--sw-fg-1); - white-space: pre-wrap; - word-break: break-word; - max-height: 280px; - overflow: auto; -} -.lg-payload.json { color: var(--sw-cyan); } -.lg-tag-row { - display: flex; - flex-wrap: wrap; - gap: 5px; - margin-bottom: 6px; -} -.lg-tag { - display: inline-flex; - align-items: baseline; - gap: 4px; - font-size: 10.5px; - padding: 1px 6px; - background: var(--sw-bg-1); - border: 1px solid var(--sw-line); - border-radius: 3px; -} -.lg-tag-k { color: var(--sw-fg-3); } -.lg-tag-v { color: var(--sw-fg-1); } -.lg-meta-row { - display: flex; - gap: 14px; - flex-wrap: wrap; - font-size: 10.5px; - color: var(--sw-fg-3); -} -.lg-meta code { - font-family: var(--sw-mono); - color: var(--sw-fg-2); - background: var(--sw-bg-1); - padding: 1px 4px; - border-radius: 3px; -} - .lg-pager { display: flex; align-items: center; @@ -1467,32 +1102,6 @@ function jumpToTrace(traceId: string, ts?: number): void { .lg-legend-chip { padding: 2px 7px; font-size: 11px; } } -/* Trace-ID + tag-key=value inputs on the conditions bar. */ -.lg-trace-input { - height: 26px; - width: 220px; - padding: 0 8px; - background: var(--sw-bg-2); - border: 1px solid var(--sw-line-2); - border-radius: 4px; - color: var(--sw-fg-0); - font: inherit; - font-size: 11px; -} -.lg-trace-input:focus { outline: none; border-color: var(--sw-accent-line); } -.lg-tag-input { - height: 26px; - width: 220px; - padding: 0 8px; - background: var(--sw-bg-2); - border: 1px solid var(--sw-line-2); - border-radius: 4px; - color: var(--sw-fg-0); - font: inherit; - font-size: 11px; -} -.lg-tag-input:focus { outline: none; border-color: var(--sw-accent-line); } - /* Active tag chips — markup + visuals lifted from `LayerTracesView` so the two pages read identically. */ .tr-tag-row { @@ -1527,103 +1136,4 @@ function jumpToTrace(traceId: string, ts?: number): void { } .tag-x:hover { color: var(--sw-err); } -/* YAML payload colour (cyan-leaning to distinguish from JSON which - is also cyan; warm-yellow scheme conveys "config / declarative"). */ -.lg-payload.yaml { color: #fbbf24; } - -/* Full-payload popout — fixed, centred, modal-like overlay. */ -.lg-popout-backdrop { - position: fixed; - inset: 0; - background: rgba(0,0,0,0.55); - display: flex; - align-items: center; - justify-content: center; - z-index: 100; - padding: 24px; -} -.lg-popout { - width: min(1100px, 92vw); - min-height: 70vh; - max-height: 92vh; - display: flex; - flex-direction: column; - overflow: hidden; -} -.lg-popout-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px 14px; - border-bottom: 1px solid var(--sw-line); -} -.lg-popout-head .kicker { margin-right: 8px; } -.lg-popout-time { font-size: 11px; color: var(--sw-fg-2); margin-right: 10px; } -.lg-popout-fmt { - display: inline-block; - padding: 1px 8px; - border-radius: 10px; - background: var(--sw-accent-soft); - color: var(--sw-accent-2); - font-size: 10px; - font-weight: 600; - letter-spacing: 0.04em; -} -.lg-popout-ctrls { display: inline-flex; gap: 6px; } -.lg-popout-meta { - display: flex; - flex-wrap: wrap; - gap: 12px; - padding: 8px 14px; - border-bottom: 1px dashed var(--sw-line); - font-size: 11px; - color: var(--sw-fg-3); -} -.lg-popout-meta code { - font-family: var(--sw-mono); - color: var(--sw-fg-1); - padding: 0 4px; - background: var(--sw-bg-2); - border-radius: 3px; -} -.lg-popout-split { flex: 1; min-height: 0; display: flex; } -.lg-popout-body { - flex: 1; - min-height: 0; - min-width: 0; - margin: 0; - padding: 12px 14px; - background: var(--sw-bg-0); - font-family: var(--sw-mono); - font-size: 12px; - line-height: 1.55; - color: var(--sw-fg-0); - white-space: pre-wrap; - word-break: break-all; - overflow: auto; -} -.lg-popout-body.json { color: var(--sw-cyan); } -.lg-popout-body.yaml { color: #fbbf24; } -.lg-popout-tags { - flex: 0 0 340px; - border-left: 1px solid var(--sw-line); - padding: 8px 14px 12px; - overflow: auto; - background: var(--sw-bg-1); -} -.lg-popout-tag-tbl { - width: 100%; - table-layout: fixed; - border-collapse: collapse; - font-size: 11px; -} -.lg-popout-tag-tbl td { word-break: break-all; vertical-align: top; } -.lg-popout-tag-tbl th, .lg-popout-tag-tbl td { - padding: 4px 10px; - text-align: left; - border-bottom: 1px solid var(--sw-line); -} -.lg-popout-tag-tbl th { color: var(--sw-fg-3); font-weight: 500; } -.lg-popout-tag-tbl td { color: var(--sw-fg-1); font-family: var(--sw-mono); } </style> diff --git a/apps/ui/src/render/widgets/LogDetailPopout.vue b/apps/ui/src/render/widgets/LogDetailPopout.vue new file mode 100644 index 0000000..7a0a669 --- /dev/null +++ b/apps/ui/src/render/widgets/LogDetailPopout.vue @@ -0,0 +1,186 @@ +<!-- + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<!-- + Shared full-payload log popout. Centred modal showing one log row's + whole payload at max size: format-aware pretty-print of `content` by + `contentType` (JSON re-serialised; YAML / text verbatim), a key/value + tags table, the service / instance / endpoint / time meta strip, a + copy button, and the trace-id link. + + Built on `_shared/Modal.vue` (backdrop + Escape + close). Used by BOTH + the per-layer Logs tab and the cross-layer Log inspect view. The host + owns the trace jump — the popout just emits `jump-trace`. + + Props: + row — the LogRow to show; `null` keeps the popout closed. + Emits: + close — dismiss (× button, Escape, backdrop). + jump-trace — { traceId, ts } when the operator clicks the trace link. +--> +<script setup lang="ts"> +import { computed } from 'vue'; +import { useI18n } from 'vue-i18n'; +import type { LogRow } from '@/api/client'; +import Modal from '@/features/operate/_shared/Modal.vue'; + +const { t } = useI18n(); + +const props = defineProps<{ row: LogRow | null }>(); +const emit = defineEmits<{ + (e: 'close'): void; + (e: 'jump-trace', payload: { traceId: string; ts: number }): void; +}>(); + +type LogFormat = 'json' | 'yaml' | 'text'; +function detectFormat(r: LogRow): LogFormat { + if (r.contentType === 'application/json') return 'json'; + const trimmed = r.content?.trim() ?? ''; + if (!trimmed) return 'text'; + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { JSON.parse(trimmed); return 'json'; } catch { /* fallthrough */ } + } + if (trimmed.startsWith('---') || trimmed.startsWith('apiVersion:')) return 'yaml'; + const lines = trimmed.split('\n'); + if (lines.length >= 2) { + const topLevelMaps = lines.filter((l) => /^[A-Za-z_][\w.-]*\s*:\s*(\S|$)/.test(l)).length; + if (topLevelMaps >= 2) return 'yaml'; + } + return 'text'; +} +function prettyContent(r: LogRow): string { + if (detectFormat(r) === 'json') { + try { return JSON.stringify(JSON.parse(r.content), null, 2); } catch { /* fall through */ } + } + return r.content; +} +function fmtTime(ts: number): string { + const d = new Date(ts); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`; +} +function fmtDate(ts: number): string { + const d = new Date(ts); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +const fmt = computed<LogFormat | null>(() => (props.row ? detectFormat(props.row) : null)); + +async function copyContent(): Promise<void> { + if (!props.row) return; + try { + await navigator.clipboard.writeText(props.row.content); + } catch { + /* clipboard may be blocked; silently no-op */ + } +} +function onJumpTrace(): void { + if (props.row?.traceId) emit('jump-trace', { traceId: props.row.traceId, ts: props.row.timestamp }); +} +</script> + +<template> + <Modal :open="row != null" :title="t('Log entry')" width="min(1100px, 92vw)" fit-body @close="emit('close')"> + <div v-if="row" class="ld"> + <div class="ld-head"> + <div class="ld-head-l"> + <span class="ld-time mono">{{ fmtTime(row.timestamp) }} · {{ fmtDate(row.timestamp) }}</span> + <span class="ld-fmt">{{ fmt!.toUpperCase() }}</span> + </div> + <div class="ld-ctrls"> + <button class="sw-btn small" type="button" @click="copyContent">{{ t('Copy') }}</button> + <button v-if="row.traceId" class="sw-btn small" type="button" @click="onJumpTrace">↗ {{ t('trace') }}</button> + </div> + </div> + <div class="ld-meta"> + <span v-if="row.serviceName" class="ld-meta-item">{{ t('Service') }} <code>{{ row.serviceName }}</code></span> + <span v-if="row.serviceInstanceName" class="ld-meta-item">{{ t('Instance') }} <code>{{ row.serviceInstanceName }}</code></span> + <span v-if="row.endpointName" class="ld-meta-item">{{ t('Endpoint') }} <code>{{ row.endpointName }}</code></span> + <span v-if="row.traceId" class="ld-meta-item">{{ t('Trace ID') }} <code class="mono">{{ row.traceId }}</code></span> + </div> + <div class="ld-split"> + <pre class="ld-body" :class="`fmt-${fmt}`">{{ prettyContent(row) }}</pre> + <aside v-if="row.tags.length > 0" class="ld-tags"> + <table class="ld-tag-tbl"> + <thead><tr><th>{{ t('Key') }}</th><th>{{ t('Value') }}</th></tr></thead> + <tbody> + <tr v-for="tg in row.tags" :key="`${tg.key}=${tg.value}`"> + <td class="mono">{{ tg.key }}</td> + <td class="mono">{{ tg.value }}</td> + </tr> + </tbody> + </table> + </aside> + </div> + </div> + </Modal> +</template> + +<style scoped> +.ld { flex: 1; display: flex; flex-direction: column; min-height: 0; } +.ld-head { + display: flex; align-items: center; justify-content: space-between; gap: 12px; + padding-bottom: 8px; border-bottom: 1px solid var(--sw-line); flex: 0 0 auto; +} +.ld-head-l { display: inline-flex; align-items: center; gap: 10px; min-width: 0; } +.ld-time { font-size: 11px; color: var(--sw-fg-2); } +.ld-fmt { + display: inline-block; padding: 1px 8px; border-radius: 10px; + background: var(--sw-accent-soft); color: var(--sw-accent-2); + font-size: 10px; font-weight: 600; letter-spacing: 0.04em; +} +.ld-ctrls { display: inline-flex; gap: 6px; flex: 0 0 auto; } +.ld-meta { + display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 0; + border-bottom: 1px dashed var(--sw-line); font-size: 11px; color: var(--sw-fg-3); flex: 0 0 auto; +} +.ld-meta-item code { + font-family: var(--sw-mono); color: var(--sw-fg-1); + padding: 0 4px; background: var(--sw-bg-2); border-radius: 3px; +} +.ld-split { flex: 1; min-height: 0; display: flex; margin-top: 8px; } +.ld-body { + flex: 1; min-height: 0; min-width: 0; margin: 0; padding: 12px 14px; + background: var(--sw-bg-0); font-family: var(--sw-mono); font-size: 12px; line-height: 1.55; + color: var(--sw-fg-0); white-space: pre-wrap; word-break: break-all; overflow: auto; + border-radius: 4px; +} +.ld-body.fmt-json { color: var(--sw-cyan); } +.ld-body.fmt-yaml { color: #fbbf24; } +.ld-tags { + flex: 0 0 340px; border-left: 1px solid var(--sw-line); padding: 8px 14px 12px; + overflow: auto; background: var(--sw-bg-1); +} +.ld-tag-tbl { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 11px; } +.ld-tag-tbl td { word-break: break-all; vertical-align: top; } +.ld-tag-tbl th, .ld-tag-tbl td { + padding: 4px 10px; text-align: left; border-bottom: 1px solid var(--sw-line); +} +.ld-tag-tbl th { color: var(--sw-fg-3); font-weight: 500; } +.ld-tag-tbl td { color: var(--sw-fg-1); font-family: var(--sw-mono); } +.sw-btn.small { + height: 24px; padding: 0 10px; background: var(--sw-bg-2); border: 1px solid var(--sw-line-2); + color: var(--sw-fg-1); border-radius: 4px; font: inherit; font-size: 11px; cursor: pointer; +} +.sw-btn.small:hover { color: var(--sw-fg-0); border-color: var(--sw-fg-3); } +.mono { font-family: var(--sw-mono); } +@media (max-width: 900px) { + .ld-split { flex-direction: column; } + .ld-tags { flex: 0 0 auto; border-left: none; border-top: 1px solid var(--sw-line); } +} +</style> diff --git a/apps/ui/src/render/widgets/LogStreamPanel.vue b/apps/ui/src/render/widgets/LogStreamPanel.vue new file mode 100644 index 0000000..a1e61be --- /dev/null +++ b/apps/ui/src/render/widgets/LogStreamPanel.vue @@ -0,0 +1,233 @@ +<!-- + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<!-- + Shared raw-log STREAM. One dense row per log line — time / date / + level stripe / service (group-decoded) / trace-id chip / format chip + + flattened single-line content preview. Used by BOTH the per-layer + Logs tab and the cross-layer Log inspect view (both click → the shared + LogDetailPopout). The component is purely presentational: it owns row + rendering + level/format detection and emits `select`; the host decides + what a click opens. + + Props: + rows — LogRow[] to render. + selectedKey — `rowKey(...)` of the active row (null when none); the + host computes the same key via the exported helper. + + Emits: + select — { row, key } for a clicked row. The trace-id chip + emits `jump-trace` instead (and stops propagation) so + the host can open the trace without selecting the row. +--> +<script setup lang="ts"> +import type { LogRow } from '@/api/client'; +import { parseServiceName } from '@/utils/serviceName'; +import { logRowKey } from '@/utils/logRow'; + +defineProps<{ + rows: LogRow[]; + selectedKey?: string | null; +}>(); +const emit = defineEmits<{ + (e: 'select', payload: { row: LogRow; key: string }): void; + (e: 'jump-trace', payload: { traceId: string; ts: number }): void; +}>(); + +type Level = 'error' | 'warn' | 'info' | 'debug' | 'other'; +const LEVEL_COLOR: Record<Level, string> = { + error: 'var(--sw-err)', + warn: 'var(--sw-warn)', + info: 'var(--sw-info)', + debug: 'var(--sw-fg-3)', + other: 'var(--sw-fg-3)', +}; + +function levelOf(r: LogRow): Level { + const tag = (r.tags ?? []).find((t) => t.key.toLowerCase() === 'level'); + const raw = (tag?.value ?? '').toLowerCase(); + if (raw.includes('error') || raw === 'err' || raw === 'fatal') return 'error'; + if (raw.includes('warn')) return 'warn'; + if (raw.includes('info')) return 'info'; + if (raw.includes('debug') || raw.includes('trace')) return 'debug'; + return 'other'; +} + +type LogFormat = 'json' | 'yaml' | 'text'; +function detectFormat(r: LogRow): LogFormat { + if (r.contentType === 'application/json') return 'json'; + const trimmed = r.content?.trim() ?? ''; + if (!trimmed) return 'text'; + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { JSON.parse(trimmed); return 'json'; } catch { /* fallthrough */ } + } + if (trimmed.startsWith('---') || trimmed.startsWith('apiVersion:')) return 'yaml'; + const lines = trimmed.split('\n'); + if (lines.length >= 2) { + const topLevelMaps = lines.filter((l) => /^[A-Za-z_][\w.-]*\s*:\s*(\S|$)/.test(l)).length; + if (topLevelMaps >= 2) return 'yaml'; + } + return 'text'; +} + +/** Single-line preview. JSON re-serialises tight; YAML/text collapse + * whitespace so the row stays one line (full payload lives in the + * host's detail / popout). */ +function summariseContent(r: LogRow): string { + if (!r.content) return ''; + const fmt = detectFormat(r); + if (fmt === 'json') { + try { return JSON.stringify(JSON.parse(r.content)); } catch { /* fall through */ } + } + if (fmt === 'yaml') return r.content.replace(/\n+/g, ' ').trim(); + return r.content.replace(/\s+/g, ' ').trim(); +} + +function fmtTime(ts: number): string { + const d = new Date(ts); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`; +} +function fmtDate(ts: number): string { + const d = new Date(ts); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +function keyOf(r: LogRow, idx: number): string { + return logRowKey(r, idx); +} +</script> + +<template> + <div class="lg-stream"> + <div + v-for="(r, idx) in rows" + :key="keyOf(r, idx)" + class="lg-row" + :class="[`lv-${levelOf(r)}`, { on: selectedKey != null && selectedKey === keyOf(r, idx) }]" + @click="emit('select', { row: r, key: keyOf(r, idx) })" + > + <span class="lg-time mono">{{ fmtTime(r.timestamp) }}</span> + <span class="lg-date mono dim">{{ fmtDate(r.timestamp) }}</span> + <span class="lg-lvl" :style="{ color: LEVEL_COLOR[levelOf(r)] }">{{ levelOf(r) }}</span> + <span class="lg-svc mono dim"> + <span + v-if="r.serviceName && parseServiceName(r.serviceName).group" + class="lg-svc-group" + >{{ parseServiceName(r.serviceName).group }}</span> + {{ r.serviceName ? parseServiceName(r.serviceName).base : '—' }} + </span> + <span + v-if="r.traceId" + class="lg-trace mono" + @click.stop="emit('jump-trace', { traceId: r.traceId, ts: r.timestamp })" + >↗ trace</span> + <span v-else class="lg-trace-spacer" aria-hidden="true"></span> + <span class="lg-content mono"> + <span class="lg-fmt-chip" :class="`fmt-${detectFormat(r)}`">{{ detectFormat(r).toUpperCase() }}</span> + <span class="lg-content-body">{{ summariseContent(r) }}</span> + </span> + </div> + </div> +</template> + +<style scoped> +.lg-stream { font-size: 11.5px; } +.lg-row { + display: grid; + grid-template-columns: 80px 60px 56px 140px 60px 1fr; + gap: 10px; + align-items: center; + padding: 4px 12px; + border-bottom: 1px solid var(--sw-line); + cursor: pointer; +} +.lg-row:hover { background: var(--sw-bg-2); } +.lg-row.on { background: var(--sw-accent-soft); } +.lg-row.lv-error { box-shadow: inset 3px 0 0 var(--sw-err); } +.lg-row.lv-warn { box-shadow: inset 3px 0 0 var(--sw-warn); } +.lg-time { font-family: var(--sw-mono); color: var(--sw-fg-1); } +.lg-date { font-size: 10px; } +.lg-lvl { + font-size: 9.5px; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 700; +} +.lg-svc { font-size: 10.5px; } +.lg-svc-group { + display: inline-block; + padding: 0 5px; + margin-right: 4px; + background: var(--sw-bg-2); + border: 1px solid var(--sw-line-2); + border-radius: 3px; + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--sw-fg-2); + text-transform: uppercase; +} +.lg-trace { + font-size: 10px; + color: var(--sw-accent-2); + cursor: pointer; + padding: 1px 5px; + background: var(--sw-accent-soft); + border: 1px solid var(--sw-accent-line); + border-radius: 3px; +} +.lg-trace:hover { color: var(--sw-fg-0); } +.lg-content { + font-family: var(--sw-mono); + font-size: 11px; + color: var(--sw-fg-1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.lg-fmt-chip { + flex: 0 0 auto; + display: inline-block; + padding: 0 5px; + height: 14px; + line-height: 14px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.04em; + border-radius: 3px; + text-transform: uppercase; + font-family: var(--sw-mono); +} +.lg-fmt-chip.fmt-json { background: var(--sw-info-soft); color: var(--sw-info); } +.lg-fmt-chip.fmt-yaml { background: rgba(251, 191, 36, 0.18); color: #fbbf24; } +.lg-fmt-chip.fmt-text { background: var(--sw-bg-3); color: var(--sw-fg-2); } +.lg-content-body { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mono { font-family: var(--sw-mono); } +.dim { color: var(--sw-fg-3); } +</style> diff --git a/apps/ui/src/render/widgets/TraceDistribution.vue b/apps/ui/src/render/widgets/TraceDistribution.vue index 8a7b364..c2b9a78 100644 --- a/apps/ui/src/render/widgets/TraceDistribution.vue +++ b/apps/ui/src/render/widgets/TraceDistribution.vue @@ -136,8 +136,9 @@ const scatterXTicks = computed(() => { const scatterSvgRef = ref<SVGSVGElement | null>(null); const dragState = ref<{ active: boolean; + pending: boolean; startVx: number; startVy: number; curVx: number; curVy: number; -}>({ active: false, startVx: 0, startVy: 0, curVx: 0, curVy: 0 }); +}>({ active: false, pending: false, startVx: 0, startVy: 0, curVx: 0, curVy: 0 }); function clientToViewbox(ev: PointerEvent): { vx: number; vy: number } | null { const svg = scatterSvgRef.value; if (!svg) return null; @@ -150,29 +151,36 @@ function clientToViewbox(ev: PointerEvent): { vx: number; vy: number } | null { function onScatterDown(ev: PointerEvent): void { const pt = clientToViewbox(ev); if (!pt) return; - const target = ev.target as Element | null; - // Click on a dot is handled by its own click handler; the drag is - // for the empty plot area. - if (target?.classList.contains('scatter-dot')) return; - ev.preventDefault(); - dragState.value = { active: true, startVx: pt.vx, startVy: pt.vy, curVx: pt.vx, curVy: pt.vy }; - (ev.currentTarget as SVGSVGElement).setPointerCapture(ev.pointerId); + // Record a potential drag; do NOT preventDefault yet, so a plain click on + // a dot still fires its @click. The brush activates only once the pointer + // actually moves (onScatterMove) — the fat hit-circles otherwise swallow + // the drag-start. + dragState.value = { active: false, pending: true, startVx: pt.vx, startVy: pt.vy, curVx: pt.vx, curVy: pt.vy }; } function onScatterMove(ev: PointerEvent): void { - if (!dragState.value.active) return; + const s = dragState.value; + if (!s.pending && !s.active) return; const pt = clientToViewbox(ev); if (!pt) return; - dragState.value = { ...dragState.value, curVx: pt.vx, curVy: pt.vy }; + if (!s.active) { + if (Math.abs(pt.vx - s.startVx) < 6 && Math.abs(pt.vy - s.startVy) < 6) { + dragState.value = { ...s, curVx: pt.vx, curVy: pt.vy }; + return; + } + ev.preventDefault(); + try { (ev.currentTarget as SVGSVGElement).setPointerCapture(ev.pointerId); } catch { /* noop */ } + dragState.value = { ...s, active: true, curVx: pt.vx, curVy: pt.vy }; + return; + } + dragState.value = { ...s, curVx: pt.vx, curVy: pt.vy }; } function onScatterUp(ev: PointerEvent): void { - if (!dragState.value.active) return; - const { startVx, startVy, curVx, curVy } = dragState.value; - dragState.value = { active: false, startVx: 0, startVy: 0, curVx: 0, curVy: 0 }; - try { - (ev.currentTarget as SVGSVGElement).releasePointerCapture(ev.pointerId); - } catch { /* noop */ } - // No real drag (click without move) → don't change selection. - if (Math.abs(curVx - startVx) < 4 && Math.abs(curVy - startVy) < 4) return; + const s = dragState.value; + const wasActive = s.active; + dragState.value = { active: false, pending: false, startVx: 0, startVy: 0, curVx: 0, curVy: 0 }; + if (!wasActive) return; + try { (ev.currentTarget as SVGSVGElement).releasePointerCapture(ev.pointerId); } catch { /* noop */ } + const { startVx, startVy, curVx, curVy } = s; const b = scatterBounds.value; if (!b) return; const vxMin = Math.min(startVx, curVx); @@ -183,9 +191,7 @@ function onScatterUp(ev: PointerEvent): void { for (const p of scatterPoints.value) { const cx = b.xMax === b.xMin ? 500 : ((p.x - b.xMin) / (b.xMax - b.xMin)) * 1000; const cy = 1000 - ((p.y - b.yMin) / (b.yMax - b.yMin || 1)) * 990; - if (cx >= vxMin && cx <= vxMax && cy >= vyMin && cy <= vyMax) { - keys.push(p.rowKey); - } + if (cx >= vxMin && cx <= vxMax && cy >= vyMin && cy <= vyMax) keys.push(p.rowKey); } if (keys.length > 0) emit('brush', keys); } diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue index bca3b92..b168006 100644 --- a/apps/ui/src/shell/AppSidebar.vue +++ b/apps/ui/src/shell/AppSidebar.vue @@ -272,8 +272,8 @@ const sections = computed<NavSection[]>(() => [ }, { icon: 'event', label: t('Capture history'), to: '/operate/live-debug/history', verb: 'live-debug:read' }, { icon: 'metric', label: t('Metrics inspect'), to: '/operate/inspect', verb: 'inspect:read' }, - { icon: 'trace', label: t('Trace inspect'), to: '/operate/trace-inspect', verb: 'traces:read' }, - { icon: 'log', label: t('Log inspect'), to: '/operate/log-inspect', verb: 'logs:read' }, + { icon: 'trace', label: t('Trace inspect'), to: '/operate/trace-inspect', verb: 'explore:read' }, + { icon: 'log', label: t('Log inspect'), to: '/operate/log-inspect', verb: 'explore:read' }, ], }, { diff --git a/apps/ui/src/shell/router/index.ts b/apps/ui/src/shell/router/index.ts index f8124e1..94ba3af 100644 --- a/apps/ui/src/shell/router/index.ts +++ b/apps/ui/src/shell/router/index.ts @@ -221,14 +221,13 @@ const shellRoutes: RouteRecordRaw[] = [ name: 'trace-inspect', component: () => import('@/features/operate/explore/ExploreView.vue'), props: { kind: 'trace' }, - meta: { verb: 'traces:read' }, + meta: { verb: 'explore:read' }, }, { path: 'operate/log-inspect', name: 'log-inspect', - component: () => import('@/features/operate/explore/ExploreView.vue'), - props: { kind: 'log' }, - meta: { verb: 'logs:read' }, + component: () => import('@/features/operate/explore/ExploreLogView.vue'), + meta: { verb: 'explore:read' }, }, // Data retention (TTL) — query-port read of getRecordsTTL/getMetricsTTL. { diff --git a/apps/ui/src/utils/logRow.ts b/apps/ui/src/utils/logRow.ts new file mode 100644 index 0000000..7fe2355 --- /dev/null +++ b/apps/ui/src/utils/logRow.ts @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { LogRow } from '@/api/client'; + +/** Stable per-row key for the shared log stream. Shared by LogStreamPanel + * and its hosts so a host's `selectedKey` lines up with the panel's rows. + * Timestamp + trace id + index disambiguate rows that share a timestamp. */ +export function logRowKey(r: LogRow, idx: number): string { + return `${r.timestamp}-${r.traceId ?? ''}-${idx}`; +}
