From 4ac74ad07faf60f57755d1937b4eb98a3564e260 Mon Sep 17 00:00:00 2001 From: Joshua Smith Date: Thu, 11 Dec 2025 14:43:30 -0700 Subject: [PATCH 1/5] new task hourly summary --- apps/flowlord/handler.go | 22 ++- apps/flowlord/handler/static/style.css | 71 +++++++ apps/flowlord/handler/static/task.js | 43 ++--- apps/flowlord/handler/task.tmpl | 249 ++++++++++++++++++++++--- apps/flowlord/sqlite/stats.go | 93 +++++++++ 5 files changed, 422 insertions(+), 56 deletions(-) diff --git a/apps/flowlord/handler.go b/apps/flowlord/handler.go index a4823be..fb7489e 100644 --- a/apps/flowlord/handler.go +++ b/apps/flowlord/handler.go @@ -542,8 +542,11 @@ func taskHTML(tasks []sqlite.TaskView, taskStats sqlite.TaskStats, totalCount in prevDate := date.AddDate(0, 0, -1) nextDate := date.AddDate(0, 0, 1) - // Get aggregate counts from TaskStats - counts := taskStats.TotalCounts() + // Get unfiltered counts for summary section (always show full day stats) + unfilteredCounts := taskStats.TotalCounts() + + // Get filtered hourly breakdown (respects filters) + _, hourlyStats := taskStats.GetCountsWithHourlyFiltered(filter) // Get unique types and jobs from TaskStats for filter dropdowns types := taskStats.UniqueTypes() @@ -564,12 +567,13 @@ func taskHTML(tasks []sqlite.TaskView, taskStats sqlite.TaskStats, totalCount in } data := map[string]interface{}{ - "Date": date.Format("Monday, January 2, 2006"), - "DateValue": date.Format("2006-01-02"), - "PrevDate": prevDate.Format("2006-01-02"), - "NextDate": nextDate.Format("2006-01-02"), - "Tasks": tasks, - "Counts": counts, + "Date": date.Format("Monday, January 2, 2006"), + "DateValue": date.Format("2006-01-02"), + "PrevDate": prevDate.Format("2006-01-02"), + "NextDate": nextDate.Format("2006-01-02"), + "Tasks": tasks, + "Counts": unfilteredCounts, + "HourlyStats": hourlyStats, "Filter": filter, "CurrentPage": "task", "PageTitle": "Task Dashboard", @@ -603,7 +607,7 @@ func taskHTML(tasks []sqlite.TaskView, taskStats sqlite.TaskStats, totalCount in // Single consolidated log with all metrics log.Printf("Task page: date=%s filters=[id=%q type=%q job=%q result=%q] total=%d filtered=%d page=%d/%d query=%v render=%v size=%.2fMB", date.Format("2006-01-02"), filter.ID, filter.Type, filter.Job, filter.Result, - counts.Total, totalCount, filter.Page, totalPages, + unfilteredCounts.Total, totalCount, filter.Page, totalPages, queryTime, renderTime, float64(htmlSize)/(1024*1024)) return buf.Bytes() diff --git a/apps/flowlord/handler/static/style.css b/apps/flowlord/handler/static/style.css index 930852a..87ebac0 100644 --- a/apps/flowlord/handler/static/style.css +++ b/apps/flowlord/handler/static/style.css @@ -793,6 +793,8 @@ th.status-cell, td.status-cell { } .collapsible-content { + max-height: none; + opacity: 1; transition: all 0.3s ease; overflow: hidden; } @@ -802,6 +804,22 @@ th.status-cell, td.status-cell { opacity: 0; } +/* Chart Container Styles */ +.chart-container { + width: 100%; + padding: 20px; + background: #ffffff; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +#hourlyChart { + display: block; + width: 100%; + height: 400px; + cursor: default; +} + .summary-section h3 { margin: 0 0 16px 0; color: #495057; @@ -809,6 +827,59 @@ th.status-cell, td.status-cell { font-weight: 600; } +.summary-stats-text { + padding: 15px 20px; + background: #f8f9fa; + border-radius: 6px; + font-size: 15px; + color: #495057; + font-weight: 500; + margin-top: 16px; + border: 1px solid #dee2e6; + display: flex; + flex-wrap: wrap; + gap: 20px; + align-items: center; +} + +.summary-stat-item { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.stat-color-box { + width: 14px; + height: 14px; + border-radius: 2px; + display: inline-block; + border: 1px solid rgba(0, 0, 0, 0.1); +} + +.stat-total { + background: #6c757d; +} + +.stat-completed { + background: #4caf50; +} + +.stat-error { + background: #f44336; +} + +.stat-alert { + background: #ff9800; +} + +.stat-warn { + background: #ffc107; +} + +.stat-running { + background: #2196f3; +} + .summary { background: #ecf0f1; padding: 15px 20px; diff --git a/apps/flowlord/handler/static/task.js b/apps/flowlord/handler/static/task.js index 43cef1c..b7d4a1a 100644 --- a/apps/flowlord/handler/static/task.js +++ b/apps/flowlord/handler/static/task.js @@ -4,6 +4,9 @@ // Initialize task page with configuration function initTaskPage(config) { + // Always initialize filters, even if there's no table + initializeFilters(config); + const table = document.getElementById('taskTable'); if (!table) { return; @@ -136,9 +139,6 @@ }); }); - // Initialize filters - initializeFilters(config); - // Event delegation for expand/collapse on click if (tbody) { tbody.addEventListener('click', function(e) { @@ -182,9 +182,8 @@ function initializeFilters(config) { const typeFilter = document.getElementById('typeFilter'); const jobFilter = document.getElementById('jobFilter'); - const table = document.getElementById('taskTable'); - if (!table || !config) return; + if (!typeFilter || !jobFilter || !config) return; const taskTypes = config.taskTypes || []; const jobMap = new Map(config.jobsByType || []); @@ -243,18 +242,28 @@ jobFilter.addEventListener('change', function() { applyFilters(); }); + + // Handle result change - reload page with filter + const resultFilter = document.getElementById('resultFilter'); + if (resultFilter) { + resultFilter.addEventListener('change', function() { + applyFilters(); + }); + } } // Apply filters by reloading page with query parameters function applyFilters() { const typeFilter = document.getElementById('typeFilter'); const jobFilter = document.getElementById('jobFilter'); + const resultFilter = document.getElementById('resultFilter'); const url = new URL(window.location); url.searchParams.delete('page'); // Reset to page 1 when filtering const selectedType = typeFilter ? typeFilter.value : ''; const selectedJob = jobFilter ? jobFilter.value : ''; + const selectedResult = resultFilter ? resultFilter.value : ''; if (selectedType) { url.searchParams.set('type', selectedType); @@ -268,6 +277,12 @@ url.searchParams.delete('job'); } + if (selectedResult) { + url.searchParams.set('result', selectedResult); + } else { + url.searchParams.delete('result'); + } + window.location.href = url.toString(); } @@ -309,24 +324,6 @@ window.location.href = url.toString(); }; - // Filter by result type using stat-cards - window.filterByResult = function(resultType) { - const url = new URL(window.location); - url.searchParams.delete('page'); // Reset to page 1 - - // Reset task type and job filters when clicking on stat cards - url.searchParams.delete('type'); - url.searchParams.delete('job'); - - if (resultType === 'all') { - url.searchParams.delete('result'); - } else { - url.searchParams.set('result', resultType); - } - - window.location.href = url.toString(); - }; - // Toggle collapsible section window.toggleCollapsible = function(sectionId) { const content = document.getElementById(sectionId + '-content'); diff --git a/apps/flowlord/handler/task.tmpl b/apps/flowlord/handler/task.tmpl index 2c20329..1fd4fcc 100644 --- a/apps/flowlord/handler/task.tmpl +++ b/apps/flowlord/handler/task.tmpl @@ -12,33 +12,46 @@
-

Task Summary

-
-
-
{{.Counts.Total}}
-
Total Tasks
+

Summary

+ {{if .HourlyStats}} +
+
+

Hourly Task Breakdown

+ â–ŧ
-
-
{{.Counts.Completed}}
-
Completed
-
-
-
{{.Counts.Error}}
-
Errors
-
-
-
{{.Counts.Alert}}
-
Alerts
-
-
-
{{.Counts.Warn}}
-
Warnings
-
-
-
{{.Counts.Running}}
-
Running
+
+
+ +
+ {{end}} +
+ + + Total: {{.Counts.Total}} + + + + Completed: {{.Counts.Completed}} + + + + Errors: {{.Counts.Error}} + + + + Alerts: {{.Counts.Alert}} + + + + Warnings: {{.Counts.Warn}} + + + + Running: {{.Counts.Running}} + +
{{if gt .TotalPages 1}} @@ -75,6 +88,17 @@
+
+ + +
@@ -176,8 +200,185 @@ } }); } + + // Render hourly task chart + {{if .HourlyStats}} + renderHourlyChart(); + + // Re-render chart on window resize with debouncing + let resizeTimeout; + window.addEventListener('resize', function() { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(function() { + renderHourlyChart(); + }, 150); + }); + {{end}} }); + function renderHourlyChart() { + const canvas = document.getElementById('hourlyChart'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + + // Get hourly data from template + const hourlyData = [ + {{range $i, $stats := .HourlyStats}} + { + hour: {{$i}}, + completed: {{$stats.Completed}}, + error: {{$stats.Error}}, + alert: {{$stats.Alert}}, + warn: {{$stats.Warn}}, + running: {{$stats.Running}}, + total: {{$stats.Total}} + }, + {{end}} + ]; + + // Chart dimensions and styling + const padding = { top: 20, right: 40, bottom: 60, left: 60 }; + const dpr = window.devicePixelRatio || 1; + + // Set canvas size + canvas.style.width = '100%'; + canvas.style.height = '400px'; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + + const width = rect.width; + const height = rect.height; + const chartWidth = width - padding.left - padding.right; + const chartHeight = height - padding.top - padding.bottom; + + // Calculate nice round max value with buffer + function getNiceMax(value) { + if (value === 0) return 20; + + // Add 12% buffer for visual spacing + const buffered = value * 1.12; + + // Try nice intervals that divide evenly by 4 + // This gives us 5 grid lines with clean tick marks + // intervals of: 5, 10, 25, 50, 75, 100, 125, 250, etc. + const niceIntervals = [ + 5, 10, 25, 50, 75, 100, 125, 250, 500, 750, + 1000, 1250, 2500, 5000, 7500, 10000, 25000, 50000 + ]; + + for (let interval of niceIntervals) { + const maxValue = interval * 4; // 4 intervals = 5 grid lines + if (maxValue >= buffered) { + return maxValue; + } + } + + // For very large numbers, round up to nearest multiple of 100000 + return Math.ceil(buffered / 100000) * 100000; + } + + // Find max total and round to nice number + const actualMax = Math.max(...hourlyData.map(d => d.total), 1); + const maxTotal = getNiceMax(actualMax); + + // Color scheme matching the summary cards + const colors = { + completed: '#4caf50', + error: '#f44336', + alert: '#ff9800', + warn: '#ffc107', + running: '#2196f3' + }; + + // Draw grid lines and y-axis labels + ctx.strokeStyle = '#e0e0e0'; + ctx.lineWidth = 1; + ctx.fillStyle = '#666'; + ctx.font = '12px system-ui, -apple-system, sans-serif'; + ctx.textAlign = 'right'; + + // Use 4 intervals (5 lines including 0) + const numIntervals = 4; + const tickInterval = maxTotal / numIntervals; + + for (let i = 0; i <= numIntervals; i++) { + const value = Math.round(tickInterval * i); + const y = padding.top + chartHeight - (chartHeight / numIntervals) * i; + + // Draw grid line + ctx.beginPath(); + ctx.moveTo(padding.left, y); + ctx.lineTo(padding.left + chartWidth, y); + ctx.stroke(); + + // Draw y-axis label + ctx.fillText(value.toString(), padding.left - 10, y + 4); + } + + // Calculate bar width + const barWidth = chartWidth / 24; + const barPadding = barWidth * 0.1; + const actualBarWidth = barWidth - barPadding; + + // Draw bars for each hour + hourlyData.forEach((data, index) => { + if (data.total === 0) return; + + const x = padding.left + (index * barWidth) + (barPadding / 2); + const scale = chartHeight / maxTotal; + + let currentY = padding.top + chartHeight; + + // Stack the bars from bottom to top + const segments = [ + { value: data.completed, color: colors.completed }, + { value: data.error, color: colors.error }, + { value: data.alert, color: colors.alert }, + { value: data.warn, color: colors.warn }, + { value: data.running, color: colors.running } + ]; + + segments.forEach(segment => { + if (segment.value > 0) { + const segmentHeight = segment.value * scale; + currentY -= segmentHeight; + + ctx.fillStyle = segment.color; + ctx.fillRect(x, currentY, actualBarWidth, segmentHeight); + } + }); + }); + + // Draw x-axis labels (hours) + ctx.fillStyle = '#666'; + ctx.textAlign = 'center'; + ctx.font = '11px system-ui, -apple-system, sans-serif'; + + for (let i = 0; i < 24; i++) { + const x = padding.left + (i * barWidth) + (barWidth / 2); + const label = i.toString().padStart(2, '0'); + ctx.fillText(label, x, padding.top + chartHeight + 20); + } + + // Draw axis labels + ctx.fillStyle = '#333'; + ctx.font = 'bold 14px system-ui, -apple-system, sans-serif'; + + // X-axis label + ctx.textAlign = 'center'; + ctx.fillText('Hour of Day', width / 2, height - 10); + + // Y-axis label + ctx.save(); + ctx.translate(15, height / 2); + ctx.rotate(-Math.PI / 2); + ctx.fillText('Number of Tasks', 0, 0); + ctx.restore(); + } + // Search by task ID - resets other filters function searchById() { const idValue = document.getElementById('idFilter').value.trim(); diff --git a/apps/flowlord/sqlite/stats.go b/apps/flowlord/sqlite/stats.go index f372c50..c6781fb 100644 --- a/apps/flowlord/sqlite/stats.go +++ b/apps/flowlord/sqlite/stats.go @@ -228,3 +228,96 @@ func (ts TaskStats) TotalCounts() TaskCounts { return counts } + +// GetCountsWithHourly returns both total and hourly task counts in a single iteration +// The hourly array contains 24 TaskCounts where index represents the hour (0-23) +func (ts TaskStats) GetCountsWithHourly() (TaskCounts, [24]TaskCounts) { + return ts.GetCountsWithHourlyFiltered(nil) +} + +// GetCountsWithHourlyFiltered returns total and hourly counts with optional filtering by type, job, and result +// The hourly array contains 24 TaskCounts where index represents the hour (0-23) +func (ts TaskStats) GetCountsWithHourlyFiltered(filter *TaskFilter) (TaskCounts, [24]TaskCounts) { + var total TaskCounts + var hourly [24]TaskCounts + + for key, stats := range ts { + // Apply type and job filters + if filter != nil { + // Parse key format "type:job" + parts := strings.SplitN(key, ":", 2) + taskType := parts[0] + taskJob := "" + if len(parts) == 2 { + taskJob = parts[1] + } + + // Skip if type filter doesn't match + if filter.Type != "" && taskType != filter.Type { + continue + } + + // Skip if job filter doesn't match + if filter.Job != "" && taskJob != filter.Job { + continue + } + } + + // Process completed tasks + if filter == nil || filter.Result == "" || filter.Result == "complete" { + for _, t := range stats.CompletedTimes { + hour := t.Hour() + hourly[hour].Completed++ + hourly[hour].Total++ + total.Completed++ + total.Total++ + } + } + + // Process error tasks + if filter == nil || filter.Result == "" || filter.Result == "error" { + for _, t := range stats.ErrorTimes { + hour := t.Hour() + hourly[hour].Error++ + hourly[hour].Total++ + total.Error++ + total.Total++ + } + } + + // Process alert tasks + if filter == nil || filter.Result == "" || filter.Result == "alert" { + for _, t := range stats.AlertTimes { + hour := t.Hour() + hourly[hour].Alert++ + hourly[hour].Total++ + total.Alert++ + total.Total++ + } + } + + // Process warn tasks + if filter == nil || filter.Result == "" || filter.Result == "warn" { + for _, t := range stats.WarnTimes { + hour := t.Hour() + hourly[hour].Warn++ + hourly[hour].Total++ + total.Warn++ + total.Total++ + } + } + + // Process running tasks + if filter == nil || filter.Result == "" || filter.Result == "running" { + for _, t := range stats.RunningTimes { + hour := t.Hour() + hourly[hour].Running++ + hourly[hour].Total++ + total.Running++ + total.Total++ + } + } + } + + return total, hourly +} From 5bafb99d616c9b613ac20d0ae15dd63bb54fe81e Mon Sep 17 00:00:00 2001 From: Joshua Smith Date: Thu, 8 Jan 2026 14:02:58 -0700 Subject: [PATCH 2/5] about page cleanup --- apps/flowlord/handler.go | 1 + apps/flowlord/handler/about.tmpl | 51 +++++++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/flowlord/handler.go b/apps/flowlord/handler.go index fb7489e..69202d9 100644 --- a/apps/flowlord/handler.go +++ b/apps/flowlord/handler.go @@ -682,6 +682,7 @@ func (tm *taskMaster) aboutHTML() []byte { "AppName": sts.AppName, "Version": sts.Version, "RunTime": sts.RunTime, + "StartTime": tm.initTime.Format(time.RFC3339), "LastUpdate": sts.LastUpdate, "NextUpdate": sts.NextUpdate, "TotalDBSize": dbSize.TotalSize, diff --git a/apps/flowlord/handler/about.tmpl b/apps/flowlord/handler/about.tmpl index ae77ba2..641a499 100644 --- a/apps/flowlord/handler/about.tmpl +++ b/apps/flowlord/handler/about.tmpl @@ -25,8 +25,9 @@
Runtime - {{.RunTime}} + {{.RunTime}}
+
@@ -112,7 +113,7 @@ {{range .TableStats}} {{.Name}} - {{.RowCount}} + {{.RowCount}} {{.TableHuman}} {{.IndexHuman}} {{.TotalHuman}} @@ -127,12 +128,46 @@
From 163cf26477cf57866b0ca7de27796a0acc62ef2a Mon Sep 17 00:00:00 2001 From: Joshua Smith Date: Thu, 8 Jan 2026 15:39:49 -0700 Subject: [PATCH 3/5] add backload tab --- apps/flowlord/handler.go | 63 +++ apps/flowlord/handler/backload.tmpl | 694 +++++++++++++++++++++++++ apps/flowlord/handler/header.tmpl | 8 + apps/flowlord/handler/static/style.css | 297 +++++++++++ apps/flowlord/handler_test.go | 26 + apps/flowlord/sqlite/workflow.go | 16 + apps/flowlord/taskmaster.go | 2 +- internal/test/workflow/jobs.toml | 2 +- 8 files changed, 1106 insertions(+), 2 deletions(-) create mode 100644 apps/flowlord/handler/backload.tmpl diff --git a/apps/flowlord/handler.go b/apps/flowlord/handler.go index 69202d9..e0573bd 100644 --- a/apps/flowlord/handler.go +++ b/apps/flowlord/handler.go @@ -47,6 +47,9 @@ var HeaderTemplate string //go:embed handler/about.tmpl var AboutTemplate string +//go:embed handler/backload.tmpl +var BackloadTemplate string + //go:embed handler/static/* var StaticFiles embed.FS @@ -128,6 +131,7 @@ func (tm *taskMaster) StartHandler() { router.Get("/web/files", tm.htmlFiles) router.Get("/web/task", tm.htmlTask) router.Get("/web/workflow", tm.htmlWorkflow) + router.Get("/web/backload", tm.htmlBackload) router.Get("/web/about", tm.htmlAbout) if tm.port == 0 { @@ -717,6 +721,65 @@ func (tm *taskMaster) aboutHTML() []byte { return buf.Bytes() } +// htmlBackload handles GET /web/backload - displays the backload form +func (tm *taskMaster) htmlBackload(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/html") + w.Write(backloadHTML(tm.taskCache)) +} + +// backloadHTML renders the backload form HTML page +func backloadHTML(tCache *sqlite.SQLite) []byte { + // Get all phases grouped by workflow file + phasesByWorkflow := tCache.GetAllPhasesGrouped() + + // Create flat list of phases for JSON encoding + type phaseJSON struct { + Workflow string `json:"workflow"` + Task string `json:"task"` + Job string `json:"job"` + Template string `json:"template"` + Rule string `json:"rule"` + DependsOn string `json:"dependsOn"` + } + var allPhases []phaseJSON + for workflow, phases := range phasesByWorkflow { + for _, p := range phases { + allPhases = append(allPhases, phaseJSON{ + Workflow: workflow, + Task: p.Topic(), + Job: p.Job(), + Template: p.Template, + Rule: p.Rule, + DependsOn: p.DependsOn, + }) + } + } + phasesJSON, _ := json.Marshal(allPhases) + + data := map[string]interface{}{ + "PhasesByWorkflow": phasesByWorkflow, + "PhasesJSON": template.JS(phasesJSON), + "CurrentPage": "backload", + "PageTitle": "Backload Tasks", + "isLocal": isLocal, + "DatesWithData": []string{}, // Backload page doesn't use date picker with highlights + } + + // Parse and execute template using the shared funcMap + tmpl, err := template.New("backload").Funcs(getBaseFuncMap()).Parse(HeaderTemplate + BackloadTemplate) + if err != nil { + return []byte("Error parsing template: " + err.Error()) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return []byte("Error executing template: " + err.Error()) + } + + return buf.Bytes() +} + // AlertData holds both the alerts and summary data for the template type AlertData struct { Alerts []sqlite.AlertRecord diff --git a/apps/flowlord/handler/backload.tmpl b/apps/flowlord/handler/backload.tmpl new file mode 100644 index 0000000..0787820 --- /dev/null +++ b/apps/flowlord/handler/backload.tmpl @@ -0,0 +1,694 @@ + + + + + + Flowlord: Backload + + + + + {{template "header" .}} +
+
+
+
+ +
+

