1.1.3 - Bug fixes & updates to the automation scheduler
This commit is contained in:
@@ -172,7 +172,7 @@
|
|||||||
class={`flex items-center px-3 py-2 text-xs ${collapsed ? "justify-center" : "justify-between"}`}
|
class={`flex items-center px-3 py-2 text-xs ${collapsed ? "justify-center" : "justify-between"}`}
|
||||||
>
|
>
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<Badge className="text-text-secondary">Version: v1.1.2</Badge>
|
<Badge className="text-text-secondary">Version: v1.1.3</Badge>
|
||||||
{:else}
|
{:else}
|
||||||
<Badge className="text-text-secondary">v</Badge>
|
<Badge className="text-text-secondary">v</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -451,6 +451,17 @@ export async function runAutomationRule(ruleId, dryRun = false) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/automation/logs - Get automation run logs
|
||||||
|
* Query params: rule_id (optional), limit (default 100)
|
||||||
|
* Returns: { success, logs: [...] }
|
||||||
|
*/
|
||||||
|
export async function getAutomationLogs(ruleId = null, limit = 100) {
|
||||||
|
const params = new URLSearchParams({ limit })
|
||||||
|
if (ruleId) params.set('rule_id', ruleId)
|
||||||
|
return apiFetch(`/automation/logs?${params}`)
|
||||||
|
}
|
||||||
|
|
||||||
// ============ SUGGESTED MATCHES API ============
|
// ============ SUGGESTED MATCHES API ============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,13 +14,24 @@
|
|||||||
? [...rule.target_folders]
|
? [...rule.target_folders]
|
||||||
: ["/media/movies"];
|
: ["/media/movies"];
|
||||||
|
|
||||||
$: if (rule) {
|
// Re-sync form whenever the incoming rule prop changes (edit ↔ create toggle).
|
||||||
|
$: {
|
||||||
|
if (rule) {
|
||||||
name = rule.name || "";
|
name = rule.name || "";
|
||||||
schedule = rule.schedule || "0 3 * * SUN";
|
schedule = rule.schedule || "0 3 * * SUN";
|
||||||
enabled = rule.enabled ?? true;
|
enabled = rule.enabled ?? true;
|
||||||
patterns = rule.patterns ? [...rule.patterns] : [];
|
patterns = rule.patterns ? [...rule.patterns] : [];
|
||||||
targetFolders = rule.target_folders ? [...rule.target_folders] : [];
|
targetFolders = rule.target_folders ? [...rule.target_folders] : [];
|
||||||
|
} else {
|
||||||
|
name = "";
|
||||||
|
schedule = "0 3 * * SUN";
|
||||||
|
enabled = true;
|
||||||
|
patterns = ["YTS", "YIFY"];
|
||||||
|
targetFolders = ["/media/movies"];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let formError = "";
|
||||||
|
|
||||||
function addPattern() {
|
function addPattern() {
|
||||||
patterns = [...patterns, ""];
|
patterns = [...patterns, ""];
|
||||||
@@ -49,12 +60,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
|
formError = "";
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
const trimmedSchedule = schedule.trim();
|
||||||
|
|
||||||
|
if (!trimmedName) {
|
||||||
|
formError = "Rule name is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!trimmedSchedule) {
|
||||||
|
formError = "Schedule is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cleanPatterns = patterns.map((p) => p.trim()).filter(Boolean);
|
const cleanPatterns = patterns.map((p) => p.trim()).filter(Boolean);
|
||||||
const cleanFolders = targetFolders.map((f) => f.trim()).filter(Boolean);
|
const cleanFolders = targetFolders.map((f) => f.trim()).filter(Boolean);
|
||||||
|
|
||||||
onSave({
|
onSave({
|
||||||
name: name.trim(),
|
name: trimmedName,
|
||||||
schedule: schedule.trim(),
|
schedule: trimmedSchedule,
|
||||||
enabled,
|
enabled,
|
||||||
patterns: cleanPatterns,
|
patterns: cleanPatterns,
|
||||||
target_folders: cleanFolders,
|
target_folders: cleanFolders,
|
||||||
@@ -200,6 +224,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if formError}
|
||||||
|
<p class="text-[12px] text-red-400">{formError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
<div class="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
let showForm = false;
|
let showForm = false;
|
||||||
let dryRunByRule = {};
|
let dryRunByRule = {};
|
||||||
let running = {};
|
let running = {};
|
||||||
|
// Last run result per rule id: { files_scanned, files_modified, removed_lines, dry_run, errors }
|
||||||
|
let lastRunResult = {};
|
||||||
|
|
||||||
async function loadRules() {
|
async function loadRules() {
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -108,9 +110,11 @@
|
|||||||
rule.id,
|
rule.id,
|
||||||
dryRunByRule[rule.id] === true,
|
dryRunByRule[rule.id] === true,
|
||||||
);
|
);
|
||||||
|
lastRunResult[rule.id] = result;
|
||||||
|
lastRunResult = { ...lastRunResult };
|
||||||
const label = result.dry_run ? "Dry run" : "Run";
|
const label = result.dry_run ? "Dry run" : "Run";
|
||||||
addToast({
|
addToast({
|
||||||
message: `${label} complete. ${result.files_modified}/${result.files_scanned} modified.`,
|
message: `${label} complete. ${result.files_modified}/${result.files_scanned} modified, ${result.removed_lines} lines removed.`,
|
||||||
tone: "success",
|
tone: "success",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -123,6 +127,21 @@
|
|||||||
running = { ...running };
|
running = { ...running };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatNextRun(isoString) {
|
||||||
|
if (!isoString) return null;
|
||||||
|
try {
|
||||||
|
const d = new Date(isoString);
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
@@ -198,9 +217,15 @@
|
|||||||
Schedule: <span class="font-mono">{rule.schedule}</span>
|
Schedule: <span class="font-mono">{rule.schedule}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[12px] text-text-tertiary">
|
<div class="text-[12px] text-text-tertiary">
|
||||||
Targets: {rule.target_folders.length} folder
|
Targets: {rule.target_folders.length} folder{rule.target_folders.length === 1 ? "" : "s"}
|
||||||
{rule.target_folders.length === 1 ? "" : "s"}
|
|
||||||
</div>
|
</div>
|
||||||
|
{#if rule.next_run_at && formatNextRun(rule.next_run_at)}
|
||||||
|
<div class="text-[11px] text-text-tertiary">
|
||||||
|
Next run: <span class="text-text-secondary">{formatNextRun(rule.next_run_at)}</span>
|
||||||
|
</div>
|
||||||
|
{:else if rule.enabled}
|
||||||
|
<div class="text-[11px] text-text-tertiary">Next run: not scheduled</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@@ -272,6 +297,26 @@
|
|||||||
<Play class="h-4 w-4" />
|
<Play class="h-4 w-4" />
|
||||||
{running[rule.id] ? "Running..." : "Run now"}
|
{running[rule.id] ? "Running..." : "Run now"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{#if lastRunResult[rule.id]}
|
||||||
|
{@const res = lastRunResult[rule.id]}
|
||||||
|
<div class="mt-3 rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-[11px] text-text-tertiary space-y-1">
|
||||||
|
<div class="font-medium text-text-secondary">
|
||||||
|
{res.dry_run ? "Dry run" : "Run"} result
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{res.files_modified} / {res.files_scanned} files modified · {res.removed_lines} lines removed
|
||||||
|
</div>
|
||||||
|
{#if res.errors && res.errors.length > 0}
|
||||||
|
<div class="mt-1 space-y-0.5">
|
||||||
|
<div class="text-red-400">{res.errors.length} error{res.errors.length === 1 ? "" : "s"}:</div>
|
||||||
|
{#each res.errors as err}
|
||||||
|
<div class="font-mono text-red-300 truncate" title={err}>{err}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
+32
-1
@@ -595,6 +595,16 @@ def update_settings():
|
|||||||
def get_automation_rules():
|
def get_automation_rules():
|
||||||
try:
|
try:
|
||||||
rules = db.get_automation_rules()
|
rules = db.get_automation_rules()
|
||||||
|
|
||||||
|
# Annotate each rule with its next scheduled run time when the engine is up.
|
||||||
|
if automation_engine is not None:
|
||||||
|
next_run_times = automation_engine.get_next_run_times()
|
||||||
|
for rule in rules:
|
||||||
|
rule["next_run_at"] = next_run_times.get(rule["id"])
|
||||||
|
else:
|
||||||
|
for rule in rules:
|
||||||
|
rule["next_run_at"] = None
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"rules": rules
|
"rules": rules
|
||||||
@@ -733,7 +743,10 @@ def run_automation_rule(rule_id):
|
|||||||
engine = _ensure_automation_engine()
|
engine = _ensure_automation_engine()
|
||||||
result = engine.run_rule_now(rule_id, dry_run=dry_run)
|
result = engine.run_rule_now(rule_id, dry_run=dry_run)
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
return jsonify(result), 404
|
# Rule not found vs execution failure — surface the right status code.
|
||||||
|
error = result.get("error", "")
|
||||||
|
status = 404 if "not found" in error.lower() else 500
|
||||||
|
return jsonify(result), status
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error running automation rule: {e}")
|
logger.error(f"Error running automation rule: {e}")
|
||||||
@@ -743,6 +756,24 @@ def run_automation_rule(rule_id):
|
|||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/automation/logs', methods=['GET'])
|
||||||
|
def get_automation_logs():
|
||||||
|
try:
|
||||||
|
rule_id = request.args.get('rule_id') or None
|
||||||
|
limit = request.args.get('limit', 100, type=int)
|
||||||
|
logs = db.get_automation_logs(rule_id=rule_id, limit=limit)
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"logs": logs
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching automation logs: {e}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
# ============ SCAN ENDPOINTS ============
|
# ============ SCAN ENDPOINTS ============
|
||||||
|
|
||||||
@app.route('/api/scan/start', methods=['POST'])
|
@app.route('/api/scan/start', methods=['POST'])
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ class AutomationEngine:
|
|||||||
logger.info("Starting automation scheduler...")
|
logger.info("Starting automation scheduler...")
|
||||||
self._scheduler.start()
|
self._scheduler.start()
|
||||||
self._started = True
|
self._started = True
|
||||||
self.reload_rules()
|
# Call the lock-free variant directly — we already hold self._lock.
|
||||||
|
self._reload_rules_unlocked()
|
||||||
logger.info("Automation scheduler successfully started.")
|
logger.info("Automation scheduler successfully started.")
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
@@ -53,10 +54,13 @@ class AutomationEngine:
|
|||||||
# RULE MANAGEMENT
|
# RULE MANAGEMENT
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def reload_rules(self):
|
def reload_rules(self):
|
||||||
"""Reload all automation rules from storage."""
|
"""Reload all automation rules from storage (thread-safe)."""
|
||||||
logger.info("Reloading automation rules from database...")
|
logger.info("Reloading automation rules from database...")
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._reload_rules_unlocked()
|
||||||
|
|
||||||
|
def _reload_rules_unlocked(self):
|
||||||
|
"""Reload rules without acquiring the lock. Caller must hold self._lock."""
|
||||||
self._scheduler.remove_all_jobs()
|
self._scheduler.remove_all_jobs()
|
||||||
logger.debug("Cleared all scheduled jobs.")
|
logger.debug("Cleared all scheduled jobs.")
|
||||||
|
|
||||||
@@ -72,6 +76,19 @@ class AutomationEngine:
|
|||||||
|
|
||||||
self._schedule_rule(rule)
|
self._schedule_rule(rule)
|
||||||
|
|
||||||
|
def get_next_run_times(self) -> Dict[str, Optional[str]]:
|
||||||
|
"""Return mapping of rule_id → ISO next run time (or None if not scheduled)."""
|
||||||
|
result: Dict[str, Optional[str]] = {}
|
||||||
|
try:
|
||||||
|
for job in self._scheduler.get_jobs():
|
||||||
|
if job.id.startswith("automation:"):
|
||||||
|
rule_id = job.id[len("automation:"):]
|
||||||
|
nrt = job.next_run_time
|
||||||
|
result[rule_id] = nrt.isoformat() if nrt else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not retrieve next run times: %s", e)
|
||||||
|
return result
|
||||||
|
|
||||||
def run_rule_now(self, rule_id: str, dry_run: bool = False) -> dict:
|
def run_rule_now(self, rule_id: str, dry_run: bool = False) -> dict:
|
||||||
logger.info("Manual execution requested for rule %s (dry_run=%s)", rule_id, dry_run)
|
logger.info("Manual execution requested for rule %s (dry_run=%s)", rule_id, dry_run)
|
||||||
|
|
||||||
|
|||||||
@@ -1173,6 +1173,30 @@ class DatabaseManager:
|
|||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
def get_automation_logs(self, rule_id=None, limit=100):
|
||||||
|
"""Get automation log entries, optionally filtered by rule_id"""
|
||||||
|
session = self.get_session()
|
||||||
|
try:
|
||||||
|
query = session.query(AutomationLog).order_by(AutomationLog.run_at.desc())
|
||||||
|
if rule_id:
|
||||||
|
query = query.filter(AutomationLog.rule_id == rule_id)
|
||||||
|
logs = query.limit(limit).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": log.id,
|
||||||
|
"rule_id": log.rule_id,
|
||||||
|
"file_path": log.file_path,
|
||||||
|
"modified": log.modified,
|
||||||
|
"removed_lines": log.removed_lines,
|
||||||
|
"dry_run": log.dry_run,
|
||||||
|
"error_message": log.error_message,
|
||||||
|
"run_at": log.run_at.isoformat() if log.run_at else None,
|
||||||
|
}
|
||||||
|
for log in logs
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
# ============ MAINTENANCE OPERATIONS ============
|
# ============ MAINTENANCE OPERATIONS ============
|
||||||
|
|
||||||
def clear_settings(self, keep_api_keys=False):
|
def clear_settings(self, keep_api_keys=False):
|
||||||
|
|||||||
Reference in New Issue
Block a user