10 Commits
1.5.1 ... 1.5.2

Author SHA1 Message Date
fccview
0ab3358e28 Merge pull request #60 from fccview/develop
Lift Off!
2025-11-19 20:46:15 +00:00
fccview
f53905c002 make sure evil single quote wont cause issues 2025-11-19 20:41:43 +00:00
fccview
90775cac7c add optional variable to automatically go to oidc provider rather than manually click on a button 2025-11-19 20:35:02 +00:00
fccview
54188eb1c0 limit max output of live logs and paginate 2025-11-19 20:23:14 +00:00
fccview
bf208e3075 fix draft feature and copy button 2025-11-19 20:01:27 +00:00
fccview
a5fb5ff484 update package.json version 2025-11-19 16:48:45 +00:00
fccview
25190f3154 Merge branch 'feature/1-5-0-improvements' into develop 2025-11-19 16:47:54 +00:00
fccview
437bdbd81f fix parenthesis issue and give immutable ids to old jobs too 2025-11-19 16:47:43 +00:00
fccview
d8ab3839c6 Merge branch 'main' into develop 2025-11-19 15:54:58 +00:00
fccview
13fe6c5f3d fix bash language highlight and enhance editor with tabbing functionality 2025-11-19 15:54:42 +00:00
27 changed files with 647 additions and 147 deletions

View File

@@ -537,6 +537,11 @@ I would like to thank the following members for raising issues and help test/deb
<a href="https://github.com/Navino16"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/22234867?v=4&size=100"><br />Navino16</a>
</td>
</tr>
<tr>
<td align="center" valign="top" width="20%">
<a href="https://github.com/ShadowTox"><img width="100" height="100" src="https://avatars.githubusercontent.com/u/558536?v=4&size=100"><br />ShadowTox</a>
</td>
</tr>
</tbody>
</table>

View File

