PostHog 仪表盘 Widget 平台开发指南:从新建 widget_type 到维护已上线类型
PostHog 的仪表盘(Dashboard)提供了一种可扩展的 widget 磁贴机制,让产品团队能把原生产品列表、实时数据等直接搬上仪表盘。本文是面向 PostHog 工程师的完整实战指南,围绕 .agents/skills/manage-dashboard-widgets/SKILL.md 及 references/ 下的全套文档,讲解如何新建一个 widget_type、如何更新已上线类型、平台有哪些不可违反的架构约束,以及如何用一套 Pydantic 配置契约驱动 OpenAPI / Zod / MCP 代码生成。读完后你将掌握从 intake 确认、分步落地、验证到发布的完整流程,以及 widget_specs/、registry.py、catalog.ts、registry.tsx、WidgetCard 组合模式等核心文件的职责与调用关系。
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-list → dashboard-widgets-batch-add),不属于本技能范围 |
| 仪表盘上的趋势/漏斗/图表需求 | — | Insight tile,不是 widget。见 architecture.md § Charts |
两个容易混淆的边界要特别记住:
- 图表主体验的 widget 不能做。时间序列、漏斗、分解、饼/柱/线图作为磁贴主体,都属于 insight tile 的职责(HogQL/查询可视化、对比、公式、订阅、告警都已覆盖)。widget 只做"产品原生列表/表格/卡片"这类上下文,例如
error_tracking_list、session_replay_list。intake 若发现请求是图表型,应停下来引导工程师改用 insight,而不是新建widget_type。 - 已上线类型的
widget_type字符串不可变。它同时是 catalog key、WIDGET_REGISTRYkey、DB 列、前端 registry key。想换可视化形态,正确做法是新增磁贴(新类型)并删除旧磁贴,而不是改字段。
已上线类型的权威列表以代码为准(不要手维护清单):EXPECTED_WIDGET_TYPES / WIDGET_REGISTRY(backend/widget_registry.py,re-export 自 widget_specs/registry.py)、WIDGET_CATALOG(backend/widget_catalog.py,由 WIDGET_SPECS 派生)、DASHBOARD_WIDGET_CATALOG(frontend/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 的四步工作流:
- 把请求解析为 spec 字段(产品领域、label 意图);
- 先在仓库里发现产品 UI(不要急着问"磁贴该展示什么"这种空泛问题);
- 应用默认值与推断规则(
groupId、copy spine、列表 UX); - 解决歧义——对仍然开放字段提问,每轮最多 6 个问题;
- 贴出 spec 汇总并等待明确确认(或"defaults fine")。
2.2 仓库 UI 发现(Discover product UI)
工程师往往只描述业务结果("top logs"、"recent recordings"),agent 的职责是在代码库里找到具体的场景组件和查询执行器。搜索路径有明确优先级:
products/dashboards/frontend/widget_types/catalog.ts—— 看已有的分组与兄弟类型;products/<product>/frontend/components/、.../scenes/—— 找*List、*Table、*Preview、*Row类导出;frontend/src/scenes/<area>/—— 仍在 products/ 之外的旧场景;products/dashboards/frontend/widgets/<product>/—— 兄弟 widget 的Component导入;products/<product>/backend/—— 独立列表场景调用的查询函数,它将成为run_*的委托目标;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_list → widgets/error_tracking/ |
| Copy spine(replay) | 涉及 recordings、throttles 或 session RBAC 时用 session_replay_list |
| Config | 列表型:limit、orderBy、orderDirection、dateRange、filterTestAccounts,可选 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_caseid(通常是产品名,如logs、feature_flags),成为该组首个 widget,需要同时更新DASHBOARD_WIDGET_GROUP_LABELS和 BEWIDGET_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 字段标为 locked 或 open: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_type、groupId、add widget 分区、placement(variant / first_in_group)、label/description、实现模板、run_* 委托目标、UI 导入来源、产品 UI 引用、UI 对等组件、config 字段与默认值、defaultLayout (w, h, minW, minH)、productAccess、setup gating、availability_requirements、sharedPlaceholder、titleHref、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常量,共享字段(dateRange、widgetFilters、filterTestAccounts)放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/description、required_product_access、product_access_denied_message、availability_requirements。
实际仓库中 WIDGET_SPECS 已是多类型清单,例如 ERROR_TRACKING_LIST_WIDGET_TYPE、SESSION_REPLAY_LIST_WIDGET_TYPE、EXPERIMENTS_LIST_WIDGET_TYPE、EXPERIMENT_RESULTS_WIDGET_TYPE、SURVEY_RESULTS_WIDGET_TYPE、LOGS_LIST_WIDGET_TYPE、CONVERSATIONS_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.py 的 DEFAULT_WIDGET_LIST_LIMIT;带 orderDirection 的列表用 Pydantic 里的 WidgetOrderDirection 字面量(ASC / DESC);throttles 用 widget_query_throttle.py 的 get_dashboard_widget_query_throttle_error(replay 等产品还有自身的列表 throttle)。
§2 权限(关键,不能跳过):在 WidgetSpec 上设置 required_product_access(必须与 FE catalog 的 productAccess 一致);可选友好拒绝文案 PRODUCT_ACCESS_DENIED_MESSAGES / catalog product_access_denied_message;run_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_requirements 与 product_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-schema 报 widget_type 枚举冲突:运行 python manage.py find_enum_collisions,然后在 posthog/settings/web.py 的 ENUM_NAME_OVERRIDES 里加 {YourWidgetTypeEnum: ["your_widget_type"]}(详见 config-and-codegen.md § Codegen & CI)。
widget_types/catalog.ts 的 DASHBOARD_WIDGET_CATALOG 条目(catalog key = widget_type)是手写的 UI 元数据,驱动 add 弹窗、布局、头部和 public/shared 占位文案:
groupId(必填)—— add 弹窗分组;引入新分组时在DASHBOARD_WIDGET_GROUP_LABELS加 label;label、description(必填)—— 组内 variant 名(如 "Top issues"、"Recent recordings");defaultLayout(w、h、minW、minH);- 默认不设
headerLayout/headerMeta(getDashboardWidgetCatalogEntry()会解析dashboard_tile默认头部元数据); - 可选
headerTitle、titleHref、productAccess、sharedPlaceholder、availability。
variant 与全新领域的差异:在已有分组加 variant(§4b)需要新的唯一 catalog key / widget_type(不是现有类型的 config fork)、复用兄弟 groupId、独立 label/description/defaultConfig/defaultLayout,并补齐完整前后端栈 + 预览 + registry 条目 + 测试。而全新产品领域(§4c)还要:在 DASHBOARD_WIDGET_GROUP_LABELS 和 DASHBOARD_WIDGET_GROUP_ICONS 加分组(图标用 defaultTree.tsx 的 iconTypes 规范产品图标)、设置 Storybook 标题路径 'Dashboards/Dashboard Widgets/Widget types/<groupLabel>/<label>'、把 products.<product> 加进 tach.toml 的 depends_on、扩展 RBAC 与 availability 结构;可选加 DASHBOARD_WIDGET_GROUP_PRODUCT_INTRO 组级引导。
3.3 前端:组件、编辑弹窗、registry(§5–7)
§5 Widget 组件,目录 products/dashboards/frontend/widgets/<product>/(snake_case 产品域)。Component 接收 DashboardWidgetComponentProps(tileId、config、result、loading、error、onRefresh、onUpdateConfig),自己负责 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:当产品尚无实体时渲染主按钮 LemonButton(targetBlank)指向产品创建流程,并在点击时捕获 dashboard widget create <product> clicked(事件带 widget_type + tile_id)。已上线示例:widgets/experiments/ExperimentResultsWidget.tsx、widgets/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 前端 registry(registry.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.py 的 run_widgets、DashboardWidgetItem.tsx、WidgetCard.tsx、dashboardLogic.tsx、widgetFetchUtils.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.py 的 test_widget_registry_catalog_and_expected_types_stay_in_sync 就在做这件事);registry.test.tsx 覆盖每个 catalog key 且条目都有 parseConfigApiError;config 字段变更跑 schema parity 两个测试;新增 widget_type 跑 test_widget_openapi_enums.py;测试 create/update 配置校验、活动日志、run_widgets 的权限拒绝;MCP 表面变更更新 services/mcp/tests/tools/dashboards.integration.test.ts;分析事件在 PATCH 与 POST 两条添加路径上都触发 dashboard tile added 与 dashboard 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>.py、widget_registry.py、widget_query_throttle.py |
test_run_widgets.py、test_widget_query_throttle.py;dashboard_widget_delivery SLO(自动) |
| Config 字段(过滤器、限制、排序) | widget_specs/configs.py(Pydantic SSOT)、WidgetSpec.form_fields、Edit*WidgetModal.tsx、*WidgetConfigValidation.ts |
generated/widget-configs.zod.ts、hogli build:openapi、registry parseConfigApiError、MCP config_schema 快照 |
run_* 结果形状 / 列表 footer |
backend/widgets/<widget_type>.py、widget Component footer |
分页 footer 模式 |
| 磁贴过滤条 | *WidgetTileFilters.tsx、widgetTileFiltersHooks.ts、registry TileFilters |
防抖刷新 scheduleRefreshDashboardWidgets(dashboardLogic 监听器) |
| 头部标题 → 产品场景 | catalog.ts titleHref、WidgetCardHeader.tsx isDashboardEditMode |
头部导航模式 |
| 磁贴名称/描述 UX | Edit*WidgetModal.tsx、EditWidgetModalTileDetailsSection.tsx |
参照 EditErrorTrackingWidgetModal.tsx 字段布局;serializer 字段变更时的活动日志测试 |
| 新增磁贴的默认尺寸 | catalog.ts defaultLayout.w / .h |
dashboardLogic.addWidgetTiles(读 catalog 默认值) |
| 网格最小/最大缩放 | catalog.ts defaultLayout.minW / .minH |
tileLayouts.ts、tileLayouts.test.ts |
| 头部 / 日期范围展示 | catalog.ts(headerLayout、headerMeta、headerTitle、titleHref) |
WidgetCardHeader、stories |
| 安装 / availability 门控 | catalog.ts availability、widgetAvailability.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.ts、widgetProductAccess.ts |
| public/shared 占位文案 | catalog.ts sharedPlaceholder |
DashboardWidgetItem.test.tsx、test_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 形状时:
- 向后兼容(首选)——在 Pydantic 模型(
widget_specs/configs.py)里用model_validator/ 字段别名接受旧 key;extra="forbid"在 validate 时剥离未知 key。存量磁贴无需数据迁移即可继续工作。 - 破坏性变更——按新
widget_type处理(或接受旧磁贴校验失败直到用户在编辑弹窗里重新保存)。 - 非破坏性增删字段要逐层打通:BE Pydantic 模型 → FE
*WidgetConfigValidation.ts(表单.pick()+ 校验)+ registryparseConfigApiError→hogli build:openapi重新生成 OpenAPI /widget-configs.zod.ts/ MCP schema → stories/fixtures(组件、编辑弹窗、preview、widgetOverviewStoryFixtures.ts)。 - 相对日期范围——更新
backend/constants.py(WIDGET_DATE_FROM_VALUES_ORDERED)和widget_specs/common.py的 PydanticWidgetDateFrom,再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.updateWidgetTile → updateDashboardWidgetTile → 一次 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_CLASS(sm:col-span-2)放在 grid child(LemonField.Pure 或 wrapper <div>)上,而不是内层 LemonInput/LemonTextArea;半宽字段(date range、sort、limit)不加 col-span-2,需要时给 LemonSelect 加 fullWidth。
弃用类型没有软删除:先从 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.py、dashboard_tile.py |
每个 tile 恰好一个内容 FK:insight | text | button_tile | widget(DB CHECK) |
| 后端运行时 | products/dashboards/backend/widgets/<type>.py + widget_specs/registry.py |
逐类型 run_*;WIDGET_SPECS(WidgetSpec)清单 |
| 配置契约 | 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.py 的 GET .../dashboards/:id/run_widgets?tile_ids= |
批量逐磁贴结果 + 逐磁贴错误(通用循环);每个磁贴查询发出 dashboard_widget_delivery SLO |
| 前端分发 | widgets/registry.tsx |
DASHBOARD_WIDGET_REGISTRY → Component + EditModal |
| Catalog / 布局 | frontend/widget_types/ |
add 弹窗、默认值、头部、RBAC 映射、网格尺寸 |
| 场景胶水 | frontend/src/scenes/dashboard/ |
获取、CRUD、复制/移动、撤销删除 |
新 widget_type 字符串无需迁移——只注册 registries + catalogs 即可。命名规范:产品 widget 目录 snake_case(widgets/error_tracking/)、catalog key / widget_type / registry key snake_case(error_tracking_list)、共享模块 snake_case 文件名、React 组件目录 PascalCase(WidgetCard/)、MCP 工具名 kebab-case(dashboard-widgets-run)。
5.2 六条平台不变量
SKILL §4 规定两条路径都必须遵守:
- RBAC 由 registry 驱动——
dashboard.py里不允许出现widget_type分支(见 permissions-and-sharing.md § Product RBAC); - 处处一个
widget_type——registries + 两个 catalogs + FE registry;变体只共享groupId; - 逐类型代码放在产品路径——不进平台壳层(architecture.md § Platform files);
- WidgetCard 组合模式——composition.md;
- 配置契约 = Pydantic SSOT——
widget_specs/configs.py+registry.py的WidgetSpec;hogli build:openapi生成 OpenAPI/FE Zod/MCP;运行时 PATCH 保持JSONField; - 不提供图表主体 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 / WidgetCardBodySkeleton、WidgetCardSharedPlaceholderBody(public/shared 占位)。生产调用点是 DashboardWidgetItem,它组合 header + body、接 ⋯ 菜单与编辑弹窗 portal、做产品 RBAC 锁定,并仅在 hasProductAccess 时挂载 registry 的 TileFilters;public 放置用 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 提示(如 ErrorTrackingWidget 从 products/error_tracking/frontend/ 导入 ErrorTrackingIssueList、ErrorTrackingIssueListSkeleton、ErrorTrackingIngestionPrompt),而不是给 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 进 WidgetCardBody;getDashboardWidgetDefinition 仍会按 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(共享 dateRange、widgetFilters、filterTestAccounts)、registry.py(WIDGET_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 掉 PatchedDashboardOpenApiSerializer(extend_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.py 的 WidgetSpec 上:
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 导入时失败):
run_widgets的结果是 seed 而非状态——载荷必须携带generatedAt(查询时刻的服务端时钟,ISO-8601)。见widgets/live/liveWidgetTypes.ts的LiveWidgetSeedPayload;- Seed 必须幂等——手动刷新和仪表盘自动刷新都会重跑
run_widgets并重新 seed(平台不跳过 live 磁贴,re-seed 能修补断连或隐藏标签页造成的流缺口);合并 seed 保证重跑不重复计数; - config 里不能有
dateRange或filterTestAccounts——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 查询返回 generatedAt,widgets/<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_list、session_replay_list 等)的职责划分:widgetFilters 存 config(持久化属性过滤选择);编辑弹窗 = test accounts + limit + sort;磁贴条 = date + type pickers + 属性过滤。后端通用工具在 backend/widgets/widget_filters.py(validate_widget_filters + build_*_from_widget_filters),磁贴条组件是 *WidgetTileFilters.tsx + widgetTileFiltersHooks.ts(useWidgetTileConfigPersist),挂载由 DashboardWidgetItem 控制(仅 hasProductAccess && showTileFilters 时挂 registry TileFilters)。canEditDashboard 决定编辑 vs 只读条(DASHBOARD_WIDGET_TILE_FILTERS_READONLY_REASON 在 constants.ts);RBAC 拒绝时整个条隐藏(body 显示锁定态);与仪表盘 quick-filter 条无耦合。
分页 footer 的契约:run_* 返回 results、hasMore、limit、offset;!hasMore 时 totalCount = 已显示;hasMore + include_total_count=True 走 capped count 查询(MAX_WIDGET_RESULT_LIMIT)。仪表盘 run_widgets 始终传 include_total_count=False——页还有更多行时跳过 count。FE 用 WidgetCardContent 的 footer + formatWidgetListCountFooter(shown, totalCount, totalCountCapped, noun, hasMore)(名词在 constants.ts 的 WIDGET_LIST_COUNT_ISSUES / RECORDINGS;省略 total + hasMore 时显示 N+ 文案)。count 失败永不导致磁贴失败(记录日志 + 省略 totals)。头部标题导航:catalog titleHref 指向产品场景路由(urls.errorTracking() 等),WidgetCardHeader 在 titleHref 已设且 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_type、dashboard_id)和 dashboard widget added(widget 专属事件,带 widget_type、dashboard_id、tile_id、widget_id 及请求 source:web / mcp / api)。两者都在 PATCH dashboard tiles[] 新增 widget 磁贴和 POST .../widgets/batch/(UI、REST、MCP)时触发;config/元数据更新不会重新触发。
7.3 Delivery SLO
_run_widget_query 中每个成功/失败的 widget 查询都经 slo_operation(SloArea.ANALYTIC_PLATFORM)发出 dashboard_widget_delivery,带 widget_type、dashboard_id、tile_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.ts 或 dashboardLogic.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.py、run_widgets 端点 api/dashboard.py(含 _run_widget_query 与 throttle 检查)、前端 registry widgets/registry.tsx、catalog widget_types/catalog.ts、同步测试 test_run_widgets.py。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00