feat: update credential editor to use submitting system and add health monitor

This commit is contained in:
LukeGus
2026-01-13 23:48:58 -06:00
parent f957959a86
commit c0f4f1d74b
6 changed files with 246 additions and 17 deletions

View File

@@ -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<string, EventListener[]> = 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();

View File

@@ -44,6 +44,8 @@
"passwordRequired": "Password is required", "passwordRequired": "Password is required",
"sshKeyRequired": "SSH key is required", "sshKeyRequired": "SSH key is required",
"credentialAddedSuccessfully": "Credential \"{{name}}\" added successfully", "credentialAddedSuccessfully": "Credential \"{{name}}\" added successfully",
"savingCredential": "Saving credential...",
"updatingCredential": "Updating credential...",
"general": "General", "general": "General",
"description": "Description", "description": "Description",
"folder": "Folder", "folder": "Folder",

View File

@@ -17,6 +17,7 @@ import { toast } from "sonner";
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx"; import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx";
import { getUserInfo, logoutUser, isElectron } from "@/ui/main-axios.ts"; import { getUserInfo, logoutUser, isElectron } from "@/ui/main-axios.ts";
import { useTheme } from "@/components/theme-provider"; import { useTheme } from "@/components/theme-provider";
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
function AppContent() { function AppContent() {
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -36,6 +37,7 @@ function AppContent() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [rightSidebarOpen, setRightSidebarOpen] = useState(false); const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
const [rightSidebarWidth, setRightSidebarWidth] = useState(400); const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
const isDarkMode = const isDarkMode =
theme === "dark" || theme === "dark" ||
@@ -47,6 +49,38 @@ function AppContent() {
const lastAltPressTime = useRef(0); 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(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.code === "ShiftLeft") { if (event.code === "ShiftLeft") {
@@ -99,7 +133,8 @@ function AppContent() {
if (hostIdentifier) { if (hostIdentifier) {
const openTerminal = async () => { const openTerminal = async () => {
try { try {
const { getSSHHostById, getSSHHosts } = await import("@/ui/main-axios.ts"); const { getSSHHostById, getSSHHosts } =
await import("@/ui/main-axios.ts");
let host = null; let host = null;
// Pure numeric → lookup by ID // Pure numeric → lookup by ID
@@ -108,7 +143,9 @@ function AppContent() {
} else { } else {
// Non-numeric → lookup by name (first match) // Non-numeric → lookup by name (first match)
const hosts = await getSSHHosts(); 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) { if (host) {
@@ -232,10 +269,10 @@ function AppContent() {
const showProfile = currentTabData?.type === "user_profile"; const showProfile = currentTabData?.type === "user_profile";
const showNetworkGraph = currentTabData?.type === "network_graph"; const showNetworkGraph = currentTabData?.type === "network_graph";
if (authLoading) { if (authLoading && !dbConnectionFailed) {
return ( return (
<div <div
className="h-screen w-screen flex items-center justify-center" className="fixed inset-0 flex items-center justify-center"
style={{ style={{
background: "var(--bg-elevated)", background: "var(--bg-elevated)",
backgroundImage: `repeating-linear-gradient( backgroundImage: `repeating-linear-gradient(
@@ -247,13 +284,39 @@ function AppContent() {
)`, )`,
}} }}
> >
<div className="text-center"> <div className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300">
<div className="w-16 h-16 border-4 border-primary/30 border-t-primary rounded-full animate-spin mx-auto" /> <div className="flex items-center justify-center h-32">
<div className="w-16 h-16 border-4 border-primary/30 border-t-primary rounded-full animate-spin mx-auto" />
</div>
</div> </div>
</div> </div>
); );
} }
if (dbConnectionFailed) {
return (
<div className="h-screen w-screen overflow-hidden bg-background">
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
<Dashboard
isAuthenticated={false}
authLoading={false}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
onSelectView={() => {}}
initialDbError="Database connection failed"
/>
</div>
<Toaster
position="bottom-right"
richColors={false}
closeButton
duration={5000}
offset={20}
/>
</div>
);
}
return ( return (
<div className="h-screen w-screen overflow-hidden bg-background"> <div className="h-screen w-screen overflow-hidden bg-background">
<CommandPalette <CommandPalette

View File

@@ -57,6 +57,7 @@ interface DashboardProps {
isTopbarOpen: boolean; isTopbarOpen: boolean;
rightSidebarOpen?: boolean; rightSidebarOpen?: boolean;
rightSidebarWidth?: number; rightSidebarWidth?: number;
initialDbError?: string | null;
} }
export function Dashboard({ export function Dashboard({
@@ -66,13 +67,14 @@ export function Dashboard({
isTopbarOpen, isTopbarOpen,
rightSidebarOpen = false, rightSidebarOpen = false,
rightSidebarWidth = 400, rightSidebarWidth = 400,
initialDbError = null,
}: DashboardProps): React.ReactElement { }: DashboardProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const [loggedIn, setLoggedIn] = useState(isAuthenticated); const [loggedIn, setLoggedIn] = useState(isAuthenticated);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [, setUsername] = useState<string | null>(null); const [, setUsername] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null); const [userId, setUserId] = useState<string | null>(null);
const [dbError, setDbError] = useState<string | null>(null); const [dbError, setDbError] = useState<string | null>(initialDbError);
const [uptime, setUptime] = useState<string>("0d 0h 0m"); const [uptime, setUptime] = useState<string>("0d 0h 0m");
const [versionStatus, setVersionStatus] = useState< const [versionStatus, setVersionStatus] = useState<
@@ -160,7 +162,8 @@ export function Dashboard({
const uptimeInfo = await getUptime(); const uptimeInfo = await getUptime();
setUptime(uptimeInfo.formatted); setUptime(uptimeInfo.formatted);
const updateCheckDisabled = localStorage.getItem("disableUpdateCheck") === "true"; const updateCheckDisabled =
localStorage.getItem("disableUpdateCheck") === "true";
if (!updateCheckDisabled) { if (!updateCheckDisabled) {
const versionInfo = await getVersionInfo(); const versionInfo = await getVersionInfo();
setVersionText(`v${versionInfo.localVersion}`); setVersionText(`v${versionInfo.localVersion}`);
@@ -606,7 +609,9 @@ export function Dashboard({
{showNetworkGraph ? ( {showNetworkGraph ? (
<> <>
<Network className="mr-3" /> <Network className="mr-3" />
{t("dashboard.networkGraph", { defaultValue: "Network Graph" })} {t("dashboard.networkGraph", {
defaultValue: "Network Graph",
})}
</> </>
) : ( ) : (
<> <>
@@ -700,10 +705,7 @@ export function Dashboard({
> >
{recentActivityLoading ? ( {recentActivityLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse"> <div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
<Loader2 <Loader2 className="animate-spin mr-2" size={16} />
className="animate-spin mr-2"
size={16}
/>
<span>{t("dashboard.loadingRecentActivity")}</span> <span>{t("dashboard.loadingRecentActivity")}</span>
</div> </div>
) : recentActivity.length === 0 ? ( ) : recentActivity.length === 0 ? (

View File

@@ -34,6 +34,7 @@ import type {
} from "../../../../../types"; } from "../../../../../types";
import { CredentialGeneralTab } from "./tabs/CredentialGeneralTab"; import { CredentialGeneralTab } from "./tabs/CredentialGeneralTab";
import { CredentialAuthenticationTab } from "./tabs/CredentialAuthenticationTab"; import { CredentialAuthenticationTab } from "./tabs/CredentialAuthenticationTab";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
export function CredentialEditor({ export function CredentialEditor({
editingCredential, editingCredential,
@@ -59,6 +60,7 @@ export function CredentialEditor({
const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null); const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [activeTab, setActiveTab] = useState("general"); const [activeTab, setActiveTab] = useState("general");
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState< const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<
string | null string | null
@@ -168,6 +170,7 @@ export function CredentialEditor({
resolver: zodResolver(formSchema) as unknown as Parameters< resolver: zodResolver(formSchema) as unknown as Parameters<
typeof useForm<FormData> typeof useForm<FormData>
>[0]["resolver"], >[0]["resolver"],
mode: "all",
defaultValues: { defaultValues: {
name: "", name: "",
description: "", 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(() => { useEffect(() => {
if (editingCredential && fullCredentialDetails) { if (editingCredential && fullCredentialDetails) {
const defaultAuthType = fullCredentialDetails.authType; const defaultAuthType = fullCredentialDetails.authType;
@@ -331,6 +373,7 @@ export function CredentialEditor({
const onSubmit = async (data: FormData) => { const onSubmit = async (data: FormData) => {
try { try {
setIsSubmitting(true);
setFormError(null); setFormError(null);
if (!data.name || data.name.trim() === "") { if (!data.name || data.name.trim() === "") {
@@ -388,6 +431,8 @@ export function CredentialEditor({
} else { } else {
toast.error(t("credentials.failedToSaveCredential")); toast.error(t("credentials.failedToSaveCredential"));
} }
} finally {
setIsSubmitting(false);
} }
}; };
@@ -457,9 +502,19 @@ export function CredentialEditor({
return ( return (
<div <div
className="flex-1 flex flex-col h-full min-h-0 w-full" className="flex-1 flex flex-col h-full min-h-0 w-full relative"
key={editingCredential?.id || "new"} key={editingCredential?.id || "new"}
> >
<SimpleLoader
visible={isSubmitting}
message={
editingCredential
? t("credentials.updatingCredential")
: t("credentials.savingCredential")
}
backgroundColor="var(--bg-base)"
/>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit, handleFormError)} onSubmit={form.handleSubmit(onSubmit, handleFormError)}
@@ -529,7 +584,12 @@ export function CredentialEditor({
</ScrollArea> </ScrollArea>
<footer className="shrink-0 w-full pb-0"> <footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25" /> <Separator className="p-0.25" />
<Button className="translate-y-2" type="submit" variant="outline"> <Button
className="translate-y-2"
type="submit"
variant="outline"
disabled={!isFormValid || isSubmitting}
>
{editingCredential {editingCredential
? t("credentials.updateCredential") ? t("credentials.updateCredential")
: t("credentials.addCredential")} : t("credentials.addCredential")}

View File

@@ -1,4 +1,9 @@
import axios, { AxiosError, type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios"; import axios, {
AxiosError,
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
} from "axios";
import type { import type {
SSHHost, SSHHost,
SSHHostData, SSHHostData,
@@ -63,6 +68,7 @@ import {
dashboardLogger, dashboardLogger,
type LogContext, type LogContext,
} from "../lib/frontend-logger.js"; } from "../lib/frontend-logger.js";
import { dbHealthMonitor } from "../lib/db-health-monitor.js";
interface FileManagerOperation { interface FileManagerOperation {
name: string; name: string;
@@ -373,13 +379,17 @@ function createApiInstance(
logger.warn(`🐌 Slow request: ${responseTime}ms`, context); logger.warn(`🐌 Slow request: ${responseTime}ms`, context);
} }
dbHealthMonitor.reportDatabaseSuccess();
return response; return response;
}, },
(error: AxiosErrorExtended) => { (error: AxiosErrorExtended) => {
const endTime = performance.now(); const endTime = performance.now();
const startTime = error.config?.startTime; const startTime = error.config?.startTime;
const requestId = error.config?.requestId; const requestId = error.config?.requestId;
const responseTime = startTime ? Math.round(endTime - startTime) : undefined; const responseTime = startTime
? Math.round(endTime - startTime)
: undefined;
const method = error.config?.method?.toUpperCase() || "UNKNOWN"; const method = error.config?.method?.toUpperCase() || "UNKNOWN";
const url = error.config?.url || "UNKNOWN"; const url = error.config?.url || "UNKNOWN";
@@ -422,6 +432,8 @@ function createApiInstance(
} }
} }
dbHealthMonitor.reportDatabaseError(error);
if (status === 401) { if (status === 401) {
const errorCode = (error.response?.data as Record<string, unknown>) const errorCode = (error.response?.data as Record<string, unknown>)
?.code; ?.code;