Files
jontsai_command-center/public/jobs.html

1380 lines
40 KiB
HTML
Raw Normal View History

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="jobs.pageTitle">AI Jobs - OpenClaw Command Center</title>
<style>
:root {
--bg: #0d1117;
--card-bg: #161b22;
--card-hover: #1c2128;
--border: #30363d;
--text: #c9d1d9;
--text-muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--purple: #a371f7;
--orange: #db6d28;
--sidebar-width: 220px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
background: var(--card-bg);
border-right: 1px solid var(--border);
height: 100vh;
position: fixed;
top: 0;
left: 0;
display: flex;
flex-direction: column;
z-index: 60;
transition: transform 0.2s ease;
}
.sidebar.collapsed {
width: 56px;
}
.sidebar.collapsed .sidebar-title,
.sidebar.collapsed .nav-section-title,
.sidebar.collapsed .nav-item span:not(.nav-icon),
.sidebar.collapsed .nav-badge,
.sidebar.collapsed .sidebar-footer {
display: none;
}
.sidebar.collapsed .sidebar-header {
justify-content: center;
padding: 16px 8px;
}
.sidebar.collapsed .sidebar-toggle {
margin-left: 0;
}
.sidebar.collapsed .nav-item {
justify-content: center;
padding: 12px;
}
.sidebar.collapsed .nav-icon {
margin: 0;
font-size: 1.2rem;
}
.sidebar.collapsed .nav-item::after {
content: attr(data-tooltip);
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
background: var(--card-bg);
border: 1px solid var(--border);
padding: 6px 12px;
border-radius: 6px;
font-size: 0.8rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
margin-left: 8px;
z-index: 100;
}
.sidebar.collapsed .nav-item:hover::after {
opacity: 1;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.sidebar-logo {
font-size: 1.3rem;
}
.sidebar-title {
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
}
.sidebar-toggle {
margin-left: auto;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 6px;
border-radius: 6px;
font-size: 1rem;
}
.sidebar-toggle:hover {
background: var(--bg);
color: var(--text);
}
.sidebar-nav {
flex: 1;
padding: 12px 8px;
overflow-y: auto;
}
.nav-section {
margin-bottom: 16px;
}
.nav-section-title {
font-size: 0.65rem;
text-transform: uppercase;
color: var(--text-muted);
padding: 8px 12px 4px;
letter-spacing: 0.5px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 0.85rem;
color: var(--text-muted);
transition: all 0.15s;
text-decoration: none;
position: relative;
}
.nav-item:hover {
background: var(--bg);
color: var(--text);
}
.nav-item.active {
background: rgba(88, 166, 255, 0.15);
color: var(--accent);
}
.nav-icon {
font-size: 1rem;
width: 20px;
text-align: center;
}
.nav-badge {
margin-left: auto;
background: var(--border);
padding: 2px 8px;
border-radius: 10px;
font-size: 0.7rem;
}
.nav-item.active .nav-badge {
background: rgba(88, 166, 255, 0.3);
}
.sidebar-footer {
padding: 12px;
border-top: 1px solid var(--border);
font-size: 0.7rem;
color: var(--text-muted);
text-align: center;
}
/* Main Content Area */
.main-wrapper {
flex: 1;
margin-left: var(--sidebar-width);
transition: margin-left 0.2s ease;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-wrapper.sidebar-collapsed {
margin-left: 56px;
}
/* Header */
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid var(--border);
background: var(--card-bg);
position: sticky;
top: 0;
z-index: 50;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.page-title {
font-size: 1.1rem;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 8px;
}
/* Stats Bar */
.stats-bar {
display: flex;
gap: 12px;
padding: 16px 24px;
background: var(--card-bg);
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 20px;
background: var(--bg);
border-radius: 8px;
min-width: 100px;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--accent);
}
.stat-value.green {
color: var(--green);
}
.stat-value.yellow {
color: var(--yellow);
}
.stat-value.red {
color: var(--red);
}
.stat-label {
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
margin-top: 4px;
}
/* Main Content */
main {
padding: 24px;
flex: 1;
}
/* Section */
.section {
margin-bottom: 32px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 8px;
}
.section-count {
background: var(--border);
padding: 2px 8px;
border-radius: 10px;
font-size: 0.75rem;
}
/* Filters */
.filters-bar {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filter-btn {
padding: 6px 12px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 16px;
font-size: 0.75rem;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 6px;
}
.filter-btn:hover {
border-color: var(--accent);
color: var(--text);
}
.filter-btn.active {
background: rgba(88, 166, 255, 0.15);
border-color: var(--accent);
color: var(--accent);
}
.filter-count {
background: var(--bg);
padding: 1px 6px;
border-radius: 8px;
font-size: 0.65rem;
}
/* Job Cards Grid */
.jobs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
/* Job Card */
.job-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
transition: all 0.15s ease;
position: relative;
overflow: hidden;
}
.job-card:hover {
background: var(--card-hover);
border-color: var(--accent);
transform: translateY(-2px);
}
.job-card.running {
border-color: var(--green);
box-shadow: 0 0 0 1px var(--green);
}
.job-card.running::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--green);
animation: running-pulse 2s infinite;
}
@keyframes running-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.job-card.paused {
border-color: var(--yellow);
opacity: 0.7;
}
.job-card.paused::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--yellow);
}
.job-card.failed {
border-color: var(--red);
}
.job-card.failed::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--red);
}
.job-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.job-icon {
width: 40px;
height: 40px;
background: var(--bg);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
flex-shrink: 0;
}
.job-icon.running {
background: rgba(63, 185, 80, 0.15);
}
.job-icon.paused {
background: rgba(210, 153, 34, 0.15);
}
.job-icon.failed {
background: rgba(248, 81, 73, 0.15);
}
.job-title-area {
flex: 1;
min-width: 0;
}
.job-name {
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.job-id {
font-size: 0.7rem;
color: var(--text-muted);
font-family: monospace;
margin-top: 2px;
}
.job-description {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 6px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.job-badge {
padding: 3px 8px;
border-radius: 12px;
font-size: 0.7rem;
font-weight: 500;
flex-shrink: 0;
}
.badge-running {
background: rgba(63, 185, 80, 0.2);
color: var(--green);
}
.badge-paused {
background: rgba(210, 153, 34, 0.2);
color: var(--yellow);
}
.badge-enabled {
background: rgba(88, 166, 255, 0.15);
color: var(--accent);
}
.badge-failed {
background: rgba(248, 81, 73, 0.2);
color: var(--red);
}
.job-schedule {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 8px 10px;
background: var(--bg);
border-radius: 8px;
font-size: 0.75rem;
}
.schedule-icon {
opacity: 0.7;
}
.schedule-cron {
font-family: monospace;
color: var(--purple);
}
.schedule-next {
margin-left: auto;
color: var(--text-muted);
}
.job-stats {
display: flex;
gap: 12px;
padding: 10px 0;
border-top: 1px solid var(--border);
margin-top: 12px;
}
.job-stat {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.job-stat-value {
font-size: 0.9rem;
font-weight: 600;
}
.job-stat-value.green {
color: var(--green);
}
.job-stat-value.red {
color: var(--red);
}
.job-stat-label {
font-size: 0.65rem;
color: var(--text-muted);
text-transform: uppercase;
margin-top: 2px;
}
.job-last-run {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
font-size: 0.75rem;
color: var(--text-muted);
}
.last-run-status {
width: 8px;
height: 8px;
border-radius: 50%;
}
.last-run-status.success {
background: var(--green);
}
.last-run-status.failed {
background: var(--red);
}
.last-run-status.running {
background: var(--yellow);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.job-actions {
display: flex;
gap: 6px;
margin-top: 12px;
}
.job-action-btn {
flex: 1;
padding: 8px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.75rem;
color: var(--text);
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.job-action-btn:hover {
background: var(--card-hover);
border-color: var(--accent);
}
.job-action-btn.primary {
background: rgba(88, 166, 255, 0.15);
border-color: var(--accent);
color: var(--accent);
}
.job-action-btn.primary:hover {
background: rgba(88, 166, 255, 0.25);
}
.job-action-btn.danger:hover {
border-color: var(--red);
color: var(--red);
}
.job-action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Tags */
.job-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.job-tag {
padding: 2px 8px;
background: var(--bg);
border-radius: 4px;
font-size: 0.65rem;
color: var(--text-muted);
}
.job-tag.lane {
color: var(--purple);
background: rgba(163, 113, 247, 0.15);
}
/* History Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.modal-overlay.visible {
opacity: 1;
pointer-events: auto;
}
.modal {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 12px;
width: 90%;
max-width: 700px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
transform: translateY(20px);
transition: transform 0.2s;
}
.modal-overlay.visible .modal {
transform: translateY(0);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.modal-title {
font-size: 1rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.2rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
}
.modal-close:hover {
background: var(--bg);
color: var(--text);
}
.modal-content {
padding: 20px;
overflow-y: auto;
flex: 1;
}
/* Run History Table */
.history-table {
width: 100%;
border-collapse: collapse;
}
.history-table th,
.history-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
}
.history-table th {
color: var(--text-muted);
font-weight: 500;
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.5px;
}
.history-table tr:hover td {
background: var(--bg);
}
.run-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
.run-status.success {
background: rgba(63, 185, 80, 0.15);
color: var(--green);
}
.run-status.failed {
background: rgba(248, 81, 73, 0.15);
color: var(--red);
}
.run-status.running {
background: rgba(210, 153, 34, 0.15);
color: var(--yellow);
}
.run-status.skipped {
background: rgba(139, 148, 158, 0.15);
color: var(--text-muted);
}
.run-duration {
color: var(--text-muted);
font-family: monospace;
}
.run-time {
color: var(--text-muted);
font-size: 0.8rem;
}
/* Loading States */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text-muted);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Empty State */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state-text {
font-size: 0.95rem;
margin-bottom: 8px;
}
.empty-state-hint {
font-size: 0.8rem;
opacity: 0.7;
}
/* Toast Notifications */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 300;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 10px;
font-size: 0.85rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation: toast-in 0.2s ease;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(10px);
}
}
.toast.success {
border-color: var(--green);
}
.toast.error {
border-color: var(--red);
}
.toast-icon {
font-size: 1rem;
}
.toast.success .toast-icon {
color: var(--green);
}
.toast.error .toast-icon {
color: var(--red);
}
/* Responsive */
@media (max-width: 1024px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar.visible {
transform: translateX(0);
}
.main-wrapper {
margin-left: 0;
}
}
@media (max-width: 768px) {
.jobs-grid {
grid-template-columns: 1fr;
}
.stats-bar {
flex-wrap: wrap;
}
.modal {
width: 95%;
max-height: 90vh;
}
}
</style>
<script src="/js/i18n.js"></script>
<!-- Shared Sidebar Loader -->
<script src="/js/sidebar.js"></script>
</head>
<body>
<!-- Sidebar container (populated by sidebar.js) -->
<div id="sidebar-container"></div>
<!-- Main Content -->
<div class="main-wrapper" id="main-wrapper">
<header>
<div class="header-left">
<h1 class="page-title" data-i18n="jobs.dashboard">🤖 AI Jobs Dashboard</h1>
</div>
<div class="header-actions">
<button
class="job-action-btn"
onclick="refreshJobs()"
title="Refresh"
data-i18n="jobs.refresh"
data-i18n-title="jobs.refresh"
>
🔄 Refresh
</button>
</div>
</header>
<!-- Stats Bar -->
<div class="stats-bar" id="stats-bar">
<div class="stat">
<div class="stat-value" id="stat-total"></div>
<div class="stat-label" data-i18n="jobs.totalJobs">Total Jobs</div>
</div>
<div class="stat">
<div class="stat-value green" id="stat-active"></div>
<div class="stat-label" data-i18n="jobs.active">Active</div>
</div>
<div class="stat">
<div class="stat-value yellow" id="stat-paused"></div>
<div class="stat-label" data-i18n="jobs.paused">Paused</div>
</div>
<div class="stat">
<div class="stat-value" id="stat-running"></div>
<div class="stat-label" data-i18n="jobs.running">Running</div>
</div>
<div class="stat">
<div class="stat-value green" id="stat-success-rate"></div>
<div class="stat-label" data-i18n="jobs.successRate">Success Rate</div>
</div>
<div class="stat">
<div class="stat-value red" id="stat-failures"></div>
<div class="stat-label" data-i18n="jobs.recentFailures">Recent Failures</div>
</div>
</div>
<main>
<!-- Filters -->
<div class="filters-bar">
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">
<span data-i18n="jobs.all">All</span>
<span class="filter-count" id="filter-all-count">0</span>
</button>
<button class="filter-btn" data-filter="active" onclick="setFilter('active')">
🟢 <span data-i18n="jobs.active">Active</span>
<span class="filter-count" id="filter-active-count">0</span>
</button>
<button class="filter-btn" data-filter="paused" onclick="setFilter('paused')">
⏸️ <span data-i18n="jobs.paused">Paused</span>
<span class="filter-count" id="filter-paused-count">0</span>
</button>
<button class="filter-btn" data-filter="failed" onclick="setFilter('failed')">
🔴 <span data-i18n="jobs.failed">Failed</span>
<span class="filter-count" id="filter-failed-count">0</span>
</button>
</div>
<!-- Jobs Grid -->
<div class="section">
<div id="jobs-loading" class="loading">
<div class="loading-spinner"></div>
<span data-i18n="jobs.loadingJobs">Loading jobs...</span>
</div>
<div id="jobs-empty" class="empty-state" style="display: none">
<div class="empty-state-icon">⚙️</div>
<div class="empty-state-text" data-i18n="jobs.noJobs">No jobs found</div>
<div class="empty-state-hint">
Job definitions are loaded from $OPENCLAW_JOBS_DIR/definitions/
</div>
</div>
<div id="jobs-grid" class="jobs-grid" style="display: none">
<!-- Job cards will be inserted here -->
</div>
</div>
</main>
</div>
<!-- History Modal -->
<div class="modal-overlay" id="history-modal">
<div class="modal">
<div class="modal-header">
<span class="modal-title" id="history-modal-title" data-i18n="jobs.runHistory"
>Run History</span
>
<button class="modal-close" onclick="closeHistoryModal()">×</button>
</div>
<div class="modal-content">
<div id="history-loading" class="loading">
<div class="loading-spinner"></div>
<span data-i18n="jobs.loadingHistory">Loading history...</span>
</div>
<table class="history-table" id="history-table" style="display: none">
<thead>
<tr>
<th data-i18n="jobs.status">Status</th>
<th data-i18n="jobs.started">Started</th>
<th data-i18n="jobs.duration">Duration</th>
<th data-i18n="jobs.details">Details</th>
</tr>
</thead>
<tbody id="history-tbody"></tbody>
</table>
<div id="history-empty" class="empty-state" style="display: none">
<div class="empty-state-icon">📭</div>
<div class="empty-state-text" data-i18n="jobs.noHistory">No run history yet</div>
</div>
</div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toast-container"></div>
<script>
// State
let jobs = [];
let currentFilter = "all";
let refreshTimer = null;
const t = (key, params = {}, fallback = key) =>
window.I18N?.t ? window.I18N.t(key, params, fallback) : fallback;
// Format schedule for display
function formatSchedule(schedule) {
if (!schedule) return "—";
if (typeof schedule === "string") return schedule;
if (schedule.cron) return schedule.cron;
if (schedule.interval)
return t("jobs.every", { value: schedule.interval }, `Every ${schedule.interval}`);
if (schedule.at) return t("jobs.at", { value: schedule.at }, `At ${schedule.at}`);
return JSON.stringify(schedule);
}
// Get job status class
function getJobStatus(job) {
if (job.paused) return "paused";
if (job.stats?.streak?.type === "failed" && job.stats.streak.count >= 2) return "failed";
return "enabled";
}
// Get job icon
function getJobIcon(job) {
const status = getJobStatus(job);
if (status === "paused") return "⏸️";
if (status === "failed") return "⚠️";
return "⚙️";
}
// Render a single job card
function renderJobCard(job) {
const status = getJobStatus(job);
const icon = getJobIcon(job);
const successRate = job.stats?.successRate
? Math.round(job.stats.successRate * 100) + "%"
: "—";
const avgDuration = job.stats?.avgDurationFormatted || "—";
return `
<div class="job-card ${status}" data-job-id="${job.id}" data-status="${status}">
<div class="job-header">
<div class="job-icon ${status}">${icon}</div>
<div class="job-title-area">
<div class="job-name">${escapeHtml(job.name)}</div>
<div class="job-id">${escapeHtml(job.id)}</div>
${job.description ? `<div class="job-description">${escapeHtml(job.description)}</div>` : ""}
</div>
<span class="job-badge badge-${status}">
${
status === "paused"
? t("jobs.paused", {}, "Paused")
: status === "failed"
? t("jobs.statusFailing", {}, "Failing")
: t("jobs.active", {}, "Active")
}
</span>
</div>
<div class="job-schedule">
<span class="schedule-icon">📅</span>
<span class="schedule-cron">${escapeHtml(formatSchedule(job.schedule))}</span>
<span class="schedule-next">${t("jobs.next", { value: job.nextRunRelative || "—" }, `Next: ${job.nextRunRelative || "—"}`)}</span>
</div>
<div class="job-tags">
<span class="job-tag lane">${t("jobs.lane", { value: job.lane || "medium" }, `🛤️ ${job.lane || "medium"}`)}</span>
${(job.tags || []).map((t) => `<span class="job-tag">${escapeHtml(t)}</span>`).join("")}
</div>
<div class="job-stats">
<div class="job-stat">
<div class="job-stat-value">${job.stats?.totalRuns || 0}</div>
<div class="job-stat-label">${t("jobs.runs", {}, "Runs")}</div>
</div>
<div class="job-stat">
<div class="job-stat-value green">${successRate}</div>
<div class="job-stat-label">${t("jobs.success", {}, "Success")}</div>
</div>
<div class="job-stat">
<div class="job-stat-value">${avgDuration}</div>
<div class="job-stat-label">${t("jobs.avgTime", {}, "Avg Time")}</div>
</div>
</div>
<div class="job-last-run">
${
job.lastRun
? `
<span class="last-run-status ${job.stats?.streak?.type === "failed" ? "failed" : "success"}"></span>
<span>${t("jobs.lastRun", { value: job.lastRunRelative || "—" }, `Last run: ${job.lastRunRelative || "—"}`)}</span>
`
: `<span>${t("jobs.neverRun", {}, "Never run")}</span>`
}
</div>
<div class="job-actions">
<button class="job-action-btn primary" onclick="runJob('${job.id}')" ${job.paused ? "disabled" : ""}>
${t("jobs.run", {}, "▶️ Run")}
</button>
${
job.paused
? `<button class="job-action-btn" onclick="resumeJob('${job.id}')">${t("jobs.resume", {}, "▶️ Resume")}</button>`
: `<button class="job-action-btn" onclick="pauseJob('${job.id}')">${t("jobs.pause", {}, "⏸️ Pause")}</button>`
}
<button class="job-action-btn" onclick="showHistory('${job.id}', '${escapeHtml(job.name)}')">
${t("jobs.history", {}, "📜 History")}
</button>
</div>
</div>
`;
}
// Escape HTML for safety
function escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// Render all jobs
function renderJobs() {
const grid = document.getElementById("jobs-grid");
const loading = document.getElementById("jobs-loading");
const empty = document.getElementById("jobs-empty");
if (jobs.length === 0) {
loading.style.display = "none";
grid.style.display = "none";
empty.style.display = "block";
return;
}
// Filter jobs
let filteredJobs = jobs;
if (currentFilter !== "all") {
filteredJobs = jobs.filter((j) => {
const status = getJobStatus(j);
if (currentFilter === "active") return status === "enabled";
if (currentFilter === "paused") return status === "paused";
if (currentFilter === "failed") return status === "failed";
return true;
});
}
loading.style.display = "none";
empty.style.display = "none";
grid.style.display = "grid";
grid.innerHTML = filteredJobs.map(renderJobCard).join("");
}
// Update stats
function updateStats() {
const active = jobs.filter((j) => !j.paused).length;
const paused = jobs.filter((j) => j.paused).length;
const failed = jobs.filter(
(j) => j.stats?.streak?.type === "failed" && j.stats.streak.count >= 2,
).length;
// Calculate overall success rate
let totalRuns = 0,
totalSuccess = 0;
jobs.forEach((j) => {
totalRuns += j.stats?.totalRuns || 0;
totalSuccess += j.stats?.totalSuccess || 0;
});
const successRate = totalRuns > 0 ? Math.round((totalSuccess / totalRuns) * 100) : 100;
document.getElementById("stat-total").textContent = jobs.length;
document.getElementById("stat-active").textContent = active;
document.getElementById("stat-paused").textContent = paused;
document.getElementById("stat-running").textContent = "0"; // TODO: track running
document.getElementById("stat-success-rate").textContent = successRate + "%";
document.getElementById("stat-failures").textContent = failed;
// Update filter counts
document.getElementById("filter-all-count").textContent = jobs.length;
document.getElementById("filter-active-count").textContent = active;
document.getElementById("filter-paused-count").textContent = paused;
document.getElementById("filter-failed-count").textContent = failed;
// Update nav badge
document.getElementById("nav-jobs-count").textContent = jobs.length;
}
// Set filter
function setFilter(filter) {
currentFilter = filter;
document.querySelectorAll(".filter-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.filter === filter);
});
renderJobs();
}
// Fetch jobs from API
async function fetchJobs() {
try {
const res = await fetch("/api/jobs");
const data = await res.json();
jobs = data.jobs || [];
renderJobs();
updateStats();
document.getElementById("sidebar-updated").textContent = t(
"sidebar.updated",
{ time: new Date().toLocaleTimeString() },
"Updated: " + new Date().toLocaleTimeString(),
);
} catch (e) {
console.error("Failed to fetch jobs:", e);
showToast(t("jobs.toastLoadFailed", {}, "Failed to load jobs"), "error");
}
}
// Refresh jobs
function refreshJobs() {
fetchJobs();
}
// Run job
async function runJob(jobId) {
try {
const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/run`, { method: "POST" });
const data = await res.json();
if (data.success) {
showToast(
t("jobs.toastRunQueued", { id: jobId }, `Job "${jobId}" queued for execution`),
"success",
);
setTimeout(refreshJobs, 1000);
} else {
showToast(data.error || t("jobs.toastRunFailed", {}, "Failed to run job"), "error");
}
} catch (e) {
showToast(t("jobs.toastRunFailed", {}, "Failed to run job"), "error");
}
}
// Pause job
async function pauseJob(jobId) {
try {
const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/pause`, {
method: "POST",
});
const data = await res.json();
if (data.success) {
showToast(t("jobs.toastPaused", { id: jobId }, `Job "${jobId}" paused`), "success");
refreshJobs();
} else {
showToast(data.error || t("jobs.toastPauseFailed", {}, "Failed to pause job"), "error");
}
} catch (e) {
showToast(t("jobs.toastPauseFailed", {}, "Failed to pause job"), "error");
}
}
// Resume job
async function resumeJob(jobId) {
try {
const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/resume`, {
method: "POST",
});
const data = await res.json();
if (data.success) {
showToast(t("jobs.toastResumed", { id: jobId }, `Job "${jobId}" resumed`), "success");
refreshJobs();
} else {
showToast(
data.error || t("jobs.toastResumeFailed", {}, "Failed to resume job"),
"error",
);
}
} catch (e) {
showToast(t("jobs.toastResumeFailed", {}, "Failed to resume job"), "error");
}
}
// Show history modal
async function showHistory(jobId, jobName) {
const modal = document.getElementById("history-modal");
const title = document.getElementById("history-modal-title");
const loading = document.getElementById("history-loading");
const table = document.getElementById("history-table");
const tbody = document.getElementById("history-tbody");
const empty = document.getElementById("history-empty");
title.textContent = t("jobs.historyTitle", { name: jobName }, `Run History: ${jobName}`);
modal.classList.add("visible");
loading.style.display = "flex";
table.style.display = "none";
empty.style.display = "none";
try {
const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/history?limit=50`);
const data = await res.json();
const runs = data.runs || [];
loading.style.display = "none";
if (runs.length === 0) {
empty.style.display = "block";
return;
}
tbody.innerHTML = runs
.map(
(run) => `
<tr>
<td>
<span class="run-status ${run.status}">
${run.status === "success" ? "✅" : run.status === "failed" ? "❌" : "⏳"}
${
run.status === "success"
? t("jobs.statusSuccess", {}, run.status)
: run.status === "failed"
? t("jobs.statusFailed", {}, run.status)
: t("jobs.statusRunning", {}, run.status)
}
</span>
</td>
<td class="run-time">${run.startedAtRelative || run.startedAt || "—"}</td>
<td class="run-duration">${run.durationFormatted || "—"}</td>
<td>${run.error ? `<span style="color: var(--red)">${escapeHtml(run.error.slice(0, 50))}</span>` : "—"}</td>
</tr>
`,
)
.join("");
table.style.display = "table";
} catch (e) {
loading.style.display = "none";
empty.style.display = "block";
console.error("Failed to fetch history:", e);
}
}
// Close history modal
function closeHistoryModal() {
document.getElementById("history-modal").classList.remove("visible");
}
// Show toast notification
function showToast(message, type = "info") {
const container = document.getElementById("toast-container");
const toast = document.createElement("div");
toast.className = `toast ${type}`;
toast.innerHTML = `
<span class="toast-icon">${type === "success" ? "✅" : type === "error" ? "❌" : ""}</span>
<span>${escapeHtml(message)}</span>
`;
container.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
// Toggle sidebar - provided by sidebar.js
// Close modal on overlay click
document.getElementById("history-modal").addEventListener("click", function (e) {
if (e.target === this) closeHistoryModal();
});
// Keyboard shortcuts
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") closeHistoryModal();
if (e.key === "r" && !e.metaKey && !e.ctrlKey) refreshJobs();
});
window.addEventListener("i18n:updated", function () {
if (jobs.length > 0) {
renderJobs();
}
});
// Initialize
fetchJobs();
// Auto-refresh every 30 seconds
refreshTimer = setInterval(fetchJobs, 30000);
</script>
</body>
</html>