mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
The NexusDashboard-parity dashboard (dDashboardServer) and everything built on it on the experimental branch: accounts, characters, properties and moderation tools, permissions shared with in-game slash commands, economy reports, World 3D and property 3D views with client scenery, scheduled events (features, vanity changes, live events, announcements, restarts), vanity files and events, the CDClient browser, the message inspector with saved captures, chat filter tools, community challenges, live ops, the AI moderator helper, and the server-side changes they need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
280 lines
14 KiB
Django/Jinja
280 lines
14 KiB
Django/Jinja
{% extends "base.jinja2" %}
|
|
|
|
{% block title %}Scheduled Tasks - DarkflameServer{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="mb-3">
|
|
<h2 class="mb-1">Scheduled Tasks</h2>
|
|
<p class="text-body-secondary mb-0">Jobs the dashboard runs on its own. Schedules are in UTC; times below are in your time zone.</p>
|
|
</div>
|
|
|
|
<div class="card mb-4">
|
|
<div class="card-body table-responsive">
|
|
<table class="table table-hover align-middle mb-0">
|
|
<thead><tr><th>Task</th><th>Schedule</th><th>Next run</th><th>Last run</th><th class="text-end">Actions</th></tr></thead>
|
|
<tbody id="taskRows"><tr><td colspan="5" class="text-body-secondary">Loading…</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
|
<h5 class="mb-0">Run history</h5>
|
|
<div><label class="visually-hidden" for="runTask">Task</label><select class="form-select form-select-sm" id="runTask"><option value="">All tasks</option></select></div>
|
|
</div>
|
|
<div class="card-body">
|
|
<table id="runsTable" class="table table-striped table-hover w-100">
|
|
<thead><tr><th>ID</th><th>Task</th><th>Started by</th><th>Started</th><th>Took</th><th>Result</th><th>Summary</th></tr></thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
<p class="small text-body-secondary mb-0">Click a run to see its log. Runs are kept for <code>log_task_days</code> days.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="taskModal" tabindex="-1" aria-labelledby="taskModalTitle" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<form class="modal-content" id="taskForm">
|
|
<div class="modal-header"><h5 class="modal-title" id="taskModalTitle">Task</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>
|
|
<div class="modal-body">
|
|
<p class="small text-body-secondary" id="taskDescription"></p>
|
|
<div class="mb-3">
|
|
<label class="form-label" for="taskSchedule">Schedule (UTC)</label>
|
|
<div class="input-group">
|
|
<input class="form-control font-monospace" id="taskSchedule" maxlength="128" autocomplete="off" spellcheck="false">
|
|
<button type="button" class="btn btn-outline-secondary" id="taskDefault">Default</button>
|
|
</div>
|
|
<div class="form-text" id="taskDefaultText"></div>
|
|
<div class="small mt-2" id="taskPreview" aria-live="polite"></div>
|
|
</div>
|
|
<details class="small mb-3">
|
|
<summary>Schedule syntax</summary>
|
|
<p class="mt-2 mb-1">Cron: <code>minute hour day-of-month month day-of-week</code>, each <code>*</code>, a number, a range <code>1-5</code>,
|
|
a step <code>*/15</code> or a list <code>1,15</code>. Months and weekdays can be names (<code>jan</code>, <code>mon</code>).</p>
|
|
<ul class="mb-0">
|
|
<li><code>15 0 * * *</code>: every day at 00:15</li>
|
|
<li><code>0 */6 * * *</code>: every 6 hours</li>
|
|
<li><code>0 3 * * sun</code>: Sundays at 03:00</li>
|
|
<li><code>@hourly</code>, <code>@daily</code>, <code>@weekly</code>, <code>@monthly</code></li>
|
|
<li><code>@every 10m</code>: a fixed interval (<code>s</code>, <code>m</code>, <code>h</code> or <code>d</code>)</li>
|
|
</ul>
|
|
</details>
|
|
<div class="form-check form-switch"><input class="form-check-input" type="checkbox" role="switch" id="taskEnabled"><label class="form-check-label" for="taskEnabled">Run on schedule</label></div>
|
|
<div class="form-text">Switched off, it only runs when someone presses Run now.</div>
|
|
</div>
|
|
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button><button type="submit" class="btn btn-primary">Save</button></div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal fade" id="logModal" tabindex="-1" aria-labelledby="logModalTitle" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
<div class="modal-content">
|
|
<div class="modal-header"><h5 class="modal-title" id="logModalTitle">Log</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>
|
|
<div class="modal-body">
|
|
<div class="small text-body-secondary mb-2" id="logMeta"></div>
|
|
<pre class="bg-body-tertiary p-2 rounded small mb-0" id="logText" style="white-space: pre-wrap;"></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
(function () {
|
|
var canEdit = DASH.can('tasks_manage');
|
|
var tasks = [], editing = null, serverOffset = 0, livePoll = null;
|
|
var taskModal = new bootstrap.Modal(document.getElementById('taskModal'));
|
|
var logModalEl = document.getElementById('logModal');
|
|
var logModal = new bootstrap.Modal(logModalEl);
|
|
// Names come from the server's enum (Labels); only the colours are kept here
|
|
var STATUS_COLOURS = { 1: 'success', 2: 'danger', 3: 'warning', 4: 'secondary' };
|
|
|
|
function statusBadge(status) {
|
|
return fmt.badge(Labels.name('taskStatus', status) || 'Unknown', STATUS_COLOURS[status] || 'secondary');
|
|
}
|
|
|
|
function duration(seconds) {
|
|
if (seconds < 1) return 'under 1s';
|
|
if (seconds < 60) return seconds + 's';
|
|
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
|
|
return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
|
|
}
|
|
|
|
// "in 5m" / "3h ago", against the server's clock
|
|
function relative(ts) {
|
|
var diff = ts - (Math.floor(Date.now() / 1000) + serverOffset);
|
|
var text = duration(Math.abs(diff));
|
|
return diff >= 0 ? 'in ' + text : text + ' ago';
|
|
}
|
|
|
|
function startedBy(trigger, actor) {
|
|
return trigger === 'manual' ? esc(actor || 'someone') : '<span class="text-body-secondary">schedule</span>';
|
|
}
|
|
|
|
function render() {
|
|
var rows = document.getElementById('taskRows');
|
|
rows.innerHTML = tasks.map(function (t) {
|
|
var schedule = t.schedule ? '<code>' + esc(t.schedule) + '</code>' : '<span class="text-body-secondary">By hand only</span>';
|
|
if (t.custom) schedule += ' ' + fmt.badge('Changed', 'info');
|
|
if (!t.valid) schedule += ' ' + fmt.badge('Invalid', 'danger');
|
|
var next = !t.enabled ? fmt.badge('Off', 'secondary')
|
|
: t.next_at ? '<span title="' + esc(fmt.unix(t.next_at)) + '">' + esc(relative(t.next_at)) + '</span><div class="small text-body-secondary">' + esc(fmt.unix(t.next_at)) + '</div>'
|
|
: '<span class="text-body-secondary">-</span>';
|
|
var last = '<span class="text-body-secondary">Never</span>';
|
|
if (t.running) {
|
|
last = '<span class="badge text-bg-primary"><span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>Running</span>' +
|
|
' <span class="small text-body-secondary">since ' + esc(relative(t.running.started_at).replace(' ago', '')) + '</span>';
|
|
} else if (t.last_run) {
|
|
last = '<a href="#" class="text-decoration-none" data-run="' + t.last_run.id + '">' + statusBadge(t.last_run.status) + '</a> <span class="small text-body-secondary" title="' +
|
|
esc(fmt.unix(t.last_run.finished_at)) + '">' + esc(relative(t.last_run.finished_at)) + '</span>' +
|
|
(t.last_run.summary ? '<div class="small">' + esc(t.last_run.summary) + '</div>' : '');
|
|
}
|
|
var actions = '';
|
|
if (t.running) actions += '<button class="btn btn-sm btn-outline-secondary me-1" data-live="' + esc(t.name) + '">Log</button>';
|
|
if (canEdit) {
|
|
actions += '<button class="btn btn-sm btn-outline-success me-1" data-start="' + esc(t.name) + '"' + (t.running ? ' disabled' : '') + '>Run now</button>' +
|
|
'<button class="btn btn-sm btn-outline-primary" data-edit="' + esc(t.name) + '">Edit</button>';
|
|
}
|
|
return '<tr><td><div class="fw-semibold">' + esc(t.title) + '</div><div class="small text-body-secondary">' + esc(t.description) + '</div>' +
|
|
'<code class="small">' + esc(t.name) + '</code></td><td>' + schedule + '</td><td class="text-nowrap">' + next + '</td><td>' + last + '</td>' +
|
|
'<td class="text-end text-nowrap">' + actions + '</td></tr>';
|
|
}).join('') || '<tr><td colspan="5" class="text-body-secondary">No tasks.</td></tr>';
|
|
|
|
var select = document.getElementById('runTask');
|
|
if (select.options.length === 1) {
|
|
tasks.forEach(function (t) { select.add(new Option(t.title, t.name)); });
|
|
}
|
|
}
|
|
|
|
function load() {
|
|
return api.get('/api/tasks').then(function (d) {
|
|
if (!d.success) return;
|
|
serverOffset = d.now - Math.floor(Date.now() / 1000);
|
|
tasks = d.tasks;
|
|
render();
|
|
});
|
|
}
|
|
|
|
function findTask(name) {
|
|
return tasks.filter(function (t) { return t.name === name; })[0];
|
|
}
|
|
|
|
// ---- Edit ----
|
|
var previewTimer = null;
|
|
function preview() {
|
|
clearTimeout(previewTimer);
|
|
var out = document.getElementById('taskPreview');
|
|
var text = document.getElementById('taskSchedule').value.trim();
|
|
if (!text) { out.innerHTML = '<span class="text-body-secondary">Empty: uses the default' + (editing.default_schedule ? '' : ' (by hand only)') + '.</span>'; return; }
|
|
previewTimer = setTimeout(function () {
|
|
api.post('/api/tasks/preview', { schedule: text }).then(function (d) {
|
|
if (!d.valid) { out.innerHTML = '<span class="text-danger">' + esc(d.error || 'Invalid schedule') + '</span>'; return; }
|
|
out.innerHTML = d.next.length ? 'Next runs: ' + d.next.map(function (t) { return esc(fmt.unix(t)); }).join(', ')
|
|
: '<span class="text-warning">This never runs.</span>';
|
|
});
|
|
}, 250);
|
|
}
|
|
|
|
function openEdit(name) {
|
|
editing = findTask(name);
|
|
if (!editing) return;
|
|
document.getElementById('taskModalTitle').textContent = editing.title;
|
|
document.getElementById('taskDescription').textContent = editing.description;
|
|
document.getElementById('taskSchedule').value = editing.schedule;
|
|
document.getElementById('taskDefaultText').textContent = 'Default: ' + (editing.default_schedule || 'by hand only');
|
|
document.getElementById('taskEnabled').checked = editing.enabled;
|
|
preview();
|
|
taskModal.show();
|
|
}
|
|
|
|
document.getElementById('taskSchedule').addEventListener('input', preview);
|
|
document.getElementById('taskDefault').addEventListener('click', function () {
|
|
document.getElementById('taskSchedule').value = editing.default_schedule;
|
|
preview();
|
|
});
|
|
document.getElementById('taskForm').addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
api.action('/api/tasks/' + encodeURIComponent(editing.name), {
|
|
schedule: document.getElementById('taskSchedule').value,
|
|
enabled: document.getElementById('taskEnabled').checked
|
|
}, 'Saved').then(function () { taskModal.hide(); load(); });
|
|
});
|
|
|
|
// ---- Logs ----
|
|
function showLog(title, meta, text) {
|
|
document.getElementById('logModalTitle').textContent = title;
|
|
document.getElementById('logMeta').innerHTML = meta;
|
|
var pre = document.getElementById('logText');
|
|
var atBottom = pre.parentElement.scrollHeight - pre.parentElement.scrollTop - pre.parentElement.clientHeight < 20;
|
|
pre.textContent = text || '(nothing logged)';
|
|
if (atBottom) pre.parentElement.scrollTop = pre.parentElement.scrollHeight;
|
|
if (!logModalEl.classList.contains('show')) logModal.show();
|
|
}
|
|
|
|
function openRun(id) {
|
|
api.get('/api/tasks/runs/' + id).then(function (d) {
|
|
if (!d.success) return toast(d.error || 'Run not found', 'danger');
|
|
var r = d.run, task = findTask(r.task);
|
|
showLog((task ? task.title : r.task) + ' #' + r.id, statusBadge(r.status) + ' ' + esc(fmt.unix(r.started_at)) + ', took ' +
|
|
esc(duration(r.finished_at - r.started_at)) + ', started by ' + startedBy(r.trigger, r.actor), r.log);
|
|
});
|
|
}
|
|
|
|
// A running task's log, refreshed until it finishes or the dialog closes
|
|
function openLive(name) {
|
|
clearInterval(livePoll);
|
|
var task = findTask(name);
|
|
function poll() {
|
|
api.get('/api/tasks/' + encodeURIComponent(name) + '/log').then(function (d) {
|
|
if (!d.success) return;
|
|
if (!d.running) {
|
|
clearInterval(livePoll);
|
|
document.getElementById('logMeta').innerHTML += ' ' + fmt.badge('Finished', 'secondary') + ' <span class="small">see the run history for the full log</span>';
|
|
return;
|
|
}
|
|
showLog((task ? task.title : name) + ' (running)', 'Started ' + esc(fmt.unix(d.started_at)) + ' by ' + startedBy(d.trigger, d.actor), d.log);
|
|
});
|
|
}
|
|
poll();
|
|
livePoll = setInterval(poll, 2000);
|
|
}
|
|
logModalEl.addEventListener('hidden.bs.modal', function () { clearInterval(livePoll); });
|
|
|
|
document.getElementById('taskRows').addEventListener('click', function (e) {
|
|
var el = e.target.closest('[data-edit],[data-start],[data-live],[data-run]');
|
|
if (!el) return;
|
|
e.preventDefault();
|
|
if (el.dataset.edit) openEdit(el.dataset.edit);
|
|
else if (el.dataset.live) openLive(el.dataset.live);
|
|
else if (el.dataset.run) openRun(el.dataset.run);
|
|
else if (el.dataset.start) {
|
|
el.disabled = true;
|
|
api.action('/api/tasks/' + encodeURIComponent(el.dataset.start) + '/run', {}, 'Started').then(load).catch(function () { el.disabled = false; });
|
|
}
|
|
});
|
|
|
|
// ---- History ----
|
|
var runs = serverTable('#runsTable', '/api/tasks/runs', [
|
|
{ data: 'id' },
|
|
{ data: 'task', orderable: false, render: function (d) { var t = findTask(d); return esc(t ? t.title : d); } },
|
|
{ data: 'trigger', orderable: false, render: function (d, type, row) { return startedBy(d, row.actor); } },
|
|
{ data: 'started_at', orderable: false, render: function (d) { return esc(fmt.unix(d)); } },
|
|
{ data: 'finished_at', orderable: false, render: function (d, type, row) { return esc(duration(d - row.started_at)); } },
|
|
{ data: 'status', orderable: false, render: function (d) { return statusBadge(d); } },
|
|
{ data: 'summary', orderable: false }
|
|
], { liveTable: 'scheduled_tasks', extra: function () { return { task: document.getElementById('runTask').value }; }, dataTable: { searching: false, pageLength: 10 } });
|
|
$('#runsTable tbody').on('click', 'tr', function () {
|
|
var row = runs.row(this).data();
|
|
if (row) openRun(row.id);
|
|
});
|
|
document.getElementById('runTask').addEventListener('change', function () { runs.ajax.reload(); });
|
|
|
|
if (window.Live) Live.on('scheduled_tasks', Live.throttle(load, 500));
|
|
// Keeps "in 5m" current
|
|
setInterval(render, 30000);
|
|
load();
|
|
})();
|
|
</script>
|
|
{% endblock %}
|