首页
/ Glance 扩展机制详解:用一个 HTTP 请求和几个特殊响应头打造自己的第三方 Widget

Glance 扩展机制详解:用一个 HTTP 请求和几个特殊响应头打造自己的第三方 Widget

2026-09-05 16:37:41作者:伍希望

Glance 的扩展(Extension)功能允许你把自己部署的任何 HTTP 服务接入仪表盘,只需在响应中携带几个约定的 Widget-* 头,Glance 就会把返回内容渲染成一个标准 widget。本文基于 docs/extensions.mddocs/configuration.md 中扩展相关章节,结合 internal/glance/widget-extension.go 等源码,完整讲解扩展的请求模型、全部配置项、四个响应头的精确语义,以及如何复用 Glance 内置的 CSS 类名与前端脚本,让你的扩展 HTML 获得与内置 widget 一致的外观和交互。

一、扩展的本质:一次普通的 HTTP 请求

Glance 设计扩展功能的目标是"让开发者以最低的知识门槛参与开发"(with the intention of requiring minimal knowledge in order to develop extensions)。它没有定义任何复杂的私有协议,扩展就是:Glance 向一个 URL 发起一次 HTTP GET 请求,服务器返回一段内容加几个特殊响应头。整个交互过程如下图所示:

Glance 与扩展之间的请求交换流程图

因此,只要你懂得搭建一个 HTTP 服务器、会写一点 HTML 和 CSS,就可以开始开发自己的扩展。

需要特别注意,原文档在开头明确声明:扩展功能以及该文档本身都是"进行中"(work in progress)状态,API 未来可能变化,你自己负责维护自己的扩展。这意味着本文描述的所有头名、配置项和类名均以当前仓库版本为准,升级 Glance 后建议对照仓库文档重新核对。

[!TIP] 默认情况下扩展 widget 的缓存时间为 30 分钟。为了避免每次修改扩展后都要重启 Glance 才能看到效果,可以在配置中把该 widget 的缓存时间设为 1 秒:

- type: extension
  url: http://localhost:8081
  cache: 1s

二、widget 配置:type: extension 的完整参数

在配置文件中添加一个扩展 widget 的最小写法如下(引自 docs/configuration.md 的 Extension 章节):

- type: extension
  url: https://domain.com/widget/display-a-message
  allow-potentially-dangerous-html: true
  parameters:
    message: Hello, world!

完整属性表如下:

属性 类型 必填 默认值
url string
fallback-content-type string
allow-potentially-dangerous-html boolean false
headers key & value
parameters key & value
cache string(时长) 30m(扩展默认值)

url

扩展服务的地址。注意:URL 中自带的 query 会被剥离,实际生效的 query 由 parameters 定义(原文档加粗强调)。从源码 internal/glance/widget-extension.go#L119-L123 可以看到,一旦 parameters 非空,就会执行 request.URL.RawQuery = options.Parameters.toQueryString() 直接替换掉原有 query。

源码中 url 也是扩展 widget 唯一必填的字段,且在 initialize() 阶段 通过 url.Parse 校验合法性:URL 为空会报 URL is required,格式非法会报 parsing URL: ...

fallback-content-type

当扩展服务没有返回合法的 Widget-Content-Type 头时使用的回退内容类型。目前该属性唯一支持的取值是 html。源码中的回退链路与之一致:先查响应头 Widget-Content-Type,查不到再查 fallback-content-type,仍查不到则按未知类型处理(见下文第四节的 convertExtensionContent 逻辑)。

headers

可选,指定随请求一起发送的自定义请求头,典型用法是携带 API 密钥:

headers:
  x-api-key: ${SECRET_KEY}

源码 fetchExtension 中对每个 key-value 依次调用 request.Header.Add(key, value) 注入请求。

allow-potentially-dangerous-html

是否允许扩展渲染 HTML。原文档给出了一条很直白的警告:

这个属性名字之所以听起来吓人是有原因的。它面向的是有能力开发并使用自己扩展的开发者。如果你不清楚它意味着什么,或者没有绝对把握你使用的扩展 URL 是安全的,就不要启用它。

