feat: expand shared system diagnostics
This commit is contained in:
1 parent
55a4dbf9eb
commit
d483f6db00
21 files changed
+645
-142
No files matched your search
@@ -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 }}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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 })');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Vendored
+9
@@ -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>;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "当前已是最新版本。",
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user