diff --git a/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx b/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx index 4ba8091..a07b64c 100644 --- a/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx +++ b/app/_components/FeatureComponents/Cronjobs/CronJobList.tsx @@ -7,18 +7,30 @@ import { CardTitle, } from "@/app/_components/GlobalComponents/Cards/Card"; import { Button } from "@/app/_components/GlobalComponents/UIElements/Button"; -import { Clock, Plus, Archive } from "lucide-react"; +import { Switch } from "@/app/_components/GlobalComponents/UIElements/Switch"; +import { + Clock, + Plus, + Archive, + ChevronDown, + Code, + MessageSquare, + Settings, + Loader2, +} from "lucide-react"; import { CronJob } from "@/app/_utils/cronjob-utils"; import { Script } from "@/app/_utils/scripts-utils"; import { UserFilter } from "@/app/_components/FeatureComponents/User/UserFilter"; import { useCronJobState } from "@/app/_hooks/useCronJobState"; import { CronJobItem } from "@/app/_components/FeatureComponents/Cronjobs/Parts/CronJobItem"; +import { MinimalCronJobItem } from "@/app/_components/FeatureComponents/Cronjobs/Parts/MinimalCronJobItem"; import { CronJobEmptyState } from "@/app/_components/FeatureComponents/Cronjobs/Parts/CronJobEmptyState"; import { CronJobListModals } from "@/app/_components/FeatureComponents/Modals/CronJobListsModals"; import { LogsModal } from "@/app/_components/FeatureComponents/Modals/LogsModal"; import { LiveLogModal } from "@/app/_components/FeatureComponents/Modals/LiveLogModal"; import { RestoreBackupModal } from "@/app/_components/FeatureComponents/Modals/RestoreBackupModal"; +import { FiltersModal } from "@/app/_components/FeatureComponents/Modals/FiltersModal"; import { useTranslations } from "next-intl"; import { useSSEContext } from "@/app/_contexts/SSEContext"; import { useEffect, useState } from "react"; @@ -49,6 +61,39 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => { backedUpAt: string; }> >([]); + const [scheduleDisplayMode, setScheduleDisplayMode] = useState< + "cron" | "human" | "both" + >("both"); + const [loadedSettings, setLoadedSettings] = useState(false); + const [isFiltersModalOpen, setIsFiltersModalOpen] = useState(false); + const [minimalMode, setMinimalMode] = useState(false); + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + + try { + const savedScheduleMode = localStorage.getItem( + "cronjob-schedule-display-mode" + ); + if ( + savedScheduleMode === "cron" || + savedScheduleMode === "human" || + savedScheduleMode === "both" + ) { + setScheduleDisplayMode(savedScheduleMode); + } + + const savedMinimalMode = localStorage.getItem("cronjob-minimal-mode"); + if (savedMinimalMode === "true") { + setMinimalMode(true); + } + + setLoadedSettings(true); + } catch (error) { + console.warn("Failed to load settings from localStorage:", error); + } + }, []); useEffect(() => { const unsubscribe = subscribe((event) => { @@ -60,6 +105,32 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => { return unsubscribe; }, [subscribe, router]); + useEffect(() => { + if (!isClient) return; + + try { + localStorage.setItem( + "cronjob-schedule-display-mode", + scheduleDisplayMode + ); + } catch (error) { + console.warn( + "Failed to save schedule display mode to localStorage:", + error + ); + } + }, [scheduleDisplayMode, isClient]); + + useEffect(() => { + if (!isClient) return; + + try { + localStorage.setItem("cronjob-minimal-mode", minimalMode.toString()); + } catch (error) { + console.warn("Failed to save minimal mode to localStorage:", error); + } + }, [minimalMode, isClient]); + const loadBackupFiles = async () => { const backups = await fetchBackupFiles(); setBackupFiles(backups); @@ -182,14 +253,24 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
- +
+ + +
-
- +
+
+ + +
{filteredJobs.length === 0 ? ( @@ -215,27 +300,55 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => { onNewTaskClick={() => setIsNewCronModalOpen(true)} /> ) : ( -
- {filteredJobs.map((job) => ( - - ))} +
+ {loadedSettings ? ( + filteredJobs.map((job) => + minimalMode ? ( + + ) : ( + + ) + ) + ) : ( +
+ +
+ )}
)} @@ -305,6 +418,15 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => { onDelete={handleDeleteBackup} onRefresh={loadBackupFiles} /> + + setIsFiltersModalOpen(false)} + selectedUser={selectedUser} + onUserChange={setSelectedUser} + scheduleDisplayMode={scheduleDisplayMode} + onScheduleDisplayModeChange={setScheduleDisplayMode} + /> ); }; diff --git a/app/_components/FeatureComponents/Cronjobs/Parts/CronJobItem.tsx b/app/_components/FeatureComponents/Cronjobs/Parts/CronJobItem.tsx index 04e4879..23cd238 100644 --- a/app/_components/FeatureComponents/Cronjobs/Parts/CronJobItem.tsx +++ b/app/_components/FeatureComponents/Cronjobs/Parts/CronJobItem.tsx @@ -39,6 +39,7 @@ interface CronJobItemProps { errors: JobError[]; runningJobId: string | null; deletingId: string | null; + scheduleDisplayMode: "cron" | "human" | "both"; onRun: (id: string) => void; onEdit: (job: CronJob) => void; onClone: (job: CronJob) => void; @@ -57,6 +58,7 @@ export const CronJobItem = ({ errors, runningJobId, deletingId, + scheduleDisplayMode, onRun, onEdit, onClone, @@ -142,6 +144,7 @@ export const CronJobItem = ({ disabled: deletingId === job.id, }, ]; + return (
- - {job.schedule} - + {(scheduleDisplayMode === "cron" || + scheduleDisplayMode === "both") && ( + + {job.schedule} + + )} + {scheduleDisplayMode === "human" && cronExplanation?.isValid && ( +
+ +

+ {cronExplanation.humanReadable} +

+
+ )}
{commandCopied === job.id && ( @@ -175,6 +189,26 @@ export const CronJobItem = ({
+
+ {scheduleDisplayMode === "both" && cronExplanation?.isValid && ( +
+ +

+ {cronExplanation.humanReadable} +

+
+ )} + + {job.comment && ( +

+ {job.comment} +

+ )} +
+
@@ -265,26 +299,6 @@ export const CronJobItem = ({ /> )}
- - {job.comment && ( -
- {cronExplanation?.isValid && ( -
- -

- {cronExplanation.humanReadable} -

-
- )} - -

- {job.comment} -

-
- )}
@@ -329,21 +343,27 @@ export const CronJobItem = ({ + + + + + + +
+
+
+ ); +}; diff --git a/app/_components/FeatureComponents/Layout/TabbedInterface.tsx b/app/_components/FeatureComponents/Layout/TabbedInterface.tsx index f5c6d02..06d006d 100644 --- a/app/_components/FeatureComponents/Layout/TabbedInterface.tsx +++ b/app/_components/FeatureComponents/Layout/TabbedInterface.tsx @@ -28,10 +28,11 @@ export const TabbedInterface = ({
-
+
{activeTab === "cronjobs" ? ( ) : ( diff --git a/app/_components/FeatureComponents/Modals/FiltersModal.tsx b/app/_components/FeatureComponents/Modals/FiltersModal.tsx new file mode 100644 index 0000000..ddb9514 --- /dev/null +++ b/app/_components/FeatureComponents/Modals/FiltersModal.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal"; +import { Button } from "@/app/_components/GlobalComponents/UIElements/Button"; +import { ChevronDown, Code, MessageSquare } from "lucide-react"; +import { UserFilter } from "@/app/_components/FeatureComponents/User/UserFilter"; +import { useTranslations } from "next-intl"; + +interface FiltersModalProps { + isOpen: boolean; + onClose: () => void; + selectedUser: string | null; + onUserChange: (user: string | null) => void; + scheduleDisplayMode: "cron" | "human" | "both"; + onScheduleDisplayModeChange: (mode: "cron" | "human" | "both") => void; +} + +export const FiltersModal = ({ + isOpen, + onClose, + selectedUser, + onUserChange, + scheduleDisplayMode, + onScheduleDisplayModeChange, +}: FiltersModalProps) => { + const t = useTranslations(); + const [localScheduleMode, setLocalScheduleMode] = + useState(scheduleDisplayMode); + const [isScheduleDropdownOpen, setIsScheduleDropdownOpen] = useState(false); + + useEffect(() => { + setLocalScheduleMode(scheduleDisplayMode); + }, [scheduleDisplayMode]); + + const handleSave = () => { + onScheduleDisplayModeChange(localScheduleMode); + onClose(); + }; + + return ( + +
+
+
+ + +
+ +
+ +
+ + + {isScheduleDropdownOpen && ( +
+ + + +
+ )} +
+
+
+ +
+ + +
+
+
+ ); +}; diff --git a/app/_components/FeatureComponents/Modals/LiveLogModal.tsx b/app/_components/FeatureComponents/Modals/LiveLogModal.tsx index b9db445..3e75343 100644 --- a/app/_components/FeatureComponents/Modals/LiveLogModal.tsx +++ b/app/_components/FeatureComponents/Modals/LiveLogModal.tsx @@ -5,6 +5,7 @@ import { Loader2, CheckCircle2, XCircle } from "lucide-react"; import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal"; import { useSSEContext } from "@/app/_contexts/SSEContext"; import { SSEEvent } from "@/app/_utils/sse-events"; +import { usePageVisibility } from "@/app/_hooks/usePageVisibility"; interface LiveLogModalProps { isOpen: boolean; @@ -22,21 +23,45 @@ export const LiveLogModal = ({ jobComment, }: LiveLogModalProps) => { const [logContent, setLogContent] = useState(""); - const [status, setStatus] = useState<"running" | "completed" | "failed">("running"); + const [status, setStatus] = useState<"running" | "completed" | "failed">( + "running" + ); const [exitCode, setExitCode] = useState(null); const logEndRef = useRef(null); const { subscribe } = useSSEContext(); + const isPageVisible = usePageVisibility(); + const lastOffsetRef = useRef(0); + const abortControllerRef = useRef(null); useEffect(() => { - if (!isOpen || !runId) return; + if (!isOpen || !runId || !isPageVisible) return; + + lastOffsetRef.current = 0; + setLogContent(""); const fetchLogs = async () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + try { - const response = await fetch(`/api/logs/stream?runId=${runId}`); + const url = `/api/logs/stream?runId=${runId}&offset=${lastOffsetRef.current}`; + const response = await fetch(url, { + signal: abortController.signal, + }); const data = await response.json(); - if (data.content) { + if (data.fileSize !== undefined) { + lastOffsetRef.current = data.fileSize; + } + + if (lastOffsetRef.current === 0 && data.content) { setLogContent(data.content); + } else if (data.newContent) { + setLogContent((prev) => prev + data.newContent); } setStatus(data.status || "running"); @@ -44,17 +69,29 @@ export const LiveLogModal = ({ if (data.exitCode !== undefined) { setExitCode(data.exitCode); } - } catch (error) { - console.error("Failed to fetch logs:", error); + } catch (error: any) { + if (error.name !== "AbortError") { + console.error("Failed to fetch logs:", error); + } } }; fetchLogs(); - const interval = setInterval(fetchLogs, 2000); + let interval: NodeJS.Timeout | null = null; + if (isPageVisible) { + interval = setInterval(fetchLogs, 2000); + } - return () => clearInterval(interval); - }, [isOpen, runId]); + return () => { + if (interval) { + clearInterval(interval); + } + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, [isOpen, runId, isPageVisible]); useEffect(() => { if (!isOpen) return; @@ -65,8 +102,8 @@ export const LiveLogModal = ({ setExitCode(event.data.exitCode); fetch(`/api/logs/stream?runId=${runId}`) - .then(res => res.json()) - .then(data => { + .then((res) => res.json()) + .then((data) => { if (data.content) { setLogContent(data.content); } @@ -76,8 +113,8 @@ export const LiveLogModal = ({ setExitCode(event.data.exitCode); fetch(`/api/logs/stream?runId=${runId}`) - .then(res => res.json()) - .then(data => { + .then((res) => res.json()) + .then((data) => { if (data.content) { setLogContent(data.content); } @@ -127,7 +164,8 @@ export const LiveLogModal = ({
-            {logContent || "Waiting for job to start...\n\nLogs will appear here in real-time."}
+            {logContent ||
+              "Waiting for job to start...\n\nLogs will appear here in real-time."}
             
diff --git a/app/_components/FeatureComponents/System/SystemInfo.tsx b/app/_components/FeatureComponents/System/SystemInfo.tsx index 9af60d3..dd52fcd 100644 --- a/app/_components/FeatureComponents/System/SystemInfo.tsx +++ b/app/_components/FeatureComponents/System/SystemInfo.tsx @@ -4,13 +4,7 @@ import { MetricCard } from "@/app/_components/GlobalComponents/Cards/MetricCard" import { SystemStatus } from "@/app/_components/FeatureComponents/System/SystemStatus"; import { PerformanceSummary } from "@/app/_components/FeatureComponents/System/PerformanceSummary"; import { Sidebar } from "@/app/_components/FeatureComponents/Layout/Sidebar"; -import { - Clock, - HardDrive, - Cpu, - Monitor, - Wifi, -} from "lucide-react"; +import { Clock, HardDrive, Cpu, Monitor, Wifi } from "lucide-react"; interface SystemInfoType { hostname: string; @@ -54,10 +48,11 @@ interface SystemInfoType { details: string; }; } -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; import { useSSEContext } from "@/app/_contexts/SSEContext"; import { SSEEvent } from "@/app/_utils/sse-events"; +import { usePageVisibility } from "@/app/_hooks/usePageVisibility"; interface SystemInfoCardProps { systemInfo: SystemInfoType; @@ -72,20 +67,36 @@ export const SystemInfoCard = ({ const [isUpdating, setIsUpdating] = useState(false); const t = useTranslations(); const { subscribe } = useSSEContext(); + const isPageVisible = usePageVisibility(); + + const abortControllerRef = useRef(null); const updateSystemInfo = async () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + try { setIsUpdating(true); - const response = await fetch('/api/system-stats'); + const response = await fetch("/api/system-stats", { + signal: abortController.signal, + }); if (!response.ok) { - throw new Error('Failed to fetch system stats'); + throw new Error("Failed to fetch system stats"); } const freshData = await response.json(); setSystemInfo(freshData); - } catch (error) { - console.error("Failed to update system info:", error); + } catch (error: any) { + if (error.name !== "AbortError") { + console.error("Failed to update system info:", error); + } } finally { - setIsUpdating(false); + if (!abortController.signal.aborted) { + setIsUpdating(false); + } } }; @@ -105,30 +116,42 @@ export const SystemInfoCard = ({ }; updateTime(); - updateSystemInfo(); + + if (isPageVisible) { + updateSystemInfo(); + } const updateInterval = parseInt( process.env.NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL || "30000" ); let mounted = true; + let timeoutId: NodeJS.Timeout | null = null; const doUpdate = () => { - if (!mounted) return; + if (!mounted || !isPageVisible) return; updateTime(); updateSystemInfo().finally(() => { - if (mounted) { - setTimeout(doUpdate, updateInterval); + if (mounted && isPageVisible) { + timeoutId = setTimeout(doUpdate, updateInterval); } }); }; - setTimeout(doUpdate, updateInterval); + if (isPageVisible) { + timeoutId = setTimeout(doUpdate, updateInterval); + } return () => { mounted = false; + if (timeoutId) { + clearTimeout(timeoutId); + } + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } }; - }, []); + }, [isPageVisible]); const quickStats = { cpu: systemInfo.cpu.usage, @@ -176,14 +199,18 @@ export const SystemInfoCard = ({ status: systemInfo.gpu.status, color: "text-indigo-500", }, - ...(systemInfo.network ? [{ - icon: Wifi, - label: t("sidebar.network"), - value: `${systemInfo.network.latency}ms`, - detail: `${systemInfo.network.latency}ms latency • ${systemInfo.network.speed}`, - status: systemInfo.network.status, - color: "text-teal-500", - }] : []), + ...(systemInfo.network + ? [ + { + icon: Wifi, + label: t("sidebar.network"), + value: `${systemInfo.network.latency}ms`, + detail: `${systemInfo.network.latency}ms latency • ${systemInfo.network.speed}`, + status: systemInfo.network.status, + color: "text-teal-500", + }, + ] + : []), ]; const performanceMetrics = [ @@ -197,18 +224,19 @@ export const SystemInfoCard = ({ value: `${systemInfo.memory.usage}%`, status: systemInfo.memory.status, }, - ...(systemInfo.network ? [{ - label: t("sidebar.networkLatency"), - value: `${systemInfo.network.latency}ms`, - status: systemInfo.network.status, - }] : []), + ...(systemInfo.network + ? [ + { + label: t("sidebar.networkLatency"), + value: `${systemInfo.network.latency}ms`, + status: systemInfo.network.status, + }, + ] + : []), ]; return ( - + ); -} +}; diff --git a/app/_components/FeatureComponents/User/UserFilter.tsx b/app/_components/FeatureComponents/User/UserFilter.tsx index 4564ae2..e5fc27b 100644 --- a/app/_components/FeatureComponents/User/UserFilter.tsx +++ b/app/_components/FeatureComponents/User/UserFilter.tsx @@ -50,32 +50,33 @@ export const UserFilter = ({ return (
- - )} +
+
- + + {selectedUser && ( + + )} +
{isOpen && (
@@ -84,8 +85,9 @@ export const UserFilter = ({ onUserChange(null); setIsOpen(false); }} - className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${!selectedUser ? "bg-accent text-accent-foreground" : "" - }`} + className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${ + !selectedUser ? "bg-accent text-accent-foreground" : "" + }`} > {t("common.allUsers")} @@ -96,8 +98,9 @@ export const UserFilter = ({ onUserChange(user); setIsOpen(false); }} - className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${selectedUser === user ? "bg-accent text-accent-foreground" : "" - }`} + className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors ${ + selectedUser === user ? "bg-accent text-accent-foreground" : "" + }`} > {user} @@ -106,4 +109,4 @@ export const UserFilter = ({ )}
); -} +}; diff --git a/app/_components/GlobalComponents/UIElements/Switch.tsx b/app/_components/GlobalComponents/UIElements/Switch.tsx new file mode 100644 index 0000000..e92cf7c --- /dev/null +++ b/app/_components/GlobalComponents/UIElements/Switch.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { cn } from "@/app/_utils/global-utils"; + +interface SwitchProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + className?: string; + disabled?: boolean; +} + +export const Switch = ({ + checked, + onCheckedChange, + className = "", + disabled = false, +}: SwitchProps) => { + return ( + + ); +}; diff --git a/app/_contexts/SSEContext.tsx b/app/_contexts/SSEContext.tsx index a0257ea..3877013 100644 --- a/app/_contexts/SSEContext.tsx +++ b/app/_contexts/SSEContext.tsx @@ -1,7 +1,14 @@ "use client"; -import React, { createContext, useContext, useEffect, useRef, useState } from "react"; +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, +} from "react"; import { SSEEvent } from "@/app/_utils/sse-events"; +import { usePageVisibility } from "@/app/_hooks/usePageVisibility"; interface SSEContextType { isConnected: boolean; @@ -10,13 +17,22 @@ interface SSEContextType { const SSEContext = createContext(null); -export const SSEProvider: React.FC<{ children: React.ReactNode, liveUpdatesEnabled: boolean }> = ({ children, liveUpdatesEnabled }) => { +export const SSEProvider: React.FC<{ + children: React.ReactNode; + liveUpdatesEnabled: boolean; +}> = ({ children, liveUpdatesEnabled }) => { const [isConnected, setIsConnected] = useState(false); const eventSourceRef = useRef(null); const subscribersRef = useRef void>>(new Set()); + const isPageVisible = usePageVisibility(); useEffect(() => { - if (!liveUpdatesEnabled) { + if (!liveUpdatesEnabled || !isPageVisible) { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + setIsConnected(false); + } return; } @@ -30,7 +46,14 @@ export const SSEProvider: React.FC<{ children: React.ReactNode, liveUpdatesEnabl setIsConnected(false); }; - const eventTypes = ["job-started", "job-completed", "job-failed", "log-line", "system-stats", "heartbeat"]; + const eventTypes = [ + "job-started", + "job-completed", + "job-failed", + "log-line", + "system-stats", + "heartbeat", + ]; eventTypes.forEach((eventType) => { eventSource.addEventListener(eventType, (event: MessageEvent) => { @@ -48,7 +71,7 @@ export const SSEProvider: React.FC<{ children: React.ReactNode, liveUpdatesEnabl return () => { eventSource.close(); }; - }, []); + }, [liveUpdatesEnabled, isPageVisible]); const subscribe = (callback: (event: SSEEvent) => void) => { subscribersRef.current.add(callback); diff --git a/app/_hooks/usePageVisibility.ts b/app/_hooks/usePageVisibility.ts new file mode 100644 index 0000000..605283c --- /dev/null +++ b/app/_hooks/usePageVisibility.ts @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Hook to detect if the page/tab is currently visible to the user. + * Returns true when the page is visible, false when hidden (user switched tabs). + * + * Use this to pause polling, SSE connections, or other resource-intensive + * operations when the user is not actively viewing the page. + */ +export function usePageVisibility(): boolean { + const [isVisible, setIsVisible] = useState( + typeof document !== "undefined" ? !document.hidden : true + ); + + useEffect(() => { + const handleVisibilityChange = () => { + setIsVisible(!document.hidden); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, []); + + return isVisible; +} diff --git a/app/_translations/en.json b/app/_translations/en.json index 109a321..ede1747 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -76,7 +76,17 @@ "restoreThisBackup": "Restore this backup", "deleteBackup": "Delete backup", "confirmDeleteBackup": "Are you sure you want to delete this backup? This action cannot be undone.", - "backupDeleted": "Backup deleted successfully" + "backupDeleted": "Backup deleted successfully", + "filters": "Filters", + "filtersAndDisplay": "Filters & Display Options", + "filterByUser": "Filter by User", + "scheduleDisplay": "Schedule Display", + "cronSyntax": "Cron Syntax", + "humanReadable": "Human Readable", + "both": "Both", + "minimalMode": "Minimal Mode", + "minimalModeDescription": "Show compact view with icons instead of full text", + "applyFilters": "Apply Filters" }, "scripts": { "scripts": "Scripts", diff --git a/app/_translations/it.json b/app/_translations/it.json index d24db9f..9374890 100644 --- a/app/_translations/it.json +++ b/app/_translations/it.json @@ -73,7 +73,17 @@ "restoreThisBackup": "Ripristina questo backup", "deleteBackup": "Elimina backup", "confirmDeleteBackup": "Sei sicuro di voler eliminare questo backup? Questa azione non può essere annullata.", - "backupDeleted": "Backup eliminato con successo" + "backupDeleted": "Backup eliminato con successo", + "filters": "Filtri", + "filtersAndDisplay": "Filtri e Opzioni di Visualizzazione", + "filterByUser": "Filtra per Utente", + "scheduleDisplay": "Visualizzazione Pianificazione", + "cronSyntax": "Sintassi Cron", + "humanReadable": "Comprensibile", + "both": "Entrambi", + "minimalMode": "Modalità Minima", + "minimalModeDescription": "Mostra vista compatta con icone invece del testo completo", + "applyFilters": "Applica Filtri" }, "scripts": { "scripts": "Script", diff --git a/app/_utils/error-utils.ts b/app/_utils/error-utils.ts index 21b3b75..7723f8b 100644 --- a/app/_utils/error-utils.ts +++ b/app/_utils/error-utils.ts @@ -11,13 +11,44 @@ export interface JobError { } const STORAGE_KEY = "cronmaster-job-errors"; +const MAX_LOG_AGE_DAYS = parseInt( + process.env.NEXT_PUBLIC_MAX_LOG_AGE_DAYS || "30", + 10 +); + +/** + * Clean up old errors from localStorage based on MAX_LOG_AGE_DAYS. + * This is called automatically when getting errors. + */ +const cleanupOldErrors = (errors: JobError[]): JobError[] => { + const maxAgeMs = MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000; + const now = Date.now(); + + return errors.filter((error) => { + try { + const errorTime = new Date(error.timestamp).getTime(); + const age = now - errorTime; + return age < maxAgeMs; + } catch { + return true; + } + }); +}; export const getJobErrors = (): JobError[] => { if (typeof window === "undefined") return []; try { const stored = localStorage.getItem(STORAGE_KEY); - return stored ? JSON.parse(stored) : []; + const errors = stored ? JSON.parse(stored) : []; + + const cleanedErrors = cleanupOldErrors(errors); + + if (cleanedErrors.length !== errors.length) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(cleanedErrors)); + } + + return cleanedErrors; } catch { return []; } @@ -37,7 +68,7 @@ export const setJobError = (error: JobError) => { } localStorage.setItem(STORAGE_KEY, JSON.stringify(errors)); - } catch { } + } catch {} }; export const removeJobError = (errorId: string) => { @@ -47,7 +78,7 @@ export const removeJobError = (errorId: string) => { const errors = getJobErrors(); const filtered = errors.filter((e) => e.id !== errorId); localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); - } catch { } + } catch {} }; export const getJobErrorsByJobId = (jobId: string): JobError[] => { @@ -59,5 +90,5 @@ export const clearAllJobErrors = () => { try { localStorage.removeItem(STORAGE_KEY); - } catch { } + } catch {} }; diff --git a/app/api/logs/stream/route.ts b/app/api/logs/stream/route.ts index b4f61a7..21f2c09 100644 --- a/app/api/logs/stream/route.ts +++ b/app/api/logs/stream/route.ts @@ -14,6 +14,8 @@ export const GET = async (request: NextRequest) => { try { const searchParams = request.nextUrl.searchParams; const runId = searchParams.get("runId"); + const offsetStr = searchParams.get("offset"); + const offset = offsetStr ? parseInt(offsetStr, 10) : 0; if (!runId) { return NextResponse.json( @@ -68,14 +70,33 @@ export const GET = async (request: NextRequest) => { const sortedFiles = files.sort().reverse(); const latestLogFile = path.join(logDir, sortedFiles[0]); - const content = await readFile(latestLogFile, "utf-8"); + const fullContent = await readFile(latestLogFile, "utf-8"); + const fileSize = Buffer.byteLength(fullContent, "utf-8"); + + let content = fullContent; + let newContent = ""; + + if (offset > 0 && offset < fileSize) { + newContent = fullContent.slice(offset); + content = newContent; + } else if (offset === 0) { + content = fullContent; + newContent = fullContent; + } else if (offset >= fileSize) { + content = ""; + newContent = ""; + } return NextResponse.json({ status: job.status, content, + newContent, + fullContent: offset === 0 ? fullContent : undefined, logFile: sortedFiles[0], isComplete: job.status !== "running", exitCode: job.exitCode, + fileSize, + offset, }); } catch (error: any) { console.error("Error streaming log:", error); diff --git a/howto/ENV_VARIABLES.md b/howto/ENV_VARIABLES.md index e576f4f..c290c56 100644 --- a/howto/ENV_VARIABLES.md +++ b/howto/ENV_VARIABLES.md @@ -34,10 +34,11 @@ This document provides a comprehensive reference for all environment variables u ## Logging Configuration -| Variable | Default | Description | -| ------------------ | ------- | ---------------------------------------------- | -| `MAX_LOG_AGE_DAYS` | `30` | Days to keep job execution logs before cleanup | -| `MAX_LOGS_PER_JOB` | `50` | Maximum number of log files to keep per job | +| Variable | Default | Description | +| ------------------------------- | ------- | -------------------------------------------------------------------- | +| `MAX_LOG_AGE_DAYS` | `30` | Days to keep job execution logs before cleanup | +| `NEXT_PUBLIC_MAX_LOG_AGE_DAYS` | `30` | Days to keep error history in browser localStorage (client-side) | +| `MAX_LOGS_PER_JOB` | `50` | Maximum number of log files to keep per job | ## Authentication & Security