Fix bugs found in local testing

- Dashboard auto-refresh rendered the full layout into its own
  refresh container, producing a duplicate sidebar every 60s; it now
  renders only the body partial.
- 'Run Now' runs synchronously with a bounded timeout and returns
  refreshed results plus success/error feedback, instead of
  firing-and-forgetting with no signal.
- Price-history chart data moved from a <script> block to a data-
  attribute: templ does not interpolate expressions inside <script>
  element content, so the JSON was emitted literally.
- The htmx indicator spinner was permanently visible due to CSS
  source order; the indicator rules now follow .v-spinner.

Also refreshes README for this session's changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-14 12:11:07 -07:00
parent 08ff1695e0
commit d87536c879
12 changed files with 550 additions and 366 deletions

View File

@@ -2,13 +2,13 @@ package handlers
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"time"
"veola/internal/apify"
"veola/internal/models"
@@ -261,46 +261,16 @@ func (a *App) runPreview(ctx context.Context, it models.Item) ([]apify.UnifiedRe
var merged []apify.UnifiedResult
primarySource := ""
for _, p := range plans {
actorID := p.ActorID()
if actorID == "" {
continue
}
raw, err := a.Apify.Run(ctx, actorID, p.Input())
decoded, err := a.Scheduler.ExecutePlan(ctx, p)
if err != nil {
slog.Warn("preview run failed", "actor", actorID, "query", p.Query(), "err", err)
slog.Warn("preview plan failed",
"provider", p.Provider(),
"marketplace", p.Marketplace(),
"query", p.Query(),
"err", err,
)
continue
}
decoded, _ := apify.Decode(raw, p.Source())
for i := range decoded {
decoded[i].MatchedQuery = p.Query()
}
usable := 0
for _, r := range decoded {
if r.URL != "" && r.Price > 0 {
usable++
}
}
slog.Info("preview decoded",
"marketplace", previewMarket,
"actor", actorID,
"query", p.Query(),
"raw", len(raw),
"decoded", len(decoded),
"usable", usable,
)
if usable == 0 && len(raw) > 0 {
var sample map[string]any
if err := json.Unmarshal(raw[0], &sample); err == nil {
ks := make([]string, 0, len(sample))
for k := range sample {
ks = append(ks, k)
}
slog.Warn("preview decoded zero usable rows; raw item keys",
"actor", actorID,
"keys", ks,
)
}
}
merged = append(merged, decoded...)
if primarySource == "" {
primarySource = p.Source()
@@ -431,9 +401,40 @@ func (a *App) PostRunItem(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
go a.Scheduler.RunPoll(context.Background(), *it)
// Re-render the row immediately so HTMX has something to swap in.
render(w, r, templates.ItemRow(*it, a.Auth.CSRFToken(r.Context())))
// Run synchronously so the response reflects the finished poll. Bounded so
// a slow Apify actor run can't tie the request up indefinitely (eBay
// Browse API polls finish in seconds, well within this). Detached from the
// request context so a client disconnect mid-run doesn't abort DB writes.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
a.Scheduler.RunPoll(ctx, *it)
// RunPoll writes best price, last_polled_at, and last_poll_error; re-fetch
// so the rendered partial shows the post-poll state.
fresh, err := a.Store.GetItem(r.Context(), id)
if err != nil || fresh == nil {
http.Error(w, "could not reload item after run", http.StatusInternalServerError)
return
}
// The results page asks for a refreshed listing table; the items list
// asks for a refreshed row. Both POST to this same endpoint.
if r.PostFormValue("from") == "results" {
d, err := a.buildItemResultsData(r, fresh, 1, "found_desc")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if fresh.LastPollError != "" {
d.RunError = "Run finished with errors: " + fresh.LastPollError
} else {
d.RunMsg = fmt.Sprintf("Run complete. Showing %d listing(s).", len(d.Results))
}
render(w, r, templates.ItemResultsTable(d))
return
}
render(w, r, templates.ItemRow(*fresh, a.Auth.CSRFToken(r.Context())))
}
func (a *App) GetItemError(w http.ResponseWriter, r *http.Request) {