diff --git a/quartz-manager-frontend/src/app/app.component.html b/quartz-manager-frontend/src/app/app.component.html index d28c563..ce9cc03 100644 --- a/quartz-manager-frontend/src/app/app.component.html +++ b/quartz-manager-frontend/src/app/app.component.html @@ -1,7 +1,11 @@ -
- -
- +@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 @@ -
-
- -
- -
-
-
- +
+
-
+
+ + + +
+

Live channel

+
WebSocketOPEN
+
+ + +
+
+
+
+

Quartz Operations Console

+
{{ scheduler?.name || 'quartz-manager-scheduler' }} / compact context
+
+ {{ scheduler?.status || 'LOADING' }} +
Instance ID{{ scheduler?.instanceId || '-' }}
+ +
WebSocketOPEN
+
+
+ + +
+
+ +
+
+
+
+

Scheduler Command Center

Supported lifecycle commands call the current backend
+
+
+
+ + + + + + +
+
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
+ +
+

Next Scheduled Fires

LIVE
+
+
+ + + + @for (triggerKey of triggerKeys; track triggerKey.name) { + + + + + + + + + } @empty { + + } + +
TriggerGroupTypeStateJobNext fire
{{ triggerKey.name }}{{ getTriggerGroup(triggerKey) }}{{ getTriggerType(triggerKey) }}{{ getTriggerState(triggerKey) }}{{ getTriggerJobName(triggerKey) }}{{ getTriggerNextFireLabel(triggerKey) }}
No triggers returned by the backend. Use the wizard to create a SimpleTrigger.
+
+ +
+
+ +
+

Execution Load

Analytics roadmap preview
+
+
+ +
+
+
{{ logs.length }}
+
{{ getProgressPercentage() }}%
+ + +
+
+
+ +
+

Event Stream

