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

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

commit d7367b58a317c80c069daa43d6814cc18cc975ba
Author: Wu Sheng <[email protected]>
AuthorDate: Thu May 28 17:16:22 2026 +0800

    feat(pod-logs): on-demand Kubernetes pod-log live-tail tab
    
    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

Reply via email to