improve performance, add minimal mode, make UI make more sense

This commit is contained in:
fccview
2025-11-13 14:30:21 +00:00
parent 8faf4d26d0
commit ef5153ce54
16 changed files with 1007 additions and 170 deletions

View File

@@ -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}
/>
</>
);
};

View File

@@ -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" />
)}

View File

@@ -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>
);
};

View File

@@ -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} />
) : (

View 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>
);
};

View File

@@ -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>

View File

@@ -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>
);
}
};

View File

@@ -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>
);
}
};

View 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>
);
};

View File

@@ -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);

View 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;
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 {}
};

View File

@@ -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);

View File

@@ -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