STREAMING
+
+
TimeSeverityTypeSourceMessage
+ @for (log of logs; track log.time) { +
{{ log.time | date:'HH:mm:ss' }}{{ log.severity }}{{ log.type }}{{ log.source }}{{ log.message }}
+ } @empty { +
--WAITJOB_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.

+
+
+
+

Eligible Job Classes

{{ jobs.length }} JOBS
+
+
+ + + + @for (jobClass of getJobClassRows(); track jobClass) { + + } + +
Job keyClassDurableRecoveryTriggers
{{ shortClassName(jobClass) }}{{ jobClass }}RoadmapRoadmapRoadmap
+
+ +
+
+
+ +
+
+

Triggers

The backend currently supports SimpleTrigger listing, details, creation, and rescheduling. Other trigger families and per-trigger operations are shown with roadmap messaging.

+
+
+
+

Trigger Inventory

{{ triggerKeys.length }} TOTALSTATE COUNTS ROADMAP
+
+
+ + + + @for (triggerKey of triggerKeys; track triggerKey.name) { + + } @empty { + + } + +
TriggerGroupTypeStateJobNext fireMisfire
{{ triggerKey.name }}{{ getTriggerGroup(triggerKey) }}{{ getTriggerType(triggerKey) }}{{ getTriggerState(triggerKey) }}{{ getTriggerJobName(triggerKey) }}{{ getTriggerNextFireLabel(triggerKey) }}{{ getTriggerDetail(triggerKey)?.misfireInstruction || '-' }}
No triggers returned by the backend.
+
+ +
+
+
+ +
+
+

Calendars

Quartz calendar registry, rule editing, trigger usage, and next included time testing are not exposed by the backend yet.

+
+
+
+

Calendar Registry

ROADMAP
+
+
+

This UI is ready for WeeklyCalendar, HolidayCalendar, MonthlyCalendar, DailyCalendar, and CronCalendar once the API surface is added.

+
Mon
Tue
Wed
Thu
Fri
Sat
Sun
+
+ +
+
+
+ +
+
+

Executions

Currently executing jobs, fire instance IDs, refire counts, execution history, and interruption by fire instance are roadmap backend features.

+
+
+
+

Currently Executing Jobs

ROADMAP
+
+
Fire instanceJobTriggerRun timeNode
Roadmap{{ getSelectedJobName() }}{{ selectedTriggerKey?.name || '-' }}RoadmapRoadmap
+ +
+
+
+ +
+
+

Event Stream

The current backend exposes per-trigger log and progress websocket topics. Global event aggregation, filters, saved views, and export are roadmap features.

+
+
+
+
+

Live Events

TRIGGER STREAM{{ logs.length }} EVENTS
+
+
TimeSeverityTypeSourceMessage
+ @for (log of logs; track log.time) { +
{{ log.time | date:'HH:mm:ss' }}{{ log.severity }}{{ log.type }}{{ log.source }}{{ log.message }}
+ } @empty { +
--WAITJOB_LOG{{ selectedTriggerKey?.name || '-' }}Select or fire a trigger to receive backend log messages.
+ } +
+
+ +
+
+ +
+
+

Scheduler / Settings

Supported lifecycle actions are wired to the backend. Cluster metadata, clear, delayed start, and state analytics are roadmap-gated.

+
{{ scheduler?.status || 'LOADING' }}
+
+
+
+

Lifecycle Controls

Global actions affect the scheduler instance
+
Strong confirmation requiredShutdown is supported and prompts before calling the backend. Clear remains roadmap-gated.
+
+
+

Scheduler Metadata

CURRENT API
+
{{ scheduler?.name || '-' }}
{{ scheduler?.instanceId || '-' }}
{{ scheduler?.status || '-' }}
{{ triggerKeys.length }}
+
+
+

Cluster Nodes

ROADMAP
+
{{ scheduler?.instanceId || 'local' }}
local scheduler instance
LOCAL
remote nodes
not exposed by backend
ROADMAP
+
+
+

Global State Overview

{{ triggerKeys.length }} TRIGGERSANALYTICS ROADMAP
+
AreaCurrent stateCountRepresentative keyRecommended action
Scheduler{{ scheduler?.status || '-' }}1{{ scheduler?.instanceId || '-' }}Use lifecycle controls above.
TriggersLISTED{{ triggerKeys.length }}{{ selectedTriggerKey?.name || '-' }}Open Triggers for details or reschedule SimpleTriggers.
Misfires / errorsROADMAPRoadmapRoadmapBackend analytics needed.
+
+
+
+
+
+ + @if (wizardOpen || detailDrawerOpen) { + + } + + @if (roadmapNotice || operationNotice || operationError) { +
+
{{ operationError ? 'Action failed' : roadmapNotice ? 'Roadmap reminder' : 'Updated' }}
+
{{ operationError || roadmapNotice || operationNotice }}
+ +
+ } + + +
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 + + + +
+ +
+
+
+
+

Quartz Operations Console

+
quartz-manager-scheduler / compact context
+
+ RUNNING +
Instance IDnode-a7f3
+
Cluster2 nodes
+
WebSocketOPEN
+
+
+ + +
+
+
+
+
+
+

Scheduler Command Center

Expanded controls live on Dashboard and Scheduler / Settings only
+
+
+
+ + + + + + +
+
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
+
+

Next Scheduled Fires

LIVE
+
+
+ + + + + + + + + + +
TriggerGroupTypeStateJobNext fire
invoice-sync-5mbillingSimpleTriggerNORMALInvoiceSyncJob09:45:00 Europe/Rome
daily-ledger-closefinanceCronTriggerNORMALLedgerCloseJob18:00:00 Europe/Rome
tenant-cleanupmaintenanceDailyTimeIntervalPAUSEDTenantCleanupJobpaused
erp-retry-windowintegrationsCalendarIntervalERRORErpRetryJobrequires reset
cache-warmupplatformSimpleTriggerBLOCKEDCacheWarmupJob09:47:30 Europe/Rome
welcome-email-oncecrmSimpleTriggerCOMPLETEWelcomeEmailJobcomplete
+
+ +
+
+
+

Execution Load

Last 30 minutes
+
+
+ +
+
+
18,942
+
2
+
maintenance
+
1
+
+
+
+
+

Event Stream

STREAMING
+
+
TimeSeverityTypeSourceMessage
+
09:44:18INFOJOB_PROGRESSbillingInvoiceSyncJob processed 144 of 200 invoices for trigger invoice-sync-5m.
+
09:43:56WARNMISFIREfinancedaily-ledger-close missed scheduled fire; policy SMART_POLICY resolved to FIRE_ONCE_NOW.
+
09:42:31ERRORTRIGGER_ERRORintegrationserp-retry-window moved to ERROR after job threw ResourceAccessException.
+
09:41:05INFOSCHEDULERnode-a7f3Cluster 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.

+
+
+
+

Job Registry

42 JOBS
+
+
+ + + + + + + + + + +
Job keyGroupClassDurableRecoveryConcurrentTriggersNext run
InvoiceSyncJobbillingit.fabioformosa.jobs.InvoiceSyncJobYESYESDISALLOW309:45
LedgerCloseJobfinanceit.fabioformosa.jobs.LedgerCloseJobYESNOALLOW118:00
TenantCleanupJobmaintenanceit.fabioformosa.jobs.TenantCleanupJobYESYESDISALLOW2paused
ErpRetryJobintegrationsit.fabioformosa.jobs.ErpRetryJobYESYESALLOW4error
CacheWarmupJobplatformit.fabioformosa.jobs.CacheWarmupJobNONODISALLOW109:47
WelcomeEmailJobcrmit.fabioformosa.jobs.WelcomeEmailJobNONOALLOW0complete
+
+ +
+
+
+
+
+

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.

+
+
+
+

Trigger Inventory

38 ACTIVE4 PAUSED1 ERROR
+
+
+ + + + + + + + + + +
TriggerGroupTypeStateJobNext firePrevious fireMisfire
invoice-sync-5mbillingSimpleTriggerNORMALInvoiceSyncJob09:4509:40FIRE_NOW
daily-ledger-closefinanceCronTriggerNORMALLedgerCloseJob18:00yesterdaySMART
tenant-cleanupmaintenanceDailyTimeIntervalPAUSEDTenantCleanupJobpaused08:30DO_NOTHING
erp-retry-windowintegrationsCalendarIntervalERRORErpRetryJobreset09:20FIRE_ONCE
weekly-reportanalyticsCronTriggerNORMALReportJobMon 07:00Mon 07:00SMART
cache-warmupplatformSimpleTriggerBLOCKEDCacheWarmupJob09:4709:42IGNORE
+
+ +
+
+
+
+
+

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.

+
+
+
+

Calendar Registry

7 CALENDARS21 TRIGGERS USING CALENDARS
+
+
+ + + + + + + + + +
CalendarTypeBase calendarTriggersNext excludedDescription
business-daysWeeklyCalendarcompany-holidays142026-05-16 00:00Exclude Saturday and Sunday.
company-holidaysHolidayCalendarnone92026-06-02 00:00Italian public holidays.
month-end-freezeMonthlyCalendarbusiness-days32026-05-31 00:00Exclude month-end close window.
batch-windowDailyCalendarbusiness-days42026-05-11 20:00Allow 06:00 to 20:00 only.
cron-blackoutCronCalendarnone1Fri 23:00Exclude release windows.
+
+ +
+
+
+

Weekly Time Grid

A visual editor for DailyCalendar / WeeklyCalendar windows
+
+
+ MonTueWedThuFriSat + 06:00openopenopenopenopenclosed + 12:00openopenopenopenopenclosed + 20:00closedclosedclosedclosedrelease freezeclosed +
+
+
+
+
+
+

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.

+
+
+
+

Currently Executing Jobs

3 RUNNING1 RECOVERING
+
+
+ + + + + + + +
Fire instanceJobTriggerScheduledActualRun timeRefireNode
node-a7f3-119238InvoiceSyncJobinvoice-sync-5m09:40:0009:40:0304:210node-a7f3
node-b912-119239CacheWarmupJobcache-warmup09:42:3009:42:3101:530node-b912
node-a7f3-119240ErpRetryJoberp-retry-window09:43:0009:43:0800:462node-a7f3
+
+ +
+
+
+

Recent Execution History

+
+ + + + + + +
CompletedJobTriggerDurationResultNodeMessage
09:39:58ReportJobweekly-report00:11SUCCESSnode-b912Report generated for analytics group.
09:38:22ErpRetryJoberp-retry-window00:31FAILEDnode-a7f3ResourceAccessException 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.

+
+
+
+
+

Live Events

STREAMING142 EVENTS / HOUR
+
+
TimeSeverityTypeSourceMessage
+
09:44:18INFOJOB_PROGRESSbillingInvoiceSyncJob processed 144 of 200 invoices for fireInstanceId node-a7f3-119238.
+
09:43:56WARNMISFIREfinancedaily-ledger-close missed fire at 09:30; SMART_POLICY resolved to FIRE_ONCE_NOW.
+
09:42:31ERRORTRIGGER_ERRORintegrationserp-retry-window entered ERROR after ErpRetryJob threw ResourceAccessException.
+
09:41:05INFOSCHEDULERnode-a7f3Cluster check-in completed. 2 scheduler instances active.
+
09:40:03INFOJOB_STARTEDbillingInvoiceSyncJob started from trigger invoice-sync-5m.
+
09:39:58INFOJOB_COMPLETEDanalyticsReportJob completed in 11 seconds on node-b912.
+
+
+ +
+
+
+
+

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
+
+
+
+

Lifecycle Controls

Global actions affect all jobs, triggers, calendars, and running executions
+
+
+
Strong confirmation requiredShutdown stops the scheduler instance. Clear removes all scheduling data from the scheduler. Both actions should require typed confirmation and role checks.
+
+
+
+

Scheduler Metadata

JDBC JOB STORE
+
+
quartz-manager-scheduler
+
node-a7f3
+
2.3.2
+
2026-05-11 08:14:03
+
SimpleThreadPool
+
12
+
JobStoreTX
+
true
+
+
+
+

Cluster Nodes

2 ACTIVE
+
+
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
+
+
+
+

Global State Overview

3 EXECUTING4 PAUSED GROUPS1 ERROR TRIGGER
+
+ + + + + + + + +
AreaCurrent stateCountRepresentative keyRecommended action
Executing jobsRUNNING3InvoiceSyncJobOpen Executions before interrupting anything.
Paused groupsPAUSED4maintenanceResume group only after maintenance window closes.
Error triggersERROR1erp-retry-windowResolve root cause, then reset from Triggers page.
MisfiresMISFIRE5 / hourdaily-ledger-closeReview thread pool saturation and misfire policy.
+
+
+
+
+
+
+ +
+ + +