首页
/ PostHog 仪表盘 Widget 平台开发指南:从新建 widget_type 到维护已上线类型

PostHog 仪表盘 Widget 平台开发指南:从新建 widget_type 到维护已上线类型

2026-09-09 16:48:39作者:吴年前Myrtle

PostHog 的仪表盘(Dashboard)提供了一种可扩展的 widget 磁贴机制,让产品团队能把原生产品列表、实时数据等直接搬上仪表盘。本文是面向 PostHog 工程师的完整实战指南,围绕 .agents/skills/manage-dashboard-widgets/SKILL.mdreferences/ 下的全套文档,讲解如何新建一个 widget_type、如何更新已上线类型、平台有哪些不可违反的架构约束,以及如何用一套 Pydantic 配置契约驱动 OpenAPI / Zod / MCP 代码生成。读完后你将掌握从 intake 确认、分步落地、验证到发布的完整流程,以及 widget_specs/registry.pycatalog.tsregistry.tsxWidgetCard 组合模式等核心文件的职责与调用关系。

1. 先分流:这个需求该走哪条路?

manage-dashboard-widgets 技能的第一步不是写代码,而是路由(Route first)。不是所有"仪表盘上加点东西"的需求都属于 widget 平台工作,先对照 SKILL.md §1 的分流表判断:

请求类型 路径 从哪里开始
新建一个尚不存在的 widget_type Ship 走 §2,intake 确认是强制前置
修改已上线类型的 config / 查询 / UI / 布局 / RBAC / 弃用 Update 走 §3,跳过 intake
把已有类型加到某个仪表盘 仅走 MCP(dashboard-widget-catalog-listdashboard-widgets-batch-add),不属于本技能范围
仪表盘上的趋势/漏斗/图表需求 Insight tile,不是 widget。见 architecture.md § Charts

两个容易混淆的边界要特别记住:

  • 图表主体验的 widget 不能做。时间序列、漏斗、分解、饼/柱/线图作为磁贴主体,都属于 insight tile 的职责(HogQL/查询可视化、对比、公式、订阅、告警都已覆盖)。widget 只做"产品原生列表/表格/卡片"这类上下文,例如 error_tracking_listsession_replay_list。intake 若发现请求是图表型,应停下来引导工程师改用 insight,而不是新建 widget_type
  • 已上线类型的 widget_type 字符串不可变。它同时是 catalog key、WIDGET_REGISTRY key、DB 列、前端 registry key。想换可视化形态,正确做法是新增磁贴(新类型)并删除旧磁贴,而不是改字段。

已上线类型的权威列表以代码为准(不要手维护清单):EXPECTED_WIDGET_TYPES / WIDGET_REGISTRYbackend/widget_registry.py,re-export 自 widget_specs/registry.py)、WIDGET_CATALOGbackend/widget_catalog.py,由 WIDGET_SPECS 派生)、DASHBOARD_WIDGET_CATALOGfrontend/widget_types/catalog.ts)、OpenAPI config serializers(widget_specs/openapi.py)。

2. 新建 widget_type:intake 先于一切编码

2.1 为什么 intake 是强制的

新类型会引入一个永久性的 registry / DB key,且涉及前端组件、编辑弹窗、预览、Storybook、OpenAPI 等多个层面。因此 widget-intake.md 规定:在拿到工程师确认之前,不得打开 checklist、不得开始编码。intake 的四步工作流:

  1. 把请求解析为 spec 字段(产品领域、label 意图);
  2. 先在仓库里发现产品 UI(不要急着问"磁贴该展示什么"这种空泛问题);
  3. 应用默认值与推断规则(groupId、copy spine、列表 UX);
  4. 解决歧义——对仍然开放字段提问,每轮最多 6 个问题
  5. 贴出 spec 汇总并等待明确确认(或"defaults fine")。

2.2 仓库 UI 发现(Discover product UI)

工程师往往只描述业务结果("top logs"、"recent recordings"),agent 的职责是在代码库里找到具体的场景组件和查询执行器。搜索路径有明确优先级:

  1. products/dashboards/frontend/widget_types/catalog.ts —— 看已有的分组与兄弟类型;
  2. products/<product>/frontend/components/.../scenes/ —— 找 *List*Table*Preview*Row 类导出;
  3. frontend/src/scenes/<area>/ —— 仍在 products/ 之外的旧场景;
  4. products/dashboards/frontend/widgets/<product>/ —— 兄弟 widget 的 Component 导入;
  5. products/<product>/backend/ —— 独立列表场景调用的查询函数,它将成为 run_* 的委托目标;
  6. urls.ts / 场景常量 —— 用于 titleHref 和产品 UI 引用(如 /error-tracking/logs)。

必须用 LSP / grep / glob 搜索,不要猜组件名。发现结果决定动作:只有一个清晰的列表/卡片 UI + runner → 锁定并进入下一步;多个可选 UI → 提问;只有图表型表面 → 停止并改走 insight tile;完全无可复用内容 → 开放式提问"磁贴该展示什么 + 场景路径或 mock"。

2.3 默认值与推断(Defaults and inference)

有把握时直接套用,拿不准就提问而不是静默默认:

可推断项 默认规则
widget_type 由 label 派生唯一的 snake_case
groupId 与产品领域中兄弟项同区(见 2.4)
Copy spine 默认 error_tracking_listwidgets/error_tracking/
Copy spine(replay) 涉及 recordings、throttles 或 session RBAC 时用 session_replay_list
Config 列表型:limitorderByorderDirectiondateRangefilterTestAccounts,可选 widgetFilters
列表 UX 卡片上的磁贴过滤条(不是编辑弹窗)、分页 footer、视图模式的 titleHref
RBAC 与兄弟项相同的 required_product_access
布局 兄弟项的 defaultLayout;密集列表显式设置 minH
图表主体 不提供——见 §1

2.4 推断 groupId 与实现模板

