feat: expand shared system diagnostics

This commit is contained in:
tianma-if committed 2026-08-29 21:37:07 +08:00
1 parent 55a4dbf9eb
commit d483f6db00
21 files changed
+645 -142

No files matched your search

+3 -1
View File
@@ -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 }}
+2
View File
@@ -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
@@ -12,4 +12,9 @@ export const createCloudflareStorageAdapter = (
): StorageAdapter => ({
db: bindings.DB,
resources: bindings.RESOURCES,
diagnostics: {
database: "d1",
resources: "r2",
migrationTable: "d1_migrations",
},
});
+31
View File
@@ -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),
});
});
+10
View File
@@ -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";
+11 -4
View File
@@ -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",
},
};
};
@@ -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",
},
});
+5
View File
@@ -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. */
@@ -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 })');
+12 -7
View File
@@ -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);
+1
View File
@@ -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),
@@ -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<InstanceHealth, "build" | "migration" | "objectStorageProvider" | "storage"> & {
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<InstanceSystemDiagnostics> | 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<ClientSyncDiagnostics>({
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 (
<div className="grid gap-3">
<div className="grid gap-4">
<div className="flex flex-wrap justify-end gap-2">
{desktopAvailable ? (
<Button
size="sm"
variant="outline"
className="h-8 bg-white px-3 text-xs"
type="button"
disabled={desktopUpdateBusy || desktopUpdateState === "available"}
onClick={handleDesktopUpdate}
>
{desktopUpdateBusy || desktopUpdateState === "available"
? <LoaderCircle className="h-3.5 w-3.5 animate-spin" />
: desktopUpdateState === "downloaded"
? <RotateCcw className="h-3.5 w-3.5" />
: <RefreshCw className="h-3.5 w-3.5" />}
{desktopUpdateState === "downloaded"
? t("systemInfo.desktopUpdateRestart")
: desktopUpdateState === "available"
? t("systemInfo.desktopUpdateDownloading")
: desktopUpdateCheckMutation.isPending
? t("systemInfo.desktopUpdateChecking")
: t("systemInfo.desktopCheckForUpdates")}
</Button>
) : null}
<Button size="sm" variant="outline" className="h-8 w-full bg-white px-3 text-xs sm:w-auto" type="button" onClick={() => void handleCopy()}>
<Copy className="h-3.5 w-3.5" />
{copied ? t("common.copied") : t("systemInfo.copy")}
</Button>
</div>
{desktopAvailable && desktopUpdateStatus ? (
<p
className={cn(
"text-right text-xs",
desktopUpdateInstallMutation.isError || desktopUpdateCheckMutation.isError || desktopUpdateStatusQuery.isError
? "text-red-600"
: "text-slate-500",
)}
role="status"
aria-live="polite"
>
{desktopUpdateStatus}
</p>
) : null}
{active && release ? (
<div className="flex items-start gap-2 rounded-md border border-emerald-200 border-l-2 border-l-emerald-500 bg-emerald-50/40 px-3 py-2 text-slate-800" role="status">
<CircleCheck className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
<div className="min-w-0 flex-1 text-xs leading-5">
<div className="font-semibold">{t("systemInfo.deployedUpdateTitle", { version: releaseTag?.replace(/^v/, "") ?? release.version })}</div>
{releaseHighlights.length > 0 ? (
<div className="mt-1 text-slate-600">
<div className="pl-4 font-medium">{t("systemInfo.releaseHighlightsLabel")}</div>
<ul className="mt-0.5 list-disc space-y-0.5 pl-4">
{releaseHighlights.map((highlight) => <li key={highlight}>{highlight}</li>)}
</ul>
{infoGroups.map((group) => {
const isCloud = group.id === "cloud";
const isClient = group.id === "client";
const headingId = `system-info-${group.id}-heading`;
return (
<section key={group.id} className="grid gap-2.5" aria-labelledby={headingId}>
<div className="flex flex-wrap items-start justify-between gap-2 px-0.5">
<div className="flex min-w-0 items-start gap-2">
{isCloud
? <Cloud className="mt-0.5 h-4 w-4 shrink-0 text-emerald-700" />
: isClient
? <MonitorSmartphone className="mt-0.5 h-4 w-4 shrink-0 text-emerald-700" />
: <Activity className="mt-0.5 h-4 w-4 shrink-0 text-emerald-700" />}
<div className="min-w-0">
<h3 id={headingId} className="text-sm font-semibold text-slate-800">{group.title}</h3>
<p className="mt-0.5 text-xs leading-4 text-slate-500">{group.description}</p>
</div>
</div>
) : (
<div className="mt-1 text-slate-500">{t("systemInfo.releaseNotesUnavailable")}</div>
)}
<p className="mt-2 border-t border-emerald-100/70 pt-1.5 text-[10px] leading-4 text-slate-400">
<Trans
i18nKey="systemInfo.clientUpdatesNote"
components={{
releases: (
<a
className="text-slate-500 underline underline-offset-2 hover:text-emerald-700"
href="https://github.com/tianma-if/edgeever/releases"
target="_blank"
rel="noreferrer"
/>
),
}}
/>
</p>
</div>
<a className="inline-flex shrink-0 items-center gap-1 text-xs font-semibold text-emerald-700 underline underline-offset-2 hover:text-emerald-900" href={releaseUrl} target="_blank" rel="noreferrer">
{t("systemInfo.viewReleaseNotes")} <ExternalLink className="h-3 w-3" />
</a>
</div>
) : null}
<dl className="grid border-y border-slate-200 sm:grid-cols-3">
{infoItems.map((item) => (
<div key={item.label} className="min-w-0 border-b border-slate-200 px-2 py-2.5 sm:px-3">
<dt className="truncate text-[11px] font-semibold uppercase text-slate-400">{item.label}</dt>
<dd className={cn("mt-0.5 break-words font-mono text-xs font-semibold leading-5 text-slate-800")} title={item.value}>{item.value}</dd>
</div>
))}
</dl>
{isClient && desktopAvailable ? (
<Button
size="sm"
variant="outline"
className="h-8 bg-white px-3 text-xs"
type="button"
disabled={desktopUpdateBusy || desktopUpdateState === "available"}
onClick={handleDesktopUpdate}
>
{desktopUpdateBusy || desktopUpdateState === "available"
? <LoaderCircle className="h-3.5 w-3.5 animate-spin" />
: desktopUpdateState === "downloaded"
? <RotateCcw className="h-3.5 w-3.5" />
: <RefreshCw className="h-3.5 w-3.5" />}
{desktopUpdateState === "downloaded"
? t("systemInfo.desktopUpdateRestart")
: desktopUpdateState === "available"
? t("systemInfo.desktopUpdateDownloading")
: desktopUpdateCheckMutation.isPending
? t("systemInfo.desktopUpdateChecking")
: t("systemInfo.desktopCheckForUpdates")}
</Button>
) : null}
</div>
{isCloud && active && release ? (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-emerald-200 border-l-2 border-l-emerald-500 bg-emerald-50/40 px-3 py-2 text-slate-800" role="status">
<CircleCheck className="h-4 w-4 shrink-0 text-emerald-600" />
<div className="min-w-0 flex-1 text-xs font-semibold leading-5">
{t("systemInfo.deployedUpdateTitle", { version: releaseTag?.replace(/^v/, "") ?? release.version })}
</div>
<a className="inline-flex shrink-0 items-center gap-1 text-xs font-semibold text-emerald-700 underline underline-offset-2 hover:text-emerald-900" href={releaseUrl} target="_blank" rel="noreferrer">
{t("systemInfo.viewReleaseNotes")} <ExternalLink className="h-3 w-3" />
</a>
</div>
) : null}
{isClient && desktopAvailable && desktopUpdateStatus ? (
<p
className={cn(
"text-right text-xs",
desktopUpdateInstallMutation.isError || desktopUpdateCheckMutation.isError || desktopUpdateStatusQuery.isError
? "text-red-600"
: "text-slate-500",
)}
role="status"
aria-live="polite"
>
{desktopUpdateStatus}
</p>
) : null}
<dl className="grid gap-px overflow-hidden rounded-md border border-slate-200 bg-slate-200 sm:grid-cols-3">
{group.items.map((item) => (
<div key={item.label} className="min-w-0 bg-white px-3 py-2.5">
<dt className="truncate text-[11px] font-semibold uppercase text-slate-400">{item.label}</dt>
<dd className="mt-0.5 break-words font-mono text-xs font-semibold leading-5 text-slate-800">{item.value}</dd>
</div>
))}
</dl>
</section>
);
})}
</div>
);
};
+7
View File
@@ -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 = {
+149
View File
@@ -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<ClientRuntimeDiagnostics> => {
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<ClientSyncDiagnostics> => {
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,
};
};
+9
View File
@@ -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<DesktopUpdateStatus>;
checkUpdate(): Promise<DesktopUpdateStatus>;
+36 -5
View File
@@ -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>Releases</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.",
+36 -5
View File
@@ -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>Releases</releases> 下载更新,移动端请通过对应的应用商店更新。",
viewReleaseNotes: "完整说明",
viewReleaseNotes: "版本说明",
desktopCheckForUpdates: "检查更新",
desktopUpdateChecking: "正在检查更新…",
desktopUpdateCurrent: "当前已是最新版本。",
+15
View File
@@ -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" },
});
+4
View File
@@ -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");
+10 -2
View File
@@ -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 () => {
+15
View File
@@ -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" },