|
| 1 | +package vlselect |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "encoding/json" |
| 6 | + "html/template" |
| 7 | + "net/http" |
| 8 | + "sort" |
| 9 | + "time" |
| 10 | + |
| 11 | + "github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver" |
| 12 | + "github.com/VictoriaMetrics/VictoriaMetrics/lib/logger" |
| 13 | + |
| 14 | + "github.com/VictoriaMetrics/VictoriaLogs/app/vlstorage" |
| 15 | +) |
| 16 | + |
| 17 | +var asyncTasksTmpl = template.Must(template.New("asyncTasks").Parse(` |
| 18 | +<!DOCTYPE html> |
| 19 | +<html lang="en"> |
| 20 | +<head> |
| 21 | + <meta http-equiv="refresh" content="5"> |
| 22 | + <meta charset="utf-8"> |
| 23 | + <title>VictoriaLogs — Async tasks (aggregated)</title> |
| 24 | + <style> |
| 25 | + :root { |
| 26 | + --bg: #f9f9f9; |
| 27 | + --border: #d0d0d0; |
| 28 | + --header-bg: #fafafa; |
| 29 | + --row-alt-bg: #ffffff; |
| 30 | + --row-hover: #eef2ff; |
| 31 | + --font: system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif; |
| 32 | + --font-size: 14px; |
| 33 | + } |
| 34 | + html,body { margin: 0; padding: 0; font-family: var(--font); background: var(--bg); font-size: var(--font-size); } |
| 35 | + main { padding: 16px; max-width: 1400px; } |
| 36 | + h2 { margin: 0 0 12px; font-weight: 600; } |
| 37 | + .table-container { overflow-x: auto; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border-radius: 8px; } |
| 38 | + table { width: 100%; border-collapse: collapse; } |
| 39 | + th,td { padding: 8px 12px; border: 1px solid var(--border); text-align: left; vertical-align: top; word-wrap: break-word; } |
| 40 | + thead th { background: var(--header-bg); position: sticky; top: 0; z-index: 1; font-weight: 600; } |
| 41 | + tbody tr:nth-child(odd) { background: var(--row-alt-bg); } |
| 42 | + tbody tr:hover { background: var(--row-hover); } |
| 43 | + .col-storage { width: 200px; max-width: 200px; } |
| 44 | + .col-created { width: 180px; max-width: 180px; } |
| 45 | + .col-type { width: 120px; max-width: 120px; } |
| 46 | + .col-status { width: 100px; max-width: 100px; } |
| 47 | + .col-tenant { width: 180px; max-width: 180px; } |
| 48 | + .col-done { width: 180px; max-width: 180px; } |
| 49 | + .col-result { width: 150px; max-width: 150px; } |
| 50 | + .col-payload { width: 300px; max-width: 300px; } |
| 51 | + .status { font-weight: 600; padding: 3px 8px; border-radius: 4px; display:inline-block; text-transform: capitalize; font-size: 12px; } |
| 52 | + .status.pending { background:#fff4cc; color:#856404; } |
| 53 | + .status.success { background:#d3f9d8; color:#14532d; } |
| 54 | + .status.error { background:#f8d7da; color:#842029; } |
| 55 | + .payload-cell { max-height: 100px; overflow-y: auto; } |
| 56 | + .payload-cell pre { margin: 0; white-space: pre-wrap; word-break: break-word; font-size: 12px; } |
| 57 | + .truncated { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 58 | + </style> |
| 59 | +</head> |
| 60 | +<body> |
| 61 | + <main> |
| 62 | + <h2>Async tasks</h2> |
| 63 | +
|
| 64 | + {{- if .Tasks }} |
| 65 | + <div class="table-container"> |
| 66 | + <table> |
| 67 | + <thead> |
| 68 | + <tr> |
| 69 | + {{- if .ShowStorage }} |
| 70 | + <th class="col-storage">Storage</th> |
| 71 | + {{- end }} |
| 72 | + <th class="col-created">Created</th> |
| 73 | + <th class="col-type">Type</th> |
| 74 | + <th class="col-status">Status</th> |
| 75 | + {{- if .ShowTenant }} |
| 76 | + <th class="col-tenant">Tenant</th> |
| 77 | + {{- end }} |
| 78 | + <th class="col-done">Done</th> |
| 79 | + <th class="col-result">Result</th> |
| 80 | + <th class="col-payload">Payload</th> |
| 81 | + </tr> |
| 82 | + </thead> |
| 83 | + <tbody> |
| 84 | + {{- range .Tasks }} |
| 85 | + <tr> |
| 86 | + {{- if $.ShowStorage }} |
| 87 | + <td class="col-storage truncated" title="{{ .Storage }}">{{ .Storage }}</td> |
| 88 | + {{- end }} |
| 89 | + <td>{{ .Created }}</td> |
| 90 | + <td>{{ .Type }}</td> |
| 91 | + <td><span class="status {{ .Status }}">{{ .Status }}</span></td> |
| 92 | + {{- if $.ShowTenant }} |
| 93 | + <td class="col-tenant truncated" title="{{ .Tenant }}">{{ html .Tenant }}</td> |
| 94 | + {{- end }} |
| 95 | + <td>{{ .Done }}</td> |
| 96 | + <td class="col-result truncated" title="{{ .Result }}">{{ html .Result }}</td> |
| 97 | + <td class="col-payload payload-cell"><pre>{{ html .PayloadJSON }}</pre></td> |
| 98 | + </tr> |
| 99 | + {{- end }} |
| 100 | + </tbody> |
| 101 | + </table> |
| 102 | + </div> |
| 103 | + {{- else }} |
| 104 | + <p>No async tasks found.</p> |
| 105 | + {{- end }} |
| 106 | + </main> |
| 107 | +</body> |
| 108 | +</html> |
| 109 | +`)) |
| 110 | + |
| 111 | +func processAsyncTasksRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) { |
| 112 | + tasks, err := vlstorage.ListAsyncTasks(ctx) |
| 113 | + if err != nil { |
| 114 | + httpserver.Errorf(w, r, "%s", err) |
| 115 | + return |
| 116 | + } |
| 117 | + |
| 118 | + // JSON output support |
| 119 | + if format := r.FormValue("format"); format == "json" || r.Header.Get("Accept") == "application/json" { |
| 120 | + w.Header().Set("Content-Type", "application/json") |
| 121 | + if err := json.NewEncoder(w).Encode(tasks); err != nil { |
| 122 | + httpserver.Errorf(w, r, "cannot encode response: %s", err) |
| 123 | + } |
| 124 | + return |
| 125 | + } |
| 126 | + |
| 127 | + // Sort tasks by Created time descending for better UX in HTML view |
| 128 | + sort.Slice(tasks, func(i, j int) bool { |
| 129 | + // Sort by CreatedTime desc, then by Storage desc |
| 130 | + if tasks[i].CreatedTime != tasks[j].CreatedTime { |
| 131 | + return tasks[i].CreatedTime > tasks[j].CreatedTime |
| 132 | + } |
| 133 | + return tasks[i].Storage > tasks[j].Storage |
| 134 | + }) |
| 135 | + |
| 136 | + // Build view model for template. |
| 137 | + type row struct { |
| 138 | + Storage string |
| 139 | + Type string |
| 140 | + Status string |
| 141 | + Tenant string |
| 142 | + PayloadJSON string |
| 143 | + Created string |
| 144 | + Done string |
| 145 | + Result string |
| 146 | + } |
| 147 | + |
| 148 | + // Check if we should show tenant column (if any tenant is not the default) |
| 149 | + showTenant := false |
| 150 | + for _, t := range tasks { |
| 151 | + if t.Tenant != "{accountID=0,projectID=0}" && t.Tenant != "*" { |
| 152 | + showTenant = true |
| 153 | + break |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + vm := struct { |
| 158 | + Tasks []row |
| 159 | + ShowTenant bool |
| 160 | + ShowStorage bool |
| 161 | + }{ |
| 162 | + ShowTenant: showTenant, |
| 163 | + ShowStorage: !vlstorage.IsLocalStorage(), |
| 164 | + } |
| 165 | + |
| 166 | + for _, t := range tasks { |
| 167 | + payloadJSON, _ := json.Marshal(t.Payload) |
| 168 | + |
| 169 | + createdStr := time.Unix(0, t.CreatedTime).Format(time.RFC3339) |
| 170 | + done := "-" |
| 171 | + if t.DoneTime > 0 { |
| 172 | + done = time.Unix(0, t.DoneTime).Format(time.RFC3339) |
| 173 | + } |
| 174 | + |
| 175 | + result := t.Error |
| 176 | + if t.Status == "success" { |
| 177 | + result = "OK" |
| 178 | + } |
| 179 | + |
| 180 | + vm.Tasks = append(vm.Tasks, row{ |
| 181 | + Storage: t.Storage, |
| 182 | + Type: string(t.Type), |
| 183 | + Status: string(t.Status), |
| 184 | + Tenant: t.Tenant, |
| 185 | + PayloadJSON: string(payloadJSON), |
| 186 | + Created: createdStr, |
| 187 | + Done: done, |
| 188 | + Result: result, |
| 189 | + }) |
| 190 | + } |
| 191 | + |
| 192 | + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 193 | + if err := asyncTasksTmpl.Execute(w, vm); err != nil { |
| 194 | + logger.Errorf("cannot execute async tasks template: %s", err) |
| 195 | + httpserver.Errorf(w, r, "internal error: %s", err) |
| 196 | + } |
| 197 | +} |
0 commit comments