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/Cronjobs/Parts/MinimalCronJobItem.tsx b/app/_components/FeatureComponents/Cronjobs/Parts/MinimalCronJobItem.tsx index 0c2fe03..5ded090 100644 --- a/app/_components/FeatureComponents/Cronjobs/Parts/MinimalCronJobItem.tsx +++ b/app/_components/FeatureComponents/Cronjobs/Parts/MinimalCronJobItem.tsx @@ -99,12 +99,12 @@ export const MinimalCronJobItem = ({ }, ...(job.logsEnabled ? [ - { - label: t("cronjobs.viewLogs"), - icon: , - onClick: () => onViewLogs(job), - }, - ] + { + label: t("cronjobs.viewLogs"), + icon: , + onClick: () => onViewLogs(job), + }, + ] : []), { label: job.paused @@ -139,12 +139,10 @@ export const MinimalCronJobItem = ({ return (
- {/* Schedule display - minimal */}
{scheduleDisplayMode === "cron" && ( 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 ( - +

@@ -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 78b92c8..08d0100 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"; @@ -21,25 +22,94 @@ 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); 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()); @@ -115,6 +185,7 @@ export const BashEditor = ({ {label}
); -} +}; diff --git a/app/_components/FeatureComponents/Scripts/ScriptsManager.tsx b/app/_components/FeatureComponents/Scripts/ScriptsManager.tsx index 5420671..569f081 100644 --- a/app/_components/FeatureComponents/Scripts/ScriptsManager.tsx +++ b/app/_components/FeatureComponents/Scripts/ScriptsManager.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/GlobalComponents/Cards/Card"; import { Button } from "@/app/_components/GlobalComponents/UIElements/Button"; import { @@ -32,6 +32,8 @@ interface ScriptsManagerProps { scripts: Script[]; } +const DRAFT_STORAGE_KEY = "cronjob_script_draft"; + export const ScriptsManager = ({ scripts: initialScripts, }: ScriptsManagerProps) => { @@ -46,11 +48,13 @@ export const ScriptsManager = ({ const [isCloning, setIsCloning] = useState(false); const t = useTranslations(); - const [createForm, setCreateForm] = useState({ + const defaultFormValues = { name: "", description: "", content: "#!/bin/bash\n# Your script here\necho 'Hello World'", - }); + }; + + const [createForm, setCreateForm] = useState(defaultFormValues); const [editForm, setEditForm] = useState({ name: "", @@ -58,6 +62,37 @@ export const ScriptsManager = ({ content: "", }); + useEffect(() => { + try { + const savedDraft = localStorage.getItem(DRAFT_STORAGE_KEY); + if (savedDraft) { + const parsedDraft = JSON.parse(savedDraft); + setCreateForm(parsedDraft); + } + } catch (error) { + console.error("Failed to load draft from localStorage:", error); + } + }, []); + + useEffect(() => { + try { + localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(createForm)); + } catch (error) { + console.error("Failed to save draft to localStorage:", error); + } + }, [createForm]); + + const isDraft = + createForm.name.trim() !== "" || + createForm.description.trim() !== "" || + createForm.content !== defaultFormValues.content; + + const handleClearDraft = () => { + setCreateForm(defaultFormValues); + localStorage.removeItem(DRAFT_STORAGE_KEY); + showToast("success", t("scripts.draftCleared")); + }; + const refreshScripts = async () => { try { const { fetchScripts } = await import("@/app/_server/actions/scripts"); @@ -78,6 +113,8 @@ export const ScriptsManager = ({ if (result.success) { await refreshScripts(); setIsCreateModalOpen(false); + setCreateForm(defaultFormValues); + localStorage.removeItem(DRAFT_STORAGE_KEY); showToast("success", "Script created successfully"); } else { showToast("error", "Failed to create script", result.message); @@ -318,6 +355,8 @@ export const ScriptsManager = ({ onFormChange={(updates) => setCreateForm((prev) => ({ ...prev, ...updates })) } + isDraft={isDraft} + onClearDraft={handleClearDraft} /> { 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/_components/GlobalComponents/UIElements/Modal.tsx b/app/_components/GlobalComponents/UIElements/Modal.tsx index cdc4abd..31ea4b1 100644 --- a/app/_components/GlobalComponents/UIElements/Modal.tsx +++ b/app/_components/GlobalComponents/UIElements/Modal.tsx @@ -10,7 +10,7 @@ interface ModalProps { onClose: () => void; title: string; children: React.ReactNode; - size?: "sm" | "md" | "lg" | "xl"; + size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl"; showCloseButton?: boolean; preventCloseOnClickOutside?: boolean; className?: string; @@ -80,6 +80,8 @@ export const Modal = ({ md: "max-w-lg", lg: "max-w-2xl", xl: "max-w-4xl", + "2xl": "max-w-6xl", + "3xl": "max-w-8xl", }; return ( diff --git a/app/_consts/commands.ts b/app/_consts/commands.ts index 59270f8..af09465 100644 --- a/app/_consts/commands.ts +++ b/app/_consts/commands.ts @@ -1,23 +1,32 @@ -export const WRITE_CRONTAB = (content: string, user: string) => `echo '${content}' | crontab -u ${user} -`; +export const WRITE_CRONTAB = (content: string, user: string) => { + return `crontab -u ${user} - << 'EOF'\n${content}\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) => { + return `crontab - << 'EOF'\n${content}\nEOF`; +}; -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/_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/_translations/en.json b/app/_translations/en.json index a9dc4bc..c73c22c 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -109,7 +109,11 @@ "commandPreview": "Command Preview", "scriptContent": "Script Content", "selectScriptToPreview": "Select a script to preview", - "searchScripts": "Search scripts..." + "searchScripts": "Search scripts...", + "draft": "Draft", + "clearDraft": "Clear Draft", + "close": "Close", + "draftCleared": "Draft cleared" }, "sidebar": { "systemOverview": "System Overview", @@ -156,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 47ce22c..f2bd21c 100644 --- a/app/_translations/it.json +++ b/app/_translations/it.json @@ -108,7 +108,11 @@ "commandPreview": "Anteprima Comando", "scriptContent": "Contenuto Script", "selectScriptToPreview": "Seleziona uno script per l'anteprima", - "searchScripts": "Cerca script..." + "searchScripts": "Cerca script...", + "draft": "Bozza", + "clearDraft": "Cancella Bozza", + "close": "Chiudi", + "draftCleared": "Bozza cancellata" }, "sidebar": { "systemOverview": "Panoramica del Sistema", @@ -155,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/_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/job-execution-utils.ts b/app/_utils/job-execution-utils.ts index 5e3e698..567a7e3 100644 --- a/app/_utils/job-execution-utils.ts +++ b/app/_utils/job-execution-utils.ts @@ -7,9 +7,10 @@ import { saveRunningJob, updateRunningJob, getRunningJob, + removeRunningJob, } from "./running-jobs-utils"; import { sseBroadcaster } from "./sse-broadcaster"; -import { generateLogFolderName } from "./wrapper-utils"; +import { generateLogFolderName, cleanupOldLogFiles } from "./wrapper-utils"; const execAsync = promisify(exec); @@ -112,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 { @@ -130,6 +128,15 @@ const monitorRunningJob = (runId: string, pid: number): void => { exitCode, }); + setTimeout(async () => { + try { + removeRunningJob(runId); + await cleanupOldLogFiles(runningJob?.cronJobId || ""); + } catch (error) { + console.error(`Error cleaning up job ${runId}:`, error); + } + }, 5000); + const runningJob = getRunningJob(runId); if (runningJob) { 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/_utils/wrapper-utils.ts b/app/_utils/wrapper-utils.ts index 51a25ef..a5c3b7a 100644 --- a/app/_utils/wrapper-utils.ts +++ b/app/_utils/wrapper-utils.ts @@ -100,3 +100,55 @@ export const extractJobIdFromWrappedCommand = ( return null; }; + +export const cleanupOldLogFiles = async ( + jobId: string, + maxFiles: number = 10 +): Promise => { + try { + const { readdir, stat, unlink } = await import("fs/promises"); + const logFolderName = generateLogFolderName(jobId); + const logDir = path.join(process.cwd(), "data", "logs", logFolderName); + + try { + await stat(logDir); + } catch { + return; + } + + const files = await readdir(logDir); + const logFiles = files + .filter((f) => f.endsWith(".log")) + .map((f) => ({ + name: f, + path: path.join(logDir, f), + stats: null as any, + })); + + for (const file of logFiles) { + try { + file.stats = await stat(file.path); + } catch (error) { + console.error(`Error stat-ing log file ${file.path}:`, error); + } + } + + const validFiles = logFiles + .filter((f) => f.stats) + .sort((a, b) => b.stats.mtime.getTime() - a.stats.mtime.getTime()); + + if (validFiles.length > maxFiles) { + const filesToDelete = validFiles.slice(maxFiles); + for (const file of filesToDelete) { + try { + await unlink(file.path); + console.log(`Cleaned up old log file: ${file.path}`); + } catch (error) { + console.error(`Error deleting log file ${file.path}:`, error); + } + } + } + } catch (error) { + console.error(`Error cleaning up log files for job ${jobId}:`, error); + } +}; 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 21f2c09..2cd87f6 100644 --- a/app/api/logs/stream/route.ts +++ b/app/api/logs/stream/route.ts @@ -68,30 +68,118 @@ export const GET = async (request: NextRequest) => { } const sortedFiles = files.sort().reverse(); - const latestLogFile = path.join(logDir, sortedFiles[0]); - const fullContent = await readFile(latestLogFile, "utf-8"); - const fileSize = Buffer.byteLength(fullContent, "utf-8"); + let latestLogFile: string | null = null; + let latestStats: any = null; + const jobStartTime = new Date(job.startTime); + 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); + } + } + } + + 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); + } + } + } + + if (!latestLogFile || !latestStats) { + return NextResponse.json( + { + status: job.status, + content: "", + message: "No log file found for this run", + }, + { status: 200 } + ); + } + + const fileSize = latestStats.size; + + const MAX_RESPONSE_SIZE = 1024 * 1024; + const MAX_TOTAL_SIZE = 10 * 1024 * 1024; - let content = fullContent; + let content = ""; 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 = ""; + if (fileSize > MAX_TOTAL_SIZE) { + const startPos = Math.max(0, fileSize - MAX_TOTAL_SIZE); + const buffer = Buffer.alloc(MAX_TOTAL_SIZE); + const { open } = await import("fs/promises"); + const fileHandle = await open(latestLogFile, "r"); + + try { + await fileHandle.read(buffer, 0, MAX_TOTAL_SIZE, startPos); + content = buffer.toString("utf-8"); + newContent = content.slice(Math.max(0, offset - startPos)); + } finally { + await fileHandle.close(); + } + + if (startPos > 0) { + content = `[LOG TRUNCATED - Showing last ${MAX_TOTAL_SIZE / 1024 / 1024 + }MB of ${fileSize / 1024 / 1024}MB total]\n\n${content}`; + } + } else { + const fullContent = await readFile(latestLogFile, "utf-8"); + + 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, + fullContent: offset === 0 ? content : undefined, logFile: sortedFiles[0], isComplete: job.status !== "running", exitCode: job.exitCode, 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; } } diff --git a/app/login/page.tsx b/app/login/page.tsx index 9f2ecb2..f0cea77 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -7,8 +7,8 @@ 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"; - // 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; @@ -20,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 ``` 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"