You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/19 14:15:41 UTC

(skywalking-horizon-ui) branch main updated: feat(traces): query traces in TraceQL, over either trace store (#167)

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


The following commit(s) were added to refs/heads/main by this push:
     new 177f9bd9 feat(traces): query traces in TraceQL, over either trace store (#167)
177f9bd9 is described below

commit 177f9bd94ea1de15adada8cd17bd7f894129e78d
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Sat Sep 19 22:15:36 2026 +0800

    feat(traces): query traces in TraceQL, over either trace store (#167)
    
    OAP can answer for its traces in Grafana Tempo's query language, and a
    layer can now expose that as its own sidebar row — TraceQL - Native over
    the SkyWalking spans, TraceQL - Zipkin over the Zipkin ones. Point
    Horizon at the datasources with oap.traceql.nativeUrl / .zipkinUrl; both
    are empty by default, because OAP ships the traceQL module disabled, and
    a row whose URL is unset says so rather than searching something else.
    
    A TraceQL trace is rendered as the OpenTelemetry trace it is — opaque
    span ids, span kind, three-valued status, resource attributes kept apart
    from span attributes — rather than folded into SkyWalking's span shape.
    Converting between the models is OAP's job; doing it a second time on
    the way out throws away what the operator opened the tab to see. The
    TraceQL waterfall, tree, statistics and span dialog therefore share no
    code with the native ones, only the tokens and the layout vocabulary.
    
    The page queries three ways: a Builder filled from the store, a TraceQL
    editor with highlighting, completion and a Schema reference, and Trace
    ID for up to twenty ids. The editor checks the query against the store's
    SCHEMA and nothing else. What a backend does with a form it parsed is
    that backend's business and differs between builds, so every finding is
    a warning, the query always runs, and a refusal is shown where the
    results would be.
    
    A layer's trace tabs become a checklist of stores, each with its own
    menu name, and the TraceQL rows can carry a regular expression narrowing
    their service picker — the Tempo API has no notion of a layer, so it
    lists every service of the underlying store. That pattern is refused if
    it can backtrack badly: it runs on the BFF against every service name,
    where a pattern like (a+)+ would stall every other request.
    
    No layer template has to change. One carrying the legacy `traces.source`
    enum is read as the stores it named; one that says nothing is read as
    the native store, which is the row such a layer has always shown. Saying
    a layer has NO trace rows is explicit: "sources": []. The bundled
    templates are byte-identical to main.
    
    Operate -> Cluster's Zipkin pane becomes Trace APIs: one table over
    every trace endpoint, its state, and the URL tried.
    
    Two things beyond traces, which the work reached:
    
      - Date fields are Horizon's own control. <input type="datetime-local">
        is drawn in the BROWSER's interface language, which neither the page
        nor Horizon's language picker can reach — a Chinese browser rendered
        a Chinese calendar over an English console. All 30 of them, across
        16 files, now read YYYY-MM-DD HH:mm with a calendar in the language
        picked in Horizon, starting the week where that language starts it.
      - color-scheme follows the theme's appearance, so a light theme stops
        getting dark native controls, and tag autocomplete opens over the
        page instead of being clipped by the card around it.
    
    Also fixes MQE target discovery, which looked for the sharing server's
    port under `sharing-server.default.restPort`. The module is
    `receiver-sharing-server`, so that branch never ran and a deployment
    that had moved its query port was still sent to core's.
    
    Validated against the demo OAP throughout, driven in a browser rather
    than curled. Docs and the 1.1.0 notes are updated with it, including the
    compatibility page, which now leads with the answer: OAP 11.x runs
    everything, OAP 10.3 / 10.4 can take a Horizon-only upgrade with
    templates.mode readonly, and anything older means upgrading OAP.
---
 apps/bff/CLAUDE.md                                 |  12 +-
 apps/bff/src/ai/lib/skills/rca/errors-sla.md       |   2 +-
 apps/bff/src/ai/lib/skills/rca/latency.md          |   2 +-
 apps/bff/src/ai/lib/skills/rca/mesh.md             |   4 +-
 .../bff/src/ai/lib/skills/rca/middleware-remote.md |   2 +-
 apps/bff/src/ai/lib/skills/rca/root-cause.md       |   2 +-
 apps/bff/src/ai/lib/skills/rca/saturation.md       |   2 +-
 apps/bff/src/client/traceql-error-tags.test.ts     |  80 ++
 apps/bff/src/client/traceql-error-tags.ts          |  93 +++
 apps/bff/src/client/traceql.ts                     | 472 +++++++++++
 apps/bff/src/config/schema.ts                      |   7 +
 .../src/http/query/menu-rows.regression.test.ts    |  32 +-
 apps/bff/src/http/query/paging-has-next.test.ts    |   5 +
 .../src/http/query/service-scope-guards.test.ts    |   5 +-
 apps/bff/src/http/query/trace-id-lookup.test.ts    |  10 +-
 apps/bff/src/http/query/trace.ts                   |  22 +-
 apps/bff/src/http/query/traceql.test.ts            | 314 +++++++
 apps/bff/src/http/query/traceql.ts                 | 288 +++++++
 apps/bff/src/http/query/zipkin-cold-stage.test.ts  |   4 +-
 apps/bff/src/logic/layers/capabilities.ts          |   9 +-
 apps/bff/src/logic/layers/loader.test.ts           |  31 +-
 apps/bff/src/logic/layers/loader.ts                |  16 +-
 apps/bff/src/logic/layers/preview-traces.test.ts   |  60 ++
 apps/bff/src/logic/layers/preview.ts               |  45 +-
 apps/bff/src/logic/templates/bundled-schema.ts     |  37 +-
 apps/bff/src/logic/traceql/availability.ts         | 105 +++
 apps/bff/src/logic/traceql/status.test.ts          | 143 ++++
 apps/bff/src/logic/traceql/status.ts               | 136 +++
 apps/bff/src/rbac/route-policy.ts                  |   5 +
 apps/bff/src/server.ts                             |   5 +
 apps/bff/src/util/mqe-target.test.ts               | 104 +++
 apps/bff/src/util/mqe-target.ts                    |  34 +-
 apps/ui/CLAUDE.md                                  |  10 +
 apps/ui/src/ai/ChatTracesBlock.vue                 |   7 +-
 apps/ui/src/api/client.ts                          |   3 +
 apps/ui/src/api/scopes/traceql.ts                  | 104 +++
 apps/ui/src/assets/styles/global.css               |   9 +
 .../components/primitives/DateTimeField.test.ts    | 145 ++++
 .../ui/src/components/primitives/DateTimeField.vue | 469 +++++++++++
 .../ui/src/components/primitives/LongValue.test.ts |  88 ++
 apps/ui/src/components/primitives/LongValue.vue    | 140 ++++
 .../ui/src/components/primitives/SpanKindGlyph.vue |  71 ++
 apps/ui/src/components/primitives/TagInput.vue     |  89 +-
 .../ui/src/components/primitives/TimeRangeMenu.vue |  13 +-
 apps/ui/src/features/admin/audit/AuditView.vue     |   5 +-
 .../ui/src/features/admin/audit/TokenUsageList.vue |  17 +-
 .../admin/layer-templates/LayerDashboardsAdmin.vue |  91 +-
 .../admin/layer-templates/LayerSetupEditor.vue     |  14 +-
 .../layer-templates/TraceStoresEditor.test.ts      | 142 ++++
 .../admin/layer-templates/TraceStoresEditor.vue    | 274 ++++++
 .../admin/layer-templates/WidgetEditorCanvas.vue   |   6 +-
 .../admin/layer-templates/menuOrderPrune.test.ts   |   6 +-
 apps/ui/src/features/alarms/AlarmWindowPicker.vue  |   5 +-
 apps/ui/src/features/events/EventsPopout.vue       |   5 +-
 .../features/operate/cluster/ClusterStatusView.vue | 183 ++++-
 .../features/operate/explore/ExploreLogView.vue    |   5 +-
 .../operate/explore/ExploreView.traceId.test.ts    |   7 +-
 .../src/features/operate/explore/ExploreView.vue   |  23 +-
 apps/ui/src/i18n/locales/de.json                   | 109 ++-
 apps/ui/src/i18n/locales/en.json                   | 109 ++-
 apps/ui/src/i18n/locales/es.json                   | 109 ++-
 apps/ui/src/i18n/locales/fr.json                   | 109 ++-
 apps/ui/src/i18n/locales/ja.json                   | 109 ++-
 apps/ui/src/i18n/locales/ko.json                   | 109 ++-
 apps/ui/src/i18n/locales/pt.json                   | 109 ++-
 apps/ui/src/i18n/locales/zh-CN.json                | 109 ++-
 apps/ui/src/layer/LayerShell.vue                   |  19 +-
 .../browser-errors/LayerBrowserErrorsView.vue      |   5 +-
 .../LayerEvaluationRecordView.vue                  |   5 +-
 apps/ui/src/layer/logs/LayerLogsView.vue           |   5 +-
 apps/ui/src/layer/profiling/NewEBPFTaskModal.vue   |   9 +-
 .../layer/profiling/NewTraceProfileTaskModal.vue   |   9 +-
 apps/ui/src/layer/traceql/LayerTraceQLView.vue     | 915 +++++++++++++++++++++
 apps/ui/src/layer/traceql/TraceQLDetailCard.vue    | 192 +++++
 apps/ui/src/layer/traceql/TraceQLEditor.vue        | 170 ++++
 apps/ui/src/layer/traceql/TraceQLSchemaPanel.vue   | 177 ++++
 apps/ui/src/layer/traceql/TraceQLSpanModal.vue     | 201 +++++
 apps/ui/src/layer/traceql/TraceQLStatsView.vue     | 140 ++++
 apps/ui/src/layer/traceql/TraceQLTraceDetail.vue   |  84 ++
 apps/ui/src/layer/traceql/TraceQLTreeView.vue      | 258 ++++++
 apps/ui/src/layer/traceql/TraceQLWaterfall.vue     | 178 ++++
 apps/ui/src/layer/traceql/TraceQLWindowFields.vue  |  71 ++
 apps/ui/src/layer/traceql/statusResolve.test.ts    | 163 ++++
 .../src/layer/traceql/traceqlDetailShared.test.ts  | 129 +++
 apps/ui/src/layer/traceql/traceqlDetailShared.ts   | 227 +++++
 apps/ui/src/layer/traceql/traceqlLint.test.ts      | 103 +++
 apps/ui/src/layer/traceql/traceqlLint.ts           | 186 +++++
 apps/ui/src/layer/traceql/useTraceQL.ts            | 445 ++++++++++
 apps/ui/src/layer/traces/LayerTracesEntry.vue      |  23 +-
 apps/ui/src/layer/traces/LayerTracesView.vue       |  31 +-
 .../traces/LayerZipkinTracesView.traceId.test.ts   |   7 +-
 apps/ui/src/layer/traces/LayerZipkinTracesView.vue |  69 +-
 apps/ui/src/layer/traces/traceIdLookup.test.ts     |   9 +-
 apps/ui/src/layer/traces/useZipkinTracePopout.ts   |   3 +-
 apps/ui/src/layer/traces/zipkinTraceRows.ts        |  10 +-
 apps/ui/src/monaco/setup.ts                        | 175 ++++
 apps/ui/src/monaco/traceql-grammar.ts              |  96 +++
 .../render/layer-dashboard/LayerDashboardsView.vue |   9 +-
 .../src/render/layer-dashboard/pageRoutes.test.ts  |  12 +-
 apps/ui/src/render/widgets/SpanDetailModal.vue     |   7 +-
 apps/ui/src/render/widgets/TraceDetailCard.vue     |   4 +
 apps/ui/src/render/widgets/TraceDistribution.vue   |  17 +-
 apps/ui/src/render/widgets/TraceListPanel.vue      |  41 +-
 .../src/render/widgets/ZipkinTraceDetailCard.vue   |  17 +-
 apps/ui/src/shell/SidebarLayerChildren.vue         |   4 +-
 apps/ui/src/shell/TimeRangeChip.vue                |  10 +
 apps/ui/src/shell/layerFromTemplate.test.ts        |   2 +-
 apps/ui/src/shell/router/index.ts                  |   4 +
 apps/ui/src/shell/useLayers.test.ts                |  18 +-
 apps/ui/src/shell/useTopbarTimeContext.ts          |   5 +
 docs/changelog/1.1.0.md                            |  50 ++
 docs/compatibility/cluster-status.md               |  34 +-
 docs/compatibility/oap-version.md                  |  26 +-
 docs/compatibility/ports.md                        |   5 +-
 docs/components/dashboard-widgets.md               |   2 +-
 docs/customization/adding-a-new-layer.md           |   2 +-
 docs/customization/global-defaults.md              |   2 +-
 docs/customization/layer-templates.md              |  42 +-
 docs/customization/menu-structure.md               |   2 +-
 docs/design-target.md                              |  15 +-
 docs/operate/cluster-metadata.md                   |   5 +-
 docs/operate/traces.md                             |  74 +-
 docs/setup/container-image.md                      |   5 +-
 docs/setup/horizon-yaml.md                         |   1 +
 docs/setup/oap.md                                  |  35 +-
 horizon.yaml                                       |   8 +
 packages/api-client/src/index.ts                   |  19 +
 packages/api-client/src/layer-menu.test.ts         |  14 +-
 packages/api-client/src/layer-menu.ts              |  46 +-
 packages/api-client/src/menu.ts                    |  11 +-
 packages/api-client/src/trace-filter.test.ts       |  86 ++
 packages/api-client/src/trace.ts                   | 229 +++++-
 packages/api-client/src/traceql.ts                 | 131 +++
 packages/design-tokens/src/themes.css              |  11 +
 packages/design-tokens/src/tokens.css              |   5 +-
 135 files changed, 9489 insertions(+), 526 deletions(-)

diff --git a/apps/bff/CLAUDE.md b/apps/bff/CLAUDE.md
index d8047522..0127875c 100644
--- a/apps/bff/CLAUDE.md
+++ b/apps/bff/CLAUDE.md
@@ -7,7 +7,7 @@ what each one is for, and the wire rules that are easy to get wrong. The root
 `client/` is the ONLY layer that talks to OAP. If you are adding a call from
 anywhere else, that is the bug — read the layering rules in the root file first.
 
-## The endpoints — there are four, and they are not interchangeable
+## The endpoints — they are not interchangeable
 
 Configured under `oap:` (see `src/config/schema.ts`). Every one is a separate
 URL, and using the wrong one fails in a way that reads like "OAP is broken".
@@ -17,6 +17,7 @@ URL, and using the wrong one fails in a way that reads like "OAP is broken".
 | `oap.queryUrl` | `http://127.0.0.1:12800` | GraphQL query-protocol + `/status/*` |
 | `oap.adminUrl` | `http://127.0.0.1:17128` | runtime rule mgmt, DSL/MQE/OAL, inspect, live debug, `/ui-management/*` |
 | `oap.zipkinUrl` | `http://127.0.0.1:9412/zipkin` | the Zipkin-compatible trace API |
+| `oap.traceql.nativeUrl` / `.zipkinUrl` | unset | OAP's TraceQL (Tempo) service, one full URL per datasource |
 | `oap.mqe.host` / `.port` | unset | optional MQE override |
 
 Two things that have cost real time:
@@ -34,6 +35,14 @@ Two things that have cost real time:
 
 `oap.auth` (basic) and `oap.timeoutMs` apply to all of them.
 
+Two things about the TraceQL pair, because both fail quietly. Its `start` / `end`
+are **UNIX seconds** — the only OAP surface that takes them — and on the native
+datasource a trace id travels as hex-encoded UTF-8, which `client/traceql.ts`
+encodes on the way out and decodes on the way back so the encoded form never
+crosses the BFF boundary. There is deliberately no default URL: OAP ships the
+`traceQL` module disabled, so a default would have every Horizon probing a port
+nobody enabled.
+
 ## Cancellation
 
 A read the operator abandoned must stop costing OAP work. The caller's
@@ -119,6 +128,7 @@ The conversation VIEW relay holds the whole document before answering (gzip in m
 | `topology.ts`, `deployment.ts`, `instance-topology.ts`, `endpoint-dependency.ts`, `infra-3d-metrics.ts` — metrics | `events.ts` — events live in `records` |
 | | `ebpf.ts` — network profiling |
 | | `trace-tag.ts` — tag key/value autocomplete |
+| | `traceql.ts` — the Tempo API has no `coldStage` parameter at all, so there is nothing to send; a cold trace is read through the native or Zipkin route |
 | | `ai/` — the assistant's own reads |
 
 **Adding a route means placing it in this table.** The default is the right-hand
diff --git a/apps/bff/src/ai/lib/skills/rca/errors-sla.md b/apps/bff/src/ai/lib/skills/rca/errors-sla.md
index 3dce5cb1..a76a4c27 100644
--- a/apps/bff/src/ai/lib/skills/rca/errors-sla.md
+++ b/apps/bff/src/ai/lib/skills/rca/errors-sla.md
@@ -12,5 +12,5 @@ RENDER a catalog metric with show_widget(layer, scope, service, widgetId) — th
 3. show_topology -> walk UPSTREAM. For each upstream dependency, re-run the SLA/cpm check. If an upstream is also unhealthy, it is likely the real cause — fix the upstream first. Keep climbing to the root service. If an upstream is an unmanaged Virtual_* remote, only the client-side edge metric exists — check it and stop. If the root service is backed by an infra/database layer, kb_resolve_hierarchy into it and check its error/connection metrics.
 4. At the root service, drill scope — OAP does not roll up. kb_resolve_scope_drill to Endpoint scope and re-query the same SLA/cpm; render with show_widget — an endpoint-ranking entry from the service page if the catalog has one, otherwise the endpoint-scope entry per endpoint (scope='endpoint', endpoint=<name>). The failing endpoints stand out.
 5. kb_resolve_scope_drill to ServiceInstance scope -> re-query SLA plus the instance runtime metrics kb_browse_catalog(<layer>, instance) exposes, each with show_widget (scope='instance', instance=<name>); pick a few abnormal instances. For a k8s workload, read their pod logs now — list_pod_containers + fetch_pod_logs (instance attributes are not an assistant tool yet).
-6. Error stack. On a k8s pod, fetch_pod_logs names the failure (filter status/errors with keywordsOfContent). SURFACE THE TRACES yourself — pick the tool from kb_layer_capabilities(layer).tracesSource, never from the layer name: native -> show_traces(layer, service); zipkin -> list_zipkin_services, match the service, then show_zipkin_traces. Either one captures a frozen trace list + span waterfall inline. You do not get span contents back, so name the failing endpoint + window in your pr [...]
+6. Error stack. On a k8s pod, fetch_pod_logs names the failure (filter status/errors with keywordsOfContent). SURFACE THE TRACES yourself — pick the tool from kb_layer_capabilities(layer).traceStores, never from the layer name: it lists the stores the layer exposes — native -> show_traces(layer, service); zipkin -> list_zipkin_services, match the service, then show_zipkin_traces; a traceql-* store has no assistant tool, so name the Traces (TraceQL) tab instead. Either one captures a froz [...]
 7. If the cause stays hidden. AVAILABLE now: show_deployment for a manifest / resource-limit fault; propose_profiling (JVM / Go / eBPF). NOT wired yet: the events feed (recent reboot?), git / code-line — name the relevant one rather than fabricating a call.
diff --git a/apps/bff/src/ai/lib/skills/rca/latency.md b/apps/bff/src/ai/lib/skills/rca/latency.md
index 727320af..c280618e 100644
--- a/apps/bff/src/ai/lib/skills/rca/latency.md
+++ b/apps/bff/src/ai/lib/skills/rca/latency.md
@@ -12,4 +12,4 @@ RENDER a catalog metric with show_widget(layer, scope, service, widgetId) — th
 3. show_topology -> walk UPSTREAM. Check each dependency's resp-time. If an upstream is slow, fix it first — your spike is inherited, not owned. If the slow dependency is a Virtual_* remote absent from list_services, you only hold the client-side edge metric — check that edge resp-time and stop there. If the root service is backed by an infra/database layer, kb_resolve_hierarchy into it and check that layer's latency/connection metrics.
 4. At the root service, drill (no auto-rollup). kb_resolve_scope_drill -> Endpoint scope, re-query the same percentile metric and render with show_widget (an endpoint-ranking entry from the service page if the catalog has one, otherwise scope='endpoint', endpoint=<name>) for the worst endpoints. Then drill -> ServiceInstance scope, re-query resp-time with show_widget (scope='instance') — one hot instance = node-local cause.
 5. Correlate runtime at the hot instance. kb_browse_catalog(<layer>, instance) for the instance runtime metrics it exposes -> show_widget (scope='instance', instance=<name>); line a p99 spike up against GC pauses or CPU saturation. Cross-check instance attributes (JVM version, host, jar+version) — attributes tool coming.
-6. Deeper. AVAILABLE now: TRACES for the slow endpoint — pick the tool from kb_layer_capabilities(layer).tracesSource, not the layer name (native -> show_traces; zipkin -> list_zipkin_services then show_zipkin_traces); it captures the trace list + span waterfall inline, so surface it yourself instead of handing it off. Also fetch_pod_logs on the worst pod's container for the error stack (k8s workloads); propose_profiling (JVM / Go / eBPF network-packet) for hot methods, lock contention,  [...]
+6. Deeper. AVAILABLE now: TRACES for the slow endpoint — pick the tool from kb_layer_capabilities(layer).traceStores, not the layer name (it lists the stores the layer exposes: native -> show_traces; zipkin -> list_zipkin_services then show_zipkin_traces; a traceql-* store has no assistant tool — name the Traces (TraceQL) tab as the operator's next step); it captures the trace list + span waterfall inline, so surface it yourself instead of handing it off. Also fetch_pod_logs on the worst [...]
diff --git a/apps/bff/src/ai/lib/skills/rca/mesh.md b/apps/bff/src/ai/lib/skills/rca/mesh.md
index 7aa3aa1b..e5373d14 100644
--- a/apps/bff/src/ai/lib/skills/rca/mesh.md
+++ b/apps/bff/src/ai/lib/skills/rca/mesh.md
@@ -3,7 +3,7 @@ id: mesh
 title: Service mesh (Istio) root-cause
 whenToUse: The symptom is on a MESH / Istio service (sidecar-reported telemetry): SLA dropping or cpm climbing on a MESH-layer service, or you must decide whether a mesh fault sits in the app, the sidecar/network, or the Istio control plane.
 ---
-Refines the root-cause master for MESH. Mesh telemetry is sidecar-reported, not in-process agent — so every call has a client side and a server side, and there is a separate control plane whose health gates the whole data plane. Don't assume the reporter or the trace source by name (Envoy / ALS / Istiod) — confirm them from kb_layer_capabilities: who reports, and tracesSource (native | zipkin | both) to pick the trace tool. If tracesSource is zipkin, use list_zipkin_services -> show_zipk [...]
+Refines the root-cause master for MESH. Mesh telemetry is sidecar-reported, not in-process agent — so every call has a client side and a server side, and there is a separate control plane whose health gates the whole data plane. Don't assume the reporter or the trace source by name (Envoy / ALS / Istiod) — confirm them from kb_layer_capabilities: who reports, and traceStores (the list of stores the layer exposes) to pick the trace tool. If it holds zipkin and not native, use list_zipkin_ [...]
 
 RENDER a catalog metric with show_widget(layer, scope, service, widgetId) — the entry/widget id kb_browse_catalog returned, plus instance/endpoint for a drilled scope. It draws the dashboard widget with its full config (type, unit, thresholds, explanation). Keep show_figure(type, …) for a DERIVED expression the catalog has no entry for.
 
@@ -13,4 +13,4 @@ RENDER a catalog metric with show_widget(layer, scope, service, widgetId) — th
 4. Unmanaged remote: if the bad upstream is a Virtual_* (Virtual Database / MQ / Cache), it appears as its OWN service — kb_browse_catalog that Virtual service and show_widget its service-scope client entries, or read its edge on show_topology, and stop there (no server side exists).
 5. Control plane: if many services degrade at once, find the control-plane layer (resolve it via kb_layer_capabilities / kb_resolve_hierarchy — don't hardcode its key) and show_widget the control-plane health entries its catalog returns. When those go bad it's a whole-mesh root cause — the control plane gates every sidecar.
 6. Root service -> scope drill (no auto-rollup): kb_resolve_scope_drill(serviceId=<id>, toScope='instance'), then re-query the same catalog metric per sidecar with show_widget (scope='instance', instance=<name>) to find the outlier; kb_browse_catalog the ServiceInstance scope and show_widget the sidecar / proxy health entries it returns. Then kb_resolve_scope_drill(serviceId=<id>, toScope='endpoint') for the failing API.
-7. Error stack + deeper. AVAILABLE now: TRACES — surface them yourself with the tool kb_layer_capabilities(layer).tracesSource names (native -> show_traces; zipkin -> list_zipkin_services then show_zipkin_traces), which captures the trace list + span waterfall inline; show_deployment for a sidecar-injection / config manifest fault; fetch_pod_logs on the sidecar or app container (mesh pods run in k8s); propose_profiling (eBPF network) to inspect the sidecar path. NOT an assistant tool yet [...]
+7. Error stack + deeper. AVAILABLE now: TRACES — surface them yourself with the tool kb_layer_capabilities(layer).traceStores names (native -> show_traces; zipkin -> list_zipkin_services then show_zipkin_traces; a traceql-* store has no assistant tool), which captures the trace list + span waterfall inline; show_deployment for a sidecar-injection / config manifest fault; fetch_pod_logs on the sidecar or app container (mesh pods run in k8s); propose_profiling (eBPF network) to inspect the [...]
diff --git a/apps/bff/src/ai/lib/skills/rca/middleware-remote.md b/apps/bff/src/ai/lib/skills/rca/middleware-remote.md
index 121b28fd..2f58a541 100644
--- a/apps/bff/src/ai/lib/skills/rca/middleware-remote.md
+++ b/apps/bff/src/ai/lib/skills/rca/middleware-remote.md
@@ -16,4 +16,4 @@ RENDER a catalog metric with show_widget(layer, scope, service, widgetId) — th
    b. k8s HIERARCHY / infra. kb_resolve_hierarchy from the middleware down to its backing K8S_SERVICE / host layer and read THAT layer's pod/node metrics — memory pressure, DISK / volume full, CPU throttle, connection limits. This is how you cross from "the app is slow" to "its database node is out of disk". Infra causes live on the infra layer, not the middleware's golden metrics.
    c. NETWORK. Read the client→remote edge on show_topology (client/server relation metrics are not renderable as standalone figures); where an eBPF / network layer is present its wire metrics ARE service-scope and renderable — kb_browse_catalog + show_widget the wire entries it returns. A broker that is up but unreachable shows as network errors on the wire, not broker errors.
 4. Broker outlier (managed only). If the managed middleware IS unhealthy on its own metrics, kb_resolve_scope_drill to ServiceInstance scope (brokers/nodes) and re-query the SAME metric — OAP does NOT auto-roll-up — show_widget (scope='instance', instance=<broker name>) for the outlier broker/partition; drill to Endpoint for per-topic / per-command hotspots.
-5. Caller-side error stack. Surface the CALLER's traces yourself — pick the tool from kb_layer_capabilities(<caller layer>).tracesSource, not the layer name (native -> show_traces; zipkin -> list_zipkin_services then show_zipkin_traces) — the captured list + span waterfall show where the call into the middleware spends its time. Still NOT tools (name as next moves, do not fabricate): reading span contents / span logs yourself, the events feed for a recent broker reboot/rebalance, instanc [...]
+5. Caller-side error stack. Surface the CALLER's traces yourself — pick the tool from kb_layer_capabilities(<caller layer>).traceStores, not the layer name (native -> show_traces; zipkin -> list_zipkin_services then show_zipkin_traces) — the captured list + span waterfall show where the call into the middleware spends its time. Still NOT tools (name as next moves, do not fabricate): reading span contents / span logs yourself, the events feed for a recent broker reboot/rebalance, instance [...]
diff --git a/apps/bff/src/ai/lib/skills/rca/root-cause.md b/apps/bff/src/ai/lib/skills/rca/root-cause.md
index 55689f58..bd99d5d9 100644
--- a/apps/bff/src/ai/lib/skills/rca/root-cause.md
+++ b/apps/bff/src/ai/lib/skills/rca/root-cause.md
@@ -14,5 +14,5 @@ RENDER a catalog metric with show_widget(layer, scope, service, widgetId) — th
 4. Cross-layer hierarchy. If the root service is backed by an INFRA / database layer (a K8S_SERVICE down to its PostgreSQL / MongoDB, a service to its host/VM layer), kb_resolve_hierarchy to follow it INTO that layer and continue root-cause THERE — infra causes (memory pressure, disk/volume, connection limits) surface on the infra layer's own metrics, not the app service's. Re-query that layer via kb_browse_catalog. (kb_resolve_hierarchy crosses layers; kb_resolve_scope_drill stays withi [...]
 5. At the root service, find outliers. kb_resolve_scope_drill to ServiceInstance scope and re-query the same catalog metric — OAP does NOT roll up between scopes — then show_widget (scope='instance', instance=<name>) to spot abnormal instances. Repeat the drill to Endpoint scope for the worst API.
 6. Correlate runtime + logs. show_widget (scope='instance') the instance runtime metrics kb_browse_catalog(<layer>, instance) exposes. For a Kubernetes workload, pod logs are AVAILABLE now — list_pod_containers + fetch_pod_logs on the outlier pod for the error stack. (Instance attributes / dependency versions are not an assistant tool yet.)
-7. Error stack for the specific endpoint. SURFACE THE TRACES yourself — read kb_layer_capabilities(layer).tracesSource and let it pick the tool, never the layer name: native -> show_traces(layer, service); zipkin -> list_zipkin_services, match the service, then show_zipkin_traces. Both capture a frozen trace list + span waterfall inline. You cannot read span contents yourself, so name the endpoint + window in your prose and let the operator read the waterfall.
+7. Error stack for the specific endpoint. SURFACE THE TRACES yourself — read kb_layer_capabilities(layer).traceStores and let it pick the tool, never the layer name: native -> show_traces(layer, service); zipkin -> list_zipkin_services, match the service, then show_zipkin_traces; a traceql-* store has no assistant tool, so name the Traces (TraceQL) tab instead. Both capture a frozen trace list + span waterfall inline. You cannot read span contents yourself, so name the endpoint + window  [...]
 8. Deeper, if hidden. AVAILABLE now: show_deployment for a k8s/helm manifest / resource-limit fault; propose_profiling (JVM / Go / eBPF network-packet) when a profile would confirm a specific hypothesis. NOT an assistant tool yet: span-log contents, the events feed (recent reboot/deploy), git / code-line inspection — name them as the operator's next move.
diff --git a/apps/bff/src/ai/lib/skills/rca/saturation.md b/apps/bff/src/ai/lib/skills/rca/saturation.md
index 2e172d04..ad159f93 100644
--- a/apps/bff/src/ai/lib/skills/rca/saturation.md
+++ b/apps/bff/src/ai/lib/skills/rca/saturation.md
@@ -14,4 +14,4 @@ RENDER a catalog metric with show_widget(layer, scope, service, widgetId) — th
 5. For each outlier, kb_browse_catalog(<layer>, instance) for the instance runtime metrics it exposes, then kb_describe_metric and show_widget (scope='instance') against the cpm curve. Decision fork: resources tracking cpm linearly across all instances = genuine load surge (scale out); one instance's heap climbing monotonically with rising GC while cpm is flat = leak; resources pinned at a ceiling = capacity limit.
 6. kb_resolve_scope_drill -> Endpoint scope; show_widget the endpoint-ranking cpm entry the catalog returns (or the endpoint-scope entry per endpoint) to see if one API drives the surge.
 7. show_deployment (available) — inspect for under-provisioned limits / missing HPA that turns a surge into saturation.
-8. Error stack + deeper. AVAILABLE now: fetch_pod_logs on the outlier pod — the error stack (OOM, thread-pool or connection-pool exhaustion) usually names the cause; TRACES for the saturated endpoint, surfaced by you with the tool kb_layer_capabilities(layer).tracesSource names (native -> show_traces; zipkin -> list_zipkin_services then show_zipkin_traces), which captures the list + span waterfall inline; propose_profiling (JVM async / pprof / eBPF network) if a profile would confirm it. [...]
+8. Error stack + deeper. AVAILABLE now: fetch_pod_logs on the outlier pod — the error stack (OOM, thread-pool or connection-pool exhaustion) usually names the cause; TRACES for the saturated endpoint, surfaced by you with the tool kb_layer_capabilities(layer).traceStores names (native -> show_traces; zipkin -> list_zipkin_services then show_zipkin_traces; a traceql-* store has no assistant tool), which captures the list + span waterfall inline; propose_profiling (JVM async / pprof / eBPF [...]
diff --git a/apps/bff/src/client/traceql-error-tags.test.ts b/apps/bff/src/client/traceql-error-tags.test.ts
new file mode 100644
index 00000000..ace1503b
--- /dev/null
+++ b/apps/bff/src/client/traceql-error-tags.test.ts
@@ -0,0 +1,80 @@
+/*
+ * 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.
+ */
+
+/**
+ * The error markers a trace list reads off a search result. Each rule decides
+ * whether a row is painted as a FAILURE, so the cases that matter are the ones
+ * where a tag is present and says nothing is wrong — a 200, an rpc code of 0,
+ * an `error="false"` — which must not turn into a red row.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { attributesReportError } from './traceql-error-tags.js';
+
+const attrs = (o: Record<string, string>) => Object.entries(o).map(([key, value]) => ({ key, value }));
+
+describe('span error markers', () => {
+  it('reads an http status code as an outcome, not as presence', () => {
+    expect(attributesReportError(attrs({ 'http.status_code': '500' }))).toBe(true);
+    expect(attributesReportError(attrs({ 'http.status_code': '404' }))).toBe(true);
+    expect(attributesReportError(attrs({ 'http.status_code': '200' }))).toBe(false);
+    expect(attributesReportError(attrs({ 'http.status_code': '302' }))).toBe(false);
+    // The OTel-conventions spelling means the same thing.
+    expect(attributesReportError(attrs({ 'http.response.status_code': '503' }))).toBe(true);
+  });
+
+  it('treats rpc codes as gRPC does — 0 and OK are success', () => {
+    expect(attributesReportError(attrs({ 'rpc.status_code': '0' }))).toBe(false);
+    expect(attributesReportError(attrs({ 'rpc.status_code': 'OK' }))).toBe(false);
+    expect(attributesReportError(attrs({ 'rpc.status_code': '14' }))).toBe(true);
+    expect(attributesReportError(attrs({ 'rpc.status_code': 'UNAVAILABLE' }))).toBe(true);
+    expect(attributesReportError(attrs({ 'grpc.status_code': '2' }))).toBe(true);
+  });
+
+  it('honours a marker that says it is NOT a failure', () => {
+    // Zipkin writes the message in `error`, but an instrumentation may write a
+    // boolean — and `error="false"` is a span reporting success.
+    expect(attributesReportError(attrs({ error: 'false' }))).toBe(false);
+    expect(attributesReportError(attrs({ error: '0' }))).toBe(false);
+    expect(attributesReportError(attrs({ error: 'connection reset' }))).toBe(true);
+    expect(attributesReportError(attrs({ error: 'true' }))).toBe(true);
+    // OAP pads a span that lacks a projected attribute with an empty value.
+    expect(attributesReportError(attrs({ error: '' }))).toBe(false);
+  });
+
+  it('reads the OpenTelemetry status attributes', () => {
+    expect(attributesReportError(attrs({ 'otel.status_code': 'ERROR' }))).toBe(true);
+    expect(attributesReportError(attrs({ 'status.code': 'STATUS_CODE_ERROR' }))).toBe(true);
+    expect(attributesReportError(attrs({ 'otel.status_code': 'OK' }))).toBe(false);
+    // UNSET is the absence of a verdict, not a failure.
+    expect(attributesReportError(attrs({ 'otel.status_code': 'UNSET' }))).toBe(false);
+  });
+
+  it('takes an exception marker as a failure by its presence', () => {
+    expect(attributesReportError(attrs({ 'exception.type': 'java.io.IOException' }))).toBe(true);
+    expect(attributesReportError(attrs({ 'error.kind': 'timeout' }))).toBe(true);
+    // An empty one says nothing.
+    expect(attributesReportError(attrs({ 'exception.type': '' }))).toBe(false);
+  });
+
+  it('ignores tags that are not markers at all', () => {
+    expect(attributesReportError(attrs({ 'http.method': 'GET', 'db.type': 'sql', 'span.kind': 'SPAN_KIND_SERVER' }))).toBe(false);
+    expect(attributesReportError([])).toBe(false);
+    // A tag whose NAME merely ends in a marker's is not that marker.
+    expect(attributesReportError(attrs({ 'app.error.kind': 'x', 'business_status': 'error' }))).toBe(false);
+  });
+});
diff --git a/apps/bff/src/client/traceql-error-tags.ts b/apps/bff/src/client/traceql-error-tags.ts
new file mode 100644
index 00000000..46ce8bc3
--- /dev/null
+++ b/apps/bff/src/client/traceql-error-tags.ts
@@ -0,0 +1,93 @@
+/*
+ * 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.
+ */
+
+/**
+ * The attributes that say a span FAILED.
+ *
+ * A Tempo search result carries no span status, and which attributes it does
+ * carry is an OAP setting (`SW_TRACEQL_*_TRACES_LIST_RESULT_TAGS`), so the
+ * only failure evidence a list can hold is whichever marker the deployment
+ * happens to project — hence a list covering SkyWalking's, Zipkin's and
+ * OpenTelemetry's conventions.
+ *
+ * They declare failure ONLY. A 200 on one span says nothing about a sibling
+ * that threw without tagging anything, so `false` here means "no marker
+ * reports a failure", never "the trace succeeded".
+ */
+
+/** One attribute, and what its value has to look like to mean a failure. */
+interface ErrorTagRule {
+  key: string;
+  /** Absent means presence alone is the failure (Zipkin/OTel error markers). */
+  failsWhen?: (value: string) => boolean;
+}
+
+const httpFails = (v: string): boolean => {
+  const n = Number(v);
+  return Number.isFinite(n) && n >= 400;
+};
+
+/** RPC status codes are gRPC-style: 0 / OK is success, anything else is not.
+ *  A non-numeric, non-OK value is a named code (`UNAVAILABLE`), so it fails. */
+const rpcFails = (v: string): boolean => {
+  const t = v.trim();
+  if (!t) return false;
+  const n = Number(t);
+  if (Number.isFinite(n)) return n !== 0;
+  return t.toUpperCase() !== 'OK';
+};
+
+const otelStatusFails = (v: string): boolean => {
+  const t = v.trim().toUpperCase();
+  return t === 'ERROR' || t === 'STATUS_CODE_ERROR' || t === '2';
+};
+
+/** A marker whose VALUE can still say "not a failure" — Zipkin writes the
+ *  message there, but an instrumentation may write a boolean. Empty says
+ *  nothing at all: OAP pads spans that lack a projected attribute with `""`,
+ *  so treating that as a failure would redden a whole trace for the spans
+ *  that never carried the tag. */
+const markerFails = (v: string): boolean => {
+  const t = v.trim().toLowerCase();
+  return t !== '' && t !== 'false' && t !== '0';
+};
+
+export const TRACE_ERROR_TAGS: readonly ErrorTagRule[] = [
+  { key: 'error', failsWhen: markerFails },
+  { key: 'http.status_code', failsWhen: httpFails },
+  { key: 'http.response.status_code', failsWhen: httpFails },
+  { key: 'rpc.status_code', failsWhen: rpcFails },
+  { key: 'grpc.status_code', failsWhen: rpcFails },
+  { key: 'rpc.grpc.status_code', failsWhen: rpcFails },
+  { key: 'otel.status_code', failsWhen: otelStatusFails },
+  { key: 'status.code', failsWhen: otelStatusFails },
+  { key: 'exception.type' },
+  { key: 'error.kind' },
+  { key: 'error.type' },
+  { key: 'error.message' },
+  { key: 'error.object' },
+];
+
+/** Whether these attributes report a failure. */
+export function attributesReportError(attrs: ReadonlyArray<{ key: string; value: string }>): boolean {
+  for (const a of attrs) {
+    const rule = TRACE_ERROR_TAGS.find((r) => r.key === a.key);
+    if (!rule) continue;
+    if (rule.failsWhen ? rule.failsWhen(a.value) : a.value.trim() !== '') return true;
+  }
+  return false;
+}
diff --git a/apps/bff/src/client/traceql.ts b/apps/bff/src/client/traceql.ts
new file mode 100644
index 00000000..341691c4
--- /dev/null
+++ b/apps/bff/src/client/traceql.ts
@@ -0,0 +1,472 @@
+/*
+ * 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.
+ */
+
+/**
+ * REST client for OAP's TraceQL service — the Tempo query API, served by a
+ * separate HTTP server (default port 3200) with one context path per
+ * datasource. Each datasource is configured as a full URL, so this client
+ * never assembles OAP's path layout.
+ *
+ *   GET <base>/api/status/buildinfo
+ *   GET <base>/api/search?q&start&end&limit
+ *   GET <base>/api/v2/search/tags?start&end
+ *   GET <base>/api/v2/search/tag/{tagName}/values?q&start&end
+ *   GET <base>/api/v2/traces/{traceId}?start&end
+ *
+ * Two things about this API differ from every other OAP surface, and both
+ * live here so nothing above has to remember them:
+ *
+ *   1. `start` / `end` are UNIX **seconds**. Horizon speaks milliseconds
+ *      everywhere else (and formatted date strings to native GraphQL).
+ *   2. On the native datasource a trace id is hex-encoded UTF-8, because
+ *      Tempo requires hex trace ids. The encoded form is a wire detail that
+ *      NEVER crosses the BFF boundary in either direction: requests leave
+ *      encoded, responses are decoded before they are returned. Span ids are
+ *      NOT encoded, even though they sit beside an encoded trace id in the
+ *      same object. Zipkin ids are already hex and are left alone.
+ */
+
+import type {
+  FetchLike,
+  TraceQLDatasource,
+  TraceQLServiceEntry,
+  TraceQLSpan,
+  TraceQLTagScope,
+  TraceQLTraceRow,
+} from '@skywalking-horizon-ui/api-client';
+import { wireFetch } from './wire-log.js';
+import { attributesReportError } from './traceql-error-tags.js';
+import type { HorizonConfig } from '../config/schema.js';
+
+export interface TraceQLClientOpts {
+  /** Full base URL including OAP's context path, e.g. `http://oap:3200/skywalking`. */
+  baseUrl: string;
+  ds: TraceQLDatasource;
+  timeoutMs: number;
+  fetch?: FetchLike;
+  auth?: { username: string; password: string };
+  /** The caller's cancellation. Read routes pass the client's; a probe that
+   *  was cancelled measured nothing, so its result must not be cached. */
+  signal?: AbortSignal;
+}
+
+/** Resolve a datasource's options, or `null` when no URL is configured —
+ *  which is how a source is turned off. */
+export function buildTraceQLOpts(
+  cfg: HorizonConfig,
+  ds: TraceQLDatasource,
+  fetch?: FetchLike,
+  signal?: AbortSignal,
+): TraceQLClientOpts | null {
+  const baseUrl = ds === 'native' ? cfg.oap.traceql.nativeUrl : cfg.oap.traceql.zipkinUrl;
+  if (!baseUrl) return null;
+  return {
+    baseUrl,
+    ds,
+    timeoutMs: cfg.oap.timeoutMs,
+    fetch,
+    auth: cfg.oap.auth,
+    ...(signal ? { signal } : {}),
+  };
+}
+
+/** A non-2xx answer with its status. 404 on a by-id read means the trace was
+ *  not found; every other status is a failure worth surfacing, including the
+ *  400 whose body carries OAP's own message for a refused expression. */
+export class TraceQLHttpError extends Error {
+  readonly status: number;
+  readonly body: string;
+  constructor(status: number, message: string, body = '') {
+    super(message);
+    this.name = 'TraceQLHttpError';
+    this.status = status;
+    this.body = body;
+  }
+}
+
+/** OAP answers a refused expression with `{"error":"…"}`. */
+function errorMessageOf(body: string): string | null {
+  try {
+    const parsed = JSON.parse(body) as { error?: unknown };
+    return typeof parsed.error === 'string' ? parsed.error : null;
+  } catch {
+    return null;
+  }
+}
+
+async function traceqlFetch<T>(
+  opts: TraceQLClientOpts,
+  path: string,
+  params?: Record<string, string | number | undefined>,
+): Promise<T> {
+  const f = wireFetch(opts.fetch ?? globalThis.fetch.bind(globalThis));
+  const qs = new URLSearchParams();
+  for (const [k, v] of Object.entries(params ?? {})) {
+    if (v !== undefined && v !== '') qs.set(k, String(v));
+  }
+  const suffix = qs.toString() ? `?${qs.toString()}` : '';
+  const url = opts.baseUrl.replace(/\/$/, '') + path + suffix;
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
+  const headers: Record<string, string> = { Accept: 'application/json' };
+  if (opts.auth) {
+    const raw = `${opts.auth.username}:${opts.auth.password}`;
+    headers.authorization = `Basic ${Buffer.from(raw, 'utf8').toString('base64')}`;
+  }
+  // Combined rather than chosen between: the caller's cancellation must not
+  // discard this client's own timeout.
+  const signal = opts.signal ? AbortSignal.any([controller.signal, opts.signal]) : controller.signal;
+  // The timer covers the BODY as well as the headers: a native search embeds
+  // every span of every trace, so the bytes are where the time actually goes.
+  try {
+    const res = await f(url, { method: 'GET', headers, signal });
+    if (!res.ok) {
+      const body = await res.text().catch(() => '');
+      const detail = errorMessageOf(body) ?? body.slice(0, 200);
+      throw new TraceQLHttpError(res.status, `traceql ${res.status} ${url} — ${detail}`, body.trim());
+    }
+    return (await res.json()) as T;
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+/** Milliseconds to the UNIX seconds this API takes. */
+function toSeconds(ms: number): number {
+  return Math.floor(ms / 1000);
+}
+
+/* ---------------------------------------------------------------- trace ids */
+
+/** Native only: hex of the id's UTF-8 bytes, which is what Tempo requires. */
+export function encodeNativeTraceId(traceId: string): string {
+  return Buffer.from(traceId, 'utf8').toString('hex');
+}
+
+/** Inverse of {@link encodeNativeTraceId}. Only ever applied to an id that
+ *  CAME from the native datasource, which is always encoded — so there is no
+ *  guessing to do, and a hex id that decodes to nothing sensible is returned
+ *  unchanged rather than mangled. */
+export function decodeNativeTraceId(encoded: string): string {
+  if (!/^(?:[0-9a-f]{2})+$/i.test(encoded)) return encoded;
+  const bytes = Buffer.from(encoded, 'hex');
+  const decoded = bytes.toString('utf8');
+  // Round-tripping proves the bytes were valid UTF-8 rather than an id that
+  // merely looked like hex.
+  return Buffer.from(decoded, 'utf8').equals(bytes) ? decoded : encoded;
+}
+
+/** What to put on the wire. The caller holds a REAL id — that is the
+ *  invariant — so this always encodes on the native datasource. A hex id
+ *  pasted out of Grafana is handled by {@link traceqlTraceById}'s retry, not
+ *  by guessing here: a guess that gets it wrong fails a valid lookup silently,
+ *  which is the worse of the two errors. */
+export function wireTraceId(ds: TraceQLDatasource, traceId: string): string {
+  return ds === 'native' ? encodeNativeTraceId(traceId) : traceId;
+}
+
+/** What to show for an id that came off the wire. */
+export function displayTraceId(ds: TraceQLDatasource, traceId: string): string {
+  return ds === 'native' ? decodeNativeTraceId(traceId) : traceId;
+}
+
+/* ------------------------------------------------------------- wire shapes */
+
+interface WireAttribute {
+  key: string;
+  value?: { stringValue?: string };
+}
+
+interface WireSearchSpan {
+  spanID?: string;
+  startTimeUnixNano?: string;
+  durationNanos?: string;
+  attributes?: WireAttribute[];
+}
+
+interface WireSearchTrace {
+  traceID?: string;
+  rootServiceName?: string;
+  rootTraceName?: string;
+  startTimeUnixNano?: string;
+  durationMs?: number;
+  spanSets?: Array<{ spans?: WireSearchSpan[]; matched?: number }>;
+}
+
+interface WireSearchResponse {
+  traces?: WireSearchTrace[];
+}
+
+interface WireOtlpSpan {
+  traceId?: string;
+  spanId?: string;
+  parentSpanId?: string;
+  name?: string;
+  kind?: string;
+  startTimeUnixNano?: string;
+  endTimeUnixNano?: string;
+  attributes?: WireAttribute[];
+  events?: Array<{ timeUnixNano?: string; name?: string; attributes?: WireAttribute[] }>;
+  status?: { code?: string; message?: string };
+}
+
+interface WireOtlpResponse {
+  trace?: {
+    resourceSpans?: Array<{
+      resource?: { attributes?: WireAttribute[] };
+      scopeSpans?: Array<{ scope?: { name?: string; version?: string }; spans?: WireOtlpSpan[] }>;
+    }>;
+  };
+}
+
+interface WireTagsV2Response {
+  scopes?: Array<{ name?: string; tags?: string[] }>;
+}
+
+interface WireTagValuesResponse {
+  tagValues?: Array<{ type?: string; value?: string }>;
+}
+
+export interface TraceQLBuildInfo {
+  version: string;
+}
+
+/** Attributes as they arrive: a LIST, which may repeat a key. OAP emits
+ *  `net.host.ip` twice when an endpoint has both IPv4 and IPv6, so folding
+ *  this into an object silently keeps only the last one. */
+function attrList(attrs: WireAttribute[] | undefined): Array<{ key: string; value: string }> {
+  const out: Array<{ key: string; value: string }> = [];
+  for (const a of attrs ?? []) {
+    if (a.key && a.value?.stringValue !== undefined) out.push({ key: a.key, value: a.value.stringValue });
+  }
+  return out;
+}
+
+/** First value for a key — for the few derived reads (service name, kind)
+ *  where one value is what the caller means. */
+function attrOf(attrs: WireAttribute[] | undefined, key: string): string | undefined {
+  return attrs?.find((a) => a.key === key)?.value?.stringValue;
+}
+
+function num(raw: string | number | undefined): number {
+  const n = typeof raw === 'number' ? raw : Number(raw ?? 0);
+  return Number.isFinite(n) ? n : 0;
+}
+
+/* ----------------------------------------------------------------- queries */
+
+export async function traceqlBuildInfo(opts: TraceQLClientOpts): Promise<TraceQLBuildInfo> {
+  const raw = await traceqlFetch<{ version?: string }>(opts, '/api/status/buildinfo');
+  return { version: raw.version ?? '' };
+}
+
+export interface TraceQLSearchQuery {
+  /** The TraceQL expression, verbatim. Empty means the bare `{}` match-all. */
+  q?: string;
+  startMs: number;
+  endMs: number;
+  limit: number;
+}
+
+/**
+ * Search, reduced.
+ *
+ * The native datasource returns EVERY span of every matched trace with no
+ * cap (`spss` is accepted and ignored upstream), so a page can be tens of
+ * thousands of span objects. Nothing above needs them: the row keeps the
+ * summary, the span count and the services touched, and the detail call
+ * fetches spans when a trace is actually opened.
+ */
+export async function traceqlSearch(
+  opts: TraceQLClientOpts,
+  query: TraceQLSearchQuery,
+): Promise<TraceQLTraceRow[]> {
+  const raw = await traceqlFetch<WireSearchResponse>(opts, '/api/search', {
+    q: query.q && query.q.trim() ? query.q.trim() : undefined,
+    start: toSeconds(query.startMs),
+    end: toSeconds(query.endMs),
+    limit: query.limit,
+  });
+  const focus = focusServiceOf(query.q);
+  return (raw.traces ?? []).map((t) => toRow(opts.ds, t, focus));
+}
+
+/**
+ * The service a query is about, if it names one.
+ *
+ * Read off the expression rather than passed in, so a hand-written query gets
+ * the same treatment as one the builder produced. Only the equality form is
+ * recognised, which is the only form this backend honours anyway.
+ */
+export function focusServiceOf(q: string | undefined): string | null {
+  if (!q) return null;
+  const m = /(?:^|[{\s&|(])\.?(?:resource\.)?service(?:\.name)?\s*=\s*"([^"]+)"/.exec(q);
+  return m ? m[1] : null;
+}
+
+function toRow(ds: TraceQLDatasource, t: WireSearchTrace, focus: string | null): TraceQLTraceRow {
+  const spans = (t.spanSets ?? []).flatMap((s) => s.spans ?? []);
+  const traceStartNs = num(t.startTimeUnixNano);
+  const services: string[] = [];
+  let anyError = false;
+  let entry: TraceQLServiceEntry | undefined;
+  let entryStartNs: number | null = null;
+  for (const s of spans) {
+    const svc = attrOf(s.attributes, 'service.name');
+    if (svc && !services.includes(svc)) services.push(svc);
+    // Which attributes reach a search result is an OAP setting, so failure is
+    // read from whichever of the known markers the span happens to carry.
+    if (!anyError && attributesReportError(attrList(s.attributes))) anyError = true;
+    if (focus && svc === focus) {
+      const startNs = num(s.startTimeUnixNano);
+      // The service's FIRST span — where it enters the trace. A service can be
+      // re-entered, and summing its spans would double-count nested ones.
+      // Compared in NANOSECONDS: the stored offset is rounded to ms, so
+      // comparing against it picks the wrong span for sub-millisecond gaps.
+      if (entryStartNs === null || startNs < entryStartNs) {
+        entryStartNs = startNs;
+        entry = {
+          service: svc,
+          offsetMs: Math.max(0, Math.round((startNs - traceStartNs) / 1e6)),
+          durationMs: Math.round(num(s.durationNanos) / 1e6),
+          kind: attrOf(s.attributes, 'span.kind') ?? '',
+        };
+      }
+    }
+  }
+  return {
+    traceId: displayTraceId(ds, t.traceID ?? ''),
+    rootService: t.rootServiceName ?? '',
+    rootName: t.rootTraceName ?? '',
+    startMs: Math.round(traceStartNs / 1e6),
+    durationMs: num(t.durationMs),
+    spanCount: spans.length,
+    services,
+    serviceEntry: entry,
+    // A marker declares FAILURE only. No marker is not success: the result
+    // tags are configurable upstream and a span can fail carrying none of
+    // them, so the row stays unknown for the status pass — or for the reader —
+    // to settle.
+    isError: anyError ? true : undefined,
+  };
+}
+
+export interface TraceQLTraceDetail {
+  traceId: string;
+  spans: TraceQLSpan[];
+}
+
+/**
+ * Trace detail as a flat span list, parent linkage intact.
+ *
+ * `startMs` / `endMs` are optional: omitting them makes the native
+ * datasource search from 2020 and makes Zipkin ignore the window entirely,
+ * which is what a lookup with no time range wants.
+ */
+export async function traceqlTraceById(
+  opts: TraceQLClientOpts,
+  traceId: string,
+  window?: { startMs: number; endMs: number },
+): Promise<TraceQLTraceDetail | null> {
+  const wireId = wireTraceId(opts.ds, traceId);
+  let raw: WireOtlpResponse;
+  try {
+    raw = await traceqlFetch<WireOtlpResponse>(opts, `/api/v2/traces/${encodeURIComponent(wireId)}`, {
+      start: window ? toSeconds(window.startMs) : undefined,
+      end: window ? toSeconds(window.endMs) : undefined,
+    });
+  } catch (e) {
+    if (e instanceof TraceQLHttpError && e.status === 404) return null;
+    throw e;
+  }
+  const spans: TraceQLSpan[] = [];
+  for (const rs of raw.trace?.resourceSpans ?? []) {
+    const resourceAttributes = attrList(rs.resource?.attributes);
+    const service = attrOf(rs.resource?.attributes, 'service.name') ?? '';
+    for (const ss of rs.scopeSpans ?? []) {
+      for (const s of ss.spans ?? []) {
+        const startNs = num(s.startTimeUnixNano);
+        const endNs = num(s.endTimeUnixNano);
+        spans.push({
+          spanId: s.spanId ?? '',
+          parentSpanId: s.parentSpanId ?? '',
+          service,
+          resourceAttributes,
+          ...(ss.scope?.name ? { scopeName: ss.scope.name } : {}),
+          ...(ss.scope?.version ? { scopeVersion: ss.scope.version } : {}),
+          name: s.name ?? '',
+          kind: s.kind ?? '',
+          startUs: Math.round(startNs / 1000),
+          durationUs: Math.max(0, Math.round((endNs - startNs) / 1000)),
+          // OTLP has three states and UNSET is not success — the Zipkin
+          // converter defaults to it, so collapsing it to `false` would paint
+          // every unknown span green.
+          status: s.status?.code === 'STATUS_CODE_ERROR' ? 'error'
+            : s.status?.code === 'STATUS_CODE_OK' ? 'ok' : 'unset',
+          statusMessage: s.status?.message || undefined,
+          attributes: attrList(s.attributes),
+          events: (s.events ?? []).map((ev) => ({
+            timeUs: Math.round(num(ev.timeUnixNano) / 1000),
+            name: ev.name ?? '',
+            attributes: attrList(ev.attributes),
+          })),
+        });
+      }
+    }
+  }
+  if (spans.length === 0) return null;
+  // The caller passed the REAL id, so it is returned unchanged — decoding it
+  // again would mangle an id that merely looks like hex.
+  return { traceId, spans };
+}
+
+export async function traceqlTags(
+  opts: TraceQLClientOpts,
+  window: { startMs: number; endMs: number },
+): Promise<TraceQLTagScope[]> {
+  const raw = await traceqlFetch<WireTagsV2Response>(opts, '/api/v2/search/tags', {
+    start: toSeconds(window.startMs),
+    end: toSeconds(window.endMs),
+  });
+  return (raw.scopes ?? []).map((s) => ({ name: s.name ?? '', tags: s.tags ?? [] }));
+}
+
+/**
+ * Values for one tag.
+ *
+ * The tag name has to reach OAP in the form its handler switches on:
+ * `resource.service.name` and `status` are special-cased, ordinary tags want
+ * the `span.` prefix, and `name` / `resource.instance` / `resource.remote.service`
+ * need a service-bearing `q` or they answer empty by design.
+ */
+export async function traceqlTagValues(
+  opts: TraceQLClientOpts,
+  tag: string,
+  window: { startMs: number; endMs: number },
+  q?: string,
+): Promise<string[]> {
+  const raw = await traceqlFetch<WireTagValuesResponse>(
+    opts,
+    `/api/v2/search/tag/${encodeURIComponent(tag)}/values`,
+    {
+      q: q && q.trim() ? q.trim() : undefined,
+      start: toSeconds(window.startMs),
+      end: toSeconds(window.endMs),
+    },
+  );
+  return (raw.tagValues ?? []).map((v) => v.value ?? '').filter((v) => v !== '');
+}
diff --git a/apps/bff/src/config/schema.ts b/apps/bff/src/config/schema.ts
index cd87b4b3..e6a5caa8 100644
--- a/apps/bff/src/config/schema.ts
+++ b/apps/bff/src/config/schema.ts
@@ -140,6 +140,13 @@ const oapSchema = z
       .strict()
       .default({}),
     zipkinUrl: z.string().url().default('http://127.0.0.1:9412/zipkin'),
+    traceql: z
+      .object({
+        nativeUrl: z.string().url().or(z.literal('')).default(''),
+        zipkinUrl: z.string().url().or(z.literal('')).default(''),
+      })
+      .strict()
+      .default({}),
   })
   .strict();
 
diff --git a/apps/bff/src/http/query/menu-rows.regression.test.ts b/apps/bff/src/http/query/menu-rows.regression.test.ts
index d6dbc4be..2ba267ee 100644
--- a/apps/bff/src/http/query/menu-rows.regression.test.ts
+++ b/apps/bff/src/http/query/menu-rows.regression.test.ts
@@ -31,18 +31,21 @@
  */
 
 import { describe, it, expect } from 'vitest';
-import type { LayerCaps, LayerDef, LayerSlots } from '@skywalking-horizon-ui/api-client';
+import type { LayerCaps, LayerDef, LayerSlots, TracesConfig } from '@skywalking-horizon-ui/api-client';
 import {
   resolveLayerMenuRows,
   firstLayerMenuRow,
   isSingleFeatureLayer,
+  resolveTraceStores,
   DEFAULT_LAYER_ROW_ORDER,
+  TRACE_STORES,
+  TRACE_STORE_ROWS,
 } from '@skywalking-horizon-ui/api-client';
 import { allLayerTemplates, type LayerTemplate } from '../../logic/layers/loader.js';
 import { componentsSchema } from '../../logic/templates/bundled-schema.js';
 import { capsForTemplate } from '../../logic/layers/caps.js';
 
-type Legacy = { caps: LayerCaps; slots: LayerSlots; traces?: { source?: string } };
+type Legacy = { caps: LayerCaps; slots: LayerSlots; traces?: TracesConfig };
 
 /** The sidebar's `v-if` chain, in template order (SidebarLayerChildren.vue). */
 function oracleSidebarRows(L: Legacy): string[] {
@@ -56,8 +59,13 @@ function oracleSidebarRows(L: Legacy): string[] {
   if (hasTopology) rows.push('topology');
   if (L.caps.deployment) rows.push('deployment');
   if (L.caps.endpointDependency) rows.push('dependency');
-  if (L.caps.traces) rows.push('trace');
-  if (L.caps.traces && L.traces?.source === 'both') rows.push('zipkin-trace');
+  // One row per declared trace store, and no default: the frozen sidebar
+  // pushed `trace` for any traces-capable layer and `zipkin-trace` only for
+  // `both`, which meant a pure-Zipkin layer showed its Zipkin explorer under
+  // the `trace` path. Stores now own their rows outright.
+  if (L.caps.traces) {
+    for (const store of resolveTraceStores(L.traces)) rows.push(TRACE_STORE_ROWS[store]);
+  }
   if (L.caps.logs) rows.push('logs');
   if (L.caps.evaluationRecord) rows.push('evaluation-record');
   if (L.caps.browserErrors) rows.push('browser-errors');
@@ -196,8 +204,9 @@ describe('default order vs. the component list', () => {
       components: { service: false, [key]: true },
       // Two components are gated on a config block as well as their flag.
       ...(key === 'deployment' ? { deployment: { roles: [], roleToRole: [] } } : {}),
-      // `traces` carries two rows when the layer ships both span formats.
-      ...(key === 'traces' ? { traces: { source: 'both' as const } } : {}),
+      // `traces` carries one row per store the layer declares; this probe
+      // declares them all, to enumerate what the component can produce.
+      ...(key === 'traces' ? { traces: { sources: [...TRACE_STORES] } } : {}),
     } as unknown as LayerTemplate;
     const caps = capsForTemplate(t, t);
     return resolveLayerMenuRows({ caps, slots: {}, traces: t.traces }).map((r) => r.path);
@@ -217,13 +226,14 @@ describe('default order vs. the component list', () => {
     expect([...DEFAULT_LAYER_ROW_ORDER].sort()).toEqual([...produced].sort());
   });
 
-  it('is one row per component, plus the second trace row', () => {
-    // 18 components → 19 rows: `traces` is the only one that resolves to
-    // two (native and Zipkin span formats get their own tabs).
-    expect(DEFAULT_LAYER_ROW_ORDER).toHaveLength(COMPONENT_KEYS.length + 1);
+  it('is one row per component, plus one per extra trace store', () => {
+    // `traces` is the only component that resolves to more than one row: each
+    // trace store a layer can declare owns its own tab, because a store is a
+    // different body of spans rather than a different view of one.
+    expect(DEFAULT_LAYER_ROW_ORDER).toHaveLength(COMPONENT_KEYS.length + TRACE_STORES.length - 1);
     const multi = COMPONENT_KEYS.filter((k) => rowsForComponent(k).length > 1);
     expect(multi).toEqual(['traces']);
-    expect(rowsForComponent('traces')).toEqual(['trace', 'zipkin-trace']);
+    expect(rowsForComponent('traces')).toEqual(TRACE_STORES.map((s) => TRACE_STORE_ROWS[s]));
   });
 });
 
diff --git a/apps/bff/src/http/query/paging-has-next.test.ts b/apps/bff/src/http/query/paging-has-next.test.ts
index 285db88f..a2bd10cc 100644
--- a/apps/bff/src/http/query/paging-has-next.test.ts
+++ b/apps/bff/src/http/query/paging-has-next.test.ts
@@ -415,10 +415,14 @@ describe('events — an exactly-full window is complete, not truncated', () => {
 
 describe('native traces — the capped badge the list never had', () => {
   const URL = '/api/layer/mesh/traces';
+  // A layer declares its trace stores; nothing defaults. These cases are about
+  // the capped badge, so they state the store rather than inherit one.
+  const NATIVE_PREVIEW = JSON.stringify({ sources: ['native'] });
 
   it('a result that exactly fills the limit is complete', async () => {
     const out = await post(registerTraceRoutes, fakeOap(5).fetch, URL, {
       source: 'native',
+      previewConfig: NATIVE_PREVIEW,
       ...IDENTITY,
       pageSize: 5,
     });
@@ -429,6 +433,7 @@ describe('native traces — the capped badge the list never had', () => {
   it('one row beyond the limit is reported as capped, and never rendered', async () => {
     const out = await post(registerTraceRoutes, fakeOap(6).fetch, URL, {
       source: 'native',
+      previewConfig: NATIVE_PREVIEW,
       ...IDENTITY,
       pageSize: 5,
     });
diff --git a/apps/bff/src/http/query/service-scope-guards.test.ts b/apps/bff/src/http/query/service-scope-guards.test.ts
index e2d574f7..227b8183 100644
--- a/apps/bff/src/http/query/service-scope-guards.test.ts
+++ b/apps/bff/src/http/query/service-scope-guards.test.ts
@@ -48,6 +48,9 @@ import { registerInstanceRoute } from './instance.js';
 import { registerEndpointRoute } from './endpoint.js';
 
 const SERVICE_NAME = 'songs.sample-services';
+/** A layer declares its trace stores; there is no default. These tests are
+ *  about scoping, so they state the store rather than inherit one. */
+const NATIVE_PREVIEW = JSON.stringify({ sources: ['native'] });
 const SERVICE_ID = 'c29uZ3M=.1';
 const INSTANCE_ID = 'c29uZ3M=.1_aW5zdC0x';
 /** The pair as every picker holds it, spelled for a query string. */
@@ -187,7 +190,7 @@ describe('traces scope on the id they were given, and refuse a lone name', () =>
       method: 'POST',
       url: '/api/layer/mesh/traces',
       headers: { cookie: `horizon_sid=${sid}`, 'content-type': 'application/json' },
-      payload: { source: 'native', ...body },
+      payload: { source: 'native', previewConfig: NATIVE_PREVIEW, ...body },
     });
     return res.json();
   };
diff --git a/apps/bff/src/http/query/trace-id-lookup.test.ts b/apps/bff/src/http/query/trace-id-lookup.test.ts
index 7c5d6016..380e22ca 100644
--- a/apps/bff/src/http/query/trace-id-lookup.test.ts
+++ b/apps/bff/src/http/query/trace-id-lookup.test.ts
@@ -122,7 +122,7 @@ const LONG_ID = '1234567890abcdef1234567890abcdef';
 describe('native: a trace id is looked up with no window unless one is sent', () => {
   it('sends the id and no queryDuration when no window is sent, even with the Cold pill on', async () => {
     const oap = fakeOap();
-    await callCold(oap, 'POST', '/api/layer/general/traces', { source: 'native', traceId: 'abc.1.2', pageSize: 20 });
+    await callCold(oap, 'POST', '/api/layer/general/traces', { previewConfig: JSON.stringify({ sources: ['native'] }), source: 'native', traceId: 'abc.1.2', pageSize: 20 });
     expect(oap.asked.conditions).toHaveLength(1);
     expect(oap.asked.conditions[0]).toMatchObject({ traceId: 'abc.1.2' });
     expect(oap.asked.conditions[0]).not.toHaveProperty('queryDuration');
@@ -130,13 +130,13 @@ describe('native: a trace id is looked up with no window unless one is sent', ()
 
   it('bounds the id by the window it is sent with, and asks for the cold stage within it', async () => {
     const oap = fakeOap();
-    await callCold(oap, 'POST', '/api/layer/general/traces', { source: 'native', traceId: 'abc.1.2', windowMinutes: 60, pageSize: 20 });
+    await callCold(oap, 'POST', '/api/layer/general/traces', { previewConfig: JSON.stringify({ sources: ['native'] }), source: 'native', traceId: 'abc.1.2', windowMinutes: 60, pageSize: 20 });
     expect(oap.asked.conditions[0]).toMatchObject({ traceId: 'abc.1.2', queryDuration: { step: 'SECOND', coldStage: true } });
   });
 
   it('keeps the window, and the cold stage with it, when no trace id is given', async () => {
     const oap = fakeOap();
-    await callCold(oap, 'POST', '/api/layer/general/traces', { source: 'native', windowMinutes: 30, pageSize: 20 });
+    await callCold(oap, 'POST', '/api/layer/general/traces', { previewConfig: JSON.stringify({ sources: ['native'] }), source: 'native', windowMinutes: 30, pageSize: 20 });
     expect(oap.asked.conditions[0]).toMatchObject({ queryDuration: { step: 'SECOND', coldStage: true } });
   });
 
@@ -227,7 +227,7 @@ describe('Zipkin: trace ids are read through traceMany, within a window only whe
 
   it('reads the ids the same way from the layer route and from Trace inspect', async () => {
     const layer = fakeOap(() => json([trace('0000000000000abc')]));
-    await callCold(layer, 'POST', '/api/layer/general/traces', { source: 'zipkin', traceIds: ['abc'] });
+    await callCold(layer, 'POST', '/api/layer/general/traces', { previewConfig: JSON.stringify({ sources: ['zipkin'] }), source: 'zipkin', traceIds: ['abc'] });
     expect(layer.asked.zipkin.map((u) => u.pathname.split('/').pop())).toEqual(['traceMany']);
     expect(params(layer.asked.zipkin[0]!)).toEqual({ traceIds: '0000000000000abc' });
 
@@ -239,7 +239,7 @@ describe('Zipkin: trace ids are read through traceMany, within a window only whe
 
   it('bounds the ids by the window from the layer route and from Trace inspect alike', async () => {
     const layer = fakeOap(() => json([trace('0000000000000abc')]));
-    await callCold(layer, 'POST', '/api/layer/general/traces', { source: 'zipkin', traceIds: ['abc'], windowMinutes: 30 });
+    await callCold(layer, 'POST', '/api/layer/general/traces', { previewConfig: JSON.stringify({ sources: ['zipkin'] }), source: 'zipkin', traceIds: ['abc'], windowMinutes: 30 });
     expect(params(layer.asked.zipkin[0]!)).toMatchObject({ traceIds: '0000000000000abc', lookback: '1800000', coldStage: 'true' });
 
     const explore = fakeOap(() => json([trace('0000000000000abc')]));
diff --git a/apps/bff/src/http/query/trace.ts b/apps/bff/src/http/query/trace.ts
index ddffc6e7..38943385 100644
--- a/apps/bff/src/http/query/trace.ts
+++ b/apps/bff/src/http/query/trace.ts
@@ -44,7 +44,7 @@ import type {
   TraceQueryOrder,
   TraceQueryState,
   TraceSource,
-  TracesConfig,
+  TraceStore,
   UITemplateClient,
   ZipkinTraceDetailResponse,
   ZipkinTraceListResponse,
@@ -59,7 +59,8 @@ import {
   type OapPaging,
   type PagedQuerySpec,
 } from '../../logic/paging/read-page.js';
-import { tracesConfigFor } from '../../logic/layers/loader.js';
+import { resolveTraceStores } from '@skywalking-horizon-ui/api-client';
+import { traceStoresFor } from '../../logic/layers/loader.js';
 import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
 import { serviceScopeOf } from '../../logic/oap/service-scope.js';
 import { parsePreviewTraces } from '../../logic/layers/preview.js';
@@ -453,9 +454,9 @@ export function registerTraceRoutes(app: FastifyInstance, deps: TraceRouteDeps):
       const body = (req.body ?? {}) as TraceListBody;
       // Admin Preview: draft `traces` block wins (bypasses remote + block).
       const previewCfg = parsePreviewTraces(body.previewConfig);
-      let tracesCfg: TracesConfig;
+      let stores: TraceStore[];
       if (previewCfg) {
-        tracesCfg = previewCfg;
+        stores = resolveTraceStores(previewCfg);
       } else {
         const eff = await resolveEffectiveLayer(deps.uiTemplateClient, layerKey);
         if (eff.blocked) {
@@ -463,15 +464,20 @@ export function registerTraceRoutes(app: FastifyInstance, deps: TraceRouteDeps):
           // traces rather than guessing the source from a default config.
           return reply.send({ generatedAt: Date.now(), source: body.source ?? 'native' });
         }
-        tracesCfg = tracesConfigFor(eff.template);
+        stores = traceStoresFor(eff.template);
       }
-      const requestedSource: TraceSource = body.source ?? tracesCfg.source;
       const opts = buildOapOpts(deps.config.current, deps.fetch);
       const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
       const maxPageSize = deps.config.current.performance.limits.maxPageSize.traces;
 
-      const wantNative = requestedSource === 'both' || requestedSource === 'native';
-      const wantZipkin = requestedSource === 'both' || requestedSource === 'zipkin';
+      // A layer that declares no store reads nothing — that is what "no
+      // default" means, and it is also what an empty `traces: {}` always meant.
+      // The caller may narrow to one of the layer's stores (the two tabs ask
+      // for their own), but it cannot ask for one the layer does not declare.
+      const wantNative = stores.includes('native') && body.source !== 'zipkin';
+      const wantZipkin = stores.includes('zipkin') && body.source !== 'native';
+      const requestedSource: TraceSource =
+        wantNative && wantZipkin ? 'both' : wantZipkin ? 'zipkin' : 'native';
       // Fan out in parallel; partial failures don't drop the whole
       // response — the UI's empty / error states cover each slot.
       const [native, zipkin] = await Promise.all([
diff --git a/apps/bff/src/http/query/traceql.test.ts b/apps/bff/src/http/query/traceql.test.ts
new file mode 100644
index 00000000..cc37f5e5
--- /dev/null
+++ b/apps/bff/src/http/query/traceql.test.ts
@@ -0,0 +1,314 @@
+/*
+ * 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.
+ */
+
+/**
+ * The TraceQL surface, asserted where it differs from every other OAP call.
+ *
+ * Three of these are invariants rather than behaviours, and each has a way of
+ * failing silently: a window sent in milliseconds asks for 1970, an encoded
+ * trace id that escapes the BFF makes a copy-paste fail somewhere else, and an
+ * absent error flag rendered as `false` paints failed traces green.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import Fastify, { type FastifyInstance } from 'fastify';
+import cookie from '@fastify/cookie';
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+import { configSchema } from '../../config/schema.js';
+import type { ConfigSource } from '../../config/loader.js';
+import { SessionStore } from '../../user/sessions.js';
+import { makeRouteAuthHook } from '../../rbac/route-policy.js';
+import {
+  buildTraceQLOpts,
+  decodeNativeTraceId,
+  encodeNativeTraceId,
+  focusServiceOf,
+  traceqlSearch,
+  traceqlTraceById,
+  wireTraceId,
+} from '../../client/traceql.js';
+import { resetTraceQLAvailability } from '../../logic/traceql/availability.js';
+import { registerTraceQLRoutes } from './traceql.js';
+
+const NATIVE_URL = 'http://oap.test:3200/skywalking';
+const ZIPKIN_URL = 'http://oap.test:3200/zipkin';
+
+/** A real id from the demo, in both shapes OAP actually emits. */
+const DOTTED = '7ac11a5bf004469780c148737a161cb2.38.17897214704563381';
+const UUID = 'f665e3ef-a5c9-4afe-a6be-12f92caf227a';
+
+function json(body: unknown, status = 200): Response {
+  return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+}
+
+function cfgWith(traceql: { nativeUrl?: string; zipkinUrl?: string }): ReturnType<typeof configSchema.parse> {
+  return configSchema.parse({ oap: { traceql } });
+}
+
+function attr(key: string, value: string) {
+  return { key, value: { stringValue: value } };
+}
+
+/** One trace, three spans, two services — the shape `/api/search` returns. */
+function searchBody() {
+  const t0 = 1_700_000_000_000;
+  const span = (svc: string, offsetMs: number, durMs: number, extra: Array<{ key: string; value: { stringValue: string } }> = []) => ({
+    spanID: `${svc}-${offsetMs}`,
+    startTimeUnixNano: String((t0 + offsetMs) * 1e6),
+    durationNanos: String(durMs * 1e6),
+    attributes: [attr('service.name', svc), attr('span.kind', 'SPAN_KIND_SERVER'), ...extra],
+  });
+  return {
+    traces: [
+      {
+        traceID: encodeNativeTraceId(DOTTED),
+        rootServiceName: 'agent::ui',
+        rootTraceName: '/homepage',
+        startTimeUnixNano: String(t0 * 1e6),
+        durationMs: 74,
+        spanSets: [{ spans: [span('agent::ui', 0, 74), span('agent::songs', 16, 7), span('agent::songs', 40, 2)], matched: 3 }],
+      },
+    ],
+  };
+}
+
+describe('trace ids: the encoded form never crosses the BFF boundary', () => {
+  it('round-trips both id shapes OAP emits', () => {
+    for (const id of [DOTTED, UUID]) {
+      expect(decodeNativeTraceId(encodeNativeTraceId(id))).toBe(id);
+    }
+  });
+
+  it('always encodes on the way out, without guessing at the input', () => {
+    // A heuristic that tried to spot an already-encoded id both corrupted
+    // valid ids that happened to look hex and missed ids outside its
+    // invented alphabet. Encoding unconditionally is the contract: the
+    // caller holds a real id.
+    expect(wireTraceId('native', DOTTED)).toBe(encodeNativeTraceId(DOTTED));
+    expect(wireTraceId('native', '31323334353637383930616263646566')).toBe(
+      encodeNativeTraceId('31323334353637383930616263646566'),
+    );
+  });
+
+  it('leaves Zipkin ids untouched — they are already hex', () => {
+    const zipkinId = '439af3ead82dff2f0e6aa92ed7167090';
+    expect(wireTraceId('zipkin', zipkinId)).toBe(zipkinId);
+    expect(decodeNativeTraceId('not-hex-at-all')).toBe('not-hex-at-all');
+  });
+
+  it('sends the encoded id and answers with the real one', async () => {
+    let asked = '';
+    const fetch: FetchLike = async (url) => {
+      asked = String(url);
+      return json({
+        trace: {
+          resourceSpans: [
+            {
+              resource: { attributes: [attr('service.name', 'agent::songs')] },
+              scopeSpans: [
+                {
+                  spans: [
+                    {
+                      // OAP stamps the ENCODED id on every span, while the span
+                      // id beside it stays raw.
+                      traceId: encodeNativeTraceId(DOTTED),
+                      spanId: `${DOTTED}S0`,
+                      name: 'GET:/songs',
+                      kind: 'SPAN_KIND_SERVER',
+                      startTimeUnixNano: '1700000000000000000',
+                      endTimeUnixNano: '1700000000004000000',
+                      status: { code: 'STATUS_CODE_OK' },
+                    },
+                  ],
+                },
+              ],
+            },
+          ],
+        },
+      });
+    };
+    const opts = buildTraceQLOpts(cfgWith({ nativeUrl: NATIVE_URL }), 'native', fetch)!;
+    const detail = await traceqlTraceById(opts, DOTTED);
+    expect(asked).toContain(encodeNativeTraceId(DOTTED));
+    expect(asked).not.toContain(DOTTED);
+    expect(detail?.traceId).toBe(DOTTED);
+    expect(detail?.spans[0]?.spanId).toBe(`${DOTTED}S0`);
+    expect(detail?.spans[0]?.durationUs).toBe(4000);
+  });
+
+  it('reads a 404 as "not there" rather than as a failure', async () => {
+    const fetch: FetchLike = async () => new Response('', { status: 404 });
+    const opts = buildTraceQLOpts(cfgWith({ nativeUrl: NATIVE_URL }), 'native', fetch)!;
+    await expect(traceqlTraceById(opts, DOTTED)).resolves.toBeNull();
+  });
+});
+
+describe('search: the window is seconds, and the rows are reduced', () => {
+  it('converts the window to seconds', async () => {
+    let asked: URL | null = null;
+    const fetch: FetchLike = async (url) => {
+      asked = new URL(String(url));
+      return json({ traces: [] });
+    };
+    const opts = buildTraceQLOpts(cfgWith({ nativeUrl: NATIVE_URL }), 'native', fetch)!;
+    await traceqlSearch(opts, { q: '{}', startMs: 1_700_000_000_000, endMs: 1_700_000_600_000, limit: 20 });
+    expect(asked!.searchParams.get('start')).toBe('1700000000');
+    expect(asked!.searchParams.get('end')).toBe('1700000600');
+  });
+
+  it('keeps the summary and drops the spans, which have no cap upstream', async () => {
+    const fetch: FetchLike = async () => json(searchBody());
+    const opts = buildTraceQLOpts(cfgWith({ nativeUrl: NATIVE_URL }), 'native', fetch)!;
+    const [row] = await traceqlSearch(opts, {
+      q: '{resource.service.name="agent::songs"}',
+      startMs: 1,
+      endMs: 2,
+      limit: 20,
+    });
+    expect(row!.traceId).toBe(DOTTED);
+    expect(row!.rootService).toBe('agent::ui');
+    expect(row!.spanCount).toBe(3);
+    expect(row!.services).toEqual(['agent::ui', 'agent::songs']);
+    expect(row).not.toHaveProperty('spans');
+  });
+
+  it('reports the queried service’s FIRST span, not a sum of its spans', async () => {
+    const fetch: FetchLike = async () => json(searchBody());
+    const opts = buildTraceQLOpts(cfgWith({ nativeUrl: NATIVE_URL }), 'native', fetch)!;
+    const [row] = await traceqlSearch(opts, {
+      q: '{resource.service.name="agent::songs"}',
+      startMs: 1,
+      endMs: 2,
+      limit: 20,
+    });
+    expect(row!.serviceEntry).toEqual({
+      service: 'agent::songs',
+      offsetMs: 16,
+      durationMs: 7,
+      kind: 'SPAN_KIND_SERVER',
+    });
+  });
+
+  it('has no service entry when the query names no service', async () => {
+    const fetch: FetchLike = async () => json(searchBody());
+    const opts = buildTraceQLOpts(cfgWith({ nativeUrl: NATIVE_URL }), 'native', fetch)!;
+    const [row] = await traceqlSearch(opts, { q: '{duration>1ms}', startMs: 1, endMs: 2, limit: 20 });
+    expect(row!.serviceEntry).toBeUndefined();
+  });
+
+  it('reads the service out of either spelling the backend honours', () => {
+    expect(focusServiceOf('{resource.service.name="a"}')).toBe('a');
+    expect(focusServiceOf('{.service.name="b" && duration>1ms}')).toBe('b');
+    expect(focusServiceOf('{resource.service="c"}')).toBe('c');
+    expect(focusServiceOf('{duration>1ms}')).toBeNull();
+    expect(focusServiceOf(undefined)).toBeNull();
+  });
+
+  it('leaves the error flag ABSENT on native, and answers it on Zipkin', async () => {
+    const fetch: FetchLike = async () => json(searchBody());
+    const native = buildTraceQLOpts(cfgWith({ nativeUrl: NATIVE_URL }), 'native', fetch)!;
+    const [nativeRow] = await traceqlSearch(native, { startMs: 1, endMs: 2, limit: 5 });
+    // The native search carries no failure marker at all. Rendering `false`
+    // here would paint every failed trace as a success.
+    expect(nativeRow!.isError).toBeUndefined();
+
+    const withError: FetchLike = async () => {
+      const body = searchBody();
+      body.traces[0]!.spanSets[0]!.spans[0]!.attributes.push(attr('error', 'true'));
+      return json(body);
+    };
+    const zipkin = buildTraceQLOpts(cfgWith({ zipkinUrl: ZIPKIN_URL }), 'zipkin', withError)!;
+    const [zipkinRow] = await traceqlSearch(zipkin, { startMs: 1, endMs: 2, limit: 5 });
+    expect(zipkinRow!.isError).toBe(true);
+  });
+});
+
+describe('the routes', () => {
+  beforeEach(() => resetTraceQLAvailability());
+
+  async function build(fetchImpl: FetchLike, traceql: { nativeUrl?: string; zipkinUrl?: string }) {
+    const cfg = cfgWith(traceql);
+    const config: ConfigSource = {
+      current: cfg,
+      current_: () => cfg,
+      path: '',
+      onChange: () => () => {},
+      close: async () => {},
+    };
+    const sessions = new SessionStore({ ttlMinutes: 60 });
+    const app: FastifyInstance = Fastify();
+    await app.register(cookie);
+    app.addHook('onRoute', makeRouteAuthHook({ config, sessions }));
+    registerTraceQLRoutes(app, { config, sessions, fetch: fetchImpl } as unknown as Parameters<typeof registerTraceQLRoutes>[1]);
+    await app.ready();
+    return { app, sid: sessions.create('op', ['admin']).sid };
+  }
+
+  const get = async (app: FastifyInstance, sid: string, url: string) =>
+    (await app.inject({ method: 'GET', url, headers: { cookie: `horizon_sid=${sid}` } })).json();
+
+  it('reports an unconfigured source instead of omitting it', async () => {
+    const { app, sid } = await build(async () => json({}), { nativeUrl: NATIVE_URL });
+    const sources = await get(app, sid, '/api/traceql/sources');
+    expect(sources).toMatchObject([
+      { ds: 'native', configured: true },
+      { ds: 'zipkin', configured: false, reachable: false },
+    ]);
+  });
+
+  it('serves nothing, reachable false, when the source has no URL', async () => {
+    const { app, sid } = await build(async () => json({}), {});
+    const out = await get(app, sid, '/api/traceql/native/search?startMs=1&endMs=2');
+    expect(out).toMatchObject({ ds: 'native', traces: [], reachable: false });
+  });
+
+  it('passes OAP’s own message through when it refuses an expression', async () => {
+    const fetch: FetchLike = async () => json({ error: 'maxDuration is only valid with minDuration' }, 400);
+    const { app, sid } = await build(fetch, { zipkinUrl: ZIPKIN_URL });
+    const out = await get(app, sid, '/api/traceql/zipkin/search?startMs=1&endMs=2&q=%7Bduration%3C1s%7D');
+    // A refused expression is an answer, not an outage: the source is up and
+    // the editor shows what it said.
+    expect(out.reachable).toBe(true);
+    expect(out.error).toContain('maxDuration is only valid with minDuration');
+  });
+
+  it('flags a full page as capped, because there is no paging to offer', async () => {
+    const rows = (n: number) => ({
+      traces: Array.from({ length: n }, (_, i) => ({
+        traceID: encodeNativeTraceId(`${DOTTED}${i}`),
+        rootServiceName: 's',
+        rootTraceName: 'n',
+        startTimeUnixNano: '1700000000000000000',
+        durationMs: 1,
+        spanSets: [{ spans: [], matched: 0 }],
+      })),
+    });
+    const { app, sid } = await build(async () => json(rows(3)), { nativeUrl: NATIVE_URL });
+    expect(await get(app, sid, '/api/traceql/native/search?startMs=1&endMs=2&limit=3')).toMatchObject({ capped: true });
+    expect(await get(app, sid, '/api/traceql/native/search?startMs=1&endMs=2&limit=4')).toMatchObject({ capped: false });
+  });
+
+  it('refuses a datasource it does not serve', async () => {
+    const { app, sid } = await build(async () => json({}), { nativeUrl: NATIVE_URL });
+    const res = await app.inject({
+      method: 'GET',
+      url: '/api/traceql/otlp/search?startMs=1&endMs=2',
+      headers: { cookie: `horizon_sid=${sid}` },
+    });
+    expect(res.statusCode).toBe(400);
+  });
+});
diff --git a/apps/bff/src/http/query/traceql.ts b/apps/bff/src/http/query/traceql.ts
new file mode 100644
index 00000000..d5493d1e
--- /dev/null
+++ b/apps/bff/src/http/query/traceql.ts
@@ -0,0 +1,288 @@
+/*
+ * 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.
+ */
+
+/**
+ * TraceQL (Tempo API) query routes, one datasource per `:ds` segment.
+ *
+ * Thin by design: each route parses the request, fires one backend query and
+ * shapes the reply. There is no `logic/` step because there is nothing
+ * stateful or shared to own — except the availability probe, which lives in
+ * `logic/traceql/`.
+ *
+ * Two answers are deliberately NOT failures:
+ *   - a 404 from a by-id read means the trace is not there;
+ *   - a 400 carries OAP's own message for an expression it refused, which the
+ *     editor shows verbatim rather than translating.
+ * Everything else reports the source as unreachable, because a wrong port, a
+ * wrong context path and a datasource nobody enabled all fail alike and differ
+ * only in the fix.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { serviceFilterOf } from '@skywalking-horizon-ui/api-client';
+import type {
+  FetchLike,
+  TraceQLDatasource,
+  TraceStore,
+  TraceQLTraceDetailResponse,
+  TraceQLTraceListResponse,
+  TraceQLTagsResponse,
+  TraceQLTagValuesResponse,
+  UITemplateClient,
+} from '@skywalking-horizon-ui/api-client';
+import type { AuthDeps } from '../../user/middleware.js';
+import { requireAuth } from '../../user/middleware.js';
+import {
+  buildTraceQLOpts,
+  traceqlSearch,
+  traceqlTagValues,
+  traceqlTags,
+  traceqlTraceById,
+  TraceQLHttpError,
+} from '../../client/traceql.js';
+import { traceqlSources } from '../../logic/traceql/availability.js';
+import { fillTraceStatuses } from '../../logic/traceql/status.js';
+import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
+import { parsePreviewTraces } from '../../logic/layers/preview.js';
+import { sessionHasVerb } from '../../rbac/policy.js';
+import { clientGone } from '../client-gone.js';
+
+export interface TraceQLRouteDeps extends AuthDeps {
+  fetch?: FetchLike;
+  /** OAP UI-template client — the layer's in-use config, for the per-store
+   *  service filter the picker applies. */
+  uiTemplateClient?: () => UITemplateClient;
+}
+
+function datasourceOf(raw: string): TraceQLDatasource | null {
+  return raw === 'native' || raw === 'zipkin' ? raw : null;
+}
+
+/** The window every query carries, in milliseconds. A by-id lookup may omit it
+ *  — that is the "no time range" choice, which this API answers by searching
+ *  its whole history. */
+function windowOf(q: Record<string, string | undefined>): { startMs: number; endMs: number } | null {
+  const startMs = Number(q.startMs);
+  const endMs = Number(q.endMs);
+  if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return null;
+  return { startMs, endMs };
+}
+
+/** OAP refuses a few expressions outright — a duration it cannot parse, for
+ *  one — with `{"error":"…"}`. The MESSAGE is what the editor shows, so the
+ *  envelope is unwrapped here; a body that is not that shape passes through
+ *  unchanged rather than being guessed at. */
+function refusedExpression(e: unknown): string | null {
+  if (!(e instanceof TraceQLHttpError) || e.status !== 400) return null;
+  const body = e.body || e.message;
+  try {
+    const parsed: unknown = JSON.parse(body);
+    if (parsed && typeof parsed === 'object' && typeof (parsed as { error?: unknown }).error === 'string') {
+      return (parsed as { error: string }).error;
+    }
+  } catch {
+    // Not JSON — the body is the message.
+  }
+  return body;
+}
+
+function failureText(e: unknown): string {
+  return e instanceof Error ? e.message : String(e);
+}
+
+/** The store a datasource fills for a layer — how a `:ds` route reaches the
+ *  per-store template settings. */
+function storeOf(ds: TraceQLDatasource): TraceStore {
+  return ds === 'native' ? 'traceql-native' : 'traceql-zipkin';
+}
+
+/**
+ * Narrow a service list to the layer that asked for it.
+ *
+ * The Tempo API has no notion of a layer: its service-name values are every
+ * service of the underlying store, so a layer owning a subset of them can only
+ * say which by pattern. Applied to the PICKER alone — never to result rows,
+ * which legitimately cross services.
+ */
+async function applyServiceFilter(
+  deps: TraceQLRouteDeps,
+  req: FastifyRequest,
+  layerKey: string | undefined,
+  ds: TraceQLDatasource,
+  values: string[],
+  previewConfig?: string,
+): Promise<string[]> {
+  if (!layerKey || !/^[a-z0-9_]+$/i.test(layerKey)) return values;
+  // In preview the DRAFT is the configuration — an admin editing the filter
+  // has to see what it does before publishing. A pattern off the request is
+  // code someone else's request runs on this event loop, so it is taken only
+  // from a caller who may edit templates, and only in a bounded form.
+  const preview = req.session && sessionHasVerb(deps.config.current, req.session, 'layer-template:write')
+    ? parsePreviewTraces(previewConfig)
+    : null;
+  if (preview) {
+    const previewRe = serviceFilterOf(preview, storeOf(ds));
+    return previewRe ? filterByPattern(values, previewRe) : values;
+  }
+  const eff = await resolveEffectiveLayer(deps.uiTemplateClient, layerKey);
+  // A layer whose configuration cannot be read must not have its picker
+  // WIDENED: serving every service of the store is the one answer that looks
+  // like more access rather than less.
+  if (eff.blocked) return [];
+  if (!eff.template) return values;
+  const re = serviceFilterOf(eff.template.traces ?? null, storeOf(ds));
+  return re ? filterByPattern(values, re) : values;
+}
+
+/** Backtracking cost grows with the SUBJECT as well as the pattern, and a
+ *  service name is nowhere near this long. */
+const MAX_MATCHED_LENGTH = 256;
+
+function filterByPattern(values: string[], re: RegExp): string[] {
+  // `test` on a /g|y/ regex advances lastIndex, so one shared instance would
+  // skip every other match. Match fresh per value instead.
+  const safe = new RegExp(re.source, re.flags.replace(/[gy]/g, ''));
+  return values.filter((v) => safe.test(v.slice(0, MAX_MATCHED_LENGTH)));
+}
+
+export function registerTraceQLRoutes(app: FastifyInstance, deps: TraceQLRouteDeps): void {
+  const auth = requireAuth(deps);
+
+  app.get('/api/traceql/sources', { preHandler: auth }, async (_req, reply) => {
+    return reply.send(await traceqlSources(deps.config.current, deps.fetch, clientGone(reply)));
+  });
+
+  app.get<{ Params: { ds: string } }>(
+    '/api/traceql/:ds/search',
+    { preHandler: auth },
+    async (req: FastifyRequest<{ Params: { ds: string } }>, reply: FastifyReply) => {
+      const ds = datasourceOf(req.params.ds);
+      if (!ds) return reply.code(400).send({ error: 'unknown_datasource' });
+      const q = req.query as Record<string, string | undefined>;
+      const window = windowOf(q);
+      if (!window) return reply.code(400).send({ error: 'invalid_window' });
+
+      const opts = buildTraceQLOpts(deps.config.current, ds, deps.fetch, clientGone(reply));
+      if (!opts) {
+        return reply.send({ ds, traces: [], capped: false, reachable: false } satisfies TraceQLTraceListResponse);
+      }
+      const maxRows = deps.config.current.performance.limits.maxPageSize.traces;
+      const limit = Math.max(1, Math.min(maxRows, Number(q.limit) || 20));
+      try {
+        const found = await traceqlSearch(opts, { q: q.q, ...window, limit });
+        // The search response cannot say whether a trace failed, so a second
+        // pass asks the backend for the failures by id.
+        const traces = await fillTraceStatuses(opts, { q: q.q, ...window, limit }, found);
+        // No paging here: `pageNum` is pinned upstream and `limit` is the only
+        // control, so a full page is the only "there may be more" signal there is.
+        return reply.send({
+          ds,
+          traces,
+          capped: traces.length >= limit,
+          reachable: true,
+        } satisfies TraceQLTraceListResponse);
+      } catch (e) {
+        const refused = refusedExpression(e);
+        return reply.send({
+          ds,
+          traces: [],
+          capped: false,
+          reachable: refused !== null,
+          error: refused ?? failureText(e),
+        } satisfies TraceQLTraceListResponse);
+      }
+    },
+  );
+
+  app.get<{ Params: { ds: string; traceId: string } }>(
+    '/api/traceql/:ds/trace/:traceId',
+    { preHandler: auth },
+    async (req: FastifyRequest<{ Params: { ds: string; traceId: string } }>, reply: FastifyReply) => {
+      const ds = datasourceOf(req.params.ds);
+      if (!ds) return reply.code(400).send({ error: 'unknown_datasource' });
+      const traceId = req.params.traceId;
+      const q = req.query as Record<string, string | undefined>;
+      const window = windowOf(q);
+
+      const opts = buildTraceQLOpts(deps.config.current, ds, deps.fetch, clientGone(reply));
+      if (!opts) {
+        return reply.send({
+          ds, traceId, spans: [], reachable: false, notFound: false,
+        } satisfies TraceQLTraceDetailResponse);
+      }
+      try {
+        const detail = await traceqlTraceById(opts, traceId, window ?? undefined);
+        return reply.send({
+          ds,
+          traceId: detail?.traceId ?? traceId,
+          spans: detail?.spans ?? [],
+          reachable: true,
+          notFound: detail === null,
+        } satisfies TraceQLTraceDetailResponse);
+      } catch (e) {
+        return reply.send({
+          ds, traceId, spans: [], reachable: false, notFound: false, error: failureText(e),
+        } satisfies TraceQLTraceDetailResponse);
+      }
+    },
+  );
+
+  app.get<{ Params: { ds: string } }>(
+    '/api/traceql/:ds/tags',
+    { preHandler: auth },
+    async (req: FastifyRequest<{ Params: { ds: string } }>, reply: FastifyReply) => {
+      const ds = datasourceOf(req.params.ds);
+      if (!ds) return reply.code(400).send({ error: 'unknown_datasource' });
+      const window = windowOf(req.query as Record<string, string | undefined>);
+      if (!window) return reply.code(400).send({ error: 'invalid_window' });
+
+      const opts = buildTraceQLOpts(deps.config.current, ds, deps.fetch, clientGone(reply));
+      if (!opts) return reply.send({ scopes: [] } satisfies TraceQLTagsResponse);
+      try {
+        return reply.send({ scopes: await traceqlTags(opts, window) } satisfies TraceQLTagsResponse);
+      } catch {
+        // Autocomplete is an aid, not an answer: a failed lookup leaves the
+        // field as plain text rather than blocking the query behind it.
+        return reply.send({ scopes: [] } satisfies TraceQLTagsResponse);
+      }
+    },
+  );
+
+  app.get<{ Params: { ds: string } }>(
+    '/api/traceql/:ds/tag-values',
+    { preHandler: auth },
+    async (req: FastifyRequest<{ Params: { ds: string } }>, reply: FastifyReply) => {
+      const ds = datasourceOf(req.params.ds);
+      if (!ds) return reply.code(400).send({ error: 'unknown_datasource' });
+      const q = req.query as Record<string, string | undefined>;
+      const window = windowOf(q);
+      if (!window || !q.tag) return reply.code(400).send({ error: 'invalid_request' });
+
+      const opts = buildTraceQLOpts(deps.config.current, ds, deps.fetch, clientGone(reply));
+      if (!opts) return reply.send({ values: [] } satisfies TraceQLTagValuesResponse);
+      try {
+        const values = await traceqlTagValues(opts, q.tag, window, q.q);
+        const shaped = q.tag === 'resource.service.name' || q.tag === 'resource.service'
+          ? await applyServiceFilter(deps, req, q.layer, ds, values, q.previewConfig)
+          : values;
+        return reply.send({ values: shaped } satisfies TraceQLTagValuesResponse);
+      } catch {
+        return reply.send({ values: [] } satisfies TraceQLTagValuesResponse);
+      }
+    },
+  );
+}
diff --git a/apps/bff/src/http/query/zipkin-cold-stage.test.ts b/apps/bff/src/http/query/zipkin-cold-stage.test.ts
index f5a00648..ea91a2d8 100644
--- a/apps/bff/src/http/query/zipkin-cold-stage.test.ts
+++ b/apps/bff/src/http/query/zipkin-cold-stage.test.ts
@@ -133,11 +133,11 @@ describe('a Zipkin by-id lookup that OAP answers 404', () => {
 
 describe('the Cold pill on the trace routes’ Zipkin branches', () => {
   it('sends coldStage and the requested window on the layer list when the source is Zipkin', async () => {
-    const on = await call(true, 'POST', '/api/layer/general/traces', { source: 'zipkin', pageSize: 5, startMs: 1700000000000, endMs: 1700432000000 });
+    const on = await call(true, 'POST', '/api/layer/general/traces', { previewConfig: JSON.stringify({ sources: ['zipkin'] }), source: 'zipkin', pageSize: 5, startMs: 1700000000000, endMs: 1700432000000 });
     const zipkin = on.filter((u) => u.pathname.endsWith('/api/v2/traces'));
     expect(zipkin).toHaveLength(1);
     expect(params(zipkin[0]!)).toMatchObject({ coldStage: 'true', endTs: '1700432000000', lookback: '432000000' });
-    const rolling = await call(false, 'POST', '/api/layer/general/traces', { source: 'zipkin', pageSize: 5, windowMinutes: 90 });
+    const rolling = await call(false, 'POST', '/api/layer/general/traces', { previewConfig: JSON.stringify({ sources: ['zipkin'] }), source: 'zipkin', pageSize: 5, windowMinutes: 90 });
     const p = params(rolling.find((u) => u.pathname.endsWith('/api/v2/traces'))!);
     expect(p.coldStage).toBeUndefined();
     expect(p.lookback).toBe(String(90 * 60_000));
diff --git a/apps/bff/src/logic/layers/capabilities.ts b/apps/bff/src/logic/layers/capabilities.ts
index bde704ab..8af03d89 100644
--- a/apps/bff/src/logic/layers/capabilities.ts
+++ b/apps/bff/src/logic/layers/capabilities.ts
@@ -29,7 +29,7 @@ import {
   instanceTopologyConfigFor,
   deploymentConfigFor,
   endpointDependencyConfigFor,
-  tracesConfigFor,
+  traceStoresFor,
   logConfigFor,
   type LayerTemplate,
   type LayerComponentFlags,
@@ -68,7 +68,7 @@ export interface LayerCapabilities {
     instanceBadge?: string;
   };
   components: string[];
-  tracesSource: string;
+  traceStores: string[];
   logsScope: string;
   metricCounts: { service: number; instance: number; endpoint: number };
   relations: {
@@ -176,7 +176,10 @@ function buildCapabilities(template: LayerTemplate): LayerCapabilities {
     components: componentList(t.components),
     // Gate on the component flag — the config resolvers default to 'both'/'service'
     // even for layers that carry no traces/logs, which would mislead the tool choice.
-    tracesSource: t.components.traces ? (tracesConfigFor(t).source ?? 'both') : 'none',
+    // The stores this layer declares, or none — the model is told exactly
+    // what the sidebar shows, with no default standing in for a layer that
+    // named nothing.
+    traceStores: t.components.traces ? traceStoresFor(t) : [],
     logsScope: t.components.logs ? (logConfigFor(t).scope ?? 'service') : 'none',
     metricCounts: {
       service: scopeCount(t, 'service'),
diff --git a/apps/bff/src/logic/layers/loader.test.ts b/apps/bff/src/logic/layers/loader.test.ts
index 436225fe..be2755db 100644
--- a/apps/bff/src/logic/layers/loader.test.ts
+++ b/apps/bff/src/logic/layers/loader.test.ts
@@ -19,7 +19,7 @@ import { describe, it, expect } from 'vitest';
 import {
   topologyConfigFor,
   endpointDependencyConfigFor,
-  tracesConfigFor,
+  traceStoresFor,
   widgetsForScope,
   type LayerTemplate,
 } from './loader.js';
@@ -142,7 +142,7 @@ describe('widgetsForScope — scope-resolution + fallback chain', () => {
   });
 });
 
-describe('topologyConfigFor / endpointDependencyConfigFor / tracesConfigFor — defaults', () => {
+describe('topologyConfigFor / endpointDependencyConfigFor / traceStoresFor — defaults', () => {
   it('topologyConfigFor returns operator override when set', () => {
     const override = {
       nodeMetrics: [],
@@ -166,12 +166,27 @@ describe('topologyConfigFor / endpointDependencyConfigFor / tracesConfigFor —
     expect(endpointDependencyConfigFor(null)).toBeDefined();
   });
 
-  it('tracesConfigFor defaults source to "both" when unspecified', () => {
-    expect(tracesConfigFor(null)).toEqual({ source: 'both' });
-    expect(tracesConfigFor(tpl({}))).toEqual({ source: 'both' });
+  // Silence is COMPATIBILITY: every template written before the checklist
+  // relied on the Traces row existing without describing it, so reading
+  // nothing as "no trace rows" would take that row off those layers.
+  it('traceStoresFor reads a template that says nothing as the native store', () => {
+    expect(traceStoresFor(null)).toEqual(['native']);
+    expect(traceStoresFor(tpl({}))).toEqual(['native']);
+    expect(traceStoresFor(tpl({ traces: {} }))).toEqual(['native']);
   });
-  it('tracesConfigFor honors the template override (e.g. zipkin for mesh)', () => {
-    const t = tpl({ traces: { source: 'zipkin' } });
-    expect(tracesConfigFor(t)).toEqual({ source: 'zipkin' });
+  // An explicit empty LIST is the one way to say none, and is what the editor
+  // writes when every box is unticked.
+  it('traceStoresFor names nothing only when the template says so', () => {
+    expect(traceStoresFor(tpl({ traces: { sources: [] } }))).toEqual([]);
+  });
+  it('traceStoresFor reads the checklist', () => {
+    const t = tpl({ traces: { sources: ['traceql-native', 'native'] } });
+    // Canonical order, so two templates naming the same set render alike.
+    expect(traceStoresFor(t)).toEqual(['native', 'traceql-native']);
+  });
+  it('traceStoresFor still reads the legacy enum from a stored template', () => {
+    expect(traceStoresFor(tpl({ traces: { source: 'zipkin' } }))).toEqual(['zipkin']);
+    expect(traceStoresFor(tpl({ traces: { source: 'both' } }))).toEqual(['native', 'zipkin']);
+    expect(traceStoresFor(tpl({ traces: { source: 'native' } }))).toEqual(['native']);
   });
 });
diff --git a/apps/bff/src/logic/layers/loader.ts b/apps/bff/src/logic/layers/loader.ts
index b12b912f..616844f5 100644
--- a/apps/bff/src/logic/layers/loader.ts
+++ b/apps/bff/src/logic/layers/loader.ts
@@ -35,6 +35,7 @@
 import { readdirSync, readFileSync, watch as fsWatch } from 'node:fs';
 import { dirname, join, basename } from 'node:path';
 import { fileURLToPath } from 'node:url';
+import { resolveTraceStores, type TraceStore } from '@skywalking-horizon-ui/api-client';
 import type {
   DashboardScope,
   DashboardWidget,
@@ -707,11 +708,16 @@ export function processTopologyConfigFor(
   return BOOSTER_PROCESS_TOPOLOGY_DEFAULTS;
 }
 
-/** Resolve the traces tab config. Defaults to surfacing both
- *  SkyWalking-native and Zipkin trace lists side-by-side. */
-export function tracesConfigFor(template: LayerTemplate | null): TracesConfig {
-  if (template?.traces) return template.traces;
-  return { source: 'both' };
+/**
+ * The trace stores a layer exposes — its checklist, legacy enum resolved.
+ *
+ * A template that says NOTHING resolves to the native store — every template
+ * written before the checklist relied on the Traces row existing without
+ * describing it. Saying none is spelled `sources: []`, which the editor writes
+ * when every box is unticked.
+ */
+export function traceStoresFor(template: LayerTemplate | null): TraceStore[] {
+  return resolveTraceStores(template?.traces ?? null);
 }
 
 /** Resolve the logs tab config — defaults to per-service scope. */
diff --git a/apps/bff/src/logic/layers/preview-traces.test.ts b/apps/bff/src/logic/layers/preview-traces.test.ts
new file mode 100644
index 00000000..1e217379
--- /dev/null
+++ b/apps/bff/src/logic/layers/preview-traces.test.ts
@@ -0,0 +1,60 @@
+/*
+ * 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.
+ */
+
+/**
+ * The draft `traces` block the admin editor previews with.
+ *
+ * Preview must answer what PUBLISHING the draft would answer, so the two
+ * compatibility fallbacks apply here exactly as they do to a stored template —
+ * otherwise a block reads one way on the preview page and another once saved.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { resolveTraceStores } from '@skywalking-horizon-ui/api-client';
+import { parsePreviewTraces } from './preview.js';
+
+const stores = (raw: string | undefined) => {
+  const cfg = parsePreviewTraces(raw);
+  return cfg === null ? null : resolveTraceStores(cfg);
+};
+
+describe('preview traces block', () => {
+  it('previews a block that names nothing as the native store', () => {
+    expect(stores('{}')).toEqual(['native']);
+    expect(stores('{"stores":{"native":{"name":"Spans"}}}')).toEqual(['native']);
+  });
+
+  it('previews the legacy enum as the store it named', () => {
+    expect(stores('{"source":"zipkin"}')).toEqual(['zipkin']);
+    expect(stores('{"source":"both"}')).toEqual(['native', 'zipkin']);
+  });
+
+  it('previews an explicit empty list as no stores', () => {
+    expect(stores('{"sources":[]}')).toEqual([]);
+  });
+
+  it('previews the checklist, dropping names it does not know', () => {
+    expect(stores('{"sources":["traceql-native","nonsense","native"]}')).toEqual(['native', 'traceql-native']);
+  });
+
+  it('reads no block at all as nothing to preview', () => {
+    // null means "fall through to the saved configuration", which is a
+    // different answer from "the draft says no stores".
+    expect(parsePreviewTraces(undefined)).toBeNull();
+    expect(parsePreviewTraces('not json')).toBeNull();
+  });
+});
diff --git a/apps/bff/src/logic/layers/preview.ts b/apps/bff/src/logic/layers/preview.ts
index 39c48545..cb2d5fb2 100644
--- a/apps/bff/src/logic/layers/preview.ts
+++ b/apps/bff/src/logic/layers/preview.ts
@@ -33,6 +33,7 @@
  * config (i.e. preview silently no-ops rather than erroring).
  */
 
+import { TRACE_STORES, type TraceStore } from '@skywalking-horizon-ui/api-client';
 import type {
   TopologyConfig,
   EndpointDependencyConfig,
@@ -136,15 +137,57 @@ export function parsePreviewEndpointDep(raw: string | undefined): EndpointDepend
   return o as unknown as EndpointDependencyConfig;
 }
 
-/** `traces` block — just the source selector. */
+/** `traces` block — the store checklist, or the legacy single-source enum a
+ *  stored template may still carry. */
 export function parsePreviewTraces(raw: string | undefined): TracesConfig | null {
   const o = parseJson(raw);
   if (!o) return null;
+  // A draft block naming neither `sources` nor `source` is previewed as what
+  // PUBLISHING it would produce, which is the native store: silence is the
+  // compatibility fallback, and only an explicit `sources: []` means none.
+  // Forcing an empty list here previewed no traces for a template that, once
+  // saved, reads them.
+  if (!Array.isArray(o.sources) && o.source === undefined) {
+    const stores = previewStores(o.stores);
+    return stores ? { stores } : {};
+  }
+  if (Array.isArray(o.sources)) {
+    const sources = o.sources.filter((s): s is TraceStore =>
+      typeof s === 'string' && (TRACE_STORES as readonly string[]).includes(s),
+    );
+    const stores = previewStores(o.stores);
+    return stores ? { sources, stores } : { sources };
+  }
   const source = o.source;
   if (source !== 'native' && source !== 'zipkin' && source !== 'both') return null;
   return { source } as TracesConfig;
 }
 
+/** The per-store settings of a previewed `traces` block — the row's name and
+ *  the service filter, which the picker applies. Taken only in the shapes the
+ *  editor writes; anything else is left out rather than trusted. */
+function previewStores(raw: unknown): TracesConfig['stores'] | null {
+  if (!raw || typeof raw !== 'object') return null;
+  const out: NonNullable<TracesConfig['stores']> = {};
+  for (const store of TRACE_STORES) {
+    const v = (raw as Record<string, unknown>)[store];
+    if (!v || typeof v !== 'object') continue;
+    const cfg: { name?: string; serviceFilter?: { pattern: string; flags?: string } } = {};
+    const name = (v as { name?: unknown }).name;
+    if (typeof name === 'string' && name.trim()) cfg.name = name;
+    const filter = (v as { serviceFilter?: unknown }).serviceFilter;
+    if (filter && typeof filter === 'object') {
+      const pattern = (filter as { pattern?: unknown }).pattern;
+      const flags = (filter as { flags?: unknown }).flags;
+      if (typeof pattern === 'string' && pattern.trim()) {
+        cfg.serviceFilter = { pattern, ...(typeof flags === 'string' ? { flags } : {}) };
+      }
+    }
+    if (cfg.name || cfg.serviceFilter) out[store] = cfg;
+  }
+  return Object.keys(out).length > 0 ? out : null;
+}
+
 /** `processTopology` block — the network-profiling edge-detail metric
  *  lists (ProcessRelation scope, client + server). */
 export function parsePreviewProcessTopology(raw: string | undefined): ProcessTopologyConfig | null {
diff --git a/apps/bff/src/logic/templates/bundled-schema.ts b/apps/bff/src/logic/templates/bundled-schema.ts
index 9e790bc3..46244ff5 100644
--- a/apps/bff/src/logic/templates/bundled-schema.ts
+++ b/apps/bff/src/logic/templates/bundled-schema.ts
@@ -478,7 +478,42 @@ function buildLayerSchemas(complete: boolean) {
       endpointDependency: endpointDependencySchema.optional(),
       processTopology: processTopologySchema.optional(),
       deployment: deploymentSchema.optional(),
-      traces: z.object({ source: z.enum(['native', 'zipkin', 'both']).optional() }).strict().optional(),
+      traces: z
+        .object({
+          // The checklist. Absent or empty means the layer has NO trace rows —
+          // there is deliberately no default, because which trace stores exist
+          // is a fact about a deployment.
+          sources: z.array(z.enum(['native', 'zipkin', 'traceql-native', 'traceql-zipkin'])).optional(),
+          // Per-store settings: the row's sidebar label, and the regex that
+          // narrows its service picker — the Tempo API has no notion of a
+          // layer, so a layer owning a subset of a store's services has no
+          // protocol-level way to say which.
+          stores: z
+            .object(
+              Object.fromEntries(
+                (['native', 'zipkin', 'traceql-native', 'traceql-zipkin'] as const).map((k) => [
+                  k,
+                  z
+                    .object({
+                      name: text.optional(),
+                      serviceFilter: z
+                        .object({ pattern: text, flags: z.string().max(8).optional() })
+                        .strict()
+                        .optional(),
+                    })
+                    .strict()
+                    .optional(),
+                ]),
+              ),
+            )
+            .strict()
+            .optional(),
+          // Legacy single-source enum: still READ from stored templates, never
+          // written. Resolved by `resolveTraceStores`.
+          source: z.enum(['native', 'zipkin', 'both']).optional(),
+        })
+        .strict()
+        .optional(),
       log: z.object({ scope: z.enum(['service', 'instance', 'endpoint']).optional() }).strict().optional(),
       naming: z
         .object({
diff --git a/apps/bff/src/logic/traceql/availability.ts b/apps/bff/src/logic/traceql/availability.ts
new file mode 100644
index 00000000..b1bfd4ce
--- /dev/null
+++ b/apps/bff/src/logic/traceql/availability.ts
@@ -0,0 +1,105 @@
+/*
+ * 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.
+ */
+
+/**
+ * Which TraceQL datasources this OAP actually answers on.
+ *
+ * OAP registers a datasource's handler only when its flag is on, so asking a
+ * context path for its build info is a per-datasource availability check: a
+ * datasource nobody enabled 404s on its own path. It proves registration and
+ * reachability and nothing more — the version string is hardcoded upstream, so
+ * it says nothing about storage or about whether search will work.
+ *
+ * A source is OFFERED when its URL is configured; whether it is REACHABLE is a
+ * separate fact the page surfaces itself. An unreachable source never hides its
+ * menu row: a row that vanishes takes the explanation with it, and the operator
+ * is left with a layer that silently lost a tab.
+ */
+
+import type { FetchLike, TraceQLDatasource, TraceQLSourceStatus } from '@skywalking-horizon-ui/api-client';
+import { buildTraceQLOpts, traceqlBuildInfo, TraceQLHttpError } from '../../client/traceql.js';
+import type { HorizonConfig } from '../../config/schema.js';
+
+const TTL_MS = 5 * 60_000;
+/** A FAILED probe expires far sooner than a good one. Holding "down" for five
+ *  minutes outlives the datasource's own recovery, and the page that polls for
+ *  it — and the Cluster page's Refresh — would read the cached failure back
+ *  without ever contacting OAP again. */
+const FAILED_TTL_MS = 5_000;
+
+interface CacheEntry {
+  status: TraceQLSourceStatus;
+  expiresAt: number;
+}
+
+/** Keyed by datasource AND url: two datasources may be configured with the
+ *  same URL, and a probe for one must not answer as the other. */
+const cache = new Map<string, CacheEntry>();
+
+export const TRACEQL_DATASOURCES: readonly TraceQLDatasource[] = ['native', 'zipkin'];
+
+function failureText(e: unknown): string {
+  if (e instanceof TraceQLHttpError) return e.message;
+  if (e instanceof Error) return e.message;
+  return String(e);
+}
+
+async function probeOne(
+  cfg: HorizonConfig,
+  ds: TraceQLDatasource,
+  fetch?: FetchLike,
+  signal?: AbortSignal,
+): Promise<TraceQLSourceStatus> {
+  const opts = buildTraceQLOpts(cfg, ds, fetch, signal);
+  const url = ds === 'native' ? cfg.oap.traceql.nativeUrl : cfg.oap.traceql.zipkinUrl;
+  if (!opts) return { ds, url: '', configured: false, reachable: false };
+
+  const key = `${ds}\u0000${url}`;
+  const cached = cache.get(key);
+  const now = Date.now();
+  if (cached && cached.expiresAt > now) return cached.status;
+
+  let status: TraceQLSourceStatus;
+  try {
+    const info = await traceqlBuildInfo(opts);
+    status = { ds, url, configured: true, reachable: true, version: info.version };
+  } catch (e) {
+    // An abandoned read measured nothing. Report it, but do not write a
+    // conclusion nobody reached into a cache every other request reads.
+    if (signal?.aborted) return { ds, url, configured: true, reachable: false, error: failureText(e) };
+    status = { ds, url, configured: true, reachable: false, error: failureText(e) };
+  }
+  cache.set(key, { status, expiresAt: now + (status.reachable ? TTL_MS : FAILED_TTL_MS) });
+  return status;
+}
+
+/** Every datasource's status, configured or not — the unconfigured ones are
+ *  reported rather than omitted, so "nobody set this up" reads differently
+ *  from "it is set up and down". */
+export async function traceqlSources(
+  cfg: HorizonConfig,
+  fetch?: FetchLike,
+  signal?: AbortSignal,
+): Promise<TraceQLSourceStatus[]> {
+  return Promise.all(TRACEQL_DATASOURCES.map((ds) => probeOne(cfg, ds, fetch, signal)));
+}
+
+/** Force a re-probe. Used by the tests; a config reload also invalidates by
+ *  virtue of the URL being the cache key. */
+export function resetTraceQLAvailability(): void {
+  cache.clear();
+}
diff --git a/apps/bff/src/logic/traceql/status.test.ts b/apps/bff/src/logic/traceql/status.test.ts
new file mode 100644
index 00000000..bbea87ea
--- /dev/null
+++ b/apps/bff/src/logic/traceql/status.test.ts
@@ -0,0 +1,143 @@
+/*
+ * 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.
+ */
+
+/**
+ * The trace-list status pass. What matters is when it declines to answer: a
+ * truncated error set, a Zipkin `status="ok"` the backend drops, and a failed
+ * second search must all leave the row UNKNOWN, because the alternative is a
+ * failed trace rendered green.
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { FetchLike, TraceQLTraceRow } from '@skywalking-horizon-ui/api-client';
+import { configSchema } from '../../config/schema.js';
+import { buildTraceQLOpts, encodeNativeTraceId } from '../../client/traceql.js';
+import { fillTraceStatuses, statusTermIn, withStatusError } from './status.js';
+
+const NATIVE_URL = 'http://oap.test:3200/skywalking';
+
+function row(traceId: string, isError?: boolean): TraceQLTraceRow {
+  return {
+    traceId,
+    rootService: 'agent::ui',
+    rootName: '/homepage',
+    startMs: 1_700_000_000_000,
+    durationMs: 12,
+    spanCount: 3,
+    services: ['agent::ui'],
+    ...(isError === undefined ? {} : { isError }),
+  };
+}
+
+/** A search answering with these ids, and recording what it was asked. */
+function backend(ids: string[]) {
+  const asked: string[] = [];
+  const fetch: FetchLike = async (url) => {
+    const u = new URL(String(url));
+    asked.push(u.searchParams.get('q') ?? '');
+    return new Response(
+      JSON.stringify({
+        traces: ids.map((id) => ({
+          traceID: encodeNativeTraceId(id),
+          rootServiceName: 'agent::ui',
+          rootTraceName: '/homepage',
+          startTimeUnixNano: '1700000000000000000',
+          durationMs: 12,
+          spanSets: [],
+        })),
+      }),
+      { status: 200, headers: { 'content-type': 'application/json' } },
+    );
+  };
+  const opts = buildTraceQLOpts(configSchema.parse({ oap: { traceql: { nativeUrl: NATIVE_URL } } }), 'native', fetch)!;
+  return { opts, asked };
+}
+
+const PARAMS = { q: '{resource.service.name="agent::ui"}', startMs: 1, endMs: 2, limit: 3 };
+
+describe('traceql trace-list status', () => {
+  it('reads a status the expression already fixes, without a second search', async () => {
+    expect(statusTermIn('{status="error"}')).toBe('error');
+    expect(statusTermIn('{ status = "ok" }')).toBe('ok');
+    expect(statusTermIn('{status="STATUS_CODE_OK"}')).toBe('other');
+    expect(statusTermIn('{resource.service.name="a"}')).toBeNull();
+    // An application tag that merely ENDS in `status` is not the intrinsic.
+    expect(statusTermIn('{span.business_status="ok"}')).toBeNull();
+    expect(statusTermIn('{span.http_status="error"}')).toBeNull();
+    // A value that merely CONTAINS the token is not a status condition.
+    expect(statusTermIn('{span.message="status=\\"error\\""}')).toBeNull();
+
+    const { opts, asked } = backend([]);
+    const out = await fillTraceStatuses(opts, { ...PARAMS, q: '{status="error"}' }, [row('a'), row('b')]);
+    expect(out.map((r) => r.isError)).toEqual([true, true]);
+    expect(asked).toEqual([]);
+  });
+
+  it('marks the failures it is told about, and infers nothing from a miss', async () => {
+    const { opts, asked } = backend(['b']);
+    const out = await fillTraceStatuses(opts, PARAMS, [row('a'), row('b'), row('c')]);
+    // Measured on the demo: OAP matches these conditions per SEGMENT, so a
+    // trace can fail downstream and still be absent from this answer. Absence
+    // therefore establishes nothing — those rows stay unknown for the reader.
+    expect(out.map((r) => r.isError)).toEqual([undefined, true, undefined]);
+    expect(asked).toEqual(['{resource.service.name="agent::ui" && status="error"}']);
+  });
+
+  it('does not read a status term inside an alternation as a promise', async () => {
+    // `{status="error" || status="ok"}` selects both, so the first term says
+    // nothing about any given row. Painting them all red is a wrong answer
+    // that also stops the per-trace read from ever correcting it.
+    const { opts } = backend([]);
+    const out = await fillTraceStatuses(opts, { ...PARAMS, q: '{status="error" || status="ok"}' }, [row('a'), row('b')]);
+    expect(out.map((r) => r.isError)).toEqual([undefined, undefined]);
+    // A literal that merely CONTAINS `||` is not an alternation.
+    const fixed = await fillTraceStatuses(opts, { ...PARAMS, q: '{span.name="a||b" && status="error"}' }, [row('a')]);
+    expect(fixed[0]!.isError).toBe(true);
+  });
+
+  it('does not read status="ok" as success', async () => {
+    const { opts, asked } = backend([]);
+    const out = await fillTraceStatuses(opts, { ...PARAMS, q: '{status="ok"}' }, [row('a')]);
+    expect(out[0]!.isError).toBeUndefined();
+    expect(asked).toEqual([]);
+  });
+
+  it('keeps a status the search already reported', async () => {
+    const { opts } = backend([]);
+    const out = await fillTraceStatuses(opts, PARAMS, [row('a', true), row('b')]);
+    expect(out.map((r) => r.isError)).toEqual([true, undefined]);
+  });
+
+  it('extends only the single spanset the editor allows', () => {
+    expect(withStatusError('{}')).toBe('{status="error"}');
+    expect(withStatusError(undefined)).toBe('{status="error"}');
+    expect(withStatusError('{duration>1ms}')).toBe('{duration>1ms && status="error"}');
+    // A shape the editor refuses to run is not one to rewrite either.
+    expect(withStatusError('{a} | count()')).toBeNull();
+  });
+
+  it('costs the statuses, not the list, when the second search fails', async () => {
+    const fetch: FetchLike = async () => new Response('nope', { status: 500 });
+    const opts = buildTraceQLOpts(
+      configSchema.parse({ oap: { traceql: { nativeUrl: NATIVE_URL } } }),
+      'native',
+      fetch,
+    )!;
+    const out = await fillTraceStatuses(opts, PARAMS, [row('a')]);
+    expect(out[0]!.isError).toBeUndefined();
+  });
+});
diff --git a/apps/bff/src/logic/traceql/status.ts b/apps/bff/src/logic/traceql/status.ts
new file mode 100644
index 00000000..fab9d132
--- /dev/null
+++ b/apps/bff/src/logic/traceql/status.ts
@@ -0,0 +1,136 @@
+/*
+ * 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.
+ */
+
+/**
+ * Whether each trace in a search result FAILED.
+ *
+ * The Tempo search response cannot say: its spans carry only the tags OAP
+ * chose to project — on the native datasource `service.name`, `span.kind` and
+ * the span's own tags — and a SkyWalking span's failure is a field, not a tag.
+ * An errored trace and a healthy one come back indistinguishable, which left
+ * every row unknown until the operator opened it.
+ *
+ * What the backend DOES apply is the `status` condition: on the native
+ * datasource it becomes `TraceState.ERROR`, on the Zipkin one an `error`
+ * annotation query, both trace-level. So the same search is run a second time
+ * with that condition, and the ids it returns are the failures.
+ *
+ * It answers ONE way only: a trace in that result failed. Absence proves
+ * nothing, for two reasons — the page may be truncated, and OAP matches these
+ * conditions per SEGMENT rather than over the whole trace. Measured on the
+ * demo: `{resource.service.name="agent::frontend"}` returns 20 traces and the
+ * same query with `status="error"` returns none, while six of those traces
+ * carry errored spans in the services downstream. Inferring success from a
+ * miss would therefore paint failed traces green, which is the one thing this
+ * must never do; success is established by reading the trace instead.
+ */
+
+import type { TraceQLTraceRow } from '@skywalking-horizon-ui/api-client';
+import { traceqlSearch, type TraceQLClientOpts } from '../../client/traceql.js';
+
+/** A `status` the expression already constrains, read outside string literals
+ *  so a value like `span.message="status=error"` is not mistaken for one. */
+export function statusTermIn(q: string | undefined): 'ok' | 'error' | 'other' | null {
+  if (!q) return null;
+  // The BARE intrinsic only: `span.business_status="ok"` is an application
+  // tag, and reading it as the trace's status would skip the verification and
+  // call every row a success.
+  const re = /(?:^|[\s{(&|])status\s*=\s*"((?:[^"\\]|\\.)*)"/g;
+  let m: RegExpExecArray | null;
+  while ((m = re.exec(q)) !== null) {
+    if (insideLiteral(q, m.index)) continue;
+    const v = m[1]!.replace(/\\(["\\])/g, '$1').toLowerCase();
+    return v === 'ok' || v === 'error' ? v : 'other';
+  }
+  return null;
+}
+
+function insideLiteral(q: string, at: number): boolean {
+  let inLit = false;
+  for (let i = 0; i < at; i++) {
+    const c = q[i];
+    if (inLit) {
+      if (c === '\\') i++;
+      else if (c === '"') inLit = false;
+    } else if (c === '"') {
+      inLit = true;
+    }
+  }
+  return inLit;
+}
+
+/** The same query, restricted to failed traces, or null when the expression is
+ *  not the single spanset the editor allows and cannot be extended safely. */
+export function withStatusError(q: string | undefined): string | null {
+  const t = (q ?? '').trim();
+  if (!t) return '{status="error"}';
+  if (!t.startsWith('{') || !t.endsWith('}')) return null;
+  const inner = t.slice(1, -1).trim();
+  return inner ? `{${inner} && status="error"}` : '{status="error"}';
+}
+
+export interface StatusPassParams {
+  q?: string;
+  startMs: number;
+  endMs: number;
+  limit: number;
+}
+
+/**
+ * Fill in each row's `isError`, leaving it undefined where the answer is not
+ * known. Never throws: a failed second search costs the statuses, not the list.
+ */
+/** Whether the expression branches, so no single condition in it is implied
+ *  by a row being in the result. Literals are blanked first — a service named
+ *  `a||b` is not an alternation. */
+function alternates(q: string | undefined): boolean {
+  return /\|\||\bor\b/i.test((q ?? '').replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, ' '));
+}
+
+export async function fillTraceStatuses(
+  opts: TraceQLClientOpts,
+  params: StatusPassParams,
+  rows: TraceQLTraceRow[],
+): Promise<TraceQLTraceRow[]> {
+  if (rows.length === 0 || rows.every((r) => r.isError !== undefined)) return rows;
+
+  // A term inside an alternation does not hold for every row the query
+  // matched: `{status="error" || status="ok"}` selects both, and reading the
+  // first term as a promise paints the healthy half red.
+  const term = alternates(params.q) ? null : statusTermIn(params.q);
+  // The operator asked for failures and the backend answered: every row here
+  // contains one. The mirror of this does NOT hold — `status="ok"` selects
+  // traces whose matching segment succeeded, which says nothing about the rest
+  // of the trace — so it establishes nothing and is left to the reader.
+  if (term === 'error') return rows.map((r) => ({ ...r, isError: true }));
+  if (term === 'ok') return rows;
+
+  const q = withStatusError(params.q);
+  if (q === null) return rows;
+
+  let errors: TraceQLTraceRow[];
+  try {
+    errors = await traceqlSearch(opts, { q, startMs: params.startMs, endMs: params.endMs, limit: params.limit });
+  } catch {
+    return rows;
+  }
+
+  const failed = new Set(errors.map((e) => e.traceId));
+  // Only the positive half is applied. A row this search did not return keeps
+  // its unknown status and is settled by reading the trace.
+  return rows.map((r) => (r.isError === undefined && failed.has(r.traceId) ? { ...r, isError: true } : r));
+}
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index 7d7d8c20..db1e75d3 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -160,6 +160,11 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'GET /api/zipkin/trace/:traceId':                'traces:read',
   'GET /api/zipkin/autocomplete/keys':             'traces:read',
   'GET /api/zipkin/autocomplete/values':           'traces:read',
+  'GET /api/traceql/sources':                      'traces:read',
+  'GET /api/traceql/:ds/search':                   'traces:read',
+  'GET /api/traceql/:ds/trace/:traceId':           'traces:read',
+  'GET /api/traceql/:ds/tags':                     'traces:read',
+  'GET /api/traceql/:ds/tag-values':               'traces:read',
 
   'POST /api/layer/:key/logs':                     'logs:read',
   'POST /api/layer/:key/logs/facets':              'logs:read',
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 89156d66..5b0ff8c7 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -48,6 +48,7 @@ import { registerEndpointDependencyRoute } from './http/query/endpoint-dependenc
 import { registerTraceRoutes } from './http/query/trace.js';
 import { registerTraceTagRoutes } from './http/query/trace-tag.js';
 import { registerZipkinRoutes } from './http/query/zipkin.js';
+import { registerTraceQLRoutes } from './http/query/traceql.js';
 import { registerLogRoute } from './http/query/log.js';
 import { registerEvaluationRecordRoute } from './http/query/evaluation-record.js';
 import { registerBrowserErrorsRoute } from './http/query/browser-errors.js';
@@ -358,6 +359,10 @@ registerTraceRoutes(app, {
 });
 registerTraceTagRoutes(app, { ...authDeps });
 registerZipkinRoutes(app, { ...authDeps });
+registerTraceQLRoutes(app, {
+  ...authDeps,
+  uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+});
 registerLogRoute(app, { ...authDeps });
 registerEvaluationRecordRoute(app, { ...authDeps });
 registerBrowserErrorsRoute(app, { ...authDeps });
diff --git a/apps/bff/src/util/mqe-target.test.ts b/apps/bff/src/util/mqe-target.test.ts
new file mode 100644
index 00000000..0b200dac
--- /dev/null
+++ b/apps/bff/src/util/mqe-target.test.ts
@@ -0,0 +1,104 @@
+/*
+ * 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.
+ */
+
+/**
+ * Where the Inspect page sends an MQE expression.
+ *
+ * Normally: nowhere new. `execExpression` is a query-protocol query on the
+ * same `/graphql` as everything else, so with no override the configured
+ * `oap.queryUrl` IS the MQE surface — scheme, host, port and basic auth all
+ * already line up. There is no version story here; MQE has been part of the
+ * query protocol throughout.
+ *
+ * The config dump is read only for a PARTIAL override, where an operator gave
+ * a host or a port but not both. That path reads OAP's own keys, so the key
+ * NAMES are the contract — and they were read as `sharing-server.default.*`
+ * when the module is `receiver-sharing-server`, so the lookup matched nothing
+ * in any dump. The dumps below are the shape a live OAP returns (verified
+ * against the public demo): every port is a string, and a listener that is off
+ * is `0`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { MqeTargetCache } from './mqe-target.js';
+import { configSchema } from '../config/schema.js';
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+
+const ADMIN = 'http://oap.test:17128';
+const QUERY = 'https://oap.test:12800';
+
+function targetFor(dump: Record<string, string>, mqe?: { host?: string; port?: number }) {
+  const fetch: FetchLike = async () =>
+    new Response(JSON.stringify(dump), { status: 200, headers: { 'content-type': 'application/json' } });
+  const config = configSchema.parse({
+    oap: { queryUrl: QUERY, adminUrl: ADMIN, ...(mqe ? { mqe } : {}) },
+  });
+  return new MqeTargetCache().resolve({ config: () => config, fetch });
+}
+
+/** What OAP ships: the query GraphQL on core, the sharing server off. */
+const DEFAULT_DUMP = {
+  'core.default.restHost': '0.0.0.0',
+  'core.default.restPort': '12800',
+  'receiver-sharing-server.default.restHost': '0.0.0.0',
+  'receiver-sharing-server.default.restPort': '0',
+};
+
+describe('mqe target', () => {
+  it('sends to the configured query endpoint, scheme and all', async () => {
+    const t = await targetFor(DEFAULT_DUMP);
+    // Not rediscovered: rebuilding it from the dump would drop the https and
+    // hand back a wildcard bind host.
+    expect(t.baseUrl).toBe(QUERY);
+    expect(t.via).toBe('oap.queryUrl');
+  });
+
+  it('discovers the half an operator did not give', async () => {
+    const t = await targetFor(DEFAULT_DUMP, { host: 'mqe.internal' });
+    expect(t.baseUrl).toBe('http://mqe.internal:12800');
+    expect(t.via).toContain('core');
+  });
+
+  it('follows the sharing server when an operator has turned it on', async () => {
+    // The key is `receiver-sharing-server`; read as `sharing-server` it
+    // matched nothing and this preference never applied.
+    const t = await targetFor(
+      { ...DEFAULT_DUMP, 'receiver-sharing-server.default.restPort': '12801' },
+      { host: 'mqe.internal' },
+    );
+    expect(t.baseUrl).toBe('http://mqe.internal:12801');
+    expect(t.via).toContain('receiver-sharing-server');
+  });
+
+  it('ignores a sharing server that is present but not listening', async () => {
+    // `0` is how OAP spells "disabled"; reading it as a port would send every
+    // expression somewhere nothing answers.
+    const t = await targetFor(DEFAULT_DUMP, { host: 'mqe.internal' });
+    expect(t.baseUrl).toBe('http://mqe.internal:12800');
+  });
+
+  it('reaches a wildcard bind through the admin URL host', async () => {
+    const t = await targetFor(DEFAULT_DUMP, { port: 12800 });
+    expect(t.baseUrl).toBe('http://oap.test:12800');
+  });
+
+  it('takes a full override without asking OAP anything', async () => {
+    const t = await targetFor(DEFAULT_DUMP, { host: 'mqe.internal', port: 9999 });
+    expect(t.baseUrl).toBe('http://mqe.internal:9999');
+    expect(t.via).toContain('override');
+  });
+});
diff --git a/apps/bff/src/util/mqe-target.ts b/apps/bff/src/util/mqe-target.ts
index c1b66026..36dd3866 100644
--- a/apps/bff/src/util/mqe-target.ts
+++ b/apps/bff/src/util/mqe-target.ts
@@ -25,11 +25,12 @@
  *      port is discovered, and vice versa.
  *
  *   2. Admin-server `GET /debugging/config/dump` — a flat
- *      `Map<String,String>` with keys like `core.default.restPort`
- *      and (when the sharing-server module is enabled)
- *      `sharing-server.default.restPort`. Prefer the sharing-server
- *      values because OAP 11.x defaults the query GraphQL there; fall
- *      back to core's REST otherwise (the v10 layout).
+ *      `Map<String,String>` carrying `core.default.restPort`, which is
+ *      where OAP serves the query GraphQL, and
+ *      `receiver-sharing-server.default.restPort`, a second listener
+ *      that ships disabled (`0`) and that an operator may turn on to
+ *      move receiver + query traffic off the core port. Prefer the
+ *      sharing server only when it is actually listening.
  *
  *   3. Host fallback: if the discovered bind host is empty / wildcard
  *      (`0.0.0.0`, `::`), reuse the admin URL's hostname. Port-forward
@@ -47,7 +48,7 @@ import type { HorizonConfig } from '../config/schema.js';
 export interface MqeTarget {
   /** e.g. `http://oap-rest.cluster.local:12800` — no trailing slash. */
   baseUrl: string;
-  /** Human-readable rationale for the operator: `sharing-server`,
+  /** Human-readable rationale for the operator: `receiver-sharing-server`,
    *  `core.restPort`, `horizon.yaml override`, `admin host fallback`,
    *  combinations thereof. */
   via: string;
@@ -134,7 +135,7 @@ async function resolveMqeTarget(deps: ResolveDeps): Promise<MqeTarget> {
 
   if (finalPort === undefined) {
     throw new Error(
-      'mqe target: could not discover REST port from /debugging/config/dump (neither sharing-server nor core REST appears in the dump)',
+      'mqe target: could not discover REST port from /debugging/config/dump (neither the sharing server nor core REST appears in the dump)',
     );
   }
 
@@ -161,10 +162,17 @@ interface PickResult {
 }
 
 function pickFromDump(dump: Record<string, string>, adminHost: string): PickResult {
-  // Prefer sharing-server over core (OAP 11.x defaults the query
-  // GraphQL on the sharing-server REST; the v10 layout has it on core).
-  const sharingHost = dump['sharing-server.default.restHost'];
-  const sharingPortStr = dump['sharing-server.default.restPort'];
+  // OAP serves the query GraphQL on CORE's REST listener, and that has not
+  // moved between releases. The sharing server is a separate listener an
+  // operator may turn on to take receiver + query traffic off that port; it
+  // ships disabled (`restPort: 0`), and `parsePort` rejects 0, so this prefers
+  // it only where it is actually listening.
+  //
+  // The module is `receiver-sharing-server`. Reading it as `sharing-server`
+  // matched nothing in any config dump, so this branch never ran — harmless
+  // while core is right, wrong for a deployment that moved the port.
+  const sharingHost = dump['receiver-sharing-server.default.restHost'];
+  const sharingPortStr = dump['receiver-sharing-server.default.restPort'];
   const coreHost = dump['core.default.restHost'];
   const corePortStr = dump['core.default.restPort'];
 
@@ -174,8 +182,8 @@ function pickFromDump(dump: Record<string, string>, adminHost: string): PickResu
   const preferSharing = sharingPort !== undefined;
   const host = preferSharing ? sharingHost : coreHost;
   const port = preferSharing ? sharingPort : corePort;
-  const moduleLabel = preferSharing ? 'sharing-server.restHost' : 'core.restHost';
-  const portLabel = preferSharing ? 'sharing-server.restPort' : 'core.restPort';
+  const moduleLabel = preferSharing ? 'receiver-sharing-server.restHost' : 'core.restHost';
+  const portLabel = preferSharing ? 'receiver-sharing-server.restPort' : 'core.restPort';
 
   // Wildcard host fallback. OAP commonly binds on 0.0.0.0; the operator
   // reaches it through the admin URL's host.
diff --git a/apps/ui/CLAUDE.md b/apps/ui/CLAUDE.md
index 8e720e2e..8fa257d4 100644
--- a/apps/ui/CLAUDE.md
+++ b/apps/ui/CLAUDE.md
@@ -82,6 +82,16 @@ When an upstream control changes — service, instance or endpoint pick, time ra
 
 Each control owns its own reset and its own indicator, and resolves as its query lands. This is the display-layer half of the rule that gates `enabled` in the data layer.
 
+## Trace sources stay in their own data model
+
+Horizon renders traces from more than one API, and each has its own idea of what a span IS. SkyWalking's native model has segments, numeric span ids within them, refs, components and attached events. OTLP — which is what TraceQL answers in — has opaque string span ids, a parent span id, a kind enum, a three-valued status, and resource attributes kept apart from span attributes. Zipkin has a third shape again.
+
+**A trace view speaks its own source's model, end to end.** The TraceQL stack under `layer/traceql/` has its own waterfall, tree, statistics and span dialog, and they share no code with the native ones under `layer/traces/` and `render/widgets/`. The two look deliberately alike; that is a matter of using the same tokens and the same layout vocabulary, not of sharing a component.
+
+**Converting between the models is OAP's job, not ours.** OAP already converts native and Zipkin spans into OTLP to answer TraceQL; doing it a second time on the way out — flattening OTLP into the native span shape so one component can render both — throws away precisely what the operator opened that tab to see, and invents fields the protocol does not have. It was tried: it needed synthetic numeric span ids, smuggled the real id into a tag, and had no honest place to put resource attrib [...]
+
+What IS shared is presentation that carries no span model: the result list and the distribution scatter (`TraceListRow` — a row of name, duration, start and status, which every source fills in its own adapter), the design tokens, and the layout of the tab itself. If a shared component starts needing to know how a span is shaped, that is the signal it belongs to one source rather than to all of them.
+
 ## Layering
 
 `api/` is the only path to HTTP. `shell/` knows about layers and routes, never about a feature's data. `controls/` owns the time range and the refresh ticker; pages subscribe and never own them. `state/` is global Pinia that survives navigation. `features/<feature>/` and `layer/<tab>/` are self-contained — views, composables and components together. `render/` is template-driven and generic. `components/{primitives,charts,icons}/` is feature-AGNOSTIC: the moment a shared component needs f [...]
diff --git a/apps/ui/src/ai/ChatTracesBlock.vue b/apps/ui/src/ai/ChatTracesBlock.vue
index 1916314e..4ade87da 100644
--- a/apps/ui/src/ai/ChatTracesBlock.vue
+++ b/apps/ui/src/ai/ChatTracesBlock.vue
@@ -30,6 +30,7 @@ import LayerTracesView from '@/layer/traces/LayerTracesView.vue';
 import ChatCapturedTag from './ChatCapturedTag.vue';
 import { useLayers } from '@/shell/useLayers';
 import type { TracesSpec } from './types';
+import { traceRowIsZipkin } from '@skywalking-horizon-ui/api-client';
 
 const props = defineProps<{ n: number; spec: TracesSpec; capturedAt?: number }>();
 const { t } = useI18n({ useScope: 'global' });
@@ -45,10 +46,8 @@ const layerDef = computed(() =>
   layers.value.find((L) => L.key.toUpperCase() === props.spec.layer.toUpperCase()),
 );
 const noTraces = computed(() => !captured.value && !layerDef.value?.caps?.traces);
-// 'native' | 'zipkin' | 'both' — only pure-'zipkin' can't be embedded here.
-const isZipkinOnly = computed(
-  () => !captured.value && (layerDef.value?.traces?.source ?? 'native') === 'zipkin',
-);
+// Only a layer whose trace row IS Zipkin can't be embedded here.
+const isZipkinOnly = computed(() => !captured.value && traceRowIsZipkin(layerDef.value?.traces));
 
 function openZipkinTab(): void {
   const href = router.resolve({ path: `/layer/${props.spec.layer.toLowerCase()}/zipkin-trace` }).href;
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index cf955629..c7521999 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -81,6 +81,7 @@ import { OverviewApi } from './scopes/overview';
 import { LayerApi } from './scopes/layer';
 import { TraceApi } from './scopes/trace';
 import { ZipkinApi } from './scopes/zipkin';
+import { TraceQLApi } from './scopes/traceql';
 import { LogApi } from './scopes/log';
 import { EvaluationRecordApi } from './scopes/evaluation-record';
 import { BrowserErrorsApi } from './scopes/browser-errors';
@@ -167,6 +168,7 @@ export type {
   TraceAttachedEvent,
   TraceRef,
   NativeSpan,
+  TraceListRow,
   NativeTraceListRow,
   NativeTraceListResponse,
   NativeTraceDetailResponse,
@@ -1024,6 +1026,7 @@ export class BffClient {
   readonly layer = new LayerApi(this);
   readonly trace = new TraceApi(this);
   readonly zipkin = new ZipkinApi(this);
+  readonly traceql = new TraceQLApi(this);
   readonly log = new LogApi(this);
   readonly evaluationRecord = new EvaluationRecordApi(this);
   readonly browserErrors = new BrowserErrorsApi(this);
diff --git a/apps/ui/src/api/scopes/traceql.ts b/apps/ui/src/api/scopes/traceql.ts
new file mode 100644
index 00000000..ddc5a08e
--- /dev/null
+++ b/apps/ui/src/api/scopes/traceql.ts
@@ -0,0 +1,104 @@
+/*
+ * 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 {
+  TraceQLDatasource,
+  TraceQLSourceStatus,
+  TraceQLTagsResponse,
+  TraceQLTagValuesResponse,
+  TraceQLTraceDetailResponse,
+  TraceQLTraceListResponse,
+} from '@skywalking-horizon-ui/api-client';
+import type { BffClient } from '../client';
+
+export interface TraceQLWindow {
+  startMs: number;
+  endMs: number;
+}
+
+/** `bff.traceql` — OAP's Tempo API, one datasource per call. Trace ids are
+ *  the real ones throughout; the hex wire form never reaches the browser. */
+export class TraceQLApi {
+  constructor(private readonly bff: BffClient) {}
+
+  sources(signal?: AbortSignal): Promise<TraceQLSourceStatus[]> {
+    return this.bff.request('GET', '/api/traceql/sources', undefined, undefined, signal);
+  }
+
+  search(
+    ds: TraceQLDatasource,
+    q: string,
+    window: TraceQLWindow,
+    limit: number,
+    signal?: AbortSignal,
+  ): Promise<TraceQLTraceListResponse> {
+    const p = new URLSearchParams({
+      startMs: String(window.startMs),
+      endMs: String(window.endMs),
+      limit: String(limit),
+    });
+    if (q.trim()) p.set('q', q.trim());
+    return this.bff.request('GET', `/api/traceql/${ds}/search?${p.toString()}`, undefined, undefined, signal);
+  }
+
+  /** Omitting the window is the "no time range" lookup this API answers by
+   *  searching its whole history. */
+  trace(
+    ds: TraceQLDatasource,
+    traceId: string,
+    window?: TraceQLWindow,
+    signal?: AbortSignal,
+  ): Promise<TraceQLTraceDetailResponse> {
+    const p = new URLSearchParams();
+    if (window) {
+      p.set('startMs', String(window.startMs));
+      p.set('endMs', String(window.endMs));
+    }
+    const qs = p.toString();
+    return this.bff.request(
+      'GET',
+      `/api/traceql/${ds}/trace/${encodeURIComponent(traceId)}${qs ? '?' + qs : ''}`,
+      undefined,
+      undefined,
+      signal,
+    );
+  }
+
+  tags(ds: TraceQLDatasource, window: TraceQLWindow, signal?: AbortSignal): Promise<TraceQLTagsResponse> {
+    const p = new URLSearchParams({ startMs: String(window.startMs), endMs: String(window.endMs) });
+    return this.bff.request('GET', `/api/traceql/${ds}/tags?${p.toString()}`, undefined, undefined, signal);
+  }
+
+  /** `layer` lets the BFF apply that layer's service filter — the Tempo API
+   *  has no notion of a layer, so the picker is narrowed here or nowhere. */
+  tagValues(
+    ds: TraceQLDatasource,
+    tag: string,
+    window: TraceQLWindow,
+    opts: { q?: string; layer?: string; previewConfig?: string; signal?: AbortSignal } = {},
+  ): Promise<TraceQLTagValuesResponse> {
+    const p = new URLSearchParams({
+      tag,
+      startMs: String(window.startMs),
+      endMs: String(window.endMs),
+    });
+    if (opts.q) p.set('q', opts.q);
+    if (opts.layer) p.set('layer', opts.layer);
+    if (opts.previewConfig) p.set('previewConfig', opts.previewConfig);
+    return this.bff.request('GET', `/api/traceql/${ds}/tag-values?${p.toString()}`, undefined, undefined, opts.signal);
+  }
+}
diff --git a/apps/ui/src/assets/styles/global.css b/apps/ui/src/assets/styles/global.css
index d8c9af87..98b09301 100644
--- a/apps/ui/src/assets/styles/global.css
+++ b/apps/ui/src/assets/styles/global.css
@@ -15,6 +15,15 @@
  * limitations under the License.
  */
 
+/* The trace tabs' top strip. Native, Zipkin and TraceQL each draw their own
+   conditions card beside a shared duration distribution, and the grid row is as
+   tall as the taller of the two — so without one number the three tabs, and the
+   query modes inside them, each settle at a different height and the shortest
+   leaves the distribution too small to read. */
+:root {
+  --sw-trace-strip-h: 290px;
+}
+
 html,
 body,
 #app {
diff --git a/apps/ui/src/components/primitives/DateTimeField.test.ts b/apps/ui/src/components/primitives/DateTimeField.test.ts
new file mode 100644
index 00000000..d8a9a982
--- /dev/null
+++ b/apps/ui/src/components/primitives/DateTimeField.test.ts
@@ -0,0 +1,145 @@
+/*
+ * 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.
+ */
+
+/**
+ * The date field that replaced `<input type="datetime-local">`.
+ *
+ * Two things matter: it emits exactly what the native control emitted, so a
+ * host keeps its parsing untouched, and its calendar follows HORIZON's locale
+ * rather than the browser's — which is the whole reason it exists.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { i18n } from '@/i18n';
+import DateTimeField from './DateTimeField.vue';
+
+function mountField(modelValue: string | null, props: Record<string, unknown> = {}) {
+  return mount(DateTimeField, { props: { modelValue, ...props }, global: { plugins: [i18n] } });
+}
+const emitted = (w: ReturnType<typeof mountField>) => {
+  const e = w.emitted('update:modelValue') as string[][] | undefined;
+  return e?.[e.length - 1]?.[0];
+};
+
+beforeEach(() => { i18n.global.locale.value = 'en'; });
+
+describe('date-time field', () => {
+  it('shows the model unambiguously, and keeps the model’s own shape', () => {
+    const w = mountField('2026-03-04T05:06');
+    // A space on screen, `T` on the wire — the native control's format.
+    expect((w.get('.dtf-input').element as HTMLInputElement).value).toBe('2026-03-04 05:06');
+  });
+
+  it('commits only a date that exists', async () => {
+    const w = mountField('');
+    await w.get('.dtf-input').setValue('2026-02-31 10:00');
+    expect(emitted(w)).toBeUndefined();
+    await w.get('.dtf-input').setValue('2026-02-28 10:00');
+    expect(emitted(w)).toBe('2026-02-28T10:00');
+  });
+
+  it('accepts what an operator is likely to paste', async () => {
+    const w = mountField('');
+    // `T` or a space, one-digit fields, seconds it does not need.
+    for (const [typed, want] of [
+      ['2026-03-04T05:06', '2026-03-04T05:06'],
+      ['2026-3-4 5:06', '2026-03-04T05:06'],
+      ['2026-03-04 05:06:42', '2026-03-04T05:06'],
+    ]) {
+      await w.get('.dtf-input').setValue(typed);
+      expect(emitted(w), typed).toBe(want);
+    }
+  });
+
+  it('clears to an empty string rather than a half-typed one', async () => {
+    const w = mountField('2026-03-04T05:06');
+    await w.get('.dtf-input').setValue('');
+    expect(emitted(w)).toBe('');
+  });
+
+  it('draws its calendar in Horizon’s locale, not the browser’s', async () => {
+    const w = mountField('2026-03-04T05:06');
+    await w.get('.dtf-open').trigger('click');
+    expect(document.querySelector('.dtf-month')?.textContent).toContain('March');
+    i18n.global.locale.value = 'zh-CN';
+    await w.vm.$nextTick();
+    // The month name is the locale's, which is what the native control would
+    // never do — it follows the browser's UI language instead.
+    expect(document.querySelector('.dtf-month')?.textContent).toContain('3月');
+    w.unmount();
+  });
+
+  it('keeps the time when a day is picked, and the day when a time is typed', async () => {
+    const w = mountField('2026-03-04T05:06');
+    await w.get('.dtf-open').trigger('click');
+    const days = [...document.querySelectorAll('.dtf-day')] as HTMLElement[];
+    const tenth = days.find((d) => !d.classList.contains('out') && d.textContent?.trim() === '10');
+    tenth!.click();
+    await w.vm.$nextTick();
+    expect(emitted(w)).toBe('2026-03-10T05:06');
+    w.unmount();
+  });
+
+  it('works in whole days when asked to', async () => {
+    const w = mountField('2026-03-04', { mode: 'date' });
+    expect((w.get('.dtf-input').element as HTMLInputElement).value).toBe('2026-03-04');
+    await w.get('.dtf-open').trigger('click');
+    // No time row: the range is days.
+    expect(document.querySelector('.dtf-foot')).toBeNull();
+    await w.get('.dtf-input').setValue('2026-03-09');
+    expect(emitted(w)).toBe('2026-03-09');
+    w.unmount();
+  });
+
+  it('starts the week where each language starts it', async () => {
+    // CLDR: Sunday for English, Japanese, Korean and Portuguese; Monday for
+    // German, Spanish, French and Simplified Chinese. Read from a table
+    // because `Intl.Locale.weekInfo` is absent in the engines we ship to, and
+    // its absence silently started every locale on Monday.
+    const w = mountField('2026-03-04T05:06');
+    await w.get('.dtf-open').trigger('click');
+    const firstCol = () => document.querySelector('.dtf-week span')?.textContent;
+    for (const [loc, want] of [['en', 'S'], ['ja', '日'], ['ko', '일'], ['pt', 'D'],
+                               ['de', 'M'], ['es', 'L'], ['fr', 'L'], ['zh-CN', '一']] as const) {
+      i18n.global.locale.value = loc;
+      await w.vm.$nextTick();
+      expect(firstCol(), loc).toBe(want);
+    }
+    w.unmount();
+  });
+
+  it('does not rewrite what is being typed, and commits only a whole value', async () => {
+    // Typed a character at a time, `2026-09-1` parses as the 1st. Committing
+    // and normalising that mid-word moved the cursor and corrupted the rest.
+    const w = mountField('');
+    const input = w.get('.dtf-input');
+    await input.trigger('focus');
+    for (const partial of ['2', '20', '202', '2026', '2026-', '2026-0', '2026-09', '2026-09-', '2026-09-1', '2026-09-19', '2026-09-19 ', '2026-09-19 1', '2026-09-19 10', '2026-09-19 10:', '2026-09-19 10:0']) {
+      await input.setValue(partial);
+      expect(emitted(w), partial).toBeUndefined();
+      expect((input.element as HTMLInputElement).value, partial).toBe(partial);
+    }
+    await input.setValue('2026-09-19 10:00');
+    expect(emitted(w)).toBe('2026-09-19T10:00');
+  });
+
+  it('takes null from a host that holds the pair that way', () => {
+    const w = mountField(null);
+    expect((w.get('.dtf-input').element as HTMLInputElement).value).toBe('');
+  });
+});
diff --git a/apps/ui/src/components/primitives/DateTimeField.vue b/apps/ui/src/components/primitives/DateTimeField.vue
new file mode 100644
index 00000000..15c3b963
--- /dev/null
+++ b/apps/ui/src/components/primitives/DateTimeField.vue
@@ -0,0 +1,469 @@
+<!--
+  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.
+-->
+<!--
+  A local date and time, in HORIZON's locale.
+
+  A drop-in for `<input type="datetime-local">`, which this replaces for one
+  reason: that control is drawn by the browser in the BROWSER's UI language,
+  which neither the page's `lang` nor Horizon's own locale picker can reach.
+  Measured in Chromium with `lang="en"` on the page: a Chinese browser renders
+  `yyyy/mm/dd` with 年 月 日 spinners, a German one `dd.mm.yyyy`. An operator
+  reading an English console got a Chinese calendar, and no styling or markup
+  changes that.
+
+  So the field is ours. The text is a fixed `YYYY-MM-DD HH:mm` — the same
+  unambiguous order the rest of the app prints timestamps in — and only the
+  CALENDAR is localized, from `Intl` with Horizon's active locale, so month and
+  weekday names follow the locale picker like every other string on screen.
+
+  The model is the same `YYYY-MM-DDTHH:mm` string the native control emits, so
+  a host swaps the tag and keeps its parsing, its `new Date(v)` and its tests.
+-->
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import Icon from '@/components/icons/Icon.vue';
+
+const props = withDefaults(
+  defineProps<{
+    /** `YYYY-MM-DDTHH:mm`, as `<input type="datetime-local">` emits. Null is
+     *  accepted because several hosts hold the pair as `string | null`, and
+     *  this has to drop straight into them. */
+    modelValue: string | null;
+    /** `date` drops the time half and works in `YYYY-MM-DD`, for the ranges
+     *  that are whole days. */
+    mode?: 'datetime' | 'date';
+    disabled?: boolean;
+    /** Marks the field as unusable input without disabling it. */
+    invalid?: boolean;
+    ariaLabel?: string;
+  }>(),
+  { mode: 'datetime', disabled: false, invalid: false, ariaLabel: '' },
+);
+const emit = defineEmits<{ 'update:modelValue': [string] }>();
+
+const { t, locale } = useI18n({ useScope: 'global' });
+
+const root = ref<HTMLElement | null>(null);
+const inputEl = ref<HTMLInputElement | null>(null);
+const open = ref(false);
+
+/* ── the model, and the text that stands for it ───────────────────────── */
+
+const pad = (n: number) => String(n).padStart(2, '0');
+/** The model's shape. Anything else is text the operator is still typing. */
+const MODEL = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/;
+/** What the field accepts back: the same fields, space or `T`, seconds
+ *  tolerated and dropped — a pasted log timestamp should just work. The time
+ *  half is optional, which is the whole of `date` mode and a lenient paste in
+ *  the other. */
+const TYPED = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{2})(?::\d{2})?)?$/;
+
+function partsOf(v: string): { y: number; m: number; d: number; hh: number; mm: number } | null {
+  const m = MODEL.exec(v) ?? TYPED.exec(v);
+  if (!m) return null;
+  const [y, mo, d] = [+m[1]!, +m[2]!, +m[3]!];
+  const [hh, mm] = [m[4] === undefined ? 0 : +m[4], m[5] === undefined ? 0 : +m[5]];
+  // A real calendar day: `2026-02-31` parses as fields and is not a date.
+  const probe = new Date(y, mo - 1, d, hh, mm);
+  if (probe.getFullYear() !== y || probe.getMonth() !== mo - 1 || probe.getDate() !== d) return null;
+  if (hh > 23 || mm > 59) return null;
+  return { y, m: mo, d, hh, mm };
+}
+const model = (y: number, m: number, d: number, hh: number, mm: number) =>
+  props.mode === 'date' ? `${y}-${pad(m)}-${pad(d)}` : `${y}-${pad(m)}-${pad(d)}T${pad(hh)}:${pad(mm)}`;
+/** What the text field shows for a model that parses. */
+const asText = (p: { y: number; m: number; d: number; hh: number; mm: number }) =>
+  props.mode === 'date'
+    ? `${p.y}-${pad(p.m)}-${pad(p.d)}`
+    : `${p.y}-${pad(p.m)}-${pad(p.d)} ${pad(p.hh)}:${pad(p.mm)}`;
+
+/** The typed text, held apart from the model. While the field has FOCUS the
+ *  model never writes back into it: `2026-09-1` parses as the 1st, and
+ *  normalising that mid-word moved the cursor and corrupted the rest of what
+ *  was being typed. Blur settles it. */
+const text = ref('');
+const focused = ref(false);
+/** What a keystroke has to reach before it commits — the whole shape, so a day
+ *  typed one digit at a time does not commit a date nobody asked for. */
+const COMPLETE = computed(() =>
+  props.mode === 'date'
+    ? /^\d{4}-\d{1,2}-\d{1,2}$/
+    : /^\d{4}-\d{1,2}-\d{1,2}[T ]\d{1,2}:\d{2}(?::\d{2})?$/,
+);
+const selected = computed(() => partsOf(props.modelValue ?? ''));
+watch(
+  () => props.modelValue,
+  (v) => {
+    if (focused.value) return;
+    const p = partsOf(v ?? '');
+    text.value = p ? asText(p) : (v ?? '');
+  },
+  { immediate: true },
+);
+
+function onInput(e: Event): void {
+  text.value = (e.target as HTMLInputElement).value;
+  const typed = text.value.trim();
+  if (!typed) {
+    emit('update:modelValue', '');
+    return;
+  }
+  if (!COMPLETE.value.test(typed)) return;
+  const p = partsOf(typed);
+  if (p) emit('update:modelValue', model(p.y, p.m, p.d, p.hh, p.mm));
+}
+/** On blur the text goes back to whatever the model says, so an unparseable
+ *  edit does not sit there looking like a value. A COMPLETE one is committed
+ *  first — leaving the field is a commit, not a cancel. */
+function onBlur(): void {
+  focused.value = false;
+  const typed = text.value.trim();
+  const p = COMPLETE.value.test(typed) ? partsOf(typed) : null;
+  if (p) emit('update:modelValue', model(p.y, p.m, p.d, p.hh, p.mm));
+  const settled = p ?? selected.value;
+  text.value = settled ? asText(settled) : '';
+}
+const textInvalid = computed(() => text.value.trim() !== '' && partsOf(text.value) === null);
+
+/* ── the calendar, in Horizon's locale ────────────────────────────────── */
+
+/** The month being shown, which is not the selection: paging to March does
+ *  not pick a day in March. */
+const view = ref<{ y: number; m: number }>({ y: 2000, m: 1 });
+function resetView(): void {
+  const p = selected.value;
+  const now = new Date();
+  view.value = p ? { y: p.y, m: p.m } : { y: now.getFullYear(), m: now.getMonth() + 1 };
+}
+
+const intlLocale = computed(() => locale.value);
+const monthLabel = computed(() =>
+  new Intl.DateTimeFormat(intlLocale.value, { year: 'numeric', month: 'long' })
+    .format(new Date(view.value.y, view.value.m - 1, 1)),
+);
+/**
+ * Which weekday a calendar starts on, 1 = Monday … 7 = Sunday.
+ *
+ * Stated per locale rather than read from `Intl.Locale.weekInfo`, which the
+ * engines we ship to mostly do NOT have — Chromium returns undefined, and the
+ * fallback then started every locale on Monday, which is wrong for four of the
+ * eight. The values are CLDR's: Sunday for English, Japanese, Korean and
+ * Portuguese, Monday for German, Spanish, French and Simplified Chinese.
+ */
+const WEEK_STARTS_ON: Readonly<Record<string, number>> = {
+  en: 7, ja: 7, ko: 7, pt: 7,
+  de: 1, es: 1, fr: 1, 'zh-CN': 1,
+};
+const firstDay = computed(() => WEEK_STARTS_ON[intlLocale.value] ?? 1);
+const weekdays = computed(() => {
+  const fmt = new Intl.DateTimeFormat(intlLocale.value, { weekday: 'narrow' });
+  // 2024-01-01 was a Monday, so it anchors the rotation.
+  return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(2024, 0, 1 + ((firstDay.value - 1 + i) % 7))));
+});
+
+interface Cell { key: string; day: number; y: number; m: number; outside: boolean }
+const cells = computed<Cell[]>(() => {
+  const { y, m } = view.value;
+  const first = new Date(y, m - 1, 1);
+  // How many leading cells belong to the previous month, given where the
+  // locale's week starts.
+  const lead = (first.getDay() - (firstDay.value % 7) + 7) % 7;
+  const start = new Date(y, m - 1, 1 - lead);
+  return Array.from({ length: 42 }, (_, i) => {
+    const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
+    return {
+      key: `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`,
+      day: d.getDate(),
+      y: d.getFullYear(),
+      m: d.getMonth() + 1,
+      outside: d.getMonth() + 1 !== m,
+    };
+  });
+});
+
+const today = computed(() => {
+  const n = new Date();
+  return { y: n.getFullYear(), m: n.getMonth() + 1, d: n.getDate() };
+});
+const isSelected = (c: Cell) => {
+  const p = selected.value;
+  return !!p && p.y === c.y && p.m === c.m && p.d === c.day;
+};
+const isToday = (c: Cell) => today.value.y === c.y && today.value.m === c.m && today.value.d === c.day;
+
+function stepMonth(by: number): void {
+  const d = new Date(view.value.y, view.value.m - 1 + by, 1);
+  view.value = { y: d.getFullYear(), m: d.getMonth() + 1 };
+}
+function pickDay(c: Cell): void {
+  const p = selected.value;
+  emit('update:modelValue', model(c.y, c.m, c.day, p?.hh ?? 0, p?.mm ?? 0));
+  if (c.outside) view.value = { y: c.y, m: c.m };
+}
+/**
+ * The time half — a PLAIN text field, for the same reason the date half is
+ * ours: `<input type="time">` is drawn by the browser too, so on a Chinese
+ * browser it reads 时 / 分 and on a US one it grows an AM/PM spinner. 24-hour
+ * `HH:mm` is what the rest of the app prints.
+ *
+ * A day with no time is midnight, which is what a bare date means anywhere.
+ */
+const timeText = ref('');
+watch(selected, (p) => { timeText.value = p ? `${pad(p.hh)}:${pad(p.mm)}` : ''; }, { immediate: true });
+const TIME = /^(\d{1,2}):(\d{2})$/;
+function setTime(e: Event): void {
+  timeText.value = (e.target as HTMLInputElement).value;
+  const m = TIME.exec(timeText.value.trim());
+  if (!m) return;
+  const [hh, mm] = [+m[1]!, +m[2]!];
+  if (hh > 23 || mm > 59) return;
+  const p = selected.value ?? { ...today.value, hh: 0, mm: 0 };
+  emit('update:modelValue', model(p.y, p.m, p.d, hh, mm));
+}
+function onTimeBlur(): void {
+  const p = selected.value;
+  timeText.value = p ? `${pad(p.hh)}:${pad(p.mm)}` : '';
+}
+function pickNow(): void {
+  const n = new Date();
+  emit('update:modelValue', model(n.getFullYear(), n.getMonth() + 1, n.getDate(), n.getHours(), n.getMinutes()));
+  resetView();
+}
+
+/* ── the panel, which must not be clipped by the card it sits in ──────── */
+
+const panelStyle = ref<Record<string, string>>({});
+const GAP = 4;
+const EDGE = 8;
+const PANEL_W = 248;
+function place(): void {
+  const el = root.value;
+  if (!el) return;
+  const r = el.getBoundingClientRect();
+  const below = window.innerHeight - r.bottom - GAP - EDGE;
+  const above = r.top - GAP - EDGE;
+  const up = below < 290 && above > below;
+  panelStyle.value = {
+    left: `${Math.max(EDGE, Math.min(r.left, window.innerWidth - PANEL_W - EDGE))}px`,
+    ...(up ? { bottom: `${window.innerHeight - r.top + GAP}px` } : { top: `${r.bottom + GAP}px` }),
+  };
+}
+function track(on: boolean): void {
+  const fn = on ? window.addEventListener : window.removeEventListener;
+  fn('scroll', place, true);
+  fn('resize', place);
+}
+function toggle(): void {
+  if (props.disabled) return;
+  open.value = !open.value;
+  if (open.value) resetView();
+}
+function onDocPointer(e: Event): void {
+  const target = e.target as Node;
+  if (root.value?.contains(target)) return;
+  if (panelEl.value?.contains(target)) return;
+  open.value = false;
+}
+function onDocKey(e: KeyboardEvent): void {
+  if (e.key === 'Escape' && open.value) {
+    open.value = false;
+    inputEl.value?.focus();
+  }
+}
+const panelEl = ref<HTMLElement | null>(null);
+watch(open, (v) => {
+  track(v);
+  if (v) {
+    void nextTick(place);
+    document.addEventListener('pointerdown', onDocPointer);
+    document.addEventListener('keydown', onDocKey);
+  } else {
+    document.removeEventListener('pointerdown', onDocPointer);
+    document.removeEventListener('keydown', onDocKey);
+  }
+});
+onBeforeUnmount(() => {
+  track(false);
+  document.removeEventListener('pointerdown', onDocPointer);
+  document.removeEventListener('keydown', onDocKey);
+});
+</script>
+
+<template>
+  <div ref="root" class="dtf" :class="{ invalid: invalid || textInvalid, disabled }">
+    <input
+      ref="inputEl"
+      class="dtf-input mono"
+      type="text"
+      inputmode="numeric"
+      :value="text"
+      :disabled="disabled"
+      :aria-label="ariaLabel || t('Date and time')"
+      :placeholder="mode === 'date' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm'"
+      autocomplete="off"
+      spellcheck="false"
+      @input="onInput"
+      @focus="focused = true"
+      @blur="onBlur"
+    />
+    <button
+      type="button"
+      class="dtf-open"
+      :disabled="disabled"
+      :aria-label="t('Open the calendar')"
+      :aria-expanded="open"
+      @click="toggle"
+    >
+      <Icon name="clock" :size="12" />
+    </button>
+
+    <Teleport to="body">
+      <!-- The panel lives in `body`, so a host that closes itself on a click
+           outside its own element — the topbar's time menu does — counts every
+           click in here as outside and discards the draft. Clicks stop at the
+           panel; the field's own outside-close reads `pointerdown` instead. -->
+      <div
+        v-if="open"
+        ref="panelEl"
+        class="dtf-panel"
+        :style="panelStyle"
+        @click.stop
+        @mousedown.stop
+      >
+        <header class="dtf-head">
+          <button type="button" class="dtf-nav" :aria-label="t('Previous month')" @click="stepMonth(-1)">‹</button>
+          <span class="dtf-month">{{ monthLabel }}</span>
+          <button type="button" class="dtf-nav" :aria-label="t('Next month')" @click="stepMonth(1)">›</button>
+        </header>
+        <div class="dtf-week">
+          <span v-for="(w, i) in weekdays" :key="i">{{ w }}</span>
+        </div>
+        <div class="dtf-grid">
+          <button
+            v-for="c in cells"
+            :key="c.key"
+            type="button"
+            class="dtf-day"
+            :class="{ out: c.outside, on: isSelected(c), today: isToday(c) }"
+            @click="pickDay(c)"
+          >{{ c.day }}</button>
+        </div>
+        <footer v-if="mode !== 'date'" class="dtf-foot">
+          <input
+            class="dtf-time mono"
+            type="text"
+            inputmode="numeric"
+            maxlength="5"
+            placeholder="HH:mm"
+            :value="timeText"
+            :aria-label="t('Time')"
+            @input="setTime"
+            @blur="onTimeBlur"
+          />
+          <button type="button" class="sw-btn small ghost" @click="pickNow">{{ t('Now') }}</button>
+        </footer>
+      </div>
+    </Teleport>
+  </div>
+</template>
+
+<style scoped>
+.dtf {
+  display: flex;
+  align-items: center;
+  gap: 2px;
+  height: 28px;
+  padding: 0 4px 0 8px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 5px;
+  min-width: 0;
+}
+.dtf:focus-within { border-color: var(--sw-accent); }
+.dtf.invalid { border-color: var(--sw-warn); }
+.dtf.disabled { opacity: 0.5; }
+.dtf-input {
+  flex: 1 1 auto;
+  min-width: 0;
+  height: 100%;
+  border: none;
+  background: transparent;
+  color: var(--sw-fg-0);
+  font: inherit;
+  font-size: 12px;
+  outline: none;
+  padding: 0;
+}
+.dtf-input::placeholder { color: var(--sw-fg-3); }
+.dtf-open {
+  flex: 0 0 auto;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 20px;
+  height: 20px;
+  border: none;
+  border-radius: 3px;
+  background: transparent;
+  color: var(--sw-fg-2);
+  cursor: pointer;
+}
+.dtf-open:hover { background: var(--sw-bg-3); color: var(--sw-accent); }
+
+/* Fixed, in `body`: every host puts this field inside a card that clips. */
+.dtf-panel {
+  position: fixed;
+  z-index: 1200;
+  width: 248px;
+  padding: 8px;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 6px;
+  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
+}
+.dtf-head { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
+.dtf-month { flex: 1; text-align: center; font-size: 11.5px; color: var(--sw-fg-0); }
+.dtf-nav {
+  width: 20px; height: 20px; border: 1px solid var(--sw-line-2); border-radius: 3px;
+  background: var(--sw-bg-2); color: var(--sw-fg-2); font: inherit; cursor: pointer; line-height: 1;
+}
+.dtf-nav:hover { border-color: var(--sw-accent); color: var(--sw-accent); }
+.dtf-week, .dtf-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; }
+.dtf-week span { text-align: center; font-size: 9.5px; color: var(--sw-fg-3); padding-bottom: 2px; }
+.dtf-day {
+  height: 24px; border: none; border-radius: 3px; background: transparent;
+  color: var(--sw-fg-1); font: inherit; font-size: 11px; cursor: pointer;
+}
+.dtf-day:hover { background: var(--sw-bg-3); }
+.dtf-day.out { color: var(--sw-fg-3); }
+.dtf-day.today { box-shadow: inset 0 0 0 1px var(--sw-line-3); }
+.dtf-day.on { background: var(--sw-accent); color: var(--sw-bg-0); }
+.dtf-foot { display: flex; align-items: center; gap: 6px; margin-top: 7px; }
+.dtf-time {
+  flex: 1;
+  height: 24px;
+  padding: 0 6px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  color: var(--sw-fg-0);
+  font: inherit;
+  font-size: 11.5px;
+}
+.mono { font-family: var(--sw-mono); }
+</style>
diff --git a/apps/ui/src/components/primitives/LongValue.test.ts b/apps/ui/src/components/primitives/LongValue.test.ts
new file mode 100644
index 00000000..2aa28a16
--- /dev/null
+++ b/apps/ui/src/components/primitives/LongValue.test.ts
@@ -0,0 +1,88 @@
+/*
+ * 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.
+ */
+
+/**
+ * The long-value viewer. The value it COPIES is the whole one — a copy that
+ * handed back the truncated text would be worse than no copy at all, since
+ * what lands in the paste looks complete.
+ */
+
+import { describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { i18n } from '@/i18n';
+import LongValue from './LongValue.vue';
+
+const LONG = 'select * from songs where id in (' + '1, '.repeat(200) + '1)';
+
+function mountValue(value: string, limit?: number) {
+  return mount(LongValue, {
+    props: { value, label: 'db.statement', ...(limit === undefined ? {} : { limit }) },
+    global: { plugins: [i18n] },
+  });
+}
+
+describe('long value', () => {
+  it('renders a short value as itself, with nothing to open', () => {
+    const w = mountValue('GET');
+    expect(w.get('.lv-text').text()).toBe('GET');
+    expect(w.find('.lv-more').exists()).toBe(false);
+  });
+
+  it('cuts a long value and offers the whole one', async () => {
+    const w = mountValue(LONG);
+    const shown = w.get('.lv-text').text();
+    expect(LONG.startsWith(shown)).toBe(true);
+    expect(shown.length).toBeLessThanOrEqual(100);
+    expect(shown.length).toBeGreaterThan(90);
+    expect(w.find('.lv-more').exists()).toBe(true);
+    await w.get('.lv-more').trigger('click');
+    // The dialog is teleported to the body, and carries the value in full.
+    expect(document.body.textContent).toContain(`${LONG.length} characters`);
+    expect(document.querySelector('.lv-full')?.textContent).toBe(LONG);
+    w.unmount();
+  });
+
+  it('treats a multi-line value as long, however short', async () => {
+    const w = mountValue('line one\nline two');
+    // One line on screen, both lines readable — the break becomes a space.
+    expect(w.get('.lv-text').text()).toBe('line one line two');
+    expect(w.find('.lv-more').exists()).toBe(true);
+    // It fits once collapsed, so nothing was cut and nothing claims otherwise.
+    expect(w.find('.lv-ellipsis').exists()).toBe(false);
+  });
+
+  it('previews a pretty-printed payload past its opening bracket', () => {
+    // Stopping at the first newline would preview `[` and nothing else, which
+    // is what an LLM request body looks like on a span.
+    const body = JSON.stringify([{ role: 'system', content: 'x'.repeat(400) }], null, 2);
+    const w = mountValue(body);
+    const shown = w.get('.lv-text').text();
+    expect(shown.length).toBe(100);
+    expect(shown).toContain('"role": "system"');
+  });
+
+  it('copies the whole value, not the part on screen', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined);
+    vi.stubGlobal('navigator', { clipboard: { writeText } });
+    const w = mountValue(LONG);
+    await w.get('.lv-more').trigger('click');
+    document.querySelector<HTMLButtonElement>('.lv-dialog-bar .sw-btn')!.click();
+    expect(writeText).toHaveBeenCalledWith(LONG);
+    vi.unstubAllGlobals();
+    w.unmount();
+  });
+});
diff --git a/apps/ui/src/components/primitives/LongValue.vue b/apps/ui/src/components/primitives/LongValue.vue
new file mode 100644
index 00000000..cc55216f
--- /dev/null
+++ b/apps/ui/src/components/primitives/LongValue.vue
@@ -0,0 +1,140 @@
+<!--
+  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.
+-->
+<!--
+  A value that may be far longer than the row it sits in — a SQL statement, a
+  stack trace, a serialized payload.
+
+  Short values render as themselves and cost nothing. A long one previews its
+  first `limit` characters on one line, with a button that opens the whole thing
+  in a scrollable dialog and a Copy that takes the WHOLE value, not the visible
+  part. It knows nothing about traces, spans or logs: it is text with a length.
+-->
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import Modal from './Modal.vue';
+
+const props = withDefaults(
+  defineProps<{
+    value: string;
+    /** Names the value in the dialog's title. */
+    label?: string;
+    /** Characters kept inline. Past this the value is cut, and the dialog is
+     *  the way to read it whole and to copy it. */
+    limit?: number;
+  }>(),
+  { label: '', limit: 100 },
+);
+
+const { t } = useI18n({ useScope: 'global' });
+
+const firstBreak = computed(() => props.value.indexOf('\n'));
+/** Long by length, or carrying more than one line — either way the row cannot
+ *  show it and the dialog must. */
+const isLong = computed(
+  () => props.value.length > props.limit || (firstBreak.value >= 0 && firstBreak.value < props.value.length - 1),
+);
+/** The preview collapses runs of whitespace rather than stopping at the first
+ *  newline: a pretty-printed payload opens on `[` or `{`, and cutting there
+ *  previews one character of a body the operator is trying to recognise. */
+const collapsed = computed(() => (isLong.value ? props.value.replace(/\s+/g, ' ').trim() : props.value));
+const shown = computed(() => collapsed.value.slice(0, props.limit));
+/** Only when something was actually cut: a value long in whitespace alone
+ *  collapses to fit, and an ellipsis there would claim a cut that never was. */
+const truncated = computed(() => collapsed.value.length > props.limit);
+
+const open = ref(false);
+const copied = ref(false);
+let copiedTimer: ReturnType<typeof setTimeout> | null = null;
+function copyAll(): void {
+  navigator.clipboard?.writeText(props.value).then(() => {
+    copied.value = true;
+    if (copiedTimer) clearTimeout(copiedTimer);
+    copiedTimer = setTimeout(() => { copied.value = false; }, 1400);
+  }, () => {});
+}
+</script>
+
+<template>
+  <span class="lv">
+    <span class="lv-text">{{ shown }}</span>
+    <template v-if="isLong">
+      <span v-if="truncated" class="lv-ellipsis">…</span>
+      <button
+        type="button"
+        class="lv-more"
+        :title="t('Show the full value')"
+        @click.stop="open = true"
+      >{{ t('full') }}</button>
+    </template>
+  </span>
+
+  <Modal :open="open" :title="label || t('Value')" width="min(880px, 92vw)" @close="open = false">
+    <div class="lv-dialog">
+      <div class="lv-dialog-bar">
+        <span class="lv-size mono">{{ t('{n} characters', { n: value.length }) }}</span>
+        <button type="button" class="sw-btn small ghost" @click="copyAll">⧉ {{ t('Copy') }}</button>
+        <transition name="lv-flash">
+          <span v-if="copied" class="lv-copied">{{ t('copied') }}</span>
+        </transition>
+      </div>
+      <pre class="lv-full mono">{{ value }}</pre>
+    </div>
+  </Modal>
+</template>
+
+<style scoped>
+.lv { display: inline; min-width: 0; }
+.lv-text { overflow-wrap: anywhere; }
+.lv-ellipsis { color: var(--sw-fg-3); }
+.lv-more {
+  margin-left: 6px;
+  padding: 0 5px;
+  height: 15px;
+  border: 1px solid var(--sw-line-2);
+  border-radius: 3px;
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-2);
+  font: inherit;
+  font-size: 9.5px;
+  line-height: 13px;
+  cursor: pointer;
+  vertical-align: baseline;
+}
+.lv-more:hover { border-color: var(--sw-accent); color: var(--sw-accent); }
+.lv-dialog { display: flex; flex-direction: column; gap: 8px; }
+.lv-dialog-bar { display: flex; align-items: center; gap: 8px; }
+.lv-size { font-size: 10.5px; color: var(--sw-fg-3); margin-right: auto; }
+.lv-copied { font-size: 10px; color: var(--sw-accent); border: 1px solid var(--sw-accent); border-radius: 3px; padding: 0 5px; }
+.lv-flash-enter-active, .lv-flash-leave-active { transition: opacity 0.2s; }
+.lv-flash-enter-from, .lv-flash-leave-to { opacity: 0; }
+.lv-full {
+  margin: 0;
+  padding: 10px 12px;
+  max-height: min(60vh, 520px);
+  overflow: auto;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line-1);
+  border-radius: 6px;
+  font-size: 11.5px;
+  line-height: 1.5;
+  color: var(--sw-fg-0);
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+.mono { font-family: var(--sw-mono); }
+</style>
diff --git a/apps/ui/src/components/primitives/SpanKindGlyph.vue b/apps/ui/src/components/primitives/SpanKindGlyph.vue
new file mode 100644
index 00000000..26162a30
--- /dev/null
+++ b/apps/ui/src/components/primitives/SpanKindGlyph.vue
@@ -0,0 +1,71 @@
+<!--
+  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.
+-->
+<!--
+  The glyph for a span's direction: into a service, out of it, or neither.
+
+  It takes a FAMILY, not a span — `entry`, `exit`, `local`, `producer`,
+  `consumer` — so it knows nothing about which trace protocol named it, and the
+  native and TraceQL waterfalls can draw the same shapes without sharing a span
+  model. Colour comes from the caller through `currentColor`.
+-->
+<script setup lang="ts">
+withDefaults(
+  defineProps<{
+    family: 'entry' | 'exit' | 'local' | 'producer' | 'consumer' | 'other';
+    /** Accessible name — the caller knows what vocabulary to say it in. */
+    label?: string;
+  }>(),
+  { label: '' },
+);
+</script>
+
+<template>
+  <svg class="kind-glyph" viewBox="0 0 14 14" :aria-label="label" :title="label">
+    <template v-if="family === 'entry'">
+      <path d="M1 7 L9 7 M6 4 L9 7 L6 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
+      <line x1="11.5" y1="3" x2="11.5" y2="11" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
+    </template>
+    <template v-else-if="family === 'exit'">
+      <line x1="2.5" y1="3" x2="2.5" y2="11" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
+      <path d="M5 7 L13 7 M10 4 L13 7 L10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
+    </template>
+    <template v-else-if="family === 'local'">
+      <circle cx="7" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="1.6" />
+      <circle cx="7" cy="7" r="1.4" fill="currentColor" />
+    </template>
+    <template v-else-if="family === 'producer'">
+      <path d="M2 4 L11 4 L11 9 L7 9 L4 11.5 L4 9 L2 9 Z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
+      <path d="M9 7 L12.5 7 M11 5.5 L12.5 7 L11 8.5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
+    </template>
+    <template v-else-if="family === 'consumer'">
+      <path d="M12 4 L3 4 L3 9 L7 9 L10 11.5 L10 9 L12 9 Z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
+      <path d="M5 7 L1.5 7 M3 5.5 L1.5 7 L3 8.5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
+    </template>
+    <template v-else>
+      <rect x="2.5" y="2.5" width="9" height="9" rx="1.6" fill="none" stroke="currentColor" stroke-width="1.4" />
+      <circle cx="7" cy="7" r="1.6" fill="currentColor" />
+    </template>
+  </svg>
+</template>
+
+<style scoped>
+.kind-glyph {
+  width: 14px;
+  height: 14px;
+  flex: 0 0 auto;
+}
+</style>
diff --git a/apps/ui/src/components/primitives/TagInput.vue b/apps/ui/src/components/primitives/TagInput.vue
index 9809bdac..361fc186 100644
--- a/apps/ui/src/components/primitives/TagInput.vue
+++ b/apps/ui/src/components/primitives/TagInput.vue
@@ -47,10 +47,53 @@ const emit = defineEmits<{
   commit: [];
 }>();
 
+const root = ref<HTMLElement | null>(null);
 const inputEl = ref<HTMLInputElement | null>(null);
 const open = ref(false);
 const activeIdx = ref(-1);
 
+/**
+ * The panel renders in a Teleport, positioned from the input's viewport rect,
+ * as {@link TypeaheadSelect} does and for the same reason: an absolutely
+ * positioned panel is cropped by any ancestor that clips, and every host here
+ * puts this field inside an `.sw-card`, which does. Half the suggestion list
+ * was cut off by the card's edge with no way to reach the rest.
+ */
+const panelStyle = ref<Record<string, string>>({});
+const GAP = 3;
+const EDGE = 8;
+
+function place(): void {
+  const el = inputEl.value;
+  if (!el) return;
+  const r = el.getBoundingClientRect();
+  const below = window.innerHeight - r.bottom - GAP - EDGE;
+  const above = r.top - GAP - EDGE;
+  // Flip up only when below cannot hold a usable list AND above is roomier,
+  // or the panel jumps as the list filters.
+  const up = below < 140 && above > below;
+  panelStyle.value = {
+    left: `${Math.max(EDGE, Math.min(r.left, window.innerWidth - r.width - EDGE))}px`,
+    width: `${r.width}px`,
+    maxHeight: `${Math.max(120, Math.min(300, up ? above : below))}px`,
+    ...(up ? { bottom: `${window.innerHeight - r.top + GAP}px` } : { top: `${r.bottom + GAP}px` }),
+  };
+}
+
+/** Re-measure on ANY ancestor scroll (capture), not just the window's: the
+ *  input moves with the container it sits in, and a panel pinned to stale
+ *  coordinates detaches from it. */
+function trackInput(on: boolean): void {
+  const fn = on ? window.addEventListener : window.removeEventListener;
+  fn('scroll', place, true);
+  fn('resize', place);
+}
+watch(open, (v) => {
+  trackInput(v);
+  if (v) void nextTick(place);
+});
+onBeforeUnmount(() => trackInput(false));
+
 // Effective suggest window — hosts may pass a custom-range sentinel (-1);
 // fall back to a sane default so the suggest call still hits real data.
 const winMinutes = computed<number>(() =>
@@ -136,6 +179,9 @@ const suggestions = computed<string[]>(() => {
     .map((v) => `${key}=${v}`);
 });
 
+// A list that grew or shrank changes the panel's height, so its placement is
+// re-measured — it is positioned from the input, not laid out beside it.
+watch(() => suggestions.value.length, () => { if (open.value) void nextTick(place); });
 watch(suggestions, () => {
   activeIdx.value = -1;
 });
@@ -213,7 +259,7 @@ onBeforeUnmount(() => {
 </script>
 
 <template>
-  <div class="tgi">
+  <div ref="root" class="tgi">
     <input
       ref="inputEl"
       class="tgi__input mono"
@@ -228,22 +274,24 @@ onBeforeUnmount(() => {
       @blur="onBlur"
       @keydown="onKeydown"
     />
-    <div v-if="open && suggestions.length > 0" class="tgi__panel">
-      <ul class="tgi__list" role="listbox">
-        <li
-          v-for="(s, i) in suggestions"
-          :key="s"
-          class="tgi__row"
-          role="option"
-          :aria-selected="i === activeIdx"
-          :class="{ 'is-active': i === activeIdx }"
-          @mouseenter="activeIdx = i"
-          @mousedown.prevent="applySuggestion(s)"
-        >
-          <span class="tgi__row-label">{{ s }}</span>
-        </li>
-      </ul>
-    </div>
+    <Teleport to="body">
+      <div v-if="open && suggestions.length > 0" class="tgi__panel" :style="panelStyle">
+        <ul class="tgi__list" role="listbox">
+          <li
+            v-for="(s, i) in suggestions"
+            :key="s"
+            class="tgi__row"
+            role="option"
+            :aria-selected="i === activeIdx"
+            :class="{ 'is-active': i === activeIdx }"
+            @mouseenter="activeIdx = i"
+            @mousedown.prevent="applySuggestion(s)"
+          >
+            <span class="tgi__row-label">{{ s }}</span>
+          </li>
+        </ul>
+      </div>
+    </Teleport>
   </div>
 </template>
 
@@ -261,11 +309,14 @@ onBeforeUnmount(() => {
 .tgi__input.mono { font-family: var(--sw-mono); }
 .tgi__input:focus { outline: none; border-color: var(--sw-accent); }
 
+/* Fixed, in `body`: the coordinates come from `place()` because the panel no
+   longer sits next to the input in the DOM. The z-index clears the cards and
+   the sticky toolbars it now draws over, and stays under the modal layer. */
 .tgi__panel {
-  position: absolute; top: calc(100% + 3px); left: 0; right: 0; z-index: 60;
+  position: fixed; z-index: 1200;
   background: var(--sw-bg-1); border: 1px solid var(--sw-line-2); border-radius: 5px;
   box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
-  max-height: 300px; overflow-y: auto;
+  overflow-y: auto;
 }
 .tgi__list { list-style: none; margin: 0; padding: 5px; }
 .tgi__row {
diff --git a/apps/ui/src/components/primitives/TimeRangeMenu.vue b/apps/ui/src/components/primitives/TimeRangeMenu.vue
index 0cea9113..0f4f111a 100644
--- a/apps/ui/src/components/primitives/TimeRangeMenu.vue
+++ b/apps/ui/src/components/primitives/TimeRangeMenu.vue
@@ -31,6 +31,7 @@
 -->
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue';
+import DateTimeField from './DateTimeField.vue';
 import { useI18n } from 'vue-i18n';
 import {
   TIME_PRESETS,
@@ -238,17 +239,15 @@ function submitCustom(step: TimeStep): void {
         <template v-for="side in (['start', 'end'] as const)" :key="side">
           <label class="tr-custom-field">
             <span>{{ side === 'start' ? t('Start') : t('End') }}</span>
-            <input
+            <DateTimeField
               v-if="activeStepTab === 'MINUTE'"
               v-model="customDraft[`${activeStepTab}-${side}`]"
-              type="datetime-local"
-              step="60"
               class="tr-custom-input"
             />
             <div v-else-if="activeStepTab === 'HOUR'" class="tr-custom-split">
-              <input
+              <DateTimeField
                 v-model="customDraft[`${activeStepTab}-${side}-date`]"
-                type="date"
+                mode="date"
                 class="tr-custom-input"
               />
               <select
@@ -260,10 +259,10 @@ function submitCustom(step: TimeStep): void {
                 </option>
               </select>
             </div>
-            <input
+            <DateTimeField
               v-else
               v-model="customDraft[`${activeStepTab}-${side}`]"
-              type="date"
+              mode="date"
               class="tr-custom-input"
             />
           </label>
diff --git a/apps/ui/src/features/admin/audit/AuditView.vue b/apps/ui/src/features/admin/audit/AuditView.vue
index 9f606d24..c5ae44b2 100644
--- a/apps/ui/src/features/admin/audit/AuditView.vue
+++ b/apps/ui/src/features/admin/audit/AuditView.vue
@@ -24,6 +24,7 @@
  * unread summary asks the operator to guess what to filter for.
  */
 import { computed, onMounted, ref } from 'vue';
+import DateTimeField from '@/components/primitives/DateTimeField.vue';
 import { useI18n } from 'vue-i18n';
 import type { AuditKind } from '@/api/scopes/admin-audit';
 import AuditList from './AuditList.vue';
@@ -141,9 +142,9 @@ onMounted(() => void page.refresh());
           <span>{{ t('Time range') }}</span>
           <template v-if="isCustomRange">
             <div class="cf-range">
-              <input v-model="page.filters.value.customStart" type="datetime-local" class="cf-input cf-range-num" />
+              <DateTimeField v-model="page.filters.value.customStart" class="cf-range-num" />
               <span class="cf-range-sep">–</span>
-              <input v-model="page.filters.value.customEnd" type="datetime-local" class="cf-input cf-range-num" />
+              <DateTimeField v-model="page.filters.value.customEnd" class="cf-range-num" />
               <button
                 class="sw-btn small ghost"
                 type="button"
diff --git a/apps/ui/src/features/admin/audit/TokenUsageList.vue b/apps/ui/src/features/admin/audit/TokenUsageList.vue
index 63b76ced..e16def8b 100644
--- a/apps/ui/src/features/admin/audit/TokenUsageList.vue
+++ b/apps/ui/src/features/admin/audit/TokenUsageList.vue
@@ -25,6 +25,7 @@
  * busiest, so a truncated list never disagrees with its own total.
  */
 import { computed } from 'vue';
+import DateTimeField from '@/components/primitives/DateTimeField.vue';
 import { useI18n } from 'vue-i18n';
 import { MAX_TOKEN_USAGE_HOURS, type TokenUsageHour } from '@/api/scopes/admin-audit';
 import { TOKEN_PRESETS, CUSTOM_RANGE_SENTINEL } from './useTokenUsagePage';
@@ -114,16 +115,16 @@ function share(total: number): string {
         <span>{{ t('Time range') }}</span>
         <template v-if="isCustom">
           <div class="cf-range">
-            <input
-              :value="props.customStart" type="datetime-local"
-              class="cf-input cf-range-num" :title="t('Snaps to a group boundary — a group is one hour')"
-              @input="emit('update:customStart', ($event.target as HTMLInputElement).value)"
+            <DateTimeField
+              :model-value="props.customStart"
+              class="cf-range-num" :title="t('Snaps to a group boundary — a group is one hour')"
+              @update:model-value="emit('update:customStart', $event)"
             />
             <span class="cf-range-sep">–</span>
-            <input
-              :value="props.customEnd" type="datetime-local"
-              class="cf-input cf-range-num" :title="t('Snaps to a group boundary — a group is one hour')"
-              @input="emit('update:customEnd', ($event.target as HTMLInputElement).value)"
+            <DateTimeField
+              :model-value="props.customEnd"
+              class="cf-range-num" :title="t('Snaps to a group boundary — a group is one hour')"
+              @update:model-value="emit('update:customEnd', $event)"
             />
             <button class="sw-btn small primary" type="button" @click="emit('apply')">{{ t('Query') }}</button>
             <button
diff --git a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index 3dc606a8..03d019b8 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -32,7 +32,7 @@
 <script setup lang="ts">
 import { computed, ref, watch, nextTick } from 'vue';
 import type { AdminLayerTemplate } from '@/api/client';
-import type { DashboardWidget, InstanceAttributePredicate } from '@skywalking-horizon-ui/api-client';
+import type { DashboardWidget, InstanceAttributePredicate, TracesConfig } from '@skywalking-horizon-ui/api-client';
 
 import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue';
 import Modal from '@/components/primitives/Modal.vue';
@@ -42,6 +42,7 @@ import ProcessConfigEditor from './ProcessConfigEditor.vue';
 import TopologyConfigEditor from './TopologyConfigEditor.vue';
 import DeploymentConfigEditor from './DeploymentConfigEditor.vue';
 import LayerSetupEditor from './LayerSetupEditor.vue';
+import TraceStoresEditor from './TraceStoresEditor.vue';
 import ServiceListMetricsEditor from './ServiceListMetricsEditor.vue';
 import LayerBrowseRail from './LayerBrowseRail.vue';
 import LayerHeaderBar from './LayerHeaderBar.vue';
@@ -344,30 +345,16 @@ function widgetsFor(scope: AdminScope): DashboardWidget[] {
 const instanceTopologyEnabled = computed(() => !!draft.template?.topology?.instanceTopology);
 
 
-/* Trace backend selector. `traces.source` decides which trace store the
- * per-layer Trace tab dispatches to: `native` (SkyWalking query-protocol),
- * `zipkin` (Envoy ALS / rover spans), or `both` (parallel tables). The
- * field IS live — `LayerTracesEntry` reads `layer.traces.source` at
- * runtime — so it belongs in the config UI. Default `both` when unset. */
-type TraceSource = 'native' | 'zipkin' | 'both';
-const traceSource = computed<TraceSource>({
-  get: () => draft.template?.traces?.source ?? 'both',
-  set: (v: TraceSource) => {
-    if (readOnly.value || !draft.template) return;
-    if (draft.template.traces) draft.template.traces.source = v;
-    else draft.template.traces = { source: v };
-  },
-});
-const TRACE_SOURCE_OPTIONS = computed<Array<{ value: TraceSource; label: string; hint: string }>>(() => [
-  { value: 'native', label: t('Native'), hint: t('SkyWalking query-protocol traces (agent-instrumented).') },
-  { value: 'zipkin', label: t('OpenTelemetry & Zipkin'), hint: t('Traces emitted from the OpenTelemetry & Zipkin ecosystem.') },
-  { value: 'both', label: t('Both'), hint: t('Layer carries both native and OpenTelemetry/Zipkin traces — their span formats and query conditions differ, so each gets its own trace tab.') },
-]);
-
-/* Logs has no per-layer config beyond the enable/disable Components
- * toggle. Trace carries one setting — `traces.source` (native / zipkin /
- * both), edited via `traceSource` above — which the per-layer Trace tab
- * honors at runtime to pick the trace backend. */
+/* The Trace tab's own config: which trace stores this layer exposes, what each
+ * row is called, and the service filter for the TraceQL rows. It replaced the
+ * single-source radio, because a layer now has as many trace rows as it names
+ * stores. Absent reads as the native store, and the editor writes an explicit
+ * list from the first edit on — see `TraceStoresEditor`. */
+function setTraces(v: TracesConfig | undefined): void {
+  if (readOnly.value || !draft.template) return;
+  if (v) draft.template.traces = v;
+  else delete draft.template.traces;
+}
 
 /** Menu-preview click: focus the component's scope (if surfaced) and
  *  scroll the scope editor into view so config + preview follow the
@@ -757,33 +744,17 @@ const namingTest = computed<NamingTestResult>(() => {
             <h4>{{ t('{scope} tab', { scope: scopeLabel(activeScope) }) }}</h4>
             <span class="sub">
               {{ activeScope === 'trace'
-                ? t('Pick the trace backend this layer reads from.')
+                ? t('which trace APIs this layer exposes — one sidebar row each')
                 : t('No per-layer config required — toggle visibility via Components in the right sidebar.') }}
             </span>
           </div>
           <div class="topo-cfg-body">
-            <div v-if="activeScope === 'trace'" class="trace-source-cfg">
-              <div class="trace-source-head">{{ t('Trace source') }}</div>
-              <div class="trace-source-opts">
-                <label
-                  v-for="o in TRACE_SOURCE_OPTIONS"
-                  :key="o.value"
-                  class="trace-source-opt"
-                  :class="{ on: traceSource === o.value }"
-                >
-                  <input
-                    type="radio"
-                    name="trace-source"
-                    :value="o.value"
-                    :checked="traceSource === o.value"
-                    :disabled="readOnly"
-                    @change="traceSource = o.value"
-                  />
-                  <span class="ts-label">{{ o.label }}</span>
-                  <span class="ts-hint">{{ o.hint }}</span>
-                </label>
-              </div>
-            </div>
+            <TraceStoresEditor
+              v-if="activeScope === 'trace'"
+              :traces="draft.template?.traces"
+              :read-only="readOnly"
+              @update="setTraces"
+            />
             <i18n-t v-else keypath="The {tab} tab is a built-in view that uses SkyWalking-native query-protocol APIs directly. Operators configure filters and time range at runtime from the page itself; nothing to wire up here." tag="p" class="topo-cfg-help" scope="global">
               <template #tab><b>{{ scopeLabel(activeScope) }}</b></template>
             </i18n-t>
@@ -1061,30 +1032,6 @@ const namingTest = computed<NamingTestResult>(() => {
   color: var(--sw-fg-3);
   line-height: 1.5;
 }
-.trace-source-cfg { display: flex; flex-direction: column; gap: 8px; }
-.trace-source-head {
-  font-size: 10px;
-  font-weight: 600;
-  letter-spacing: 0.06em;
-  text-transform: uppercase;
-  color: var(--sw-fg-3);
-}
-.trace-source-opts { display: flex; flex-direction: column; gap: 6px; }
-.trace-source-opt {
-  display: grid;
-  grid-template-columns: 16px 64px 1fr;
-  align-items: center;
-  gap: 8px;
-  padding: 8px 10px;
-  border: 1px solid var(--sw-line);
-  border-radius: 4px;
-  background: var(--sw-bg-1);
-  cursor: pointer;
-  font-size: 11.5px;
-}
-.trace-source-opt.on { border-color: var(--sw-accent); background: var(--sw-bg-2); }
-.trace-source-opt .ts-label { font-weight: 600; color: var(--sw-fg-0); }
-.trace-source-opt .ts-hint { color: var(--sw-fg-3); }
 .topo-cfg-help code {
   font-family: var(--sw-mono);
   color: var(--sw-fg-1);
diff --git a/apps/ui/src/features/admin/layer-templates/LayerSetupEditor.vue b/apps/ui/src/features/admin/layer-templates/LayerSetupEditor.vue
index 97490857..8256a2ee 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerSetupEditor.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerSetupEditor.vue
@@ -36,7 +36,7 @@ import { useI18n } from 'vue-i18n';
 import { isBuiltInOrder, pruneMenuOrder } from './menuOrder';
 import type { AdminLayerTemplate } from '@/api/client';
 import type { AdminScope, ComponentKey, SlotKey } from './layer-dashboards.scopes';
-import { resolveLayerMenuRows } from '@skywalking-horizon-ui/api-client';
+import { resolveLayerMenuRows, type TraceStore } from '@skywalking-horizon-ui/api-client';
 import { componentsToCaps } from '@/shell/layerFromTemplate';
 
 const { t } = useI18n({ useScope: 'global' });
@@ -255,8 +255,10 @@ function labelForRow(path: string): string {
     case 'topology': return slots.topology || t('Topology');
     case 'deployment': return slots.deployment || t('Deployment');
     case 'dependency': return slots.endpointDependency || t('Dependency');
-    case 'trace': return t('Traces');
-    case 'zipkin-trace': return t('OTel & Zipkin Traces');
+    case 'trace': return storeName('native') || t('Traces');
+    case 'zipkin-trace': return storeName('zipkin') || t('Zipkin Traces');
+    case 'traceql-native-trace': return storeName('traceql-native') || t('TraceQL - Native');
+    case 'traceql-zipkin-trace': return storeName('traceql-zipkin') || t('TraceQL - Zipkin');
     case 'logs': return t('Logs');
     case 'browser-errors': return t('Browser Logs');
     case 'pod-logs': return t('Pod Logs');
@@ -270,6 +272,12 @@ function labelForRow(path: string): string {
   }
 }
 
+/** A trace row's name as the template overrides it — the preview shows what
+ *  the sidebar will, not the built-in label it is replacing. */
+function storeName(store: TraceStore): string {
+  return template.value.traces?.stores?.[store]?.name?.trim() ?? '';
+}
+
 /** A stored arrangement exists. Independent of whether dragging is on:
  *  closing the drag mode must not throw the arrangement away. */
 const customOrder = computed(() => Array.isArray(template.value.menuOrder));
diff --git a/apps/ui/src/features/admin/layer-templates/TraceStoresEditor.test.ts b/apps/ui/src/features/admin/layer-templates/TraceStoresEditor.test.ts
new file mode 100644
index 00000000..bb870910
--- /dev/null
+++ b/apps/ui/src/features/admin/layer-templates/TraceStoresEditor.test.ts
@@ -0,0 +1,142 @@
+/*
+ * 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.
+ */
+
+/**
+ * What the trace-store checklist WRITES, which is the half a rendered page
+ * cannot show. Three shapes matter to the push route and to the sidebar: an
+ * empty checklist must leave no `traces` block at all, unchecking a store must
+ * take its settings with it, and the legacy `source` enum must not survive an
+ * edit — a template carrying both would have the checklist silently win.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { mount, type VueWrapper } from '@vue/test-utils';
+import type { TracesConfig } from '@skywalking-horizon-ui/api-client';
+import { i18n } from '@/i18n';
+import TraceStoresEditor from './TraceStoresEditor.vue';
+
+function mountEditor(traces?: TracesConfig): VueWrapper {
+  return mount(TraceStoresEditor, { props: { traces }, global: { plugins: [i18n] } });
+}
+
+/** Rows are in `TRACE_STORES` order: native, zipkin, traceql-native, traceql-zipkin. */
+const ROW = { native: 0, zipkin: 1, traceqlNative: 2, traceqlZipkin: 3 };
+
+const toggle = (w: VueWrapper, row: number) =>
+  w.findAll('.ts-item')[row]!.get('input[type="checkbox"]').trigger('change');
+const field = (w: VueWrapper, row: number, i: number) =>
+  w.findAll('.ts-item')[row]!.findAll('input[type="text"]')[i]!;
+/** The last config the editor emitted. */
+const emitted = (w: VueWrapper): TracesConfig | undefined => {
+  const events = w.emitted('update') as Array<[TracesConfig | undefined]> | undefined;
+  return events?.[events.length - 1]?.[0];
+};
+
+describe('trace stores editor', () => {
+  it('checks a store on and off', async () => {
+    const w = mountEditor({ sources: ['zipkin'] });
+    await toggle(w, ROW.traceqlNative);
+    expect(emitted(w)).toEqual({ sources: ['zipkin', 'traceql-native'] });
+
+    const w2 = mountEditor({ sources: ['traceql-native'] });
+    await toggle(w2, ROW.traceqlNative);
+    // An empty LIST, not an absent block: absence is compatibility — it reads
+    // as the native store — so it cannot also mean "none".
+    expect(emitted(w2)).toEqual({ sources: [] });
+  });
+
+  it('writes a row name and a service filter for the store they belong to', async () => {
+    const w = mountEditor({ sources: ['traceql-native'] });
+    await field(w, ROW.traceqlNative, 0).setValue('OTLP traces');
+    expect(emitted(w)).toEqual({
+      sources: ['traceql-native'],
+      stores: { 'traceql-native': { name: 'OTLP traces' } },
+    });
+  });
+
+  it('keeps the case flag beside the pattern', async () => {
+    const w = mountEditor({ sources: ['traceql-native'] });
+    await field(w, ROW.traceqlNative, 1).setValue('^agent::');
+    // The editor is controlled — the parent writes each emitted config straight
+    // back onto the draft — so a second edit must read the first one back.
+    await w.setProps({ traces: emitted(w) });
+    // `i` is the one flag offered, as a checkbox: the others are meaningless on
+    // a service name or carry state between tests.
+    await w.findAll('.ts-item')[ROW.traceqlNative]!.get('.ts-field.check input').setValue(true);
+    expect(emitted(w)?.stores?.['traceql-native']?.serviceFilter).toEqual({
+      pattern: '^agent::',
+      flags: 'i',
+    });
+  });
+
+  it('opens on what the layer resolves to, and writes it out on the first edit', async () => {
+    // A template written before the checklist: silence means the native store,
+    // and the legacy enum means the store it named. Both must be TICKED, or an
+    // operator adding one store would silently drop the one already in effect.
+    const silent = mountEditor(undefined);
+    expect((silent.findAll('.ts-item')[ROW.native]!.get('input[type="checkbox"]').element as HTMLInputElement).checked).toBe(true);
+    await toggle(silent, ROW.traceqlNative);
+    expect(emitted(silent)?.sources).toEqual(['native', 'traceql-native']);
+
+    const legacy = mountEditor({ source: 'zipkin' });
+    expect((legacy.findAll('.ts-item')[ROW.zipkin]!.get('input[type="checkbox"]').element as HTMLInputElement).checked).toBe(true);
+    await toggle(legacy, ROW.traceqlZipkin);
+    expect(emitted(legacy)?.sources).toEqual(['zipkin', 'traceql-zipkin']);
+    expect(emitted(legacy)?.source).toBeUndefined();
+  });
+
+  it('says none as an empty list, because an absent block means native', async () => {
+    const w = mountEditor({ sources: ['native'] });
+    await toggle(w, ROW.native);
+    expect(emitted(w)).toEqual({ sources: [] });
+  });
+
+  it('drops a store’s settings when the store is unchecked', async () => {
+    const w = mountEditor({
+      sources: ['native', 'traceql-native'],
+      stores: { 'traceql-native': { name: 'OTLP', serviceFilter: { pattern: '^a' } } },
+    });
+    await toggle(w, ROW.traceqlNative);
+    // Settings for a row nobody can see are settings nobody remembers writing.
+    expect(emitted(w)).toEqual({ sources: ['native'] });
+  });
+
+  it('replaces the legacy enum rather than editing alongside it', async () => {
+    const w = mountEditor({ source: 'both' });
+    await toggle(w, ROW.zipkin);
+    const next = emitted(w);
+    expect(next?.sources).toEqual(['native']);
+    expect(next?.source).toBeUndefined();
+  });
+
+  it('says so when the pattern does not compile, and still stores it', async () => {
+    const w = mountEditor({ sources: ['traceql-zipkin'] });
+    await field(w, ROW.traceqlZipkin, 1).setValue('[unclosed');
+    await w.setProps({ traces: emitted(w) });
+    expect(w.find('.ts-warn').exists()).toBe(true);
+    // Refusing to store it would lose the operator's half-typed expression;
+    // the backend already treats a broken pattern as no filter.
+    expect(emitted(w)?.stores?.['traceql-zipkin']?.serviceFilter?.pattern).toBe('[unclosed');
+  });
+
+  it('offers a service filter only where one applies', () => {
+    const w = mountEditor({ sources: ['native', 'zipkin', 'traceql-native'] });
+    expect(w.findAll('.ts-item')[ROW.native]!.findAll('input[type="text"]')).toHaveLength(1);
+    expect(w.findAll('.ts-item')[ROW.zipkin]!.findAll('input[type="text"]')).toHaveLength(1);
+    expect(w.findAll('.ts-item')[ROW.traceqlNative]!.findAll('input[type="text"]')).toHaveLength(2);
+  });
+});
diff --git a/apps/ui/src/features/admin/layer-templates/TraceStoresEditor.vue b/apps/ui/src/features/admin/layer-templates/TraceStoresEditor.vue
new file mode 100644
index 00000000..c4b36a30
--- /dev/null
+++ b/apps/ui/src/features/admin/layer-templates/TraceStoresEditor.vue
@@ -0,0 +1,274 @@
+<!--
+  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.
+-->
+<!--
+  The layer's trace stores: which trace APIs it exposes, what each row is
+  called, and — for the TraceQL rows — which services its picker offers.
+
+  A checklist, not a picker: a layer can expose any combination, and each
+  checked store becomes its own sidebar row.
+
+  What is ticked when the page opens is what the layer RESOLVES to, not what its
+  template literally says — a template written before the checklist carries the
+  legacy `source` enum, or says nothing at all and falls back to the native
+  store. Showing that is what lets the operator change one box and save: the
+  first edit writes the resolved set out as an explicit `sources`, which then
+  overrides the fallback.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import {
+  backtracksBadly,
+  TRACE_STORES,
+  TRACE_STORE_ROWS,
+  resolveTraceStores,
+  type TraceStore,
+  type TraceStoreConfig,
+  type TracesConfig,
+} from '@skywalking-horizon-ui/api-client';
+
+const props = defineProps<{ traces?: TracesConfig; readOnly?: boolean }>();
+const emit = defineEmits<{ (e: 'update', value: TracesConfig | undefined): void }>();
+const { t } = useI18n({ useScope: 'global' });
+
+/** The TraceQL stores are the two that answer over Tempo's API, and the only
+ *  ones a service filter applies to — the others know their layer already. */
+const TRACEQL_STORES = new Set<TraceStore>(['traceql-native', 'traceql-zipkin']);
+
+const STORE_ROWS = computed(() =>
+  TRACE_STORES.map((store) => ({
+    store,
+    path: TRACE_STORE_ROWS[store],
+    label: defaultName(store),
+    hint: hintFor(store),
+    traceql: TRACEQL_STORES.has(store),
+  })),
+);
+
+function defaultName(store: TraceStore): string {
+  switch (store) {
+    case 'native': return t('Traces');
+    case 'zipkin': return t('Zipkin Traces');
+    case 'traceql-native': return t('TraceQL - Native');
+    case 'traceql-zipkin': return t('TraceQL - Zipkin');
+  }
+}
+
+function hintFor(store: TraceStore): string {
+  switch (store) {
+    case 'native': return t("SkyWalking's own trace query.");
+    case 'zipkin': return t('OAP’s Zipkin v2 API, which also serves the OpenTelemetry spans OAP converts into Zipkin form.');
+    case 'traceql-native': return t('Grafana Tempo’s API over the native spans. Needs OAP’s traceQL module and a configured datasource URL.');
+    case 'traceql-zipkin': return t('Grafana Tempo’s API over the Zipkin spans. Needs OAP’s traceQL module and a configured datasource URL.');
+  }
+}
+
+const checked = computed(() => new Set(resolveTraceStores(props.traces)));
+
+/** Every write goes through here, so the stored block never keeps settings for
+ *  a store the layer no longer exposes — configuration nobody can see is
+ *  configuration nobody remembers writing. Both fallbacks are resolved away on
+ *  the first edit: what was implied becomes an explicit `sources`, and the
+ *  legacy `source` enum is not written back. */
+function commit(sources: TraceStore[], stores: Partial<Record<TraceStore, TraceStoreConfig>>): void {
+  const kept: Partial<Record<TraceStore, TraceStoreConfig>> = {};
+  for (const s of sources) {
+    const cfg = stores[s];
+    if (cfg && (cfg.name || cfg.serviceFilter?.pattern)) kept[s] = cfg;
+  }
+  // An empty LIST, never an absent block: a template that says nothing about
+  // traces resolves to the native store for compatibility, so removing the
+  // block would tick native back on rather than turn everything off.
+  if (sources.length === 0 && Object.keys(kept).length === 0) {
+    emit('update', { sources: [] });
+    return;
+  }
+  emit('update', Object.keys(kept).length > 0 ? { sources, stores: kept } : { sources });
+}
+
+function currentStores(): Partial<Record<TraceStore, TraceStoreConfig>> {
+  return { ...(props.traces?.stores ?? {}) };
+}
+
+function toggle(store: TraceStore): void {
+  if (props.readOnly) return;
+  const next = TRACE_STORES.filter((s) => (s === store ? !checked.value.has(s) : checked.value.has(s)));
+  commit(next, currentStores());
+}
+
+function setName(store: TraceStore, name: string): void {
+  if (props.readOnly) return;
+  const stores = currentStores();
+  const cfg = { ...(stores[store] ?? {}) };
+  if (name.trim()) cfg.name = name;
+  else delete cfg.name;
+  stores[store] = cfg;
+  commit([...checked.value], stores);
+}
+
+function setFilter(store: TraceStore, part: 'pattern' | 'flags', value: string): void {
+  if (props.readOnly) return;
+  const stores = currentStores();
+  const cfg = { ...(stores[store] ?? {}) };
+  const filter = { ...(cfg.serviceFilter ?? { pattern: '' }), [part]: value };
+  if (filter.pattern.trim()) cfg.serviceFilter = filter;
+  else delete cfg.serviceFilter;
+  stores[store] = cfg;
+  commit([...checked.value], stores);
+}
+
+/** The only regular-expression flag worth offering for a service name. The
+ *  others are meaningless on one line (`m`, `s`) or change what the pattern
+ *  matches under the operator (`g`, `y` carry state between tests). */
+function ignoresCase(store: TraceStore): boolean {
+  return (filterOf(store).flags ?? '').includes('i');
+}
+function setIgnoreCase(store: TraceStore, on: boolean): void {
+  setFilter(store, 'flags', on ? 'i' : '');
+}
+
+function nameOf(store: TraceStore): string {
+  return props.traces?.stores?.[store]?.name ?? '';
+}
+function filterOf(store: TraceStore): { pattern: string; flags?: string } {
+  return props.traces?.stores?.[store]?.serviceFilter ?? { pattern: '' };
+}
+/** A pattern Horizon will not run filters NOTHING rather than emptying the
+ *  picker, so the operator is told here rather than left wondering — whether it
+ *  does not compile or is refused for backtracking. */
+function filterInvalid(store: TraceStore): boolean {
+  const f = filterOf(store);
+  if (!f.pattern.trim()) return false;
+  if (backtracksBadly(f.pattern)) return true;
+  try {
+    new RegExp(f.pattern, f.flags ?? '');
+    return false;
+  } catch {
+    return true;
+  }
+}
+</script>
+
+<template>
+  <div class="ts-list">
+    <div v-for="row in STORE_ROWS" :key="row.store" class="ts-item" :class="{ on: checked.has(row.store) }">
+      <label class="ts-head">
+        <input
+          type="checkbox"
+          :checked="checked.has(row.store)"
+          :disabled="readOnly"
+          @change="toggle(row.store)"
+        />
+        <span class="ts-name">{{ row.label }}</span>
+        <code class="ts-path">{{ row.path }}</code>
+      </label>
+      <p class="ts-hint">{{ row.hint }}</p>
+      <div v-if="checked.has(row.store)" class="ts-fields">
+        <label class="ts-field">
+          <span>{{ t('Menu name') }}</span>
+          <input
+            type="text"
+            class="alias-input sm"
+            :value="nameOf(row.store)"
+            :placeholder="row.label"
+            :disabled="readOnly"
+            spellcheck="false"
+            @input="setName(row.store, ($event.target as HTMLInputElement).value)"
+          />
+        </label>
+        <template v-if="row.traceql">
+          <label class="ts-field">
+            <span>{{ t('Service filter') }}</span>
+            <input
+              type="text"
+              class="alias-input sm mono"
+              :value="filterOf(row.store).pattern"
+              placeholder="^agent::"
+              :disabled="readOnly"
+              spellcheck="false"
+              @input="setFilter(row.store, 'pattern', ($event.target as HTMLInputElement).value)"
+            />
+          </label>
+          <label class="ts-field check">
+            <span>{{ t('Ignore case') }}</span>
+            <input
+              type="checkbox"
+              :checked="ignoresCase(row.store)"
+              :disabled="readOnly"
+              @change="setIgnoreCase(row.store, ($event.target as HTMLInputElement).checked)"
+            />
+          </label>
+        </template>
+      </div>
+      <p v-if="row.traceql && checked.has(row.store)" class="ts-hint sub">
+        {{ t('The Tempo API has no notion of a layer, so it lists every service of the store. This pattern narrows the service PICKER only — result rows are never filtered, so cross-service traces still appear.') }}
+      </p>
+      <p v-if="filterInvalid(row.store)" class="ts-warn">
+        {{ t('Horizon will not run this expression, so the picker keeps showing every service. It either does not compile, or it can backtrack exponentially — avoid a repeat applied to a group that itself repeats or branches, such as (a+)+.') }}
+      </p>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.ts-list { display: flex; flex-direction: column; gap: 6px; }
+.ts-item {
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  padding: 8px 10px;
+  background: var(--sw-bg-1);
+}
+.ts-item.on { border-color: var(--sw-accent); background: var(--sw-bg-2); }
+.ts-head { display: flex; align-items: center; gap: 8px; cursor: pointer; }
+.ts-name { font-size: 12.5px; color: var(--sw-fg-0); }
+.ts-path {
+  font-family: var(--sw-mono);
+  font-size: 10px;
+  color: var(--sw-fg-3);
+  background: var(--sw-bg-2);
+  border-radius: 3px;
+  padding: 0 4px;
+}
+.ts-hint { margin: 3px 0 0 22px; font-size: 10.5px; color: var(--sw-fg-3); line-height: 1.4; }
+.ts-hint.sub { margin-top: 5px; }
+.ts-warn { margin: 5px 0 0 22px; font-size: 10.5px; color: var(--sw-warn); line-height: 1.4; }
+.ts-fields { display: flex; flex-wrap: wrap; gap: 8px; margin: 7px 0 0 22px; }
+.ts-field {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+  color: var(--sw-fg-3);
+  min-width: 170px;
+}
+.ts-field.check { min-width: 0; flex-direction: row; align-items: center; gap: 6px; }
+.alias-input {
+  height: 28px;
+  padding: 0 10px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 5px;
+  color: var(--sw-fg-0);
+  font: inherit;
+  font-size: 12px;
+  width: 100%;
+}
+.alias-input:focus { outline: none; border-color: var(--sw-accent); }
+.alias-input.mono { font-family: var(--sw-mono); }
+</style>
diff --git a/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue b/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue
index 716fda4f..9f7eece3 100644
--- a/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue
+++ b/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue
@@ -38,7 +38,7 @@ import type {
   DashboardWidget,
   DashboardTab,
 } from '@skywalking-horizon-ui/api-client';
-import { collectWidgetIds } from '@skywalking-horizon-ui/api-client';
+import { tracesInclude, collectWidgetIds } from '@skywalking-horizon-ui/api-client';
 import { useExtPages } from './useExtPages';
 import { useCanvasResize } from './useCanvasResize';
 import { useCanvasReorder } from './useCanvasReorder';
@@ -410,9 +410,7 @@ function setWidgetTraceDrill(v: string): void {
 }
 const layerTracesEnabled = computed<boolean>(() => {
   const tpl = props.draft.template;
-  if (!tpl?.components?.traces) return false;
-  const src = tpl.traces?.source ?? 'native';
-  return src === 'native' || src === 'both';
+  return Boolean(tpl?.components?.traces) && tracesInclude(tpl?.traces, 'native');
 });
 
 // `format: 'enum'` value→label editor — the valueMap is a coded-value → label
diff --git a/apps/ui/src/features/admin/layer-templates/menuOrderPrune.test.ts b/apps/ui/src/features/admin/layer-templates/menuOrderPrune.test.ts
index c098083b..6e9bdff4 100644
--- a/apps/ui/src/features/admin/layer-templates/menuOrderPrune.test.ts
+++ b/apps/ui/src/features/admin/layer-templates/menuOrderPrune.test.ts
@@ -55,12 +55,10 @@ describe('menu order after a component toggle', () => {
   it('drops BOTH rows when one component owns two', () => {
     // Traces owns `trace` and `zipkin-trace`. A per-component table that
     // mapped one row each would have left the second entry behind.
-    const on = { service: true, traces: true };
-    const withZipkin = { ...on, tracesSource: undefined };
     const rows = resolveLayerMenuRows({
-      caps: componentsToCaps(withZipkin),
+      caps: componentsToCaps({ service: true, traces: true }),
       slots: {},
-      traces: { source: 'both' },
+      traces: { sources: ['native', 'zipkin'] },
     }).map((r) => r.path);
     expect(rows).toEqual(expect.arrayContaining(['trace', 'zipkin-trace']));
 
diff --git a/apps/ui/src/features/alarms/AlarmWindowPicker.vue b/apps/ui/src/features/alarms/AlarmWindowPicker.vue
index 321a216e..800623f2 100644
--- a/apps/ui/src/features/alarms/AlarmWindowPicker.vue
+++ b/apps/ui/src/features/alarms/AlarmWindowPicker.vue
@@ -27,6 +27,7 @@
 <script setup lang="ts">
 import { useI18n } from 'vue-i18n';
 import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
+import DateTimeField from '@/components/primitives/DateTimeField.vue';
 import { PRESETS, MAX_CUSTOM_MS, type AlarmWindow } from './useAlarmWindow';
 
 const { t } = useI18n();
@@ -57,11 +58,11 @@ useEscapeToClose(() => w.customOpen.value, () => w.closeCustom());
   <div v-else-if="part === 'editor' && w.customOpen.value" class="ax__custom">
     <label class="ax__custom-field">
       <span>{{ t('Start') }}</span>
-      <input v-model="w.customStartInput.value" type="datetime-local" step="60" />
+      <DateTimeField v-model="w.customStartInput.value" />
     </label>
     <label class="ax__custom-field">
       <span>{{ t('End') }}</span>
-      <input v-model="w.customEndInput.value" type="datetime-local" step="60" />
+      <DateTimeField v-model="w.customEndInput.value" />
     </label>
     <div v-if="w.customError.value" class="ax__custom-err">{{ w.customError.value }}</div>
     <div class="ax__custom-actions">
diff --git a/apps/ui/src/features/events/EventsPopout.vue b/apps/ui/src/features/events/EventsPopout.vue
index bcee3d0f..205d1ced 100644
--- a/apps/ui/src/features/events/EventsPopout.vue
+++ b/apps/ui/src/features/events/EventsPopout.vue
@@ -25,6 +25,7 @@
 import { computed, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import Modal from '@/components/primitives/Modal.vue';
+import DateTimeField from '@/components/primitives/DateTimeField.vue';
 import EventsGantt from './EventsGantt.vue';
 import EventsDetailPanel from './EventsDetailPanel.vue';
 import { useEventsWindow, PRESETS, MAX_CUSTOM_MS } from './useEventsWindow';
@@ -97,11 +98,11 @@ function onClose(): void {
       <div v-if="win.customOpen.value" class="evtp__custom">
         <label class="evtp__custom-field">
           <span>{{ t('Start') }}</span>
-          <input v-model="win.customStartInput.value" type="datetime-local" step="60" />
+          <DateTimeField v-model="win.customStartInput.value" />
         </label>
         <label class="evtp__custom-field">
           <span>{{ t('End') }}</span>
-          <input v-model="win.customEndInput.value" type="datetime-local" step="60" />
+          <DateTimeField v-model="win.customEndInput.value" />
         </label>
         <div v-if="win.customError.value" class="evtp__custom-err">{{ win.customError.value }}</div>
         <div class="evtp__custom-actions">
diff --git a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
index b38d30e9..99051f88 100644
--- a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
+++ b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
@@ -17,21 +17,25 @@
 <script setup lang="ts">
 import { computed, ref, onMounted, onUnmounted } from 'vue';
 import { useI18n } from 'vue-i18n';
-import type { PreflightModule } from '@skywalking-horizon-ui/api-client';
+import { useQuery } from '@tanstack/vue-query';
+import type { PreflightModule, TraceQLSourceStatus } from '@skywalking-horizon-ui/api-client';
+import { bffClient } from '@/api/client';
 import { useOapInfo } from '@/shell/useOapInfo';
 import { useAdminFeatures } from '@/shell/useAdminFeatures';
 
-// Two-pane Cluster Status:
+// Three-pane Cluster Status:
 //   - Pane A (graphql / :12800): version, server clock, timezone,
 //     health score. Drives the global topbar status chip and the
 //     global "OAP unreachable" banner.
 //   - Pane B (admin host / :17128): preflight of SWIP-13 selectors
 //     (admin-server, receiver-runtime-rule, dsl-debugging, inspect).
 //     Drives the per-page warning header on admin-host routes.
-// The two halves are queried separately on purpose — a healthy
-// :12800 with a broken admin port is a real, recoverable state
-// (forgot to expose :17128 in the k8s service) and the page must
-// show that clearly.
+//   - Pane C (trace APIs): the Zipkin v2 endpoint and each TraceQL
+//     datasource, which feed the per-layer trace rows other than the
+//     native one.
+// The panes are queried separately on purpose — a healthy :12800 with
+// a broken admin port is a real, recoverable state (forgot to expose
+// :17128 in the k8s service) and the page must show that clearly.
 
 const { t } = useI18n({ useScope: 'global' });
 const {
@@ -52,6 +56,99 @@ const {
   recheck: refetchPreflight,
 } = useAdminFeatures();
 
+/**
+ * Pane D · OAP's TraceQL (Tempo API) service, one datasource per store.
+ *
+ * Read through the façade rather than the Traces tab's own composable — a
+ * feature does not reach into another's data — and answered by the same cached
+ * probe the tab uses, so this page and the tab cannot disagree. It needs
+ * `traces:read`; a role without it sees the pane as unknown rather than as an
+ * outage, because a permission is not a failure.
+ */
+const traceqlQuery = useQuery({
+  queryKey: ['traceql-sources'],
+  queryFn: ({ signal }) => bffClient.traceql.sources(signal),
+  staleTime: 60_000,
+  retry: false,
+});
+const TRACEQL_ROWS = computed<ReadonlyArray<{ ds: 'native' | 'zipkin'; label: string; path: string }>>(() => [
+  { ds: 'native', label: t('SkyWalking-native spans'), path: '/skywalking' },
+  { ds: 'zipkin', label: t('Zipkin spans'), path: '/zipkin' },
+]);
+const traceqlSources = computed<TraceQLSourceStatus[]>(() => traceqlQuery.data.value ?? []);
+const traceqlDenied = computed(() => traceqlQuery.isError.value);
+function traceqlOf(ds: 'native' | 'zipkin'): TraceQLSourceStatus | null {
+  return traceqlSources.value.find((x) => x.ds === ds) ?? null;
+}
+/** Configured sources only: an unconfigured datasource is a deployment that
+ *  does not use it, which is not a state to report as broken. */
+const traceqlConfigured = computed(() => traceqlSources.value.filter((x) => x.configured));
+/** One row of the Trace APIs table: the API, the store it answers over, the
+ *  endpoint, and its state in the admin table's own vocabulary. */
+interface TraceApiRow {
+  api: string;
+  source: string;
+  url: string;
+  state: { cls: string; label: string };
+  version?: string;
+  ds?: 'native' | 'zipkin';
+}
+const traceApiRows = computed<TraceApiRow[]>(() => {
+  const rows: TraceApiRow[] = [
+    {
+      api: 'Zipkin v2 REST',
+      source: t('Zipkin spans'),
+      url: info.value?.zipkinUrl ?? '—',
+      state:
+        zipkinReachable.value === undefined
+          ? { cls: 'is-unknown', label: t('loading…') }
+          : zipkinReachable.value
+            ? { cls: 'is-ok', label: t('reachable') }
+            : { cls: 'is-err', label: t('unreachable') },
+    },
+  ];
+  for (const r of TRACEQL_ROWS.value) {
+    const st = traceqlOf(r.ds);
+    rows.push({
+      api: 'TraceQL',
+      source: r.label,
+      url: st?.url || t('no URL configured'),
+      state: traceqlDenied.value || !st
+        ? { cls: 'is-unknown', label: t('unknown') }
+        : !st.configured
+          ? { cls: 'is-unknown', label: t('not configured') }
+          : st.reachable
+            ? { cls: 'is-ok', label: t('reachable') }
+            : { cls: 'is-err', label: t('unreachable') },
+      ...(st?.version ? { version: st.version } : {}),
+      ds: r.ds,
+    });
+  }
+  return rows;
+});
+
+/**
+ * The Trace APIs pane's verdict: the Zipkin endpoint and every CONFIGURED
+ * TraceQL datasource, read together. An unconfigured datasource is a
+ * deployment that does not use it — not a state to report as broken — and a
+ * probe nobody could read (no `traces:read`) leaves the pane unknown rather
+ * than claiming an outage.
+ */
+const traceApiBadgeState = computed<'ok' | 'err' | 'unknown'>(() => {
+  const failing = zipkinReachable.value === false
+    || (!traceqlDenied.value && traceqlConfigured.value.some((x) => !x.reachable));
+  if (failing) return 'err';
+  const known = zipkinReachable.value === true
+    || (!traceqlDenied.value && traceqlConfigured.value.some((x) => x.reachable));
+  return known ? 'ok' : 'unknown';
+});
+const traceApiBadgeLabel = computed<string>(() => {
+  if (traceApiBadgeState.value === 'err') return t('degraded');
+  if (traceApiBadgeState.value === 'ok') return t('reachable');
+  return traceqlQuery.isLoading.value ? t('loading…') : t('not configured');
+});
+
+
 // "Checked Ns ago" advances against a slow ticker, anchored to the BFF's
 // generatedAt (so it reflects the real probe time incl. cache age, not the
 // render moment). 5s granularity is plenty for a 30s-cached / 60s-polled check.
@@ -130,18 +227,13 @@ const adminGeneratedAt = computed<string>(() => agoLabel(preflight.value?.genera
 // dot here is NOT a cluster-wide outage. Reachability is undefined
 // until the first /api/oap/info lands.
 const zipkinReachable = computed<boolean | undefined>(() => info.value?.zipkinReachable);
-const zipkinBadgeState = computed<'ok' | 'err' | 'unknown'>(() => {
-  if (zipkinReachable.value === undefined) return 'unknown';
-  return zipkinReachable.value ? 'ok' : 'err';
-});
-const zipkinBadgeLabel = computed<string>(() => {
-  if (zipkinReachable.value === undefined) return t('loading…');
-  return zipkinReachable.value ? t('reachable') : t('unreachable');
-});
 
 function refreshAll(): void {
   void refetchInfo();
   void refetchPreflight();
+  // The TraceQL probe is cached for five minutes in the BFF, so a recovered
+  // datasource would go on reading red until something else refetched it.
+  void traceqlQuery.refetch();
 }
 
 /**
@@ -154,7 +246,10 @@ const store = computed(() => preflight.value?.templateStore ?? null);
 const storeBadgeState = computed<'ok' | 'warn' | 'err' | 'unknown'>(() => {
   const s = store.value;
   if (!s) return 'unknown';
-  if (s.mode === 'readonly') return 'ok';
+  // Readonly is yellow, as the module table's `readonly · bundled` row is: the
+  // store is not being read at all, which is a deliberate state rather than a
+  // healthy one, and the two places that say it must not disagree.
+  if (s.mode === 'readonly') return 'warn';
   // Unreachable but still rendering is a warning; unreachable with nothing to
   // render is an outage. Collapsing the two is what made a blip look fatal.
   if (s.servingRetained) return 'warn';
@@ -219,7 +314,7 @@ const storeLastSyncShort = computed<string>(() => {
         <div class="kicker">{{ t('Operate') }} · {{ t('Cluster status') }}</div>
         <h1>{{ t('OAP cluster') }}</h1>
         <p class="lede">
-          {{ t('Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.') }}
+          {{ t('Every port of the OAP backend Horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the trace APIs — Zipkin and each TraceQL datasource — feed only the trace rows they serve. Each is polled independently: if one shows red the others can still be green.') }}
         </p>
       </div>
       <button type="button" class="refresh" @click="refreshAll">{{ t('refresh both') }}</button>
@@ -379,28 +474,46 @@ const storeLastSyncShort = computed<string>(() => {
       </div>
     </section>
 
-    <!-- ── Pane C · Zipkin / OTLP trace endpoint ─────────────────── -->
+    <!-- ── Pane C · Trace APIs ───────────────────────────────────
+         The endpoints behind the trace rows. The NATIVE trace query is not
+         here: it rides the GraphQL port, so Pane A already answers for it. -->
     <section class="pane">
       <header class="pane-head">
-        <h2>{{ t('Zipkin / OTLP traces') }} <span class="port">{{ t('v2 REST') }}</span></h2>
-        <span class="sw-badge" :class="`is-${zipkinBadgeState}`">
-          <span class="state-dot" />{{ zipkinBadgeLabel }}
+        <h2>{{ t('Trace APIs') }} <span class="port">{{ t('Zipkin v2 · Tempo') }}</span></h2>
+        <span class="sw-badge" :class="`is-${traceApiBadgeState}`">
+          <span class="state-dot" />{{ traceApiBadgeLabel }}
         </span>
       </header>
 
       <p class="pane-lede">
-        {{ t("OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.") }}
+        {{ t("The endpoints behind a layer's trace rows, each probed on its own. Native traces are not here — they are answered by the GraphQL port above. A red dot in this pane is not a cluster-wide outage: only the trace rows fed by that endpoint are affected, and every other page keeps working.") }}
       </p>
 
-      <div class="grid">
-        <div class="sw-card kpi">
-          <div class="sw-card-head"><h4>{{ t('Endpoint') }}</h4></div>
-          <div class="kpi-body">
-            <div class="kpi-value mono">{{ zipkinBadgeLabel }}</div>
-            <div class="kpi-label">{{ info?.zipkinUrl ?? '—' }}</div>
-          </div>
-        </div>
-      </div>
+      <table class="mod-table">
+        <thead>
+          <tr>
+            <th>{{ t('API') }}</th>
+            <th>{{ t('Source') }}</th>
+            <th>{{ t('State') }}</th>
+            <th>{{ t('Endpoint') }}</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="r in traceApiRows" :key="`${r.api}-${r.source}`" :class="{ off: r.state.cls === 'is-err' }">
+            <td class="modname"><code>{{ r.api }}</code></td>
+            <td class="modname">{{ r.source }}</td>
+            <td>
+              <span class="sw-badge" :class="r.state.cls">
+                <span class="state-dot" />{{ r.state.label }}
+              </span>
+              <div v-if="r.version" class="state-foot">
+                <span class="sel">{{ t('Tempo API {version}', { version: r.version }) }}</span>
+              </div>
+            </td>
+            <td class="modpath"><code>{{ r.url }}</code></td>
+          </tr>
+        </tbody>
+      </table>
 
       <div v-if="zipkinReachable === false" class="last-error block">
         <strong>{{ t('Zipkin endpoint unreachable') }}</strong>
@@ -409,6 +522,16 @@ const storeLastSyncShort = computed<string>(() => {
           {{ t("Tried {url}. Confirm OAP's Zipkin receiver / query is enabled and the oap.zipkinUrl in horizon's config points at the right host:port (shared GraphQL port → <queryUrl>/zipkin; standalone → :9412/zipkin). Only the Zipkin trace menu is affected.", { url: `${info?.zipkinUrl ?? ''}/api/v2/services` }) }}
         </p>
       </div>
+
+      <div v-for="row in TRACEQL_ROWS" :key="`e-${row.ds}`">
+        <div v-if="traceqlOf(row.ds)?.configured && traceqlOf(row.ds)?.reachable === false" class="last-error block">
+          <strong>{{ t('{name} datasource unreachable', { name: row.label }) }}</strong>
+          <code v-if="traceqlOf(row.ds)?.error">{{ traceqlOf(row.ds)?.error }}</code>
+          <p class="hint">
+            {{ t("Tried {url}. Enable OAP's traceQL module (SW_TRACEQL=default with the datasource switched on) and confirm oap.traceql points at its host:port and context path — {path} by default on port 3200.", { url: `${traceqlOf(row.ds)?.url ?? ''}/api/status/buildinfo`, path: row.path }) }}
+          </p>
+        </div>
+      </div>
     </section>
   </div>
 </template>
diff --git a/apps/ui/src/features/operate/explore/ExploreLogView.vue b/apps/ui/src/features/operate/explore/ExploreLogView.vue
index 74bf93aa..c68ab348 100644
--- a/apps/ui/src/features/operate/explore/ExploreLogView.vue
+++ b/apps/ui/src/features/operate/explore/ExploreLogView.vue
@@ -61,6 +61,7 @@ import { useLayers } from '@/shell/useLayers';
 import { useTracePopout } from '@/layer/traces/useTracePopout';
 import { WINDOW_OPTS as POD_WINDOW_OPTS, INTERVAL_OPTS as POD_INTERVAL_OPTS } from '@/layer/pod-logs/useLayerPodLogs';
 import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue';
+import DateTimeField from '@/components/primitives/DateTimeField.vue';
 import TagInput from '@/components/primitives/TagInput.vue';
 import LogStreamPanel from '@/render/widgets/LogStreamPanel.vue';
 import LogDetailPopout from '@/render/widgets/LogDetailPopout.vue';
@@ -800,9 +801,9 @@ watch(logSource, (next, prev) => {
               <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" />
+                  <DateTimeField v-model="customStart" class="cf-range-num" />
                   <span class="cf-range-sep">–</span>
-                  <input v-model="customEnd" type="datetime-local" class="cf-input cf-range-num" />
+                  <DateTimeField v-model="customEnd" class="cf-range-num" />
                   <button class="iq-range-reset" type="button" :title="t('Back to presets')" @click="cond.windowMinutes = 30">×</button>
                 </span>
               </template>
diff --git a/apps/ui/src/features/operate/explore/ExploreView.traceId.test.ts b/apps/ui/src/features/operate/explore/ExploreView.traceId.test.ts
index b5ea6f87..13086e08 100644
--- a/apps/ui/src/features/operate/explore/ExploreView.traceId.test.ts
+++ b/apps/ui/src/features/operate/explore/ExploreView.traceId.test.ts
@@ -196,7 +196,7 @@ describe('Trace inspect — the Trace ID mode looks traces up by id', () => {
     const bff = fakeBff();
     vi.stubGlobal('fetch', bff.fetchSpy);
     const w = await mountInspect();
-    const bounds = () => w.findAll('.iq-time input[type="datetime-local"]');
+    const bounds = () => w.findAll('.iq-time .dtf-input');
     const [filterFrom, filterTo, idFrom, idTo] = [localDt(3), localDt(2), localDt(48), localDt(47)];
     await w.get('.iq-time select').setValue('-1');
     await bounds()[0]!.setValue(filterFrom);
@@ -208,7 +208,10 @@ describe('Trace inspect — the Trace ID mode looks traces up by id', () => {
     await bounds()[1]!.setValue(idTo);
 
     await pick(w, 'Filter');
-    expect(bounds().map((b) => (b.element as HTMLInputElement).value)).toEqual([filterFrom, filterTo]);
+    // The field SHOWS a space where the model carries `T` — it is Horizon's
+    // own control now, not the browser's.
+    const shown = (v: string) => v.replace('T', ' ');
+    expect(bounds().map((b) => (b.element as HTMLInputElement).value)).toEqual([shown(filterFrom), shown(filterTo)]);
     await run(w);
     expect(bff.asked[0]!.window).toEqual({ startMs: new Date(filterFrom).getTime(), endMs: new Date(filterTo).getTime() });
   });
diff --git a/apps/ui/src/features/operate/explore/ExploreView.vue b/apps/ui/src/features/operate/explore/ExploreView.vue
index aded25b4..8b766efd 100644
--- a/apps/ui/src/features/operate/explore/ExploreView.vue
+++ b/apps/ui/src/features/operate/explore/ExploreView.vue
@@ -46,7 +46,7 @@ import type {
   ExploreWindow,
   NativeSpan,
   NativeTraceListResponse,
-  NativeTraceListRow,
+  TraceListRow,
   TraceQueryOrder,
   TraceQueryState,
 } from '@/api/client';
@@ -63,6 +63,7 @@ import TraceDetailCard from '@/render/widgets/TraceDetailCard.vue';
 import ZipkinTraceDetailCard from '@/render/widgets/ZipkinTraceDetailCard.vue';
 import TraceDistribution from '@/render/widgets/TraceDistribution.vue';
 import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue';
+import DateTimeField from '@/components/primitives/DateTimeField.vue';
 import TagInput from '@/components/primitives/TagInput.vue';
 import ChipInput from '@/components/primitives/ChipInput.vue';
 
@@ -536,10 +537,9 @@ const isZipkin = computed(() => traceSource.value === 'zipkin');
 
 /** Adapt a Zipkin list row to the shared list/distribution row model.
  *  Zipkin durations + timestamps are µs; the shared widgets read ms. */
-function zipkinToNativeRow(r: ZipkinTraceListRow): NativeTraceListRow {
+function zipkinToListRow(r: ZipkinTraceListRow): TraceListRow {
   return {
     key: r.traceId,
-    segmentId: r.traceId,
     endpointNames: [r.rootName ?? r.rootService ?? '—'],
     duration: Math.round((r.duration ?? 0) / 1000),
     start: String(Math.round((r.timestamp ?? 0) / 1000)),
@@ -548,10 +548,10 @@ function zipkinToNativeRow(r: ZipkinTraceListRow): NativeTraceListRow {
   };
 }
 
-const rows = computed<NativeTraceListRow[]>(() => {
+const rows = computed<TraceListRow[]>(() => {
   if (!hasQueried.value) return [];
   return isZipkin.value
-    ? zipkinRows.value.map(zipkinToNativeRow)
+    ? zipkinRows.value.map(zipkinToListRow)
     : native.value?.traces ?? [];
 });
 const maxDuration = computed(() => (rows.value.length === 0 ? 0 : Math.max(...rows.value.map((r) => r.duration))));
@@ -560,7 +560,7 @@ const maxDuration = computed(() => (rows.value.length === 0 ? 0 : Math.max(...ro
 // the list to them; a single click still opens one trace's detail.
 const brushedKeys = ref<string[]>([]);
 const brushedSet = computed(() => new Set(brushedKeys.value));
-const displayRows = computed<NativeTraceListRow[]>(() =>
+const displayRows = computed<TraceListRow[]>(() =>
   brushedKeys.value.length > 0 ? rows.value.filter((r) => brushedSet.value.has(r.key)) : rows.value,
 );
 function onScatterBrush(keys: string[]): void { brushedKeys.value = keys; }
@@ -606,11 +606,14 @@ const zipkinSpans = computed<ZipkinSpan[]>(() => zipkinRowSpans.value ?? fetched
 const waterfallSpans = computed<NativeSpan[]>(() => embeddedSpans.value ?? nativeDetail.value?.spans ?? []);
 const detailLoading = computed(() => (isZipkin.value ? zipkinDetailFetching.value : detailFetching.value));
 
-function openRow(row: NativeTraceListRow): void {
+function openRow(row: TraceListRow): void {
   selectedRowKey.value = row.key;
   selectedTraceIds.value = row.traceIds;
   selectedTraceId.value = row.traceIds[0] ?? null;
-  embeddedSpans.value = row.spans ?? null;
+  // Spans ride on the native RESPONSE, never on the presentation row.
+  embeddedSpans.value = isZipkin.value
+    ? null
+    : (native.value?.traces ?? []).find((t) => t.key === row.key)?.spans ?? null;
 }
 function closeDetail(): void {
   selectedTraceId.value = null;
@@ -820,9 +823,9 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onPageKeyDown, true)
                 <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" />
+                    <DateTimeField v-model="customStart" class="cf-range-num" />
                     <span class="cf-range-sep">–</span>
-                    <input v-model="customEnd" type="datetime-local" class="cf-input cf-range-num" />
+                    <DateTimeField v-model="customEnd" class="cf-range-num" />
                     <button class="iq-range-reset" type="button" :title="t('Back to presets')" @click="leaveCustomRange">×</button>
                   </span>
                 </template>
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 3a519ed7..b15879fd 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -801,7 +801,6 @@
   "Last-seen activity is tracked in each BFF replica's own memory and is NOT shared across the cluster. This count reflects only the node that served this page.": "Die Zuletzt-gesehen-Aktivität wird im eigenen Speicher jeder BFF-Replika geführt und NICHT clusterweit geteilt. Diese Zahl spiegelt nur den Knoten wider, der diese Seite ausgeliefert hat.",
   "Latency": "Latenz",
   "Layer": "Ebene",
-  "Layer carries both native and OpenTelemetry/Zipkin traces — their span formats and query conditions differ, so each gets its own trace tab.": "Die Ebene führt sowohl native als auch OpenTelemetry-/Zipkin-Traces — Span-Formate und Abfragebedingungen unterscheiden sich, daher bekommt jede Quelle einen eigenen Trace-Tab.",
   "Layer dashboard editor": "Editor für Ebenen-Dashboards",
   "Layer dashboard is duplicated": "Layer-Dashboard ist doppelt vorhanden",
   "Layer dashboards": "Ebenen-Dashboards",
@@ -1122,12 +1121,10 @@
   "OAP unreachable — page is read-only": "OAP nicht erreichbar — Seite ist schreibgeschützt",
   "OAP unreachable.": "OAP nicht erreichbar.",
   "OAP unreachable: {error}": "OAP nicht erreichbar: {error}",
-  "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "Der Zipkin-v2-Endpunkt von OAP, Quelle für das Trace-Menü von OpenTelemetry und Zipkin (sichtbar, wenn die Trace-Quelle eines Layers zipkin oder both ist). Nur diese Seite ist betroffen — bei Nichterreichbarkeit funktionieren native Traces [...]
   "OAP-stored": "auf OAP gespeichert",
   "OAuth": "OAuth",
   "OIDC": "OIDC",
   "OK": "OK",
-  "OTel & Zipkin Traces": "OTel- & Zipkin-Traces",
   "Observability Analysis Language": "Observability Analysis Language",
   "Off": "Aus",
   "Off:": "Aus:",
@@ -1149,7 +1146,6 @@
   "Open the Topology tab and narrow the scope to see a complete map.": "Öffnen Sie den Topologie-Tab und grenzen Sie den Scope ein, um eine vollständige Karte zu sehen.",
   "Open the originating trace": "Den zugehörigen Trace öffnen",
   "Open {name} in {layer} (new tab)": "{name} in {layer} öffnen (neuer Tab)",
-  "OpenTelemetry & Zipkin": "OpenTelemetry & Zipkin",
   "Operate": "Betrieb",
   "Operate · Alerting": "Betrieb · Alarmierung",
   "Operate · Data retention": "Betrieb · Datenaufbewahrung",
@@ -1225,7 +1221,6 @@
   "Pick instances, then click Analyze.": "Instanzen wählen, dann auf Analysieren klicken.",
   "Pick processes": "Prozesse auswählen",
   "Pick refresh interval": "Aktualisierungsintervall wählen",
-  "Pick the trace backend this layer reads from.": "Wählen Sie das Trace-Backend, aus dem diese Ebene liest.",
   "Pick your conditions, then click Run query.": "Bedingungen auswählen, dann auf „Abfrage ausführen“ klicken.",
   "Pin process {name}": "Prozess {name} anheften",
   "Pin the OAP layers that get their own KPI tile at the top of": "Hefte die OAP-Ebenen an, die oben eine eigene KPI-Kachel erhalten auf",
@@ -1571,7 +1566,6 @@
   "Single sign-on is not configured on this server.": "Single Sign-on ist auf diesem Server nicht konfiguriert.",
   "Single sign-on via {provider}": "Single Sign-on über {provider}",
   "SkyWalking Horizon": "SkyWalking Horizon",
-  "SkyWalking query-protocol traces (agent-instrumented).": "Traces über das SkyWalking-Query-Protocol (vom Agent instrumentiert).",
   "Slowest": "Langsamste",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "Einige Metriken konnten nicht geladen werden ({failed} von {total} Batches fehlgeschlagen) — leere Werte sind möglicherweise nicht verfügbar, nicht null.",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "Einige Metriken konnten nicht geladen werden ({failed} von {total} Batches fehlgeschlagen) — einige Endpoints oder Verbindungen fehlen möglicherweise.",
@@ -1772,10 +1766,8 @@
   "Trace explorer scoped to this layer.": "Trace-Explorer, begrenzt auf diese Ebene.",
   "Trace inspect": "Trace-Inspektion",
   "Trace profiling requires an endpoint, and this proposal carries none.": "Trace-Profiling erfordert einen Endpoint, dieser Vorschlag enthält jedoch keinen.",
-  "Trace source": "Trace-Quelle",
   "Trace-driven thread profiling — the original SkyWalking profile.": "Trace-gesteuertes Thread-Profiling — das ursprüngliche SkyWalking-Profil.",
   "Traces": "Traces",
-  "Traces emitted from the OpenTelemetry & Zipkin ecosystem.": "Traces aus dem Ökosystem von OpenTelemetry und Zipkin.",
   "Traces feed failed.": "Trace-Feed fehlgeschlagen.",
   "Traces · Zipkin": "Traces · Zipkin",
   "Traffic": "Traffic",
@@ -1794,7 +1786,6 @@
   "Turn auto-refresh on": "Automatische Aktualisierung einschalten",
   "Turn off and delete": "Deaktivieren und löschen",
   "Turn off {component}?": "{component} deaktivieren?",
-  "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "Zwei-Port-Ansicht des OAP-Backends, mit dem Horizon verbunden ist. Query / GraphQL (:12800) versorgt jede Observability-Seite; der Admin [...]
   "Type": "Tippen Sie",
   "Type a name — the assistant will find it": "Namen eingeben – der Assistent findet ihn",
   "Type {ruleName} to confirm:": "Tippe {ruleName} zur Bestätigung:",
@@ -1904,7 +1895,6 @@
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see": "Deine Sidebar passt sich deiner Rolle an: Was du nicht nutzen kannst, wird einfach nicht angezeigt. Die Abschnitte unten gruppieren die Fähigkeiten von SkyWalking nach Funktionsbereich. Für jeden Bereich siehst du",
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see {menuScope} (which sidebar entries fall in the area) and {actions} (the concrete things you can do, marked per role). Multiple roles add up — a user holding more than one role gets every permission any of their roles grant.": "Deine Sidebar passt sich deiner Rolle an: Was du nicht nutzen kannst, wird einfach nicht an [...]
   "Your username is your identity here, and your roles are resolved from it rather than stored against you. A token you hold re-reads them on every request, so removing you from the directory or configuration ends it at once. This browser session keeps the roles it was given until you sign in again.": "Ihr Benutzername ist hier Ihre Identität, und Ihre Rollen werden daraus aufgelöst, statt bei Ihnen gespeichert zu werden. Ein Token, das Sie besitzen, liest sie bei jeder Anfrage neu — wir [...]
-  "Zipkin / OTLP traces": "Zipkin- / OTLP-Traces",
   "Zipkin endpoint unreachable": "Zipkin-Endpunkt nicht erreichbar",
   "Zipkin query failed.": "Zipkin-Abfrage fehlgeschlagen.",
   "Zipkin trace": "Zipkin-Trace",
@@ -3319,5 +3309,102 @@
   "Tool result": "Werkzeugergebnis",
   "message": "Nachricht",
   "Tool result, which failed": "Werkzeugergebnis, das fehlgeschlagen ist",
-  "for {id}": "für {id}"
+  "for {id}": "für {id}",
+  "No TraceQL URL is configured for this source. Set oap.traceql in horizon.yaml.": "Für diese Quelle ist keine TraceQL-URL konfiguriert. Setzen Sie oap.traceql in horizon.yaml.",
+  "TraceQL is configured but not answering at {url}.": "TraceQL ist konfiguriert, antwortet aber unter {url} nicht.",
+  "Builder": "Baukasten",
+  "TraceQL": "TraceQL",
+  "Edit as TraceQL": "Als TraceQL bearbeiten",
+  "No traces matched.": "Keine Traces gefunden.",
+  "No trace with that id.": "Kein Trace mit dieser ID.",
+  "The trace source is not answering.": "Die Trace-Quelle antwortet nicht.",
+  "This trace source does not report failure": "Diese Trace-Quelle meldet keine Fehler",
+  "Traces · TraceQL · Zipkin": "Traces · TraceQL · Zipkin",
+  "Traces · TraceQL": "Traces · TraceQL",
+  "not configured": "nicht konfiguriert",
+  "Open trace": "Trace öffnen",
+  "Min duration": "Mindestdauer",
+  "Max duration": "Maximaldauer",
+  "TraceQL query": "TraceQL-Abfrage",
+  "Shift+Enter runs; a bare trace id opens that trace": "Shift+Enter führt aus; eine bloße Trace-ID öffnet diesen Trace",
+  "capped at {n} — narrow the query": "auf {n} begrenzt — Abfrage eingrenzen",
+  "{n} tags": "{n} Tags",
+  "Intrinsics": "Intrinsics",
+  "Resource": "Resource",
+  "Span tags": "Span-Tags",
+  "This store reported no tags for the selected window.": "Dieser Speicher hat für das gewählte Zeitfenster keine Tags gemeldet.",
+  "unset": "unbestimmt",
+  "This API reads one id per request, so several ids are {n} separate reads.": "Diese API liest eine ID pro Anfrage; mehrere IDs sind also bis zu {n} einzelne Lesevorgänge.",
+  "{n} not found": "{n} nicht gefunden",
+  "STARTED": "GESTARTET",
+  "DURATION": "DAUER",
+  "SPANS": "SPANS",
+  "SERVICES": "SERVICES",
+  "Parent span ID": "Übergeordnete Span-ID",
+  "Status message": "Statusmeldung",
+  "Scope": "Scope",
+  "Resource attributes": "Resource-Attribute",
+  "Span attributes": "Span-Attribute",
+  "COUNT": "ANZAHL",
+  "ERRORS": "FEHLER",
+  "TOTAL": "GESAMT",
+  "AVG": "MITTEL",
+  "MAX": "MAX",
+  "which trace APIs this layer exposes — one sidebar row each": "welche Trace-APIs diese Ebene bereitstellt — je ein Eintrag in der Sidebar",
+  "SkyWalking's own trace query.": "SkyWalkings eigene Trace-Abfrage.",
+  "OAP’s Zipkin v2 API, which also serves the OpenTelemetry spans OAP converts into Zipkin form.": "Die Zipkin-v2-API von OAP, die auch die OpenTelemetry-Spans liefert, die OAP ins Zipkin-Format umwandelt.",
+  "Grafana Tempo’s API over the native spans. Needs OAP’s traceQL module and a configured datasource URL.": "Die API von Grafana Tempo über den nativen Spans. Erfordert das traceQL-Modul von OAP und eine konfigurierte Datenquellen-URL.",
+  "Grafana Tempo’s API over the Zipkin spans. Needs OAP’s traceQL module and a configured datasource URL.": "Die API von Grafana Tempo über den Zipkin-Spans. Erfordert das traceQL-Modul von OAP und eine konfigurierte Datenquellen-URL.",
+  "Flags": "Flags",
+  "The Tempo API has no notion of a layer, so it lists every service of the store. This pattern narrows the service PICKER only — result rows are never filtered, so cross-service traces still appear.": "Die Tempo-API kennt keine Ebenen und listet daher jeden Service des Speichers auf. Dieses Muster schränkt nur die Service-AUSWAHL ein — Ergebniszeilen werden nie gefiltert, serviceübergreifende Traces erscheinen also weiterhin.",
+  "checking {n} statuses…": "{n} Status werden geprüft…",
+  "Show the full value": "Vollständigen Wert anzeigen",
+  "full": "ganz",
+  "{n} characters": "{n} Zeichen",
+  "Duration range": "Dauerbereich",
+  "Trace duration, with a unit — us, ms, s, m, h.": "Trace-Dauer, mit Einheit — us, ms, s, m, h.",
+  "picked from {n}": "aus {n} ausgewählt",
+  "{n} not read — the source did not answer": "{n} nicht gelesen — die Quelle hat nicht geantwortet",
+  "Tempo API": "Tempo-API",
+  "not available": "nicht verfügbar",
+  "no URL configured": "keine URL konfiguriert",
+  "SkyWalking-native spans": "SkyWalking-native Spans",
+  "{name} datasource unreachable": "Datenquelle {name} nicht erreichbar",
+  "Tried {url}. Enable OAP's traceQL module (SW_TRACEQL=default with the datasource switched on) and confirm oap.traceql points at its host:port and context path — {path} by default on port 3200.": "{url} wurde versucht. Aktivieren Sie das traceQL-Modul von OAP (SW_TRACEQL=default mit eingeschalteter Datenquelle) und prüfen Sie, dass oap.traceql auf dessen host:port und Kontextpfad zeigt — standardmäßig {path} auf Port 3200.",
+  "Trace APIs": "Trace-APIs",
+  "Zipkin v2 · Tempo": "Zipkin v2 · Tempo",
+  "Zipkin v2 REST": "Zipkin v2 REST",
+  "degraded": "beeinträchtigt",
+  "Tempo API {version}": "Tempo-API {version}",
+  "The endpoints behind a layer's trace rows, each probed on its own. Native traces are not here — they are answered by the GraphQL port above. A red dot in this pane is not a cluster-wide outage: only the trace rows fed by that endpoint are affected, and every other page keeps working.": "Die Endpunkte hinter den Trace-Zeilen einer Ebene, jeder für sich geprüft. Native Traces stehen hier nicht — sie beantwortet der GraphQL-Port oben. Ein roter Punkt in diesem Bereich ist kein clusterwei [...]
+  "API": "API",
+  "scope · {scope}": "Scope · {scope}",
+  "The source refused this query": "Die Quelle hat diese Abfrage abgelehnt",
+  "Every port of the OAP backend Horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the trace APIs — Zipkin and each TraceQL datasource — feed only the trace rows they serve. Each is polled independently: if one shows red the others can still be green.": "Jeder Port des OAP-Backends, mit dem Horizon verbunden ist. Query/GraphQL (:12800) treibt jede Observability-Seite;  [...]
+  "{n} not read — over the {max}-id limit": "{n} nicht gelesen — über dem Limit von {max} IDs",
+  "Any span name": "Beliebiger Span-Name",
+  "Schema reference": "Schema-Referenz",
+  "This condition has no value.": "Diese Bedingung hat keinen Wert.",
+  "Not a complete spanset.": "Kein vollständiges Spanset.",
+  "An attribute needs a scope — span.http.method, resource.service.name, or .http.method for the unscoped form.": "Ein Attribut braucht einen Scope — span.http.method, resource.service.name oder .http.method für die scope-lose Form.",
+  "Not a field in this schema. The intrinsics are duration, name and status; everything else is an attribute and carries a scope.": "Kein Feld in diesem Schema. Die Intrinsics sind duration, name und status; alles andere ist ein Attribut und trägt einen Scope.",
+  "This attribute belongs to the other store’s schema.": "Dieses Attribut gehört zum Schema des anderen Stores.",
+  "A status other than “ok”, “error” or “unset”.": "Ein anderer Status als „ok“, „error“ oder „unset“.",
+  "Span tags are those this store reported for the window being queried, so the list follows the time range.": "Span-Tags sind die, die dieser Store für den abgefragten Zeitraum gemeldet hat — die Liste folgt also dem Zeitbereich.",
+  "ok, error or unset": "ok, error oder unset",
+  "Service instance (native)": "Service-Instanz (nativ)",
+  "Peer service (Zipkin)": "Peer-Service (Zipkin)",
+  "Trace duration — >, >=, <, <= with us/µs/ms/s/m/h": "Trace-Dauer — >, >=, <, <= mit us/µs/ms/s/m/h",
+  "Zipkin Traces": "Zipkin-Traces",
+  "TraceQL - Native": "TraceQL - Native",
+  "TraceQL - Zipkin": "TraceQL - Zipkin",
+  "Zipkin spans": "Zipkin-Spans",
+  "Menu name": "Menüname",
+  "Ignore case": "Groß-/Kleinschreibung ignorieren",
+  "Horizon will not run this expression, so the picker keeps showing every service. It either does not compile, or it can backtrack exponentially — avoid a repeat applied to a group that itself repeats or branches, such as (a+)+.": "Horizon führt diesen Ausdruck nicht aus, daher zeigt die Auswahl weiterhin alle Services. Er lässt sich entweder nicht übersetzen oder kann exponentiell zurücksetzen — vermeiden Sie eine Wiederholung auf einer Gruppe, die selbst wiederholt oder verzweigt, etw [...]
+  "Date and time": "Datum und Uhrzeit",
+  "Open the calendar": "Kalender öffnen",
+  "Previous month": "Vorheriger Monat",
+  "Next month": "Nächster Monat",
+  "Now": "Jetzt"
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 11b295c6..79081740 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -801,7 +801,6 @@
   "Last-seen activity is tracked in each BFF replica's own memory and is NOT shared across the cluster. This count reflects only the node that served this page.": "Last-seen activity is tracked in each BFF replica's own memory and is NOT shared across the cluster. This count reflects only the node that served this page.",
   "Latency": "Latency",
   "Layer": "Layer",
-  "Layer carries both native and OpenTelemetry/Zipkin traces — their span formats and query conditions differ, so each gets its own trace tab.": "Layer carries both native and OpenTelemetry/Zipkin traces — their span formats and query conditions differ, so each gets its own trace tab.",
   "Layer dashboard editor": "Layer dashboard editor",
   "Layer dashboard is duplicated": "Layer dashboard is duplicated",
   "Layer dashboards": "Layer dashboards",
@@ -1122,12 +1121,10 @@
   "OAP unreachable — page is read-only": "OAP unreachable — page is read-only",
   "OAP unreachable.": "OAP unreachable.",
   "OAP unreachable: {error}": "OAP unreachable: {error}",
-  "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep w [...]
   "OAP-stored": "OAP-stored",
   "OAuth": "OAuth",
   "OIDC": "OIDC",
   "OK": "OK",
-  "OTel & Zipkin Traces": "OTel & Zipkin Traces",
   "Observability Analysis Language": "Observability Analysis Language",
   "Off": "Off",
   "Off:": "Off:",
@@ -1149,7 +1146,6 @@
   "Open the Topology tab and narrow the scope to see a complete map.": "Open the Topology tab and narrow the scope to see a complete map.",
   "Open the originating trace": "Open the originating trace",
   "Open {name} in {layer} (new tab)": "Open {name} in {layer} (new tab)",
-  "OpenTelemetry & Zipkin": "OpenTelemetry & Zipkin",
   "Operate": "Operate",
   "Operate · Alerting": "Operate · Alerting",
   "Operate · Data retention": "Operate · Data retention",
@@ -1225,7 +1221,6 @@
   "Pick instances, then click Analyze.": "Pick instances, then click Analyze.",
   "Pick processes": "Pick processes",
   "Pick refresh interval": "Pick refresh interval",
-  "Pick the trace backend this layer reads from.": "Pick the trace backend this layer reads from.",
   "Pick your conditions, then click Run query.": "Pick your conditions, then click Run query.",
   "Pin process {name}": "Pin process {name}",
   "Pin the OAP layers that get their own KPI tile at the top of": "Pin the OAP layers that get their own KPI tile at the top of",
@@ -1571,7 +1566,6 @@
   "Single sign-on is not configured on this server.": "Single sign-on is not configured on this server.",
   "Single sign-on via {provider}": "Single sign-on via {provider}",
   "SkyWalking Horizon": "SkyWalking Horizon",
-  "SkyWalking query-protocol traces (agent-instrumented).": "SkyWalking query-protocol traces (agent-instrumented).",
   "Slowest": "Slowest",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.",
@@ -1772,10 +1766,8 @@
   "Trace explorer scoped to this layer.": "Trace explorer scoped to this layer.",
   "Trace inspect": "Trace inspect",
   "Trace profiling requires an endpoint, and this proposal carries none.": "Trace profiling requires an endpoint, and this proposal carries none.",
-  "Trace source": "Trace source",
   "Trace-driven thread profiling — the original SkyWalking profile.": "Trace-driven thread profiling — the original SkyWalking profile.",
   "Traces": "Traces",
-  "Traces emitted from the OpenTelemetry & Zipkin ecosystem.": "Traces emitted from the OpenTelemetry & Zipkin ecosystem.",
   "Traces feed failed.": "Traces feed failed.",
   "Traces · Zipkin": "Traces · Zipkin",
   "Traffic": "Traffic",
@@ -1794,7 +1786,6 @@
   "Turn auto-refresh on": "Turn auto-refresh on",
   "Turn off and delete": "Turn off and delete",
   "Turn off {component}?": "Turn off {component}?",
-  "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:171 [...]
   "Type": "Type",
   "Type a name — the assistant will find it": "Type a name — the assistant will find it",
   "Type {ruleName} to confirm:": "Type {ruleName} to confirm:",
@@ -1904,7 +1895,6 @@
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see": "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see",
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see {menuScope} (which sidebar entries fall in the area) and {actions} (the concrete things you can do, marked per role). Multiple roles add up — a user holding more than one role gets every permission any of their roles grant.": "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections  [...]
   "Your username is your identity here, and your roles are resolved from it rather than stored against you. A token you hold re-reads them on every request, so removing you from the directory or configuration ends it at once. This browser session keeps the roles it was given until you sign in again.": "Your username is your identity here, and your roles are resolved from it rather than stored against you. A token you hold re-reads them on every request, so removing you from the directory [...]
-  "Zipkin / OTLP traces": "Zipkin / OTLP traces",
   "Zipkin endpoint unreachable": "Zipkin endpoint unreachable",
   "Zipkin query failed.": "Zipkin query failed.",
   "Zipkin trace": "Zipkin trace",
@@ -3319,5 +3309,102 @@
   "Tool result": "Tool result",
   "message": "message",
   "Tool result, which failed": "Tool result, which failed",
-  "for {id}": "for {id}"
+  "for {id}": "for {id}",
+  "No TraceQL URL is configured for this source. Set oap.traceql in horizon.yaml.": "No TraceQL URL is configured for this source. Set oap.traceql in horizon.yaml.",
+  "TraceQL is configured but not answering at {url}.": "TraceQL is configured but not answering at {url}.",
+  "Builder": "Builder",
+  "TraceQL": "TraceQL",
+  "Edit as TraceQL": "Edit as TraceQL",
+  "No traces matched.": "No traces matched.",
+  "No trace with that id.": "No trace with that id.",
+  "The trace source is not answering.": "The trace source is not answering.",
+  "This trace source does not report failure": "This trace source does not report failure",
+  "Traces · TraceQL · Zipkin": "Traces · TraceQL · Zipkin",
+  "Traces · TraceQL": "Traces · TraceQL",
+  "not configured": "not configured",
+  "Open trace": "Open trace",
+  "Min duration": "Min duration",
+  "Max duration": "Max duration",
+  "TraceQL query": "TraceQL query",
+  "Shift+Enter runs; a bare trace id opens that trace": "Shift+Enter runs; a bare trace id opens that trace",
+  "capped at {n} — narrow the query": "capped at {n} — narrow the query",
+  "{n} tags": "{n} tags",
+  "Intrinsics": "Intrinsics",
+  "Resource": "Resource",
+  "Span tags": "Span tags",
+  "This store reported no tags for the selected window.": "This store reported no tags for the selected window.",
+  "unset": "unset",
+  "This API reads one id per request, so several ids are {n} separate reads.": "This API reads one id per request, so several ids are {n} separate reads.",
+  "{n} not found": "{n} not found",
+  "STARTED": "STARTED",
+  "DURATION": "DURATION",
+  "SPANS": "SPANS",
+  "SERVICES": "SERVICES",
+  "Parent span ID": "Parent span ID",
+  "Status message": "Status message",
+  "Scope": "Scope",
+  "Resource attributes": "Resource attributes",
+  "Span attributes": "Span attributes",
+  "COUNT": "COUNT",
+  "ERRORS": "ERRORS",
+  "TOTAL": "TOTAL",
+  "AVG": "AVG",
+  "MAX": "MAX",
+  "which trace APIs this layer exposes — one sidebar row each": "which trace APIs this layer exposes — one sidebar row each",
+  "SkyWalking's own trace query.": "SkyWalking's own trace query.",
+  "OAP’s Zipkin v2 API, which also serves the OpenTelemetry spans OAP converts into Zipkin form.": "OAP’s Zipkin v2 API, which also serves the OpenTelemetry spans OAP converts into Zipkin form.",
+  "Grafana Tempo’s API over the native spans. Needs OAP’s traceQL module and a configured datasource URL.": "Grafana Tempo’s API over the native spans. Needs OAP’s traceQL module and a configured datasource URL.",
+  "Grafana Tempo’s API over the Zipkin spans. Needs OAP’s traceQL module and a configured datasource URL.": "Grafana Tempo’s API over the Zipkin spans. Needs OAP’s traceQL module and a configured datasource URL.",
+  "Flags": "Flags",
+  "The Tempo API has no notion of a layer, so it lists every service of the store. This pattern narrows the service PICKER only — result rows are never filtered, so cross-service traces still appear.": "The Tempo API has no notion of a layer, so it lists every service of the store. This pattern narrows the service PICKER only — result rows are never filtered, so cross-service traces still appear.",
+  "checking {n} statuses…": "checking {n} statuses…",
+  "Show the full value": "Show the full value",
+  "full": "full",
+  "{n} characters": "{n} characters",
+  "Duration range": "Duration range",
+  "Trace duration, with a unit — us, ms, s, m, h.": "Trace duration, with a unit — us, ms, s, m, h.",
+  "picked from {n}": "picked from {n}",
+  "{n} not read — the source did not answer": "{n} not read — the source did not answer",
+  "Tempo API": "Tempo API",
+  "not available": "not available",
+  "no URL configured": "no URL configured",
+  "SkyWalking-native spans": "SkyWalking-native spans",
+  "{name} datasource unreachable": "{name} datasource unreachable",
+  "Tried {url}. Enable OAP's traceQL module (SW_TRACEQL=default with the datasource switched on) and confirm oap.traceql points at its host:port and context path — {path} by default on port 3200.": "Tried {url}. Enable OAP's traceQL module (SW_TRACEQL=default with the datasource switched on) and confirm oap.traceql points at its host:port and context path — {path} by default on port 3200.",
+  "Trace APIs": "Trace APIs",
+  "Zipkin v2 · Tempo": "Zipkin v2 · Tempo",
+  "Zipkin v2 REST": "Zipkin v2 REST",
+  "degraded": "degraded",
+  "Tempo API {version}": "Tempo API {version}",
+  "The endpoints behind a layer's trace rows, each probed on its own. Native traces are not here — they are answered by the GraphQL port above. A red dot in this pane is not a cluster-wide outage: only the trace rows fed by that endpoint are affected, and every other page keeps working.": "The endpoints behind a layer's trace rows, each probed on its own. Native traces are not here — they are answered by the GraphQL port above. A red dot in this pane is not a cluster-wide outage: only th [...]
+  "API": "API",
+  "scope · {scope}": "scope · {scope}",
+  "The source refused this query": "The source refused this query",
+  "Every port of the OAP backend Horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the trace APIs — Zipkin and each TraceQL datasource — feed only the trace rows they serve. Each is polled independently: if one shows red the others can still be green.": "Every port of the OAP backend Horizon is connected to. Query / GraphQL (:12800) drives every observability page; the [...]
+  "{n} not read — over the {max}-id limit": "{n} not read — over the {max}-id limit",
+  "Any span name": "Any span name",
+  "Schema reference": "Schema reference",
+  "This condition has no value.": "This condition has no value.",
+  "Not a complete spanset.": "Not a complete spanset.",
+  "An attribute needs a scope — span.http.method, resource.service.name, or .http.method for the unscoped form.": "An attribute needs a scope — span.http.method, resource.service.name, or .http.method for the unscoped form.",
+  "Not a field in this schema. The intrinsics are duration, name and status; everything else is an attribute and carries a scope.": "Not a field in this schema. The intrinsics are duration, name and status; everything else is an attribute and carries a scope.",
+  "This attribute belongs to the other store’s schema.": "This attribute belongs to the other store’s schema.",
+  "A status other than “ok”, “error” or “unset”.": "A status other than “ok”, “error” or “unset”.",
+  "Span tags are those this store reported for the window being queried, so the list follows the time range.": "Span tags are those this store reported for the window being queried, so the list follows the time range.",
+  "ok, error or unset": "ok, error or unset",
+  "Service instance (native)": "Service instance (native)",
+  "Peer service (Zipkin)": "Peer service (Zipkin)",
+  "Trace duration — >, >=, <, <= with us/µs/ms/s/m/h": "Trace duration — >, >=, <, <= with us/µs/ms/s/m/h",
+  "Zipkin Traces": "Zipkin Traces",
+  "TraceQL - Native": "TraceQL - Native",
+  "TraceQL - Zipkin": "TraceQL - Zipkin",
+  "Zipkin spans": "Zipkin spans",
+  "Menu name": "Menu name",
+  "Ignore case": "Ignore case",
+  "Horizon will not run this expression, so the picker keeps showing every service. It either does not compile, or it can backtrack exponentially — avoid a repeat applied to a group that itself repeats or branches, such as (a+)+.": "Horizon will not run this expression, so the picker keeps showing every service. It either does not compile, or it can backtrack exponentially — avoid a repeat applied to a group that itself repeats or branches, such as (a+)+.",
+  "Date and time": "Date and time",
+  "Open the calendar": "Open the calendar",
+  "Previous month": "Previous month",
+  "Next month": "Next month",
+  "Now": "Now"
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 8a17ad5d..f6803b52 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -801,7 +801,6 @@
   "Last-seen activity is tracked in each BFF replica's own memory and is NOT shared across the cluster. This count reflects only the node that served this page.": "La actividad de last-seen se rastrea en la memoria propia de cada réplica del BFF y NO se comparte entre el clúster. Este conteo refleja solo el nodo que sirvió esta página.",
   "Latency": "Latencia",
   "Layer": "Capa",
-  "Layer carries both native and OpenTelemetry/Zipkin traces — their span formats and query conditions differ, so each gets its own trace tab.": "La capa lleva trazas nativas y de OpenTelemetry/Zipkin — sus formatos de span y condiciones de consulta difieren, así que cada una tiene su propia pestaña de trazas.",
   "Layer dashboard editor": "Editor de tableros por capa",
   "Layer dashboard is duplicated": "El dashboard del layer está duplicado",
   "Layer dashboards": "Paneles por capa",
@@ -1122,12 +1121,10 @@
   "OAP unreachable — page is read-only": "OAP inaccesible — la página es de solo lectura",
   "OAP unreachable.": "OAP inaccesible.",
   "OAP unreachable: {error}": "OAP inaccesible: {error}",
-  "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "Endpoint Zipkin v2 de OAP, fuente del menú de trazas de OpenTelemetry y Zipkin (visible cuando la fuente de trazas de un layer es zipkin o both). Es la única página afectada — si no es accesible, las trazas nativas y todas las demás página [...]
   "OAP-stored": "almacenado en OAP",
   "OAuth": "OAuth",
   "OIDC": "OIDC",
   "OK": "OK",
-  "OTel & Zipkin Traces": "Trazas OTel y Zipkin",
   "Observability Analysis Language": "Observability Analysis Language",
   "Off": "Apagado",
   "Off:": "Desactivado:",
@@ -1149,7 +1146,6 @@
   "Open the Topology tab and narrow the scope to see a complete map.": "Abre la pestaña Topología y acota el alcance para ver un mapa completo.",
   "Open the originating trace": "Abrir la traza de origen",
   "Open {name} in {layer} (new tab)": "Abrir {name} en {layer} (pestaña nueva)",
-  "OpenTelemetry & Zipkin": "OpenTelemetry y Zipkin",
   "Operate": "Operación",
   "Operate · Alerting": "Operar · Alertas",
   "Operate · Data retention": "Operar · Retención de datos",
@@ -1225,7 +1221,6 @@
   "Pick instances, then click Analyze.": "Elige instancias y luego haz clic en Analizar.",
   "Pick processes": "Elegir procesos",
   "Pick refresh interval": "Elige el intervalo de actualización",
-  "Pick the trace backend this layer reads from.": "Elige el backend de trazas del que lee esta capa.",
   "Pick your conditions, then click Run query.": "Elige tus condiciones y luego haz clic en Ejecutar consulta.",
   "Pin process {name}": "Fijar el proceso {name}",
   "Pin the OAP layers that get their own KPI tile at the top of": "Fija las capas de OAP que obtienen su propia tarjeta KPI en la parte superior de",
@@ -1571,7 +1566,6 @@
   "Single sign-on is not configured on this server.": "El inicio de sesión único no está configurado en este servidor.",
   "Single sign-on via {provider}": "Inicio de sesión único con {provider}",
   "SkyWalking Horizon": "SkyWalking Horizon",
-  "SkyWalking query-protocol traces (agent-instrumented).": "Trazas del query-protocol de SkyWalking (instrumentadas por agente).",
   "Slowest": "Más lentas",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "No se pudieron cargar algunas métricas ({failed} de {total} lotes fallaron) — los valores en blanco pueden no estar disponibles, no son cero.",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "No se pudieron cargar algunas métricas ({failed} de {total} lotes fallaron) — algunos endpoints o enlaces pueden faltar.",
@@ -1772,10 +1766,8 @@
   "Trace explorer scoped to this layer.": "Explorador de trazas acotado a esta capa.",
   "Trace inspect": "Inspección de trazas",
   "Trace profiling requires an endpoint, and this proposal carries none.": "El perfilado de trazas requiere un endpoint y esta propuesta no incluye ninguno.",
-  "Trace source": "Fuente de trazas",
   "Trace-driven thread profiling — the original SkyWalking profile.": "Perfilado de hilos guiado por trazas — el profiling original de SkyWalking.",
   "Traces": "Trazas",
-  "Traces emitted from the OpenTelemetry & Zipkin ecosystem.": "Trazas emitidas por el ecosistema de OpenTelemetry y Zipkin.",
   "Traces feed failed.": "Falló la obtención de trazas.",
   "Traces · Zipkin": "Trazas · Zipkin",
   "Traffic": "Tráfico",
@@ -1794,7 +1786,6 @@
   "Turn auto-refresh on": "Activar la actualización automática",
   "Turn off and delete": "Desactivar y eliminar",
   "Turn off {component}?": "¿Desactivar {component}?",
-  "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "Vista de dos puertos del backend OAP al que está conectado Horizon. Query / GraphQL (:12800) alimenta cada página de observabilidad; el  [...]
   "Type": "Escribe",
   "Type a name — the assistant will find it": "Escribe un nombre; el asistente lo encontrará",
   "Type {ruleName} to confirm:": "Escribe {ruleName} para confirmar:",
@@ -1904,7 +1895,6 @@
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see": "Tu menú lateral se adapta a tu rol: lo que no puedes usar simplemente no se muestra. Las secciones de abajo agrupan las capacidades de SkyWalking por área de funcionalidad. Para cada área verás",
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see {menuScope} (which sidebar entries fall in the area) and {actions} (the concrete things you can do, marked per role). Multiple roles add up — a user holding more than one role gets every permission any of their roles grant.": "Tu menú lateral se adapta a tu rol: lo que no puedes usar simplemente no se muestra. Las se [...]
   "Your username is your identity here, and your roles are resolved from it rather than stored against you. A token you hold re-reads them on every request, so removing you from the directory or configuration ends it at once. This browser session keeps the roles it was given until you sign in again.": "Tu nombre de usuario es tu identidad aquí, y tus roles se resuelven a partir de él en lugar de almacenarse contigo. Un token que poseas los vuelve a leer en cada solicitud, así que elimina [...]
-  "Zipkin / OTLP traces": "Trazas Zipkin / OTLP",
   "Zipkin endpoint unreachable": "Endpoint de Zipkin no accesible",
   "Zipkin query failed.": "La consulta a Zipkin falló.",
   "Zipkin trace": "Traza Zipkin",
@@ -3319,5 +3309,102 @@
   "Tool result": "Resultado de la herramienta",
   "message": "mensaje",
   "Tool result, which failed": "Resultado de la herramienta, que falló",
-  "for {id}": "para {id}"
+  "for {id}": "para {id}",
+  "No TraceQL URL is configured for this source. Set oap.traceql in horizon.yaml.": "No hay una URL de TraceQL configurada para esta fuente. Define oap.traceql en horizon.yaml.",
+  "TraceQL is configured but not answering at {url}.": "TraceQL está configurado pero no responde en {url}.",
+  "Builder": "Constructor",
+  "TraceQL": "TraceQL",
+  "Edit as TraceQL": "Editar como TraceQL",
+  "No traces matched.": "Ninguna traza coincide.",
+  "No trace with that id.": "No hay ninguna traza con ese id.",
+  "The trace source is not answering.": "La fuente de trazas no responde.",
+  "This trace source does not report failure": "Esta fuente de trazas no informa de fallos",
+  "Traces · TraceQL · Zipkin": "Trazas · TraceQL · Zipkin",
+  "Traces · TraceQL": "Trazas · TraceQL",
+  "not configured": "sin configurar",
+  "Open trace": "Abrir traza",
+  "Min duration": "Duración mínima",
+  "Max duration": "Duración máxima",
+  "TraceQL query": "Consulta TraceQL",
+  "Shift+Enter runs; a bare trace id opens that trace": "Shift+Intro ejecuta; un id de traza a secas abre esa traza",
+  "capped at {n} — narrow the query": "limitado a {n}: acota la consulta",
+  "{n} tags": "{n} etiquetas",
+  "Intrinsics": "Intrínsecos",
+  "Resource": "Resource",
+  "Span tags": "Etiquetas de span",
+  "This store reported no tags for the selected window.": "Este almacén no informó etiquetas para la ventana seleccionada.",
+  "unset": "sin definir",
+  "This API reads one id per request, so several ids are {n} separate reads.": "Esta API lee un id por petición, así que varios ids son hasta {n} lecturas separadas.",
+  "{n} not found": "{n} no encontrados",
+  "STARTED": "INICIO",
+  "DURATION": "DURACIÓN",
+  "SPANS": "SPANS",
+  "SERVICES": "SERVICIOS",
+  "Parent span ID": "ID del span padre",
+  "Status message": "Mensaje de estado",
+  "Scope": "Scope",
+  "Resource attributes": "Atributos de resource",
+  "Span attributes": "Atributos de span",
+  "COUNT": "RECUENTO",
+  "ERRORS": "ERRORES",
+  "TOTAL": "TOTAL",
+  "AVG": "MEDIA",
+  "MAX": "MÁX",
+  "which trace APIs this layer exposes — one sidebar row each": "qué APIs de trazas expone esta capa: una fila del menú lateral por cada una",
+  "SkyWalking's own trace query.": "La consulta de trazas propia de SkyWalking.",
+  "OAP’s Zipkin v2 API, which also serves the OpenTelemetry spans OAP converts into Zipkin form.": "La API Zipkin v2 de OAP, que también sirve los spans de OpenTelemetry que OAP convierte al formato Zipkin.",
+  "Grafana Tempo’s API over the native spans. Needs OAP’s traceQL module and a configured datasource URL.": "La API de Grafana Tempo sobre los spans nativos. Requiere el módulo traceQL de OAP y una URL de origen de datos configurada.",
+  "Grafana Tempo’s API over the Zipkin spans. Needs OAP’s traceQL module and a configured datasource URL.": "La API de Grafana Tempo sobre los spans de Zipkin. Requiere el módulo traceQL de OAP y una URL de origen de datos configurada.",
+  "Flags": "Flags",
+  "The Tempo API has no notion of a layer, so it lists every service of the store. This pattern narrows the service PICKER only — result rows are never filtered, so cross-service traces still appear.": "La API de Tempo no conoce el concepto de capa, así que lista todos los servicios del almacén. Este patrón solo acota el SELECTOR de servicios: las filas de resultados nunca se filtran, por lo que las trazas entre servicios siguen apareciendo.",
+  "checking {n} statuses…": "comprobando {n} estados…",
+  "Show the full value": "Mostrar el valor completo",
+  "full": "completo",
+  "{n} characters": "{n} caracteres",
+  "Duration range": "Rango de duración",
+  "Trace duration, with a unit — us, ms, s, m, h.": "Duración de la traza, con unidad: us, ms, s, m, h.",
+  "picked from {n}": "seleccionadas de {n}",
+  "{n} not read — the source did not answer": "{n} sin leer: la fuente no respondió",
+  "Tempo API": "API de Tempo",
+  "not available": "no disponible",
+  "no URL configured": "sin URL configurada",
+  "SkyWalking-native spans": "Spans nativos de SkyWalking",
+  "{name} datasource unreachable": "Fuente de datos {name} inaccesible",
+  "Tried {url}. Enable OAP's traceQL module (SW_TRACEQL=default with the datasource switched on) and confirm oap.traceql points at its host:port and context path — {path} by default on port 3200.": "Se intentó {url}. Habilita el módulo traceQL de OAP (SW_TRACEQL=default con la fuente de datos activada) y comprueba que oap.traceql apunte a su host:puerto y ruta de contexto: {path} por defecto en el puerto 3200.",
+  "Trace APIs": "APIs de trazas",
+  "Zipkin v2 · Tempo": "Zipkin v2 · Tempo",
+  "Zipkin v2 REST": "REST de Zipkin v2",
+  "degraded": "degradado",
+  "Tempo API {version}": "API de Tempo {version}",
+  "The endpoints behind a layer's trace rows, each probed on its own. Native traces are not here — they are answered by the GraphQL port above. A red dot in this pane is not a cluster-wide outage: only the trace rows fed by that endpoint are affected, and every other page keeps working.": "Los endpoints detrás de las filas de trazas de una capa, cada uno sondeado por separado. Las trazas nativas no están aquí: las responde el puerto GraphQL de arriba. Un punto rojo en este panel no es un [...]
+  "API": "API",
+  "scope · {scope}": "ámbito · {scope}",
+  "The source refused this query": "La fuente rechazó esta consulta",
+  "Every port of the OAP backend Horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the trace APIs — Zipkin and each TraceQL datasource — feed only the trace rows they serve. Each is polled independently: if one shows red the others can still be green.": "Todos los puertos del backend OAP al que Horizon está conectado. Query/GraphQL (:12800) impulsa todas las páginas de [...]
+  "{n} not read — over the {max}-id limit": "{n} sin leer: por encima del límite de {max} ids",
+  "Any span name": "Cualquier nombre de span",
+  "Schema reference": "Referencia del esquema",
+  "This condition has no value.": "Esta condición no tiene valor.",
+  "Not a complete spanset.": "No es un spanset completo.",
+  "An attribute needs a scope — span.http.method, resource.service.name, or .http.method for the unscoped form.": "Un atributo necesita un ámbito: span.http.method, resource.service.name o .http.method para la forma sin ámbito.",
+  "Not a field in this schema. The intrinsics are duration, name and status; everything else is an attribute and carries a scope.": "No es un campo de este esquema. Los intrínsecos son duration, name y status; todo lo demás es un atributo y lleva un ámbito.",
+  "This attribute belongs to the other store’s schema.": "Este atributo pertenece al esquema del otro almacén.",
+  "A status other than “ok”, “error” or “unset”.": "Un estado distinto de «ok», «error» o «unset».",
+  "Span tags are those this store reported for the window being queried, so the list follows the time range.": "Las etiquetas de span son las que este almacén informó para la ventana consultada, así que la lista sigue al rango de tiempo.",
+  "ok, error or unset": "ok, error o unset",
+  "Service instance (native)": "Instancia de servicio (nativa)",
+  "Peer service (Zipkin)": "Servicio par (Zipkin)",
+  "Trace duration — >, >=, <, <= with us/µs/ms/s/m/h": "Duración de la traza — >, >=, <, <= con us/µs/ms/s/m/h",
+  "Zipkin Traces": "Trazas de Zipkin",
+  "TraceQL - Native": "TraceQL - Native",
+  "TraceQL - Zipkin": "TraceQL - Zipkin",
+  "Zipkin spans": "Spans de Zipkin",
+  "Menu name": "Nombre en el menú",
+  "Ignore case": "Ignorar mayúsculas",
+  "Horizon will not run this expression, so the picker keeps showing every service. It either does not compile, or it can backtrack exponentially — avoid a repeat applied to a group that itself repeats or branches, such as (a+)+.": "Horizon no ejecutará esta expresión, así que el selector seguirá mostrando todos los servicios. O no compila, o puede retroceder exponencialmente: evite una repetición aplicada a un grupo que a su vez se repite o se bifurca, como (a+)+.",
+  "Date and time": "Fecha y hora",
+  "Open the calendar": "Abrir el calendario",
+  "Previous month": "Mes anterior",
+  "Next month": "Mes siguiente",
+  "Now": "Ahora"
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 4894a18c..5f96c24d 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -801,7 +801,6 @@
   "Last-seen activity is tracked in each BFF replica's own memory and is NOT shared across the cluster. This count reflects only the node that served this page.": "La dernière activité est suivie dans la mémoire propre de chaque réplica BFF et N'EST PAS partagée dans le cluster. Ce compteur ne reflète que le nœud qui a servi cette page.",
   "Latency": "Latence",
   "Layer": "Couche",
-  "Layer carries both native and OpenTelemetry/Zipkin traces — their span formats and query conditions differ, so each gets its own trace tab.": "La couche porte à la fois des traces natives et OpenTelemetry/Zipkin — leurs formats de span et leurs conditions de requête diffèrent, chacune a donc son propre onglet de traces.",
   "Layer dashboard editor": "Éditeur de tableau de bord par couche",
   "Layer dashboard is duplicated": "Le tableau de bord du layer est en double",
   "Layer dashboards": "Tableaux de bord par couche",
@@ -1122,12 +1121,10 @@
   "OAP unreachable — page is read-only": "OAP injoignable — la page est en lecture seule",
   "OAP unreachable.": "OAP injoignable.",
   "OAP unreachable: {error}": "OAP injoignable : {error}",
-  "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "Endpoint Zipkin v2 d'OAP, source du menu de traces OpenTelemetry et Zipkin (visible quand la source de traces d'un layer est zipkin ou both). C'est la seule page concernée — s'il est inaccessible, les traces natives et toutes les autres pa [...]
   "OAP-stored": "stocké sur OAP",
   "OAuth": "OAuth",
   "OIDC": "OIDC",
   "OK": "OK",
-  "OTel & Zipkin Traces": "Traces OTel et Zipkin",
   "Observability Analysis Language": "Observability Analysis Language",
   "Off": "Désactivé",
   "Off:": "Désactivé :",
@@ -1149,7 +1146,6 @@
   "Open the Topology tab and narrow the scope to see a complete map.": "Ouvrez l'onglet Topologie et restreignez la portée pour voir une carte complète.",
   "Open the originating trace": "Ouvrir la trace d'origine",
   "Open {name} in {layer} (new tab)": "Ouvrir {name} dans {layer} (nouvel onglet)",
-  "OpenTelemetry & Zipkin": "OpenTelemetry et Zipkin",
   "Operate": "Exploitation",
   "Operate · Alerting": "Exploitation · Alertes",
   "Operate · Data retention": "Exploitation · Rétention des données",
@@ -1225,7 +1221,6 @@
   "Pick instances, then click Analyze.": "Choisissez des instances, puis cliquez sur Analyser.",
   "Pick processes": "Choisir des processus",
   "Pick refresh interval": "Choisir l'intervalle d'actualisation",
-  "Pick the trace backend this layer reads from.": "Choisissez le backend de traces que cette couche interroge.",
   "Pick your conditions, then click Run query.": "Choisissez vos conditions, puis cliquez sur Exécuter la requête.",
   "Pin process {name}": "Épingler le processus {name}",
   "Pin the OAP layers that get their own KPI tile at the top of": "Épinglez les couches OAP qui obtiennent leur propre tuile KPI en haut de",
@@ -1571,7 +1566,6 @@
   "Single sign-on is not configured on this server.": "L’authentification unique n’est pas configurée sur ce serveur.",
   "Single sign-on via {provider}": "Authentification unique via {provider}",
   "SkyWalking Horizon": "SkyWalking Horizon",
-  "SkyWalking query-protocol traces (agent-instrumented).": "Traces du query-protocol SkyWalking (instrumentées par agent).",
   "Slowest": "Plus lents",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "Certaines métriques n'ont pas pu être chargées ({failed} lots sur {total} en échec) — les valeurs vides peuvent être indisponibles, et non nulles.",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "Certaines métriques n'ont pas pu être chargées ({failed} lots sur {total} en échec) — certains endpoints ou liens peuvent être manquants.",
@@ -1772,10 +1766,8 @@
   "Trace explorer scoped to this layer.": "Explorateur de traces limité à cette couche.",
   "Trace inspect": "Inspection des traces",
   "Trace profiling requires an endpoint, and this proposal carries none.": "Le profilage de traces nécessite un endpoint, or cette proposition n'en contient aucun.",
-  "Trace source": "Source des traces",
   "Trace-driven thread profiling — the original SkyWalking profile.": "Profilage de threads piloté par les traces — le profilage SkyWalking d'origine.",
   "Traces": "Traces",
-  "Traces emitted from the OpenTelemetry & Zipkin ecosystem.": "Traces émises par l'écosystème OpenTelemetry et Zipkin.",
   "Traces feed failed.": "Échec du flux de traces.",
   "Traces · Zipkin": "Traces · Zipkin",
   "Traffic": "Trafic",
@@ -1794,7 +1786,6 @@
   "Turn auto-refresh on": "Activer l’actualisation automatique",
   "Turn off and delete": "Désactiver et supprimer",
   "Turn off {component}?": "Désactiver {component} ?",
-  "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "Vue à deux ports du backend OAP auquel Horizon est connecté. Query / GraphQL (:12800) alimente chaque page d'observabilité ; l'hôte d'ad [...]
   "Type": "Saisissez",
   "Type a name — the assistant will find it": "Saisissez un nom ; l'assistant le trouvera",
   "Type {ruleName} to confirm:": "Tapez {ruleName} pour confirmer :",
@@ -1904,7 +1895,6 @@
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see": "Votre barre latérale s'adapte à votre rôle : tout ce que vous ne pouvez pas utiliser n'est simplement pas affiché. Les sections ci-dessous regroupent les capacités de SkyWalking par domaine fonctionnel. Pour chaque domaine, vous verrez",
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see {menuScope} (which sidebar entries fall in the area) and {actions} (the concrete things you can do, marked per role). Multiple roles add up — a user holding more than one role gets every permission any of their roles grant.": "Votre barre latérale s'adapte à votre rôle : tout ce que vous ne pouvez pas utiliser n'est  [...]
   "Your username is your identity here, and your roles are resolved from it rather than stored against you. A token you hold re-reads them on every request, so removing you from the directory or configuration ends it at once. This browser session keeps the roles it was given until you sign in again.": "Votre nom d'utilisateur est votre identité ici, et vos rôles en sont déduits plutôt que stockés à votre nom. Un jeton que vous détenez les relit à chaque requête : vous retirer de l'annuai [...]
-  "Zipkin / OTLP traces": "Traces Zipkin / OTLP",
   "Zipkin endpoint unreachable": "Endpoint Zipkin inaccessible",
   "Zipkin query failed.": "Échec de la requête Zipkin.",
   "Zipkin trace": "Trace Zipkin",
@@ -3319,5 +3309,102 @@
   "Tool result": "Résultat d’outil",
   "message": "message",
   "Tool result, which failed": "Résultat de l’outil, en échec",
-  "for {id}": "pour {id}"
+  "for {id}": "pour {id}",
+  "No TraceQL URL is configured for this source. Set oap.traceql in horizon.yaml.": "Aucune URL TraceQL n'est configurée pour cette source. Renseignez oap.traceql dans horizon.yaml.",
+  "TraceQL is configured but not answering at {url}.": "TraceQL est configuré mais ne répond pas sur {url}.",
+  "Builder": "Assistant",
+  "TraceQL": "TraceQL",
+  "Edit as TraceQL": "Modifier en TraceQL",
+  "No traces matched.": "Aucune trace ne correspond.",
+  "No trace with that id.": "Aucune trace avec cet identifiant.",
+  "The trace source is not answering.": "La source de traces ne répond pas.",
+  "This trace source does not report failure": "Cette source de traces ne signale pas les échecs",
+  "Traces · TraceQL · Zipkin": "Traces · TraceQL · Zipkin",
+  "Traces · TraceQL": "Traces · TraceQL",
+  "not configured": "non configuré",
+  "Open trace": "Ouvrir la trace",
+  "Min duration": "Durée minimale",
+  "Max duration": "Durée maximale",
+  "TraceQL query": "Requête TraceQL",
+  "Shift+Enter runs; a bare trace id opens that trace": "Maj+Entrée exécute ; un identifiant de trace seul ouvre cette trace",
+  "capped at {n} — narrow the query": "limité à {n} — affinez la requête",
+  "{n} tags": "{n} tags",
+  "Intrinsics": "Intrinsèques",
+  "Resource": "Resource",
+  "Span tags": "Tags de span",
+  "This store reported no tags for the selected window.": "Ce stockage n'a signalé aucun tag pour la fenêtre sélectionnée.",
+  "unset": "non défini",
+  "This API reads one id per request, so several ids are {n} separate reads.": "Cette API lit un identifiant par requête : plusieurs identifiants représentent donc jusqu'à {n} lectures distinctes.",
+  "{n} not found": "{n} introuvables",
+  "STARTED": "DÉBUT",
+  "DURATION": "DURÉE",
+  "SPANS": "SPANS",
+  "SERVICES": "SERVICES",
+  "Parent span ID": "ID du span parent",
+  "Status message": "Message de statut",
+  "Scope": "Scope",
+  "Resource attributes": "Attributs de resource",
+  "Span attributes": "Attributs de span",
+  "COUNT": "NOMBRE",
+  "ERRORS": "ERREURS",
+  "TOTAL": "TOTAL",
+  "AVG": "MOY",
+  "MAX": "MAX",
+  "which trace APIs this layer exposes — one sidebar row each": "quelles API de traces cette couche expose — une entrée de barre latérale chacune",
+  "SkyWalking's own trace query.": "La requête de traces native de SkyWalking.",
+  "OAP’s Zipkin v2 API, which also serves the OpenTelemetry spans OAP converts into Zipkin form.": "L’API Zipkin v2 d’OAP, qui sert aussi les spans OpenTelemetry qu’OAP convertit au format Zipkin.",
+  "Grafana Tempo’s API over the native spans. Needs OAP’s traceQL module and a configured datasource URL.": "L’API de Grafana Tempo sur les spans natifs. Nécessite le module traceQL d’OAP et une URL de source de données configurée.",
+  "Grafana Tempo’s API over the Zipkin spans. Needs OAP’s traceQL module and a configured datasource URL.": "L’API de Grafana Tempo sur les spans Zipkin. Nécessite le module traceQL d’OAP et une URL de source de données configurée.",
+  "Flags": "Drapeaux",
+  "The Tempo API has no notion of a layer, so it lists every service of the store. This pattern narrows the service PICKER only — result rows are never filtered, so cross-service traces still appear.": "L’API Tempo ignore la notion de couche : elle liste tous les services du magasin. Ce motif restreint uniquement le SÉLECTEUR de services — les lignes de résultats ne sont jamais filtrées, les traces inter-services restent donc visibles.",
+  "checking {n} statuses…": "vérification de {n} statuts…",
+  "Show the full value": "Afficher la valeur complète",
+  "full": "complet",
+  "{n} characters": "{n} caractères",
+  "Duration range": "Plage de durée",
+  "Trace duration, with a unit — us, ms, s, m, h.": "Durée de la trace, avec une unité — us, ms, s, m, h.",
+  "picked from {n}": "sélectionnées parmi {n}",
+  "{n} not read — the source did not answer": "{n} non lus — la source n’a pas répondu",
+  "Tempo API": "API Tempo",
+  "not available": "indisponible",
+  "no URL configured": "aucune URL configurée",
+  "SkyWalking-native spans": "Spans natifs SkyWalking",
+  "{name} datasource unreachable": "Source de données {name} injoignable",
+  "Tried {url}. Enable OAP's traceQL module (SW_TRACEQL=default with the datasource switched on) and confirm oap.traceql points at its host:port and context path — {path} by default on port 3200.": "{url} a été tenté. Activez le module traceQL d’OAP (SW_TRACEQL=default avec la source de données activée) et vérifiez que oap.traceql pointe vers son host:port et son chemin de contexte — {path} par défaut sur le port 3200.",
+  "Trace APIs": "API de traces",
+  "Zipkin v2 · Tempo": "Zipkin v2 · Tempo",
+  "Zipkin v2 REST": "REST Zipkin v2",
+  "degraded": "dégradé",
+  "Tempo API {version}": "API Tempo {version}",
+  "The endpoints behind a layer's trace rows, each probed on its own. Native traces are not here — they are answered by the GraphQL port above. A red dot in this pane is not a cluster-wide outage: only the trace rows fed by that endpoint are affected, and every other page keeps working.": "Les points d’accès derrière les entrées de traces d’une couche, sondés séparément. Les traces natives ne figurent pas ici : elles sont servies par le port GraphQL ci-dessus. Un point rouge dans ce pann [...]
+  "API": "API",
+  "scope · {scope}": "portée · {scope}",
+  "The source refused this query": "La source a refusé cette requête",
+  "Every port of the OAP backend Horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the trace APIs — Zipkin and each TraceQL datasource — feed only the trace rows they serve. Each is polled independently: if one shows red the others can still be green.": "Chaque port du backend OAP auquel Horizon est connecté. Query/GraphQL (:12800) alimente toutes les pages d’observabi [...]
+  "{n} not read — over the {max}-id limit": "{n} non lus — au-delà de la limite de {max} identifiants",
+  "Any span name": "N’importe quel nom de span",
+  "Schema reference": "Référence du schéma",
+  "This condition has no value.": "Cette condition n'a pas de valeur.",
+  "Not a complete spanset.": "Spanset incomplet.",
+  "An attribute needs a scope — span.http.method, resource.service.name, or .http.method for the unscoped form.": "Un attribut a besoin d'une portée — span.http.method, resource.service.name, ou .http.method pour la forme sans portée.",
+  "Not a field in this schema. The intrinsics are duration, name and status; everything else is an attribute and carries a scope.": "Ce n'est pas un champ de ce schéma. Les intrinsèques sont duration, name et status ; tout le reste est un attribut et porte une portée.",
+  "This attribute belongs to the other store’s schema.": "Cet attribut appartient au schéma de l'autre magasin.",
+  "A status other than “ok”, “error” or “unset”.": "Un statut autre que « ok », « error » ou « unset ».",
+  "Span tags are those this store reported for the window being queried, so the list follows the time range.": "Les étiquettes de span sont celles que ce magasin a signalées pour la fenêtre interrogée : la liste suit donc la plage de temps.",
+  "ok, error or unset": "ok, error ou unset",
+  "Service instance (native)": "Instance de service (native)",
+  "Peer service (Zipkin)": "Service pair (Zipkin)",
+  "Trace duration — >, >=, <, <= with us/µs/ms/s/m/h": "Durée de la trace — >, >=, <, <= avec us/µs/ms/s/m/h",
+  "Zipkin Traces": "Traces Zipkin",
+  "TraceQL - Native": "TraceQL - Native",
+  "TraceQL - Zipkin": "TraceQL - Zipkin",
+  "Zipkin spans": "Spans Zipkin",
+  "Menu name": "Nom dans le menu",
+  "Ignore case": "Ignorer la casse",
+  "Horizon will not run this expression, so the picker keeps showing every service. It either does not compile, or it can backtrack exponentially — avoid a repeat applied to a group that itself repeats or branches, such as (a+)+.": "Horizon n'exécutera pas cette expression, le sélecteur continue donc d'afficher tous les services. Soit elle ne compile pas, soit elle peut revenir en arrière de façon exponentielle — évitez une répétition appliquée à un groupe qui se répète ou se ramifie lui [...]
+  "Date and time": "Date et heure",
+  "Open the calendar": "Ouvrir le calendrier",
+  "Previous month": "Mois précédent",
+  "Next month": "Mois suivant",
+  "Now": "Maintenant"
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index b8d75622..4251b7b4 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -801,7 +801,6 @@
   "Last-seen activity is tracked in each BFF replica's own memory and is NOT shared across the cluster. This count reflects only the node that served this page.": "最終確認のアクティビティは BFF 各レプリカ自身のメモリに保持され、クラスタ間では共有されません。このカウントはこのページを提供したノードのみを反映します。",
   "Latency": "レイテンシー",
   "Layer": "レイヤー",
-  "Layer carries both native and OpenTelemetry/Zipkin traces — their span formats and query conditions differ, so each gets its own trace tab.": "レイヤーがネイティブと OpenTelemetry / Zipkin の両方のトレースを持つ場合です。スパン形式とクエリ条件が異なるため、それぞれ専用のトレースタブが用意されます。",
   "Layer dashboard editor": "レイヤーダッシュボードエディタ",
   "Layer dashboard is duplicated": "レイヤーのダッシュボードが重複しています",
   "Layer dashboards": "レイヤーダッシュボード",
@@ -1122,12 +1121,10 @@
   "OAP unreachable — page is read-only": "OAP に到達できません — ページは読み取り専用です",
   "OAP unreachable.": "OAP に接続できません。",
   "OAP unreachable: {error}": "OAP に接続できません: {error}",
-  "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "OAP の Zipkin v2 エンドポイントで、OpenTelemetry および Zipkin トレース メニューのデータ ソースです(レイヤーの trace ソースが zipkin または both のときに表示)。影響を受けるのはこのページのみで、到達不可でもネイティブ トレースおよび他の可観測ページはそのまま動作します。",
   "OAP-stored": "OAP 保存版",
   "OAuth": "OAuth",
   "OIDC": "OIDC",
   "OK": "OK",
-  "OTel & Zipkin Traces": "OTel & Zipkin トレース",
   "Observability Analysis Language": "Observability Analysis Language",
   "Off": "オフ",
   "Off:": "オフ:",
@@ -1149,7 +1146,6 @@
   "Open the Topology tab and narrow the scope to see a complete map.": "トポロジータブを開いて範囲を絞ると、全体のマップを表示できます。",
   "Open the originating trace": "元のトレースを開く",
   "Open {name} in {layer} (new tab)": "{name} を {layer} で開く(新しいタブ)",
-  "OpenTelemetry & Zipkin": "OpenTelemetry & Zipkin",
   "Operate": "運用",
   "Operate · Alerting": "運用 · アラート",
   "Operate · Data retention": "運用 · データ保持",
@@ -1225,7 +1221,6 @@
   "Pick instances, then click Analyze.": "インスタンスを選択して、「解析」をクリックしてください。",
   "Pick processes": "プロセスを選択",
   "Pick refresh interval": "更新間隔を選択",
-  "Pick the trace backend this layer reads from.": "このレイヤーが読み取るトレースのバックエンドを選択します。",
   "Pick your conditions, then click Run query.": "条件を指定して「クエリを実行」をクリックしてください。",
   "Pin process {name}": "プロセス {name} を選択",
   "Pin the OAP layers that get their own KPI tile at the top of": "次の上部に独自の KPI タイルを持つ OAP レイヤーをピン留めします:",
@@ -1571,7 +1566,6 @@
   "Single sign-on is not configured on this server.": "このサーバーではシングルサインオンが設定されていません。",
   "Single sign-on via {provider}": "{provider} によるシングルサインオン",
   "SkyWalking Horizon": "SkyWalking Horizon",
-  "SkyWalking query-protocol traces (agent-instrumented).": "SkyWalking の query-protocol によるトレース(エージェント計装)。",
   "Slowest": "遅い順",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "一部のメトリクスを読み込めませんでした({total} バッチ中 {failed} バッチが失敗)。空白の値はゼロではなく、取得できなかった可能性があります。",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "一部のメトリクスを読み込めませんでした({total} バッチ中 {failed} バッチが失敗)。一部のエンドポイントやリンクが欠落している可能性があります。",
@@ -1772,10 +1766,8 @@
   "Trace explorer scoped to this layer.": "このレイヤーに絞ったトレースエクスプローラーです。",
   "Trace inspect": "トレース調査",
   "Trace profiling requires an endpoint, and this proposal carries none.": "トレースプロファイリングには Endpoint が必要ですが、この提案には含まれていません。",
-  "Trace source": "トレースのソース",
   "Trace-driven thread profiling — the original SkyWalking profile.": "トレース起点のスレッドプロファイリング — SkyWalking 従来のプロファイル機能です。",
   "Traces": "トレース",
-  "Traces emitted from the OpenTelemetry & Zipkin ecosystem.": "OpenTelemetry / Zipkin エコシステムから送信されるトレース。",
   "Traces feed failed.": "トレースの取得に失敗しました。",
   "Traces · Zipkin": "トレース · Zipkin",
   "Traffic": "トラフィック",
@@ -1794,7 +1786,6 @@
   "Turn auto-refresh on": "自動更新をオンにする",
   "Turn off and delete": "無効にして削除",
   "Turn off {component}?": "{component} を無効にしますか?",
-  "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "Horizon が接続している OAP バックエンドの二ポートビュー。Query / GraphQL (:12800) はすべての可観測性ページを駆動し、管理ホスト (:17128) は DSL 管理、ライブデバッガー、メトリック検査、Dump を制御し、Zipkin / [...]
   "Type": "次を入力",
   "Type a name — the assistant will find it": "名前を入力してください。アシスタントが見つけます",
   "Type {ruleName} to confirm:": "確認のため {ruleName} を入力してください:",
@@ -1904,7 +1895,6 @@
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see": "サイドバーはロールに合わせて変化します: 使用できないものは単に表示されません。以下のセクションでは SkyWalking の機能を領域ごとにグループ化しています。各領域では次が表示されます:",
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see {menuScope} (which sidebar entries fall in the area) and {actions} (the concrete things you can do, marked per role). Multiple roles add up — a user holding more than one role gets every permission any of their roles grant.": "サイドバーはロールに合わせて変化します: 使用できないものは単に表示されません。以下のセクションでは SkyWalking の機能を領域ごとにグループ化しています。各領域では {me [...]
   "Your username is your identity here, and your roles are resolved from it rather than stored against you. A token you hold re-reads them on every request, so removing you from the directory or configuration ends it at once. This browser session keeps the roles it was given until you sign in again.": "ここではユーザー名があなたの識別子であり、ロールはあなたに保存されるのではなく、そこから解決されます。保有しているトークンはリクエストのたびにロールを読み直すため、ディレクトリや設定からあなたを削除すると直ちに無効になります。このブラウザーセッションは、再度サインインするまで付与されたロールを保持します。",
-  "Zipkin / OTLP traces": "Zipkin / OTLP トレース",
   "Zipkin endpoint unreachable": "Zipkin エンドポイントに到達できません",
   "Zipkin query failed.": "Zipkin クエリに失敗しました。",
   "Zipkin trace": "Zipkin トレース",
@@ -3319,5 +3309,102 @@
   "Tool result": "ツール結果",
   "message": "メッセージ",
   "Tool result, which failed": "ツールの結果(失敗)",
-  "for {id}": "{id} に対して"
+  "for {id}": "{id} に対して",
+  "No TraceQL URL is configured for this source. Set oap.traceql in horizon.yaml.": "このソースには TraceQL の URL が設定されていません。horizon.yaml の oap.traceql を設定してください。",
+  "TraceQL is configured but not answering at {url}.": "TraceQL は設定されていますが、{url} で応答がありません。",
+  "Builder": "ビルダー",
+  "TraceQL": "TraceQL",
+  "Edit as TraceQL": "TraceQL として編集",
+  "No traces matched.": "一致するトレースはありません。",
+  "No trace with that id.": "その ID のトレースはありません。",
+  "The trace source is not answering.": "トレースのデータソースが応答していません。",
+  "This trace source does not report failure": "このトレースのデータソースは失敗を報告しません",
+  "Traces · TraceQL · Zipkin": "トレース · TraceQL · Zipkin",
+  "Traces · TraceQL": "トレース · TraceQL",
+  "not configured": "未設定",
+  "Open trace": "トレースを開く",
+  "Min duration": "最小時間",
+  "Max duration": "最大時間",
+  "TraceQL query": "TraceQL クエリ",
+  "Shift+Enter runs; a bare trace id opens that trace": "Shift+Enter で実行。トレース ID だけを入力するとそのトレースが開きます",
+  "capped at {n} — narrow the query": "{n} 件で打ち切り — クエリを絞り込んでください",
+  "{n} tags": "{n} 個のタグ",
+  "Intrinsics": "Intrinsic",
+  "Resource": "Resource",
+  "Span tags": "Span タグ",
+  "This store reported no tags for the selected window.": "このストアは選択した期間のタグを返しませんでした。",
+  "unset": "未設定",
+  "This API reads one id per request, so several ids are {n} separate reads.": "この API は 1 リクエストにつき 1 件の ID しか読めないため、複数指定すると最大 {n} 回の個別リクエストになります。",
+  "{n} not found": "{n} 件が見つかりません",
+  "STARTED": "開始",
+  "DURATION": "所要時間",
+  "SPANS": "SPAN 数",
+  "SERVICES": "サービス数",
+  "Parent span ID": "親 Span ID",
+  "Status message": "ステータスメッセージ",
+  "Scope": "Scope",
+  "Resource attributes": "Resource 属性",
+  "Span attributes": "Span 属性",
+  "COUNT": "件数",
+  "ERRORS": "エラー",
+  "TOTAL": "合計",
+  "AVG": "平均",
+  "MAX": "最大",
+  "which trace APIs this layer exposes — one sidebar row each": "このレイヤーが公開するトレース API — それぞれがサイドバーの 1 行になります",
+  "SkyWalking's own trace query.": "SkyWalking 自身のトレースクエリ。",
+  "OAP’s Zipkin v2 API, which also serves the OpenTelemetry spans OAP converts into Zipkin form.": "OAP の Zipkin v2 API。OAP が Zipkin 形式に変換した OpenTelemetry のスパンもここから返されます。",
+  "Grafana Tempo’s API over the native spans. Needs OAP’s traceQL module and a configured datasource URL.": "ネイティブスパンに対する Grafana Tempo の API。OAP の traceQL モジュールとデータソース URL の設定が必要です。",
+  "Grafana Tempo’s API over the Zipkin spans. Needs OAP’s traceQL module and a configured datasource URL.": "Zipkin スパンに対する Grafana Tempo の API。OAP の traceQL モジュールとデータソース URL の設定が必要です。",
+  "Flags": "フラグ",
+  "The Tempo API has no notion of a layer, so it lists every service of the store. This pattern narrows the service PICKER only — result rows are never filtered, so cross-service traces still appear.": "Tempo API にレイヤーの概念はなく、ストア内のすべてのサービスを列挙します。このパターンが絞り込むのはサービスの選択リストだけで、結果の行は一切フィルタされないため、サービスをまたぐトレースもそのまま表示されます。",
+  "checking {n} statuses…": "{n} 件のステータスを確認中…",
+  "Show the full value": "値を全文表示",
+  "full": "全文",
+  "{n} characters": "{n} 文字",
+  "Duration range": "継続時間の範囲",
+  "Trace duration, with a unit — us, ms, s, m, h.": "トレースの所要時間(単位付き — us, ms, s, m, h)。",
+  "picked from {n}": "{n} 件中から選択",
+  "{n} not read — the source did not answer": "{n} 件は読み取れませんでした — ソースが応答しませんでした",
+  "Tempo API": "Tempo API",
+  "not available": "利用不可",
+  "no URL configured": "URL 未設定",
+  "SkyWalking-native spans": "SkyWalking ネイティブ span",
+  "{name} datasource unreachable": "データソース {name} に到達できません",
+  "Tried {url}. Enable OAP's traceQL module (SW_TRACEQL=default with the datasource switched on) and confirm oap.traceql points at its host:port and context path — {path} by default on port 3200.": "{url} を試しました。OAP の traceQL モジュールを有効化し(SW_TRACEQL=default とデータソースの有効化)、oap.traceql がその host:port とコンテキストパスを指しているか確認してください。既定はポート 3200 の {path} です。",
+  "Trace APIs": "トレース API",
+  "Zipkin v2 · Tempo": "Zipkin v2・Tempo",
+  "Zipkin v2 REST": "Zipkin v2 REST",
+  "degraded": "一部利用不可",
+  "Tempo API {version}": "Tempo API {version}",
+  "The endpoints behind a layer's trace rows, each probed on its own. Native traces are not here — they are answered by the GraphQL port above. A red dot in this pane is not a cluster-wide outage: only the trace rows fed by that endpoint are affected, and every other page keeps working.": "レイヤーのトレース行を支えるエンドポイントで、それぞれ個別に確認します。ネイティブトレースはここにはなく、上の GraphQL ポートが応答します。この領域の赤い点はクラスタ全体の障害ではありません。影響を受けるのはそのエンドポイントが支えるトレース行だけで、他のページは動き続けます。",
+  "API": "API",
+  "scope · {scope}": "スコープ · {scope}",
+  "The source refused this query": "ソースがこのクエリを拒否しました",
+  "Every port of the OAP backend Horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the trace APIs — Zipkin and each TraceQL datasource — feed only the trace rows they serve. Each is polled independently: if one shows red the others can still be green.": "Horizon が接続している OAP バックエンドの各ポートです。Query/GraphQL(:12800)はすべての可観測性ページを支え、管理ホスト(:17128)は DSL 管理・Live Debugger・Metrics I [...]
+  "{n} not read — over the {max}-id limit": "{n} 件は未読取 — {max} 件の上限を超えています",
+  "Any span name": "任意の span 名",
+  "Schema reference": "スキーマリファレンス",
+  "This condition has no value.": "この条件には値がありません。",
+  "Not a complete spanset.": "spanset が完結していません。",
+  "An attribute needs a scope — span.http.method, resource.service.name, or .http.method for the unscoped form.": "属性にはスコープが必要です — span.http.method、resource.service.name、またはスコープなしの形 .http.method と書いてください。",
+  "Not a field in this schema. The intrinsics are duration, name and status; everything else is an attribute and carries a scope.": "このスキーマのフィールドではありません。intrinsic は duration、name、status で、それ以外はすべてスコープを伴う属性です。",
+  "This attribute belongs to the other store’s schema.": "この属性はもう一方のストアのスキーマに属します。",
+  "A status other than “ok”, “error” or “unset”.": "「ok」「error」「unset」以外の status。",
+  "Span tags are those this store reported for the window being queried, so the list follows the time range.": "スパンタグは、このストアが照会対象の期間について報告したものです。そのため一覧は時間範囲に従います。",
+  "ok, error or unset": "ok、error、unset",
+  "Service instance (native)": "サービスインスタンス(ネイティブ)",
+  "Peer service (Zipkin)": "ピアサービス(Zipkin)",
+  "Trace duration — >, >=, <, <= with us/µs/ms/s/m/h": "トレース時間 — us/µs/ms/s/m/h を使って >、>=、<、<=",
+  "Zipkin Traces": "Zipkin トレース",
+  "TraceQL - Native": "TraceQL - Native",
+  "TraceQL - Zipkin": "TraceQL - Zipkin",
+  "Zipkin spans": "Zipkin スパン",
+  "Menu name": "メニュー名",
+  "Ignore case": "大文字と小文字を区別しない",
+  "Horizon will not run this expression, so the picker keeps showing every service. It either does not compile, or it can backtrack exponentially — avoid a repeat applied to a group that itself repeats or branches, such as (a+)+.": "Horizon はこの式を実行しないため、ピッカーには引き続きすべてのサービスが表示されます。コンパイルできないか、バックトラックが指数的に増える可能性があります。(a+)+ のように、繰り返しや分岐を含むグループへの繰り返しは避けてください。",
+  "Date and time": "日時",
+  "Open the calendar": "カレンダーを開く",
+  "Previous month": "前の月",
+  "Next month": "次の月",
+  "Now": "現在"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index c251b19e..df091318 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -801,7 +801,6 @@
   "Last-seen activity is tracked in each BFF replica's own memory and is NOT shared across the cluster. This count reflects only the node that served this page.": "마지막 접속 활동은 각 BFF 복제본의 자체 메모리에서 추적되며 클러스터 간에 공유되지 않습니다. 이 수치는 이 페이지를 제공한 노드만 반영합니다.",
   "Latency": "지연 시간",
   "Layer": "레이어",
-  "Layer carries both native and OpenTelemetry/Zipkin traces — their span formats and query conditions differ, so each gets its own trace tab.": "이 레이어는 네이티브 트레이스와 OpenTelemetry/Zipkin 트레이스를 모두 수집합니다 — 스팬 형식과 조회 조건이 다르므로 각각 별도의 트레이스 탭으로 제공됩니다.",
   "Layer dashboard editor": "레이어 대시보드 편집기",
   "Layer dashboard is duplicated": "레이어 대시보드가 중복되었습니다",
   "Layer dashboards": "레이어 대시보드",
@@ -1122,12 +1121,10 @@
   "OAP unreachable — page is read-only": "OAP에 연결할 수 없습니다 — 페이지가 읽기 전용입니다",
   "OAP unreachable.": "OAP에 연결할 수 없습니다.",
   "OAP unreachable: {error}": "OAP에 연결할 수 없음: {error}",
-  "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "OAP의 Zipkin v2 엔드포인트로, OpenTelemetry 및 Zipkin 트레이스 메뉴의 데이터 소스입니다(레이어의 trace 소스가 zipkin 또는 both일 때 표시). 영향을 받는 페이지는 이 페이지뿐이며, 연결 불가 상태여도 네이티브 트레이스와 다른 모든 옵저버빌리티 페이지는 정상 동작합니다.",
   "OAP-stored": "OAP 저장본",
   "OAuth": "OAuth",
   "OIDC": "OIDC",
   "OK": "OK",
-  "OTel & Zipkin Traces": "OTel & Zipkin 트레이스",
   "Observability Analysis Language": "Observability Analysis Language",
   "Off": "꺼짐",
   "Off:": "끄면:",
@@ -1149,7 +1146,6 @@
   "Open the Topology tab and narrow the scope to see a complete map.": "전체 맵을 보려면 토폴로지 탭에서 범위를 좁히세요.",
   "Open the originating trace": "원본 트레이스 열기",
   "Open {name} in {layer} (new tab)": "{name}을(를) {layer}에서 열기 (새 탭)",
-  "OpenTelemetry & Zipkin": "OpenTelemetry & Zipkin",
   "Operate": "운영",
   "Operate · Alerting": "운영 · 알림",
   "Operate · Data retention": "운영 · 데이터 보존",
@@ -1225,7 +1221,6 @@
   "Pick instances, then click Analyze.": "인스턴스를 선택한 뒤 분석을 클릭하세요.",
   "Pick processes": "프로세스 선택",
   "Pick refresh interval": "새로 고침 간격 선택",
-  "Pick the trace backend this layer reads from.": "이 레이어가 트레이스를 읽어올 백엔드를 선택하세요.",
   "Pick your conditions, then click Run query.": "조건을 선택한 다음 쿼리 실행을 클릭하세요.",
   "Pin process {name}": "프로세스 {name} 고정",
   "Pin the OAP layers that get their own KPI tile at the top of": "다음 페이지 상단에 전용 KPI 타일이 표시되는 OAP 레이어를 고정:",
@@ -1571,7 +1566,6 @@
   "Single sign-on is not configured on this server.": "이 서버에는 싱글 사인온이 구성되어 있지 않습니다.",
   "Single sign-on via {provider}": "{provider}을(를) 통한 싱글 사인온",
   "SkyWalking Horizon": "SkyWalking Horizon",
-  "SkyWalking query-protocol traces (agent-instrumented).": "SkyWalking query-protocol 트레이스 (에이전트 계측).",
   "Slowest": "느린 순",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "일부 지표를 불러오지 못했습니다 ({total}개 배치 중 {failed}개 실패) — 빈 값은 0이 아니라 사용할 수 없는 값일 수 있습니다.",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "일부 지표를 불러오지 못했습니다 ({total}개 배치 중 {failed}개 실패) — 일부 엔드포인트나 링크가 누락되었을 수 있습니다.",
@@ -1772,10 +1766,8 @@
   "Trace explorer scoped to this layer.": "이 레이어로 범위가 한정된 트레이스 탐색기입니다.",
   "Trace inspect": "트레이스 조회",
   "Trace profiling requires an endpoint, and this proposal carries none.": "트레이스 프로파일링에는 엔드포인트가 필요하지만 이 제안에는 포함되어 있지 않습니다.",
-  "Trace source": "트레이스 소스",
   "Trace-driven thread profiling — the original SkyWalking profile.": "트레이스 기반 스레드 프로파일링 — SkyWalking의 원래 프로파일 기능입니다.",
   "Traces": "트레이스",
-  "Traces emitted from the OpenTelemetry & Zipkin ecosystem.": "OpenTelemetry & Zipkin 생태계에서 전송된 트레이스입니다.",
   "Traces feed failed.": "트레이스 피드를 가져오지 못했습니다.",
   "Traces · Zipkin": "트레이스 · Zipkin",
   "Traffic": "트래픽",
@@ -1794,7 +1786,6 @@
   "Turn auto-refresh on": "자동 새로 고침 켜기",
   "Turn off and delete": "끄고 삭제",
   "Turn off {component}?": "{component}을(를) 끌까요?",
-  "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "Horizon이 연결된 OAP 백엔드의 두 포트 보기입니다. Query / GraphQL (:12800)은 모든 관측 페이지를 구동하고, 관리 호스트 (:17128)는 DSL 관리·라이브 디버거·메트릭 검사·덤프를 통제하며, Zipkin / O [...]
   "Type": "유형",
   "Type a name — the assistant will find it": "이름을 입력하면 어시스턴트가 찾아줍니다",
   "Type {ruleName} to confirm:": "확인하려면 {ruleName}을(를) 입력하세요:",
@@ -1904,7 +1895,6 @@
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see": "사이드바는 역할에 맞춰 자동으로 조정됩니다: 사용할 수 없는 항목은 표시되지 않습니다. 아래 섹션은 SkyWalking의 기능을 영역별로 분류합니다. 각 영역에서 다음을 볼 수 있습니다:",
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see {menuScope} (which sidebar entries fall in the area) and {actions} (the concrete things you can do, marked per role). Multiple roles add up — a user holding more than one role gets every permission any of their roles grant.": "사이드바는 역할에 맞춰 자동으로 조정됩니다: 사용할 수 없는 항목은 표시되지 않습니다. 아래 섹션은 SkyWalking의 기능을 영역별로 분류합니다. 각 영역에서는 [...]
   "Your username is your identity here, and your roles are resolved from it rather than stored against you. A token you hold re-reads them on every request, so removing you from the directory or configuration ends it at once. This browser session keeps the roles it was given until you sign in again.": "여기서는 사용자 이름이 곧 신원이며, 역할은 사용자에게 저장되는 것이 아니라 이름으로부터 확인됩니다. 보유한 토큰은 요청마다 역할을 다시 읽으므로 디렉터리나 설정에서 계정을 제거하면 즉시 무효화됩니다. 이 브라우저 세션은 다시 로그인할 때까지 부여받은 역할을 유지합니다.",
-  "Zipkin / OTLP traces": "Zipkin / OTLP 트레이스",
   "Zipkin endpoint unreachable": "Zipkin 엔드포인트에 연결할 수 없음",
   "Zipkin query failed.": "Zipkin 쿼리를 실행하지 못했습니다.",
   "Zipkin trace": "Zipkin 트레이스",
@@ -3319,5 +3309,102 @@
   "Tool result": "도구 결과",
   "message": "메시지",
   "Tool result, which failed": "도구 결과(실패)",
-  "for {id}": "{id}에 대한"
+  "for {id}": "{id}에 대한",
+  "No TraceQL URL is configured for this source. Set oap.traceql in horizon.yaml.": "이 소스에 TraceQL URL이 설정되어 있지 않습니다. horizon.yaml의 oap.traceql을 설정하세요.",
+  "TraceQL is configured but not answering at {url}.": "TraceQL이 설정되어 있지만 {url}에서 응답하지 않습니다.",
+  "Builder": "빌더",
+  "TraceQL": "TraceQL",
+  "Edit as TraceQL": "TraceQL로 편집",
+  "No traces matched.": "일치하는 트레이스가 없습니다.",
+  "No trace with that id.": "해당 ID의 트레이스가 없습니다.",
+  "The trace source is not answering.": "트레이스 소스가 응답하지 않습니다.",
+  "This trace source does not report failure": "이 트레이스 소스는 실패 여부를 보고하지 않습니다",
+  "Traces · TraceQL · Zipkin": "트레이스 · TraceQL · Zipkin",
+  "Traces · TraceQL": "트레이스 · TraceQL",
+  "not configured": "설정되지 않음",
+  "Open trace": "트레이스 열기",
+  "Min duration": "최소 소요 시간",
+  "Max duration": "최대 소요 시간",
+  "TraceQL query": "TraceQL 쿼리",
+  "Shift+Enter runs; a bare trace id opens that trace": "Shift+Enter로 실행하며, 트레이스 ID만 입력하면 해당 트레이스가 열립니다",
+  "capped at {n} — narrow the query": "{n}개로 제한됨 — 쿼리를 좁히세요",
+  "{n} tags": "태그 {n}개",
+  "Intrinsics": "Intrinsic",
+  "Resource": "Resource",
+  "Span tags": "Span 태그",
+  "This store reported no tags for the selected window.": "이 스토어는 선택한 기간에 대한 태그를 보고하지 않았습니다.",
+  "unset": "미설정",
+  "This API reads one id per request, so several ids are {n} separate reads.": "이 API는 요청당 ID 하나만 조회하므로, 여러 ID는 최대 {n}번의 개별 요청이 됩니다.",
+  "{n} not found": "{n}개를 찾지 못함",
+  "STARTED": "시작",
+  "DURATION": "소요 시간",
+  "SPANS": "SPAN 수",
+  "SERVICES": "서비스 수",
+  "Parent span ID": "상위 Span ID",
+  "Status message": "상태 메시지",
+  "Scope": "Scope",
+  "Resource attributes": "Resource 속성",
+  "Span attributes": "Span 속성",
+  "COUNT": "건수",
+  "ERRORS": "오류",
+  "TOTAL": "합계",
+  "AVG": "평균",
+  "MAX": "최대",
+  "which trace APIs this layer exposes — one sidebar row each": "이 레이어가 노출하는 트레이스 API — 각각 사이드바 한 줄",
+  "SkyWalking's own trace query.": "SkyWalking 자체 트레이스 조회.",
+  "OAP’s Zipkin v2 API, which also serves the OpenTelemetry spans OAP converts into Zipkin form.": "OAP의 Zipkin v2 API로, OAP가 Zipkin 형식으로 변환한 OpenTelemetry 스팬도 함께 제공합니다.",
+  "Grafana Tempo’s API over the native spans. Needs OAP’s traceQL module and a configured datasource URL.": "네이티브 스팬에 대한 Grafana Tempo API. OAP의 traceQL 모듈과 데이터소스 URL 설정이 필요합니다.",
+  "Grafana Tempo’s API over the Zipkin spans. Needs OAP’s traceQL module and a configured datasource URL.": "Zipkin 스팬에 대한 Grafana Tempo API. OAP의 traceQL 모듈과 데이터소스 URL 설정이 필요합니다.",
+  "Flags": "플래그",
+  "The Tempo API has no notion of a layer, so it lists every service of the store. This pattern narrows the service PICKER only — result rows are never filtered, so cross-service traces still appear.": "Tempo API에는 레이어 개념이 없어 저장소의 모든 서비스를 나열합니다. 이 패턴은 서비스 선택 목록만 좁히며 결과 행은 걸러내지 않으므로, 서비스를 넘나드는 트레이스는 그대로 표시됩니다.",
+  "checking {n} statuses…": "{n}개의 상태를 확인하는 중…",
+  "Show the full value": "전체 값 보기",
+  "full": "전체",
+  "{n} characters": "{n}자",
+  "Duration range": "지속 시간 범위",
+  "Trace duration, with a unit — us, ms, s, m, h.": "트레이스 지속 시간, 단위 포함 — us, ms, s, m, h.",
+  "picked from {n}": "{n}개 중 선택",
+  "{n} not read — the source did not answer": "{n}개를 읽지 못했습니다 — 소스가 응답하지 않았습니다",
+  "Tempo API": "Tempo API",
+  "not available": "사용할 수 없음",
+  "no URL configured": "URL이 설정되지 않음",
+  "SkyWalking-native spans": "SkyWalking 네이티브 스팬",
+  "{name} datasource unreachable": "{name} 데이터소스에 연결할 수 없음",
+  "Tried {url}. Enable OAP's traceQL module (SW_TRACEQL=default with the datasource switched on) and confirm oap.traceql points at its host:port and context path — {path} by default on port 3200.": "{url}을(를) 시도했습니다. OAP의 traceQL 모듈을 활성화하고(SW_TRACEQL=default 및 데이터소스 켜기), oap.traceql이 해당 host:port와 컨텍스트 경로를 가리키는지 확인하세요 — 기본값은 3200 포트의 {path}입니다.",
+  "Trace APIs": "트레이스 API",
+  "Zipkin v2 · Tempo": "Zipkin v2 · Tempo",
+  "Zipkin v2 REST": "Zipkin v2 REST",
+  "degraded": "일부 장애",
+  "Tempo API {version}": "Tempo API {version}",
+  "The endpoints behind a layer's trace rows, each probed on its own. Native traces are not here — they are answered by the GraphQL port above. A red dot in this pane is not a cluster-wide outage: only the trace rows fed by that endpoint are affected, and every other page keeps working.": "레이어의 트레이스 행 뒤에 있는 엔드포인트로, 각각 따로 확인합니다. 네이티브 트레이스는 여기 없으며 위의 GraphQL 포트가 응답합니다. 이 패널의 빨간 점은 클러스터 전체 장애가 아닙니다. 해당 엔드포인트가 제공하는 트레이스 행만 영향을 받고 다른 페이지는 계속 동작합니다.",
+  "API": "API",
+  "scope · {scope}": "스코프 · {scope}",
+  "The source refused this query": "소스가 이 쿼리를 거부했습니다",
+  "Every port of the OAP backend Horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the trace APIs — Zipkin and each TraceQL datasource — feed only the trace rows they serve. Each is polled independently: if one shows red the others can still be green.": "Horizon이 연결된 OAP 백엔드의 각 포트입니다. Query/GraphQL(:12800)은 모든 관측 페이지를 구동하고, 관리 호스트(:17128)는 DSL 관리·Live Debugger·Metrics  [...]
+  "{n} not read — over the {max}-id limit": "{n}개를 읽지 않음 — {max}개 제한을 초과",
+  "Any span name": "모든 스팬 이름",
+  "Schema reference": "스키마 참조",
+  "This condition has no value.": "이 조건에는 값이 없습니다.",
+  "Not a complete spanset.": "완전한 spanset이 아닙니다.",
+  "An attribute needs a scope — span.http.method, resource.service.name, or .http.method for the unscoped form.": "속성에는 스코프가 필요합니다 — span.http.method, resource.service.name, 또는 스코프 없는 형태인 .http.method로 쓰세요.",
+  "Not a field in this schema. The intrinsics are duration, name and status; everything else is an attribute and carries a scope.": "이 스키마의 필드가 아닙니다. intrinsic은 duration, name, status이며 나머지는 모두 스코프를 갖는 속성입니다.",
+  "This attribute belongs to the other store’s schema.": "이 속성은 다른 저장소의 스키마에 속합니다.",
+  "A status other than “ok”, “error” or “unset”.": "“ok”, “error”, “unset”이 아닌 status.",
+  "Span tags are those this store reported for the window being queried, so the list follows the time range.": "스팬 태그는 이 저장소가 조회 구간에 대해 보고한 것이므로, 목록은 시간 범위를 따릅니다.",
+  "ok, error or unset": "ok, error 또는 unset",
+  "Service instance (native)": "서비스 인스턴스(네이티브)",
+  "Peer service (Zipkin)": "피어 서비스(Zipkin)",
+  "Trace duration — >, >=, <, <= with us/µs/ms/s/m/h": "트레이스 소요 시간 — us/µs/ms/s/m/h 와 함께 >, >=, <, <=",
+  "Zipkin Traces": "Zipkin 트레이스",
+  "TraceQL - Native": "TraceQL - Native",
+  "TraceQL - Zipkin": "TraceQL - Zipkin",
+  "Zipkin spans": "Zipkin 스팬",
+  "Menu name": "메뉴 이름",
+  "Ignore case": "대소문자 무시",
+  "Horizon will not run this expression, so the picker keeps showing every service. It either does not compile, or it can backtrack exponentially — avoid a repeat applied to a group that itself repeats or branches, such as (a+)+.": "Horizon은 이 식을 실행하지 않으므로 선택기에는 모든 서비스가 계속 표시됩니다. 컴파일되지 않거나 역추적이 기하급수적으로 늘어날 수 있습니다. (a+)+ 처럼 반복이나 분기를 포함한 그룹에 반복을 적용하지 마세요.",
+  "Date and time": "날짜와 시간",
+  "Open the calendar": "달력 열기",
+  "Previous month": "이전 달",
+  "Next month": "다음 달",
+  "Now": "지금"
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 4dd2cd9c..aaf78597 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -801,7 +801,6 @@
   "Last-seen activity is tracked in each BFF replica's own memory and is NOT shared across the cluster. This count reflects only the node that served this page.": "A atividade do último acesso é rastreada na memória de cada réplica do BFF e NÃO é compartilhada no cluster. Esta contagem reflete apenas o nó que serviu esta página.",
   "Latency": "Latência",
   "Layer": "Camada",
-  "Layer carries both native and OpenTelemetry/Zipkin traces — their span formats and query conditions differ, so each gets its own trace tab.": "A camada carrega traces native e OpenTelemetry/Zipkin — como os formatos de span e as condições de consulta diferem, cada um ganha sua própria aba de traces.",
   "Layer dashboard editor": "Editor de dashboards de camada",
   "Layer dashboard is duplicated": "O painel do layer está duplicado",
   "Layer dashboards": "Painéis por camada",
@@ -1122,12 +1121,10 @@
   "OAP unreachable — page is read-only": "OAP indisponível — a página está em modo somente leitura",
   "OAP unreachable.": "OAP inacessível.",
   "OAP unreachable: {error}": "OAP inacessível: {error}",
-  "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "Endpoint Zipkin v2 do OAP, fonte do menu de traces OpenTelemetry e Zipkin (exibido quando a fonte de traces de um layer é zipkin ou both). É a única página afetada — se ficar inacessível, os traces nativos e todas as outras páginas de obse [...]
   "OAP-stored": "armazenado no OAP",
   "OAuth": "OAuth",
   "OIDC": "OIDC",
   "OK": "OK",
-  "OTel & Zipkin Traces": "Traces OTel e Zipkin",
   "Observability Analysis Language": "Observability Analysis Language",
   "Off": "Desligado",
   "Off:": "Desligado:",
@@ -1149,7 +1146,6 @@
   "Open the Topology tab and narrow the scope to see a complete map.": "Abra a aba Topologia e restrinja o escopo para ver um mapa completo.",
   "Open the originating trace": "Abrir o trace de origem",
   "Open {name} in {layer} (new tab)": "Abrir {name} em {layer} (nova aba)",
-  "OpenTelemetry & Zipkin": "OpenTelemetry e Zipkin",
   "Operate": "Operação",
   "Operate · Alerting": "Operar · Alertas",
   "Operate · Data retention": "Operar · Retenção de dados",
@@ -1225,7 +1221,6 @@
   "Pick instances, then click Analyze.": "Escolha as instâncias e clique em Analisar.",
   "Pick processes": "Escolher processos",
   "Pick refresh interval": "Escolher o intervalo de atualização",
-  "Pick the trace backend this layer reads from.": "Escolha o backend de traces do qual esta camada lê.",
   "Pick your conditions, then click Run query.": "Defina seus critérios e depois clique em \"Executar consulta\".",
   "Pin process {name}": "Fixar o processo {name}",
   "Pin the OAP layers that get their own KPI tile at the top of": "Fixe as camadas do OAP que ganham seu próprio tile de KPI no topo de",
@@ -1571,7 +1566,6 @@
   "Single sign-on is not configured on this server.": "O início de sessão único não está configurado neste servidor.",
   "Single sign-on via {provider}": "Início de sessão único via {provider}",
   "SkyWalking Horizon": "SkyWalking Horizon",
-  "SkyWalking query-protocol traces (agent-instrumented).": "Traces do query-protocol do SkyWalking (instrumentados por agente).",
   "Slowest": "Mais lentos",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "Não foi possível carregar algumas métricas ({failed} de {total} lotes falharam) — valores em branco podem estar indisponíveis, não são zero.",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "Não foi possível carregar algumas métricas ({failed} de {total} lotes falharam) — alguns endpoints ou ligações podem estar ausentes.",
@@ -1772,10 +1766,8 @@
   "Trace explorer scoped to this layer.": "Explorador de traces restrito a esta camada.",
   "Trace inspect": "Inspeção de traces",
   "Trace profiling requires an endpoint, and this proposal carries none.": "O profiling de traces exige um endpoint, e esta proposta não traz nenhum.",
-  "Trace source": "Origem dos traces",
   "Trace-driven thread profiling — the original SkyWalking profile.": "Profiling de threads guiado por trace — o profile original do SkyWalking.",
   "Traces": "Traces",
-  "Traces emitted from the OpenTelemetry & Zipkin ecosystem.": "Traces emitidos pelo ecossistema OpenTelemetry e Zipkin.",
   "Traces feed failed.": "O feed de traces falhou.",
   "Traces · Zipkin": "Traces · Zipkin",
   "Traffic": "Tráfego",
@@ -1794,7 +1786,6 @@
   "Turn auto-refresh on": "Ativar a atualização automática",
   "Turn off and delete": "Desativar e excluir",
   "Turn off {component}?": "Desativar {component}?",
-  "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "Visão de duas portas do backend OAP ao qual o Horizon está conectado. Query / GraphQL (:12800) alimenta cada página de observabilidade;  [...]
   "Type": "Tipo",
   "Type a name — the assistant will find it": "Digite um nome; o assistente vai encontrá-lo",
   "Type {ruleName} to confirm:": "Digite {ruleName} para confirmar:",
@@ -1904,7 +1895,6 @@
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see": "Sua barra lateral se adapta ao seu papel: o que você não pode usar simplesmente não é exibido. As seções abaixo agrupam as capacidades do SkyWalking por área funcional. Para cada área, você verá",
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see {menuScope} (which sidebar entries fall in the area) and {actions} (the concrete things you can do, marked per role). Multiple roles add up — a user holding more than one role gets every permission any of their roles grant.": "Sua barra lateral se adapta ao seu papel: o que você não pode usar simplesmente não é exibi [...]
   "Your username is your identity here, and your roles are resolved from it rather than stored against you. A token you hold re-reads them on every request, so removing you from the directory or configuration ends it at once. This browser session keeps the roles it was given until you sign in again.": "O seu nome de utilizador é a sua identidade aqui, e os seus papéis são resolvidos a partir dele em vez de ficarem guardados consigo. Um token que detenha volta a lê-los em cada pedido, pel [...]
-  "Zipkin / OTLP traces": "Traces Zipkin / OTLP",
   "Zipkin endpoint unreachable": "Endpoint Zipkin inacessível",
   "Zipkin query failed.": "A consulta ao Zipkin falhou.",
   "Zipkin trace": "Trace do Zipkin",
@@ -3319,5 +3309,102 @@
   "Tool result": "Resultado da ferramenta",
   "message": "mensagem",
   "Tool result, which failed": "Resultado da ferramenta, que falhou",
-  "for {id}": "para {id}"
+  "for {id}": "para {id}",
+  "No TraceQL URL is configured for this source. Set oap.traceql in horizon.yaml.": "Nenhuma URL de TraceQL está configurada para esta fonte. Defina oap.traceql em horizon.yaml.",
+  "TraceQL is configured but not answering at {url}.": "O TraceQL está configurado, mas não responde em {url}.",
+  "Builder": "Construtor",
+  "TraceQL": "TraceQL",
+  "Edit as TraceQL": "Editar como TraceQL",
+  "No traces matched.": "Nenhum rastreamento corresponde.",
+  "No trace with that id.": "Nenhum rastreamento com esse id.",
+  "The trace source is not answering.": "A fonte de rastreamentos não está respondendo.",
+  "This trace source does not report failure": "Esta fonte de rastreamentos não informa falhas",
+  "Traces · TraceQL · Zipkin": "Rastreamentos · TraceQL · Zipkin",
+  "Traces · TraceQL": "Rastreamentos · TraceQL",
+  "not configured": "não configurado",
+  "Open trace": "Abrir rastreamento",
+  "Min duration": "Duração mínima",
+  "Max duration": "Duração máxima",
+  "TraceQL query": "Consulta TraceQL",
+  "Shift+Enter runs; a bare trace id opens that trace": "Shift+Enter executa; um id de rastreamento sozinho abre esse rastreamento",
+  "capped at {n} — narrow the query": "limitado a {n} — refine a consulta",
+  "{n} tags": "{n} tags",
+  "Intrinsics": "Intrínsecos",
+  "Resource": "Resource",
+  "Span tags": "Tags de span",
+  "This store reported no tags for the selected window.": "Este armazenamento não informou tags para a janela selecionada.",
+  "unset": "indefinido",
+  "This API reads one id per request, so several ids are {n} separate reads.": "Esta API lê um id por requisição, portanto vários ids são até {n} leituras separadas.",
+  "{n} not found": "{n} não encontrados",
+  "STARTED": "INÍCIO",
+  "DURATION": "DURAÇÃO",
+  "SPANS": "SPANS",
+  "SERVICES": "SERVIÇOS",
+  "Parent span ID": "ID do span pai",
+  "Status message": "Mensagem de status",
+  "Scope": "Scope",
+  "Resource attributes": "Atributos de resource",
+  "Span attributes": "Atributos de span",
+  "COUNT": "CONTAGEM",
+  "ERRORS": "ERROS",
+  "TOTAL": "TOTAL",
+  "AVG": "MÉDIA",
+  "MAX": "MÁX",
+  "which trace APIs this layer exposes — one sidebar row each": "quais APIs de trace esta camada expõe — uma linha da barra lateral para cada",
+  "SkyWalking's own trace query.": "A consulta de traces do próprio SkyWalking.",
+  "OAP’s Zipkin v2 API, which also serves the OpenTelemetry spans OAP converts into Zipkin form.": "A API Zipkin v2 do OAP, que também serve os spans do OpenTelemetry que o OAP converte para o formato Zipkin.",
+  "Grafana Tempo’s API over the native spans. Needs OAP’s traceQL module and a configured datasource URL.": "A API do Grafana Tempo sobre os spans nativos. Requer o módulo traceQL do OAP e uma URL de fonte de dados configurada.",
+  "Grafana Tempo’s API over the Zipkin spans. Needs OAP’s traceQL module and a configured datasource URL.": "A API do Grafana Tempo sobre os spans do Zipkin. Requer o módulo traceQL do OAP e uma URL de fonte de dados configurada.",
+  "Flags": "Flags",
+  "The Tempo API has no notion of a layer, so it lists every service of the store. This pattern narrows the service PICKER only — result rows are never filtered, so cross-service traces still appear.": "A API do Tempo não conhece o conceito de camada, então lista todos os serviços do armazenamento. Este padrão restringe apenas o SELETOR de serviços — as linhas de resultado nunca são filtradas, portanto traces entre serviços continuam aparecendo.",
+  "checking {n} statuses…": "verificando {n} status…",
+  "Show the full value": "Mostrar o valor completo",
+  "full": "completo",
+  "{n} characters": "{n} caracteres",
+  "Duration range": "Intervalo de duração",
+  "Trace duration, with a unit — us, ms, s, m, h.": "Duração do trace, com unidade — us, ms, s, m, h.",
+  "picked from {n}": "selecionados de {n}",
+  "{n} not read — the source did not answer": "{n} não lidos — a fonte não respondeu",
+  "Tempo API": "API do Tempo",
+  "not available": "indisponível",
+  "no URL configured": "nenhuma URL configurada",
+  "SkyWalking-native spans": "Spans nativos do SkyWalking",
+  "{name} datasource unreachable": "Fonte de dados {name} inacessível",
+  "Tried {url}. Enable OAP's traceQL module (SW_TRACEQL=default with the datasource switched on) and confirm oap.traceql points at its host:port and context path — {path} by default on port 3200.": "Tentou-se {url}. Ative o módulo traceQL do OAP (SW_TRACEQL=default com a fonte de dados ligada) e confirme que oap.traceql aponta para o host:porta e o caminho de contexto dele — {path} por padrão na porta 3200.",
+  "Trace APIs": "APIs de trace",
+  "Zipkin v2 · Tempo": "Zipkin v2 · Tempo",
+  "Zipkin v2 REST": "REST do Zipkin v2",
+  "degraded": "degradado",
+  "Tempo API {version}": "API do Tempo {version}",
+  "The endpoints behind a layer's trace rows, each probed on its own. Native traces are not here — they are answered by the GraphQL port above. A red dot in this pane is not a cluster-wide outage: only the trace rows fed by that endpoint are affected, and every other page keeps working.": "Os endpoints por trás das linhas de trace de uma camada, cada um sondado por si. Traces nativos não estão aqui — quem responde por eles é a porta GraphQL acima. Um ponto vermelho neste painel não é uma [...]
+  "API": "API",
+  "scope · {scope}": "escopo · {scope}",
+  "The source refused this query": "A fonte recusou esta consulta",
+  "Every port of the OAP backend Horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the trace APIs — Zipkin and each TraceQL datasource — feed only the trace rows they serve. Each is polled independently: if one shows red the others can still be green.": "Cada porta do backend OAP ao qual o Horizon está conectado. Query/GraphQL (:12800) alimenta todas as páginas de obse [...]
+  "{n} not read — over the {max}-id limit": "{n} não lidos — acima do limite de {max} ids",
+  "Any span name": "Qualquer nome de span",
+  "Schema reference": "Referência do esquema",
+  "This condition has no value.": "Esta condição não tem valor.",
+  "Not a complete spanset.": "Não é um spanset completo.",
+  "An attribute needs a scope — span.http.method, resource.service.name, or .http.method for the unscoped form.": "Um atributo precisa de um âmbito — span.http.method, resource.service.name, ou .http.method para a forma sem âmbito.",
+  "Not a field in this schema. The intrinsics are duration, name and status; everything else is an attribute and carries a scope.": "Não é um campo deste esquema. Os intrínsecos são duration, name e status; tudo o resto é um atributo e leva um âmbito.",
+  "This attribute belongs to the other store’s schema.": "Este atributo pertence ao esquema do outro repositório.",
+  "A status other than “ok”, “error” or “unset”.": "Um status diferente de «ok», «error» ou «unset».",
+  "Span tags are those this store reported for the window being queried, so the list follows the time range.": "As etiquetas de span são as que este repositório reportou para a janela consultada, pelo que a lista acompanha o intervalo de tempo.",
+  "ok, error or unset": "ok, error ou unset",
+  "Service instance (native)": "Instância de serviço (nativa)",
+  "Peer service (Zipkin)": "Serviço par (Zipkin)",
+  "Trace duration — >, >=, <, <= with us/µs/ms/s/m/h": "Duração do trace — >, >=, <, <= com us/µs/ms/s/m/h",
+  "Zipkin Traces": "Traces do Zipkin",
+  "TraceQL - Native": "TraceQL - Native",
+  "TraceQL - Zipkin": "TraceQL - Zipkin",
+  "Zipkin spans": "Spans do Zipkin",
+  "Menu name": "Nome no menu",
+  "Ignore case": "Ignorar maiúsculas",
+  "Horizon will not run this expression, so the picker keeps showing every service. It either does not compile, or it can backtrack exponentially — avoid a repeat applied to a group that itself repeats or branches, such as (a+)+.": "O Horizon não executará esta expressão, por isso o seletor continua a mostrar todos os serviços. Ou não compila, ou pode retroceder exponencialmente — evite uma repetição aplicada a um grupo que se repete ou ramifica, como (a+)+.",
+  "Date and time": "Data e hora",
+  "Open the calendar": "Abrir o calendário",
+  "Previous month": "Mês anterior",
+  "Next month": "Mês seguinte",
+  "Now": "Agora"
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 0df18b43..b40a9428 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -801,7 +801,6 @@
   "Last-seen activity is tracked in each BFF replica's own memory and is NOT shared across the cluster. This count reflects only the node that served this page.": "上次可见活动信息仅记录在各 BFF 副本自己的内存中,不会在集群间共享。此计数仅反映为本页提供服务的节点。",
   "Latency": "延迟",
   "Layer": "层级",
-  "Layer carries both native and OpenTelemetry/Zipkin traces — their span formats and query conditions differ, so each gets its own trace tab.": "该 layer 同时存在原生与 OpenTelemetry/Zipkin 两种 trace —— 二者的 span 格式与查询条件不同,因此各自拥有独立的 trace 标签页。",
   "Layer dashboard editor": "Layer 仪表板编辑器",
   "Layer dashboard is duplicated": "该 layer 的仪表板重复",
   "Layer dashboards": "层级仪表板",
@@ -1122,12 +1121,10 @@
   "OAP unreachable — page is read-only": "OAP 不可达 —— 页面为只读",
   "OAP unreachable.": "无法连接 OAP。",
   "OAP unreachable: {error}": "OAP 不可达:{error}",
-  "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "OAP 的 Zipkin v2 端点,是 OpenTelemetry 与 Zipkin 链路菜单的数据源(当某个 layer 的 trace 源为 zipkin 或 both 时显示)。仅此一处会受影响 —— 即便不可达,原生 trace 以及其他所有可观测页面仍可正常使用。",
   "OAP-stored": "OAP 已存储",
   "OAuth": "OAuth",
   "OIDC": "OIDC",
   "OK": "正常",
-  "OTel & Zipkin Traces": "OTel & Zipkin Trace",
   "Observability Analysis Language": "Observability Analysis Language",
   "Off": "关闭",
   "Off:": "关闭:",
@@ -1149,7 +1146,6 @@
   "Open the Topology tab and narrow the scope to see a complete map.": "打开“拓扑”标签页并缩小范围,即可看到完整的拓扑图。",
   "Open the originating trace": "打开来源 trace",
   "Open {name} in {layer} (new tab)": "在 {layer} 中打开 {name}(新标签页)",
-  "OpenTelemetry & Zipkin": "OpenTelemetry & Zipkin",
   "Operate": "运维",
   "Operate · Alerting": "运维 · 告警",
   "Operate · Data retention": "运维 · 数据保留",
@@ -1225,7 +1221,6 @@
   "Pick instances, then click Analyze.": "选择实例,然后点击“分析”。",
   "Pick processes": "选择进程",
   "Pick refresh interval": "选择刷新间隔",
-  "Pick the trace backend this layer reads from.": "选择该 layer 读取 trace 的后端。",
   "Pick your conditions, then click Run query.": "请设置筛选条件,然后点击\"执行查询\"。",
   "Pin process {name}": "固定进程 {name}",
   "Pin the OAP layers that get their own KPI tile at the top of": "固定要在以下位置顶部拥有独立 KPI 图块的 OAP layer:",
@@ -1571,7 +1566,6 @@
   "Single sign-on is not configured on this server.": "本服务器未配置单点登录。",
   "Single sign-on via {provider}": "通过 {provider} 单点登录",
   "SkyWalking Horizon": "SkyWalking Horizon",
-  "SkyWalking query-protocol traces (agent-instrumented).": "SkyWalking query-protocol 的 trace(Agent 埋点)。",
   "Slowest": "最慢",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "部分指标加载失败({total} 批中有 {failed} 批失败),空白值可能表示数据暂不可用,而非零值。",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "部分指标加载失败({total} 批中有 {failed} 批失败),部分端点或链路可能缺失。",
@@ -1772,10 +1766,8 @@
   "Trace explorer scoped to this layer.": "限定在该 layer 的 trace 浏览器。",
   "Trace inspect": "链路查询",
   "Trace profiling requires an endpoint, and this proposal carries none.": "trace profiling 需要指定 Endpoint,而此建议未提供。",
-  "Trace source": "Trace 数据源",
   "Trace-driven thread profiling — the original SkyWalking profile.": "由 trace 驱动的线程剖析 —— SkyWalking 最早的 profile 能力。",
   "Traces": "Trace",
-  "Traces emitted from the OpenTelemetry & Zipkin ecosystem.": "来自 OpenTelemetry 与 Zipkin 生态的 trace。",
   "Traces feed failed.": "trace 数据获取失败。",
   "Traces · Zipkin": "Trace · Zipkin",
   "Traffic": "流量",
@@ -1794,7 +1786,6 @@
   "Turn auto-refresh on": "开启自动刷新",
   "Turn off and delete": "关闭并删除",
   "Turn off {component}?": "关闭{component}?",
-  "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "与 horizon 相连的 OAP 后端的双端口视图。Query / GraphQL(:12800)支撑所有可观测性页面;管理端(:17128)控制 DSL 管理、实时调试器、指标检查与 Dump;Zipkin / OTLP 端点仅为 Zipkin Trace 菜单供数据 [...]
   "Type": "请输入",
   "Type a name — the assistant will find it": "输入名称,助手会自动匹配",
   "Type {ruleName} to confirm:": "请输入 {ruleName} 以确认:",
@@ -1904,7 +1895,6 @@
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see": "侧边栏会根据你的角色自适应:用不到的条目会直接隐藏。下方按功能区域对 SkyWalking 的能力进行了分组。对于每个区域,你将看到",
   "Your sidebar adapts to your role: anything you can't use is simply not shown. The sections below group SkyWalking's capabilities by feature area. For each area you'll see {menuScope} (which sidebar entries fall in the area) and {actions} (the concrete things you can do, marked per role). Multiple roles add up — a user holding more than one role gets every permission any of their roles grant.": "侧边栏会根据你的角色自适应:用不到的条目会直接隐藏。下方按功能区域对 SkyWalking 的能力进行了分组。对于每个区域,你将看到 {menuScope}(哪些侧边栏条目属于该区域 [...]
   "Your username is your identity here, and your roles are resolved from it rather than stored against you. A token you hold re-reads them on every request, so removing you from the directory or configuration ends it at once. This browser session keeps the roles it was given until you sign in again.": "在这里,用户名就是你的身份,角色是据此解析的,而非存储在你名下。你持有的令牌会在每次请求时重新读取角色,因此将你从目录或配置中移除会立即生效。而当前浏览器会话会保留登录时获得的角色,直到你重新登录。",
-  "Zipkin / OTLP traces": "Zipkin / OTLP trace",
   "Zipkin endpoint unreachable": "Zipkin 端点不可达",
   "Zipkin query failed.": "Zipkin 查询失败。",
   "Zipkin trace": "Zipkin trace",
@@ -3319,5 +3309,102 @@
   "Tool result": "工具结果",
   "message": "消息",
   "Tool result, which failed": "工具结果(失败)",
-  "for {id}": "对应 {id}"
+  "for {id}": "对应 {id}",
+  "No TraceQL URL is configured for this source. Set oap.traceql in horizon.yaml.": "该数据源未配置 TraceQL 地址。请在 horizon.yaml 中设置 oap.traceql。",
+  "TraceQL is configured but not answering at {url}.": "已配置 TraceQL,但 {url} 无响应。",
+  "Builder": "构建器",
+  "TraceQL": "TraceQL",
+  "Edit as TraceQL": "以 TraceQL 编辑",
+  "No traces matched.": "没有匹配的追踪。",
+  "No trace with that id.": "没有该 id 对应的追踪。",
+  "The trace source is not answering.": "追踪数据源无响应。",
+  "This trace source does not report failure": "该追踪数据源不提供失败状态",
+  "Traces · TraceQL · Zipkin": "追踪 · TraceQL · Zipkin",
+  "Traces · TraceQL": "追踪 · TraceQL",
+  "not configured": "未配置",
+  "Open trace": "打开追踪",
+  "Min duration": "最短耗时",
+  "Max duration": "最长耗时",
+  "TraceQL query": "TraceQL 查询",
+  "Shift+Enter runs; a bare trace id opens that trace": "Shift+Enter 执行;只输入 trace id 则直接打开该追踪",
+  "capped at {n} — narrow the query": "上限 {n} 条 — 请缩小查询范围",
+  "{n} tags": "{n} 个标签",
+  "Intrinsics": "内置字段",
+  "Resource": "Resource",
+  "Span tags": "Span 标签",
+  "This store reported no tags for the selected window.": "该存储在所选时间范围内没有返回标签。",
+  "unset": "未设置",
+  "This API reads one id per request, so several ids are {n} separate reads.": "该 API 每次请求只能查询一个 id,因此多个 id 会拆成最多 {n} 次独立请求。",
+  "{n} not found": "{n} 个未找到",
+  "STARTED": "开始时间",
+  "DURATION": "耗时",
+  "SPANS": "SPAN 数",
+  "SERVICES": "服务数",
+  "Parent span ID": "父 Span ID",
+  "Status message": "状态信息",
+  "Scope": "Scope",
+  "Resource attributes": "Resource 属性",
+  "Span attributes": "Span 属性",
+  "COUNT": "数量",
+  "ERRORS": "错误数",
+  "TOTAL": "总计",
+  "AVG": "平均",
+  "MAX": "最大",
+  "which trace APIs this layer exposes — one sidebar row each": "该 layer 暴露哪些 Trace API —— 每个 API 在侧边栏中各占一行",
+  "SkyWalking's own trace query.": "SkyWalking 自身的 trace 查询。",
+  "OAP’s Zipkin v2 API, which also serves the OpenTelemetry spans OAP converts into Zipkin form.": "OAP 的 Zipkin v2 API,它同时提供 OAP 转换为 Zipkin 格式的 OpenTelemetry span。",
+  "Grafana Tempo’s API over the native spans. Needs OAP’s traceQL module and a configured datasource URL.": "基于原生 span 的 Grafana Tempo API。需要 OAP 的 traceQL 模块,并配置数据源 URL。",
+  "Grafana Tempo’s API over the Zipkin spans. Needs OAP’s traceQL module and a configured datasource URL.": "基于 Zipkin span 的 Grafana Tempo API。需要 OAP 的 traceQL 模块,并配置数据源 URL。",
+  "Flags": "正则标志",
+  "The Tempo API has no notion of a layer, so it lists every service of the store. This pattern narrows the service PICKER only — result rows are never filtered, so cross-service traces still appear.": "Tempo API 没有 layer 的概念,会列出该存储中的所有服务。此表达式只收窄服务选择列表,结果行不会被过滤,因此跨服务的 trace 依然会出现。",
+  "checking {n} statuses…": "正在检查 {n} 条状态…",
+  "Show the full value": "查看完整值",
+  "full": "完整",
+  "{n} characters": "{n} 个字符",
+  "Duration range": "耗时范围",
+  "Trace duration, with a unit — us, ms, s, m, h.": "Trace 耗时,需带单位 —— us、ms、s、m、h。",
+  "picked from {n}": "从 {n} 条中选出",
+  "{n} not read — the source did not answer": "{n} 条未读取 —— 数据源没有响应",
+  "Tempo API": "Tempo API",
+  "not available": "不可用",
+  "no URL configured": "未配置 URL",
+  "SkyWalking-native spans": "SkyWalking 原生 span",
+  "{name} datasource unreachable": "数据源 {name} 不可达",
+  "Tried {url}. Enable OAP's traceQL module (SW_TRACEQL=default with the datasource switched on) and confirm oap.traceql points at its host:port and context path — {path} by default on port 3200.": "已尝试 {url}。请启用 OAP 的 traceQL 模块(SW_TRACEQL=default 并打开对应数据源),并确认 oap.traceql 指向其 host:port 与 context path —— 默认是 3200 端口上的 {path}。",
+  "Trace APIs": "Trace API",
+  "Zipkin v2 · Tempo": "Zipkin v2 · Tempo",
+  "Zipkin v2 REST": "Zipkin v2 REST",
+  "degraded": "部分不可用",
+  "Tempo API {version}": "Tempo API {version}",
+  "The endpoints behind a layer's trace rows, each probed on its own. Native traces are not here — they are answered by the GraphQL port above. A red dot in this pane is not a cluster-wide outage: only the trace rows fed by that endpoint are affected, and every other page keeps working.": "支撑 layer 各个 trace 行的端点,逐个探测。原生 trace 不在此处,由上方的 GraphQL 端口负责。此面板中的红点并不表示整个集群故障:只有该端点所支撑的 trace 行会受影响,其他页面照常工作。",
+  "API": "API",
+  "scope · {scope}": "作用域 · {scope}",
+  "The source refused this query": "数据源拒绝了该查询",
+  "Every port of the OAP backend Horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the trace APIs — Zipkin and each TraceQL datasource — feed only the trace rows they serve. Each is polled independently: if one shows red the others can still be green.": "Horizon 所连接的 OAP 后端的各个端口。Query/GraphQL(:12800)支撑所有可观测性页面;管理端口(:17128)决定 DSL 管理、Live Debugger、Metrics Inspect 与 Dump; [...]
+  "{n} not read — over the {max}-id limit": "{n} 条未读取 —— 超过 {max} 个 id 的上限",
+  "Any span name": "任意 span 名称",
+  "Schema reference": "Schema 参考",
+  "This condition has no value.": "该条件没有值。",
+  "Not a complete spanset.": "不是完整的 spanset。",
+  "An attribute needs a scope — span.http.method, resource.service.name, or .http.method for the unscoped form.": "属性需要作用域——请写 span.http.method、resource.service.name,或用前置点的 .http.method 表示无作用域形式。",
+  "Not a field in this schema. The intrinsics are duration, name and status; everything else is an attribute and carries a scope.": "不是该 schema 中的字段。intrinsic 只有 duration、name 和 status,其余都是带作用域的属性。",
+  "This attribute belongs to the other store’s schema.": "该属性属于另一个存储的 schema。",
+  "A status other than “ok”, “error” or “unset”.": "“ok”“error”“unset”以外的 status。",
+  "Span tags are those this store reported for the window being queried, so the list follows the time range.": "span 标签是该存储针对所查询时间窗上报的,因此列表会随时间范围变化。",
+  "ok, error or unset": "ok、error 或 unset",
+  "Service instance (native)": "服务实例(原生)",
+  "Peer service (Zipkin)": "对端服务(Zipkin)",
+  "Trace duration — >, >=, <, <= with us/µs/ms/s/m/h": "trace 耗时——可用 >、>=、<、<=,单位 us/µs/ms/s/m/h",
+  "Zipkin Traces": "Zipkin Trace",
+  "TraceQL - Native": "TraceQL - Native",
+  "TraceQL - Zipkin": "TraceQL - Zipkin",
+  "Zipkin spans": "Zipkin span",
+  "Menu name": "菜单名称",
+  "Ignore case": "忽略大小写",
+  "Horizon will not run this expression, so the picker keeps showing every service. It either does not compile, or it can backtrack exponentially — avoid a repeat applied to a group that itself repeats or branches, such as (a+)+.": "Horizon 不会执行该表达式,因此选择器仍会显示全部服务。它要么无法编译,要么可能产生指数级回溯——请避免对本身带有重复或分支的分组再加重复,例如 (a+)+。",
+  "Date and time": "日期和时间",
+  "Open the calendar": "打开日历",
+  "Previous month": "上一个月",
+  "Next month": "下一个月",
+  "Now": "此刻"
 }
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index a2016c37..15c2cd51 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -43,6 +43,7 @@ import { useLayerLanding } from '@/layer/useLayerLanding';
 import { useTimeRangeStore } from '@/controls/timeRange';
 import { isBuiltInLayerRow, FALLBACK_LAYER_ROW } from '@skywalking-horizon-ui/api-client';
 import { useLayers, firstLayerTab, layerMenuRows } from '@/shell/useLayers';
+import { resolveTraceStores } from '@skywalking-horizon-ui/api-client';
 import { layerContentToDef, type LayerTemplateContent } from '@/shell/layerFromTemplate';
 import { useSelectedService } from '@/layer/useSelectedService';
 import { useLayerServices } from '@/layer/useLayerServices';
@@ -219,6 +220,17 @@ watch(
     // it by row alone redirected the URL onto the embedded view, which
     // renders without its own toolbar.
     if (scope === 'zipkin-trace' && L.caps?.traces) return;
+    // Old bookmarks. `/trace` used to render whichever store a layer had —
+    // a pure-Zipkin layer showed its Zipkin explorer under this path. Stores
+    // now own their rows, so send the URL to the layer's first trace row
+    // rather than to its first tab, which would be a dashboard.
+    if (scope === 'trace') {
+      const firstTrace = layerMenuRows(L).find((r) => r.path.endsWith('trace'));
+      if (firstTrace) {
+        void router.replace({ path: `/layer/${L.key}/${firstTrace.path}`, query: route.query });
+        return;
+      }
+    }
     const fallback = firstLayerTab(L);
     if (fallback === scope) return; // already at the best fallback
     void router.replace({ path: `/layer/${L.key}/${fallback}`, query: route.query });
@@ -429,8 +441,11 @@ const comparable = computed(
 // `/zipkin-trace` route always, plus the `/trace` route on a pure-
 // zipkin-source layer (mesh / k8s).
 const isZipkinTrace = computed<boolean>(() => {
-  if (/\/zipkin-trace(\/|$|\?)/.test(route.path)) return true;
-  return scopeSegment.value === 'trace' && layer.value?.traces?.source === 'zipkin';
+  // Every TraceQL row is one too: the query names its own service, so the
+  // shell's picked service would sit above a list that has nothing to do
+  // with it — a header describing a different question than the page asks.
+  if (/\/(zipkin-trace|traceql-[a-z]+-trace)(\/|$|\?)/.test(route.path)) return true;
+  return scopeSegment.value === 'trace' && resolveTraceStores(layer.value?.traces).join() === 'zipkin';
 });
 
 // Keep the URL-backed service selection honest for every page that
diff --git a/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue b/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue
index 9c630935..c4e3b939 100644
--- a/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue
+++ b/apps/ui/src/layer/browser-errors/LayerBrowserErrorsView.vue
@@ -24,6 +24,7 @@
 -->
 <script setup lang="ts">
 import { computed, onMounted, ref, watch } from 'vue';
+import DateTimeField from '@/components/primitives/DateTimeField.vue';
 import {
   resolveRecordRange,
   recordRangeWarning,
@@ -447,9 +448,9 @@ function loc(row: BrowserErrorRow): string {
           <span>{{ t('Time range') }}</span>
           <template v-if="isCustomRange">
             <div class="cf-range">
-              <input v-model="customStart" type="datetime-local" class="cf-input cf-range-num" />
+              <DateTimeField v-model="customStart" class="cf-range-num" />
               <span class="cf-range-sep">–</span>
-              <input v-model="customEnd" type="datetime-local" class="cf-input cf-range-num" />
+              <DateTimeField v-model="customEnd" class="cf-range-num" />
               <button class="sw-btn small ghost" type="button" :title="t('Back to presets')" @click="windowMinutes = 30">×</button>
             </div>
           </template>
diff --git a/apps/ui/src/layer/evaluation-record/LayerEvaluationRecordView.vue b/apps/ui/src/layer/evaluation-record/LayerEvaluationRecordView.vue
index 3feac47c..04ce4073 100644
--- a/apps/ui/src/layer/evaluation-record/LayerEvaluationRecordView.vue
+++ b/apps/ui/src/layer/evaluation-record/LayerEvaluationRecordView.vue
@@ -45,6 +45,7 @@ import EvaluationRecordStreamPanel from '@/render/widgets/EvaluationRecordStream
 import EvaluationRecordDetailPopout from '@/render/widgets/EvaluationRecordDetailPopout.vue';
 import RelatedTraceSpanPicker, { type RelatedSpanPick } from '@/layer/evaluation-record/RelatedTraceSpanPicker.vue';
 import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue';
+import DateTimeField from '@/components/primitives/DateTimeField.vue';
 
 const route = useRoute();
 const router = useRouter();
@@ -802,9 +803,9 @@ watch([drillArmed, providerReady], ([armed, ready]) => {
           <span>Time range</span>
           <template v-if="isCustomRange">
             <div class="cf-range">
-              <input v-model="customStart" type="datetime-local" class="cf-input cf-range-num" />
+              <DateTimeField v-model="customStart" class="cf-range-num" />
               <span class="cf-range-sep">to</span>
-              <input v-model="customEnd" type="datetime-local" class="cf-input cf-range-num" />
+              <DateTimeField v-model="customEnd" class="cf-range-num" />
               <button class="sw-btn small ghost" type="button" title="Back to presets" @click="windowMinutes = 30">Back</button>
             </div>
           </template>
diff --git a/apps/ui/src/layer/logs/LayerLogsView.vue b/apps/ui/src/layer/logs/LayerLogsView.vue
index 60a728f9..55928228 100644
--- a/apps/ui/src/layer/logs/LayerLogsView.vue
+++ b/apps/ui/src/layer/logs/LayerLogsView.vue
@@ -51,6 +51,7 @@ import EndpointCombo from '@/layer/_shared/EndpointCombo.vue';
 import LogStreamPanel from '@/render/widgets/LogStreamPanel.vue';
 import LogDetailPopout from '@/render/widgets/LogDetailPopout.vue';
 import TagInput from '@/components/primitives/TagInput.vue';
+import DateTimeField from '@/components/primitives/DateTimeField.vue';
 
 const { t } = useI18n({ useScope: 'global' });
 
@@ -547,9 +548,9 @@ watch(
           <span>{{ t('Time range') }}</span>
           <template v-if="isCustomRange">
             <div class="cf-range">
-              <input v-model="customStart" type="datetime-local" name="log-start" class="cf-input cf-range-num" />
+              <DateTimeField v-model="customStart" class="cf-range-num" />
               <span class="cf-range-sep">–</span>
-              <input v-model="customEnd" type="datetime-local" name="log-end" class="cf-input cf-range-num" />
+              <DateTimeField v-model="customEnd" class="cf-range-num" />
               <button class="sw-btn small ghost" type="button" :title="t('Back to presets')" @click="windowMinutes = 30">×</button>
             </div>
           </template>
diff --git a/apps/ui/src/layer/profiling/NewEBPFTaskModal.vue b/apps/ui/src/layer/profiling/NewEBPFTaskModal.vue
index d5332207..72889194 100644
--- a/apps/ui/src/layer/profiling/NewEBPFTaskModal.vue
+++ b/apps/ui/src/layer/profiling/NewEBPFTaskModal.vue
@@ -25,6 +25,7 @@
 import { reactive, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
+import DateTimeField from '@/components/primitives/DateTimeField.vue';
 import type { EBPFTargetType } from '@/api/client';
 import type { NewEBPFTaskPayload } from '@/layer/profiling/useEBPFProfiling';
 
@@ -139,12 +140,10 @@ function submit(): void {
               <button :class="{ on: newTask.monitorTime === 'now' }" @click="newTask.monitorTime = 'now'">{{ t('now') }}</button>
               <button :class="{ on: newTask.monitorTime === 'set' }" @click="newTask.monitorTime = 'set'">{{ t('set time') }}</button>
             </div>
-            <input
+            <DateTimeField
               v-if="newTask.monitorTime === 'set'"
-              type="datetime-local"
-              class="ti-input"
-              :value="new Date(newTask.monitorTimeAt.getTime() - newTask.monitorTimeAt.getTimezoneOffset() * 60_000).toISOString().slice(0, 16)"
-              @input="(ev: Event) => (newTask.monitorTimeAt = new Date((ev.target as HTMLInputElement).value))"
+              :model-value="new Date(newTask.monitorTimeAt.getTime() - newTask.monitorTimeAt.getTimezoneOffset() * 60_000).toISOString().slice(0, 16)"
+              @update:model-value="(v: string) => (newTask.monitorTimeAt = new Date(v))"
             />
           </div>
           <div class="field">
diff --git a/apps/ui/src/layer/profiling/NewTraceProfileTaskModal.vue b/apps/ui/src/layer/profiling/NewTraceProfileTaskModal.vue
index 19c94c63..ae06cadb 100644
--- a/apps/ui/src/layer/profiling/NewTraceProfileTaskModal.vue
+++ b/apps/ui/src/layer/profiling/NewTraceProfileTaskModal.vue
@@ -26,6 +26,7 @@
 import { reactive, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
+import DateTimeField from '@/components/primitives/DateTimeField.vue';
 import EndpointCombo from '@/layer/_shared/EndpointCombo.vue';
 
 const { t } = useI18n({ useScope: 'global' });
@@ -136,12 +137,10 @@ function submit(): void {
               <button :class="{ on: newTask.monitorTime === 'now' }" @click="newTask.monitorTime = 'now'">{{ t('now') }}</button>
               <button :class="{ on: newTask.monitorTime === 'set' }" @click="newTask.monitorTime = 'set'">{{ t('set time') }}</button>
             </div>
-            <input
+            <DateTimeField
               v-if="newTask.monitorTime === 'set'"
-              type="datetime-local"
-              class="ti-input"
-              :value="new Date(newTask.monitorTimeAt.getTime() - newTask.monitorTimeAt.getTimezoneOffset() * 60_000).toISOString().slice(0, 16)"
-              @input="(ev: Event) => (newTask.monitorTimeAt = new Date((ev.target as HTMLInputElement).value))"
+              :model-value="new Date(newTask.monitorTimeAt.getTime() - newTask.monitorTimeAt.getTimezoneOffset() * 60_000).toISOString().slice(0, 16)"
+              @update:model-value="(v: string) => (newTask.monitorTimeAt = new Date(v))"
             />
           </div>
           <div class="field">
diff --git a/apps/ui/src/layer/traceql/LayerTraceQLView.vue b/apps/ui/src/layer/traceql/LayerTraceQLView.vue
new file mode 100644
index 00000000..8191b9d7
--- /dev/null
+++ b/apps/ui/src/layer/traceql/LayerTraceQLView.vue
@@ -0,0 +1,915 @@
+<!--
+  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.
+-->
+<!--
+  The TraceQL tab, laid out and dressed as the native Traces tab: the
+  conditions card beside the distribution, the list below, and one renderer
+  whichever store answers.
+
+  Two ways in, not three. A trace id is not a separate mode here because it is
+  already a valid query in this language — Tempo's own editor takes a bare id
+  in the same box — so typing one and pressing Run opens that trace.
+-->
+<script setup lang="ts">
+import { computed, onBeforeUnmount, ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { useI18n } from 'vue-i18n';
+import type { TraceListRow, TraceQLDatasource } from '@skywalking-horizon-ui/api-client';
+import { useTimeRangeStore } from '@/controls/timeRange';
+import TraceDistribution from '@/render/widgets/TraceDistribution.vue';
+import TraceListPanel from '@/render/widgets/TraceListPanel.vue';
+import TypeaheadSelect from '@/components/primitives/TypeaheadSelect.vue';
+import ChipInput from '@/components/primitives/ChipInput.vue';
+import { TIME_RANGE_PRESETS } from '@/layer/logs/useLogTimeRange';
+import { MAX_RECORD_RANGE_DAYS, resolveRecordRange } from '@/utils/recordTimeRange';
+import TraceQLTraceDetail from './TraceQLTraceDetail.vue';
+import TraceQLEditor from './TraceQLEditor.vue';
+import TraceQLSchemaPanel from './TraceQLSchemaPanel.vue';
+import TraceQLWindowFields from './TraceQLWindowFields.vue';
+import { buildTraceQL, lintTraceQL, serviceInExpression, type BuilderRows } from './traceqlLint';
+import { TRACEQL_POPOUT_QUERY } from './traceqlDetailShared';
+import { TRACEQL_STATUS_VALUES } from '@/monaco/traceql-grammar';
+import {
+  useTraceQLOptions,
+  useTraceQLSearch,
+  useTraceQLStatusResolver,
+  useTraceQLSources,
+  useTraceQLTraceIds,
+  NO_TIME_RANGE,
+  type TraceIdSubmission,
+  TRACE_ID_LOOKUP_MAX,
+  type TraceQLSubmission,
+} from './useTraceQL';
+
+const { t } = useI18n({ useScope: 'global' });
+const route = useRoute();
+const router = useRouter();
+const time = useTimeRangeStore();
+
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+/** The row decides the store — each store has its own row. */
+const ds = computed<TraceQLDatasource>(() =>
+  /traceql-zipkin-trace/.test(route.path) ? 'zipkin' : 'native',
+);
+
+const { statusOf } = useTraceQLSources();
+const status = computed(() => statusOf(ds.value));
+
+type Mode = 'builder' | 'traceql' | 'traceId';
+const mode = ref<Mode>('builder');
+const limit = ref(30);
+const rows = ref<BuilderRows>({ tags: [] });
+const expression = ref('');
+
+/** What Run was pressed with. Null until then: nothing queries on mount. */
+const submitted = ref<TraceQLSubmission | null>(null);
+let runs = 0;
+
+const isTraceIdMode = computed(() => mode.value === 'traceId');
+const generated = computed(() => buildTraceQL(rows.value));
+const editorText = computed({
+  get: () => (mode.value === 'traceql' ? expression.value : generated.value),
+  set: (v: string) => {
+    expression.value = v;
+  },
+});
+
+/** A bare id, the way Tempo's own editor accepts one. */
+const typedTraceId = computed(() => {
+  const text = editorText.value.trim();
+  return mode.value === 'traceql' && text && !text.startsWith('{') && !/\s/.test(text) ? text : null;
+});
+const issues = computed(() => (typedTraceId.value ? [] : lintTraceQL(editorText.value, ds.value)));
+
+
+/**
+ * This tab owns its time range, as the native Traces tab does.
+ *
+ * The topbar's range drives dashboards; a triage screen that followed it would
+ * have an auto-refresh tick widen an operator's search mid-read. The topbar's
+ * current window is only the STARTING value.
+ */
+const windowMinutes = ref<number>(
+  TIME_RANGE_PRESETS.find((p) => p.minutes * 60_000 >= time.range.endMs - time.range.startMs)?.minutes ?? 30,
+);
+/** The picker's "Custom…" choice. -1 cannot collide with a preset's minutes. */
+const CUSTOM_RANGE = -1;
+const isCustomRange = computed(() => windowMinutes.value === CUSTOM_RANGE);
+/** `datetime-local` reads "YYYY-MM-DDTHH:MM" in the browser's zone. Seeded
+ *  with the last hour so switching to Custom shows a usable pair rather than
+ *  two empty fields. */
+function toLocalDtValue(ms: number): string {
+  const d = new Date(ms);
+  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())}`;
+}
+const customStart = ref(toLocalDtValue(Date.now() - 60 * 60_000));
+const customEnd = ref(toLocalDtValue(Date.now()));
+/** The window as the picker currently reads it. Anchored when it is USED —
+ *  `Date.now()` is not reactive, so a computed would freeze at first read and
+ *  hand every later Run the same stale window. A custom range is absolute, so
+ *  it is the one window that does NOT move between runs. */
+function currentWindow(): { startMs: number; endMs: number } | null {
+  if (isCustomRange.value) {
+    const r = resolveRecordRange(customStart.value, customEnd.value);
+    // A half-filled or inverted pair resolves to NOTHING. Substituting another
+    // window would answer a question the operator did not ask, and an absence
+    // read off the wrong hour is worse than no answer at all.
+    return typeof r === 'string' ? null : r;
+  }
+  const endMs = Date.now();
+  return { startMs: endMs - windowMinutes.value * 60_000, endMs };
+}
+/** The complaint that stops a run, as an i18n key, or null when it resolves. */
+const rangeError = computed<string | null>(() => {
+  if (!isCustomRange.value) return null;
+  const r = resolveRecordRange(customStart.value, customEnd.value);
+  return typeof r === 'string' ? r : null;
+});
+/** For the option lookups, which follow the picker rather than a run. They fall
+ *  back to the last hour while a custom range is half-typed: a picker that
+ *  empties mid-edit is not an honest answer, it is a missing one. */
+const window = computed(() => {
+  void windowMinutes.value;
+  const w = currentWindow();
+  if (w) return w;
+  const endMs = Date.now();
+  return { startMs: endMs - 60 * 60_000, endMs };
+});
+const search = useTraceQLSearch({ ds, submitted });
+const serviceModel = computed({
+  get: () => rows.value.service ?? '',
+  set: (v: string) => {
+    rows.value.service = v;
+    // A span name resolves against the service, so it cannot outlive it.
+    rows.value.spanName = '';
+  },
+});
+// Span names resolve against a SERVICE. In TraceQL mode that is whichever
+// service the EXPRESSION names — the builder's picker is not what the operator
+// is typing, and following it completes `name=` with another service's spans.
+// A typed name SETTLES first: every keystroke of `agent::rating` is otherwise a
+// real backend search for a service nobody has finished naming.
+const optionsService = ref('');
+let serviceSettleTimer: ReturnType<typeof setTimeout> | null = null;
+watch(
+  () => (mode.value === 'traceql' ? (serviceInExpression(expression.value) ?? '') : serviceModel.value),
+  (v, prev) => {
+    if (serviceSettleTimer) clearTimeout(serviceSettleTimer);
+    // A pick from the builder is a finished choice; only typing has to settle.
+    if (prev === undefined || mode.value !== 'traceql') {
+      optionsService.value = v;
+      return;
+    }
+    serviceSettleTimer = setTimeout(() => { optionsService.value = v; }, 400);
+  },
+  { immediate: true },
+);
+onBeforeUnmount(() => { if (serviceSettleTimer) clearTimeout(serviceSettleTimer); });
+const options = useTraceQLOptions(ds, layerKey, window, optionsService);
+// The empty option is what CLEARS the condition: a typeahead only emits values
+// it was given, so without it a picked service can never be widened again.
+const serviceOptions = computed(() => [
+  { value: '', label: t('All services') },
+  ...options.serviceOptions.value.map((s) => ({ value: s, label: s })),
+]);
+const spanNameOptions = computed(() => [
+  { value: '', label: t('Any span name') },
+  ...options.spanNameOptions.value.map((s) => ({ value: s, label: s })),
+]);
+const spanNameModel = computed({
+  get: () => rows.value.spanName ?? '',
+  set: (v: string) => { rows.value.spanName = v; },
+});
+
+const schemaOpen = ref(false);
+/** What the editor knows about this store — its tags, its services, its span
+ *  names, and a way to ask for one tag's values. */
+const schema = computed(() => ({
+  ds: ds.value,
+  tagKeys: options.tagKeys.value,
+  services: options.serviceOptions.value,
+  spanNames: options.spanNameOptions.value,
+  valuesFor: options.valuesFor,
+  requestValues: (tag: string) => void options.loadTagValues(tag),
+}));
+function insertIntoExpression(text: string): void {
+  mode.value = 'traceql';
+  // Spaced around the operator, as a query is read rather than as it is
+  // minimally spelled. `duration` is ordered, so it gets an inequality.
+  const snippet = text === 'duration' ? 'duration >' : `${text} = ""`;
+  const current = expression.value.trim();
+  if (!current) expression.value = `{${snippet}}`;
+  else if (current.endsWith('}')) expression.value = `${current.slice(0, -1)} && ${snippet}}`;
+  else expression.value = `${current} ${snippet}`;
+}
+const issueMessage = (id: string): string => issueText[id] ?? id;
+
+const traceIds = ref<string[]>([]);
+const submittedIds = ref<TraceIdSubmission | null>(null);
+const byId = useTraceQLTraceIds(ds, submittedIds);
+
+/** A trace picked from the list — shown INLINE beside it, as the native tab
+ *  does. The overlay below is a different thing: a trace arrived at by URL,
+ *  where there is no list for a card to sit beside. */
+const openTraceId = ref<string | null>(null);
+const urlTraceId = computed(() => {
+  const raw = route.query[TRACEQL_POPOUT_QUERY];
+  return typeof raw === 'string' && raw ? raw : null;
+});
+/** Clears only ITS key: wiping the whole query took the datasource and every
+ *  other page parameter with it. */
+function closeUrlTrace(): void {
+  const query = { ...route.query };
+  delete query[TRACEQL_POPOUT_QUERY];
+  void router.replace({ query });
+}
+const railOpen = ref(true);
+// Picking dots / brushing a box narrows the LIST to those traces; no query
+// fires. The inline detail still opens from a list row, so a pick and a
+// selection stay separate gestures.
+const pickedTraceIds = ref<Set<string>>(new Set());
+const pickedKeys = computed(() => [...pickedTraceIds.value]);
+const isPicking = computed(() => pickedTraceIds.value.size > 0);
+function togglePick(rowKey: string): void {
+  const next = new Set(pickedTraceIds.value);
+  if (next.has(rowKey)) next.delete(rowKey);
+  else next.add(rowKey);
+  pickedTraceIds.value = next;
+}
+function onScatterBrush(keys: string[]): void {
+  const next = new Set(pickedTraceIds.value);
+  for (const k of keys) next.add(k);
+  pickedTraceIds.value = next;
+}
+function resetPick(): void {
+  pickedTraceIds.value = new Set();
+}
+
+/** Statuses learned from a detail read, keyed by trace id. The search response
+ *  has no failure marker, so a row is unknown until something authoritative
+ *  says otherwise — and a detail is authoritative, because every OTLP span
+ *  carries its own status. Cleared with the rest of the results. */
+const knownStatus = ref<Map<string, boolean>>(new Map());
+function recordStatus(v: { traceId: string; isError: boolean }): void {
+  if (knownStatus.value.get(v.traceId) === v.isError) return;
+  knownStatus.value = new Map(knownStatus.value).set(v.traceId, v.isError);
+}
+/** Rows the search could not classify are read, one trace at a time, once the
+ *  list has landed — `recordStatus` receives each answer as it arrives. The
+ *  window is the one the RUN captured, not the picker's current reading. */
+const ranWindow = computed(() => submitted.value?.window ?? null);
+const statusResolver = useTraceQLStatusResolver(ds, ranWindow, recordStatus);
+// The search answers first and the statuses follow, because a list that waited
+// for them would be slower than the question the operator actually asked.
+watch(
+  () => search.rows.value,
+  (list) => {
+    if (mode.value === 'traceId') return;
+    void statusResolver.resolve(list);
+  },
+);
+
+/** Apply what we have learned to the rows the list renders. */
+function withKnownStatus(rows: TraceListRow[]): TraceListRow[] {
+  if (knownStatus.value.size === 0) return rows;
+  return rows.map((r) => {
+    const known = knownStatus.value.get(r.key);
+    return known === undefined ? r : { ...r, isError: known, errorUnknown: false };
+  });
+}
+/**
+ * Whether to SAY the source is down.
+ *
+ * The availability probe is cached for minutes and is only advisory: a query
+ * that just succeeded is newer evidence than a probe that failed before the
+ * source recovered, and must win. A failing query says so on its own.
+ */
+const sourceDown = computed(() => {
+  if (!status.value || !status.value.configured) return false;
+  if (hasRun.value && search.reachable.value && !search.error.value) return false;
+  return !status.value.reachable;
+});
+const resultRows = computed(() =>
+  withKnownStatus(mode.value === 'traceId' ? byId.rows.value : search.rows.value),
+);
+/** What the list shows: every row, or the picked ones while a pick is on. The
+ *  distribution always draws them all — it is where the pick is made. */
+const visibleRows = computed(() =>
+  isPicking.value ? resultRows.value.filter((r) => pickedTraceIds.value.has(r.key)) : resultRows.value,
+);
+const resultMax = computed(() =>
+  mode.value === 'traceId' ? resultRows.value.reduce((m, r) => Math.max(m, r.duration), 0) : search.maxDuration.value,
+);
+// A SUBMITTED lookup is a run: keying this on `answered` let the results area
+// ask for a click on Run while the lookup it started was still in flight.
+const hasRun = computed(() => (mode.value === 'traceId' ? submittedIds.value !== null : submitted.value !== null));
+const busy = computed(() => (mode.value === 'traceId' ? byId.isFetching.value : search.isFetching.value));
+
+// Cascade-clear: the store and the layer both invalidate everything on screen.
+// The window does not, because a run CAPTURES it — an auto-refresh tick must
+// not wipe a result set an operator is reading.
+watch([ds, layerKey], () => {
+  submitted.value = null;
+  submittedIds.value = null;
+  knownStatus.value = new Map();
+  statusResolver.stop();
+  openTraceId.value = null;
+  rows.value = { tags: [] };
+  expression.value = '';
+});
+
+// A service switch is a context change, not a staged edit: the committed query
+// belonged to the previous service, so its result set, the open trace and the
+// pick go with it and the page returns to the Run-query prompt. Every other
+// builder field only stages — it waits for Run query, as the native tab does.
+watch(
+  () => rows.value.service ?? '',
+  (next, prev) => {
+    if (next === prev || mode.value === 'traceId') return;
+    submitted.value = null;
+    openTraceId.value = null;
+    statusResolver.stop();
+    knownStatus.value = new Map();
+    resetPick();
+  },
+);
+
+// A lookup by id is the one query that can run without a window, and it
+// DEFAULTS to none: an id identifies a trace on its own, and a window an
+// operator did not choose is the usual reason a known-good id finds nothing.
+// Every other mode needs a window, so leaving "No time range" on would refuse
+// them — the switch carries the choice both ways, as the native tab does.
+watch(mode, (m) => {
+  // The list is mode-scoped, so the open trace and the pick are too: leaving
+  // them would show a trace from a result set the page is no longer rendering.
+  openTraceId.value = null;
+  resetPick();
+  if (m === 'traceId') windowMinutes.value = NO_TIME_RANGE;
+  else if (windowMinutes.value === NO_TIME_RANGE) windowMinutes.value = 30;
+});
+
+function run(): void {
+  // Cascade-clear: a new run invalidates the open detail, the in-page pick and
+  // everything learned from the previous result set. A pick left behind would
+  // filter the NEXT result set against ids it does not contain — an empty list
+  // under a query that answered.
+  openTraceId.value = null;
+  knownStatus.value = new Map();
+  statusResolver.stop();
+  resetPick();
+  schemaOpen.value = false;
+  if (rangeError.value) return;
+  if (mode.value === 'traceId') {
+    runs += 1;
+    submittedIds.value = {
+      ids: [...traceIds.value],
+      window: windowMinutes.value === NO_TIME_RANGE ? null : currentWindow(),
+      nonce: runs,
+    };
+    return;
+  }
+  if (typedTraceId.value) {
+    openTraceId.value = typedTraceId.value;
+    return;
+  }
+  runs += 1;
+  // The tab's own picker, not the topbar's range — this screen owns its window.
+  const window = currentWindow();
+  if (!window) return;
+  submitted.value = { q: editorText.value, window, limit: limit.value, nonce: runs };
+}
+
+function editAsTraceQL(): void {
+  expression.value = generated.value;
+  mode.value = 'traceql';
+}
+
+function addTag(): void {
+  rows.value.tags = [...(rows.value.tags ?? []), { key: '', value: '' }];
+}
+function removeTag(i: number): void {
+  rows.value.tags = (rows.value.tags ?? []).filter((_, n) => n !== i);
+}
+
+/** One line per finding. They say what the SCHEMA does not have, never what
+ *  the backend would do with it. */
+const issueText: Record<string, string> = {
+  'incomplete-spanset': t('Not a complete spanset.'),
+  'empty-value': t('This condition has no value.'),
+  'unscoped-attribute': t('An attribute needs a scope — span.http.method, resource.service.name, or .http.method for the unscoped form.'),
+  'unknown-field': t('Not a field in this schema. The intrinsics are duration, name and status; everything else is an attribute and carries a scope.'),
+  'other-store-attribute': t('This attribute belongs to the other store’s schema.'),
+  'status-vocabulary': t('A status other than “ok”, “error” or “unset”.'),
+};
+</script>
+
+<template>
+  <div class="tql-tab">
+    <div class="tql-top-strip">
+      <header class="tql-toolbar sw-card">
+        <div class="tql-toolbar-head">
+          <span class="kicker">{{ ds === 'zipkin' ? t('Traces · TraceQL · Zipkin') : t('Traces · TraceQL') }}</span>
+          <div class="seg" role="group" :aria-label="t('Query by')">
+            <button type="button" :class="{ on: mode === 'builder' }" :aria-pressed="mode === 'builder'" @click="mode = 'builder'">
+              {{ t('Builder') }}
+            </button>
+            <button type="button" :class="{ on: mode === 'traceql' }" :aria-pressed="mode === 'traceql'" @click="mode = 'traceql'">
+              {{ t('TraceQL') }}
+            </button>
+            <button type="button" :class="{ on: mode === 'traceId' }" :aria-pressed="mode === 'traceId'" @click="mode = 'traceId'">
+              {{ t('Trace ID') }}
+            </button>
+          </div>
+          <span v-if="status && !status.configured" class="hint">{{ t('not configured') }}</span>
+          <span v-else-if="sourceDown" class="hint err">{{ t('unreachable') }}</span>
+          <span v-else-if="busy" class="hint">{{ t('refreshing…') }}</span>
+          <button
+            class="sw-btn primary tql-run-btn"
+            type="button"
+            :disabled="Boolean(rangeError) || (mode === 'traceId' && !traceIds.length)"
+            @click="run"
+          >
+            {{ mode !== 'traceId' && typedTraceId ? t('Open trace') : t('Run query') }}
+          </button>
+        </div>
+
+        <div v-if="mode === 'builder'" class="tql-conditions">
+          <label class="cf">
+            <span>{{ t('Service') }}</span>
+            <TypeaheadSelect
+              v-model="serviceModel"
+              :aria-label="t('Service')"
+              :options="serviceOptions"
+              :placeholder="t('All')"
+              class="cf-tas"
+            />
+          </label>
+          <label class="cf" :class="{ disabled: !rows.service }">
+            <span>{{ t('Span name') }} <small v-if="!rows.service" class="dim">— {{ t('pick a service') }}</small></span>
+            <TypeaheadSelect
+              v-model="spanNameModel"
+              :aria-label="t('Span name')"
+              :options="spanNameOptions"
+              :placeholder="rows.service ? t('any') : t('select a service first')"
+              :disabled="!rows.service"
+              class="cf-tas"
+            />
+          </label>
+          <label class="cf">
+            <span>{{ t('Status') }}</span>
+            <!-- The values the language defines, on both stores. What a
+                 backend applies is its own business. -->
+            <select v-model="rows.status" class="cf-input">
+              <option value="">{{ t('All') }}</option>
+              <option v-for="v in TRACEQL_STATUS_VALUES" :key="v" :value="v">{{ v }}</option>
+            </select>
+          </label>
+          <TraceQLWindowFields
+            v-model:window-minutes="windowMinutes"
+            v-model:custom-start="customStart"
+            v-model:custom-end="customEnd"
+            v-model:limit="limit"
+            :custom-range="CUSTOM_RANGE"
+            :trace-id-mode="isTraceIdMode"
+          />
+          <!-- One labelled pair, as the native tab's duration range is. The
+               values keep TraceQL's units (`100ms`, `2s`), which is what the
+               expression carries — hence text inputs rather than numbers. -->
+          <div class="cf" :title="t('Trace duration, with a unit — us, ms, s, m, h.')">
+            <span>{{ t('Duration range') }}</span>
+            <div class="cf-range">
+              <input v-model="rows.minDuration" class="cf-input cf-range-num mono" type="text" :placeholder="t('min')" />
+              <span class="cf-range-sep">–</span>
+              <input v-model="rows.maxDuration" class="cf-input cf-range-num mono" type="text" :placeholder="t('max')" />
+            </div>
+          </div>
+          <div class="cf cf-full tql-tag-row">
+            <span>{{ t('Tags') }}</span>
+            <div class="tql-taglist">
+              <div v-for="(tag, i) in rows.tags ?? []" :key="i" class="tql-tag">
+                <input
+                  v-model="tag.key"
+                  list="tql-tagkeys"
+                  class="cf-input mono"
+                  :placeholder="t('key')"
+                  @change="options.loadTagValues(tag.key)"
+                />
+                <input
+                  v-model="tag.value"
+                  :list="`tql-tagvals-${i}`"
+                  class="cf-input mono"
+                  :placeholder="t('value')"
+                />
+                <datalist :id="`tql-tagvals-${i}`">
+                  <option v-for="v in options.valuesFor(tag.key)" :key="v" :value="v" />
+                </datalist>
+                <button type="button" class="sw-btn small ghost" :aria-label="t('Remove')" @click="removeTag(i)">×</button>
+              </div>
+              <button type="button" class="sw-btn small ghost" @click="addTag">+ {{ t('Tag') }}</button>
+              <datalist id="tql-tagkeys">
+                <option v-for="k in options.tagKeys.value" :key="k" :value="k" />
+              </datalist>
+            </div>
+          </div>
+          <div class="cf cf-full">
+            <span>{{ t('TraceQL query') }}</span>
+            <div class="tql-generated">
+              <code class="mono">{{ generated }}</code>
+              <button type="button" class="sw-btn small ghost" @click="editAsTraceQL">{{ t('Edit as TraceQL') }}</button>
+            </div>
+          </div>
+        </div>
+
+        <div v-else-if="mode === 'traceId'" class="tql-conditions">
+          <!-- Not inside a <label>: a label forwards a click on its text to the
+               first control in it, which here is a chip's remove button. -->
+          <div class="cf cf-wide">
+            <span>{{ t('Trace ID(s)') }}</span>
+            <ChipInput
+              v-model="traceIds"
+              :placeholder="t('paste a trace id, then Enter')"
+              :aria-label="t('Trace ID(s)')"
+            />
+            <span class="cf-note">
+              {{ t('This API reads one id per request, so several ids are {n} separate reads.', { n: TRACE_ID_LOOKUP_MAX }) }}
+            </span>
+          </div>
+          <TraceQLWindowFields
+            v-model:window-minutes="windowMinutes"
+            v-model:custom-start="customStart"
+            v-model:custom-end="customEnd"
+            v-model:limit="limit"
+            :custom-range="CUSTOM_RANGE"
+            :trace-id-mode="isTraceIdMode"
+          />
+        </div>
+
+        <div v-else class="tql-conditions">
+          <div class="cf cf-full">
+            <span>
+              {{ t('TraceQL query') }}
+              <small class="dim">— {{ t('Shift+Enter runs; a bare trace id opens that trace') }}</small>
+            </span>
+            <TraceQLEditor
+              v-model="editorText"
+              :schema="schema"
+              :message-for="issueMessage"
+              @run="run"
+            />
+          </div>
+          <TraceQLWindowFields
+            v-model:window-minutes="windowMinutes"
+            v-model:custom-start="customStart"
+            v-model:custom-end="customEnd"
+            v-model:limit="limit"
+            :custom-range="CUSTOM_RANGE"
+            :trace-id-mode="isTraceIdMode"
+          />
+
+          <div class="cf cf-full">
+            <TraceQLSchemaPanel
+              v-model:open="schemaOpen"
+              :ds="ds"
+              :tag-keys="options.tagKeys.value"
+              @insert="insertIntoExpression"
+            />
+          </div>
+        </div>
+
+        <!-- What the editor found in the text being written. What the BACKEND
+             said about the text that was RUN belongs with the result it stands
+             in for, in the list below. -->
+        <div v-if="issues.length || rangeError" class="tql-notice">
+          <p v-if="rangeError" class="tql-range-err">{{ t(rangeError, { d: MAX_RECORD_RANGE_DAYS }) }}</p>
+          <ul v-if="issues.length" class="tql-issues">
+            <li v-for="i in issues" :key="i.id">
+              <code class="mono">{{ i.found }}</code>
+              {{ issueText[i.id] }}
+            </li>
+          </ul>
+        </div>
+      </header>
+
+      <section class="tql-scatter sw-card">
+        <header class="tql-scatter-head">
+          <span v-if="!isPicking" class="kicker">{{ t('Distribution') }}</span>
+          <span v-else class="kicker pick-kicker">{{ t('{n} picked', { n: pickedTraceIds.size }) }}</span>
+          <span class="legend">
+            <span class="lg ok" /> {{ t('ok') }}
+            <span class="lg err" /> {{ t('err') }}
+          </span>
+          <button
+            v-if="isPicking"
+            class="sw-btn small ghost reset-btn"
+            type="button"
+            :title="t('Clear in-page filter')"
+            @click="resetPick"
+          >{{ t('Reset') }}</button>
+        </header>
+        <TraceDistribution
+          v-if="resultRows.length"
+          :rows="resultRows"
+          :max-duration="resultMax"
+          :selected-key="openTraceId"
+          :highlight-keys="pickedKeys"
+          @select="togglePick($event.key)"
+          @brush="onScatterBrush"
+        />
+        <div v-else class="tql-empty">{{ t('no traces') }}</div>
+      </section>
+    </div>
+
+    <article class="tql-list-card sw-card">
+      <header class="tql-list-head">
+        <h4>{{ t('Traces') }}</h4>
+        <span v-if="!search.reachable.value" class="err-chip">{{ t('unreachable') }}</span>
+        <span v-if="resultRows.length" class="hint">{{ visibleRows.length }} {{ t('traces') }}</span>
+        <span v-if="isPicking" class="hint">{{ t('picked from {n}', { n: resultRows.length }) }}</span>
+        <span v-if="mode !== 'traceId' && search.capped.value" class="hint">
+          {{ t('capped at {n} — narrow the query', { n: resultRows.length }) }}
+        </span>
+        <span v-if="statusResolver.pending.value" class="hint">
+          {{ t('checking {n} statuses…', { n: statusResolver.pending.value }) }}
+        </span>
+        <!-- An id the source could not be ASKED about is not an id it does not
+             have: an outage reported as "not found" sends the operator looking
+             for a trace that may well be there. -->
+        <span v-if="mode === 'traceId' && byId.skipped.value.length" class="hint err">
+          {{ t('{n} not read — over the {max}-id limit', { n: byId.skipped.value.length, max: TRACE_ID_LOOKUP_MAX }) }}
+        </span>
+        <span v-if="mode === 'traceId' && byId.failed.value.length" class="hint err">
+          {{ t('{n} not read — the source did not answer', { n: byId.failed.value.length }) }}
+        </span>
+        <span v-if="mode === 'traceId' && byId.missing.value.length" class="hint err">
+          {{ t('{n} not found', { n: byId.missing.value.length }) }}
+        </span>
+      </header>
+
+      <div v-if="status && !status.configured" class="tql-empty">
+        {{ t('No TraceQL URL is configured for this source. Set oap.traceql in horizon.yaml.') }}
+      </div>
+      <div v-else-if="sourceDown" class="tql-empty err">
+        {{ t('TraceQL is configured but not answering at {url}.', { url: status?.url ?? '' }) }}
+        <code v-if="status?.error" class="mono">{{ status.error }}</code>
+      </div>
+      <div v-else-if="!hasRun" class="tql-empty">
+        {{ t('Pick your conditions, then click Run query.') }}
+      </div>
+      <div v-else-if="busy" class="tql-empty">{{ t('Reading data…') }}</div>
+      <div v-else-if="mode !== 'traceId' && search.error.value" class="tql-empty err">
+        <span class="tql-refused-label">{{ t('The source refused this query') }}</span>
+        <code class="mono">{{ search.error.value }}</code>
+      </div>
+      <div v-else-if="!resultRows.length" class="tql-empty">{{ t('No traces matched.') }}</div>
+      <TraceListPanel
+        v-else-if="!openTraceId"
+        :rows="visibleRows"
+        :selected-key="openTraceId"
+        :max-duration="resultMax"
+        @select="openTraceId = $event.key"
+      />
+    </article>
+
+    <!-- A picked trace: the list folds to a rail and the detail sits beside it,
+         which is how the native tab reads. -->
+    <section v-if="openTraceId" class="tql-detail-split" :class="{ 'rail-collapsed': !railOpen }">
+      <TraceListPanel
+        foldable
+        :rail-open="railOpen"
+        :rows="visibleRows"
+        :selected-key="openTraceId"
+        :max-duration="resultMax"
+        :title="t('Traces')"
+        :count-hint="visibleRows.length"
+        @select="openTraceId = $event.key"
+        @toggle-rail="railOpen = !railOpen"
+      />
+      <TraceQLTraceDetail
+        :ds="ds"
+        :trace-id="openTraceId"
+        @status="recordStatus"
+        @close="openTraceId = null"
+      />
+    </section>
+
+    <!-- Arrived by URL, with no list to sit beside. -->
+    <div v-if="urlTraceId" class="tql-overlay" @click.self="closeUrlTrace">
+      <TraceQLTraceDetail
+        class="tql-overlay-card"
+        :ds="ds"
+        :trace-id="urlTraceId"
+        @status="recordStatus"
+        @close="closeUrlTrace"
+      />
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.tql-tab { display: flex; flex-direction: column; gap: 12px; }
+.tql-top-strip {
+  display: grid;
+  grid-template-columns: 4fr 1fr;
+  gap: 12px;
+  align-items: stretch;
+}
+.tql-top-strip .tql-scatter { min-height: var(--sw-trace-strip-h); }
+@media (max-width: 1100px) { .tql-top-strip { grid-template-columns: 1fr; } }
+
+.tql-toolbar { padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; overflow: visible; }
+/* Centred rather than baseline-aligned: the switch and the button are the same
+   height here, so they read as one bar. */
+.tql-toolbar-head { display: flex; align-items: center; gap: 10px; }
+.tql-run-btn { margin-left: auto; }
+/* The native tab's Run button, to the pixel — the global `.sw-btn.primary` is
+   narrower, and the two sit on the same row of the same console. */
+.sw-btn.primary {
+  background: var(--sw-accent);
+  color: var(--sw-bg-0);
+  border: none;
+  height: 26px;
+  padding: 0 14px;
+  border-radius: 4px;
+  font-size: 11px;
+  font-weight: 600;
+  cursor: pointer;
+}
+.sw-btn.primary:disabled { opacity: 0.5; cursor: not-allowed; }
+.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;
+  height: 26px;
+  padding: 0 12px;
+  font: inherit;
+  font-size: 11px;
+  cursor: pointer;
+}
+.seg button + button { border-left: 1px solid var(--sw-line-2); }
+.seg button.on { background: var(--sw-accent); color: #fff; }
+.kicker {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.1em;
+  color: var(--sw-accent);
+  font-weight: 600;
+}
+.hint { font-size: 10.5px; color: var(--sw-fg-3); }
+.hint.err { color: var(--sw-err); }
+
+.tql-conditions { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px 10px; }
+/* `:deep` so the window/limit fields, which live in their own component, wear
+   the same condition-field styling as the ones written inline here — a scoped
+   rule stops at the component boundary and left them browser-default. */
+.cf, :deep(.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-full { grid-column: 1 / -1; }
+.cf.disabled > span { color: var(--sw-fg-3); opacity: 0.7; }
+.cf small, :deep(.cf small) { font-weight: 400; font-size: 9.5px; margin-left: 4px; font-style: italic; }
+.cf .dim { color: var(--sw-fg-3); }
+.cf-input, :deep(.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:focus, :deep(.cf-input:focus) { outline: none; border-color: var(--sw-accent-line); }
+.cf-input:disabled, :deep(.cf-input:disabled) { opacity: 0.5; cursor: not-allowed; background: var(--sw-bg-1); }
+.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;
+}
+.tql-editor { height: auto; padding: 6px 8px; line-height: 1.5; resize: vertical; }
+/* Three key/value pairs to a row, each pair sized to its share rather than to
+   a fixed width, so a long tag key is readable instead of clipped. */
+.tql-taglist { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px 10px; align-items: center; }
+.tql-tag { display: flex; gap: 4px; align-items: center; min-width: 0; }
+.tql-tag .cf-input { min-width: 0; flex: 1 1 auto; }
+.tql-taglist > .sw-btn { justify-self: start; }
+.tql-generated { display: flex; align-items: center; gap: 8px; }
+.tql-generated code {
+  flex: 1;
+  min-width: 0;
+  overflow-x: auto;
+  white-space: nowrap;
+  font-size: 11px;
+  color: var(--sw-fg-1);
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-1);
+  border-radius: 4px;
+  padding: 5px 8px;
+}
+.cf-note { font-size: 9.5px; color: var(--sw-fg-3); }
+.tql-notice {
+  margin-top: 8px;
+  padding: 7px 10px;
+  border: 1px solid var(--sw-warn);
+  border-radius: 5px;
+  background: color-mix(in srgb, var(--sw-warn) 8%, transparent);
+  /* Bounded, so a query that trips many rules scrolls its own findings rather
+     than stretching the card and the distribution drawn beside it. */
+  max-height: 96px;
+  overflow: auto;
+}
+.tql-refused-label { font-weight: 600; margin-right: 8px; }
+.tql-range-err { margin: 0 0 3px; font-size: 10.5px; color: var(--sw-warn); }
+.tql-issues { margin: 0; padding-left: 16px; font-size: 10.5px; color: var(--sw-warn); }
+.tql-issues code { color: var(--sw-fg-1); }
+
+.tql-scatter { padding: 6px 10px 8px; display: flex; flex-direction: column; min-height: 0; }
+.tql-scatter-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 2px; flex: 0 0 auto; }
+.tql-scatter-head .legend { margin-left: auto; font-size: 10.5px; color: var(--sw-fg-3); display: inline-flex; gap: 10px; align-items: center; }
+.tql-scatter-head .lg { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 3px; vertical-align: middle; }
+.tql-scatter-head .lg.ok { background: var(--sw-accent); }
+.tql-scatter-head .lg.err { background: var(--sw-err); }
+.tql-scatter :deep(.scatter-wrap) { flex: 1; min-height: 0; }
+
+.tql-detail-split {
+  display: grid;
+  grid-template-columns: 320px 1fr;
+  gap: 12px;
+  align-items: start;
+}
+.tql-detail-split.rail-collapsed { grid-template-columns: 64px 1fr; }
+.tql-overlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.55);
+  z-index: 40;
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+  padding: 40px 16px;
+  overflow: auto;
+}
+.tql-overlay-card { width: min(1100px, 100%); }
+.tql-list-card { padding: 0; display: flex; flex-direction: column; min-height: 0; max-height: calc(100vh - 80px); overflow: hidden; }
+.pick-kicker { color: var(--sw-accent-2); font-weight: 700; }
+.reset-btn { margin-left: 6px; }
+
+.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; }
+
+.tql-list-head {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+  padding: 8px 12px;
+  border-bottom: 1px solid var(--sw-line-1);
+  flex: 0 0 auto;
+}
+.tql-list-head h4 { margin: 0; font-size: 12px; font-weight: 600; }
+.err-chip {
+  font-size: 10px;
+  color: var(--sw-err);
+  border: 1px solid var(--sw-err);
+  border-radius: 3px;
+  padding: 0 5px;
+}
+.tql-list-card :deep(.tr-rowlist) { overflow-y: auto; min-height: 0; }
+.tql-empty {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  align-items: center;
+  justify-content: center;
+  flex: 1;
+  min-height: 90px;
+  padding: 18px 12px;
+  font-size: 11.5px;
+  color: var(--sw-fg-3);
+  text-align: center;
+}
+.tql-empty.err { color: var(--sw-err); }
+.tql-empty code { color: var(--sw-fg-3); font-size: 10.5px; overflow-wrap: anywhere; }
+.mono { font-family: var(--sw-mono); }
+</style>
diff --git a/apps/ui/src/layer/traceql/TraceQLDetailCard.vue b/apps/ui/src/layer/traceql/TraceQLDetailCard.vue
new file mode 100644
index 00000000..87e0d7a0
--- /dev/null
+++ b/apps/ui/src/layer/traceql/TraceQLDetailCard.vue
@@ -0,0 +1,192 @@
+<!--
+  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.
+-->
+<!--
+  A TraceQL trace, in full.
+
+  Structurally the native trace detail — the same header, the same
+  Default / Tree / Statistics switch, the same copy affordances — and
+  deliberately a SEPARATE component rather than the native one fed converted
+  spans. Converting OTLP into SkyWalking's span model is OAP's job, not
+  Horizon's; here a span is an OTLP span, and every field on screen is one the
+  protocol actually defines.
+-->
+<script setup lang="ts">
+import { computed, onBeforeUnmount, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import type { TraceQLDatasource, TraceQLSpan } from '@skywalking-horizon-ui/api-client';
+import TraceQLWaterfall from './TraceQLWaterfall.vue';
+import TraceQLTreeView from './TraceQLTreeView.vue';
+import TraceQLStatsView from './TraceQLStatsView.vue';
+import TraceQLSpanModal from './TraceQLSpanModal.vue';
+import { buildServiceColors, fmtDateTime, fmtMs, serviceColorFrom, TRACEQL_POPOUT_QUERY } from './traceqlDetailShared';
+
+const props = defineProps<{
+  ds: TraceQLDatasource;
+  traceId: string;
+  spans: TraceQLSpan[];
+  loading?: boolean;
+}>();
+const emit = defineEmits<{ (e: 'close'): void }>();
+const { t } = useI18n({ useScope: 'global' });
+
+type ViewMode = 'default' | 'tree' | 'statistics';
+const viewMode = ref<ViewMode>('default');
+const selected = ref<TraceQLSpan | null>(null);
+
+// Keeping the picked span would show the previous trace's span under this
+// trace's id.
+watch(
+  () => props.traceId,
+  () => {
+    selected.value = null;
+    viewMode.value = 'default';
+  },
+);
+
+const services = computed(() => [...buildServiceColors(props.spans).keys()]);
+const colors = computed(() => buildServiceColors(props.spans));
+const startedUs = computed(() => (props.spans.length ? Math.min(...props.spans.map((s) => s.startUs)) : 0));
+const durationUs = computed(() => {
+  if (props.spans.length === 0) return 0;
+  return Math.max(...props.spans.map((s) => s.startUs + s.durationUs)) - startedUs.value;
+});
+
+/** Copy the id, and a URL that reopens this trace on THIS store — the two
+ *  TraceQL rows are different pages over different stores, so a link without
+ *  the datasource would reopen the id against whichever one the recipient
+ *  happened to be on. */
+const copyFlash = ref<'id' | 'url' | null>(null);
+let copyFlashTimer: ReturnType<typeof setTimeout> | null = null;
+function flashCopy(kind: 'id' | 'url'): void {
+  copyFlash.value = kind;
+  if (copyFlashTimer) clearTimeout(copyFlashTimer);
+  copyFlashTimer = setTimeout(() => { copyFlash.value = null; }, 1400);
+}
+function copyTraceId(): void {
+  navigator.clipboard?.writeText(props.traceId).then(() => flashCopy('id'), () => {});
+}
+function copyShareableUrl(): void {
+  const url = new URL(globalThis.location.href);
+  // The REAL id, the one every other surface shows — never the hex wire form.
+  url.searchParams.set(TRACEQL_POPOUT_QUERY, props.traceId);
+  url.searchParams.set('ds', props.ds);
+  navigator.clipboard?.writeText(url.toString()).then(() => flashCopy('url'), () => {});
+}
+onBeforeUnmount(() => { if (copyFlashTimer) clearTimeout(copyFlashTimer); });
+</script>
+
+<template>
+  <article class="tqc sw-card">
+    <header class="tqc-head">
+      <span class="tqc-label">{{ t('trace') }}</span>
+      <h3 class="mono">{{ traceId }}</h3>
+      <button class="sw-btn small ghost" type="button" :title="t('Copy trace id')" @click="copyTraceId">⧉ {{ t('id') }}</button>
+      <button class="sw-btn small ghost" type="button" :title="t('Copy shareable URL')" @click="copyShareableUrl">⧉ {{ t('url') }}</button>
+      <transition name="copy-flash">
+        <span v-if="copyFlash" class="copy-chip">{{ copyFlash === 'url' ? t('url copied') : t('id copied') }}</span>
+      </transition>
+      <div class="view-toggle">
+        <button :class="['vt-btn', { on: viewMode === 'default' }]" type="button" @click="viewMode = 'default'">{{ t('Default') }}</button>
+        <button :class="['vt-btn', { on: viewMode === 'tree' }]" type="button" @click="viewMode = 'tree'">{{ t('Tree') }}</button>
+        <button :class="['vt-btn', { on: viewMode === 'statistics' }]" type="button" @click="viewMode = 'statistics'">{{ t('Statistics') }}</button>
+      </div>
+      <button type="button" class="sw-btn small ghost" :aria-label="t('Close')" @click="emit('close')">×</button>
+    </header>
+
+    <div class="tqc-stats">
+      <div><span class="k">{{ t('STARTED') }}</span><span class="v mono">{{ fmtDateTime(startedUs) }}</span></div>
+      <div><span class="k">{{ t('DURATION') }}</span><span class="v mono">{{ fmtMs(durationUs) }}</span></div>
+      <div><span class="k">{{ t('SPANS') }}</span><span class="v mono">{{ spans.length }}</span></div>
+      <div><span class="k">{{ t('SERVICES') }}</span><span class="v mono">{{ services.length }}</span></div>
+    </div>
+
+    <div class="tqc-legend">
+      <span v-for="svc in services" :key="svc" class="lg">
+        <span class="dot" :style="{ background: serviceColorFrom(colors, svc) }" />{{ svc || '—' }}
+      </span>
+    </div>
+
+    <p v-if="loading" class="tqc-note">{{ t('Reading data…') }}</p>
+    <TraceQLWaterfall
+      v-else-if="viewMode === 'default'"
+      :spans="spans"
+      :selected-span="selected"
+      @select-span="selected = $event"
+    />
+    <TraceQLTreeView
+      v-else-if="viewMode === 'tree'"
+      :spans="spans"
+      :selected-span="selected"
+      @select-span="selected = $event"
+    />
+    <TraceQLStatsView v-else :spans="spans" />
+
+    <TraceQLSpanModal v-if="selected" :span="selected" :trace-id="traceId" @close="selected = null" />
+  </article>
+</template>
+
+<style scoped>
+/* The same ceiling the trace list beside it uses, so a long trace ends on the
+   same line as the rail instead of stopping 40px short. */
+.tqc { padding: 0; display: flex; flex-direction: column; min-width: 0; max-height: calc(100vh - 80px); overflow: auto; }
+.tqc-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  border-bottom: 1px solid var(--sw-line-1);
+  flex-wrap: wrap;
+}
+.tqc-label { font-size: 12px; font-weight: 500; color: var(--sw-fg-3); }
+.tqc-head h3 { margin: 0; font-size: 12px; font-weight: 400; color: var(--sw-fg-1); overflow-wrap: anywhere; }
+.copy-chip { font-size: 10px; color: var(--sw-accent); border: 1px solid var(--sw-accent); border-radius: 3px; padding: 0 5px; }
+.copy-flash-enter-active, .copy-flash-leave-active { transition: opacity 0.2s; }
+.copy-flash-enter-from, .copy-flash-leave-to { opacity: 0; }
+.view-toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--sw-line-2); border-radius: 5px; overflow: hidden; }
+.vt-btn {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-2);
+  border: none;
+  height: 24px;
+  padding: 0 10px;
+  font: inherit;
+  font-size: 11px;
+  font-weight: 600;
+  cursor: pointer;
+}
+.vt-btn + .vt-btn { border-left: 1px solid var(--sw-line-2); }
+.vt-btn.on { background: var(--sw-accent); color: #fff; }
+.tqc-stats { display: flex; gap: 26px; padding: 8px 12px 4px; }
+.tqc-stats .k { display: block; font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--sw-fg-3); }
+.tqc-stats .v { display: block; font-size: 14px; line-height: 1.45; font-weight: 700; color: var(--sw-fg-0); }
+.tqc-legend { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 12px 8px; }
+.tqc-legend .lg {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 10.5px;
+  font-family: var(--sw-sans);
+  color: var(--sw-fg-2);
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 10px;
+  padding: 1px 6px 1px 4px;
+}
+.tqc-legend .dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; flex: 0 0 auto; }
+.tqc-note { padding: 16px 12px; font-size: 11.5px; color: var(--sw-fg-3); }
+.mono { font-family: var(--sw-mono); }
+</style>
diff --git a/apps/ui/src/layer/traceql/TraceQLEditor.vue b/apps/ui/src/layer/traceql/TraceQLEditor.vue
new file mode 100644
index 00000000..e95726cb
--- /dev/null
+++ b/apps/ui/src/layer/traceql/TraceQLEditor.vue
@@ -0,0 +1,170 @@
+<!--
+  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.
+-->
+<!--
+  The TraceQL expression field: highlighting, completion fed by the store's own
+  schema, and the lint's findings as editor markers on the text they are about.
+
+  The schema is attached to the MODEL rather than passed to a provider, so one
+  language registration serves both datasources without either one's tags
+  leaking into the other's suggestions.
+-->
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
+import * as monaco from 'monaco-editor';
+import { RR_THEME_NAME, setupMonaco, type TraceQLSchema } from '@/monaco/setup';
+import { TRACEQL_LANGUAGE_ID } from '@/monaco/traceql-grammar';
+import { lintTraceQL } from './traceqlLint';
+
+const props = defineProps<{ modelValue: string; schema: TraceQLSchema; messageFor: (id: string) => string }>();
+
+/** The shape of a query — one spanset, conditions joined with `&&`, a duration
+ *  bound — shown behind an empty editor so it is visible before anything is
+ *  typed. */
+const EXAMPLE = '{resource.service.name="agent::songs" && span.http.method="GET" && duration>100ms}';
+const emit = defineEmits<{ 'update:modelValue': [value: string]; run: [] }>();
+
+const host = ref<HTMLElement | null>(null);
+const editor = shallowRef<monaco.editor.IStandaloneCodeEditor | null>(null);
+const model = shallowRef<(monaco.editor.ITextModel & { __tqlSchema?: TraceQLSchema }) | null>(null);
+
+/** Put the lint on the text it is about, so a marker is read where the problem
+ *  is rather than in a list underneath. */
+function applyMarkers(): void {
+  const m = model.value;
+  if (!m) return;
+  const text = m.getValue();
+  const markers: monaco.editor.IMarkerData[] = [];
+  // The SAME datasource the notice below lints for — the two stores' schemas
+  // differ, and two surfaces disagreeing about one expression is worse than
+  // either being silent.
+  for (const issue of lintTraceQL(text, props.schema.ds)) {
+    const at = issue.found ? text.indexOf(issue.found) : -1;
+    const start = at >= 0 ? m.getPositionAt(at) : { lineNumber: 1, column: 1 };
+    const end = at >= 0 ? m.getPositionAt(at + issue.found.length) : { lineNumber: 1, column: text.length + 1 };
+    markers.push({
+      // A warning, never an error: none of these stops the query.
+      severity: monaco.MarkerSeverity.Warning,
+      message: props.messageFor(issue.id),
+      startLineNumber: start.lineNumber,
+      startColumn: start.column,
+      endLineNumber: end.lineNumber,
+      endColumn: end.column,
+    });
+  }
+  monaco.editor.setModelMarkers(m, 'traceql', markers);
+}
+
+onMounted(() => {
+  setupMonaco();
+  if (!host.value) return;
+  const m = monaco.editor.createModel(props.modelValue, TRACEQL_LANGUAGE_ID);
+  (m as monaco.editor.ITextModel & { __tqlSchema?: TraceQLSchema }).__tqlSchema = props.schema;
+  model.value = m;
+  const ed = monaco.editor.create(host.value, {
+    model: m,
+    theme: RR_THEME_NAME,
+    fontSize: 13,
+    lineHeight: 20,
+    // Monaco lays itself out inside its host, so breathing room has to come
+    // from ITS padding, not the container's — padding on the host would clip
+    // the cursor at the edges instead.
+    padding: { top: 8, bottom: 8 },
+    lineNumbers: 'off',
+    minimap: { enabled: false },
+    scrollbar: { vertical: 'auto', horizontal: 'auto' },
+    scrollBeyondLastLine: false,
+    folding: false,
+    lineDecorationsWidth: 12,
+    overviewRulerLanes: 0,
+    renderLineHighlight: 'none',
+    wordWrap: 'on',
+    automaticLayout: true,
+    fixedOverflowWidgets: true,
+  });
+  editor.value = ed;
+  ed.onDidChangeModelContent(() => {
+    emit('update:modelValue', m.getValue());
+    applyMarkers();
+  });
+  // Shift+Enter runs, as Tempo's own editor does; a plain Enter is a newline.
+  ed.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => emit('run'));
+  applyMarkers();
+});
+
+watch(
+  () => props.modelValue,
+  (v) => {
+    const m = model.value;
+    if (m && m.getValue() !== v) m.setValue(v);
+  },
+);
+watch(
+  () => props.schema,
+  (s) => {
+    if (model.value) model.value.__tqlSchema = s;
+    // The datasource decides which rules apply, so the markers are re-read
+    // when it changes rather than describing the store left behind.
+    applyMarkers();
+  },
+);
+
+onBeforeUnmount(() => {
+  const m = model.value;
+  editor.value?.dispose();
+  if (m) {
+    monaco.editor.setModelMarkers(m, 'traceql', []);
+    m.dispose();
+  }
+});
+</script>
+
+<template>
+  <div class="tql-editor-wrap">
+    <div ref="host" class="tql-monaco" />
+    <!-- An example, not a value: it shows only while the editor is empty and
+         takes no pointer events, so the first click lands in the editor. The
+         builder writes real expressions of exactly this shape. -->
+    <p v-if="!modelValue.trim()" class="tql-monaco-example mono" aria-hidden="true">{{ EXAMPLE }}</p>
+  </div>
+</template>
+
+<style scoped>
+.tql-editor-wrap { position: relative; }
+.tql-monaco {
+  /* Room for a real expression: three or four conditions wrap past 66px, and
+     an editor that scrolls at its second line reads as a single-line input. */
+  height: 132px;
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  overflow: hidden;
+}
+.tql-monaco-example {
+  position: absolute;
+  /* Exactly where the first typed character lands — measured against the
+     rendered view line, so the example does not shift as you start typing. */
+  top: 9px;
+  left: 13px;
+  right: 12px;
+  margin: 0;
+  pointer-events: none;
+  font-size: 13px;
+  line-height: 20px;
+  color: var(--sw-fg-3);
+  opacity: 0.55;
+  overflow-wrap: anywhere;
+}
+</style>
diff --git a/apps/ui/src/layer/traceql/TraceQLSchemaPanel.vue b/apps/ui/src/layer/traceql/TraceQLSchemaPanel.vue
new file mode 100644
index 00000000..9c83bafc
--- /dev/null
+++ b/apps/ui/src/layer/traceql/TraceQLSchemaPanel.vue
@@ -0,0 +1,177 @@
+<!--
+  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.
+-->
+<!--
+  What this store can be asked, and how.
+
+  The fields this store answers for: its intrinsics, its resource attributes,
+  and the span tags it reported for the window being queried. Clicking one
+  inserts it into the query. It describes the SCHEMA and nothing about what the
+  backend does with a query — that is the backend's own business.
+
+  It opens OVER the page rather than in the flow: the conditions card is as tall
+  as the duration distribution beside it, and a reference the operator opens
+  while writing a query must not resize the chart they are reading. It closes
+  the moment attention moves back to the query — a click anywhere outside it, or
+  Escape — because it is something consulted, not a panel to be dismissed.
+-->
+<script setup lang="ts">
+import { computed, onBeforeUnmount, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import type { TraceQLDatasource } from '@skywalking-horizon-ui/api-client';
+import {
+  TRACEQL_INTRINSICS,
+  TRACEQL_RESOURCE_ATTRS,
+  operatorsFor,
+} from '@/monaco/traceql-grammar';
+
+const props = defineProps<{ ds: TraceQLDatasource; tagKeys: string[]; open: boolean }>();
+const emit = defineEmits<{ insert: [text: string]; 'update:open': [value: boolean] }>();
+
+const root = ref<HTMLElement | null>(null);
+
+function closeOnOutside(e: Event): void {
+  if (!root.value?.contains(e.target as Node)) emit('update:open', false);
+}
+function closeOnEscape(e: KeyboardEvent): void {
+  if (e.key === 'Escape') emit('update:open', false);
+}
+watch(
+  () => props.open,
+  (open) => {
+    if (open) {
+      document.addEventListener('pointerdown', closeOnOutside);
+      document.addEventListener('keydown', closeOnEscape);
+    } else {
+      document.removeEventListener('pointerdown', closeOnOutside);
+      document.removeEventListener('keydown', closeOnEscape);
+    }
+  },
+);
+onBeforeUnmount(() => {
+  document.removeEventListener('pointerdown', closeOnOutside);
+  document.removeEventListener('keydown', closeOnEscape);
+});
+
+const { t } = useI18n({ useScope: 'global' });
+const resourceAttrs = computed(() => TRACEQL_RESOURCE_ATTRS.filter((a) => !a.ds || a.ds === props.ds));
+</script>
+
+<template>
+  <div ref="root" class="tqs">
+    <button type="button" class="tqs-toggle" @click="emit('update:open', !open)">
+      {{ open ? '▾' : '▸' }} {{ t('Schema reference') }}
+      <span class="dim">{{ t('{n} tags', { n: tagKeys.length }) }}</span>
+    </button>
+
+    <div v-if="open" class="tqs-body">
+      <section>
+        <h5>{{ t('Intrinsics') }}</h5>
+        <ul>
+          <li v-for="i in TRACEQL_INTRINSICS" :key="i.name">
+            <button type="button" class="tqs-chip mono" @click="emit('insert', i.name)">{{ i.name }}</button>
+            <span class="ops mono">{{ operatorsFor(i.name).join(' ') }}</span>
+            <span class="dim">{{ t(i.detail) }}</span>
+          </li>
+        </ul>
+      </section>
+
+      <section>
+        <h5>{{ t('Resource') }}</h5>
+        <ul>
+          <li v-for="a in resourceAttrs" :key="a.name">
+            <button type="button" class="tqs-chip mono" @click="emit('insert', a.name)">{{ a.name }}</button>
+            <span class="ops mono">{{ operatorsFor(a.name).join(' ') }}</span>
+            <span class="dim">{{ t(a.detail) }}</span>
+          </li>
+        </ul>
+      </section>
+
+      <section class="tqs-tags">
+        <h5>{{ t('Span tags') }}</h5>
+        <p v-if="!tagKeys.length" class="dim">{{ t('This store reported no tags for the selected window.') }}</p>
+        <div v-else class="tqs-taglist">
+          <button
+            v-for="k in tagKeys"
+            :key="k"
+            type="button"
+            class="tqs-chip mono"
+            @click="emit('insert', `span.${k}`)"
+          >
+            {{ k }}
+          </button>
+        </div>
+      </section>
+
+      <p class="tqs-note">
+        {{ t('Span tags are those this store reported for the window being queried, so the list follows the time range.') }}
+      </p>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.tqs { display: flex; flex-direction: column; gap: 6px; position: relative; }
+.tqs-toggle {
+  align-self: flex-start;
+  background: none;
+  border: none;
+  color: var(--sw-fg-2);
+  font: inherit;
+  font-size: 10.5px;
+  cursor: pointer;
+  padding: 0;
+  display: inline-flex;
+  gap: 6px;
+  align-items: baseline;
+}
+.tqs-body {
+  position: absolute;
+  top: calc(100% + 6px);
+  left: 0;
+  right: 0;
+  z-index: 20;
+  max-height: 340px;
+  overflow: auto;
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+  gap: 10px 16px;
+  padding: 8px 10px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-1);
+  border-radius: 4px;
+  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
+}
+h5 { margin: 0 0 4px; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--sw-fg-3); }
+ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
+li { display: flex; align-items: baseline; gap: 6px; font-size: 10.5px; flex-wrap: wrap; }
+.ops { color: var(--sw-accent); font-size: 10px; }
+.dim { color: var(--sw-fg-3); font-size: 10px; }
+.tqs-tags { grid-column: 1 / -1; }
+.tqs-taglist { display: flex; flex-wrap: wrap; gap: 4px; }
+.tqs-chip {
+  background: var(--sw-bg-3);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 3px;
+  color: var(--sw-fg-1);
+  font-size: 10.5px;
+  padding: 1px 6px;
+  cursor: pointer;
+}
+.tqs-chip:hover { border-color: var(--sw-accent); color: var(--sw-accent); }
+.tqs-note { grid-column: 1 / -1; margin: 0; font-size: 10px; color: var(--sw-fg-3); }
+.mono { font-family: var(--sw-mono); }
+</style>
diff --git a/apps/ui/src/layer/traceql/TraceQLSpanModal.vue b/apps/ui/src/layer/traceql/TraceQLSpanModal.vue
new file mode 100644
index 00000000..0e62f78e
--- /dev/null
+++ b/apps/ui/src/layer/traceql/TraceQLSpanModal.vue
@@ -0,0 +1,201 @@
+<!--
+  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.
+-->
+<!--
+  One OTLP span, in OTLP's own terms.
+
+  The fields are the protocol's: trace and span ids, the parent span id, the
+  kind enum, a three-valued status with its message, the start and end times,
+  and — kept apart, because OTLP keeps them apart and they answer different
+  questions — the RESOURCE attributes (who emitted this), the instrumentation
+  SCOPE (what produced it), the SPAN attributes (what happened), and the
+  EVENTS with their own attributes.
+-->
+<script setup lang="ts">
+import { useI18n } from 'vue-i18n';
+import type { TraceQLSpan } from '@skywalking-horizon-ui/api-client';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
+import { fmtDateTime, fmtMs, kindColor, kindLabel, otlpKindName, statusColor } from './traceqlDetailShared';
+import LongValue from '@/components/primitives/LongValue.vue';
+
+defineProps<{ span: TraceQLSpan; traceId: string }>();
+const emit = defineEmits<{ (e: 'close'): void }>();
+const { t } = useI18n({ useScope: 'global' });
+useEscapeToClose(
+  () => true,
+  () => emit('close'),
+);
+</script>
+
+<template>
+  <div class="tqm-backdrop" @click.self="emit('close')">
+    <div class="tqm sw-card" role="dialog" aria-modal="true">
+      <header class="tqm-head">
+        <h4>
+          <span class="dim">{{ t('Span detail') }}</span>
+          <span class="mono">{{ span.name || '—' }}</span>
+        </h4>
+        <span class="tqm-kind mono">{{ kindLabel(span.kind) }}</span>
+        <span class="tqm-status mono" :style="{ color: statusColor(span.status) }">{{ span.status }}</span>
+        <button type="button" class="sw-btn small ghost tqm-close" @click="emit('close')">×</button>
+      </header>
+
+      <!-- One grid for the span's own fields and both attribute blocks, so the
+           value column starts at the same place in all three. Each block keeps
+           its own <dl> for semantics and joins the grid with display:contents. -->
+      <div class="tqm-fields">
+      <section>
+        <h5>{{ t('Meta') }}</h5>
+      </section>
+      <dl class="tqm-kv">
+        <dt>{{ t('Service') }}</dt><dd class="mono">{{ span.service || '—' }}</dd>
+        <dt>{{ t('Kind') }}</dt>
+        <dd>
+          <span :style="{ color: kindColor(span.kind) }">{{ kindLabel(span.kind) }}</span>
+          <!-- The kind the label was read from, for anyone working in OTLP's
+               own terms. -->
+          <span class="tqm-kind-raw mono">· {{ otlpKindName(span.kind) }}</span>
+        </dd>
+        <dt>{{ t('Duration') }}</dt><dd class="mono">{{ fmtMs(span.durationUs) }}</dd>
+        <dt>{{ t('Started') }}</dt><dd class="mono">{{ fmtDateTime(span.startUs) }}</dd>
+        <dt>{{ t('Ended') }}</dt><dd class="mono">{{ fmtDateTime(span.startUs + span.durationUs) }}</dd>
+        <dt>{{ t('Trace ID') }}</dt><dd class="mono break">{{ traceId }}</dd>
+        <dt>{{ t('Span ID') }}</dt><dd class="mono break">{{ span.spanId }}</dd>
+        <dt>{{ t('Parent span ID') }}</dt><dd class="mono break">{{ span.parentSpanId || '—' }}</dd>
+        <template v-if="span.statusMessage">
+          <dt>{{ t('Status message') }}</dt><dd class="mono break err">{{ span.statusMessage }}</dd>
+        </template>
+        <template v-if="span.scopeName">
+          <dt>{{ t('Scope') }}</dt>
+          <dd class="mono">{{ span.scopeName }}{{ span.scopeVersion ? ` ${span.scopeVersion}` : '' }}</dd>
+        </template>
+      </dl>
+
+      <section v-if="span.resourceAttributes.length">
+        <h5>{{ t('Resource attributes') }}</h5>
+        <dl class="tqm-kv">
+          <template v-for="a in span.resourceAttributes" :key="a.key">
+            <dt class="mono">{{ a.key }}</dt>
+            <dd class="mono break"><LongValue :value="a.value" :label="a.key" /></dd>
+          </template>
+        </dl>
+      </section>
+
+      <section v-if="span.attributes.length">
+        <h5>{{ t('Span attributes') }}</h5>
+        <dl class="tqm-kv">
+          <template v-for="a in span.attributes" :key="a.key">
+            <dt class="mono">{{ a.key }}</dt>
+            <dd class="mono break"><LongValue :value="a.value" :label="a.key" /></dd>
+          </template>
+        </dl>
+      </section>
+      </div>
+
+      <section v-if="span.events.length">
+        <h5>{{ t('Events') }}</h5>
+        <ul class="tqm-events">
+          <li v-for="(ev, i) in span.events" :key="i">
+            <div class="tqm-event-head mono">
+              <span>{{ ev.name || '—' }}</span>
+              <span class="dim">{{ fmtDateTime(ev.timeUs) }}</span>
+            </div>
+            <dl v-if="ev.attributes.length" class="tqm-kv nested">
+              <template v-for="a in ev.attributes" :key="a.key">
+                <dt class="mono">{{ a.key }}</dt>
+                <dd class="mono break"><LongValue :value="a.value" :label="a.key" /></dd>
+              </template>
+            </dl>
+          </li>
+        </ul>
+      </section>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.tqm-backdrop {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.5);
+  z-index: 45;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 24px 16px;
+}
+.tqm { width: min(720px, 100%); max-height: 82vh; overflow: auto; padding: 12px 14px; }
+.tqm-head { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
+.tqm-head h4 {
+  margin: 0;
+  font-size: 12px;
+  font-weight: 600;
+  display: inline-flex;
+  gap: 10px;
+  align-items: baseline;
+  min-width: 0;
+}
+.tqm-head h4 .dim { color: var(--sw-fg-3); font-weight: 500; }
+.tqm-head h4 .mono { font-family: var(--sw-mono); color: var(--sw-fg-1); overflow-wrap: anywhere; }
+.tqm-kind { font-size: 10.5px; color: var(--sw-fg-3); }
+.tqm-kind-raw { margin-left: 8px; font-size: 10px; color: var(--sw-fg-3); }
+.tqm-status { font-size: 10.5px; }
+.tqm-close { margin-left: auto; }
+.tqm-fields {
+  display: grid;
+  grid-template-columns: max-content 1fr;
+  /* The native span dialog's key column and type scale, so a field reads the
+     same whichever trace store it came from. */
+  grid-template-columns: 160px 1fr;
+  gap: 4px 12px;
+  align-items: baseline;
+  margin: 6px 0;
+  font-size: 11px;
+}
+/* The blocks are containers for meaning, not for layout: dissolving their
+   boxes puts every key and value in the ONE grid above, which is what makes a
+   single key column wide enough for all of them. */
+.tqm-fields > dl,
+.tqm-fields > section,
+.tqm-fields > section > dl { display: contents; }
+.tqm-fields > section > h5 { grid-column: 1 / -1; }
+/* Events keep a grid of their own — each is a card, indented under its head. */
+.tqm-kv { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; margin: 6px 0; font-size: 11px; }
+.tqm-kv.nested { margin: 2px 0 6px 12px; }
+.tqm-kv dt,
+.tqm-fields dt { color: var(--sw-fg-3); font-size: 10.5px; }
+.tqm-kv dd,
+.tqm-fields dd { margin: 0; color: var(--sw-fg-1); }
+.tqm-kv dd.break,
+.tqm-fields dd.break { overflow-wrap: anywhere; }
+.tqm-kv dd.err,
+.tqm-fields dd.err { color: var(--sw-err); }
+h5 {
+  margin: 12px 0 6px;
+  font-size: 9.5px;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  /* Accent, as the native dialog's section heads are — they are the only
+     landmarks in a long list of key/value rows. */
+  color: var(--sw-accent);
+  font-weight: 700;
+}
+.tqm-fields > section:first-child h5 { margin-top: 0; }
+.tqm-events { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
+.tqm-event-head { display: flex; gap: 10px; align-items: baseline; font-size: 11px; }
+.dim { color: var(--sw-fg-3); font-size: 10px; margin-left: auto; }
+.mono { font-family: var(--sw-mono); }
+</style>
diff --git a/apps/ui/src/layer/traceql/TraceQLStatsView.vue b/apps/ui/src/layer/traceql/TraceQLStatsView.vue
new file mode 100644
index 00000000..9ea5cdc6
--- /dev/null
+++ b/apps/ui/src/layer/traceql/TraceQLStatsView.vue
@@ -0,0 +1,140 @@
+<!--
+  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.
+-->
+<!--
+  Where a trace spent its time, rolled up per span name and service — the
+  native statistics view's question, asked of OTLP spans. The error column
+  counts spans whose OTLP status is `error`, and unset is not counted as
+  either: it is a third state, not a quiet success.
+-->
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import type { TraceQLSpan } from '@skywalking-horizon-ui/api-client';
+import { buildServiceColors, fmtMs, kindLabel, otlpKindName, serviceColorFrom } from './traceqlDetailShared';
+
+const props = defineProps<{ spans: TraceQLSpan[] }>();
+const { t } = useI18n({ useScope: 'global' });
+const colors = computed(() => buildServiceColors(props.spans));
+
+interface StatRow {
+  name: string;
+  service: string;
+  kind: string;
+  count: number;
+  errors: number;
+  totalUs: number;
+  avgUs: number;
+  maxUs: number;
+}
+
+type SortKey = 'total' | 'avg' | 'max' | 'count';
+const sortKey = ref<SortKey>('total');
+const sortDir = ref<'asc' | 'desc'>('desc');
+function toggleSort(key: SortKey): void {
+  if (sortKey.value === key) sortDir.value = sortDir.value === 'desc' ? 'asc' : 'desc';
+  else {
+    sortKey.value = key;
+    sortDir.value = 'desc';
+  }
+}
+
+const rows = computed<StatRow[]>(() => {
+  const acc = new Map<string, StatRow>();
+  for (const s of props.spans) {
+    const key = `${s.service}\u0000${s.name}\u0000${s.kind}`;
+    const row = acc.get(key) ?? {
+      name: s.name || '—',
+      service: s.service,
+      kind: s.kind,
+      count: 0,
+      errors: 0,
+      totalUs: 0,
+      avgUs: 0,
+      maxUs: 0,
+    };
+    row.count += 1;
+    if (s.status === 'error') row.errors += 1;
+    row.totalUs += s.durationUs;
+    row.maxUs = Math.max(row.maxUs, s.durationUs);
+    acc.set(key, row);
+  }
+  const list = [...acc.values()].map((r) => ({ ...r, avgUs: r.count ? r.totalUs / r.count : 0 }));
+  const field = { total: 'totalUs', avg: 'avgUs', max: 'maxUs', count: 'count' } as const;
+  const f = field[sortKey.value];
+  return list.sort((a, b) => (sortDir.value === 'desc' ? b[f] - a[f] : a[f] - b[f]));
+});
+</script>
+
+<template>
+  <div class="tqs-wrap">
+    <table class="tqs-table">
+      <thead>
+        <tr>
+          <th>{{ t('Span name') }}</th>
+          <th>{{ t('Service') }}</th>
+          <th>{{ t('Kind') }}</th>
+          <th class="num" @click="toggleSort('count')">{{ t('COUNT') }}</th>
+          <th class="num">{{ t('ERRORS') }}</th>
+          <th class="num sortable" :class="{ on: sortKey === 'total' }" @click="toggleSort('total')">{{ t('TOTAL') }}</th>
+          <th class="num sortable" :class="{ on: sortKey === 'avg' }" @click="toggleSort('avg')">{{ t('AVG') }}</th>
+          <th class="num sortable" :class="{ on: sortKey === 'max' }" @click="toggleSort('max')">{{ t('MAX') }}</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr v-for="r in rows" :key="`${r.service}/${r.name}/${r.kind}`">
+          <td class="mono">{{ r.name }}</td>
+          <td class="mono">
+            <span class="dot" :style="{ background: serviceColorFrom(colors, r.service) }" />{{ r.service || '—' }}
+          </td>
+          <!-- The rows group on the OTLP kind, so two of them can both read
+               Entry — a server call and a consumed message. The protocol's own
+               word tells them apart. -->
+          <td class="mono dim">{{ kindLabel(r.kind) }} <span class="raw">{{ otlpKindName(r.kind) }}</span></td>
+          <td class="num mono">{{ r.count }}</td>
+          <td class="num mono" :class="{ err: r.errors > 0 }">{{ r.errors || '—' }}</td>
+          <td class="num mono">{{ fmtMs(r.totalUs) }}</td>
+          <td class="num mono">{{ fmtMs(r.avgUs) }}</td>
+          <td class="num mono">{{ fmtMs(r.maxUs) }}</td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+</template>
+
+<style scoped>
+.raw { color: var(--sw-fg-3); opacity: 0.75; font-size: 9.5px; }
+.tqs-wrap { overflow: auto; max-height: 60vh; }
+.tqs-table { width: 100%; border-collapse: collapse; font-size: 11px; }
+th, td { text-align: left; padding: 5px 10px; border-bottom: 1px solid var(--sw-line-1); }
+th {
+  position: sticky;
+  top: 0;
+  background: var(--sw-bg-1);
+  color: var(--sw-fg-3);
+  font-size: 9.5px;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  font-weight: 600;
+}
+th.sortable { cursor: pointer; }
+th.on { color: var(--sw-accent); }
+.num { text-align: right; }
+.dim { color: var(--sw-fg-3); }
+.err { color: var(--sw-err); }
+.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
+.mono { font-family: var(--sw-mono); }
+</style>
diff --git a/apps/ui/src/layer/traceql/TraceQLTraceDetail.vue b/apps/ui/src/layer/traceql/TraceQLTraceDetail.vue
new file mode 100644
index 00000000..87eaa2ea
--- /dev/null
+++ b/apps/ui/src/layer/traceql/TraceQLTraceDetail.vue
@@ -0,0 +1,84 @@
+<!--
+  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.
+-->
+<!--
+  TraceQL trace detail.
+
+  A thin fetch around {@link TraceQLDetailCard}: the views are TraceQL's own,
+  because converting OTLP into SkyWalking's span model is OAP's job rather than
+  Horizon's. The two detail stacks look alike on purpose and share no code.
+-->
+<script setup lang="ts">
+import { computed, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import type { TraceQLDatasource } from '@skywalking-horizon-ui/api-client';
+import TraceQLDetailCard from './TraceQLDetailCard.vue';
+import { useTraceQLTrace } from './useTraceQL';
+
+const props = defineProps<{ ds: TraceQLDatasource; traceId: string }>();
+const emit = defineEmits<{
+  (e: 'close'): void;
+  /** A trace's status, derived from its spans, once they are known. The list
+   *  cannot answer this — the search response carries no failure marker — so
+   *  the row stays unknown until a detail says otherwise. */
+  (e: 'status', value: { traceId: string; isError: boolean }): void;
+}>();
+
+const { t } = useI18n({ useScope: 'global' });
+const traceId = computed(() => props.traceId);
+const ds = computed(() => props.ds);
+// A lookup by id needs no window: this API answers one across its whole
+// history, which is what an id pasted from a log row wants.
+const detail = useTraceQLTrace(ds, traceId, computed(() => null));
+
+// A trace is failed when any of its spans is: that is what the spans say, and
+// it is the same rule the Zipkin list applies to its own error tag.
+watch(
+  () => detail.spans.value,
+  (list) => {
+    if (list.length === 0) return;
+    // `unset` is OTLP's absence of a verdict: a trace of only-unset spans
+    // stays unknown, exactly as the deferred check leaves it. Reporting
+    // `false` here would turn opening a trace into a claim of success.
+    if (!list.some((s) => s.status !== 'unset')) return;
+    emit('status', { traceId: props.traceId, isError: list.some((s) => s.status === 'error') });
+  },
+  { immediate: true },
+);
+</script>
+
+<template>
+  <div class="tqd">
+    <p v-if="detail.notFound.value" class="tqd-note sw-card">{{ t('No trace with that id.') }}</p>
+    <p v-else-if="!detail.reachable.value" class="tqd-note sw-card err">
+      {{ t('The trace source is not answering.') }}
+    </p>
+    <TraceQLDetailCard
+      v-else
+      :ds="ds"
+      :trace-id="traceId"
+      :spans="detail.spans.value"
+      :loading="detail.isFetching.value"
+      @close="emit('close')"
+    />
+  </div>
+</template>
+
+<style scoped>
+.tqd { min-width: 0; }
+.tqd-note { padding: 14px; font-size: 12px; color: var(--sw-fg-3); }
+.tqd-note.err { color: var(--sw-err); border-color: var(--sw-err); }
+</style>
diff --git a/apps/ui/src/layer/traceql/TraceQLTreeView.vue b/apps/ui/src/layer/traceql/TraceQLTreeView.vue
new file mode 100644
index 00000000..7a76f98d
--- /dev/null
+++ b/apps/ui/src/layer/traceql/TraceQLTreeView.vue
@@ -0,0 +1,258 @@
+<!--
+  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.
+-->
+<!--
+  The trace as a left-to-right hierarchy, laid out with d3 — the native tree
+  view's layout, over OTLP spans. Parentage is `parentSpanId` against `spanId`;
+  several roots are wrapped in a synthetic one so d3 treats them as siblings,
+  and that synthetic node is dropped before rendering.
+
+  Drag to pan, wheel to zoom, or use the controls — a wide trace does not fit a
+  screen, and scrollbars alone cannot follow one branch across it.
+-->
+<script setup lang="ts">
+import { computed, nextTick, watch } from 'vue';
+import * as d3 from 'd3';
+import { useI18n } from 'vue-i18n';
+import type { TraceQLSpan } from '@skywalking-horizon-ui/api-client';
+// The d3.zoom lifecycle only — it knows about an SVG and a transform, never
+// about a span, so both tree views bind the same behaviour without either
+// knowing the other's model.
+import { useTraceTreeZoom } from '@/render/widgets/useTraceTreeZoom';
+import {
+  buildServiceColors,
+  fmtMs,
+  kindLabel,
+  latencyColor,
+  serviceColorFrom,
+  statusColor,
+} from './traceqlDetailShared';
+
+/** The selected SPAN OBJECT rather than its id — see the waterfall. */
+const props = defineProps<{ spans: TraceQLSpan[]; selectedSpan?: TraceQLSpan | null }>();
+const emit = defineEmits<{ (e: 'select-span', span: TraceQLSpan): void }>();
+const { t } = useI18n({ useScope: 'global' });
+
+const colors = computed(() => buildServiceColors(props.spans));
+
+const NODE_W = 180;
+const NODE_H = 54;
+const NODE_GAP_X = 80;
+const NODE_GAP_Y = 14;
+/** The datum index a synthetic root carries: a forest needs one parent for d3
+ *  to lay it out, and it is never drawn. */
+const SYNTH = -1;
+
+interface TreeNode { span: TraceQLSpan; x: number; y: number; key: number }
+interface TreeLink { d: string }
+
+const layout = computed<{ nodes: TreeNode[]; links: TreeLink[]; width: number; height: number }>(() => {
+  const spans = props.spans;
+  if (spans.length === 0) return { nodes: [], links: [], width: 0, height: 0 };
+
+  // Identity is a span's POSITION, as in the waterfall: a Zipkin client and
+  // server span can share one span id, and both records must be drawn.
+  const firstWithId = new Map<string, number>();
+  spans.forEach((s, i) => { if (!firstWithId.has(s.spanId)) firstWithId.set(s.spanId, i); });
+  const children = new Map<string, number[]>();
+  const roots: number[] = [];
+  spans.forEach((s, i) => {
+    if (s.parentSpanId && s.parentSpanId !== s.spanId && firstWithId.has(s.parentSpanId)) {
+      const arr = children.get(s.parentSpanId) ?? [];
+      arr.push(i);
+      children.set(s.parentSpanId, arr);
+    } else {
+      roots.push(i);
+    }
+  });
+
+  interface Datum { span: TraceQLSpan; index: number; children: Datum[] }
+  const seen = new Set<number>();
+  function build(i: number): Datum {
+    const span = spans[i]!;
+    if (seen.has(i)) return { span, index: i, children: [] };
+    seen.add(i);
+    const owns = firstWithId.get(span.spanId) === i;
+    return { span, index: i, children: owns ? (children.get(span.spanId) ?? []).map(build) : [] };
+  }
+  const forest = roots.map(build);
+  // Spans left unvisited belong to a component with no root (a cycle, or a
+  // self-parent). They are entered at their earliest span rather than
+  // vanishing from a view whose own header counts them.
+  for (const i of spans.map((_, n) => n).sort((a, b) => spans[a]!.startUs - spans[b]!.startUs)) {
+    if (!seen.has(i)) forest.push(build(i));
+  }
+  const root: Datum =
+    forest.length === 1
+      ? forest[0]!
+      : { span: {} as TraceQLSpan, index: SYNTH, children: forest };
+
+  const hierarchy = d3.hierarchy<Datum>(root, (d) => d.children);
+  const tree = d3.tree<Datum>().nodeSize([NODE_H + NODE_GAP_Y, NODE_W + NODE_GAP_X])(hierarchy);
+
+  // d3 gives `y` for depth and `x` for sibling position; a left-to-right tree
+  // reads them the other way round.
+  const nodes: TreeNode[] = [];
+  // Keyed by the DATUM rather than the span id, so two records sharing an id
+  // keep their own node and their own links.
+  const placed = new Map<Datum, TreeNode>();
+  for (const d of tree.descendants()) {
+    if (d.data.index === SYNTH) continue;
+    const n: TreeNode = { span: d.data.span, x: d.y, y: d.x, key: d.data.index };
+    nodes.push(n);
+    placed.set(d.data, n);
+  }
+  if (nodes.length === 0) return { nodes: [], links: [], width: 0, height: 0 };
+  const minX = Math.min(...nodes.map((n) => n.x));
+  const minY = Math.min(...nodes.map((n) => n.y));
+  for (const n of nodes) {
+    n.x -= minX;
+    n.y -= minY;
+  }
+
+  const links: TreeLink[] = [];
+  for (const d of tree.descendants()) {
+    if (!d.parent || d.parent.data.index === SYNTH || d.data.index === SYNTH) continue;
+    const parent = placed.get(d.parent.data);
+    const child = placed.get(d.data);
+    if (!parent || !child) continue;
+    const x1 = parent.x + NODE_W;
+    const y1 = parent.y + NODE_H / 2;
+    const x2 = child.x;
+    const y2 = child.y + NODE_H / 2;
+    const mid = (x1 + x2) / 2;
+    links.push({ d: `M${x1},${y1} C${mid},${y1} ${mid},${y2} ${x2},${y2}` });
+  }
+
+  return {
+    nodes,
+    links,
+    width: Math.max(...nodes.map((n) => n.x)) + NODE_W + 8,
+    height: Math.max(...nodes.map((n) => n.y)) + NODE_H + 8,
+  };
+});
+
+const { treeSvgEl, treeTransform, ensureZoom, zoomBy, zoomReset } = useTraceTreeZoom();
+// The view is mounted only while it is the chosen one, so mount is activation;
+// re-attach if the element swaps under a re-render.
+watch(treeSvgEl, async () => {
+  await nextTick();
+  ensureZoom();
+}, { immediate: true });
+</script>
+
+<template>
+  <div class="tqt">
+    <div class="tqt-zoom">
+      <button class="sw-btn small ghost" type="button" :title="t('Zoom in')" @click="zoomBy(1.25)">+</button>
+      <button class="sw-btn small ghost" type="button" :title="t('Zoom out')" @click="zoomBy(1 / 1.25)">−</button>
+      <button class="sw-btn small ghost" type="button" :title="t('Reset')" @click="zoomReset">⊙</button>
+      <span class="tqt-pct mono">{{ Math.round(treeTransform.k * 100) }}%</span>
+    </div>
+    <svg
+      v-if="layout.nodes.length"
+      ref="treeSvgEl"
+      class="tqt-svg"
+      width="100%"
+      height="100%"
+      :viewBox="`0 0 ${layout.width} ${layout.height}`"
+      preserveAspectRatio="xMidYMid meet"
+    >
+      <g :transform="`translate(${treeTransform.x}, ${treeTransform.y}) scale(${treeTransform.k})`">
+      <path v-for="(l, i) in layout.links" :key="i" :d="l.d" class="tqt-link" />
+      <g
+        v-for="n in layout.nodes"
+        :key="n.key"
+        :transform="`translate(${n.x}, ${n.y})`"
+        class="tqt-node"
+        :class="{ on: selectedSpan === n.span }"
+        @click="emit('select-span', n.span)"
+      >
+        <rect :width="NODE_W" :height="NODE_H" rx="4" class="tqt-box" :style="{ stroke: serviceColorFrom(colors, n.span.service) }" />
+        <rect :width="3" :height="NODE_H" rx="1" :style="{ fill: serviceColorFrom(colors, n.span.service) }" />
+        <circle :cx="NODE_W - 8" :cy="10" :r="3.5" :style="{ fill: statusColor(n.span.status) }" />
+        <!-- The text lives in HTML, not in <text>: an SVG string neither wraps
+             nor ellipsizes, so a long span name ran straight out of its box. -->
+        <foreignObject :x="0" :y="0" :width="NODE_W" :height="NODE_H">
+          <div xmlns="http://www.w3.org/1999/xhtml" class="tqt-body">
+            <span class="tqt-svc" :style="{ color: serviceColorFrom(colors, n.span.service) }">{{ n.span.service || '—' }}</span>
+            <span class="tqt-name" :title="n.span.name || ''">{{ n.span.name || '—' }}</span>
+            <span class="tqt-meta" :style="{ color: latencyColor(n.span.durationUs) }">
+              {{ fmtMs(n.span.durationUs) }} · {{ kindLabel(n.span.kind) }}
+            </span>
+          </div>
+        </foreignObject>
+      </g>
+      </g>
+    </svg>
+  </div>
+</template>
+
+<style scoped>
+.tqt {
+  position: relative;
+  padding: 8px;
+  height: clamp(420px, 60vh, 720px);
+  overflow: hidden;
+}
+.tqt-svg { display: block; cursor: grab; }
+.tqt-svg:active { cursor: grabbing; }
+/* The control cluster floats over the canvas, top-right, as the native tree's does. */
+.tqt-zoom {
+  position: absolute;
+  top: 12px;
+  right: 12px;
+  z-index: 2;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 6px;
+  background: rgba(15, 18, 30, 0.85);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 6px;
+  backdrop-filter: blur(6px);
+}
+.tqt-pct { font-size: 10.5px; color: var(--sw-fg-2); min-width: 36px; text-align: right; padding-right: 2px; }
+.tqt-link { fill: none; stroke: var(--sw-line-2); stroke-width: 1.2; }
+.tqt-node { cursor: pointer; }
+.tqt-box { fill: var(--sw-bg-2); stroke-width: 1; }
+.tqt-node.on .tqt-box { fill: var(--sw-bg-3); stroke-width: 2; }
+/* The node's three lines, on the native tree's type scale. Each clips rather
+   than overflowing: a span name is as long as the instrumentation made it. */
+.tqt-body {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  gap: 1px;
+  height: 100%;
+  padding: 4px 16px 4px 10px;
+  box-sizing: border-box;
+  overflow: hidden;
+  font-family: var(--sw-mono);
+  line-height: 1.25;
+}
+.tqt-body > span {
+  display: block;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.tqt-svc { font-size: 9.5px; font-weight: 700; }
+.tqt-name { font-size: 11px; font-weight: 500; color: var(--sw-fg-0); }
+.tqt-meta { font-size: 9.5px; font-weight: 700; }
+foreignObject { pointer-events: none; }
+</style>
diff --git a/apps/ui/src/layer/traceql/TraceQLWaterfall.vue b/apps/ui/src/layer/traceql/TraceQLWaterfall.vue
new file mode 100644
index 00000000..5c3df061
--- /dev/null
+++ b/apps/ui/src/layer/traceql/TraceQLWaterfall.vue
@@ -0,0 +1,178 @@
+<!--
+  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.
+-->
+<!--
+  OTLP span waterfall. The same shape as the native one — indented rows,
+  service-coloured bars, a kind glyph, a duration suffix — over OTLP's own
+  fields: span id and parent span id as opaque strings, `SPAN_KIND_*`, and a
+  three-valued status.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import SpanKindGlyph from '@/components/primitives/SpanKindGlyph.vue';
+import type { TraceQLSpan } from '@skywalking-horizon-ui/api-client';
+import {
+  buildServiceColors,
+  buildSpanTree,
+  fmtMs,
+  kindColor,
+  kindGlyphFamily,
+  kindLabel,
+  otlpKindName,
+  serviceColorFrom,
+  statusColor,
+} from './traceqlDetailShared';
+
+/** The selected SPAN OBJECT rather than its id: two Zipkin records can share a
+ *  span id, and comparing ids highlights both. */
+const props = defineProps<{ spans: TraceQLSpan[]; selectedSpan?: TraceQLSpan | null }>();
+const emit = defineEmits<{ (e: 'select-span', span: TraceQLSpan): void }>();
+
+const { t } = useI18n({ useScope: 'global' });
+const rows = computed(() => buildSpanTree(props.spans));
+const colors = computed(() => buildServiceColors(props.spans));
+const total = computed(() => {
+  const r = rows.value;
+  if (r.length === 0) return 1;
+  return Math.max(1, ...r.map((x) => x.offsetUs + x.span.durationUs));
+});
+const pct = (v: number): string => `${Math.min(100, (v / total.value) * 100)}%`;
+</script>
+
+<template>
+  <ul class="tqw">
+    <li
+      v-for="row in rows"
+      :key="row.key"
+      class="tqw-row"
+      :class="{ on: selectedSpan === row.span, err: row.span.status === 'error' }"
+      @click="emit('select-span', row.span)"
+    >
+      <span class="tqw-svc" :style="{ background: serviceColorFrom(colors, row.span.service) }" />
+      <span class="tqw-status" :style="{ background: statusColor(row.span.status) }" />
+      <SpanKindGlyph
+        class="tqw-kind"
+        :family="kindGlyphFamily(row.span.kind)"
+        :label="`${kindLabel(row.span.kind)} · ${otlpKindName(row.span.kind)}`"
+        :style="{ color: kindColor(row.span.kind) }"
+      />
+      <!-- One band per row carries the span's place in time and its name, as
+           the native waterfall does. -->
+      <span class="tqw-band">
+        <span
+          class="tqw-band-bg"
+          :style="{
+            left: pct(row.offsetUs),
+            width: pct(Math.max(row.span.durationUs, total / 400)),
+            background: serviceColorFrom(colors, row.span.service),
+            outlineColor: row.span.status === 'error' ? 'var(--sw-err)' : 'transparent',
+          }"
+        />
+        <span
+          v-for="g in row.depth"
+          :key="g"
+          class="tqw-depth-guide"
+          :style="{ left: `${(g - 1) * 22 + 10}px` }"
+        />
+        <span class="tqw-indent" :style="{ width: `${row.depth * 22}px` }" />
+        <span class="tqw-name mono" :title="row.span.name || '—'">{{ row.span.name || '—' }}</span>
+        <span
+          v-if="row.span.scopeName"
+          class="tqw-scope mono"
+          :title="t('scope · {scope}', { scope: row.span.scopeName })"
+        >→ {{ row.span.scopeName }}</span>
+      </span>
+      <span class="tqw-dur mono">{{ fmtMs(row.span.durationUs) }}</span>
+    </li>
+  </ul>
+</template>
+
+<style scoped>
+.tqw { list-style: none; margin: 0; padding: 0; }
+.tqw-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  /* The native waterfall's row metrics — same padding, same 11px, so the two
+     trace details read at one size rather than two. */
+  padding: 2px 8px 2px 0;
+  border-bottom: 1px solid var(--sw-line-1);
+  cursor: pointer;
+  font-size: 11px;
+  min-height: 28px;
+}
+.tqw-row:hover { background: var(--sw-bg-2); }
+.tqw-row.on { background: var(--sw-bg-3); }
+.tqw-row.err .tqw-name { color: var(--sw-err); }
+.tqw-svc { width: 3px; height: 16px; border-radius: 1px; flex: 0 0 auto; }
+.tqw-status { width: 6px; height: 6px; border-radius: 50%; flex: 0 0 auto; }
+.tqw-kind { flex: 0 0 auto; }
+/* The band: the bar behind, the name in front, the depth guides between —
+   the native waterfall's row, over OTLP fields. */
+.tqw-band {
+  position: relative;
+  flex: 1 1 auto;
+  min-width: 0;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  height: 24px;
+  padding: 0 6px;
+}
+.tqw-band-bg {
+  position: absolute;
+  top: 4px;
+  bottom: 4px;
+  border-radius: 3px;
+  opacity: 0.22;
+  outline: 1px solid transparent;
+}
+.tqw-depth-guide {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  width: 1px;
+  background: var(--sw-line-2);
+  opacity: 0.5;
+}
+.tqw-indent { flex: 0 0 auto; }
+.tqw-name {
+  font-size: 12px;
+  font-weight: 500;
+  color: var(--sw-fg-0);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  position: relative;
+  z-index: 1;
+  min-width: 0;
+  flex: 0 1 auto;
+}
+.tqw-scope {
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  position: relative;
+  z-index: 1;
+  min-width: 0;
+  flex: 0 1 auto;
+}
+.tqw-dur { font-size: 10.5px; font-weight: 600; color: var(--sw-fg-2); flex: 0 0 auto; padding-right: 4px; }
+.mono { font-family: var(--sw-mono); }
+</style>
diff --git a/apps/ui/src/layer/traceql/TraceQLWindowFields.vue b/apps/ui/src/layer/traceql/TraceQLWindowFields.vue
new file mode 100644
index 00000000..f02bbc43
--- /dev/null
+++ b/apps/ui/src/layer/traceql/TraceQLWindowFields.vue
@@ -0,0 +1,71 @@
+<!--
+  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.
+-->
+<!--
+  The window and the result cap.
+
+  They belong to the page rather than to a query mode — this tab owns its time
+  range and the topbar's picker is disabled while on it — so every mode renders
+  these, in the position the native Traces tab puts them.
+-->
+<script setup lang="ts">
+import { useI18n } from 'vue-i18n';
+import DateTimeField from '@/components/primitives/DateTimeField.vue';
+import { TIME_RANGE_PRESETS } from '@/layer/logs/useLogTimeRange';
+import { NO_TIME_RANGE } from './useTraceQL';
+
+const props = defineProps<{
+  /** The picker's "Custom…" choice, owned by the page so both agree on it. */
+  customRange: number;
+  /** A lookup by id may have no window at all, and no cap to apply. */
+  traceIdMode?: boolean;
+}>();
+
+const windowMinutes = defineModel<number>('windowMinutes', { required: true });
+const customStart = defineModel<string>('customStart', { required: true });
+const customEnd = defineModel<string>('customEnd', { required: true });
+const limit = defineModel<number>('limit', { required: true });
+
+const { t } = useI18n({ useScope: 'global' });
+</script>
+
+<template>
+  <label v-if="!props.traceIdMode" class="cf">
+    <span>{{ t('Limit') }}</span>
+    <select v-model.number="limit" class="cf-input">
+      <option :value="20">20</option>
+      <option :value="30">30</option>
+      <option :value="50">50</option>
+      <option :value="100">100</option>
+    </select>
+  </label>
+  <label class="cf">
+    <span>{{ t('Time range') }}</span>
+    <select v-model.number="windowMinutes" class="cf-input">
+      <option v-if="props.traceIdMode" :value="NO_TIME_RANGE">{{ t('No time range') }}</option>
+      <option v-for="pr in TIME_RANGE_PRESETS" :key="pr.minutes" :value="pr.minutes">{{ t(pr.label) }}</option>
+      <option :value="props.customRange">{{ t('Custom range…') }}</option>
+    </select>
+  </label>
+  <label v-if="windowMinutes === props.customRange" class="cf">
+    <span>{{ t('From') }}</span>
+    <DateTimeField v-model="customStart" />
+  </label>
+  <label v-if="windowMinutes === props.customRange" class="cf">
+    <span>{{ t('To') }}</span>
+    <DateTimeField v-model="customEnd" />
+  </label>
+</template>
diff --git a/apps/ui/src/layer/traceql/statusResolve.test.ts b/apps/ui/src/layer/traceql/statusResolve.test.ts
new file mode 100644
index 00000000..b9b03867
--- /dev/null
+++ b/apps/ui/src/layer/traceql/statusResolve.test.ts
@@ -0,0 +1,163 @@
+/*
+ * 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.
+ */
+
+/**
+ * The deferred status check. It reads traces the operator did not ask for, so
+ * what matters is that it reads only the rows nobody could classify, abandons
+ * a round the moment a new query starts, and refuses to turn a trace of
+ * only-unset spans into a success.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { defineComponent, computed, shallowRef, h } from 'vue';
+import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
+import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
+import type { TraceListRow, TraceQLSpan } from '@skywalking-horizon-ui/api-client';
+import { useTraceQLStatusResolver } from './useTraceQL';
+
+function row(key: string, unknown = true): TraceListRow {
+  return {
+    key,
+    endpointNames: ['/homepage'],
+    duration: 10,
+    start: '1700000000000',
+    isError: false,
+    ...(unknown ? { errorUnknown: true } : {}),
+    traceIds: [key],
+  };
+}
+
+function span(status: TraceQLSpan['status']): TraceQLSpan {
+  return {
+    spanId: 's1',
+    parentSpanId: '',
+    name: '/homepage',
+    kind: 'SPAN_KIND_SERVER',
+    service: 'agent::ui',
+    startUs: 0,
+    durationUs: 10,
+    status,
+    attributes: [],
+    resourceAttributes: [],
+    events: [],
+  };
+}
+
+/** A BFF whose trace reads resolve when the test says so. */
+function fakeBff(statusOf: (id: string) => TraceQLSpan['status']) {
+  const reads: string[] = [];
+  const fetchSpy = vi.fn(async (input: string | URL | Request) => {
+    const path = new URL(String(input), 'http://ui').pathname;
+    const id = decodeURIComponent(path.split('/trace/')[1] ?? '');
+    reads.push(id);
+    return new Response(
+      JSON.stringify({ ds: 'native', traceId: id, spans: [span(statusOf(id))], reachable: true, notFound: false }),
+      { status: 200, headers: { 'content-type': 'application/json' } },
+    );
+  });
+  vi.stubGlobal('fetch', fetchSpy);
+  return { reads };
+}
+
+/** Mount the composable, which needs a query client to live in. */
+function harness() {
+  const seen: Array<{ traceId: string; isError: boolean }> = [];
+  // shallowRef, or Vue unwraps the composable's own refs inside the holder and
+  // `pending.value` reads as undefined.
+  const api = shallowRef<ReturnType<typeof useTraceQLStatusResolver> | null>(null);
+  const comp = defineComponent({
+    setup() {
+      api.value = useTraceQLStatusResolver(
+        computed(() => 'native' as const),
+        computed(() => ({ startMs: 1, endMs: 2 })),
+        (v) => seen.push(v),
+      );
+      return () => h('div');
+    },
+  });
+  const wrapper = mount(comp, {
+    global: { plugins: [[VueQueryPlugin, { queryClient: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }]] },
+  });
+  return { wrapper, seen, api: () => api.value! };
+}
+
+let mounted: VueWrapper | null = null;
+afterEach(() => {
+  mounted?.unmount();
+  mounted = null;
+  vi.unstubAllGlobals();
+});
+
+describe('deferred trace status', () => {
+  it('reads only the rows the list could not classify', async () => {
+    const { reads } = fakeBff(() => 'ok');
+    const h1 = harness();
+    mounted = h1.wrapper;
+    await h1.api().resolve([row('a'), row('b', false), row('c')]);
+    await flushPromises();
+    expect(reads.sort()).toEqual(['a', 'c']);
+    expect(h1.seen).toEqual([
+      { traceId: 'a', isError: false },
+      { traceId: 'c', isError: false },
+    ]);
+  });
+
+  it('reports a failure when any span carries one', async () => {
+    fakeBff((id) => (id === 'bad' ? 'error' : 'ok'));
+    const h1 = harness();
+    mounted = h1.wrapper;
+    await h1.api().resolve([row('bad'), row('good')]);
+    await flushPromises();
+    expect(h1.seen.find((s) => s.traceId === 'bad')?.isError).toBe(true);
+    expect(h1.seen.find((s) => s.traceId === 'good')?.isError).toBe(false);
+  });
+
+  it('leaves a trace of only-unset spans unknown', async () => {
+    fakeBff(() => 'unset');
+    const h1 = harness();
+    mounted = h1.wrapper;
+    await h1.api().resolve([row('a')]);
+    await flushPromises();
+    // `unset` is the absence of a verdict; reporting `false` would paint it
+    // green on no evidence at all.
+    expect(h1.seen).toEqual([]);
+  });
+
+  it('abandons the round a new query replaces', async () => {
+    const { reads } = fakeBff(() => 'ok');
+    const h1 = harness();
+    mounted = h1.wrapper;
+    const first = h1.api().resolve([row('a'), row('b'), row('c'), row('d'), row('e'), row('f')]);
+    h1.api().stop();
+    await first;
+    await flushPromises();
+    // The four workers in flight finish their own read; nothing past them runs.
+    expect(reads.length).toBeLessThanOrEqual(4);
+    expect(h1.api().pending.value).toBe(0);
+  });
+
+  it('counts down what is left to check', async () => {
+    fakeBff(() => 'ok');
+    const h1 = harness();
+    mounted = h1.wrapper;
+    const done = h1.api().resolve([row('a'), row('b')]);
+    expect(h1.api().pending.value).toBe(2);
+    await done;
+    await flushPromises();
+    expect(h1.api().pending.value).toBe(0);
+  });
+});
diff --git a/apps/ui/src/layer/traceql/traceqlDetailShared.test.ts b/apps/ui/src/layer/traceql/traceqlDetailShared.test.ts
new file mode 100644
index 00000000..3e725416
--- /dev/null
+++ b/apps/ui/src/layer/traceql/traceqlDetailShared.test.ts
@@ -0,0 +1,129 @@
+/*
+ * 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.
+ */
+
+/**
+ * The TraceQL detail's own reading of OTLP spans: the order they are drawn in,
+ * and the words put on a span kind.
+ *
+ * The tree cases are the ones a real trace produces and a naive walk loses —
+ * a span whose parent is not in the set, a self-parent, a cycle. Every span
+ * must appear exactly once, because the header counts them all and a view that
+ * quietly drops some is worse than one that draws them oddly.
+ */
+
+import { describe, expect, it } from 'vitest';
+import type { TraceQLSpan } from '@skywalking-horizon-ui/api-client';
+import { buildSpanTree, kindLabel, otlpKindName, rootSpanOf, statusColor } from './traceqlDetailShared';
+
+function span(spanId: string, parentSpanId: string, startUs = 0, durationUs = 10): TraceQLSpan {
+  return {
+    spanId,
+    parentSpanId,
+    name: spanId,
+    kind: 'SPAN_KIND_INTERNAL',
+    service: 'svc',
+    startUs,
+    durationUs,
+    status: 'unset',
+    attributes: [],
+    resourceAttributes: [],
+    events: [],
+  };
+}
+
+const ids = (rows: ReturnType<typeof buildSpanTree>) => rows.map((r) => r.span.spanId);
+
+describe('span tree', () => {
+  it('walks parents before children, siblings by start time', () => {
+    const rows = buildSpanTree([span('b', 'a', 20), span('a', '', 0), span('c', 'a', 10)]);
+    expect(ids(rows)).toEqual(['a', 'c', 'b']);
+    expect(rows.map((r) => r.depth)).toEqual([0, 1, 1]);
+    // Offsets are measured from the trace's earliest span.
+    expect(rows.map((r) => r.offsetUs)).toEqual([0, 10, 20]);
+  });
+
+  it('treats a span whose parent is absent as a root', () => {
+    // A partial trace: the parent was sampled away or is in another store.
+    const rows = buildSpanTree([span('child', 'missing-parent', 5)]);
+    expect(ids(rows)).toEqual(['child']);
+    expect(rows[0]!.depth).toBe(0);
+  });
+
+  it('keeps every span when the parentage is a cycle', () => {
+    // A → B → A has no root at all; walking only from roots loses both.
+    const rows = buildSpanTree([span('a', 'b', 0), span('b', 'a', 5)]);
+    expect(ids(rows).sort()).toEqual(['a', 'b']);
+    expect(rows).toHaveLength(2);
+  });
+
+  it('keeps a span that is its own parent', () => {
+    const rows = buildSpanTree([span('root', '', 0), span('self', 'self', 5)]);
+    expect(ids(rows).sort()).toEqual(['root', 'self']);
+  });
+
+  it('keeps both records of a shared Zipkin span id', () => {
+    // A Zipkin client and server span carry ONE span id, and OAP's converter
+    // keeps both records. Keying identity on the id loses the second.
+    const rows = buildSpanTree([
+      span('root', '', 0),
+      span('shared', 'root', 5),
+      { ...span('shared', 'root', 6), name: 'server side' },
+      span('leaf', 'shared', 8),
+    ]);
+    expect(rows).toHaveLength(4);
+    expect(ids(rows)).toEqual(['root', 'shared', 'leaf', 'shared']);
+    // The subtree is drawn once, under the first record with that id.
+    expect(rows.filter((r) => r.span.spanId === 'leaf')).toHaveLength(1);
+  });
+
+  it('draws nothing for no spans', () => {
+    expect(buildSpanTree([])).toEqual([]);
+  });
+
+  it('finds the trace root, or the earliest span when there is none', () => {
+    expect(rootSpanOf([span('b', 'a', 5), span('a', '', 0)])?.spanId).toBe('a');
+    expect(rootSpanOf([span('a', 'b', 0), span('b', 'a', 5)])?.spanId).toBe('a');
+    expect(rootSpanOf([])).toBeNull();
+  });
+});
+
+describe('span kind', () => {
+  it('reads OTLP kinds in SkyWalking’s vocabulary', () => {
+    // A server call and a consumed message both ENTER this service; a client
+    // call and a produced message both leave it.
+    expect(kindLabel('SPAN_KIND_SERVER')).toBe('Entry');
+    expect(kindLabel('SPAN_KIND_CONSUMER')).toBe('Entry');
+    expect(kindLabel('SPAN_KIND_CLIENT')).toBe('Exit');
+    expect(kindLabel('SPAN_KIND_PRODUCER')).toBe('Exit');
+    expect(kindLabel('SPAN_KIND_INTERNAL')).toBe('Local');
+    expect(kindLabel('SPAN_KIND_UNSPECIFIED')).toBe('Unspecified');
+    expect(kindLabel('')).toBe('Unspecified');
+  });
+
+  it('keeps the protocol’s own word available beside it', () => {
+    expect(otlpKindName('SPAN_KIND_CONSUMER')).toBe('consumer');
+    expect(otlpKindName('')).toBe('unspecified');
+  });
+});
+
+describe('span status colour', () => {
+  it('paints unset apart from ok, because it is not a success', () => {
+    expect(statusColor('error')).toBe('var(--sw-err)');
+    expect(statusColor('ok')).toBe('var(--sw-ok)');
+    expect(statusColor('unset')).toBe('var(--sw-fg-3)');
+  });
+});
diff --git a/apps/ui/src/layer/traceql/traceqlDetailShared.ts b/apps/ui/src/layer/traceql/traceqlDetailShared.ts
new file mode 100644
index 00000000..680678ec
--- /dev/null
+++ b/apps/ui/src/layer/traceql/traceqlDetailShared.ts
@@ -0,0 +1,227 @@
+/*
+ * 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 bits of the TraceQL trace detail, in OTLP's own terms.
+ *
+ * These mirror the native trace detail's helpers deliberately — same palette,
+ * same latency colours, so the two read alike — but they take OTLP spans and
+ * speak OTLP's vocabulary. Nothing here maps OTLP onto SkyWalking's span
+ * model: a span kind is `SPAN_KIND_SERVER`, a status is ok / error / unset,
+ * and a parent is a span id string, because that is what the data is.
+ */
+
+import type { TraceQLSpan } from '@skywalking-horizon-ui/api-client';
+
+const SERVICE_PALETTE = [
+  'var(--sw-accent)', 'var(--sw-info)', 'var(--sw-cyan)', 'var(--sw-purple)',
+  'var(--sw-ok)', 'var(--sw-warn)', 'var(--sw-pink)',
+  '#a78bfa', '#fb7185', '#34d399', '#fbbf24', '#60a5fa',
+];
+
+function hashString(s: string): number {
+  let h = 2166136261;
+  for (let i = 0; i < s.length; i++) {
+    h ^= s.charCodeAt(i);
+    h = Math.imul(h, 16777619);
+  }
+  return h >>> 0;
+}
+
+export function buildServiceColors(spans: readonly TraceQLSpan[]): Map<string, string> {
+  const m = new Map<string, string>();
+  for (const s of spans) {
+    if (!m.has(s.service)) m.set(s.service, SERVICE_PALETTE[hashString(s.service) % SERVICE_PALETTE.length]!);
+  }
+  return m;
+}
+
+export function serviceColorFrom(colors: Map<string, string>, service: string): string {
+  return colors.get(service) ?? 'var(--sw-fg-2)';
+}
+
+/**
+ * An OTLP span kind, read in SkyWalking's vocabulary.
+ *
+ * OTLP has no entry/exit: it has client, server, producer and consumer. They
+ * say the same thing from the other side — a SERVER span is where a call
+ * ENTERS this service and a CLIENT span is where one LEAVES it, and messaging
+ * follows: a CONSUMER span is an arrival, a PRODUCER span a departure. Every
+ * other trace surface in Horizon says Entry and Exit, so these do too.
+ *
+ * Not translated, for the reason the native tab's `Entry` is not: it is
+ * SkyWalking's enum vocabulary, which operators read across the project.
+ */
+export function kindLabel(kind: string): string {
+  switch (kind) {
+    case 'SPAN_KIND_SERVER':
+    case 'SPAN_KIND_CONSUMER':
+      return 'Entry';
+    case 'SPAN_KIND_CLIENT':
+    case 'SPAN_KIND_PRODUCER':
+      return 'Exit';
+    case 'SPAN_KIND_INTERNAL':
+      return 'Local';
+    default:
+      return 'Unspecified';
+  }
+}
+
+/** The protocol's own name for the kind — `server`, `consumer`, … The label
+ *  above is a reading of this, not a replacement for it, so the span detail
+ *  shows both and nothing about the OTLP data is lost. */
+export function otlpKindName(kind: string): string {
+  return kind.replace(/^SPAN_KIND_/, '').toLowerCase() || 'unspecified';
+}
+
+export function kindColor(kind: string): string {
+  switch (kind) {
+    case 'SPAN_KIND_SERVER': return 'var(--sw-accent)';
+    case 'SPAN_KIND_CLIENT': return 'var(--sw-info)';
+    case 'SPAN_KIND_PRODUCER':
+    case 'SPAN_KIND_CONSUMER': return 'var(--sw-purple)';
+    // Purple, as the native detail paints a Local span — the glyph beside it
+    // is the same ring, so the colour must not say something else.
+    case 'SPAN_KIND_INTERNAL': return 'var(--sw-purple)';
+    default: return 'var(--sw-fg-2)';
+  }
+}
+
+/** The direction an OTLP kind describes, for the shared glyph. Messaging keeps
+ *  its own two shapes: a consumer span reads Entry, but it is an envelope
+ *  arriving rather than a call, and the picture should say which. */
+export function kindGlyphFamily(kind: string): 'entry' | 'exit' | 'local' | 'producer' | 'consumer' | 'other' {
+  switch (kind) {
+    case 'SPAN_KIND_SERVER': return 'entry';
+    case 'SPAN_KIND_CLIENT': return 'exit';
+    case 'SPAN_KIND_PRODUCER': return 'producer';
+    case 'SPAN_KIND_CONSUMER': return 'consumer';
+    case 'SPAN_KIND_INTERNAL': return 'local';
+    default: return 'other';
+  }
+}
+
+export function statusColor(status: TraceQLSpan['status']): string {
+  if (status === 'error') return 'var(--sw-err)';
+  if (status === 'ok') return 'var(--sw-ok)';
+  // UNSET is not success: the Zipkin converter defaults to it.
+  return 'var(--sw-fg-3)';
+}
+
+export function fmtMs(us: number): string {
+  const ms = us / 1000;
+  if (ms >= 1000) return `${(ms / 1000).toFixed(2)} s`;
+  if (ms >= 1) return `${ms.toFixed(1)} ms`;
+  return `${us} µs`;
+}
+
+export function fmtDateTime(us: number): string {
+  const d = new Date(us / 1000);
+  const p = (n: number, w = 2) => String(n).padStart(w, '0');
+  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
+}
+
+export function latencyColor(durationUs: number): string {
+  const ms = durationUs / 1000;
+  if (ms >= 1000) return 'var(--sw-err)';
+  if (ms >= 300) return 'var(--sw-warn)';
+  return 'var(--sw-ok)';
+}
+
+/**
+ * The query key the TraceQL rows share a trace with.
+ *
+ * Deliberately NOT `traceId`: that one belongs to the native and Zipkin
+ * popouts, which are reachable from a TraceQL page too — the AI panel's log
+ * rows link to them. A TraceQL page claiming `traceId` read those ids through
+ * the Tempo API, which is the wrong trace in the wrong renderer, and dropped
+ * the timestamp hint the popout needs for an old id.
+ */
+export const TRACEQL_POPOUT_QUERY = 'traceqlId';
+
+export interface SpanTreeRow {
+  span: TraceQLSpan;
+  depth: number;
+  /** Offset from the trace's earliest span, in microseconds. */
+  offsetUs: number;
+  /** The span's position in the trace as it arrived — a render identity, since
+   *  two Zipkin records can share one `spanId`. Never shown. */
+  key: number;
+}
+
+/**
+ * Depth-first order with each span's offset, built from OTLP parentage —
+ * `parentSpanId` against `spanId`, both opaque strings. A span whose parent is
+ * absent from the set is a root, which is how a partial trace still renders.
+ *
+ * Identity is a span's POSITION, not its id. A Zipkin client and server span
+ * legitimately share one span id and OAP's converter keeps both records, so
+ * keying on the id drops the second from the waterfall while the header and the
+ * statistics go on counting it. The first record with an id owns that id's
+ * children, or a shared id would draw its whole subtree twice.
+ */
+export function buildSpanTree(spans: readonly TraceQLSpan[]): SpanTreeRow[] {
+  if (spans.length === 0) return [];
+  const firstWithId = new Map<string, number>();
+  spans.forEach((s, i) => { if (!firstWithId.has(s.spanId)) firstWithId.set(s.spanId, i); });
+
+  const children = new Map<string, number[]>();
+  const roots: number[] = [];
+  spans.forEach((s, i) => {
+    // A span that is its own parent is a root; anything else is parented when
+    // the id it names is in this set.
+    if (s.parentSpanId && s.parentSpanId !== s.spanId && firstWithId.has(s.parentSpanId)) {
+      const arr = children.get(s.parentSpanId) ?? [];
+      arr.push(i);
+      children.set(s.parentSpanId, arr);
+    } else {
+      roots.push(i);
+    }
+  });
+  const byStart = (a: number, b: number) => spans[a]!.startUs - spans[b]!.startUs;
+  for (const arr of children.values()) arr.sort(byStart);
+  roots.sort(byStart);
+
+  const minStart = Math.min(...spans.map((s) => s.startUs));
+  const out: SpanTreeRow[] = [];
+  const seen = new Set<number>();
+  function walk(i: number, depth: number): void {
+    // A cycle cannot happen in a well-formed trace, but a malformed one must
+    // not hang the page.
+    if (seen.has(i)) return;
+    seen.add(i);
+    const s = spans[i]!;
+    out.push({ span: s, depth, offsetUs: s.startUs - minStart, key: i });
+    if (firstWithId.get(s.spanId) !== i) return;
+    for (const c of children.get(s.spanId) ?? []) walk(c, depth + 1);
+  }
+  for (const r of roots) walk(r, 0);
+  // Malformed parentage — a cycle — leaves a component with no root. Walking
+  // only from roots would drop those spans silently while the header and the
+  // statistics still counted them, so each remaining component is entered at
+  // its earliest span and shown at depth 0.
+  if (out.length < spans.length) {
+    for (const i of spans.map((_, n) => n).sort(byStart)) walk(i, 0);
+  }
+  return out;
+}
+
+/** The trace's own span, if it has one: the root of the tree. */
+export function rootSpanOf(spans: readonly TraceQLSpan[]): TraceQLSpan | null {
+  const byId = new Set(spans.map((s) => s.spanId));
+  return spans.find((s) => !s.parentSpanId || !byId.has(s.parentSpanId)) ?? spans[0] ?? null;
+}
diff --git a/apps/ui/src/layer/traceql/traceqlLint.test.ts b/apps/ui/src/layer/traceql/traceqlLint.test.ts
new file mode 100644
index 00000000..bdd84a4a
--- /dev/null
+++ b/apps/ui/src/layer/traceql/traceqlLint.test.ts
@@ -0,0 +1,103 @@
+/*
+ * 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 { describe, it, expect } from 'vitest';
+import { lintTraceQL, buildTraceQL, serviceInExpression } from '@/layer/traceql/traceqlLint';
+/** Nothing to warn about: every field is one the schema has. */
+const clean = (q: string, ds?: 'native' | 'zipkin') => lintTraceQL(q, ds).length === 0;
+const ids = (q: string) => lintTraceQL(q).map((i) => i.id);
+
+describe('lint', () => {
+  it('passes every form the language defines, whatever a backend does with it', () => {
+    // None of these is Horizon's to judge: the fields are in the schema, so the
+    // query goes to the backend and the backend answers for it.
+    expect(clean('{span.http.method!="GET"}')).toBe(true);
+    expect(clean('{span.foo=~"bar"}')).toBe(true);
+    expect(clean('{resource.service.name="a"} && {span.http.method="GET"}')).toBe(true);
+    expect(clean('{span.a="1" || span.b="2"}')).toBe(true);
+    expect(clean('{!status="error"}')).toBe(true);
+    expect(clean('{duration=100ms}')).toBe(true);
+    expect(clean('{span.retries>=3}')).toBe(true);
+    expect(clean('{span.http.status_code>=500}')).toBe(true);
+    expect(clean('{resource.instance="aaa"}')).toBe(true);
+    expect(clean('{name="/checkout"}')).toBe(true);
+    expect(clean('{resource.service.name="a" && duration>100ms && duration<2s}')).toBe(true);
+    expect(clean('{}')).toBe(true);
+    expect(clean('')).toBe(true);
+  });
+
+  it('warns about an attribute with no scope', () => {
+    expect(ids('{http.method="GET"}')).toEqual(['unscoped-attribute']);
+    expect(ids('{service.name="skywalking"}')).toEqual(['unscoped-attribute']);
+    // A scope the schema does not define is the same mistake by another name.
+    expect(ids('{event.foo="x"}')).toEqual(['unscoped-attribute']);
+    expect(clean('{span.http.method="GET"}')).toBe(true);
+    expect(clean('{.http.method="GET"}')).toBe(true);
+    expect(clean('{resource.service.name="a"}')).toBe(true);
+  });
+
+  it('warns about a bare name that is not an intrinsic', () => {
+    expect(ids('{kind=server}')).toEqual(['unknown-field']);
+    expect(ids('{traceDuration>1s}')).toEqual(['unknown-field']);
+    // The three intrinsics are bare by grammar.
+    expect(clean('{duration>100ms}')).toBe(true);
+    expect(clean('{status="ok"}')).toBe(true);
+    expect(clean('{name="/checkout"}')).toBe(true);
+  });
+
+  it('warns about the other store’s resource attribute', () => {
+    expect(ids('{resource.instance="aaa"}').length).toBe(0);
+    expect(lintTraceQL('{resource.instance="aaa"}', 'zipkin').map((i) => i.id)).toEqual(['other-store-attribute']);
+    expect(lintTraceQL('{resource.remote.service="a"}', 'zipkin')).toEqual([]);
+    expect(lintTraceQL('{resource.remote.service="a"}', 'native').map((i) => i.id)).toEqual(['other-store-attribute']);
+  });
+
+  it('does not fire on text inside a literal', () => {
+    expect(clean('{span.message="x!=y"}')).toBe(true);
+    expect(clean('{resource.service.name="or"}')).toBe(true);
+    expect(clean('{span.message="}" }')).toBe(true);
+    expect(clean('{span.kind="server"}')).toBe(true);
+    expect(clean('{span.message="not found"}')).toBe(true);
+    expect(clean('{span.path="/a!b"}')).toBe(true);
+  });
+
+  it('warns on a status the language does not define', () => {
+    expect(lintTraceQL('{status="STATUS_CODE_OK"}').map((i) => i.id)).toEqual(['status-vocabulary']);
+    expect(clean('{status="error"}')).toBe(true);
+    expect(clean('{status="unset"}')).toBe(true);
+  });
+
+  it('warns about an expression that is not one complete spanset', () => {
+    expect(ids('{')).toContain('incomplete-spanset');
+    expect(ids('{resource.service.name="a"')).toContain('incomplete-spanset');
+    expect(ids('{resource.service.name=}')).toContain('empty-value');
+    expect(ids('{resource.service.name="a" && span.http.method=}')).toContain('empty-value');
+  });
+
+  it('escapes a value that carries a quote', () => {
+    expect(buildTraceQL({ service: 'svc"quoted' })).toBe('{resource.service.name="svc\\"quoted"}');
+  });
+
+  it('reads the service out of a hand-written expression', () => {
+    expect(serviceInExpression('{resource.service.name="agent::songs" && duration>10ms}')).toBe('agent::songs');
+    expect(serviceInExpression('{ resource.service.name = "a b" }')).toBe('a b');
+    // The escapes buildTraceQL wrote come back off.
+    expect(serviceInExpression('{resource.service.name="svc\\"quoted"}')).toBe('svc"quoted');
+    expect(serviceInExpression('{duration>10ms}')).toBeNull();
+    expect(serviceInExpression('')).toBeNull();
+  });
+});
diff --git a/apps/ui/src/layer/traceql/traceqlLint.ts b/apps/ui/src/layer/traceql/traceqlLint.ts
new file mode 100644
index 00000000..c8d9c62b
--- /dev/null
+++ b/apps/ui/src/layer/traceql/traceqlLint.ts
@@ -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,
... 3176 lines suppressed ...