mirror of
https://github.com/fccview/cronmaster.git
synced 2025-12-23 22:18:20 -05:00
improve performance, add minimal mode, make UI make more sense
This commit is contained in:
@@ -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<boolean>(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) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 w-full justify-between sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setIsBackupModalOpen(true)}
|
||||
variant="outline"
|
||||
className="btn-outline"
|
||||
>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
{t("cronjobs.backups")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setIsFiltersModalOpen(true)}
|
||||
variant="outline"
|
||||
className="btn-outline"
|
||||
title={t("cronjobs.filters")}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsBackupModalOpen(true)}
|
||||
variant="outline"
|
||||
className="btn-outline"
|
||||
>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
{t("cronjobs.backups")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsNewCronModalOpen(true)}
|
||||
className="btn-primary glow-primary"
|
||||
@@ -201,12 +282,16 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4">
|
||||
<UserFilter
|
||||
selectedUser={selectedUser}
|
||||
onUserChange={setSelectedUser}
|
||||
className="w-full sm:w-64"
|
||||
/>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground cursor-pointer"
|
||||
onClick={() => setMinimalMode(!minimalMode)}
|
||||
>
|
||||
{t("cronjobs.minimalMode")}
|
||||
</label>
|
||||
<Switch checked={minimalMode} onCheckedChange={setMinimalMode} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredJobs.length === 0 ? (
|
||||
@@ -215,27 +300,55 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
onNewTaskClick={() => setIsNewCronModalOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
{filteredJobs.map((job) => (
|
||||
<CronJobItem
|
||||
key={job.id}
|
||||
job={job}
|
||||
errors={jobErrors[job.id] || []}
|
||||
runningJobId={runningJobId}
|
||||
deletingId={deletingId}
|
||||
onRun={handleRunLocal}
|
||||
onEdit={handleEdit}
|
||||
onClone={confirmClone}
|
||||
onResume={handleResumeLocal}
|
||||
onPause={handlePauseLocal}
|
||||
onToggleLogging={handleToggleLoggingLocal}
|
||||
onViewLogs={handleViewLogs}
|
||||
onDelete={confirmDelete}
|
||||
onBackup={handleBackupLocal}
|
||||
onErrorClick={handleErrorClickLocal}
|
||||
onErrorDismiss={refreshJobErrorsLocal}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-3 max-h-[55vh] overflow-y-auto">
|
||||
{loadedSettings ? (
|
||||
filteredJobs.map((job) =>
|
||||
minimalMode ? (
|
||||
<MinimalCronJobItem
|
||||
key={job.id}
|
||||
job={job}
|
||||
errors={jobErrors[job.id] || []}
|
||||
runningJobId={runningJobId}
|
||||
deletingId={deletingId}
|
||||
scheduleDisplayMode={scheduleDisplayMode}
|
||||
onRun={handleRunLocal}
|
||||
onEdit={handleEdit}
|
||||
onClone={confirmClone}
|
||||
onResume={handleResumeLocal}
|
||||
onPause={handlePauseLocal}
|
||||
onToggleLogging={handleToggleLoggingLocal}
|
||||
onViewLogs={handleViewLogs}
|
||||
onDelete={confirmDelete}
|
||||
onBackup={handleBackupLocal}
|
||||
onErrorClick={handleErrorClickLocal}
|
||||
/>
|
||||
) : (
|
||||
<CronJobItem
|
||||
key={job.id}
|
||||
job={job}
|
||||
errors={jobErrors[job.id] || []}
|
||||
runningJobId={runningJobId}
|
||||
deletingId={deletingId}
|
||||
scheduleDisplayMode={scheduleDisplayMode}
|
||||
onRun={handleRunLocal}
|
||||
onEdit={handleEdit}
|
||||
onClone={confirmClone}
|
||||
onResume={handleResumeLocal}
|
||||
onPause={handlePauseLocal}
|
||||
onToggleLogging={handleToggleLoggingLocal}
|
||||
onViewLogs={handleViewLogs}
|
||||
onDelete={confirmDelete}
|
||||
onBackup={handleBackupLocal}
|
||||
onErrorClick={handleErrorClickLocal}
|
||||
onErrorDismiss={refreshJobErrorsLocal}
|
||||
/>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full min-h-[55vh]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -305,6 +418,15 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
|
||||
onDelete={handleDeleteBackup}
|
||||
onRefresh={loadBackupFiles}
|
||||
/>
|
||||
|
||||
<FiltersModal
|
||||
isOpen={isFiltersModalOpen}
|
||||
onClose={() => setIsFiltersModalOpen(false)}
|
||||
selectedUser={selectedUser}
|
||||
onUserChange={setSelectedUser}
|
||||
scheduleDisplayMode={scheduleDisplayMode}
|
||||
onScheduleDisplayModeChange={setScheduleDisplayMode}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
key={job.id}
|
||||
@@ -152,9 +155,20 @@ export const CronJobItem = ({
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<code className="text-sm bg-purple-500/10 text-purple-600 dark:text-purple-400 px-2 py-1 rounded font-mono border border-purple-500/20">
|
||||
{job.schedule}
|
||||
</code>
|
||||
{(scheduleDisplayMode === "cron" ||
|
||||
scheduleDisplayMode === "both") && (
|
||||
<code className="text-sm bg-purple-500/10 text-purple-600 dark:text-purple-400 px-2 py-1 rounded font-mono border border-purple-500/20">
|
||||
{job.schedule}
|
||||
</code>
|
||||
)}
|
||||
{scheduleDisplayMode === "human" && cronExplanation?.isValid && (
|
||||
<div className="flex items-start gap-1.5 border-b border-primary/30 bg-primary/10 rounded text-primary px-2 py-0.5">
|
||||
<Info className="h-3 w-3 text-primary mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm italic">
|
||||
{cronExplanation.humanReadable}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0 w-full">
|
||||
{commandCopied === job.id && (
|
||||
@@ -175,6 +189,26 @@ export const CronJobItem = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pb-2 pt-4">
|
||||
{scheduleDisplayMode === "both" && cronExplanation?.isValid && (
|
||||
<div className="flex items-start gap-1.5 border-b border-primary/30 bg-primary/10 rounded text-primary px-2 py-0.5">
|
||||
<Info className="h-3 w-3 text-primary mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs italic">
|
||||
{cronExplanation.humanReadable}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.comment && (
|
||||
<p
|
||||
className="text-xs text-muted-foreground italic truncate"
|
||||
title={job.comment}
|
||||
>
|
||||
{job.comment}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 py-3">
|
||||
<div className="flex items-center gap-1 text-xs bg-muted/50 text-muted-foreground px-2 py-0.5 rounded border border-border/30 cursor-pointer hover:bg-muted/70 transition-colors relative">
|
||||
<User className="h-3 w-3" />
|
||||
@@ -265,26 +299,6 @@ export const CronJobItem = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{job.comment && (
|
||||
<div className="flex items-center gap-2 pb-2 pt-4">
|
||||
{cronExplanation?.isValid && (
|
||||
<div className="flex items-start gap-1.5 border-b border-primary/30 bg-primary/10 rounded text-primary px-2 py-0.5">
|
||||
<Info className="h-3 w-3 text-primary mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs italic">
|
||||
{cronExplanation.humanReadable}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p
|
||||
className="text-xs text-muted-foreground italic truncate"
|
||||
title={job.comment}
|
||||
>
|
||||
{job.comment}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 justify-between sm:justify-end">
|
||||
@@ -329,21 +343,27 @@ export const CronJobItem = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onToggleLogging(job.id)}
|
||||
onClick={() => {
|
||||
if (job.logsEnabled) {
|
||||
onViewLogs(job);
|
||||
} else {
|
||||
onToggleLogging(job.id);
|
||||
}
|
||||
}}
|
||||
className="btn-outline h-8 px-3"
|
||||
title={
|
||||
job.logsEnabled
|
||||
? t("cronjobs.disableLogging")
|
||||
? t("cronjobs.viewLogs")
|
||||
: t("cronjobs.enableLogging")
|
||||
}
|
||||
aria-label={
|
||||
job.logsEnabled
|
||||
? t("cronjobs.disableLogging")
|
||||
? t("cronjobs.viewLogs")
|
||||
: t("cronjobs.enableLogging")
|
||||
}
|
||||
>
|
||||
{job.logsEnabled ? (
|
||||
<FileX className="h-3 w-3" />
|
||||
<FileText className="h-3 w-3" />
|
||||
) : (
|
||||
<FileOutput className="h-3 w-3" />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
|
||||
import { DropdownMenu } from "@/app/_components/GlobalComponents/UIElements/DropdownMenu";
|
||||
import {
|
||||
Trash2,
|
||||
Edit,
|
||||
Files,
|
||||
Play,
|
||||
Pause,
|
||||
Code,
|
||||
Info,
|
||||
Download,
|
||||
Check,
|
||||
FileX,
|
||||
FileText,
|
||||
FileOutput,
|
||||
} from "lucide-react";
|
||||
import { CronJob } from "@/app/_utils/cronjob-utils";
|
||||
import { JobError } from "@/app/_utils/error-utils";
|
||||
import {
|
||||
parseCronExpression,
|
||||
type CronExplanation,
|
||||
} from "@/app/_utils/parser-utils";
|
||||
import { unwrapCommand } from "@/app/_utils/wrapper-utils-client";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { copyToClipboard } from "@/app/_utils/global-utils";
|
||||
|
||||
interface MinimalCronJobItemProps {
|
||||
job: CronJob;
|
||||
errors: JobError[];
|
||||
runningJobId: string | null;
|
||||
deletingId: string | null;
|
||||
scheduleDisplayMode: "cron" | "human" | "both";
|
||||
onRun: (id: string) => void;
|
||||
onEdit: (job: CronJob) => void;
|
||||
onClone: (job: CronJob) => void;
|
||||
onResume: (id: string) => void;
|
||||
onPause: (id: string) => void;
|
||||
onDelete: (job: CronJob) => void;
|
||||
onToggleLogging: (id: string) => void;
|
||||
onViewLogs: (job: CronJob) => void;
|
||||
onBackup: (id: string) => void;
|
||||
onErrorClick: (error: JobError) => void;
|
||||
}
|
||||
|
||||
export const MinimalCronJobItem = ({
|
||||
job,
|
||||
errors,
|
||||
runningJobId,
|
||||
deletingId,
|
||||
scheduleDisplayMode,
|
||||
onRun,
|
||||
onEdit,
|
||||
onClone,
|
||||
onResume,
|
||||
onPause,
|
||||
onDelete,
|
||||
onToggleLogging,
|
||||
onViewLogs,
|
||||
onBackup,
|
||||
onErrorClick,
|
||||
}: MinimalCronJobItemProps) => {
|
||||
const [cronExplanation, setCronExplanation] =
|
||||
useState<CronExplanation | null>(null);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [commandCopied, setCommandCopied] = useState<string | null>(null);
|
||||
const locale = useLocale();
|
||||
const t = useTranslations();
|
||||
const displayCommand = unwrapCommand(job.command);
|
||||
|
||||
useEffect(() => {
|
||||
if (job.schedule) {
|
||||
const explanation = parseCronExpression(job.schedule, locale);
|
||||
setCronExplanation(explanation);
|
||||
} else {
|
||||
setCronExplanation(null);
|
||||
}
|
||||
}, [job.schedule]);
|
||||
|
||||
const dropdownMenuItems = [
|
||||
{
|
||||
label: t("cronjobs.editCronJob"),
|
||||
icon: <Edit className="h-3 w-3" />,
|
||||
onClick: () => onEdit(job),
|
||||
},
|
||||
{
|
||||
label: job.logsEnabled
|
||||
? t("cronjobs.disableLogging")
|
||||
: t("cronjobs.enableLogging"),
|
||||
icon: job.logsEnabled ? (
|
||||
<FileX className="h-3 w-3" />
|
||||
) : (
|
||||
<Code className="h-3 w-3" />
|
||||
),
|
||||
onClick: () => onToggleLogging(job.id),
|
||||
},
|
||||
...(job.logsEnabled
|
||||
? [
|
||||
{
|
||||
label: t("cronjobs.viewLogs"),
|
||||
icon: <Code className="h-3 w-3" />,
|
||||
onClick: () => onViewLogs(job),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: job.paused
|
||||
? t("cronjobs.resumeCronJob")
|
||||
: t("cronjobs.pauseCronJob"),
|
||||
icon: job.paused ? (
|
||||
<Play className="h-3 w-3" />
|
||||
) : (
|
||||
<Pause className="h-3 w-3" />
|
||||
),
|
||||
onClick: () => (job.paused ? onResume(job.id) : onPause(job.id)),
|
||||
},
|
||||
{
|
||||
label: t("cronjobs.cloneCronJob"),
|
||||
icon: <Files className="h-3 w-3" />,
|
||||
onClick: () => onClone(job),
|
||||
},
|
||||
{
|
||||
label: t("cronjobs.backupJob"),
|
||||
icon: <Download className="h-3 w-3" />,
|
||||
onClick: () => onBackup(job.id),
|
||||
},
|
||||
{
|
||||
label: t("cronjobs.deleteCronJob"),
|
||||
icon: <Trash2 className="h-3 w-3" />,
|
||||
onClick: () => onDelete(job),
|
||||
variant: "destructive" as const,
|
||||
disabled: deletingId === job.id,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
className={`glass-card p-3 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors ${
|
||||
isDropdownOpen ? "relative z-10" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Schedule display - minimal */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{scheduleDisplayMode === "cron" && (
|
||||
<code className="text-xs bg-purple-500/10 text-purple-600 dark:text-purple-400 px-1.5 py-0.5 rounded font-mono border border-purple-500/20">
|
||||
{job.schedule}
|
||||
</code>
|
||||
)}
|
||||
{scheduleDisplayMode === "human" && cronExplanation?.isValid && (
|
||||
<div className="flex items-center gap-1 border-b border-primary/30 bg-primary/10 rounded text-primary px-1.5 py-0.5">
|
||||
<Info className="h-3 w-3 text-primary flex-shrink-0" />
|
||||
<span className="text-xs italic truncate max-w-32">
|
||||
{cronExplanation.humanReadable}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{scheduleDisplayMode === "both" && (
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs bg-purple-500/10 text-purple-600 dark:text-purple-400 px-1 py-0.5 rounded font-mono border border-purple-500/20">
|
||||
{job.schedule}
|
||||
</code>
|
||||
{cronExplanation?.isValid && (
|
||||
<div
|
||||
className="flex items-center gap-1 border-b border-primary/30 bg-primary/10 rounded text-primary px-1 py-0.5 cursor-help"
|
||||
title={cronExplanation.humanReadable}
|
||||
>
|
||||
<Info className="h-2.5 w-2.5 text-primary flex-shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{commandCopied === job.id && (
|
||||
<Check className="h-3 w-3 text-green-600 flex-shrink-0" />
|
||||
)}
|
||||
<pre
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(unwrapCommand(job.command));
|
||||
setCommandCopied(job.id);
|
||||
setTimeout(() => setCommandCopied(null), 3000);
|
||||
}}
|
||||
className="flex-1 cursor-pointer overflow-hidden text-sm font-medium text-foreground bg-muted/30 px-2 py-1 rounded border border-border/30 truncate"
|
||||
title={unwrapCommand(job.command)}
|
||||
>
|
||||
{unwrapCommand(displayCommand)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{job.logsEnabled && (
|
||||
<div
|
||||
className="w-2 h-2 bg-blue-500 rounded-full"
|
||||
title={t("cronjobs.logged")}
|
||||
/>
|
||||
)}
|
||||
{job.paused && (
|
||||
<div
|
||||
className="w-2 h-2 bg-yellow-500 rounded-full"
|
||||
title={t("cronjobs.paused")}
|
||||
/>
|
||||
)}
|
||||
{!job.logError?.hasError && job.logsEnabled && (
|
||||
<div
|
||||
className="w-2 h-2 bg-green-500 rounded-full"
|
||||
title={t("cronjobs.healthy")}
|
||||
/>
|
||||
)}
|
||||
{job.logsEnabled && job.logError?.hasError && (
|
||||
<div
|
||||
className="w-2 h-2 bg-red-500 rounded-full cursor-pointer"
|
||||
title="Latest execution failed - Click to view error log"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewLogs(job);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!job.logsEnabled && errors.length > 0 && (
|
||||
<div
|
||||
className="w-2 h-2 bg-orange-500 rounded-full cursor-pointer"
|
||||
title={`${errors.length} error(s)`}
|
||||
onClick={(e) => onErrorClick(errors[0])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRun(job.id)}
|
||||
disabled={runningJobId === job.id || job.paused}
|
||||
className="h-6 w-6 p-0"
|
||||
title={t("cronjobs.runCronManually")}
|
||||
>
|
||||
{runningJobId === job.id ? (
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<Code className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (job.paused) {
|
||||
onResume(job.id);
|
||||
} else {
|
||||
onPause(job.id);
|
||||
}
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
title={t("cronjobs.pauseCronJob")}
|
||||
>
|
||||
{job.paused ? (
|
||||
<Play className="h-3 w-3" />
|
||||
) : (
|
||||
<Pause className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (job.logsEnabled) {
|
||||
onViewLogs(job);
|
||||
} else {
|
||||
onToggleLogging(job.id);
|
||||
}
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
title={
|
||||
job.logsEnabled
|
||||
? t("cronjobs.viewLogs")
|
||||
: t("cronjobs.enableLogging")
|
||||
}
|
||||
>
|
||||
{job.logsEnabled ? (
|
||||
<FileText className="h-3 w-3" />
|
||||
) : (
|
||||
<FileOutput className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<DropdownMenu
|
||||
items={dropdownMenuItems}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -28,10 +28,11 @@ export const TabbedInterface = ({
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab("cronjobs")}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${activeTab === "cronjobs"
|
||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${
|
||||
activeTab === "cronjobs"
|
||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
{t("cronjobs.cronJobs")}
|
||||
@@ -41,10 +42,11 @@ export const TabbedInterface = ({
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("scripts")}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${activeTab === "scripts"
|
||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-all duration-200 rounded-md flex-1 justify-center ${
|
||||
activeTab === "scripts"
|
||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
{t("scripts.scripts")}
|
||||
@@ -55,7 +57,7 @@ export const TabbedInterface = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[400px]">
|
||||
<div className="min-h-[60vh]">
|
||||
{activeTab === "cronjobs" ? (
|
||||
<CronJobList cronJobs={cronJobs} scripts={scripts} />
|
||||
) : (
|
||||
|
||||
158
app/_components/FeatureComponents/Modals/FiltersModal.tsx
Normal file
158
app/_components/FeatureComponents/Modals/FiltersModal.tsx
Normal file
@@ -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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t("cronjobs.filtersAndDisplay")}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
{t("cronjobs.filterByUser")}
|
||||
</label>
|
||||
<UserFilter
|
||||
selectedUser={selectedUser}
|
||||
onUserChange={onUserChange}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
{t("cronjobs.scheduleDisplay")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setIsScheduleDropdownOpen(!isScheduleDropdownOpen)
|
||||
}
|
||||
className="btn-outline w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{localScheduleMode === "cron" && (
|
||||
<Code className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{localScheduleMode === "human" && (
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{localScheduleMode === "both" && (
|
||||
<>
|
||||
<Code className="h-4 w-4 mr-1" />
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
</>
|
||||
)}
|
||||
<span>
|
||||
{localScheduleMode === "cron" && t("cronjobs.cronSyntax")}
|
||||
{localScheduleMode === "human" &&
|
||||
t("cronjobs.humanReadable")}
|
||||
{localScheduleMode === "both" && t("cronjobs.both")}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
|
||||
{isScheduleDropdownOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-50 min-w-[140px]">
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocalScheduleMode("cron");
|
||||
setIsScheduleDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex items-center gap-2 ${
|
||||
localScheduleMode === "cron"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Code className="h-3 w-3" />
|
||||
{t("cronjobs.cronSyntax")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocalScheduleMode("human");
|
||||
setIsScheduleDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex items-center gap-2 ${
|
||||
localScheduleMode === "human"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{t("cronjobs.humanReadable")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocalScheduleMode("both");
|
||||
setIsScheduleDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent transition-colors flex items-center gap-2 ${
|
||||
localScheduleMode === "both"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Code className="h-3 w-3" />
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{t("cronjobs.both")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-border">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button className="btn-primary" onClick={handleSave}>
|
||||
{t("cronjobs.applyFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -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<string>("");
|
||||
const [status, setStatus] = useState<"running" | "completed" | "failed">("running");
|
||||
const [status, setStatus] = useState<"running" | "completed" | "failed">(
|
||||
"running"
|
||||
);
|
||||
const [exitCode, setExitCode] = useState<number | null>(null);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const { subscribe } = useSSEContext();
|
||||
const isPageVisible = usePageVisibility();
|
||||
const lastOffsetRef = useRef<number>(0);
|
||||
const abortControllerRef = useRef<AbortController | null>(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 = ({
|
||||
<div className="space-y-4">
|
||||
<div className="bg-black/90 dark:bg-black/60 rounded-lg p-4 max-h-[60vh] overflow-auto">
|
||||
<pre className="text-xs font-mono text-green-400 whitespace-pre-wrap break-words">
|
||||
{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."}
|
||||
<div ref={logEndRef} />
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -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<AbortController | null>(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 (
|
||||
<Sidebar
|
||||
defaultCollapsed={false}
|
||||
quickStats={quickStats}
|
||||
>
|
||||
<Sidebar defaultCollapsed={false} quickStats={quickStats}>
|
||||
<SystemStatus
|
||||
status={systemInfo.systemStatus.overall}
|
||||
details={systemInfo.systemStatus.details}
|
||||
@@ -262,7 +290,7 @@ export const SystemInfoCard = ({
|
||||
{t("sidebar.statsUpdateEvery")}{" "}
|
||||
{Math.round(
|
||||
parseInt(process.env.NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL || "30000") /
|
||||
1000
|
||||
1000
|
||||
)}
|
||||
s • {t("sidebar.networkSpeedEstimatedFromLatency")}
|
||||
{isUpdating && (
|
||||
@@ -271,4 +299,4 @@ export const SystemInfoCard = ({
|
||||
</div>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,32 +50,33 @@ export const UserFilter = ({
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
{selectedUser ? `${t("common.userWithUsername", { user: selectedUser })}` : t("common.allUsers")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectedUser && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUserChange(null);
|
||||
}}
|
||||
className="p-1 hover:bg-accent rounded"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex-1 justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
{selectedUser
|
||||
? `${t("common.userWithUsername", { user: selectedUser })}`
|
||||
: t("common.allUsers")}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</div>
|
||||
</Button>
|
||||
</Button>
|
||||
{selectedUser && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onUserChange(null)}
|
||||
className="p-2 h-8 w-8 flex-shrink-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-50 max-h-48 overflow-y-auto">
|
||||
@@ -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")}
|
||||
</button>
|
||||
@@ -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}
|
||||
</button>
|
||||
@@ -106,4 +109,4 @@ export const UserFilter = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
35
app/_components/GlobalComponents/UIElements/Switch.tsx
Normal file
35
app/_components/GlobalComponents/UIElements/Switch.tsx
Normal file
@@ -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 (
|
||||
<label
|
||||
className={cn(
|
||||
"relative inline-flex items-center cursor-pointer",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckedChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="w-9 h-5 bg-muted peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary/25 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary peer-disabled:opacity-50 peer-disabled:cursor-not-allowed"></div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -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<SSEContextType | null>(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<EventSource | null>(null);
|
||||
const subscribersRef = useRef<Set<(event: SSEEvent) => 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);
|
||||
|
||||
30
app/_hooks/usePageVisibility.ts
Normal file
30
app/_hooks/usePageVisibility.ts
Normal file
@@ -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<boolean>(
|
||||
typeof document !== "undefined" ? !document.hidden : true
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
setIsVisible(!document.hidden);
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isVisible;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user