fix(agent): keep offline import resilient per item with os error detail

This commit is contained in:
zipg authored and GitHub committed 2026-09-19 00:22:18 +08:00
1 parent e0e6a696b0
commit 00c4d30453
20 files changed
+858 -153

No files matched your search

@@ -18,7 +18,7 @@ import { uuid } from "@/lib/common/utils";
import { countAvailableDriverUpdates } from "@/lib/connection/agentDriverUpdateBadge";
import type { JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, JdbcPluginStatus } from "@/types/database";
import * as api from "@/lib/backend/api";
import type { AgentDriverInfo, AgentOfflineExportPreview, DriverRuntimeInfo, DriverRuntimeSummary, DriverStoreUsage, JavaRuntimeConfig } from "@/lib/backend/api";
import type { AgentDriverInfo, AgentOfflineExportPreview, AgentOfflineImportResult, DriverRuntimeInfo, DriverRuntimeSummary, DriverStoreUsage, JavaRuntimeConfig } from "@/lib/backend/api";
import { formatRuntimeBytes, formatRuntimeCpu, formatRuntimeUptime, runtimeHealthClass, runtimeStatusClass, runtimeStatusDotClass } from "@/lib/connection/driverRuntimePresentation";
import {
addDriverInstallQueue,
@@ -745,7 +745,7 @@ async function importOfflineZip() {
try {
const result = await api.importAgentsFromZip(selected, activeAgentOperationId.value);
await Promise.all([refreshAgents(), loadJdbcDrivers(), loadJdbcPluginStatus()]);
toast(t(result.jreCount > 0 ? (result.count > 0 ? "driverStore.offlineImportWithJreSuccess" : "driverStore.offlineJreImportSuccess") : "driverStore.offlineImportSuccess", { count: result.count, jreCount: result.jreCount }));
toastOfflineImportResult(result);
} catch (e: any) {
toast(t("driverStore.offlineImportFailed", { error: backendError(e) }));
} finally {
@@ -755,6 +755,26 @@ async function importOfflineZip() {
}
}
// An offline package keeps importing after one item fails (a blocked JRE
// archive, a corrupt driver JAR), so surface what did install alongside the
// per-item failure instead of reporting the whole import as failed.
function toastOfflineImportResult(result: AgentOfflineImportResult) {
if (result.failures.length > 0) {
const [failure] = result.failures;
toast(
t("driverStore.offlineImportPartial", {
count: result.count,
jreCount: result.jreCount,
failed: result.failures.length,
item: failure.is_jre ? `JRE ${failure.key}` : driverLabel(failure.key),
error: failure.error,
}),
);
return;
}
toast(t(result.jreCount > 0 ? (result.count > 0 ? "driverStore.offlineImportWithJreSuccess" : "driverStore.offlineJreImportSuccess") : "driverStore.offlineImportSuccess", { count: result.count, jreCount: result.jreCount }));
}
async function importDriverFile(driver: AgentDriverInfo) {
if (agentPackageBusy.value) return;
const dbType = driver.db_type;
@@ -777,7 +797,7 @@ async function importDriverFile(driver: AgentDriverInfo) {
try {
const result = await api.importAgentsFromZip(selected, activeAgentOperationId.value);
await Promise.all([refreshAgents(), loadJdbcDrivers(), loadJdbcPluginStatus()]);
toast(t(result.jreCount > 0 ? (result.count > 0 ? "driverStore.offlineImportWithJreSuccess" : "driverStore.offlineJreImportSuccess") : "driverStore.offlineImportSuccess", { count: result.count, jreCount: result.jreCount }));
toastOfflineImportResult(result);
} finally {
activeAgentOperationId.value = null;
resetAgentInstallProgress();
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import az from "../locales/az";
import en from "../locales/en";
import es from "../locales/es";
import itLocale from "../locales/it";
import ja from "../locales/ja";
import ko from "../locales/ko";
import ptBR from "../locales/pt-BR";
import tr from "../locales/tr";
import zhCN from "../locales/zh-CN";
import zhTW from "../locales/zh-TW";
type Messages = Record<string, unknown>;
const locales: Array<[string, Messages]> = [
["az", az],
["es", es],
["it", itLocale],
["ja", ja],
["ko", ko],
["pt-BR", ptBR],
["tr", tr],
["zh-CN", zhCN],
["zh-TW", zhTW],
];
function offlineImportEntries(locale: Messages): Map<string, string> {
const driverStore = locale.driverStore as Record<string, unknown>;
return new Map(
Object.entries(driverStore)
.filter(([key, value]) => (key.startsWith("offlineImport") || key.startsWith("offlineJreImport")) && typeof value === "string")
.map(([key, value]) => [key, value as string]),
);
}
function placeholders(message: string): string[] {
return [...new Set([...message.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]))].sort();
}
describe("offline Agent import i18n parity", () => {
const englishEntries = offlineImportEntries(en);
it("declares the complete English message set", () => {
expect([...englishEntries.keys()].sort()).toEqual(["offlineImportFailed", "offlineImportPartial", "offlineImportSuccess", "offlineImportWithJreSuccess", "offlineJreImportSuccess"]);
});
it.each(locales)("%s declares matching keys and placeholders", (_name, locale) => {
const localeEntries = offlineImportEntries(locale);
expect([...localeEntries.keys()].sort()).toEqual([...englishEntries.keys()].sort());
for (const [key, englishMessage] of englishEntries) {
expect(placeholders(localeEntries.get(key) ?? ""), key).toEqual(placeholders(englishMessage));
}
});
});
+1
View File
@@ -8054,6 +8054,7 @@ export default withEnglishFallback({
offlineJreImportSuccess: "Oflayn idxal tamamlandı. {jreCount} JRE quraşdırıldı.",
offlineImportWithJreSuccess: "Oflayn idxal tamamlandı. {jreCount} JRE və {count} drayver quraşdırıldı.",
offlineImportFailed: "Oflayn idxal uğursuz oldu: {error}",
offlineImportPartial: "Oflayn idxal qismən tamamlandı: {failed} element uğursuz oldu, {jreCount} JRE və {count} drayver quraşdırıldı. {item}: {error}",
driverImportSuccess: "{label} drayveri idxal edildi",
driverImportFailed: "{label} drayverini idxal etmək mümkün olmadı: {error}",
chooseDriverJar: "Drayver faylını seç",
+1
View File
@@ -8289,6 +8289,7 @@ export default {
offlineJreImportSuccess: "Offline import complete. Installed {jreCount} JRE(s).",
offlineImportWithJreSuccess: "Offline import complete. Installed {jreCount} JRE(s) and {count} driver(s).",
offlineImportFailed: "Offline import failed: {error}",
offlineImportPartial: "Offline import finished with problems: {failed} item(s) failed, installed {jreCount} JRE(s) and {count} driver(s). {item}: {error}",
driverImportSuccess: "{label} driver imported",
driverImportFailed: "Failed to import {label} driver: {error}",
chooseDriverJar: "Choose driver file",
+1
View File
@@ -7824,6 +7824,7 @@ export default withEnglishFallback({
offlineJreImportSuccess: "Importación offline completada. Se instalaron {jreCount} JRE(s).",
offlineImportWithJreSuccess: "Importación offline completada. Se instalaron {jreCount} JRE(s) y {count} driver(s).",
offlineImportFailed: "Error en la importación offline: {error}",
offlineImportPartial: "Importación offline completada con incidencias: {failed} elemento(s) fallidos, se instalaron {jreCount} JRE(s) y {count} driver(s). {item}: {error}",
driverImportSuccess: "Driver {label} importado",
driverImportFailed: "Error al importar el driver {label}: {error}",
chooseDriverJar: "Seleccionar archivo del driver",
+1
View File
@@ -7824,6 +7824,7 @@ export default withEnglishFallback({
offlineJreImportSuccess: "Importazione offline completata. Installato/i {jreCount} JRE.",
offlineImportWithJreSuccess: "Importazione offline completata. Installato/i {jreCount} JRE e {count} driver.",
offlineImportFailed: "Importazione offline non riuscita: {error}",
offlineImportPartial: "Importazione offline completata con problemi: {failed} elemento/i non riusciti, installati {jreCount} JRE e {count} driver. {item}: {error}",
driverImportSuccess: "Driver {label} importato",
driverImportFailed: "Importazione del driver {label} non riuscita: {error}",
chooseDriverJar: "Scegli il file del driver",
+1
View File
@@ -7876,6 +7876,7 @@ export default withEnglishFallback({
offlineJreImportSuccess: "オフラインインポートが完了しました。{jreCount}件のJREをインストールしました。",
offlineImportWithJreSuccess: "オフラインインポートが完了しました。{jreCount}件のJREと{count}件のドライバーをインストールしました。",
offlineImportFailed: "オフラインインポートに失敗しました: {error}",
offlineImportPartial: "オフラインインポートが一部完了しました:{failed}件が失敗し、{jreCount}件のJREと{count}件のドライバーをインストールしました。{item}: {error}",
driverImportSuccess: "{label} ドライバーをインポートしました",
driverImportFailed: "{label} ドライバーのインポートに失敗しました: {error}",
chooseDriverJar: "ドライバーファイルを選択",
+1
View File
@@ -7556,6 +7556,7 @@ export default withEnglishFallback({
offlineJreImportSuccess: "오프라인 가져오기 완료. JRE {jreCount}개 설치.",
offlineImportWithJreSuccess: "오프라인 가져오기 완료. JRE {jreCount}개와 드라이버 {count}개 설치.",
offlineImportFailed: "오프라인 가져오기 실패: {error}",
offlineImportPartial: "오프라인 가져오기가 일부 완료되었습니다: {failed}개 실패, JRE {jreCount}개와 드라이버 {count}개 설치됨. {item}: {error}",
driverImportSuccess: "{label} 드라이버를 가져왔습니다",
driverImportFailed: "{label} 드라이버 가져오기 실패: {error}",
chooseDriverJar: "드라이버 파일 선택",
+1
View File
@@ -7826,6 +7826,7 @@ export default withEnglishFallback({
offlineJreImportSuccess: "Importação offline concluída. {jreCount} JRE(s) instalado(s).",
offlineImportWithJreSuccess: "Importação offline concluída. {jreCount} JRE(s) e {count} driver(s) instalado(s).",
offlineImportFailed: "Falha na importação offline: {error}",
offlineImportPartial: "Importação offline concluída com problemas: {failed} item(ns) com falha, {jreCount} JRE(s) e {count} driver(s) instalado(s). {item}: {error}",
driverImportSuccess: "Driver {label} importado",
driverImportFailed: "Falha ao importar o driver {label}: {error}",
chooseDriverJar: "Escolher arquivo do driver",
+1
View File
@@ -7909,6 +7909,7 @@ export default withEnglishFallback({
offlineJreImportSuccess: "Çevrimdışı içe aktarma tamamlandı. {jreCount} JRE kuruldu.",
offlineImportWithJreSuccess: "Çevrimdışı içe aktarma tamamlandı. {jreCount} JRE ve {count} sürücü kuruldu.",
offlineImportFailed: "Çevrimdışı içe aktarma başarısız: {error}",
offlineImportPartial: "Çevrimdışı içe aktarma sorunlarla tamamlandı: {failed} öğe başarısız, {jreCount} JRE ve {count} sürücü kuruldu. {item}: {error}",
driverImportSuccess: "{label} sürücüsü içe aktarıldı",
driverImportFailed: "{label} sürücüsü içe aktarılamadı: {error}",
chooseDriverJar: "Sürücü dosyasını seçin",
+1
View File
@@ -8261,6 +8261,7 @@ export default withEnglishFallback({
offlineJreImportSuccess: "离线导入完成,已安装 {jreCount} 个 JRE",
offlineImportWithJreSuccess: "离线导入完成,已安装 {jreCount} 个 JRE 和 {count} 个驱动",
offlineImportFailed: "离线导入失败: {error}",
offlineImportPartial: "离线导入部分完成:{failed} 项失败,已安装 {jreCount} 个 JRE 和 {count} 个驱动。{item}:{error}",
driverImportSuccess: "{label} 驱动导入成功",
driverImportFailed: "{label} 驱动导入失败: {error}",
chooseDriverJar: "选择驱动文件",
+1
View File
@@ -7131,6 +7131,7 @@ export default withEnglishFallback({
offlineJreImportSuccess: "離線匯入完成,已安裝 {jreCount} 個 JRE",
offlineImportWithJreSuccess: "離線匯入完成,已安裝 {jreCount} 個 JRE 和 {count} 個驅動程式",
offlineImportFailed: "離線匯入失敗: {error}",
offlineImportPartial: "離線匯入部分完成:{failed} 項失敗,已安裝 {jreCount} 個 JRE 和 {count} 個驅動程式。{item}:{error}",
driverImportSuccess: "{label} 驅動程式匯入成功",
driverImportFailed: "{label} 驅動程式匯入失敗: {error}",
chooseDriverJar: "選擇驅動程式檔案",
@@ -17,11 +17,12 @@ describe("offline Agent and standalone JRE import", () => {
});
it("uploads standalone JRE packages and preserves both result counts", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ count: 0, jreCount: 1 })));
const failures = [{ key: "21", is_jre: true, error: "Failed to extract JRE archive: Access is denied. (os error 5)" }];
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ count: 0, jreCount: 1, failures })));
vi.stubGlobal("fetch", fetchMock);
const { importAgentsFromZip } = await import("../http");
const file = new File(["archive"], "renamed.tar.zst");
expect(await importAgentsFromZip(file, "import-2")).toEqual({ count: 0, jreCount: 1 });
expect(await importAgentsFromZip(file, "import-2")).toEqual({ count: 0, jreCount: 1, failures });
const [url, request] = fetchMock.mock.calls[0];
expect(url).toContain("/api/agents/import-offline");
expect(request.method).toBe("POST");
@@ -32,6 +33,11 @@ describe("offline Agent and standalone JRE import", () => {
it("remains compatible with web backends that only return a driver count", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ count: 2 }))));
const { importAgentsFromZip } = await import("../http");
expect(await importAgentsFromZip(new File(["archive"], "drivers.zip"))).toEqual({ count: 2, jreCount: 0 });
// Older web backends report no per-item failures; the caller must still see an empty list.
expect(await importAgentsFromZip(new File(["archive"], "drivers.zip"))).toEqual({
count: 2,
jreCount: 0,
failures: [],
});
});
});
+2
View File
@@ -921,6 +921,8 @@ export type {
AgentOfflineExportCandidate,
AgentOfflineExportPreview,
AgentOfflineExportResult,
AgentOfflineImportFailure,
AgentOfflineImportResult,
DriverStoreUsage,
DriverStoreUsageItem,
DriverRuntimeHealth,
+1 -1
View File
@@ -879,7 +879,7 @@ export async function importAgentsFromZip(fileOrPath: string | File, operationId
});
if (!res.ok) throw await backendResponseError(res);
const result: AgentOfflineImportResult = await res.json();
return { count: result.count, jreCount: result.jreCount ?? 0 };
return { count: result.count, jreCount: result.jreCount ?? 0, failures: result.failures ?? [] };
}
export async function previewAgentOfflineExport(): Promise<AgentOfflineExportPreview> {
+11
View File
@@ -206,6 +206,17 @@ export interface AgentOfflineExportResult {
export interface AgentOfflineImportResult {
count: number;
jreCount: number;
/** Items the package could not install; the rest of the import still ran. */
failures: AgentOfflineImportFailure[];
}
export interface AgentOfflineImportFailure {
/** Managed JRE key (e.g. "21") or driver key (e.g. "oracle"). */
key: string;
/** True when the failed item is a managed JRE runtime rather than a driver. */
is_jre: boolean;
/** Failure text, including the underlying OS error when there is one. */
error: string;
}
export type JavaRuntimeMode = "managed" | "system" | "custom";