从源码看它的确是一个安全开关:当扩展返回 html 内容类型但该属性为 false 时,内容会被整体 HTML 转义后放进 <pre> 标签以纯文本展示(见 convertExtensionContent)。因此该属性本质上是"是否信任该扩展提供的 HTML"的声明。

parameters

以键值对形式发送给扩展的 query 参数。从 queryParametersField 的 YAML 解析实现 看,值支持多种写法:

parameters:
  param1: value1            # 字符串
  count: 10                  # 数字,会被转成字符串
  enabled: true             # 布尔值
  param2:
    - item1                 # 列表:同一 key 对应多个值
    - item2

解析后内部统一为 map[string][]string,再由 toQueryString() 用标准库 url.Values.Encode() 编码为 query string。列表写法对应同 key 多值的 query 参数。

cache 与时长格式

cachewidgetBase 的公共字段,扩展 widget 在 initialize() 中通过 withCacheDuration(time.Minute * 30) 设定 30 分钟默认值;若你显式配置了 cache,则用户配置优先(见 withCacheDurationw.CustomCacheDuration 的判断分支)。

Glance 的时长字段使用自定义格式(durationField):数字 + 单位,单位为 s(秒)、m(分)、h(时)、d(天)、w(周)、mo(月,按 30 天)、y(年,按 365 天)。所以 cache: 1s 表示 1 秒。

三、四个特殊响应头:扩展的"API"

