Files
Varaverk/Plugin/unraid/api/recent.php
T
Gmer4Lfe 987313e7dc Document the PHP api layer and fix what documenting it exposed
Writing down what each endpoint actually guarantees made the places it
didn't obvious — shell arguments reaching a crontab or a bash -c
unescaped, master.conf written without tmp+rename, and conf edits that
could be saved without ever being parsed.
2026-08-02 10:11:39 -04:00

83 lines
4.1 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Recent run history. The last 24 finished job runs across every orchestrator and script,
// newest first, with status and duration — the activity feed on the scheduler page.
//
// OPERATIONAL MODEL
// There is no run database. Each job writes a .json stat file beside its log, and this
// endpoint reconstructs history by walking LOG_DIR recursively and sorting what it finds by
// start time. That keeps the stat file the single source of truth for a job's outcome —
// the same file status.php reads for live state — instead of maintaining a second record
// that could disagree with it.
//
// DESIGN PRINCIPLES
// Finished runs only.
// status === 'running' is skipped, because a run in progress has no duration and
// belongs to status.php's live indicators, not to history. The two endpoints partition
// the same files rather than overlapping.
//
// Capped at 24 after sorting, not before.
// Every stat file is read and ranked before the slice, so the newest 24 are genuinely
// the newest — filesystem iteration order says nothing about run time.
//
// Duration is derived, never stored.
// end - start is computed here, so a stat file that recorded a start but never got to
// write an end still yields a usable row rather than being discarded.
//
// OPERATIONAL SAFEGUARDS
// Read-only. Nothing here deletes a stat file, truncates a log, or re-runs a job.
//
// The whole walk is wrapped in a try/catch.
// RecursiveDirectoryIterator throws when LOG_DIR does not exist or a subdirectory is
// unreadable — on a fresh install that is the normal state, not an error. The catch
// yields an empty run list so the page renders "no runs yet" instead of a 500.
//
// Every individual file read is independently suppressed and validated.
// @file_get_contents, @json_decode, then an is_array plus required-key check. A stat
// file caught mid-write, truncated, or left over from an older schema is skipped —
// one bad file cannot cost the other 23 rows.
//
// Negative durations are clamped.
// max(0, end - start) — a stat file whose clock went backwards across an NTP step
// reports 0s rather than a negative duration the UI would have to special-case.
//
// REQUEST
// GET, no parameters
//
// RESPONSE
// {"ok":true,"runs":[{"id","label","status","start","dur"}, …]} newest first, max 24
//
// DEPENDS ON
// include/config.php LOG_DIR
// LOG_DIR/**/*.json stat files written by run_job.sh
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
$logDir = LOG_DIR;
$runs = [];
try {
$ri = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($logDir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($ri as $file) {
if ($file->getExtension() !== 'json') continue;
$d = @json_decode(@file_get_contents($file->getPathname()), true);
if (!is_array($d) || empty($d['start']) || empty($d['status'])) continue;
if ($d['status'] === 'running') continue;
$id = (string)($d['id'] ?? '');
$runs[] = [
'id' => $id,
'label' => basename(str_replace('.sh', '', $id)),
'status' => $d['status'],
'start' => (int)$d['start'],
'dur' => isset($d['end']) ? max(0, (int)$d['end'] - (int)$d['start']) : 0,
];
}
} catch (Exception $e) {}
usort($runs, fn($a, $b) => $b['start'] - $a['start']);
echo json_encode(['ok' => true, 'runs' => array_slice($runs, 0, 24)]);