Skip to content

Commit dc4d3c0

Browse files
authored
Merge pull request #111 from fccview/develop
Develop
2 parents 7478b4c + 0b23281 commit dc4d3c0

182 files changed

Lines changed: 10387 additions & 3500 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.dockerignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ README.md
77
.git
88
.gitignore
99
.next
10+
.claude
11+
data

‎.github/workflows/pr-checks.yml‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,16 @@ jobs:
1919
echo "Please create a PR to 'develop' first, this will become a release candidate when merged into 'main' by a maintainer"
2020
exit 1
2121
fi
22-
echo "Valid PR: develop → main"
22+
echo "Valid PR: develop -> main"
2323
2424
- name: PR info
2525
run: |
2626
echo "PR validation passed"
2727
echo "Source: ${{ github.head_ref }}"
2828
echo "Target: ${{ github.base_ref }}"
2929
30-
typing:
31-
name: Type and install checks
30+
tests:
31+
name: Tests now - BOOORING
3232
runs-on: ubuntu-latest
3333
needs: validate-branch
3434
steps:
@@ -38,11 +38,17 @@ jobs:
3838
- name: Setup the best engine ever
3939
uses: actions/setup-node@v4
4040
with:
41-
node-version: "20"
41+
node-version: "24"
4242
cache: "yarn"
4343

4444
- name: Install all dependencies
4545
run: yarn install --frozen-lockfile
4646

4747
- name: This will totally fail if you only use AI
4848
run: yarn tsc --noEmit
49+
50+
- name: You better have linting rules in your vscode
51+
run: yarn lint
52+
53+
- name: Regression tests, because we have those now
54+
run: yarn test

‎.github/workflows/prebuild-release.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
- name: Setup the best engine ever
1717
uses: actions/setup-node@v4
1818
with:
19-
node-version: '20'
19+
node-version: '24'
2020
cache: 'yarn'
2121

2222
- name: Install dependencies

‎.gitignore‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ node_modules
1515
tsconfig.tsbuildinfo
1616
docker-compose.test.yml
1717
/data
18-
claude.md
18+
claude.md
19+
.claude/worktrees

