From 13fe6c5f3dd2e79b9bcbdf62d955123a847e137d Mon Sep 17 00:00:00 2001 From: fccview Date: Wed, 19 Nov 2025 15:54:42 +0000 Subject: [PATCH 1/7] fix bash language highlight and enhance editor with tabbing functionality --- .../FeatureComponents/Scripts/BashEditor.tsx | 86 +++++++++++++++++-- package.json | 1 + yarn.lock | 7 ++ 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/app/_components/FeatureComponents/Scripts/BashEditor.tsx b/app/_components/FeatureComponents/Scripts/BashEditor.tsx index 78b92c8..5d8bd6a 100644 --- a/app/_components/FeatureComponents/Scripts/BashEditor.tsx +++ b/app/_components/FeatureComponents/Scripts/BashEditor.tsx @@ -1,9 +1,10 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { EditorView } from "@codemirror/view"; -import { EditorState } from "@codemirror/state"; -import { javascript } from "@codemirror/lang-javascript"; +import { EditorView, keymap } from "@codemirror/view"; +import { EditorState, Transaction } from "@codemirror/state"; +import { shell } from "@codemirror/legacy-modes/mode/shell"; +import { StreamLanguage } from "@codemirror/language"; import { oneDark } from "@codemirror/theme-one-dark"; import { Button } from "@/app/_components/GlobalComponents/UIElements/Button"; import { Terminal, Copy, Check } from "lucide-react"; @@ -27,19 +28,88 @@ export const BashEditor = ({ const editorRef = useRef(null); const editorViewRef = useRef(null); + const insertFourSpaces = ({ + state, + dispatch, + }: { + state: EditorState; + dispatch: (tr: Transaction) => void; + }) => { + if (state.selection.ranges.some((range) => !range.empty)) { + const changes = state.selection.ranges + .map((range) => { + const fromLine = state.doc.lineAt(range.from).number; + const toLine = state.doc.lineAt(range.to).number; + const changes = []; + for (let line = fromLine; line <= toLine; line++) { + const lineObj = state.doc.line(line); + changes.push({ from: lineObj.from, insert: " " }); + } + return changes; + }) + .flat(); + dispatch(state.update({ changes })); + } else { + dispatch(state.update(state.replaceSelection(" "))); + } + return true; + }; + + const removeFourSpaces = ({ + state, + dispatch, + }: { + state: EditorState; + dispatch: (tr: Transaction) => void; + }) => { + if (state.selection.ranges.some((range) => !range.empty)) { + const changes = state.selection.ranges + .map((range) => { + const fromLine = state.doc.lineAt(range.from).number; + const toLine = state.doc.lineAt(range.to).number; + const changes = []; + for (let line = fromLine; line <= toLine; line++) { + const lineObj = state.doc.line(line); + const indent = lineObj.text.match(/^ /); + if (indent) { + changes.push({ from: lineObj.from, to: lineObj.from + 4 }); + } + } + return changes; + }) + .flat(); + dispatch(state.update({ changes })); + } else { + const cursor = state.selection.main.head; + const line = state.doc.lineAt(cursor); + const beforeCursor = line.text.slice(0, cursor - line.from); + const spacesToRemove = beforeCursor.match(/ {1,4}$/); + if (spacesToRemove) { + const removeCount = spacesToRemove[0].length; + dispatch( + state.update({ + changes: { from: cursor - removeCount, to: cursor }, + }) + ); + } + } + return true; + }; + useEffect(() => { if (!editorRef.current) return; - const bashLanguage = javascript({ - typescript: false, - jsx: false, - }); + const bashLanguage = StreamLanguage.define(shell); const state = EditorState.create({ doc: value || placeholder, extensions: [ bashLanguage, oneDark, + keymap.of([ + { key: "Tab", run: insertFourSpaces }, + { key: "Shift-Tab", run: removeFourSpaces }, + ]), EditorView.updateListener.of((update: any) => { if (update.docChanged) { onChange(update.state.doc.toString()); @@ -134,4 +204,4 @@ export const BashEditor = ({ ); -} +}; diff --git a/package.json b/package.json index 02e5f1a..b89622c 100644 --- a/package.json +++ b/package.json @@ -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" From 437bdbd81f63a4dce042a825c42f666cfa3bee90 Mon Sep 17 00:00:00 2001 From: fccview Date: Wed, 19 Nov 2025 16:47:43 +0000 Subject: [PATCH 2/7] fix parenthesis issue and give immutable ids to old jobs too --- .../UIElements/DropdownMenu.tsx | 4 +- app/_consts/commands.ts | 29 +++++--- app/_utils/cronjob-utils.ts | 24 ++++++- app/_utils/line-manipulation-utils.ts | 71 +++++++++++++++---- app/login/page.tsx | 1 - 5 files changed, 98 insertions(+), 31 deletions(-) diff --git a/app/_components/GlobalComponents/UIElements/DropdownMenu.tsx b/app/_components/GlobalComponents/UIElements/DropdownMenu.tsx index 43595f1..a60dfbe 100644 --- a/app/_components/GlobalComponents/UIElements/DropdownMenu.tsx +++ b/app/_components/GlobalComponents/UIElements/DropdownMenu.tsx @@ -4,7 +4,7 @@ import { useState, useRef, useEffect, ReactNode } from "react"; import { Button } from "@/app/_components/GlobalComponents/UIElements/Button"; import { MoreVertical } from "lucide-react"; -const DROPDOWN_HEIGHT = 200; // Approximate max height of dropdown +const DROPDOWN_HEIGHT = 200; interface DropdownMenuItem { label: string; @@ -36,13 +36,11 @@ export const DropdownMenu = ({ const handleOpenChange = (open: boolean) => { if (open && triggerRef.current) { - // Calculate if dropdown should be positioned above or below const rect = triggerRef.current.getBoundingClientRect(); const viewportHeight = window.innerHeight; const spaceBelow = viewportHeight - rect.bottom; const spaceAbove = rect.top; - // Position above if there's not enough space below setPositionAbove(spaceBelow < DROPDOWN_HEIGHT && spaceAbove > spaceBelow); } setIsOpen(open); diff --git a/app/_consts/commands.ts b/app/_consts/commands.ts index 59270f8..1011ff7 100644 --- a/app/_consts/commands.ts +++ b/app/_consts/commands.ts @@ -1,23 +1,34 @@ -export const WRITE_CRONTAB = (content: string, user: string) => `echo '${content}' | crontab -u ${user} -`; +export const WRITE_CRONTAB = (content: string, user: string) => { + const escapedContent = content.replace(/'/g, "'\\''"); + return `crontab -u ${user} - << 'EOF'\n${escapedContent}\nEOF`; +}; -export const READ_CRONTAB = (user: string) => `crontab -l -u ${user} 2>/dev/null || echo ""`; +export const READ_CRONTAB = (user: string) => + `crontab -l -u ${user} 2>/dev/null || echo ""`; -export const READ_CRON_FILE = () => 'crontab -l 2>/dev/null || echo ""' +export const READ_CRON_FILE = () => 'crontab -l 2>/dev/null || echo ""'; -export const WRITE_CRON_FILE = (content: string) => `echo "${content}" | crontab -`; +export const WRITE_CRON_FILE = (content: string) => { + const escapedContent = content.replace(/"/g, '\\"'); + return `echo "${escapedContent}" | crontab -`; +}; -export const WRITE_HOST_CRONTAB = (base64Content: string, user: string) => `echo '${base64Content}' | base64 -d | crontab -u ${user} -`; +export const WRITE_HOST_CRONTAB = (base64Content: string, user: string) => { + const escapedContent = base64Content.replace(/'/g, "'\\''"); + return `echo '${escapedContent}' | base64 -d | crontab -u ${user} -`; +}; export const ID_U = (username: string) => `id -u ${username}`; export const ID_G = (username: string) => `id -g ${username}`; -export const MAKE_SCRIPT_EXECUTABLE = (scriptPath: string) => `chmod +x "${scriptPath}"`; +export const MAKE_SCRIPT_EXECUTABLE = (scriptPath: string) => + `chmod +x "${scriptPath}"`; export const RUN_SCRIPT = (scriptPath: string) => `bash "${scriptPath}"`; -export const GET_TARGET_USER = `getent passwd | grep ":/home/" | head -1 | cut -d: -f1` +export const GET_TARGET_USER = `getent passwd | grep ":/home/" | head -1 | cut -d: -f1`; -export const GET_DOCKER_SOCKET_OWNER = 'stat -c "%U" /var/run/docker.sock' +export const GET_DOCKER_SOCKET_OWNER = 'stat -c "%U" /var/run/docker.sock'; -export const READ_CRONTABS_DIRECTORY = `ls /var/spool/cron/crontabs/ 2>/dev/null || echo ''`; \ No newline at end of file +export const READ_CRONTABS_DIRECTORY = `ls /var/spool/cron/crontabs/ 2>/dev/null || echo ''`; diff --git a/app/_utils/cronjob-utils.ts b/app/_utils/cronjob-utils.ts index 89c205e..69fdbc6 100644 --- a/app/_utils/cronjob-utils.ts +++ b/app/_utils/cronjob-utils.ts @@ -251,7 +251,13 @@ export const deleteCronJob = async (id: string): Promise => { }; export const updateCronJob = async ( - jobData: { id: string; schedule: string; command: string; comment?: string; user: string }, + jobData: { + id: string; + schedule: string; + command: string; + comment?: string; + user: string; + }, schedule: string, command: string, comment: string = "", @@ -275,7 +281,12 @@ export const updateCronJob = async ( if (logsEnabled && !isWrapped) { const docker = await isDocker(); - finalCommand = await wrapCommandWithLogger(jobData.id, command, docker, comment); + finalCommand = await wrapCommandWithLogger( + jobData.id, + command, + docker, + comment + ); } else if (!logsEnabled && isWrapped) { finalCommand = unwrapCommand(command); } else if (logsEnabled && isWrapped) { @@ -390,7 +401,14 @@ export const cleanupCrontab = async (): Promise => { }; export const findJobIndex = ( - jobData: { id: string; schedule: string; command: string; comment?: string; user: string; paused?: boolean }, + jobData: { + id: string; + schedule: string; + command: string; + comment?: string; + user: string; + paused?: boolean; + }, lines: string[], user: string ): number => { diff --git a/app/_utils/line-manipulation-utils.ts b/app/_utils/line-manipulation-utils.ts index 1926654..2d76b7f 100644 --- a/app/_utils/line-manipulation-utils.ts +++ b/app/_utils/line-manipulation-utils.ts @@ -1,5 +1,20 @@ import { CronJob } from "@/app/_utils/cronjob-utils"; import { generateShortUUID } from "@/app/_utils/uuid-utils"; +import { createHash } from "crypto"; + +const generateStableJobId = ( + schedule: string, + command: string, + user: string, + comment?: string, + lineIndex?: number +): string => { + const content = `${schedule}|${command}|${user}|${comment || ""}|${ + lineIndex || 0 + }`; + const hash = createHash("md5").update(content).digest("hex"); + return hash.substring(0, 8); +}; export const pauseJobInLines = ( lines: string[], @@ -55,7 +70,11 @@ export const pauseJobInLines = ( if (currentJobIndex === targetJobIndex) { const commentText = trimmedLine.substring(1).trim(); const { comment, logsEnabled } = parseCommentMetadata(commentText); - const formattedComment = formatCommentWithMetadata(comment, logsEnabled, uuid); + const formattedComment = formatCommentWithMetadata( + comment, + logsEnabled, + uuid + ); const nextLine = lines[i + 1].trim(); const pausedEntry = `# PAUSED: ${formattedComment}\n# ${nextLine}`; newCronEntries.push(pausedEntry); @@ -128,8 +147,14 @@ export const resumeJobInLines = ( const { comment, logsEnabled } = parseCommentMetadata(commentText); if (i + 1 < lines.length && lines[i + 1].trim().startsWith("# ")) { const cronLine = lines[i + 1].trim().substring(2); - const formattedComment = formatCommentWithMetadata(comment, logsEnabled, uuid); - const resumedEntry = formattedComment ? `# ${formattedComment}\n${cronLine}` : cronLine; + const formattedComment = formatCommentWithMetadata( + comment, + logsEnabled, + uuid + ); + const resumedEntry = formattedComment + ? `# ${formattedComment}\n${cronLine}` + : cronLine; newCronEntries.push(resumedEntry); i += 2; } else { @@ -175,7 +200,9 @@ export const parseCommentMetadata = ( let uuid: string | undefined; if (parts.length > 1) { - const firstPartIsMetadata = parts[0].match(/logsEnabled:\s*(true|false)/i) || parts[0].match(/id:\s*([a-z0-9]{4}-[a-z0-9]{4})/i); + const firstPartIsMetadata = + parts[0].match(/logsEnabled:\s*(true|false)/i) || + parts[0].match(/id:\s*([a-z0-9]{8}|[a-z0-9]{4}-[a-z0-9]{4})/i); if (firstPartIsMetadata) { comment = ""; @@ -186,9 +213,11 @@ export const parseCommentMetadata = ( logsEnabled = logsMatch[1].toLowerCase() === "true"; } - const uuidMatch = metadata.match(/id:\s*([a-z0-9]{4}-[a-z0-9]{4})/i); - if (uuidMatch) { - uuid = uuidMatch[1].toLowerCase(); + const uuidMatches = Array.from( + metadata.matchAll(/id:\s*([a-z0-9]{8}|[a-z0-9]{4}-[a-z0-9]{4})/gi) + ); + if (uuidMatches.length > 0) { + uuid = uuidMatches[uuidMatches.length - 1][1].toLowerCase(); } } else { comment = parts[0] || ""; @@ -199,14 +228,18 @@ export const parseCommentMetadata = ( logsEnabled = logsMatch[1].toLowerCase() === "true"; } - const uuidMatch = metadata.match(/id:\s*([a-z0-9]{4}-[a-z0-9]{4})/i); - if (uuidMatch) { - uuid = uuidMatch[1].toLowerCase(); + const uuidMatches = Array.from( + metadata.matchAll(/id:\s*([a-z0-9]{8}|[a-z0-9]{4}-[a-z0-9]{4})/gi) + ); + if (uuidMatches.length > 0) { + uuid = uuidMatches[uuidMatches.length - 1][1].toLowerCase(); } } } else { const logsMatch = commentText.match(/logsEnabled:\s*(true|false)/i); - const uuidMatch = commentText.match(/id:\s*([a-z0-9]{4}-[a-z0-9]{4})/i); + const uuidMatch = commentText.match( + /id:\s*([a-z0-9]{8}|[a-z0-9]{4}-[a-z0-9]{4})/i + ); if (logsMatch || uuidMatch) { if (logsMatch) { @@ -288,7 +321,8 @@ export const parseJobsFromLines = ( const schedule = parts.slice(0, 5).join(" "); const command = parts.slice(5).join(" "); - const jobId = uuid || generateShortUUID(); + const jobId = + uuid || generateStableJobId(schedule, command, user, comment, i); jobs.push({ id: jobId, @@ -317,7 +351,8 @@ export const parseJobsFromLines = ( lines[i + 1].trim() ) { const commentText = trimmedLine.substring(1).trim(); - const { comment, logsEnabled, uuid } = parseCommentMetadata(commentText); + const { comment, logsEnabled, uuid } = + parseCommentMetadata(commentText); currentComment = comment; currentLogsEnabled = logsEnabled; currentUuid = uuid; @@ -343,7 +378,9 @@ export const parseJobsFromLines = ( } if (schedule && command) { - const jobId = currentUuid || generateShortUUID(); + const jobId = + currentUuid || + generateStableJobId(schedule, command, user, currentComment, i); jobs.push({ id: jobId, @@ -545,7 +582,11 @@ export const updateJobInLines = ( } if (currentJobIndex === targetJobIndex) { - const formattedComment = formatCommentWithMetadata(comment, logsEnabled, uuid); + const formattedComment = formatCommentWithMetadata( + comment, + logsEnabled, + uuid + ); const newEntry = formattedComment ? `# ${formattedComment}\n${schedule} ${command}` : `${schedule} ${command}`; diff --git a/app/login/page.tsx b/app/login/page.tsx index 9f2ecb2..e05b70a 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -8,7 +8,6 @@ export default async function LoginPage() { const hasPassword = !!process.env.AUTH_PASSWORD; const hasOIDC = process.env.SSO_MODE === "oidc"; - // Read package.json to get version const packageJsonPath = path.join(process.cwd(), "package.json"); const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); const version = packageJson.version; From a5fb5ff484299f9f47c3fff1dd4cccdfa7805ef8 Mon Sep 17 00:00:00 2001 From: fccview Date: Wed, 19 Nov 2025 16:48:45 +0000 Subject: [PATCH 3/7] update package.json version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b89622c..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", From bf208e3075ba46e1396cbf8022fb77754a47a1db Mon Sep 17 00:00:00 2001 From: fccview Date: Wed, 19 Nov 2025 20:01:27 +0000 Subject: [PATCH 4/7] fix draft feature and copy button --- README.md | 5 + .../Modals/CreateScriptModal.tsx | 6 ++ .../FeatureComponents/Modals/LiveLogModal.tsx | 14 ++- .../FeatureComponents/Modals/ScriptModal.tsx | 57 +++++++++--- .../FeatureComponents/Scripts/BashEditor.tsx | 3 +- .../Scripts/ScriptsManager.tsx | 45 ++++++++- .../GlobalComponents/UIElements/Modal.tsx | 4 +- app/_translations/en.json | 6 +- app/_translations/it.json | 6 +- app/_utils/job-execution-utils.ts | 12 ++- app/_utils/wrapper-utils.ts | 52 +++++++++++ app/api/logs/stream/route.ts | 93 ++++++++++++++++--- 12 files changed, 264 insertions(+), 39 deletions(-) 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
+ + +
ShadowTox
+ + 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..4fe2bb4 100644 --- a/app/_components/FeatureComponents/Modals/LiveLogModal.tsx +++ b/app/_components/FeatureComponents/Modals/LiveLogModal.tsx @@ -61,7 +61,17 @@ export const LiveLogModal = ({ if (lastOffsetRef.current === 0 && data.content) { setLogContent(data.content); } else if (data.newContent) { - setLogContent((prev) => prev + data.newContent); + setLogContent((prev) => { + const newContent = prev + data.newContent; + const maxLength = 2 * 1024 * 1024; + if (newContent.length > maxLength) { + return ( + "[LOG CONTENT TRUNCATED FOR PERFORMANCE]\n\n" + + newContent.slice(-maxLength + 100) + ); + } + return newContent; + }); } setStatus(data.status || "running"); @@ -80,7 +90,7 @@ export const LiveLogModal = ({ let interval: NodeJS.Timeout | null = null; if (isPageVisible) { - interval = setInterval(fetchLogs, 2000); + interval = setInterval(fetchLogs, 3000); } return () => { 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 ( - +
@@ -121,6 +128,11 @@ export const ScriptModal = ({

Script Content *

+ {isDraft && ( + + {t("scripts.draft")} + + )}
-
- - +
+
+ {isDraft && onClearDraft && ( + + )} +
+
+ + +
diff --git a/app/_components/FeatureComponents/Scripts/BashEditor.tsx b/app/_components/FeatureComponents/Scripts/BashEditor.tsx index 5d8bd6a..08d0100 100644 --- a/app/_components/FeatureComponents/Scripts/BashEditor.tsx +++ b/app/_components/FeatureComponents/Scripts/BashEditor.tsx @@ -22,7 +22,7 @@ export const BashEditor = ({ onChange, placeholder = "#!/bin/bash\n# Your bash script here\necho 'Hello World'", className = "", - label = "Bash Script", + label, }: BashEditorProps) => { const [copied, setCopied] = useState(false); const editorRef = useRef(null); @@ -185,6 +185,7 @@ export const BashEditor = ({ {label}
+
+ )} +
             {logContent ||
@@ -180,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/_hooks/usePageVisibility.ts b/app/_hooks/usePageVisibility.ts index 605283c..96a5bc1 100644 --- a/app/_hooks/usePageVisibility.ts +++ b/app/_hooks/usePageVisibility.ts @@ -2,13 +2,7 @@ import { useEffect, useState } from "react"; -/** - * Hook to detect if the page/tab is currently visible to the user. - * Returns true when the page is visible, false when hidden (user switched tabs). - * - * Use this to pause polling, SSE connections, or other resource-intensive - * operations when the user is not actively viewing the page. - */ + export function usePageVisibility(): boolean { const [isVisible, setIsVisible] = useState( typeof document !== "undefined" ? !document.hidden : true diff --git a/app/_server/actions/translations/index.ts b/app/_server/actions/translations/index.ts index c79a414..7156a2b 100644 --- a/app/_server/actions/translations/index.ts +++ b/app/_server/actions/translations/index.ts @@ -42,11 +42,7 @@ export const loadTranslationMessages = async (locale: string): Promise => { type TranslationFunction = (key: string) => string; -/** - * Get a translation function for a given locale. - * This function is server-only and should only be called from server components - * or server actions. - */ + export const getTranslations = async ( locale: string = process.env.LOCALE || "en" ): Promise => { diff --git a/app/_utils/job-execution-utils.ts b/app/_utils/job-execution-utils.ts index d87a67b..567a7e3 100644 --- a/app/_utils/job-execution-utils.ts +++ b/app/_utils/job-execution-utils.ts @@ -113,9 +113,6 @@ export const runJobInBackground = async ( }; }; -/** - * Monitor a running job and update status when complete - */ const monitorRunningJob = (runId: string, pid: number): void => { const checkInterval = setInterval(async () => { try { diff --git a/app/api/auth/check-session/route.ts b/app/api/auth/check-session/route.ts index a1ec72c..0151aff 100644 --- a/app/api/auth/check-session/route.ts +++ b/app/api/auth/check-session/route.ts @@ -3,10 +3,6 @@ import { validateSession, getSessionCookieName } from "@/app/_utils/session-util export const dynamic = "force-dynamic"; -/** - * Validate session for middleware - * This runs in Node.js runtime so it can access the filesystem - */ export async function GET(request: NextRequest) { const cookieName = getSessionCookieName(); const sessionId = request.cookies.get(cookieName)?.value; diff --git a/app/api/logs/stream/route.ts b/app/api/logs/stream/route.ts index 8879263..2cd87f6 100644 --- a/app/api/logs/stream/route.ts +++ b/app/api/logs/stream/route.ts @@ -72,31 +72,54 @@ export const GET = async (request: NextRequest) => { let latestLogFile: string | null = null; let latestStats: any = null; const jobStartTime = new Date(job.startTime); - - for (const file of sortedFiles) { - const filePath = path.join(logDir, file); - try { - const { stat } = await import("fs/promises"); - const stats = await stat(filePath); - const fileCreateTime = stats.birthtime || stats.mtime; - - if (fileCreateTime >= jobStartTime) { - latestLogFile = filePath; - latestStats = stats; - break; + const TIME_TOLERANCE_MS = 5000; + + if (job.logFileName) { + const cachedFilePath = path.join(logDir, job.logFileName); + if (existsSync(cachedFilePath)) { + try { + const { stat } = await import("fs/promises"); + latestLogFile = cachedFilePath; + latestStats = await stat(latestLogFile); + } catch (error) { + console.error(`Error reading cached log file ${job.logFileName}:`, error); } - } catch (error) { - console.error(`Error checking file ${file}:`, error); } } - if (!latestLogFile && sortedFiles.length > 0) { - try { - const { stat } = await import("fs/promises"); - latestLogFile = path.join(logDir, sortedFiles[0]); - latestStats = await stat(latestLogFile); - } catch (error) { - console.error(`Error stat-ing fallback file:`, error); + if (!latestLogFile) { + for (const file of sortedFiles) { + const filePath = path.join(logDir, file); + try { + const { stat } = await import("fs/promises"); + const stats = await stat(filePath); + const fileCreateTime = stats.birthtime || stats.mtime; + + if (fileCreateTime.getTime() >= jobStartTime.getTime() - TIME_TOLERANCE_MS) { + latestLogFile = filePath; + latestStats = stats; + break; + } + } catch (error) { + console.error(`Error checking file ${file}:`, error); + } + } + + if (!latestLogFile && sortedFiles.length > 0) { + try { + const { stat } = await import("fs/promises"); + const fallbackPath = path.join(logDir, sortedFiles[0]); + const fallbackStats = await stat(fallbackPath); + const now = new Date(); + const fileAge = now.getTime() - (fallbackStats.birthtime || fallbackStats.mtime).getTime(); + + if (fileAge <= TIME_TOLERANCE_MS) { + latestLogFile = fallbackPath; + latestStats = fallbackStats; + } + } catch (error) { + console.error(`Error stat-ing fallback file:`, error); + } } } diff --git a/app/globals.css b/app/globals.css index 9d2860c..55e578b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -107,31 +107,26 @@ font-variation-settings: normal; } - /* Terminal-style fonts for code elements */ code, pre, .font-mono { font-family: var(--font-mono); font-feature-settings: "liga" 1, "calt" 1; } - /* Brand styling */ .brand-text { font-family: var(--font-mono); font-weight: 600; letter-spacing: -0.025em; } - /* Terminal-style headings */ h1, h2, h3, h4, h5, h6 { font-family: var(--font-sans); font-weight: 600; } - /* Terminal-style buttons and inputs */ button, input, textarea, select { font-family: var(--font-sans); } - /* Code blocks and terminal areas */ .terminal-text { font-family: var(--font-mono); font-size: 0.875rem; @@ -139,7 +134,6 @@ } } -/* Cyberpunk-inspired gradient background */ @layer components { .hero-gradient { background: linear-gradient(135deg, @@ -171,7 +165,6 @@ radial-gradient(circle at 40% 40%, hsl(340 100% 45% / 0.06) 0%, transparent 50%); } - /* Glass morphism cards */ .glass-card { @apply backdrop-blur-md bg-card/80 border border-border/50; } @@ -184,7 +177,6 @@ @apply glass-card; } - /* Vibrant gradient text */ .brand-gradient { @apply bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 bg-clip-text text-transparent; } @@ -201,7 +193,6 @@ @apply bg-gradient-to-r from-cyan-600 via-blue-600 to-purple-600 bg-clip-text text-transparent; } - /* Neon glow effects */ .glow-primary { box-shadow: none; } @@ -222,12 +213,10 @@ box-shadow: none; } - /* Status indicators */ .status-error { @apply bg-red-500/20 text-red-700 dark:text-red-400 border-red-500/30; } - /* Custom scrollbar */ .custom-scrollbar { scrollbar-width: thin; scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent; @@ -250,7 +239,6 @@ background-color: hsl(var(--muted-foreground) / 0.5); } - /* Tooltip styles */ .tooltip { @apply absolute z-50 px-3 py-2 text-sm text-white bg-gray-900 rounded-lg shadow-lg opacity-0 invisible transition-all duration-200; } @@ -259,7 +247,6 @@ @apply opacity-100 visible; } - /* Responsive text utilities */ .text-responsive { @apply text-sm sm:text-base lg:text-lg; } @@ -272,7 +259,6 @@ @apply text-lg sm:text-xl lg:text-2xl; } - /* Button variants with new colors */ .btn-primary { @apply bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 transition-all; } @@ -305,7 +291,6 @@ @apply bg-gradient-to-r from-red-600 to-pink-600 text-white hover:from-red-700 hover:to-pink-700 transition-all; } - /* Neon accent borders */ .neon-border { border: 1px solid transparent; background: linear-gradient(white, white) padding-box, @@ -321,7 +306,7 @@ @layer utilities { body.sidebar-collapsed main.lg\:ml-80 { - margin-left: 4rem !important; /* 64px */ + margin-left: 4rem !important; } } From 90775cac7c5a9b26d767cc3739c2642dbe4ef0ea Mon Sep 17 00:00:00 2001 From: fccview Date: Wed, 19 Nov 2025 20:35:02 +0000 Subject: [PATCH 6/7] add optional variable to automatically go to oidc provider rather than manually click on a button --- .../FeatureComponents/LoginForm/LoginForm.tsx | 42 +++++++++++++++++-- app/_translations/en.json | 2 + app/_translations/it.json | 2 + app/login/page.tsx | 2 + howto/ENV_VARIABLES.md | 4 +- 5 files changed, 48 insertions(+), 4 deletions(-) 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/_translations/en.json b/app/_translations/en.json index 99ff38b..c73c22c 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -160,6 +160,8 @@ "signingIn": "Signing in...", "signIn": "Sign In", "redirecting": "Redirecting...", + "redirectingToOIDC": "Redirecting to OIDC provider", + "pleaseWait": "Please wait...", "orContinueWith": "Or continue with", "loginFailed": "Login failed", "genericError": "An error occurred. Please try again." diff --git a/app/_translations/it.json b/app/_translations/it.json index 6203f8d..f2bd21c 100644 --- a/app/_translations/it.json +++ b/app/_translations/it.json @@ -159,6 +159,8 @@ "signingIn": "Accesso in corso...", "signIn": "Accedi", "redirecting": "Reindirizzamento...", + "redirectingToOIDC": "Reindirizzamento al provider OIDC", + "pleaseWait": "Attendere prego...", "orContinueWith": "Oppure continua con", "loginFailed": "Accesso fallito", "genericError": "Si è verificato un errore. Riprova." diff --git a/app/login/page.tsx b/app/login/page.tsx index e05b70a..f0cea77 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -7,6 +7,7 @@ import path from "path"; export default async function LoginPage() { const hasPassword = !!process.env.AUTH_PASSWORD; const hasOIDC = process.env.SSO_MODE === "oidc"; + const oidcAutoRedirect = process.env.OIDC_AUTO_REDIRECT === "true"; const packageJsonPath = path.join(process.cwd(), "package.json"); const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); @@ -19,6 +20,7 @@ export default async function LoginPage() {
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 ``` From f53905c002ad09805933e14c97fd2f81e5f81f89 Mon Sep 17 00:00:00 2001 From: fccview Date: Wed, 19 Nov 2025 20:41:43 +0000 Subject: [PATCH 7/7] make sure evil single quote wont cause issues --- app/_consts/commands.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/_consts/commands.ts b/app/_consts/commands.ts index 1011ff7..af09465 100644 --- a/app/_consts/commands.ts +++ b/app/_consts/commands.ts @@ -1,6 +1,5 @@ export const WRITE_CRONTAB = (content: string, user: string) => { - const escapedContent = content.replace(/'/g, "'\\''"); - return `crontab -u ${user} - << 'EOF'\n${escapedContent}\nEOF`; + return `crontab -u ${user} - << 'EOF'\n${content}\nEOF`; }; export const READ_CRONTAB = (user: string) => @@ -9,8 +8,7 @@ export const READ_CRONTAB = (user: string) => export const READ_CRON_FILE = () => 'crontab -l 2>/dev/null || echo ""'; export const WRITE_CRON_FILE = (content: string) => { - const escapedContent = content.replace(/"/g, '\\"'); - return `echo "${escapedContent}" | crontab -`; + return `crontab - << 'EOF'\n${content}\nEOF`; }; export const WRITE_HOST_CRONTAB = (base64Content: string, user: string) => {