)]}'
{
  "log": [
    {
      "commit": "53c1f63b635bc8efbaec0e4bc28ec9539e526328",
      "tree": "0a1e0367aa7d157a86019670817ae4d6ac460120",
      "parents": [
        "0ce1f8d6a8484b127abf83897b0aa4f5f567f363"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Sep 17 09:40:25 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Sep 17 09:40:25 2026 +0800"
      },
      "message": "Tell a compaction apart from a history that merely grew (#166)\n\n* Tell a compaction apart from a history that merely grew\n\nA message of one text block is sent as a list while it is the newest, and as a\nplain string once the cache marker has left it. The Sessionizer records that,\nbecause it is what Claude Code was measured to write. The prompt panel compared\nthe two spellings as written, so a message stopped matching the moment it\nstopped being the newest: the shared prefix ended one message early, and the\npanel reported a rewritten history.\n\nIt said so on 60 of the 62 growth steps of one real 94-call conversation, where\ntwo were compactions and the rest had merely grown. The warning that exists to\ncatch a rewritten history was firing on almost every call, which is the same as\nnot having it.\n\nThe comparison now reads a one-block text content and its plain string as one\nmessage, as it already reads a message that only gained or lost the cache\nmarker. A message of several blocks, or of one block that is not text, never\ntakes the short spelling and is left alone.\n\nWith the comparison honest, the conclusion is worth drawing. A compaction\nreplaces the context with a summary and starts the list again, so it is shorter\nas well as different, and the panel now says which happened: the context was\nreplaced here and the list went from so many messages to so many, or, when the\nhistory changed without getting shorter, a warning, which is the rare case and\nnow the only one that raises one.\n\nNone of the three was covered. The delta tests asserted the shared count and the\nsettings, and left the rewritten flag, the compaction and the empty delta\nuntested, which is why this held. All three have tests now.\n\nThe two projects documented this together and both said only that the cache\nmarker moves, which is the sentence that leads to the comparison this fixes.\nBoth now describe the second spelling as well, and what a compaction does to\nthe list.\n\n* Carry the two new prompt strings into every locale\n\nThe catalogs are keyed by the English text, so replacing one string and adding\nanother leaves the old key orphaned in all eight and the new ones missing from\nall eight. Translated rather than copied: a reader of the German or Japanese UI\nis told which of the two happened, as an English reader is.\n\n* Guard the growth case where the spelling actually changes\n\nThe first render guard pointed at the fixture\u0027s second call, where no message\nhad yet stopped being the newest, so it passed with the fix and without it. It\nnow points at the first call whose history holds a message that has changed\nspelling, which is the transition that regressed: the shared prefix ended one\nmessage early and an ordinary growth step was reported as a rewrite.\n\nBoth guards were checked by reverting the fix and watching them fail."
    },
    {
      "commit": "0ce1f8d6a8484b127abf83897b0aa4f5f567f363",
      "tree": "bb12eec66b055d9cd0a6434ecf37a3144d39c488",
      "parents": [
        "0f9ea4082b18d12e93b712c559b2ffe14a556f13"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Wed Sep 16 19:50:40 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Wed Sep 16 19:50:40 2026 +0800"
      },
      "message": "feat(ai-conversation): show the prompt a model call sent and the answer it got (#165)"
    },
    {
      "commit": "0f9ea4082b18d12e93b712c559b2ffe14a556f13",
      "tree": "16396b221a9b1a162ffcf886a3a286ee1f26ae62",
      "parents": [
        "564945b7df6f15845748eaf2162e4a859d11e1e5"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Mon Sep 14 22:35:34 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Sep 14 22:35:34 2026 +0800"
      },
      "message": "feat(layer): show the Envoy AI Gateway layer as Agent Router (#164)\n\nEnvoy AI Gateway joined the Agentic AI Foundation as Agent Router, with the\nsame code, APIs and signals. The bundled ENVOY_AI_GATEWAY template now names\nthe layer \"Agent Router\" and its services \"Agent Routers\" in every locale.\n\nThe layer key, the meter_envoy_ai_gw_* metrics, the widgets and the SkyWalking\nsetup-guide link are unchanged; those names belong to OAP. The dashboard page\nnotes the former name, and the changelog tells operators whose OAP already\nstores the template how to adopt the new name."
    },
    {
      "commit": "564945b7df6f15845748eaf2162e4a859d11e1e5",
      "tree": "57287a74549b79dc0a8c9131c587bd23e2e0640b",
      "parents": [
        "4c737bdc658bea732515f6394995f27ca3f8b965"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Mon Sep 14 15:13:35 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Sep 14 15:13:35 2026 +0800"
      },
      "message": "fix(sidebar): restore group folding and align layer navigation (#162)"
    },
    {
      "commit": "4c737bdc658bea732515f6394995f27ca3f8b965",
      "tree": "3e6725d6b7b8172ae940bc3f6d81cfa51b0b3b0a",
      "parents": [
        "b292a44a1f2664373ff95afc0d6fd46ae2fe2790"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Mon Sep 14 13:52:40 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Sep 14 13:52:40 2026 +0800"
      },
      "message": "fix(ai-conversation): forward the selected cold stage to details (#161)"
    },
    {
      "commit": "b292a44a1f2664373ff95afc0d6fd46ae2fe2790",
      "tree": "90be8781d77a8ea53ddd84068e13e33d20815b7b",
      "parents": [
        "03666d578a86cdb0ec39fbd8bbfd4cbd04fd91c4"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sat Sep 12 08:42:59 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sat Sep 12 08:42:59 2026 +0800"
      },
      "message": "fix(conversation-view): put each edit side\u0027s copy button beside its own name (#160)\n\nAn edit\u0027s header drew one \"old_string → new_string\" label and then both\ncopy buttons after it, so the button that copies old_string sat next to\nnew_string and only its tooltip told the two apart. Each side is now its\nname followed by its own copy button, with the arrow between the sides:\n\"old_string [copy] → new_string [copy]\". The transcript card and the\ninspector\u0027s Details tab both draw it."
    },
    {
      "commit": "03666d578a86cdb0ec39fbd8bbfd4cbd04fd91c4",
      "tree": "eb9dc0cbb2f81ac331195c90227a74d094d5ee9c",
      "parents": [
        "b779c075734cc1e9f340814f98cb5b4847ecde20"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Fri Sep 11 16:11:30 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Sep 11 16:11:30 2026 +0800"
      },
      "message": "feat(traces): look traces up by id from a Filter / Trace ID switch, and reset the open trace on re-query (#159)\n\n* feat(traces): look traces up by id from a Filter / Trace ID switch, and reset the open trace on re-query\n\n- The Traces tab (native and Zipkin) and Trace inspect gain a Filter / Trace ID\n  switch at the top of the query form. Trace ID mode reads the id(s) with an\n  optional time range (No time range by default); the filter keeps its values\n  and its own custom range for the way back. Zipkin takes several ids as chips,\n  read through /api/v2/traceMany, normalized as OAP does, and names the ids\n  that matched nothing. This replaces the Zipkin tab\u0027s Open trace ID field.\n- Queries run only from Run query: no Enter-to-run, no run on a mode switch.\n- The BFF sends a window with a trace id only when one is asked for, and the\n  cold stage only within a window. OAP\u0027s traceMany 404 reads as \"not found\"\n  only when its body names exactly the requested ids.\n- Run query closes the open trace and clears the distribution pick\n  (apache/skywalking#14055).\n- The Zipkin trace popout shows span detail as a centered dialog again.\n- Trace inspect\u0027s Zipkin annotation query suggests keys and values; compound\n  annotation suggestions keep the terms already typed; switching the Zipkin\n  service on Trace inspect clears the span name and remote service.\n\n* test(e2e): move the OAP pin to master for its first-round metric loss fixes\n\nThe old pin predates apache/skywalking#14063: OAP\u0027s first runtime-rule pass\nafter boot tore down and reloaded every bundled MAL and LAL rule, dropping\nthat round\u0027s samples without a log line, and OAP accepted requests on\n11800/12800 before its rule sets were set up. It also predates #14060, where\na persistence round could overwrite a minute\u0027s earlier points on BanyanDB.\nUpstream tests this OAP against the same BanyanDB commit pinned here.\n\n* test(e2e): wait for the named rule file in the MAL/LAL debugger specs\n\nThe debugger fills its rule-file list catalog by catalog, from one query\nper catalog, and shows whichever have answered. The specs polled until the\nlist held more than its placeholder, then read it once, so a slow catalog\ncould land after the read: on the deployment case the otel-rules catalog,\nwhich holds the BanyanDB files, arrived 12-25 ms too late. Wait for an\noption matching the file instead."
    },
    {
      "commit": "b779c075734cc1e9f340814f98cb5b4847ecde20",
      "tree": "b7f255231a8cbc7896e927e92eca942f049a8383",
      "parents": [
        "cb2f6d9eefac88ccadc9d28ce5f59475c47bdcb7"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Fri Sep 11 09:32:37 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Sep 11 09:32:37 2026 +0800"
      },
      "message": "feat(genai): finish the Evaluation records tab, add a related-trace span picker, and an e2e case fed from both trace sources (#158)\n\nThe Evaluation records tab from #99 reaches operators as designed, and gains what reading a judged call needs.\n\nFixes from review: a role with logs:read alone gets a working provider picker, drawn from the evaluation catalog on the page\u0027s layer rather than the metrics roster (the tab owns its picker, so the auto-pick can no longer land on a service from another layer); changing the model writes provider and model to the address together, so a reload or a shared link applies the pair; an OTLP trace link keeps the record\u0027s time as its lookup moment, so an older trace still opens with the cold stage on. Also from #99: the empty root CHANGELOG.md the repo forbids is removed, the encoding damage in the pager, the histogram tooltip and every comment is repaired, and the feature gets its changelog entry and an operator docs page.\n\nThe tab follows the Logs tab\u0027s contract: conditions stage until Run query, a provider switch returns to the prompt, a dashboard drill or a link with a trace id runs on arrival, and the level chips under the results act at once. Provider, model and the calling service are type-to-filter pickers; a caller that only reports through the Zipkin receiver has no catalog entry, so the facet sample carries the callers seen in the records with their ids and the picker lists them. The value is one condition with the operand its type takes; sort is one ordering; the conditions sit in fixed rows so a control that appears never reflows its neighbours; the stream has a header row. The level strip shows each level\u0027s share of the newest sampled records, with the count in the tooltip, since a count in a fixed-size sample saturates and says nothing. In the record detail a score or a true/false verdict leads the table, which then spans the popout; a string or JSON verdict keeps its content pane.\n\nThe trace-id condition narrows to one span: Pick span… opens the trace (native or OTLP) and lists its spans with the ones OAP\u0027s judge samples marked LLM and shown first — the rule mirrors GenAIEvaluationAnalysisListener and AIEvaluationSpanListener — and a pick fills segment + span index or span id; the same fields can be typed from ids in logs. The picker looks the trace up over the page\u0027s whole selected window, taken when it opens, and needs traces:read as the row links do; a failure the trace route reports inside a 200 is shown as a failure. A trace that reads as empty is re-read for a while — in the picker and in both trace popouts — since a record judged seconds ago can name a segment still landing; the Zipkin route marks OAP\u0027s 404 as notFound so that case is told from a misconfigured Zipkin URL, which it otherwise looks like. The BFF forwards the narrowed relatedTrace, dropping the other scheme\u0027s field since OAP applies spanIndex to any non-OTLP query. A caller seen in any facet sample stays pickable, and the picked one stays visible after a query whose sample has no row of it; a catalog that cannot be read is reported with a retry where the provider prompt would otherwise stand.\n\nThe Zipkin trace popout shows the selected span in a side panel beside the waterfall, as the native popout does.\n\ne2e: a new `genai` case runs OAP\u0027s LLM judge over a mock provider called from spring-ai on the Java agent (native traces) and from a Python OpenTelemetry caller (OTLP traces). A compose `traffic` service drives both callers in turn for the whole run, so records of both sources stay in every page the specs read; readiness checks cover records and traces of both types, and a browser project covers the tab, both popouts, the span picker and the layer\u0027s card widgets. The callers come from apache/skywalking\u0027s published e2e images: the Spring AI app (e2e-spring-ai-service) is joined to the agent image by build-images.sh the way the provider and consumer are, and the mock LLM that answers both callers and OAP\u0027s judge runs as its own service (e2e-mock-llm-server). The demo-service pin moves to the upstream commit that vendored the app and split the mock out; nothing is built from a checkout."
    },
    {
      "commit": "cb2f6d9eefac88ccadc9d28ce5f59475c47bdcb7",
      "tree": "10136eaf5e7213c0c62888beffbaf03ea922bf64",
      "parents": [
        "0263481af45e13c3de6cc29cf05d89e445b4f22e"
      ],
      "author": {
        "name": "peachisai",
        "email": "2581009893@qq.com",
        "time": "Thu Sep 10 20:29:17 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Sep 10 20:29:17 2026 +0800"
      },
      "message": "add the evaluation record dashboard and the new evaluation score tab (#99)"
    },
    {
      "commit": "0263481af45e13c3de6cc29cf05d89e445b4f22e",
      "tree": "40c5317312fbcf86aff82e5a3b65b1d92a48a512",
      "parents": [
        "22e2f8692de57b12529fad4b6c819d8943b7e565"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Sep 10 12:55:00 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Sep 10 12:55:00 2026 +0800"
      },
      "message": "fix(zipkin): honour the Query cold stage toggle on Zipkin traces, and read a trace\u0027s age in days (#157)"
    },
    {
      "commit": "22e2f8692de57b12529fad4b6c819d8943b7e565",
      "tree": "3774c0530a56e6b5ae0c20f42ed77f9d19d7cb15",
      "parents": [
        "d6253a86eb2e6486e6282b0b7c8792264130f6cc"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Sep 10 08:37:58 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Sep 10 08:37:58 2026 +0800"
      },
      "message": "feat: AI agent metrics, workspace changes and an AI Agent Overview (#156)\n\nThe AI Agents layer gains what the Sessionizer and OAP now report: metric\ndashboards for agents and agent runtimes, a landing page with Tokens and\nCache read share, the levels aliased Agents and Agent runtimes, and a\nConversations tab that lists per conversation its talks, model calls,\nsubagents, Bash runs and changes with the lines added and removed; a\ncount an older Sessionizer did not report is a dash, never a zero.\n\nThe conversation renderer draws a document\u0027s workspace changes: a mark\non each tool card that changed files (or an eye for a read-only shell\ncommand), the changed files and their diffs under the card, a Changes\ntab in the inspector with each record\u0027s provenance, and a panel over the\nwhole conversation by workspace root, each record a link to its step.\nTool inputs and results are drawn as the fields they hold, with a copy\nbutton on each, a whole-result copy, and an edit\u0027s old and new text as\none line diff. The inspector pops out into a modal panel over the page,\nand its Details tab says where a step sits in words.\n\nTwo overview widget types: a calendar heatmap of a fixed window by the\nhour or the day, fitted to its card and turned when that reads better,\nwith pickable cells; and a ranking of a layer\u0027s services by one metric\nover the picked range, in as many columns as its height needs. Page-side\nKPI rows and rankings can ask for a range total, which sums a service\u0027s\nbuckets instead of averaging them; the landing top-N cap moves to\ntwenty. A bundled AI Agent Overview page uses both.\n\nDocs for the overview page, the layer, the conversation page and the two\nwidgets, and the 1.1.0 changelog."
    },
    {
      "commit": "d6253a86eb2e6486e6282b0b7c8792264130f6cc",
      "tree": "16c1c9cc6ea25105121c926d69df5e025b4483f7",
      "parents": [
        "aeec709e34a5898fe800d8ee7bb55ea44d68f6b1"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Mon Sep 07 16:26:08 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Sep 07 16:26:08 2026 +0800"
      },
      "message": "fix(live-debug): keep an active capture\u0027s start time out of the rule column (#155)\n\nOn the Capture history page an Active row packs the DSL badge, the LIVE marker and the start time into the fixed-width meta column. The three together are wider than the track, and the time is set not to wrap, so it overflowed into the catalog · file · rule text beside it.\n\nThe meta column now wraps: on an active row the time sits under the badges, matching the two-line rule column. Completed rows still fit on one line and the column width is unchanged, so the Active and Completed sections stay aligned."
    },
    {
      "commit": "aeec709e34a5898fe800d8ee7bb55ea44d68f6b1",
      "tree": "0f9b45833022eb62fee4b55104da9c73ec288fef",
      "parents": [
        "bb27b7c73d42e2b6e65b6ee667d0b255f756bc76"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Sep 06 22:10:59 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Sep 06 22:10:59 2026 +0800"
      },
      "message": "feat: AI agent conversations — the AI Agents layer, a conversation list and a shareable conversation page (#152)\n\nOAP 11.1.0 stores the conversations the SkyWalking AI Sessionizer pushes\nunder the AI_AGENT layer and exposes a GraphQL list and a REST route that\nreproduces one conversation as an asz.view document. Horizon now reads\nboth.\n\nBFF: the list route (window up to 90 days; OAP\u0027s limit counts rounds, so\nthe reply states the limit it was folded from; sender, title and\nconversation id as OAP conditions) and the document relay, held before\nanswering so the page can be told the document\u0027s size and summary ahead\nof the bytes; a new ai-conversation:read verb, a capability probe, two\nperformance.aiConversation settings, and an optional windowMinutes on\nthe instance route.\n\nBundled AI Agents layer (ai_agent.json with seven locale overlays, the\naiConversations component flag) with one tab, Conversations: the runtime\nfrom the layer header\u0027s picker, a range, a sender, a title text and a\nconversation id, all applied by OAP; a row opens the conversation in a\nnew tab.\n\npackages/conversation-view: a framework-free renderer of an asz.view\ndocument on the --sw-* tokens, with a categorical series per theme for\nthe kinds of step, a viewport-culled flow timeline, transcript and\ninspector, floating overview, help and problem panels, and a host shell\nfor embedding outside Horizon; the theme list moves to design-tokens so\nthe UI picker and themes.json read one list.\n\nConversation page at /ai-conversation/:id, outside the AppShell and\nsized to the viewport: the wait shown in phases (OAP assembling, n of m\nMB with rate and time left, parse, draw), the position kept in the URL,\ntheme and language pickers, every failure named, an OAP older than\n11.1.0 named as such, and a document OAP could fold nothing of saying\nso. 198 strings in eight locales, with the asz terms stream, child\nstream and nested stream kept verbatim."
    },
    {
      "commit": "bb27b7c73d42e2b6e65b6ee667d0b255f756bc76",
      "tree": "4c853e40392d584179f2f5101d9d0061909f7f2a",
      "parents": [
        "6540ad5a56b8b43c9bc53f6769da69bcea74489c"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Sep 06 21:16:21 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Sep 06 21:16:21 2026 +0800"
      },
      "message": "fix(infra-3d): frame the populated tiers and follow the theme (#154)\n\nThe 3D Infra Map opened with its one populated slab at the top edge of the view on a deployment with services on a single tier, because the camera framed the whole four-tier stack; the placement bounds now cover the planes that hold a zone, and every plane only when none does, so the default view and Reset frame what is there.\n\nUnder a light theme the map showed dark panels, a dark canvas and dark slabs with the light theme\u0027s dark text on top. WebGL cannot read a CSS variable, so a palette is read from the --sw-* tokens at mount and re-applied on a theme change for the canvas clear colour and the six neutral materials (tier slab and rim, ghost box, cube edges, cluster frames, hierarchy lines); the chrome\u0027s literal colours are tokens and colour-mix, and the brand logo turns blue on a light theme. Layer colours and alarm reds are signals and stay."
    },
    {
      "commit": "6540ad5a56b8b43c9bc53f6769da69bcea74489c",
      "tree": "ebb61bb2242423aa6f992e86161baef9b4518793",
      "parents": [
        "4622885bc70f621ae2d87b7725e4d2d065813116"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Sep 06 20:57:54 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Sep 06 20:57:54 2026 +0800"
      },
      "message": "ci: pin pnpm/action-setup to the commit on the ASF Actions allow-list (#153)\n\nSince 2026-09-05 every pull request\u0027s CI and E2E workflow fails at\nstartup, before any job runs: the ASF GitHub Actions policy admits a\nthird-party action only at an approved commit SHA, and `pnpm/action-setup`\nis on the list at one commit while the workflows asked for the `v4` tag,\nwhich resolves elsewhere. The Docker actions in publish-image.yaml were\nalready pinned this way; this does the same for the three pnpm\nreferences."
    },
    {
      "commit": "4622885bc70f621ae2d87b7725e4d2d065813116",
      "tree": "58214ea04a69ddb773275d1ddcc9f464b35dc1ed",
      "parents": [
        "cf37c374da80a3677af717afd0acedab733730a8"
      ],
      "author": {
        "name": "Wan Kai",
        "email": "wankai123@foxmail.com",
        "time": "Thu Sep 03 14:52:39 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Sep 03 14:52:39 2026 +0800"
      },
      "message": "feat(layer): add a Trace Sampling page to the BanyanDB layer (#148)"
    },
    {
      "commit": "cf37c374da80a3677af717afd0acedab733730a8",
      "tree": "8ddf853e7b9f794007ee711ab6dc9aa153ece872",
      "parents": [
        "fb743c15dc4ae8905883b29a6e6bdc78e3ceafbb"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Wed Sep 02 15:01:45 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Wed Sep 02 15:01:45 2026 +0800"
      },
      "message": "fix(rbac): a malformed grant confers nothing, instead of the area it starts with (#147)\n\nThe verb grammar is at most three segments, and `matchOne` parsed grants with\n`split(\u0027:\u0027, 3)` — which TRUNCATES rather than failing. A fourth segment was\ntherefore dropped on the floor, so `rule:write:structural:extra` matched\n`rule:write:structural`. Worse, the `area:*` branch never looked at the\nsub-segment at all, so `rule:*:typo` was read as `rule:*` and handed over\nevery rule permission there is: read, write, the structural sub-action and\ndelete. A typo widened access rather than failing closed.\n\nBoth are refused now. `isGrantRecognised` already delegates to this matcher,\nso such a grant is also named in the startup warning rather than sitting in\na config looking effective.\n\nThe same fix lands in the two UI copies of the matcher — the auth store and\nthe Roles board — and the three are pinned to a shared table of cases,\nasserted on both sides. They diverged together here, which is the failure a\nper-file review cannot see: each copy was internally consistent and all\nthree were wrong in the same way."
    },
    {
      "commit": "fb743c15dc4ae8905883b29a6e6bdc78e3ceafbb",
      "tree": "293af30c8b449741a6f2bdb2aa38dc4d9e586522",
      "parents": [
        "78381485dbac4ee2719c949b5eae81410d03cf2c"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Wed Sep 02 12:48:54 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Wed Sep 02 12:48:54 2026 +0800"
      },
      "message": "feat(rbac): one read/write permission pair per Dashboard-setup page (#146)"
    },
    {
      "commit": "78381485dbac4ee2719c949b5eae81410d03cf2c",
      "tree": "fc0b3a610d4c862560f8621c298ab47f31174052",
      "parents": [
        "1f2390ef978588b4ec66268620c0651b70100e9d"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Tue Sep 01 18:48:14 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Sep 01 18:48:14 2026 +0800"
      },
      "message": "feat(templates): run an MQE from the layer template editor and show what OAP returns (#145)"
    },
    {
      "commit": "1f2390ef978588b4ec66268620c0651b70100e9d",
      "tree": "4b0181704a11168963898abac84cb212f808efb8",
      "parents": [
        "9c6690c6b2befcfcf9c114e20ef5b1adcdedf3fb"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Fri Aug 28 20:42:47 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Aug 28 20:42:47 2026 +0800"
      },
      "message": "ci(release): promote images when the GitHub release is published (#144)\n\nCloses #14048 (apache/skywalking).\n\nThe stable image tags and the Docker Hub mirror are vote-gated: a `v*` tag\npush cuts a release CANDIDATE and publishes the immutable `:\u003csha\u003e` alone.\nAttaching `:\u003cver\u003e`, `:\u003cmajor.minor\u003e` and `:latest` was a manual\nworkflow_dispatch the release manager had to remember to run, in the right\norder, with the ordering recorded only in a comment.\n\nPublishing the GitHub release now triggers it. `release: types: [released]`\nfires for a real publish only — never a draft, never a prerelease — and the\nrelease is created after the vote passes, so it is a faithful signal that a\ncandidate became a release, and the tag it names already exists. This is the\nsame trigger apache/skywalking uses in publish-docker.yaml.\n\n`workflow_dispatch` stays: it back-fills a release whose tag pre-dates this\nworkflow, and retries a publish that failed. Both paths resolve the COMMIT\nfrom the tag rather than trusting the event\u0027s sha, so the image is built from\nthe ref that was voted on, and both set the same `is_promote` flag — the one\nswitch that gates the stable tags and the mirror.\n\nThe order inverts, so release-finalize.sh inverts with it. Its Docker Hub check\nused to assume the promotion had already happened and failed with \"run the\ndispatch, then re-run me\"; publishing the release is now what starts that run,\nso the check WAITS for it — bounded, DH_WAIT_SECONDS (default 30 min), since a\nper-architecture build finishes only when the manifest job stitches it. A\ntimeout says the run should be building and points at it, rather than telling\nthe operator to start something already running."
    },
    {
      "commit": "9c6690c6b2befcfcf9c114e20ef5b1adcdedf3fb",
      "tree": "418d2798c7a3dbe50a5b2e2bc676ac6e9713d41b",
      "parents": [
        "423dd0ec46b12c44e8af688a139653a733e21a1c"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Fri Aug 28 20:20:29 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Aug 28 20:20:29 2026 +0800"
      },
      "message": "refactor(ui): move Modal and MonacoDiff into components/primitives (#143)\n\nBoth are generic — a dialog shell and a Monaco diff viewer — and neither\ncontains a single reference to operate, cluster, inspect, dsl or live-debug.\nThey lived under `features/operate/_shared/` because that is where they were\nfirst written, and fifteen files came to import them from there: `shell/`,\n`render/widgets/`, `layer/`, and three features besides operate. A shell-level\nprompt reaching into a feature folder for a dialog is the shape that says the\naddress is wrong rather than the rule.\n\nMoving them takes the cross-feature imports the layering rule forbids from\nfourteen down to one — the admin editor for the 3D map reading that feature\u0027s\nown config composable, which is a page editing the thing it is the editor for.\n\nNo behaviour change: the two files are unmodified apart from a relative\n`monaco/setup` import that the new depth broke, now on the `@/` alias like\neverything around it."
    },
    {
      "commit": "423dd0ec46b12c44e8af688a139653a733e21a1c",
      "tree": "e5ee712ce8731e50f8e41a86f3efa781505bede2",
      "parents": [
        "2cdb8e946771d09ada43e4285f9b49c0d95e9b23"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Fri Aug 28 19:10:00 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Aug 28 19:10:00 2026 +0800"
      },
      "message": "feat(layer): read the header\u0027s KPIs once an hour, and say which hour (#142)"
    },
    {
      "commit": "2cdb8e946771d09ada43e4285f9b49c0d95e9b23",
      "tree": "a2f371e2a042b8bd649ec50393f715ed0bc5e8dc",
      "parents": [
        "f97b19b33e731752c550651e976b9f8dac2f004f"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Fri Aug 28 08:22:57 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Aug 28 08:22:57 2026 +0800"
      },
      "message": "fix(cold-stage): ask for cold where cold is kept, and stop reading on the flip (#141)\n\nReview of the refresh work found ten things. Nine were verified against the\ncode before being acted on; the tenth was the reviewer\u0027s own, and it was right.\n\n**Cold has a scope, and it is ours rather than the protocol\u0027s.** The protocol\naccepts `Duration.coldStage` far more widely than a deployment should use it.\nTraces, logs and metrics are the classes worth ageing into cold storage;\nalarms, the instance and endpoint pickers, events and everything from profiling\nare small and are the first things reached for during an incident. Those routes\nnow send no flag at all — and since `coldStage: true` REPLACES the hot read,\nsending it there did not widen the answer, it emptied it. `apps/bff/CLAUDE.md`\ncarries the route table, with the right-hand column as the default.\n\n**Flipping the pill reads nothing.** It used to fire a round, which on a cold\ntier routinely ran past the sixty-second cap; the sweep that followed selected\non `dataUpdatedAt`, which a cancelled query never updates, so it re-queued\nexactly what the cap had just cancelled. The cap was at its most useless during\nthe outage it exists for. The stage left the query keys too — a key that moved\non the flip has no cached entry, so the fetch would have happened anyway.\n\n**A capped round now stops the work it started.** The signal reached the four\ngraph routes but not the landing, the dashboard batches or the overview, so the\nbrowser stopped waiting while OAP finished a fan-out nobody would read.\n\n**Two shared caches were racing.** The timezone and capability probes were\nfetched per request; behind a load balancer a fast success could be overwritten\nby a slower timeout falling back to UTC, and that answer then served every\nrequest for a minute. Each is read once per expiry and shared, and the shared\nflight deliberately carries no caller signal — whoever arrived first must not\ncancel a probe the others are waiting on.\n\n**Failures stop being published as facts.** A failed service-catalog read no\nlonger replaces the known-good roster (kept, marked stale, retried in seconds\nrather than a minute); a landing metric batch that timed out no longer sorts its\nservices to the bottom as though idle, where they could fall out of the top-N\nand rename the layer\u0027s default — unread now ranks above genuinely-absent, and\nthe response says the ranking was partial.\n\n**A retained comparison series is drawn where its data actually is.** Each\nentity keeps its last good reading when its own read fails — that is the design,\nso one failure does not blank its siblings — but the retained array was plotted\nacross the CURRENT axis and ran to the right-hand edge beside the fresh ones.\nIt is now placed at the buckets it was read for: whatever falls outside the axis\nis dropped, the buckets it never covered stay empty, and a differing STEP draws\nnothing, because no offset makes those buckets comparable. The endpoint-\ndependency graph learns the same way — its window moved out of a WeakMap keyed\nby response identity, which vue-query\u0027s proxy and structural sharing both\ndefeat, and into the cache entry itself.\n\n**A layer an administrator removed is not an outage.** The page explained the\ntemplate was disabled and pointed at an admin control to re-enable it — one most\noperators cannot reach, on a page that does not exist under that name — while\nthe refresh history blamed OAP for an administrative decision on a healthy\nserver. The page says it is not available; the sidebar drops the layer on the\nnext menu read; nothing is filed.\n\n**Reactivate restores the layer\u0027s own dashboard.** OAP keeps a disabled row\u0027s\nconfiguration — `disabled` is only a flag on it — and Reactivate was re-pushing\nthe bundled default over the top, discarding every edit the layer carried.\n\n**The cold-stage warning gave advice that could not work.** With no cold stage\nconfigured — the BanyanDB default — it still said \"pick a window older than N\ndays\", and following it moved the range out of hot+warm so the warning hid\nitself: a blank page with the one sentence explaining it gone. It now says no\ncold stage is configured and stays until Cold is turned off. Where cold IS\nconfigured, the suggested window clears the deepest class rather than the\nshallowest; naming the records boundary left every metric widget as empty as\nbefore.\n\n**The flip cancels what is already out.** Cancelling is not reading, so this\nsits inside the rule above: the stage is sampled when a request DISPATCHES, and\na call queued behind the concurrency limiter would otherwise go out under the\nnew stage while belonging to the batch that asked under the old one — a compare\ngrid half hot and half cold, with nothing on screen to show it.\n\n**The window is snapshotted before the request, not after it.** Both envelopes\nread it after awaiting, so a slow W1 answer landing into a re-anchored W2 was\nstamped W2 — the mislabel the stamp exists to prevent, with a comment claiming\nit was \"captured at firing time\".\n\n**Stale-good survives the whole outage, not just its first failure.** The\nretention asked whether the PREVIOUS read had succeeded, so the first failure\nmarked the snapshot unreachable and the second then saw that mark and overwrote\nthe rows with the empty answer. It now asks whether the rows it holds are worth\nkeeping.\n\n**Four process caches stop pretending to be keyed.** `capabilities`,\n`hierarchy`, `backend` and the timezone probe each keyed on `oap.queryUrl` — a\nsingle config field, so the map held one entry for the life of the process and\nseparated nothing. One Horizon reads one deployment and every node in it answers\nalike; there is no second answer for a key to tell apart. They are plain\nvariables now, and `apps/bff/CLAUDE.md` says so, since the shape invited the\nquestion more than once. A test that had pinned the keying as a contract goes\nwith it.\n\nValidated on a real OAP: `core` 11/11 and `es` 3/3, zero spec failures. Eight\nnew unit tests, each confirmed to fail against the code it replaces."
    },
    {
      "commit": "f97b19b33e731752c550651e976b9f8dac2f004f",
      "tree": "5fb1672b09caf965eea6e03d57b81e1988458c74",
      "parents": [
        "ef3a161539d983bcabf25261ea2125562a7ecbe8"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Aug 27 16:41:25 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Aug 27 16:41:25 2026 +0800"
      },
      "message": "feat: one coordinated refresh round, and a retention page shaped by its backend (#140)\n\n* feat(refresh): refresh the whole page as one coordinated round\n\nA page refreshed in pieces. The header and the service roster followed the\ntopbar timer, a dashboard\u0027s widgets and the alarms card each polled on\nintervals of their own, and every graph re-keyed itself off the clock. The page\nredrew in waves; on a slow backend a new wave began before the last had\nfinished; and each tick blanked the maps to a loading line, discarding the\noperator\u0027s zoom, pan and node placements with them.\n\nThe graphs made it worst, because the BFF\u0027s graph routes never throw: an OAP\nfailure arrives as HTTP 200 with `{ nodes: [], reachable: false }`. A query\nlibrary sees a success, replaces the cached graph with the empty one, and the\ncanvas goes blank — nothing pending to wait on, no error to retry.\nMid-incident, that is the moment the picture disappears.\n\n**Refreshing is one round.** The store re-anchors the rolling window once,\ncalls every subscriber, waits for them all, and only then counts toward the\nnext round. The countdown measures the gap between rounds rather than between\nstarts: while readings are out it says \"Refreshing\", and it starts again from\nthe landing, so loading time is never charged against the interval. A round\nthat has started always finishes; switching off or entering a page that pauses\nrefreshing stops the NEXT one, because abandoning a round halfway leaves the\npage holding whatever fraction landed beside values from the round before.\n\nEach round publishes what it is — id, trigger, window, step, stage — so every\nscreen in it agrees about the question. Participants take the window from the\nround rather than re-deriving it, but only while it still describes what the\noperator is asking: the picker stays live, and a range changed mid-round would\notherwise file an answer about the old window under the key that names the new\none. A trigger arriving mid-round is coalesced into one trailing round, and an\noperator\u0027s action — a click, a time change, a stage flip — survives the\nsuspension a custom range imposes.\n\nThe sixty-second cap CANCELS. `AbortSignal` runs from the browser through the\nBFF to OAP, and a round names every query it holds — a fan-out is one\nparticipant with many — so a capped round stops its work rather than releasing\nthe scheduler while it continues. It also records itself, because a cap that\nleaves no trace is indistinguishable from a round that merely took a while.\nReads only: anything that CHANGES something runs to completion, so a closed tab\ncannot leave a profiling task half created.\n\nEvery OAP call on a read route takes that signal, including the two probes\nthat are easy to miss because no route names them: the timezone lookup and the\ncapability introspection. Both also refuse to CACHE a result derived from a\nprobe that was cancelled — a UTC-0 offset held for a minute, an all-false\ncapability set held for another — because one abandoned request must not answer\nevery other request with a conclusion nobody reached.\n\nCancelling only works if nothing asks again. The retry policy therefore exempts\na request we cancelled as well as an answer that cannot differ, and the three\nqueries that had overridden it with a plain `retry: 1` — the layer landing and\nboth dashboard fan-outs — inherit it again. Retrying an aborted fan-out is\nworse than never capping it: the work restarts with the cap already spent.\n\nWhether the caller is still listening is asked of the RESPONSE socket alone. A\nrequest\u0027s own readable is destroyed as soon as its body has been read — Node\nauto-destroys it on `end`, so it reads as destroyed in every handler behind an\nasync preHandler, which is every authenticated read route. Consulting it\nreported each of those reads as abandoned and cancelled it before it issued a\nsingle query, while the route still replied with a well-formed 200 saying OAP\ncould not be reached.\n\n**Every screen keys on the QUESTION, not the clock.** A graph query is keyed on\none structured predicate — layer, focus, service, endpoint, depth, preview,\nstage and the window\u0027s identity — while the bounds travel as the request\nargument. The overview keyed on raw bounds and emptied its grid every tick, the\nlayer dashboard bucketed them to the minute and emptied every other one, and\nthe roster refreshed itself by accident, outside any round. A round now\nproduces one request per logical query: refetching used to cancel what was in\nflight and start again, so two components sharing a query issued it twice.\n\n**A response that cannot be drawn is a failure, not data.** It is thrown rather\nthan returned, so the last good answer survives both a failed round and a\nremount by living in the query cache. A five-state phase machine is derived\nfrom the snapshot and the latest attempt together, so no view reads half its\npicture from each, and a first failure says so instead of showing a loading\nline for ever. Everything describing the drawn graph comes from the snapshot;\nonly what describes the failure comes from the attempt. The rule reaches the\nroster and the overview too, where an unreadable answer had been rendering as a\npage of zeroes — indistinguishable from a system with nothing running.\n\n**Failures route by who asked.** The timer\u0027s collect beside the refresh\ncontrol, five deep, with an unread count, because nobody asked for that round.\nAn operator\u0027s own answer where they are looking, as a toast that shows its\nremaining time, pauses on hover or focus, and returns focus when dismissed.\nA refresh THEY asked for does both: it is recorded like any other, and says on\nscreen that part of the page failed — once, however many screens failed, since\nthey pressed one button. One event is one record however many observers saw it,\na comparison entity that fails alone is recorded at all, and a request WE\ncancelled is never reported: that is the app\u0027s own decision, not an outage.\nBoth kinds render the same card; URLs and bodies are recursively redacted and\nlength-capped; the history clears on sign-out.\n\n**A refresh moves nothing the operator arranged.** Placements are absolute\nrather than offsets from a layout that shifts beneath them, and are pruned only\nfrom a snapshot that arrived. The deployment map re-arms its drag handlers on\nthe identity of what is drawn rather than on counts. The endpoint columns still\nselect live but no longer re-sort themselves every cycle. A replaced canvas is\nrestored to the operator\u0027s framing instead of starting at identity, and a fit\ntaken on an empty canvas no longer latches. Expansions say what they are built\non: dropped when a new base commits or the question changes, kept when a\nrefresh fails, asked with the window their base was read with, and resolved\nagainst the draft when a preview is open.\n\n**Switching Query cold stage is one event.** The flag and the header are\nwritten in the same tick, the stage scopes every cache entry so an in-flight\nhot read cannot answer a cold question, and the page is re-read as one round\nrather than a page-wide invalidate.\n\n**The template store keeps the console up.** An unreadable store used to empty\nevery dashboard, overview and map; they now render the last rows Horizon read,\nwith the banner saying how stale that is. It still never substitutes the\nrelease\u0027s bundled templates — that would misrepresent whose configuration is on\nscreen — so a Horizon that has never read the store still blocks. The store is\nre-read on a cycle, so a template published from another Horizon or from swctl\narrives without a reload and an unreachable store clears itself. Cluster Status\ngains a pane saying what is loaded, when it was read, and what the last failure\nsaid.\n\n* fix(ttl): one retention catalogue, and say which setting governs each figure\n\nData retention mirrored OAP\u0027s wire shape rather than anything an operator\ndecides: a Records / Metrics split, a Minute / Hour / Day trio, and a class\ncalled \"Normal\". On ElasticSearch and JDBC that meant nine numbers which were\nreally two, presented as nine knobs — those backends register the SAME\nDefaultStorageTTLStatusQuery, which repeats `core.recordDataTTL` across every\nrecord class and `core.metricsDataTTL` across every metric one. BanyanDB is the\nonly storage plugin implementing the TTL query, and the only one where the\nclasses are separate groups that can be tuned apart.\n\nBoth backends now show the same five, in the same order — Metadata, Metrics,\nLogs, Traces, Others — with the governing setting named beneath each. BanyanDB\nexpands Metrics into its minute / hour / day groups and adds Zipkin traces and\nBrowser error logs, because there each is its own `bydb.yml` group.\n\nRows whose retention happens to MATCH are no longer merged. A single\n\"All records (5)\" bar reads as a fact about the backend when it is only a fact\nabout today\u0027s configuration, and it hid four rows an operator may want to\nchange. `Others` is the one row named for a group rather than a data type:\n`records` holds alarms, alarm recovery, events, the three sampled-trace records,\nthe four top-N records, every profiling record and GenAI evaluations — 22 models\nsharing one `ttl` and one `enableColdStage`, so no single data type names it.\n\nThe group keys are also not the wire field names: the response says `log` and\n`browserErrorLog` where the groups are `recordsLog` and\n`recordsBrowserErrorLog`. Pointing an operator at the wire name sends them to a\nkey that does not exist, so only `records` — the one mapping nobody can guess —\nis printed, and the section says once that a row is a `bydb.yml` group.\n\nCovered by a new `es` spec, since the page\u0027s SHAPE is decided by the backend and\ncan only be checked where that backend is not BanyanDB. It asserts the catalogue\nand its order, and counts the cards attributed to each of the two settings —\npresence alone would pass with a card wired to the wrong one.\n\nAlso moves the OAP wire rules out of the root CLAUDE.md into apps/bff/CLAUDE.md,\nnext to the code that speaks them: the four endpoints and what each carries,\ncancellation, time/step/timezone, cold stage, metric entity-scope and TTL\u0027s\nper-backend shape. The root file keeps the principle and points there."
    },
    {
      "commit": "ef3a161539d983bcabf25261ea2125562a7ecbe8",
      "tree": "78779b241d37856835150bca26f6ca3c0bf90f4f",
      "parents": [
        "3c1d5bcd8e25e43185b0e198a5ede317d393afa3"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Wed Aug 26 14:01:55 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Wed Aug 26 14:01:55 2026 +0800"
      },
      "message": "docs(claude): state what Horizon requires, not what preceded it (#139)"
    },
    {
      "commit": "3c1d5bcd8e25e43185b0e198a5ede317d393afa3",
      "tree": "ff5b7cdb81304ead7c57a324220527dbda1abfeb",
      "parents": [
        "eb227c82827b95459aca5490d5f236ee77b3a20c"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Wed Aug 26 10:34:08 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Wed Aug 26 10:34:08 2026 +0800"
      },
      "message": "feat(audit): store the login audit in BanyanDB (#138)"
    },
    {
      "commit": "eb227c82827b95459aca5490d5f236ee77b3a20c",
      "tree": "cadd651ce556708a5dea22b28b32d5417edfb694",
      "parents": [
        "eeaa4df8aef410c1654bd4575eb6c5f30c502456"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Tue Aug 25 19:41:07 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Aug 25 19:41:07 2026 +0800"
      },
      "message": "feat(banyandb): a Node client for measure and stream (#137)\n\n* feat(banyandb): a Node client for measure and stream\n\nGroundwork for backing the login audit log with BanyanDB. This is the client\nonly — it knows nothing about what will be stored in it, and no store uses it\nyet. BanyanDB\u0027s own vocabulary throughout: groups and their retention, streams\nand measures, tag families, entities, index rules and bindings, mod revisions,\nthe schema barrier.\n\nMeasure and Stream, schema and data. Property, Trace, TopN and the query\nlanguage are deliberately not wrapped.\n\nSchema is reconciled rather than created. PostgreSQL gets idempotence from\nsyntax — `CREATE TABLE IF NOT EXISTS` — so init and update are one statement\nthere. BanyanDB has no such form and `Create` is not idempotent, so\nget-compare-create-or-update is the client\u0027s work, and drift the server would\nrefuse is reported instead of attempted: an entity is frozen for life, and a\ntag retype IS accepted by the server and then decodes old rows against the new\ntype, so it is refused here.\n\nTwo silent failure modes shaped the write path. Values are positional within a\ntag family and the families are positional too, so the definition decides\nplacement rather than the caller\u0027s object. And the spec travels on the first\nmessage of every stream, where the server latches it — the write-side\n`TagFamilySpec` carrying tag NAMES, which is a different message from the\nregistry\u0027s despite the shared name. Sending the registry\u0027s shape costs nothing\non the wire and everything at the server: the field is unknown and dropped, so\nevery tag resolves to NULL, the row\u0027s series becomes `hash(subject + NULL)`,\nand the element lands on disk where no query can ever reach it. The write\nstill succeeds. Request types are derived from the generated declarations so\nthe compiler now decides this rather than a hand-written interface.\n\nCredentials travel as metadata, not composed onto the channel: gRPC refuses to\ncompose call credentials onto an insecure channel, and username-and-password\nover plaintext is BanyanDB\u0027s own documented setup.\n\nIntegration tests run against a real server — `pnpm test:it`, which starts the\nBanyanDB that `test/e2e/script/env` pins, runs, and stops it again. They found\nevery bug above; type-check, lint and unit tests passed throughout. The e2e\npins move to the 11.0 line and BanyanDB 0.11.0, the first release carrying the\nschema barrier, `STATUS_SCHEMA_NOT_APPLIED` and `created_at`.\n\n* fix(banyandb): reject values the server would silently accept\n\nA review pass over the client. Most of these are the same shape as the bug the\nintegration tests found: a request the server takes without complaint and then\nstores as something other than what was meant.\n\nValues. `int64` accepted any string and any bigint, and the serializer does not\nvalidate — \"abc\" was written as 0, \"12.5\" as 12, and 2^63 wrapped to its own\nnegative. Parsed and range-checked now, where the caller still has a stack to\nblame.\n\nEntity values may no longer be absent. Every other tag can legitimately be\nnull; these ARE the series, so a missing one was hashed like any other value\nand collapsed rows that belong to different series into one. The sharding key\nhas the same problem, and the ordered-subset rule it has always documented is\nnow enforced rather than described.\n\nReconciliation walked only the desired side, so a removal was invisible:\ndropping a tag reported `unchanged` and never happened, while a removal\nalongside an addition happened by accident, because the update replaces the\nresource with whatever was sent. Both sides are walked now. Losing a create\nrace no longer implies agreeing with the winner — the resource is read back and\ncompared. The measure\u0027s sharding key and `index_mode` are compared rather than\nignored.\n\nLifecycle stages are expressible. BanyanDB has no global setting for tiering:\nstages belong to each group, so a group whose stages this client could not\nrepresent would have had them erased by an unrelated TTL change.\n\nGenerated types were wrong in two ways that type-check cleanly and fail at\nruntime: `defaults: true` leaves an unset MESSAGE null rather than absent, so\n`a.b.c` compiled and could throw; and a Struct is `{fields}` on this wire, not\na bare record, so a plausible literal serialized to an empty one. The barrier\u0027s\nproto is generated too, instead of being hand-written outside `proto:check`.\n\nThe write path honours backpressure rather than growing gRPC\u0027s buffer, and the\nbarrier no longer reports a genuine NOT_FOUND as an old server.\n\nIntegration tests read metadata BACK and compare it, rather than trusting an\naction and a revision: groups, streams, measures, index rules and the binding\nwindow are all asserted against what was declared. The indexed non-entity tag\nis now queried, which is the only thing that shows a binding is live — creating\na rule and a binding proves neither.\n\nThe fixture\u0027s readiness check no longer accepts NOT_SERVING, which its\nsubstring match had been matching all along, and a container no longer outlives\na run whose setup threw.\n\n* refactor(banyandb): name the schema side SchemaManager\n\n`reconcile` was borrowed from Kubernetes, where it names a control loop that\nwatches a resource and keeps returning to it. This runs once at boot and then\nstops, so the word promised machinery that is not there — and a term that\nmisleads when read literally is the wrong term however familiar it is.\n\nThe five functions become methods on a `SchemaManager` the client exposes as\n`client.schema`, so a call site reads `schema.stream(def)` rather than\n`reconcileStream(def)`. `ReconcileResult` becomes `SchemaChange` and\n`ReconcileAction` becomes `SchemaAction`; the actions themselves — created,\nupdated, unchanged — already said what they meant.\n\nNo behaviour changes.\n\n* fix(banyandb): serialise schema work and validate what decides a write\n\nA third review pass, and the interesting one is what it says about the\nconcurrent create.\n\nSchema methods now run one at a time, in call order. Each is read-then-decide,\nso two running at once both read \"absent\" and both create — one of them then\nlosing a race it need never have entered. The schema is a small fixed set\napplied at boot, so ordering it costs nothing.\n\nAcross replicas the create is still contended, and losing it no longer gets a\nspecial case: the winner\u0027s resource falls through to the ordinary comparison,\nbecause a replica on an older definition may have created something this one\nwould refuse or would need to add a tag to. That removes code rather than\nadding it, and fixes groups, index rules and bindings, which previously\naccepted any winner without looking.\n\nA lifecycle-only change was invisible: stages could be declared and sent but\nwere never compared, so editing only the tiering reported `unchanged` and\nnever happened. Queries can now name stages too, which is how a caller reaches\ndata that has aged into a warm or cold tier.\n\n`version` decides which measure write WINS and was the one 64-bit value still\ncopied unvalidated — \"abc\" wrote as 0, \"12.5\" as 12, and 2^63 wrapped to its\nown negative, each losing to the row it was meant to replace. An empty\n`elementId` is refused: the server mints a fresh one per write, so the dedup\nthat makes a retry safe stops working silently and a re-sent batch duplicates\nevery row.\n\nBackpressure raced only against `drain`, which never arrives if the stream\nerrored first — the write hung to the deadline while the real failure sat\nunread. It races the stream\u0027s end now.\n\nStruct, Value and ListValue are generated in their real protobuf shapes rather\nthan as `Record\u003cstring, unknown\u003e` and `unknown`: the loader keeps them wrapped,\nso a plausible literal type-checked and then serialised to an empty message.\nThe barrier\u0027s request and response types are derived from the generated\ndeclarations instead of restated beside them.\n\n* fix(banyandb): do not let one node\u0027s schema remove another\u0027s\n\nMultiple Horizon nodes apply the schema at boot, and the registry offers no\ncompare-and-set: UpdateStream overwrites whatever mod_revision the client sent\nwith its own timestamp, so concurrent updates are unconditionally\nlast-writer-wins and a client cannot say \"only if unchanged\".\n\nThat makes additions and removals different risks. During a rolling upgrade\ntwo definitions are live at once. An addition an older node does not know about\nis harmless to it. A removal an older node believes in destroys a tag the newer\nnodes are actively writing to — and a node crash-looping after the upgrade\nwould strip it repeatedly.\n\nSo a difference that DELETES something the server holds is now reported and not\nacted on, unless the caller says it means one. Additions alongside an unapplied\nremoval are held back too, because applying them means sending the definition,\nand sending it performs the removal. `SchemaManager` takes the opt-in for a\ndeliberate migration, where one actor applies the change and no older\ndefinition is still running.\n\nDetection is unchanged — the difference is still found and still reported. What\nchanged is that finding one at boot is treated as evidence of version skew\nrather than as an instruction.\n\n* fix(banyandb): apply additions even when a removal is held back\n\nHolding the whole update back was too blunt. Live A+B against a definition\nsaying A+C left A+B, so a node upgraded to write C could not write it — the\nguard against one node\u0027s schema removing another\u0027s had turned into a guard\nagainst it adding anything.\n\nThe update now carries the UNION of what the server holds and what the\ndefinition adds, so that case reaches A+B+C: the addition lands, the drop does\nnot happen, and both an old and a new definition can work against the result.\nThe kept difference is still reported.\n\nStages had the same shape of bug from the other side. `undefined` compared as\nan empty list, so a definition that did not manage tiering reported drift on\nevery boot — and the update could not act on it, because an unmanaged value is\nnot sent. `undefined` now means \"not managed\" in both halves, and an explicit\nempty list means hot-only, which is what makes converting a tiered group back\npossible at all.\n\nAn explicit `version: \u0027\u0027` was read as no version rather than reaching int64\nvalidation. The barrier\u0027s request and response envelopes are derived from the\ngenerated declarations like its nested types already were.\n\n* refactor(banyandb): use the generated wire types, not copies of them\n\nThe barrier declared its own SchemaKey and NodeLaggard and mapped the wire\nshapes into them. Both are generated now, so the copies were a second\ndefinition that `proto:check` could not keep honest — and they encoded an\noptimism the wire does not share: every scalar in a protobuf message is\noptional, whatever the client would prefer, which is why the mapping had to\ninvent empty strings for fields it had declared required.\n\nThe generated types are used directly and the mapping is gone, taking a third\nof the file with it. `SchemaKind` stays: the wire calls it a plain string, and\nnaming the accepted values saves a caller guessing the spelling.\n\n`Duration` and `Timestamp` were hand-written the same way in two more places.\nThe Date conversion now goes through `pbTimestamp`, which already owned the\nwhole-millisecond rule the server enforces — a second copy of that is a second\nplace for it to drift."
    },
    {
      "commit": "eeaa4df8aef410c1654bd4575eb6c5f30c502456",
      "tree": "6eab5f4db43572cbea1596d1cc604e1f3749cfff",
      "parents": [
        "e7b2ab366954ee05298678a63b91c75eba2779da"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Tue Aug 25 13:34:57 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Aug 25 13:34:57 2026 +0800"
      },
      "message": "chore(banyandb): vendor the wire contract and generate its types (#136)\n\n* chore(banyandb): vendor the wire contract and generate its types\n\nGroundwork for the BanyanDB-backed login audit store: bring the BanyanDB\nprotobuf contract into the tree so a client can be written against it, with\nno runtime code and nothing that talks to a server yet.\n\nThe .proto files are vendored as tracked files, synced from PINNED upstream\ncommits by `pnpm proto:sync` and guarded by `pnpm proto:check`, which\nre-fetches and fails on any diff. Vendoring rather than a submodule keeps the\nbuild offline and the source tarball self-contained: `git archive` does not\nexport submodule content, so an ASF source release would have shipped an\nempty directory, and the fix would have had to work around the compiled\n`maven-wrapper.jar` the upstream repo tracks, which a source release must not\ncarry. Pinning commits rather than branches means an upstream release cannot\nturn an unrelated PR red; a bump is a reviewable change whose diff shows what\nthe contract gained.\n\nTwo upstream sources, not one. Every banyandb .proto imports\n`validate/validate.proto` and the BanyanDB proto repo does not vendor it — it\ncomes from protoc-gen-validate, and without it the tree does not parse at all.\n\nTypes are generated and committed, serialization is not. `PackageDefinition`\nis opaque, so a mistyped field would otherwise be a silent empty result rather\nthan an error; `proto.pb.d.ts` makes it a compile error. Generated\nserialization would need Apache headers it does not carry and would date the\nmoment the pin moves, so the wire codec stays @grpc/proto-loader reading the\n.proto at runtime. The declarations encode the loader\u0027s options too —\nsnake_case names, 64-bit integers as strings, enums as their names — and a\ntest asserts that, so the two cannot drift apart quietly.\n\nBecause proto-loader reads from disk, the .proto tree is a runtime asset: no\nbundler carries it. `scripts/package.mjs` copies it to `dist/proto` and the\nDockerfile copies that into the image, the same arrangement `skills` uses.\n\nPlaced under `client/banyandb/` rather than a new top-level group: `client/`\nis already the role for talking to an external system over a wire protocol,\nand already holds a non-OAP backend in `client/zipkin.ts`.\n\n* chore(banyandb): re-pin to the commit id carrying the 0.11 contract\n\nThe first pin, commit b1c8766, was taken from a local clone that was seven\nmonths behind. `main` is synced from the BanyanDB server and its tip is\ncommit 8fb4409, which is what the pin now names.\n\nThis is not housekeeping. The Java client SDK is deprecated — frozen at\nBanyanDB 0.9.0, with upstream directing consumers to implement locally against\nthese definitions — so this repo is the contract\u0027s home rather than a mirror\nof an SDK. The earlier commit predates BanyanDB 0.11.0 and so was missing\nsurface this store depends on:\n\n  - `SchemaBarrierService` (`AwaitSchemaApplied`) — `ensureSchema` creates\n    objects and then immediately writes rows, which on a cluster races schema\n    propagation to the data nodes. Without the barrier that needs a\n    poll-until-visible workaround.\n  - `STATUS_SCHEMA_NOT_APPLIED` — a write rejected for schema propagation is\n    TRANSIENT, and the audit service drops a failed batch rather than retrying\n    it, so classifying this as permanent would discard sign-in records rather\n    than defer them.\n  - `created_at` — carried on schema objects and preserved across updates.\n\nTwo files arrive with it, `banyandb-schema.proto` and `banyandb-bydbql.proto`.\nThe barrier is loaded; the query language is vendored but deliberately not, as\nit is not how this client talks to the store.\n\n`proto-sources.json` gains a `track` field saying which ref the commit id came\nfrom and when to move it, so a bare SHA does not leave the next reader\nguessing. The pin is always a commit id — `sync-proto.mjs` refuses anything\nthat is not a full 40-character SHA, since an abbreviated one cannot be\nfetched and a tag can be moved."
    },
    {
      "commit": "e7b2ab366954ee05298678a63b91c75eba2779da",
      "tree": "7a953cbe62c7d8ec40589678c6f042f326282737",
      "parents": [
        "26b960cacf784161562d774c192d272081794daa"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Tue Aug 25 09:50:54 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Aug 25 09:50:54 2026 +0800"
      },
      "message": "Release 1.0.0, bump to 1.1.0-dev (#135)\n\n* Prepare release 1.0.0\n\n* Prepare next release 1.1.0-dev"
    },
    {
      "commit": "26b960cacf784161562d774c192d272081794daa",
      "tree": "b45cfcc6a40fd104f661ce86df1d86ba332b40b4",
      "parents": [
        "a6618ff2237a6d2193ec92f771df2b6707746e15"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Tue Aug 25 08:43:12 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Aug 25 08:43:12 2026 +0800"
      },
      "message": "fix(release): pin license-eye instead of taking it from PATH (#134)\n\nThe release generates the binary LICENSE with license-eye, so a different\nbuild of the tool produces a different file and the drift gate fails on a\ndifference that is the TOOL\u0027s, not the dependency tree\u0027s. That is what stopped\nthe 1.0.0 run: the machine had Homebrew\u0027s 0.8.0 on PATH, released before the\npnpm resolver, so every package resolved through npm\u0027s layout and came out as\na `node_modules/.pnpm/…` path rather than its name — 2,976 such rows, plus\nduplicates and a `SEE LICENSE IN LICENSE` group that the same release fixed.\n\nCI already installed a pinned commit; only the release script did not, and it\nmerely checked that SOME license-eye existed. Both now read the SHA from\n`.license-eye-version`, so the two cannot drift apart again. The release\ninstalls it into `scripts/.release-tools` — deliberately NOT under the work\ndirectory, which is wiped when the clone is made, halfway through the run.\n\n`go` joins the required-tool check, since the install needs it, and\nlicense-eye leaves it: a version from PATH is no longer used at all."
    },
    {
      "commit": "a6618ff2237a6d2193ec92f771df2b6707746e15",
      "tree": "94c1ccd21c1bee80badc4e6ecaf8fa48b98b5c48",
      "parents": [
        "2db7e3578da315cf9abfa6ff5f1fdd564eca7198"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Tue Aug 25 08:20:58 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Aug 25 08:20:58 2026 +0800"
      },
      "message": "test(ui): guarantee a usable localStorage for every test file (#133)\n\nA release run failed with `TypeError: localStorage.clear is not a function` in\nall four UI test files that touch storage, 22 tests at once. jsdom installs\n`localStorage` per environment and tears that environment down between files;\nunder the load the release creates — three workspace suites at once, straight\nafter both app builds — a file ran against a global whose Storage methods were\nalready gone. Node has no `localStorage` of its own, so this is jsdom\u0027s,\nmid-teardown, rather than a fallback leaking through.\n\nIt reproduced once and not in a dozen repeat runs of the same commit, the same\nclone and the same build-then-test order, so it is a timing race rather than a\ndefect in those tests — and it fails a release rather than a PR, because that\nis where the load is.\n\nThe setup file installs a Storage only when the ambient one is unusable, so a\nhealthy environment still exercises jsdom\u0027s own. The path is absolute because\n`test:unit` passes `--root src/`, under which a relative one resolves to\n`src/vitest.setup.ts` and every suite fails to load."
    },
    {
      "commit": "2db7e3578da315cf9abfa6ff5f1fdd564eca7198",
      "tree": "a571709aa7608d9b0d14243a068189838ed656bb",
      "parents": [
        "acf584bfc59b0a758669df4a5fde2e9e0df7e40f"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Mon Aug 24 23:41:24 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Aug 24 23:41:24 2026 +0800"
      },
      "message": "fix: defects found reviewing for 1.0 (#129)"
    },
    {
      "commit": "acf584bfc59b0a758669df4a5fde2e9e0df7e40f",
      "tree": "d25e1656da13384b6c3d88dfe92d50dcbc47e084",
      "parents": [
        "a6fba5427554cd52e9d3ef5d3e2200d2efe88159"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Mon Aug 24 16:54:33 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Aug 24 16:54:33 2026 +0800"
      },
      "message": "chore(license): generate the binary LICENSE with license-eye (#128)\n\nThe binary tarball\u0027s LICENSE, NOTICE and per-dependency license texts were\nproduced by two bespoke scripts totalling ~560 lines. license-eye now reads\npnpm workspaces (apache/skywalking-eyes#282), so it can do the job the same\nway the skywalking repo does it: render the LICENSE from a template, and let\n`git diff` against the committed copy be the drift gate.\n\n`pnpm licenses:bin:update` regenerates the file; `pnpm licenses:bin:check`\nregenerates and fails on any difference. Both the pre-tag gate and CI run the\ncheck, so a dependency added or removed without refreshing the LICENSE is\ncaught. The release script now COPIES the reviewed bytes into the tarball\nrather than regenerating them at package time, so what ships cannot differ\nfrom what the vote saw.\n\nThe dependency set is unchanged: 364 packages before and after, none added,\nnone lost, multi-version packages still listed once per version.\n\nTwo things move from generated to hand-maintained, which CLAUDE.md now\ndocuments alongside the workflow for adding a dependency:\n\n- licenses/ carries the full text only for licenses outside the standard\n  permissive set (MIT, ISC, BSD-2-Clause, BSD-3-Clause, 0BSD, Apache-2.0):\n  five BlueOak-1.0.0 packages, two OFL-1.1 fonts, and the dual-licensed\n  type-fest, dompurify and robust-predicates. Ten files rather than 332.\n- NOTICE is static. It previously claimed no bundled package ships a NOTICE\n  file, which was wrong — echarts ships one — but that NOTICE carries nothing\n  beyond its own copyright line, which ours already states, so there is\n  nothing to carry across and the false claim is gone.\n\n`.licenserc.yaml` loses both of its stale entries: the `excludes:` block never\napplied to Node projects at all, since no Node resolver consults it, and the\nhand-written license for @pmndrs/pointer-events is no longer needed now that\nlicense-eye reads the file its package.json points at.\n\nlicense-eye is installed from a commit rather than v0.8.0 because pnpm support\nlanded after that release; the CI comment says to move to a tag once one ships."
    },
    {
      "commit": "a6fba5427554cd52e9d3ef5d3e2200d2efe88159",
      "tree": "4070d8e53ead6befbeac6645a1a981a6fbf27937",
      "parents": [
        "4aeb6c5989501cee641e500d778251bf1b2a8aff"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Mon Aug 24 08:08:21 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Aug 24 08:08:21 2026 +0800"
      },
      "message": "feat(audit): a Token usage tab, counted per token per hour (#127)"
    },
    {
      "commit": "4aeb6c5989501cee641e500d778251bf1b2a8aff",
      "tree": "2e47e89edccb658070d755a374d0ecf3af35a7d5",
      "parents": [
        "4b4303ce6327f135095eb3dbe1951cb5d2df6401"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Aug 23 15:22:30 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Aug 23 15:22:30 2026 +0800"
      },
      "message": "fix(audit): match a login id exactly, and give every statement its own bound (#126)"
    },
    {
      "commit": "4b4303ce6327f135095eb3dbe1951cb5d2df6401",
      "tree": "d5cd1fd55b4cc207f4691b0e9041412f6e6e998a",
      "parents": [
        "01ccef391df635c82f48049460d5ceea389f88a1"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Aug 23 13:48:18 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Aug 23 13:48:18 2026 +0800"
      },
      "message": "feat(audit): optional login audit log, backed by a shared store (#125)"
    },
    {
      "commit": "01ccef391df635c82f48049460d5ceea389f88a1",
      "tree": "df456557530f148c0956e516343ff38c34133935",
      "parents": [
        "bb3cf036e43d935761e5301fb90644693e74f228"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sat Aug 22 07:31:11 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sat Aug 22 07:31:11 2026 +0800"
      },
      "message": "fix(config): make a hot reload change exactly what it says it changes (#124)"
    },
    {
      "commit": "bb3cf036e43d935761e5301fb90644693e74f228",
      "tree": "15c6d969abc4e363f6937d84de393f901608b380",
      "parents": [
        "66ddf68beea927989d64f75adf0f51be83958596"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Fri Aug 21 09:20:02 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Aug 21 09:20:02 2026 +0800"
      },
      "message": "feat(ai): give an MCP agent the whole capture, and the words to read it (#123)\n\nAn agent reading Horizon over MCP got a summary Horizon had computed for it —\na median, a p95, a bucket table — and could only answer the questions whoever\nwrote that summary had thought of. Every tool now returns the capture itself:\none payload, in structuredContent under one key, as a flat envelope carrying\nthe tool, the block kind, when it was read, the OAP server\u0027s UTC offset, the\nlayer, and then `spec` (what the readings ARE — title, unit, the MQE, the tip,\nthe operator\u0027s own threshold bands) beside `data` (the readings). Nothing is\nsummarised server-side, and a per-kind note in the reply says how to read that\nshape, so the model works from rows rather than from prose about rows.\n\nPresenting them is the client\u0027s job, and what an agent should do depends on\nwhere it is. A host that can mount a frame is pointed at the card bundle and\ndraws the same components the web UI draws. A terminal agent has no frame, and\nHorizon does not try to draw for it: `presentation.terminal.md` teaches the\nform per kind — a time series described in words, top-N and labelled metrics as\ntables, every dependency map as a nodes table plus a calls table, a profile as\na ranked list of the frames holding the time. Traces are summarised and the\noperator is asked which one is worth opening, so ten span trees are never\nprinted at once.\n\nThe card pointer now rides only on replies that carry a card. It rode on every\nreply with structured content, which is all of them, so a host mounted an empty\nframe for each list_services call and the widget correctly reported it had been\nhanded nothing to draw.\n\nTwo capabilities the maps were missing. show_layer_topology reads a whole\nlayer — every service and every call between them — in one call, where the only\noption before was to centre on one service or walk them one at a time,\nrepeating edges and never learning how many services there were. And the\nexisting maps now take what their own views take: several services seeded into\none graph with a depth (two hops by default, since naming the services already\ntells you who they are), and an API-dependency chain pinned to a named\nendpoint. A name that matches nothing says so instead of reporting that the\nservice has no endpoints.\n\nA deployment that only configures single sign-on gets a login page that says\nso: no username and password boxes, since every credential they could take\nwould be rejected. Where password login is also configured the providers fold\ninto one picker, so the card is the same height with one provider or nine;\nwhere SSO is the only way in, up to four get their own button and the rest fold\nbehind \"More\", in the order auth.sso.providers lists them."
    },
    {
      "commit": "66ddf68beea927989d64f75adf0f51be83958596",
      "tree": "b307b203eb6e6c44624dfa10f13ff707170d963c",
      "parents": [
        "a6b8ff44bf75bf32add7fc8281132f0db05127b0"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Wed Aug 19 13:50:16 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Wed Aug 19 13:50:16 2026 +0800"
      },
      "message": "feat: serve Horizon over MCP, and sign in through an identity provider (#122)\n\nTwo capabilities that meet on the login page, plus the security work that\nmade them safe to ship.\n\nMCP — an external agent reads this Horizon\n------------------------------------------\n`POST /api/mcp` exposes the assistant\u0027s own tool registry over the Model\nContext Protocol, so Claude Code, Codex or Claude Desktop can investigate a\ndeployment with the model staying on the caller\u0027s side. Tools are read-only\nand say so through annotations, which is what stops a host prompting for\nconsent on every call. Results carry a rendered card for hosts that can mount\none and a text rendering for those that cannot; spans are stripped from the\nmodel-facing copy, which is 98.5% of a trace card.\n\nSingle sign-on — Horizon as an OAuth client\n-------------------------------------------\n`auth.sso` hands the login to any OpenID Connect provider, and to plain OAuth2\nproviders that never adopted it. There is no per-provider code and one\ncallback address for all of them. Password login keeps working alongside, so a\nmisconfigured provider cannot lock you out during an incident.\n\nAn authorization server — Horizon as the issuer\n-----------------------------------------------\n`oauth` lets an MCP client send its operator through Horizon\u0027s own login page\ninstead of being handed a token. Nothing is stored to make it work: the client\nregistration, the authorization code and the tokens are all signed values.\nOff by default, and it refuses to start without a signing key worth the name.\n\nIdentity, and what it is allowed to prove\n-----------------------------------------\nRoles resolve from a username on every request, so no credential outlives its\nowner\u0027s access. That constraint shapes the rest: one role table rather than one\nper provider, since a token carries no record of which provider issued it; the\naddress namespace belongs to single sign-on, so neither a local account nor a\ndirectory entry can answer for it; and a `kind: oauth2` provider must prove the\naddress it reports is verified, or Horizon refuses to start — an unproven\naddress is not an identity when roles resolve from it.\n\nSecurity fixes found by review\n------------------------------\n- an unverified, self-asserted address could be presented as anyone\u0027s identity\n- an LDAP hiccup granted break-glass admin to a standing bearer token\n- a forged flow cookie could start a sign-in as the attacker in someone else\u0027s\n  browser, and redirect them off-site afterwards\n- four IPv4-in-IPv6 notations walked past the metadata SSRF guard, NAT64 among\n  them — the form that reaches a cloud metadata service\n- an unauthenticated endpoint echoed internal hostnames and private addresses\n- with RBAC off, a deleted user\u0027s token still authenticated and held every verb\n- declining on the consent screen navigated to an address a stranger registered\n- a subdomain inherited its parent domain\u0027s roles on a shared issuer\n\nAlso: the audit trail is removed — it promised a security record a file per pod\ncould not deliver, and the application log already carried the events that\nmatter. Website links, heading anchors and menu coverage are now checked in CI."
    },
    {
      "commit": "a6b8ff44bf75bf32add7fc8281132f0db05127b0",
      "tree": "ffc8e1208c36639fad9b82a7578b6b930ddb8490",
      "parents": [
        "22ab25bd7a52c6a8b5d39a13256881fc978f8ed4"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Mon Aug 17 09:32:19 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Aug 17 09:32:19 2026 +0800"
      },
      "message": "feat: extension pages, entity filters, and an operator-defined layer menu (#121)\n\nA layer component can carry more than one dashboard page. Service,\nInstance and Endpoint each keep their existing grid as the DEFAULT page —\nsame route, same widgets, same translations — and may declare extension\npages beside it, each with its own id, name, widgets and place in the\nmenu. No bundled template declares one, so every layer that exists today\nresolves exactly the rows it did before.\n\nA page can say which entities it is about. Any entity page narrows the\nservice list; an Instance page also narrows the instance list and matches\non instance attributes, through the same evaluator the widget-level\n`visibleWhen` gate uses, so one vocabulary answers both questions. The\nDEFAULT page filters too — it is the page every component already has,\nnot an unfiltered one — and its filters live in their own block because\n`dashboards.\u003cscope\u003e` is a bare widget array with nowhere to hang them.\n\nThe filter is configuration, not a control. The rendered page shows the\nresult and never the pattern: the reader did not write it, and the page\u0027s\nname is what tells them what it holds. It is authored and checked in the\nadmin, where a preview lists what the filter selects out of what exists —\nbecause seeing what a pattern EXCLUDES is what makes it checkable.\n\nAn operator can order the menu. Dragging is a mode that only makes\nentries draggable; moving one records the order, and an order equal to\nthe built-in one is deleted rather than stored, since absence already\nsays it.\n\nIdentity is the id, never the label. It is asked for once when the page\nis created and never moves again, so a rename cannot change a URL or\nstrand the translations stored against it — which is also why duplicate\ndisplay names are legal, and why the admin shows the id beside them.\n\nThe BFF serves pages through the same contracts as the default grid, and\nrefuses a page that does not resolve rather than answering with the\ndefault one. Menu rows resolve in a single shared resolver the runtime,\nthe sidebar and the admin preview all read, so a previewed draft and a\npublished layer cannot disagree about what a layer contains."
    },
    {
      "commit": "22ab25bd7a52c6a8b5d39a13256881fc978f8ed4",
      "tree": "b630c8cd1a24899920ab11445750dd1eaa196ef7",
      "parents": [
        "38dc14f63cce9459acadef0e4c639ffa3c4defa5"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Aug 13 18:42:59 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Aug 13 18:42:59 2026 +0800"
      },
      "message": "fix(i18n): address template translations by widget id, not array position (#120)\n\nA translation overlay mirrored the source template\u0027s tree and matched\nwidgets by their index in the dashboard array. Reordering, inserting or\ndeleting a widget in the English source therefore slid every later\ntranslation onto the wrong widget — silently, and only in the languages\nthe author doesn\u0027t read. The same hazard applied to a catalog exported\nfrom one OAP and imported into another whose dashboard had since been\nre-laid-out.\n\nOverlay entries now carry the source widget\u0027s `id` and are merged by it.\nAn array is id-addressable only when every entry is an object with a\nunique non-empty string id; everything else stays positional, which\nkeeps expression labels, table headers, an overview tile\u0027s KPI rows, a\n`tab` widget\u0027s panels, and the deliberately id-repeating\n`deployment.roleToRole[].metrics` behaving as before. An overlay id\naddresses a source entry and never becomes one, so a catalog cannot\nrename the widget or the dashboard it translates. Widgets nested in a\n`tab` panel are addressed too.\n\nThe merge moves to `@skywalking-horizon-ui/api-client`, beside the\nexisting template-shape helpers, so the render path and the admin\neditor\u0027s preview cannot disagree about what a locale shows. Legacy\noverlays carrying no ids still merge positionally, so nothing has to be\nmigrated to keep rendering.\n\nThe 322 bundled catalogs are migrated by the seeder, which stamps ids on\nboth sides before merging — that stamping is the migration — and prunes\nsubtrees that translate nothing, since scaffolding the editor never\nreproduces reads to it as an unpushed edit. Entries it cannot place are\nreported rather than dropped in silence. Migration changes exactly one\nrendered string across all 322 (template, locale) pairs, under both the\nnew merger and the previously released positional one: a BanyanDB table\nheader the lexicon can now fill, whose widget title was already\ntranslated. A migrated catalog is therefore safe to publish to an OAP\nthat older instances read.\n\n`i18n:validate` gains findings for an entry addressing a widget the\nsource no longer has, two entries claiming the same widget, an array\nmixing id-addressed and positional entries, and a catalog that has not\nbeen migrated. Both CLIs already had a directory-scoped entry point;\nexporting it and guarding `main()` against import makes them testable\nagainst temp fixtures, matching how `bundled-validate` is split. The\nvalidator\u0027s negative cases are the point — a run over the shipped\ncatalogs only proves the happy path — and the seeder\u0027s are the\nproperties a bad rewrite breaks silently.\n\nThe Translations page gains one precedence contract, fixing several ways\nit showed something other than the truth. Sources are layered in a\nsingle ordered pass, because each pass treats what the previous one\nwrote as untouchable in-progress work: the baseline, then the operator\u0027s\nstaged local draft, then whatever is being typed. Applying them in\nseparate passes made the FIRST source win, so a staged edit was replaced\nby the published wording on reload and the next push wrote that wording\nback and discarded the staged copy. The baseline follows the same rule\nas the templates themselves — the remote row wins, and the disk catalog\nreaches the runtime through exactly two doors, `templates.mode:\nreadonly` and the explicit reset-to-bundled preview. It used to sit\nunderneath the OAP row, so a field the published overlay didn\u0027t carry\nshowed the shipped translation while the live site served English, and\npublishing adopted it.\n\nTwo translatable fields were also missing from the editor\u0027s field\nwalker, and a field it omits is not merely hidden from the translator —\nit is dropped from every overlay the editor rebuilds, so publishing\ndeletes whatever the seeder had put there. `naming.alias` is the\ndimension label the topology and 3D map print beside a cluster, excluded\nas if it were a regex replacement though the capture groups are\n`displayGroup` / `valueGroup`; `tabs[].name` is a tab widget\u0027s panel\nlabel. The BFF seeder carried both all along."
    },
    {
      "commit": "38dc14f63cce9459acadef0e4c639ffa3c4defa5",
      "tree": "5db27823a9b1f7880217b35404901ee43a918668",
      "parents": [
        "e7dbb307e1abbe47f54970d7e793473c2460fcb1"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Aug 13 11:01:47 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Aug 13 11:01:47 2026 +0800"
      },
      "message": "test(e2e): make the tightened assertions prove what their comments claim (#119)\n\nAn adversarial audit of the assertions added in #118, plus review of the fixes,\nfound assertions that pass on a build they describe as broken, harness gaps\nthat stop whole specs from failing, and comments stating a reason that is\nfalse. The suite was green throughout — none of this is something a run can\nsurface.\n\nSix assertions were vacuous. The layer-dashboard roster is the editor\u0027s\nbundled-preview source, and the disk bundle otherwise reaches the runtime only\nthrough initialization and read-only mode — so those rows render identically\nwith OAP\u0027s template store unreachable and can never be evidence about it; the\nOAP half now comes from the row\u0027s sync badge, which compares the bundled copy\nagainst the stored one and admits both `synced` and `diverged`. The overview\nKPI check required a digit from the tile\u0027s service tally, always numeric and\n\"0\" for a layer OAP never answered for, instead of the MQE-bound rows. A\n`.time-chart` renders for an all-null series, so the instance tab now counts\nwidgets: 71 of its 74 are gated on an `exists` MQE and are dropped when the\nmetric returns nothing, making anything above the 3 ungated ones proof that\ninstance-scope metrics arrived. The deployment sparkline was unscoped across\nthe pair\u0027s seven metrics, re-admitting the `bytes`/`err` rows the readiness\nfilter had just been narrowed to exclude. The MAL record assertion could not\nfail once the line above it passed, and now requires captured steps. The\nvisibleWhen gate check took its absence test and its positive control as\nseparate DOM sweeps, so a grid unmounted by the refresh tick satisfied both;\nboth counts now come from one snapshot.\n\nThe deployment case failed on a healthy cluster because its two halves\ndisagreed about \"ready\": Sparkline needs two finite points to have a line to\ndraw, while the readiness check opened on one. The check now waits for two.\nWhich edge carries the writes is a matter of how the cluster hashed the data,\nso the spec no longer pins a row — it reads the Write/s and Query/s values the\nFlows table already prints per edge, and tries each reporting edge until one\ndraws. That pair can hold four edges, so the test states its own budget with\nthe arithmetic behind it rather than relying on the default.\n\nTwo harness gaps. The diagnostics recorders attached only to the default page,\nso specs that open their own page asserted against an array nothing wrote to;\nthey now attach per context. Its regex matched two strings Vue emits only in\ndev builds and only via console.warn, while pinning two error classes let every\nother class blank a page silently — it now matches the error class generally,\nwhich is what Vue\u0027s production handler prints.\n\nThe widget-form coverage guard moves out of the browser project into\nscripts/check-e2e-widget-coverage.mjs, run by `pnpm lint`. It drives nothing\nand observes nothing, so it cost a browser and a full OAP + BanyanDB stack to\nanswer a question about two files on disk, and failed the UI e2e project for a\nspec-maintenance problem. It checks the SELECTOR, not just the key: a blank one\nkeeps the key while the spec\u0027s dispatch treats the form as absent and skips it,\nleaving both sides green and the form uncovered — which the spec now refuses\ntoo, failing explicitly instead of moving on. Adversarial fixtures run on every\ninvocation, so a gate that loses a check refuses to run rather than reporting\nzero findings.\n\nTwo comments were wrong: the layout spec imported the diagnostics `test`\nwithout destructuring `pageErrors`, so the lazy fixture never attached, and the\ncapture-history rationale described a failure the old locator could not have\nhad.\n\nValidated against a live console where data was needed: the GENERAL row carries\na `diverged` badge, so pinning `synced` would fail on a healthy system; 12 of\n15 KPI rows bind a value; the instance grid renders 17 widgets."
    },
    {
      "commit": "e7dbb307e1abbe47f54970d7e793473c2460fcb1",
      "tree": "63c0825dbbb74cd0318123f67e83013155b7a7fb",
      "parents": [
        "8a292369d668f152811793087082114231400e0d"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Aug 13 08:49:36 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Aug 13 08:49:36 2026 +0800"
      },
      "message": "feat(security): content-security-policy, outbound-link policy, and CI security gates (#118)"
    },
    {
      "commit": "8a292369d668f152811793087082114231400e0d",
      "tree": "250c82888f79d8fbd5d409f24b861789899a8b51",
      "parents": [
        "537ad5f7e1c38330d238fe8eeed743884e2e070c"
      ],
      "author": {
        "name": "songzhendong",
        "email": "xiaodong12315@qq.com",
        "time": "Mon Aug 10 09:08:01 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Aug 10 09:08:01 2026 +0800"
      },
      "message": "feat(ui): add six Node.js runtime dashboard panels (6 -\u003e 12) (#116)"
    },
    {
      "commit": "537ad5f7e1c38330d238fe8eeed743884e2e070c",
      "tree": "160ec4c6da16d5b445d7d24d38e7f79cb66a95d3",
      "parents": [
        "e1c1363fe6c598a148648531653e9f72a423614e"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Fri Aug 07 17:26:40 2026 -0700"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sat Aug 08 08:26:40 2026 +0800"
      },
      "message": "feat(dsl): the meter-analyzer-config catalog, and stored-log content search (#117)\n\n`meter-analyzer-config` is now a catalog like the others: a **MAL · Meter** page under DSL management with the same browse / edit / override / inactivate / revert flow, its rules in the live debugger\u0027s MAL picker, and Inspect attributing metrics to their rule file as `MAL·Meter`.\n\nThe catalog list is static, so the menu entry is always present. An OAP that doesn\u0027t know the catalog answers with its own reason, and the page now shows it — `400 invalid_catalog — catalog must be one of [lal, telegraf-rules, otel-rules, log-mal-rules]` instead of a bare \"could not load\", which is the difference between \"my backend is too old\" and \"the request failed\". Everything around it degrades rather than breaking: the debugger\u0027s picker keeps offering the three catalogs that resolve, and Inspect\u0027s attribution fan-out drops the one that 400s. Verified by running this build against the previous pin.\n\nThe Logs tab gains a **Content** field — space-separated words the line must contain, AND-joined — that appears only on a storage backend that can answer it. On one that can\u0027t, the field is absent rather than disabled: OAP accepts the condition there and returns the unfiltered stream, so a search box would answer every query with \"everything matched\". The capability is read once per OAP and cached with the existing probe; asking is guarded by schema introspection so an older backend never sees an unknown field.\n\nThe fixture turns the Java agent\u0027s meter sender on (`SW_METER_ACTIVE`), which is what gives `core` a live MAL rule to sample. Three rule files in that catalog have a producer there — `java-agent`, plus HikariCP\u0027s `datasource` and Tomcat\u0027s `thread_pool`."
    },
    {
      "commit": "e1c1363fe6c598a148648531653e9f72a423614e",
      "tree": "1a6d799f1578c985abb42987d8ccb4514c232177",
      "parents": [
        "a1d14784e183938cb39e27a4d1529b98089ed55f"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Aug 06 18:02:53 2026 -0700"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Aug 07 09:02:53 2026 +0800"
      },
      "message": "test(e2e): live debug that CAPTURES — LAL and OAL on core, MAL on deployment (#115)\n\nThe suite proved the live debugger mounts, and that a session starts on the\nwire. Neither says a record ever reaches the screen, which is the only thing\nthe feature exists to do.\n\nLAL samples the logs the demo services emit, and the fixture emits those as a\none-off burst during setup — a capture started afterwards sees nothing. The\nspec drives the provider\u0027s log endpoint from inside the page while the session\nis live, so records flow THROUGH the rule while it is sampling, then filters\nthe matrix by content and clears it again.\n\nMAL belongs to `deployment`. The debugger lists only otel-rules, log-mal-rules\nand telegraf-rules, and the one fixture that feeds any of them is the OTel\ncollector standing beside the BanyanDB cluster.\n\nOAL enters from the catalog rather than from the debugger\u0027s own controls,\nbecause the OAL tab has no rule list to pick from — a metric is any LHS in any\nloaded .oal file, so the control is free text. The spec takes the operator\u0027s\npath: pick the file by name, find the `service_cpm` statement by its CONTENT\n(core.oal is upstream\u0027s file and its line numbers move whenever a metric is\nadded above), click the green arrow in its gutter, and assert the deep link\nfilled the picker. Then it asserts the capture came back keyed to that metric\nand that a captured Service source row carries a fixture service name — a\nwaterfall renders its steps with empty payloads exactly like a working one\nuntil a value is read out of it.\n\nThe case table records all three, naming the capability rather than the\ncomponent, so the entry a case echoes before it boots says which DSL it\ncaptures and which only mounts the page.\n\n`make -C test/e2e run CASE\u003d\u003ccase\u003e` never worked: it ran the CLI from test/e2e\nwhile every case spells its scripts from the repo root, so a case died on its\nfirst step with a \"No such file or directory\" that read like a missing script\nrather than a wrong directory. The targets run from the root now, and the\nheader advertises the dev targets that exist instead of setup/cleanup, which\ndo not."
    },
    {
      "commit": "a1d14784e183938cb39e27a4d1529b98089ed55f",
      "tree": "2932911d1c4c3f060f5d290d3de095680bcd1c3d",
      "parents": [
        "a6cb441518be4c7826af04d0535b9ce9735af3dc"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Aug 06 05:45:00 2026 -0700"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Aug 06 20:45:00 2026 +0800"
      },
      "message": "test(e2e): make the metric aggregation-mode spec fail without the fix (#114)\n\n* test(e2e): make the metric aggregation-mode spec fail without the fix\n\nThe spec opened whichever widget came first on the bundled Services\nDashboard — a kpi-tile, which has always carried the aggregation-mode\ncontrol — and then asserted the radios existed. It therefore asserted nothing\nabout `metric` and passed against a build where the control was still\nwithheld from that type, which is visible in the run logs from before the\nimage was rebuilt. A guard that passes without the fix guards nothing.\n\nIt now ADDS a metric widget through the composer, scopes its assertions to\n`.ot__widget--metric` (the drawer\u0027s per-type modifier) and requires exactly\ntwo mode radios there. It also covers the other half of the fix, which\nnothing asserted: the Aggregation select is absent in server-side mode, where\nit is never read, and appears once page-side is chosen.\n\nVerified in both directions — against the pre-fix drawer it fails with\n\"Expected: 2, Received: 0\"; against the current one the case is 10/10."
    },
    {
      "commit": "a6cb441518be4c7826af04d0535b9ce9735af3dc",
      "tree": "112d375cadf5e8859918ba5abee2a75a7812b698",
      "parents": [
        "14628d0c1da65cba9cdbd8c24781acf9fdd63f71"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Wed Aug 05 22:37:56 2026 -0700"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Aug 06 13:37:56 2026 +0800"
      },
      "message": "fix(overview): the Metric widget\u0027s form no longer sets you up for an empty tile (#113)\n\nAdding a Metric widget — the first kind the composer offers — could render a\npermanent em dash with nothing to explain it. Three separate reasons, each\ninvisible from the form:\n\nThe MQE placeholder suggested `service_cpm`, a plain per-service metric. In\nthe default server-side mode the expression is fired ONCE with no service\nentity, so it has to collapse the layer itself; a plain metric matches nothing\nand OAP answers with an empty result and no error.\n\nThe Aggregation dropdown was shown but never read: `aggregation` is consulted\nonly when the page aggregates, and that mode was gated to kpi-tile and\nmetric-composite even though the renderer has always honoured `aggregateOnPage`\nfor `metric`.\n\nA widget left on layer \"— any —\" is skipped by the renderer outright\n(`if (!layer) continue`), and \"— any —\" was the default.\n\nSo: the mode control is offered for `metric` too, the MQE placeholder follows\nthe chosen mode, the aggregation choice appears only where it applies, the\ninert `aggregation` seed is gone, and the layer option that guarantees no data\nis no longer offered for the widgets that read metrics.\n\nCovered by two specs. `core` authors a dashboard of its own, pushes it to OAP,\nwaits for the write to become visible and reads the tile back — asserting the\nVALUE, since an unusable MQE renders the same tile with an em dash. `admin`\nasserts the form offers both modes. The value assertion lives in `core`\nbecause only that fixture has traffic; the admin stack runs no demo app, so a\nmetric there correctly renders nothing.\n\nAlso: kind cases name Docker Hub images the way the daemon lists them. Spelled\n`docker.io/...` the \"already present?\" check misses an image sitting on the\nmachine and pulls anyway, so a run needed registry reachability it should not\nneed — and failed with Hub down while nothing was actually missing."
    },
    {
      "commit": "14628d0c1da65cba9cdbd8c24781acf9fdd63f71",
      "tree": "437ec21671adf7f4eb3723713ccb66a4a3b93bc9",
      "parents": [
        "4d16040359a1e08b631a97095aca35566e853262"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Wed Aug 05 18:36:38 2026 -0700"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Aug 06 09:36:38 2026 +0800"
      },
      "message": "ci(e2e): a BanyanDB-cluster deployment case, and the widgets nothing asserted (#112)\n\nAdds the sixth deployment scenario and closes the widget-coverage gaps an\naudit of the suite turned up.\n\n`deployment` stands up BanyanDB as a real CLUSTER — its own helm chart, two\nliaisons, two data nodes, FODC enabled — with an OTel collector scraping the\nFODC proxy into OAP\u0027s banyandb rules. Nothing else in the suite could produce\nit: a standalone node has no roles to draw and no FODC to report them. It is\nthe first case anywhere to render a `table` widget, and the only one that can\nreach the Deployment tab\u0027s edge sparklines, which need two roles to draw\nbetween.\n\nThree things about that pipeline are load-bearing and fail in ways that point\nelsewhere, so each is documented where it is configured: the scrape job must\nbe named `banyandb-monitoring`; the scrape interval must be finer than the\nrules\u0027 PT15S rate windows, or every rate reads 0, no samples are produced and\nthe SERVICE is never created; and the FODC proxy — not the per-node endpoints\n— is the target, because only the proxy stamps the pod / container / role\nlabels the tab groups by.\n\nWidget coverage: trace detail was five renderers behind one click with only\n\"the pane appeared\" asserted — the duration scatter, waterfall, d3 tree,\nstatistics roll-up and span modal are now each asserted. Overview dashboards\nhad no spec at all, so one page covers KpiTileWidget, MetricCompositeWidget,\nAlarmsWidget and SectionBreak. Plus the alarms timeline, the Explore\nbrowser-error popout, and both trace deep links opened COLD in a fresh page —\na different path from clicking a row, and the one a shared link takes.\n\nFixes an overview-editor bug found while testing: an unsaved widget followed\nthe operator to another dashboard, where it read as — and would have been\nsaved to — the wrong template. The editor\u0027s dirty guard protects against a\nbackground refetch clobbering edits; it must not suppress the reseed when the\noperator picks a different dashboard.\n\nThe helm chart pin moves past 3aee62a (`helm --wait` deadlock against OAP\u0027s\ninit job) and every image a kind case runs is side-loaded, including the\n`-slim` and FODC tags the cluster chart pulls."
    },
    {
      "commit": "4d16040359a1e08b631a97095aca35566e853262",
      "tree": "ad0e0a38c240578ebea39dace5a94a768e03e5c7",
      "parents": [
        "72a841019df6fdc874eb00cdeb4b2577865d2a22"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Tue Aug 04 19:44:26 2026 -0700"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Aug 04 19:44:26 2026 -0700"
      },
      "message": "ci: browser-driven e2e across five deployments, and the principles behind it (#111)"
    },
    {
      "commit": "72a841019df6fdc874eb00cdeb4b2577865d2a22",
      "tree": "f9bf6fea49e4e2bf2711d34c82318a431d6682fd",
      "parents": [
        "701c8042e5c2a6549b8778f2ae15cfd5de3509bd"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Mon Aug 03 20:31:46 2026 -0700"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Aug 04 11:31:46 2026 +0800"
      },
      "message": "ci: cache swctl, cover DSL from core, and add the ES and Zipkin deployments (#110)\n\nFour changes, in the order they pay off.\n\nswctl is built from source on every e2e run because skywalking-cli publishes\nno binaries — 66 seconds, measured, and the largest fixed cost in the case.\nIt is now cached on the pinned commit rather than on the file holding it, so\nbumping an unrelated image pin does not throw the compiler back at us. The\ninstaller checks the bin directory before PATH: CI restores the cached binary\nstraight into it, and that restore has to count as installed regardless of\nwhether the PATH export step has run. Cache hit measured at 0.1s.\n\nDSL management and the live debugger fold into `core` for nothing: both OAP\nmodules are enabled by default and the case already exposes the admin host, so\nthey cost no containers at all. The assertion worth naming ties the fixture to\nthe feature — /api/catalog/bundled?catalog\u003dlal returns the very LAL rule\nbase-compose mounts, so the admin host is demonstrably reading the same\nconfiguration the log pipeline runs on, rather than the two being separately\nplausible. All read-only: pushing a rule would mutate the OAP every other case\nasserts against, and a half-applied hot-update would surface as an unrelated\nfailure elsewhere in the run.\n\nElasticSearch earns a deployment because Trace Query v2 is BanyanDB-only, so\nthis is the ONLY configuration where hasQueryTracesV2Support comes back false\nand the v1 path runs — no amount of BanyanDB testing reaches that branch. It\nis deliberately not a copy of core: re-asserting every signal on a second\nbackend would double the runtime to re-prove what storage does not affect.\nNote the backend assertion reads `other`, not `elasticsearch`: `backend` is a\ncapability classification inferred from TTL shape, not a storage name, and the\ndistinction it draws — BanyanDB capability set or not — is exactly the one\nthat decides whether v2 is available.\n\nZipkin earns one for a different receiver and a different query surface: the\ndemo app reports Zipkin spans straight to OAP rather than through a SkyWalking\nagent, and Horizon reads them over the Zipkin-compatible API instead of\nGraphQL. The reachability probe is asserted FIRST, because with the default\nlocalhost URL Horizon reports zipkinReachable false and the whole surface\nstays dark — which reads as \"no traces\" rather than \"wrong URL\". The Brave\nfrontend also calls its peer at the hardcoded hostname `backend`, so the\ndescriptive service name carries an alias; without it the frontend answers 500\nwith an NXDOMAIN and infra-e2e\u0027s trigger blocks forever waiting for a 2xx,\nturning a fixture bug into a job that burns to its timeout.\n\nThe three cases run as parallel matrix entries, so wall clock stays near one\ncase rather than their sum.\n\nVerified locally, all three end to end: core 7 verify cases, es 5, zipkin 2."
    },
    {
      "commit": "701c8042e5c2a6549b8778f2ae15cfd5de3509bd",
      "tree": "a6d775da3fe0b673900d3d240ea00fca7058745d",
      "parents": [
        "6af18c1af680271f1d42d344eafd43aab307a383"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Tue Aug 04 08:07:00 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Aug 04 08:07:00 2026 +0800"
      },
      "message": "ci: one job for every build and check, plus an end-to-end suite on a real OAP (#109)\n\nCI ran ten jobs where eight did the same checkout, the same install, and then\na gate finishing in seconds. Two were duplicate work outright: `pnpm package`\nalready builds the workspace packages, the BFF bundle and the UI, so the\nseparate build jobs recompiled what packaging compiles anyway, and `pnpm lint`\nalready ends with the bundled-template validator the templates job re-ran.\nThose collapse into one \"Build and check\" job, steps ordered cheapest-first so\na lint error reports before the package build starts. License-header stays\nparallel: it needs neither Node nor an install.\n\nOne ordering constraint the parallel layout hid — collect-dist-licenses writes\nits LICENSE / NOTICE / report into dist/, and `pnpm package` begins by wiping\nthat directory, so a naive sequential merge would have deleted the artifact\nupload\u0027s own inputs. Licence verification now runs after packaging, which is\nalso the truthful order: the uploaded report describes the artifact that was\nbuilt.\n\nNothing in CI ran Horizon against a backend. A green type-check proves the\ncode compiles; it does not prove a widget resolves its MQE against live data,\nthat the trace list renders what the BFF returned, or that a view mounts at\nall. test/e2e/ now stands up BanyanDB, OAP, two instrumented demo services and\nHorizon, and verifies BOTH halves: Playwright\u0027s request fixture asserts the\nBFF\u0027s HTTP surface, headless Chromium drives the rendered app. They are\nseparate projects sharing one login, so a red run says which half broke — a\nroute returning correct JSON into a view that throws on mount passes every API\nassertion and is blank for the operator.\n\nDriven by skywalking-infra-e2e, the harness the rest of the project uses:\nscript/env holds the image pins, script/docker-compose/base-compose.yml holds\nthe shared services, and each case extends the base with only its own ports\nand ordering. Cases are split by DEPLOYMENT, not by upstream fixture file —\nupstream\u0027s log case differs from simple/jdk by two OAP environment variables\nand a LAL rule, so giving it a stack of its own would spend a minute of\ncontainer boot to learn nothing. `core` therefore covers traces, metrics,\nlogs, topology and events from one fixture.\n\nThe demo app is apache/skywalking\u0027s own e2e provider/consumer. Upstream\u0027s\ncompose bind-mounts a Maven-built jar, which would drag a JDK and an upstream\ncheckout into this repo\u0027s CI; both halves are published images, so a two-line\nDockerfile joins the agent image and the service jar and the whole dependency\nbecomes registry pulls.\n\nThe browser runs inside a pinned Ubuntu image rather than on the host, in CI\nand on a laptop alike, so a local run reproduces CI exactly instead of\napproximately and the host needs nothing but docker. The container joins the\nfixture\u0027s compose network and reaches Horizon by service name, so no port\nmapping is threaded through.\n\nVerify cases are ordered so a failure localises itself: swctl asks OAP\ndirectly what it knows (if that fails the demo app is broken, not Horizon),\nthen Horizon\u0027s roster bridges \"OAP has it\" to \"the BFF serves it\", then\nreadiness gates for metrics and for the service relation, then the browser\nsuites. The gates matter because OAP persists on a ~25s cycle into minute\nbuckets: without them the wait lands on the most expensive case, which absorbs\nit by retrying — 58 retries measured, each re-running the whole browser\nproject. With them, zero.\n\nScreenshots, traces and container logs are collected on failure only, as\nevidence rather than as an oracle; no image is ever compared. Layout is\nguarded geometrically instead — widget boxes, tiling, no horizontal overflow —\nwhich needs no baseline and behaves identically everywhere. A failing browser\ncase also short-circuits infra-e2e\u0027s retry budget, which otherwise re-ran it\n40 times and overwrote the very screenshots it was meant to preserve.\n\nE2E lives in its own workflow, since it answers a different question at a\ndifferent cost than ci.yaml, and its header carries the case matrix. It\nreports on every PR without gating merges.\n\nVerified: the full case through the e2e CLI (7 verify cases) and 28 Playwright\nassertions, plus lint, type-check, licence headers and the packaged-artifact\nboot smoke."
    },
    {
      "commit": "6af18c1af680271f1d42d344eafd43aab307a383",
      "tree": "78df57787558217f0e34b03ac7392263fdb83278",
      "parents": [
        "091fb6d26c8b1f40fa811f51f20f6051ea7e4d8d"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Tue Aug 04 07:25:02 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Aug 04 07:25:02 2026 +0800"
      },
      "message": "fix: layer-template editors minting drafts their own push refuses, and the orphaned i18n-key backlog (#108)\n\n* fix: layer-template editors minting drafts their own push refuses, and the orphaned i18n-key backlog\n\nThe service-list column editor could leave the columns in a state publishing\nrejects, discovered only at \"Check diff \u0026 push\" several edits later. A new\ncolumn was named `metric_\u003ccount + 1\u003e`, so add / delete / add re-minted a name a\nsurviving column still held; deleting or renaming the column the default sort\npointed at stranded `orderBy` on an id no column carried; nothing stopped an\neleventh column, past what the service list can query; and an emptied `metric`\nor `label` is refused too. A new column now takes the lowest free id, the sort\nfollows a rename and falls back to the first column when its column is deleted,\nthe add button stops at ten, and an emptied required field is marked where it\nwas typed.\n\nThe same length-based id minting sits in the topology, endpoint-dependency,\nprocess and deployment metric editors and in the role list. No validator\nrefuses those, so the collision was silent: two configured rows alias onto one\nvalue at render, and a repeated role key makes the second role\u0027s metrics dead\nconfig. All of them now share one free-id helper, which also absorbs the\nprivate copy the widget canvas carried.\n\nThe UI i18n gate reported 81 orphaned English keys without failing, on the\ngrounds that a plain substring test cannot tell a dead key from one reached from\noutside apps/ui/src. Triaged: 14 were keys whose quotes are escaped at the call\nsite and so never appear verbatim in the source, 4 were the OAP module blurbs\nthe BFF authors and Cluster status renders through `t()`, and 63 were genuinely\ndead — deleted from all eight catalogs. The triage also turned up the\nui-management module\u0027s blurb, which was never in en.json at all and so fell back\nto English in every locale; it is now cataloged and translated.\n\nThe gate vouches for a key three ways — a parsed, unescaped `t()` literal, a\nBFF-authored label read exactly out of the preflight module table, or a\nsubstring of the source for keys reached dynamically — fails when a\nBFF-authored label is missing from en.json, and fails on an orphan instead of\nwarning. The label extraction is asserted against the module rows it reads, so\none label reformatted to a template literal fails loudly rather than half-\nrunning and reporting a live key as deletable. Keys carried only by the weak\nsubstring match are counted in the output so that residue stays visible.\n\n* fix: name the tooltip\u0027s action after the button the operator clicks\n\nThe two new required-field tooltips used the DSL-rule push verb in ja / ko /\nzh-CN, while the template editor\u0027s own button reads 公開 / 게시 / 发布 — an\noperator could not map the message to the control it referred to. de keeps\n\"per Push\", which is already how that flow reads there; es / fr / pt were\nalready aligned.\n\nAlso name the BFF constant the column cap mirrors, so a future bump is\ngreppable across the package boundary."
    },
    {
      "commit": "091fb6d26c8b1f40fa811f51f20f6051ea7e4d8d",
      "tree": "47381fabe1a61a7b70135412b577605450d3bf76",
      "parents": [
        "826998416efad7b386487198d8e356f4e37e451a"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Mon Aug 03 10:26:44 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Aug 03 10:26:44 2026 +0800"
      },
      "message": "fix: permissions that say what they do, no unbuilt promises, per-version changelog (#107)\n\nThe Roles board offered capabilities nothing checks, and two more that\ncontradicted themselves. \"Edit alarm rules\", \"Add / remove local users\",\n\"Change role grants\" and \"Read the audit log\" read as things you could\ndelegate: alarm rules are read-only upstream, users and roles are edited\nin horizon.yaml, and the audit trail is a file you ship elsewhere. All\nfour are now marked Reserved, declared by the server so the page cannot\ndrift, and the built-in operator role no longer grants alarm-rule:write.\nSix other permissions absent from the route table turned out to be\nenforced in a handler or as a nav gate; calling those reserved would\nhave been a fresh falsehood.\n\nA route\u0027s permission is checked before its handler runs, so when the two\ndisagreed the hook won and the handler\u0027s — the one the page and the docs\nare written against — never decided. Downloading a runtime-rule dump\nasked for a debug permission while its button, handler and docs all said\nread, so a read-only role saw an enabled button and got a 403; exporting\nrules is a read and is gated as one. The Live Debugger is now\nlive-debug:read to watch and live-debug:write to start and stop, and\nnothing else: rule:debug was demanded on top of every one of those\ncalls, which broke the documented read-only role, and it never permitted\nor denied a request on its own. It is gone from the vocabulary, the\noperator role, the board and the docs. That loosens a hand-written role\nwhich granted live-debug:write while withholding rule:debug — the\nchangelog says so plainly.\n\nTwo structural tests hold this: one derives enforcement from the real\nsources and fails when the board and the code disagree, the other fails\nwhen a route is gated on a permission its handler never mentions. The\nfirst was fooled once by a permission surviving only as an audit-log\nfield, so detection now requires something that can actually deny a\nrequest.\n\nThe runtime-rule page advertised a restore panel tagged \"later release\"\nand leaked an internal working name into shipped strings. There is no\nrestore — reverting a rule to its bundled version is the reset Horizon\noffers — so the panel is gone rather than reworded, and a lint gate now\nfails the build on any future-promise affordance. That string had also\ndrifted off its source literal, so every locale silently fell back to\nEnglish; rekeying it removes the name and restores the translation.\n\nChangelog notes move out of one 810-line file: every version owns\ndocs/changelog/\u003cversion\u003e.md, listed in the docs menu, with no root file.\nRelease reads that version\u0027s file and seeds the next cycle\u0027s empty one,\nreusing the version it already computes; both tarballs still carry a\nroot CHANGELOG.md copied at packaging time. The gate now requires the\ntitle to be the version being cut, since the extractor drops the first\nheading whatever it says.\n\nAlso corrected: the 3D map admin promised bundled defaults as a fallback\nlive mode deliberately removed, in eight languages; the local-user doc\ndescribed a per-request user lookup that does not exist, so removal now\nsays a held session survives until sign-out, TTL or restart; two paging\nsummaries still described per-request record caps; and the README\ncounted instrumentation layers, which goes stale. The release script\nkeeps version derivation in one expression so nothing pre-confirmation\nsurvives an operator correcting it at the prompt, and the UI i18n gate\nnow reports keys in en.json that no source mentions."
    },
    {
      "commit": "826998416efad7b386487198d8e356f4e37e451a",
      "tree": "55772a062f1403ea5155fc0edc57ac0797e0961f",
      "parents": [
        "ddfb356865b28598ce3aaa93738d2ddcb7b2b2b0"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Aug 02 08:33:41 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Aug 02 08:33:41 2026 +0800"
      },
      "message": "fix: publish identity, shared paging probe, live session TTL, and stale prose (#106)\n\n## Why\n\nFour defects found after the 1.0 review pass, plus a sweep of statements that had outrun the code.\n\n**A template could be stored under a name that is not what it holds.** Publishing `horizon.layer.general` succeeded, but the runtime only ever reads `horizon.layer.GENERAL` — so the push reported success and the row rendered for nobody. An alias key (`CACHE` for `VIRTUAL_CACHE`) was a second route to the same orphan. On the overview side the content\u0027s own `id` was never checked against the row it was published as, so a `services` row could carry a `mesh` dashboard. Rows already in that state were visible **nowhere**: no picker row, no badge, no conflict entry — at best an anonymous \"1 remote-only\" in a banner summary, while a mis-filed overview appeared under the *other* dashboard\u0027s id.\n\n**Paged lists reported the current page length as the global total.** The Logs footer could read \"showing 50 of 50 total\" on page 3 of 50, and a result that exactly filled a page left **Next** enabled onto an empty one. Reproduced on the live demo OAP: a 24-row window at `pageSize\u003d12` returns 12 rows on page 2, so Next stays enabled and page 3 is empty. The totals were never knowable in the first place — no OAP list read offers one (`queryAlarms` returns `msgs` alone).\n\n**Hot-reloading `session.ttlMinutes` changed the cookie but not the server.** The cookie was stamped from live config on every request; the store froze the TTL at boot. An operator shortening the window after an incident saw the new `Max-Age` and believed it applied, while the server kept accepting the session for the old window — four times longer, for anything holding the sid outside a browser.\n\n## What\n\n**One identity rule** for publishing: the row must carry the name its readers compute, and its content must declare that same identity. Publishing **refuses with the canonical form named** rather than normalising — the read side builds exactly one readable name and never searches for a near miss, so a non-canonical publish has no correct silent destination, and normalising would strand any pre-existing bad row invisibly. Rows already orphaned or mis-filed are now reported on the admin banner with their OAP record ids. The overview push path stops using a weaker legacy schema, which was neither stricter nor laxer but simply *different* — it missed unknown keys and empty required values while carrying four bounds the strict one lacks, so swapping wholesale would have dropped guards.\n\n**One shared paging probe.** Every paged surface over-fetches by one row and uses the extra row\u0027s presence as the has-next signal. Adopted by logs (and its facet sample), browser errors, native traces v1 and v2, alarms and the alarm count badge, events, and Explore/Inspect. Zipkin takes the page-one half only — its API has no offset parameter, so it can never take the page-N probe. Five surfaces deliberately keep what they have: they load complete lists and hold the *exact* total, where a has-next boolean would destroy information.\n\n**Session TTL is read live** on every expiry check — `touch`, `get`, and the reaper — matching the getter pattern already used for source maps and the wire log. Already-issued sessions are re-evaluated against the new value rather than grandfathered: the cookie half already did, and the model is an idle window from `lastSeenAt`, so no session ever carried a TTL to preserve.\n\n**Corrected statements** that had outrun the code: the image docs said a tag push publishes stable tags when it publishes only the sha digest; several comments still promised a bundled fallback that `live` mode deliberately removed; four CHANGELOG entries described a name-to-id resolver that no longer exists. The release gate matched the version heading as a regex, so the dots in `1.0.0` also accepted `1x0x0`. The packaged startup command had a line continuation followed by a comment — not valid shell — and named a config path the packaged folder does not contain."
    },
    {
      "commit": "ddfb356865b28598ce3aaa93738d2ddcb7b2b2b0",
      "tree": "8cdc46b1f34f65b02aea0f274129f8c7b638848b",
      "parents": [
        "2975fc3494578c2973ccbab52ff7799f4a799ec6"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sat Aug 01 21:02:44 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sat Aug 01 21:02:44 2026 +0800"
      },
      "message": "fix: carry service identity as an {id, name} pair, validate layer publishes, and stop bundled fallback in live mode (#105)\n\nfix: session isolation, live-mode config source, and service identity\n\nCached responses outlived the session. The query cache was never cleared\non logout, login, or a mid-session 401, and some entries never go stale,\nso after one operator signed out and another signed in in the same tab\nthe new user could be served the previous user\u0027s service lists, alarms,\ntraces and dashboards. The same held for module-level state the cache\ndoes not own, including everything the 3D map keeps between sessions;\n/3d/map carries no permission of its own, so any signed-in account could\nland there and read the previous session\u0027s alarm state. Identity changes\nnow remove cached responses rather than marking them stale, which also\ncancels the reads the cache owns, and a session epoch keeps a superseded\nin-flight answer from being written back after the switch.\n\nIn live mode the disk bundle was served as operator configuration. The\ntheme, the default time window and the alarms page setup each resolved\nremote-else-bundled, so an unreachable or admin-disabled OAP row quietly\nfell back to files shipped in the image. They now resolve server-side\nunder templates.mode: remote-only in live, where no value means the\ncaller applies its in-code default, and the bundle in readonly where it\nis the declared source. They also moved off the admin template endpoint,\nwhich requires overview:read and returns every template with both its\nstored and shipped copy: a custom role without that verb lost all three\non boot, and reading one theme id disclosed the whole template store.\n\nPublishing a layer dashboard could not fail. Overview and 3D-map content\nwas schema-checked on the way out; layer content was not, so a\nhand-edited or imported template could break that layer for everyone.\nPublishing now runs the same cross-reference rules bundled files pass in\nCI, plus a check that the content\u0027s own key matches the template it is\npublished as, and writes nothing on failure.\n\nA duplicated overview stayed visible while a duplicated layer was\nhidden, and byte-identical copies were hidden even though either renders\nthe same dashboard. Overviews now get the layer treatment, identical\ncopies still render and are reported for cleanup, and a conflicted layer\nnames the real cause instead of claiming it is inactive or unknown.\nHiding requires a positive conflict signal, so an unreadable template\nstore still shows everything.\n\nA service name cannot safely address a service: OAP mints the id as\nbuildId(name, layer.isNormal()), so rebuilding one from a bare name\nrequires knowing whether the service is agent-reporting, and a wrong\nguess addresses a different entity that silently matches nothing. Every\nservice-scoped query now carries the id and the name together as the\nlayer roster returned them, and the resolution machinery is deleted\nrather than made conditional. Alarms are the one surface whose OAP query\nhas no id form, so it carries the name with the roster row\u0027s flag; an\nomitted flag defaults to agent-reporting upstream, which is why a\nservice arriving without one is refused instead of assumed.\n\nAlso: the org default time window was never applied, because the picker\npromoted the in-code fallback before the stored value loaded and latched\nit. The release script\u0027s pre-tag gate matched \"In development\" as a\nsubstring and so rejected the real CHANGELOG, whose current section\ndocuments that gate; it now matches the generated stub as a whole line,\ndefined once for both the writer and the checker. The unit suites pin\nvitest\u0027s threads pool, where two flakes found this cycle reproduce and\nthe default forks pool hides them, with a guard test that fails if the\npin is dropped."
    },
    {
      "commit": "2975fc3494578c2973ccbab52ff7799f4a799ec6",
      "tree": "d965b5aa20d1b3c8f966379fd848cea9d2e210d1",
      "parents": [
        "855b60cd8f92aced122242d45b41a1814cff4057"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sat Aug 01 10:55:22 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sat Aug 01 10:55:22 2026 +0800"
      },
      "message": "fix: remote-only live mode, verified releases, validated templates, scoped service queries (#104)\n\n## Why\n\nFive defects found in review, each verified against the code before being fixed.\n\n**The release could publish an artifact it had never checked.** `release-finalize.sh` moved the candidate into the official SVN *release* location before downloading it, and never verified the `.asc` signatures at all. Driven against command doubles with a deliberately bad signature, the old script promoted the candidate, cut a GitHub release, and exited `0`. Separately `release.sh` picked a signing key by taking the **last** secret key in the ring, validated the `@apache.org` identity against the **first** uid found across *all* keys, and signed with gpg\u0027s own default — three independent choices that need not agree, on the one operation an Apache release is verified by.\n\n**`live` template mode had grown a bundled fallback it must not have.** An unreachable OAP template store had started rendering the shipped bundle instead of blocking, which presents Horizon\u0027s defaults as if they were the operator\u0027s published configuration.\n\n**Two bundled templates were broken in the shipped bundle**, and the loader only `JSON.parse`d them, so nothing caught it.\n\n**A service name that resolved to nothing silently widened the query** — and a service page could fire before resolution finished — turning a request scoped to one service into one across every service.\n\n## What\n\n**Live mode is remote-only.** In `live`, the OAP-stored row is the only source; an unreachable store or a disabled row blocks the route, and the UI shows its connectivity banner. The disk bundle reaches the runtime through exactly two doors: `templates.mode: readonly` and bundled preview. Because that rule had been broken by inference once, it is now stated in `CLAUDE.md`, on the `templates.mode` schema field, in the shipped `horizon.yaml`, and in the module docblock. It follows that an OAP 10.x — which has no `/ui-management/templates*` surface, template management having lived on the query port\u0027s GraphQL there — **requires** `templates.mode: readonly`; the docs say required rather than recommended.\n\n**Release.** Finalize now locates → downloads → verifies → *then* promotes, where verifying means the `.sha512`, both detached signatures against a throwaway keyring built only from the project `KEYS` file, an `@apache.org` signer, and the same signer on both artifacts. It is resumable: the old flow required the candidate under `dev/`, which is precisely the state its own promote destroys, so any retry died. Promotion refuses if the candidate directory holds anything beyond the six voted artifacts, or is missing one — it moves the whole directory, so its contents must be exactly what was voted on. Existing GitHub assets are compared against the verified bytes rather than trusted by filename, and re-uploaded when they differ. Superseded-release cleanup selects strictly below the version being finalized, so finalizing a maintenance patch can no longer propose deleting a newer release. `gh release create` gained `--verify-tag`, without which a tag that never reached origin makes GitHub mint one at the default branch\u0027s HEAD. `:latest` is expected only for the highest `v*` tag, matching the vote-gated promotion, and is digest-matched against the versioned tag. A new `release-common.sh` resolves exactly one signing key to a full fingerprint, refuses to guess when several exist, pins it with `--local-user` on every signing call, and re-reads each `.asc` to prove the signature came from it. The post-vote instructions now direct the release manager to run the finalize script — they previously described a raw `svn mv`, which skips every check above.\n\n**Templates.** Kafka\u0027s Max Lag averages, matching how that metric is charted on the Kafka cluster dashboard; DynamoDB orders by a column that exists. A schema plus cross-reference validator runs as its own `Bundled templates` CI check in the `Required` rollup: closed enums, `orderBy` naming a column of its own header, metric references resolving, and an overview `id` matching its filename stem — that last because overlays are stored per filename but looked up per id, so a mismatch renders the dashboard English-only in every locale with no error.\n\n**Scoping.** A named-but-unresolvable service answers `Unknown service \"\u003cname\u003e\" in layer \u003cLAYER\u003e` with no rows, through a shared resolver, and the Traces view shows that reason in the body rather than only a tooltip. Service-scoped queries wait for resolution instead of firing while it is in flight. Ids and names are distinguished explicitly, so a service called `api.1` or `orders.2026` is no longer mistaken for an encoded id. A query that names no service at all is untouched — that is a legitimate all-services scope, and telling the two apart is the point of the helper."
    },
    {
      "commit": "855b60cd8f92aced122242d45b41a1814cff4057",
      "tree": "961bd77e587fc522bb0356af10910a010754be3a",
      "parents": [
        "422089e150d24513dac52dacc0719aa7234138f6"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Fri Jul 31 22:28:46 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Jul 31 22:28:46 2026 +0800"
      },
      "message": "fix(layers): render bundled templates when the OAP template store is unreachable (#103)\n\nAn unreachable template store used to block every layer-driven page: routes\nserved nothing, so the Traces tab was empty with no explanation. The intent\nwas to avoid rendering content that might not match the operator\u0027s\nOAP-stored configuration, but the trade was wrong — a blank page is not\nsafer than a stated fallback.\n\nThe unreachable case is also predictable rather than exotic. An OAP 10.x has\nno `/ui-management/templates*` endpoint at all: template management lives on\nthe query port\u0027s GraphQL there, and Horizon implements only the OAP 11 REST\nprotocol. The store is therefore unreachable for the whole life of such a\ndeployment, which is what left Traces empty (apache/skywalking#13959).\n\nLayer resolution now falls back to the templates bundled in the release,\nwhile the connectivity banner keeps reporting the store as unreachable — the\ndegrade is visible, not silent. An admin-disabled layer still blocks, which\nis a different and deliberate signal. What a fallback cannot supply is a\ntemplate edit stored on OAP; those return as soon as a reachable store\nanswers.\n\nDocs updated: `templates.mode: readonly` moves from required to recommended\non OAP 10.x, since it states the deployment\u0027s reality up front and makes the\nadmin surface honestly display-only.\n\nAlso carries in-place doc corrections made against main: OAP 10 does have\npersistent template management (legacy query-port GraphQL, gated by\nSW_ENABLE_UPDATE_UI_TEMPLATE) — the gap is Horizon-side protocol support,\nnot a missing OAP feature — plus the minor-specific limits Horizon\u0027s current\nqueries impose (queryTrace duration needs 10.3+, findEndpoint duration 10.2+)."
    },
    {
      "commit": "422089e150d24513dac52dacc0719aa7234138f6",
      "tree": "2d0d52e25ea8032ee874e4c82fb7645e17fb3731",
      "parents": [
        "173043f1be9909463b23813e283ce75e5c11b5d1"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Fri Jul 31 21:53:03 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Jul 31 21:53:03 2026 +0800"
      },
      "message": "chore: pre-1.0 hardening — diagnostics, i18n coverage, duplicate detection, release gates (#102)\n\n* chore: pre-1.0 hardening — diagnostics, i18n coverage, duplicate detection, release gates\n\nA pre-1.0 review pass across docs, CI, tests and runtime behavior, plus the\nfixes it turned up.\n\nDiagnostics and configuration\n- An invalid horizon.yaml edit at runtime is rejected out loud, with the failing\n  field paths, instead of being silently swallowed; the previous valid config\n  keeps serving.\n- The production default log level moves from `error` to `warn`, so\n  misconfiguration and security signals (break-glass logins, LDAP failures,\n  rejected reloads) reach stdout. Adds the three startup warnings the docs\n  promised but the code never emitted.\n- The `debugLog` wire log is implemented: every outbound OAP request/response is\n  appended as JSONL with auth-header redaction, body truncation and hot-reload.\n  Binary payloads pass through byte-identical. LLM traffic is excluded by design.\n- The packaged server finds its UI beside server.js, so the tarball and\n  `pnpm start` serve the SPA without HORIZON_STATIC_DIR.\n\nTemplates\n- Duplicate template names on OAP are detected and reported, never resolved.\n  Boot writes nothing; the rendered row is the lowest enabled id, content-blind.\n  A duplicated layer is hidden from the sidebar, marked in the admin browse list\n  and explained in a banner; deleting one is refused rather than half-applied.\n\nInternationalization\n- ~850 strings across 90 components wired into the catalogs and translated in\n  all 8 locales, closing the sidebar, logs, profiling, admin editors and 3D map.\n  `\u003chtml lang\u003e` follows the active locale.\n\nTime range\n- Rolling presets follow the clock again: the window was cached at first read,\n  so \"Last 1 hour\" froze and auto-refresh replayed the same window.\n\nRelease and CI\n- CI gains a package + boot job on the Required check, and both i18n gates.\n- The release script runs the full battery inside the release clone before the\n  tag is pushed; stable image tags wait for the vote.\n- Dependencies refreshed to no known advisories in the production tree.\n\nAdds 281 unit tests over the time/step layer, sessions, api-client, template\nsync and the UI step contract.\n\n* docs: state that OAP 10.x needs templates.mode readonly\n\nOAP moved UI-template management from the query port\u0027s GraphQL surface to\nthe admin REST port `/ui-management/templates*` in 11.0.0 (apache/skywalking\n#13877). Horizon speaks only the REST surface, so on a v10 OAP the template\nstore is unreachable by construction — and in the default `live` mode an\nunreachable store is a deliberate feature block, so layer-driven pages come\nback empty, most visibly Traces.\n\n`templates.mode: readonly` removes the dependency: templates render from the\nrelease bundle, the store is never contacted, and the configuration surface\nis read-only. That is the supported way to run against v10, and the docs now\nsay so instead of promising the data plane \"just works\" there.\n\nCovers the compatibility page (with a dedicated v10 recipe), the setup\noverview prerequisite, and the templates.mode reference.\n\n* fix(templates): guard the delete route, and stop claiming a retired copy is unrecoverable\n\nThe conflict guard landed on the wrong endpoint: it sat on\n`POST /api/admin/templates/:name/push-bundled` while\n`POST /api/admin/templates/disable` — the route that actually half-applies a\ndelete on a duplicated name — was left unguarded. Both now refuse a\nconflicted name, each worded for what it would have done: a delete would\ndisable only the copy Horizon renders and leave its twin rendering; a push\nwould update only that copy and leave the other in place.\n\nThe banner, the log line and the CHANGELOG all asserted that retiring a row\nis \"irreversible — there is no re-enable entrance\", contradicted by the\nReactivate control on the same admin screen. What is actually true is\nnarrower: Reactivate re-enables a name from the BUNDLED default, so the\ncontent of the copy you disabled does not come back. Corrected in all eight\nlocale catalogs and everywhere else the claim appeared.\n\nAlso drops the last references to a `winnerId` field that no longer exists,\nand to the content-aware winner rule that was replaced by the content-blind\nlowest-id pick."
    },
    {
      "commit": "173043f1be9909463b23813e283ce75e5c11b5d1",
      "tree": "65b65491df1716611c7589fc745bc124f9e62fce",
      "parents": [
        "d61e601b6517dad97f0c5c450752cdf513573609"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Fri Jul 31 00:14:53 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Jul 31 00:14:53 2026 +0800"
      },
      "message": "feat(profiling): let OAP own the profiling rules, and add a continuous-profiling policy editor (#101)\n\nTwo threads that turned out to be the same problem: Horizon was encoding\nOAP-owned profiling rules as its own constants, and there was no way to see or\nedit the rules that start a profiling task by itself.\n\n## Let OAP decide what profiling is allowed\n\n`propose_profiling` refused what our config could not vouch for — a layer\ntemplate missing a component flag, an unrecognised event, an instance whose\nreported language did not match the flavour. None of that is OAP\u0027s test: its\ncheckCreateRequest / checkArgumentError paths consult none of it, so a\nHorizon-side \"no\" blocked tasks the backend would have taken and blamed the\ndeployment for our own config. Those refusals are now caveats carried on the\ndecision card; OAP does the refusing, and its rejection is a fact rather than a\nguess.\n\nReading results is fixed to match how OAP actually reports them. An empty\nanalysis is now three distinct answers — the agent FAILED (a *_ERROR log), the\ntask finished and found nothing, or it is still collecting — instead of one\n\"ran but found nothing, do not retry\" that told operators to give up on a task\nstill running. A task past its own window with neither outcome logged is\nreported as stalled, not as forever-running. ALLOC reads both TLAB variants, so\nlarge-object allocation paths stop vanishing. OFF_CPU aggregates by DURATION,\nbecause ranking a blocked profile by switch count inverts the question it was\nopened to answer. Over-cap durations and instance counts are rejected instead of\nsilently clamped: an approved card that says 15 minutes must not start a\n10-minute task.\n\n## Continuous Profiling tab\n\nA new per-layer tab edits the policies that make OAP start eBPF profiling on its\nown when a process crosses a threshold. A policy is per service and carries up\nto three targets (ON_CPU / OFF_CPU / NETWORK), each with conditions built from a\nmeasurement, a threshold, a period and a trigger count. One tab per target;\nApply sits outside them because OAP replaces the whole policy in one call.\n\nThe form enforces exactly what OAP will accept rather than letting the save\nfail: whole numbers only (OAP Integer.parseInts every threshold even where Rover\nwould take a float), 1-100 for the two percentages, count no larger than period,\nand no two conditions on one measurement. The URI filter is an explicit\nthree-way choice, because nothing upstream rejects a rule carrying both a list\nand a regex — the agent just takes the list and drops the regex.\n\n\"Where it runs\" folds into each target: the instances OAP evaluates, expanding\nto processes, bounded at every level because a production service has dozens to\n100+ instances. The service picker labels each service with the rules it already\nhas and filters by them, sectioned by OAP service group.\n\nNothing is gated on the agent being present. Arming a policy before deploying\nRover is a valid order of work, so absence is a warning, never a disabled\ncontrol.\n\nTasks a policy starts are now visible: every eBPF task read asked OAP for\nFIXED_TIME only, so a firing policy produced profiles that appeared nowhere.\nBoth trigger types are read and merged.\n\nShips on MESH, matching where the previous UI placed it."
    },
    {
      "commit": "d61e601b6517dad97f0c5c450752cdf513573609",
      "tree": "10b6f49dd623d6fc827ba80ca7ead644331850f1",
      "parents": [
        "d30321509e49dcde2f76023f9b6dbef4948f8589"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Wed Jul 29 06:58:11 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Wed Jul 29 06:58:11 2026 +0800"
      },
      "message": "feat(ai): profiling, captured-snapshot chat blocks, and template-driven RCA (#100)\n\nThree gaps limited how far an AI-assistant investigation could go.\n\nThe assistant\u0027s profiling path dead-ended. The Profiling tab already supported\nevery flavour, but the assistant could only ever propose a TRACE task, and once\nthe user approved it the task id was stored on the chat block and never read\nback — there was no tool that could show the result. Profiling is also not one\nthing: a JVM service wants async-profiler, a Go service pprof, a kernel question\neBPF, a network question a process graph.\n\nThe assistant\u0027s rendered answers drifted from the data behind them. Its embedded\nviews re-queried OAP on reload, so reopening a conversation quietly showed\nCURRENT data under an OLD question, and a couple of blocks handed the operator\noff to a live tab, which drifts by construction.\n\nThe RCA playbooks duplicated per-layer facts in prose — metric ids, bundled\nwidget names, entity vocabulary, native-vs-Zipkin trace source. Those live in the\nlayer template and drift from any prose that restates them.\n\nPROFILING, END TO END. The assistant picks the flavour that fits the target by\nreading which types the layer declares and the service\u0027s runtime language, so it\nwill not offer pprof for a Java service; it targets only the instances that can\nactually run the profiler, and for network profiling only an instance that\nreports a profilable process (OAP rejects the create otherwise). On approval it\nfires that flavour\u0027s own create call with its own units and event/target\nparameters. Once collected, analyze_profiling renders the flame graph inline —\nthe same component the Profiling tab draws — with the hottest frames named\nper flavour (self time for trace/async/pprof; eBPF carries none, so inclusive\nsample share). A trace profile also shows the profiled trace\u0027s span waterfall\nbeside the flame, and network profiling captures the process-conversation graph.\nReading a result requires profile:read; nothing runs without approval.\n\nEVERY RENDERED ANSWER IS A CAPTURED SNAPSHOT. Figures, the five maps, traces /\nZipkin traces / logs / browser errors, pod logs, the flame and the process graph\nall freeze their data at read time and replay identically on reload — zero\nre-query, offline-safe, each stamped \"captured \u003ctime\u003e\" with a replay badge.\nTrace captures carry their spans so the waterfall opens from frozen data. When\nthere is nothing to capture — no Rover agent, a task that collected nothing —\nthe assistant says so in plain text rather than rendering something misleading.\nThe last live-tab handoff (show_service_list) is removed.\n\nPLAYBOOKS READ THE TEMPLATE. The RCA playbooks keep their method and ordering\nbut resolve per-layer specifics at runtime through the capability + catalog\nlookups, and prefer rendering an existing catalog widget over rebuilding one.\n\nAlso: chat history persists per user in the browser (IndexedDB, with a toggle,\nusage meter and clear-all; writes are serialized and deletes are tombstoned so\nconcurrent tabs cannot resurrect or clobber each other), and every skill\u0027s tool\ndescriptions moved out of code into editable resource files."
    },
    {
      "commit": "d30321509e49dcde2f76023f9b6dbef4948f8589",
      "tree": "45b870f9deaff0fec01878056842ade95dabcce2",
      "parents": [
        "8126ac3de1e4600be50f1b4ee1c9637d2cd461f0"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Tue Jul 07 22:47:41 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Jul 07 22:47:41 2026 +0800"
      },
      "message": "refactor(overview): aggregate KPI tiles server-side + retire dead template config (#98)\n\nrefactor(overview): whole-layer KPI aggregation + per-widget page-side control (#98)\n\nOverview KPI tiles (e.g. \"General services · RPM / Latency / SLA\") aggregated over only a single service instead of the whole layer (a hardcoded topN:1), so a busy layer read far too low. Reported in apache/skywalking#13934.\n\nOverview KPI aggregation\n- Multi-service tiles (General / Mesh / Virtual Database·Cache·MQ·GenAI) now roll up the entire layer server-side via sum/avg(top_n(\u003cmetric\u003e,{{topn}},DES[,attr0\u003d\u0027\u003clayer\u003e\u0027])) — one query per tile instead of a per-service fan-out. Bounded by a new HORIZON_QUERY_OVERVIEW_TOPN setting (default 100).\n- K8s-cluster and Istio-pilot composites stay page-side (their latest() snapshots + label-split metrics can\u0027t be top_n-rolled). Mesh\u0027s second tile is now Latency (avg response time) in place of P95.\n\nPage-side aggregation as a first-class, per-widget choice\n- Explicit aggregateOnPage flag (Server-side vs Page-side) with its own top-N limit, plus a new rankBy — rank the top-N services by one of the widget\u0027s KPIs (default the first) or by a separate ranking metric ({ kpi: n } | { mqe: \"…\" }). Fixes a latent bug where a page-side widget\u0027s first KPI being a LABELED_VALUE metric skewed the ranking; pilot_summary now ranks by xDS connections.\n- Each page-side widget gets its own landing call (own limit + ranking); self-aggregating tiles batch by layer.\n\nEditor UX\n- {{topn}} explained inline via a WidgetTip popover showing its live resolved value (surfaced through /api/oap/info); redesigned aggregation controls (mode radio + nested Top-N / Rank-by); auto-growing dashboard-description textarea; removed a duplicate \"mqe\" option in the KPI Source selector.\n\nCleanup (no behavior change)\n- Retired the dead legacy per-layer-overview machinery (LayerOverviewConfig / headerColumns / overviewGroups / overviewMetrics) and a sweep of verified-dead template config (serviceCountTile, the LayerDef.header twin, LogConfig.defaultTags, LandingConfig.style, LandingColumn.tip, OverviewWidgetResult, the BFF LayerMetricsConfig alias) + dead loader migrations.\n\nNo layer-template or OAP wire-contract change — only the two Overview dashboard templates (services.json, mesh.json) + their i18n overlays changed. Overviews are served remote-only from OAP, so existing deployments need an admin re-sync of the bundled Overview templates (Overview-templates admin → push-bundled); fresh installs get it via boot-seed."
    },
    {
      "commit": "8126ac3de1e4600be50f1b4ee1c9637d2cd461f0",
      "tree": "513506d4cb11591f8883233225ae83248e75bc2b",
      "parents": [
        "ab29bc8080e60dd094f22c611a0b903ffff8c9b3"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Tue Jul 07 16:36:02 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Jul 07 16:36:02 2026 +0800"
      },
      "message": "fix(traces): Zipkin trace list carries its spans so the inline waterfall skips a redundant re-query (#97)\n\nZipkin\u0027s `/api/v2/traces` already returns every span per trace, but the BFF\nsummarised the list rows and discarded the spans, so clicking a trace re-fetched\n`/api/v2/trace/{id}` on the client to draw the waterfall — a redundant second\nround-trip to OAP for data it already had (native BanyanDB traces ship spans\ninline on the row and render with no re-fetch; this brings Zipkin in line).\n\n- ZipkinTraceListRow gains an optional `spans?: ZipkinSpan[]` (mirrors NativeTraceListRow.spans?).\n- The /api/zipkin/traces route keeps the spans it already fetched instead of dropping them.\n- LayerZipkinTracesView renders the inline waterfall from the row\u0027s spans, falling\n  back to the by-id fetch only when a selected id isn\u0027t in the current list.\n- The /api/zipkin/trace/:id route + popout stay for opening a trace by pasted id /\n  a shared deep link / a log-\u003etrace jump (no list row → the by-id query is right there).\n- The AI-assistant Zipkin list path is unaffected (spans optional; it omits them)."
    },
    {
      "commit": "ab29bc8080e60dd094f22c611a0b903ffff8c9b3",
      "tree": "2ff71fc8898b4777be05dbb2dd7c1ef6b7d846f9",
      "parents": [
        "88db62d0b93139b75145f347324d9fe8968669ed"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Mon Jul 06 12:35:01 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Jul 06 12:35:01 2026 +0800"
      },
      "message": "refactor(config, ai): config to OAP, harden the AI assistant, fix K8S_SERVICE layout (#96)"
    },
    {
      "commit": "88db62d0b93139b75145f347324d9fe8968669ed",
      "tree": "1dd0f5503f676293870c2774c6c093f0a03b4818",
      "parents": [
        "55f36f3312dc772cac8596e6746a84b3656ba5e7"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Jul 05 22:48:35 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Jul 05 22:48:35 2026 +0800"
      },
      "message": "feat(ai): AI assistant — chat that answers with live-data widgets (#95)\n\nAdd an opt-in, read-only AI assistant. A floating launcher (after login) opens a\nchat: ask in plain language (\"what\u0027s unhealthy right now?\", \"why is agent::app\nslow?\") and the assistant reads live OAP data through the same query path as the\ndashboards, then streams an ordered narrative with numbered inline figures —\ncharts, top-N lists, tables — drawn by the same widget components as the rest of\nthe UI. Open it as a side drawer, expand to a full page (/ai), or a new tab;\nconversations persist locally in the browser.\n\nRenders the REAL feature views inline, read-only and focused on the service:\ntopology (focused one-hop ego graph), cross-layer Smartscape hierarchy, native\nAND Zipkin traces (list → match → embed for mesh/k8s layers), layer logs,\nbrowser errors, deployment (intra-service instance graph), the per-pair instance\nmap (source → dest), and API-dependency chains. Each is the actual layer-tab\ncomponent given an additive, default-off `embedded` prop that seeds focus from\nprops and never writes shared state (time range, route, refresh ticker) — so the\nnon-embedded full page is untouched. On-demand Kubernetes pod logs are fetched\nand shown inline as a read-only result (a live tail lives in the Pod Logs tab).\n\nRead-only and permission-inheriting: every data tool re-checks the same read\nverb the operator already holds (metrics / alarms / topology / traces / logs /\nbrowser-errors), so the assistant never sees more than the user and never\nchanges configuration. Its one gated action is proposing a profile — a decision\ncard the user approves in a popout; nothing runs otherwise.\n\nGuided root-cause: built-in investigation playbooks (master method plus latency\n/ error-rate / saturation / middleware / kubernetes-workload / service-mesh\nspecializations) that walk the dependency topology upstream and follow the\ncross-layer hierarchy down into backing infra.\n\nVendor-neutral and off by default: the `ai:` config block (HORIZON_AI_*) takes\nan OpenAI-compatible endpoint (default) or Amazon Bedrock; the API key is\nenv-only, redacted from logs, and excluded from the audit trail. The system\nprompt and starter chips are operator-overridable; a starter naming \u003cservice\u003e\nopens a free-text fill-in the assistant resolves to a real service.\n\nBFF: new ai/ subsystem — chat SSE (reply.hijack), per-request session lifecycle\n+ abort, provider/model build, tools, and prompt/playbook resource files.\nclient/ stays the only OAP-talking layer; every route is RBAC-gated (ai:read).\nUI: new ai/ feature (drawer + /ai page), api façade, and the embedded-mode props\non the reused layer/render views.\n\nDocs: docs/operate/ai-assistant.md (in menu.yml); CHANGELOG under the unreleased\n1.0.0. English-source i18n across all 8 locales."
    },
    {
      "commit": "55f36f3312dc772cac8596e6746a84b3656ba5e7",
      "tree": "3dbdfe12c27f735c53889d7b0089c93cf747cc41",
      "parents": [
        "389e7506d217737eee76e0a37f1d72b22a5f33f6"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Fri Jul 03 22:42:46 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Jul 03 22:42:46 2026 +0800"
      },
      "message": "chore(local-boot): rename demo OAP secret file and align .gitignore (#94)\n\nThe demo OAP basic-auth password file was renamed\noap-password.local -\u003e oap-demo-env-auth.key, and .gitignore now ignores\n.claude/skills/local-boot/*.key (covering both the demo OAP password and the\nBedrock API key). Update the local-boot skill doc to the new filename and point\nthe \"git-ignored via\" note at the actual .claude/skills/local-boot/*.key rule\ninstead of the stale repo-wide *.local rule."
    },
    {
      "commit": "389e7506d217737eee76e0a37f1d72b22a5f33f6",
      "tree": "605bb109faef190cb1aa7a7f717778256a7f4305",
      "parents": [
        "cb479c06680fa0b39422ef663096babe66554f86"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Fri Jul 03 15:49:37 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Jul 03 15:49:37 2026 +0800"
      },
      "message": "fix(traces,logs): custom time range uses absolute epoch-ms (tz-correct) (#93)\n\nThe native trace and log custom-range paths sent a browser-local wall-clock\nstring that the server re-read in its own timezone — trace via new Date() in\nthe BFF process TZ, logs via passthrough to OAP as server-local. When the\nbrowser TZ differs from the server TZ (any containerized deploy), the query\nwindow shifted by the UTC offset and returned no results; the rolling presets\nworked because they derive from an absolute instant.\n\nBoth now send absolute epoch ms (startMs/endMs) and the BFF converts to\nOAP-server-local via fmtSecond(ms, offset) — the same pattern the other\nsurfaces already use (browser errors, events, alarms, global picker, Zipkin).\nThe operate trace-inspect path drops its now-redundant ISO round-trip. One\nconsistent, timezone-proof way everywhere; the metric-\u003etrace drill (same send\npath) is fixed too."
    },
    {
      "commit": "cb479c06680fa0b39422ef663096babe66554f86",
      "tree": "2dccbaac1ab3d595a8ad8b73cd7d074afa98ec4f",
      "parents": [
        "14b9ce6a14675c631777a4735903f98411b44828"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Fri Jul 03 14:33:17 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Jul 03 14:33:17 2026 +0800"
      },
      "message": "live-debug: show the LAL drop reason (#92)"
    },
    {
      "commit": "14b9ce6a14675c631777a4735903f98411b44828",
      "tree": "ff53bf436d0bc39c5dceec5ca540419c14751946",
      "parents": [
        "a9922bd920591efa10310c092575825e5ced9a0e"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Jul 02 15:39:11 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Jul 02 15:39:11 2026 +0800"
      },
      "message": "fix(traces): drill window snapping + HOUR-view axis-label overlap (#91)\n\n* fix(traces): drill window snaps to the clicked hour/day bucket\n\nThe window centered on a reconstructed mid-bucket point and capped the\nhalf-window at 6h, so clicking a DAY bucket opened a 12h mid-day slice\n(08:35–20:35) instead of that day. Now MINUTE → a ±5min neighborhood,\nHOUR → the whole clicked hour, DAY → the whole clicked day (snapped to\nthe local boundary and spanning one bucket).\n\n* fix(charts): hide overlapping x-axis labels on HOUR-step line charts\n\nThe x-axis forced a label every ~length/6 buckets, but the HOUR label\ncarries the date (\"07-02 05:00\"), so several wide labels collided in a\nnarrow widget. Add ECharts `hideOverlap` so any colliding label is dropped\n(interval stays as the density target).\n\n* fix(traces): address drill re-review findings + trim comments\n\n- TimeChart: a line-body click carries no dataIndex/value, so recover the\n  bucket from the click pixel and read the value from series data — clicks\n  between dots now drill, and a real 0 value still drills.\n- Traces tab: defer the drill\u0027s first query until the instance/endpoint id\n  resolves (no unfiltered wrong-scope flash), and seed the endpoint keyword\n  so a \u003e50-endpoint service still resolves the drilled endpoint.\n- Drill window: HOUR/DAY snap via local calendar arithmetic (DST-safe).\n- Trim the feature\u0027s comments to the non-obvious minimum."
    },
    {
      "commit": "a9922bd920591efa10310c092575825e5ced9a0e",
      "tree": "fc4c73a4edc6d15809ac09ba087c8213d52e1666",
      "parents": [
        "048305fbb517e3f05b0b2aa58f80f846936ff834"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Jul 02 09:13:53 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Jul 02 09:13:53 2026 +0800"
      },
      "message": "feat(traces): drill from General-service metric charts to native traces (#90)\n\nImplemented — [apache/skywalking-horizon-ui#90](https://github.com/apache/skywalking-horizon-ui/pull/90).\n\nIt\u0027s **config-driven**, not tied to a specific layer. The whole thing hangs on two conditions: a line widget declares a `traceDrill` mode, and its layer has the **Traces component activated**. Nothing is hardcoded to General service — that\u0027s just where the bundled widgets ship with it turned on.\n\nThe reason it stays simple: only latency and success/error metrics have a natural trace criterion, so the drill is driven by the declared **mode** rather than any metric↔trace mapping.\n\n**How it works** — a drill-capable line widget shows a small **traces** flag in its header and its datapoints are clickable. Click a point and it opens the native Traces list **in a new browser tab**, pre-filtered to that service (and the selected instance / endpoint) and centered on the clicked bucket\u0027s window:\n\n- **latency** widgets (response time / percentiles) → slowest-first, `minTraceDuration` \u003d the value you clicked (click p99 → the tail; click p50 → median-and-slower).\n- **error** widgets (error rate / success rate / Apdex) → the trace list filtered to ERROR status.\n\nThroughput (cpm) and CPU / memory / GC gauges have no meaningful \"which traces\" answer, so they offer no drill.\n\n**Enabling it** — it\u0027s a per-widget config field (`traceDrill: latency | error`) authored in the dashboard widget editor. The editor only lets you set it when the layer\u0027s Traces component is on. So it works on any dashboard — bundled or your own custom ones — as long as that layer has traces. The bundled **General service** layer ships these ten enabled:\n\n| Scope | Widget | Mode |\n|---|---|---|\n| Service | Avg Response Time | latency |\n| Service | Response Time Percentile | latency |\n| Service | Error Rate | error |\n| Service | Apdex | error |\n| Instance | Service Instance Latency | latency |\n| Instance | Service Instance Success Rate | error |\n| Endpoint | Response Time | latency |\n| Endpoint | Response Time Percentile | latency |\n| Endpoint | Success Rate | error |\n| Endpoint | MQ Avg Consuming Latency | latency |\n\nNo backend change was needed — `minTraceDuration` and `traceState` already exist on the trace query, so it\u0027s a client-side classify-then-navigate."
    },
    {
      "commit": "048305fbb517e3f05b0b2aa58f80f846936ff834",
      "tree": "d8e302aa9b498d6c651d9dc64cd9a4cf624ac085",
      "parents": [
        "cf2b97032af2ae7637f55eb761603cde449802a2"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Wed Jul 01 14:13:13 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Wed Jul 01 14:13:13 2026 +0800"
      },
      "message": "feat(events): add a per-service events popout (queryEvents swimlane) (#89)"
    },
    {
      "commit": "cf2b97032af2ae7637f55eb761603cde449802a2",
      "tree": "ae756fa0c8b2cf1c3240849e6d23e85f4974c308",
      "parents": [
        "cf6e846201538f43978e1ad807ed951e6f765344"
      ],
      "author": {
        "name": "songzhendong",
        "email": "xiaodong12315@qq.com",
        "time": "Tue Jun 30 10:18:53 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Jun 30 10:18:53 2026 +0800"
      },
      "message": "Add Node.js runtime dashboard panels for meter_instance_nodejs_* (#87)"
    },
    {
      "commit": "cf6e846201538f43978e1ad807ed951e6f765344",
      "tree": "75a20221960caebde7c4cb459a14341a89c44c77",
      "parents": [
        "eeeef36b71b1f9b11e34179d81d388853d6d07d8"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Tue Jun 30 09:53:43 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Jun 30 09:53:43 2026 +0800"
      },
      "message": "feat(live-debug): Envoy-ALS rendering, LAL matrix UX (filter/inspect/diff), grid/flow (#88)\n\n## Why\n\nDebugging a real **Envoy access-log (ALS)** rule against a live OAP cluster surfaced a chain of gaps in the Live Debugger\u0027s LAL view: first the grid/flow issues (the frozen column drifting, tall results trapped in an inner scroll box); then, once those were fixed, that the matrix only rendered generic `LogData` (Envoy ALS cells were **blank**), that there was no way to find the few records that reached a given stage, that cluster nodes\u0027 grids interfered with each other, and that there was no way to read a cell\u0027s full data or compare pipeline stages.\n\n## What\n\n**Grid \u0026 flow** — the frozen first column stays pinned on horizontal scroll; clicking a source line flashes the whole step row; MAL/LAL/OAL pages scroll as one page for tall captures instead of trapping the result in a fixed-height box.\n\n**Format-agnostic rendering** — cells render whatever fields OAP serialized, not a fixed `LogData` subset: an `EnvoyAccessLogBuilder` snapshot shows its service/endpoint/response data (was blank); a raw proto input OAP couldn\u0027t serialize surfaces the reason (`jsonformat-failed …`) instead of a blank cell; complex object fields pretty-print; each cell names its payload class. Search + the cell popout follow the same rendering.\n\n**Matrix UX** — a per-row \"has-data\" filter (shown only on rows with gaps) narrows the grid to the records that produced data for that step; row counts reflect the whole capture, not the visible page. The row filter and the column-pin highlight are **per-node**, so acting on one cluster node\u0027s grid no longer affects another\u0027s. Cells are height-capped, columns width-capped. Statement-mode labels render `function @7` (the vue-i18n `@` is escaped, clearing the broken `function @{line}` label and its ~33 compile warnings — pre-existing, from #16).\n\n**Inspect \u0026 diff** — a persistent `VIEW` (input) / `DIFF` (builder) button per cell opens the complete payload in a read-only Monaco JSON viewer with the nested log `content` inlined. For builder snapshots a compare picker renders the **captured DSL itself** — per-statement steps marked on their line, the `extractor {…}` / `sink {…}` block snapshots drawn as selectable ranges — and picking one shows a side-by-side Monaco diff of the two snapshots, so you can see exactly what a statement or stage changed."
    },
    {
      "commit": "eeeef36b71b1f9b11e34179d81d388853d6d07d8",
      "tree": "356416c6b2001bcb9bf0d261be0d72d5de5d0fe7",
      "parents": [
        "3839a7e46e1b90d7a58f156bb39dd15baaa34c14"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Jun 28 23:42:21 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Jun 28 23:42:21 2026 +0800"
      },
      "message": "Satellite series labels, K8S_SERVICE network profiling, and async/pprof create-dialog fixes (#86)\n\n## Why\n\nA live-review pass against the public demo OAP surfaced a cluster of small defects in the dashboard render path and the per-layer profiling tabs, plus one missing capability. This bundles them.\n\n## What\n\n**Dashboards**\n- **Satellite so11y series were all labelled `all`.** The dashboard parser named each labeled series off the *last* metric label. The Satellite event/queue metrics carry two labels — `pipe` (the series identity) and `status` (a constant aggregation dim whose value is `all`/`success`) — so every series collapsed onto the constant. Now named by the labels that *vary* across the result, dropping constant dims. Fixes Receive/Fetch Events, Queue Input/Output and the queue_io prefix; percentile (`p`), `queue_used` (single `pipeline`) and unlabeled series are unchanged.\n\n**Profiling**\n- **K8S_SERVICE gains a Network Profiling tab** (Rover eBPF, the same capability Mesh has), documented on the layer page.\n- **Create dialogs**: the post-create hint counts down to a single list refresh (`refreshing in Ns`) instead of an opaque `N/4` counter; **Escape** now closes the Async and pprof create dialogs (parity with Trace/eBPF/Network); **Analyze** stays disabled until at least one instance is selected (Async, pprof).\n- **Async event-type picker** is derived from the selected task\u0027s captured events — only the JFR trees those events actually produce — instead of a static list of all five. Drops `PROFILER_LIVE_OBJECT` (no create-modal event produces it; needs async-profiler `--live`) and allocation types on non-ALLOC tasks, which rendered an empty graph. Mirrors booster-ui\u0027s behaviour."
    },
    {
      "commit": "3839a7e46e1b90d7a58f156bb39dd15baaa34c14",
      "tree": "332e40b3dbdafcabba2e9037d3f648edbe82178b",
      "parents": [
        "f4cc470b90911c360168ac1bea7ec82d64b18dd9"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Jun 28 12:14:53 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Jun 28 12:14:53 2026 +0800"
      },
      "message": "chore(deps): upgrade to clear 6 security advisories (both highs + all dev-only) (#85)"
    },
    {
      "commit": "f4cc470b90911c360168ac1bea7ec82d64b18dd9",
      "tree": "9d010dcb934c6c06dcfd7d792d3d22a331d89202",
      "parents": [
        "8cdbcb7edd7a7cdb058e08cd593516cfa23fcdcc"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Jun 28 11:02:06 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Jun 28 11:02:06 2026 +0800"
      },
      "message": "fix: pre-1.0 audit follow-ups — changelog consolidation, RBAC UI parity, 500 error-leak (#84)"
    },
    {
      "commit": "8cdbcb7edd7a7cdb058e08cd593516cfa23fcdcc",
      "tree": "208102eba9f788dc3416a1901d7d2a3d6484777a",
      "parents": [
        "e36615afef6767c2556053a0f17ab6b0529df733"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Jun 28 08:39:41 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Jun 28 08:39:41 2026 +0800"
      },
      "message": "refactor(layer-templates): decompose LayerDashboardsAdmin + enforce a CI source-size budget (#83)\n\n## Why\n\n`LayerDashboardsAdmin.vue` had grown to ~6.4k lines — one file mixing layer/template browsing, five per-scope config editors, the widget canvas, and all persistence/sync. It was effectively unreviewable and the worst offender against the project\u0027s \"keep files small, single-responsibility\" principle. This PR decomposes it, and adds a CI-enforced source-size guardrail so the same class of file can\u0027t silently grow back.\n\n## What\n\nTwo logical commits in one PR (on top of the earlier MetricDefinitionRow extraction already on the branch).\n\n**Guardrail + CI** (`chore(ci)`)\n\n- ESLint `max-lines` raised 1000 → 2000 lines of *code* (comments + blank lines excluded) across ui + bff. The old 1000 cap shredded the cohesive template/style blocks a dense SFC legitimately carries.\n- A comment-volume budget script (`scripts/check-source-budget.mjs`, ≤500 comment lines/file) for the dimension ESLint can\u0027t express, wired into `pnpm lint` + `pnpm lint:budget`.\n- A new CI `lint` job runs both and joins the `Required` status rollup — per-file source size/style is CI-gated for the first time.\n\n**Decomposition** (`refactor(layer-templates)`)\n\n- `LayerDashboardsAdmin.vue` 6365 → 888 lines (now an orchestration shell), split into ~14 cohesive single-responsibility files under `apps/ui/src/features/admin/layer-templates/`:\n- `useLayerTemplateStore` — the keystone: load / source / dirty / save / push / import / reset, the URL⇄state sync, and the single shared reactive `draft`.\n- per-scope config editors (Topology, Deployment, Process, Dependency, ServiceList) — each owns its `draft.template` slice via `defineModel` and mutates it in place (never clones); `RoleMetricRow` shared across deployment metric rows.\n- `WidgetEditorCanvas` (widget grid + drawer + drag/resize/reorder), `LayerSetupEditor`, and the presentational chrome trio (`LayerHeaderBar`, `LayerBrowseRail`, `ScopeTabsBar`).\n- `layer-dashboards.scopes` — the scope/component/slot constants seam.\n- Clears the lone remaining grandfather exemption now the file is within budget."
    },
    {
      "commit": "e36615afef6767c2556053a0f17ab6b0529df733",
      "tree": "d458746bf70b7f6a27ea5c403d9f9f50fd857d6d",
      "parents": [
        "30c11ce5ed580fcb7f0cfad11027a7dd9867c05d"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sat Jun 27 15:35:02 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sat Jun 27 15:35:02 2026 +0800"
      },
      "message": "feat: K8s Node \"Pod Total\" card and alarm-timeline range-select hint (#82)\n\nK8s Node dashboard — add a \"Pod Total\" card directly under Node Status,\nshowing the current count of pods scheduled on the node\n(`latest(k8s_node_pod_total)`, all phases). It fills the grid cell that\nwas previously left blank beside the single-line Node Status card. The\nnode scope has no Running-only pod metric, so this is total pods on the\nnode — the latest value of the same series the \"Pods on Node\" trend\ncharts. Title/tip added to the English source plus all seven locale\noverlays; node-dashboard doc and changelog updated.\n\nAlarms timeline — the hover tooltip now hints both affordances: click a\nminute to filter, or drag across the timeline to select a range. The\nbrush range-select was previously undiscoverable (no on-screen cue)."
    },
    {
      "commit": "30c11ce5ed580fcb7f0cfad11027a7dd9867c05d",
      "tree": "80cf0e4d4a353ccf0e14980b55363cb92482c656",
      "parents": [
        "d3b66d203d19622f03f976d0b8f62de6e4f9f880"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sat Jun 27 10:05:28 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sat Jun 27 10:05:28 2026 +0800"
      },
      "message": "UI correctness pass: comment audit, verified gap fixes, manual-fire query model, colored status cards, BFF boot-crash fix (#81)\n\nfeat(ui): colored status-chip cards, manual-fire query model, + correctness pass (#81)\n\nColored value-mapping cards: a format:\u0027enum\u0027 card renders each matched value — or metric label — as a colored status chip (ok/warn/err/info/neutral), editable in the layer-dashboard admin\u0027s value-map editor. The K8s Node Status card consumes it, showing a node\u0027s active conditions (Ready green, *Pressure/NetworkUnavailable amber-red) instead of a raw 1. Also fixes the widget schema silently stripping format/valueMap/valueColors/labelTopN from the resolver\u0027s request body, so the existing enum/valueMap feature is honored on that path too.\n\nBrowser Errors manual-fire: mirrors the Logs tab — stage filter edits into an applied snapshot, fetch only on Run query, and reset to the prompt (clearing the category filter) on service switch.\n\nCritical fix: the BFF crashed on boot because /api/ebpf/network/processes had no ROUTE_POLICY entry and the RBAC guard throws on any unpoliced route. It was masked by a stale dev process and invisible to type-check; caught by booting fresh against the demo OAP.\n\nHygiene + bug fixes: codebase-wide comment audit (strip ~331 non-earning comments, restore 8 over-removed gotchas) feeding a 32-finding gap review that was adversarially verified (5 false-positives dropped) — fixes for a loading-stuck modal, two missing-Escape dropdowns, Logs/Browser-Errors cascade-clear on service switch, Logs page reset, source-map clear on service switch, a partial form reset, and a label-case regression.\n\nProfiling: consistent, honest create gating; network tasks pick their instance in-dialog and validate it has rover-monitored processes before Create.\n\nDocs + i18n: profiling create-gating and Logs/Browser-Errors manual-fire docs; 18 i18n keys across all 8 locales for the new profiling strings.\n\nValidation: type-check 0, lint clean, both builds, license check, 278 unit tests, i18n complete across 8 locales; the colored chips, Browser-Errors manual-fire gate, and the network-processes route all live-validated headless against the demo OAP. The LayerDashboardsAdmin XL-file decomposition is intentionally deferred to its own focused PR."
    },
    {
      "commit": "d3b66d203d19622f03f976d0b8f62de6e4f9f880",
      "tree": "d2513a71f7382345803aad804d12720ddbe94238",
      "parents": [
        "59ebc236acd6f203ca864465638815f77c529aff"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Fri Jun 26 11:15:54 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Jun 26 11:15:54 2026 +0800"
      },
      "message": "feat: offline-capable readonly template mode, env-native config, and reachability-based Cluster Status (#80)\n\n* feat(config): readonly template mode (run without ui_template API) + env-native config (mount-free image)\n\nGoal A — `templates.mode: live | readonly` (HORIZON_TEMPLATES_MODE):\n- `readonly` renders every dashboard / overview / alert / 3D / translation from\n  the local disk bundle, loaded into the same in-memory row shape live mode\n  reads from OAP, so every render consumer resolves it unchanged. The\n  ui_template store is never called (no seed, no readiness wait); OAP\u0027s query API\n  is still used + boot-checked (config-only offline).\n- The config surface is read-only: admin pages still open + show the bundled\n  config, but write controls are gone (the BFF rejects config-template writes at\n  the route edge regardless — UI bypass still fails). Per-page + cluster-status\n  surfaces show the mode; the global banner suppresses the ui_template-store\n  strip in readonly (the query-unreachable strip still fires).\n- `live` (default) is unchanged: seed-to-OAP + read-from-OAP.\n\nGoal B — env-native config:\n- Every field in horizon.example.yaml is now a `${HORIZON_…:default}` token, so\n  the config file is the complete, self-documenting env-var reference. Scalars\n  take a value; lists / optional blocks (users, ldap, oap.auth, rbac.roles,\n  performance, …) take a JSON-string env var; a `:null` token falls through to\n  the schema default via a new `stripNullish` loader pass. Precedence env \u003e file\n  \u003e default.\n- The image bakes the tokenized config AS /app/horizon.yaml, so `docker run`\n  with env vars alone works — no mount, no repackage. A bind-mount still\n  overrides it.\n\nDocs: container-image full env table + env-only / readonly run recipes.\nTests: example parses to defaults (parity); env-merge (scalar / JSON / null /\nmalformed); readonly status. type-check / lint / license / 263 unit tests / i18n\ngreen. Validated live (demo OAP): readonly bundled render + write-409 + query\nlive; live-mode regression; mount-free env-only boot + login.\n\n* fix(config): review pass 1 — readonly translations + env-native robustness\n\n- P1: readonly mode rendered every non-English locale in English. Translation\n  overlays were sourced from `deps.bundledOverlays`, which only the (skipped-in-\n  readonly) boot seed supplies; the render callers omit it. The readonly branch\n  now sources overlays from the canonical `iterateBundledOverlays()` disk\n  iterator. Validated live: zh-CN readonly bundle carries the bundled overlays\n  (8551 CJK chars).\n- Scalar string env values are now injected into a quoted YAML position\n  (`\"${HORIZON_X:default}\"`), so a value with YAML metacharacters can\u0027t break\n  parsing; JSON list/object tokens + numbers/bools stay unquoted for typing.\n- `templates.mode` is fixed at boot (a hot-reload flip can\u0027t run the boot seed),\n  warning if the file later changes it.\n- `setTemplateReadOnly` also clears the in-flight sync probe (no cross-mode\n  backfill); the parity test resolves tokens against the same env the schema\u0027s\n  inline defaults read; the admin `TemplateSyncStatus` UI type gains `mode`;\n  config-bundle cache bumped v2→v3 so a stale cache can\u0027t show the wrong mode.\n\ntype-check / lint / license / 263 unit tests green; readonly + env-native re-validated live.\n\n* test(bff): readonly-mode sync — renders from disk, includes translation overlays, no OAP call\n\n* fix(config): review pass 2 — readonly UI gating, friendly config error, tests\n\n- Translations page is now read-only in readonly mode like its siblings: the\n  widget editor never opens (openPanel early-returns) and pushToOap guards on\n  readOnly. Previously the operator could translate→stage→push and was only\n  stopped by the server 409 at the end.\n- Invalid config (e.g. a bool env var set to no/off/0, a non-numeric port, or\n  malformed JSON env) now fails boot with a field-path-annotated fatal message\n  instead of a raw ZodError dump (the crash itself is correct — fail loud).\n- Tests: the readonly write-deny route hook (isTemplateWriteRoute matches the\n  config-template writes, not runtime-rule / local-state; deny → 409 in\n  readonly, no-op in live); quoted string scalars survive YAML-metachar values;\n  the example-parity test interpolates only the env vars the schema reads inline\n  (a stray HORIZON_* no longer reads as drift).\n\ntype-check / lint / license / 155 BFF + 116 UI unit tests green.\n\n* feat(cluster): reachability-based admin preflight + ui_template unified as a feature\n\nHealth on the Cluster Status admin pane is now the live probe, not config-\npresence. Each admin feature declares the relative REST path it actually calls\non OAP; the BFF fires a safe GET at it and reports whether it responds:\n\n  admin-server           /debugging/config/dump\n  receiver-runtime-rule  /runtime/rule/list\n  dsl-debugging          /dsl-debugging/status\n  inspect                /inspect/metrics\n  ui-management          /ui-management/templates\n\n`reachable` drives the green/red + the per-page warning gates. The config-dump\nprefix check (`enabled`) is demoted to an informational \"selector detected\"\nfootnote — it only tells you the official upstream release advertises that\nselector, so a renamed / forked / on-but-broken module that 404s now reads as\nunreachable instead of a misleading green (validated live: inspect on the demo\nOAP advertises its selector but only /inspect/metrics responds — the catalog\npath 404s).\n\nui_template joins the same table as a feature, mode-aware: in readonly it is\nnever probed (`reachable: null` → \"readonly · bundled\"). Each row shows how long\nago it was last checked (anchored to the BFF\u0027s generatedAt, so it reflects real\nprobe + cache age), and a force re-check bypasses the cache. The 5 probes are\nsingle-flighted for 30s on the BFF so the UI\u0027s 60s poll across N sessions\ndoesn\u0027t fan out on OAP.\n\nValidated live (demo OAP): live \u003d all five reachable; readonly \u003d ui_template\nnull + others probed; admin-port down \u003d all unreachable, no path probes.\ntype-check / lint / license / 161 BFF + 116 UI tests / builds / 8-locale i18n green.\n\n* refactor(cluster): fold the Env var column into the Gates description\n\nDrop the standalone Env var column; the selector now reads as an \u0027Enable on\nOAP: SW_X\u003ddefault\u0027 line under each feature\u0027s Gates description. Four columns\n(Feature / State / Probe path / Gates) read cleaner now that reachability — not\nthe selector — is the verdict.\n\n* fix(cluster): badge no longer claims \u0027all reachable\u0027 when a feature is bundled\n\nreachable\u003d\u003d\u003dnull (ui_template in readonly: not probed, serving bundled\ntemplates) was slipping past the reachable\u003d\u003d\u003dfalse filter, so the summary read\n\u0027all reachable\u0027 while a row showed \u0027readonly · bundled\u0027. Count the null/bundled\nfeatures separately — the badge now reads \u00274 reachable · 1 bundled\u0027.\n\n* fix(cluster): admin badge reads \u0027N/M reachable\u0027 in yellow when not all live\n\n\u00274 reachable · 1 bundled\u0027 wasn\u0027t a proper summary. The badge now shows a plain\nfraction — \u0027all reachable\u0027 (green) only when every feature probes live, else\n\u0027{n}/{total} reachable\u0027 (yellow). Covers both a 404\u0027d path and a bundled /\nnot-probed feature (ui_template in readonly); the per-row chip says which.\n\n* fix(readonly): close alert-page write gap, yellow read-only banner, stronger env parity\n\nThree review findings + a UX fix:\n\n- HIGH: the alert page-setup (/api/alarms/config) was still writable in readonly.\n  The readonly backstop only covered /api/admin/templates* and\n  /api/admin/overview-templates*; /api/alarms/config writes the\n  horizon.alert.page-setup template (file-backed) and slipped through. It\u0027s now\n  in the config-surface write set — a direct POST returns 409 in readonly — and\n  GET serves the bundled alert page-setup (like every other template) instead of\n  the local store. Validated live: POST 409, GET serves the bundle.\n\n- The read-only SyncStatusBanner was muted gray (\"white\") and easy to miss next\n  to the green per-row sync chips. It\u0027s now warning-yellow so it\u0027s unmistakable\n  that the whole config surface is bundle-served and uneditable. (The per-row\n  \"synced\" chip is correct as-is: in readonly the bundle IS the presented remote,\n  so bundled \u003d\u003d remote and the diff legitimately reads synced.)\n\n- The env parity guard only checked top-level sections + four hard-coded tokens.\n  It now recursively walks every schema-default field and asserts each is covered\n  by a ${HORIZON_…} token in horizon.example.yaml (a scalar token, or a JSON-env\n  token for a whole subtree). It immediately caught a real gap — oap.mqe shipped\n  untokenized — now fixed (HORIZON_OAP_MQE). A future nested field without env\n  coverage will now fail the test.\n\ntype-check / lint / license / 162 BFF + 116 UI tests / builds green.\n\n* fix(infra-3d): remove the silent disk-bundle fallback from the live render path\n\nThe 3D map was the lone surface that silently served the disk bundle when its\nremote couldn\u0027t be resolved — in live mode an unreachable ui_template store (or\na missing / invalid remote row) returned the bundled config instead of blocking.\nEvery other surface (bundle.ts) renders ONLY from remote rows and treats an\nunsynced / disabled / unreachable template as simply absent.\n\nresolveEffectiveConfig now does the same: render only from the remote row — a\nreal OAP row in live mode, the synthetic bundled row in readonly — and return\nnull otherwise. The route 503s on null, so the map blocks with its connectivity\nstate (like layer/overview/alert) instead of masking an outage with a stale\nbundle. The bundle still reaches OAP via boot-seed and is served in readonly\nthrough the fake-remote row; it is never a live runtime fallback.\n\nValidated live: readonly 200 (bundle via fake-remote), live+reachable 200\n(remote), live+ui_template-unreachable 503 (blocks). 162 BFF + 116 UI tests green.\n\n* refactor(config): one committed env-driven horizon.yaml (drop the example/copy split)\n\nCollapse `horizon.example.yaml` + the gitignored local copy into a single\ncommitted `horizon.yaml` — the same fully tokenized file the image ships and the\ndefault `pnpm start` reads. Every field is a `${HORIZON_…:default}` token, so all\nconfig is supplied via environment variables; nothing is edited in the file.\n\n- `git mv horizon.example.yaml horizon.yaml`; un-ignore it in .gitignore +\n  .dockerignore (now a tracked build input, not a local secret-bearing file).\n- Dockerfile bakes `dist/horizon.yaml` directly as `/app/horizon.yaml` — no more\n  separate `/app/horizon.example.yaml` reference copy, no rename-on-copy.\n- package.mjs copies the committed `horizon.yaml` into dist/; release.sh + the\n  parity tests (schema/loader) read `horizon.yaml`.\n- Docs: README, setup/overview, container-image, rbac, local-backend, ports no\n  longer reference an example file; local-backend clarifies the committed file\n  holds only `${HORIZON_…}` tokens (real hashes via HORIZON_AUTH_LOCAL_USERS).\n- local-boot skill: boots the repo `horizon.yaml` with OAP target + dev users\n  (`dev-users.json`) / LDAP (`dev-ldap.json`) injected via HORIZON_* env vars;\n  the three per-scenario yaml configs are removed. Documents the single-line-JSON\n  constraint for env-injected lists/objects.\n\nValidated live: env-native boot (root horizon.yaml + env vars only) against the\ndemo OAP — auth configured, login admin/admin → 200, OAP admin URL injected.\ntype-check / lint / license / 162 BFF + 116 UI tests green."
    },
    {
      "commit": "59ebc236acd6f203ca864465638815f77c529aff",
      "tree": "6e8090f7511123d81f457e96961c48c8ba531b68",
      "parents": [
        "f4ef9e0b4473c5ac2f93dedadc9d175650d3c9af"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Jun 25 23:52:15 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Jun 25 23:52:15 2026 +0800"
      },
      "message": "feat(layer-dashboards): tab widgets — several widgets in one named-panel slot, lazily loaded (#79)\n\nA layer-dashboard widget can now be a `tab` container: one grid slot holding any\nnumber of named tab panels, each its own little dashboard of card / line / top /\nrecord / table widgets in a 12-column sub-grid. Switching a tab swaps the whole\nset, and only the ACTIVE tab is queried — an unopened tab costs nothing and a\npreviously-viewed one stays warm. A tab carries no MQE of its own and cannot\nnest another tab (one level deep).\n\nAuthored inline in the Layer dashboards admin: add a widget, set its type to\n`tab`, size the slot; a segmented tab bar switches the active panel, a per-tab\n`+ widget` drops widgets in place, click a widget to edit it in the drawer,\ndrag its corner to resize, or drag it out to the top level. Tabs are managed\n(add / rename / reorder / delete) from the drawer\u0027s Tabs list.\n\nWidgets inside a tab render at full parity with top-level widgets — multi-entity\nCOMPARE (per-entity card rows / overlaid lines / grouped tables), the top/record\nPOP-OUT, and `visibleWhen` gating (compare-aware union, never the container) all\nwork, via a shared host render context. A unified widget-tree traversal\n(`walkWidgets` / `findWidgetById` / `collectWidgetIds`) is the single place that\ndescends tab panels, so find-by-id / id-uniqueness / enrichment never miss a\ntab child.\n\nQuerying: the SPA flattens to the active tab\u0027s leaves and chunks at the 40-widget\nrequest cap; the BFF flattens a tab to its first (default-active) panel for the\ntemplate-fallback / direct-API paths, re-checks the cap after expansion for a\nhand-built body, and accepts the `tab` type + empty `expressions` in its schema.\nA half-authored (blank-MQE) leaf is dropped from the batch instead of 400-ing the\nscope. `topNOrder` is enriched for top/record widgets inside tabs too.\n\nAlso: the compare bar gains a \"Clear all\" button to exit comparison (the CURRENT\nchip has no per-entity ×); the \"OAP unreachable\" banner / \"refreshing\" badge are\ncompare-aware (a failed primary no longer mislabels a working cohort); and the\ndead single-entity `record` pop-out button is removed.\n\nDocs + CHANGELOG updated; new UI strings translated across all 8 locales."
    },
    {
      "commit": "f4ef9e0b4473c5ac2f93dedadc9d175650d3c9af",
      "tree": "d66a74d3e743688a9efcff76c0397724a42f966d",
      "parents": [
        "b8864147e9b9cbc58838265f1610c6d1c5275ae5"
      ],
      "author": {
        "name": "songzhendong",
        "email": "xiaodong12315@qq.com",
        "time": "Thu Jun 25 10:34:08 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Jun 25 10:34:08 2026 +0800"
      },
      "message": "Add PHP PHM instance widgets and i18n overlays. (#74)\n\nAdd six General Service instance line widgets for meter_instance_php_* with\nvisibleWhen gates and i18n overlays for de/es/fr/ja/ko/pt/zh-CN. Document in\nCHANGELOG 1.1.0."
    },
    {
      "commit": "b8864147e9b9cbc58838265f1610c6d1c5275ae5",
      "tree": "8b6b8682a2b173d47288a2a0b49be9dc2b0f44bd",
      "parents": [
        "15e03601603fef52bc9b64fc3db86ad600d1f642"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Jun 25 09:50:29 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Jun 25 09:50:29 2026 +0800"
      },
      "message": "fix(layer-dashboards): pin the widget editor in place so it always opens complete (#77)"
    },
    {
      "commit": "15e03601603fef52bc9b64fc3db86ad600d1f642",
      "tree": "ec9c4d9fbdeaaffbae911f289ff58e61ca101823",
      "parents": [
        "ade759f02b34c1ae7bae1aec6a317fe72cc2c446"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Jun 25 09:20:45 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Jun 25 09:20:45 2026 +0800"
      },
      "message": "fix(layer-dashboards): keep the widget editor\u0027s move / delete controls in view (#75)\n\nThe per-widget editor drawer kept Up / Down / Delete as the last block inside a\nscrolling body, and its fixed height (calc(100vh - 52px)) overshot because the\ndrawer sits below the editor card\u0027s header — so the drawer\u0027s bottom, with the\nDelete button, fell below the viewport, hidden behind a scrollbar even on a\nshort board with few widgets.\n\nMove Up / Down / Delete into a pinned footer outside the scroll region, and size\nthe drawer in JS from its live top offset down to the viewport bottom (re-synced\non open, scroll, and resize) so the footer is always visible regardless of pin\nstate or board height. Also scroll a newly-added widget into view, next to the\neditor that opens for it, instead of leaving it off-screen at the canvas bottom."
    },
    {
      "commit": "ade759f02b34c1ae7bae1aec6a317fe72cc2c446",
      "tree": "4b9de5cb0fa9430e720a762cfdd8902b9af175a9",
      "parents": [
        "97462438983bc15046ef8df94dd4e7055fa50b24"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Wed Jun 24 23:21:11 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Wed Jun 24 23:21:11 2026 +0800"
      },
      "message": "feat(inspect): add \u0026 chart foreign metrics another OAP wrote to shared storage (#73)\n\nMetrics Inspect can now add metrics the connected OAP doesn\u0027t define — ones\nanother OAP (an older version, or a different distribution that doesn\u0027t carry\nthe analysis rule) wrote into the shared storage — and chart their values.\n\nA new \"Foreign metric\" tab in the + add metric drawer takes the metric name,\nscope, and the storage value column + type (read from the catalog of the OAP\nthat does define the metric). Stage several with \"+ add to list\" and bulk-add\nthem, board-cap aware, through the same counting footer as the catalog tab.\nEach widget enumerates the metric\u0027s entities and plots the value series via\nOAP\u0027s new POST /inspect/values (the admin-port counterpart to execExpression,\nwhich can\u0027t evaluate a metric the OAP has no local model for). Foreign widgets\ncarry a FOREIGN pill, behave like any other widget (entity nav, chart-cycle,\nmulti-select), and persist their selection + custom entities across reloads.\n\nAbsent-endpoint and upstream errors are reported honestly: a 404 on\n/inspect/values says the OAP build is too old rather than \"inspect disabled\",\nand OAP\u0027s own {error} text (a wrong value column, a metric that is actually\ndefined locally, an unsupported shape) now reaches the operator instead of a\nbare HTTP 4xx."
    },
    {
      "commit": "97462438983bc15046ef8df94dd4e7055fa50b24",
      "tree": "caee1690fbb068e46e1d8b24ca5ac432eef89c40",
      "parents": [
        "1618a7268be70325293af319dba413bfd85f2f46"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Wed Jun 24 16:09:12 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Wed Jun 24 16:09:12 2026 +0800"
      },
      "message": "feat(explore): Trace inspect + Log inspect — cross-layer trace/log query tools (#72)\n\n## Why\n\nThe per-layer Traces / Logs / Browser-errors / Pod-logs tabs answer \"what\u0027s happening in *this* service?\" — but operators often need to query **across layers**, by trace-id / keyword / category / pod, with no layer in hand. This adds two cross-layer **inspect** power-tools modeled on Metrics inspect: name a service (pick it, type it, or leave it blank), set conditions, get one result. As an inspect surface it deliberately exposes every layer / service / flag rather than pinning defaults.\n\n## What\n\n### Trace inspect (`/operate/trace-inspect`)\n\nCross-layer trace query with a **Native ↔ Zipkin** source toggle. Optional entity — **Pick** (layer→service→instance→endpoint), **Type** (service name + real flag, which the BFF base64-encodes into the OAP id), or **blank** (all services). Native conditions (status / order / duration / tags / trace-id / time) plus the duration-distribution scatter — drag to brush-filter the list, click a dot to open the trace (a brush drag no longer also opens a detail). Zipkin gets its own service / remote-service / span pickers + annotation query. Result and detail reuse the **same shared widgets** as the per-layer Traces tab.\n\n### Log inspect (`/operate/log-inspect`)\n\nCross-layer log query; row-click opens a shared full-payload popout. Three sources:\n\n- **Raw** — OAP `queryLogs` (Tags / Trace ID / Time / Limit); Tags is a custom on-theme key/value autocomplete.\n\n- **Browser** — the BROWSER layer\u0027s JS errors, filtered by **Category, Version and Page** (`serviceVersionId` / `pagePathId`) plus time; **upload and manage source maps** inline, and the row popout resolves the minified stack against them. Entity is Pick / Type / blank with no pinned layer.\n\n- **Kubernetes Pod logs** — on-demand container tail. **Pick or Type** a service (Type is layer-less — the typed name encodes to a service id, and a Real flag is exposed), then the pod + container **auto-select**, and it **live auto-tails** (Interval + Pause/Start + Refresh) with **Include / Exclude** raw-regex filters. Live and never persisted → no cold-stage; surfaces OAP\u0027s `errorReason` (e.g. a terminated pod). A tip notes that each pod is the service instance, surfaced as its live Kubernetes pod, so it must be currently running.\n\n### Shared + cross-cutting\n\n- New shared widgets (`render/widgets/`): `TraceListPanel`, `TraceDetailCard`, `TraceDistribution`, `ZipkinTraceDetailCard`, `LogStreamPanel`, `LogDetailPopout`, `BrowserErrorPopout` — extracted from the per-layer views and reused by **both** the inspect tools and those tabs (no behavior/look change to the per-layer Traces / Logs / Browser tabs).\n\n- New `TagInput` primitive — a custom dark, anchored autocomplete dropdown (tag key suggestions before `\u003d`, per-key values after) replacing the browser-native `\u003cdatalist\u003e`, in the inspect Tags fields and the per-layer Traces / Logs tag inputs.\n\n- BFF: one `POST /api/explore/query` dispatches by kind + source; byte-exact entity-id encoding in `util/entityId.ts` (verified against OAP `IDManager`); `fetchNativeList` / `fetchLogs` / `fetchBrowserErrors` extracted from the per-layer routes; k8s reuses the existing pod-log routes. Cold-stage is honored for native trace / raw logs / browser errors and correctly a no-op for k8s pod logs and Zipkin.\n\n- **RBAC**: both tools gate on **`inspect:read`** — the same verb as Metrics inspect, so one grant covers all three inspectors (the prior dedicated `explore:read` verb was removed). Note: this also grants the **viewer** role Metrics inspect.\n\n- MAL/DSL rule cards now show the full rule name (wrapping to a second line) with the status pills moved to the bottom-right corner.\n\n- i18n: every new string in `en.json` first + all 7 non-English locales."
    },
    {
      "commit": "1618a7268be70325293af319dba413bfd85f2f46",
      "tree": "36f950dd78cd50388c8049967b4b22bb62f8dc45",
      "parents": [
        "6fded53d2f1a4ef5445077e04ece9bb268449350"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Tue Jun 23 23:36:07 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Jun 23 23:36:07 2026 +0800"
      },
      "message": "fix(templates): single-value metrics render as cards, not flat lines (#71)\n\n* fix(templates): single-value metrics render as cards, not flat lines\n\nAuditing the bundled layer dashboards against their booster-ui originals\n(the templates horizon was ported from) surfaced a mis-port class: a\nwidget whose MQE collapses the window to one number (a latest(...) total)\nwas rendered as type:\"line\" — a lone dot that misreads as a time series,\nand where a total was merged with an average into one widget, two scales\nshared one axis. The Virtual GenAI Input/Output Token + Estimated Cost\ntiles were the most visible case.\n\nSplit each affected widget into a single-value card (the total) plus a\ntrend line (the average), matching booster\u0027s original Card/Line split and\nthe \"widget type follows MQE shape\" rule. Audited all 46 bundled\ntemplates one-by-one against booster; 6 had the mis-port: virtual_genai\n(provider+model), elasticsearch, clickhouse, rabbitmq, rocketmq, apisix.\n\nEvery changed dashboard row still tiles to exactly full width (12 cols);\nsome pre-existing partial rows were closed in passing. The index-keyed\ni18n overlays were realigned across all 7 locales; i18n:validate clean.\n\n* fix(templates): close pre-existing layout gaps in clickhouse + elasticsearch\n\nSpan/rowSpan-only reflow so every dashboard section dense-packs the 12-col\ngrid with no empty cells, and single-value cards align to their row-mates\u0027\nheight (rs1 cards beside rs2 line charts were both a gap and a height\nmismatch; rs2 is the standard card height). No widgets added, removed, or\nreordered — the index-keyed i18n overlays are untouched (i18n:validate\nclean)."
    },
    {
      "commit": "6fded53d2f1a4ef5445077e04ece9bb268449350",
      "tree": "7baa8b0bb528ddedd78be969bda6714eb0ae6309",
      "parents": [
        "1acd0ba1f3a910191b02c7f3d276ff901a678e70"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Tue Jun 23 09:28:37 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Jun 23 09:28:37 2026 +0800"
      },
      "message": "feat(traces): Zipkin trace view reaches native parity + unified ?traceId\u003d links (#70)\n\n## Why\n\nThe Zipkin trace view (mesh / k8s / Envoy-ALS layers) looked and behaved like a second-class citizen next to the SkyWalking-native trace view — a different waterfall, a side-panel span detail, browser `\u003cdatalist\u003e` filters that wouldn\u0027t reopen, and no duration distribution. It also used a separate `?openZipkinTraceId\u003d` share param even though `/layer/\u003clayer\u003e/trace` already implies the source.\n\n## What\n\n**Zipkin trace detail + popout now mirror the native trace experience** (copy-and-adapted, since the wire fields differ):\n- KPI strip (started / duration / spans / services) + service legend.\n- **Duration-distribution scatter** — drag to filter the list, click a dot to open the trace (ported from native, adapted to Zipkin\u0027s µs units).\n- Time-positioned **waterfall bars** with the service · operation + kind/status affordances *inside* the bar, a time axis, and the duration flipped inside near-full-width bars (no stray x-scrollbar).\n- **Centered span-detail modal** (was a cramped side panel).\n- Zipkin annotation codes (`cr` / `cs` / `sr` / `ss` …) get an inline plain-language hint; the raw OAP value is kept verbatim.\n\n**Unified shareable links** — both native and Zipkin open from a single `?traceId\u003d` under the layer\u0027s trace tab. The two globally-mounted popouts self-select by trace-ID shape (native is dotted `x.y.z`, Zipkin is bare 16/32-hex), so `/layer/\u003clayer\u003e/trace?traceId\u003d…` always opens the right viewer. The separate `?openZipkinTraceId\u003d` param is gone.\n\n**Searchable, on-theme filter dropdowns** — native Instance/Endpoint and Zipkin Service/Remote/Span name now use the shared `TypeaheadSelect` (dark, type-to-filter, reopens after a pick); small enums (status/order/limit) stay plain `\u003cselect\u003e`."
    },
    {
      "commit": "1acd0ba1f3a910191b02c7f3d276ff901a678e70",
      "tree": "829d1c6fd792a414a276b63b4591408350103558",
      "parents": [
        "17360c1af68fc4c52fec52fbe4b13defe782107e"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Tue Jun 23 08:22:42 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Jun 23 08:22:42 2026 +0800"
      },
      "message": "feat(config): performance tuning section in horizon.yaml (fan-out + caps + 3D pipeline relocation) (#69)\n\n## Why\n\nOperational tuning — how hard the BFF fans metric queries out to OAP, and the caps that protect storage — was scattered in two wrong places: **hardcoded constants** in the query routes, and a **`pipeline` block inside the 3D dashboard template** (which is published to OAP, editable in the admin UI, and travels with export/import — none of which fits per-deployment fan-out tuning). Operators had no single, documented place to tune it, and couldn\u0027t tune it per-instance at all.\n\nThis consolidates all of it into one operator-owned, hot-reloaded `performance` section in `horizon.yaml`, next to the existing `query.landingServiceCap` / `oap.timeoutMs` knobs.\n\n## What\n\n**New `performance` section** (defaults equal today\u0027s built-ins, so it\u0027s optional and behavior-preserving):\n- `performance.bulk` — per-route bulk (request) size + concurrency for the **topology / 3D-map / landing / dashboard** OAP fan-outs.\n- `performance.limits` — the **service-map render valve** (`topologyMaxNodes` 5000 / `topologyMaxEdges` 15000) and per-request **record caps** (`maxPageSize.{traces,logs,browserLogs}` \u003d 100). Every value clamps to a hard ceiling (config can lower, never exceed).\n\n**3D pipeline relocation** — the 3D map\u0027s metric fan-out moved out of the OAP-published template into `performance.bulk.infra3d`. The BFF injects it into the config response, so the UI is unchanged; a stale template still carrying the old `pipeline` block is **accept-and-ignored** (no breakage on upgrade).\n\n**Unified page-size pickers** — Traces, Logs, and Browser Logs now share a `20 / 30 / 50 / 100` dropdown. Browser Logs **gains a picker it never had** (it was a fixed 100); the trace cap drops `200 → 100` to match the dropdown. Each picker\u0027s max equals the server-side cap.\n\n**Memory** — the image sets a default `NODE_OPTIONS\u003d--max-old-space-size`, with docs on sizing the Node heap against the in-memory source-map budget. (It\u0027s a process flag, not a config field — V8 reads it before `horizon.yaml` loads.)\n\n**Drift guard** — a new test (`schema.test.ts`) asserts `configSchema.parse({})` succeeds (every field has a default) **and** that `horizon.example.yaml` is byte-identical to the schema defaults — so the example can\u0027t silently drift again. (It already had: the rbac roles were missing `infra-3d:read`; fixed here.)"
    },
    {
      "commit": "17360c1af68fc4c52fec52fbe4b13defe782107e",
      "tree": "e2f095a9e0fa0e52a17c49867627c080b05cb987",
      "parents": [
        "09be88d4dfdd747188edbe077431ea40032d9fc8"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Mon Jun 22 19:02:09 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Jun 22 19:02:09 2026 +0800"
      },
      "message": "fix: correct malformed next-dev version + validate versions in release.sh (#68)"
    },
    {
      "commit": "09be88d4dfdd747188edbe077431ea40032d9fc8",
      "tree": "a08ef3330e04fa9e1e654f19ebf896e538fe2803",
      "parents": [
        "ce3e940e79010f0a7c04e26fb3440fa134947dd5"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Mon Jun 22 16:43:41 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Jun 22 16:43:41 2026 +0800"
      },
      "message": "Release 0.7.0, bump to 1.0.0-dev-dev (#67)"
    },
    {
      "commit": "ce3e940e79010f0a7c04e26fb3440fa134947dd5",
      "tree": "787729158f31425546777d2477cb0bbaa5f72aee",
      "parents": [
        "c66e2c81273cb5c4ba110a4ead864d1b474bb515"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Mon Jun 22 16:12:01 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Jun 22 16:12:01 2026 +0800"
      },
      "message": "docs: features \u0026 bundled-dashboard reference + README/CHANGELOG release cleanup (#66)"
    },
    {
      "commit": "c66e2c81273cb5c4ba110a4ead864d1b474bb515",
      "tree": "e750c5d98b500ff29f3c45a62c72cb3c7d4cf235",
      "parents": [
        "82a0bfab9d79c52677a12ca327eb58819b3c3989"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Mon Jun 22 10:00:53 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Jun 22 10:00:53 2026 +0800"
      },
      "message": "perf(maps): topology scale caps, partial-metric banners, parallel 3D load (#65)\n\nThree independent scale/resilience hardenings for the topology maps and the\n3D Infra Map. Output-identical on the happy path.\n\n- 3D Infra Map: per-node metric values now load in bounded-concurrency\n  batches (new `metricConcurrency` config, default 4) instead of one chunk\n  at a time, so load rings / traffic values fill in sooner on large layers.\n- Layer service map: when a graph exceeds the render ceiling (5000 services\n  / 15000 calls) the route returns a `tooLarge` envelope and the UI shows a\n  \"too large to render\" notice plus narrow-scope guidance, rather than\n  attempting an unreadable layout. Reject-with-guidance, not truncation.\n- All four topology maps (service map, instance topology, deployment,\n  endpoint dependency) thread per-chunk soft-fail counts and surface a\n  `metricsPartial` advisory when some metric batches fail, so a transient\n  OAP error reads as \"unavailable\" not \"zero\". The endpoint-dependency copy\n  notes nodes/edges may be missing (it prunes valueless nodes). New banner\n  strings translated across all 8 locales."
    },
    {
      "commit": "82a0bfab9d79c52677a12ca327eb58819b3c3989",
      "tree": "a8683e58cebf90f6eba689f68bfb2285175be9cb",
      "parents": [
        "9fd499c686167dfe0d24ec1994ad5efd73b42b5e"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Jun 21 21:58:04 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Jun 21 21:58:04 2026 +0800"
      },
      "message": "perf(bff): parallelize topology node/edge MQE fan-out across the 4 map routes (#64)\n\nThe service-map, instance-map, deployment, and endpoint-dependency routes each loaded per-node then per-edge metrics via serial chunked loops. They now build both fragment families up front and fan them out concurrently via a shared fetchAliasedChunks helper (bounded-concurrency mapPool), cutting the metric-load wall-clock toward a single round-trip for typical graphs.\n\nOutput is identical on the success path — same fragments, same merge, same values. On a partial OAP failure the new per-chunk soft-fail degrades only the failed chunk instead of aborting the rest (break-on-first-error before), so strictly more metrics are retained.\n\n- util/mapPool.ts: bounded-concurrency ordered map (+ unit test)\n- client/graphql.ts: fetchAliasedChunks\u003cT\u003e (chunk + pool + per-chunk soft-fail + merge)\n- topology / instance-topology / deployment / endpoint-dependency: node+edge fragments built up front, then Promise.all(fetchAliasedChunks node, edge)"
    },
    {
      "commit": "9fd499c686167dfe0d24ec1994ad5efd73b42b5e",
      "tree": "394a3220d00419b230998f9d89ea60e9dc19cf12",
      "parents": [
        "39b701cb8cf0b758ebd6854606ccd6d14f4cd465"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Jun 21 21:11:59 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Jun 21 21:11:59 2026 +0800"
      },
      "message": "RC0: dashboard/alarms query reductions + entity Compare goes standard (#63)\n\n## 1. BFF — fewer redundant OAP queries\n\n**Dashboard — reuse the warm service catalog.** `POST /api/layer/:key/dashboard` used to issue its own `listServices(layer)` on every request just to auto-pick the service and carry its `normal`/`group` entity-scope flags. It now reads the shared per-layer `ServiceLayerCatalog` (60s TTL, already kept warm by the sidebar) first, and only falls back to a live `listServices` on a miss — a cold/empty snapshot, a just-registered service, or OAP being unreachable.\n\n**Alarms — parallelize the startup probes.** `/api/alarms` and `/api/alarms/count` ran the server-time-offset probe and the backend-capability probe serially; they now `Promise.all`.\n\n### Reachability correctness\n`ServiceLayerCatalog.get()` soft-fails to an empty snapshot when OAP is down (it never throws), so a naive cache-first swap would let a *warm* cache mask an outage. Reachability now follows the **actual widget data fetch**: when every batched widget fails, the response is `reachable: false` with the widgets labelled `oap unreachable` — strictly more correct than the old Step-1 probe. (This was the one `major` from an adversarial pre-PR review; 7 other candidates verified as false positives.)\n\n## 2. UI — entity Compare graduates from preview to standard\n\nThe multi-entity lock + cross-check (\"pin entity\") feature, previously gated behind `FF_ENTITY_COMPARE` (off by default), is now **standard on every service / instance / endpoint layer dashboard — no flag, no config**. This removes the `featureFlags` module (it held only this one flag) + its test and ungates the call sites: the lock pins are now always present, while the compare fan-out still only engages once entities are actually pinned (an unlocked dashboard renders exactly as before). The CHANGELOG \"Lock \u0026 compare\" entry drops the preview/flag instructions. A separate adversarial pre-PR review of this diff returned SHIP with zero findings."
    },
    {
      "commit": "39b701cb8cf0b758ebd6854606ccd6d14f4cd465",
      "tree": "6a6dd2fd4d412b5d901d0c31c4562cdd3f5547d3",
      "parents": [
        "9a498a56355f303ed05f5584f1780a8337296909"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Jun 21 11:29:10 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Jun 21 11:29:10 2026 +0800"
      },
      "message": "fix(admin): per-kind save-local gate + overview validation; wrap inspect charts + step-formatted time-axis (#62)\n\nThree RC0-review follow-ups: the per-kind template-save validation/gate consistency (§4.5), the InspectView \"charts must be wrapped\" cleanup, and a time-axis formatting fix that fell out of it.\n\n## What\n\n**§4.5 — save validation + gate consistency (BFF)** · \n- `/api/admin/templates/save-local` was gated wholesale on `overview:write`. It now enforces the **same per-kind verb** as the OAP-backed save — `layer → dashboard:write`, else `overview:write` — in the handler (route gated `auth`), so a `dashboard:write`-only role can save layer drafts.\n- **Overview content is now validated before any write.** The `dashboardSchema` guard the legacy overview-create route ran was lost when editing moved to `/templates/save`; a malformed overview (missing required field, unknown widget type) is now rejected with a field-level error on **both** save paths instead of being written to OAP. `dashboardSchema` is exported for reuse.\n\n**InspectView — wrap the board charts (UI)** · \n- The view instantiated ECharts directly (violating \"charts are wrapped\"). The shared `TimeChart` is line/area-only but the inspect board also has a **bar** mode, so I extracted a feature-local `InspectMetricChart.vue` that owns one ECharts instance + lifecycle. A `ResizeObserver` replaces the manual window-resize + density-watch wiring, and Vue\u0027s per-widget mount/unmount replaces the manual instance `Map` + disposal. The chart-option builder moved **verbatim** — behavior unchanged. Net **−160 lines** in the view.\n\n**Inspect time-axis format (UI)** · \n- The inspect charts showed raw epoch-ms on the x-axis and tooltip header. They now format each bucket by the **query step**, identical to the layer-dashboard line widgets: `DAY → MM-DD`, `HOUR → MM-DD HH:00`, `MINUTE → HH:MM` (browser-local). The per-step formatter is extracted to a shared `bucketTimeLabel` in `utils/formatters` and used by **both** the dashboard widgets and the inspect chart, so they can\u0027t drift."
    },
    {
      "commit": "9a498a56355f303ed05f5584f1780a8337296909",
      "tree": "e0dc50807407ac665da85486287c3ba7cec676a9",
      "parents": [
        "213db03b69c9d4c7b435c7572bdda28cc9823d62"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Jun 21 09:52:13 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Jun 21 09:52:13 2026 +0800"
      },
      "message": "fix: RC0 Tier-1 correctness + Tier-2 hygiene (#61)\n\n* fix: RC0 Tier-1 correctness + Tier-2 hygiene\n\nTier-1 (correctness):\n- endpoint-dependency: honor the topbar time picker end-to-end (BFF route + client + composable + in-view expand). It was pinned to the last hour; it now queries the selected window like the service/instance maps. Stale \"last 15 minutes\" empty-state copy fixed.\n- dashboard: degrade per metric chunk — a transient failure on one widget batch marks only those widgets instead of blanking the whole dashboard (keeps reachable:true).\n- window: key the server-timezone cache on the OAP query URL, so a config reload that repoints OAP re-probes immediately instead of serving the previous server\u0027s offset for up to the TTL.\n- trace (v2 / BanyanDB): select the root span by empty refs, not just parentSpanId \u003d\u003d\u003d -1, so a multi-segment trace\u0027s list row reflects the true entry span; guard the empty endpoint name.\n- live-debug: gate /api/debug/status on live-debug:read alone (drop the redundant in-handler cluster:read double-gate).\n\nTier-2 (hygiene):\n- Remove the ?mockTop\u003d synthetic-row injector from the dashboard route.\n- Remove two leftover console.log statements from the topology request path.\n- Add baseline security response headers (nosniff / DENY / no-referrer) via an onSend hook.\n- Dead code: drop the unused invalidateServerOffsetCache + defaultSecondWindow, the UTC-formatting chain behind the no-consumer timeRange `duration` export, and the unused requireVerb pre-handler.\n\ni18n: one swapped UI string translated across all 8 locales. CHANGELOG updated under 0.7.0.\n\n* fix(profiling): use more of the page height\n\nThe Trace / eBPF / Async / pprof / network profiling layouts sized their shell off a fixed `calc(100vh - 280px)`, which over-counts the chrome above them (the topbar already sits outside the scrolling content cell) and left dead space at the bottom — most visible on taller screens. Reduce the offset so the shell extends closer to the bottom of the view."
    },
    {
      "commit": "213db03b69c9d4c7b435c7572bdda28cc9823d62",
      "tree": "0aca3b29e29fa820423450a09331f6bc68a4b015",
      "parents": [
        "ef66f70173970b165a0bf837d9219bfd4270a426"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Sun Jun 21 08:32:36 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Jun 21 08:32:36 2026 +0800"
      },
      "message": "fix(rbac): per-kind template-save permission, surface infra-3d:read, plus docs + UI fixes (#60)\n\nRBAC:\n- Add `infra-3d:read` to the verb catalog so it appears on the Roles board and Auth Status. It was already enforced and granted to every built-in role (viewer and up) but was invisible there.\n- Enforce template save per kind: a layer template now requires `dashboard:write` (the verb the layer-dashboard editor advertises); overview / alert / 3D-map kinds keep `overview:write`. Built-in roles are unaffected (operator and admin hold both).\n- Regroup `ROUTE_POLICY` by the lowest built-in role that can reach each route (viewer ⊂ maintainer ⊂ operator ⊂ admin) and trim comments. Behavior-neutral — every route→verb mapping is unchanged.\n\nUI fixes:\n- Metrics Inspect: render the crosshair tooltip at body level (appendToBody) so it is no longer clipped by the widget overflow or painted under the sidebar.\n- 3D map config: require a saved local draft before \"Check diff \u0026 push\"; give the push diff a definite height (fit-body modal + flex wrapper) so the side-by-side comparison renders instead of an empty panel.\n\nDocs: operator-perspective accuracy sweep — Cluster Status three panes / no per-node member list, container readiness probe → public /api/health, layer-template `components` default + `aliases` authoring key, removal of the dead `visibleWhen` free-text and embedded-i18n shapes, data-retention cold stage, and RBAC wording reconciled to the per-kind gate.\n\ni18n: two new UI strings translated across all 8 locales. CHANGELOG updated under 0.7.0."
    },
    {
      "commit": "ef66f70173970b165a0bf837d9219bfd4270a426",
      "tree": "b30e2154c893276eeae6b305596522f23b91be9f",
      "parents": [
        "3d5f1ac26d8295b64b7da15d3c36560e7413db5e"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Jun 18 19:44:24 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Jun 18 19:44:24 2026 +0800"
      },
      "message": "chore(ci): pin docker actions to the ASF-allow-listed SHAs (#59)\n\nASF tightened the GitHub Actions allow-list, and the docker/setup-buildx-action\nand docker/login-action SHAs in publish-image.yaml are no longer permitted —\nthe image-publish workflow now fails with \"actions ... are not allowed in\napache/skywalking-horizon-ui\".\n\nBump both to the SHAs the main apache/skywalking repo pins (vetted under the\nsame enterprise allow-list):\n  docker/setup-buildx-action  8d2750c → d7f5e7f5 (v4.1.0)\n  docker/login-action         c94ce9f → 650006c6 (v4.2.0)"
    },
    {
      "commit": "3d5f1ac26d8295b64b7da15d3c36560e7413db5e",
      "tree": "9e958c4682847a2df85ff2b3175650a38fa10d25",
      "parents": [
        "84bbbe62f77e27138d886ad3260b6b16a64f540f"
      ],
      "author": {
        "name": "吴晟 Wu Sheng",
        "email": "wu.sheng@foxmail.com",
        "time": "Thu Jun 18 19:08:11 2026 +0800"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Jun 18 19:08:11 2026 +0800"
      },
      "message": "fix(layer): jump-to-trace + copy on record widgets, fix the unfulfilled tip (#58)\n\nRecord widgets (slow SQL / slow statements / slow cache commands) advertised\n\"click to drill into the originating trace\", but the trace id never reached the\nUI — the BFF selected only id/value/owner from the MQE response and dropped the\nper-sample trace. The tip promised a drill-in that couldn\u0027t happen.\n\nOAP\u0027s MQE exposes traceID on each RECORD_LIST sample (metrics-v3.graphqls).\nSelect it, and forward it per row — omitted when the sample has none, since\nrecords are sampled and the trace can be absent.\n\nOn the UI, the record widget now renders via a dedicated RecordList: each\nsampled row gets a jump-to-trace icon at the row head, shown ONLY when the\nsample carries a trace id, that opens the trace in the global popout (resolved\nby id, so it works even when the trace belongs to a service on another layer).\nThe statement text itself is click-to-copy. Compare mode keeps the plain\nTopList (no single trace to jump to).\n\nCorrect the bundled Virtual Database / Virtual Cache slow-record tips (English\nplus all 7 locale overlays) and the dashboard-widgets doc to describe the new\ncopy + jump interaction instead of the old \"click to drill\" wording."
    }
  ],
  "next": "84bbbe62f77e27138d886ad3260b6b16a64f540f"
}
