技术解读

以下内容由 GitRead 中文社区 编辑团队基于项目 README 与官方资料整理, 结合中文开发者的常见使用场景补充技术选型建议。具体以项目官方文档为准。

Nocturne:Nightscout 的"平台化"突围

项目背景与痛点

要理解 Nocturne,得先理解 Nightscout 的历史包袱。自 2013 年起,Nightscout 从一段 PHP 脚本长成了一个全球性的糖尿病数据平台,核心价值很朴素:把 CGM 的血糖曲线从厂商黑盒里拽出来,让患者、家属和医生都能看见。但十年演进下来,这套系统是被需求推着长大的,而不是被架构牵引着长大的。

痛点集中在四处。第一是数据模型的刚性:entries、treatments、profiles、devicestatus 这四个集合几乎定义了整个系统的边界,想加一个新语义字段,往往意味着一次对下游全链路的破坏性变更。第二是实时链路的脆弱:socket.io 依赖单实例内存维护连接,水平扩展时状态同步成了难题。第三是设备接入的硬编码:每支持一款新的 CGM 或胰岛素泵,都要动核心代码。第四是部署门槛:MongoDB 加 Node 的组合,对只想给家人跑一套监控的普通用户并不友好。

Nocturne 的野心不在于"做一个更漂亮的前端",而是把 Nightscout 重新钉在平台的坐标系里——把设备接入、数据规范化、实时分发、告警触发这些能力,从一处耦合的单体里拆出来,变成可替换、可扩展的层。这个定位差异,决定了它值得被认真对待。

核心技术揭秘

从仓库的组织方式与 Nightscout 社区的演进脉络推断,Nocturne 采用的是一条"接入分层 + 事件驱动"的路线,数据流向大致如下:

graph TD A[设备数据源] --> B[接入网关] B --> C{"协议识别"} C -->|"Nightscout 兼容协议"| D[标准适配器] C -->|"厂商私有协议"| E[设备插件] D --> F[数据规范化层] E --> F F --> G[时序存储] F --> H[事件总线] H --> I[实时推送] H --> J[告警引擎] G --> K[查询 API] I --> L[客户端] J --> L

这条链路里真正的心脏是规范化层。不同厂商的 CGM 数据差异远比想象中琐碎:单位有 mg/dL 和 mmol/L 两套体系,采样间隔从 1 分钟到 5 分钟不等,时间戳里有带时区的、有不带的、还有设备本地时钟不准的。规范化的职责就是把它们归一成一套内部表示——统一单位、统一时间栅格、统一来源标记。这一层做不好,下游的告警、报表、API 就全是脏活。

读写分离是另一个明显的设计取向。写入路径(设备数据摄入)走网关到存储,读取路径(图表、告警、API 查询)独立走时序存储。这和 CQRS 的思路一致:摄入侧要高吞吐和幂等,查询侧要低延迟和灵活聚合,两者的优化目标本就冲突,硬塞进一个模型只会两头不讨好。

事件总线则承担了解耦的职责。把"数据落库"和"通知下游"分开后,实时推送、告警引擎、第三方集成都变成了订阅者,新增一个消费方不需要动摄入路径。这比老版 Nightscout 里"写完数据库顺手 socket 广播"的做法要干净得多。

这里面最容易被低估的技术难点是幂等去重。设备重传、网络抖动、App 离线缓存回补,都会让同一条血糖读数以不同时间抵达。若没有基于业务键(设备 ID + 采样时间戳)的去重,曲线就会出现毛刺甚至重复计数。类似的还有"修正数据":用户手动补录的碳水和胰岛素剂量,必须与设备自动上报的数据在来源上可区分,否则统计口径全乱。

功能亮点与差异

相比原版 Nightscout,Nocturne 的差异不在功能清单的长短,而在几个结构性选择上。

设备接入插件化是最大的杀手锏。适配器模式让"标准协议走内建、私有协议走插件",新增硬件不再意味着改动核心。对硬件厂商和社区贡献者来说,这是能否参与的门槛问题。

