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 8a2cccc feat(pod-logs): on-demand Kubernetes pod-log live-tail tab
(#25)
8a2cccc is described below
commit 8a2cccc98cd7de3d472798fbd0fdaaafd0ce149c
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Thu May 28 18:29:25 2026 +0800
feat(pod-logs): on-demand Kubernetes pod-log live-tail tab (#25)
A per-layer "Pod Logs" tab live-tails a pod's container logs, fetched on
demand from the K8s API through OAP (listContainers / ondemandPodLogs)
and never persisted. Instance-pinned: pick a pod, pick a container,
Start; the trailing SECOND-precision window streams into a read-only
Monaco pane on a chosen interval until paused. Include / Exclude forward
to OAP's keywordsOfContent / excludingKeywordsOfContent (full-line regex).
- BFF: new GET/POST /api/layer/:key/pod-logs(/containers) routes
(logs:read), epoch-seconds -> ms, errorReason passthrough for the
feature-disabled and stale-pod cases.
- Caps: podLogs component flag on k8s_service, mesh, mesh_dp bundled
templates; mergeComponentFallback back-fills flags a remote OAP-stored
template predates, so the tab surfaces without a re-push.
- UI: sidebar tab + caps-gated route, layer-template admin toggle, topbar
auto-refresh + time-picker opt-out (the page runs its own tail loop).
- i18n: the new view is fully wrapped in t(); 22 keys seeded across all
eight locales.
Also fixes the template-admin reset-from-bundled flow: Save / Check diff
& push now gate on editor-vs-remote (not just vs the load snapshot), so a
reset to bundled is publishable when bundled differs from remote (layer +
overview editors); the unpublished-edits nudge is suppressed on the admin
editor routes; and the save / flash message moved to its own row so it no
longer overlaps the action buttons.
CHANGELOG + CLAUDE.md: record the feature and bundled-template changes,
and add the changelog policy (new features + bundled-template changes go
in the changelog; released sections take bug fixes only).
---
CHANGELOG.md | 31 +-
CLAUDE.md | 7 +
.../src/bundled_templates/layers/k8s_service.json | 3 +-
apps/bff/src/bundled_templates/layers/mesh.json | 3 +-
apps/bff/src/bundled_templates/layers/mesh_dp.json | 3 +-
apps/bff/src/http/query/menu.ts | 19 +-
apps/bff/src/http/query/pod-log.ts | 215 +++++++++++
apps/bff/src/logic/layers/loader.ts | 4 +
apps/bff/src/rbac/route-policy.ts | 2 +
apps/bff/src/server.ts | 2 +
apps/ui/src/api/client.ts | 1 +
apps/ui/src/api/scopes/log.ts | 51 +++
.../admin/layer-templates/LayerDashboardsAdmin.vue | 22 +-
.../overview-templates/OverviewTemplatesAdmin.vue | 20 +-
apps/ui/src/i18n/locales/de.json | 24 +-
apps/ui/src/i18n/locales/en.json | 24 +-
apps/ui/src/i18n/locales/es.json | 24 +-
apps/ui/src/i18n/locales/fr.json | 24 +-
apps/ui/src/i18n/locales/ja.json | 24 +-
apps/ui/src/i18n/locales/ko.json | 24 +-
apps/ui/src/i18n/locales/pt.json | 24 +-
apps/ui/src/i18n/locales/zh-CN.json | 24 +-
apps/ui/src/layer/pod-logs/LayerPodLogsView.vue | 391 +++++++++++++++++++++
apps/ui/src/layer/pod-logs/useLayerPodLogs.ts | 224 ++++++++++++
apps/ui/src/shell/AppSidebar.vue | 16 +
apps/ui/src/shell/AppTopbar.vue | 4 +
apps/ui/src/shell/TemplateConflictPrompt.vue | 18 +-
apps/ui/src/shell/layerFromTemplate.ts | 1 +
apps/ui/src/shell/router/index.ts | 3 +
apps/ui/src/shell/useLayers.ts | 1 +
packages/api-client/src/menu.ts | 4 +
31 files changed, 1217 insertions(+), 20 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d1f525..e1ab159 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -292,6 +292,31 @@ one click away on every selected hex.
no longer sit on *"Resolving service…"* forever waiting for a row
that won't arrive.
+### On-demand pod logs (live tail)
+
+A new per-layer **Pod Logs** tab live-tails a Kubernetes pod's container
+logs, pulled on demand from the K8s API through OAP and never persisted.
+
+- **Instance-pinned tail.** Pick a pod, pick one of its containers, press
+ Start; the trailing window (30s / 1m / 5m / 15m / 30m) streams into a
+ read-only log pane and refreshes on a chosen interval (2s / 5s / 10s /
+ 30s) until paused. A header strip shows the container, line count, a
+ live dot, and "updated Ns ago".
+- **Include / Exclude filtering** forwards to OAP's content keyword
+ filters — full-line regex, so a substring match reads `.*error.*`.
+- **Enabled on the Kubernetes-deployed layers** — Kubernetes Services
+ (`K8S_SERVICE`), Istio Managed Services (`MESH`), and Istio Data Plane
+ (`MESH_DP`) — whose service instances resolve to a pod. The tab is gated
+ by a new `podLogs` component flag added to those bundled layer
+ templates; an existing OAP whose stored template predates the flag still
+ gets the tab, because the flag is back-filled from the bundled default
+ (no re-push needed).
+- The page **owns its own refresh** — the global auto-refresh ticker and
+ the topbar time picker are paused while on it, the same as Traces / Logs.
+- When the selected instance carries no pod metadata (or the pod has
+ rotated away), OAP's reason is shown verbatim with a hint to pick a
+ currently-running pod or enable the feature on OAP.
+
### BanyanDB cold-stage query
The cold lifecycle stage is now reachable from the UI on BanyanDB
@@ -386,7 +411,11 @@ live, shared version is whatever OAP serves.
- **Publish with a diff.** **Check diff & push** shows a side-by-side
local→remote diff and publishes to OAP; it's enabled only when your local
draft actually differs from remote. Bundled can also be pushed straight to
- OAP. Resetting to remote clears the local draft.
+ OAP. Resetting to remote clears the local draft. **Reset to bundled** then
+ publishes correctly when the bundled default differs from remote — Save
+ (local) and Check diff & push now compare the editor against remote, not
+ just against what was first loaded, so a bundled-vs-remote divergence is
+ no longer mistaken for "no changes" (layer + overview editors).
- Preview faithfully reflects your draft's **enabled components / menu
labels** — disabled tabs disappear and renamed nouns ("Nodes", "Topics")
show through — without pushing anything to the server. Preview works even
diff --git a/CLAUDE.md b/CLAUDE.md
index 28b7dad..8715d81 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -104,6 +104,13 @@ English is the source of truth. Every UI string and every
translatable template
**Never** add `Co-Authored-By: Claude` (or any AI / Anthropic / claude.com /
`[email protected]` line) to commit messages or PR bodies. Do not append
the "🤖 Generated with Claude Code" footer. Per-project directive.
+## Changelog (`CHANGELOG.md`)
+
+Keep `CHANGELOG.md` current as part of the change, not as an afterthought.
Written from the operator's point of view — what's new on screen and what's now
possible — never file-by-file implementation (that's the git log).
+
+- **New features go in the changelog.** Any operator-visible capability — a
new page / tab / widget, a new component flag, **bundled template changes** (a
layer gaining a capability, new dashboards / widgets / metrics), a new admin
surface — must be recorded under the current **unreleased** version section. If
a change alters what an operator sees or can do, it belongs here.
+- **Released version sections are frozen.** A version that's been
tagged/released only ever receives **bug-fix** entries afterward (and only for
fixes shipped to that line). Never add a new feature to an already-released
version's section — features always land under the current/next unreleased
version. The current version is `*-dev` in `package.json`; the newest released
line is the latest `v*` git tag.
+
## Common AI failure modes to avoid
1. **Skipping the read.** If you change a route, composable, store, or
template without reading it end-to-end first, you will break something subtle.
Read first, every time.
diff --git a/apps/bff/src/bundled_templates/layers/k8s_service.json
b/apps/bff/src/bundled_templates/layers/k8s_service.json
index 6b23d06..98620e6 100644
--- a/apps/bff/src/bundled_templates/layers/k8s_service.json
+++ b/apps/bff/src/bundled_templates/layers/k8s_service.json
@@ -23,7 +23,8 @@
"topology": true,
"traces": false,
"logs": false,
- "ebpfProfiling": true
+ "ebpfProfiling": true,
+ "podLogs": true
},
"layer-header": {
"orderBy": "httpCpm",
diff --git a/apps/bff/src/bundled_templates/layers/mesh.json
b/apps/bff/src/bundled_templates/layers/mesh.json
index 5ed2463..04e4eb7 100644
--- a/apps/bff/src/bundled_templates/layers/mesh.json
+++ b/apps/bff/src/bundled_templates/layers/mesh.json
@@ -24,7 +24,8 @@
"traces": true,
"logs": true,
"ebpfProfiling": true,
- "networkProfiling": true
+ "networkProfiling": true,
+ "podLogs": true
},
"layer-header": {
"orderBy": "cpm",
diff --git a/apps/bff/src/bundled_templates/layers/mesh_dp.json
b/apps/bff/src/bundled_templates/layers/mesh_dp.json
index 445c340..242c90f 100644
--- a/apps/bff/src/bundled_templates/layers/mesh_dp.json
+++ b/apps/bff/src/bundled_templates/layers/mesh_dp.json
@@ -21,7 +21,8 @@
"topology": false,
"traces": false,
"logs": true,
- "ebpfProfiling": true
+ "ebpfProfiling": true,
+ "podLogs": true
},
"log": {
"scope": "instance"
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index bb335fa..12529c5 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -44,6 +44,20 @@ import { localize, getLayerOverlay, localeFromRequest } from
'../../i18n/index.j
* consults. We expand a few aliases (service ⇒ no separate cap; the
* components flag is the source of truth for whether the page exists).
*/
+/** Fill component flags the live template omits from the bundled one, so
+ * a newly-shipped capability surfaces on an OAP whose stored template
+ * predates it (no re-push needed). Flags the live template defines
+ * (true OR false) are kept; bundled only fills `undefined` keys. */
+function mergeComponentFallback(rawKey: string, live: LayerComponentFlags):
LayerComponentFlags {
+ const bundled = getLayerTemplate(rawKey)?.components;
+ if (!bundled) return live;
+ const merged: LayerComponentFlags = { ...live };
+ for (const [k, v] of Object.entries(bundled) as [keyof LayerComponentFlags,
boolean][]) {
+ if (merged[k] === undefined) merged[k] = v;
+ }
+ return merged;
+}
+
function componentsToCaps(components: LayerComponentFlags): LayerCaps {
return {
dashboards: components.service !== false,
@@ -60,6 +74,7 @@ function componentsToCaps(components: LayerComponentFlags):
LayerCaps {
asyncProfiling: !!components.asyncProfiling,
networkProfiling: !!components.networkProfiling,
pprofProfiling: !!components.pprofProfiling,
+ podLogs: !!components.podLogs,
events: false,
// Bundled service-count tile defaults on — every layer benefits
// from the headline count, and operators can opt out per-layer
@@ -273,7 +288,9 @@ function deriveLayer(
visibility: tpl.visibility,
documentLink: tpl.documentLink ?? undefined,
slots: tpl.slots,
- caps: componentsToCaps(tpl.components),
+ // Bundled fills component flags the live template omits (see
+ // mergeComponentFallback) — scoped to caps, not widgets/metrics.
+ caps: componentsToCaps(mergeComponentFallback(rawKey, tpl.components)),
header: tpl.header,
metrics: tpl.metrics,
overview: tpl.overview,
diff --git a/apps/bff/src/http/query/pod-log.ts
b/apps/bff/src/http/query/pod-log.ts
new file mode 100644
index 0000000..5441b7e
--- /dev/null
+++ b/apps/bff/src/http/query/pod-log.ts
@@ -0,0 +1,215 @@
+/*
+ * 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.
+ */
+
+/**
+ * On-demand Pod logs — live-tail a Kubernetes pod's container logs,
+ * fetched on demand straight from the K8s API through OAP and NEVER
+ * persisted. Backs the per-layer "Pod Logs" tab.
+ *
+ * GET /api/layer/:key/pod-logs/containers?instance=<id>
+ * → list the pod's containers (OAP `listContainers`).
+ * POST /api/layer/:key/pod-logs
+ * → tail one container's logs over a rolling SECOND window
+ * (OAP `ondemandPodLogs`).
+ *
+ * Two OAP sharp edges this route smooths:
+ * - The feature is DISABLED by default on OAP (logs can leak secrets).
+ * When off — or when the pod can't be resolved (a stale instance id
+ * from a terminated pod) — OAP returns `errorReason` instead of data.
+ * We forward it verbatim so the UI can show a hint rather than an
+ * empty pane.
+ * - The window is SECOND-precision (live tail), formatted in OAP-local
+ * time via the cached server offset. `container` is REQUIRED by the
+ * OAP condition.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { z } from 'zod';
+import type { FetchLike } from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../../config/loader.js';
+import type { SessionStore } from '../../user/sessions.js';
+import { requireAuth } from '../../user/middleware.js';
+import { graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { fmtSecond, getServerOffsetMinutes } from '../../util/window.js';
+
+export interface PodLogRouteDeps {
+ config: ConfigSource;
+ sessions: SessionStore;
+ fetch?: FetchLike;
+}
+
+/** Default tail window in seconds when the client omits one. Matches
+ * the UI picker's smallest sensible "recent" slice. */
+const DEFAULT_WINDOW_SEC = 60;
+const MAX_WINDOW_SEC = 30 * 60; // cap at 30m — same ceiling as booster-ui
+
+const QUERY_CONTAINERS = /* GraphQL */ `
+ query ListContainers($condition: OndemandContainergQueryCondition) {
+ containers: listContainers(condition: $condition) {
+ errorReason
+ containers
+ }
+ }
+`;
+
+const QUERY_POD_LOGS = /* GraphQL */ `
+ query OndemandPodLogs($condition: OndemandLogQueryCondition) {
+ logs: ondemandPodLogs(condition: $condition) {
+ errorReason
+ logs {
+ content
+ timestamp
+ }
+ }
+ }
+`;
+
+interface OapContainers {
+ errorReason?: string | null;
+ containers?: string[] | null;
+}
+interface OapPodLogLine {
+ content?: string | null;
+ timestamp?: number | null;
+}
+interface OapPodLogs {
+ errorReason?: string | null;
+ logs?: OapPodLogLine[] | null;
+}
+
+const logBodySchema = z
+ .object({
+ serviceInstanceId: z.string().min(1),
+ container: z.string().min(1),
+ windowSeconds: z.number().int().positive().optional(),
+ keywordsOfContent: z.array(z.string().min(1)).optional(),
+ excludingKeywordsOfContent: z.array(z.string().min(1)).optional(),
+ })
+ .strict();
+
+function validLayerKey(k: string): boolean {
+ return /^[a-z0-9_]+$/i.test(k);
+}
+
+export function registerPodLogRoutes(app: FastifyInstance, deps:
PodLogRouteDeps): void {
+ const auth = requireAuth(deps);
+
+ // ── List a pod's containers ──────────────────────────────────────
+ app.get(
+ '/api/layer/:key/pod-logs/containers',
+ { preHandler: auth },
+ async (req: FastifyRequest, reply: FastifyReply) => {
+ const { key } = req.params as { key: string };
+ if (!validLayerKey(key)) return reply.code(400).send({ error:
'invalid_layer_key' });
+ const instance = (req.query as { instance?: string }).instance ?? '';
+ if (!instance) {
+ return reply.send({ containers: [], errorReason: null, reachable:
true, generatedAt: Date.now() });
+ }
+ const opts = buildOapOpts(deps.config.current, deps.fetch);
+ try {
+ const data = await graphqlPost<{ containers: OapContainers }>(opts,
QUERY_CONTAINERS, {
+ condition: { serviceInstanceId: instance },
+ });
+ const c = data.containers ?? {};
+ return reply.send({
+ containers: c.containers ?? [],
+ // OAP returns a non-empty errorReason when the pod can't be
+ // found (stale instance) or the feature is disabled. The
+ // empty-string case is normalized to null.
+ errorReason: c.errorReason ? c.errorReason : null,
+ reachable: true,
+ generatedAt: Date.now(),
+ });
+ } catch (err) {
+ return reply.send({
+ containers: [],
+ errorReason: null,
+ reachable: false,
+ error: err instanceof Error ? err.message : String(err),
+ generatedAt: Date.now(),
+ });
+ }
+ },
+ );
+
+ // ── Tail a container's logs ──────────────────────────────────────
+ app.post(
+ '/api/layer/:key/pod-logs',
+ { preHandler: auth },
+ async (req: FastifyRequest, reply: FastifyReply) => {
+ const { key } = req.params as { key: string };
+ if (!validLayerKey(key)) return reply.code(400).send({ error:
'invalid_layer_key' });
+ const parsed = logBodySchema.safeParse(req.body);
+ if (!parsed.success) {
+ return reply.code(400).send({ error: 'invalid_body', detail:
parsed.error.flatten() });
+ }
+ const body = parsed.data;
+ const opts = buildOapOpts(deps.config.current, deps.fetch);
+ const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
+
+ const windowSec = Math.min(
+ MAX_WINDOW_SEC,
+ body.windowSeconds && body.windowSeconds > 0 ? body.windowSeconds :
DEFAULT_WINDOW_SEC,
+ );
+ const endMs = Date.now();
+ const startMs = endMs - windowSec * 1000;
+ const duration = {
+ start: fmtSecond(startMs, offset),
+ end: fmtSecond(endMs, offset),
+ step: 'SECOND' as const,
+ };
+
+ const condition: Record<string, unknown> = {
+ serviceInstanceId: body.serviceInstanceId,
+ container: body.container,
+ duration,
+ };
+ if (body.keywordsOfContent?.length) condition.keywordsOfContent =
body.keywordsOfContent;
+ if (body.excludingKeywordsOfContent?.length) {
+ condition.excludingKeywordsOfContent = body.excludingKeywordsOfContent;
+ }
+
+ try {
+ const data = await graphqlPost<{ logs: OapPodLogs }>(opts,
QUERY_POD_LOGS, { condition });
+ const l = data.logs ?? {};
+ const lines = (l.logs ?? []).map((row) => ({
+ content: row.content ?? '',
+ // OAP returns timestamp in epoch SECONDS; surface milliseconds
+ // so the UI's date handling matches every other timestamp it
+ // renders (echarts / Date all expect ms).
+ timestamp: typeof row.timestamp === 'number' ? row.timestamp * 1000
: null,
+ }));
+ return reply.send({
+ lines,
+ errorReason: l.errorReason ? l.errorReason : null,
+ reachable: true,
+ generatedAt: Date.now(),
+ window: duration,
+ });
+ } catch (err) {
+ return reply.send({
+ lines: [],
+ errorReason: null,
+ reachable: false,
+ error: err instanceof Error ? err.message : String(err),
+ generatedAt: Date.now(),
+ window: duration,
+ });
+ }
+ },
+ );
+}
diff --git a/apps/bff/src/logic/layers/loader.ts
b/apps/bff/src/logic/layers/loader.ts
index c2082ed..42621b9 100644
--- a/apps/bff/src/logic/layers/loader.ts
+++ b/apps/bff/src/logic/layers/loader.ts
@@ -67,6 +67,10 @@ export interface LayerComponentFlags {
networkProfiling?: boolean;
/** Go pprof integration. */
pprofProfiling?: boolean;
+ /** On-demand Kubernetes pod logs — live-tail a pod's container logs
+ * fetched on demand from the K8s API (never persisted). Only K8s-
+ * deployed layers (k8s_service, mesh) carry pods that resolve. */
+ podLogs?: boolean;
}
export interface LayerSlotsConfig {
diff --git a/apps/bff/src/rbac/route-policy.ts
b/apps/bff/src/rbac/route-policy.ts
index 3dc88d4..7614180 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -94,6 +94,8 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
'POST /api/layer/:key/logs/facets': 'logs:read',
'GET /api/log-tags/keys': 'logs:read',
'GET /api/log-tags/values': 'logs:read',
+ 'GET /api/layer/:key/pod-logs/containers': 'logs:read',
+ 'POST /api/layer/:key/pod-logs': 'logs:read',
// ── Topology (read) ──────────────────────────────────────────────
'GET /api/layer/:key/topology': 'topology:read',
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 71683ed..1fe25d7 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -41,6 +41,7 @@ import { registerTraceRoutes } from './http/query/trace.js';
import { registerTraceTagRoutes } from './http/query/trace-tag.js';
import { registerZipkinRoutes } from './http/query/zipkin.js';
import { registerLogRoute } from './http/query/log.js';
+import { registerPodLogRoutes } from './http/query/pod-log.js';
import { registerDashboardQueryRoute } from './http/query/dashboard.js';
import { registerAlarmsQueryRoutes } from './http/query/alarms.js';
import { registerPreflightRoutes } from './http/query/preflight.js';
@@ -177,6 +178,7 @@ registerTraceRoutes(app, { config: source, sessions });
registerTraceTagRoutes(app, { config: source, sessions });
registerZipkinRoutes(app, { config: source, sessions });
registerLogRoute(app, { config: source, sessions });
+registerPodLogRoutes(app, { config: source, sessions });
registerDashboardQueryRoute(app, { config: source, sessions });
registerAlarmsQueryRoutes(app, { config: source, sessions, serviceLayer });
registerPreflightRoutes(app, { config: source, sessions });
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index cbd6931..7a735a0 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -266,6 +266,7 @@ export interface AdminLayerTemplate {
topology?: boolean;
traces?: boolean;
logs?: boolean;
+ podLogs?: boolean;
profiling?: boolean;
traceProfiling?: boolean;
ebpfProfiling?: boolean;
diff --git a/apps/ui/src/api/scopes/log.ts b/apps/ui/src/api/scopes/log.ts
index d89e2da..95fd0b2 100644
--- a/apps/ui/src/api/scopes/log.ts
+++ b/apps/ui/src/api/scopes/log.ts
@@ -60,4 +60,55 @@ export class LogApi {
`/api/log-tags/values?key=${encodeURIComponent(key)}&windowMinutes=${windowMinutes}`,
);
}
+
+ // ── On-demand pod logs (live tail) ───────────────────────────────
+
+ /** List a pod's containers. `errorReason` is non-null when the pod
+ * can't be resolved (stale instance) or the OAP feature is off. */
+ podContainers(layerKey: string, instanceId: string):
Promise<PodContainersResponse> {
+ return this.bff.request<PodContainersResponse>(
+ 'GET',
+
`/api/layer/${encodeURIComponent(layerKey)}/pod-logs/containers?instance=${encodeURIComponent(instanceId)}`,
+ );
+ }
+
+ /** Tail one container's logs over a rolling SECOND window. */
+ podLogs(layerKey: string, body: PodLogsRequest): Promise<PodLogsResponse> {
+ return this.bff.request<PodLogsResponse>(
+ 'POST',
+ `/api/layer/${encodeURIComponent(layerKey)}/pod-logs`,
+ body,
+ );
+ }
+}
+
+export interface PodContainersResponse {
+ containers: string[];
+ errorReason: string | null;
+ reachable: boolean;
+ error?: string;
+ generatedAt: number;
+}
+
+export interface PodLogsRequest {
+ serviceInstanceId: string;
+ container: string;
+ windowSeconds?: number;
+ keywordsOfContent?: string[];
+ excludingKeywordsOfContent?: string[];
+}
+
+export interface PodLogLine {
+ content: string;
+ /** ms epoch (BFF converts OAP's epoch-seconds), or null. */
+ timestamp: number | null;
+}
+
+export interface PodLogsResponse {
+ lines: PodLogLine[];
+ errorReason: string | null;
+ reachable: boolean;
+ error?: string;
+ generatedAt: number;
+ window: { start: string; end: string; step: 'SECOND' };
}
diff --git
a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index 98c5284..d5fdc4c 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -485,6 +485,16 @@ const dirty = computed(() => {
return JSON.stringify(draft.template) !== loadedSnapshot.value;
});
+/** Editor content differs from the publish target (remote) — gates Save
+ * so "Reset to bundled" (pristine-vs-load, `dirty=false`) is still
+ * publishable when bundled ≠ remote. Key-stable to ignore key order. */
+const editorDiffersFromRemote = computed<boolean>(() => {
+ if (!draft.template) return false;
+ const remote = sources.remote<AdminLayerTemplate>(editName.value);
+ if (!remote) return true;
+ return stableStringify(draft.template) !== stableStringify(remote);
+});
+
function widgetsFor(scope: AdminScope): DashboardWidget[] {
const tpl = draft.template;
if (!tpl) return [];
@@ -1226,6 +1236,7 @@ const COMPONENT_TOGGLES: Array<{ key: ComponentKey;
label: string; hint: string
{ key: 'topology', label: 'Topology', hint: 'Service topology graph for this
layer.' },
{ key: 'traces', label: 'Traces', hint: 'Trace explorer scoped to this
layer.' },
{ key: 'logs', label: 'Logs', hint: 'Log explorer scoped to this layer.' },
+ { key: 'podLogs', label: 'Pod Logs', hint: 'On-demand Kubernetes pod-log
live tail. Only K8s-deployed layers (k8s_service, mesh) carry pods that
resolve.' },
{ key: 'traceProfiling', label: 'Trace Profiling', hint: 'Trace-driven
thread profiling — the original SkyWalking profile.' },
{ key: 'ebpfProfiling', label: 'eBPF Profiling', hint: 'Kernel-level CPU /
off-CPU profiling via eBPF agents.' },
{ key: 'asyncProfiling', label: 'Async Profiling', hint: 'JVM async-profiler
integration (Java-only).' },
@@ -1254,6 +1265,9 @@ const COMPONENT_SCOPE: Record<ComponentKey, AdminScope> =
{
topology: 'topology',
traces: 'trace',
logs: 'logs',
+ // Pod Logs has no editable widget grid — filler to satisfy the
+ // exhaustive Record; the menu-preview click no-ops for it.
+ podLogs: 'logs',
traceProfiling: 'traceProfiling',
ebpfProfiling: 'ebpfProfiling',
asyncProfiling: 'asyncProfiling',
@@ -1647,7 +1661,6 @@ const namingTest = computed<NamingTestResult>(() => {
</button>
</div>
<div class="actions">
- <span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
<!-- Source pill — three visible states, one per
`editorSource`. The pill is gated on
`sourcesReady` to suppress the flash on initial
@@ -1738,7 +1751,7 @@ const namingTest = computed<NamingTestResult>(() => {
<button
class="sw-btn is-primary"
type="button"
- :disabled="!dirty || isSaving"
+ :disabled="(!dirty && !editorDiffersFromRemote) || isSaving"
title="Save the editor to your browser (local). Publish later
with “Push local → OAP”."
@click="save"
>
@@ -1746,6 +1759,10 @@ const namingTest = computed<NamingTestResult>(() => {
</button>
</div>
</div>
+ <!-- Own row so a long flash never overlaps the action cluster. -->
+ <div v-if="saveMsg" class="save-msg-row">
+ <span class="save-msg">{{ saveMsg }}</span>
+ </div>
<!-- Sidebar placement: `public` (default) → regular Layers
section. `operate` → operations block (alongside Cluster,
DSL Management, etc.). Use for layers that an operator
@@ -3284,6 +3301,7 @@ const namingTest = computed<NamingTestResult>(() => {
.confirm-msg { margin: 0; font-size: 13px; line-height: 1.55; color:
var(--sw-fg-1); }
.push-diff { height: 50vh; min-height: 320px; border: 1px solid
var(--sw-line); border-radius: 6px; overflow: hidden; }
.actions .sw-btn[disabled] { opacity: 0.4; pointer-events: none; }
+.save-msg-row { display: flex; justify-content: flex-end; }
.save-msg {
font-size: 11px;
color: var(--sw-ok);
diff --git
a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
index 039ee56..a71d0b7 100644
--- a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
+++ b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
@@ -688,6 +688,16 @@ const isDirty = computed<boolean>(() =>
draft.value ? JSON.stringify(draft.value) !== loadedSnapshot.value : false,
);
+/** Editor content differs from the publish target (remote) — gates Save
+ * so "Reset to bundled" (pristine-vs-load, `isDirty=false`) is still
+ * publishable when bundled ≠ remote. Key-stable to ignore key order. */
+const editorDiffersFromRemote = computed<boolean>(() => {
+ if (!draft.value) return false;
+ const remote = sources.remote<OverviewDashboard>(editName.value);
+ if (!remote) return true;
+ return stableStringify(draft.value) !== stableStringify(remote);
+});
+
// Which source to seed the editor from for the current selection.
// Remote is the canonical baseline — it's what `pickOverviewContent`
// in the runtime bundle serves to end users — so the editor opens
@@ -1062,8 +1072,7 @@ function widgetKindLabel(type: OverviewWidget['type']):
string {
<span class="ot__count mono">
{{ draft.widgets.length }} widget{{ draft.widgets.length === 1 ?
'' : 's' }}
</span>
- <span v-if="flash" class="ot__flash">{{ flash }}</span>
- <span v-else-if="isDirty" class="ot__dirty">unsaved changes</span>
+ <span v-if="isDirty" class="ot__dirty">unsaved changes</span>
<span v-else class="ot__clean">saved</span>
<!-- Delete. A local-only draft is removed from the browser; a
dashboard on OAP (bundled or remote-only) is soft-disabled
@@ -1154,7 +1163,7 @@ function widgetKindLabel(type: OverviewWidget['type']):
string {
<button
type="button"
class="ot__btn ot__btn--primary"
- :disabled="!isDirty || saving"
+ :disabled="(!isDirty && !editorDiffersFromRemote) || saving"
title="Save the editor to your browser (local). Publish later
with “Check diff & push”."
@click="onSave"
>
@@ -1162,6 +1171,10 @@ function widgetKindLabel(type: OverviewWidget['type']):
string {
</button>
</div>
</header>
+ <!-- Own row so a long flash never overlaps the action cluster. -->
+ <div v-if="flash" class="ot__flash-row">
+ <span class="ot__flash">{{ flash }}</span>
+ </div>
<!-- One-page editor: mock-data widget grid (canvas, left) +
drawer (right) that edits the clicked widget. -->
@@ -2260,6 +2273,7 @@ function widgetKindLabel(type: OverviewWidget['type']):
string {
font-style: italic;
}
+.ot__flash-row { display: flex; justify-content: flex-end; padding: 4px 0 0; }
.ot__flash { font-size: 11px; color: var(--sw-ok); }
.ot__dirty { font-size: 11px; color: var(--sw-warn); }
/* Source pill — per-state theme matched across all three editors
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 9a441c9..737e554 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -1191,5 +1191,27 @@
"No dashboard configured yet": "Noch kein Dashboard konfiguriert",
"{n} layer reporting services but no overview dashboard is set up.": "{n}
Ebene(n) melden Services, aber es ist kein Übersichts-Dashboard eingerichtet.",
"Ask your operations team to set up a dashboard for you.": "Bitte das
Operations-Team, ein Dashboard einzurichten.",
- "Routing…": "Weiterleitung…"
+ "Routing…": "Weiterleitung…",
+ "Pod": "Pod",
+ "Container": "Container",
+ "Interval": "Intervall",
+ "Include": "Einschließen",
+ "Exclude": "Ausschließen",
+ "Pause": "Pausieren",
+ "Live": "Live",
+ "lines": "Zeilen",
+ "updated": "aktualisiert",
+ "Select a container…": "Container auswählen…",
+ "Last 30s": "Letzte 30 s",
+ "Last 1m": "Letzte 1 Min",
+ "Last 5m": "Letzte 5 Min",
+ "{seconds}s ago": "vor {seconds} s",
+ "Select a pod…": "Pod auswählen…",
+ "Select a service first": "Zuerst einen Service auswählen",
+ "regex (e.g. .*error.*) + Enter": "Regex (z. B. .*error.*) + Enter",
+ "Logs unavailable:": "Logs nicht verfügbar:",
+ "— pick a currently-running pod, or check that on-demand pod logs are
enabled on OAP.": "— Wähle einen laufenden Pod oder prüfe, ob
On-Demand-Pod-Logs in OAP aktiviert sind.",
+ "Select a service to begin.": "Wähle einen Service, um zu beginnen.",
+ "Select a pod to list its containers.": "Wähle einen Pod, um seine Container
aufzulisten.",
+ "Select a container, then press Start to tail its logs.": "Wähle einen
Container und drücke Start, um seine Logs live zu verfolgen."
}
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 3b33c6b..9584dd9 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -1191,5 +1191,27 @@
"No dashboard configured yet": "No dashboard configured yet",
"{n} layer reporting services but no overview dashboard is set up.": "{n}
layer reporting services but no overview dashboard is set up.",
"Ask your operations team to set up a dashboard for you.": "Ask your
operations team to set up a dashboard for you.",
- "Routing…": "Routing…"
+ "Routing…": "Routing…",
+ "Pod": "Pod",
+ "Container": "Container",
+ "Interval": "Interval",
+ "Include": "Include",
+ "Exclude": "Exclude",
+ "Pause": "Pause",
+ "Live": "Live",
+ "lines": "lines",
+ "updated": "updated",
+ "Select a container…": "Select a container…",
+ "Last 30s": "Last 30s",
+ "Last 1m": "Last 1m",
+ "Last 5m": "Last 5m",
+ "{seconds}s ago": "{seconds}s ago",
+ "Select a pod…": "Select a pod…",
+ "Select a service first": "Select a service first",
+ "regex (e.g. .*error.*) + Enter": "regex (e.g. .*error.*) + Enter",
+ "Logs unavailable:": "Logs unavailable:",
+ "— pick a currently-running pod, or check that on-demand pod logs are
enabled on OAP.": "— pick a currently-running pod, or check that on-demand pod
logs are enabled on OAP.",
+ "Select a service to begin.": "Select a service to begin.",
+ "Select a pod to list its containers.": "Select a pod to list its
containers.",
+ "Select a container, then press Start to tail its logs.": "Select a
container, then press Start to tail its logs."
}
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 34acc6f..9d90d79 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -1191,5 +1191,27 @@
"No dashboard configured yet": "Aún no hay un panel configurado",
"{n} layer reporting services but no overview dashboard is set up.": "{n}
capa(s) están reportando servicios, pero no se ha configurado un panel
general.",
"Ask your operations team to set up a dashboard for you.": "Pide al equipo
de operaciones que configure un panel para ti.",
- "Routing…": "Redirigiendo…"
+ "Routing…": "Redirigiendo…",
+ "Pod": "Pod",
+ "Container": "Contenedor",
+ "Interval": "Intervalo",
+ "Include": "Incluir",
+ "Exclude": "Excluir",
+ "Pause": "Pausar",
+ "Live": "En vivo",
+ "lines": "líneas",
+ "updated": "actualizado",
+ "Select a container…": "Selecciona un contenedor…",
+ "Last 30s": "Últimos 30 s",
+ "Last 1m": "Últimos 1 min",
+ "Last 5m": "Últimos 5 min",
+ "{seconds}s ago": "hace {seconds} s",
+ "Select a pod…": "Selecciona un Pod…",
+ "Select a service first": "Selecciona primero un servicio",
+ "regex (e.g. .*error.*) + Enter": "regex (p. ej. .*error.*) + Enter",
+ "Logs unavailable:": "Logs no disponibles:",
+ "— pick a currently-running pod, or check that on-demand pod logs are
enabled on OAP.": "— elige un Pod en ejecución, o verifica que los logs de Pod
bajo demanda estén habilitados en OAP.",
+ "Select a service to begin.": "Selecciona un servicio para empezar.",
+ "Select a pod to list its containers.": "Selecciona un Pod para listar sus
contenedores.",
+ "Select a container, then press Start to tail its logs.": "Selecciona un
contenedor y pulsa Inicio para seguir sus logs en vivo."
}
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 6efaabe..0d64b90 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -1191,5 +1191,27 @@
"No dashboard configured yet": "Aucun tableau de bord configuré pour
l'instant",
"{n} layer reporting services but no overview dashboard is set up.": "{n}
couche(s) signalent des services, mais aucun tableau de bord d'ensemble n'est
configuré.",
"Ask your operations team to set up a dashboard for you.": "Demandez à votre
équipe d'exploitation de configurer un tableau de bord pour vous.",
- "Routing…": "Routage…"
+ "Routing…": "Routage…",
+ "Pod": "Pod",
+ "Container": "Conteneur",
+ "Interval": "Intervalle",
+ "Include": "Inclure",
+ "Exclude": "Exclure",
+ "Pause": "Pause",
+ "Live": "En direct",
+ "lines": "lignes",
+ "updated": "mis à jour",
+ "Select a container…": "Sélectionner un conteneur…",
+ "Last 30s": "30 dernières s",
+ "Last 1m": "1 dernière minute",
+ "Last 5m": "5 dernières minutes",
+ "{seconds}s ago": "il y a {seconds} s",
+ "Select a pod…": "Sélectionner un Pod…",
+ "Select a service first": "Sélectionnez d'abord un service",
+ "regex (e.g. .*error.*) + Enter": "regex (p. ex. .*error.*) + Enter",
+ "Logs unavailable:": "Logs indisponibles :",
+ "— pick a currently-running pod, or check that on-demand pod logs are
enabled on OAP.": "— choisissez un Pod en cours d'exécution, ou vérifiez que
les logs de Pod à la demande sont activés sur OAP.",
+ "Select a service to begin.": "Sélectionnez un service pour commencer.",
+ "Select a pod to list its containers.": "Sélectionnez un Pod pour lister ses
conteneurs.",
+ "Select a container, then press Start to tail its logs.": "Sélectionnez un
conteneur, puis appuyez sur Début pour suivre ses logs en direct."
}
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 2213ab5..4a2321e 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -1191,5 +1191,27 @@
"No dashboard configured yet": "まだダッシュボードが設定されていません",
"{n} layer reporting services but no overview dashboard is set up.": "{n}
個のレイヤーがサービスを報告していますが、概要ダッシュボードが設定されていません。",
"Ask your operations team to set up a dashboard for you.":
"運用チームにダッシュボードの設定を依頼してください。",
- "Routing…": "ルーティング中…"
+ "Routing…": "ルーティング中…",
+ "Pod": "Pod",
+ "Container": "コンテナ",
+ "Interval": "間隔",
+ "Include": "含む",
+ "Exclude": "除外",
+ "Pause": "一時停止",
+ "Live": "ライブ",
+ "lines": "行",
+ "updated": "更新",
+ "Select a container…": "コンテナを選択…",
+ "Last 30s": "直近 30 秒",
+ "Last 1m": "直近 1 分",
+ "Last 5m": "直近 5 分",
+ "{seconds}s ago": "{seconds} 秒前",
+ "Select a pod…": "Pod を選択…",
+ "Select a service first": "先にサービスを選択してください",
+ "regex (e.g. .*error.*) + Enter": "正規表現 (例: .*error.*) + Enter",
+ "Logs unavailable:": "ログを利用できません:",
+ "— pick a currently-running pod, or check that on-demand pod logs are
enabled on OAP.": "— 実行中の Pod を選択するか、オンデマンド Pod ログが OAP で有効になっているか確認してください。",
+ "Select a service to begin.": "サービスを選択して開始してください。",
+ "Select a pod to list its containers.": "Pod を選択してコンテナを一覧表示します。",
+ "Select a container, then press Start to tail its logs.":
"コンテナを選択し、「開始」を押してログをライブで追尾します。"
}
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index c11a8c7..6b077ed 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -1191,5 +1191,27 @@
"No dashboard configured yet": "아직 대시보드가 구성되지 않았습니다",
"{n} layer reporting services but no overview dashboard is set up.": "{n}개
레이어가 서비스를 보고하고 있지만 개요 대시보드가 설정되어 있지 않습니다.",
"Ask your operations team to set up a dashboard for you.": "운영 팀에 대시보드를 설정해
달라고 요청하세요.",
- "Routing…": "라우팅 중…"
+ "Routing…": "라우팅 중…",
+ "Pod": "Pod",
+ "Container": "컨테이너",
+ "Interval": "간격",
+ "Include": "포함",
+ "Exclude": "제외",
+ "Pause": "일시 중지",
+ "Live": "실시간",
+ "lines": "줄",
+ "updated": "업데이트됨",
+ "Select a container…": "컨테이너 선택…",
+ "Last 30s": "최근 30초",
+ "Last 1m": "최근 1분",
+ "Last 5m": "최근 5분",
+ "{seconds}s ago": "{seconds}초 전",
+ "Select a pod…": "Pod 선택…",
+ "Select a service first": "먼저 서비스를 선택하세요",
+ "regex (e.g. .*error.*) + Enter": "정규식 (예: .*error.*) + Enter",
+ "Logs unavailable:": "로그를 사용할 수 없음:",
+ "— pick a currently-running pod, or check that on-demand pod logs are
enabled on OAP.": "— 실행 중인 Pod를 선택하거나, OAP에서 온디맨드 Pod 로그가 활성화되어 있는지 확인하세요.",
+ "Select a service to begin.": "시작하려면 서비스를 선택하세요.",
+ "Select a pod to list its containers.": "컨테이너를 나열하려면 Pod를 선택하세요.",
+ "Select a container, then press Start to tail its logs.": "컨테이너를 선택한 다음 시작을
눌러 로그를 실시간으로 확인하세요."
}
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 3732315..90378e1 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -1191,5 +1191,27 @@
"No dashboard configured yet": "Ainda não há dashboard configurado",
"{n} layer reporting services but no overview dashboard is set up.": "{n}
camada(s) estão reportando serviços, mas nenhum dashboard de visão geral foi
configurado.",
"Ask your operations team to set up a dashboard for you.": "Peça à equipe de
operações para configurar um dashboard para você.",
- "Routing…": "Roteando…"
+ "Routing…": "Roteando…",
+ "Pod": "Pod",
+ "Container": "Contêiner",
+ "Interval": "Intervalo",
+ "Include": "Incluir",
+ "Exclude": "Excluir",
+ "Pause": "Pausar",
+ "Live": "Ao vivo",
+ "lines": "linhas",
+ "updated": "atualizado",
+ "Select a container…": "Selecione um contêiner…",
+ "Last 30s": "Últimos 30 s",
+ "Last 1m": "Últimos 1 min",
+ "Last 5m": "Últimos 5 min",
+ "{seconds}s ago": "há {seconds} s",
+ "Select a pod…": "Selecione um Pod…",
+ "Select a service first": "Selecione primeiro um serviço",
+ "regex (e.g. .*error.*) + Enter": "regex (ex.: .*error.*) + Enter",
+ "Logs unavailable:": "Logs indisponíveis:",
+ "— pick a currently-running pod, or check that on-demand pod logs are
enabled on OAP.": "— escolha um Pod em execução, ou verifique se os logs de Pod
sob demanda estão habilitados no OAP.",
+ "Select a service to begin.": "Selecione um serviço para começar.",
+ "Select a pod to list its containers.": "Selecione um Pod para listar seus
contêineres.",
+ "Select a container, then press Start to tail its logs.": "Selecione um
contêiner e pressione Início para acompanhar seus logs ao vivo."
}
diff --git a/apps/ui/src/i18n/locales/zh-CN.json
b/apps/ui/src/i18n/locales/zh-CN.json
index e6c7aab..e7bb9fe 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -1191,5 +1191,27 @@
"No dashboard configured yet": "尚未配置仪表板",
"{n} layer reporting services but no overview dashboard is set up.": "{n}
个分层已上报服务,但尚未配置概览仪表板。",
"Ask your operations team to set up a dashboard for you.": "请运维团队为你配置仪表板。",
- "Routing…": "路由跳转中…"
+ "Routing…": "路由跳转中…",
+ "Pod": "Pod",
+ "Container": "容器",
+ "Interval": "间隔",
+ "Include": "包含",
+ "Exclude": "排除",
+ "Pause": "暂停",
+ "Live": "实时",
+ "lines": "行",
+ "updated": "更新于",
+ "Select a container…": "选择容器…",
+ "Last 30s": "最近 30 秒",
+ "Last 1m": "最近 1 分钟",
+ "Last 5m": "最近 5 分钟",
+ "{seconds}s ago": "{seconds} 秒前",
+ "Select a pod…": "选择 Pod…",
+ "Select a service first": "请先选择服务",
+ "regex (e.g. .*error.*) + Enter": "正则 (例如 .*error.*) + Enter",
+ "Logs unavailable:": "日志不可用:",
+ "— pick a currently-running pod, or check that on-demand pod logs are
enabled on OAP.": "— 请选择一个正在运行的 Pod,或确认 OAP 已启用按需 Pod 日志。",
+ "Select a service to begin.": "请选择服务以开始。",
+ "Select a pod to list its containers.": "选择一个 Pod 以列出其容器。",
+ "Select a container, then press Start to tail its logs.":
"选择一个容器,然后点击“开始”以实时查看其日志。"
}
diff --git a/apps/ui/src/layer/pod-logs/LayerPodLogsView.vue
b/apps/ui/src/layer/pod-logs/LayerPodLogsView.vue
new file mode 100644
index 0000000..ab87f9d
--- /dev/null
+++ b/apps/ui/src/layer/pod-logs/LayerPodLogsView.vue
@@ -0,0 +1,391 @@
+<!--
+ 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.
+-->
+<!--
+ Pod Logs — live-tail a Kubernetes pod's container logs, fetched on
+ demand from the K8s API through OAP and never persisted. The page is
+ instance-pinned: pick a pod (instance) in the header, pick a
+ container, tap Start, and the trailing window streams into a read-only
+ Monaco pane, polled on the chosen interval until paused.
+-->
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useRoute } from 'vue-router';
+import * as monaco from 'monaco-editor';
+import { useSelectedService } from '@/layer/useSelectedService';
+import { useSelectedInstance } from '@/layer/useSelectedInstance';
+import { useLayerInstances } from '@/layer/useLayerInstances';
+import { useLayerPodLogs, WINDOW_OPTS, INTERVAL_OPTS } from
'./useLayerPodLogs';
+import { setupMonaco, RR_THEME_NAME } from '@/monaco/setup';
+
+const { t } = useI18n({ useScope: 'global' });
+const route = useRoute();
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+
+// Service comes from the shell header picker; feed its id straight to
+// the instances list (the BFF route accepts id OR name).
+const { selectedId } = useSelectedService();
+const { instances: instanceList } = useLayerInstances(layerKey, selectedId);
+
+// Pod (instance) is the pinned entity for this page. Resolve the picked
+// name to its OAP instance id for the on-demand queries.
+const { selectedInstance, setSelectedInstance } = useSelectedInstance();
+const instanceId = computed<string | null>(() => {
+ if (!selectedInstance.value) return null;
+ return instanceList.value.find((i) => i.name === selectedInstance.value)?.id
?? null;
+});
+// Clear the pod when the service changes — a pod id from another
+// service would never resolve.
+watch(selectedId, (next, prev) => {
+ if (prev !== undefined && next !== prev && selectedInstance.value)
setSelectedInstance(null);
+});
+
+const {
+ containers,
+ selectedContainer,
+ windowSeconds,
+ intervalSeconds,
+ keywords,
+ excludes,
+ lines,
+ errorReason,
+ loadingContainers,
+ tailing,
+ lastUpdatedAt,
+ toggleTail,
+} = useLayerPodLogs(layerKey, instanceId);
+
+// ── Keyword chips ────────────────────────────────────────────────────
+const keywordInput = ref('');
+const excludeInput = ref('');
+function addKeyword(): void {
+ const v = keywordInput.value.trim();
+ if (v && !keywords.value.includes(v)) keywords.value = [...keywords.value,
v];
+ keywordInput.value = '';
+}
+function removeKeyword(i: number): void {
+ keywords.value = keywords.value.filter((_, idx) => idx !== i);
+}
+function addExclude(): void {
+ const v = excludeInput.value.trim();
+ if (v && !excludes.value.includes(v)) excludes.value = [...excludes.value,
v];
+ excludeInput.value = '';
+}
+function removeExclude(i: number): void {
+ excludes.value = excludes.value.filter((_, idx) => idx !== i);
+}
+
+// ── "updated Xs ago" ticker ──────────────────────────────────────────
+const nowTick = ref(Date.now());
+let agoTimer: ReturnType<typeof setInterval> | null = null;
+const updatedAgo = computed<string | null>(() => {
+ if (!lastUpdatedAt.value) return null;
+ const s = Math.max(0, Math.round((nowTick.value - lastUpdatedAt.value) /
1000));
+ return s < 1 ? t('just now') : t('{seconds}s ago', { seconds: s });
+});
+
+// ── Monaco read-only log pane ────────────────────────────────────────
+const host = ref<HTMLDivElement | null>(null);
+let editor: monaco.editor.IStandaloneCodeEditor | null = null;
+let model: monaco.editor.ITextModel | null = null;
+
+function renderLines(): void {
+ if (!model) return;
+ const text = lines.value.map((l) => l.content).join('\n');
+ model.setValue(text);
+ // Tail behaviour — keep the newest line in view after each refresh.
+ if (editor && lines.value.length > 0) {
+ const last = model.getLineCount();
+ editor.revealLine(last);
+ editor.setPosition({ lineNumber: last, column: 1 });
+ }
+}
+
+onMounted(() => {
+ setupMonaco();
+ if (host.value) {
+ model = monaco.editor.createModel('', 'plaintext');
+ editor = monaco.editor.create(host.value, {
+ model,
+ theme: RR_THEME_NAME,
+ readOnly: true,
+ automaticLayout: true,
+ minimap: { enabled: false },
+ wordWrap: 'on',
+ scrollBeyondLastLine: false,
+ lineNumbers: 'on',
+ fontSize: 12,
+ fontFamily: "'JetBrains Mono', ui-monospace, monospace",
+ renderLineHighlight: 'none',
+ });
+ renderLines();
+ }
+ agoTimer = setInterval(() => { nowTick.value = Date.now(); }, 1000);
+});
+onBeforeUnmount(() => {
+ editor?.dispose();
+ model?.dispose();
+ if (agoTimer !== null) clearInterval(agoTimer);
+});
+watch(lines, renderLines);
+
+const hasService = computed(() => !!selectedId.value);
+const hasInstance = computed(() => !!instanceId.value);
+</script>
+
+<template>
+ <div class="pod-logs">
+ <header class="bar">
+ <div class="ctrls">
+ <label class="ctrl">
+ <span class="lbl">{{ t('Pod') }}</span>
+ <select
+ class="inp"
+ :value="selectedInstance ?? ''"
+ :disabled="!hasService"
+ @change="setSelectedInstance(($event.target as
HTMLSelectElement).value || null)"
+ >
+ <option value="">{{ hasService ? t('Select a pod…') : t('Select a
service first') }}</option>
+ <option v-for="i in instanceList" :key="i.id" :value="i.name">{{
i.name }}</option>
+ </select>
+ </label>
+
+ <label class="ctrl">
+ <span class="lbl">{{ t('Container') }}</span>
+ <select
+ class="inp"
+ :value="selectedContainer ?? ''"
+ :disabled="!hasInstance || containers.length === 0"
+ @change="selectedContainer = ($event.target as
HTMLSelectElement).value || null"
+ >
+ <option value="">{{ loadingContainers ? t('Loading…') : t('Select
a container…') }}</option>
+ <option v-for="c in containers" :key="c" :value="c">{{ c
}}</option>
+ </select>
+ </label>
+
+ <label class="ctrl">
+ <span class="lbl">{{ t('Window') }}</span>
+ <select class="inp" v-model.number="windowSeconds">
+ <option v-for="o in WINDOW_OPTS" :key="o.value"
:value="o.value">{{ t(o.label) }}</option>
+ </select>
+ </label>
+
+ <label class="ctrl">
+ <span class="lbl">{{ t('Interval') }}</span>
+ <select class="inp" v-model.number="intervalSeconds">
+ <option v-for="o in INTERVAL_OPTS" :key="o.value"
:value="o.value">{{ o.label }}</option>
+ </select>
+ </label>
+
+ <button
+ class="tail-btn"
+ :class="{ on: tailing }"
+ :disabled="!selectedContainer"
+ @click="toggleTail"
+ >
+ <span class="dot" :class="{ live: tailing }" />
+ {{ tailing ? t('Pause') : t('Start') }}
+ </button>
+ </div>
+
+ <div class="filters">
+ <div class="kw">
+ <span class="lbl">{{ t('Include') }}</span>
+ <span v-for="(k, i) in keywords" :key="`kw${i}`" class="chip">
+ {{ k }}<button class="x" @click="removeKeyword(i)">×</button>
+ </span>
+ <input
+ class="kw-inp"
+ v-model="keywordInput"
+ :placeholder="t('regex (e.g. .*error.*) + Enter')"
+ @keydown.enter.prevent="addKeyword"
+ />
+ </div>
+ <div class="kw">
+ <span class="lbl">{{ t('Exclude') }}</span>
+ <span v-for="(k, i) in excludes" :key="`ex${i}`" class="chip ex">
+ {{ k }}<button class="x" @click="removeExclude(i)">×</button>
+ </span>
+ <input
+ class="kw-inp"
+ v-model="excludeInput"
+ :placeholder="t('regex (e.g. .*error.*) + Enter')"
+ @keydown.enter.prevent="addExclude"
+ />
+ </div>
+ </div>
+ </header>
+
+ <div v-if="errorReason" class="banner">
+ <strong>{{ t('Logs unavailable:') }}</strong> {{ errorReason }}
+ <span class="hint">{{ t('— pick a currently-running pod, or check that
on-demand pod logs are enabled on OAP.') }}</span>
+ </div>
+
+ <div v-if="selectedContainer && hasInstance" class="pane-head">
+ <span class="ph-name">{{ selectedContainer }}</span>
+ <span class="ph-count">{{ lines.length }} {{ t('lines') }}</span>
+ <span class="ph-right">
+ <span class="dot" :class="{ live: tailing }" />
+ <span class="ph-state">{{ tailing ? t('Live') : t('Paused') }}</span>
+ <span v-if="updatedAgo" class="ph-ago">· {{ t('updated') }} {{
updatedAgo }}</span>
+ </span>
+ </div>
+
+ <div class="pane-wrap">
+ <div
+ v-if="!hasService || !hasInstance || !selectedContainer"
+ class="empty"
+ >
+ <template v-if="!hasService">{{ t('Select a service to begin.')
}}</template>
+ <template v-else-if="!hasInstance">{{ t('Select a pod to list its
containers.') }}</template>
+ <template v-else>{{ t('Select a container, then press Start to tail
its logs.') }}</template>
+ </div>
+ <div ref="host" class="pane" :class="{ hidden: !hasService ||
!hasInstance || !selectedContainer }" />
+ </div>
+ </div>
+</template>
+
+<style scoped>
+.pod-logs {
+ display: flex;
+ flex-direction: column;
+ background: var(--sw-bg-0);
+}
+.bar {
+ flex: 0 0 auto;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--sw-line);
+ background: var(--sw-bg-1);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.ctrls { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; }
+.ctrl { display: flex; flex-direction: column; gap: 3px; }
+.lbl { font-size: 11px; color: var(--sw-fg-3); }
+.inp {
+ height: 26px;
+ padding: 0 24px 0 8px;
+ background: var(--sw-bg-2)
+ url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 10 6' width='10' height='6'><path d='M1 1l4 4 4-4'
stroke='%23818a9c' stroke-width='1.4' fill='none'
stroke-linecap='round'/></svg>")
+ right 8px center / 9px no-repeat;
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ color: var(--sw-fg-0);
+ font-size: 12px;
+ min-width: 150px;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ cursor: pointer;
+}
+.inp:hover:not(:disabled) { border-color: var(--sw-line); }
+.inp:focus { outline: none; border-color: var(--sw-accent); }
+.inp:disabled { opacity: 0.5; cursor: not-allowed; background-image: none; }
+.tail-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 26px;
+ padding: 0 14px;
+ border: 1px solid var(--sw-accent);
+ border-radius: 4px;
+ background: var(--sw-accent);
+ color: #1a1106;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+}
+.tail-btn.on { background: transparent; color: var(--sw-accent); }
+.tail-btn:disabled { opacity: 0.45; cursor: default; border-color:
var(--sw-line-2); background: var(--sw-bg-2); color: var(--sw-fg-3); }
+.dot { width: 7px; height: 7px; border-radius: 50%; background:
var(--sw-fg-3); }
+.dot.live { background: #4ade80; animation: pulse 1.2s infinite ease-in-out; }
+@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
+.tail-btn .dot { background: currentColor; }
+.tail-btn .dot.live { background: #4ade80; }
+
+.filters { display: flex; gap: 18px; flex-wrap: wrap; }
+.kw { flex: 1 1 340px; display: flex; align-items: center; gap: 5px;
flex-wrap: wrap; }
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ padding: 1px 4px 1px 7px;
+ border-radius: 10px;
+ background: rgba(125, 211, 252, 0.16);
+ color: #7dd3fc;
+ font-size: 11px;
+}
+.chip.ex { background: rgba(248, 113, 113, 0.16); color: #f87171; }
+.chip .x { border: none; background: transparent; color: inherit; cursor:
pointer; font-size: 13px; line-height: 1; padding: 0 2px; }
+.kw-inp {
+ height: 22px;
+ flex: 1 1 200px;
+ min-width: 200px;
+ padding: 0 8px;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ color: var(--sw-fg-0);
+ font-size: 11.5px;
+}
+
+.banner {
+ flex: 0 0 auto;
+ margin: 8px 14px 0;
+ padding: 7px 10px;
+ border: 1px solid rgba(240, 160, 75, 0.5);
+ background: rgba(240, 160, 75, 0.1);
+ border-radius: 4px;
+ font-size: 11.5px;
+ color: #f0a04b;
+}
+.banner .hint { color: var(--sw-fg-3); }
+
+.pane-head {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 10px 14px 0;
+ padding: 5px 10px;
+ border: 1px solid var(--sw-line);
+ border-bottom: 0;
+ border-radius: 4px 4px 0 0;
+ background: var(--sw-bg-1);
+ font-size: 11px;
+}
+.ph-name { font-family: 'JetBrains Mono', ui-monospace, monospace; color:
var(--sw-fg-0); font-weight: 600; }
+.ph-count { color: var(--sw-fg-3); font-variant-numeric: tabular-nums; }
+.ph-right { margin-left: auto; display: inline-flex; align-items: center; gap:
6px; }
+.ph-state { color: var(--sw-fg-2); }
+.ph-ago { color: var(--sw-fg-3); font-variant-numeric: tabular-nums; }
+
+/* Fixed height, not flex: the shell's tab-body is content-height, so flex:1
collapses the absolute Monaco pane. */
+.pane-wrap { position: relative; height: min(68vh, 720px); min-height: 360px;
margin: 0 14px 14px; }
+.pane { position: absolute; inset: 0; border: 1px solid var(--sw-line);
border-radius: 0 0 4px 4px; overflow: hidden; }
+.pane.hidden { visibility: hidden; }
+.empty {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ color: var(--sw-fg-3);
+ font-size: 12.5px;
+ z-index: 1;
+}
+</style>
diff --git a/apps/ui/src/layer/pod-logs/useLayerPodLogs.ts
b/apps/ui/src/layer/pod-logs/useLayerPodLogs.ts
new file mode 100644
index 0000000..f1541fd
--- /dev/null
+++ b/apps/ui/src/layer/pod-logs/useLayerPodLogs.ts
@@ -0,0 +1,224 @@
+/*
+ * 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.
+ */
+
+/**
+ * Live-tail engine for on-demand Kubernetes pod logs.
+ *
+ * The page is INSTANCE-pinned: the operator picks a pod, then a
+ * container, then taps Start. Each poll fetches the trailing
+ * `windowSeconds` of the container's logs and replaces the buffer; the
+ * poll repeats every `intervalSeconds` until the operator pauses. This
+ * is a true tail — there is no stored history to page through, the logs
+ * are streamed live from the K8s API through OAP and never persisted.
+ *
+ * Why not vue-query: this is an imperative timer loop, not a
+ * declarative cache. The composable owns its own interval and tears it
+ * down on unmount + whenever the pinned inputs (instance / container)
+ * change, so a stale loop never keeps hitting OAP after the operator
+ * navigates away or re-targets.
+ *
+ * The "no pod can be found" gotcha: a stale instance id (a terminated
+ * pod) makes OAP return `errorReason` instead of containers/logs. We
+ * surface it verbatim so the view can tell the operator to re-pick a
+ * live pod rather than showing a silent empty pane.
+ */
+
+import { onUnmounted, readonly, ref, watch, type Ref } from 'vue';
+import { bff } from '@/api/client';
+import type { PodLogLine } from '@/api/scopes/log';
+
+/** Tail look-back window per poll (seconds). */
+export const WINDOW_OPTS = [
+ { label: 'Last 30s', value: 30 },
+ { label: 'Last 1m', value: 60 },
+ { label: 'Last 5m', value: 300 },
+ { label: 'Last 15m', value: 900 },
+ { label: 'Last 30m', value: 1800 },
+] as const;
+
+/** Poll cadence while tailing (seconds). */
+export const INTERVAL_OPTS = [
+ { label: '2s', value: 2 },
+ { label: '5s', value: 5 },
+ { label: '10s', value: 10 },
+ { label: '30s', value: 30 },
+] as const;
+
+export function useLayerPodLogs(layerKey: Ref<string>, instanceId: Ref<string
| null>) {
+ const containers = ref<string[]>([]);
+ const selectedContainer = ref<string | null>(null);
+ const windowSeconds = ref<number>(60);
+ const intervalSeconds = ref<number>(5);
+ const keywords = ref<string[]>([]);
+ const excludes = ref<string[]>([]);
+
+ const lines = ref<PodLogLine[]>([]);
+ const errorReason = ref<string | null>(null);
+ const loadingContainers = ref(false);
+ const fetching = ref(false);
+ const tailing = ref(false);
+ const lastUpdatedAt = ref<number | null>(null);
+
+ let timer: ReturnType<typeof setInterval> | null = null;
+
+ function stopTail(): void {
+ tailing.value = false;
+ if (timer !== null) {
+ clearInterval(timer);
+ timer = null;
+ }
+ }
+
+ /** Reset everything tied to the pinned pod — called when the instance
+ * changes so a tail never bleeds across pods. */
+ function resetForInstance(): void {
+ stopTail();
+ containers.value = [];
+ selectedContainer.value = null;
+ lines.value = [];
+ errorReason.value = null;
+ lastUpdatedAt.value = null;
+ }
+
+ async function loadContainers(): Promise<void> {
+ const id = instanceId.value;
+ if (!layerKey.value || !id) {
+ resetForInstance();
+ return;
+ }
+ loadingContainers.value = true;
+ errorReason.value = null;
+ try {
+ const r = await bff.log.podContainers(layerKey.value, id);
+ if (r.errorReason) {
+ containers.value = [];
+ selectedContainer.value = null;
+ errorReason.value = r.errorReason;
+ return;
+ }
+ if (!r.reachable) {
+ containers.value = [];
+ errorReason.value = r.error ?? 'OAP unreachable';
+ return;
+ }
+ containers.value = r.containers;
+ // Auto-pick the first container — the operator almost always wants
+ // the app container, and it's listed first by OAP. They can switch.
+ selectedContainer.value = r.containers[0] ?? null;
+ } catch (err) {
+ containers.value = [];
+ errorReason.value = err instanceof Error ? err.message : String(err);
+ } finally {
+ loadingContainers.value = false;
+ }
+ }
+
+ async function fetchOnce(): Promise<void> {
+ const id = instanceId.value;
+ const container = selectedContainer.value;
+ if (!layerKey.value || !id || !container) return;
+ fetching.value = true;
+ try {
+ const r = await bff.log.podLogs(layerKey.value, {
+ serviceInstanceId: id,
+ container,
+ windowSeconds: windowSeconds.value,
+ keywordsOfContent: keywords.value.length ? keywords.value : undefined,
+ excludingKeywordsOfContent: excludes.value.length ? excludes.value :
undefined,
+ });
+ if (r.errorReason) {
+ // A pod that vanished mid-tail (rollout / scale-down) — stop the
+ // loop and surface the reason rather than spinning on errors.
+ errorReason.value = r.errorReason;
+ stopTail();
+ return;
+ }
+ if (!r.reachable) {
+ errorReason.value = r.error ?? 'OAP unreachable';
+ stopTail();
+ return;
+ }
+ errorReason.value = null;
+ lines.value = r.lines;
+ lastUpdatedAt.value = Date.now();
+ } catch (err) {
+ errorReason.value = err instanceof Error ? err.message : String(err);
+ stopTail();
+ } finally {
+ fetching.value = false;
+ }
+ }
+
+ function startTail(): void {
+ if (!selectedContainer.value) return;
+ stopTail();
+ tailing.value = true;
+ void fetchOnce();
+ timer = setInterval(() => void fetchOnce(), intervalSeconds.value * 1000);
+ }
+
+ function toggleTail(): void {
+ if (tailing.value) stopTail();
+ else startTail();
+ }
+
+ // Re-fetch containers whenever the pinned pod changes; tear down any
+ // running tail first so it can't keep hitting the old pod.
+ watch(
+ [layerKey, instanceId],
+ () => {
+ resetForInstance();
+ void loadContainers();
+ },
+ { immediate: true },
+ );
+
+ // Changing container / window / interval / filters while tailing
+ // restarts the loop so OAP re-runs the query with the new condition;
+ // while paused it just clears the now-stale buffer for the next Start.
+ watch([selectedContainer, windowSeconds, intervalSeconds], () => {
+ if (tailing.value) startTail();
+ });
+ watch([keywords, excludes], () => {
+ if (tailing.value) startTail();
+ }, { deep: true });
+
+ onUnmounted(stopTail);
+
+ return {
+ // pinned inputs
+ containers: readonly(containers),
+ selectedContainer,
+ windowSeconds,
+ intervalSeconds,
+ keywords,
+ excludes,
+ // state
+ lines: readonly(lines),
+ errorReason: readonly(errorReason),
+ loadingContainers: readonly(loadingContainers),
+ fetching: readonly(fetching),
+ tailing: readonly(tailing),
+ lastUpdatedAt: readonly(lastUpdatedAt),
+ // actions
+ loadContainers,
+ fetchOnce,
+ startTail,
+ stopTail,
+ toggleTail,
+ };
+}
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index 4eadf05..8bddc7f 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -571,6 +571,14 @@ watch(
>
<Icon name="log" /><span>Logs</span>
</RouterLink>
+ <RouterLink
+ v-if="L.caps.podLogs"
+ :to="`/layer/${L.key}/pod-logs`"
+ class="sw-nav-item"
+ :class="{ 'is-active': isActive(`/layer/${L.key}/pod-logs`)
}"
+ >
+ <Icon name="log" /><span>Pod Logs</span>
+ </RouterLink>
<RouterLink
v-if="L.caps.traceProfiling"
:to="`/layer/${L.key}/trace-profiling`"
@@ -710,6 +718,14 @@ watch(
>
<Icon name="log" /><span>Logs</span>
</RouterLink>
+ <RouterLink
+ v-if="E.layer.caps.podLogs"
+ :to="`/layer/${E.layer.key}/pod-logs`"
+ class="sw-nav-item"
+ :class="{ 'is-active': isActive(`/layer/${E.layer.key}/pod-logs`)
}"
+ >
+ <Icon name="log" /><span>Pod Logs</span>
+ </RouterLink>
<RouterLink
v-if="E.layer.caps.traceProfiling"
:to="`/layer/${E.layer.key}/trace-profiling`"
diff --git a/apps/ui/src/shell/AppTopbar.vue b/apps/ui/src/shell/AppTopbar.vue
index 9b5a145..0ffff79 100644
--- a/apps/ui/src/shell/AppTopbar.vue
+++ b/apps/ui/src/shell/AppTopbar.vue
@@ -204,6 +204,10 @@ const TIME_RANGE_OPT_OUT = [
// operator is mid-investigation. Block the global picker + pause the
// auto-refresher whenever the operator is on a Logs tab.
/^\/layer\/[^/]+\/logs$/,
+ // Pod Logs is a live tail driven by its own interval poll — the
+ // global ticker would double-fire and the page has no rolling window
+ // to refresh. Pause it while on the Pod Logs tab.
+ /^\/layer\/[^/]+\/pod-logs$/,
// Alarms is a triage view — auto-refresh shifts the visible window
// out from under any selection / brush the operator is making, and
// we already chunk the traffic backfill ourselves with explicit
diff --git a/apps/ui/src/shell/TemplateConflictPrompt.vue
b/apps/ui/src/shell/TemplateConflictPrompt.vue
index 67964d6..ff11cb9 100644
--- a/apps/ui/src/shell/TemplateConflictPrompt.vue
+++ b/apps/ui/src/shell/TemplateConflictPrompt.vue
@@ -26,7 +26,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
-import { useRouter } from 'vue-router';
+import { useRoute, useRouter } from 'vue-router';
import Modal from '@/features/operate/_shared/Modal.vue';
import { useLayers } from '@/shell/useLayers';
import { useConfigBundle } from '@/controls/configBundle';
@@ -36,6 +36,18 @@ import { useAuthStore } from '@/state/auth';
const { t } = useI18n({ useScope: 'global' });
const router = useRouter();
+const route = useRoute();
+
+// Suppressed on the template-admin editors — the draft state + push
+// button are already in-context there, so the nudge is redundant.
+const onTemplateEditor = computed<boolean>(() => {
+ const p = route.path;
+ return (
+ p.startsWith('/admin/layer-dashboards') ||
+ p.startsWith('/admin/overview-templates') ||
+ p.startsWith('/admin/translations')
+ );
+});
const previewMode = usePreviewMode();
const { layers } = useLayers();
const { bundle } = useConfigBundle();
@@ -85,7 +97,9 @@ const dismissed = ref<boolean>(
);
// Not while previewing — a preview tab shows the dedicated preview banner
// instead of the editor's unpublished-edits reminder.
-const open = computed(() => !previewMode.value && !dismissed.value &&
draftItems.value.length > 0);
+const open = computed(
+ () => !previewMode.value && !onTemplateEditor.value && !dismissed.value &&
draftItems.value.length > 0,
+);
function dismiss(): void {
dismissed.value = true;
diff --git a/apps/ui/src/shell/layerFromTemplate.ts
b/apps/ui/src/shell/layerFromTemplate.ts
index 53f0d6a..f082ddf 100644
--- a/apps/ui/src/shell/layerFromTemplate.ts
+++ b/apps/ui/src/shell/layerFromTemplate.ts
@@ -52,6 +52,7 @@ export function componentsToCaps(components: Record<string,
boolean | undefined>
endpointDependency: !!c.endpointDependency,
traces: !!c.traces,
logs: !!c.logs,
+ podLogs: !!c.podLogs,
traceProfiling: !!c.traceProfiling,
ebpfProfiling: !!c.ebpfProfiling,
asyncProfiling: !!c.asyncProfiling,
diff --git a/apps/ui/src/shell/router/index.ts
b/apps/ui/src/shell/router/index.ts
index 96c71e3..72b27a5 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -73,6 +73,9 @@ function layerRoute(): RouteRecordRaw {
// this path regardless of source.
{ path: 'zipkin-trace', component: () =>
import('@/layer/traces/LayerTracesEntry.vue') },
{ path: 'logs', component: () =>
import('@/layer/logs/LayerLogsView.vue') },
+ // On-demand pod logs (live tail). Instance-pinned; only K8s-
+ // deployed layers (caps.podLogs) surface the tab in the sidebar.
+ { path: 'pod-logs', component: () =>
import('@/layer/pod-logs/LayerPodLogsView.vue') },
{ path: 'trace-profiling', component: () =>
import('@/layer/profiling/LayerTraceProfilingView.vue') },
{ path: 'ebpf-profiling', component: () =>
import('@/layer/profiling/LayerEBPFProfilingView.vue') },
{ path: 'async-profiling', component: () =>
import('@/layer/profiling/LayerAsyncProfilingView.vue') },
diff --git a/apps/ui/src/shell/useLayers.ts b/apps/ui/src/shell/useLayers.ts
index e1d3efe..080d3f7 100644
--- a/apps/ui/src/shell/useLayers.ts
+++ b/apps/ui/src/shell/useLayers.ts
@@ -155,6 +155,7 @@ export function firstLayerTab(L: LayerDef | undefined):
string {
if (L.caps?.endpointDependency) return 'dependency';
if (L.caps?.traces) return 'trace';
if (L.caps?.logs) return 'logs';
+ if (L.caps?.podLogs) return 'pod-logs';
if (L.caps?.traceProfiling) return 'trace-profiling';
if (L.caps?.ebpfProfiling) return 'ebpf-profiling';
if (L.caps?.networkProfiling) return 'network-profiling';
diff --git a/packages/api-client/src/menu.ts b/packages/api-client/src/menu.ts
index 97759dc..fd59f65 100644
--- a/packages/api-client/src/menu.ts
+++ b/packages/api-client/src/menu.ts
@@ -58,6 +58,10 @@ export interface LayerCaps {
networkProfiling?: boolean;
/** Go pprof integration. */
pprofProfiling?: boolean;
+ /** On-demand Kubernetes pod logs — live-tail a pod's container logs
+ * fetched on demand from the K8s API (never persisted). Gates the
+ * per-layer "Pod Logs" tab; only K8s-deployed layers set it. */
+ podLogs?: boolean;
events?: boolean;
/** Bundle a dedicated square tile per layer on the Overview strip,
* showing live service count. When on, regular tiles drop the