@@ -99,12 +99,12 @@ export const MinimalCronJobItem = ({
},
...(job.logsEnabled
? [
{
label: t("cronjobs.viewLogs"),
icon: <Code className="h-3 w-3" />,
onClick: () => onViewLogs(job),
},
]
{
label: t("cronjobs.viewLogs"),
icon: <Code className="h-3 w-3" />,
onClick: () => onViewLogs(job),
},
]
: []),
{
label: job.paused
@@ -139,12 +139,10 @@ export const MinimalCronJobItem = ({
return (
<div
key={job.id}
className={`glass-card p-3 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors ${
isDropdownOpen ? "relative z-10" : ""
}`}
className={`glass-card p-3 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors ${isDropdownOpen ? "relative z-10" : ""
}`}
>
<div className="flex items-center gap-3">
{/* Schedule display - minimal */}
<div className="flex items-center gap-1 flex-shrink-0">
{scheduleDisplayMode === "cron" && (
<code className="text-xs bg-purple-500/10 text-purple-600 dark:text-purple-400 px-1.5 py-0.5 rounded font-mono border border-purple-500/20">

View File

@@ -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 (
<Card className="w-full max-w-md shadow-xl">
<CardContent className="pt-6">
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<Loader2 className="w-12 h-12 text-primary animate-spin" />
<div className="text-center">
<p className="text-lg font-medium">{t("login.redirectingToOIDC")}</p>
<p className="text-sm text-muted-foreground mt-1">
{t("login.pleaseWait")}
</p>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="w-full max-w-md shadow-xl">
<CardHeader className="text-center">

View File

@@ -15,6 +15,8 @@ interface CreateScriptModalProps {
content: string;
};
onFormChange: (updates: Partial<CreateScriptModalProps["form"]>) => void;
isDraft?: boolean;
onClearDraft?: () => void;
}
export const CreateScriptModal = ({
@@ -23,6 +25,8 @@ export const CreateScriptModal = ({
onSubmit,
form,
onFormChange,
isDraft,
onClearDraft,
}: CreateScriptModalProps) => {
return (
<ScriptModal
@@ -34,6 +38,8 @@ export const CreateScriptModal = ({
submitButtonIcon={<Plus className="h-4 w-4 mr-2" />}
form={form}
onFormChange={onFormChange}
isDraft={isDraft}
onClearDraft={onClearDraft}
/>
);
}

View File

@@ -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,18 +31,30 @@ export const LiveLogModal = ({
"running"
);
const [exitCode, setExitCode] = useState<number | null>(null);
const [tailMode, setTailMode] = useState<boolean>(false);
const [showSizeWarning, setShowSizeWarning] = useState<boolean>(false);
const logEndRef = useRef<HTMLDivElement>(null);
const { subscribe } = useSSEContext();
const isPageVisible = usePageVisibility();
const lastOffsetRef = useRef<number>(0);
const abortControllerRef = useRef<AbortController | null>(null);
const [fileSize, setFileSize] = useState<number>(0);
const [lineCount, setLineCount] = useState<number>(0);
useEffect(() => {
if (isOpen) {
lastOffsetRef.current = 0;
setLogContent("");
setTailMode(false);
setShowSizeWarning(false);
setFileSize(0);
setLineCount(0);
}
}, [isOpen, runId]);
useEffect(() => {
if (!isOpen || !runId || !isPageVisible) return;
lastOffsetRef.current = 0;
setLogContent("");
const fetchLogs = async () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
@@ -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 = (
<div className="flex items-center gap-3">
<span>Live Job Execution{jobComment && `: ${jobComment}`}</span>
@@ -162,6 +247,28 @@ export const LiveLogModal = ({
preventCloseOnClickOutside={status === "running"}
>
<div className="space-y-4">
{showSizeWarning && (
<div className="bg-orange-500/10 border border-orange-500/30 rounded-lg p-3 flex items-start gap-3">
<AlertTriangle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground">
<span className="font-medium">Large log file detected</span> ({formatFileSize(fileSize)})
{tailMode && ` - Tail mode enabled, showing last ${TAIL_LINES.toLocaleString()} lines`}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={toggleTailMode}
className="text-orange-500 hover:text-orange-400 hover:bg-orange-500/10 h-auto py-1 px-2 text-xs"
title={tailMode ? "Show all lines" : "Enable tail mode"}
>
{tailMode ? <Maximize2 className="h-3 w-3" /> : <Minimize2 className="h-3 w-3" />}
</Button>
</div>
)}
<div className="bg-black/90 dark:bg-black/60 rounded-lg p-4 max-h-[60vh] overflow-auto">
<pre className="text-xs font-mono text-green-400 whitespace-pre-wrap break-words">
{logContent ||
@@ -170,8 +277,15 @@ export const LiveLogModal = ({
</pre>
</div>
<div className="text-xs text-muted-foreground">
Run ID: {runId} | Job ID: {jobId}
<div className="flex justify-between items-center text-xs text-muted-foreground">
<span>
Run ID: {runId} | Job ID: {jobId}
</span>
<span>
{lineCount.toLocaleString()} lines
{tailMode && ` (showing last ${TAIL_LINES.toLocaleString()})`}
{fileSize > 0 && `${formatFileSize(fileSize)}`}
</span>
</div>
</div>
</Modal>

View File

@@ -127,14 +127,12 @@ export const RestoreBackupModal = ({
className="glass-card p-3 border border-border/50 rounded-lg hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
{/* Schedule */}
<div className="flex-shrink-0">
<code className="text-xs bg-purple-500/10 text-purple-600 dark:text-purple-400 px-1.5 py-0.5 rounded font-mono border border-purple-500/20">
{backup.job.schedule}
</code>
</div>
{/* Command */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{commandCopied === backup.filename && (
@@ -155,7 +153,6 @@ export const RestoreBackupModal = ({
</div>
</div>
{/* User & Date */}
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-shrink-0">
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
@@ -167,7 +164,6 @@ export const RestoreBackupModal = ({
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
@@ -198,7 +194,6 @@ export const RestoreBackupModal = ({
</div>
</div>
{/* Comment (if present) */}
{backup.job.comment && (
<p className="text-xs text-muted-foreground italic mt-2 ml-0">
{backup.job.comment}

View File

@@ -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<ScriptModalProps["form"]>) => void;
additionalFormData?: Record<string, string>;
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<HTMLFormElement>) => {
e.preventDefault();
@@ -72,7 +79,7 @@ export const ScriptModal = ({
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="xl">
<Modal isOpen={isOpen} onClose={onClose} title={title} size="2xl">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
@@ -121,6 +128,11 @@ export const ScriptModal = ({
<h3 className="text-sm font-medium text-foreground">
Script Content <span className="text-red-500">*</span>
</h3>
{isDraft && (
<span className="ml-auto px-2 py-0.5 text-xs font-medium bg-blue-500/10 text-blue-500 border border-blue-500/30 rounded-full">
{t("scripts.draft")}
</span>
)}
</div>
<div className="flex-1 min-h-0">
<BashEditor
@@ -133,19 +145,34 @@ export const ScriptModal = ({
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border/30">
<Button
type="button"
variant="outline"
onClick={onClose}
className="btn-outline"
>
Cancel
</Button>
<Button type="submit" className="btn-primary glow-primary">
{submitButtonIcon}
{submitButtonText}
</Button>
<div className="flex justify-between items-center gap-3 pt-4 border-t border-border/30">
<div>
{isDraft && onClearDraft && (
<Button
type="button"
variant="ghost"
onClick={onClearDraft}
className="text-muted-foreground hover:text-foreground"
>
<Trash2 className="h-4 w-4 mr-2" />
{t("scripts.clearDraft")}
</Button>
)}
</div>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={onClose}
className="btn-outline"
>
{t("scripts.close")}
</Button>
<Button type="submit" className="btn-primary glow-primary">
{submitButtonIcon}
{submitButtonText}
</Button>
</div>
</div>
</form>
</Modal>

View File

@@ -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<HTMLDivElement>(null);
const editorViewRef = useRef<EditorView | null>(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 = ({
<span className="text-sm font-medium">{label}</span>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopy}
@@ -134,4 +205,4 @@ export const BashEditor = ({
</div>
</div>
);
}
};

View File

@@ -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}
/>
<EditScriptModal

View File

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

View File

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

View File

@@ -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 ''`;
export const READ_CRONTABS_DIRECTORY = `ls /var/spool/cron/crontabs/ 2>/dev/null || echo ''`;

View File

@@ -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<boolean>(
typeof document !== "undefined" ? !document.hidden : true

View File

@@ -42,11 +42,7 @@ export const loadTranslationMessages = async (locale: string): Promise<any> => {
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<TranslationFunction> => {

View File

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

View File

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

View File

@@ -251,7 +251,13 @@ export const deleteCronJob = async (id: string): Promise<boolean> => {
};
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<boolean> => {
};
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 => {

View File

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

View File

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

View File

@@ -100,3 +100,55 @@ export const extractJobIdFromWrappedCommand = (
return null;
};
export const cleanupOldLogFiles = async (
jobId: string,
maxFiles: number = 10
): Promise<void> => {
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);
}
};

View File

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

View File

@@ -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;
let content = fullContent;
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 = "";
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,

View File

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

View File

@@ -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() {
<LoginForm
hasPassword={hasPassword}
hasOIDC={hasOIDC}
oidcAutoRedirect={oidcAutoRedirect}
version={version}
/>
</div>

View File

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

View File

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

View File

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