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
