One-time timers now auto-unload from systemd after firing, so they no longer linger as "elapsed" in list-timers. Unit files remain on disk for ID/edit/status lookups; -C still cleans those up. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
826 lines
24 KiB
Bash
Executable file
826 lines
24 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# systab - A cron/at/batch-like interface for systemd
|
|
# Managed jobs are marked with: # SYSTAB_MANAGED
|
|
|
|
readonly SCRIPT_NAME="systab"
|
|
readonly SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
|
|
readonly MARKER="# SYSTAB_MANAGED"
|
|
|
|
# Global variables for options
|
|
opt_time=""
|
|
opt_command=""
|
|
opt_file=""
|
|
opt_notify=false
|
|
opt_syslog=false
|
|
opt_email=""
|
|
opt_edit=false
|
|
opt_list=false
|
|
opt_clean=false
|
|
opt_status=false
|
|
opt_filter=""
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: $SCRIPT_NAME [OPTIONS]
|
|
|
|
Create and manage systemd timer jobs with cron/at-like simplicity.
|
|
|
|
Job Creation Options:
|
|
-t <time> Time specification (see TIME FORMATS below)
|
|
-c <command> Command string to execute
|
|
-f <script> Script file to execute
|
|
-i Send desktop notification on completion
|
|
-s Log output to system journal
|
|
-m <email> Send email notification to address
|
|
|
|
Management Options:
|
|
-E Edit jobs in crontab-like format
|
|
-L [filter] List job logs (optionally filtered)
|
|
-S Show status of all managed jobs and timers
|
|
-C Clean up completed one-time jobs
|
|
|
|
TIME FORMATS:
|
|
- Relative: "in 5 minutes", "in 2 hours", "tomorrow"
|
|
- Absolute: "2025-01-21 14:30", "next tuesday at noon"
|
|
- Systemd: "daily", "weekly", "hourly", "*:0/15" (every 15 min)
|
|
|
|
EXAMPLES:
|
|
# Run command in 5 minutes
|
|
$SCRIPT_NAME -t "in 5 minutes" -c "echo Hello"
|
|
|
|
# Run script tomorrow with notification
|
|
$SCRIPT_NAME -t tomorrow -f ~/backup.sh -i
|
|
|
|
# Run command every hour
|
|
$SCRIPT_NAME -t hourly -c "curl -s https://example.com"
|
|
|
|
# Read command from stdin
|
|
echo "ls -la" | $SCRIPT_NAME -t "next monday at 9am"
|
|
|
|
# Edit existing jobs
|
|
$SCRIPT_NAME -E
|
|
|
|
# View logs for backup jobs
|
|
$SCRIPT_NAME -L backup
|
|
|
|
# Show status of all jobs
|
|
$SCRIPT_NAME -S
|
|
|
|
# Clean up completed jobs
|
|
$SCRIPT_NAME -C
|
|
|
|
EOF
|
|
exit "${1:-0}"
|
|
}
|
|
|
|
error() {
|
|
echo "Error: $*" >&2
|
|
exit 1
|
|
}
|
|
|
|
warn() {
|
|
echo "Warning: $*" >&2
|
|
}
|
|
|
|
# Parse time specification into systemd OnCalendar format
|
|
parse_time() {
|
|
local time_spec="$1"
|
|
|
|
# Common systemd calendar formats
|
|
case "${time_spec,,}" in
|
|
hourly|daily|weekly|monthly|yearly) echo "$time_spec"; return ;;
|
|
*:*) echo "$time_spec"; return ;; # Assume systemd time format
|
|
esac
|
|
|
|
# Try to parse with date command
|
|
local parsed_date
|
|
if parsed_date=$(date -d "$time_spec" '+%Y-%m-%d %H:%M:%S' 2>/dev/null); then
|
|
echo "$parsed_date"
|
|
return
|
|
fi
|
|
|
|
error "Unable to parse time specification: $time_spec"
|
|
}
|
|
|
|
# Check if time spec is recurring
|
|
is_recurring() {
|
|
local time_spec="$1"
|
|
case "${time_spec,,}" in
|
|
hourly|daily|weekly|monthly|yearly|*:*/*|*/*) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
# Generate unique job name
|
|
generate_job_name() {
|
|
local timestamp
|
|
timestamp=$(date +%Y%m%d_%H%M%S)
|
|
echo "${SCRIPT_NAME}_${timestamp}_$$"
|
|
}
|
|
|
|
# Generate a random 6-char hex short ID
|
|
generate_short_id() {
|
|
od -An -tx1 -N3 /dev/urandom | tr -d ' \n'
|
|
}
|
|
|
|
# Extract SYSTAB_ID from a job's timer file; empty if missing
|
|
get_job_id() {
|
|
local job_name="$1"
|
|
local timer_file="$SYSTEMD_USER_DIR/${job_name}.timer"
|
|
if [[ -f "$timer_file" ]]; then
|
|
sed -n 's/^# SYSTAB_ID=//p' "$timer_file"
|
|
fi
|
|
}
|
|
|
|
# Find job name by short ID; empty if not found
|
|
get_job_by_id() {
|
|
local target_id="$1"
|
|
local job
|
|
while IFS= read -r job; do
|
|
[[ -z "$job" ]] && continue
|
|
local jid
|
|
jid=$(get_job_id "$job")
|
|
if [[ "$jid" == "$target_id" ]]; then
|
|
echo "$job"
|
|
return
|
|
fi
|
|
done < <(get_managed_timers)
|
|
}
|
|
|
|
# Ensure a managed job has a SYSTAB_ID; assign one if missing
|
|
ensure_job_id() {
|
|
local job_name="$1"
|
|
local existing_id
|
|
existing_id=$(get_job_id "$job_name")
|
|
if [[ -n "$existing_id" ]]; then
|
|
echo "$existing_id"
|
|
return
|
|
fi
|
|
local new_id
|
|
new_id=$(generate_short_id)
|
|
# Insert SYSTAB_ID line after the MARKER in both files
|
|
for ext in timer service; do
|
|
local unit_file="$SYSTEMD_USER_DIR/${job_name}.${ext}"
|
|
if [[ -f "$unit_file" ]]; then
|
|
sed -i "s/^$MARKER$/&\n# SYSTAB_ID=$new_id/" "$unit_file"
|
|
fi
|
|
done
|
|
echo "$new_id"
|
|
}
|
|
|
|
# Get all managed service files
|
|
get_managed_services() {
|
|
if [[ ! -d "$SYSTEMD_USER_DIR" ]]; then
|
|
return
|
|
fi
|
|
|
|
local file seen=()
|
|
for file in "$SYSTEMD_USER_DIR"/*.service; do
|
|
[[ -f "$file" ]] || continue
|
|
if grep -q "^$MARKER" "$file" 2>/dev/null; then
|
|
local jobname
|
|
jobname=$(basename "$file" .service)
|
|
# Only output each job once
|
|
if [[ ! " ${seen[*]} " =~ " ${jobname} " ]]; then
|
|
echo "$jobname"
|
|
seen+=("$jobname")
|
|
fi
|
|
fi
|
|
done
|
|
}
|
|
|
|
# Get all managed timer files
|
|
get_managed_timers() {
|
|
if [[ ! -d "$SYSTEMD_USER_DIR" ]]; then
|
|
return
|
|
fi
|
|
|
|
local file seen=()
|
|
for file in "$SYSTEMD_USER_DIR"/*.timer; do
|
|
[[ -f "$file" ]] || continue
|
|
if grep -q "^$MARKER" "$file" 2>/dev/null; then
|
|
local jobname
|
|
jobname=$(basename "$file" .timer)
|
|
# Only output each job once
|
|
if [[ ! " ${seen[*]} " =~ " ${jobname} " ]]; then
|
|
echo "$jobname"
|
|
seen+=("$jobname")
|
|
fi
|
|
fi
|
|
done
|
|
}
|
|
|
|
# Create systemd service and timer files
|
|
create_job() {
|
|
local job_name command_to_run time_spec
|
|
job_name=$(generate_job_name)
|
|
time_spec=$(parse_time "$opt_time")
|
|
|
|
# Determine command to run
|
|
if [[ -n "$opt_command" ]]; then
|
|
command_to_run="$opt_command"
|
|
elif [[ -n "$opt_file" ]]; then
|
|
[[ -f "$opt_file" ]] || error "File not found: $opt_file"
|
|
[[ -x "$opt_file" ]] || error "File not executable: $opt_file"
|
|
command_to_run="$opt_file"
|
|
else
|
|
# Read from stdin
|
|
if [[ -t 0 ]]; then
|
|
echo "Please enter a command, then press Ctrl+D when done:" >&2
|
|
fi
|
|
command_to_run=$(cat)
|
|
[[ -n "$command_to_run" ]] || error "No command provided"
|
|
fi
|
|
|
|
# Create systemd user directory if needed
|
|
mkdir -p "$SYSTEMD_USER_DIR"
|
|
|
|
# Generate short ID for human-friendly display
|
|
local short_id
|
|
short_id=$(generate_short_id)
|
|
|
|
# Create service file
|
|
local service_file="$SYSTEMD_USER_DIR/${job_name}.service"
|
|
cat > "$service_file" <<EOF
|
|
$MARKER
|
|
# SYSTAB_ID=$short_id
|
|
[Unit]
|
|
Description=$SCRIPT_NAME job: $job_name
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=${SHELL:-/bin/bash} -c '$command_to_run'
|
|
EOF
|
|
|
|
# Add logging options
|
|
if $opt_syslog; then
|
|
cat >> "$service_file" <<EOF
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
SyslogIdentifier=$job_name
|
|
EOF
|
|
fi
|
|
|
|
# Add notification wrapper if needed
|
|
if $opt_notify || [[ -n "$opt_email" ]]; then
|
|
if $opt_notify; then
|
|
# Get current user's runtime directory and display
|
|
local user_id
|
|
user_id=$(id -u)
|
|
cat >> "$service_file" <<EOF
|
|
ExecStartPost=/bin/sh -c 'export DISPLAY=:0; export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$user_id/bus; /usr/bin/notify-send "$SCRIPT_NAME" "Job completed: $job_name" || true'
|
|
EOF
|
|
fi
|
|
|
|
if [[ -n "$opt_email" ]]; then
|
|
cat >> "$service_file" <<EOF
|
|
ExecStartPost=/bin/sh -c 'echo "Job $job_name completed at \$(date)" | mail -s "$SCRIPT_NAME: Job completed" "$opt_email"'
|
|
EOF
|
|
fi
|
|
fi
|
|
|
|
# Create timer file
|
|
local timer_file="$SYSTEMD_USER_DIR/${job_name}.timer"
|
|
cat > "$timer_file" <<EOF
|
|
$MARKER
|
|
# SYSTAB_ID=$short_id
|
|
[Unit]
|
|
Description=Timer for $SCRIPT_NAME job: $job_name
|
|
|
|
[Timer]
|
|
OnCalendar=$time_spec
|
|
EOF
|
|
|
|
if ! is_recurring "$opt_time"; then
|
|
cat >> "$timer_file" <<'ONETIME'
|
|
Persistent=false
|
|
RemainAfterElapse=no
|
|
ONETIME
|
|
fi
|
|
|
|
cat >> "$timer_file" <<EOF
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF
|
|
|
|
# Reload and start
|
|
systemctl --user daemon-reload
|
|
systemctl --user enable "$job_name.timer"
|
|
systemctl --user start "$job_name.timer"
|
|
|
|
echo "Job created: $short_id ($job_name)"
|
|
echo "Next run: $(systemctl --user list-timers "$job_name.timer" --no-pager | tail -n 2 | head -n 1 | awk '{print $1, $2, $3}')"
|
|
}
|
|
|
|
# Remove a managed job (stop, disable, delete unit files)
|
|
remove_job() {
|
|
local job_name="$1"
|
|
systemctl --user stop "${job_name}.timer" 2>/dev/null || true
|
|
systemctl --user disable "${job_name}.timer" 2>/dev/null || true
|
|
rm -f "$SYSTEMD_USER_DIR/${job_name}.service" "$SYSTEMD_USER_DIR/${job_name}.timer"
|
|
}
|
|
|
|
# Create a job from edit mode (schedule + command, returns short ID)
|
|
create_job_from_edit() {
|
|
local schedule="$1" command_to_run="$2"
|
|
local job_name short_id
|
|
|
|
job_name=$(generate_job_name)
|
|
short_id=$(generate_short_id)
|
|
|
|
mkdir -p "$SYSTEMD_USER_DIR"
|
|
|
|
# Service file
|
|
cat > "$SYSTEMD_USER_DIR/${job_name}.service" <<EOF
|
|
$MARKER
|
|
# SYSTAB_ID=$short_id
|
|
[Unit]
|
|
Description=$SCRIPT_NAME job: $job_name
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=${SHELL:-/bin/bash} -c '$command_to_run'
|
|
EOF
|
|
|
|
# Timer file
|
|
cat > "$SYSTEMD_USER_DIR/${job_name}.timer" <<EOF
|
|
$MARKER
|
|
# SYSTAB_ID=$short_id
|
|
[Unit]
|
|
Description=Timer for $SCRIPT_NAME job: $job_name
|
|
|
|
[Timer]
|
|
OnCalendar=$schedule
|
|
EOF
|
|
|
|
if ! is_recurring "$schedule"; then
|
|
cat >> "$SYSTEMD_USER_DIR/${job_name}.timer" <<'ONETIME'
|
|
Persistent=false
|
|
RemainAfterElapse=no
|
|
ONETIME
|
|
fi
|
|
|
|
cat >> "$SYSTEMD_USER_DIR/${job_name}.timer" <<EOF
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF
|
|
|
|
systemctl --user enable "$job_name.timer"
|
|
systemctl --user start "$job_name.timer"
|
|
|
|
echo "$short_id ($job_name)"
|
|
}
|
|
|
|
# Edit jobs in crontab-like format
|
|
edit_jobs() {
|
|
local temp_file orig_file
|
|
temp_file="${TMPDIR:-/dev/shm}/systab_edit_$$"
|
|
orig_file="${TMPDIR:-/dev/shm}/systab_orig_$$"
|
|
trap 'rm -f "$temp_file" "$orig_file"' EXIT
|
|
|
|
# Build crontab content
|
|
{
|
|
cat <<'HEADER'
|
|
# systab jobs — edit schedule/command, add/remove lines
|
|
# Format: ID SCHEDULE COMMAND
|
|
# Lines starting with # are ignored. Remove a line to delete a job.
|
|
# Add a line with "new" as ID to create a job: new daily /path/to/cmd
|
|
HEADER
|
|
|
|
local job
|
|
while IFS= read -r job; do
|
|
[[ -z "$job" ]] && continue
|
|
|
|
local timer_file="$SYSTEMD_USER_DIR/${job}.timer"
|
|
local service_file="$SYSTEMD_USER_DIR/${job}.service"
|
|
|
|
if [[ -f "$timer_file" && -f "$service_file" ]]; then
|
|
local short_id schedule command
|
|
short_id=$(ensure_job_id "$job")
|
|
schedule=$(grep "^OnCalendar=" "$timer_file" | cut -d= -f2-)
|
|
command=$(grep "^ExecStart=" "$service_file" | sed 's/^ExecStart=.*-c //' | sed "s/^'//" | sed "s/'$//")
|
|
printf '%s\t%s\t%s\n' "$short_id" "$schedule" "$command"
|
|
fi
|
|
done < <(get_managed_timers)
|
|
} > "$temp_file"
|
|
|
|
# Save original for diffing
|
|
cp "$temp_file" "$orig_file"
|
|
|
|
# Open in editor
|
|
if ! "${EDITOR:-vi}" "$temp_file"; then
|
|
echo "Editor exited with error, aborting."
|
|
return 1
|
|
fi
|
|
|
|
# Check for no changes
|
|
if diff -q "$orig_file" "$temp_file" &>/dev/null; then
|
|
echo "No changes."
|
|
return
|
|
fi
|
|
|
|
# Parse files into associative arrays: id -> "schedule\tcmd"
|
|
declare -A orig_jobs edited_jobs
|
|
# Also track id order for original (to map id -> job_name)
|
|
declare -A id_to_jobname
|
|
|
|
# Build id -> jobname mapping from current managed timers
|
|
local job
|
|
while IFS= read -r job; do
|
|
[[ -z "$job" ]] && continue
|
|
local jid
|
|
jid=$(get_job_id "$job")
|
|
if [[ -n "$jid" ]]; then
|
|
id_to_jobname["$jid"]="$job"
|
|
fi
|
|
done < <(get_managed_timers)
|
|
|
|
# Parse original file
|
|
while IFS= read -r line; do
|
|
[[ "$line" =~ ^#.*$ || -z "$line" ]] && continue
|
|
local id sched cmd
|
|
id=$(printf '%s' "$line" | cut -f1)
|
|
sched=$(printf '%s' "$line" | cut -f2)
|
|
cmd=$(printf '%s' "$line" | cut -f3-)
|
|
[[ -n "$id" && -n "$sched" ]] || continue
|
|
orig_jobs["$id"]="${sched} ${cmd}"
|
|
done < "$orig_file"
|
|
|
|
# Parse edited file
|
|
while IFS= read -r line; do
|
|
[[ "$line" =~ ^#.*$ || -z "$line" ]] && continue
|
|
local id sched cmd
|
|
id=$(printf '%s' "$line" | cut -f1)
|
|
sched=$(printf '%s' "$line" | cut -f2)
|
|
cmd=$(printf '%s' "$line" | cut -f3-)
|
|
[[ -n "$id" && -n "$sched" ]] || continue
|
|
edited_jobs["$id"]="${sched} ${cmd}"
|
|
done < "$temp_file"
|
|
|
|
local created=0 deleted=0 updated=0 needs_reload=false
|
|
|
|
# Deletions: IDs in original but not in edited
|
|
for id in "${!orig_jobs[@]}"; do
|
|
if [[ -z "${edited_jobs[$id]+x}" ]]; then
|
|
local jname="${id_to_jobname[$id]:-}"
|
|
if [[ -n "$jname" ]]; then
|
|
remove_job "$jname"
|
|
echo "Deleted: $id ($jname)"
|
|
deleted=$((deleted + 1))
|
|
needs_reload=true
|
|
fi
|
|
fi
|
|
done
|
|
|
|
# Creations: IDs in edited but not in original (must be "new")
|
|
for id in "${!edited_jobs[@]}"; do
|
|
if [[ -z "${orig_jobs[$id]+x}" ]]; then
|
|
if [[ "$id" != "new" ]]; then
|
|
warn "Ignoring unknown ID '$id' — use 'new' to create jobs"
|
|
continue
|
|
fi
|
|
local entry="${edited_jobs[$id]}"
|
|
local sched cmd
|
|
sched=$(printf '%s' "$entry" | cut -f1)
|
|
cmd=$(printf '%s' "$entry" | cut -f2-)
|
|
local result
|
|
result=$(create_job_from_edit "$sched" "$cmd")
|
|
echo "Created: $result"
|
|
created=$((created + 1))
|
|
needs_reload=true
|
|
fi
|
|
done
|
|
|
|
# Updates: IDs in both but with changed values
|
|
for id in "${!edited_jobs[@]}"; do
|
|
[[ "$id" == "new" ]] && continue
|
|
if [[ -n "${orig_jobs[$id]+x}" && "${orig_jobs[$id]}" != "${edited_jobs[$id]}" ]]; then
|
|
local jname="${id_to_jobname[$id]:-}"
|
|
if [[ -z "$jname" ]]; then
|
|
warn "Cannot find job for ID '$id', skipping update"
|
|
continue
|
|
fi
|
|
|
|
local old_entry="${orig_jobs[$id]}" new_entry="${edited_jobs[$id]}"
|
|
local old_sched new_sched old_cmd new_cmd
|
|
old_sched=$(printf '%s' "$old_entry" | cut -f1)
|
|
new_sched=$(printf '%s' "$new_entry" | cut -f1)
|
|
old_cmd=$(printf '%s' "$old_entry" | cut -f2-)
|
|
new_cmd=$(printf '%s' "$new_entry" | cut -f2-)
|
|
|
|
local timer_file="$SYSTEMD_USER_DIR/${jname}.timer"
|
|
local service_file="$SYSTEMD_USER_DIR/${jname}.service"
|
|
|
|
if [[ "$old_sched" != "$new_sched" && -f "$timer_file" ]]; then
|
|
sed -i "s|^OnCalendar=.*|OnCalendar=$new_sched|" "$timer_file"
|
|
|
|
# Update one-time vs recurring settings
|
|
if is_recurring "$new_sched"; then
|
|
sed -i '/^Persistent=false$/d' "$timer_file"
|
|
sed -i '/^RemainAfterElapse=no$/d' "$timer_file"
|
|
else
|
|
if ! grep -q "^Persistent=false" "$timer_file"; then
|
|
sed -i "/^OnCalendar=/a Persistent=false\nRemainAfterElapse=no" "$timer_file"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [[ "$old_cmd" != "$new_cmd" && -f "$service_file" ]]; then
|
|
sed -i "s|^ExecStart=.*|ExecStart=${SHELL:-/bin/bash} -c '$new_cmd'|" "$service_file"
|
|
fi
|
|
|
|
systemctl --user restart "${jname}.timer" 2>/dev/null || true
|
|
echo "Updated: $id ($jname)"
|
|
updated=$((updated + 1))
|
|
needs_reload=true
|
|
fi
|
|
done
|
|
|
|
if $needs_reload; then
|
|
systemctl --user daemon-reload
|
|
fi
|
|
|
|
echo ""
|
|
echo "Summary: $created created, $updated updated, $deleted deleted"
|
|
}
|
|
|
|
# Show status of all managed jobs
|
|
show_status() {
|
|
local job
|
|
local job_list=()
|
|
|
|
while IFS= read -r job; do
|
|
[[ -z "$job" ]] && continue
|
|
job_list+=("$job")
|
|
done < <(get_managed_timers)
|
|
|
|
if [[ ${#job_list[@]} -eq 0 ]]; then
|
|
echo "No managed jobs found."
|
|
return
|
|
fi
|
|
|
|
local count=${#job_list[@]}
|
|
echo "Managed Jobs Status - $count total"
|
|
echo "=========================================="
|
|
echo ""
|
|
|
|
for job in "${job_list[@]}"; do
|
|
local timer_file="$SYSTEMD_USER_DIR/${job}.timer"
|
|
local service_file="$SYSTEMD_USER_DIR/${job}.service"
|
|
|
|
local short_id
|
|
short_id=$(get_job_id "$job")
|
|
if [[ -n "$short_id" ]]; then
|
|
echo "Job: $job (ID: $short_id)"
|
|
else
|
|
echo "Job: $job"
|
|
fi
|
|
|
|
# Get schedule from timer file
|
|
if [[ -f "$timer_file" ]]; then
|
|
local schedule
|
|
schedule=$(grep "^OnCalendar=" "$timer_file" | cut -d= -f2-)
|
|
echo " Schedule: $schedule"
|
|
|
|
# Check if recurring
|
|
if grep -q "^Persistent=false" "$timer_file"; then
|
|
echo " Type: One-time"
|
|
else
|
|
echo " Type: Recurring"
|
|
fi
|
|
fi
|
|
|
|
# Get command from service file
|
|
if [[ -f "$service_file" ]]; then
|
|
local command
|
|
command=$(grep "^ExecStart=" "$service_file" | sed 's/^ExecStart=.*-c //' | sed "s/^'//" | sed "s/'$//")
|
|
echo " Command: $command"
|
|
fi
|
|
|
|
# Timer status
|
|
if systemctl --user is-active "${job}.timer" &>/dev/null; then
|
|
echo " Timer: Active"
|
|
|
|
# Get next run time
|
|
local next_run
|
|
next_run=$(systemctl --user list-timers "${job}.timer" --no-pager 2>/dev/null | tail -n 2 | head -n 1 | awk '{print $1, $2, $3, $4, $5}')
|
|
if [[ -n "$next_run" ]]; then
|
|
echo " Next run: $next_run"
|
|
fi
|
|
|
|
# Get last run time
|
|
local last_run
|
|
last_run=$(systemctl --user list-timers "${job}.timer" --no-pager 2>/dev/null | tail -n 2 | head -n 1 | awk '{for(i=6;i<=10;i++) printf $i" "; print ""}')
|
|
if [[ -n "$last_run" && "$last_run" != "n/a "* ]]; then
|
|
echo " Last run: $last_run"
|
|
fi
|
|
else
|
|
echo " Timer: Inactive/Completed"
|
|
fi
|
|
|
|
# Service status
|
|
if systemctl --user is-failed "${job}.service" &>/dev/null; then
|
|
echo " Service: Failed"
|
|
elif systemctl --user is-active "${job}.service" &>/dev/null; then
|
|
echo " Service: Running"
|
|
else
|
|
local exit_status
|
|
exit_status=$(systemctl --user show "${job}.service" -p ExecMainStatus --value 2>/dev/null)
|
|
if [[ -n "$exit_status" && "$exit_status" != "0" ]]; then
|
|
echo " Service: Completed with exit code $exit_status"
|
|
else
|
|
echo " Service: Completed"
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
done
|
|
|
|
# Show systemctl list-timers summary
|
|
echo "Active Timers:"
|
|
echo "--------------"
|
|
systemctl --user list-timers "systab_*" --no-pager 2>/dev/null || echo "none active"
|
|
}
|
|
|
|
# List logs
|
|
list_logs() {
|
|
local grep_cmd
|
|
if command -v rg &>/dev/null; then
|
|
grep_cmd="rg"
|
|
else
|
|
grep_cmd="grep"
|
|
fi
|
|
|
|
local job
|
|
local job_list=()
|
|
while IFS= read -r job; do
|
|
[[ -z "$job" ]] && continue
|
|
job_list+=("$job")
|
|
done < <(get_managed_services)
|
|
|
|
if [[ ${#job_list[@]} -eq 0 ]]; then
|
|
echo "No managed jobs found."
|
|
return
|
|
fi
|
|
|
|
local count=${#job_list[@]}
|
|
echo "Found $count managed jobs"
|
|
echo ""
|
|
|
|
for job in "${job_list[@]}"; do
|
|
local short_id
|
|
short_id=$(get_job_id "$job")
|
|
if [[ -n "$short_id" ]]; then
|
|
echo "=== Logs for $job (ID: $short_id) ==="
|
|
else
|
|
echo "=== Logs for $job ==="
|
|
fi
|
|
|
|
# Show timer status
|
|
if systemctl --user is-active "${job}.timer" &>/dev/null; then
|
|
echo "Status: Active"
|
|
systemctl --user status "${job}.timer" --no-pager -l | head -n 3
|
|
else
|
|
echo "Status: Inactive"
|
|
fi
|
|
echo ""
|
|
|
|
# Show service logs
|
|
if [[ -n "$opt_filter" ]]; then
|
|
journalctl --user -u "${job}.service" --no-pager 2>/dev/null | $grep_cmd "$opt_filter" || echo "no matching logs"
|
|
else
|
|
journalctl --user -u "${job}.service" --no-pager 2>/dev/null || echo "no logs yet"
|
|
fi
|
|
echo ""
|
|
done
|
|
}
|
|
|
|
# Clean up completed jobs
|
|
clean_jobs() {
|
|
local job cleaned=0
|
|
|
|
while IFS= read -r job; do
|
|
[[ -z "$job" ]] && continue
|
|
|
|
local timer_file="$SYSTEMD_USER_DIR/${job}.timer"
|
|
|
|
# Check if it's a one-time job (has Persistent=false)
|
|
if grep -q "^Persistent=false" "$timer_file" 2>/dev/null; then
|
|
# Check if timer is in "elapsed" state (completed one-time timer)
|
|
local timer_state
|
|
timer_state=$(systemctl --user show "${job}.timer" -p SubState --value 2>/dev/null)
|
|
|
|
# One-time timers that have fired will be in "elapsed" or "dead" state
|
|
if [[ "$timer_state" == "elapsed" || "$timer_state" == "dead" ]]; then
|
|
# Get the command for display
|
|
local command
|
|
command=$(grep "^ExecStart=" "$SYSTEMD_USER_DIR/${job}.service" 2>/dev/null | sed 's/^ExecStart=.*-c //' | sed "s/^'//" | sed "s/'$//" || echo "unknown")
|
|
|
|
read -p "Remove completed job '$job' (command: $command)? [y/N] " -n 1 -r </dev/tty
|
|
echo
|
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|
systemctl --user stop "${job}.timer" 2>/dev/null || true
|
|
systemctl --user disable "${job}.timer" 2>/dev/null || true
|
|
rm -f "$SYSTEMD_USER_DIR/${job}.service" "$SYSTEMD_USER_DIR/${job}.timer"
|
|
echo "Removed: $job"
|
|
cleaned=$((cleaned + 1))
|
|
fi
|
|
fi
|
|
fi
|
|
done < <(get_managed_timers)
|
|
|
|
if [[ $cleaned -gt 0 ]]; then
|
|
systemctl --user daemon-reload
|
|
echo "Cleaned up $cleaned jobs."
|
|
else
|
|
echo "No completed one-time jobs to clean up."
|
|
fi
|
|
}
|
|
|
|
# Parse command-line options
|
|
parse_options() {
|
|
while getopts "t:c:f:ism:ELSCh" opt; do
|
|
case $opt in
|
|
t) opt_time="$OPTARG" ;;
|
|
c) opt_command="$OPTARG" ;;
|
|
f) opt_file="$OPTARG" ;;
|
|
i) opt_notify=true ;;
|
|
s) opt_syslog=true ;;
|
|
m) opt_email="$OPTARG" ;;
|
|
E) opt_edit=true ;;
|
|
L) opt_list=true ;;
|
|
S) opt_status=true ;;
|
|
C) opt_clean=true ;;
|
|
h) usage 0 ;;
|
|
*) usage 1 ;;
|
|
esac
|
|
done
|
|
|
|
# Check if there's a filter argument after -L
|
|
if $opt_list && [[ $OPTIND -le $# ]]; then
|
|
opt_filter="${!OPTIND}"
|
|
fi
|
|
|
|
# Validate mutually exclusive options
|
|
local manage_count=0
|
|
$opt_edit && manage_count=$((manage_count + 1))
|
|
$opt_list && manage_count=$((manage_count + 1))
|
|
$opt_status && manage_count=$((manage_count + 1))
|
|
$opt_clean && manage_count=$((manage_count + 1))
|
|
|
|
if [[ $manage_count -gt 1 ]]; then
|
|
error "Options -E, -L, -S, and -C are mutually exclusive"
|
|
fi
|
|
|
|
if [[ $manage_count -gt 0 && -n "$opt_time$opt_command$opt_file" ]]; then
|
|
error "Management options -E, -L, -S, and -C cannot be used with job creation options"
|
|
fi
|
|
|
|
if [[ -n "$opt_command" && -n "$opt_file" ]]; then
|
|
error "Options -c and -f are mutually exclusive"
|
|
fi
|
|
|
|
# Validate create mode requirements
|
|
local is_manage_mode=false
|
|
if $opt_edit || $opt_list || $opt_status || $opt_clean; then
|
|
is_manage_mode=true
|
|
fi
|
|
|
|
if ! $is_manage_mode; then
|
|
[[ -n "$opt_time" ]] || error "Option -t is required for job creation"
|
|
fi
|
|
}
|
|
|
|
main() {
|
|
# Show usage if no arguments or -h
|
|
if [[ $# -eq 0 ]]; then
|
|
usage 0
|
|
fi
|
|
|
|
for arg in "$@"; do
|
|
if [[ "$arg" == "-h" ]]; then
|
|
usage 0
|
|
fi
|
|
done
|
|
|
|
parse_options "$@"
|
|
|
|
# Determine mode based on options
|
|
if $opt_edit; then
|
|
edit_jobs
|
|
elif $opt_list; then
|
|
list_logs
|
|
elif $opt_status; then
|
|
show_status
|
|
elif $opt_clean; then
|
|
clean_jobs
|
|
else
|
|
create_job
|
|
fi
|
|
}
|
|
|
|
main "$@"
|