Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 75 additions & 11 deletions apps/flowlord/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -284,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, "", " ")
Expand Down Expand Up @@ -391,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))
}
Expand All @@ -413,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))
}
Expand Down Expand Up @@ -464,22 +466,20 @@ 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)
}

// 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())
}
Expand Down Expand Up @@ -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()
Expand All @@ -569,7 +572,8 @@ func taskHTML(tasks []sqlite.TaskView, taskStats sqlite.TaskStats, totalCount in
"PrevDate": prevDate.Format("2006-01-02"),
"NextDate": nextDate.Format("2006-01-02"),
"Tasks": tasks,
"Counts": counts,
"Counts": unfilteredCounts,
"HourlyStats": hourlyStats,
"Filter": filter,
"CurrentPage": "task",
"PageTitle": "Task Dashboard",
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -678,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,
Expand Down Expand Up @@ -712,6 +717,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",
"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
Expand Down
51 changes: 43 additions & 8 deletions apps/flowlord/handler/about.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
</div>
<div class="info-item">
<span class="info-label">Runtime</span>
<span class="info-value">{{.RunTime}}</span>
<span class="info-value" id="uptime">{{.RunTime}}</span>
</div>
<input type="hidden" id="startTime" value="{{.StartTime}}">
</div>

<div class="info-card">
Expand Down Expand Up @@ -112,7 +113,7 @@
{{range .TableStats}}
<tr>
<td>{{.Name}}</td>
<td>{{.RowCount}}</td>
<td class="row-count">{{.RowCount}}</td>
<td class="size-cell">{{.TableHuman}}</td>
<td class="size-cell">{{.IndexHuman}}</td>
<td class="size-cell">{{.TotalHuman}}</td>
Expand All @@ -127,12 +128,46 @@
</div>

<script>
// Auto-refresh every 30 seconds
if (window.location.search.indexOf('norefresh') === -1) {
setTimeout(function() {
location.reload();
}, 30000);
}
// Format numbers with commas for readability
document.querySelectorAll('.row-count').forEach(function(cell) {
const num = parseInt(cell.textContent, 10);
if (!isNaN(num)) {
cell.textContent = num.toLocaleString();
}
});

// Calculate and update uptime dynamically
(function() {
const startTimeStr = document.getElementById('startTime').value;
if (!startTimeStr) return;

const startTime = new Date(startTimeStr);
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No validation is performed to check if startTime is a valid date after parsing. If the date string is malformed, the code will continue with an invalid date object. Consider adding a check: if (isNaN(startTime.getTime())) return; after creating the Date object.

Suggested change
const startTime = new Date(startTimeStr);
const startTime = new Date(startTimeStr);
if (isNaN(startTime.getTime())) return;

Copilot uses AI. Check for mistakes.
const uptimeEl = document.getElementById('uptime');

function formatDuration(ms) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);

const parts = [];
if (days > 0) parts.push(days + 'd');
if (hours % 24 > 0) parts.push((hours % 24) + 'h');
if (minutes % 60 > 0) parts.push((minutes % 60) + 'm');
if (seconds % 60 > 0 || parts.length === 0) parts.push((seconds % 60) + 's');

return parts.join(' ');
}

function updateUptime() {
const now = new Date();
const diff = now - startTime;
uptimeEl.textContent = formatDuration(diff);
}

updateUptime();
setInterval(updateUptime, 1000);
})();
</script>
</body>
</html>
Loading
Loading