契约化的 API 让 Nocturne 有能力成为"数据中台",而不是一个只服务自家前端的后端。第三方 App、研究项目、临床系统可以按契约接入,而不是去逆向 socket 消息格式。

多租户支持意味着一个实例可以服务多个家庭或社区,这对运营方是实打实的成本优势。当然,多租户同时也是最大的风险点——租户隔离一旦有闪失,泄露的是别人的健康数据。

应用场景与落地建议

生产环境首先要过的是数据合规这一关。血糖数据属于健康敏感信息,落到 GDPR、HIPAA 或国内的相应法规语境里,至少要明确几件事:数据存储的物理位置、传输往返的加密、租户隔离的边界、以及日志里是否会意外记录可识别的健康数据。开源项目通常给出的是能力,不是合规承诺,这块得自己补。

从老版 Nightscout 迁移建议走影子流量:让 Nocturne 与旧系统并行消费同一份设备数据一段时间,比对曲线与告警是否一致,再决定切流。历史数据的导入要留意单位与时间戳的历史偏差,尤其是早期数据里常见的乱码时间。

容量规划上,时序存储的写入压力与设备数量、采样频率成正比,而读取压力集中在告警风暴和报表生成,两者要分开评估。

适合的场景:家庭自托管(想要更好的稳定性与扩展性)、社区或研究机构(需要多租户与标准 API)、第三方硬件厂商(需要一个稳定的数据接入层)。如果只是想跑一个血糖图表给自己的手机看,老版 Nightscout 依然够用,Nocturne 的价值在这个规模上体现不出来。

综合评价

优点:架构边界清晰,接入层、规范化层、事件层各司其职,扩展点明确;读写分离与事件驱动的组合,为高吞吐摄入和低延迟查询留了空间;设备插件化真正解决了 Nightscout 最痛的扩展问题;契约化 API 打开了生态可能性。

缺点:成熟度与生态远不及十年积累的老版,文档和迁移工具大概率还不完整;对普通用户而言部署复杂度并未真正下降,只是把复杂度从"运维"转移到了"架构理解";多租户带来的隔离与合规压力,是原版单实例模式所没有的;在糖尿病这类强监管领域,任何架构上的"先进"都要用正确性来兑现,而正确性只能靠时间验证。

一句话总结:Nocturne 是 Nightscout 从"一个能跑的工具"向"一个能被依赖的平台"迈出的关键一步,方向对了,但离成为默认选择还有相当长的路要走。

关于

GitHub 原始描述(英文)

A next-generation platform for diabetes management

描述取自 GitHub 官方 API,保持仓库作者书写的原文。

语言占比

C#
C++
CSS
Dockerfile
Go Template
HTML
Inno Setup
JavaScript
Mustache
PLpgSQL
PowerShell
Python
Rust
Shell
Svelte
TypeScript
mdsvex

Release

README

Nocturne

A modern, high-performance diabetes management platform built with .NET 10. Nocturne is a complete rewrite of the Nightscout API with full feature parity, providing native C# implementations of all endpoints with optimized performance and modern cloud-native architecture.

The Nocturne home screen. A large number shows the most recent glucose reading with an arrow for which way it is heading, and a graph underneath plots the readings from the last few hours alongside markers for insulin doses and meals.

What is Nocturne?

Nocturne is a comprehensive diabetes data platform that provides:

  • Complete Nightscout API Implementation - All Nightscout endpoints natively implemented in C# with full compatibility
  • Data Connectors - Native integration with major diabetes platforms (Dexcom, Glooko, LibreLinkUp, MiniMed CareLink, MyFitnessPal, Nightscout)
  • Real-time Updates - WebSocket/SignalR support for live glucose readings and alerts
  • Advanced Analytics - Comprehensive glucose statistics, time-in-range calculations, and reports
  • Cloud-Native - Built on Aspire for seamless local development and cloud deployment

Architecture

