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",
"sshKeyRequired": "SSH key is required",
"credentialAddedSuccessfully": "Credential \"{{name}}\" added successfully",
"savingCredential": "Saving credential...",
"updatingCredential": "Updating credential...",
"general": "General",
"description": "Description",
"folder": "Folder",

View File

@@ -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 (
<div
className="h-screen w-screen flex items-center justify-center"
className="fixed inset-0 flex items-center justify-center"
style={{
background: "var(--bg-elevated)",
backgroundImage: `repeating-linear-gradient(
@@ -247,13 +284,39 @@ function AppContent() {
)`,
}}
>
<div className="text-center">
<div className="w-16 h-16 border-4 border-primary/30 border-t-primary rounded-full animate-spin mx-auto" />
<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="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>
);
}
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 (
<div className="h-screen w-screen overflow-hidden bg-background">
<CommandPalette

View File

@@ -57,6 +57,7 @@ interface DashboardProps {
isTopbarOpen: boolean;
rightSidebarOpen?: boolean;
rightSidebarWidth?: number;
initialDbError?: string | null;
}
export function Dashboard({
@@ -66,13 +67,14 @@ export function Dashboard({
isTopbarOpen,
rightSidebarOpen = false,
rightSidebarWidth = 400,
initialDbError = null,
}: DashboardProps): React.ReactElement {
const { t } = useTranslation();
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
const [isAdmin, setIsAdmin] = useState(false);
const [, setUsername] = 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 [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 ? (
<>
<Network className="mr-3" />
{t("dashboard.networkGraph", { defaultValue: "Network Graph" })}
{t("dashboard.networkGraph", {
defaultValue: "Network Graph",
})}
</>
) : (
<>
@@ -700,10 +705,7 @@ export function Dashboard({
>
{recentActivityLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
<Loader2
className="animate-spin mr-2"
size={16}
/>
<Loader2 className="animate-spin mr-2" size={16} />
<span>{t("dashboard.loadingRecentActivity")}</span>
</div>
) : recentActivity.length === 0 ? (

View File

@@ -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<NodeJS.Timeout | null>(null);
const [activeTab, setActiveTab] = useState("general");
const [formError, setFormError] = useState<string | null>(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<FormData>
>[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 (
<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"}
>
<SimpleLoader
visible={isSubmitting}
message={
editingCredential
? t("credentials.updatingCredential")
: t("credentials.savingCredential")
}
backgroundColor="var(--bg-base)"
/>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit, handleFormError)}
@@ -529,7 +584,12 @@ export function CredentialEditor({
</ScrollArea>
<footer className="shrink-0 w-full pb-0">
<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
? t("credentials.updateCredential")
: 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 {
SSHHost,
SSHHostData,
@@ -63,6 +68,7 @@ import {
dashboardLogger,
type LogContext,
} from "../lib/frontend-logger.js";
import { dbHealthMonitor } from "../lib/db-health-monitor.js";
interface FileManagerOperation {
name: string;
@@ -373,13 +379,17 @@ function createApiInstance(
logger.warn(`🐌 Slow request: ${responseTime}ms`, context);
}
dbHealthMonitor.reportDatabaseSuccess();
return response;
},
(error: AxiosErrorExtended) => {
const endTime = performance.now();
const startTime = error.config?.startTime;
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 url = error.config?.url || "UNKNOWN";
@@ -422,6 +432,8 @@ function createApiInstance(
}
}
dbHealthMonitor.reportDatabaseError(error);
if (status === 401) {
const errorCode = (error.response?.data as Record<string, unknown>)
?.code;