1380 lines
40 KiB
HTML
1380 lines
40 KiB
HTML
<!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>
|