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{} )