‎AGENTS.md‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Cr*nMaster
2+
3+
Cr*nMaster is a web UI for cron. It reads and writes the real crontabs of real users on a machine somebody owns, and it can run any of those jobs on demand. It runs natively, or in a container that reaches into the host to do the same thing. Either way, whoever can use it can run commands as any user on that host, root included.
4+
5+
Treat it like a root shell with a nice UI, because that is what it is.
6+
7+
There is no company behind this and no support desk. When a crontab gets mangled, a backup script stops running at 3am and nobody notices for a month. There is a person, their server, and whatever backup they happened to take.
8+
9+
The name has an asterisk in it. Cr*nMaster, `cr*nmaster` in log tags. It is not a typo, so don't fix it.
10+
11+
This file covers what we value. Branching and pull requests live in `CONTRIBUTING.md`, and the user-facing documentation lives in `howto/`.
12+
13+
## The crontab is the database
14+
15+
There is no database. The source of truth is the crontab itself, plus some metadata we tuck into the comment line above each job. People edit those files by hand, with `crontab -e`, with Ansible, with other tools. Our lines have to survive that, and their lines have to survive us.
16+
17+
A line we wrote two years ago has to parse today. People update when they remember, so the shape you are reading may come from a version that no longer exists. We keep reading old shapes forever. Changing how we write a line means every parser accepts both the old and the new form first, with tests built from real lines in the old format.
18+
19+
A job line that works today is written back byte for byte. Don't quote it, reformat it, normalise its whitespace or tidy it on the way through. I have been burned by this more than once. The current format has worked for a long time without complaints, and that is worth more than whatever your change makes prettier.
20+
21+
Read, change, write back. A write that fails halfway can leave somebody with an empty crontab, and an empty crontab looks exactly like a working one until the jobs don't run.
22+
23+
## Every setup is different
24+
25+
Cr*nMaster runs on NAS boxes, Raspberry Pis, LXC containers, VPSes, laptops and old towers under desks. Different distros, different cron daemons, service users with no login shell, paths with spaces, containers in a different timezone from the host, filesystems that don't record when a file was created, and drives that spin down to save power.
26+
27+
"It works on my machine" proves very little here. Before you rely on something about the host, ask what happens on the weirdest machine you can think of. Somebody runs it there.
28+
29+
New behaviour is opt-in. Defaults don't change under people's feet: no new required environment variables, no inputs rejected that used to work, no feature that starts touching disks it never touched before. If the secure or correct choice would break existing setups, add it behind an environment variable, document it, and tell me so it can become the default in a major release.
30+
31+
The disks thing is real. Some people run this on a NAS full of sleeping drives. Anything that polls, scans or stats the filesystem has to stay on what it was asked about, cache its answer, and leave everything else alone.
32+
33+
## Security
34+
35+
Every value that ends up in a shell command or a crontab line is hostile until proven otherwise. Usernames, job ids, schedules, comments, script names, log file names. Quote what goes into a shell, validate what goes into a crontab, and check containment on every path you build from something the browser sent. A newline in a comment is a new cron line. A `..` in a filename is somebody else's file.
36+
37+
Server actions are public endpoints, whatever the UI does with them. When auth is configured, every action checks it on the server, every time. Client-side checks exist so people don't click buttons that will refuse them, and that is all they do.
38+
39+
Running without auth is a choice people make on purpose, usually behind a VPN or on a home network. Respect it. Warn about it, document it, don't force a login on them. The API key protects the REST API and nothing else, and people who set it without a password get an orange warning at startup telling them so. That warning stays.
40+
41+
Secrets never reach the logs or the browser. Passwords, API keys, session ids, cookies, and the full contents of somebody's crontab. Error messages shown to the user say what went wrong, not what was in the file.
42+
43+
A mutation shipped without its auth check, or a shell string built from user input without quoting, is the first thing I look for in review.
44+
45+
## Running jobs
46+
47+
"Run now" and the scheduled run have to behave the same way, as the same user, with the same environment as close as we can get it. If a job works when the host's cron runs it and fails when somebody presses the button, people stop trusting both.
48+
49+
Job logs are written on the host by a small wrapper script, and they belong to the user. Cleanup follows the documented limits and nothing else. A log that disappears when somebody refreshes the page is a bug that makes people think their job never ran.
50+
51+
Never touch the real crontab of the machine you are working on. Not in tests, not to check something quickly, not to "just see if it works". Mock it, fake it, or spin up a throwaway container as the host and delete it afterwards. Don't install or purge packages on shared machines or VMs either. The crontab on this laptop runs my actual stuff.
52+
53+
## Translations
54+
55+
Anything a user reads goes through a translation key. Buttons, toasts, tooltips, errors, empty states. A hardcoded string is an English word in somebody's German interface. Every locale file has to carry the same keys as English, and the tests check it. Don't leave English placeholders in the other locales, translate them.
56+
57+
## The REST API
58+
59+
People call it from scripts, Home Assistant, n8n and shell aliases, and they never read the changelog. Adding a field is safe. Renaming one, dropping one, or changing what a status means breaks somebody's automation.
60+
61+
## How I like to work
62+
63+
Look before you build. What you need probably exists already, under a name you wouldn't have picked. Most requests are an environment variable, a check on an existing action, or a few lines next to a case that is already handled.
64+
65+
Fix causes. A guard that hides the error while the crontab on disk is still wrong is worse than the error.
66+
67+
Boy scout rule, within reason. Tidy the mess next to your change while you are in the file. Don't turn a bug fix into a rewrite of the crontab parser.
68+
69+
Pull requests go to `develop`. Never `main`.
70+
71+
Don't push, don't edit `.env`, and don't run anything against a real crontab unless I asked for it in the message you are answering. If a variable is missing, name it and ask.
72+
73+
Treat all of this as good defaults. What I ask for in the message you are answering beats anything here. What you decided on your own does not, so if a rule here fights the task, say so and ask.
74+
75+
## Taste
76+
77+
- No comments. Say it in the naming, or say it to me in the pull request.
78+
- Arrow functions. Short, honest names. If the name needs a paragraph, the function is doing too much.
79+
- `any` is not a solution. Catch `unknown` and narrow it.
80+
- Constants over magic strings, especially for anything that ends up in a cron line or a shell command.
81+
- Log when you catch, through the project logger, with a scope. Never `console.*`, the tests will catch you. Never log a secret or a whole crontab.
82+
- Small files and real modules. Shared logic lives in one place. If the client and the server both need it, they import the same thing instead of keeping two copies that drift.
83+
- New abstractions earn their place. Two similar things are not a pattern.
84+
- The UI is terminal-styled and themed. Reuse the theme variables and the existing components so every theme and dark mode keep working. Nothing hardcoded green on black.
85+
- Keep it quirky. The tag is `cr*nmaster`, the asterisk is red, and commit messages are allowed to have opinions. Understandable first, funny second.
86+
87+
## Words
88+
89+
Same words for the same things, please.
90+
91+
- **you** is the agent reading this. **we** is me, fccview, plus whoever is contributing. **user** is somebody using a running instance, not the developer and not me.
92+
- **instance** is one deployment somebody self-hosts. **host** is the machine whose crontabs we manage. In Docker that is not the container.
93+
- **job** is one cron entry with its schedule, command and our metadata. **crontab user** is the account that owns it and that it runs as.
94+
- **run now** is a manual run from the UI or API. **scheduled run** is the host's cron doing its job. Both write logs when logging is on.
95+
- **wrapper** is the script that wraps a job's command when logging is on, and writes its log on the host.
96+
- **script** is a file in the scripts library. **snippet** is a reusable piece of bash for the editor. **backup** is a saved copy of a job that can be restored.
97+
- **howto** is the user-facing documentation, written for the person running the instance.
98+
99+
## Verifying
100+
101+
Smallest thing that proves the change works. Type check, lint with zero warnings, and the tests covering the area. Run the whole suite if you touched something shared.
102+
103+
Bug fixes come with a regression test where it is practical. If you changed behaviour the tests cover, update them and tell me you did.
104+
105+
The tests around auth, injection, path containment and crontab line round-trips exist because those things broke, or nearly did. If your change makes one of them fail, the change is wrong until proven otherwise.
106+
107+
<!-- BEGIN:nextjs-agent-rules -->
108+
109+
# This is NOT the Next.js you know
110+
111+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
112+
113+
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
114+
115+
<!-- END:nextjs-agent-rules -->

