mirror of
https://github.com/fabioformosa/quartz-manager.git
synced 2026-05-14 22:00:30 +09:00
#134 added the new UI layout and style
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
<div class="app-shell flex flex-column justify-space-between h-100">
|
||||
<app-header class="flex-none"></app-header>
|
||||
<div class="content flex h-100">
|
||||
<router-outlet></router-outlet>
|
||||
@if (isOperationsConsoleRoute()) {
|
||||
<router-outlet></router-outlet>
|
||||
} @else {
|
||||
<div class="app-shell flex flex-column justify-space-between h-100">
|
||||
<app-header class="flex-none"></app-header>
|
||||
<div class="content flex h-100">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
<app-footer class="flex-none"></app-footer>
|
||||
</div>
|
||||
<app-footer class="flex-none"></app-footer>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: block;
|
||||
color: rgba(0,0,0,.54);
|
||||
font-family: Roboto,"Helvetica Neue";
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {Component} from '@angular/core';
|
||||
import {Router} from '@angular/router';
|
||||
|
||||
import fontawesome from '@fortawesome/fontawesome';
|
||||
import {
|
||||
@@ -19,5 +20,12 @@ fontawesome.library.add(faCheckCircle, faExclamationCircle, faExclamationTriangl
|
||||
standalone: false
|
||||
})
|
||||
|
||||
export class AppComponent {
|
||||
}
|
||||
export class AppComponent {
|
||||
constructor(private router: Router) {
|
||||
}
|
||||
|
||||
isOperationsConsoleRoute(): boolean {
|
||||
const url = this.router.url || '/';
|
||||
return url === '/' || url.startsWith('/manager');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,353 @@
|
||||
<div id="managerViewContainer" class="flex flex-column flex-1 gap-6 h-100">
|
||||
<div id="schedulerBarContainer">
|
||||
<qrzmng-scheduler-control></qrzmng-scheduler-control>
|
||||
</div>
|
||||
|
||||
<div id="manager-content-container" class="flex flex-row flex-1 gap-6">
|
||||
<div class="flex-1" style="max-width: 250px">
|
||||
<div class="flex h-100">
|
||||
<qrzmng-trigger-list
|
||||
(onNewTriggerClicked)="onNewTriggerRequested()"
|
||||
[openedNewTriggerForm]="newTriggerFormOpened"
|
||||
(onSelectedTrigger)="setSelectedTrigger($event)"
|
||||
class="h-100 w-100"></qrzmng-trigger-list>
|
||||
<div class="qm-app" [class.object-mode]="activePage !== 'dashboard'" (click)="handleConsoleClick($event)">
|
||||
<aside class="rail" aria-label="Primary navigation">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">QM</div>
|
||||
<div>
|
||||
<div class="brand-title">Quartz Manager</div>
|
||||
<div class="brand-subtitle">Operations Console</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1" style="max-width: 350px">
|
||||
<div class="flex h-100">
|
||||
<div class="flex flex-column h-100 w-100">
|
||||
<qrzmng-simple-trigger-config
|
||||
class="h-100 w-100"
|
||||
[triggerKey]="selectedTriggerKey"
|
||||
(triggerFormOpenChange)="setNewTriggerFormOpened($event)"
|
||||
(onTriggerSubmitting)="monitorTrigger($event)"
|
||||
(onNewTrigger)="onNewTriggerCreated($event)">
|
||||
</qrzmng-simple-trigger-config>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="h-100 min-h-100 flex flex-column gap-6">
|
||||
<div class="flex flex-column" >
|
||||
<progress-panel class="flex-1"
|
||||
[triggerKey]=monitoredTriggerKey
|
||||
>
|
||||
</progress-panel>
|
||||
</div>
|
||||
<div class="flex flex-column flex-1" style="max-height: calc(100% - 136px); min-height: calc(100% - 210px);">
|
||||
<logs-panel class="flex flex-1 h-100 max-h-100"
|
||||
[triggerKey]=monitoredTriggerKey
|
||||
>
|
||||
</logs-panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<button type="button" [class.active]="activePage === 'dashboard'" [attr.aria-current]="activePage === 'dashboard' ? 'page' : null" (click)="selectPage('dashboard')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 13h8V3H3v10Zm10 8h8V3h-8v18ZM3 21h8v-6H3v6Z"/></svg><span>Dashboard</span>
|
||||
</button>
|
||||
<button type="button" [class.active]="activePage === 'jobs'" [attr.aria-current]="activePage === 'jobs' ? 'page' : null" (click)="selectPage('jobs')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M7 8h10M7 12h10M7 16h6"/><rect x="4" y="4" width="16" height="16" rx="2"/></svg><span>Jobs</span>
|
||||
</button>
|
||||
<button type="button" [class.active]="activePage === 'triggers'" [attr.aria-current]="activePage === 'triggers' ? 'page' : null" (click)="selectPage('triggers')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 6v6l4 2"/><circle cx="12" cy="12" r="9"/></svg><span>Triggers</span>
|
||||
</button>
|
||||
<button type="button" [class.active]="activePage === 'calendars'" [attr.aria-current]="activePage === 'calendars' ? 'page' : null" (click)="selectPage('calendars')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M7 3v4M17 3v4M4 9h16M5 5h14v15H5z"/></svg><span>Calendars</span>
|
||||
</button>
|
||||
<button type="button" [class.active]="activePage === 'executions'" [attr.aria-current]="activePage === 'executions' ? 'page' : null" (click)="selectPage('executions')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 17h16M7 17V7m5 10V4m5 13v-6"/></svg><span>Executions</span>
|
||||
</button>
|
||||
<button type="button" [class.active]="activePage === 'events'" [attr.aria-current]="activePage === 'events' ? 'page' : null" (click)="selectPage('events')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 7h16M4 12h16M4 17h10"/></svg><span>Event Stream</span>
|
||||
</button>
|
||||
<button type="button" [class.active]="activePage === 'scheduler'" [attr.aria-current]="activePage === 'scheduler' ? 'page' : null" (click)="selectPage('scheduler')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="m19.4 15 .6 2-1.7 3-2.1-.5a8.3 8.3 0 0 1-2 1.1L13.5 23h-3l-.7-2.4a8.3 8.3 0 0 1-2-1.1l-2.1.5-1.7-3 .6-2a8.9 8.9 0 0 1 0-2.1l-.6-2 1.7-3 2.1.5a8.3 8.3 0 0 1 2-1.1l.7-2.4h3l.7 2.4a8.3 8.3 0 0 1 2 1.1l2.1-.5 1.7 3-.6 2a8.9 8.9 0 0 1 0 2.1Z"/></svg><span>Scheduler</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="rail-card">
|
||||
<h3>Live channel</h3>
|
||||
<div class="connection"><span>WebSocket</span><span class="chip success">OPEN</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div class="scheduler-meta">
|
||||
<div class="scheduler-title">
|
||||
<h1>Quartz Operations Console</h1>
|
||||
<div class="caption">{{ scheduler?.name || 'quartz-manager-scheduler' }} / compact context</div>
|
||||
</div>
|
||||
<span class="chip" [ngClass]="getSchedulerStatusClass()">{{ scheduler?.status || 'LOADING' }}</span>
|
||||
<div class="kv"><span>Instance ID</span><span>{{ scheduler?.instanceId || '-' }}</span></div>
|
||||
<button type="button" class="kv kv-button" data-roadmap="Cluster metadata is not exposed by the current backend"><span>Cluster</span><span>Roadmap</span></button>
|
||||
<div class="kv"><span>WebSocket</span><span>OPEN</span></div>
|
||||
</div>
|
||||
<div class="actions compact-actions" aria-label="Compact scheduler status actions">
|
||||
<button type="button" class="btn compact" (click)="toggleStandby()">{{ scheduler?.status === 'PAUSED' ? 'Resume' : 'Standby' }}</button>
|
||||
<button type="button" class="btn compact" (click)="jumpToScheduler()">Scheduler</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="content">
|
||||
<div class="page" [class.active]="activePage === 'dashboard'">
|
||||
<div class="dashboard-grid">
|
||||
<section class="card span-12">
|
||||
<div class="card-header"><h2 class="card-title">Scheduler Command Center</h2><span class="caption">Supported lifecycle commands call the current backend</span></div>
|
||||
<div class="card-body scheduler-command-grid">
|
||||
<div class="command-panel">
|
||||
<div class="command-row" aria-label="Dashboard scheduler actions">
|
||||
<button type="button" class="btn primary" (click)="startScheduler()">Start</button>
|
||||
<button type="button" class="btn" (click)="standbyScheduler()">Standby</button>
|
||||
<button type="button" class="btn" (click)="resumeScheduler()">Resume</button>
|
||||
<button type="button" class="btn" data-roadmap="Pause all trigger groups is not available in the current backend">Pause All</button>
|
||||
<button type="button" class="btn danger" data-roadmap="Clear scheduler is not available in the current backend">Clear</button>
|
||||
<button type="button" class="btn danger" (click)="shutdownScheduler()">Shutdown</button>
|
||||
</div>
|
||||
<div class="help">Global lifecycle operations are centralized here. Group-level and destructive data operations stay visible as roadmap actions until backend endpoints exist.</div>
|
||||
</div>
|
||||
<div class="metadata-grid">
|
||||
<div class="field"><label>Scheduler name</label><strong>{{ scheduler?.name || '-' }}</strong></div>
|
||||
<div class="field"><label>Instance ID</label><strong>{{ scheduler?.instanceId || '-' }}</strong></div>
|
||||
<div class="field"><label>Status</label><strong>{{ scheduler?.status || '-' }}</strong></div>
|
||||
<div class="field"><label>Triggers</label><strong>{{ triggerKeys.length }}</strong></div>
|
||||
<div class="field"><label>Eligible jobs</label><strong>{{ jobs.length }}</strong></div>
|
||||
<button type="button" class="field field-button" data-roadmap="Quartz version and job-store metadata are not exposed by the current backend"><label>Quartz metadata</label><strong>Roadmap</strong></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<article class="card span-3"><div class="card-body metric"><span class="chip running">TRIGGERS</span><div class="metric-value">{{ triggerKeys.length }}</div><div class="metric-label">Trigger keys returned by backend</div><div class="metric-line"><span style="--w: 64%"></span></div></div></article>
|
||||
<article class="card span-3"><div class="card-body metric"><span class="chip blocked">JOBS</span><div class="metric-value">{{ jobs.length }}</div><div class="metric-label">Eligible job classes</div><div class="metric-line"><span style="--w: 48%"></span></div></div></article>
|
||||
<article class="card span-3"><div class="card-body metric"><span class="chip warn">EVENTS</span><div class="metric-value">{{ getExecutionLoadValue() }}</div><div class="metric-label">Logs received for selected trigger</div><div class="metric-line"><span style="--w: 32%"></span></div></div></article>
|
||||
<article class="card span-3"><div class="card-body metric"><span class="chip accent">STATUS</span><div class="metric-value compact-metric">{{ scheduler?.status || '-' }}</div><div class="metric-label">Scheduler lifecycle state</div><div class="metric-line"><span style="--w: 67%"></span></div></div></article>
|
||||
|
||||
<section class="card span-7">
|
||||
<div class="card-header"><h2 class="card-title">Next Scheduled Fires</h2><div class="toolbar"><span class="chip normal">LIVE</span><button type="button" class="btn" (click)="selectPage('triggers')">Open Triggers</button></div></div>
|
||||
<div class="split">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th style="width:22%">Trigger</th><th style="width:15%">Group</th><th style="width:18%">Type</th><th style="width:13%">State</th><th style="width:16%">Job</th><th style="width:16%">Next fire</th></tr></thead>
|
||||
<tbody>
|
||||
@for (triggerKey of triggerKeys; track triggerKey.name) {
|
||||
<tr class="selectable" [class.selected]="selectedTriggerKey?.name === triggerKey.name" (click)="selectTrigger(triggerKey)">
|
||||
<td class="mono">{{ triggerKey.name }}</td>
|
||||
<td class="mono">{{ getTriggerGroup(triggerKey) }}</td>
|
||||
<td>{{ getTriggerType(triggerKey) }}</td>
|
||||
<td><span class="chip" [ngClass]="getTriggerStateClass(triggerKey)">{{ getTriggerState(triggerKey) }}</span></td>
|
||||
<td class="mono">{{ getTriggerJobName(triggerKey) }}</td>
|
||||
<td class="mono">{{ getTriggerNextFireLabel(triggerKey) }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="6">No triggers returned by the backend. Use the wizard to create a SimpleTrigger.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'dashboard'" aria-label="Trigger detail drawer">
|
||||
@if (selectedTriggerKey) {
|
||||
<div class="drawer-title"><div><span class="chip" [ngClass]="getSelectedTriggerStateClass()">{{ getSelectedTriggerState() }}</span><h2>{{ selectedTriggerKey.name }}</h2><div class="caption">{{ getSelectedTriggerGroup() }} / linked to {{ getSelectedJobName() }}</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div>
|
||||
<div class="tabs"><button type="button" class="tab active">Overview</button><button type="button" class="tab" data-roadmap="Per-trigger execution history is on the roadmap">Executions</button><button type="button" class="tab" (click)="selectPage('events')">Logs</button></div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Previous fire</label><strong>{{ selectedTrigger?.timesTriggered ? 'tracked by progress events' : 'not exposed' }}</strong></div>
|
||||
<div class="field"><label>Next fire</label><strong>{{ formatDateTime(selectedTrigger?.nextFireTime) || '-' }}</strong></div>
|
||||
<div class="field"><label>Priority</label><strong>{{ selectedTrigger?.priority || '-' }}</strong></div>
|
||||
<div class="field"><label>Calendar</label><strong>Roadmap</strong></div>
|
||||
<div class="field"><label>Misfire</label><strong>{{ selectedTrigger?.misfireInstruction || '-' }}</strong></div>
|
||||
<div class="field"><label>Repeat</label><strong>{{ getSelectedTriggerRepeatSummary() }}</strong></div>
|
||||
</div>
|
||||
<div class="progress-card"><div class="caption">Current run progress</div><div class="progress-line"><span [style.width.%]="getProgressPercentage()"></span></div><div class="mono">{{ getProgressLabel() }}</div></div>
|
||||
<div class="actions"><button type="button" class="btn" data-roadmap="Trigger pause/resume endpoints are not available yet">Pause</button><button type="button" class="btn" (click)="openRescheduleWizard()">Reschedule</button><button type="button" class="btn danger" data-roadmap="Unschedule trigger is on the roadmap">Unschedule</button></div>
|
||||
} @else {
|
||||
<div class="drawer-title"><div><span class="chip warn">EMPTY</span><h2>No trigger selected</h2><div class="caption">Create a SimpleTrigger or refresh trigger keys.</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div>
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card span-5">
|
||||
<div class="card-header"><h2 class="card-title">Execution Load</h2><span class="caption">Analytics roadmap preview</span></div>
|
||||
<div class="card-body">
|
||||
<div class="mini-chart" data-roadmap="Execution analytics are not exposed by the current backend" aria-label="Execution load chart">
|
||||
<span class="bar" style="--h:34%"></span><span class="bar" style="--h:42%"></span><span class="bar" style="--h:28%"></span><span class="bar" style="--h:62%"></span><span class="bar warn" style="--h:52%"></span><span class="bar" style="--h:38%"></span><span class="bar" style="--h:55%"></span><span class="bar" style="--h:72%"></span><span class="bar error" style="--h:44%"></span><span class="bar" style="--h:67%"></span><span class="bar" style="--h:46%"></span><span class="bar" style="--h:58%"></span><span class="bar warn" style="--h:81%"></span><span class="bar" style="--h:64%"></span><span class="bar" style="--h:35%"></span><span class="bar" style="--h:50%"></span><span class="bar" style="--h:70%"></span><span class="bar" style="--h:40%"></span>
|
||||
</div>
|
||||
<div class="field-grid top-space">
|
||||
<div class="field"><label>Logs received</label><strong>{{ logs.length }}</strong></div>
|
||||
<div class="field"><label>Current progress</label><strong>{{ getProgressPercentage() }}%</strong></div>
|
||||
<button type="button" class="field field-button" data-roadmap="Misfire analytics are on the roadmap"><label>Misfires</label><strong>Roadmap</strong></button>
|
||||
<button type="button" class="field field-button" data-roadmap="Recovering jobs endpoint is on the roadmap"><label>Recovering jobs</label><strong>Roadmap</strong></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card span-12">
|
||||
<div class="card-header"><h2 class="card-title">Event Stream</h2><div class="toolbar"><input class="search" value="Filter: selected trigger logs" data-roadmap="Event stream filtering is on the roadmap"><span class="chip normal">STREAMING</span><button type="button" class="btn" data-roadmap="Pausing the merged event stream is on the roadmap">Pause</button><button type="button" class="btn" data-roadmap="Event export is on the roadmap">Export</button></div></div>
|
||||
<div class="stream">
|
||||
<div class="stream-row"><span>Time</span><span>Severity</span><span>Type</span><span>Source</span><span>Message</span></div>
|
||||
@for (log of logs; track log.time) {
|
||||
<div class="stream-row"><span class="mono">{{ log.time | date:'HH:mm:ss' }}</span><span class="chip" [ngClass]="log.severity === 'ERROR' ? 'danger' : log.severity === 'WARN' ? 'warn' : 'success'">{{ log.severity }}</span><span>{{ log.type }}</span><span class="mono">{{ log.source }}</span><span>{{ log.message }}</span></div>
|
||||
} @empty {
|
||||
<div class="stream-row muted-row"><span class="mono">--</span><span class="chip warn">WAIT</span><span>JOB_LOG</span><span class="mono">{{ selectedTriggerKey?.name || '-' }}</span><span>Waiting for log messages from the selected trigger.</span></div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page" [class.active]="activePage === 'jobs'">
|
||||
<div class="page-kicker">
|
||||
<div><h2>Jobs</h2><p>The current backend exposes eligible Quartz Manager job classes. Full job registry metadata, CRUD, durability, recovery, and group operations remain roadmap features.</p></div>
|
||||
<div class="toolbar"><input class="search" value="Filter jobs, groups, classes" data-roadmap="Job filtering is on the roadmap"><button type="button" class="btn primary" data-roadmap="Creating jobs from the UI is on the roadmap">New Job</button></div>
|
||||
</div>
|
||||
<section class="card">
|
||||
<div class="card-header"><h2 class="card-title">Eligible Job Classes</h2><div class="toolbar"><span class="chip normal">{{ jobs.length }} JOBS</span><button type="button" class="btn" data-roadmap="Pause job group is on the roadmap">Pause Group</button><button type="button" class="btn" data-roadmap="Job export is on the roadmap">Export</button></div></div>
|
||||
<div class="split">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th style="width:22%">Job key</th><th style="width:48%">Class</th><th style="width:10%">Durable</th><th style="width:10%">Recovery</th><th style="width:10%">Triggers</th></tr></thead>
|
||||
<tbody>
|
||||
@for (jobClass of getJobClassRows(); track jobClass) {
|
||||
<tr class="selectable" [class.selected]="selectedJobClass === jobClass" (click)="selectJob(jobClass)"><td class="mono">{{ shortClassName(jobClass) }}</td><td class="mono">{{ jobClass }}</td><td><span class="chip warn">Roadmap</span></td><td><span class="chip warn">Roadmap</span></td><td class="mono">Roadmap</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'jobs'" aria-label="Job detail drawer">
|
||||
<div class="drawer-title"><div><span class="chip normal">ELIGIBLE</span><h2>{{ getSelectedJobShortName() }}</h2><div class="caption">{{ selectedJobClass || 'Select a job class' }}</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div>
|
||||
<div class="tabs"><button type="button" class="tab active">Overview</button><button type="button" class="tab" data-roadmap="Job trigger relationships are on the roadmap">Triggers</button><button type="button" class="tab" data-roadmap="JobDataMap editing is on the roadmap">Data Map</button><button type="button" class="tab" data-roadmap="Job execution history is on the roadmap">Executions</button></div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Class</label><strong>{{ getSelectedJobShortName() }}</strong></div>
|
||||
<button type="button" class="field field-button" data-roadmap="Job key/group metadata is on the roadmap"><label>Group</label><strong>Roadmap</strong></button>
|
||||
<button type="button" class="field field-button" data-roadmap="Durability metadata is on the roadmap"><label>Durable</label><strong>Roadmap</strong></button>
|
||||
<button type="button" class="field field-button" data-roadmap="Recovery metadata is on the roadmap"><label>Requests recovery</label><strong>Roadmap</strong></button>
|
||||
</div>
|
||||
<pre class="code-block">Backend contract
|
||||
GET /quartz-manager/jobs
|
||||
Returns eligible Java job class names.</pre>
|
||||
<div class="actions"><button type="button" class="btn primary" data-roadmap="Manual trigger-now by job key is on the roadmap">Trigger Now</button><button type="button" class="btn" data-roadmap="Pause job is on the roadmap">Pause</button><button type="button" class="btn" (click)="openCreateTriggerWizard(); triggerDraft.jobClass = selectedJobClass">Create SimpleTrigger</button></div>
|
||||
<div class="danger-zone"><strong>Danger zone</strong><span class="help">Interrupt and delete need backend support and explicit confirmation.</span><div class="actions"><button type="button" class="btn danger" data-roadmap="Job interruption is on the roadmap">Interrupt</button><button type="button" class="btn danger" data-roadmap="Job deletion is on the roadmap">Delete Job</button></div></div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page" [class.active]="activePage === 'triggers'">
|
||||
<div class="page-kicker">
|
||||
<div><h2>Triggers</h2><p>The backend currently supports SimpleTrigger listing, details, creation, and rescheduling. Other trigger families and per-trigger operations are shown with roadmap messaging.</p></div>
|
||||
<div class="toolbar"><input class="search" value="Filter triggers, jobs, groups" data-roadmap="Trigger filtering is on the roadmap"><button type="button" class="btn primary" (click)="openCreateTriggerWizard()">Create Trigger</button></div>
|
||||
</div>
|
||||
<section class="card">
|
||||
<div class="card-header"><h2 class="card-title">Trigger Inventory</h2><div class="toolbar"><span class="chip normal">{{ triggerKeys.length }} TOTAL</span><span class="chip warn" data-roadmap="Trigger state counts are on the roadmap">STATE COUNTS ROADMAP</span></div></div>
|
||||
<div class="split">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th style="width:18%">Trigger</th><th style="width:12%">Group</th><th style="width:15%">Type</th><th style="width:12%">State</th><th style="width:18%">Job</th><th style="width:15%">Next fire</th><th style="width:10%">Misfire</th></tr></thead>
|
||||
<tbody>
|
||||
@for (triggerKey of triggerKeys; track triggerKey.name) {
|
||||
<tr class="selectable" [class.selected]="selectedTriggerKey?.name === triggerKey.name" (click)="selectTrigger(triggerKey)"><td class="mono">{{ triggerKey.name }}</td><td class="mono">{{ getTriggerGroup(triggerKey) }}</td><td>{{ getTriggerType(triggerKey) }}</td><td><span class="chip" [ngClass]="getTriggerStateClass(triggerKey)">{{ getTriggerState(triggerKey) }}</span></td><td class="mono">{{ getTriggerJobName(triggerKey) }}</td><td class="mono">{{ getTriggerNextFireLabel(triggerKey) }}</td><td class="mono">{{ getTriggerDetail(triggerKey)?.misfireInstruction || '-' }}</td></tr>
|
||||
} @empty {
|
||||
<tr><td colspan="7">No triggers returned by the backend.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'triggers'" aria-label="Trigger detail drawer">
|
||||
<div class="drawer-title"><div><span class="chip" [ngClass]="getSelectedTriggerStateClass()">{{ getSelectedTriggerState() }}</span><h2>{{ selectedTriggerKey?.name || 'No trigger' }}</h2><div class="caption">SimpleTrigger / {{ getSelectedTriggerGroup() }}</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div>
|
||||
<div class="tabs"><button type="button" class="tab active">Overview</button><button type="button" class="tab">Schedule</button><button type="button" class="tab" data-roadmap="Quartz calendars are on the roadmap">Calendar</button><button type="button" class="tab" data-roadmap="Trigger execution history is on the roadmap">Executions</button></div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Linked job</label><strong>{{ getSelectedJobName() }}</strong></div>
|
||||
<div class="field"><label>Priority</label><strong>{{ selectedTrigger?.priority || '-' }}</strong></div>
|
||||
<div class="field"><label>Final fire</label><strong>{{ formatDateTime(selectedTrigger?.finalFireTime) || 'none' }}</strong></div>
|
||||
<button type="button" class="field field-button" data-roadmap="Timezone metadata is on the roadmap"><label>Timezone</label><strong>Roadmap</strong></button>
|
||||
<div class="field"><label>Repeat interval</label><strong>{{ selectedTrigger?.repeatInterval ? formatDuration(selectedTrigger.repeatInterval) : '-' }}</strong></div>
|
||||
<button type="button" class="field field-button" data-roadmap="Calendar attachment is on the roadmap"><label>Calendar</label><strong>Roadmap</strong></button>
|
||||
</div>
|
||||
<section class="preview"><h4>Schedule summary</h4><div>{{ getSelectedTriggerRepeatSummary() }}. Next fire: {{ formatDateTime(selectedTrigger?.nextFireTime) || 'not available' }}.</div></section>
|
||||
<div class="actions"><button type="button" class="btn" data-roadmap="Trigger pause endpoint is on the roadmap">Pause</button><button type="button" class="btn" (click)="openRescheduleWizard()">Reschedule</button><button type="button" class="btn" data-roadmap="Trigger duplication is on the roadmap">Duplicate</button></div>
|
||||
<div class="danger-zone"><strong>Danger zone</strong><span class="help">Unschedule and reset-error require backend endpoints that are still on the roadmap.</span><div class="actions"><button type="button" class="btn danger" data-roadmap="Unschedule trigger is on the roadmap">Unschedule</button><button type="button" class="btn danger" data-roadmap="Reset error trigger is on the roadmap">Reset Error</button></div></div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page" [class.active]="activePage === 'calendars'">
|
||||
<div class="page-kicker">
|
||||
<div><h2>Calendars</h2><p>Quartz calendar registry, rule editing, trigger usage, and next included time testing are not exposed by the backend yet.</p></div>
|
||||
<div class="toolbar"><input class="search" value="Filter calendars" data-roadmap="Calendar filtering is on the roadmap"><button type="button" class="btn primary" data-roadmap="Calendar creation is on the roadmap">New Calendar</button></div>
|
||||
</div>
|
||||
<section class="card roadmap-card" data-roadmap="Quartz calendar management is on the roadmap">
|
||||
<div class="card-header"><h2 class="card-title">Calendar Registry</h2><span class="chip warn">ROADMAP</span></div>
|
||||
<div class="card-body two-column compact-roadmap">
|
||||
<div>
|
||||
<p class="roadmap-copy">This UI is ready for WeeklyCalendar, HolidayCalendar, MonthlyCalendar, DailyCalendar, and CronCalendar once the API surface is added.</p>
|
||||
<div class="calendar-grid" aria-label="Calendar exclusion preview"><div class="calendar-cell">Mon</div><div class="calendar-cell">Tue</div><div class="calendar-cell">Wed</div><div class="calendar-cell">Thu</div><div class="calendar-cell">Fri</div><div class="calendar-cell excluded">Sat</div><div class="calendar-cell excluded">Sun</div></div>
|
||||
</div>
|
||||
<aside class="filter-panel"><h3>Planned backend</h3><div class="help">List calendars, inspect calendar type/base calendar, attach calendars to triggers, test included time, and edit exclusion rules.</div><button type="button" class="btn" data-roadmap="Calendar trigger usage is on the roadmap">Show Triggers</button><button type="button" class="btn danger" data-roadmap="Calendar deletion is on the roadmap">Delete Calendar</button></aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page" [class.active]="activePage === 'executions'">
|
||||
<div class="page-kicker">
|
||||
<div><h2>Executions</h2><p>Currently executing jobs, fire instance IDs, refire counts, execution history, and interruption by fire instance are roadmap backend features.</p></div>
|
||||
<div class="toolbar"><input class="search" value="Filter running jobs" data-roadmap="Execution filtering is on the roadmap"><button type="button" class="btn" data-roadmap="Execution refresh endpoint is on the roadmap">Refresh</button></div>
|
||||
</div>
|
||||
<section class="card roadmap-card" data-roadmap="Currently executing jobs endpoint is on the roadmap">
|
||||
<div class="card-header"><h2 class="card-title">Currently Executing Jobs</h2><div class="toolbar"><span class="chip warn">ROADMAP</span></div></div>
|
||||
<div class="split">
|
||||
<div class="table-wrap"><table><thead><tr><th>Fire instance</th><th>Job</th><th>Trigger</th><th>Run time</th><th>Node</th></tr></thead><tbody><tr class="selectable" (click)="openDetailDrawer()"><td class="mono">Roadmap</td><td class="mono">{{ getSelectedJobName() }}</td><td class="mono">{{ selectedTriggerKey?.name || '-' }}</td><td class="mono">Roadmap</td><td class="mono">Roadmap</td></tr></tbody></table></div>
|
||||
<aside class="detail drawer detail-drawer" [class.drawer-open]="detailDrawerOpen && activePage === 'executions'" aria-label="Execution detail drawer"><div class="drawer-title"><div><span class="chip warn">ROADMAP</span><h2>Execution Inspector</h2><div class="caption">Live progress remains available through the selected trigger websocket.</div></div><button type="button" class="drawer-close" (click)="closeDetailDrawer()">Close</button></div><div class="progress-card"><div class="caption">Selected trigger progress</div><div class="progress-line"><span [style.width.%]="getProgressPercentage()"></span></div><div class="mono">{{ getProgressLabel() }}</div></div><div class="warning-box"><strong>Interrupt confirmation</strong><span>Interrupt operations need backend support and explicit operator confirmation.</span></div><div class="actions"><button type="button" class="btn danger" data-roadmap="Interrupt by fire instance is on the roadmap">Interrupt Fire Instance</button><button type="button" class="btn danger" data-roadmap="Interrupt by job key is on the roadmap">Interrupt Job Key</button></div></aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page" [class.active]="activePage === 'events'">
|
||||
<div class="page-kicker">
|
||||
<div><h2>Event Stream</h2><p>The current backend exposes per-trigger log and progress websocket topics. Global event aggregation, filters, saved views, and export are roadmap features.</p></div>
|
||||
<div class="toolbar"><input class="search" value="Search messages, job keys, fire ids" data-roadmap="Event searching is on the roadmap"><button type="button" class="btn" data-roadmap="Pause global stream is on the roadmap">Pause Stream</button><button type="button" class="btn" data-roadmap="Export CSV is on the roadmap">Export CSV</button></div>
|
||||
</div>
|
||||
<div class="two-column">
|
||||
<section class="card">
|
||||
<div class="card-header"><h2 class="card-title">Live Events</h2><div class="toolbar"><span class="chip normal">TRIGGER STREAM</span><span class="chip accent">{{ logs.length }} EVENTS</span></div></div>
|
||||
<div class="stream tall-stream">
|
||||
<div class="stream-row"><span>Time</span><span>Severity</span><span>Type</span><span>Source</span><span>Message</span></div>
|
||||
@for (log of logs; track log.time) {
|
||||
<div class="stream-row"><span class="mono">{{ log.time | date:'HH:mm:ss' }}</span><span class="chip" [ngClass]="log.severity === 'ERROR' ? 'danger' : log.severity === 'WARN' ? 'warn' : 'success'">{{ log.severity }}</span><span>{{ log.type }}</span><span class="mono">{{ log.source }}</span><span>{{ log.message }}</span></div>
|
||||
} @empty {
|
||||
<div class="stream-row muted-row"><span class="mono">--</span><span class="chip warn">WAIT</span><span>JOB_LOG</span><span class="mono">{{ selectedTriggerKey?.name || '-' }}</span><span>Select or fire a trigger to receive backend log messages.</span></div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<aside class="filter-panel">
|
||||
<h3>Filters</h3>
|
||||
<div class="control"><label>Severity</label><select class="select" data-roadmap="Severity filtering is on the roadmap"><option>INFO, WARN, ERROR</option></select></div>
|
||||
<div class="control"><label>Event type</label><select class="select" data-roadmap="Event type filtering is on the roadmap"><option>All event types</option></select></div>
|
||||
<div class="control"><label>Job / trigger / group</label><input class="input" [value]="selectedTriggerKey?.name || ''" data-roadmap="Event text filtering is on the roadmap"></div>
|
||||
<section class="preview"><h4>Supported now</h4><div>Per-trigger logs and progress through existing websocket topics.</div></section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page" [class.active]="activePage === 'scheduler'">
|
||||
<div class="page-kicker">
|
||||
<div><h2>Scheduler / Settings</h2><p>Supported lifecycle actions are wired to the backend. Cluster metadata, clear, delayed start, and state analytics are roadmap-gated.</p></div>
|
||||
<div class="toolbar"><span class="chip" [ngClass]="getSchedulerStatusClass()">{{ scheduler?.status || 'LOADING' }}</span><button type="button" class="btn" (click)="refreshScheduler()">Refresh Metadata</button></div>
|
||||
</div>
|
||||
<div class="dashboard-grid">
|
||||
<section class="card span-12">
|
||||
<div class="card-header"><h2 class="card-title">Lifecycle Controls</h2><span class="caption">Global actions affect the scheduler instance</span></div>
|
||||
<div class="card-body command-panel"><div class="command-row"><button type="button" class="btn primary" (click)="startScheduler()">Start</button><button type="button" class="btn" data-roadmap="Delayed start is on the roadmap">Delayed Start 60s</button><button type="button" class="btn" (click)="standbyScheduler()">Standby</button><button type="button" class="btn" (click)="resumeScheduler()">Resume</button><button type="button" class="btn" data-roadmap="Pause all trigger groups is on the roadmap">Pause All</button><button type="button" class="btn danger" (click)="shutdownScheduler()">Shutdown</button><button type="button" class="btn danger" data-roadmap="Clear scheduler is on the roadmap">Clear Scheduler</button></div><div class="warning-box"><strong>Strong confirmation required</strong><span>Shutdown is supported and prompts before calling the backend. Clear remains roadmap-gated.</span></div></div>
|
||||
</section>
|
||||
<section class="card span-8">
|
||||
<div class="card-header"><h2 class="card-title">Scheduler Metadata</h2><span class="chip accent">CURRENT API</span></div>
|
||||
<div class="card-body summary-grid"><div class="field"><label>Scheduler name</label><strong>{{ scheduler?.name || '-' }}</strong></div><div class="field"><label>Instance ID</label><strong>{{ scheduler?.instanceId || '-' }}</strong></div><div class="field"><label>Status</label><strong>{{ scheduler?.status || '-' }}</strong></div><div class="field"><label>Trigger keys</label><strong>{{ triggerKeys.length }}</strong></div><button type="button" class="field field-button" data-roadmap="Quartz version metadata is on the roadmap"><label>Quartz version</label><strong>Roadmap</strong></button><button type="button" class="field field-button" data-roadmap="Thread pool metadata is on the roadmap"><label>Thread pool</label><strong>Roadmap</strong></button><button type="button" class="field field-button" data-roadmap="Job store metadata is on the roadmap"><label>Job store</label><strong>Roadmap</strong></button><button type="button" class="field field-button" data-roadmap="Cluster mode support is on the roadmap"><label>Clustered</label><strong>Roadmap</strong></button></div>
|
||||
</section>
|
||||
<section class="card span-4">
|
||||
<div class="card-header"><h2 class="card-title">Cluster Nodes</h2><span class="chip warn">ROADMAP</span></div>
|
||||
<div class="card-body node-list" data-roadmap="Cluster node visibility is on the roadmap"><div class="node-row"><div><strong class="mono">{{ scheduler?.instanceId || 'local' }}</strong><div class="caption">local scheduler instance</div></div><span class="chip running">LOCAL</span></div><div class="node-row"><div><strong class="mono">remote nodes</strong><div class="caption">not exposed by backend</div></div><span class="chip warn">ROADMAP</span></div></div>
|
||||
</section>
|
||||
<section class="card span-12">
|
||||
<div class="card-header"><h2 class="card-title">Global State Overview</h2><div class="toolbar"><span class="chip normal">{{ triggerKeys.length }} TRIGGERS</span><span class="chip warn">ANALYTICS ROADMAP</span></div></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Area</th><th>Current state</th><th>Count</th><th>Representative key</th><th>Recommended action</th></tr></thead><tbody><tr><td>Scheduler</td><td><span class="chip" [ngClass]="getSchedulerStatusClass()">{{ scheduler?.status || '-' }}</span></td><td class="mono">1</td><td class="mono">{{ scheduler?.instanceId || '-' }}</td><td>Use lifecycle controls above.</td></tr><tr><td>Triggers</td><td><span class="chip normal">LISTED</span></td><td class="mono">{{ triggerKeys.length }}</td><td class="mono">{{ selectedTriggerKey?.name || '-' }}</td><td>Open Triggers for details or reschedule SimpleTriggers.</td></tr><tr data-roadmap="Misfire and error trigger analytics are on the roadmap"><td>Misfires / errors</td><td><span class="chip warn">ROADMAP</span></td><td class="mono">Roadmap</td><td class="mono">Roadmap</td><td>Backend analytics needed.</td></tr></tbody></table></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@if (wizardOpen || detailDrawerOpen) {
|
||||
<button type="button" class="drawer-backdrop" aria-label="Close drawer" (click)="closeDrawers()"></button>
|
||||
}
|
||||
|
||||
@if (roadmapNotice || operationNotice || operationError) {
|
||||
<section class="toast-overlay" [class.error]="operationError" [class.success]="operationNotice && !operationError">
|
||||
<div class="toast-kicker">{{ operationError ? 'Action failed' : roadmapNotice ? 'Roadmap reminder' : 'Updated' }}</div>
|
||||
<div class="toast-message">{{ operationError || roadmapNotice || operationNotice }}</div>
|
||||
<button type="button" class="toast-close" (click)="dismissNotice()">Dismiss</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
<aside class="wizard drawer" [class.drawer-open]="wizardOpen" aria-label="Trigger creation wizard">
|
||||
<div class="wizard-header"><div><h2>{{ getWizardTitle() }}</h2><div class="caption">SimpleTrigger is supported now. Other trigger types are roadmap-gated.</div></div><button type="button" class="drawer-close" (click)="closeWizardDrawer()">Close</button></div>
|
||||
<div class="stepper"><div class="step done"><span></span><span>Identity</span></div><div class="step active"><span></span><span>Type</span></div><div class="step done"><span></span><span>Schedule</span></div><div class="step done"><span></span><span>Advanced</span></div><div class="step active"><span></span><span>Preview</span></div></div>
|
||||
<form class="wizard-form" (ngSubmit)="submitTriggerWizard()">
|
||||
<div class="wizard-scroll">
|
||||
@if (wizardError) { <div class="warning-box"><strong>Unable to save</strong><span>{{ wizardError }}</span></div> }
|
||||
<section class="form-card"><h3>Identity</h3><div class="form-section"><div class="control"><label>Trigger key</label><div class="input-row"><input class="input" name="triggerName" [(ngModel)]="triggerDraft.triggerName" [readonly]="wizardMode === 'edit'" required><input class="input mono" name="group" [(ngModel)]="triggerDraft.group" readonly data-roadmap="Trigger group editing is on the roadmap"></div><div class="help">The current backend schedules SimpleTriggers by name. Group editing is tracked in the roadmap.</div></div><div class="control"><label>Target job</label><select class="select" name="jobClass" [(ngModel)]="triggerDraft.jobClass" required>@for (job of jobs; track job) { <option [value]="job">{{ job }}</option> }</select><div class="help">Loaded from GET /quartz-manager/jobs.</div></div></div></section>
|
||||
<section class="form-card"><h3>Trigger Type</h3><div class="form-section"><div class="radio-grid"><div class="type-option active"><strong>Simple</strong><span class="help">Repeat every fixed interval. Supported now.</span></div><button type="button" class="type-option" data-roadmap="CronTrigger support is on the roadmap"><strong>Cron</strong><span class="help">Calendar expression builder.</span></button><button type="button" class="type-option" data-roadmap="DailyTimeIntervalTrigger support is on the roadmap"><strong>Daily Time</strong><span class="help">Run in a daily time window.</span></button><button type="button" class="type-option" data-roadmap="CalendarIntervalTrigger support is on the roadmap"><strong>Calendar Interval</strong><span class="help">Every N days, weeks, months.</span></button></div></div></section>
|
||||
<section class="form-card"><h3>Schedule Editor</h3><div class="form-section"><div class="control"><label>Start</label><input class="input mono" type="datetime-local" name="startDate" [(ngModel)]="triggerDraft.startDate"></div><div class="control"><label>Repeat interval</label><div class="input-row"><input class="input mono" type="number" min="1" name="repeatIntervalAmount" [(ngModel)]="triggerDraft.repeatIntervalAmount" required><select class="select" name="repeatIntervalUnit" [(ngModel)]="triggerDraft.repeatIntervalUnit"><option value="milliseconds">milliseconds</option><option value="seconds">seconds</option><option value="minutes">minutes</option><option value="hours">hours</option><option value="days">days</option></select></div><div class="help">The UI edits operational units and persists the current backend repeatInterval in milliseconds.</div></div><div class="control"><label>Repeat count</label><input class="input mono" type="number" name="repeatCount" [(ngModel)]="triggerDraft.repeatCount" required><div class="help">Use -1 to repeat indefinitely.</div></div><div class="control"><label>End</label><input class="input mono" type="datetime-local" name="endDate" [(ngModel)]="triggerDraft.endDate"></div></div></section>
|
||||
<section class="form-card"><h3>Advanced</h3><div class="form-section"><div class="control"><label>Misfire policy</label><select class="select" name="misfireInstruction" [(ngModel)]="triggerDraft.misfireInstruction" required><option value="MISFIRE_INSTRUCTION_FIRE_NOW">MISFIRE_INSTRUCTION_FIRE_NOW</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT">RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT">RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT">RESCHEDULE_NEXT_WITH_REMAINING_COUNT</option><option value="MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_EXISTING_COUNT">RESCHEDULE_NEXT_WITH_EXISTING_COUNT</option></select></div><div class="control"><label>Job data map override</label><textarea class="textarea mono" readonly data-roadmap="JobDataMap editing is on the roadmap">{}</textarea></div></div></section>
|
||||
<section class="preview"><h4>Plain-language summary</h4><div>Run <strong>{{ shortClassName(triggerDraft.jobClass) || 'selected job' }}</strong> every <strong>{{ triggerDraft.repeatIntervalAmount }} {{ triggerDraft.repeatIntervalUnit }}</strong>, starting at <strong>{{ triggerDraft.startDate || 'backend default start time' }}</strong>.</div><div class="fire-list">@for (fireTime of getFirePreview(); track fireTime) { <span>{{ fireTime }}</span> }</div></section>
|
||||
<div class="warning-box"><strong>Backend support boundary</strong><span>Only SimpleTrigger create/reschedule is submitted. Trigger groups, calendars, job data maps, and other trigger families will show roadmap reminders.</span></div>
|
||||
</div>
|
||||
<div class="wizard-footer"><button type="button" class="btn" (click)="resetWizard()">Reset</button><button type="submit" class="btn primary" [disabled]="wizardSubmitting || !canSubmitTrigger()">{{ wizardSubmitting ? 'Saving...' : getWizardCta() }}</button></div>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,347 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
--bg: oklch(98% 0.005 250);
|
||||
--surface: oklch(100% 0 0);
|
||||
--fg: oklch(22% 0.02 240);
|
||||
--muted: oklch(50% 0.018 240);
|
||||
--border: oklch(90% 0.008 240);
|
||||
--accent: oklch(56% 0.19 302);
|
||||
--success: oklch(58% 0.16 145);
|
||||
--warning: oklch(72% 0.15 82);
|
||||
--danger: oklch(58% 0.19 28);
|
||||
--info: oklch(58% 0.18 255);
|
||||
--radius: 8px;
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
color: var(--fg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#manager-content-container {
|
||||
height: calc(100% - 80px);
|
||||
max-height: calc(100% - 80px);
|
||||
* { box-sizing: border-box; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
|
||||
.qm-app {
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(780px, 1fr);
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.qm-app.object-mode { grid-template-columns: 248px minmax(780px, 1fr); }
|
||||
|
||||
.rail {
|
||||
border-right: 1px solid var(--border);
|
||||
background: oklch(99% 0.003 250);
|
||||
padding: 18px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 8px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-title { font-weight: 700; font-size: 14px; line-height: 1.15; }
|
||||
.brand-subtitle, .caption, .help { color: var(--muted); font-size: 12px; }
|
||||
.brand-subtitle, .caption, .mono, .kv span:last-child, .chip, .card-title, .field strong, .code-block, .fire-list { font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; }
|
||||
.caption { font-size: 11px; }
|
||||
|
||||
.nav { display: flex; flex-direction: column; gap: 3px; }
|
||||
.nav button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
padding: 9px 10px;
|
||||
border-radius: 7px;
|
||||
text-align: left;
|
||||
}
|
||||
.nav button.active {
|
||||
background: oklch(56% 0.19 302 / 0.10);
|
||||
color: var(--fg);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
.nav svg { width: 17px; height: 17px; stroke-width: 1.8; }
|
||||
|
||||
.rail-card {
|
||||
margin-top: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
padding: 12px;
|
||||
}
|
||||
.rail-card h3, .filter-panel h3 {
|
||||
margin: 0 0 7px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace;
|
||||
font-weight: 700;
|
||||
}
|
||||
.connection { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size: 12px; }
|
||||
|
||||
.main { min-width: 0; display: flex; flex-direction: column; }
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
min-height: 60px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: oklch(99% 0.002 250 / 0.92);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.scheduler-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; min-width: 0; }
|
||||
.scheduler-title { min-width: 210px; }
|
||||
h1 { margin: 0; font-size: 21px; font-weight: 700; letter-spacing: 0; }
|
||||
h2 { margin: 0; }
|
||||
.kv { display: grid; gap: 2px; min-width: 118px; border: 0; background: transparent; padding: 0; color: inherit; text-align: left; }
|
||||
.kv span:first-child { color: var(--muted); font-size: 11px; }
|
||||
.kv span:last-child { font-size: 12px; white-space: nowrap; }
|
||||
.kv-button span:last-child { color: var(--warning); }
|
||||
|
||||
.actions, .toolbar, .command-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.compact-actions { gap: 7px; }
|
||||
.btn {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
padding: 8px 11px;
|
||||
min-height: 36px;
|
||||
background: var(--surface);
|
||||
color: var(--fg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
|
||||
.btn.compact { min-height: 32px; padding: 6px 10px; font-size: 12px; }
|
||||
.btn.danger { color: var(--danger); border-color: oklch(58% 0.19 28 / 0.35); background: oklch(58% 0.19 28 / 0.06); }
|
||||
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
.toast-overlay {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
z-index: 90;
|
||||
width: min(460px, calc(100vw - 36px));
|
||||
padding: 16px 46px 16px 16px;
|
||||
border: 1px solid oklch(72% 0.15 82 / 0.55);
|
||||
border-left: 5px solid var(--warning);
|
||||
background: oklch(99% 0.02 82);
|
||||
color: var(--fg);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 22px 60px oklch(22% 0.02 240 / 0.20);
|
||||
}
|
||||
.toast-overlay.success { border-color: oklch(58% 0.16 145 / 0.36); border-left-color: var(--success); background: oklch(98% 0.02 145); }
|
||||
.toast-overlay.error { border-color: oklch(58% 0.19 28 / 0.40); border-left-color: var(--danger); background: oklch(98% 0.02 28); }
|
||||
.toast-kicker { font: 800 12px 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; letter-spacing: 0.05em; text-transform: uppercase; }
|
||||
.toast-message { margin-top: 6px; line-height: 1.45; }
|
||||
.toast-close { position: absolute; top: 10px; right: 10px; border: 0; background: transparent; color: var(--muted); }
|
||||
|
||||
.content { padding: 18px 20px 22px; display: grid; gap: 16px; }
|
||||
.page { display: none; }
|
||||
.page.active { display: grid; gap: 16px; }
|
||||
.page-kicker { display: flex; justify-content: space-between; align-items: flex-end; gap: 14px; margin-bottom: 2px; }
|
||||
.page-kicker h2 { font-size: 19px; }
|
||||
.page-kicker p { margin: 4px 0 0; max-width: 760px; color: var(--muted); font-size: 13px; }
|
||||
|
||||
.dashboard-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 14px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); min-width: 0; overflow: hidden; }
|
||||
.card-header { min-height: 48px; padding: 12px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.card-title { font-size: 12px; text-transform: uppercase; color: var(--muted); font-weight: 700; }
|
||||
.card-body { padding: 14px; }
|
||||
.span-3 { grid-column: span 3; }
|
||||
.span-4 { grid-column: span 4; }
|
||||
.span-5 { grid-column: span 5; }
|
||||
.span-7 { grid-column: span 7; }
|
||||
.span-8 { grid-column: span 8; }
|
||||
.span-12 { grid-column: span 12; }
|
||||
|
||||
.scheduler-command-grid { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr); gap: 14px; align-items: stretch; }
|
||||
.command-panel { display: grid; gap: 12px; }
|
||||
.metadata-grid, .summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||||
.summary-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
|
||||
.metric { display: grid; gap: 7px; min-height: 112px; }
|
||||
.metric-value { font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size: 27px; font-weight: 720; font-variant-numeric: tabular-nums; }
|
||||
.compact-metric { font-size: 22px; }
|
||||
.metric-label { color: var(--muted); font-size: 12px; }
|
||||
.metric-line { height: 5px; border-radius: 999px; background: var(--border); overflow: hidden; margin-top: auto; }
|
||||
.metric-line > span { display: block; height: 100%; background: var(--success); width: var(--w); }
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
}
|
||||
.chip::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.chip.running, .chip.normal, .chip.success { color: var(--success); background: oklch(58% 0.16 145 / 0.08); border-color: oklch(58% 0.16 145 / 0.25); }
|
||||
.chip.paused, .chip.warn { color: var(--warning); background: oklch(72% 0.15 82 / 0.12); border-color: oklch(72% 0.15 82 / 0.30); }
|
||||
.chip.error, .chip.danger { color: var(--danger); background: oklch(58% 0.19 28 / 0.08); border-color: oklch(58% 0.19 28 / 0.25); }
|
||||
.chip.blocked { color: var(--info); background: oklch(58% 0.18 255 / 0.08); border-color: oklch(58% 0.18 255 / 0.25); }
|
||||
.chip.accent { color: var(--accent); background: oklch(56% 0.19 302 / 0.08); border-color: oklch(56% 0.19 302 / 0.25); }
|
||||
|
||||
.table-wrap { overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 12px; }
|
||||
th, td { border-bottom: 1px solid var(--border); padding: 10px; text-align: left; vertical-align: middle; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
th { color: var(--muted); font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-weight: 650; background: oklch(98% 0.004 250); }
|
||||
.selectable:hover { background: oklch(56% 0.19 302 / 0.035); }
|
||||
tr.selected { background: oklch(56% 0.19 302 / 0.06); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
|
||||
.split { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 420px; }
|
||||
.object-mode .split { grid-template-columns: minmax(0, 1fr); }
|
||||
.detail { background: oklch(99% 0.003 250); padding: 14px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.detail h2 { font-size: 17px; }
|
||||
.drawer-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }
|
||||
.drawer-close { border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--muted); padding: 6px 10px; font-size: 12px; }
|
||||
.drawer-backdrop { position: fixed; inset: 0; z-index: 70; border: 0; background: oklch(22% 0.02 240 / 0.32); backdrop-filter: blur(2px); }
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 80;
|
||||
width: min(460px, 100vw);
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
overflow: auto;
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -24px 0 70px oklch(22% 0.02 240 / 0.22);
|
||||
transform: translateX(104%);
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
.drawer.drawer-open { transform: translateX(0); }
|
||||
.detail-drawer { width: min(430px, 100vw); }
|
||||
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); overflow-x: auto; }
|
||||
.tab { padding: 8px 9px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--muted); font-size: 12px; white-space: nowrap; }
|
||||
.tab.active { color: var(--fg); border-color: var(--accent); }
|
||||
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.field { display: grid; gap: 4px; padding: 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); min-width: 0; text-align: left; color: inherit; }
|
||||
.field-button { cursor: pointer; }
|
||||
.field label { color: var(--muted); font-size: 11px; }
|
||||
.field strong { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.progress-card { display: grid; gap: 10px; }
|
||||
.progress-line { height: 8px; border-radius: 999px; background: var(--border); overflow: hidden; }
|
||||
.progress-line span { display: block; height: 100%; background: var(--success); }
|
||||
.preview { display: grid; gap: 9px; padding: 13px; border-radius: var(--radius); background: oklch(56% 0.19 302 / 0.07); border: 1px solid oklch(56% 0.19 302 / 0.18); }
|
||||
.preview h4 { margin: 0; font-size: 13px; }
|
||||
.fire-list { display: grid; gap: 5px; font-size: 12px; }
|
||||
.warning-box, .danger-zone { border: 1px solid oklch(58% 0.19 28 / 0.30); background: oklch(58% 0.19 28 / 0.07); border-radius: 7px; padding: 10px; display: grid; gap: 5px; }
|
||||
.warning-box strong, .danger-zone strong { color: var(--danger); font-size: 12px; }
|
||||
.danger-zone { border-radius: var(--radius); padding: 12px; }
|
||||
.code-block { margin: 0; padding: 10px; border: 1px solid var(--border); border-radius: 7px; background: oklch(97% 0.006 250); font-size: 12px; overflow: auto; white-space: pre-wrap; }
|
||||
|
||||
.stream { display: grid; grid-template-columns: 1fr; gap: 0; max-height: 310px; overflow: auto; }
|
||||
.tall-stream { max-height: 560px; }
|
||||
.stream-row { display: grid; grid-template-columns: 92px 78px 112px 140px 1fr; gap: 10px; align-items: center; padding: 9px 12px; border-bottom: 1px solid var(--border); font-size: 12px; }
|
||||
.stream-row:first-child { background: oklch(98% 0.004 250); color: var(--muted); font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-weight: 650; position: sticky; top: 0; z-index: 1; }
|
||||
.muted-row { color: var(--muted); }
|
||||
.search { min-width: 220px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); height: 32px; padding: 0 12px; color: var(--muted); font-size: 12px; }
|
||||
|
||||
.mini-chart { height: 154px; display: grid; grid-template-columns: repeat(18, 1fr); align-items: end; gap: 5px; border-bottom: 1px solid var(--border); padding-top: 18px; }
|
||||
.bar { background: color-mix(in oklch, var(--success), white 38%); border-radius: 4px 4px 0 0; height: var(--h); min-height: 12px; }
|
||||
.bar.warn { background: color-mix(in oklch, var(--warning), white 35%); }
|
||||
.bar.error { background: color-mix(in oklch, var(--danger), white 35%); }
|
||||
.top-space { margin-top: 14px; }
|
||||
|
||||
.two-column { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 14px; }
|
||||
.filter-panel { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); padding: 12px; display: grid; gap: 12px; align-content: start; }
|
||||
.control { display: grid; gap: 6px; }
|
||||
.control label { font-size: 12px; color: var(--muted); }
|
||||
.input, .select, .textarea { width: 100%; border: 1px solid var(--border); border-radius: 6px; background: oklch(99% 0.002 250); min-height: 38px; padding: 8px 10px; color: var(--fg); outline: none; }
|
||||
.textarea { min-height: 70px; resize: vertical; }
|
||||
|
||||
.calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; }
|
||||
.calendar-cell { min-height: 44px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 6px; font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; font-size: 11px; color: var(--muted); }
|
||||
.calendar-cell.excluded { color: var(--danger); background: oklch(58% 0.19 28 / 0.06); border-color: oklch(58% 0.19 28 / 0.25); }
|
||||
.roadmap-copy { margin: 0 0 14px; color: var(--muted); }
|
||||
.compact-roadmap { align-items: start; }
|
||||
.node-list { display: grid; gap: 8px; }
|
||||
.node-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; padding: 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); }
|
||||
|
||||
.wizard { background: oklch(99% 0.002 250); display: flex; flex-direction: column; width: min(460px, 100vw); }
|
||||
.wizard-header { min-height: 76px; padding: 16px 18px; border-bottom: 1px solid var(--border); background: var(--surface); display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.wizard-header h2 { font-size: 17px; }
|
||||
.stepper { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 7px; padding: 14px 18px; border-bottom: 1px solid var(--border); }
|
||||
.step { display: grid; gap: 5px; color: var(--muted); font-size: 11px; }
|
||||
.step span:first-child { height: 4px; border-radius: 999px; background: var(--border); }
|
||||
.step.done span:first-child, .step.active span:first-child { background: var(--accent); }
|
||||
.step.active { color: var(--fg); font-weight: 650; }
|
||||
.wizard-form { display: flex; flex-direction: column; min-height: 0; flex: 1; }
|
||||
.wizard-scroll { padding: 16px 18px; overflow: auto; display: grid; gap: 14px; }
|
||||
.form-card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); overflow: hidden; }
|
||||
.form-card h3 { margin: 0; padding: 12px 13px; border-bottom: 1px solid var(--border); font-size: 12px; font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace; color: var(--muted); text-transform: uppercase; }
|
||||
.form-section { padding: 13px; display: grid; gap: 12px; }
|
||||
.input-row { display: grid; grid-template-columns: 1fr 118px; gap: 8px; }
|
||||
.radio-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.type-option { border: 1px solid var(--border); border-radius: 7px; padding: 10px; display: grid; gap: 4px; background: oklch(99% 0.002 250); text-align: left; color: inherit; }
|
||||
.type-option.active { border-color: oklch(56% 0.19 302 / 0.55); box-shadow: inset 0 0 0 1px oklch(56% 0.19 302 / 0.22); background: oklch(56% 0.19 302 / 0.06); }
|
||||
.type-option strong { font-size: 12px; }
|
||||
.wizard-footer { margin-top: auto; display: flex; justify-content: space-between; gap: 8px; padding: 14px 18px; border-top: 1px solid var(--border); background: var(--surface); }
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.qm-app { grid-template-columns: 78px minmax(680px, 1fr); }
|
||||
.qm-app.object-mode { grid-template-columns: 78px minmax(680px, 1fr); }
|
||||
.brand-title, .brand-subtitle, .nav span, .rail-card { display: none; }
|
||||
.rail { align-items: center; }
|
||||
.nav button { justify-content: center; }
|
||||
.brand { padding-inline: 0; border-bottom: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.qm-app, .qm-app.object-mode { grid-template-columns: 1fr; }
|
||||
.rail { position: sticky; top: 0; z-index: 5; border-right: 0; border-bottom: 1px solid var(--border); flex-direction: row; align-items: center; overflow-x: auto; padding: 10px; }
|
||||
.brand-title, .brand-subtitle, .nav span { display: block; }
|
||||
.brand { border-bottom: 0; padding: 0; min-width: 190px; }
|
||||
.nav { flex-direction: row; }
|
||||
.topbar, .page-kicker, .scheduler-command-grid, .two-column, .split, .object-mode .split { grid-template-columns: 1fr; }
|
||||
.drawer { width: min(420px, 100vw); }
|
||||
.span-3, .span-4, .span-5, .span-7, .span-8, .span-12 { grid-column: span 12; }
|
||||
.metadata-grid, .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.content { padding: 12px; }
|
||||
.dashboard-grid { grid-template-columns: 1fr; }
|
||||
.span-3, .span-4, .span-5, .span-7, .span-8, .span-12 { grid-column: span 1; }
|
||||
.metadata-grid, .summary-grid, .field-grid, .radio-grid, .input-row { grid-template-columns: 1fr; }
|
||||
.stream-row { grid-template-columns: 1fr; gap: 4px; }
|
||||
.toast-overlay { top: 10px; right: 10px; width: calc(100vw - 20px); }
|
||||
.page-kicker { align-items: stretch; }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core';
|
||||
import { SimpleTrigger } from '../../model/simple-trigger.model';
|
||||
import { TriggerKey } from '../../model/triggerKey.model';
|
||||
import { SimpleTriggerConfigComponent } from '../../components/simple-trigger-config';
|
||||
import { TriggerListComponent } from '../../components';
|
||||
import {Component, NgZone, OnDestroy, OnInit} from '@angular/core';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {map} from 'rxjs/operators';
|
||||
|
||||
import {SchedulerService, TriggerService} from '../../services';
|
||||
import JobService from '../../services/job.service';
|
||||
import {LogsRxWebsocketService} from '../../services/logs.rx-websocket.service';
|
||||
import {ProgressRxWebsocketService} from '../../services/progress.rx-websocket.service';
|
||||
import {Scheduler} from '../../model/scheduler.model';
|
||||
import {SimpleTriggerCommand} from '../../model/simple-trigger.command';
|
||||
import {SimpleTrigger} from '../../model/simple-trigger.model';
|
||||
import {TriggerKey} from '../../model/triggerKey.model';
|
||||
import TriggerFiredBundle from '../../model/trigger-fired-bundle.model';
|
||||
|
||||
type ConsolePage = 'dashboard' | 'jobs' | 'triggers' | 'calendars' | 'executions' | 'events' | 'scheduler';
|
||||
type WizardMode = 'create' | 'edit';
|
||||
|
||||
interface ConsoleLogRecord {
|
||||
time: Date;
|
||||
severity: string;
|
||||
type: string;
|
||||
source: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface TriggerDraft {
|
||||
triggerName: string;
|
||||
group: string;
|
||||
jobClass: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
repeatIntervalAmount: number;
|
||||
repeatIntervalUnit: string;
|
||||
repeatCount: number;
|
||||
misfireInstruction: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'manager',
|
||||
@@ -10,63 +41,645 @@ import { TriggerListComponent } from '../../components';
|
||||
styleUrls: ['./manager.component.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class ManagerComponent implements OnInit, AfterViewInit {
|
||||
@ViewChild(SimpleTriggerConfigComponent)
|
||||
private triggerConfigComponent!: SimpleTriggerConfigComponent;
|
||||
export class ManagerComponent implements OnInit, OnDestroy {
|
||||
|
||||
@ViewChild(TriggerListComponent)
|
||||
private triggerListComponent: TriggerListComponent;
|
||||
|
||||
newTriggerFormOpened = false;
|
||||
readonly roadmapMessage = 'This feature is not supported by the current backend yet. '
|
||||
+ 'It is tracked in the Quartz Manager roadmap and will come with a future release.';
|
||||
|
||||
activePage: ConsolePage = 'dashboard';
|
||||
scheduler: Scheduler;
|
||||
schedulerLoading = false;
|
||||
triggerKeys: TriggerKey[] = [];
|
||||
triggerDetailsByName: {[triggerName: string]: SimpleTrigger} = {};
|
||||
selectedTriggerKey: TriggerKey;
|
||||
selectedTrigger: SimpleTrigger;
|
||||
selectedJobClass: string;
|
||||
jobs: string[] = [];
|
||||
logs: ConsoleLogRecord[] = [];
|
||||
progress: TriggerFiredBundle;
|
||||
roadmapNotice: string;
|
||||
operationNotice: string;
|
||||
operationError: string;
|
||||
triggerLoading = false;
|
||||
wizardMode: WizardMode = 'create';
|
||||
wizardOpen = false;
|
||||
detailDrawerOpen = false;
|
||||
wizardSubmitting = false;
|
||||
wizardError: string;
|
||||
triggerDraft: TriggerDraft = this.buildEmptyDraft();
|
||||
|
||||
monitoredTriggerKey: TriggerKey;
|
||||
private readonly roadmapPages = new Set<ConsolePage>(['calendars', 'executions']);
|
||||
private readonly subscriptions: Subscription[] = [];
|
||||
private logsSubscription: Subscription;
|
||||
private progressSubscription: Subscription;
|
||||
private noticeTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
private pendingNewTriggerRequest = false;
|
||||
constructor(
|
||||
private schedulerService: SchedulerService,
|
||||
private triggerService: TriggerService,
|
||||
private jobService: JobService,
|
||||
private logsRxWebsocketService: LogsRxWebsocketService,
|
||||
private progressRxWebsocketService: ProgressRxWebsocketService,
|
||||
private ngZone: NgZone
|
||||
) {}
|
||||
|
||||
constructor() {}
|
||||
ngOnInit() {
|
||||
this.refreshScheduler();
|
||||
this.fetchTriggers();
|
||||
this.fetchJobs();
|
||||
}
|
||||
|
||||
ngOnInit() {}
|
||||
|
||||
ngAfterViewInit() {
|
||||
if (this.pendingNewTriggerRequest) {
|
||||
queueMicrotask(() => this.openNewTriggerForm());
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.forEach(subscription => subscription.unsubscribe());
|
||||
this.unsubscribeFromTriggerTopics();
|
||||
if (this.noticeTimer) {
|
||||
clearTimeout(this.noticeTimer);
|
||||
}
|
||||
}
|
||||
|
||||
onNewTriggerRequested() {
|
||||
this.selectedTriggerKey = null;
|
||||
this.monitoredTriggerKey = null;
|
||||
if (this.triggerConfigComponent) {
|
||||
this.openNewTriggerForm();
|
||||
} else {
|
||||
this.pendingNewTriggerRequest = true;
|
||||
selectPage(page: ConsolePage) {
|
||||
this.activePage = page;
|
||||
this.closeDrawers();
|
||||
if (this.roadmapPages.has(page)) {
|
||||
this.showRoadmapNotice(`${this.getPageTitle(page)} is on the Quartz Manager roadmap`);
|
||||
}
|
||||
}
|
||||
|
||||
private openNewTriggerForm() {
|
||||
this.newTriggerFormOpened = true;
|
||||
this.pendingNewTriggerRequest = false;
|
||||
this.triggerConfigComponent.openNewTriggerForm();
|
||||
jumpToScheduler() {
|
||||
this.selectPage('scheduler');
|
||||
}
|
||||
|
||||
onNewTriggerCreated(newTrigger: SimpleTrigger) {
|
||||
this.triggerListComponent.onNewTrigger(newTrigger);
|
||||
this.newTriggerFormOpened = false;
|
||||
handleConsoleClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const roadmapElement = target.closest('[data-roadmap]') as HTMLElement;
|
||||
if (!roadmapElement) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.showRoadmapNotice(roadmapElement.getAttribute('data-roadmap'));
|
||||
}
|
||||
|
||||
setSelectedTrigger(triggerKey: TriggerKey) {
|
||||
this.selectedTriggerKey = triggerKey;
|
||||
this.monitoredTriggerKey = triggerKey;
|
||||
this.newTriggerFormOpened = false;
|
||||
showRoadmapNotice(feature?: string) {
|
||||
this.operationError = null;
|
||||
this.operationNotice = null;
|
||||
this.roadmapNotice = feature ? `${feature}. ${this.roadmapMessage}` : this.roadmapMessage;
|
||||
if (this.noticeTimer) {
|
||||
clearTimeout(this.noticeTimer);
|
||||
}
|
||||
this.noticeTimer = setTimeout(() => this.roadmapNotice = null, 8000);
|
||||
}
|
||||
|
||||
monitorTrigger(triggerKey: TriggerKey) {
|
||||
this.monitoredTriggerKey = triggerKey;
|
||||
dismissNotice() {
|
||||
this.roadmapNotice = null;
|
||||
this.operationNotice = null;
|
||||
this.operationError = null;
|
||||
this.wizardError = null;
|
||||
}
|
||||
|
||||
setNewTriggerFormOpened(opened: boolean) {
|
||||
this.newTriggerFormOpened = opened;
|
||||
openDetailDrawer() {
|
||||
this.detailDrawerOpen = true;
|
||||
this.wizardOpen = false;
|
||||
}
|
||||
|
||||
closeDetailDrawer() {
|
||||
this.detailDrawerOpen = false;
|
||||
}
|
||||
|
||||
closeWizardDrawer() {
|
||||
this.wizardOpen = false;
|
||||
}
|
||||
|
||||
closeDrawers() {
|
||||
this.detailDrawerOpen = false;
|
||||
this.wizardOpen = false;
|
||||
}
|
||||
|
||||
refreshScheduler() {
|
||||
this.schedulerLoading = true;
|
||||
const subscription = this.schedulerService.getScheduler().subscribe({
|
||||
next: scheduler => {
|
||||
this.scheduler = scheduler;
|
||||
this.schedulerLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.schedulerLoading = false;
|
||||
this.operationError = 'Unable to load scheduler metadata.';
|
||||
}
|
||||
});
|
||||
this.subscriptions.push(subscription);
|
||||
}
|
||||
|
||||
startScheduler() {
|
||||
const subscription = this.schedulerService.startScheduler().subscribe({
|
||||
next: () => this.setSchedulerStatus('RUNNING', 'Scheduler started.'),
|
||||
error: () => this.operationError = 'Unable to start the scheduler.'
|
||||
});
|
||||
this.subscriptions.push(subscription);
|
||||
}
|
||||
|
||||
standbyScheduler() {
|
||||
const subscription = this.schedulerService.pauseScheduler().subscribe({
|
||||
next: () => this.setSchedulerStatus('PAUSED', 'Scheduler moved to standby.'),
|
||||
error: () => this.operationError = 'Unable to move the scheduler to standby.'
|
||||
});
|
||||
this.subscriptions.push(subscription);
|
||||
}
|
||||
|
||||
resumeScheduler() {
|
||||
const subscription = this.schedulerService.resumeScheduler().subscribe({
|
||||
next: () => this.setSchedulerStatus('RUNNING', 'Scheduler resumed.'),
|
||||
error: () => this.operationError = 'Unable to resume the scheduler.'
|
||||
});
|
||||
this.subscriptions.push(subscription);
|
||||
}
|
||||
|
||||
shutdownScheduler() {
|
||||
if (!window.confirm('Shutdown the scheduler instance?')) {
|
||||
return;
|
||||
}
|
||||
const subscription = this.schedulerService.stopScheduler().subscribe({
|
||||
next: () => this.setSchedulerStatus('STOPPED', 'Scheduler shut down.'),
|
||||
error: () => this.operationError = 'Unable to shut down the scheduler.'
|
||||
});
|
||||
this.subscriptions.push(subscription);
|
||||
}
|
||||
|
||||
toggleStandby() {
|
||||
if (this.scheduler?.status === 'PAUSED') {
|
||||
this.resumeScheduler();
|
||||
return;
|
||||
}
|
||||
this.standbyScheduler();
|
||||
}
|
||||
|
||||
fetchTriggers() {
|
||||
const subscription = this.triggerService.fetchTriggers().subscribe({
|
||||
next: triggerKeys => {
|
||||
this.triggerKeys = triggerKeys || [];
|
||||
this.fetchTriggerDetails(this.triggerKeys);
|
||||
if (this.triggerKeys.length > 0) {
|
||||
this.selectTrigger(this.selectedTriggerKey || this.triggerKeys[0], false);
|
||||
} else {
|
||||
this.resetWizard();
|
||||
}
|
||||
},
|
||||
error: () => this.operationError = 'Unable to load triggers.'
|
||||
});
|
||||
this.subscriptions.push(subscription);
|
||||
}
|
||||
|
||||
fetchJobs() {
|
||||
const subscription = this.jobService.fetchJobs().subscribe({
|
||||
next: jobs => {
|
||||
this.jobs = jobs || [];
|
||||
this.selectedJobClass = this.jobs[0];
|
||||
if (!this.triggerDraft.jobClass && this.jobs.length > 0) {
|
||||
this.triggerDraft.jobClass = this.jobs[0];
|
||||
}
|
||||
},
|
||||
error: () => this.operationError = 'Unable to load eligible job classes.'
|
||||
});
|
||||
this.subscriptions.push(subscription);
|
||||
}
|
||||
|
||||
fetchTriggerDetails(triggerKeys: TriggerKey[]) {
|
||||
triggerKeys.forEach(triggerKey => {
|
||||
const subscription = this.schedulerService.getSimpleTriggerConfig(triggerKey.name).subscribe({
|
||||
next: trigger => this.triggerDetailsByName[triggerKey.name] = trigger as SimpleTrigger,
|
||||
error: () => {
|
||||
this.triggerDetailsByName[triggerKey.name] = null;
|
||||
}
|
||||
});
|
||||
this.subscriptions.push(subscription);
|
||||
});
|
||||
}
|
||||
|
||||
selectTrigger(triggerKey: TriggerKey, openDrawer = true) {
|
||||
if (!triggerKey?.name) {
|
||||
return;
|
||||
}
|
||||
this.selectedTriggerKey = {...triggerKey};
|
||||
if (openDrawer) {
|
||||
this.openDetailDrawer();
|
||||
}
|
||||
this.triggerLoading = true;
|
||||
this.selectedTrigger = this.triggerDetailsByName[triggerKey.name] || null;
|
||||
this.subscribeToTriggerTopics(this.selectedTriggerKey);
|
||||
const subscription = this.schedulerService.getSimpleTriggerConfig(triggerKey.name).subscribe({
|
||||
next: trigger => {
|
||||
this.selectedTrigger = trigger as SimpleTrigger;
|
||||
this.triggerDetailsByName[triggerKey.name] = trigger as SimpleTrigger;
|
||||
this.triggerLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.triggerLoading = false;
|
||||
this.showRoadmapNotice('Only SimpleTrigger details are supported by the current backend');
|
||||
}
|
||||
});
|
||||
this.subscriptions.push(subscription);
|
||||
}
|
||||
|
||||
selectJob(jobClass: string) {
|
||||
this.selectedJobClass = jobClass;
|
||||
this.openDetailDrawer();
|
||||
}
|
||||
|
||||
openCreateTriggerWizard() {
|
||||
this.resetWizard();
|
||||
this.wizardOpen = true;
|
||||
this.detailDrawerOpen = false;
|
||||
this.selectPage('dashboard');
|
||||
this.wizardOpen = true;
|
||||
}
|
||||
|
||||
openRescheduleWizard(triggerKey?: TriggerKey) {
|
||||
if (triggerKey) {
|
||||
this.selectTrigger(triggerKey, false);
|
||||
}
|
||||
if (!this.selectedTrigger && !this.selectedTriggerKey) {
|
||||
this.showRoadmapNotice('Reschedule requires a SimpleTrigger loaded from the backend');
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = this.selectedTrigger || this.triggerDetailsByName[this.selectedTriggerKey.name];
|
||||
const repeatInterval = this.splitRepeatInterval(trigger?.repeatInterval || 60000);
|
||||
this.wizardMode = 'edit';
|
||||
this.wizardOpen = true;
|
||||
this.detailDrawerOpen = false;
|
||||
this.triggerDraft = {
|
||||
triggerName: this.selectedTriggerKey.name,
|
||||
group: this.selectedTriggerKey.group || 'DEFAULT',
|
||||
jobClass: trigger?.jobDetailDTO?.jobClassName || this.jobs[0] || '',
|
||||
startDate: this.toDatetimeLocalValue(trigger?.startTime),
|
||||
endDate: this.toDatetimeLocalValue(trigger?.endTime),
|
||||
repeatIntervalAmount: repeatInterval.amount,
|
||||
repeatIntervalUnit: repeatInterval.unit,
|
||||
repeatCount: trigger?.repeatCount ?? -1,
|
||||
misfireInstruction: this.getMisfireInstructionName(trigger?.misfireInstruction)
|
||||
};
|
||||
this.selectPage('dashboard');
|
||||
this.wizardOpen = true;
|
||||
}
|
||||
|
||||
resetWizard() {
|
||||
this.wizardMode = 'create';
|
||||
this.wizardError = null;
|
||||
this.triggerDraft = this.buildEmptyDraft();
|
||||
}
|
||||
|
||||
submitTriggerWizard() {
|
||||
this.wizardError = null;
|
||||
if (!this.canSubmitTrigger()) {
|
||||
this.wizardError = 'Trigger name, job class, misfire policy, and both repeat fields are required for the current backend.';
|
||||
return;
|
||||
}
|
||||
|
||||
const command = new SimpleTriggerCommand();
|
||||
command.triggerName = this.triggerDraft.triggerName.trim();
|
||||
command.jobClass = this.triggerDraft.jobClass;
|
||||
command.startDate = this.fromDatetimeLocalValue(this.triggerDraft.startDate);
|
||||
command.endDate = this.fromDatetimeLocalValue(this.triggerDraft.endDate);
|
||||
command.repeatInterval = this.getRepeatIntervalMs();
|
||||
command.repeatCount = this.triggerDraft.repeatCount;
|
||||
command.misfireInstruction = this.triggerDraft.misfireInstruction;
|
||||
|
||||
this.wizardSubmitting = true;
|
||||
const request = this.wizardMode === 'edit'
|
||||
? this.schedulerService.updateSimpleTriggerConfig(command)
|
||||
: this.schedulerService.saveSimpleTriggerConfig(command);
|
||||
|
||||
const subscription = request.subscribe({
|
||||
next: trigger => {
|
||||
this.wizardSubmitting = false;
|
||||
this.triggerDetailsByName[trigger.triggerKeyDTO.name] = trigger as SimpleTrigger;
|
||||
this.upsertTriggerKey(trigger.triggerKeyDTO);
|
||||
this.selectTrigger(trigger.triggerKeyDTO);
|
||||
this.wizardOpen = false;
|
||||
this.detailDrawerOpen = true;
|
||||
this.operationNotice = this.wizardMode === 'edit' ? 'SimpleTrigger rescheduled.' : 'SimpleTrigger created.';
|
||||
if (this.wizardMode === 'create') {
|
||||
this.resetWizard();
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.wizardSubmitting = false;
|
||||
this.wizardError = 'Unable to save the SimpleTrigger with the current backend.';
|
||||
}
|
||||
});
|
||||
this.subscriptions.push(subscription);
|
||||
}
|
||||
|
||||
getSchedulerStatusClass(): string {
|
||||
switch (this.scheduler?.status) {
|
||||
case 'RUNNING': return 'running';
|
||||
case 'PAUSED': return 'paused';
|
||||
case 'STOPPED': return 'error';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
getTriggerDetail(triggerKey: TriggerKey): SimpleTrigger {
|
||||
return triggerKey?.name ? this.triggerDetailsByName[triggerKey.name] : null;
|
||||
}
|
||||
|
||||
getTriggerGroup(triggerKey: TriggerKey): string {
|
||||
return triggerKey?.group || 'DEFAULT';
|
||||
}
|
||||
|
||||
getTriggerType(triggerKey: TriggerKey): string {
|
||||
return this.getTriggerDetail(triggerKey) ? 'SimpleTrigger' : 'SimpleTrigger';
|
||||
}
|
||||
|
||||
getTriggerState(triggerKey: TriggerKey): string {
|
||||
const trigger = this.getTriggerDetail(triggerKey);
|
||||
if (!trigger) {
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
if (!trigger.mayFireAgain) {
|
||||
return 'COMPLETE';
|
||||
}
|
||||
if (this.scheduler?.status === 'PAUSED') {
|
||||
return 'PAUSED';
|
||||
}
|
||||
return 'NORMAL';
|
||||
}
|
||||
|
||||
getTriggerStateClass(triggerKey: TriggerKey): string {
|
||||
const state = this.getTriggerState(triggerKey);
|
||||
if (state === 'NORMAL') {
|
||||
return 'normal';
|
||||
}
|
||||
if (state === 'PAUSED') {
|
||||
return 'paused';
|
||||
}
|
||||
if (state === 'UNKNOWN') {
|
||||
return 'warn';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
getTriggerJobName(triggerKey: TriggerKey): string {
|
||||
const trigger = this.getTriggerDetail(triggerKey);
|
||||
return trigger?.jobKeyDTO?.name || this.shortClassName(trigger?.jobDetailDTO?.jobClassName) || 'Roadmap';
|
||||
}
|
||||
|
||||
getTriggerNextFireLabel(triggerKey: TriggerKey): string {
|
||||
const trigger = this.getTriggerDetail(triggerKey);
|
||||
return this.formatDateTime(trigger?.nextFireTime) || 'not available';
|
||||
}
|
||||
|
||||
getTriggerPreviousFireLabel(triggerKey: TriggerKey): string {
|
||||
const trigger = this.getTriggerDetail(triggerKey);
|
||||
return this.formatDateTime(trigger?.['previousFireTime']) || 'not available';
|
||||
}
|
||||
|
||||
getSelectedTriggerGroup(): string {
|
||||
return this.getTriggerGroup(this.selectedTriggerKey);
|
||||
}
|
||||
|
||||
getSelectedTriggerState(): string {
|
||||
return this.selectedTriggerKey ? this.getTriggerState(this.selectedTriggerKey) : 'NONE';
|
||||
}
|
||||
|
||||
getSelectedTriggerStateClass(): string {
|
||||
return this.selectedTriggerKey ? this.getTriggerStateClass(this.selectedTriggerKey) : '';
|
||||
}
|
||||
|
||||
getSelectedJobName(): string {
|
||||
return this.selectedTriggerKey ? this.getTriggerJobName(this.selectedTriggerKey) : '-';
|
||||
}
|
||||
|
||||
getSelectedTriggerRepeatSummary(): string {
|
||||
if (!this.selectedTrigger) {
|
||||
return 'not loaded';
|
||||
}
|
||||
const repeatInterval = this.selectedTrigger.repeatInterval;
|
||||
if (!repeatInterval) {
|
||||
return 'Run once';
|
||||
}
|
||||
return `Every ${this.formatDuration(repeatInterval)}`;
|
||||
}
|
||||
|
||||
getProgressPercentage(): number {
|
||||
return this.progress?.percentage >= 0 ? this.progress.percentage : 0;
|
||||
}
|
||||
|
||||
getProgressLabel(): string {
|
||||
if (!this.progress || this.progress.percentage < 0) {
|
||||
return 'Waiting for progress events';
|
||||
}
|
||||
return `${this.progress.percentage}% / ${this.progress.timesTriggered || 0} fired`;
|
||||
}
|
||||
|
||||
getExecutionLoadValue(): string {
|
||||
return this.logs.length > 0 ? `${this.logs.length}` : '0';
|
||||
}
|
||||
|
||||
getJobClassRows(): string[] {
|
||||
return this.jobs.length > 0 ? this.jobs : ['No eligible Quartz Manager job classes returned by the backend'];
|
||||
}
|
||||
|
||||
getSelectedJobShortName(): string {
|
||||
return this.shortClassName(this.selectedJobClass) || '-';
|
||||
}
|
||||
|
||||
getWizardTitle(): string {
|
||||
return this.wizardMode === 'edit' ? 'Reschedule SimpleTrigger' : 'Create SimpleTrigger';
|
||||
}
|
||||
|
||||
getWizardCta(): string {
|
||||
return this.wizardMode === 'edit' ? 'Save Reschedule' : 'Create SimpleTrigger';
|
||||
}
|
||||
|
||||
canSubmitTrigger(): boolean {
|
||||
return !!(
|
||||
this.triggerDraft.triggerName?.trim()
|
||||
&& this.triggerDraft.jobClass
|
||||
&& this.triggerDraft.misfireInstruction
|
||||
&& this.triggerDraft.repeatCount !== null
|
||||
&& this.triggerDraft.repeatCount !== undefined
|
||||
&& this.triggerDraft.repeatIntervalAmount
|
||||
&& this.triggerDraft.repeatIntervalUnit
|
||||
);
|
||||
}
|
||||
|
||||
getFirePreview(): string[] {
|
||||
const start = this.fromDatetimeLocalValue(this.triggerDraft.startDate) || new Date();
|
||||
const repeatInterval = this.getRepeatIntervalMs();
|
||||
if (!repeatInterval || repeatInterval <= 0) {
|
||||
return [this.formatDateTime(start) || 'Next fire unavailable'];
|
||||
}
|
||||
|
||||
return Array.from({length: 5}).map((_, index) => {
|
||||
const fireTime = new Date(start.getTime() + repeatInterval * index);
|
||||
return `${index + 1}. ${this.formatDateTime(fireTime)}`;
|
||||
});
|
||||
}
|
||||
|
||||
shortClassName(className: string): string {
|
||||
if (!className) {
|
||||
return null;
|
||||
}
|
||||
const parts = className.split('.');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
formatDateTime(value: Date | string): string {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
formatDuration(milliseconds: number): string {
|
||||
if (!milliseconds) {
|
||||
return '0 ms';
|
||||
}
|
||||
if (milliseconds % 3600000 === 0) {
|
||||
return `${milliseconds / 3600000} h`;
|
||||
}
|
||||
if (milliseconds % 60000 === 0) {
|
||||
return `${milliseconds / 60000} min`;
|
||||
}
|
||||
if (milliseconds % 1000 === 0) {
|
||||
return `${milliseconds / 1000} sec`;
|
||||
}
|
||||
return `${milliseconds} ms`;
|
||||
}
|
||||
|
||||
private setSchedulerStatus(status: string, notice: string) {
|
||||
if (!this.scheduler) {
|
||||
this.scheduler = new Scheduler(null, null, status, []);
|
||||
}
|
||||
this.scheduler.status = status;
|
||||
this.operationNotice = notice;
|
||||
this.roadmapNotice = null;
|
||||
}
|
||||
|
||||
private subscribeToTriggerTopics(triggerKey: TriggerKey) {
|
||||
this.unsubscribeFromTriggerTopics();
|
||||
this.logs = [];
|
||||
this.progress = null;
|
||||
|
||||
this.logsSubscription = this.logsRxWebsocketService.watch(`/topic/logs/${triggerKey.name}`)
|
||||
.pipe(map((msg: any) => JSON.parse(msg.body)))
|
||||
.subscribe(logRecord => this.ngZone.run(() => this.addLogRecord(logRecord)), err => console.log(err));
|
||||
|
||||
this.progressSubscription = this.progressRxWebsocketService.watch(`/topic/progress/${triggerKey.name}`)
|
||||
.pipe(map((msg: any) => JSON.parse(msg.body)))
|
||||
.subscribe(progress => this.ngZone.run(() => this.progress = progress), err => console.log(err));
|
||||
}
|
||||
|
||||
private unsubscribeFromTriggerTopics() {
|
||||
if (this.logsSubscription) {
|
||||
this.logsSubscription.unsubscribe();
|
||||
this.logsSubscription = null;
|
||||
}
|
||||
if (this.progressSubscription) {
|
||||
this.progressSubscription.unsubscribe();
|
||||
this.progressSubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
private addLogRecord(logRecord: any) {
|
||||
const selectedSource = this.selectedTriggerKey?.group || this.selectedTriggerKey?.name || 'trigger';
|
||||
this.logs = [{
|
||||
time: logRecord.date,
|
||||
severity: logRecord.type || 'INFO',
|
||||
type: 'JOB_LOG',
|
||||
source: logRecord.threadName || selectedSource,
|
||||
message: logRecord.message || JSON.stringify(logRecord)
|
||||
}, ...this.logs].slice(0, 50);
|
||||
}
|
||||
|
||||
private upsertTriggerKey(triggerKey: TriggerKey) {
|
||||
if (!this.triggerKeys.some(currentTriggerKey => currentTriggerKey.name === triggerKey.name)) {
|
||||
this.triggerKeys = [triggerKey, ...this.triggerKeys];
|
||||
}
|
||||
}
|
||||
|
||||
private buildEmptyDraft(): TriggerDraft {
|
||||
return {
|
||||
triggerName: '',
|
||||
group: 'DEFAULT',
|
||||
jobClass: this.jobs[0] || '',
|
||||
startDate: this.toDatetimeLocalValue(new Date()),
|
||||
endDate: '',
|
||||
repeatIntervalAmount: 1,
|
||||
repeatIntervalUnit: 'minutes',
|
||||
repeatCount: -1,
|
||||
misfireInstruction: 'MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT'
|
||||
};
|
||||
}
|
||||
|
||||
private getRepeatIntervalMs(): number {
|
||||
const amount = Number(this.triggerDraft.repeatIntervalAmount || 0);
|
||||
switch (this.triggerDraft.repeatIntervalUnit) {
|
||||
case 'seconds': return amount * 1000;
|
||||
case 'minutes': return amount * 60000;
|
||||
case 'hours': return amount * 3600000;
|
||||
case 'days': return amount * 86400000;
|
||||
default: return amount;
|
||||
}
|
||||
}
|
||||
|
||||
private splitRepeatInterval(milliseconds: number): {amount: number; unit: string} {
|
||||
if (milliseconds && milliseconds % 86400000 === 0) {
|
||||
return {amount: milliseconds / 86400000, unit: 'days'};
|
||||
}
|
||||
if (milliseconds && milliseconds % 3600000 === 0) {
|
||||
return {amount: milliseconds / 3600000, unit: 'hours'};
|
||||
}
|
||||
if (milliseconds && milliseconds % 60000 === 0) {
|
||||
return {amount: milliseconds / 60000, unit: 'minutes'};
|
||||
}
|
||||
if (milliseconds && milliseconds % 1000 === 0) {
|
||||
return {amount: milliseconds / 1000, unit: 'seconds'};
|
||||
}
|
||||
return {amount: milliseconds || 1, unit: 'milliseconds'};
|
||||
}
|
||||
|
||||
private fromDatetimeLocalValue(value: string): Date {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
private toDatetimeLocalValue(value: Date | string): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const offsetDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||||
return offsetDate.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
private getMisfireInstructionName(misfireInstruction: number): string {
|
||||
switch (misfireInstruction) {
|
||||
case 1: return 'MISFIRE_INSTRUCTION_FIRE_NOW';
|
||||
case 2: return 'MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT';
|
||||
case 3: return 'MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT';
|
||||
case 4: return 'MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT';
|
||||
case 5: return 'MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_EXISTING_COUNT';
|
||||
default: return 'MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT';
|
||||
}
|
||||
}
|
||||
|
||||
private getPageTitle(page: ConsolePage): string {
|
||||
switch (page) {
|
||||
case 'calendars': return 'Quartz calendars';
|
||||
case 'executions': return 'Execution history and currently executing jobs';
|
||||
default: return page;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1120
quartz-manager-parent/quartz-operations-console.html
Normal file
1120
quartz-manager-parent/quartz-operations-console.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user