扩展返回的 HTTP 响应中,Glance 读取以下四个头来描述 widget 的元信息(头名常量定义于 widget-extension.go#L84-L89):

响应头 作用 不返回时的行为
Widget-Title widget 标题 标题为 "Extension"
Widget-Title-URL 点击 widget 标题时打开的 URL 标题不可点击
Widget-Content-Type 内容类型 内容按纯文本展示
Widget-Content-Frameless 设为 true 时,内容去掉默认背景和边框(frame) 内容带默认"边框"样式

Widget-Title

指定 widget 标题。若不提供,标题回退为 "Extension"。源码中有两处兜底:请求侧在 fetchExtension 中检查该头为空时直接赋 "Extension";widget 侧在 update() 中仅当用户没有在配置里自定义 title 时才采用扩展传来的标题——也就是说配置中的 title 优先于 Widget-Title

Widget-Title-URL

指定点击 widget 标题时打开的链接。原文档明确:如果用户在配置里指定了 title-url,它以优先。源码验证了这一点——update() 中的条件是 if widget.TitleURL == "",只有配置未设置时才回填头的值。

TitleURL 时,widget-base.html 模板 会把标题渲染为 <h2><a href="..." target="_blank">...</a></h2>,即新标签页打开。

Widget-Content-Type

指定扩展返回的内容类型。当前唯一受支持的取值是 html(见 extensionStringToType 映射表)。未提供或取值不受支持时,内容按纯文本展示。

Widget-Content-Frameless

当该头的值是 true 时,widget 内容区域会去掉默认背景与边框,用于展示自带完整样式的扩展内容。源码解析细节有两点值得注意:

  1. 头值通过 stringToBool 解析,只有 "true""yes"(小写)会被识别为真1TrueYES 都不行;
  2. 解析结果最终落到 extension.html 模板Frameless 为真时,widget-content 容器额外获得 widget-content-frameless CSS 类。

四、内容类型:目前只有 html,以及它的回退链

原文档的 NOTE 说明了现状与长期目标:

目前 html 是唯一支持的内容类型。长期目标是支持 videosforum-postsmarketsstreams 这类通用内容类型——扩展只需返回 JSON 数据,由 Glance 使用内置样式和功能渲染,开发者专注于从自己的数据源取数,即可获得原生外观。

内容处理的判定链路

结合 fetchExtensionconvertExtensionContent,一次响应内容的处理顺序是:

  1. Widget-Content-Type 头,若映射表中不存在(当前映射表只有 html),则回退到配置项 fallback-content-type
  2. 仍不存在则为 extensionContentUnknown
  3. 进入 convertExtensionContent 做最终转换:
    • 类型为 html allow-potentially-dangerous-html: true → 原样输出为 template.HTML(即不转义地注入页面);
    • 类型为 html允许 HTML → fallthrough 到默认分支,内容经 html.EscapeString 转义后包进 <pre> 标签,以纯文本形式展示;
    • 未知类型 → 同样走转义 + <pre> 分支。

这条链路解释了文档中"如果不设置 allow-potentially-dangerous-html,HTML 内容会被当作纯文本显示"的原因:不是丢弃内容,而是转义后原样展示源码文本。

渲染位置

扩展内容最终由 templates/extension.html 注入标准 widget 骨架:它复用 widget-base.html 模板的 widget-header(标题、WIP 图标、错误/通知图标)结构,并把 .Extension.Content 放在 widget-content 容器中。因此扩展自动获得了与内置 widget 一致的标题样式、错误提示(拉取失败时显示 ERROR 区块及错误信息)等基础能力。

五、复用 Glance 现有样式:一份可直接运行的完整 HTML 示例

Glance 内置 widget 的大部分视觉效果来自一组工具类 CSS 和少量前端脚本,这些在扩展的 HTML 中可以直接复用。原文档给出了一份覆盖面很广的示例,这里完整保留:

<p class="color-subdue">Text with subdued color</p>
<p>Text with base color</p>
<p class="color-highlight">Text with highlighted color</p>
<p class="color-primary">Text with primary color</p>
<p class="color-positive">Text with positive color</p>
<p class="color-negative">Text with negative color</p>

<hr class="margin-block-15">

<p class="size-h1">Font size 1</p>
<p class="size-h2">Font size 2</p>
<p class="size-h3">Font size 3</p>
<p class="size-h4">Font size 4</p>
<p class="size-base">Font size base</p>
<p class="size-h5">Font size 5</p>
<p class="size-h6">Font size 6</p>

<hr class="margin-block-15">

<a class="visited-indicator" href="#notvisitedprobably">Link with visited indicator</a>

<hr class="margin-block-15">

<a class="color-primary-if-not-visited" href="#notvisitedprobably">Link with primary color if not visited</a>

<hr class="margin-block-15">

<p>Event happened <span data-dynamic-relative-time="<unix timestamp>"></span> ago</p>

<hr class="margin-block-15">

<ul class="list-horizontal-text">
    <li>horizontal</li>
    <li>list</li>
    <li>with</li>
    <li>multiple</li>
    <li>text</li>
    <li>items</li>
</ul>

<hr class="margin-block-15">

<ul class="list list-gap-10 list-with-separator">
    <li>list</li>
    <li>with</li>
    <li>gap</li>
    <li>and</li>
    <li>horizontal</li>
    <li>lines</li>
</ul>

<hr class="margin-block-15">

<ul class="list collapsible-container" data-collapse-after="3">
    <li>collapsible</li>
    <li>list</li>
    <li>with</li>
    <li>many</li>
    <li>items</li>
    <li>that</li>
    <li>will</li>
    <li>appear</li>
    <li>when</li>
    <li>you</li>
    <li>click</li>
    <li>the</li>
    <li>button</li>
    <li>below</li>
</ul>

<hr class="margin-bottom-15">

<p class="margin-bottom-10">Lazily loaded image:</p>

<img src="https://picsum.photos/200" alt="" loading="lazy">

<hr class="margin-block-15">

<p class="margin-bottom-10">List of posts:</p>

<ul class="list list-gap-14 collapsible-container" data-collapse-after="5">
    <li>
        <a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
        <ul class="list-horizontal-text">
            <li data-dynamic-relative-time="<unix timestamp>"></li>
            <li>3,321 points</li>
            <li>139 comments</li>
        </ul>
    </li>
    <li>
        <a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
        <ul class="list-horizontal-text">
            <li data-dynamic-relative-time="<unix timestamp>"></li>
            <li>3,321 points</li>
            <li>139 comments</li>
        </ul>
    </li>
    <li>
        <a class="size-h3 color-primary-if-not-visited" href="#link">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptatum, ipsa?</a>
        <ul class="list-horizontal-text">
            <li data-dynamic-relative-time="<unix timestamp>"></li>
            <li>3,321 points</li>
            <li>139 comments</li>
        </ul>
    </li>
</ul>

渲染效果如下(把 <unix timestamp> 替换为真实 Unix 时间戳后):

上述 HTML 示例在 Glance 中的渲染效果预览

示例中各功能的源码出处

这些能力并非扩展专属,全部来自 Glance 的全局静态资源,因此在扩展 HTML 中天然可用:

  • 颜色与字体类color-subduecolor-highlightcolor-primarycolor-positivecolor-negativesize-h1~size-h6size-basemargin-block-15margin-bottom-10 等):定义在 static/css/utils.css,属于纯 CSS 工具类;
  • visited-indicator / color-primary-if-not-visited:已访问链接的样式标记。Glance 会在本地记录已打开的链接并据此切换样式,扩展中的链接同样参与该机制;
  • data-dynamic-relative-time="<unix timestamp>":由 static/js/page.js#L210 附近document.querySelectorAll("[data-dynamic-relative-time]") 统一处理,将时间戳渲染为"相对时间"(如 3 小时前)并定期刷新,适用于"事件发生于何时"这类元信息;
  • collapsible-container + data-collapse-after="N":由 page.js 的 attachExpandToggleButton 处理,脚本会在列表后自动插入"展开/收起"按钮,点击后切换 container-expanded 类;
  • list-horizontal-textlistlist-gap-10list-with-separator 等列表类:均为 utils.css 中的通用列表样式,示例末尾的"帖子列表"组合(标题链接 + 水平元信息行 + 可折叠列表)正是 RSS、论坛类 widget 的常见排版模式,值得在自己的扩展中照搬。

