first commit

This commit is contained in:
2026-07-09 22:51:03 +02:00
commit fac491134d
50 changed files with 7547 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "web-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--workspace", "web"],
"port": 5173
}
]
}
+7
View File
@@ -0,0 +1,7 @@
node_modules
**/node_modules
**/dist
data
.env
.git
*.log
+19
View File
@@ -0,0 +1,19 @@
# Public URL the app is reachable at (used for OIDC redirect_uri and cookie behavior).
APP_BASE_URL=https://schedule.example.lan
# Random long string used to sign session cookies. Generate with:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
SESSION_SECRET=change-me-to-a-random-64-char-hex-string
# Port docker-compose publishes on the host (container always listens on 3000).
HOST_PORT=3000
# --- Authentik OIDC application/provider ---
# Create an OAuth2/OIDC "Provider" in Authentik with:
# Redirect URI: <APP_BASE_URL>/auth/callback
# Scopes: openid, email, profile
# then create an "Application" using that provider, and assign the users/groups
# who should be able to sign in. Copy the provider's values below.
AUTHENTIK_ISSUER_URL=https://authentik.example.lan/application/o/schedule-task-manager/
AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
build/
.env
data/*.sqlite
data/*.sqlite-*
*.log
.DS_Store
+28
View File
@@ -0,0 +1,28 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY server/package.json server/package.json
COPY web/package.json web/package.json
RUN npm ci
FROM deps AS build
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
COPY server/package.json server/package.json
RUN npm ci --omit=dev --workspace server
COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/web/dist ./web/dist
COPY server/drizzle ./server/drizzle
COPY agent ./agent
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD node -e "fetch('http://localhost:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "server/dist/index.js"]
+90
View File
@@ -0,0 +1,90 @@
# Schedule Task Manager
Tracks scheduled tasks (cron jobs, systemd timers) across your homelab servers,
grouped by server and schedule type. Servers push their task list to this app
via a small agent script; sign-in is delegated to Authentik (OIDC).
v1 scope: Linux servers only (cron + systemd timers). Windows Task Scheduler
support is a documented future addition — see [`agent/windows/README.md`](agent/windows/README.md).
## Architecture
- `server/` — Express + TypeScript API, SQLite (via libSQL) storage, Authentik OIDC login.
- `web/` — React + Vite single-page app, served as static files by the Express server.
- `agent/linux/``report-tasks.sh` (collects cron + systemd timer data) and
`install.sh` (installs it as a systemd timer on a target server).
Data model, API contract, and sync behavior are documented in code:
[`server/src/db/schema.ts`](server/src/db/schema.ts),
[`server/src/routes/agentReport.ts`](server/src/routes/agentReport.ts),
[`server/src/services/taskSync.ts`](server/src/services/taskSync.ts).
## 1. Set up an Authentik application
In Authentik:
1. Create an **OAuth2/OpenID Provider**:
- Redirect URI: `<APP_BASE_URL>/auth/callback` (e.g. `https://schedule.example.lan/auth/callback`)
- Scopes: `openid`, `email`, `profile`
2. Create an **Application** using that provider, and assign the users/groups who
should be allowed to sign in — Authentik controls who can authenticate; the
app itself has no separate user list.
3. Copy the provider's issuer URL, client ID, and client secret into `.env`
(see `.env.example`).
## 2. Run with Docker (recommended)
```sh
cp .env.example .env
# edit .env: APP_BASE_URL, SESSION_SECRET, AUTHENTIK_*
docker compose up -d --build
```
The app listens on `HOST_PORT` (default `3000`). SQLite data and session
files persist in `./data` on the host.
## 3. Local development
Requires Node.js 20+.
```sh
npm install
cp .env.example .env # fill in AUTHENTIK_* to test real login, or leave
# blank to run everything except /auth/login locally
npm run dev:server # http://localhost:3000 (API)
npm run dev:web # http://localhost:5173 (Vite dev server, proxies /api and /auth to :3000)
```
Visit `http://localhost:5173` during development. Database migrations run
automatically on server start.
## 4. Add a server and install the agent
1. Sign in, go to **Servers**, and add a server. The generated API token is
shown once — copy it.
2. On the target Linux server (as root):
```sh
curl -fsSL https://schedule.example.lan/agent/linux/install.sh | \
API_URL=https://schedule.example.lan API_TOKEN=stm_xxx bash
```
This installs `report-tasks.sh` to `/usr/local/bin`, writes the API
credentials to `/etc/schedule-task-manager-agent.env`, and installs a
`schedule-task-manager-agent.timer` systemd timer that reports every 15
minutes (override with `INTERVAL_MINUTES=5` before the `bash` above).
Requires `curl`, `jq`, and `systemd` on the target server.
3. Tasks appear on the Dashboard, grouped by server and then by schedule type,
within one reporting interval.
Tasks that stop appearing in a server's report are marked **stale** rather
than deleted (so a temporary agent failure doesn't wipe history) and are
hidden by default — toggle "Show stale/missing tasks" to see them.
## Environment variables
See [`.env.example`](.env.example) for the full list with descriptions.
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Installs the Schedule Task Manager agent as a systemd timer.
#
# Usage:
# curl -fsSL https://schedule.example.lan/agent/linux/install.sh | \
# API_URL=https://schedule.example.lan API_TOKEN=stm_xxx bash
#
set -euo pipefail
: "${API_URL:?Set API_URL to your Schedule Task Manager URL, e.g. https://schedule.example.lan}"
: "${API_TOKEN:?Set API_TOKEN to the per-server token generated on the Servers page}"
INSTALL_DIR="/usr/local/bin"
CONFIG_DIR="/etc"
SYSTEMD_DIR="/etc/systemd/system"
INTERVAL_MINUTES="${INTERVAL_MINUTES:-15}"
if [[ "$EUID" -ne 0 ]]; then
echo "This installer must be run as root (it installs a systemd timer)." >&2
exit 1
fi
for bin in curl jq systemctl; do
if ! command -v "$bin" >/dev/null 2>&1; then
echo "Required dependency '$bin' is not installed." >&2
exit 1
fi
done
echo "Installing Schedule Task Manager agent from $API_URL ..."
curl -fsSL "$API_URL/agent/linux/report-tasks.sh" -o "$INSTALL_DIR/schedule-task-manager-agent.sh"
chmod 755 "$INSTALL_DIR/schedule-task-manager-agent.sh"
umask 077
cat > "$CONFIG_DIR/schedule-task-manager-agent.env" <<EOF
API_URL=$API_URL
API_TOKEN=$API_TOKEN
EOF
chmod 600 "$CONFIG_DIR/schedule-task-manager-agent.env"
cat > "$SYSTEMD_DIR/schedule-task-manager-agent.service" <<EOF
[Unit]
Description=Report scheduled tasks to Schedule Task Manager
[Service]
Type=oneshot
EnvironmentFile=$CONFIG_DIR/schedule-task-manager-agent.env
ExecStart=$INSTALL_DIR/schedule-task-manager-agent.sh
EOF
cat > "$SYSTEMD_DIR/schedule-task-manager-agent.timer" <<EOF
[Unit]
Description=Periodically report scheduled tasks to Schedule Task Manager
[Timer]
OnBootSec=2min
OnUnitActiveSec=${INTERVAL_MINUTES}min
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now schedule-task-manager-agent.timer
echo "Installed. Running an initial report now..."
"$INSTALL_DIR/schedule-task-manager-agent.sh"
echo "Done. The agent reports every ${INTERVAL_MINUTES} minute(s) via the 'schedule-task-manager-agent.timer' systemd timer."
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
# Collects cron jobs and systemd timers on this host and POSTs them to the
# Schedule Task Manager API. Intended to run as root via a periodic systemd
# timer (see install.sh) but can be run manually for testing:
#
# API_URL=https://schedule.example.lan API_TOKEN=stm_xxx ./report-tasks.sh --dry-run
#
set -euo pipefail
ENV_FILE="${ENV_FILE:-/etc/schedule-task-manager-agent.env}"
if [[ -f "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
source "$ENV_FILE"
fi
API_URL="${API_URL:-}"
API_TOKEN="${API_TOKEN:-}"
DRY_RUN=0
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
if [[ -z "$API_URL" || -z "$API_TOKEN" ]]; then
echo "API_URL and API_TOKEN must be set (env vars or $ENV_FILE)" >&2
exit 1
fi
for bin in curl jq; do
if ! command -v "$bin" >/dev/null 2>&1; then
echo "Required dependency '$bin' is not installed." >&2
exit 1
fi
done
TASKS_JSON="[]"
add_task() {
local schedule_type="$1" name="$2" command="$3" schedule_expression="$4" source="$5" enabled="$6"
TASKS_JSON=$(jq -c \
--arg schedule_type "$schedule_type" \
--arg name "$name" \
--arg command "$command" \
--arg schedule_expression "$schedule_expression" \
--arg source "$source" \
--argjson enabled "$enabled" \
'. + [{
schedule_type: $schedule_type,
name: $name,
command: $command,
schedule_expression: $schedule_expression,
source: $source,
enabled: $enabled
}]' <<<"$TASKS_JSON")
}
add_task_with_next_run() {
local schedule_type="$1" name="$2" command="$3" schedule_expression="$4" source="$5" enabled="$6" next_run_at="$7"
TASKS_JSON=$(jq -c \
--arg schedule_type "$schedule_type" \
--arg name "$name" \
--arg command "$command" \
--arg schedule_expression "$schedule_expression" \
--arg source "$source" \
--argjson enabled "$enabled" \
--arg next_run_at "$next_run_at" \
'. + [{
schedule_type: $schedule_type,
name: $name,
command: $command,
schedule_expression: $schedule_expression,
source: $source,
enabled: $enabled,
next_run_at: $next_run_at
}]' <<<"$TASKS_JSON")
}
parse_crontab_lines() {
# Reads 5-field-schedule + command cron lines from stdin.
# $1 = source label, $2 = "system" (7-field, includes a user column) or "user" (6-field)
local source="$1" mode="$2"
while IFS= read -r line; do
line="${line%%$'\r'}"
[[ -z "$line" ]] && continue
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" =~ ^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*= ]] && continue
if [[ "$mode" == "system" ]]; then
# min hour dom mon dow user command...
if [[ "$line" =~ ^[[:space:]]*([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+[^[:space:]]+[[:space:]]+(.+)$ ]]; then
local sched="${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]} ${BASH_REMATCH[4]} ${BASH_REMATCH[5]}"
local cmd="${BASH_REMATCH[6]}"
add_task "cron" "$cmd" "$cmd" "$sched" "$source" true
fi
else
# min hour dom mon dow command...
if [[ "$line" =~ ^[[:space:]]*([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+(.+)$ ]]; then
local sched="${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]} ${BASH_REMATCH[4]} ${BASH_REMATCH[5]}"
local cmd="${BASH_REMATCH[6]}"
add_task "cron" "$cmd" "$cmd" "$sched" "$source" true
fi
fi
done
}
collect_cron() {
[[ -r /etc/crontab ]] && parse_crontab_lines "/etc/crontab" "system" < /etc/crontab
if [[ -d /etc/cron.d ]]; then
for f in /etc/cron.d/*; do
[[ -f "$f" && -r "$f" ]] || continue
parse_crontab_lines "$f" "system" < "$f"
done
fi
if command -v crontab >/dev/null 2>&1 && [[ -r /etc/passwd ]]; then
while IFS=: read -r user _ uid _ _ _ shell; do
case "$shell" in
*/nologin|*/false|"") continue ;;
esac
[[ "$uid" -lt 1000 && "$uid" != "0" ]] && continue
local_crontab=$(crontab -l -u "$user" 2>/dev/null || true)
[[ -z "$local_crontab" ]] && continue
parse_crontab_lines "crontab:$user" "user" <<<"$local_crontab"
done < /etc/passwd
fi
}
collect_systemd_timers() {
command -v systemctl >/dev/null 2>&1 || return 0
local timers_json
timers_json=$(systemctl list-timers --all --output=json 2>/dev/null || echo "[]")
while IFS= read -r entry; do
local unit activates next_usec enabled_state schedule_expr next_run_at
unit=$(jq -r '.unit' <<<"$entry")
activates=$(jq -r '.activates // ""' <<<"$entry")
next_usec=$(jq -r '.next // 0' <<<"$entry")
enabled_state=$(systemctl is-enabled "$unit" 2>/dev/null || echo "unknown")
local enabled_bool="false"
[[ "$enabled_state" == "enabled" || "$enabled_state" == "static" ]] && enabled_bool="true"
schedule_expr=$(systemctl show "$unit" -p TimersCalendar --value 2>/dev/null | sed -n 's/.*OnCalendar=\([^;]*\);.*/\1/p' | sed 's/[[:space:]]*$//')
[[ -z "$schedule_expr" ]] && schedule_expr="(see: systemctl status $unit)"
next_run_at=""
if [[ "$next_usec" =~ ^[0-9]+$ && "$next_usec" -gt 0 ]]; then
next_run_at=$(date -u -d "@$((next_usec / 1000000))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
fi
if [[ -n "$next_run_at" ]]; then
add_task_with_next_run "systemd_timer" "$unit" "$activates" "$schedule_expr" "$unit" "$enabled_bool" "$next_run_at"
else
add_task "systemd_timer" "$unit" "$activates" "$schedule_expr" "$unit" "$enabled_bool"
fi
done < <(jq -c '.[]' <<<"$timers_json")
}
collect_cron
collect_systemd_timers
HOSTNAME_VALUE=$(hostname -f 2>/dev/null || hostname)
PAYLOAD=$(jq -n \
--arg hostname "$HOSTNAME_VALUE" \
--arg os_type "linux" \
--arg reported_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
--argjson tasks "$TASKS_JSON" \
'{hostname: $hostname, os_type: $os_type, reported_at: $reported_at, tasks: $tasks}')
if [[ "$DRY_RUN" == "1" ]]; then
echo "$PAYLOAD" | jq .
exit 0
fi
response=$(curl -sS -o /tmp/stm-agent-response.json -w "%{http_code}" \
-X POST "$API_URL/api/agent/report" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
if [[ "$response" -lt 200 || "$response" -ge 300 ]]; then
echo "Report failed with HTTP $response:" >&2
cat /tmp/stm-agent-response.json >&2
exit 1
fi
echo "Reported $(jq 'length' <<<"$TASKS_JSON") task(s) successfully."
+13
View File
@@ -0,0 +1,13 @@
# Windows agent (planned, not yet implemented)
v1 of Schedule Task Manager only supports Linux servers (cron + systemd timers). A
Windows agent is a natural v2 addition and would follow the same contract as the
Linux agent in [`../linux/report-tasks.sh`](../linux/report-tasks.sh):
- Collect tasks with `Get-ScheduledTask | Get-ScheduledTaskInfo` (name, action/command,
trigger description, next run time, enabled state).
- POST the same JSON shape to `POST /api/agent/report` with `schedule_type: "windows_task"`
(the server and UI already treat `schedule_type` as an open string in storage; only the
`tasks` API and UI schedule-type filter would need the new value added).
- Ship as a scheduled task (naturally) or a small Windows service that runs on a timer,
configured via the same `API_URL` / `API_TOKEN` environment variables as the Linux agent.
+14
View File
@@ -0,0 +1,14 @@
services:
schedule-task-manager:
build: .
container_name: schedule-task-manager
restart: unless-stopped
ports:
- "${HOST_PORT:-3000}:3000"
env_file:
- .env
environment:
PORT: 3000
NODE_ENV: production
volumes:
- ./data:/app/data
+5248
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "schedule-task-manager",
"private": true,
"version": "1.0.0",
"workspaces": [
"server",
"web"
],
"scripts": {
"dev:server": "npm run dev --workspace server",
"dev:web": "npm run dev --workspace web",
"build": "npm run build --workspace web && npm run build --workspace server",
"db:generate": "npm run db:generate --workspace server",
"db:migrate": "npm run db:migrate --workspace server",
"start": "npm run start --workspace server"
}
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./src/db/schema.ts",
out: "./drizzle",
dbCredentials: {
url: `file:${process.env.DATABASE_PATH ?? "../data/schedule-task-manager.sqlite"}`,
},
});
+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE `scheduled_tasks` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`server_id` integer NOT NULL,
`schedule_type` text NOT NULL,
`name` text NOT NULL,
`command` text,
`schedule_expression` text,
`source` text,
`enabled` integer DEFAULT true NOT NULL,
`next_run_at` text,
`raw_metadata` text,
`is_stale` integer DEFAULT false NOT NULL,
`first_seen_at` text DEFAULT (current_timestamp) NOT NULL,
`last_seen_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `servers` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`hostname` text,
`os_type` text DEFAULT 'linux' NOT NULL,
`description` text,
`api_token_hash` text NOT NULL,
`api_token_prefix` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`last_seen_at` text
);
+212
View File
@@ -0,0 +1,212 @@
{
"version": "6",
"dialect": "sqlite",
"id": "81d34268-977d-459b-b4a1-8d62b2d81a56",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"scheduled_tasks": {
"name": "scheduled_tasks",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"server_id": {
"name": "server_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_type": {
"name": "schedule_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"schedule_expression": {
"name": "schedule_expression",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"next_run_at": {
"name": "next_run_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"raw_metadata": {
"name": "raw_metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_stale": {
"name": "is_stale",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"first_seen_at": {
"name": "first_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {
"scheduled_tasks_server_id_servers_id_fk": {
"name": "scheduled_tasks_server_id_servers_id_fk",
"tableFrom": "scheduled_tasks",
"tableTo": "servers",
"columnsFrom": [
"server_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"servers": {
"name": "servers",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hostname": {
"name": "hostname",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"os_type": {
"name": "os_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'linux'"
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"api_token_hash": {
"name": "api_token_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_token_prefix": {
"name": "api_token_prefix",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1783628241015,
"tag": "0000_aspiring_blink",
"breakpoints": true
}
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "server",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts"
},
"dependencies": {
"@libsql/client": "^0.14.0",
"drizzle-orm": "^0.38.4",
"express": "^4.21.2",
"express-session": "^1.18.1",
"openid-client": "^6.1.7",
"session-file-store": "^1.5.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.1",
"@types/node": "^22.10.5",
"@types/session-file-store": "^1.2.5",
"drizzle-kit": "^0.30.2",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}
+8
View File
@@ -0,0 +1,8 @@
import type { Request, Response, NextFunction } from "express";
export function requireAuth(req: Request, res: Response, next: NextFunction) {
if (req.session.user) {
return next();
}
return res.status(401).json({ error: "unauthorized" });
}
+15
View File
@@ -0,0 +1,15 @@
import * as client from "openid-client";
import { env } from "../env.js";
let configPromise: Promise<client.Configuration> | null = null;
export function getOidcConfig(): Promise<client.Configuration> {
if (!configPromise) {
configPromise = client.discovery(
new URL(env.authentik.issuerUrl),
env.authentik.clientId,
env.authentik.clientSecret,
);
}
return configPromise;
}
+97
View File
@@ -0,0 +1,97 @@
import { Router } from "express";
import * as client from "openid-client";
import { getOidcConfig } from "./oidc.js";
import { env } from "../env.js";
export const authRouter = Router();
authRouter.get("/login", async (req, res, next) => {
try {
const config = await getOidcConfig();
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();
req.session.pendingAuth = { codeVerifier, state };
const redirectUri = new URL("/auth/callback", env.appBaseUrl).toString();
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope: "openid email profile",
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
});
req.session.save((err) => {
if (err) return next(err);
res.redirect(authUrl.href);
});
} catch (err) {
next(err);
}
});
authRouter.get("/callback", async (req, res, next) => {
try {
const pending = req.session.pendingAuth;
if (!pending) {
return res.status(400).send("Login session expired. Please try signing in again.");
}
const config = await getOidcConfig();
const currentUrl = new URL(req.originalUrl, env.appBaseUrl);
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: pending.codeVerifier,
expectedState: pending.state,
});
const claims = tokens.claims();
if (!claims?.sub) {
return res.status(400).send("Identity provider did not return a valid identity.");
}
let email: string | undefined = typeof claims.email === "string" ? claims.email : undefined;
let name: string | undefined = typeof claims.name === "string" ? claims.name : undefined;
try {
const userinfo = await client.fetchUserInfo(config, tokens.access_token, claims.sub);
email = userinfo.email ?? email;
name = userinfo.name ?? userinfo.preferred_username ?? name;
} catch {
// fall back to ID token claims already captured above
}
delete req.session.pendingAuth;
req.session.user = { sub: claims.sub, email, name, idToken: tokens.id_token };
req.session.save((err) => {
if (err) return next(err);
res.redirect("/");
});
} catch (err) {
next(err);
}
});
authRouter.get("/logout", async (req, res, next) => {
const idToken = req.session.user?.idToken;
try {
const config = await getOidcConfig();
let endSessionUrl: URL | undefined;
try {
endSessionUrl = client.buildEndSessionUrl(config, {
post_logout_redirect_uri: env.appBaseUrl,
...(idToken ? { id_token_hint: idToken } : {}),
});
} catch {
// Provider doesn't advertise RP-Initiated Logout; just clear our own session.
}
req.session.destroy((err) => {
if (err) return next(err);
res.redirect(endSessionUrl ? endSessionUrl.href : "/");
});
} catch (err) {
next(err);
}
});
+15
View File
@@ -0,0 +1,15 @@
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { resolveDataPath } from "../paths.js";
import * as schema from "./schema.js";
const dbPath = resolveDataPath(process.env.DATABASE_PATH ?? "../data/schedule-task-manager.sqlite");
mkdirSync(dirname(dbPath), { recursive: true });
const client = createClient({ url: `file:${dbPath}` });
await client.execute("PRAGMA foreign_keys = ON;");
export const db = drizzle(client, { schema });
export { client };
+17
View File
@@ -0,0 +1,17 @@
import { migrate } from "drizzle-orm/libsql/migrator";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { db, client } from "./client.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
export async function runMigrations() {
await migrate(db, { migrationsFolder: join(__dirname, "..", "..", "drizzle") });
}
// Allow running directly via `npm run db:migrate`
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
await runMigrations();
client.close();
console.log("Migrations applied.");
}
+38
View File
@@ -0,0 +1,38 @@
import { sql } from "drizzle-orm";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const servers = sqliteTable("servers", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
hostname: text("hostname"),
osType: text("os_type").notNull().default("linux"),
description: text("description"),
apiTokenHash: text("api_token_hash").notNull(),
apiTokenPrefix: text("api_token_prefix").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
lastSeenAt: text("last_seen_at"),
});
export const scheduledTasks = sqliteTable("scheduled_tasks", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer'
name: text("name").notNull(),
command: text("command"),
scheduleExpression: text("schedule_expression"),
source: text("source"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
nextRunAt: text("next_run_at"),
rawMetadata: text("raw_metadata"),
isStale: integer("is_stale", { mode: "boolean" }).notNull().default(false),
firstSeenAt: text("first_seen_at")
.notNull()
.default(sql`(current_timestamp)`),
lastSeenAt: text("last_seen_at")
.notNull()
.default(sql`(current_timestamp)`),
});
+25
View File
@@ -0,0 +1,25 @@
export const env = {
port: Number(process.env.PORT ?? 3000),
nodeEnv: process.env.NODE_ENV ?? "development",
appBaseUrl: process.env.APP_BASE_URL ?? "http://localhost:3000",
sessionSecret: process.env.SESSION_SECRET ?? "dev-insecure-session-secret-change-me",
sessionDir: process.env.SESSION_DIR ?? "../data/sessions",
databasePath: process.env.DATABASE_PATH ?? "../data/schedule-task-manager.sqlite",
authentik: {
issuerUrl: process.env.AUTHENTIK_ISSUER_URL ?? "",
clientId: process.env.AUTHENTIK_CLIENT_ID ?? "",
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET ?? "",
},
get authEnabled() {
return Boolean(this.authentik.issuerUrl && this.authentik.clientId && this.authentik.clientSecret);
},
};
export function warnIfAuthNotConfigured() {
if (!env.authEnabled) {
console.warn(
"AUTHENTIK_ISSUER_URL / AUTHENTIK_CLIENT_ID / AUTHENTIK_CLIENT_SECRET are not fully set. " +
"The app will boot, but /auth/login and /auth/logout will fail until they are configured.",
);
}
}
+71
View File
@@ -0,0 +1,71 @@
import express from "express";
import session from "express-session";
import FileStoreFactory from "session-file-store";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { mkdirSync, existsSync } from "node:fs";
import { env, warnIfAuthNotConfigured } from "./env.js";
import { resolveDataPath } from "./paths.js";
import { runMigrations } from "./db/migrate.js";
import { authRouter } from "./auth/router.js";
import { meRouter } from "./routes/me.js";
import { serversRouter } from "./routes/servers.js";
import { tasksRouter } from "./routes/tasks.js";
import { agentReportRouter } from "./routes/agentReport.js";
warnIfAuthNotConfigured();
await runMigrations();
const __dirname = dirname(fileURLToPath(import.meta.url));
const webDist = join(__dirname, "..", "..", "web", "dist");
const agentDir = join(__dirname, "..", "..", "agent");
const FileStore = FileStoreFactory(session);
const sessionDir = resolveDataPath(env.sessionDir);
mkdirSync(sessionDir, { recursive: true });
const app = express();
app.set("trust proxy", 1);
app.use(express.json());
app.use(
session({
store: new FileStore({ path: sessionDir, logFn: () => {} }),
secret: env.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: "lax",
secure: env.nodeEnv === "production",
maxAge: 7 * 24 * 60 * 60 * 1000,
},
}),
);
app.get("/health", (_req, res) => res.json({ ok: true }));
// Publicly readable so `curl .../agent/linux/install.sh | bash` works from a
// freshly provisioned server with no prior session. Contains no secrets.
if (existsSync(agentDir)) {
app.use("/agent", express.static(agentDir));
}
app.use("/auth", authRouter);
app.use("/api/me", meRouter);
app.use("/api/servers", serversRouter);
app.use("/api/tasks", tasksRouter);
app.use("/api/agent/report", agentReportRouter);
if (existsSync(webDist)) {
app.use(express.static(webDist));
app.get("*", (_req, res) => res.sendFile(join(webDist, "index.html")));
}
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
res.status(500).json({ error: "internal_error" });
});
app.listen(env.port, () => {
console.log(`schedule-task-manager listening on port ${env.port}`);
});
+10
View File
@@ -0,0 +1,10 @@
import { fileURLToPath } from "node:url";
import { dirname, resolve, isAbsolute } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const serverRoot = resolve(__dirname, ".."); // server/
/** Resolves a config path relative to the server package root, regardless of process.cwd(). */
export function resolveDataPath(path: string): string {
return isAbsolute(path) ? path : resolve(serverRoot, path);
}
+62
View File
@@ -0,0 +1,62 @@
import { Router } from "express";
import { z } from "zod";
import { db } from "../db/client.js";
import { hashToken } from "../services/tokens.js";
import { syncServerTasks } from "../services/taskSync.js";
export const agentReportRouter = Router();
const reportSchema = z.object({
hostname: z.string().max(255).optional(),
os_type: z.string().optional(),
reported_at: z.string().optional(),
tasks: z.array(
z.object({
schedule_type: z.enum(["cron", "systemd_timer"]),
name: z.string().min(1),
command: z.string().optional(),
schedule_expression: z.string().optional(),
source: z.string().optional(),
enabled: z.boolean().optional(),
next_run_at: z.string().optional(),
metadata: z.unknown().optional(),
}),
),
});
agentReportRouter.post("/", async (req, res) => {
const authHeader = req.header("authorization") ?? "";
const match = authHeader.match(/^Bearer\s+(.+)$/i);
if (!match) {
return res.status(401).json({ error: "missing_token" });
}
const tokenHash = hashToken(match[1]);
const server = await db.query.servers.findFirst({
where: (s, { eq }) => eq(s.apiTokenHash, tokenHash),
});
if (!server) {
return res.status(401).json({ error: "invalid_token" });
}
const parsed = reportSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
await syncServerTasks(server.id, {
hostname: parsed.data.hostname,
tasks: parsed.data.tasks.map((t) => ({
scheduleType: t.schedule_type,
name: t.name,
command: t.command,
scheduleExpression: t.schedule_expression,
source: t.source,
enabled: t.enabled,
nextRunAt: t.next_run_at,
metadata: t.metadata,
})),
});
res.status(202).json({ ok: true, taskCount: parsed.data.tasks.length });
});
+9
View File
@@ -0,0 +1,9 @@
import { Router } from "express";
import { requireAuth } from "../auth/middleware.js";
export const meRouter = Router();
meRouter.get("/", requireAuth, (req, res) => {
const { sub, email, name } = req.session.user!;
res.json({ user: { sub, email, name } });
});
+76
View File
@@ -0,0 +1,76 @@
import { Router } from "express";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
import { servers } from "../db/schema.js";
import { requireAuth } from "../auth/middleware.js";
import { generateApiToken } from "../services/tokens.js";
export const serversRouter = Router();
serversRouter.use(requireAuth);
const createServerSchema = z.object({
name: z.string().min(1).max(100),
hostname: z.string().max(255).optional(),
osType: z.literal("linux").default("linux"),
description: z.string().max(500).optional(),
});
serversRouter.get("/", async (_req, res) => {
const rows = await db.query.servers.findMany({ orderBy: (s, { asc }) => [asc(s.name)] });
res.json({
servers: rows.map(({ apiTokenHash, ...rest }) => rest),
});
});
serversRouter.post("/", async (req, res) => {
const parsed = createServerSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const { token, prefix, hash } = generateApiToken();
const [created] = await db
.insert(servers)
.values({
name: parsed.data.name,
hostname: parsed.data.hostname,
osType: parsed.data.osType,
description: parsed.data.description,
apiTokenHash: hash,
apiTokenPrefix: prefix,
})
.returning();
const { apiTokenHash, ...serverOut } = created;
// The full token is only ever shown once, at creation time.
res.status(201).json({ server: serverOut, token });
});
serversRouter.post("/:id/rotate-token", async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const { token, prefix, hash } = generateApiToken();
const [updated] = await db
.update(servers)
.set({ apiTokenHash: hash, apiTokenPrefix: prefix })
.where(eq(servers.id, id))
.returning();
if (!updated) return res.status(404).json({ error: "not_found" });
const { apiTokenHash, ...serverOut } = updated;
res.json({ server: serverOut, token });
});
serversRouter.delete("/:id", async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const deleted = await db.delete(servers).where(eq(servers.id, id)).returning();
if (deleted.length === 0) return res.status(404).json({ error: "not_found" });
res.status(204).end();
});
+53
View File
@@ -0,0 +1,53 @@
import { Router } from "express";
import { and, eq, like, or } from "drizzle-orm";
import { db } from "../db/client.js";
import { scheduledTasks, servers } from "../db/schema.js";
import { requireAuth } from "../auth/middleware.js";
export const tasksRouter = Router();
tasksRouter.use(requireAuth);
tasksRouter.get("/", async (req, res) => {
const serverId = req.query.serverId ? Number(req.query.serverId) : undefined;
const scheduleType = typeof req.query.scheduleType === "string" ? req.query.scheduleType : undefined;
const search = typeof req.query.search === "string" ? req.query.search.trim() : undefined;
const includeStale = req.query.includeStale === "true";
const conditions = [];
if (serverId && Number.isInteger(serverId)) {
conditions.push(eq(scheduledTasks.serverId, serverId));
}
if (scheduleType === "cron" || scheduleType === "systemd_timer") {
conditions.push(eq(scheduledTasks.scheduleType, scheduleType));
}
if (!includeStale) {
conditions.push(eq(scheduledTasks.isStale, false));
}
if (search) {
const pattern = `%${search}%`;
conditions.push(or(like(scheduledTasks.name, pattern), like(scheduledTasks.command, pattern)));
}
const rows = await db
.select({
id: scheduledTasks.id,
serverId: scheduledTasks.serverId,
serverName: servers.name,
scheduleType: scheduledTasks.scheduleType,
name: scheduledTasks.name,
command: scheduledTasks.command,
scheduleExpression: scheduledTasks.scheduleExpression,
source: scheduledTasks.source,
enabled: scheduledTasks.enabled,
nextRunAt: scheduledTasks.nextRunAt,
isStale: scheduledTasks.isStale,
firstSeenAt: scheduledTasks.firstSeenAt,
lastSeenAt: scheduledTasks.lastSeenAt,
})
.from(scheduledTasks)
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(servers.name, scheduledTasks.scheduleType, scheduledTasks.name);
res.json({ tasks: rows });
});
+89
View File
@@ -0,0 +1,89 @@
import { and, eq, notInArray } from "drizzle-orm";
import { db } from "../db/client.js";
import { scheduledTasks, servers } from "../db/schema.js";
export interface IncomingTask {
scheduleType: "cron" | "systemd_timer";
name: string;
command?: string;
scheduleExpression?: string;
source?: string;
enabled?: boolean;
nextRunAt?: string;
metadata?: unknown;
}
export interface AgentReport {
hostname?: string;
tasks: IncomingTask[];
}
export async function syncServerTasks(serverId: number, report: AgentReport) {
const now = new Date().toISOString();
const existing = await db.query.scheduledTasks.findMany({
where: eq(scheduledTasks.serverId, serverId),
});
const seenIds: number[] = [];
for (const task of report.tasks) {
const match = existing.find(
(t) =>
t.scheduleType === task.scheduleType &&
t.name === task.name &&
(t.source ?? "") === (task.source ?? ""),
);
if (match) {
const [updated] = await db
.update(scheduledTasks)
.set({
command: task.command,
scheduleExpression: task.scheduleExpression,
enabled: task.enabled ?? true,
nextRunAt: task.nextRunAt,
rawMetadata: task.metadata ? JSON.stringify(task.metadata) : null,
isStale: false,
lastSeenAt: now,
})
.where(eq(scheduledTasks.id, match.id))
.returning({ id: scheduledTasks.id });
seenIds.push(updated.id);
} else {
const [created] = await db
.insert(scheduledTasks)
.values({
serverId,
scheduleType: task.scheduleType,
name: task.name,
command: task.command,
scheduleExpression: task.scheduleExpression,
source: task.source,
enabled: task.enabled ?? true,
nextRunAt: task.nextRunAt,
rawMetadata: task.metadata ? JSON.stringify(task.metadata) : null,
firstSeenAt: now,
lastSeenAt: now,
})
.returning({ id: scheduledTasks.id });
seenIds.push(created.id);
}
}
// Anything belonging to this server that wasn't in this report is now stale,
// rather than deleted, so a bad/partial agent run doesn't wipe history.
if (seenIds.length > 0) {
await db
.update(scheduledTasks)
.set({ isStale: true })
.where(and(eq(scheduledTasks.serverId, serverId), notInArray(scheduledTasks.id, seenIds)));
} else {
await db.update(scheduledTasks).set({ isStale: true }).where(eq(scheduledTasks.serverId, serverId));
}
await db
.update(servers)
.set({ lastSeenAt: now, ...(report.hostname ? { hostname: report.hostname } : {}) })
.where(eq(servers.id, serverId));
}
+20
View File
@@ -0,0 +1,20 @@
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
const TOKEN_PREFIX_LENGTH = 8;
export function generateApiToken(): { token: string; prefix: string; hash: string } {
const token = `stm_${randomBytes(24).toString("hex")}`;
const prefix = token.slice(0, TOKEN_PREFIX_LENGTH);
return { token, prefix, hash: hashToken(token) };
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function tokensMatch(hashA: string, hashB: string): boolean {
const bufA = Buffer.from(hashA, "hex");
const bufB = Buffer.from(hashB, "hex");
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
+16
View File
@@ -0,0 +1,16 @@
import "express-session";
declare module "express-session" {
interface SessionData {
user?: {
sub: string;
email?: string;
name?: string;
idToken?: string;
};
pendingAuth?: {
codeVerifier: string;
state: string;
};
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": false
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Schedule Task Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"cronstrue": "^2.53.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import { Routes, Route, NavLink } from "react-router-dom";
import { api, type CurrentUser, UnauthorizedError } from "./api/client";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import ServersAdmin from "./pages/ServersAdmin";
export default function App() {
const [user, setUser] = useState<CurrentUser | null | undefined>(undefined);
useEffect(() => {
api.me().then(
(res) => setUser(res.user),
(err) => {
if (!(err instanceof UnauthorizedError)) {
console.error(err);
}
setUser(null);
},
);
}, []);
if (user === undefined) {
return <div className="loading-screen">Loading</div>;
}
if (user === null) {
return <Login />;
}
return (
<div className="app-shell">
<header className="app-header">
<div className="brand">Schedule Task Manager</div>
<nav>
<NavLink to="/" end>
Dashboard
</NavLink>
<NavLink to="/servers">Servers</NavLink>
</nav>
<div className="user-info">
<span>{user.name ?? user.email ?? user.sub}</span>
<a href="/auth/logout">Sign out</a>
</div>
</header>
<main>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/servers" element={<ServersAdmin />} />
</Routes>
</main>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
export interface ServerRecord {
id: number;
name: string;
hostname: string | null;
osType: string;
description: string | null;
apiTokenPrefix: string;
createdAt: string;
lastSeenAt: string | null;
}
export interface TaskRecord {
id: number;
serverId: number;
serverName: string;
scheduleType: "cron" | "systemd_timer";
name: string;
command: string | null;
scheduleExpression: string | null;
source: string | null;
enabled: boolean;
nextRunAt: string | null;
isStale: boolean;
firstSeenAt: string;
lastSeenAt: string;
}
export interface CurrentUser {
sub: string;
email?: string;
name?: string;
}
export class UnauthorizedError extends Error {}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
credentials: "same-origin",
});
if (res.status === 401) {
throw new UnauthorizedError("unauthorized");
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Request failed (${res.status}): ${body}`);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
me: () => request<{ user: CurrentUser }>("/api/me"),
servers: {
list: () => request<{ servers: ServerRecord[] }>("/api/servers"),
create: (data: { name: string; hostname?: string; description?: string }) =>
request<{ server: ServerRecord; token: string }>("/api/servers", {
method: "POST",
body: JSON.stringify(data),
}),
rotateToken: (id: number) =>
request<{ server: ServerRecord; token: string }>(`/api/servers/${id}/rotate-token`, {
method: "POST",
}),
remove: (id: number) => request<void>(`/api/servers/${id}`, { method: "DELETE" }),
},
tasks: {
list: (
params: { serverId?: number; scheduleType?: string; search?: string; includeStale?: boolean } = {},
) => {
const qs = new URLSearchParams();
if (params.serverId) qs.set("serverId", String(params.serverId));
if (params.scheduleType) qs.set("scheduleType", params.scheduleType);
if (params.search) qs.set("search", params.search);
if (params.includeStale) qs.set("includeStale", "true");
const query = qs.toString();
return request<{ tasks: TaskRecord[] }>(`/api/tasks${query ? `?${query}` : ""}`);
},
},
};
+56
View File
@@ -0,0 +1,56 @@
import type { ServerRecord } from "../api/client";
export interface Filters {
serverId: number | undefined;
scheduleType: string | undefined;
search: string;
includeStale: boolean;
}
interface Props {
servers: ServerRecord[];
filters: Filters;
onChange: (filters: Filters) => void;
}
export default function FilterBar({ servers, filters, onChange }: Props) {
return (
<div className="filter-bar">
<select
value={filters.serverId ?? ""}
onChange={(e) =>
onChange({ ...filters, serverId: e.target.value ? Number(e.target.value) : undefined })
}
>
<option value="">All servers</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<select
value={filters.scheduleType ?? ""}
onChange={(e) => onChange({ ...filters, scheduleType: e.target.value || undefined })}
>
<option value="">All schedule types</option>
<option value="cron">Cron</option>
<option value="systemd_timer">systemd timer</option>
</select>
<input
type="search"
placeholder="Search name or command…"
value={filters.search}
onChange={(e) => onChange({ ...filters, search: e.target.value })}
/>
<label className="checkbox-label">
<input
type="checkbox"
checked={filters.includeStale}
onChange={(e) => onChange({ ...filters, includeStale: e.target.checked })}
/>
Show stale/missing tasks
</label>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { TaskRecord } from "../api/client";
import TaskTable from "./TaskTable";
const SCHEDULE_TYPE_LABELS: Record<string, string> = {
cron: "Cron jobs",
systemd_timer: "systemd timers",
};
export default function ServerGroup({ serverName, tasks }: { serverName: string; tasks: TaskRecord[] }) {
const byType = new Map<string, TaskRecord[]>();
for (const task of tasks) {
const list = byType.get(task.scheduleType) ?? [];
list.push(task);
byType.set(task.scheduleType, list);
}
return (
<section className="server-group">
<h2>{serverName}</h2>
{[...byType.entries()].map(([scheduleType, groupTasks]) => (
<div key={scheduleType} className="schedule-type-group">
<h3>{SCHEDULE_TYPE_LABELS[scheduleType] ?? scheduleType}</h3>
<TaskTable tasks={groupTasks} />
</div>
))}
</section>
);
}
+52
View File
@@ -0,0 +1,52 @@
import cronstrue from "cronstrue";
import type { TaskRecord } from "../api/client";
function describeSchedule(task: TaskRecord): string {
if (!task.scheduleExpression) return "—";
if (task.scheduleType === "cron") {
try {
return cronstrue.toString(task.scheduleExpression, { verbose: false });
} catch {
return task.scheduleExpression;
}
}
return task.scheduleExpression;
}
export default function TaskTable({ tasks }: { tasks: TaskRecord[] }) {
return (
<table className="task-table">
<thead>
<tr>
<th>Name</th>
<th>Command</th>
<th>Schedule</th>
<th>Next run</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => (
<tr key={task.id} className={task.isStale ? "stale" : undefined}>
<td>{task.name}</td>
<td className="mono">{task.command ?? "—"}</td>
<td>
<div className="schedule-human">{describeSchedule(task)}</div>
{task.scheduleExpression && <span className="mono schedule-raw">{task.scheduleExpression}</span>}
</td>
<td>{task.nextRunAt ? new Date(task.nextRunAt).toLocaleString() : "—"}</td>
<td>
{task.isStale ? (
<span className="badge badge-stale">Missing</span>
) : task.enabled ? (
<span className="badge badge-ok">Enabled</span>
) : (
<span className="badge badge-disabled">Disabled</span>
)}
</td>
</tr>
))}
</tbody>
</table>
);
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useMemo, useState } from "react";
import { api, type ServerRecord, type TaskRecord } from "../api/client";
import FilterBar, { type Filters } from "../components/FilterBar";
import ServerGroup from "../components/ServerGroup";
export default function Dashboard() {
const [servers, setServers] = useState<ServerRecord[]>([]);
const [tasks, setTasks] = useState<TaskRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filters, setFilters] = useState<Filters>({
serverId: undefined,
scheduleType: undefined,
search: "",
includeStale: false,
});
useEffect(() => {
api.servers
.list()
.then((res) => setServers(res.servers))
.catch((err) => setError(String(err)));
}, []);
useEffect(() => {
setLoading(true);
const handle = setTimeout(() => {
api.tasks
.list(filters)
.then((res) => {
setTasks(res.tasks);
setError(null);
})
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, 200);
return () => clearTimeout(handle);
}, [filters]);
const groups = useMemo(() => {
const byServer = new Map<number, { serverId: number; serverName: string; tasks: TaskRecord[] }>();
for (const task of tasks) {
const entry = byServer.get(task.serverId) ?? {
serverId: task.serverId,
serverName: task.serverName,
tasks: [],
};
entry.tasks.push(task);
byServer.set(task.serverId, entry);
}
return [...byServer.values()];
}, [tasks]);
return (
<div className="dashboard">
<FilterBar servers={servers} filters={filters} onChange={setFilters} />
{error && <div className="error-banner">{error}</div>}
{loading && <div className="loading-inline">Loading tasks</div>}
{!loading && groups.length === 0 && (
<div className="empty-state">
No scheduled tasks found yet. Add a server under "Servers" and install the agent to start reporting.
</div>
)}
{groups.map((group) => (
<ServerGroup key={group.serverId} serverName={group.serverName} tasks={group.tasks} />
))}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
export default function Login() {
return (
<div className="login-screen">
<div className="login-card">
<h1>Schedule Task Manager</h1>
<p>Sign in with your homelab identity provider to continue.</p>
<a className="btn btn-primary" href="/auth/login">
Sign in with Authentik
</a>
</div>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useState, type FormEvent } from "react";
import { api, type ServerRecord } from "../api/client";
export default function ServersAdmin() {
const [servers, setServers] = useState<ServerRecord[]>([]);
const [name, setName] = useState("");
const [hostname, setHostname] = useState("");
const [description, setDescription] = useState("");
const [newToken, setNewToken] = useState<{ server: ServerRecord; token: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const load = () => api.servers.list().then((res) => setServers(res.servers));
useEffect(() => {
load().catch((err) => setError(String(err)));
}, []);
async function handleCreate(e: FormEvent) {
e.preventDefault();
setError(null);
try {
const result = await api.servers.create({
name,
hostname: hostname || undefined,
description: description || undefined,
});
setNewToken(result);
setName("");
setHostname("");
setDescription("");
await load();
} catch (err) {
setError(String(err));
}
}
async function handleRotate(id: number) {
try {
const result = await api.servers.rotateToken(id);
setNewToken(result);
await load();
} catch (err) {
setError(String(err));
}
}
async function handleDelete(id: number) {
if (!confirm("Remove this server and all its tracked tasks?")) return;
try {
await api.servers.remove(id);
await load();
} catch (err) {
setError(String(err));
}
}
return (
<div className="servers-admin">
<section className="add-server">
<h2>Add a server</h2>
<form onSubmit={handleCreate}>
<input
placeholder="Server name (e.g. nas01)"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<input
placeholder="Hostname (optional)"
value={hostname}
onChange={(e) => setHostname(e.target.value)}
/>
<input
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<button type="submit" className="btn btn-primary">
Add server
</button>
</form>
</section>
{error && <div className="error-banner">{error}</div>}
{newToken && (
<section className="token-reveal">
<h3>Agent token for {newToken.server.name}</h3>
<p>This token is only shown once copy it now.</p>
<code className="token-value">{newToken.token}</code>
<p>Install the agent on the server:</p>
<pre className="install-command">{`curl -fsSL ${window.location.origin}/agent/linux/install.sh | API_URL=${window.location.origin} API_TOKEN=${newToken.token} bash`}</pre>
<button onClick={() => setNewToken(null)} className="btn">
Dismiss
</button>
</section>
)}
<section className="server-list">
<h2>Servers</h2>
<table>
<thead>
<tr>
<th>Name</th>
<th>Hostname</th>
<th>Token</th>
<th>Last seen</th>
<th></th>
</tr>
</thead>
<tbody>
{servers.map((s) => (
<tr key={s.id}>
<td>{s.name}</td>
<td>{s.hostname ?? "—"}</td>
<td className="mono">{s.apiTokenPrefix}</td>
<td>{s.lastSeenAt ? new Date(s.lastSeenAt).toLocaleString() : "never"}</td>
<td className="actions">
<button onClick={() => handleRotate(s.id)} className="btn btn-small">
Rotate token
</button>
<button onClick={() => handleDelete(s.id)} className="btn btn-small btn-danger">
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
);
}
+333
View File
@@ -0,0 +1,333 @@
:root {
color-scheme: light dark;
--bg: #f5f6f8;
--surface: #ffffff;
--border: #dde1e6;
--text: #1c2126;
--text-muted: #5b6470;
--accent: #2f6fed;
--accent-contrast: #ffffff;
--danger: #d64545;
--stale-bg: #fff6e5;
--ok-bg: #e5f6ea;
--ok-text: #1f8a3d;
--disabled-bg: #eceff2;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171c;
--surface: #1c2128;
--border: #2c333c;
--text: #e6e9ed;
--text-muted: #9aa4b1;
--accent: #5b8dfc;
--accent-contrast: #0b1220;
--stale-bg: #332a12;
--ok-bg: #123321;
--ok-text: #4fd07a;
--disabled-bg: #232830;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
}
.loading-screen,
.login-screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2.5rem;
max-width: 380px;
text-align: center;
}
.login-card h1 {
margin-top: 0;
font-size: 1.4rem;
}
.login-card p {
color: var(--text-muted);
}
.app-shell {
min-height: 100vh;
}
.app-header {
display: flex;
align-items: center;
gap: 2rem;
padding: 0.75rem 1.5rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.app-header .brand {
font-weight: 600;
}
.app-header nav {
display: flex;
gap: 1rem;
flex: 1;
}
.app-header nav a {
color: var(--text-muted);
text-decoration: none;
padding: 0.25rem 0;
border-bottom: 2px solid transparent;
}
.app-header nav a.active,
.app-header nav a:hover {
color: var(--text);
border-bottom-color: var(--accent);
}
.user-info {
display: flex;
align-items: center;
gap: 1rem;
color: var(--text-muted);
font-size: 0.9rem;
}
.user-info a {
color: var(--accent);
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.5rem;
align-items: center;
}
.filter-bar select,
.filter-bar input[type="search"] {
padding: 0.4rem 0.6rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--text-muted);
font-size: 0.9rem;
}
.server-group {
margin-bottom: 2rem;
}
.server-group h2 {
border-bottom: 1px solid var(--border);
padding-bottom: 0.4rem;
}
.schedule-type-group {
margin-bottom: 1.25rem;
}
.schedule-type-group h3 {
color: var(--text-muted);
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 0.5rem;
}
table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
th,
td {
text-align: left;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
th {
color: var(--text-muted);
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
}
tr:last-child td {
border-bottom: none;
}
tr.stale {
opacity: 0.65;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85rem;
}
.schedule-human {
font-size: 0.85rem;
}
.schedule-raw {
display: block;
color: var(--text-muted);
}
.badge {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-ok {
background: var(--ok-bg);
color: var(--ok-text);
}
.badge-disabled {
background: var(--disabled-bg);
color: var(--text-muted);
}
.badge-stale {
background: var(--stale-bg);
color: #a16207;
}
.empty-state,
.loading-inline {
color: var(--text-muted);
padding: 2rem 0;
text-align: center;
}
.error-banner {
background: #fdecec;
color: var(--danger);
border: 1px solid #f3b8b8;
padding: 0.6rem 0.9rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.btn {
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
padding: 0.45rem 0.9rem;
border-radius: 6px;
cursor: pointer;
text-decoration: none;
display: inline-block;
font-size: 0.9rem;
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-contrast);
}
.btn-small {
padding: 0.3rem 0.6rem;
font-size: 0.8rem;
margin-right: 0.4rem;
}
.btn-danger {
color: var(--danger);
border-color: var(--danger);
}
.add-server form {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.add-server input {
padding: 0.45rem 0.6rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
flex: 1;
min-width: 160px;
}
.token-reveal {
background: var(--ok-bg);
border: 1px solid var(--ok-text);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
}
.token-value {
display: block;
background: var(--surface);
padding: 0.5rem 0.75rem;
border-radius: 6px;
margin: 0.5rem 0;
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.install-command {
background: var(--surface);
padding: 0.75rem;
border-radius: 6px;
overflow-x: auto;
font-size: 0.8rem;
}
.servers-admin section {
margin-bottom: 2rem;
}
.actions {
white-space: nowrap;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/filterbar.tsx","./src/components/servergroup.tsx","./src/components/tasktable.tsx","./src/pages/dashboard.tsx","./src/pages/login.tsx","./src/pages/serversadmin.tsx"],"version":"5.9.3"}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:3000",
"/auth": "http://localhost:3000",
"/health": "http://localhost:3000",
},
},
build: {
outDir: "dist",
},
});