package dashboard import ( "context" "errors" "fmt" "net/http" "net/url" "strings" ) // bulkChunk is how many addresses go into one DeleteIPs/SubmitIPs call when // an operation spans a whole filter ("scope=all"). const bulkChunk = 500 // ipStates are the queue states control-api accepts in the `state` filter. var ipStates = []string{"queued", "assigning_fip", "awaiting_self_check", "checking", "aggregating", "done", "failed", "occupied"} var ipResults = []string{"pass", "partial", "fail", "cancelled"} func containsStr(list []string, s string) bool { for _, v := range list { if v == s { return true } } return false } // ipsFilter is the server-side filter of the /ips list. State is a filter // token: one of "", "queued", "active" (every in-progress state), "done", // "failed", "occupied", or a comma-separated list of raw queue states; the // result filter is separate (Result) and may be combined with it. type ipsFilter struct { Q string State string Result string } // parseIPsFilter reads q/state/result from the request (query or form body). // The state option matching the filter. func (f ipsFilter) Token() string { if f.State == "" && f.Result != "" { return "result:" + f.Result } return f.State } func (f ipsFilter) values() url.Values { v := url.Values{} if f.Q != "" { v.Set("q", f.Q) } if f.State != "" { v.Set("state", f.State) } if f.Result != "" { v.Set("result", f.Result) } return v } type ipsPageData struct { PageData Items []ipQueueItem FIPSettleSeconds int Filter ipsFilter Page, PerPage int // Total is the number of rows matching the filter; QueueTotal is the whole // queue (what "Очистить всё" would remove). Total, QueueTotal int Pager pagerData // SelfURL is this page's own URL (filter + page), re-requested to reload // the table when a scan finishes. SelfURL string PerPageOptions []int Scan scanProgressData } type ipDetailData struct { PageData Detail ipDetailResponse } // loadIPsData fetches the page of the queue selected by the request's // page/per_page/q/state/result params. A page that became empty (e.g. after // deleting its last rows) is clamped to the last non-empty page. func (s *Server) loadIPsData(r *http.Request) (ipsPageData, error) { ctx := r.Context() f := parseIPsFilter(r) perPage := parsePerPage(r.FormValue("per_page")) page := parsePage(r.FormValue("page")) q := ipsQuery{States: f.States(), Q: f.Q, Result: f.Result, Order: "sequence", Limit: perPage, Offset: (page - 1) * perPage} res, err := s.CA.ListIPsPage(ctx, q) if err == nil { if clamped := clampPage(page, res.Total, perPage); clamped != page { page = clamped q.Offset = (page - 1) * perPage res, err = s.CA.ListIPsPage(ctx, q) } } data := ipsPageData{ Items: res.Items, Filter: f, Page: page, PerPage: perPage, Total: res.Total, QueueTotal: res.Total, PerPageOptions: perPageOptions, } data.Pager = newPager("/ips", "ips-table-wrap", f.values(), page, perPage, res.Total) data.SelfURL = pageURL("/ips", f.values(), page, perPage) if settings, settingsErr := s.CA.GetOrchestratorSettings(ctx); settingsErr != nil { if err == nil { err = settingsErr } } else { data.FIPSettleSeconds = settings.FIPSettleSeconds } if err == nil && f.Active() { // "Очистить всё" ignores the filter: show the real queue size in its // confirmation. Non-fatal — the label just falls back to the filtered total. if st, stErr := s.CA.Status(ctx); stErr == nil { data.QueueTotal = st.TotalIPs } } return data, err } func (s *Server) handleIPsPage(w http.ResponseWriter, r *http.Request) { data, err := s.loadIPsData(r) // A filter/pager request from htmx swaps only #ips-table-wrap (hx-select), // so there is no need to re-render the whole page (and re-query the scan // status). A history-restore fetch needs the full page. if r.Header.Get("HX-Request") == "true" && r.Header.Get("HX-History-Restore-Request") != "true" { s.renderFragment(w, "ips_table_wrap", data, err) return } if st, scanErr := s.CA.ScanStatus(r.Context()); scanErr != nil { s.Log.Warn("ips: scan status unavailable", "err", scanErr) } else { data.Scan = newScanProgress(st) } data.ActiveNav = "ips" data.Banner = bannerFor(err) s.renderPage(w, r, "ips_page", data) } func (s *Server) handleIPDetail(w http.ResponseWriter, r *http.Request) { ip := r.PathValue("ip") detail, err := s.CA.GetIP(r.Context(), ip) data := ipDetailData{Detail: detail} data.ActiveNav = "ips" data.Banner = bannerFor(err) s.renderPage(w, r, "ip_detail_page", data) } // renderIPsTable re-fetches the current page of the queue (same page/filter as // the request, carried in hidden #ips-form inputs) and renders the // ips_table_wrap fragment, tagging actionErr (if any) on the shared error // banner. Called after every mutating /ips/* request so the table always // reflects true current state regardless of whether the mutation itself // succeeded. func (s *Server) renderIPsTable(w http.ResponseWriter, r *http.Request, actionErr error) { data, err := s.loadIPsData(r) if actionErr == nil { actionErr = err } s.renderFragment(w, "ips_table_wrap", data, actionErr) } func (s *Server) handleIPsSubmit(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { s.renderIPsTable(w, r, fmt.Errorf("invalid form: %w", err)) return } addresses := splitList(r.PostFormValue("addresses")) if len(addresses) == 0 { s.renderIPsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "укажите хотя бы один адрес"}) return } err := s.submitChunked(r.Context(), addresses) s.renderIPsTable(w, r, err) } func (s *Server) handleIPRecheck(w http.ResponseWriter, r *http.Request) { ip := r.PathValue("ip") _, err := s.CA.SubmitIPs(r.Context(), []string{ip}) s.renderIPsTable(w, r, err) } func (s *Server) handleIPCancel(w http.ResponseWriter, r *http.Request) { ip := r.PathValue("ip") err := s.CA.CancelIP(r.Context(), ip) s.renderIPsTable(w, r, err) } func (s *Server) handleIPDelete(w http.ResponseWriter, r *http.Request) { ip := r.PathValue("ip") err := s.CA.DeleteIP(r.Context(), ip) s.renderIPsTable(w, r, err) } // submitChunked feeds addresses to SubmitIPs in chunks of bulkChunk so one // huge list never becomes one huge request/transaction. func (s *Server) submitChunked(ctx context.Context, addresses []string) error { for _, part := range chunk(addresses, bulkChunk) { if _, err := s.CA.SubmitIPs(ctx, part); err != nil { return err } } return nil } // resolveFilterAddresses lists every address matching filter by paging // ListIPsPage (≤ maxPageLimit rows per call), for "select all N by filter". func (s *Server) resolveFilterAddresses(ctx context.Context, f ipsFilter) ([]string, error) { var out []string for offset := 0; ; { page, err := s.CA.ListIPsPage(ctx, ipsQuery{States: f.States(), Q: f.Q, Result: f.Result, Order: "sequence", Limit: maxPageLimit, Offset: offset}) if err != nil { return nil, err } for _, it := range page.Items { out = append(out, it.IPAddress) } offset += len(page.Items) if len(page.Items) == 0 || offset >= page.Total { return out, nil } } } // bulkAddresses returns the addresses a bulk delete/recheck acts on: the // checked rows of the current page, or — with scope=all — everything that // matches the current filter, resolved server-side. func (s *Server) bulkAddresses(r *http.Request) ([]string, error) { if err := r.ParseForm(); err != nil { return nil, fmt.Errorf("invalid form: %w", err) } var addresses []string if r.FormValue("scope") == "all" { var err error addresses, err = s.resolveFilterAddresses(r.Context(), parseIPsFilter(r)) if err != nil { return nil, err } } else { addresses = r.Form["addresses"] } if len(addresses) == 0 { return nil, &apiErr{Status: http.StatusBadRequest, Message: "ничего не выбрано"} } return addresses, nil } func (s *Server) handleIPsDeleteSelected(w http.ResponseWriter, r *http.Request) { addresses, err := s.bulkAddresses(r) if err == nil { for _, part := range chunk(addresses, bulkChunk) { if _, err = s.CA.DeleteIPs(r.Context(), part); err != nil { break } } } s.renderIPsTable(w, r, err) } func (s *Server) handleIPsRecheckSelected(w http.ResponseWriter, r *http.Request) { addresses, err := s.bulkAddresses(r) if err == nil { err = s.submitChunked(r.Context(), addresses) } s.renderIPsTable(w, r, err) } func (s *Server) handleIPsClear(w http.ResponseWriter, r *http.Request) { _, err := s.CA.ClearQueue(r.Context()) s.renderIPsTable(w, r, err) } // scanProgressData drives the scan_progress partial. Poll keeps the partial's // own hx-trigger="every 2s" alive: while the job runs, or while control-api is // transiently unreachable (so one failed poll doesn't freeze the panel). type scanProgressData struct { Status scanStatusDTO Poll bool } func newScanProgress(st scanStatusDTO) scanProgressData { return scanProgressData{Status: st, Poll: st.Running} } // renderScanProgress renders the scan panel fragment. A finished job also // sends `HX-Trigger: scan-finished`, which makes #ips-table-wrap reload. // Errors go to the shared banner; only transient ones (transport/5xx) keep // polling when keepPolling is set. func (s *Server) renderScanProgress(w http.ResponseWriter, st scanStatusDTO, err error, keepPolling bool) { data := newScanProgress(st) if err != nil { data = scanProgressData{} var ae *apiErr if keepPolling && errors.As(err, &ae) && (ae.Status == 0 || ae.Status >= 500) { data.Poll = true } } else if st.Finished() { w.Header().Set("HX-Trigger", "scan-finished") } s.renderFragment(w, "scan_progress", data, err) } // handleIPsScan starts the background floating-IP scan (or joins the running // one) and returns the progress panel at once — the job itself runs in // control-api, so this never waits for OpenStack. ?dry_run=true only counts. func (s *Server) handleIPsScan(w http.ResponseWriter, r *http.Request) { st, err := s.CA.StartScan(r.Context(), r.URL.Query().Get("dry_run") == "true") s.renderScanProgress(w, st, err, false) } // handleIPsScanStatus is the progress panel's poll target. func (s *Server) handleIPsScanStatus(w http.ResponseWriter, r *http.Request) { st, err := s.CA.ScanStatus(r.Context()) s.renderScanProgress(w, st, err, true) }