-
+@if (isOperationsConsoleRoute()) {
+
+} @else {
+
-
-
+}
diff --git a/quartz-manager-frontend/src/app/app.component.scss b/quartz-manager-frontend/src/app/app.component.scss
index 63672be..d941b7c 100644
--- a/quartz-manager-frontend/src/app/app.component.scss
+++ b/quartz-manager-frontend/src/app/app.component.scss
@@ -2,7 +2,7 @@
display: block;
color: rgba(0,0,0,.54);
font-family: Roboto,"Helvetica Neue";
- height: 100%;
+ min-height: 100%;
}
.content {
diff --git a/quartz-manager-frontend/src/app/app.component.ts b/quartz-manager-frontend/src/app/app.component.ts
index 3ab0889..9ede1cb 100644
--- a/quartz-manager-frontend/src/app/app.component.ts
+++ b/quartz-manager-frontend/src/app/app.component.ts
@@ -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');
+ }
+}
diff --git a/quartz-manager-frontend/src/app/views/manager/manager.component.html b/quartz-manager-frontend/src/app/views/manager/manager.component.html
index fd48af8..72623a7 100644
--- a/quartz-manager-frontend/src/app/views/manager/manager.component.html
+++ b/quartz-manager-frontend/src/app/views/manager/manager.component.html
@@ -1,48 +1,353 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
QM
+
+
Quartz Manager
+
Operations Console
-
-
-
-
-
-
-
+
+
+
+
+ Dashboard
+
+
+ Jobs
+
+
+ Triggers
+
+
+ Calendars
+
+
+ Executions
+
+
+ Event Stream
+
+
+ Scheduler
+
+
+
+
+
Live channel
+
WebSocket OPEN
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Start
+ Standby
+ Resume
+ Pause All
+ Clear
+ Shutdown
+
+
Global lifecycle operations are centralized here. Group-level and destructive data operations stay visible as roadmap actions until backend endpoints exist.
+
+
+
+
+
+
TRIGGERS {{ triggerKeys.length }}
Trigger keys returned by backend
+
JOBS {{ jobs.length }}
Eligible job classes
+
EVENTS {{ getExecutionLoadValue() }}
Logs received for selected trigger
+
STATUS {{ scheduler?.status || '-' }}
Scheduler lifecycle state
+
+
+
+
+
+
+ Trigger Group Type State Job Next fire
+
+ @for (triggerKey of triggerKeys; track triggerKey.name) {
+
+ {{ triggerKey.name }}
+ {{ getTriggerGroup(triggerKey) }}
+ {{ getTriggerType(triggerKey) }}
+ {{ getTriggerState(triggerKey) }}
+ {{ getTriggerJobName(triggerKey) }}
+ {{ getTriggerNextFireLabel(triggerKey) }}
+
+ } @empty {
+ No triggers returned by the backend. Use the wizard to create a SimpleTrigger.
+ }
+
+
+
+
+ @if (selectedTriggerKey) {
+ {{ getSelectedTriggerState() }} {{ selectedTriggerKey.name }} {{ getSelectedTriggerGroup() }} / linked to {{ getSelectedJobName() }}
Close
+ Overview Executions Logs
+
+
Previous fire {{ selectedTrigger?.timesTriggered ? 'tracked by progress events' : 'not exposed' }}
+
Next fire {{ formatDateTime(selectedTrigger?.nextFireTime) || '-' }}
+
Priority {{ selectedTrigger?.priority || '-' }}
+
Calendar Roadmap
+
Misfire {{ selectedTrigger?.misfireInstruction || '-' }}
+
Repeat {{ getSelectedTriggerRepeatSummary() }}
+
+ Current run progress
{{ getProgressLabel() }}
+ Pause Reschedule Unschedule
+ } @else {
+ EMPTY No trigger selected Create a SimpleTrigger or refresh trigger keys.
Close
+ }
+
+
+
+
+
+
+
+
+
+
+
+
Logs received {{ logs.length }}
+
Current progress {{ getProgressPercentage() }}%
+
Misfires Roadmap
+
Recovering jobs Roadmap
+
+
+
+
+
+
+
+
Time Severity Type Source Message
+ @for (log of logs; track log.time) {
+
{{ log.time | date:'HH:mm:ss' }} {{ log.severity }} {{ log.type }} {{ log.source }} {{ log.message }}
+ } @empty {
+
-- WAIT JOB_LOG {{ selectedTriggerKey?.name || '-' }} Waiting for log messages from the selected trigger.
+ }
+
+
+
+
+
+
+
+
Jobs The current backend exposes eligible Quartz Manager job classes. Full job registry metadata, CRUD, durability, recovery, and group operations remain roadmap features.
+
New Job
+
+
+
+
+
+
+ Job key Class Durable Recovery Triggers
+
+ @for (jobClass of getJobClassRows(); track jobClass) {
+ {{ shortClassName(jobClass) }} {{ jobClass }} Roadmap Roadmap Roadmap
+ }
+
+
+
+
+ ELIGIBLE {{ getSelectedJobShortName() }} {{ selectedJobClass || 'Select a job class' }}
Close
+ Overview Triggers Data Map Executions
+
+
Class {{ getSelectedJobShortName() }}
+
Group Roadmap
+
Durable Roadmap
+
Requests recovery Roadmap
+
+ Backend contract
+GET /quartz-manager/jobs
+Returns eligible Java job class names.
+ Trigger Now Pause Create SimpleTrigger
+ Danger zone Interrupt and delete need backend support and explicit confirmation. Interrupt Delete Job
+
+
+
+
+
+
+
+
Triggers The backend currently supports SimpleTrigger listing, details, creation, and rescheduling. Other trigger families and per-trigger operations are shown with roadmap messaging.
+
Create Trigger
+
+
+
+
+
+
+ Trigger Group Type State Job Next fire Misfire
+
+ @for (triggerKey of triggerKeys; track triggerKey.name) {
+ {{ triggerKey.name }} {{ getTriggerGroup(triggerKey) }} {{ getTriggerType(triggerKey) }} {{ getTriggerState(triggerKey) }} {{ getTriggerJobName(triggerKey) }} {{ getTriggerNextFireLabel(triggerKey) }} {{ getTriggerDetail(triggerKey)?.misfireInstruction || '-' }}
+ } @empty {
+ No triggers returned by the backend.
+ }
+
+
+
+
+ {{ getSelectedTriggerState() }} {{ selectedTriggerKey?.name || 'No trigger' }} SimpleTrigger / {{ getSelectedTriggerGroup() }}
Close
+ Overview Schedule Calendar Executions
+
+
Linked job {{ getSelectedJobName() }}
+
Priority {{ selectedTrigger?.priority || '-' }}
+
Final fire {{ formatDateTime(selectedTrigger?.finalFireTime) || 'none' }}
+
Timezone Roadmap
+
Repeat interval {{ selectedTrigger?.repeatInterval ? formatDuration(selectedTrigger.repeatInterval) : '-' }}
+
Calendar Roadmap
+
+ Schedule summary {{ getSelectedTriggerRepeatSummary() }}. Next fire: {{ formatDateTime(selectedTrigger?.nextFireTime) || 'not available' }}.
+ Pause Reschedule Duplicate
+ Danger zone Unschedule and reset-error require backend endpoints that are still on the roadmap. Unschedule Reset Error
+
+
+
+
+
+
+
+
Calendars Quartz calendar registry, rule editing, trigger usage, and next included time testing are not exposed by the backend yet.
+
New Calendar
+
+
+
+
+
+
This UI is ready for WeeklyCalendar, HolidayCalendar, MonthlyCalendar, DailyCalendar, and CronCalendar once the API surface is added.
+
+
+
Planned backend List calendars, inspect calendar type/base calendar, attach calendars to triggers, test included time, and edit exclusion rules.
Show Triggers Delete Calendar
+
+
+
+
+
+
+
Executions Currently executing jobs, fire instance IDs, refire counts, execution history, and interruption by fire instance are roadmap backend features.
+
Refresh
+
+
+
+
+
Fire instance Job Trigger Run time Node Roadmap {{ getSelectedJobName() }} {{ selectedTriggerKey?.name || '-' }} Roadmap Roadmap
+
ROADMAP Execution Inspector Live progress remains available through the selected trigger websocket.
Close Selected trigger progress
{{ getProgressLabel() }}
Interrupt confirmation Interrupt operations need backend support and explicit operator confirmation.
Interrupt Fire Instance Interrupt Job Key
+
+
+
+
+
+
+
Event Stream The current backend exposes per-trigger log and progress websocket topics. Global event aggregation, filters, saved views, and export are roadmap features.
+
Pause Stream Export CSV
+
+
+
+
+
+
Time Severity Type Source Message
+ @for (log of logs; track log.time) {
+
{{ log.time | date:'HH:mm:ss' }} {{ log.severity }} {{ log.type }} {{ log.source }} {{ log.message }}
+ } @empty {
+
-- WAIT JOB_LOG {{ selectedTriggerKey?.name || '-' }} Select or fire a trigger to receive backend log messages.
+ }
+
+
+
+ Filters
+ Severity INFO, WARN, ERROR
+ Event type All event types
+ Job / trigger / group
+ Supported now Per-trigger logs and progress through existing websocket topics.
+
+
+
+
+
+
+
Scheduler / Settings Supported lifecycle actions are wired to the backend. Cluster metadata, clear, delayed start, and state analytics are roadmap-gated.
+
{{ scheduler?.status || 'LOADING' }} Refresh Metadata
+
+
+
+
+ Start Delayed Start 60s Standby Resume Pause All Shutdown Clear Scheduler
Strong confirmation required Shutdown is supported and prompts before calling the backend. Clear remains roadmap-gated.
+
+
+
+ Scheduler name {{ scheduler?.name || '-' }}
Instance ID {{ scheduler?.instanceId || '-' }}
Status {{ scheduler?.status || '-' }}
Trigger keys {{ triggerKeys.length }}
Quartz version Roadmap Thread pool Roadmap Job store Roadmap Clustered Roadmap
+
+
+
+ {{ scheduler?.instanceId || 'local' }} local scheduler instance
LOCAL remote nodes not exposed by backend
ROADMAP
+
+
+
+ Area Current state Count Representative key Recommended action Scheduler {{ scheduler?.status || '-' }} 1 {{ scheduler?.instanceId || '-' }} Use lifecycle controls above. Triggers LISTED {{ triggerKeys.length }} {{ selectedTriggerKey?.name || '-' }} Open Triggers for details or reschedule SimpleTriggers. Misfires / errors ROADMAP Roadmap Roadmap Backend analytics needed.
+
+
+
+
+
+
+ @if (wizardOpen || detailDrawerOpen) {
+
+ }
+
+ @if (roadmapNotice || operationNotice || operationError) {
+
+ {{ operationError ? 'Action failed' : roadmapNotice ? 'Roadmap reminder' : 'Updated' }}
+ {{ operationError || roadmapNotice || operationNotice }}
+ Dismiss
+
+ }
+
+
+
+ Identity
Type
Schedule
Advanced
Preview
+
+
+
diff --git a/quartz-manager-frontend/src/app/views/manager/manager.component.scss b/quartz-manager-frontend/src/app/views/manager/manager.component.scss
index a273cab..9a59851 100644
--- a/quartz-manager-frontend/src/app/views/manager/manager.component.scss
+++ b/quartz-manager-frontend/src/app/views/manager/manager.component.scss
@@ -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; }
}
diff --git a/quartz-manager-frontend/src/app/views/manager/manager.component.ts b/quartz-manager-frontend/src/app/views/manager/manager.component.ts
index 8ad2ab3..2bbda83 100644
--- a/quartz-manager-frontend/src/app/views/manager/manager.component.ts
+++ b/quartz-manager-frontend/src/app/views/manager/manager.component.ts
@@ -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
(['calendars', 'executions']);
+ private readonly subscriptions: Subscription[] = [];
+ private logsSubscription: Subscription;
+ private progressSubscription: Subscription;
+ private noticeTimer: ReturnType;
- 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;
+ }
}
}
diff --git a/quartz-manager-parent/quartz-operations-console.html b/quartz-manager-parent/quartz-operations-console.html
new file mode 100644
index 0000000..18b1f75
--- /dev/null
+++ b/quartz-manager-parent/quartz-operations-console.html
@@ -0,0 +1,1120 @@
+
+
+
+
+
+
+ Quartz Operations Console Prototype
+
+
+
+
+
+
+
QM
+
+
Quartz Manager
+
Operations Console
+
+
+
+ Dashboard
+ Jobs
+ Triggers
+ Calendars
+ Executions
+ Event Stream
+ Scheduler
+
+
+
Live channel
+
WebSocket OPEN
+
+
+
+
+
+
+
+
+
+
+
+
+
Start
+
Standby
+
Resume All
+
Pause All
+
Clear
+
Shutdown
+
+
Global scheduler operations are intentionally centralized here because they affect every job and trigger. Object pages keep only compact scheduler context.
+
+
+
+
+
NORMAL 42
Jobs scheduled across 7 groups
+
BLOCKED 3
Currently executing jobs
+
MISFIRE 5
Misfires in the last hour
+
THREADS 8 / 12
Thread pool active capacity
+
+
+
+
+
+ Trigger Group Type State Job Next fire
+
+ invoice-sync-5m billing SimpleTrigger NORMAL InvoiceSyncJob 09:45:00 Europe/Rome
+ daily-ledger-close finance CronTrigger NORMAL LedgerCloseJob 18:00:00 Europe/Rome
+ tenant-cleanup maintenance DailyTimeInterval PAUSED TenantCleanupJob paused
+ erp-retry-window integrations CalendarInterval ERROR ErpRetryJob requires reset
+ cache-warmup platform SimpleTrigger BLOCKED CacheWarmupJob 09:47:30 Europe/Rome
+ welcome-email-once crm SimpleTrigger COMPLETE WelcomeEmailJob complete
+
+
+
+
+ NORMAL invoice-sync-5m billing / linked to InvoiceSyncJob
+
+
+
Previous fire 09:40:00
+
Next fire 09:45:00
+
Priority 5
+
Calendar business-days
+
Misfire FIRE_NOW
+
Repeat Every 5 min
+
+ Current run progress
72% / fireInstanceId: node-a7f3-119238
+ Pause Reschedule Unschedule
+
+
+
+
+
+
+
+
+
+
+
Total jobs executed 18,942
+
Recent errors 2
+
Paused groups maintenance
+
Recovering jobs 1
+
+
+
+
+
+
+
Time Severity Type Source Message
+
09:44:18 INFO JOB_PROGRESS billing InvoiceSyncJob processed 144 of 200 invoices for trigger invoice-sync-5m.
+
09:43:56 WARN MISFIRE finance daily-ledger-close missed scheduled fire; policy SMART_POLICY resolved to FIRE_ONCE_NOW.
+
09:42:31 ERROR TRIGGER_ERROR integrations erp-retry-window moved to ERROR after job threw ResourceAccessException.
+
09:41:05 INFO SCHEDULER node-a7f3 Cluster check-in completed. 2 scheduler instances active.
+
+
+
+
+
+
+
Jobs Jobs become first-class Quartz objects: class, group, durability, recovery, concurrency, data map, associated triggers, and execution history are visible in one operational surface.
+
New Job
+
+
+
+
+
+
+ Job key Group Class Durable Recovery Concurrent Triggers Next run
+
+ InvoiceSyncJob billing it.fabioformosa.jobs.InvoiceSyncJob YES YES DISALLOW 3 09:45
+ LedgerCloseJob finance it.fabioformosa.jobs.LedgerCloseJob YES NO ALLOW 1 18:00
+ TenantCleanupJob maintenance it.fabioformosa.jobs.TenantCleanupJob YES YES DISALLOW 2 paused
+ ErpRetryJob integrations it.fabioformosa.jobs.ErpRetryJob YES YES ALLOW 4 error
+ CacheWarmupJob platform it.fabioformosa.jobs.CacheWarmupJob NO NO DISALLOW 1 09:47
+ WelcomeEmailJob crm it.fabioformosa.jobs.WelcomeEmailJob NO NO ALLOW 0 complete
+
+
+
+
+ RUNNING InvoiceSyncJob billing / DisallowConcurrentExecution
+ Overview
Triggers
Data Map
Executions
Logs
+
+
Job class InvoiceSyncJob
+
Trigger count 3
+
Last execution 09:40:03
+
Next execution 09:45:00
+
Persist data true
+
Requests recovery true
+
+ JobDataMap
+tenant = eu-west
+batchSize = 200
+source = invoice-api
+ Trigger Now Pause Add Trigger
+ Danger zone Interrupt running executions or delete the job only after explicit confirmation. Durable jobs may outlive their triggers. Interrupt Delete Job
+
+
+
+
+
+
+
Triggers The trigger page expands beyond SimpleTrigger: every row shows type, state, linked job, fire times, calendar, priority, and misfire policy, with operational actions exposed close to the selected trigger.
+
Create Trigger
+
+
+
+
+
+
+ Trigger Group Type State Job Next fire Previous fire Misfire
+
+ invoice-sync-5m billing SimpleTrigger NORMAL InvoiceSyncJob 09:45 09:40 FIRE_NOW
+ daily-ledger-close finance CronTrigger NORMAL LedgerCloseJob 18:00 yesterday SMART
+ tenant-cleanup maintenance DailyTimeInterval PAUSED TenantCleanupJob paused 08:30 DO_NOTHING
+ erp-retry-window integrations CalendarInterval ERROR ErpRetryJob reset 09:20 FIRE_ONCE
+ weekly-report analytics CronTrigger NORMAL ReportJob Mon 07:00 Mon 07:00 SMART
+ cache-warmup platform SimpleTrigger BLOCKED CacheWarmupJob 09:47 09:42 IGNORE
+
+
+
+
+ NORMAL invoice-sync-5m SimpleTrigger / billing
+ Overview
Schedule
Calendar
Executions
+
+
Linked job InvoiceSyncJob
+
Priority 5
+
Final fire none
+
Timezone Europe/Rome
+
Repeat interval 5 minutes
+
Calendar business-days
+
+
+ Schedule summary
+ Runs every 5 minutes indefinitely. If a misfire occurs, execute once immediately and continue the cadence from the next scheduled fire time.
+ 09:45:00 Europe/Rome 09:50:00 Europe/Rome 09:55:00 Europe/Rome
+
+ Pause Reschedule Duplicate
+ Danger zone Unschedule removes this trigger from its job. Reset from ERROR should require the operator to confirm the failed cause was handled. Unschedule Reset Error
+
+
+
+
+
+
+
Calendars Quartz calendars are exclusion rules, not date pickers. This page makes the calendar type, base calendar, trigger usage, excluded windows, and next included time testable before operators attach them to triggers.
+
New Calendar
+
+
+
+
+
+
+ Calendar Type Base calendar Triggers Next excluded Description
+
+ business-days WeeklyCalendar company-holidays 14 2026-05-16 00:00 Exclude Saturday and Sunday.
+ company-holidays HolidayCalendar none 9 2026-06-02 00:00 Italian public holidays.
+ month-end-freeze MonthlyCalendar business-days 3 2026-05-31 00:00 Exclude month-end close window.
+ batch-window DailyCalendar business-days 4 2026-05-11 20:00 Allow 06:00 to 20:00 only.
+ cron-blackout CronCalendar none 1 Fri 23:00 Exclude release windows.
+
+
+
+
+ ACTIVE business-days WeeklyCalendar / base: company-holidays
+ Overview
Rules
Triggers
Test
+
+
Calendar type WeeklyCalendar
+
Base calendar company-holidays
+
Triggers using 14
+
Timezone Europe/Rome
+
+
+
Mon 11
Tue 12
Wed 13
Thu 14
Fri 15
Sat 16
Sun 17
+
+ Test timestamp Input: 2026-05-17 09:00 Europe/Rome Included: false Next included time: 2026-05-18 00:00 Europe/Rome
+ Edit Rules Show Triggers Delete Calendar
+
+
+
+
+
+
+
+ Mon Tue Wed Thu Fri Sat
+ 06:00 open open open open open closed
+ 12:00 open open open open open closed
+ 20:00 closed closed closed closed release freeze closed
+
+
+
+
+
+
+
Executions Currently executing jobs are treated as live operational objects with fire instance id, scheduled versus actual fire time, run time, refire count, recovery state, and node ownership.
+
Refresh
+
+
+
+
+
+
+ Fire instance Job Trigger Scheduled Actual Run time Refire Node
+
+ node-a7f3-119238 InvoiceSyncJob invoice-sync-5m 09:40:00 09:40:03 04:21 0 node-a7f3
+ node-b912-119239 CacheWarmupJob cache-warmup 09:42:30 09:42:31 01:53 0 node-b912
+ node-a7f3-119240 ErpRetryJob erp-retry-window 09:43:00 09:43:08 00:46 2 node-a7f3
+
+
+
+
+ RUNNING node-a7f3-119238 InvoiceSyncJob / billing
+ Overview
Progress
Logs
Data Map
+
+
Recovering false
+
Refire count 0
+
Scheduled fire 09:40:00
+
Actual fire 09:40:03
+
Run time 04:21
+
Instance node-a7f3
+
+ Live progress from WebSocketProgressNotifier
144 / 200 invoices processed
+ Interrupt confirmation Interrupt by job key affects all matching running instances when the scheduler supports interruption. Prefer fire-instance interruption where available.
+ Interrupt Fire Instance Interrupt Job Key
+
+
+
+
+
+
+
+ Completed Job Trigger Duration Result Node Message
+
+ 09:39:58 ReportJob weekly-report 00:11 SUCCESS node-b912 Report generated for analytics group.
+ 09:38:22 ErpRetryJob erp-retry-window 00:31 FAILED node-a7f3 ResourceAccessException from ERP endpoint.
+
+
+
+
+
+
+
+
Event Stream The old logs and progress panels become one observable stream with live mode, pause, text search, severity filtering, event-type filtering, and export for incident review.
+
Pause Stream Export CSV
+
+
+
+
+
+
Time Severity Type Source Message
+
09:44:18 INFO JOB_PROGRESS billing InvoiceSyncJob processed 144 of 200 invoices for fireInstanceId node-a7f3-119238.
+
09:43:56 WARN MISFIRE finance daily-ledger-close missed fire at 09:30; SMART_POLICY resolved to FIRE_ONCE_NOW.
+
09:42:31 ERROR TRIGGER_ERROR integrations erp-retry-window entered ERROR after ErpRetryJob threw ResourceAccessException.
+
09:41:05 INFO SCHEDULER node-a7f3 Cluster check-in completed. 2 scheduler instances active.
+
09:40:03 INFO JOB_STARTED billing InvoiceSyncJob started from trigger invoice-sync-5m.
+
09:39:58 INFO JOB_COMPLETED analytics ReportJob completed in 11 seconds on node-b912.
+
+
+
+ Filters
+ Severity INFO, WARN, ERROR WARN and ERROR ERROR only
+ Event type All event types Scheduler events Job lifecycle Misfires Progress updates
+ Job / trigger / group
+ Scheduler instance All nodes node-a7f3 node-b912
+ Time range Last 30 minutes Last 4 hours Custom range
+ Saved view Production incident filter: ERROR + MISFIRE across integrations and finance groups, last 4 hours.
+
+
+
+
+
+
Scheduler / Settings The full scheduler command surface belongs here: global lifecycle actions, delayed start, shutdown, clear, cluster metadata, nodes, currently executing jobs, and safety warnings for destructive operations.
+
RUNNING Refresh Metadata
+
+
+
+
+
+
Start Delayed Start 60s Standby Resume All Pause All Shutdown Clear Scheduler
+
Strong confirmation required Shutdown stops the scheduler instance. Clear removes all scheduling data from the scheduler. Both actions should require typed confirmation and role checks.
+
+
+
+
+
+
Scheduler name quartz-manager-scheduler
+
Instance ID node-a7f3
+
Quartz version 2.3.2
+
Started 2026-05-11 08:14:03
+
Thread pool class SimpleThreadPool
+
Thread count 12
+
Job store class JobStoreTX
+
Clustered true
+
+
+
+
+
+
node-a7f3 last check-in 2s ago / 8 active threads
LOCAL
+
node-b912 last check-in 4s ago / 4 active threads
REMOTE
+
node-c401 last check-in 19m ago / stale
STALE
+
+
+
+
+
+
+ Area Current state Count Representative key Recommended action
+
+ Executing jobs RUNNING 3 InvoiceSyncJob Open Executions before interrupting anything.
+ Paused groups PAUSED 4 maintenance Resume group only after maintenance window closes.
+ Error triggers ERROR 1 erp-retry-window Resolve root cause, then reset from Triggers page.
+ Misfires MISFIRE 5 / hour daily-ledger-close Review thread pool saturation and misfire policy.
+
+
+
+
+
+
+
+
+
+
+
+
+