groupId 是"添加 widget"弹窗里的产品分区(如 "Error tracking"、"Session replay"),不该用内部术语问工程师。规则:

  • 同一产品已有 catalog 条目 → 复用其 groupId,作为该分区下的 variant(新行);
  • 全新产品、catalog 尚无 → 新建 snake_case id(通常是产品名,如 logsfeature_flags),成为该组首个 widget,需要同时更新 DASHBOARD_WIDGET_GROUP_LABELS 和 BE WIDGET_CATALOG
  • 请求提到 recordings / replay → session_replay;提到 errors / exceptions / ET → error_tracking

实现模板(copy spine)二选一:session_replay_list 仅在 Session replay 产品域、recordings 列表、replay throttles、replay RBAC、session_replay_enabled availability 场景使用;error_tracking_list 是其余一切场景的默认(error tracking 变体、logs、flags、experiments、warehouse、新领域、通用列表 widget)。工程师显式点名模板(如"scaffold like session replay")才可覆盖。

2.5 解决歧义:问而不是猜

把 spec 字段标为 lockedopen:locked 直接进 recap;open 每个缺口问一个问题;banned 主题按规则推断。提问用平实的对话式语言(或 AskQuestion),一个问题只关心一件事(如"磁贴应该用 LogsTable 还是 /logs 的 stream 视图?"),每轮上限 6 个,不要提供"你选吧/我不确定"这类 deferral 选项。

当发现 2 个以上候选 UI 时,AskQuestion 的推荐形态是直接列出仓库里的组件名,例如:

  • ErrorTrackingIssueList —— /error-tracking 的 issues 列表
  • SessionRecordingPreview —— session replay 播放列表行
  • Something else —— 我在对话里描述

严禁只给抽象选项("实体列表/表格"、"卡片网格"、"单个 KPI"),那是 agent 的推断而非用户的真实选择。banned 提问清单还包括:"copy spine"、"实现模板"、"镜像哪个已上线 widget"、add-widget 弹窗位置、groupId、"variant in existing group"、registry / validate_* / 测试文件布局,以及未做代码发现就先问"磁贴主体展示什么"。

2.6 Spec recap:确认清单

即使全部是推断值,也要在 recap 里请工程师签字,因为下面这些字段要么不可变、要么是 agent 推断需要人工纠偏:

字段 为什么要确认
widget_type 不可变的 registry/DB key
groupId + Add widget 分区 agent 推断 —— 用人类语言描述("Error tracking 分区,与 Top issues 并列的 variant")
实现模板 agent 推断 —— 写在 recap 里让工程师纠错
查询执行器 run_* 调用的精确函数,不允许并行查询路径
产品 UI 引用 来自仓库发现的场景 + 组件

recap 的完整字段模板(widget-intake.md § Spec fields to lock)包含:widget_typegroupId、add widget 分区、placement(variant / first_in_group)、label/description、实现模板、run_* 委托目标、UI 导入来源、产品 UI 引用、UI 对等组件、config 字段与默认值、defaultLayout (w, h, minW, minH)productAccess、setup gating、availability_requirementssharedPlaceholdertitleHref、throttles。文中的示例 recap 给出了 error_tracking_top_by_volume 的完整填写样例,可以直接对照。

红旗(Red flags):图表型主体 → 停止;同一个 widget_type 配不同 config → 需要新类型;过滤器放进 ⋯ 菜单 → 应放设置弹窗;在 dashboard.py 里特判 → 必须是 registry 驱动;只做前端 → 无效,每个类型都必须有 validate_* + run_*

3. 执行清单:§1 → §8 完整落地

确认后严格按 checklist-new-widget-type.md§1 → §8 顺序执行。下面按后端、前端、测试三块拆解。

3.1 后端:配置契约 + registry(§1–3)

§1 配置契约,涉及 widget_specs/widgets/<widget_type>.py

  • widget_specs/configs.py —— 新增 Pydantic *WidgetConfig 模型 + *_WIDGET_TYPE 常量,共享字段(dateRangewidgetFiltersfilterTestAccounts)放 common.py
  • widgets/<widget_type>.py —— 实现 run_<type>_widget,先 validate_widget_config(TYPE, config),然后调用与独立产品相同的查询执行器(不允许并行查询路径);支持时传 resolve_filter_test_accounts(config, team)
  • widget_specs/registry.py —— 在 _load_widget_specs() 中加一条 WidgetSpec(懒加载 run_*):config_model、scopes、group_id/group_label/label/descriptionrequired_product_accessproduct_access_denied_messageavailability_requirements

实际仓库中 WIDGET_SPECS 已是多类型清单,例如 ERROR_TRACKING_LIST_WIDGET_TYPESESSION_REPLAY_LIST_WIDGET_TYPEEXPERIMENTS_LIST_WIDGET_TYPEEXPERIMENT_RESULTS_WIDGET_TYPESURVEY_RESULTS_WIDGET_TYPELOGS_LIST_WIDGET_TYPECONVERSATIONS_RECENT_TICKETS_WIDGET_TYPE 等(见 registry.py)。registry 条目的形状(architecture.md § Backend registry entry shape):

# widgets/your_type.py — 只放 run_*;校验由 widget_specs/registry.py 负责
def run_your_type_widget(team, config, user=None, **kwargs) -> dict:
    typed = validate_widget_config(YOUR_TYPE, config)
    ...

# widget_specs/registry.py — WIDGET_SPECS 清单(懒加载 run_*)
WIDGET_SPECS[YOUR_TYPE] = WidgetSpec(
    widget_type=YOUR_TYPE,
    config_model=YourWidgetConfig,
    query_fn=run_your_type_widget,
    required_scopes=("your_product:read",),
    group_id="your_product",
    group_label="Your product",
    label="Your widget",
    description="Agent-facing catalog copy.",
    required_product_access="your_product",
    product_access_denied_message="You do not have access to your product.",
    availability_requirements=("your_setup_flag",),
)

要点:配置校验纯 Pydantic(没有逐类型的 validate_<type>_config 函数);EXPECTED_WIDGET_TYPES、OpenAPI 多态 serializer、Zod codegen 输入都会自动从 WIDGET_SPECS + configs.py 派生;默认列表条数用 backend/constants.pyDEFAULT_WIDGET_LIST_LIMIT;带 orderDirection 的列表用 Pydantic 里的 WidgetOrderDirection 字面量(ASC / DESC);throttles 用 widget_query_throttle.pyget_dashboard_widget_query_throttle_error(replay 等产品还有自身的列表 throttle)。