‎Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM node:20-alpine AS base
1+
FROM node:24-alpine AS base
22

33
FROM base AS deps
44
RUN apk add --no-cache libc6-compat

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- [Localization](#localization)
1313
- [Local Development](#local-development)
1414
- [Environment Variables](howto/ENV_VARIABLES.md)
15+
- [System Logging](howto/LOGGING.md)
1516
- [Authentication](#authentication)
1617
- [Usage](#usage)
1718
- [Viewing System Information](#viewing-system-information)
@@ -185,7 +186,7 @@ This includes all configuration options for:
185186
### Important Notes for Docker
186187

187188
- Root user is required for cron operations and direct file access. There is no way around this, if you don't feel comfortable in running it as root feel free to run the app locally with `yarn install`, `yarn build` and `yarn start`
188-
- The `DOCKER=true` environment variable enables direct file access mode for crontab operations. This is REQUIRED when running the application in docker mode.
189+
- Docker is detected automatically, there is no `DOCKER` environment variable to set
189190
- The Docker socket and data volume mounts are required for proper functionality
190191

191192
**Important Note on Root Commands**: When running commands as `root` within Cronmaster, ensure that these commands also function correctly as `root` on your host machine. If a command works as `root` on your host but fails within Cronmaster, please open an issue with detailed information.
@@ -258,6 +259,8 @@ The application automatically detects your operating system and displays:
258259

259260
📖 **For complete logging documentation, see [howto/LOGS.md](howto/LOGS.md)**
260261

262+
📖 **For Cronmaster's own system logs (`LOG_LEVEL`, `LOG_FORMAT=json`, scopes), see [howto/LOGGING.md](howto/LOGGING.md)**
263+
261264
### Cron Schedule Format
262265

263266
The application uses standard cron format: `* * * * *`

‎app/_components/FeatureComponents/Cronjobs/CronJobList.tsx‎

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,11 @@ import {
1212
ClockIcon,
1313
PlusIcon,
1414
Archive,
15-
CaretDownIcon,
16-
CodeIcon,
17-
ChatTextIcon,
18-
GearIcon,
1915
CircleNotchIcon,
2016
FunnelIcon,
2117
} from "@phosphor-icons/react";
2218
import { CronJob } from "@/app/_utils/cronjob-utils";
2319
import { Script } from "@/app/_utils/scripts-utils";
24-
import { UserFilter } from "@/app/_components/FeatureComponents/User/UserFilter";
2520

2621
import { useCronJobState } from "@/app/_hooks/useCronJobState";
2722
import { CronJobItem } from "@/app/_components/FeatureComponents/Cronjobs/Parts/CronJobItem";
@@ -44,13 +39,24 @@ import {
4439
restoreAllCronJobs,
4540
} from "@/app/_server/actions/cronjobs";
4641
import { showToast } from "@/app/_components/GlobalComponents/UIElements/Toast";
42+
import { createLogger } from "@/app/_utils/logger";
43+
import { useIsHydrated } from "@/app/_hooks/useIsHydrated";
44+
45+
const log = createLogger("ui:jobs");
4746

4847
interface CronJobListProps {
4948
cronJobs: CronJob[];
5049
scripts: Script[];
50+
scriptToSchedule?: Script | null;
51+
onScriptScheduled?: () => void;
5152
}
5253

53-
export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
54+
export const CronJobList = ({
55+
cronJobs,
56+
scripts,
57+
scriptToSchedule,
58+
onScriptScheduled,
59+
}: CronJobListProps) => {
5460
const t = useTranslations();
5561
const router = useRouter();
5662
const { subscribe } = useSSEContext();
@@ -68,10 +74,11 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
6874
const [loadedSettings, setLoadedSettings] = useState<boolean>(false);
6975
const [isFiltersModalOpen, setIsFiltersModalOpen] = useState(false);
7076
const [minimalMode, setMinimalMode] = useState(false);
71-
const [isClient, setIsClient] = useState(false);
77+
const isClient = useIsHydrated();
78+
const [settingsChecked, setSettingsChecked] = useState(false);
7279

73-
useEffect(() => {
74-
setIsClient(true);
80+
if (isClient && !settingsChecked) {
81+
setSettingsChecked(true);
7582

7683
try {
7784
const savedScheduleMode = localStorage.getItem(
@@ -92,9 +99,9 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
9299

93100
setLoadedSettings(true);
94101
} catch (error) {
95-
console.warn("Failed to load settings from localStorage:", error);
102+
log.warn("Failed to load settings from localStorage", error);
96103
}
97-
}, []);
104+
}
98105

99106
useEffect(() => {
100107
const unsubscribe = subscribe((event) => {
@@ -115,8 +122,8 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
115122
scheduleDisplayMode
116123
);
117124
} catch (error) {
118-
console.warn(
119-
"Failed to save schedule display mode to localStorage:",
125+
log.warn(
126+
"Failed to save schedule display mode to localStorage",
120127
error
121128
);
122129
}
@@ -128,7 +135,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
128135
try {
129136
localStorage.setItem("cronjob-minimal-mode", minimalMode.toString());
130137
} catch (error) {
131-
console.warn("Failed to save minimal mode to localStorage:", error);
138+
log.warn("Failed to save minimal mode to localStorage", error);
132139
}
133140
}, [minimalMode, isClient]);
134141

@@ -151,18 +158,18 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
151158
const handleRestoreAll = async () => {
152159
const result = await restoreAllCronJobs();
153160
if (result.success) {
154-
showToast("success", result.message);
161+
showToast("success", t("cronjobs.restoreAllSuccess"), result.message);
155162
router.refresh();
156163
setIsBackupModalOpen(false);
157164
} else {
158-
showToast("error", "Failed to restore all jobs", result.message);
165+
showToast("error", t("cronjobs.restoreAllFailed"), result.message);
159166
}
160167
};
161168

162169
const handleBackupAll = async () => {
163170
const result = await backupAllCronJobs();
164171
if (result.success) {
165-
showToast("success", result.message);
172+
showToast("success", t("cronjobs.backupAllSuccess"));
166173
loadBackupFiles();
167174
} else {
168175
showToast("error", t("cronjobs.backupAllFailed"), result.message);
@@ -175,7 +182,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
175182
showToast("success", t("cronjobs.backupDeleted"));
176183
loadBackupFiles();
177184
} else {
178-
showToast("error", "Failed to delete backup", result.message);
185+
showToast("error", t("cronjobs.deleteBackupFailed"), result.message);
179186
}
180187
};
181188

@@ -225,11 +232,18 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
225232
confirmDelete,
226233
confirmClone,
227234
handleEdit,
235+
openNewCronWithScript,
228236
handleEditSubmitLocal,
229237
handleNewCronSubmitLocal,
230238
handleBackupLocal,
231239
} = useCronJobState({ cronJobs, scripts });
232240

241+
useEffect(() => {
242+
if (!scriptToSchedule) return;
243+
openNewCronWithScript(scriptToSchedule);
244+
onScriptScheduled?.();
245+
}, [scriptToSchedule, openNewCronWithScript, onScriptScheduled]);
246+
233247
return (
234248
<>
235249
<Card className="glass-card">
@@ -301,7 +315,7 @@ export const CronJobList = ({ cronJobs, scripts }: CronJobListProps) => {
301315
onNewTaskClick={() => setIsNewCronModalOpen(true)}
302316
/>
303317
) : (
304-
<div className="space-y-4 max-h-[55vh] min-h-[55vh] overflow-y-auto tui-scrollbar pr-1">
318+
<div className="space-y-4 lg:max-h-[55vh] lg:min-h-[55vh] lg:overflow-y-auto tui-scrollbar lg:pr-1">
305319
{loadedSettings ? (
306320
filteredJobs.map((job) =>
307321
minimalMode ? (

0 commit comments

Comments
 (0)