Nocturne/
├── src/
│   ├── API/                        # REST API (Nightscout-compatible)
│   ├── Aspire/                     # Aspire orchestration
│   ├── Connectors/                 # Data source integrations (Dexcom, Libre, etc.)
│   ├── Core/                       # Domain models, interfaces, and constants
│   ├── Desktop/                    # Desktop application
│   ├── Infrastructure/             # EF Core data access, caching, security
│   ├── Portal/                     # Marketing website
│   ├── Services/                   # Background services
│   ├── Tools/                      # CLI tools and MCP server
│   ├── Web/                        # pnpm monorepo (SvelteKit frontend, bot, bridge)
│   └── Widgets/                    # Embeddable widgets
└── tests/                          # Comprehensive test suite

Key Features

  • Full Nightscout API Parity - All v1, v2, and v3 endpoints
  • High Performance - Optimized queries with PostgreSQL
  • Authentication - JWT-based auth with API_SECRET support
  • Real-time - SignalR hubs for live data streaming
  • Data Connectors - Dexcom Share, Glooko, LibreLinkUp, MiniMed CareLink, MyFitnessPal, Nightscout, and MyLife
  • PostgreSQL - Modern relational database with EF Core migrations
  • Observability - OpenTelemetry (OTLP) export for metrics, traces, and logs
  • Containerized - Docker support for all services

Quick Start with Aspire (Development)

Prerequisites

Aspire orchestrates all services with a single command:

aspire start

Aspire will automatically:

  • Start PostgreSQL in a container
  • Build the alert engine’s native library (crates/nocturne-alerts-ffi) when Rust is installed
  • Run database migrations
  • Start the Nocturne API and SvelteKit frontend
  • Launch any configured data connectors
  • Set up service discovery, health checks, and a YARP gateway

Once running, access the app at https://nocturne.localhost:1612. On an instance with no tenants (a fresh clone, and every worktree, whose Postgres is ephemeral), the AppHost seeds a dev tenant with 30 days of sample data before the web app starts, at https://dev.nocturne.localhost:1612, and dev auto-login signs you in as its owner, so the first page is a populated dashboard. aspire logs nocturne-dev-seed shows what the seed did and how long it took. An instance that already holds a tenant is never seeded, so an existing database keeps its own tenants and has no dev. A first start stopped during the seed leaves a partial dev tenant that is not seeded again: delete it (the “Delete Tenants” command on nocturne-postgres-server) and restart. aspire describe lists the services and aspire logs <resource> tails one. The Aspire dashboard is off by default; start with Aspire__OptionalServices__AspireDashboard__Enabled=true aspire start (or set Aspire:OptionalServices:AspireDashboard:Enabled in the AppHost appsettings) to get it.

If .NET is installed outside the default location (e.g. Homebrew), set DOTNET_ROOT to the directory holding the real dotnet binary before aspire start (Homebrew: export DOTNET_ROOT="$(brew --prefix dotnet)/libexec").

In run mode the AppHost pins two host ports (main checkout; worktrees stay dynamic):

  • https://nocturne.localhost:1612 — the YARP gateway (the app; tenants at https://<slug>.nocturne.localhost:1612, the seeded one at https://dev.nocturne.localhost:1612)
  • http://localhost:1610 — nocturne-api directly (stable target for scripts and the dev-only admin API)