六、缓存与失败重试:扩展拉不到数据时会发生什么

除了 cache 时长,理解扩展的更新与重试行为有助于调试:

  1. 默认 30 分钟initialize()withCacheDuration(time.Minute * 30)nextUpdate 到点后下一次页面刷新触发重新拉取;
  2. 拉取失败会触发提前重试update() 将错误交给 canContinueUpdateAfterHandlingErr 处理(widget.go#L297-L329)——出现错误时调用 scheduleEarlyUpdate(),按重试次数的平方分钟数递增提前重试,且封顶 5 次(widget.go#L354-L370);期间 widget 会展示错误区块而不是旧内容;
  3. 标题/链接的合并时机Widget-TitleWidget-Title-URL 只在新一次拉取成功返回头之后才回填到 widget(且让位于用户配置),所以扩展服务的标题是动态跟随服务响应的。

调试时把 cache: 1s 设为最小值、同时保证扩展服务对 HEAD/GET 都能快速返回,是原文档 TIP 之外的实用组合。

七、适用边界与维护责任

把原文档的告诫汇总成检查清单,开发扩展前请确认:

  • API 未稳定:扩展功能的头名、取值和默认值可能随版本变化,升级 Glance 后需重新核对 docs/extensions.md
  • 样式类名不承诺稳定:第五节复用的 color-*size-*list-* 等类名以及 data-dynamic-relative-timecollapsible-container 等脚本约定"可能会被修改",原文档明确要求你自己负责维护自己的扩展;
  • 安全自担:启用 allow-potentially-dangerous-html 后,扩展返回的 HTML 会不经过转义注入页面。扩展服务泄露或被篡改,等同于向所有 Glance 用户浏览器投递任意 HTML/脚本,务必只信任自己的扩展源;
  • 内容类型有限:当前仅 html 一种内容类型,无法像内置 widget 那样复用 Glance 的 JSON 数据渲染管线;通用内容类型(videosforum-posts 等)是文档中声明的长期目标,当前版本尚不可用。

如果你需要一个"非扩展"的替代方案,仓库中的 custom-api widget(docs/custom-api.md) 支持用 Go template 直接对接口响应做服务端渲染,两者可以按"是否需要自己部署服务"来取舍。

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