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 578cd8002db3ab0fb8d4a56771e54513545daae9 Author: Wu Sheng <[email protected]> AuthorDate: Tue Jun 23 15:35:15 2026 +0800 refactor(explore): two menus (Trace/Log inspect), optional entity, wider form Address review feedback: - Two sidebar menus + routes — /operate/trace-inspect (traces:read) and /operate/log-inspect (logs:read) — instead of one "Explore"; one component driven by a `kind` prop. - Entity is OPTIONAL: leave the target blank to query every service in the window (OAP's serviceId is nullable). Validated cross-service. - Layer picker lists all active layers (serviceCount > 0), not just the few that declare a native-traces capability. - Form adopts the Traces page's .cf grid vocab (full-width inputs in a 4-col grid) so fields no longer truncate. --- apps/bff/src/http/query/explore.ts | 9 +- .../src/features/operate/explore/ExploreView.vue | 474 ++++++++++----------- apps/ui/src/shell/AppSidebar.vue | 3 +- apps/ui/src/shell/router/index.ts | 23 +- packages/api-client/src/explore.ts | 4 +- 5 files changed, 242 insertions(+), 271 deletions(-) diff --git a/apps/bff/src/http/query/explore.ts b/apps/bff/src/http/query/explore.ts index f2d8e14..ee78f4c 100644 --- a/apps/bff/src/http/query/explore.ts +++ b/apps/bff/src/http/query/explore.ts @@ -105,10 +105,9 @@ export function registerExploreRoutes(app: FastifyInstance, deps: ExploreRouteDe }; if (traceSource === 'native') { - const ids = resolveNativeEntity(body.entity); - if (!ids.serviceId && !body.traceId) { - return reply.code(400).send({ error: 'entity_required' }); - } + // Entity is optional — no service means "all services in the + // window" (OAP's TraceQueryCondition.serviceId is nullable). + const ids = body.entity ? resolveNativeEntity(body.entity) : {}; const native = await fetchNativeList( opts, { ...base, ...ids }, @@ -141,7 +140,7 @@ export function registerExploreRoutes(app: FastifyInstance, deps: ExploreRouteDe // zipkin: a raw service name (no OAP id). remoteService/span/ // annotation enrichment lands with the zipkin form increment. - const service = body.entity.serviceName; + const service = body.entity?.serviceName; const zipkin = await fetchZipkinList(opts, { ...base, service }, maxTraces); const resolved: ExploreResolved = { kind: 'trace', diff --git a/apps/ui/src/features/operate/explore/ExploreView.vue b/apps/ui/src/features/operate/explore/ExploreView.vue index e1ee616..06c836f 100644 --- a/apps/ui/src/features/operate/explore/ExploreView.vue +++ b/apps/ui/src/features/operate/explore/ExploreView.vue @@ -15,17 +15,18 @@ limitations under the License. --> <!-- - Explore — cross-layer trace/log query power-tool. - - Layer-less by design: the operator names an entity by PICKING it (a - layer-filtered dropdown) or TYPING it (service name + the real/normal - flag, which the BFF encodes into the OAP id). One query → one result, - rendered with the same waterfall the per-layer Traces tab uses. The - resolved-query panel surfaces the exact condition the BFF ran — the - trace/log analog of Metrics-inspect showing the bare MQE. - - This pass implements Trace · native end to end. Trace · zipkin and the - Log kinds (raw / browser-error) are wired into the same spine next. + Trace inspect / Log inspect — cross-layer trace/log query power-tools + (the `kind` prop picks which; two sidebar menus, one component). + + Layer-less by design: the entity is OPTIONAL. Name a service by PICKING + it (a layer-filtered dropdown), TYPING it (service name + the real/ + normal flag, which the BFF encodes into the OAP id), or leave it blank + to query every service in the window. One query → one result, rendered + 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. --> <script setup lang="ts"> import { computed, reactive, ref, watch } from 'vue'; @@ -35,7 +36,6 @@ import type { ExploreEntity, ExploreRequest, ExploreResolved, - LayerDef, NativeSpan, NativeTraceListResponse, NativeTraceListRow, @@ -47,25 +47,20 @@ import { useTraceDetail } from '@/layer/traces/useLayerTraces'; import NativeTraceWaterfall, { type WaterfallSpan } from '@/layer/traces/NativeTraceWaterfall.vue'; import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue'; +const props = defineProps<{ kind: 'trace' | 'log' }>(); const { t } = useI18n(); -const { layers } = useLayers(); +const { availableLayers } = useLayers(); + +const title = computed(() => (props.kind === 'trace' ? t('Trace inspect') : t('Log inspect'))); -// ── kind + source (zipkin / log land in the next increments) ────────── -type Kind = 'trace' | 'log'; +// ── source (zipkin / log sources land in the next increments) ───────── type TraceSource = 'native' | 'zipkin'; -const kind = ref<Kind>('trace'); const traceSource = ref<TraceSource>('native'); -// ── entity: pick (layer-filtered) vs type (name + real flag) ────────── +// ── entity (OPTIONAL): pick (layer-filtered) vs type (name + real) ──── type EntityMode = 'pick' | 'type'; const entityMode = ref<EntityMode>('pick'); -// Layers that actually serve native traces. -const traceLayers = computed<LayerDef[]>(() => - layers.value.filter((l) => l.caps?.traces && (l.traces?.source ?? 'native') !== 'zipkin'), -); - -// pick mode const pickLayer = ref<string>(''); const pickServiceId = ref<string>(''); const pickInstanceId = ref<string>(''); @@ -133,6 +128,7 @@ watch(pickServiceId, () => { }); watch(endpointQuery, () => 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 })), ); @@ -147,7 +143,6 @@ const endpointOptions = computed(() => [ const instanceSel = computed<string>({ get: () => pickInstanceId.value, set: (v) => (pickInstanceId.value = v ?? '') }); const endpointSel = computed<string>({ get: () => pickEndpointId.value, set: (v) => (pickEndpointId.value = v ?? '') }); -// type mode const typeService = ref<string>(''); const typeReal = ref<boolean>(true); const typeInstance = ref<string>(''); @@ -219,20 +214,19 @@ const native = ref<NativeTraceListResponse | null>(null); const resolved = ref<ExploreResolved | null>(null); const showResolved = ref(false); -const canRun = computed(() => kind.value === 'trace' && traceSource.value === 'native' && currentEntity() !== null); +const canRun = computed(() => props.kind === 'trace' && traceSource.value === 'native'); async function runQuery(): Promise<void> { - const entity = currentEntity(); - if (!entity) return; running.value = true; hasQueried.value = true; errorMsg.value = null; selectedTraceId.value = null; embeddedSpans.value = null; + const entity = currentEntity(); const req: ExploreRequest = { kind: 'trace', traceSource: 'native', - entity, + ...(entity ? { entity } : {}), window: { windowMinutes: cond.windowMinutes }, pageSize: cond.limit, traceId: cond.traceId.trim() || undefined, @@ -283,253 +277,219 @@ function fmtTime(start: string): string { </script> <template> - <div class="ex"> - <header class="ex-head"> - <h1>{{ t('Explore') }}</h1> - <p class="ex-sub">{{ t('Cross-layer trace and log query. Pick any service, or type its name.') }}</p> + <div class="iq"> + <header class="iq-head"> + <h1>{{ title }}</h1> + <p class="iq-sub">{{ t('Query any service across layers — pick it, type its name, or leave it blank.') }}</p> </header> - <div class="ex-bar"> - <div class="ex-seg"> - <button :class="{ on: kind === 'trace' }" @click="kind = 'trace'">{{ t('Trace') }}</button> - <button class="muted" disabled :title="t('coming next')">{{ t('Log') }}</button> - </div> - <div class="ex-seg" v-if="kind === 'trace'"> - <button :class="{ on: traceSource === 'native' }" @click="traceSource = 'native'">{{ t('Native') }}</button> - <button class="muted" disabled :title="t('coming next')">Zipkin</button> + <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"> + <span class="iq-bar-l">{{ t('Source') }}</span> + <div class="seg"> + <button :class="{ on: traceSource === 'native' }" @click="traceSource = 'native'">{{ t('Native') }}</button> + <button class="muted" disabled :title="t('coming next')">Zipkin</button> + </div> </div> - </div> - <div class="ex-form"> - <div class="ex-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 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> + <input v-model="endpointQuery" class="cf-input" type="text" :disabled="!pickServiceId" :placeholder="t('search…')" /> + <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> - <!-- Pick: layer-filtered dropdowns --> - <template v-if="entityMode === 'pick'"> - <label class="ex-f"> - <span>{{ t('Layer') }}</span> - <TypeaheadSelect - v-model="pickLayer" - :aria-label="t('Layer')" - :options="traceLayers.map((l) => ({ value: l.key, label: l.name || l.key }))" - :placeholder="t('Select a layer')" - /> + <div class="iq-grid"> + <label 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> - <label class="ex-f"> - <span>{{ t('Service') }}</span> - <TypeaheadSelect - v-model="pickServiceId" - :aria-label="t('Service')" - :options="serviceOptions" - :disabled="!pickLayer || servicesLoading" - :placeholder="servicesLoading ? t('Reading…') : t('Select a service')" - /> + <label class="cf"> + <span>{{ t('Status') }}</span> + <select v-model="cond.traceState" class="cf-input"> + <option value="ALL">{{ t('All') }}</option> + <option value="SUCCESS">{{ t('Success') }}</option> + <option value="ERROR">{{ t('Error') }}</option> + </select> </label> - <label class="ex-f"> - <span>{{ t('Instance') }}</span> - <TypeaheadSelect - v-model="instanceSel" - :aria-label="t('Instance')" - :options="instanceOptions" - :disabled="!pickServiceId" - :placeholder="t('All instances')" - /> + <label class="cf"> + <span>{{ t('Order') }}</span> + <select v-model="cond.queryOrder" class="cf-input"> + <option value="BY_START_TIME">{{ t('Newest') }}</option> + <option value="BY_DURATION">{{ t('Slowest') }}</option> + </select> </label> - <label class="ex-f cf-wide"> - <span>{{ t('Endpoint') }}</span> - <input - v-model="endpointQuery" - class="ex-input" - type="text" - :disabled="!pickServiceId" - :placeholder="t('Search endpoints…')" - /> - <TypeaheadSelect - v-model="endpointSel" - :aria-label="t('Endpoint')" - :options="endpointOptions" - :disabled="!pickServiceId" - :placeholder="t('All endpoints')" - /> + <label class="cf"> + <span>{{ t('Duration (ms)') }}</span> + <span class="cf-range"> + <input v-model="cond.minDuration" class="cf-input cf-range-num" type="number" min="0" :placeholder="t('min')" /> + <span class="cf-range-sep">–</span> + <input v-model="cond.maxDuration" class="cf-input cf-range-num" type="number" min="0" :placeholder="t('max')" /> + </span> </label> - <button class="ex-link" :disabled="!pickServiceId" @click="seedTypeFromPick">{{ t('→ edit as text') }}</button> - </template> - - <!-- Type: name + real flag (no layer) --> - <template v-else> - <label class="ex-f"> - <span>{{ t('Service name') }}</span> - <input v-model="typeService" class="ex-input" type="text" :placeholder="t('e.g. agent::checkout')" /> + <label class="cf cf-wide"> + <span>{{ t('Tags') }}</span> + <input v-model="cond.tags" class="cf-input mono" type="text" :placeholder="t('http.status_code=500, …')" /> </label> - <label class="ex-f chk"> - <input v-model="typeReal" type="checkbox" /> - <span>{{ t('real') }}</span> - <small class="dim">{{ t('agent-reported (off = virtual/peer)') }}</small> + <label class="cf"> + <span>{{ t('Time') }}</span> + <select 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> + </select> </label> - <label class="ex-f"> - <span>{{ t('Instance') }}</span> - <input v-model="typeInstance" class="ex-input" type="text" :placeholder="t('optional')" /> + <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> - <label class="ex-f"> - <span>{{ t('Endpoint') }}</span> - <input v-model="typeEndpoint" class="ex-input" type="text" :placeholder="t('optional')" /> - </label> - </template> - </div> - - <div class="ex-form"> - <label class="ex-f"> - <span>{{ t('Trace ID') }}</span> - <input v-model="cond.traceId" class="ex-input mono" type="text" :placeholder="t('paste a trace id')" /> - </label> - <label class="ex-f"> - <span>{{ t('Status') }}</span> - <select v-model="cond.traceState" class="ex-input"> - <option value="ALL">{{ t('All') }}</option> - <option value="SUCCESS">{{ t('Success') }}</option> - <option value="ERROR">{{ t('Error') }}</option> - </select> - </label> - <label class="ex-f"> - <span>{{ t('Order') }}</span> - <select v-model="cond.queryOrder" class="ex-input"> - <option value="BY_START_TIME">{{ t('Newest') }}</option> - <option value="BY_DURATION">{{ t('Slowest') }}</option> - </select> - </label> - <label class="ex-f sm"> - <span>{{ t('Duration (ms)') }}</span> - <span class="ex-dur"> - <input v-model="cond.minDuration" class="ex-input" type="number" min="0" :placeholder="t('min')" /> - <input v-model="cond.maxDuration" class="ex-input" type="number" min="0" :placeholder="t('max')" /> - </span> - </label> - <label class="ex-f cf-wide"> - <span>{{ t('Tags') }}</span> - <input v-model="cond.tags" class="ex-input mono" type="text" :placeholder="t('http.status_code=500, …')" /> - </label> - <label class="ex-f"> - <span>{{ t('Time') }}</span> - <select v-model.number="cond.windowMinutes" class="ex-input"> - <option v-for="w in WINDOWS" :key="w" :value="w">{{ w < 60 ? `${w}m` : `${w / 60}h` }}</option> - </select> - </label> - <label class="ex-f"> - <span>{{ t('Limit') }}</span> - <select v-model.number="cond.limit" class="ex-input"> - <option v-for="l in LIMITS" :key="l" :value="l">{{ l }}</option> - </select> - </label> - <button class="ex-run" :disabled="!canRun || running" @click="runQuery"> - {{ running ? t('Running…') : t('Run query') }} - </button> - </div> - - <div v-if="resolved" class="ex-resolved"> - <button class="ex-resolved-tog" @click="showResolved = !showResolved"> - {{ showResolved ? '▾' : '▸' }} {{ t('Resolved query') }} - <span class="dim">{{ resolved.source }}{{ resolved.backend ? ` · ${resolved.backend}` : '' }}</span> - </button> - <pre v-if="showResolved" class="ex-resolved-body">{{ JSON.stringify(resolved.condition, null, 2) }}</pre> - </div> - - <div class="ex-result"> - <div v-if="!hasQueried" class="ex-empty">{{ t('Pick or type an entity, then Run query.') }}</div> - <div v-else-if="errorMsg" class="ex-err">{{ errorMsg }}</div> - <div v-else-if="rows.length === 0" class="ex-empty">{{ t('No traces in this window.') }}</div> - <div v-else class="ex-split"> - <ul class="ex-list"> - <li - v-for="(row, i) in rows" - :key="row.key || i" - class="ex-row" - :class="{ on: selectedTraceId === (row.traceIds[0] ?? ''), err: row.isError }" - @click="openRow(row)" - > - <span class="ex-flag" :class="{ err: row.isError }"></span> - <span class="ex-ep">{{ row.endpointNames[0] || row.traceIds[0] || row.key }}</span> - <span class="ex-dur-v">{{ fmtDur(row.duration) }}</span> - <span class="ex-time">{{ fmtTime(row.start) }}</span> - </li> - </ul> - <div class="ex-detail"> - <div v-if="!selectedTraceId" class="ex-empty sm">{{ t('Select a trace.') }}</div> - <div v-else-if="detailFetching && waterfallSpans.length === 0" class="ex-empty sm">{{ t('Reading trace…') }}</div> - <div v-else-if="waterfallSpans.length === 0" class="ex-empty sm">{{ t('No spans (older than the detail window).') }}</div> - <NativeTraceWaterfall - v-else - :spans="waterfallSpans" - :selected-span="selectedSpan" - @select-span="selectedSpan = $event" - /> + </div> + + <div class="iq-run-row"> + <button class="iq-run" :disabled="!canRun || running" @click="runQuery"> + {{ running ? t('Running…') : t('Run query') }} + </button> + <button v-if="resolved" class="iq-resolved-tog" @click="showResolved = !showResolved"> + {{ showResolved ? '▾' : '▸' }} {{ t('Resolved query') }} + <span class="dim">{{ resolved.source }}{{ resolved.backend ? ` · ${resolved.backend}` : '' }}</span> + </button> + </div> + <pre v-if="resolved && showResolved" class="iq-resolved-body">{{ JSON.stringify(resolved.condition, null, 2) }}</pre> + + <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="errorMsg" class="iq-err">{{ errorMsg }}</div> + <div v-else-if="rows.length === 0" class="iq-empty">{{ t('No traces in this window.') }}</div> + <div v-else class="iq-split"> + <ul class="iq-list"> + <li + v-for="(row, i) in rows" :key="row.key || i" class="iq-row" + :class="{ on: selectedTraceId === (row.traceIds[0] ?? ''), err: row.isError }" @click="openRow(row)" + > + <span class="iq-flag" :class="{ err: row.isError }"></span> + <span class="iq-ep">{{ row.endpointNames[0] || row.traceIds[0] || row.key }}</span> + <span class="iq-dur">{{ fmtDur(row.duration) }}</span> + <span class="iq-time">{{ fmtTime(row.start) }}</span> + </li> + </ul> + <div class="iq-detail"> + <div v-if="!selectedTraceId" class="iq-empty sm">{{ t('Select a trace.') }}</div> + <div v-else-if="detailFetching && waterfallSpans.length === 0" class="iq-empty sm">{{ t('Reading trace…') }}</div> + <div v-else-if="waterfallSpans.length === 0" class="iq-empty sm">{{ t('No spans (older than the detail window).') }}</div> + <NativeTraceWaterfall v-else :spans="waterfallSpans" :selected-span="selectedSpan" @select-span="selectedSpan = $event" /> + </div> </div> </div> - </div> + </template> </div> </template> <style scoped> -.ex { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; padding: 14px 16px; } -.ex-head h1 { font-size: 16px; margin: 0; } -.ex-sub { color: var(--sw-fg-3); font-size: 12px; margin: 2px 0 0; } -.ex-bar { display: flex; gap: 12px; align-items: center; } -.ex-seg { display: inline-flex; border: 1px solid var(--sw-line-2); border-radius: 5px; overflow: hidden; } -.ex-seg.sm button { padding: 3px 10px; } -.ex-seg button { - background: var(--sw-bg-2); color: var(--sw-fg-2); border: none; padding: 4px 14px; - font: inherit; font-size: 12px; cursor: pointer; -} -.ex-seg button + button { border-left: 1px solid var(--sw-line-2); } -.ex-seg button.on { background: var(--sw-accent); color: #fff; } -.ex-seg button.muted { opacity: 0.45; cursor: not-allowed; } -.ex-form { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: flex-end; } -.ex-f { display: flex; flex-direction: column; gap: 3px; font-size: 11px; color: var(--sw-fg-3); } -.ex-f.cf-wide { min-width: 240px; flex: 1 1 240px; } -.ex-f.chk { flex-direction: row; align-items: center; gap: 6px; padding-bottom: 4px; } -.ex-f.chk .dim { color: var(--sw-fg-4); } -.ex-input { - background: var(--sw-bg-2); color: var(--sw-fg-1); border: 1px solid var(--sw-line-2); - border-radius: 4px; padding: 3px 8px; font: inherit; font-size: 12px; min-width: 140px; -} -.ex-input.mono { font-family: var(--sw-mono); } -.ex-dur { display: inline-flex; gap: 6px; } -.ex-dur .ex-input { min-width: 70px; width: 80px; } -.ex-link { background: none; border: none; color: var(--sw-accent); font-size: 11px; cursor: pointer; padding: 0 0 4px; } -.ex-link:disabled { color: var(--sw-fg-4); cursor: not-allowed; } -.ex-run { - background: var(--sw-accent); color: #fff; border: none; border-radius: 4px; - padding: 5px 16px; font: inherit; font-size: 12px; cursor: pointer; height: 28px; -} -.ex-run:disabled { opacity: 0.5; cursor: not-allowed; } -.ex-resolved { border: 1px solid var(--sw-line); border-radius: 5px; } -.ex-resolved-tog { - width: 100%; text-align: left; background: var(--sw-bg-1); color: var(--sw-fg-2); - border: none; padding: 5px 10px; font: inherit; font-size: 11px; cursor: pointer; -} -.ex-resolved-tog .dim { color: var(--sw-fg-4); margin-left: 8px; } -.ex-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: 180px; - border-top: 1px solid var(--sw-line); -} -.ex-result { flex: 1; min-height: 0; border: 1px solid var(--sw-line); border-radius: 5px; overflow: hidden; } -.ex-empty, .ex-err { padding: 24px; text-align: center; color: var(--sw-fg-3); font-size: 12px; } -.ex-empty.sm { padding: 14px; } -.ex-err { color: var(--sw-err); } -.ex-split { display: grid; grid-template-columns: minmax(280px, 360px) 1fr; height: 100%; min-height: 0; } -.ex-list { list-style: none; margin: 0; padding: 0; overflow: auto; border-right: 1px solid var(--sw-line); } -.ex-row { - display: grid; grid-template-columns: 10px 1fr auto auto; gap: 8px; align-items: center; - padding: 5px 10px; cursor: pointer; font-size: 12px; border-bottom: 1px solid var(--sw-line); +.iq { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; 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; } +.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.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; } +.seg button.muted { opacity: 0.45; cursor: not-allowed; } +.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(4, minmax(0, 1fr)); gap: 8px 10px; } +@media (max-width: 900px) { .iq-grid { grid-template-columns: repeat(2, minmax(0, 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.cf-chk { justify-content: flex-end; } +.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; } -.ex-row:hover { background: var(--sw-bg-2); } -.ex-row.on { background: var(--sw-bg-3); } -.ex-flag { width: 8px; height: 8px; border-radius: 50%; background: var(--sw-ok, #3a7); } -.ex-flag.err { background: var(--sw-err); } -.ex-ep { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.ex-dur-v { font-family: var(--sw-mono); font-size: 11px; color: var(--sw-fg-2); } -.ex-time { font-size: 11px; color: var(--sw-fg-3); } -.ex-detail { overflow: auto; min-width: 0; } +.cf-input.mono { font-family: var(--sw-mono); } +.cf-input:disabled { opacity: 0.5; cursor: not-allowed; } +.cf-input + .cf-tas { margin-top: 3px; } +.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-run-row { display: flex; align-items: center; gap: 12px; } +.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; } +.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; } +.iq-result { flex: 1; min-height: 0; border: 1px solid var(--sw-line); border-radius: 6px; overflow: hidden; } +.iq-empty, .iq-err { padding: 24px; text-align: center; color: var(--sw-fg-3); font-size: 12px; } +.iq-empty.sm { padding: 14px; } +.iq-err { color: var(--sw-err); } +.iq-split { display: grid; grid-template-columns: minmax(280px, 360px) 1fr; height: 100%; min-height: 0; } +.iq-list { list-style: none; margin: 0; padding: 0; overflow: auto; border-right: 1px solid var(--sw-line); } +.iq-row { display: grid; grid-template-columns: 10px 1fr auto auto; gap: 8px; align-items: center; padding: 5px 10px; cursor: pointer; font-size: 12px; border-bottom: 1px solid var(--sw-line); } +.iq-row:hover { background: var(--sw-bg-2); } +.iq-row.on { background: var(--sw-bg-3); } +.iq-flag { width: 8px; height: 8px; border-radius: 50%; background: var(--sw-ok, #3a7); } +.iq-flag.err { background: var(--sw-err); } +.iq-ep { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.iq-dur { font-family: var(--sw-mono); font-size: 11px; color: var(--sw-fg-2); } +.iq-time { font-size: 11px; color: var(--sw-fg-3); } +.iq-detail { overflow: auto; min-width: 0; } </style> diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue index 69c574a..bca3b92 100644 --- a/apps/ui/src/shell/AppSidebar.vue +++ b/apps/ui/src/shell/AppSidebar.vue @@ -272,7 +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('Explore'), to: '/operate/explore', verb: 'explore: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' }, ], }, { diff --git a/apps/ui/src/shell/router/index.ts b/apps/ui/src/shell/router/index.ts index ef9c763..f8124e1 100644 --- a/apps/ui/src/shell/router/index.ts +++ b/apps/ui/src/shell/router/index.ts @@ -211,15 +211,24 @@ const shellRoutes: RouteRecordRaw[] = [ component: () => import('@/features/operate/inspect/InspectView.vue'), meta: { verb: 'inspect:read' }, }, - // Explore — cross-layer trace/log power-tool. Layer-less: the entity is - // picked or typed (name + real flag); dispatches by source via - // /api/explore/query. Uses the standard query-protocol (always on), - // so it is NOT gated on the inspect module like Metrics inspect. + // Trace inspect / Log inspect — cross-layer trace/log query power-tools. + // Layer-less: the entity is picked or typed (name + real flag) or + // omitted; dispatched by source via /api/explore/query. They use the + // standard query-protocol (always on), so — unlike Metrics inspect — + // they are NOT gated on the OAP inspect module. { - path: 'operate/explore', - name: 'explore', + path: 'operate/trace-inspect', + name: 'trace-inspect', component: () => import('@/features/operate/explore/ExploreView.vue'), - meta: { verb: 'explore:read' }, + props: { kind: 'trace' }, + meta: { verb: 'traces:read' }, + }, + { + path: 'operate/log-inspect', + name: 'log-inspect', + component: () => import('@/features/operate/explore/ExploreView.vue'), + props: { kind: 'log' }, + meta: { verb: 'logs:read' }, }, // Data retention (TTL) — query-port read of getRecordsTTL/getMetricsTTL. { diff --git a/packages/api-client/src/explore.ts b/packages/api-client/src/explore.ts index 39061b9..0f3f333 100644 --- a/packages/api-client/src/explore.ts +++ b/packages/api-client/src/explore.ts @@ -74,7 +74,9 @@ export interface ExploreRequest { traceSource?: ExploreTraceSource; /** when kind === 'log' */ logSource?: ExploreLogSource; - entity: ExploreEntity; + /** Optional — a trace/log query needs no entity (query all services in + * the window, or filter by trace id / conditions only). */ + entity?: ExploreEntity; window: ExploreWindow; pageNum?: number; pageSize?: number;