§2 权限(关键,不能跳过):在 WidgetSpec 上设置 required_product_access(必须与 FE catalog 的 productAccess 一致);可选友好拒绝文案 PRODUCT_ACCESS_DENIED_MESSAGES / catalog product_access_denied_messagerun_widgets 对每个磁贴返回 { tile_id, error } —— 单个磁贴失败不能让整个请求失败;查询异常被捕获后转成逐磁贴错误(参考现有 dashboard_run_widgets_failed 日志)。

§3 后端 catalog:从 WIDGET_SPECS 派生,不需要手维护 WIDGET_CATALOG 字典。widget_catalog.py 通过 config_model.model_json_schema() 暴露 config_schema(含 bounds、choices、descriptions,不只是默认值)。availability_requirementsproduct_access_denied_message 都是给 agent catalog 用的。Agent 通过 dashboard-widget-catalog-list / GET .../widget_catalog/ 读取每类型的带类型 OpenAPI config_schema

3.2 前端:catalog、config schema、codegen(§4)

不要在前端手工复制一遍 schema。运行时校验在 Pydantic,OpenAPI 组件由 model_json_schema()pydantic_openapi.py 派生,不要手写并行的 DRF config serializer 或 FE Zod。

命名约定:Pydantic config 模型命名为 *ListWidgetConfig(或 *WidgetConfig),这样 generate-widget-config-zod.mjs 能自动派生友好的 Zod re-export。然后在 §1 之后运行 hogli build:openapi提交 products/dashboards/frontend/generated/*(CI 的 check-openapi-types 会 diff 生成产物)。该命令会重新生成(不可手改):

  • frontend/generated/api.schemas.ts —— TS 类型(含多态 DashboardWidgetConfigApi);
  • products/dashboards/frontend/generated/widget-config-schemas/*.zod.ts —— 各组件 Orval Zod(generateReusableSchemas);
  • products/dashboards/frontend/generated/widget-configs.zod.ts —— 友好 re-export、推断类型、表单 .pick() schema(从这里 import,不要直接用 Orval 原始组件名);
  • products/dashboards/frontend/generated/widget-config-property-keys.json —— 每类型 config 属性键;
  • products/dashboards/frontend/generated/widget-date-from-options.json —— 日期预设值与 label;
  • products/dashboards/frontend/generated/widget-form-fields.json —— 弹窗字段清单(来自 WidgetSpec.form_fields);
  • MCP 工具 schema(services/mcp/...)。

如果 build:openapi-schemawidget_type 枚举冲突:运行 python manage.py find_enum_collisions,然后在 posthog/settings/web.pyENUM_NAME_OVERRIDES 里加 {YourWidgetTypeEnum: ["your_widget_type"]}(详见 config-and-codegen.md § Codegen & CI)。

widget_types/catalog.tsDASHBOARD_WIDGET_CATALOG 条目(catalog key = widget_type)是手写的 UI 元数据,驱动 add 弹窗、布局、头部和 public/shared 占位文案:

  • groupId(必填)—— add 弹窗分组;引入新分组时在 DASHBOARD_WIDGET_GROUP_LABELS 加 label;
  • labeldescription(必填)—— 组内 variant 名(如 "Top issues"、"Recent recordings");
  • defaultLayoutwhminWminH);
  • 默认不设 headerLayout/headerMetagetDashboardWidgetCatalogEntry() 会解析 dashboard_tile 默认头部元数据);
  • 可选 headerTitletitleHrefproductAccesssharedPlaceholderavailability

variant 与全新领域的差异:在已有分组加 variant(§4b)需要新的唯一 catalog key / widget_type(不是现有类型的 config fork)、复用兄弟 groupId、独立 label/description/defaultConfig/defaultLayout,并补齐完整前后端栈 + 预览 + registry 条目 + 测试。而全新产品领域(§4c)还要:在 DASHBOARD_WIDGET_GROUP_LABELSDASHBOARD_WIDGET_GROUP_ICONS 加分组(图标用 defaultTree.tsxiconTypes 规范产品图标)、设置 Storybook 标题路径 'Dashboards/Dashboard Widgets/Widget types/<groupLabel>/<label>'、把 products.<product> 加进 tach.tomldepends_on、扩展 RBAC 与 availability 结构;可选加 DASHBOARD_WIDGET_GROUP_PRODUCT_INTRO 组级引导。

3.3 前端:组件、编辑弹窗、registry(§5–7)

§5 Widget 组件,目录 products/dashboards/frontend/widgets/<product>/(snake_case 产品域)。Component 接收 DashboardWidgetComponentPropstileIdconfigresultloadingerroronRefreshonUpdateConfig),自己负责 loading UI(early-return WidgetLoadingState + 类型化 skeleton),可滚动列表/表格用 WidgetCardContent,空态用 WidgetCardBodyMessage。最小骨架(checklist §5):

export function YourWidget({ result, loading, config }: DashboardWidgetComponentProps): JSX.Element {
  if (loading) {
    return (
      <WidgetLoadingState>
        <YourTypedSkeleton />
      </WidgetLoadingState>
    )
  }
  const rows = (result as YourResult)?.results ?? []
  if (rows.length === 0) {
    return <WidgetCardBodyMessage>No data found.</WidgetCardBodyMessage>
  }
  return (
    <WidgetCardContent>
      <YourList data={rows} />
    </WidgetCardContent>
  )
}

空态还要带产品采用 CTA:当产品尚无实体时渲染主按钮 LemonButtontargetBlank)指向产品创建流程,并在点击时捕获 dashboard widget create <product> clicked(事件带 widget_type + tile_id)。已上线示例:widgets/experiments/ExperimentResultsWidget.tsxwidgets/surveys/SurveyResultsWidget.tsx。点击进入实体的"See more"链接则捕获 dashboard widget open <product> clicked(额外带实体 id)。

§5b Storybook 是硬性要求:每个新 widget_type 必须有专属 <YourWidget>.stories.tsx,有编辑弹窗的还要 Edit*WidgetModal.stories.tsx。catalog 总览 story(getWidgetOverviewDemoState不能替代(它只渲染每类型一个 demo 状态)。Storybook meta 的 title 必须是字符串字面量(CSF 拒绝动态标题),铺开 widgetStorybookParameters(冻结 mockDate 供视觉回归),用 WidgetTileFrame 装饰器,并导出 Populated / TileFiltersReadOnly / Loading / Empty / Error 各视觉状态。参考 ErrorTrackingWidget.stories.tsx。本地运行 pnpm storybook

§6 编辑弹窗 + add-widget 预览EditModal + edit*WidgetModalLogic.ts(Zod 校验 → LemonField 错误,saving/无效时禁用保存);组合 EditWidgetModalTileDetailsSection,再放产品 <section>getDashboardWidgetGroupLabel)和 EditWidgetModalFiltersSubsection(test accounts + 排序);日期过滤用 WIDGET_DATE_RANGE_SELECT_OPTIONS;把 parse*WidgetConfigApiError 挂到 registry 条目的 parseConfigApiError。预览组件放 widgets/previews/ 并注册进 DASHBOARD_WIDGET_PREVIEWS不要放进 catalog——catalog 在 app shell 的导入路径上,previews 必须留在壳外)。

§7 前端 registryregistry.tsx)—— 每个 widget_type 一个条目:

import { YourWidget } from './your_product/YourWidget'
import { EditYourWidgetModal } from './your_product/EditYourWidgetModal'

export const DASHBOARD_WIDGET_REGISTRY = {
  your_type: {
    Component: YourWidget,
    EditModal: EditYourWidgetModal,
    productAccess: 'your_product',
    parseConfigApiError: parseYourWidgetConfigApiError,
  },
} satisfies Record<DashboardWidgetCatalogKey, DashboardWidgetDefinition>

RBAC 门控时同步设置 catalog + registry 的 productAccess(必须与后端 required_product_access 一致),扩展 DashboardWidgetProductAccess(types.ts)并在 WIDGET_PRODUCT_ACCESS_CHECKS(widgetProductAccess.ts)加对应条目;public/shared 文案不同时在 catalog 条目设 sharedPlaceholder。平台文件(dashboard.pyrun_widgetsDashboardWidgetItem.tsxWidgetCard.tsxdashboardLogic.tsxwidgetFetchUtils.ts不允许按类型分支——新增类型只在 backend/widgets/frontend/widgets/<product>/ 下加文件。

3.4 测试与验证(§8 + §6)

MVP 冒烟测试(可渲染 + 核心测试,非发布点):

hogli test products/dashboards/backend/api/test/test_run_widgets.py
hogli test products/dashboards/backend/api/test/test_dashboard_widgets.py
hogli test products/dashboards/frontend/widgets/registry.test.tsx

发布门槛(PR 之前必做):checklist §8 + hogli build:openapi + 专属 Storybook stories(组件 + 编辑弹窗)。config SSOT 变更后还要跑:

hogli test products/dashboards/backend/api/test/test_widget_config_schema_parity.py
hogli test products/dashboards/frontend/widgets/widgetConfigSchemaParity.test.ts
hogli test products/dashboards/backend/api/test/test_widget_openapi_enums.py   # 仅新 widget_type

最少测试集(§8):断言 EXPECTED_WIDGET_TYPES == WIDGET_REGISTRY.keys()(实际仓库中 test_run_widgets.pytest_widget_registry_catalog_and_expected_types_stay_in_sync 就在做这件事);registry.test.tsx 覆盖每个 catalog key 且条目都有 parseConfigApiError;config 字段变更跑 schema parity 两个测试;新增 widget_typetest_widget_openapi_enums.py;测试 create/update 配置校验、活动日志、run_widgets 的权限拒绝;MCP 表面变更更新 services/mcp/tests/tools/dashboards.integration.test.ts;分析事件在 PATCH 与 POST 两条添加路径上都触发 dashboard tile addeddashboard widget added不要空测试脚手架——每个 .test.tsx 必须断言真实行为。

4. 更新已上线类型:按变更类型路由到对应文件

跳过 intake,从请求或 EXPECTED_WIDGET_TYPES 识别类型,然后对照 managing-existing-widgets.md"What kind of change?" 路由表定位主文件,再顺着表中链接的 references 走(不要再读新类型 checklist)。核心变更类型的映射:

目标 主要文件 还要检查
Widget 查询 / run_widgets 载荷 backend/widgets/<widget_type>.pywidget_registry.pywidget_query_throttle.py test_run_widgets.pytest_widget_query_throttle.pydashboard_widget_delivery SLO(自动)
Config 字段(过滤器、限制、排序) widget_specs/configs.py(Pydantic SSOT)、WidgetSpec.form_fieldsEdit*WidgetModal.tsx*WidgetConfigValidation.ts generated/widget-configs.zod.tshogli build:openapi、registry parseConfigApiError、MCP config_schema 快照
run_* 结果形状 / 列表 footer backend/widgets/<widget_type>.py、widget Component footer 分页 footer 模式
磁贴过滤条 *WidgetTileFilters.tsxwidgetTileFiltersHooks.ts、registry TileFilters 防抖刷新 scheduleRefreshDashboardWidgets(dashboardLogic 监听器)
头部标题 → 产品场景 catalog.ts titleHrefWidgetCardHeader.tsx isDashboardEditMode 头部导航模式
磁贴名称/描述 UX Edit*WidgetModal.tsxEditWidgetModalTileDetailsSection.tsx 参照 EditErrorTrackingWidgetModal.tsx 字段布局;serializer 字段变更时的活动日志测试
新增磁贴的默认尺寸 catalog.ts defaultLayout.w / .h dashboardLogic.addWidgetTiles(读 catalog 默认值)
网格最小/最大缩放 catalog.ts defaultLayout.minW / .minH tileLayouts.tstileLayouts.test.ts
头部 / 日期范围展示 catalog.tsheaderLayoutheaderMetaheaderTitletitleHref WidgetCardHeader、stories
安装 / availability 门控 catalog.ts availabilitywidgetAvailability.ts WidgetRuntimeAvailabilityGuard;BE availability_requirements
实时磁贴行为 widgets/live/*、产品 live logic、WidgetSpec.is_live / creation_flag hogli build:widget-types 清单;LiveWidgetSlidingWindow.test.ts
RBAC 锁定 widget_registry.py required_product_access、FE catalog.ts productAccess types.tswidgetProductAccess.ts
public/shared 占位文案 catalog.ts sharedPlaceholder DashboardWidgetItem.test.tsxtest_sharing.py
Agent-facing catalog 文案 widget_specs/registry.py WidgetSpec dashboard-widget-catalog-list / catalog 断言
弃用 / 移除类型 见下方弃用章节 孤儿磁贴回退到 unknown-type UI;BE runner 保留到磁贴清空

不可原地修改的两件事widget_type 字符串(已存在行上不可变);已存储的磁贴 w / h(catalog defaultLayout 改变不会重写存量数据,只在下次布局 pass 时对新增和 min/max 生效,详见 layout-and-ux.md)。新可视化形态 = 走 Ship图表为主 = 走 insight tile

4.1 存量磁贴的配置迁移

改已存 Postgres 行的 config 形状时:

  1. 向后兼容(首选)——在 Pydantic 模型(widget_specs/configs.py)里用 model_validator / 字段别名接受旧 key;extra="forbid" 在 validate 时剥离未知 key。存量磁贴无需数据迁移即可继续工作。
  2. 破坏性变更——按新 widget_type 处理(或接受旧磁贴校验失败直到用户在编辑弹窗里重新保存)。
  3. 非破坏性增删字段要逐层打通:BE Pydantic 模型 → FE *WidgetConfigValidation.ts(表单 .pick() + 校验)+ registry parseConfigApiErrorhogli build:openapi 重新生成 OpenAPI / widget-configs.zod.ts / MCP schema → stories/fixtures(组件、编辑弹窗、preview、widgetOverviewStoryFixtures.ts)。
  4. 相对日期范围——更新 backend/constants.pyWIDGET_DATE_FROM_VALUES_ORDERED)和 widget_specs/common.py 的 Pydantic WidgetDateFrom,再 hogli build:openapi(重新生成 widget-date-from-options.json)。

4.2 运行时配置更新链路

用户打开 ⋯ → Edit → Edit*WidgetModal 用 Zod 校验 → onSave(config, metadataPatch?)(metadata 来自 buildWidgetTileMetadataPatch)→ DashboardItems 通过 useAsyncActions(dashboardLogic).updateWidgetTile 等待 PATCH → dashboardLogic.updateWidgetTileupdateDashboardWidgetTile → 一次 dashboardsPartialUpdate(嵌套 widget{ config, name, description } 与可选 show_description)→ config 变更后 refreshDashboardWidgets 对该磁贴重跑 run_widgets。保存按钮必须用 loading / disabledReason 防护,mutation 中途不可点击。

4.3 编辑弹窗布局与弃用

编辑弹窗的类型专属 section 用 2 列 CSS grid(grid grid-cols-1 sm:grid-cols-2 gap-4,抄 EditErrorTrackingWidgetModal.tsx)。整宽字段(name、description、test-account filter)把 WIDGET_SETTINGS_FIELD_FULL_WIDTH_CLASSsm:col-span-2)放在 grid childLemonField.Pure 或 wrapper <div>)上,而不是内层 LemonInput/LemonTextArea;半宽字段(date range、sort、limit)不加 col-span-2,需要时给 LemonSelectfullWidth

弃用类型没有软删除:先从 FE/BE 的 catalogs 与 registries 里停止列出;保留 run_* + registry 条目直到生产环境没有磁贴引用该类型(否则接受 unknown-type 回退:头部 + body ErrorBoundary,无实时数据);更新 MCP 工具 schema 快照与 dashboards.integration.test.ts不要posthog_dashboardwidget 里仍有行的类型移除后端校验。

5. 平台架构与不变量

5.1 数据模型心智图

architecture.md 给出清晰的分层:

Dashboard
  └── DashboardTile (layout, color, placement)
        └── widget_id → DashboardWidget (team-scoped content entity)
              ├── widget_type: str  # 无 DB enum —— 在 registry/serializer 校验;Python 侧类型为 DashboardWidgetType
              ├── config: JSON
              ├── name, description, audit fields
              └── team FK (租户隔离)

各层职责与位置:

位置 职责
持久化 products/dashboards/backend/models/dashboard_widget.pydashboard_tile.py 每个 tile 恰好一个内容 FK:insight | text | button_tile | widget(DB CHECK)
后端运行时 products/dashboards/backend/widgets/<type>.py + widget_specs/registry.py 逐类型 run_*WIDGET_SPECSWidgetSpec)清单
配置契约 products/dashboards/backend/widget_specs/ Pydantic SSOT → 校验、OpenAPI、Zod
后端访问控制 products/dashboards/backend/widget_access.py required_product_access → RBAC 检查
后端 catalog products/dashboards/backend/widget_catalog.py WIDGET_SPECS 构建;REST/MCP config_schema = Pydantic model_json_schema()
数据获取 dashboard.pyGET .../dashboards/:id/run_widgets?tile_ids= 批量逐磁贴结果 + 逐磁贴错误(通用循环);每个磁贴查询发出 dashboard_widget_delivery SLO
前端分发 widgets/registry.tsx DASHBOARD_WIDGET_REGISTRYComponent + EditModal
Catalog / 布局 frontend/widget_types/ add 弹窗、默认值、头部、RBAC 映射、网格尺寸
场景胶水 frontend/src/scenes/dashboard/ 获取、CRUD、复制/移动、撤销删除

widget_type 字符串无需迁移——只注册 registries + catalogs 即可。命名规范:产品 widget 目录 snake_casewidgets/error_tracking/)、catalog key / widget_type / registry key snake_caseerror_tracking_list)、共享模块 snake_case 文件名、React 组件目录 PascalCaseWidgetCard/)、MCP 工具名 kebab-casedashboard-widgets-run)。

5.2 六条平台不变量

SKILL §4 规定两条路径都必须遵守:

  1. RBAC 由 registry 驱动——dashboard.py 里不允许出现 widget_type 分支(见 permissions-and-sharing.md § Product RBAC);
  2. 处处一个 widget_type——registries + 两个 catalogs + FE registry;变体只共享 groupId
  3. 逐类型代码放在产品路径——不进平台壳层(architecture.md § Platform files);
  4. WidgetCard 组合模式——composition.md
  5. 配置契约 = Pydantic SSOT——widget_specs/configs.py + registry.pyWidgetSpechogli build:openapi 生成 OpenAPI/FE Zod/MCP;运行时 PATCH 保持 JSONField
  6. 不提供图表主体 widget

5.3 WidgetCard 组合模式与产品视觉对等

遵循 Quill Card 模式:薄壳 + 调用点组合的子组件WidgetCard 只是磁贴壳(装饰性 resize 手柄、编辑模式边缘覆盖、RGL 槽),没有 header/body props;WidgetCardHeader 是布局路由器(simple vs dashboard_tile);WidgetCardBody 提供 body 槽与 locked/error 壳状态,同时导出 WidgetCardContent(可滚动列 + 可选 footer)、WidgetCardBodyMessage(空态)、WidgetLoadingState / WidgetCardBodySkeletonWidgetCardSharedPlaceholderBody(public/shared 占位)。生产调用点是 DashboardWidgetItem,它组合 header + body、接 ⋯ 菜单与编辑弹窗 portal、做产品 RBAC 锁定,并仅在 hasProductAccess 时挂载 registry 的 TileFilterspublic 放置用 WidgetCardSharedPlaceholderBody 而非实时 body

组合示例(composition.md § Compound pattern):

<WidgetCard
    ref={ref}
    className={className}
    style={style}
    showResizeHandles={showResizeHandles}
    canEnterEditModeFromEdge={canEnterEditModeFromEdge}
    onEnterEditModeFromEdge={onEnterEditModeFromEdge}
    gridChildren={rglHandles} // react-grid-layout 注入
>
    <WidgetCardHeader
        layout={headerLayout}
        title={title}
        defaultTitle={defaultTitle}
        shouldHideMoreButton={widgetCardShouldHideMoreButton(placement, showEditingControls)}
        moreButtonOverlay={…}
    />
    <WidgetCardBody locked={locked} error={error}>
        <WidgetComponent … />
    </WidgetCardBody>
</WidgetCard>

规则要点:gridChildren 只留给 RGL resize 手柄,内容放组合的 WidgetCardBody;loading 由 widget Component 自己负责(early-return WidgetLoadingState),壳层不显示 skeleton;宽表格在 WidgetCardContent 内部横向滚动(min-w-0 + overflow-auto),不进仪表盘网格;时间周期存 config.dateRange(选项、编辑弹窗、头部展示三处一致);头部默认布局 dashboard_tile,仅在覆盖默认值时设 headerLayout/headerMeta;标题/描述只能在设置弹窗编辑。

产品视觉对等:widget 若展示已有 PostHog 产品的数据,默认复用产品场景的同一套呈现——列表行、卡片、空态文案、骨架、setup 提示(如 ErrorTrackingWidgetproducts/error_tracking/frontend/ 导入 ErrorTrackingIssueListErrorTrackingIssueListSkeletonErrorTrackingIngestionPrompt),而不是给 widget 另造专属表格。平台 chrome(头部、⋯ 菜单、resize、编辑弹窗壳)归 Dashboard,产品 chrome(行、空态、骨架、setup 提示)归产品——不要在产品 body 里重复产品菜单、过滤器或页面级 chrome。

未知类型 / 部署偏差:FE catalog 缺 widget_type 时(部分部署、未 rebase 的栈),头部用 tryGetDashboardWidgetCatalogEntry + getUnknownDashboardWidgetCatalogFallback(标题与 ⋯ 菜单的 remove/duplicate/copy/move 仍可用);body 由 ErrorBoundary 包裹 DashboardWidgetItemBody;不要给未知类型传 run_widgets 的 fetch error 进 WidgetCardBodygetDashboardWidgetDefinition 仍会按 canonical 类型去重 PostHog captureException

5.4 Config 契约与代码生成管线

Pydantic 是 widget.config 形状的唯一事实源,一条命令生成全部下游:

hogli build:openapi   # openapi-schema → build:widget-types → openapi-types → MCP

生成管线(config-and-codegen.md):

widget_specs/configs.py         每类型 Pydantic *WidgetConfig(共享字段在 common.py)
        │
        ├─► pydantic_openapi.py       把 model_json_schema() 注入 OpenAPI 组件(无 DRF 桥)
        ├─► openapi.py                多态 batch-add / PATCH / catalog OpenAPI(自动从 WIDGET_SPECS)
        ├─► registry.py               WIDGET_SPECS 清单 + validate_widget_config()
        └─► widget_catalog.py         config_schema = model_json_schema()(给 agent)

bin/build-dashboard-widget-types.py  (hogli build:widget-types — 步骤 1)
        ├─► widget-date-from-options.json   日期预设值与 label(来自 constants.py)
        └─► widget-form-fields.json         弹窗 .pick() 字段(来自 WidgetSpec.form_fields)

generate-widget-config-zod.mjs      (hogli build:widget-types — 步骤 2)
        ├─► widget-config-property-keys.json  每类型 key/tree
        └─► Orval generateReusableSchemas(catalog 切片 → widget-config-schemas/*.zod.ts)

hogli build:openapi
        ├─► frontend/generated/api.schemas.ts
        ├─► products/dashboards/frontend/generated/widget-configs.zod.ts
        └─► services/mcp/...

关键文件职责:configs.py(字段变更先改这里)、common.py(共享 dateRangewidgetFiltersfilterTestAccounts)、registry.pyWIDGET_SPECS + validate_widget_config())、widgets/config.py(仅查询期——resolve_filter_test_accounts(config, team))、openapi.py(多态 OpenAPI,无逐类型手工接线)、api/widget_openapi_serializers.py(稳定 re-export 表面)。前端 config 分层同样不手工复制:widget-configs.zod.ts 直接 import;widget-config-property-keys.json 供每类型顶层 key;widgetConfigShared.ts 从生成的 JSON re-export 日期选项。

CI 类型安全网(每层都有 drift guard):BE 断言 EXPECTED_WIDGET_TYPES == WIDGET_REGISTRY.keys() 且 catalog keys 匹配;BE OpenAPI 多态 config serializer 数量与 registry 一致(test_run_widgets.py);catalog config_schema 与 Pydantic JSON schema 一致(test_widget_config_schema_parity.py);widget_type 枚举覆盖(test_widget_openapi_enums.py + build:widget-types preflight);FE DASHBOARD_WIDGET_REGISTRY satisfies Record<DashboardWidgetCatalogKey, …>;Zod config 顶层 key 与后端 property map 一致(widgetConfigSchemaParity.test.ts);运行时 getDashboardWidgetDefinition miss 时 PostHog captureException(部署偏差兜底)。本地开发时 Vite 读取已提交generated/ 文件(hogli up / 保存时不重新生成),改完 widget_specs/ 要跑 hogli build:openapi 并提交 diff(无 pre-commit hook);CI 的 check-openapi-types 对 fork PR 报 "run hogli build:openapi locally"。

常见坑(Footguns):slim 掉 PatchedDashboardOpenApiSerializerextend_schema整体替换 PATCH schema,应扩展而非重写);在 dashboard PATCH 上嵌套逐类型 config serializer(运行时保持 JSONField,类型化 OpenAPI 只在 widget_specs/openapi.py);手写 FE Zod;把 posthog.schema 共享模型导入 widget config(改用 configs.py 本地模型避免组件名冲突);手抄 catalog config_schema(用 model_json_schema());手工编辑生成产物(重跑 hogli build:openapi);漏 registry 条目只有 catalog 条目(catalog 驱动 add 弹窗、布局、头部、previews——registry 单独不够)。

6. 实时(Live)Widget:自更新磁贴的契约

Live widget 先由 run_widgets seed 一次,然后客户端自更新,不依赖仪表盘刷新循环。契约的两个 SSOT 字段在 widget_specs/registry.pyWidgetSpec 上:

  • is_live: bool —— 标记类型为 live,流入 widget-form-fields.json(经 hogli build:widget-types),解析为 FE catalog 的 entry.live(或 isLiveDashboardWidgetType()),也作为 REST/MCP catalog 的 live 字段。不要手维护 FE live 类型清单。免费附带:头部脉动 "Live" 标记、add-widget 选择卡上的 "Live" 标签、agent 在 dashboard-widget-catalog-list 里看到的 live 字段。
  • creation_flag: str | None —— 仅增量的上线门控,在 widget_create.py 里经 feature_flags.widget_flag_enabled 通用解析。创建磁贴需要该 flag;已放置磁贴在其关闭后仍继续渲染。并非 live 专属,但新 live widget 家族应把它作为 kill switch。

live 类型必须遵守三条规则(规则 3 由 WidgetSpec 构造时强制——违规 spec 会在 registry 导入时失败):

  1. run_widgets 的结果是 seed 而非状态——载荷必须携带 generatedAt(查询时刻的服务端时钟,ISO-8601)。见 widgets/live/liveWidgetTypes.tsLiveWidgetSeedPayload
  2. Seed 必须幂等——手动刷新和仪表盘自动刷新都会重跑 run_widgets 并重新 seed(平台不跳过 live 磁贴,re-seed 能修补断连或隐藏标签页造成的流缺口);合并 seed 保证重跑不重复计数;
  3. config 里不能有 dateRangefilterTestAccounts——live 磁贴显示固定实时窗口(dateRange 与之矛盾),livestream 无法应用 test-account 过滤(seed 与流会不一致);头部日期范围对 live 类型自动隐藏。

前端工具包在 products/dashboards/frontend/widgets/live/,直接组合、不要手写 SSE/flush/tick:liveWidgetTypes.ts(FE 读取 is_live + 基础 seed 载荷接口)、LiveWidgetSlidingWindow(分钟分桶窗口:总计数 + 命名分解域,extractor (event) => string | null,null 跳过)、liveWidgetStream(options)(Kea logic builder:livestream SSE 连接 /events + live_events_token、300ms flush 批处理 onEvents、60s onMinuteTick,通过 cache.disposables 在隐藏标签页暂停)、useLiveWidgetSeed(payload, seed)(prop→action 桥,从磁贴 result prop 播种)、LiveWidgetEmptyState("窗口内暂无数据"空态)、LiveWidgetIndicator(脉动 "Live" 头部标记,平台渲染)。

Seed 合并语义(不要"简化"):SSE 流读 Kafka(新鲜),seed 读 ClickHouse(可能滞后摄取)。LiveWidgetSlidingWindow 因此按桶用 max 合并 seed(绝不 replace——滞后的空 re-seed 不能清掉流累计的计数),并丢弃 seed generatedAt 及之前的流事件(严格 >),这样 re-seed 永不重复计数。widgets/live/LiveWidgetSlidingWindow.test.ts 守护这些规则。kea-typegen 约束:liveWidgetStream 只加 connect/events 接线——logic 自己声明 actions/reducers/selectors,builder 通过 onEvents/onMinuteTick 回调分发。每个仪表盘一条连接:产品 live logic 不设 key,在家族磁贴间共享(kea 引用计数保证任意数量的 live 磁贴只有一条 SSE 连接,最后一块卸载时拆除);afterMount 里重置状态防止跨仪表盘泄漏。契约与传输无关——没有 livestream 数据的产品可以加同形状的轮询 helper。

新 live widget 家族的配方(叠加在普通新类型 checklist 上):spec 设 is_live=True + creation_flag="<rollout-flag>",config 模型去掉 dateRange/filterTestAccounts;产品后端 seed 查询返回 generatedAtwidgets/<type>.py 里放薄 query_fn 包装;catalog 条目照常(live 类型 showDateRange 自动隐藏);一个无 key 的共享 logic(liveWidgetStream + LiveWidgetSlidingWindow);组件用 useLiveWidgetSeed,窗口为空时 LiveWidgetEmptyState;跑 hogli build:widget-types 后走常规验证套件。首个端到端消费者是 web analytics live widgets(products/dashboards/frontend/widgets/web_analytics/)。

7. 列表 widget 模式与其余平台能力

7.1 磁贴过滤条与分页 footer

列表/表格 widget(error_tracking_listsession_replay_list 等)的职责划分:widgetFilters 存 config(持久化属性过滤选择);编辑弹窗 = test accounts + limit + sort磁贴条 = date + type pickers + 属性过滤。后端通用工具在 backend/widgets/widget_filters.pyvalidate_widget_filters + build_*_from_widget_filters),磁贴条组件是 *WidgetTileFilters.tsx + widgetTileFiltersHooks.tsuseWidgetTileConfigPersist),挂载由 DashboardWidgetItem 控制(仅 hasProductAccess && showTileFilters 时挂 registry TileFilters)。canEditDashboard 决定编辑 vs 只读条(DASHBOARD_WIDGET_TILE_FILTERS_READONLY_REASONconstants.ts);RBAC 拒绝时整个条隐藏(body 显示锁定态);与仪表盘 quick-filter 条无耦合。

分页 footer 的契约:run_* 返回 resultshasMorelimitoffset!hasMoretotalCount = 已显示;hasMore + include_total_count=True 走 capped count 查询(MAX_WIDGET_RESULT_LIMIT)。仪表盘 run_widgets 始终传 include_total_count=False——页还有更多行时跳过 count。FE 用 WidgetCardContentfooter + formatWidgetListCountFooter(shown, totalCount, totalCountCapped, noun, hasMore)(名词在 constants.tsWIDGET_LIST_COUNT_ISSUES / RECORDINGS;省略 total + hasMore 时显示 N+ 文案)。count 失败永不导致磁贴失败(记录日志 + 省略 totals)。头部标题导航:catalog titleHref 指向产品场景路由(urls.errorTracking() 等),WidgetCardHeadertitleHref 已设且 isDashboardEditMode === false 时把标题包进 <Link>(不要在 showEditingControls 上做判断——可编辑仪表盘在视图模式仍保留编辑 chrome);布局编辑模式下标题保持纯文本,避免头部拖动与导航竞争;public 放置时 titleHref 被抑制。

7.2 添加磁贴的两种入口与分析事件

  • UI add 弹窗AddWidgetModal 多选 → addWidgetTiles → POST .../dashboards/:id/widgets/batch/
  • REST / MCP 添加:同一个 POST .../dashboards/:id/widgets/batch/(1–10 个磁贴)。新磁贴经 widget_layouts.stack_widget_layout_at_bottom 落在底行

首次插入 widget 磁贴会经 dashboard.py_report_dashboard_tile_added 触发两个服务端事件:dashboard tile added(通用磁贴事件,带 tile_type: "widget"widget_typedashboard_id)和 dashboard widget added(widget 专属事件,带 widget_typedashboard_idtile_idwidget_id 及请求 sourceweb / mcp / api)。两者都在 PATCH dashboard tiles[] 新增 widget 磁贴和 POST .../widgets/batch/(UI、REST、MCP)时触发;config/元数据更新不会重新触发。

7.3 Delivery SLO

_run_widget_query 中每个成功/失败的 widget 查询都经 slo_operationSloArea.ANALYTIC_PLATFORM)发出 dashboard_widget_delivery,带 widget_typedashboard_idtile_id。无逐类型 SLO 接线——发布新 registry 条目即自动生效。query_fn 运行前的 access/validation 失败不发出该 SLO。

7.4 复制、移动、重复与跨项目

跨项目转移、跨仪表盘复制/移动、仪表盘复制都会深克隆 widget 行(移动只 reassign 磁贴)。易漏路径:backend/widget_layouts.py(batch-add 放置)、posthog/models/resource_transfer/visitors/dashboard_widget.py(跨项目复制)、posthog/api/test/test_sharing.py(shared payload)。

8. 配套技能与验证闭环

8.1 配套技能映射

技能 何时使用
improving-drf-endpoints dashboard @extend_schema(config serializers 自动从 WIDGET_SPECS 派生)
writing-kea-logics edit*WidgetModalLogic.tsdashboardLogic.tsx
django-migrations DashboardWidget / DashboardTile schema 变更
adopting-generated-api-types frontend/utils.ts 的 tile PATCH

8.2 更新场景的验证

每个触碰的层都要验证,优先用 products/dashboards/frontend/widgets/<product>/ 下的定向路径与 test_run_widgets.py。BE 变更至少跑 test_run_widgets.py;FE 变更跑 products/dashboards/frontend/widgets/;config OpenAPI 变更跑 hogli build:openapi。更新场景同样要跑 SKILL §6 的验证命令,并对照 managing-existing-widgets.md 路由表的 Also check 列。改完文档类工作流后,按 skill-maintenance.md 维护技能文档。

8.3 全流程速查

  • Ship 新类型:intake(发现 UI → 推断 → 提问 ≤6 → spec recap 确认)→ checklist §1–8(后端 config+registry → 权限 → catalog → 前端 catalog+codegen → 组件 → Storybook → 编辑弹窗+预览 → FE registry → 测试)→ hogli build:openapi 提交生成物 → 专属 stories → PR。参考实现镜像 products/dashboards/frontend/widgets/error_tracking/error_tracking_list);replay 模式看 widgets/session_replay/
  • Update 已上线类型:跳过 intake,用路由表定位主文件,逐层打通(BE Pydantic → FE validation/registry → codegen → stories),优先向后兼容迁移,验证每个触碰层。
  • 任何路径:遵守六条平台不变量,绝不手写并行 schema,绝不按类型分支平台文件,绝不给图表型需求建 widget_type。

相关代码入口:后端 registry widget_specs/registry.py、config 模型 widget_specs/configs.py、backend catalog widget_catalog.pyrun_widgets 端点 api/dashboard.py(含 _run_widget_query 与 throttle 检查)、前端 registry widgets/registry.tsx、catalog widget_types/catalog.ts、同步测试 test_run_widgets.py

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
docsdocs
暂无描述
Markdown
899
5.83 K
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.04 K
525