diff --git a/README.md b/README.md
index 8dcc625..281bb6a 100644
--- a/README.md
+++ b/README.md
@@ -537,6 +537,11 @@ I would like to thank the following members for raising issues and help test/deb
Navino16
+
,
- onClick: () => onViewLogs(job),
- },
- ]
+ {
+ label: t("cronjobs.viewLogs"),
+ icon: ,
+ onClick: () => onViewLogs(job),
+ },
+ ]
: []),
{
label: job.paused
@@ -139,12 +139,10 @@ export const MinimalCronJobItem = ({
return (
diff --git a/app/_components/FeatureComponents/LoginForm/LoginForm.tsx b/app/_components/FeatureComponents/LoginForm/LoginForm.tsx
index b611c98..fb5fb6a 100644
--- a/app/_components/FeatureComponents/LoginForm/LoginForm.tsx
+++ b/app/_components/FeatureComponents/LoginForm/LoginForm.tsx
@@ -1,7 +1,7 @@
"use client";
-import { useState } from "react";
-import { useRouter } from "next/navigation";
+import { useState, useEffect } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
@@ -12,26 +12,44 @@ import {
CardDescription,
CardContent,
} from "@/app/_components/GlobalComponents/Cards/Card";
-import { Lock, Eye, EyeOff, Shield, AlertTriangle } from "lucide-react";
+import { Lock, Eye, EyeOff, Shield, AlertTriangle, Loader2 } from "lucide-react";
interface LoginFormProps {
hasPassword?: boolean;
hasOIDC?: boolean;
+ oidcAutoRedirect?: boolean;
version?: string;
}
export const LoginForm = ({
hasPassword = false,
hasOIDC = false,
+ oidcAutoRedirect = false,
version,
}: LoginFormProps) => {
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
+ const [isRedirecting, setIsRedirecting] = useState(false);
const router = useRouter();
+ const searchParams = useSearchParams();
const t = useTranslations();
+ useEffect(() => {
+ const errorParam = searchParams.get("error");
+
+ if (errorParam) {
+ setError(decodeURIComponent(errorParam));
+ return;
+ }
+
+ if (oidcAutoRedirect && !hasPassword && hasOIDC) {
+ setIsRedirecting(true);
+ window.location.href = "/api/oidc/login";
+ }
+ }, [oidcAutoRedirect, hasPassword, hasOIDC, searchParams]);
+
const handlePasswordSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
@@ -65,6 +83,24 @@ export const LoginForm = ({
window.location.href = "/api/oidc/login";
};
+ if (isRedirecting) {
+ return (
+
+
+
+
+
+ {t("login.redirectingToOIDC")}
+
+ {t("login.pleaseWait")}
+
+
+
+
+
+ );
+ }
+
return (
diff --git a/app/_components/FeatureComponents/Modals/CreateScriptModal.tsx b/app/_components/FeatureComponents/Modals/CreateScriptModal.tsx
index 5bc0182..74905c7 100644
--- a/app/_components/FeatureComponents/Modals/CreateScriptModal.tsx
+++ b/app/_components/FeatureComponents/Modals/CreateScriptModal.tsx
@@ -15,6 +15,8 @@ interface CreateScriptModalProps {
content: string;
};
onFormChange: (updates: Partial) => void;
+ isDraft?: boolean;
+ onClearDraft?: () => void;
}
export const CreateScriptModal = ({
@@ -23,6 +25,8 @@ export const CreateScriptModal = ({
onSubmit,
form,
onFormChange,
+ isDraft,
+ onClearDraft,
}: CreateScriptModalProps) => {
return (
}
form={form}
onFormChange={onFormChange}
+ isDraft={isDraft}
+ onClearDraft={onClearDraft}
/>
);
}
diff --git a/app/_components/FeatureComponents/Modals/LiveLogModal.tsx b/app/_components/FeatureComponents/Modals/LiveLogModal.tsx
index 3e75343..e123772 100644
--- a/app/_components/FeatureComponents/Modals/LiveLogModal.tsx
+++ b/app/_components/FeatureComponents/Modals/LiveLogModal.tsx
@@ -1,8 +1,9 @@
"use client";
import { useState, useEffect, useRef } from "react";
-import { Loader2, CheckCircle2, XCircle } from "lucide-react";
+import { Loader2, CheckCircle2, XCircle, AlertTriangle, Minimize2, Maximize2 } from "lucide-react";
import { Modal } from "@/app/_components/GlobalComponents/UIElements/Modal";
+import { Button } from "@/app/_components/GlobalComponents/UIElements/Button";
import { useSSEContext } from "@/app/_contexts/SSEContext";
import { SSEEvent } from "@/app/_utils/sse-events";
import { usePageVisibility } from "@/app/_hooks/usePageVisibility";
@@ -15,6 +16,9 @@ interface LiveLogModalProps {
jobComment?: string;
}
+const MAX_LINES_FULL_RENDER = 10000;
+const TAIL_LINES = 5000;
+
export const LiveLogModal = ({
isOpen,
onClose,
@@ -27,17 +31,29 @@ export const LiveLogModal = ({
"running"
);
const [exitCode, setExitCode] = useState(null);
+ const [tailMode, setTailMode] = useState(false);
+ const [showSizeWarning, setShowSizeWarning] = useState(false);
const logEndRef = useRef(null);
const { subscribe } = useSSEContext();
const isPageVisible = usePageVisibility();
const lastOffsetRef = useRef(0);
const abortControllerRef = useRef(null);
+ const [fileSize, setFileSize] = useState(0);
+ const [lineCount, setLineCount] = useState(0);
useEffect(() => {
- if (!isOpen || !runId || !isPageVisible) return;
+ if (isOpen) {
+ lastOffsetRef.current = 0;
+ setLogContent("");
+ setTailMode(false);
+ setShowSizeWarning(false);
+ setFileSize(0);
+ setLineCount(0);
+ }
+ }, [isOpen, runId]);
- lastOffsetRef.current = 0;
- setLogContent("");
+ useEffect(() => {
+ if (!isOpen || !runId || !isPageVisible) return;
const fetchLogs = async () => {
if (abortControllerRef.current) {
@@ -56,12 +72,51 @@ export const LiveLogModal = ({
if (data.fileSize !== undefined) {
lastOffsetRef.current = data.fileSize;
+ setFileSize(data.fileSize);
+
+ if (data.fileSize > 10 * 1024 * 1024 && !showSizeWarning) {
+ setShowSizeWarning(true);
+ }
}
if (lastOffsetRef.current === 0 && data.content) {
- setLogContent(data.content);
+ const lines = data.content.split("\n");
+ setLineCount(lines.length);
+
+ if (lines.length > MAX_LINES_FULL_RENDER) {
+ setTailMode(true);
+ setShowSizeWarning(true);
+ setLogContent(lines.slice(-TAIL_LINES).join("\n"));
+ } else {
+ setLogContent(data.content);
+ }
} else if (data.newContent) {
- setLogContent((prev) => prev + data.newContent);
+ setLogContent((prev) => {
+ const newContent = prev + data.newContent;
+ const lines = newContent.split("\n");
+ setLineCount(lines.length);
+
+ if (lines.length > MAX_LINES_FULL_RENDER && !tailMode) {
+ setTailMode(true);
+ setShowSizeWarning(true);
+ return lines.slice(-TAIL_LINES).join("\n");
+ }
+
+ if (tailMode && lines.length > TAIL_LINES) {
+ return lines.slice(-TAIL_LINES).join("\n");
+ }
+
+ const maxLength = 50 * 1024 * 1024;
+ if (newContent.length > maxLength) {
+ setTailMode(true);
+ setShowSizeWarning(true);
+ const truncated = newContent.slice(-maxLength + 200);
+ const truncatedLines = truncated.split("\n");
+ return truncatedLines.slice(-TAIL_LINES).join("\n");
+ }
+
+ return newContent;
+ });
}
setStatus(data.status || "running");
@@ -80,7 +135,7 @@ export const LiveLogModal = ({
let interval: NodeJS.Timeout | null = null;
if (isPageVisible) {
- interval = setInterval(fetchLogs, 2000);
+ interval = setInterval(fetchLogs, 3000);
}
return () => {
@@ -91,7 +146,7 @@ export const LiveLogModal = ({
abortControllerRef.current.abort();
}
};
- }, [isOpen, runId, isPageVisible]);
+ }, [isOpen, runId, isPageVisible, showSizeWarning, tailMode]);
useEffect(() => {
if (!isOpen) return;
@@ -101,34 +156,64 @@ export const LiveLogModal = ({
setStatus("completed");
setExitCode(event.data.exitCode);
- fetch(`/api/logs/stream?runId=${runId}`)
+ fetch(`/api/logs/stream?runId=${runId}&offset=0`)
.then((res) => res.json())
.then((data) => {
if (data.content) {
- setLogContent(data.content);
+ const lines = data.content.split("\n");
+ setLineCount(lines.length);
+ if (tailMode && lines.length > TAIL_LINES) {
+ setLogContent(lines.slice(-TAIL_LINES).join("\n"));
+ } else {
+ setLogContent(data.content);
+ }
}
});
} else if (event.type === "job-failed" && event.data.runId === runId) {
setStatus("failed");
setExitCode(event.data.exitCode);
- fetch(`/api/logs/stream?runId=${runId}`)
+ fetch(`/api/logs/stream?runId=${runId}&offset=0`)
.then((res) => res.json())
.then((data) => {
if (data.content) {
- setLogContent(data.content);
+ const lines = data.content.split("\n");
+ setLineCount(lines.length);
+ if (tailMode && lines.length > TAIL_LINES) {
+ setLogContent(lines.slice(-TAIL_LINES).join("\n"));
+ } else {
+ setLogContent(data.content);
+ }
}
});
}
});
return unsubscribe;
- }, [isOpen, runId, subscribe]);
+ }, [isOpen, runId, subscribe, tailMode]);
useEffect(() => {
- logEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ if (logEndRef.current) {
+ logEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
}, [logContent]);
+ const toggleTailMode = () => {
+ setTailMode(!tailMode);
+ if (!tailMode) {
+ const lines = logContent.split("\n");
+ if (lines.length > TAIL_LINES) {
+ setLogContent(lines.slice(-TAIL_LINES).join("\n"));
+ }
+ }
+ };
+
+ const formatFileSize = (bytes: number): string => {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ };
+
const titleWithStatus = (
Live Job Execution{jobComment && `: ${jobComment}`}
@@ -162,6 +247,28 @@ export const LiveLogModal = ({
preventCloseOnClickOutside={status === "running"}
>
+ {showSizeWarning && (
+
+
+
+
+ Large log file detected ({formatFileSize(fileSize)})
+ {tailMode && ` - Tail mode enabled, showing last ${TAIL_LINES.toLocaleString()} lines`}
+
+
+
+
+ )}
+
{logContent ||
@@ -170,8 +277,15 @@ export const LiveLogModal = ({
-
- Run ID: {runId} | Job ID: {jobId}
+
+
+ Run ID: {runId} | Job ID: {jobId}
+
+
+ {lineCount.toLocaleString()} lines
+ {tailMode && ` (showing last ${TAIL_LINES.toLocaleString()})`}
+ {fileSize > 0 && ` • ${formatFileSize(fileSize)}`}
+
diff --git a/app/_components/FeatureComponents/Modals/RestoreBackupModal.tsx b/app/_components/FeatureComponents/Modals/RestoreBackupModal.tsx
index 7d7726b..2fb4795 100644
--- a/app/_components/FeatureComponents/Modals/RestoreBackupModal.tsx
+++ b/app/_components/FeatureComponents/Modals/RestoreBackupModal.tsx
@@ -127,14 +127,12 @@ export const RestoreBackupModal = ({
className="glass-card p-3 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors"
>
- {/* Schedule */}
{backup.job.schedule}
- {/* Command */}
{commandCopied === backup.filename && (
@@ -155,7 +153,6 @@ export const RestoreBackupModal = ({
- {/* User & Date */}
@@ -167,7 +164,6 @@ export const RestoreBackupModal = ({
- {/* Action buttons */}
- {/* Comment (if present) */}
{backup.job.comment && (
{backup.job.comment}
diff --git a/app/_components/FeatureComponents/Modals/ScriptModal.tsx b/app/_components/FeatureComponents/Modals/ScriptModal.tsx
index 3e80d49..c09a4a0 100644
--- a/app/_components/FeatureComponents/Modals/ScriptModal.tsx
+++ b/app/_components/FeatureComponents/Modals/ScriptModal.tsx
@@ -6,7 +6,8 @@ import { Input } from "@/app/_components/GlobalComponents/FormElements/Input";
import { BashEditor } from "@/app/_components/FeatureComponents/Scripts/BashEditor";
import { BashSnippetHelper } from "@/app/_components/FeatureComponents/Scripts/BashSnippetHelper";
import { showToast } from "@/app/_components/GlobalComponents/UIElements/Toast";
-import { FileText, Code } from "lucide-react";
+import { FileText, Code, Info, Trash2 } from "lucide-react";
+import { useTranslations } from "next-intl";
interface ScriptModalProps {
isOpen: boolean;
@@ -24,6 +25,8 @@ interface ScriptModalProps {
};
onFormChange: (updates: Partial) => void;
additionalFormData?: Record;
+ isDraft?: boolean;
+ onClearDraft?: () => void;
}
export const ScriptModal = ({
@@ -36,7 +39,11 @@ export const ScriptModal = ({
form,
onFormChange,
additionalFormData = {},
+ isDraft = false,
+ onClearDraft,
}: ScriptModalProps) => {
+ const t = useTranslations();
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -72,7 +79,7 @@ export const ScriptModal = ({
};
return (
-
+
diff --git a/howto/ENV_VARIABLES.md b/howto/ENV_VARIABLES.md
index 8baa98a..59493cb 100644
--- a/howto/ENV_VARIABLES.md
+++ b/howto/ENV_VARIABLES.md
@@ -84,6 +84,7 @@ Translation loading priority:
| `OIDC_CLIENT_SECRET` | `N/A` | OIDC client secret (optional, for confidential clients) |
| `OIDC_LOGOUT_URL` | `N/A` | Custom logout URL for OIDC provider |
| `OIDC_GROUPS_SCOPE` | `groups` | Scope for requesting user groups |
+| `OIDC_AUTO_REDIRECT` | `false` | Automatically redirect to OIDC provider when it's the only authentication method (no password set) |
| `INTERNAL_API_URL` | `http://localhost:3000` | Internal API URL override for specific nginx configurations with SSO |
### API Authentication
@@ -134,7 +135,7 @@ services:
- AUTH_PASSWORD=your_secure_password
- HOST_CRONTAB_USER=root
- APP_URL=https://cron.yourdomain.com
- - LOCALE=en # Can be any locale code, including custom ones
+ - LOCALE=en
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
- LIVE_UPDATES=true
- MAX_LOG_AGE_DAYS=30
@@ -144,6 +145,7 @@ services:
- OIDC_CLIENT_ID=your_client_id
- OIDC_CLIENT_SECRET=your_client_secret
- OIDC_LOGOUT_URL=https://auth.yourdomain.com/logout
+ - OIDC_AUTO_REDIRECT=true
- API_KEY=your_api_key
```
diff --git a/package.json b/package.json
index 02e5f1a..ac07834 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cronjob-manager",
- "version": "1.5.0",
+ "version": "1.5.2",
"private": true,
"scripts": {
"dev": "next dev",
@@ -13,6 +13,7 @@
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/language": "^6.11.3",
+ "@codemirror/legacy-modes": "^6.5.2",
"@codemirror/lint": "^6.8.5",
"@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2",
diff --git a/yarn.lock b/yarn.lock
index 19f6c1d..5d7e303 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -856,6 +856,13 @@
"@lezer/lr" "^1.0.0"
style-mod "^4.0.0"
+"@codemirror/legacy-modes@^6.5.2":
+ version "6.5.2"
+ resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz#7e2976c79007cd3fa9ed8a1d690892184a7f5ecf"
+ integrity sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==
+ dependencies:
+ "@codemirror/language" "^6.0.0"
+
"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.8.5":
version "6.8.5"
resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.8.5.tgz#9edaa808e764e28e07665b015951934c8ec3a418"