PostHog 仪表盘系统全景:从领域模型到多渲染表面(Placement)的前后端所有权地图
PostHog 的仪表盘(Dashboard)并不是一个只存在于标准仪表盘页面的功能——同一份仪表盘数据会在标准页面、项目主页、功能标志详情页、群组/用户详情页、公开分享链接、导出打印等多种“渲染表面”上呈现。本文基于 PostHog 仓库中仪表盘技能的参考文档 surfaces-and-ownership.md,结合其上下文 managing-dashboards/SKILL.md 与真实源码,完整梳理仪表盘领域的领域模型划分、DashboardPlacement 前端放置契约、前后端文件的“所有权地图”,以及 API 契约变更的标准流程。读完本文,你将能够准确判断一个仪表盘改动应落在哪些文件、哪些渲染表面必须逐一验证,以及如何安全地执行序列化器/视图集级别的 API 契约变更。
一、从领域模型开始:谁拥有什么数据
文档要求任何仪表盘改动都应“从领域模型开始”(Start from the domain model),其核心结论是四个模型各自拥有不同的数据职责,改动前必须先判断你要动的状态属于哪一层:
Dashboard模型拥有项目作用域的元数据:仪表盘过滤器(filters)、变量(variables)、分享状态(sharing state)、限制级别(restriction_level)以及与磁贴(tile)的关系。DashboardTile模型拥有一条磁贴关系以及每个断点(breakpoint)的布局 JSON。- 一个磁贴恰好是一个 insight、文本卡片(text card)、按钮(button)或部件(widget)之一。
DashboardTemplate存储的是可复制的仪表盘定义,它不是一个活的仪表盘。- 仪表盘 widget 使用独立的模型,widget 相关的改动应走
manage-dashboard-widgets这条独立技能线。
Dashboard 模型:元数据与可见性
从源码 dashboard.py 可以完整印证文档中“Dashboard 拥有什么”的说法:
filters = models.JSONField(default=dict)与variables = models.JSONField(...)承载仪表盘级过滤器和变量;restriction_level默认值为RestrictionLevel.EVERYONE_IN_PROJECT_CAN_EDIT,与“限制级别”归 Dashboard 所有对应;- 旧的
share_token/is_shared字段已被标记为 DEPRECATED,注释明确写着“use the new 'sharing' relation instead”,当前分享状态由独立的SharingConfiguration关系承载,is_sharing_enabled属性(dashboard.py)从sharingconfiguration_set读取——这正是文档中 “sharing state” 的落地方式; - 模型还定义了网格间距档位
DASHBOARD_GRID_SPACING_GAPS(tight 8px / condensed 12px / standard 16px / relaxed 32px / wide 48px)和布局压缩模式LayoutCompaction(vertical / horizontal / stable),说明布局几何参数也归 Dashboard 层管辖; - 软删除通过
deleted字段加自定义DashboardManager实现:默认查询集自动exclude(deleted=True),需要包含已删除行时用objects_including_soft_deleted。
CreationMode.UNLISTED:产品内嵌的“隐藏”仪表盘
文档特别强调的一点是:产品创建的非公开(unlisted)仪表盘必须纳入检查范围。Dashboard.CreationMode.UNLISTED 只是让这些仪表盘从常规列表中隐藏,但并不会移除任何仪表盘规则。源码中该枚举定义及其注释(dashboard.py)给出了典型场景:
class CreationMode(models.TextChoices):
DEFAULT = "default", "Default"
TEMPLATE = ("template", "Template") # 由预定义模板创建
DUPLICATE = ("duplicate", "Duplicate") # 从另一个仪表盘复制
UNLISTED = ("unlisted", "Unlisted (product-embedded)")
# Product dashboards (e.g. AI observability) - hidden from general lists,
# accessed via tag queries
即 AI observability 等产品内嵌仪表盘使用 unlisted 模式创建,通过 tag 查询访问。对工程实践的含义是:任何基于“仪表盘列表”做假设的改动(列表接口、列表页 UI、批量操作)都要意识到存在一个不在列表里但依然完整受权限、过滤器、布局规则约束的仪表盘集合。
DashboardTile 模型:一个磁贴恰好一个内容
dashboard_tile.py 精确实现了文档中“一个磁贴恰好是 insight、text、button 或 widget 之一”的约束,且用双重手段保证:
- 数据库层:
build_unique_relationship_check(("insight", "text", "button_tile", "widget"))生成CheckConstraint(约束名dash_tile_exactly_one_related_object);同时针对每个 (dashboard, 内容) 组合建立了部分唯一约束(unique_dashboard_insight、unique_dashboard_text等)。 - 应用层:
clean()方法显式校验“related_fields != 1 就抛 ValidationError”,并规定刷新相关字段(filters_hash、refreshing、refresh_attempt、last_refresh)只允许出现在 insight 磁贴上。
磁贴的其余字段也印证了“每断点布局 JSON 归磁贴所有”:layouts = models.JSONField(default=dict),其结构形如 {"sm": {"h": 3, "w": 4, "x": 0, "y": 2, "minH": 3, "minW": 3}, "xs": {...}}——可以推断 sm/xs 等键就是文档所指的 breakpoint。排序工具函数 sort_tiles_by_layout 按 (y, x, id) 排序,id 作为兜底 tiebreak 以避免数据库返回顺序的随机性。
此外,save() 中会自动从 dashboard.team_id 回填 team_id(该字段被反规范化,以便该表能通过 HogQL 暴露——HogQL 打印器会对每张 Postgres 表注入 WHERE team_id = <ctx.team_id>),这与技能主文档中“保持仪表盘与磁贴数据团队作用域化”的规则相呼应。
DashboardTemplate 模型:模板不是活仪表盘
dashboard_templates.py 证实了“DashboardTemplate 存储可复制的仪表盘定义,它不是活仪表盘”:
- 模板的磁贴内容是一整个 JSON 字段
tiles = models.JSONField(...),而非对DashboardTile表的外键引用;dashboard_filters、variables同样是 JSON 快照; - 模板有独立的作用域枚举
Scope(team/organization/global/feature_flag),以及availability_contexts(例如general、onboarding)与is_featured等分发字段; - 团队内模板名唯一(
unique_template_name_per_team约束); - 模型内置了两个硬编码的“Product analytics”模板:
legacy_signup_template()(旧版 DEFAULT_APP 种子)与default_signup_template()(新版注册默认布局,含 TEXT/INSIGHT/BUTTON 混合磁贴与 sm/xs 双断点布局),源码注释说明系统假设该模板始终存在、不会等待从模板仓库导入。功能标志的用量仪表盘则由feature_flag_template(feature_flag_key)生成——这正是后文FeatureFlag渲染表面的数据来源。
二、渲染表面:DashboardPlacement 前端放置契约
文档给出的核心规范是:使用 DashboardPlacement 作为前端放置契约(frontend placement contract),即任何在前端渲染仪表盘 UI 的组件,都必须显式声明自己处于哪个放置位置,并按该位置执行对应行为。
| Placement | 要求的行为 |
|---|---|
Dashboard |
完整的已认证仪表盘。可能可用编辑能力。 |
ProjectHomepage 与 Builtin |
仪表盘内容渲染在另一个已认证的产品表面中。检查操作可见性与可用宽度。 |
Public |
公开分享。只读。不得暴露作者信息、文件夹、私有配置或强制刷新操作。 |
Export |
导出渲染。不得添加交互控件,也不得假设有浏览器用户会话。 |
FeatureFlag 与 Group |
嵌入式仪表盘上下文。检查宿主页面、URL 状态、权限与刷新行为。 |
前端枚举的实际定义
DashboardPlacement 枚举定义于 frontend/src/types.ts,其成员比文档表格更完整——文档表只挑了改动决策中最关键的一批,实际枚举还包含若干产品内嵌表面:
export enum DashboardPlacement {
Dashboard = 'dashboard', // When on the standard dashboard page
CustomerAnalytics = 'customer-analytics', // When embedded on the customer analytics page
ProjectHomepage = 'project-homepage', // When embedded on the project homepage
FeatureFlag = 'feature-flag',
Public = 'public', // When viewing the dashboard publicly
Export = 'export', // When the dashboard is being exported (alike to being printed)
Person = 'person', // When the dashboard is being viewed on a person page
Group = 'group', // When the dashboard is being viewed on a group page
Builtin = 'builtin', // Dashboard built into product UI with external controls provided by parent context
DataOps = 'data-ops', // When embedded on the data ops scene dashboard tab
}
仓库中已有大量组件以此契约分支行为,例如:
- FeatureFlag.tsx 以
DashboardPlacement.FeatureFlag渲染功能标志用量面板(数据来自feature_flag_template); - GroupDashboardCard.tsx 在群组详情页以
DashboardPlacement.Group嵌入仪表盘; - ExporterDashboardScene.tsx 以
DashboardPlacement.Export驱动导出渲染。
这说明放置契约不是纸面约定,而是贯穿组件树的真实分派依据。
每个表面的行为差异要点
结合文档表格,各表面的关键差异可以归纳为:
Dashboard(标准页):功能全集,编辑是否可用取决于restriction_level与 RBAC,对应 dashboardLogic.tsx 承载的场景状态与 DashboardItems.tsx 的主布局。ProjectHomepage/Builtin:内容被嵌入另一个已认证产品表面。两条检查项必须落实——操作可见性(编辑、分享等入口可能应隐藏)和可用宽度(宿主容器比标准页窄,布局断点与栅格宽度都要按实际宽度计算)。Builtin的枚举注释还补充了一条语义:外部控件由父上下文提供。Public:公开分享表面。只读;必须屏蔽作者(authorship)、文件夹、私有配置以及“强制刷新”这类依赖登录态会话的操作。Export:导出渲染等价于“打印”。不允许出现交互控件(按钮、下拉、刷新按钮等),也不能假设存在浏览器用户会话——从源码结构看,该表面由 Exporter.tsx 与ExporterDashboardScene.tsx承载,运行在无登录态的导出管线中。FeatureFlag/Group:嵌入在产品详情上下文里。必须检查宿主页面(功能标志详情页、群组页)、URL 状态(宿主路由的参数如何映射到仪表盘过滤器/变量)、权限(宿主页面可见是否等同于仪表盘可见)与刷新行为(嵌入表面通常不应沿用标准页的自动刷新策略)。
技能主文档 SKILL.md 的“请求路由”一节进一步要求:编码前必须对七个表面——已认证仪表盘、公开分享、嵌入式、导出、产品内嵌、模板、仪表盘列表与项目主页——逐一记录 affected / unaffected / not applicable。这可以理解为对本文所述放置契约的工程化落地:先做表面影响面分析,再动手改代码。
三、所有权地图:改动落在哪些文件
文档给出的所有权地图(Ownership map)规定了各关注点的“责任文件”,这是避免改动散落错层的关键:
| 领域 | 文件 |
|---|---|
| 模型 | dashboard.py、dashboard_tile.py、dashboard_templates.py |
| 仪表盘端点 | dashboard.py(API) |
| 模板端点 | dashboard_templates.py |
| 产品路由 | routes.py |
| 场景状态 | dashboardLogic.tsx |
| 主布局 | DashboardItems.tsx、tileLayouts.ts |
| 共享/导出宿主 | ExporterDashboardScene.tsx、Exporter.tsx |
上表全部路径均经核实存在于当前仓库。结合技能主文档的代码地图,还可以补全两条相邻边界:布局几何与磁贴尺寸约束的辅助逻辑在 dashboardUtils.ts,刷新默认值与共享安全钳制在 refresh_policy.py。理解所有权地图的实践价值在于:
- 模型层改动(新增字段、约束、软删除语义)归
products/dashboards/backend/models/三个文件,并伴随 Django 迁移; - 契约层改动(序列化器、视图集动作)归
api/dashboard.py或api/dashboard_templates.py,产品对外路由注册在routes.py; - 前端状态改动归
dashboardLogic.tsx(Kea logic,负责场景状态、刷新与布局持久化); - 布局渲染改动归
DashboardItems.tsx+tileLayouts.ts——前者是主布局容器,后者负责断点几何计算; - 共享与导出宿主改动归 exporter 目录下的两个文件,不要试图在标准仪表盘场景里“顺手”修共享渲染。
这种分层与文档第二节的“跨层实现”规则一致:当持久化契约改变时,产品模型、序列化器、API 动作与生成类型要一起改;数据查找与变更必须走仪表盘所属的 team 作用域;旧行与旧布局 JSON 要当作版本化输入来保护。
四、API 契约变更的标准流程
文档最后规定了当改动触及序列化器或视图集时必须执行的流程:
- 添加或更新请求与响应 schema(request and response schema);
- 运行
hogli build:openapi重新生成 OpenAPI 输出; - 在前端代码中使用生成的 API 类型,而不是手写的类型定义;
- 同时测试 API 端点与 UI 契约;
- 不要直接编辑生成的文件(Do not edit generated files directly)。
仓库中可以直接验证这一流程的接线:OpenAPI 任务定义在 hogli.yaml 中,包括 build:openapi-schema、build:openapi-types 等任务(见 hogli.yaml)。技能主文档的配套技能表也印证了这条链路的分工:improving-drf-endpoints 负责视图集/序列化器契约与 OpenAPI 输出,adopting-generated-api-types 负责在前端消费变更后的生成类型,django-migrations 负责模型 schema 变更。测试边界的对应要求见 SKILL.md 第 5 节的清单——其中“序列化器变更后的 API schema 与生成类型”是显式的必测边界之一。
对 Agent 或工程师的实际约束可以概括为三点:生成文件永远只读(它是 schema 的编译产物);前端不得在生成类型之外私自定义仪表盘 DTO;任何序列化器字段变更都必须能回答“哪个表面会渲染这个字段、公开/导出表面是否会因此泄露内部信息”——这与第二节中 Public 表面的“不得暴露私有配置”要求是同一条安全边界的两个侧面。
五、小结:一张表看懂决策顺序
把整份文档压缩成可执行的决策顺序:
- 先定位数据所有者:要改的状态在
Dashboard、DashboardTile还是DashboardTemplate?widget 归另一条技能线; - 再列出渲染表面:用
DashboardPlacement枚举逐一确认 affected/unaffected,特别注意UNLISTED产品内嵌仪表盘与导出/公开表面的只读约束; - 按所有权地图选文件:模型 →
models/;契约 →api/;状态 →dashboardLogic.tsx;布局 →DashboardItems.tsx+tileLayouts.ts;共享/导出 → exporter 场景; - 触碰序列化器/视图集时走契约流程:更新 schema →
hogli build:openapi→ 前端消费生成类型 → 双端测试,且绝不手改生成文件。
这套“领域模型 → 渲染表面 → 文件所有权 → 契约流程”的骨架,配合 SKILL.md 中的功能准入标准、变更契约清单与边界测试清单,构成了 PostHog 仪表盘系统改动从设计到验证的完整路径;本文引用的每一处实现事实都可以直接在对应仓库路径中复核。
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 StartedRust0632
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