feat: update credential editor to use submitting system and add health monitor
This commit is contained in:
90
src/lib/db-health-monitor.ts
Normal file
90
src/lib/db-health-monitor.ts
Normal 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();
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user