- 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
351 lines
12 KiB
Go
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{}
|
|
)
|