Files
sm-seo-plugin/controllers/crawls.go
Jakub Zych c4f0b4f94c feat: Site audit and Crawls screens with their MCP tools
- golem15.seo.issues (Site audit): current issues errors first, labels,
  summary and fix columns, filters, P7 issue_details, Check this address
  again, Ignore, Stop ignoring, bulk Ignore, ignored rows badged
- golem15.seo.crawls (Crawls): newest first, P6 crawl_summary with one
  status callout, staging and rendered-comparison lines, stats strip and
  per-kind table; Cancel; a running crawl refuses delete
- Start crawl toolbar action on both, refusing already_running and
  no_site_url; navigation Site audit (gauge) and Crawls (globe)
- routes and MCP tools start/cancel/list/get/delete crawls, list_issues
  with filters (open by default), recheck_url, ignore and unignore
- AdminOperations maps both controllers; toolbar actions get their own
  operation kind
- TestCrawlAdmin, TestCrawlTools, TestIssueKindLabels
2026-11-04 15:34:22 +01:00

351 lines
12 KiB
Go

package controllers
import (
"context"
"errors"
"fmt"
"net/url"
"sort"
"strconv"
"time"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/classes/crawl"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// CrawlsControllerID is the Crawls screen (UI-SPEC C24).
const CrawlsControllerID = "golem15.seo.crawls"
// CrawlActionCancel is the preview's Cancel crawl.
const CrawlActionCancel = "cancel"
// errCrawlReadOnly refuses creating or editing crawls.
var errCrawlReadOnly = errors.New("golem15.seo: crawls are read-only")
// crawlsAdminController lists the crawls, newest first, and previews one
// with P6 and Cancel while it is queued or running; the toolbar starts a
// crawl and deletes the selected ones (a running crawl refuses).
type crawlsAdminController struct {
app func() *backpack.App
}
func (crawlsAdminController) ID() string { return CrawlsControllerID }
func (crawlsAdminController) ModelName() string { return `Golem15\SEO\Models\Crawl` }
func (crawlsAdminController) ConfigDir() string { return "controllers/crawls" }
func (crawlsAdminController) NewRecord() any { return &models.Crawl{} }
func (crawlsAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
// FormBeforeCreate refuses: crawls are started with Start crawl.
func (crawlsAdminController) FormBeforeCreate(context.Context, any) error { return errCrawlReadOnly }
// FormBeforeUpdate refuses: crawls are never edited.
func (crawlsAdminController) FormBeforeUpdate(context.Context, any) error { return errCrawlReadOnly }
// FormBeforeDelete refuses a queued or running crawl with
// crawls.delete_running (403).
func (c crawlsAdminController) FormBeforeDelete(ctx context.Context, model any) error {
row, ok := model.(*models.Crawl)
if ok && row != nil && row.Active() {
return &cabana.ForbiddenError{Message: Phrase(ctx, translator(c.app), "golem15.seo::lang.crawls.delete_running", nil)}
}
return nil
}
func (c crawlsAdminController) service() *classes.Service {
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return nil
}
return svc
}
// AdminActions is the toolbar's Start crawl.
func (c crawlsAdminController) AdminActions() []pact.AdminAction {
return []pact.AdminAction{startCrawl(c.app)}
}
// AdminRecordActions is Cancel crawl while queued or running; the pages
// and issues fetched so far are kept.
func (c crawlsAdminController) AdminRecordActions() []pact.AdminRecordAction {
return []pact.AdminRecordAction{{
Name: CrawlActionCancel,
Label: "golem15.seo::lang.crawls.cancel",
Confirm: "golem15.seo::lang.crawls.cancel_confirm",
Permissions: []string{PermissionManage},
Applies: func(_ context.Context, record any) (bool, error) {
row, ok := record.(*models.Crawl)
return ok && row != nil && row.Active(), nil
},
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
row, ok := in.Record.(*models.Crawl)
svc := c.service()
if !ok || row == nil || svc == nil {
return pact.AdminRecordActionResult{}, classes.ErrNotBooted
}
db, err := database(ctx, c.app)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
_, name, _ := svc.Actor(ctx, db)
if err := crawl.Cancel(ctx, svc, db, *row, name); err != nil {
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{Message: "golem15.seo::lang.crawls.cancelled"}, nil
},
}}
}
// statusKeys are the phrase keys of the crawl states.
var statusKeys = map[string]string{
models.CrawlQueued: "golem15.seo::lang.crawls.st_queued",
models.CrawlRunning: "golem15.seo::lang.crawls.st_running",
models.CrawlFinished: "golem15.seo::lang.crawls.st_finished",
models.CrawlFailed: "golem15.seo::lang.crawls.st_failed",
models.CrawlCancelled: "golem15.seo::lang.crawls.st_cancelled",
}
// TriggerLabel is the Started by cell: Manual: name, Schedule or MCP:
// client.
func TriggerLabel(ctx context.Context, svc *classes.Service, row models.Crawl) string {
switch row.Trigger {
case models.TriggerSchedule:
return svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.by_schedule", nil)
case models.TriggerMCP:
return svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.by_mcp", map[string]string{"client": row.TriggerName})
}
return svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.by_admin", map[string]string{"name": row.TriggerName})
}
// Duration is a crawl's duration, e.g. "4 min 12 s"; "—" before it ends.
func Duration(row models.Crawl) string {
if row.StartedAt == nil {
return "—"
}
end := time.Now().UTC()
if row.FinishedAt != nil {
end = *row.FinishedAt
}
d := end.Sub(*row.StartedAt).Round(time.Second)
if d < 0 {
d = 0
}
h, m, s := int(d.Hours()), int(d.Minutes())%60, int(d.Seconds())%60
switch {
case h > 0:
return fmt.Sprintf("%d h %d min", h, m)
case m > 0:
return fmt.Sprintf("%d min %d s", m, s)
}
return fmt.Sprintf("%d s", s)
}
// issueCounts counts the issues of crawls.
func issueCounts(db *gorm.DB, ids []uint) (map[uint]int, error) {
out := map[uint]int{}
if len(ids) == 0 {
return out, nil
}
var rows []struct {
CrawlID uint
N int
}
if err := db.Raw(`SELECT crawl_id, COUNT(*) AS n FROM golem15_seo_issues WHERE crawl_id IN ? GROUP BY crawl_id`, ids).Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
out[r.CrawlID] = r.N
}
return out, nil
}
// ListExtendRecords shows the state, pages (with the limit note), issue
// count, trigger and duration of each crawl in the request locale.
func (c crawlsAdminController) ListExtendRecords(ctx context.Context, db *gorm.DB, records []any) error {
svc := c.service()
if svc == nil {
return nil
}
var ids []uint
for _, r := range records {
if row, ok := r.(*models.Crawl); ok && row != nil {
ids = append(ids, row.ID)
}
}
counts, err := issueCounts(db.WithContext(ctx), ids)
if err != nil {
return err
}
for _, r := range records {
row, ok := r.(*models.Crawl)
if !ok || row == nil {
continue
}
row.PagesCell = strconv.Itoa(row.Pages)
if row.LimitHit {
row.PagesCell = svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.pages_limit", map[string]string{"count": strconv.Itoa(row.Pages)})
}
row.IssuesCell = strconv.Itoa(counts[row.ID])
row.DurationCell = Duration(*row)
row.TriggerCell = TriggerLabel(ctx, svc, *row)
if key, ok := statusKeys[row.Status]; ok {
row.Status = svc.PhraseCtx(ctx, key, nil)
}
}
return nil
}
// DropdownOptions labels the preview's read-only status and trigger.
func (crawlsAdminController) DropdownOptions(field string) []pact.Option {
switch field {
case "status":
out := make([]pact.Option, 0, len(models.CrawlStatuses))
for _, s := range models.CrawlStatuses {
out = append(out, pact.Option{Value: s, Label: statusKeys[s]})
}
return out
case "trigger":
return []pact.Option{
{Value: models.TriggerAdmin, Label: "golem15.seo::lang.crawls.trigger_admin"},
{Value: models.TriggerSchedule, Label: "golem15.seo::lang.crawls.trigger_schedule"},
{Value: models.TriggerMCP, Label: "golem15.seo::lang.crawls.trigger_mcp"},
}
}
return nil
}
// FilterOptions serves the status filter.
func (c crawlsAdminController) FilterOptions(scope string) []pact.Option {
if scope != models.FilterCrawlStatus {
return nil
}
return c.DropdownOptions("status")
}
// PartialData serves P6 crawl_summary (UI-SPEC C24).
func (c crawlsAdminController) PartialData(ctx context.Context, name string, record any) (any, error) {
row, ok := record.(*models.Crawl)
svc := c.service()
if name != "crawl_summary" || !ok || row == nil || svc == nil {
return classes.CrawlSummaryView{}, nil
}
db, err := database(ctx, c.app)
if err != nil {
return nil, err
}
return CrawlSummary(ctx, svc, db, c.app, *row)
}
// CrawlSummary builds P6: one status callout, the staging and rendered
// comparison lines, the stats strip and the per-kind table whose counts
// link to the Site audit list filtered by the kind.
func CrawlSummary(ctx context.Context, svc *classes.Service, db *gorm.DB, app func() *backpack.App, row models.Crawl) (classes.CrawlSummaryView, error) {
view := classes.CrawlSummaryView{}
p := func(key string, params map[string]string) string {
return svc.PhraseCtx(ctx, "golem15.seo::lang.crawls."+key, params)
}
stamp := func(t *time.Time) string {
if t == nil {
return "—"
}
return t.UTC().Format("2006-01-02 15:04 UTC")
}
switch row.Status {
case models.CrawlRunning:
view.Title = p("running_title", nil)
view.Text = p("running_text", map[string]string{"done": strconv.Itoa(row.Pages), "limit": strconv.Itoa(row.MaxPages)})
case models.CrawlQueued:
view.Text = p("queued_text", nil)
case models.CrawlFailed:
view.Tone = "danger"
view.Title = p("failed_title", nil)
view.Text = p("failed_text", map[string]string{"error": row.Error})
case models.CrawlCancelled:
view.Text = p("cancelled_text", map[string]string{"name": row.CancelledBy, "time": stamp(row.FinishedAt)})
case models.CrawlFinished:
if row.LimitHit {
view.Tone = "warning"
view.Text = p("limit_text", map[string]string{"limit": strconv.Itoa(row.MaxPages)})
}
}
view.HasCallout = view.Text != ""
if svc.Staging() {
view.Staging = p("staging_text", nil)
}
switch row.RenderStatus {
case models.RenderDone:
view.Render = svc.ChoiceCtx(ctx, "golem15.seo::lang.crawls.render_done", row.RenderPages, nil)
case models.RenderFailed:
view.Render = p("render_failed", map[string]string{"error": row.RenderNote})
default:
view.Render = p("render_skipped", nil)
}
var rows []struct {
Kind string
Severity string
N int
}
if err := db.Raw(`SELECT kind, severity, COUNT(*) AS n FROM golem15_seo_issues WHERE crawl_id = ? GROUP BY kind, severity`, row.ID).Scan(&rows).Error; err != nil {
return view, err
}
sevCount := map[string]int{}
prefix, err := cabana.AdminPrefix(appOf(app))
if err != nil {
prefix = cabana.DefaultAdminPrefix
}
for _, r := range rows {
sevCount[r.Severity] += r.N
view.Kinds = append(view.Kinds, classes.CrawlKindRow{
Kind: r.Kind,
Label: svc.PhraseCtx(ctx, "golem15.seo::lang.issues.kind_"+r.Kind, nil),
Severity: svc.PhraseCtx(ctx, labelKeys[r.Severity], nil),
Count: r.N,
Link: prefix + "/golem15/seo/issues?" + url.Values{"filter[kind]": {r.Kind}}.Encode(),
})
}
rank := map[string]int{}
for _, r := range rows {
rank[r.Kind] = models.SeverityRank(r.Severity)
}
sort.SliceStable(view.Kinds, func(i, j int) bool {
a, b := view.Kinds[i], view.Kinds[j]
if rank[a.Kind] != rank[b.Kind] {
return rank[a.Kind] < rank[b.Kind]
}
if a.Count != b.Count {
return a.Count > b.Count
}
return a.Kind < b.Kind
})
view.NoIssues = len(view.Kinds) == 0 && !row.Active()
view.Stats = []classes.CrawlStat{
{Label: p("stat_pages", nil), Value: strconv.Itoa(row.Pages)},
{Label: p("stat_errors", nil), Value: strconv.Itoa(sevCount[models.SeverityError])},
{Label: p("stat_warnings", nil), Value: strconv.Itoa(sevCount[models.SeverityWarning])},
{Label: p("stat_notices", nil), Value: strconv.Itoa(sevCount[models.SeverityNotice])},
{Label: p("stat_duration", nil), Value: Duration(row)},
}
return view, nil
}
var (
_ pact.AdminController = crawlsAdminController{}
_ pact.AdminPermissioned = crawlsAdminController{}
_ pact.AdminRecordSource = crawlsAdminController{}
_ pact.FormBeforeCreate = crawlsAdminController{}
_ pact.FormBeforeUpdate = crawlsAdminController{}
_ pact.FormBeforeDelete = crawlsAdminController{}
_ pact.HasAdminActions = crawlsAdminController{}
_ pact.HasAdminRecordActions = crawlsAdminController{}
_ pact.ListExtendRecords = crawlsAdminController{}
_ pact.DropdownOptionsProvider = crawlsAdminController{}
_ pact.FilterOptions = crawlsAdminController{}
_ pact.AdminPartialData = crawlsAdminController{}
)