Task Selection

+
+ +
+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+

Date

+
+ +
+ + +
+
+
+
+ + +
+
+ +
+ + +
+
+
+ + + + + + + + + + + +
+ + + +
+ + + + + + + + + +
+
+
+ + + + diff --git a/apps/flowlord/handler/header.tmpl b/apps/flowlord/handler/header.tmpl index 27c90e2..cea56b1 100644 --- a/apps/flowlord/handler/header.tmpl +++ b/apps/flowlord/handler/header.tmpl @@ -34,6 +34,10 @@ đŸŒŗ Workflow + + 🔄 + Backload + â„šī¸ About @@ -55,6 +59,10 @@ đŸŒŗ Workflow + + 🔄 + Backload + â„šī¸ About diff --git a/apps/flowlord/handler/static/style.css b/apps/flowlord/handler/static/style.css index 87ebac0..cc7b4ea 100644 --- a/apps/flowlord/handler/static/style.css +++ b/apps/flowlord/handler/static/style.css @@ -1358,4 +1358,301 @@ th.status-cell, td.status-cell { .pagination-controls .btn { padding: 0.5rem 1rem; text-decoration: none; +} + +/* ===== BACKLOAD FORM STYLES ===== */ +.backload-form { + padding: 20px; +} + +.backload-form .info-card { + margin-bottom: 20px; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + font-size: 14px; + font-weight: 500; + color: #495057; + margin-bottom: 6px; +} + +.form-control { + width: 100%; + padding: 10px 12px; + font-size: 14px; + border: 1px solid #ced4da; + border-radius: 4px; + background-color: white; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + box-sizing: border-box; +} + +.form-control:focus { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.15); +} + +.form-control:disabled { + background-color: #e9ecef; + cursor: not-allowed; +} + +select.form-control { + background-color: white; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 8px center; + background-repeat: no-repeat; + background-size: 16px; + padding-right: 32px; +} + +select.form-control:disabled { + background-color: #e9ecef; +} + +.form-hint { + display: block; + font-size: 12px; + color: #6c757d; + margin-top: 4px; +} + +.toggle-group { + display: inline-flex; + border: 1px solid #ced4da; + border-radius: 4px; + overflow: hidden; +} + +.toggle-btn { + padding: 8px 20px; + border: none; + background: #f8f9fa; + color: #495057; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.toggle-btn:not(:last-child) { + border-right: 1px solid #ced4da; +} + +.toggle-btn:hover { + background: #e9ecef; +} + +.toggle-btn.active { + background: #3498db; + color: white; +} + +.template-section { + background: #f8f9fa; +} + +.template-display { + background: white; + border: 1px solid #e1e5e9; + border-radius: 4px; + padding: 12px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 13px; + color: #495057; + word-break: break-word; + white-space: pre-wrap; + line-height: 1.6; + max-height: 200px; + overflow-y: auto; +} + +.meta-hint { + color: #6c757d; + font-size: 14px; + margin-bottom: 16px; +} + +.action-buttons { + display: flex; + gap: 12px; + padding: 20px 0; + border-top: 1px solid #e1e5e9; + margin-top: 10px; +} + +.btn-primary { + background: #3498db; + color: white; + border: 1px solid #2980b9; +} + +.btn-primary:hover { + background: #2980b9; +} + +.btn-primary:disabled { + background: #a0c4e8; + border-color: #a0c4e8; + cursor: not-allowed; +} + +.btn-success { + background: #27ae60; + color: white; + border: 1px solid #1e8449; +} + +.btn-success:hover { + background: #1e8449; +} + +.btn-success:disabled { + background: #82c99b; + border-color: #82c99b; + cursor: not-allowed; +} + +.preview-status, .execution-status { + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 16px; + font-weight: 500; +} + +.preview-status.info { + background: #d1ecf1; + color: #0c5460; + border: 1px solid #bee5eb; +} + +.preview-status.error, .execution-status.error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.execution-status.success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +#previewTable { + margin-top: 16px; +} + +/* Preview table column widths */ +#previewTable { + table-layout: fixed; + width: 100%; +} + +#previewTable .num-column { + width: 40px; + max-width: 40px; + text-align: center; +} + +#previewTable .type-column { + width: 100px; + max-width: 120px; +} + +#previewTable .job-column { + width: 100px; + max-width: 120px; +} + +#previewTable .info-column, +#previewTable .meta-column { + width: auto; +} + +#previewTable .info-cell, +#previewTable .meta-cell { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 13px; + word-break: break-word; + white-space: normal; + line-height: 1.4; +} + +#previewTable .num-cell { + text-align: center; + color: #6c757d; +} + +/* Request body display */ +.request-body-display { + background: #1e1e1e; + color: #d4d4d4; + border: 1px solid #333; + border-radius: 4px; + padding: 12px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 13px; + line-height: 1.5; + max-height: 300px; + overflow: auto; + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} + +/* Search dropdown styles */ +.search-select-container { + position: relative; +} + +.search-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #ced4da; + border-top: none; + border-radius: 0 0 4px 4px; + max-height: 250px; + overflow-y: auto; + z-index: 100; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.search-dropdown-item { + padding: 10px 12px; + cursor: pointer; + font-size: 14px; + color: #495057; + border-bottom: 1px solid #f0f0f0; +} + +.search-dropdown-item:last-child { + border-bottom: none; +} + +.search-dropdown-item:hover { + background: #f8f9fa; +} + +.search-dropdown-item strong { + color: #3498db; +} + +.search-dropdown-empty { + padding: 12px; + color: #6c757d; + font-style: italic; + text-align: center; } \ No newline at end of file diff --git a/apps/flowlord/handler_test.go b/apps/flowlord/handler_test.go index 5b46de5..be39aac 100644 --- a/apps/flowlord/handler_test.go +++ b/apps/flowlord/handler_test.go @@ -588,6 +588,32 @@ func generateSummary(tasks []sqlite.TaskView) sqlite.TaskStats { return sqlite.TaskStats(data) } +func TestBackloadHTML(t *testing.T) { + // Load workflow files + taskCache := &sqlite.SQLite{LocalPath: ":memory:"} + if err := taskCache.Open(testPath+"/workflow/", nil); err != nil { + t.Fatalf("Failed to create test cache: %v", err) + } + + // Test with no filters - summary will be generated from tasks data + html := backloadHTML(taskCache) + + // Validate HTML using the new function + if err := validateHTML(html); err != nil { + t.Errorf("HTML validation failed: %v", err) + } + + // Write HTML to a file for easy viewing + outputFile := "handler/backload_preview.html" + err := os.WriteFile(outputFile, html, 0644) + if err != nil { + t.Fatalf("Failed to write HTML file: %v", err) + } + + t.Logf("backload preview generated and saved to: ./%s", outputFile) + +} + func TestWorkflowHTML(t *testing.T) { // Load workflow files taskCache := &sqlite.SQLite{LocalPath: ":memory:"} diff --git a/apps/flowlord/sqlite/workflow.go b/apps/flowlord/sqlite/workflow.go index 2cc5b9e..de4103b 100644 --- a/apps/flowlord/sqlite/workflow.go +++ b/apps/flowlord/sqlite/workflow.go @@ -364,6 +364,22 @@ func (s *SQLite) GetWorkflowFiles() []string { return files } +// GetAllPhasesGrouped returns all phases grouped by workflow file +func (s *SQLite) GetAllPhasesGrouped() map[string][]PhaseDB { + result := make(map[string][]PhaseDB) + + workflowFiles := s.GetWorkflowFiles() + for _, filePath := range workflowFiles { + phases, err := s.GetPhasesForWorkflow(filePath) + if err != nil { + continue + } + result[filePath] = phases + } + + return result +} + // GetPhasesForWorkflow returns all phases for a specific workflow file func (s *SQLite) GetPhasesForWorkflow(filePath string) ([]PhaseDB, error) { rows, err := s.db.Query(` diff --git a/apps/flowlord/taskmaster.go b/apps/flowlord/taskmaster.go index 9bd8ac4..87d482d 100644 --- a/apps/flowlord/taskmaster.go +++ b/apps/flowlord/taskmaster.go @@ -176,7 +176,7 @@ func (tm *taskMaster) refreshCache() ([]string, error) { if len(files) > 0 { log.Println("reloading workflow changes") tcron := tm.cron - tm.cron = cron.New(cron.WithSeconds()) + tm.cron = cron.New(cron.WithParser(cronParser)) if err := tm.schedule(); err != nil { tm.cron = tcron // revert to old cron schedule return files, fmt.Errorf("cron schedule: %w", err) diff --git a/internal/test/workflow/jobs.toml b/internal/test/workflow/jobs.toml index 2a2ff05..2dab99b 100644 --- a/internal/test/workflow/jobs.toml +++ b/internal/test/workflow/jobs.toml @@ -1,6 +1,6 @@ [[phase]] task = "worker" -rule = "cron=0 */5 * * *?job=parent_job" +rule = "cron=0 */5 * * *&job=parent_job" retry = 3 template = "?date={yyyy}-{mm}-{dd}T{hh}" From 1124859af9c46a3b84b76dfc6ae3aa9c4826826c Mon Sep 17 00:00:00 2001 From: Joshua Smith Date: Thu, 8 Jan 2026 16:21:38 -0700 Subject: [PATCH 4/5] cleanup of css,js and tmpl files --- apps/flowlord/handler/alert.tmpl | 276 +-------- apps/flowlord/handler/backload.tmpl | 532 +---------------- apps/flowlord/handler/static/backload.js | 635 +++++++++++++++++++++ apps/flowlord/handler/static/style.css | 30 + apps/flowlord/handler/static/table-sort.js | 185 ++++++ apps/flowlord/handler/static/utils.js | 67 ++- apps/flowlord/handler/workflow.tmpl | 289 +--------- 7 files changed, 938 insertions(+), 1076 deletions(-) create mode 100644 apps/flowlord/handler/static/backload.js create mode 100644 apps/flowlord/handler/static/table-sort.js diff --git a/apps/flowlord/handler/alert.tmpl b/apps/flowlord/handler/alert.tmpl index ae7f3ee..f20202f 100644 --- a/apps/flowlord/handler/alert.tmpl +++ b/apps/flowlord/handler/alert.tmpl @@ -41,9 +41,9 @@ {{.TaskID}} @@ -51,9 +51,9 @@ {{.Job}} {{.Msg}} @@ -69,269 +69,17 @@
+ - \ No newline at end of file diff --git a/apps/flowlord/handler/backload.tmpl b/apps/flowlord/handler/backload.tmpl index 0787820..4689397 100644 --- a/apps/flowlord/handler/backload.tmpl +++ b/apps/flowlord/handler/backload.tmpl @@ -161,534 +161,14 @@ + diff --git a/apps/flowlord/handler/static/backload.js b/apps/flowlord/handler/static/backload.js new file mode 100644 index 0000000..6b32039 --- /dev/null +++ b/apps/flowlord/handler/static/backload.js @@ -0,0 +1,635 @@ +// Backload form functionality +(function() { + 'use strict'; + + // Module state + let allPhases = []; + let currentPhase = null; + let previewTasks = []; + let searchTimeout = null; + let selectedDropdownIndex = -1; + + // DOM element references (cached on init) + const elements = {}; + + // Initialize the backload form + function init(phasesData, apiEndpoint) { + allPhases = phasesData || []; + + // Cache DOM elements + elements.taskSearch = document.getElementById('taskSearch'); + elements.taskDropdown = document.getElementById('taskDropdown'); + elements.taskSelect = document.getElementById('taskSelect'); + elements.workflowFilter = document.getElementById('workflowFilter'); + elements.jobSelect = document.getElementById('jobSelect'); + elements.templateSection = document.getElementById('templateSection'); + elements.templateDisplay = document.getElementById('templateDisplay'); + elements.ruleDisplay = document.getElementById('ruleDisplay'); + elements.metaSection = document.getElementById('metaSection'); + elements.metaFieldsContainer = document.getElementById('metaFieldsContainer'); + elements.metaFileSection = document.getElementById('metaFileSection'); + elements.metaFileInput = document.getElementById('metaFileInput'); + elements.previewBtn = document.getElementById('previewBtn'); + elements.executeBtn = document.getElementById('executeBtn'); + elements.resetBtn = document.getElementById('resetBtn'); + elements.previewSection = document.getElementById('previewSection'); + elements.previewStatus = document.getElementById('previewStatus'); + elements.previewTableBody = document.getElementById('previewTableBody'); + elements.previewCount = document.getElementById('previewCount'); + elements.executionSection = document.getElementById('executionSection'); + elements.executionStatus = document.getElementById('executionStatus'); + elements.requestBodySection = document.getElementById('requestBodySection'); + elements.requestBodyDisplay = document.getElementById('requestBodyDisplay'); + elements.fromDate = document.getElementById('fromDate'); + elements.toDate = document.getElementById('toDate'); + elements.atDate = document.getElementById('atDate'); + elements.bySelect = document.getElementById('bySelect'); + elements.singleDateInput = document.getElementById('singleDateInput'); + elements.dateRangeInputs = document.getElementById('dateRangeInputs'); + + // Store API endpoint + elements.apiEndpoint = apiEndpoint || '/backload'; + + // Setup event listeners + setupEventListeners(); + + // Initialize date inputs with today's date + initializeDates(); + } + + // Get unique tasks from phases + function getUniqueTasks(workflowFilterValue) { + const taskSet = new Set(); + allPhases.forEach(p => { + if (!workflowFilterValue || p.workflow === workflowFilterValue) { + taskSet.add(p.task); + } + }); + return Array.from(taskSet).sort(); + } + + // Setup all event listeners + function setupEventListeners() { + // Date mode toggle + document.querySelectorAll('.toggle-btn').forEach(btn => { + btn.addEventListener('click', handleDateModeToggle); + }); + + // Task search + elements.taskSearch.addEventListener('input', handleTaskSearchInput); + elements.taskSearch.addEventListener('focus', handleTaskSearchFocus); + elements.taskSearch.addEventListener('keydown', handleTaskSearchKeydown); + + // Task dropdown + elements.taskDropdown.addEventListener('click', handleDropdownClick); + + // Close dropdown when clicking outside + document.addEventListener('click', handleDocumentClick); + + // Workflow filter + elements.workflowFilter.addEventListener('change', handleWorkflowFilterChange); + + // Job selection + elements.jobSelect.addEventListener('change', handleJobSelectChange); + + // Date inputs + ['fromDate', 'toDate', 'atDate'].forEach(id => { + document.getElementById(id).addEventListener('change', updatePreviewButton); + }); + + // Buttons + elements.previewBtn.addEventListener('click', handlePreviewClick); + elements.executeBtn.addEventListener('click', handleExecuteClick); + elements.resetBtn.addEventListener('click', handleResetClick); + } + + // Date mode toggle handler + function handleDateModeToggle() { + document.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active')); + this.classList.add('active'); + + if (this.dataset.mode === 'range') { + elements.dateRangeInputs.style.display = 'block'; + elements.singleDateInput.style.display = 'none'; + } else { + elements.dateRangeInputs.style.display = 'none'; + elements.singleDateInput.style.display = 'block'; + } + updatePreviewButton(); + } + + // Task search input handler + function handleTaskSearchInput() { + clearTimeout(searchTimeout); + const query = this.value.trim(); + + searchTimeout = setTimeout(() => { + showTaskDropdown(query); + }, 100); + } + + // Task search focus handler + function handleTaskSearchFocus() { + this.value = ''; + elements.taskSelect.value = ''; + selectedDropdownIndex = -1; + showTaskDropdown(''); + } + + // Task search keydown handler for keyboard navigation + function handleTaskSearchKeydown(e) { + const items = elements.taskDropdown.querySelectorAll('.search-dropdown-item'); + if (items.length === 0) return; + + switch(e.key) { + case 'ArrowDown': + e.preventDefault(); + selectedDropdownIndex = Math.min(selectedDropdownIndex + 1, items.length - 1); + updateDropdownSelection(items); + break; + case 'ArrowUp': + e.preventDefault(); + selectedDropdownIndex = Math.max(selectedDropdownIndex - 1, 0); + updateDropdownSelection(items); + break; + case 'Enter': + e.preventDefault(); + if (selectedDropdownIndex >= 0 && items[selectedDropdownIndex]) { + const task = items[selectedDropdownIndex].dataset.task; + selectTask(task); + } + break; + case 'Escape': + e.preventDefault(); + elements.taskDropdown.style.display = 'none'; + selectedDropdownIndex = -1; + break; + } + } + + // Update dropdown selection highlight + function updateDropdownSelection(items) { + items.forEach((item, index) => { + if (index === selectedDropdownIndex) { + item.classList.add('selected'); + item.scrollIntoView({ block: 'nearest' }); + } else { + item.classList.remove('selected'); + } + }); + } + + // Select a task + function selectTask(task) { + elements.taskSearch.value = task; + elements.taskSelect.value = task; + elements.taskDropdown.style.display = 'none'; + selectedDropdownIndex = -1; + onTaskSelected(task); + } + + // Show task dropdown with optional filtering + function showTaskDropdown(query) { + const workflow = elements.workflowFilter.value; + const tasks = getUniqueTasks(workflow); + const matches = query + ? tasks.filter(t => t.toLowerCase().includes(query.toLowerCase())) + : tasks; + + selectedDropdownIndex = -1; + + if (matches.length > 0) { + elements.taskDropdown.innerHTML = matches.map(task => + `
${query ? highlightMatch(task, query) : escapeHtml(task)}
` + ).join(''); + elements.taskDropdown.style.display = 'block'; + } else { + elements.taskDropdown.innerHTML = '
No matching tasks
'; + elements.taskDropdown.style.display = 'block'; + } + } + + // Highlight matching text in search results + function highlightMatch(text, query) { + const idx = text.toLowerCase().indexOf(query.toLowerCase()); + if (idx === -1) return escapeHtml(text); + return escapeHtml(text.slice(0, idx)) + '' + escapeHtml(text.slice(idx, idx + query.length)) + '' + escapeHtml(text.slice(idx + query.length)); + } + + // Handle dropdown item click + function handleDropdownClick(e) { + const item = e.target.closest('.search-dropdown-item'); + if (item) { + const task = item.dataset.task; + selectTask(task); + } + } + + // Close dropdown when clicking outside + function handleDocumentClick(e) { + if (!elements.taskSearch.contains(e.target) && !elements.taskDropdown.contains(e.target)) { + elements.taskDropdown.style.display = 'none'; + selectedDropdownIndex = -1; + } + } + + // Workflow filter change handler + function handleWorkflowFilterChange() { + elements.taskSearch.value = ''; + elements.taskSelect.value = ''; + elements.jobSelect.innerHTML = ''; + elements.jobSelect.disabled = true; + hideTemplateInfo(); + updatePreviewButton(); + } + + // Handle task selection + function onTaskSelected(task) { + const workflow = elements.workflowFilter.value; + elements.jobSelect.innerHTML = ''; + elements.jobSelect.disabled = true; + hideTemplateInfo(); + + // Find phases matching this task (optionally filtered by workflow) + const phases = allPhases.filter(p => + p.task === task && (!workflow || p.workflow === workflow) + ); + const jobs = [...new Set(phases.map(p => p.job).filter(j => j))]; + + if (jobs.length > 0) { + jobs.sort().forEach(job => { + const option = document.createElement('option'); + option.value = job; + option.textContent = job; + elements.jobSelect.appendChild(option); + }); + elements.jobSelect.disabled = false; + } else { + // No job needed, use first phase + const phase = phases[0]; + if (phase) { + showTemplateInfo(phase); + } + } + updatePreviewButton(); + } + + // Job selection change handler + function handleJobSelectChange() { + const task = elements.taskSelect.value; + const job = this.value; + const workflow = elements.workflowFilter.value; + + const phase = allPhases.find(p => + p.task === task && + (p.job === job || (!job && !p.job)) && + (!workflow || p.workflow === workflow) + ); + if (phase) { + showTemplateInfo(phase); + } + updatePreviewButton(); + } + + // Format rule string for better readability + function formatRule(str) { + if (!str) return '(no rule)'; + return str.split('&').join('\n'); + } + + // Show template information and detect meta fields + function showTemplateInfo(phase) { + currentPhase = phase; + elements.templateSection.style.display = 'block'; + elements.templateDisplay.textContent = phase.template || '(no template)'; + elements.ruleDisplay.textContent = formatRule(phase.rule); + + // Parse template for meta fields + const metaRegex = /\{meta:(\w+)\}/g; + const metaKeys = []; + let match; + while ((match = metaRegex.exec(phase.template)) !== null) { + if (!metaKeys.includes(match[1])) { + metaKeys.push(match[1]); + } + } + + // Check if rule has meta-file + const hasMetaFile = phase.rule && phase.rule.includes('meta-file='); + + // Show meta fields section if template has meta placeholders and no meta-file in rule + if (metaKeys.length > 0 && !hasMetaFile) { + elements.metaSection.style.display = 'block'; + elements.metaFieldsContainer.innerHTML = ''; + + metaKeys.forEach(key => { + const formGroup = document.createElement('div'); + formGroup.className = 'form-group'; + formGroup.innerHTML = ` + + + Comma-separated values create multiple tasks + `; + elements.metaFieldsContainer.appendChild(formGroup); + }); + } else { + elements.metaSection.style.display = 'none'; + } + + // Show meta file section if rule has meta-file + if (hasMetaFile) { + elements.metaFileSection.style.display = 'block'; + const metaFileMatch = phase.rule.match(/meta-file=([^&]+)/); + if (metaFileMatch) { + elements.metaFileInput.value = metaFileMatch[1]; + } + } else { + elements.metaFileSection.style.display = 'none'; + elements.metaFileInput.value = ''; + } + } + + // Hide template information + function hideTemplateInfo() { + currentPhase = null; + elements.templateSection.style.display = 'none'; + elements.metaSection.style.display = 'none'; + elements.metaFileSection.style.display = 'none'; + elements.previewSection.style.display = 'none'; + elements.executionSection.style.display = 'none'; + elements.executeBtn.style.display = 'none'; + elements.requestBodySection.style.display = 'none'; + + elements.metaFieldsContainer.innerHTML = ''; + elements.metaFileInput.value = ''; + } + + // Get current date mode from toggle + function getDateMode() { + const activeBtn = document.querySelector('.toggle-btn.active'); + return activeBtn ? activeBtn.dataset.mode : 'range'; + } + + // Update preview button state + function updatePreviewButton() { + const task = elements.taskSelect.value; + const dateMode = getDateMode(); + let hasDate = false; + + if (dateMode === 'range') { + hasDate = elements.fromDate.value || elements.toDate.value; + } else { + hasDate = elements.atDate.value; + } + + elements.previewBtn.disabled = !task || !hasDate; + } + + // Build request object + function buildRequest(execute) { + const dateMode = getDateMode(); + const request = { + Task: elements.taskSelect.value, + }; + + if (execute) { + request.Execute = true; + } + + const job = elements.jobSelect.value; + if (job) { + request.Job = job; + } + + const by = elements.bySelect.value; + if (by && by !== 'day') { + request.By = by; + } + + if (dateMode === 'range') { + if (elements.fromDate.value) request.From = elements.fromDate.value; + if (elements.toDate.value) request.To = elements.toDate.value; + } else { + if (elements.atDate.value) request.At = elements.atDate.value; + } + + // Collect meta fields + if (elements.metaSection.style.display !== 'none') { + const metaInputs = document.querySelectorAll('.meta-input'); + if (metaInputs.length > 0) { + const meta = {}; + metaInputs.forEach(input => { + const key = input.dataset.metaKey; + const value = input.value.trim(); + if (value) { + meta[key] = value.split(',').map(v => v.trim()); + } + }); + if (Object.keys(meta).length > 0) { + request.meta = meta; + } + } + } + + // Collect meta file + if (elements.metaFileSection.style.display !== 'none') { + const metaFile = elements.metaFileInput.value.trim(); + if (metaFile) { + request['meta-file'] = metaFile; + } + } + + return request; + } + + // Set button loading state + function setButtonLoading(btn, loading, originalText) { + if (loading) { + btn.disabled = true; + btn.classList.add('btn-loading'); + btn.innerHTML = ' Loading...'; + } else { + btn.disabled = false; + btn.classList.remove('btn-loading'); + btn.textContent = originalText; + } + } + + // Preview button click handler + async function handlePreviewClick() { + const request = buildRequest(false); + const requestBody = JSON.stringify(request, null, 2); + + elements.requestBodySection.style.display = 'block'; + elements.requestBodyDisplay.textContent = requestBody; + + setButtonLoading(elements.previewBtn, true, 'Preview (Dry Run)'); + + try { + const response = await fetch(elements.apiEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + + const responseText = await response.text(); + let data; + + try { + data = JSON.parse(responseText); + } catch (e) { + throw new Error(responseText || 'Request failed'); + } + + if (!response.ok) { + throw new Error(data.Status || responseText || 'Request failed'); + } + + previewTasks = data.Tasks || []; + showPreviewResults(data); + + } catch (error) { + elements.previewStatus.className = 'preview-status error'; + elements.previewStatus.textContent = 'Error: ' + error.message; + elements.previewSection.style.display = 'block'; + elements.previewTableBody.innerHTML = ''; + elements.previewCount.textContent = ''; + elements.executeBtn.style.display = 'none'; + } finally { + setButtonLoading(elements.previewBtn, false, 'Preview (Dry Run)'); + updatePreviewButton(); + } + } + + // Execute button click handler + async function handleExecuteClick() { + if (!confirm('Are you sure you want to execute this backload? This will create ' + previewTasks.length + ' tasks.')) { + return; + } + + const request = buildRequest(true); + elements.requestBodyDisplay.textContent = JSON.stringify(request, null, 2); + + setButtonLoading(elements.executeBtn, true, 'Execute Backload'); + + try { + const response = await fetch(elements.apiEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + + const responseText = await response.text(); + let data; + + try { + data = JSON.parse(responseText); + } catch (e) { + throw new Error(responseText || 'Execution failed'); + } + + if (!response.ok) { + throw new Error(data.Status || responseText || 'Execution failed'); + } + + elements.executionSection.style.display = 'block'; + elements.executionStatus.className = 'execution-status success'; + elements.executionStatus.innerHTML = ` + Success!
+ ${escapeHtml(data.Status)}
+ Created ${data.Count} tasks. + `; + elements.executeBtn.style.display = 'none'; + + } catch (error) { + elements.executionSection.style.display = 'block'; + elements.executionStatus.className = 'execution-status error'; + elements.executionStatus.textContent = 'Error: ' + error.message; + } finally { + setButtonLoading(elements.executeBtn, false, 'Execute Backload'); + } + } + + // Reset button click handler + function handleResetClick() { + elements.taskSearch.value = ''; + elements.taskSelect.value = ''; + elements.workflowFilter.value = ''; + elements.jobSelect.innerHTML = ''; + elements.jobSelect.disabled = true; + elements.fromDate.value = ''; + elements.toDate.value = ''; + elements.atDate.value = ''; + elements.bySelect.value = 'day'; + + document.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active')); + document.querySelector('.toggle-btn[data-mode="single"]').classList.add('active'); + elements.singleDateInput.style.display = 'block'; + elements.dateRangeInputs.style.display = 'none'; + + hideTemplateInfo(); + initializeDates(); + updatePreviewButton(); + } + + // Show preview results + function showPreviewResults(data) { + elements.previewSection.style.display = 'block'; + elements.previewStatus.className = 'preview-status info'; + elements.previewStatus.textContent = data.Status || 'Dry run complete'; + + elements.previewTableBody.innerHTML = ''; + + if (data.Tasks && data.Tasks.length > 0) { + data.Tasks.forEach((task, index) => { + const row = document.createElement('tr'); + row.innerHTML = ` + ${index + 1} + ${escapeHtml(task.type || '')} + ${escapeHtml(task.job || '')} + ${escapeHtml(task.info || '')} + ${escapeHtml(task.meta || '')} + `; + elements.previewTableBody.appendChild(row); + }); + + elements.previewCount.textContent = `Total tasks to be created: ${data.Count}`; + elements.executeBtn.style.display = 'inline-block'; + elements.executeBtn.disabled = false; + } else { + elements.previewTableBody.innerHTML = 'No tasks would be created'; + elements.previewCount.textContent = ''; + elements.executeBtn.style.display = 'none'; + } + + // Add expand/collapse functionality to cells + document.querySelectorAll('#previewTableBody .expandable').forEach(cell => { + cell.addEventListener('click', function() { + this.classList.toggle('expanded'); + }); + }); + } + + // Initialize date inputs with today's date + function initializeDates() { + const today = new Date().toISOString().split('T')[0]; + elements.fromDate.value = today; + elements.toDate.value = today; + elements.atDate.value = today; + updatePreviewButton(); + } + + // Escape HTML for safe display + function escapeHtml(text) { + if (text === null || text === undefined) return ''; + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; + } + + // Export to global scope + window.FlowlordBackload = { + init: init + }; +})(); diff --git a/apps/flowlord/handler/static/style.css b/apps/flowlord/handler/static/style.css index cc7b4ea..256fce9 100644 --- a/apps/flowlord/handler/static/style.css +++ b/apps/flowlord/handler/static/style.css @@ -1655,4 +1655,34 @@ select.form-control:disabled { color: #6c757d; font-style: italic; text-align: center; +} + +.search-dropdown-item.selected { + background: #e3f2fd; + color: #1976d2; +} + +/* ===== LOADING SPINNER STYLES ===== */ +.loading-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: white; + animation: spin 0.8s linear infinite; + vertical-align: middle; + margin-right: 6px; +} + +.btn-loading { + pointer-events: none; + opacity: 0.7; + cursor: wait; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } } \ No newline at end of file diff --git a/apps/flowlord/handler/static/table-sort.js b/apps/flowlord/handler/static/table-sort.js new file mode 100644 index 0000000..1295bea --- /dev/null +++ b/apps/flowlord/handler/static/table-sort.js @@ -0,0 +1,185 @@ +// Reusable table sorting functionality +(function() { + 'use strict'; + + /** + * Initialize table sorting for a given table + * @param {string} tableId - The ID of the table element + * @param {Object} options - Configuration options + * @param {Function} options.onSort - Callback when sort changes (column, direction) + * @param {boolean} options.persistToUrl - Whether to persist sort state to URL (default: true) + * @param {Object} options.columnTypes - Map of column names to types ('string', 'number', 'date') + */ + function init(tableId, options) { + const table = document.getElementById(tableId); + if (!table) return null; + + const tbody = table.querySelector('tbody'); + const headers = table.querySelectorAll('th.sortable'); + + if (!tbody || headers.length === 0) return null; + + options = options || {}; + const persistToUrl = options.persistToUrl !== false; + const columnTypes = options.columnTypes || {}; + const onSort = options.onSort || null; + + let currentSort = { column: null, direction: 'asc' }; + + // Get URL parameters + function getUrlParams() { + const urlParams = new URLSearchParams(window.location.search); + return { + sort: urlParams.get('sort') || '', + direction: urlParams.get('direction') || 'asc' + }; + } + + // Update URL with sort parameters + function updateUrl(column, direction) { + if (!persistToUrl) return; + + const url = new URL(window.location); + + if (column) { + url.searchParams.set('sort', column); + url.searchParams.set('direction', direction); + } else { + url.searchParams.delete('sort'); + url.searchParams.delete('direction'); + } + + window.history.replaceState({}, '', url.toString()); + } + + // Perform the sort + function sortTable(column, direction) { + const rows = Array.from(tbody.querySelectorAll('tr')); + const columnIndex = Array.from(headers).findIndex(th => th.dataset.sort === column); + + if (columnIndex === -1) return; + + const columnType = columnTypes[column] || 'string'; + + rows.sort((a, b) => { + const aCell = a.cells[columnIndex]; + const bCell = b.cells[columnIndex]; + + if (!aCell || !bCell) return 0; + + const aVal = aCell.textContent.trim(); + const bVal = bCell.textContent.trim(); + + let comparison = 0; + + switch (columnType) { + case 'date': + case 'datetime': + const aDate = new Date(aVal); + const bDate = new Date(bVal); + if (!isNaN(aDate.getTime()) && !isNaN(bDate.getTime())) { + comparison = aDate - bDate; + } else { + comparison = aVal.localeCompare(bVal); + } + break; + + case 'number': + const aNum = parseFloat(aVal); + const bNum = parseFloat(bVal); + if (!isNaN(aNum) && !isNaN(bNum)) { + comparison = aNum - bNum; + } else { + comparison = aVal.localeCompare(bVal); + } + break; + + default: // string + // Try to parse as numbers first + const aNumeric = parseFloat(aVal); + const bNumeric = parseFloat(bVal); + if (!isNaN(aNumeric) && !isNaN(bNumeric)) { + comparison = aNumeric - bNumeric; + } else { + comparison = aVal.localeCompare(bVal); + } + } + + return direction === 'asc' ? comparison : -comparison; + }); + + // Clear tbody and re-append sorted rows + tbody.innerHTML = ''; + rows.forEach(row => tbody.appendChild(row)); + + currentSort = { column, direction }; + } + + // Update sort indicators on headers + function updateSortIndicators(activeColumn, direction) { + headers.forEach(th => { + th.classList.remove('sort-asc', 'sort-desc'); + if (th.dataset.sort === activeColumn) { + th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc'); + } + }); + } + + // Handle header click + function handleHeaderClick(e) { + const header = e.target.closest('th.sortable'); + if (!header) return; + + const column = header.dataset.sort; + let direction = 'asc'; + + if (currentSort.column === column) { + direction = currentSort.direction === 'asc' ? 'desc' : 'asc'; + } + + sortTable(column, direction); + updateSortIndicators(column, direction); + updateUrl(column, direction); + + if (onSort) { + onSort(column, direction); + } + } + + // Add click listeners to headers + headers.forEach(header => { + header.addEventListener('click', handleHeaderClick); + }); + + // Initialize from URL if enabled + if (persistToUrl) { + const params = getUrlParams(); + if (params.sort) { + sortTable(params.sort, params.direction); + updateSortIndicators(params.sort, params.direction); + } + } + + // Return API for programmatic control + return { + sort: function(column, direction) { + sortTable(column, direction || 'asc'); + updateSortIndicators(column, direction || 'asc'); + updateUrl(column, direction || 'asc'); + }, + getCurrentSort: function() { + return { ...currentSort }; + }, + refresh: function() { + if (currentSort.column) { + sortTable(currentSort.column, currentSort.direction); + } + } + }; + } + + // Export to global scope + window.FlowlordTableSort = { + init: init + }; +})(); diff --git a/apps/flowlord/handler/static/utils.js b/apps/flowlord/handler/static/utils.js index f12ddbd..1d2b649 100644 --- a/apps/flowlord/handler/static/utils.js +++ b/apps/flowlord/handler/static/utils.js @@ -2,8 +2,25 @@ (function() { 'use strict'; + // Escape HTML for safe display in innerHTML + function escapeHtml(text) { + if (text === null || text === undefined) return ''; + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; + } + + // Escape text for use in inline JavaScript attributes + function escapeJsString(text) { + if (text === null || text === undefined) return ''; + return String(text).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"'); + } + // Context menu functionality function showContextMenu(event, text) { + event.preventDefault(); + event.stopPropagation(); + // Remove any existing context menu const existingMenu = document.querySelector('.context-menu'); if (existingMenu) { @@ -13,11 +30,16 @@ // Create context menu const contextMenu = document.createElement('div'); contextMenu.className = 'context-menu'; - contextMenu.innerHTML = ` -
- 📋 Copy -
- `; + + const menuItem = document.createElement('div'); + menuItem.className = 'context-menu-item'; + menuItem.innerHTML = '📋 Copy'; + menuItem.addEventListener('click', function() { + copyToClipboard(text); + contextMenu.remove(); + }); + + contextMenu.appendChild(menuItem); // Position the context menu contextMenu.style.left = event.pageX + 'px'; @@ -38,11 +60,6 @@ }, 100); } - // Escape HTML for safe insertion - function escapeHtml(text) { - return text.replace(/'/g, "\\'").replace(/"/g, '\\"'); - } - // Copy to clipboard functionality with enhanced feedback function copyToClipboard(text) { const targetElement = event ? event.target : document.activeElement; @@ -72,9 +89,11 @@ } // Show copy feedback with animation - function showCopyFeedback(element, message, isError = false) { + function showCopyFeedback(element, message, isError) { + isError = isError || false; + // Remove any existing feedback - const existingFeedback = element.querySelector('.copy-feedback'); + const existingFeedback = document.querySelector('.copy-feedback'); if (existingFeedback) { existingFeedback.remove(); } @@ -92,7 +111,7 @@ feedback.style.top = (rect.top - 10) + 'px'; feedback.style.transform = 'translateX(-50%)'; - element.appendChild(feedback); + document.body.appendChild(feedback); // Remove feedback after animation setTimeout(() => { @@ -104,27 +123,35 @@ // Toggle field expansion function toggleField(element, fullText) { - if (element.classList.contains('truncated')) { - element.classList.remove('truncated'); - element.classList.add('expanded'); - element.textContent = fullText; - } else { - element.classList.add('truncated'); + // Prevent event bubbling to avoid conflicts with sorting + if (event) { + event.stopPropagation(); + } + + if (element.classList.contains('expanded')) { + // Collapse the field element.classList.remove('expanded'); + element.classList.add('truncated'); // Reset to truncated text if available in data attribute const truncatedText = element.getAttribute('data-truncated-text'); if (truncatedText) { element.textContent = truncatedText; } + } else { + // Expand the field + element.classList.remove('truncated'); + element.classList.add('expanded'); + element.textContent = fullText; } } // Export to global scope window.FlowlordUtils = { + escapeHtml: escapeHtml, + escapeJsString: escapeJsString, showContextMenu: showContextMenu, copyToClipboard: copyToClipboard, showCopyFeedback: showCopyFeedback, toggleField: toggleField }; })(); - diff --git a/apps/flowlord/handler/workflow.tmpl b/apps/flowlord/handler/workflow.tmpl index 013db21..f9d91aa 100644 --- a/apps/flowlord/handler/workflow.tmpl +++ b/apps/flowlord/handler/workflow.tmpl @@ -77,9 +77,9 @@ {{if ge (len .Rule) 120}}{{slice .Rule 0 120}}...{{else}}{{.Rule}}{{end}} @@ -88,9 +88,9 @@ {{if ge (len .Template) 120}}{{slice .Template 0 120}}...{{else}}{{.Template}}{{end}} @@ -115,123 +115,17 @@ {{end}} + From f33a645155949a90752c08d1b4ea0a179a3e012c Mon Sep 17 00:00:00 2001 From: Joshua Smith Date: Fri, 9 Jan 2026 15:20:12 -0700 Subject: [PATCH 5/5] minor UI adjustments --- apps/flowlord/handler.go | 26 ++-- apps/flowlord/handler/header.tmpl | 35 ++++- apps/flowlord/handler/static/backload.js | 9 ++ apps/flowlord/handler/static/style.css | 162 +++++++++++++++++++++++ internal/docs/img/flowlord_backload.png | Bin 0 -> 273378 bytes internal/docs/img/flowlord_tasks.png | Bin 257396 -> 291358 bytes 6 files changed, 215 insertions(+), 17 deletions(-) create mode 100644 internal/docs/img/flowlord_backload.png diff --git a/apps/flowlord/handler.go b/apps/flowlord/handler.go index e0573bd..46603cb 100644 --- a/apps/flowlord/handler.go +++ b/apps/flowlord/handler.go @@ -288,11 +288,11 @@ func (tm *taskMaster) refreshHandler(w http.ResponseWriter, _ *http.Request) { } v := struct { Files []string `json:",omitempty"` - Cache string + Cache string Updated time.Time }{ Files: files, - Cache: s, + Cache: s, Updated: tm.lastUpdate.UTC(), } b, _ := json.MarshalIndent(v, "", " ") @@ -395,7 +395,6 @@ func (tm *taskMaster) htmlAlert(w http.ResponseWriter, r *http.Request) { // Get dates with alerts for calendar highlighting datesWithData, _ := tm.taskCache.DatesByType("alerts") - w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "text/html") w.Write(alertHTML(alerts, dt, datesWithData)) } @@ -417,7 +416,6 @@ func (tm *taskMaster) htmlFiles(w http.ResponseWriter, r *http.Request) { // Get dates with file messages for calendar highlighting datesWithData, _ := tm.taskCache.DatesByType("files") - w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "text/html") w.Write(filesHTML(files, dt, datesWithData)) } @@ -468,7 +466,6 @@ func (tm *taskMaster) htmlTask(w http.ResponseWriter, r *http.Request) { // Get dates with tasks for calendar highlighting datesWithData, _ := tm.taskCache.DatesByType("tasks") - w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "text/html") htmlBytes := taskHTML(tasks, taskStats, totalCount, dt, filter, datesWithData, summaryTime+queryTime) w.Write(htmlBytes) @@ -476,14 +473,13 @@ func (tm *taskMaster) htmlTask(w http.ResponseWriter, r *http.Request) { // htmlWorkflow handles GET /web/workflow - displays workflow phases from database func (tm *taskMaster) htmlWorkflow(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "text/html") w.Write(workflowHTML(tm.taskCache)) } // htmlAbout handles GET /web/about - displays system information and cache statistics func (tm *taskMaster) htmlAbout(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/html") w.Write(tm.aboutHTML()) } @@ -571,13 +567,13 @@ func taskHTML(tasks []sqlite.TaskView, taskStats sqlite.TaskStats, totalCount in } data := map[string]interface{}{ - "Date": date.Format("Monday, January 2, 2006"), - "DateValue": date.Format("2006-01-02"), - "PrevDate": prevDate.Format("2006-01-02"), - "NextDate": nextDate.Format("2006-01-02"), - "Tasks": tasks, - "Counts": unfilteredCounts, - "HourlyStats": hourlyStats, + "Date": date.Format("Monday, January 2, 2006"), + "DateValue": date.Format("2006-01-02"), + "PrevDate": prevDate.Format("2006-01-02"), + "NextDate": nextDate.Format("2006-01-02"), + "Tasks": tasks, + "Counts": unfilteredCounts, + "HourlyStats": hourlyStats, "Filter": filter, "CurrentPage": "task", "PageTitle": "Task Dashboard", @@ -761,7 +757,7 @@ func backloadHTML(tCache *sqlite.SQLite) []byte { "PhasesByWorkflow": phasesByWorkflow, "PhasesJSON": template.JS(phasesJSON), "CurrentPage": "backload", - "PageTitle": "Backload Tasks", + "PageTitle": "Backload", "isLocal": isLocal, "DatesWithData": []string{}, // Backload page doesn't use date picker with highlights } diff --git a/apps/flowlord/handler/header.tmpl b/apps/flowlord/handler/header.tmpl index cea56b1..3730667 100644 --- a/apps/flowlord/handler/header.tmpl +++ b/apps/flowlord/handler/header.tmpl @@ -2,7 +2,7 @@