diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 77156d19..227b3e35 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -76,7 +76,7 @@ jobs: fetch-depth: 0 - name: Build local image - run: docker build --tag edgeever:ci . + run: docker build --build-arg EDGE_EVER_BUILD_ID="${GITHUB_SHA}" --tag edgeever:ci . - name: Start container run: | @@ -183,6 +183,8 @@ jobs: uses: docker/build-push-action@v6 with: context: . + build-args: | + EDGE_EVER_BUILD_ID=${{ github.sha }} platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.image.outputs.tags }} diff --git a/Dockerfile b/Dockerfile index 931f212c..24f59583 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,9 @@ RUN bun run build:web FROM oven/bun:1.3.14-alpine AS runtime WORKDIR /app +ARG EDGE_EVER_BUILD_ID=unknown ENV NODE_ENV=production \ + EDGE_EVER_BUILD_ID=${EDGE_EVER_BUILD_ID} \ EDGE_EVER_DATA_DIR=/data \ EDGE_EVER_WEB_DIR=/app/apps/web/dist \ PORT=8787 diff --git a/apps/api/src/cloudflare-storage-adapter.ts b/apps/api/src/cloudflare-storage-adapter.ts index e12c21bb..9739fc39 100644 --- a/apps/api/src/cloudflare-storage-adapter.ts +++ b/apps/api/src/cloudflare-storage-adapter.ts @@ -12,4 +12,9 @@ export const createCloudflareStorageAdapter = ( ): StorageAdapter => ({ db: bindings.DB, resources: bindings.RESOURCES, + diagnostics: { + database: "d1", + resources: "r2", + migrationTable: "d1_migrations", + }, }); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 17cc64b4..536d7f58 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -59,6 +59,7 @@ import { DEMO_SEED_REVISIONS, } from "./demo-seed-data"; import { createCloudflareStorageAdapter } from "./cloudflare-storage-adapter"; +import { INSTANCE_BUILD_ID } from "./instance-build"; import type { DatabaseAdapter, PreparedStatementAdapter, @@ -151,6 +152,7 @@ import { registerNotebookRoutes } from "./notebook-routes"; import { registerMemoShareRoutes, registerPublicShareRoutes } from "./share-routes"; import { deleteStoredObjects, + getActiveObjectStorageConfig, resolveObjectStorage, } from "./object-storage"; import { @@ -273,6 +275,28 @@ app.use( app.get("/api/release", (c) => c.json(releaseSummary)); +const getAppliedMigration = async (env: Bindings) => { + try { + const row = await env.storage.db + .prepare(`SELECT name FROM ${env.storage.diagnostics.migrationTable} ORDER BY name DESC LIMIT 1`) + .first<{ name?: string }>(); + return row?.name?.match(/^(\d+)/)?.[1] ?? row?.name ?? "unknown"; + } catch { + return "unknown"; + } +}; + +const getActiveObjectStorageProvider = async (env: Bindings) => { + try { + const active = await getActiveObjectStorageConfig(env.storage.db); + return active?.provider === "builtin" || active?.provider === "s3" + ? active.provider + : "unknown"; + } catch { + return "unknown"; + } +}; + app.get("/api/health", async (c) => { const authMode = await getInstanceAuthMode(c.env, true); @@ -297,6 +321,13 @@ app.get("/api/health", async (c) => { name: "edgeever", runtime: c.env.EDGE_EVER_RUNTIME ?? "cloudflare-workers", authMode, + build: INSTANCE_BUILD_ID.slice(0, 12), + migration: await getAppliedMigration(c.env), + storage: { + database: c.env.storage.diagnostics.database, + resources: c.env.storage.diagnostics.resources, + }, + objectStorageProvider: await getActiveObjectStorageProvider(c.env), }); }); diff --git a/apps/api/src/instance-build.ts b/apps/api/src/instance-build.ts new file mode 100644 index 00000000..fd8e3f25 --- /dev/null +++ b/apps/api/src/instance-build.ts @@ -0,0 +1,10 @@ +declare const __EDGEEVER_INSTANCE_BUILD_ID__: string; + +const bundledBuildId = typeof __EDGEEVER_INSTANCE_BUILD_ID__ !== "undefined" + ? __EDGEEVER_INSTANCE_BUILD_ID__ + : ""; +const runtimeBuildId = typeof process !== "undefined" + ? process.env.EDGE_EVER_BUILD_ID ?? process.env.GITHUB_SHA ?? "" + : ""; + +export const INSTANCE_BUILD_ID = (bundledBuildId || runtimeBuildId).trim() || "unknown"; diff --git a/apps/api/src/s3-compatible-storage-adapter.ts b/apps/api/src/s3-compatible-storage-adapter.ts index 4bac236a..a195315c 100644 --- a/apps/api/src/s3-compatible-storage-adapter.ts +++ b/apps/api/src/s3-compatible-storage-adapter.ts @@ -135,7 +135,14 @@ export const createS3CompatibleStorageAdapter = ( sqlite: SqliteDatabaseLike, config: S3CompatibleStorageConfig, client?: S3Client, -): StorageAdapter => ({ - db: createSelfHostedStorageAdapter(sqlite, ".edgeever-unused-resources").db, - resources: createS3BlobStore(config, client), -}); +): StorageAdapter => { + const selfHosted = createSelfHostedStorageAdapter(sqlite, ".edgeever-unused-resources"); + return { + db: selfHosted.db, + resources: createS3BlobStore(config, client), + diagnostics: { + ...selfHosted.diagnostics, + resources: "s3", + }, + }; +}; diff --git a/apps/api/src/self-hosted-storage-adapter.ts b/apps/api/src/self-hosted-storage-adapter.ts index cdadef23..0c3ddf7d 100644 --- a/apps/api/src/self-hosted-storage-adapter.ts +++ b/apps/api/src/self-hosted-storage-adapter.ts @@ -166,4 +166,9 @@ export const createSelfHostedStorageAdapter = ( ): StorageAdapter => ({ db: new SqliteDatabaseAdapter(sqlite) as unknown as DatabaseAdapter, resources: createLocalBlobStore(resourcesDirectory), + diagnostics: { + database: "sqlite", + resources: "filesystem", + migrationTable: "_edgeever_migrations", + }, }); diff --git a/apps/api/src/storage-contract.ts b/apps/api/src/storage-contract.ts index 8c29f366..8eb169d6 100644 --- a/apps/api/src/storage-contract.ts +++ b/apps/api/src/storage-contract.ts @@ -44,6 +44,11 @@ export type BlobStoreAdapter = { export type StorageAdapter = { db: DatabaseAdapter; resources: BlobStoreAdapter; + diagnostics: { + database: "d1" | "sqlite"; + resources: "r2" | "filesystem" | "s3"; + migrationTable: "d1_migrations" | "_edgeever_migrations"; + }; }; /** Database engines that a self-hosted deployment may select. */ diff --git a/apps/desktop/src/main/desktop-update-flow.test.mjs b/apps/desktop/src/main/desktop-update-flow.test.mjs index fea5dd6e..3146c0f3 100644 --- a/apps/desktop/src/main/desktop-update-flow.test.mjs +++ b/apps/desktop/src/main/desktop-update-flow.test.mjs @@ -24,6 +24,14 @@ describe("desktop update flow", () => { expect(systemInfoSource).toContain('t("systemInfo.desktopUpdateCurrent")'); }); + test("shares precise desktop runtime diagnostics with the system information panel", () => { + expect(mainSource).toContain('ipcMain.handle("desktop:system-info", () => desktopRuntimeSystemInfo())'); + expect(preloadSource).toContain('systemInfo: () => ipcRenderer.invoke("desktop:system-info")'); + expect(systemInfoSource).toContain("getClientRuntimeDiagnostics"); + expect(systemInfoSource).toContain('t("systemInfo.runtimeEngine")'); + expect(systemInfoSource).toContain('t("systemInfo.connectionSection")'); + }); + test("rechecks for updates while a packaged app remains open", () => { expect(mainSource).toContain('checkForDesktopUpdate("startup", { force: true })'); expect(mainSource).toContain('checkForDesktopUpdate("interval", { force: true })'); diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index b31ea840..5e4eec56 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -145,6 +145,16 @@ const writeDiagnostic = async (event, details = {}) => { } }; +const desktopRuntimeSystemInfo = () => ({ + appVersion: app.getVersion(), + platform: process.platform, + architecture: process.arch, + osVersion: process.getSystemVersion?.() || "unknown", + osRelease: operatingSystemRelease(), + electron: process.versions.electron || "unknown", + chrome: process.versions.chrome || "unknown", +}); + const desktopDiagnosticSystemInfo = async () => { let gpu = "unknown"; let gpuFeatures = "unknown"; @@ -168,13 +178,7 @@ const desktopDiagnosticSystemInfo = async () => { // Some renderer failures can also make GPU feature inspection unavailable. } return { - appVersion: app.getVersion(), - platform: process.platform, - architecture: process.arch, - osVersion: process.getSystemVersion?.() || "unknown", - osRelease: operatingSystemRelease(), - electron: process.versions.electron || "unknown", - chrome: process.versions.chrome || "unknown", + ...desktopRuntimeSystemInfo(), gpu, gpuFeatures, }; @@ -911,6 +915,7 @@ app.whenReady().then(async () => { return result; }); ipcMain.handle("desktop:sidecar-status", () => ({ available: Boolean(sidecar), path: sidecarPath, scope: sidecarScopeKey })); + ipcMain.handle("desktop:system-info", () => desktopRuntimeSystemInfo()); ipcMain.handle("desktop:set-account-scope", async (_event, accountId) => { const normalizedAccountId = typeof accountId === "string" && accountId.trim() ? accountId.trim() : null; const nextScopeKey = accountScopeKey(configuredApiBaseUrl, normalizedAccountId); diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index 9a60ec9e..4f491afb 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -5,6 +5,7 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({ canClearLocalData: ipcRenderer.sendSync("desktop:local-data-reset-available-sync"), recoveredAfterAbnormalExit: ipcRenderer.sendSync("desktop:recovered-after-abnormal-exit-sync"), sidecarStatus: () => ipcRenderer.invoke("desktop:sidecar-status"), + systemInfo: () => ipcRenderer.invoke("desktop:system-info"), setAccountScope: (accountId) => ipcRenderer.invoke("desktop:set-account-scope", accountId), apiBaseUrl: ipcRenderer.sendSync("desktop:api-base-url-sync"), setApiBaseUrl: (value) => ipcRenderer.invoke("desktop:set-api-base-url", value), diff --git a/apps/web/src/components/settings/SystemInfoPanel.tsx b/apps/web/src/components/settings/SystemInfoPanel.tsx index 8bd2b17a..8f8315a2 100644 --- a/apps/web/src/components/settings/SystemInfoPanel.tsx +++ b/apps/web/src/components/settings/SystemInfoPanel.tsx @@ -1,18 +1,35 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { CircleCheck, Copy, ExternalLink, LoaderCircle, RefreshCw, RotateCcw } from "lucide-react"; -import { Trans, useTranslation } from "react-i18next"; +import { Activity, CircleCheck, Cloud, Copy, ExternalLink, LoaderCircle, MonitorSmartphone, RefreshCw, RotateCcw } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { useDeployedUpdateNotice } from "@/hooks/useDeployedUpdateNotice"; import { detectWebClientKind } from "@/lib/client-environment"; -import { api, getConfiguredDesktopApiBaseUrl } from "@/lib/api"; +import { api, getConfiguredDesktopApiBaseUrl, type InstanceHealth } from "@/lib/api"; import { resolveDeploymentPlatform } from "@/lib/instance-runtime"; +import { + getClientRuntimeDiagnostics, + getClientSyncDiagnostics, + type ClientRuntimeDiagnostics, + type ClientSyncDiagnostics, +} from "@/lib/system-diagnostics"; import { cn } from "@/lib/utils"; -import { getReleaseTagForVersion, resolveLocalizedReleaseChanges } from "@/lib/version-check"; +import { getReleaseTagForVersion } from "@/lib/version-check"; import { copyTextToClipboard } from "./settings-utils"; export type SystemInfoItem = { label: string; value: string }; +type InstanceSystemDiagnostics = Pick & { + runtime?: string | null; +}; + +type SystemInfoGroup = { + id: "cloud" | "client" | "connection"; + title: string; + description: string; + items: SystemInfoItem[]; +}; + type NavigatorWithStandalone = Navigator & { standalone?: boolean }; const detectBrowser = (userAgent: string) => { @@ -39,11 +56,15 @@ const getDeploymentDescription = (t: (key: string) => string) => { return `${trigger} · ${method}`; }; -export const getWebSystemInfoItems = ( +const getWebSystemInfoGroups = ( t: (key: string) => string, language: string, - instanceRuntime?: string | null, -): SystemInfoItem[] => { + diagnostics: { + clientRuntime?: ClientRuntimeDiagnostics | null; + instance?: Partial | null; + instanceVersion?: string | null; + } = {}, +): SystemInfoGroup[] => { const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || t("systemInfo.unknown"); const userAgent = navigator.userAgent; const clientKind = detectWebClientKind({ @@ -56,31 +77,97 @@ export const getWebSystemInfoItems = ( return [ { - label: t("systemInfo.clientVersion"), - value: `v${__EDGEEVER_APP_VERSION__}`, + id: "cloud", + title: t("systemInfo.cloudSection"), + description: t("systemInfo.cloudSectionDescription"), + items: [ + { + label: t("systemInfo.instanceVersion"), + value: diagnostics.instanceVersion + ? `v${diagnostics.instanceVersion.replace(/^v/, "")}` + : clientKind === "desktopApp" + ? t("systemInfo.unknown") + : `v${__EDGEEVER_APP_VERSION__}`, + }, + { label: t("systemInfo.instanceBuild"), value: diagnostics.instance?.build ?? t("systemInfo.unknown") }, + { label: t("systemInfo.databaseMigration"), value: diagnostics.instance?.migration ?? t("systemInfo.unknown") }, + { + label: t("systemInfo.databaseBackend"), + value: diagnostics.instance?.storage?.database + ? t(`systemInfo.databaseBackends.${diagnostics.instance.storage.database}`) + : t("systemInfo.unknown"), + }, + { + label: t("systemInfo.newUploadObjectStorage"), + value: diagnostics.instance?.objectStorageProvider === "s3" + ? t("systemInfo.objectStorageBackends.external_s3") + : diagnostics.instance?.objectStorageProvider === "builtin" && diagnostics.instance.storage?.resources + ? t(`systemInfo.objectStorageBackends.builtin_${diagnostics.instance.storage.resources}`) + : t("systemInfo.unknown"), + }, + ...(diagnostics.instance?.objectStorageProvider === "s3" + ? [{ + label: t("systemInfo.existingAttachments"), + value: t("systemInfo.existingAttachmentsOriginalStorage"), + }] + : []), + { + label: t("systemInfo.deploymentPlatform"), + value: t(`systemInfo.deploymentPlatforms.${resolveDeploymentPlatform(diagnostics.instance?.runtime)}`), + }, + { label: t("systemInfo.deployment"), value: getDeploymentDescription(t) }, + ], }, { - label: t("systemInfo.releaseTime"), - value: __EDGEEVER_RELEASED_AT__ - ? new Intl.DateTimeFormat(language, { dateStyle: "medium", timeStyle: "short" }).format(new Date(__EDGEEVER_RELEASED_AT__)) - : t("systemInfo.unknown"), + id: "client", + title: t("systemInfo.clientSection"), + description: t("systemInfo.clientSectionDescription"), + items: [ + { + label: t("systemInfo.clientVersion"), + value: `v${diagnostics.clientRuntime?.appVersion ?? __EDGEEVER_APP_VERSION__}`, + }, + { + label: t("systemInfo.releaseTime"), + value: __EDGEEVER_RELEASED_AT__ + ? new Intl.DateTimeFormat(language, { dateStyle: "medium", timeStyle: "short" }).format(new Date(__EDGEEVER_RELEASED_AT__)) + : t("systemInfo.unknown"), + }, + { label: t("systemInfo.build"), value: __EDGEEVER_BUILD_LABEL__ }, + { label: t("systemInfo.client"), value: t(`systemInfo.clients.${clientKind}`) }, + { + label: t("systemInfo.os"), + value: diagnostics.clientRuntime?.operatingSystem + ?? detectOperatingSystem(userAgent, navigator.platform) + ?? t("systemInfo.unknown"), + }, + { + label: t("systemInfo.architecture"), + value: diagnostics.clientRuntime?.architecture ?? t("systemInfo.unknown"), + }, + { + label: t("systemInfo.runtimeEngine"), + value: diagnostics.clientRuntime?.engine + ?? (clientKind === "desktopApp" ? t("systemInfo.unknown") : detectBrowser(userAgent) ?? t("systemInfo.unknown")), + }, + { label: t("systemInfo.language"), value: navigator.language || language }, + { label: t("systemInfo.timeZone"), value: timeZone }, + ], }, - { label: t("systemInfo.build"), value: __EDGEEVER_BUILD_LABEL__ }, - { label: t("systemInfo.client"), value: t(`systemInfo.clients.${clientKind}`) }, - { - label: t("systemInfo.deploymentPlatform"), - value: t(`systemInfo.deploymentPlatforms.${resolveDeploymentPlatform(instanceRuntime)}`), - }, - { label: t("systemInfo.deployment"), value: getDeploymentDescription(t) }, - ...(clientKind === "desktopApp" - ? [] - : [{ label: t("systemInfo.browser"), value: detectBrowser(userAgent) ?? t("systemInfo.unknown") }]), - { label: t("systemInfo.os"), value: detectOperatingSystem(userAgent, navigator.platform) ?? t("systemInfo.unknown") }, - { label: t("systemInfo.language"), value: navigator.language || language }, - { label: t("systemInfo.timeZone"), value: timeZone }, ]; }; +export const getWebSystemInfoItems = ( + t: (key: string) => string, + language: string, + instanceRuntime?: string | null, + instanceVersion?: string | null, +): SystemInfoItem[] => getWebSystemInfoGroups(t, language, { + instance: { runtime: instanceRuntime }, + instanceVersion, +}) + .flatMap((group) => group.items); + export const SystemInfoPanel = ({ active = true }: { active?: boolean }) => { const { t, i18n } = useTranslation(); const [copied, setCopied] = useState(false); @@ -92,9 +179,27 @@ export const SystemInfoPanel = ({ active = true }: { active?: boolean }) => { const instanceUrl = desktopAvailable ? getConfiguredDesktopApiBaseUrl() : window.location.origin; const healthQuery = useQuery({ queryKey: ["instance-health", instanceUrl], - queryFn: () => api.getInstanceHealth(), + queryFn: async () => { + const startedAt = performance.now(); + const health = await api.getInstanceHealth(); + return { health, latencyMs: Math.max(0, Math.round(performance.now() - startedAt)) }; + }, enabled: active && Boolean(instanceUrl), - staleTime: 5 * 60 * 1000, + staleTime: 60 * 1000, + retry: 1, + }); + const clientRuntimeQuery = useQuery({ + queryKey: ["system-info-client-runtime"], + queryFn: getClientRuntimeDiagnostics, + enabled: active, + staleTime: Number.POSITIVE_INFINITY, + retry: 1, + }); + const syncDiagnosticsQuery = useQuery({ + queryKey: ["system-info-sync-diagnostics", instanceUrl], + queryFn: getClientSyncDiagnostics, + enabled: active && Boolean(instanceUrl), + refetchInterval: active ? 10_000 : false, retry: 1, }); const desktopUpdateStatusQuery = useQuery({ @@ -114,21 +219,72 @@ export const SystemInfoPanel = ({ active = true }: { active?: boolean }) => { const desktopUpdateInstallMutation = useMutation({ mutationFn: () => desktopBridge!.installUpdate(), }); - const infoItems = useMemo( - () => getWebSystemInfoItems(t, i18n.language, healthQuery.data?.runtime), - [healthQuery.data?.runtime, i18n.language, t], - ); + const infoGroups = useMemo(() => { + const groups = getWebSystemInfoGroups(t, i18n.language, { + clientRuntime: clientRuntimeQuery.data, + instance: healthQuery.data?.health, + instanceVersion: release?.version, + }); + const lastSyncedAt = syncDiagnosticsQuery.data?.lastSyncedAt; + const lastSyncedDate = lastSyncedAt ? new Date(lastSyncedAt) : null; + const formattedLastSync = !syncDiagnosticsQuery.data + ? t("systemInfo.unknown") + : lastSyncedDate && Number.isFinite(lastSyncedDate.getTime()) + ? new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" }).format(lastSyncedDate) + : t("systemInfo.neverSynced"); + groups.push({ + id: "connection", + title: t("systemInfo.connectionSection"), + description: t("systemInfo.connectionSectionDescription"), + items: [ + { + label: t("systemInfo.instanceConnection"), + value: healthQuery.isError + ? t("systemInfo.connectionFailed") + : healthQuery.isSuccess + ? t("systemInfo.connectionConnected") + : t("systemInfo.connectionChecking"), + }, + { + label: t("systemInfo.requestLatency"), + value: healthQuery.data ? `${healthQuery.data.latencyMs} ms` : t("systemInfo.unknown"), + }, + { label: t("systemInfo.lastSuccessfulSync"), value: formattedLastSync }, + { + label: t("systemInfo.pendingSync"), + value: syncDiagnosticsQuery.data + ? String(syncDiagnosticsQuery.data.pending + syncDiagnosticsQuery.data.syncing) + : t("systemInfo.unknown"), + }, + { + label: t("systemInfo.failedSync"), + value: syncDiagnosticsQuery.data + ? String(syncDiagnosticsQuery.data.error + syncDiagnosticsQuery.data.conflict) + : t("systemInfo.unknown"), + }, + ], + }); + return groups; + }, [ + clientRuntimeQuery.data, + healthQuery.data, + healthQuery.isError, + healthQuery.isSuccess, + i18n.language, + release?.version, + syncDiagnosticsQuery.data, + t, + ]); const releaseTag = release ? getReleaseTagForVersion(release.version) : null; - const releaseHighlights = resolveLocalizedReleaseChanges( - release?.changes ?? {}, - i18n.resolvedLanguage ?? i18n.language - ); const releaseUrl = releaseTag ? `https://github.com/tianma-if/edgeever/releases/tag/${encodeURIComponent(releaseTag)}` : "https://github.com/tianma-if/edgeever/releases/latest"; const handleCopy = async () => { - if (!(await copyTextToClipboard(infoItems.map((item) => `${item.label}: ${item.value}`).join("\n")))) return; + const text = infoGroups + .map((group) => [group.title, ...group.items.map((item) => `${item.label}: ${item.value}`)].join("\n")) + .join("\n\n"); + if (!(await copyTextToClipboard(text))) return; setCopied(true); window.setTimeout(() => setCopied(false), 1600); }; @@ -155,94 +311,91 @@ export const SystemInfoPanel = ({ active = true }: { active?: boolean }) => { }; return ( -
+
- {desktopAvailable ? ( - - ) : null}
- {desktopAvailable && desktopUpdateStatus ? ( -

- {desktopUpdateStatus} -

- ) : null} - {active && release ? ( -
- -
-
{t("systemInfo.deployedUpdateTitle", { version: releaseTag?.replace(/^v/, "") ?? release.version })}
- {releaseHighlights.length > 0 ? ( -
-
{t("systemInfo.releaseHighlightsLabel")}
-
    - {releaseHighlights.map((highlight) =>
  • {highlight}
  • )} -
+ {infoGroups.map((group) => { + const isCloud = group.id === "cloud"; + const isClient = group.id === "client"; + const headingId = `system-info-${group.id}-heading`; + return ( +
+
+
+ {isCloud + ? + : isClient + ? + : } +
+

{group.title}

+

{group.description}

+
- ) : ( -
{t("systemInfo.releaseNotesUnavailable")}
- )} -

- - ), - }} - /> -

-
- - {t("systemInfo.viewReleaseNotes")} - -
- ) : null} -
- {infoItems.map((item) => ( -
-
{item.label}
-
{item.value}
-
- ))} -
+ {isClient && desktopAvailable ? ( + + ) : null} +
+ {isCloud && active && release ? ( +
+ +
+ {t("systemInfo.deployedUpdateTitle", { version: releaseTag?.replace(/^v/, "") ?? release.version })} +
+ + {t("systemInfo.viewReleaseNotes")} + +
+ ) : null} + {isClient && desktopAvailable && desktopUpdateStatus ? ( +

+ {desktopUpdateStatus} +

+ ) : null} +
+ {group.items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ + ); + })}
); }; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 737ee1b9..18b06a9a 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -55,6 +55,13 @@ export type InstanceHealth = { name: string; runtime: string; authMode: string; + build: string; + migration: string; + storage: { + database: "d1" | "sqlite"; + resources: "r2" | "filesystem" | "s3"; + }; + objectStorageProvider: "builtin" | "s3" | "unknown"; }; type ListMemosResponse = { diff --git a/apps/web/src/lib/system-diagnostics.ts b/apps/web/src/lib/system-diagnostics.ts new file mode 100644 index 00000000..94f55371 --- /dev/null +++ b/apps/web/src/lib/system-diagnostics.ts @@ -0,0 +1,149 @@ +import { summarizeSyncQueue } from "@edgeever/shared"; +import { api, getConfiguredDesktopApiBaseUrl } from "./api"; +import { localDb } from "./local-db"; +import { createLocalDataScope } from "./local-mirror"; + +export type ClientRuntimeDiagnostics = { + appVersion: string | null; + architecture: string | null; + engine: string | null; + operatingSystem: string | null; +}; + +export type ClientSyncDiagnostics = { + conflict: number; + error: number; + lastSyncedAt: string | null; + pending: number; + syncing: number; +}; + +type UserAgentData = { + architecture?: string; + platform?: string; + platformVersion?: string; + getHighEntropyValues?: (hints: string[]) => Promise<{ + architecture?: string; + platformVersion?: string; + }>; +}; + +type NavigatorWithUserAgentData = Navigator & { userAgentData?: UserAgentData }; + +const browserEngine = (userAgent: string) => { + const match = userAgent.match(/Edg(?:A|iOS)?\/([\d.]+)/) + ?? userAgent.match(/(?:Chrome|CriOS)\/([\d.]+)/) + ?? userAgent.match(/Firefox\/([\d.]+)/) + ?? userAgent.match(/Version\/([\d.]+).*Safari\//); + const name = /Edg(?:A|iOS)?\//.test(userAgent) + ? "Microsoft Edge" + : /(?:Chrome|CriOS)\//.test(userAgent) + ? "Chrome" + : /Firefox\//.test(userAgent) + ? "Firefox" + : /Safari\//.test(userAgent) + ? "Safari" + : null; + return name ? `${name}${match?.[1] ? ` ${match[1]}` : ""}` : null; +}; + +const browserOperatingSystem = (userAgent: string, platform: string, platformVersion?: string) => { + const source = `${userAgent} ${platform}`; + if (/Windows/i.test(source)) return `Windows ${platformVersion || "10/11"}`; + const android = userAgent.match(/Android ([\d.]+)/i); + if (android) return `Android ${android[1]}`; + const ios = userAgent.match(/(?:CPU (?:iPhone )?OS|iPhone OS) ([\d_]+)/i); + if (ios) return `iOS ${ios[1].replaceAll("_", ".")}`; + const mac = userAgent.match(/Mac OS X ([\d_]+)/i); + if (/Mac/i.test(source)) return `macOS ${platformVersion || mac?.[1]?.replaceAll("_", ".") || ""}`.trim(); + if (/Linux/i.test(source)) return "Linux"; + return null; +}; + +const desktopOperatingSystem = (platform: string, version: string) => { + const name = platform === "darwin" + ? "macOS" + : platform === "win32" + ? "Windows" + : platform === "linux" + ? "Linux" + : platform; + return `${name} ${version === "unknown" ? "" : version}`.trim(); +}; + +export const getClientRuntimeDiagnostics = async (): Promise => { + const bridge = window.edgeeverDesktop; + if (bridge?.isAvailable) { + const info = await bridge.systemInfo(); + return { + appVersion: info.appVersion, + architecture: info.architecture === "unknown" ? null : info.architecture, + engine: [ + info.electron === "unknown" ? null : `Electron ${info.electron}`, + info.chrome === "unknown" ? null : `Chromium ${info.chrome}`, + ].filter(Boolean).join(" · ") || null, + operatingSystem: desktopOperatingSystem(info.platform, info.osVersion), + }; + } + + const navigatorWithData = navigator as NavigatorWithUserAgentData; + const userAgentData = navigatorWithData.userAgentData; + let architecture = userAgentData?.architecture; + let platformVersion: string | undefined; + if (userAgentData?.getHighEntropyValues) { + try { + const highEntropy = await userAgentData.getHighEntropyValues(["architecture", "platformVersion"]); + architecture = highEntropy.architecture || architecture; + platformVersion = highEntropy.platformVersion; + } catch { + // Browsers may decline high-entropy hints; the safe fallback remains useful. + } + } + + return { + appVersion: null, + architecture: architecture || null, + engine: browserEngine(navigator.userAgent), + operatingSystem: browserOperatingSystem( + navigator.userAgent, + userAgentData?.platform || navigator.platform, + platformVersion, + ), + }; +}; + +export const getClientSyncDiagnostics = async (): Promise => { + const bridge = window.edgeeverDesktop; + if (bridge?.isAvailable) { + const status = await bridge.sidecarRequest<{ + conflict: number; + error: number; + lastSyncedAt: string | null; + pending: number; + syncing: number; + }>("sync.status", {}); + return { + conflict: Number(status.conflict) || 0, + error: Number(status.error) || 0, + lastSyncedAt: typeof status.lastSyncedAt === "string" ? status.lastSyncedAt : null, + pending: Number(status.pending) || 0, + syncing: Number(status.syncing) || 0, + }; + } + + const session = await api.getSession(); + const scope = createLocalDataScope(getConfiguredDesktopApiBaseUrl() || window.location.origin, session.user?.id); + const [allItems, syncIdentity] = await Promise.all([ + localDb.syncQueue.toArray(), + localDb.syncMeta.get([scope, "identity"]), + ]); + const items = allItems.filter((item) => !item.scope || item.scope === scope); + const summary = summarizeSyncQueue(items); + return { + conflict: summary.conflict, + error: summary.error, + lastSyncedAt: syncIdentity?.updatedAt ?? null, + pending: summary.pending, + syncing: summary.syncing, + }; +}; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index d1243f50..4a292ea1 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -31,6 +31,15 @@ interface EdgeEverDesktopBridge { recordRendererError(details: DesktopRendererErrorDetails): Promise<{ recorded: true }>; openRendererIssue(details: DesktopRendererErrorDetails): Promise<{ opened: true }>; sidecarStatus(): Promise<{ available: boolean; path: string; scope: string }>; + systemInfo(): Promise<{ + appVersion: string; + platform: string; + architecture: string; + osVersion: string; + osRelease: string; + electron: string; + chrome: string; + }>; setAccountScope(accountId: string | null): Promise<{ ready: true; scope: string }>; updateStatus(): Promise; checkUpdate(): Promise; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 7324d887..c7cf3281 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -627,8 +627,31 @@ export const enUS = { }, systemInfo: { title: "System info", - description: "View the current app version, build identifier, and runtime environment.", + description: "View version and runtime details for the cloud instance and current client.", + cloudSection: "Cloud instance", + cloudSectionDescription: "Version and deployment environment for the connected instance.", + clientSection: "Current client", + clientSectionDescription: "The EdgeEver app and runtime environment on this device.", + connectionSection: "Connection and sync", + connectionSectionDescription: "Connection status between this client and the cloud instance.", version: "Version", + instanceVersion: "Instance version", + instanceBuild: "Instance build", + databaseMigration: "Database version", + databaseBackend: "Database backend", + databaseBackends: { + d1: "D1", + sqlite: "SQLite", + }, + newUploadObjectStorage: "New upload object storage", + objectStorageBackends: { + builtin_r2: "Built-in R2", + builtin_filesystem: "Local filesystem", + builtin_s3: "Instance-provided S3-compatible storage", + external_s3: "Third-party S3-compatible OSS", + }, + existingAttachments: "Existing attachments", + existingAttachmentsOriginalStorage: "Continue reading from their original storage", clientVersion: "Client version", releaseTime: "Release time", build: "Build", @@ -660,18 +683,26 @@ export const enUS = { }, browser: "Browser", os: "System", + architecture: "Architecture", + runtimeEngine: "Runtime engine", language: "Language", timeZone: "Time zone", + instanceConnection: "Instance connection", + requestLatency: "Request latency", + lastSuccessfulSync: "Last successful sync", + pendingSync: "Pending sync", + failedSync: "Failed or conflicted", + connectionConnected: "Connected", + connectionChecking: "Checking", + connectionFailed: "Connection failed", + neverSynced: "Not synced yet", installMode: "Mode", standalone: "Installed app", browserMode: "Browser", unknown: "Unknown", copy: "Copy info", deployedUpdateTitle: "Your cloud instance was automatically updated to v{{version}}", - releaseHighlightsLabel: "What's new:", - releaseNotesUnavailable: "This build does not include an update summary. You can view the full Release notes.", - clientUpdatesNote: "Download desktop updates from Releases, or update mobile apps through the corresponding app store.", - viewReleaseNotes: "Full notes", + viewReleaseNotes: "Release notes", desktopCheckForUpdates: "Check for updates", desktopUpdateChecking: "Checking for updates…", desktopUpdateCurrent: "You're up to date.", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index c2c70112..43bcbe11 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -627,8 +627,31 @@ export const zhCN = { }, systemInfo: { title: "系统信息", - description: "查看当前应用版本、构建标识和运行环境。", + description: "查看云端实例与当前客户端的版本和运行环境。", + cloudSection: "云端实例", + cloudSectionDescription: "当前连接实例的版本与部署环境。", + clientSection: "当前客户端", + clientSectionDescription: "这台设备上的 EdgeEver 应用与运行环境。", + connectionSection: "连接与同步", + connectionSectionDescription: "当前客户端与云端实例之间的连接状态。", version: "版本号", + instanceVersion: "实例版本", + instanceBuild: "实例构建", + databaseMigration: "数据库版本", + databaseBackend: "数据库后端", + databaseBackends: { + d1: "D1", + sqlite: "SQLite", + }, + newUploadObjectStorage: "新上传对象存储", + objectStorageBackends: { + builtin_r2: "内置 R2", + builtin_filesystem: "本地文件系统", + builtin_s3: "实例内置 S3 兼容存储", + external_s3: "第三方 S3 兼容 OSS", + }, + existingAttachments: "已有附件", + existingAttachmentsOriginalStorage: "继续从原存储读取", clientVersion: "客户端版本", releaseTime: "发版时间", build: "构建", @@ -660,18 +683,26 @@ export const zhCN = { }, browser: "浏览器", os: "系统", + architecture: "架构", + runtimeEngine: "运行引擎", language: "语言", timeZone: "时区", + instanceConnection: "实例连接", + requestLatency: "请求耗时", + lastSuccessfulSync: "最近成功同步", + pendingSync: "待同步", + failedSync: "失败或冲突", + connectionConnected: "连接正常", + connectionChecking: "正在检查", + connectionFailed: "连接失败", + neverSynced: "尚未同步", installMode: "运行模式", standalone: "已安装应用", browserMode: "浏览器", unknown: "未知", copy: "复制信息", deployedUpdateTitle: "您的云端实例已自动更新至 v{{version}}", - releaseHighlightsLabel: "更新内容:", - releaseNotesUnavailable: "本次构建未包含更新摘要,可查看完整 Release 说明。", - clientUpdatesNote: "桌面端可前往 Releases 下载更新,移动端请通过对应的应用商店更新。", - viewReleaseNotes: "完整说明", + viewReleaseNotes: "版本说明", desktopCheckForUpdates: "检查更新", desktopUpdateChecking: "正在检查更新…", desktopUpdateCurrent: "当前已是最新版本。", diff --git a/scripts/build-cloudflare-worker.mjs b/scripts/build-cloudflare-worker.mjs index cd9dc7d8..f1fbb236 100644 --- a/scripts/build-cloudflare-worker.mjs +++ b/scripts/build-cloudflare-worker.mjs @@ -1,9 +1,21 @@ +import { execSync } from "node:child_process"; import { rm, stat } from "node:fs/promises"; import { resolve, sep } from "node:path"; import { build } from "esbuild"; export const CLOUDFLARE_WORKER_OUTPUT_DIRECTORY = resolve(".wrangler/edgeever-worker"); const normalizePath = (value) => value.replaceAll("\\", "/"); +const resolveBuildId = () => { + const environmentBuildId = process.env.WORKERS_CI_COMMIT_SHA + ?? process.env.CF_PAGES_COMMIT_SHA + ?? process.env.GITHUB_SHA; + if (environmentBuildId) return environmentBuildId.slice(0, 12); + try { + return execSync("git rev-parse --short=12 HEAD", { encoding: "utf8" }).trim(); + } catch { + return "unknown"; + } +}; export const buildCloudflareWorker = async () => { const generatedRoot = resolve(".wrangler"); @@ -32,6 +44,9 @@ export const buildCloudflareWorker = async () => { metafile: true, write: true, allowOverwrite: true, + define: { + __EDGEEVER_INSTANCE_BUILD_ID__: JSON.stringify(resolveBuildId()), + }, outExtension: { ".js": ".js" }, }); diff --git a/scripts/docker-release.test.mjs b/scripts/docker-release.test.mjs index 39a95c2b..e53b2790 100644 --- a/scripts/docker-release.test.mjs +++ b/scripts/docker-release.test.mjs @@ -71,6 +71,8 @@ describe("Docker release contract", () => { expect(dockerfile).toContain("USER bun"); expect(dockerfile).toContain('VOLUME ["/data"]'); expect(dockerfile).toContain("HEALTHCHECK"); + expect(dockerfile).toContain("ARG EDGE_EVER_BUILD_ID=unknown"); + expect(dockerfile).toContain("EDGE_EVER_BUILD_ID=${EDGE_EVER_BUILD_ID}"); }); test("keeps authentication explicit in Compose", () => { @@ -114,6 +116,8 @@ describe("Docker release contract", () => { expect(workflow).not.toContain("releases/tags/${RELEASE_TAG}"); expect(workflow).toContain("docker logout ghcr.io"); expect(workflow).toContain("docker buildx imagetools inspect"); + expect(workflow).toContain('docker build --build-arg EDGE_EVER_BUILD_ID="${GITHUB_SHA}"'); + expect(workflow).toContain("EDGE_EVER_BUILD_ID=${{ github.sha }}"); expect(workflow).not.toContain("TCR_IMAGE_NAME"); expect(workflow).not.toContain("TENCENT_TCR_USERNAME"); diff --git a/tests/auth-fail-closed.test.ts b/tests/auth-fail-closed.test.ts index 9f4f6fbf..3f96fee7 100644 --- a/tests/auth-fail-closed.test.ts +++ b/tests/auth-fail-closed.test.ts @@ -9,10 +9,12 @@ const executionContext = { const createDatabase = (options: { userId?: string; error?: Error } = {}) => ({ - prepare() { + prepare(sql: string) { return { async first() { if (options.error) throw options.error; + if (sql.includes("d1_migrations")) return { name: "0035_normalized_memo_tags.sql" }; + if (sql.includes("object_storage_configs")) return { provider: "s3" }; return options.userId ? { id: options.userId } : null; }, }; @@ -74,7 +76,13 @@ describe("production authentication guard", () => { }); expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ ok: true }); + expect(await response.json()).toMatchObject({ + ok: true, + build: expect.any(String), + migration: "0035", + storage: { database: "d1", resources: "r2" }, + objectStorageProvider: "s3", + }); }); test("does not misreport transient D1 failures as unapplied migrations", async () => { diff --git a/tests/storage-adapter.test.ts b/tests/storage-adapter.test.ts index 6d152a23..1794f463 100644 --- a/tests/storage-adapter.test.ts +++ b/tests/storage-adapter.test.ts @@ -14,6 +14,11 @@ describe("storage adapter", () => { expect(adapter.db).toBe(db); expect(adapter.resources).toBe(resources); + expect(adapter.diagnostics).toEqual({ + database: "d1", + resources: "r2", + migrationTable: "d1_migrations", + }); }); test("keeps the self-hosted database dialect explicit", () => { @@ -29,6 +34,11 @@ describe("storage adapter", () => { try { const adapter = createSelfHostedStorageAdapter(sqlite, directory); + expect(adapter.diagnostics).toEqual({ + database: "sqlite", + resources: "filesystem", + migrationTable: "_edgeever_migrations", + }); await adapter.resources.put("workspace/memo/image.bin", new Uint8Array([1, 2, 3])); expect(await readFile(`${directory}/workspace/memo/image.bin`)).toEqual(new Uint8Array([1, 2, 3])); @@ -77,6 +87,11 @@ describe("storage adapter", () => { { bucket: "edgeever", endpoint: "http://minio:9000" }, client as never, ); + expect(adapter.diagnostics).toEqual({ + database: "sqlite", + resources: "s3", + migrationTable: "_edgeever_migrations", + }); await adapter.resources.put("memo/image.txt", new Uint8Array([1]), { httpMetadata: { contentType: "text/plain" },