diff --git a/apps/api/src/api-context.ts b/apps/api/src/api-context.ts index 2e6b1c5b..51ebec5e 100644 --- a/apps/api/src/api-context.ts +++ b/apps/api/src/api-context.ts @@ -12,6 +12,8 @@ export type Bindings = { EDGE_EVER_CONTAINER_IMAGE?: string; EDGE_EVER_DEPLOYMENT_TRIGGER?: string; EDGE_EVER_DEPLOYMENT_METHOD?: string; + /** Creation time of the active deployment version, supplied by a runtime adapter. */ + deploymentVersionCreatedAt?: string; EDGE_EVER_AUTH_PASSWORD?: string; EDGE_EVER_AUTH_PASSWORD_HASH?: string; EDGE_EVER_AUTH_PASSWORD_FALLBACK?: string; @@ -31,7 +33,9 @@ export type Bindings = { EDGE_EVER_ALLOW_UNAUTHENTICATED?: string; }; -export type WorkerBindings = Omit & CloudflareStorageBindings; +export type WorkerBindings = Omit & CloudflareStorageBindings & { + CF_VERSION_METADATA?: { timestamp?: string }; +}; export type AuthContext = { kind: "user" | "agent"; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 51b0dc94..ab3cc1c5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -46,7 +46,7 @@ import { } from "./backup-service"; import { sha256, sha256Bytes } from "./hash-utils"; import { INSTANCE_BUILD_ID } from "./instance-build"; -import { resolveInstanceDeploymentMetadata } from "./instance-deployment"; +import { resolveDeploymentVersionCreatedAt, resolveInstanceDeploymentMetadata } from "./instance-deployment"; import type { DatabaseAdapter, PreparedStatementAdapter, @@ -233,6 +233,7 @@ app.get("/api/health", async (c) => { authMode, build: INSTANCE_BUILD_ID.slice(0, 12), deployment: resolveInstanceDeploymentMetadata(c.env), + deploymentVersionCreatedAt: resolveDeploymentVersionCreatedAt(c.env), migration: await getAppliedMigration(c.env), storage: { database: c.env.storage.diagnostics.database, @@ -441,6 +442,7 @@ const worker = { return fetchEdgeEverApp(request, { ...env, storage: createCloudflareStorageAdapter(env), + deploymentVersionCreatedAt: env.CF_VERSION_METADATA?.timestamp, // workerd's default Internet egress checks resolved addresses against its public-only network policy. publicNetworkFetch: (url, init) => fetch(url, init), }, ctx); diff --git a/apps/api/src/instance-deployment.test.mjs b/apps/api/src/instance-deployment.test.mjs new file mode 100644 index 00000000..6fb46ee2 --- /dev/null +++ b/apps/api/src/instance-deployment.test.mjs @@ -0,0 +1,9 @@ +import { expect, test } from "bun:test"; +import { resolveDeploymentVersionCreatedAt } from "./instance-deployment"; + +test("normalizes a runtime-supplied deployment version timestamp and rejects missing or invalid values", () => { + expect(resolveDeploymentVersionCreatedAt({ deploymentVersionCreatedAt: "2026-10-02T08:00:00+08:00" })) + .toBe("2026-10-02T00:00:00.000Z"); + expect(resolveDeploymentVersionCreatedAt({})).toBeNull(); + expect(resolveDeploymentVersionCreatedAt({ deploymentVersionCreatedAt: "unknown" })).toBeNull(); +}); diff --git a/apps/api/src/instance-deployment.ts b/apps/api/src/instance-deployment.ts index 810186ec..e77e92d4 100644 --- a/apps/api/src/instance-deployment.ts +++ b/apps/api/src/instance-deployment.ts @@ -21,3 +21,11 @@ export const resolveInstanceDeploymentMetadata = ( trigger: resolveDeploymentTrigger(environment.EDGE_EVER_DEPLOYMENT_TRIGGER?.trim() || bundledTrigger), method: resolveDeploymentMethod(environment.EDGE_EVER_DEPLOYMENT_METHOD?.trim() || bundledMethod), }); + +export const resolveDeploymentVersionCreatedAt = ( + environment: Pick, +): string | null => { + const timestamp = environment.deploymentVersionCreatedAt; + if (!timestamp || !Number.isFinite(Date.parse(timestamp))) return null; + return new Date(timestamp).toISOString(); +}; diff --git a/apps/ios/EdgeEver/Data/Models/Models.swift b/apps/ios/EdgeEver/Data/Models/Models.swift index 18034883..1ef5c39b 100644 --- a/apps/ios/EdgeEver/Data/Models/Models.swift +++ b/apps/ios/EdgeEver/Data/Models/Models.swift @@ -41,6 +41,7 @@ struct InstanceHealth: Codable, Equatable, Sendable { var containerImageSource: String? var authMode: String? var build: String? + var deploymentVersionCreatedAt: String? var migration: String? var storage: InstanceStorageDiagnostics? var objectStorageProvider: String? diff --git a/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift b/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift index a0db7c04..0936ab93 100644 --- a/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift @@ -661,6 +661,10 @@ struct SettingsView: View { value: instanceVersion.map { "v\($0.replacingOccurrences(of: "^v", with: "", options: .regularExpression))" } ?? unknownSystemInfoValue ), SystemInfoItem(label: env.preferences.t("实例构建", en: "Instance build"), value: instanceHealth?.build ?? unknownSystemInfoValue), + SystemInfoItem( + label: env.preferences.t("实例部署时间", en: "Instance deployment time", ja: "インスタンスのデプロイ時刻"), + value: deploymentVersionTimeLabel(instanceHealth?.deploymentVersionCreatedAt) + ), SystemInfoItem(label: env.preferences.t("数据库版本", en: "Database version"), value: instanceHealth?.migration ?? unknownSystemInfoValue), SystemInfoItem(label: env.preferences.t("数据库后端", en: "Database backend"), value: databaseBackendLabel(instanceHealth?.storage?.database)), SystemInfoItem(label: env.preferences.t("新上传对象存储", en: "New upload object storage"), value: objectStorageLabel(instanceHealth)), @@ -708,6 +712,18 @@ struct SettingsView: View { env.preferences.t("未知", en: "Unknown") } + private func deploymentVersionTimeLabel(_ timestamp: String?) -> String { + guard let timestamp else { return unknownSystemInfoValue } + let parser = ISO8601DateFormatter() + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let parsed = parser.date(from: timestamp) ?? ISO8601DateFormatter().date(from: timestamp) + guard let parsed else { return unknownSystemInfoValue } + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: parsed) + } + private func databaseBackendLabel(_ backend: String?) -> String { switch backend { case "d1": return "D1" diff --git a/apps/mobile/src/screens/WorkspaceSettingsView.tsx b/apps/mobile/src/screens/WorkspaceSettingsView.tsx index 8bb1ff11..e31a96e0 100644 --- a/apps/mobile/src/screens/WorkspaceSettingsView.tsx +++ b/apps/mobile/src/screens/WorkspaceSettingsView.tsx @@ -547,6 +547,7 @@ const getMobileSystemInfoText = (localePreference: MobileLocaleMode) => followSystem: "Follow system", installMode: "Mode", instanceBuild: "Instance build", + deploymentVersionTime: "Instance deployment time", instanceConnection: "Instance connection", instanceUrl: "Instance URL", instanceVersion: "Instance version", @@ -596,6 +597,7 @@ const getMobileSystemInfoText = (localePreference: MobileLocaleMode) => followSystem: "跟随系统", installMode: "安装形态", instanceBuild: "实例构建", + deploymentVersionTime: "实例部署时间", instanceConnection: "实例连接", instanceUrl: "实例地址", instanceVersion: "实例版本", @@ -758,6 +760,12 @@ const getMobileSystemInfoGroups = ( { label: copy.databaseBackend, value: getMobileDatabaseBackend(instance?.health.storage?.database, copy.unknown) }, { label: copy.deploymentPlatform, value: getMobileDeploymentPlatform(instance?.health.runtime, english) }, { label: copy.instanceBuild, mono: true, value: instance?.health.build || copy.unknown, wide: true }, + { + label: copy.deploymentVersionTime, + value: instance?.health.deploymentVersionCreatedAt && Number.isFinite(Date.parse(instance.health.deploymentVersionCreatedAt)) + ? new Intl.DateTimeFormat(english ? "en-US" : "zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(new Date(instance.health.deploymentVersionCreatedAt)) + : copy.unknown, + }, { fullWidth: true, label: copy.newUploadObjectStorage, value: getMobileObjectStorage(instance?.health, english, copy.unknown) }, ...(instance?.health.objectStorageProvider === "s3" ? [{ fullWidth: true, label: copy.existingAttachments, value: copy.existingAttachmentsOriginalStorage }] diff --git a/apps/web/src/components/settings/SystemInfoPanel.tsx b/apps/web/src/components/settings/SystemInfoPanel.tsx index 5802c09d..36a7d1e7 100644 --- a/apps/web/src/components/settings/SystemInfoPanel.tsx +++ b/apps/web/src/components/settings/SystemInfoPanel.tsx @@ -32,7 +32,7 @@ export type SystemInfoItem = { localOnly?: boolean; }; -type InstanceSystemDiagnostics = Pick & { +type InstanceSystemDiagnostics = Pick & { runtime?: string | null; }; @@ -162,6 +162,12 @@ const getWebSystemInfoGroups = ( mono: true, }, { label: t("systemInfo.instanceBuild"), value: diagnostics.instance?.build ?? t("systemInfo.unknown"), mono: true }, + { + label: t("systemInfo.deploymentVersionTime"), + value: diagnostics.instance?.deploymentVersionCreatedAt && Number.isFinite(Date.parse(diagnostics.instance.deploymentVersionCreatedAt)) + ? new Intl.DateTimeFormat(language, { dateStyle: "medium", timeStyle: "short" }).format(new Date(diagnostics.instance.deploymentVersionCreatedAt)) + : t("systemInfo.unknown"), + }, { label: t("systemInfo.databaseMigration"), value: diagnostics.instance?.migration ?? t("systemInfo.unknown"), mono: true }, { label: t("systemInfo.databaseBackend"), diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 44711744..0168affe 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -134,6 +134,7 @@ export type InstanceHealth = { authMode?: string | null; build?: string | null; deployment?: DeploymentMetadata | null; + deploymentVersionCreatedAt?: string | null; migration?: string | null; storage?: { database?: "d1" | "sqlite" | string | null; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 7b38a839..e58c6d4f 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -1481,6 +1481,7 @@ export const enUS = { instanceUrl: "Instance URL", instanceVersion: "Instance version", instanceBuild: "Instance build", + deploymentVersionTime: "Instance deployment time", databaseMigration: "Database version", databaseBackend: "Database backend", databaseBackends: { diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 6dba41f7..c468c513 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -1481,6 +1481,7 @@ export const ja = { instanceUrl: "インスタンス URL", instanceVersion: "インスタンスバージョン", instanceBuild: "インスタンスビルド", + deploymentVersionTime: "インスタンスのデプロイ時刻", databaseMigration: "データベースバージョン", databaseBackend: "データベースバックエンド", databaseBackends: { diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index b26dda25..ec11437f 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -1481,6 +1481,7 @@ export const zhCN = { instanceUrl: "实例地址", instanceVersion: "实例版本", instanceBuild: "实例构建", + deploymentVersionTime: "实例部署时间", databaseMigration: "数据库版本", databaseBackend: "数据库后端", databaseBackends: { diff --git a/scripts/wrangler-runner.mjs b/scripts/wrangler-runner.mjs index 16df23fc..99797bfd 100644 --- a/scripts/wrangler-runner.mjs +++ b/scripts/wrangler-runner.mjs @@ -13,7 +13,7 @@ const REPOSITORY_WRANGLER_VALUES = { bucket_name: "edgeever-resources", preview_bucket_name: "edgeever-resources-preview", }; -const REPOSITORY_WRANGLER_SHA256 = "d49ed530169a8597d2c17d5c6b99e03e371c8710124bbce4e526cf2b3a720f7a"; +const REPOSITORY_WRANGLER_SHA256 = "7817c91538634a2aa2eebaae990ea9d11870d92486f522939f2e02c8eee30c09"; const configValue = (config, key) => { const match = config.match(new RegExp(`^${key}\\s*=\\s*(?:"([^"]*)"|(true|false))`, "m")); diff --git a/tests/deployment-entrypoints.test.ts b/tests/deployment-entrypoints.test.ts index 734b81a9..3297f3b1 100644 --- a/tests/deployment-entrypoints.test.ts +++ b/tests/deployment-entrypoints.test.ts @@ -227,6 +227,7 @@ describe("Cloudflare deployment entrypoints", () => { test("rejects instance-specific values in the repository Wrangler config", () => { const repositoryConfig = readRepositoryFile("wrangler.toml"); + expect(repositoryConfig).toContain('[version_metadata]\nbinding = "CF_VERSION_METADATA"'); const instanceConfigs = [ repositoryConfig.replace('name = "edgeever"', 'name = "my-notes"'), repositoryConfig.replace("workers_dev = true", "workers_dev = false"), diff --git a/wrangler.toml b/wrangler.toml index bb676e5f..0f9fd306 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -34,3 +34,6 @@ preview_bucket_name = "edgeever-resources-preview" [observability] enabled = true + +[version_metadata] +binding = "CF_VERSION_METADATA"