fix(agent): keep offline import resilient per item with os error detail
This commit is contained in:
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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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ç",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "ドライバーファイルを選択",
|
||||
|
||||
@@ -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: "드라이버 파일 선택",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "选择驱动文件",
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -921,6 +921,8 @@ export type {
|
||||
AgentOfflineExportCandidate,
|
||||
AgentOfflineExportPreview,
|
||||
AgentOfflineExportResult,
|
||||
AgentOfflineImportFailure,
|
||||
AgentOfflineImportResult,
|
||||
DriverStoreUsage,
|
||||
DriverStoreUsageItem,
|
||||
DriverRuntimeHealth,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user