The API always runs with ASPNETCORE_ENVIRONMENT=Development under aspire start, so the dev-only surface (/api/v4/dev-only/*) exists locally and never in published images.

The dev shortcuts live under Dev in the AppHost appsettings (see appsettings.example.json). To test the first-run wizard and the passkey UI, turn both off:

Dev__AutoSeed__Enabled=false Dev__AutoLogin=false aspire start

Dev__AutoSeed__SampleDataDays sets how much history the dev tenant gets (default 30).

Multitenancy and Passkeys (Local Domains)

The default local base domain is nocturne.localhost. Browsers resolve every *.localhost name to loopback themselves, so tenant subdomains (dev.nocturne.localhost) need no DNS or hosts-file setup, and WebAuthn accepts nocturne.localhost as the passkey Relying Party ID — unlike bare localhost, which browsers reject on subdomain origins as a public suffix.

TLS: if mkcert is installed (winget install FiloSottile.mkcert / brew install mkcert), the AppHost generates a trusted wildcard certificate for *.nocturne.localhost automatically — note this runs mkcert -install, which adds the mkcert root CA to your system trust store. Without mkcert, the gateway falls back to the ASP.NET dev certificate and tenant subdomains show a browser certificate warning (functional, just noisy).

Migrating from an older checkout: the local base domain used to be bare localhost, and passkeys are scoped to the WebAuthn RP ID derived from it — credentials registered under the old domain won’t assert under nocturne.localhost. Register a fresh passkey once (then export it as a fixture, below).

Seed another loginable tenant with data in one call

The dev tenant already exists (see above), so another tenant needs another slug:

curl -X POST http://localhost:1610/api/v4/dev-only/admin/seed-tenant \
  -H "Content-Type: application/json" \
  -d '{ "slug": "test", "displayName": "Test", "ownerUsername": "dev", "sampleData": true }'

The response includes url and loginLink — open the loginLink in a browser and you’re signed in as the owner, looking at realistic CGM data. GET /api/v4/dev-only/auth/login?tenant=<slug>&format=json (or POST) returns a token pair for headless clients instead. scripts/dev-smoke.cs runs the whole path end to end: dotnet run scripts/dev-smoke.cs.

Other one-call dev endpoints: POST /api/v4/dev-only/admin/tenants/{id}/seed-sample-data (populate an existing tenant) and POST /api/v4/dev-only/admin/tenants/{id}/recovery-mode (orphan a member’s credentials to exercise the break-glass flow).

Log in with your real passkey everywhere

Register a passkey once (any tenant’s normal setup/login flow), then export it as a committed fixture:

curl http://localhost:1610/api/v4/dev-only/auth/passkey-fixture > docs/seed/dev-identities.json

WebAuthn public keys are not secret, and subjects are global rather than tenant-scoped, so the fixture is safe to commit. On every Development startup the API re-inserts the fixture’s subjects and credentials (surviving DB wipes), and seed-tenant adds each fixture subject as an owner of new tenants — your authenticator signs in to all of them. The fixture only works while the base domain (and thus the RP ID) stays the same, which the nocturne.localhost default guarantees.

Custom local domain (optional)

To use a dedicated domain on port 443 instead:

cd src/Aspire/Nocturne.Aspire.Host
dotnet user-secrets set "LocalDev:Domain" "nocturne.test"

This requires mkcert and one hosts-file line per tenant slug (127.0.0.1 dev.nocturne.test — hosts files don’t support wildcards). The app is then at https://nocturne.test with no port.

Production Deployment (Docker Compose)

The easiest way to deploy Nocturne is with the production Docker Compose bundle. Each GitHub Release includes ready-to-use artifacts, or you can generate them locally.

Using a release

Download docker-compose.yaml and .env.example from the latest release.

# 1. Copy the env template and fill in your passwords and domain
cp .env.example .env

# 2. Start Nocturne
docker compose up -d

Behind a CDN

The bundled Caddy is the hop that decides which address Nocturne records in audit rows and counts rate limits against, and by default that is whoever connected to it. Behind a CDN that is the CDN’s edge, so every visitor sharing a point of presence shares one bucket. Set TRUSTED_PROXIES to the CDN’s published ranges (Cloudflare’s are at https://www.cloudflare.com/ips/) and Caddy will take the visitor’s address from CF-Connecting-IP, or from the last entry of X-Forwarded-For that no declared proxy vouched for — but only from those ranges, so nobody else can claim to be someone they aren’t. This works for any CDN, not just Cloudflare. Leave it alone if no CDN is in front, and never set it empty: an empty value is a Caddy startup error, not a way to turn the feature off.

The production compose includes Watchtower for automatic container updates (checks daily), and omits the Aspire dashboard and Scalar API explorer. Watchtower will automatically pull new images as they are published. It runs in label-only mode and every service in the bundle carries a com.centurylinklabs.watchtower.enable label, so it only ever updates Nocturne’s own containers and leaves anything else on the same Docker host alone. Caddy and Postgres carry false: they take their config from Compose, which Watchtower does not carry across a recreate. Update them with docker compose pull && docker compose up -d. An install from before this change still has true on caddy: set the label to false there, or download the new bundle, then run docker compose up -d to recreate it.

First-run setup

A fresh install has no tenants, so the API answers every request with 503 {"error":"setup_required"} until the first owner secures the instance. This is expected — it’s the signal for the web UI to show the setup wizard. A local aspire start skips this step: it seeds a dev tenant (see Quick Start with Aspire).

In a browser (normal path). Open your Nocturne site (the apex https://<BASE_DOMAIN>/). The setup wizard walks you through creating the first tenant and registering a passkey (or linking an OIDC provider). When it completes you’re shown a set of recovery codes — save them. That’s the whole setup.

Headless / automated setup (no browser). Trusted automation can stand up a tenant — create it, configure connectors, change settings, push data — before any human registers a passkey, using the INSTANCE_KEY from your .env. The instance key is the highest-trust service credential (cross-tenant platform admin), so treat it like a root password. Requests authenticate with two headers: X-Instance-Key carrying the SHA-256 hex of INSTANCE_KEY, and an X-Instance-Service marker naming the caller.

# The X-Instance-Key header is the SHA-256 hex of your raw INSTANCE_KEY, not the key itself.
KEY_HASH=$(printf %s "$INSTANCE_KEY" | sha256sum | cut -d' ' -f1)

# 1. Create the first tenant. This endpoint is anonymous and tenantless — call it on the apex.
curl -fsS -X POST "https://$BASE_DOMAIN/api/v4/setup/tenant" \
  -H 'Content-Type: application/json' \
  -d '{"slug":"alice","displayName":"Alice"}'

# 2. Configure the tenant as trusted automation. Target the tenant's subdomain and
#    present the instance key + service marker. Normal tenant traffic still gets 503
#    until a human completes setup, but instance-key calls are allowed through.
curl -fsS -X PUT "https://alice.$BASE_DOMAIN/api/v4/connectors/config/Dexcom/secrets" \
  -H "X-Instance-Key: $KEY_HASH" \
  -H 'X-Instance-Service: nocturne-setup-agent' \
  -H 'Content-Type: application/json' \
  -d '{ "username": "...", "password": "..." }'

When the human is ready, they open the site and register the first passkey through the normal wizard — recovery codes and all — exactly as on a brand-new instance. Their pre-configured connectors and data are already there. (A bare X-Instance-Key without the X-Instance-Service marker is ignored, so an instance key accidentally forwarded onto a browser request can’t bypass setup.)

Troubleshooting

Redirected to /setup even though a tenant is already configured. Two usual causes:

  • BASE_DOMAIN is unset. The API resolves tenants as {slug}.{BASE_DOMAIN}, and the WebSocket bridge requires it. If it’s empty, host→tenant resolution fails and the dashboard bounces to /setup (and the bridge logs BASE_DOMAIN is required). Set it in .env to the domain you serve Nocturne on, for both the API and web containers, then recreate them.
  • Single-tenant install served at the base domain. If your one tenant lives at the apex (https://<BASE_DOMAIN>/, no subdomain), update to the latest image — older builds reported setup_required for /api/v4/status on the apex and bounced configured single-tenant installs to /setup.

Redirected to /auth/recovery. A member of the tenant has no way left to sign in (no passkey, and no working sign-in provider), so the tenant is in recovery mode until they register a passkey. The member can sign in with one of their own recovery codes. Otherwise someone has to issue them a one-time recovery code: an administrator who can still sign in sees the locked-out members on the /auth/recovery page and can create a link there. If nobody who can sign in has that authority (the only owner is the one locked out, or the account is an instance administrator or also belongs to another tenant), the operator issues the code with the instance key:

KEY_HASH=$(printf %s "$INSTANCE_KEY" | sha256sum | cut -d' ' -f1)
H=(-H "X-Instance-Key: $KEY_HASH" -H 'X-Instance-Service: nocturne-operator')
curl -fsS "${H[@]}" "https://$BASE_DOMAIN/api/v4/admin/tenants"               # find the tenant id
curl -fsS "${H[@]}" "https://$BASE_DOMAIN/api/v4/admin/tenants/<tenant-id>"   # find the member's subjectId
curl -fsS "${H[@]}" -X POST \
  "https://$BASE_DOMAIN/api/v4/admin/tenants/<tenant-id>/members/<subject-id>/recovery-code"

Send the member https://<tenant-host>/auth/recovery?code=<code>. The code works once and expires after 24 hours. Using it cancels any recovery codes the member still had.

Generating locally

If you have the .NET 10 SDK and Aspire CLI installed, you can generate the production bundle from source:

dotnet run scripts/publish-release.cs              # regenerates deploy/ and docs/diagrams/; CI fails a PR whose bundle drifts

PostgreSQL Roles

Nocturne uses three separate PostgreSQL roles for defense in depth. All three have NOBYPASSRLS so they obey Row Level Security policies, even when the database has no superuser connected.

Role Purpose Privileges
nocturne_migrator Runs EF Core migrations (schema DDL). Owns the database and public schema. CREATE, ALTER, DROP on tables. Cannot bypass RLS.
nocturne_app Runtime connection pool for the .NET API. Owns nothing. SELECT, INSERT, UPDATE, DELETE on migrator-created tables. Cannot bypass RLS.
nocturne_web SvelteKit bot framework (chat state storage). Owns only its own chat_state_* tables. CREATE on public schema (for its own tables only). No access to Nocturne tenant tables. Cannot bypass RLS.

The bootstrap user (POSTGRES_USER) is only used for initial container setup. After container-init/00-init.sh runs, all application traffic flows through the three roles above. Passwords are set via environment variables in .env.

For bring-your-own PostgreSQL (not using the bundled container), run docs/postgres/bootstrap-roles.sql once as a superuser. See the comments in that file for details.

Development

Running Tests

# Run every collected .NET test
dotnet test

# Run unit tests only
dotnet test --filter "Category!=Integration&Category!=Performance"

# Run the end-to-end suite (production images in docker compose; see tests/README.md)
cd e2e && pnpm install && pnpm e2e

# Run with coverage
dotnet test --collect:"XPlat Code Coverage"

Database Migrations

# Create a new migration
dotnet ef migrations add YourMigrationName -p src/Infrastructure/Nocturne.Infrastructure.Data.Migrations -s src/API/Nocturne.API

# Apply migrations
dotnet ef database update -p src/Infrastructure/Nocturne.Infrastructure.Data.Migrations -s src/API/Nocturne.API

The API loads the migrations project at runtime and applies pending migrations on startup. dotnet watch does not see that project, so after adding a migration restart the API: aspire resource nocturne-api restart.

API Documentation

API documentation is available via Scalar at https://localhost:1612/scalar when running locally.

Nocturne aims to match Nightscout’s API 1:1, so any Nightscout API endpoint should be usable. Nocturne-only endpoints are scoped to v4.

Observability

Both the API and web containers are instrumented with OpenTelemetry and export metrics, traces, and logs over OTLP push when OTEL_EXPORTER_OTLP_ENDPOINT is set. With no endpoint configured, telemetry is collected in-process and discarded with negligible overhead, so there is nothing to turn off on a default install. There is no Prometheus /metrics scrape endpoint — to use Prometheus/Grafana, run an OpenTelemetry Collector with an otlp receiver and a prometheus exporter.

  • Docker Compose: add the OTEL_* variables to the nocturne-api and nocturne-web services.
  • Kubernetes (Helm): set observability.otlp.enabled: true and observability.otlp.endpoint (lights up both containers). See the Helm chart README.

Full setup guide: Observability docs.

Other stuff

License

Nocturne is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). Commercial licensing is available for organizations that need to use Nocturne without AGPL obligations — contact the maintainers for details.

Disclaimer

Nocturne is a community project and is not affiliated with or endorsed by the Nightscout Project, Abbott, Dexcom, Medtronic, Glooko, or MyFitnessPal.

Important: This software is provided as-is for personal use. Always verify glucose readings with approved medical devices. Never make treatment decisions based solely on data from this application.

Support us

Nocturne is a labor of love built by volunteers. If you find it useful, please consider supporting the project:

Acknowledgments

评论

暂无评论