From c0f4f1d74b9c9547025b1637c961d298e63a6d01 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 13 Jan 2026 23:48:58 -0600 Subject: [PATCH] feat: update credential editor to use submitting system and add health monitor --- src/lib/db-health-monitor.ts | 90 +++++++++++++++++++ src/locales/en.json | 2 + src/ui/desktop/DesktopApp.tsx | 75 ++++++++++++++-- src/ui/desktop/apps/dashboard/Dashboard.tsx | 16 ++-- .../credentials/CredentialEditor.tsx | 64 ++++++++++++- src/ui/main-axios.ts | 16 +++- 6 files changed, 246 insertions(+), 17 deletions(-) create mode 100644 src/lib/db-health-monitor.ts diff --git a/src/lib/db-health-monitor.ts b/src/lib/db-health-monitor.ts new file mode 100644 index 00000000..2413a0c7 --- /dev/null +++ b/src/lib/db-health-monitor.ts @@ -0,0 +1,90 @@ +type EventListener = (...args: any[]) => void; + +class DatabaseHealthMonitor { + private static instance: DatabaseHealthMonitor; + private dbHealthy: boolean = true; + private lastCheckTime: number = 0; + private checkInProgress: boolean = false; + private listeners: Map = new Map(); + + private constructor() {} + + static getInstance(): DatabaseHealthMonitor { + if (!DatabaseHealthMonitor.instance) { + DatabaseHealthMonitor.instance = new DatabaseHealthMonitor(); + } + return DatabaseHealthMonitor.instance; + } + + on(event: string, listener: EventListener): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event)!.push(listener); + } + + off(event: string, listener: EventListener): void { + const eventListeners = this.listeners.get(event); + if (eventListeners) { + const index = eventListeners.indexOf(listener); + if (index !== -1) { + eventListeners.splice(index, 1); + } + } + } + + private emit(event: string, ...args: any[]): void { + const eventListeners = this.listeners.get(event); + if (eventListeners) { + eventListeners.forEach((listener) => listener(...args)); + } + } + + reportDatabaseError(error: any) { + const errorMessage = error?.response?.data?.error || error?.message || ""; + const errorCode = error?.response?.data?.code || error?.code; + + const isDatabaseError = + errorMessage.toLowerCase().includes("database") || + errorMessage.toLowerCase().includes("sqlite") || + errorMessage.toLowerCase().includes("drizzle") || + errorCode === "DATABASE_ERROR" || + errorCode === "DB_CONNECTION_FAILED"; + + const isBackendUnreachable = + errorCode === "ERR_NETWORK" || + errorCode === "ECONNREFUSED" || + (errorMessage.toLowerCase().includes("network error") && + error?.response === undefined); + + if ((isDatabaseError || isBackendUnreachable) && this.dbHealthy) { + this.dbHealthy = false; + this.emit("database-connection-lost", { + error: errorMessage || "Backend server unreachable", + code: errorCode, + timestamp: Date.now(), + }); + } + } + + reportDatabaseSuccess() { + if (!this.dbHealthy) { + this.dbHealthy = true; + this.emit("database-connection-restored", { + timestamp: Date.now(), + }); + } + } + + isDatabaseHealthy(): boolean { + return this.dbHealthy; + } + + reset() { + this.dbHealthy = true; + this.lastCheckTime = 0; + this.checkInProgress = false; + } +} + +export const dbHealthMonitor = DatabaseHealthMonitor.getInstance(); diff --git a/src/locales/en.json b/src/locales/en.json index d4718c9b..3c4bb2ec 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -44,6 +44,8 @@ "passwordRequired": "Password is required", "sshKeyRequired": "SSH key is required", "credentialAddedSuccessfully": "Credential \"{{name}}\" added successfully", + "savingCredential": "Saving credential...", + "updatingCredential": "Updating credential...", "general": "General", "description": "Description", "folder": "Folder", diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 034fd4e4..2f7bec72 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -17,6 +17,7 @@ import { toast } from "sonner"; import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx"; import { getUserInfo, logoutUser, isElectron } from "@/ui/main-axios.ts"; import { useTheme } from "@/components/theme-provider"; +import { dbHealthMonitor } from "@/lib/db-health-monitor.ts"; function AppContent() { const [isAuthenticated, setIsAuthenticated] = useState(false); @@ -36,6 +37,7 @@ function AppContent() { const { theme, setTheme } = useTheme(); const [rightSidebarOpen, setRightSidebarOpen] = useState(false); const [rightSidebarWidth, setRightSidebarWidth] = useState(400); + const [dbConnectionFailed, setDbConnectionFailed] = useState(false); const isDarkMode = theme === "dark" || @@ -47,6 +49,38 @@ function AppContent() { const lastAltPressTime = useRef(0); + useEffect(() => { + const handleDatabaseConnectionLost = () => { + setDbConnectionFailed(true); + setIsAuthenticated(false); + }; + + const handleDatabaseConnectionRestored = () => { + setDbConnectionFailed(false); + window.location.reload(); + }; + + dbHealthMonitor.on( + "database-connection-lost", + handleDatabaseConnectionLost, + ); + dbHealthMonitor.on( + "database-connection-restored", + handleDatabaseConnectionRestored, + ); + + return () => { + dbHealthMonitor.off( + "database-connection-lost", + handleDatabaseConnectionLost, + ); + dbHealthMonitor.off( + "database-connection-restored", + handleDatabaseConnectionRestored, + ); + }; + }, []); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.code === "ShiftLeft") { @@ -99,7 +133,8 @@ function AppContent() { if (hostIdentifier) { const openTerminal = async () => { try { - const { getSSHHostById, getSSHHosts } = await import("@/ui/main-axios.ts"); + const { getSSHHostById, getSSHHosts } = + await import("@/ui/main-axios.ts"); let host = null; // Pure numeric → lookup by ID @@ -108,7 +143,9 @@ function AppContent() { } else { // Non-numeric → lookup by name (first match) const hosts = await getSSHHosts(); - host = hosts.find((h: { name?: string }) => h.name === hostIdentifier) || null; + host = + hosts.find((h: { name?: string }) => h.name === hostIdentifier) || + null; } if (host) { @@ -232,10 +269,10 @@ function AppContent() { const showProfile = currentTabData?.type === "user_profile"; const showNetworkGraph = currentTabData?.type === "network_graph"; - if (authLoading) { + if (authLoading && !dbConnectionFailed) { return (
-
-
+
+
+
+
); } + if (dbConnectionFailed) { + return ( +
+
+ {}} + initialDbError="Database connection failed" + /> +
+ +
+ ); + } + return (
(null); const [userId, setUserId] = useState(null); - const [dbError, setDbError] = useState(null); + const [dbError, setDbError] = useState(initialDbError); const [uptime, setUptime] = useState("0d 0h 0m"); const [versionStatus, setVersionStatus] = useState< @@ -160,7 +162,8 @@ export function Dashboard({ const uptimeInfo = await getUptime(); setUptime(uptimeInfo.formatted); - const updateCheckDisabled = localStorage.getItem("disableUpdateCheck") === "true"; + const updateCheckDisabled = + localStorage.getItem("disableUpdateCheck") === "true"; if (!updateCheckDisabled) { const versionInfo = await getVersionInfo(); setVersionText(`v${versionInfo.localVersion}`); @@ -606,7 +609,9 @@ export function Dashboard({ {showNetworkGraph ? ( <> - {t("dashboard.networkGraph", { defaultValue: "Network Graph" })} + {t("dashboard.networkGraph", { + defaultValue: "Network Graph", + })} ) : ( <> @@ -700,10 +705,7 @@ export function Dashboard({ > {recentActivityLoading ? (
- + {t("dashboard.loadingRecentActivity")}
) : recentActivity.length === 0 ? ( diff --git a/src/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx b/src/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx index bdde15e1..985aa5e3 100644 --- a/src/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx +++ b/src/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx @@ -34,6 +34,7 @@ import type { } from "../../../../../types"; import { CredentialGeneralTab } from "./tabs/CredentialGeneralTab"; import { CredentialAuthenticationTab } from "./tabs/CredentialAuthenticationTab"; +import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; export function CredentialEditor({ editingCredential, @@ -59,6 +60,7 @@ export function CredentialEditor({ const keyDetectionTimeoutRef = useRef(null); const [activeTab, setActiveTab] = useState("general"); const [formError, setFormError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); const [detectedPublicKeyType, setDetectedPublicKeyType] = useState< string | null @@ -168,6 +170,7 @@ export function CredentialEditor({ resolver: zodResolver(formSchema) as unknown as Parameters< typeof useForm >[0]["resolver"], + mode: "all", defaultValues: { name: "", description: "", @@ -183,6 +186,45 @@ export function CredentialEditor({ }, }); + const watchedFields = form.watch(); + + const isFormValid = React.useMemo(() => { + const values = form.getValues(); + + if (!values.name || !values.username) return false; + + if (authTab === "password") { + return !!(values.password && values.password.trim() !== ""); + } else if (authTab === "key") { + if (editingCredential) { + return true; + } + return !!values.key; + } + + return false; + }, [watchedFields, authTab, editingCredential]); + + useEffect(() => { + const updateAuthFields = async () => { + form.setValue("authType", authTab, { shouldValidate: true }); + + if (authTab === "password") { + form.setValue("key", null, { shouldValidate: true }); + form.setValue("publicKey", "", { shouldValidate: true }); + form.setValue("keyPassword", "", { shouldValidate: true }); + form.setValue("keyType", "auto", { shouldValidate: true }); + } else if (authTab === "key") { + form.setValue("password", "", { shouldValidate: true }); + } + + await form.trigger(); + }; + + updateAuthFields(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authTab]); + useEffect(() => { if (editingCredential && fullCredentialDetails) { const defaultAuthType = fullCredentialDetails.authType; @@ -331,6 +373,7 @@ export function CredentialEditor({ const onSubmit = async (data: FormData) => { try { + setIsSubmitting(true); setFormError(null); if (!data.name || data.name.trim() === "") { @@ -388,6 +431,8 @@ export function CredentialEditor({ } else { toast.error(t("credentials.failedToSaveCredential")); } + } finally { + setIsSubmitting(false); } }; @@ -457,9 +502,19 @@ export function CredentialEditor({ return (
+ +