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
This commit is contained in:
Jakub Zych
2026-11-04 15:34:22 +01:00
parent edc03cae4b
commit c4f0b4f94c
32 changed files with 2778 additions and 20 deletions

View File

@@ -33,6 +33,8 @@ func AdminControllers(app func() *backpack.App) []pact.AdminController {
notFoundAdminController{app: app},
rulesAdminController{app: app},
pagesAdminController{app: app},
issuesAdminController{app: app},
crawlsAdminController{app: app},
robotsAdminController{app: app},
historyAdminController{app: app},
transferAdminController{app: app},

View File

@@ -4,6 +4,8 @@ import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/classes"
@@ -61,7 +63,7 @@ func StartCrawl(service func() *classes.Service) http.HandlerFunc {
if err != nil {
return 0, nil, StartRefusal(ctx, svc, err)
}
return http.StatusAccepted, map[string]any{"data": crawlView(row), "message": svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.started", nil)}, nil
return http.StatusAccepted, map[string]any{"data": crawlMap(ctx, svc, row), "message": svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.started", nil)}, nil
})
}
@@ -88,3 +90,222 @@ func StartRefusal(ctx context.Context, svc *classes.Service, err error) error {
func RunningMessage(ctx context.Context, svc *classes.Service, running *crawl.AlreadyRunningError) string {
return svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.already_running", map[string]string{"time": running.Since().UTC().Format("2006-01-02 15:04 UTC")})
}
// RenderView is a crawl's rendered comparison in an answer (P33-55):
// skipped (no golem15.seo.crawl_chrome_path), done with the pages
// compared, or failed with the note; message says it in the request
// locale.
type RenderView struct {
Status string `json:"status"`
Pages int `json:"pages"`
Note string `json:"note,omitempty"`
Message string `json:"message"`
}
// renderView is the answer form of a crawl's comparison.
func renderView(ctx context.Context, svc *classes.Service, c models.Crawl) RenderView {
out := RenderView{Status: c.RenderStatus, Pages: c.RenderPages, Note: c.RenderNote}
switch c.RenderStatus {
case models.RenderDone:
out.Message = svc.ChoiceCtx(ctx, "golem15.seo::lang.crawls.render_done", c.RenderPages, nil)
case models.RenderFailed:
out.Message = svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.render_failed", map[string]string{"error": c.RenderNote})
default:
out.Status = models.RenderSkipped
out.Message = svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.render_skipped", nil)
}
return out
}
// ListCrawlsInput is the query of GET /_seo/api/v1/crawls.
type ListCrawlsInput struct {
Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"`
PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"rows per page, at most 200 (50 by default)"`
Status string `json:"status,omitempty" query:"status" jsonschema:"queued, running, finished, failed or cancelled"`
}
// CrawlIDInput names one crawl.
type CrawlIDInput struct {
ID uint `json:"id" path:"id" jsonschema:"the crawl id"`
}
// DeleteCrawlsInput is the query of DELETE /_seo/api/v1/crawls.
type DeleteCrawlsInput struct {
IDs string `json:"ids" query:"ids" jsonschema:"the crawl ids to delete, comma-separated, e.g. 3,4; a queued or running crawl refuses the whole call"`
}
// ListCrawls serves GET /_seo/api/v1/crawls: the crawls, newest first,
// with their issue counts and rendered comparison.
func ListCrawls(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
in := listParams(r)
q := db.Model(&models.Crawl{}).Order("id DESC")
if s := strings.TrimSpace(r.URL.Query().Get("status")); s != "" {
q = q.Where("status = ?", s)
}
var rows []models.Crawl
total, err := paginate(q, in, &rows)
if err != nil {
return 0, nil, err
}
ids := make([]uint, 0, len(rows))
for _, c := range rows {
ids = append(ids, c.ID)
}
counts := map[uint]int{}
if len(ids) > 0 {
var cs []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(&cs).Error; err != nil {
return 0, nil, err
}
for _, c := range cs {
counts[c.CrawlID] = c.N
}
}
out := make([]map[string]any, 0, len(rows))
for _, c := range rows {
v := crawlMap(ctx, svc, c)
v["issues"] = counts[c.ID]
out = append(out, v)
}
return http.StatusOK, listBody(out, in, total), nil
})
}
// crawlMap is a crawl with its rendered comparison.
func crawlMap(ctx context.Context, svc *classes.Service, c models.Crawl) map[string]any {
v := crawlView(c)
return map[string]any{
"id": v.ID, "status": v.Status, "trigger": v.Trigger, "trigger_name": v.TriggerName, "site": v.Site,
"max_pages": v.MaxPages, "pages": v.Pages, "limit_hit": v.LimitHit, "error": v.Error,
"started_at": v.StartedAt, "finished_at": v.FinishedAt, "created_at": v.CreatedAt,
"cancelled_by": c.CancelledBy, "render": renderView(ctx, svc, c),
}
}
// ShowCrawl serves GET /_seo/api/v1/crawls/{id}: one crawl with its
// rendered comparison, whether the staging lock's noindex was left out,
// and its issues per kind and severity.
func ShowCrawl(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
id, err := pathID(r, "id")
if err != nil {
return 0, nil, err
}
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
c, err := crawl.LoadCrawl(db, id)
if err != nil {
return 0, nil, err
}
var kinds []struct {
Kind string `json:"kind"`
Severity string `json:"severity"`
Count int `json:"count"`
}
if err := db.Raw(`SELECT kind, severity, COUNT(*) AS count FROM golem15_seo_issues WHERE crawl_id = ? GROUP BY kind, severity ORDER BY MIN(rank), COUNT(*) DESC, kind`, id).Scan(&kinds).Error; err != nil {
return 0, nil, err
}
out := crawlMap(ctx, svc, c)
out["staging"] = svc.Staging()
bySeverity := map[string]int{}
total := 0
for _, k := range kinds {
bySeverity[k.Severity] += k.Count
total += k.Count
}
out["issues"] = total
out["severities"] = bySeverity
out["kinds"] = kinds
return http.StatusOK, map[string]any{"data": out}, nil
})
}
// CancelCrawl serves POST /_seo/api/v1/crawls/{id}/cancel: a queued or
// running crawl stops and keeps what it fetched; another answers 409
// crawls.not_running.
func CancelCrawl(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
id, err := pathID(r, "id")
if err != nil {
return 0, nil, err
}
var row models.Crawl
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
c, err := crawl.LoadCrawl(tx, id)
if err != nil {
return err
}
_, name, _ := svc.Actor(ctx, tx)
if err := crawl.Cancel(ctx, svc, tx, c, name); err != nil {
return err
}
row, err = crawl.LoadCrawl(tx, id)
return err
})
if errors.Is(err, crawl.ErrNotRunning) {
return 0, nil, Fail(http.StatusConflict, "crawls.not_running")
}
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"data": crawlMap(ctx, svc, row), "message": svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.cancelled", nil)}, nil
})
}
// DeleteCrawls serves DELETE /_seo/api/v1/crawls?ids=…: the crawls go with
// their pages and issues; a queued or running one refuses the whole call
// with 403 crawls.delete_running.
func DeleteCrawls(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
ids, err := idList(r.URL.Query().Get("ids"))
if err != nil {
return 0, nil, err
}
n := 0
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
var err error
n, err = crawl.DeleteCrawls(tx, ids)
return err
})
if errors.Is(err, crawl.ErrDeleteRunning) {
return 0, nil, &apiError{Status: http.StatusForbidden, Body: map[string]any{
"error": "crawls.delete_running",
"message": svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.delete_running", nil),
}}
}
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"deleted": n}, nil
})
}
// idList reads a comma-separated id list of at most 500 ids.
func idList(raw string) ([]uint, error) {
var out []uint
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
n, err := strconv.ParseUint(part, 10, 64)
if err != nil || n == 0 {
return nil, Fail(http.StatusUnprocessableEntity, "ids_invalid")
}
out = append(out, uint(n))
}
if len(out) == 0 || len(out) > 500 {
return nil, Fail(http.StatusUnprocessableEntity, "ids_invalid")
}
return out, nil
}

View File

@@ -2,6 +2,7 @@ package api
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
@@ -15,7 +16,23 @@ import (
// ListIssuesInput is the query of GET /_seo/api/v1/issues.
type ListIssuesInput struct {
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"one kind only, e.g. h1_multiple, broken_link, missing_title, redirect_chain or sitemap_blocked"`
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"one kind only, e.g. h1_multiple, broken_link, missing_title, content_js, orphan_page, redirect_chain or sitemap_blocked"`
Severity string `json:"severity,omitempty" query:"severity" jsonschema:"error, warning or notice"`
Source string `json:"source,omitempty" query:"source" jsonschema:"crawl (found by the crawler) or cms (CMS data checks)"`
Fix string `json:"fix,omitempty" query:"fix" jsonschema:"tool (fixable through the MCP tool the issue names) or code (needs a change in the site's templates)"`
Status string `json:"status,omitempty" query:"status" jsonschema:"open (the default), ignored or all"`
URL string `json:"url,omitempty" query:"url" jsonschema:"only issues of this address (a path such as /blog/post)"`
Crawl uint `json:"crawl,omitempty" query:"crawl" jsonschema:"the issues of this crawl id instead of the current ones"`
}
// IssueIDInput names one issue.
type IssueIDInput struct {
ID uint `json:"id" path:"id" jsonschema:"the issue id"`
}
// IssueIDsInput is the body of the ignore and unignore calls.
type IssueIDsInput struct {
IDs []uint `json:"ids" jsonschema:"the issue ids"`
}
// IssueView is a stored issue in an answer: the Phase 32 keys (kind, url,
@@ -61,8 +78,11 @@ func issueViews(db *gorm.DB, rows []models.Issue) ([]IssueView, error) {
// ListIssues serves GET /_seo/api/v1/issues (D-13, D-14): the audit's
// current issues, errors first: the newest crawl's findings with the
// CMS-data issues stored with it, or before any crawl the CMS-data issues
// as they are now. Each issue names the tool that fixes it, or fix code.
// counts holds the number per kind.
// as they are now (crawl selects another crawl's). Each issue names the
// tool that fixes it, or fix code. The filters are severity, kind,
// source, fix, url and status (open by default: ignored issues are left
// out; ignored or all). counts holds the number per kind before the kind
// filter.
func ListIssues(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
db, err := read(ctx, svc)
@@ -86,15 +106,127 @@ func ListIssues(service func() *classes.Service) http.HandlerFunc {
if err != nil {
return 0, nil, err
}
kind := strings.TrimSpace(q.Get("kind"))
status := strings.TrimSpace(q.Get("status"))
if status == "" {
status = models.IssueOpen
}
match := map[string]string{
"kind": strings.TrimSpace(q.Get("kind")), "severity": strings.TrimSpace(q.Get("severity")),
"source": strings.TrimSpace(q.Get("source")), "fix": strings.TrimSpace(q.Get("fix")),
"url": strings.TrimSpace(q.Get("url")),
}
counts := map[string]int{}
out := make([]IssueView, 0, len(views))
for _, v := range views {
if status == models.IssueOpen && v.Ignored || status == models.IssueIgnored && !v.Ignored {
continue
}
if !matches(match["severity"], v.Severity) || !matches(match["source"], v.Source) || !matches(match["fix"], v.Fix) || !matches(match["url"], v.URL) {
continue
}
counts[v.Kind]++
if kind == "" || v.Kind == kind {
if matches(match["kind"], v.Kind) {
out = append(out, v)
}
}
return http.StatusOK, map[string]any{"data": out, "counts": counts, "total": len(out)}, nil
})
}
// matches reports whether a filter value (empty: any) matches.
func matches(filter, value string) bool { return filter == "" || filter == value }
// RecheckIssue serves POST /_seo/api/v1/issues/{id}/recheck: the issue's
// address is fetched again through the crawler client and its issues are
// replaced; the answer has the address, the issues left and the message.
func RecheckIssue(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
id, err := pathID(r, "id")
if err != nil {
return 0, nil, err
}
var res crawl.RecheckResult
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
issue, err := crawl.LoadIssue(tx, id)
if err != nil {
return err
}
res, err = crawl.Recheck(ctx, svc, tx, issue)
return err
})
if errors.Is(err, crawl.ErrNoSiteURL) {
return 0, nil, StartRefusal(ctx, svc, err)
}
if err != nil {
return 0, nil, err
}
msg := svc.PhraseCtx(ctx, "golem15.seo::lang.issues.recheck_fixed", nil)
if res.Remaining > 0 {
msg = svc.ChoiceCtx(ctx, "golem15.seo::lang.issues.rechecked", res.Remaining, nil)
}
return http.StatusOK, map[string]any{"url": res.URL, "remaining": res.Remaining, "message": msg}, nil
})
}
// IgnoreIssues serves POST /_seo/api/v1/issues/ignore: the issues are
// hidden in this and later crawls until they are no longer ignored.
func IgnoreIssues(service func() *classes.Service) http.HandlerFunc {
return ignoreHandler(service, true)
}
// UnignoreIssues serves POST /_seo/api/v1/issues/unignore: the issues are
// shown in the audit again.
func UnignoreIssues(service func() *classes.Service) http.HandlerFunc {
return ignoreHandler(service, false)
}
func ignoreHandler(service func() *classes.Service, ignore bool) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
var in IssueIDsInput
if err := decode(r, maxBody, &in); err != nil {
return 0, nil, err
}
if len(in.IDs) == 0 || len(in.IDs) > 1000 {
return 0, nil, Fail(http.StatusUnprocessableEntity, "ids_invalid")
}
n := 0
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
var rows []models.Issue
if err := tx.Where("id IN ?", in.IDs).Find(&rows).Error; err != nil {
return err
}
if len(rows) != len(uniqueIDs(in.IDs)) {
return gorm.ErrRecordNotFound
}
var err error
if ignore {
adminID, _, _ := svc.Actor(ctx, tx)
n, err = crawl.Ignore(tx, rows, adminID)
} else {
n, err = crawl.Unignore(tx, rows)
}
return err
})
if err != nil {
return 0, nil, err
}
key, field := "golem15.seo::lang.issues.unignored", "unignored"
if ignore {
key, field = "golem15.seo::lang.issues.ignored", "ignored"
}
return http.StatusOK, map[string]any{field: n, "message": svc.ChoiceCtx(ctx, key, n, nil)}, nil
})
}
// uniqueIDs drops repeated ids.
func uniqueIDs(ids []uint) []uint {
seen := map[uint]bool{}
var out []uint
for _, id := range ids {
if !seen[id] {
seen[id] = true
out = append(out, id)
}
}
return out
}

350
controllers/crawls.go Normal file
View File

@@ -0,0 +1,350 @@
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{}
)

View File

@@ -0,0 +1,31 @@
{{- with .Data -}}
{{- if .HasCallout -}}
<div class="summer-callout{{ if .Tone }} summer-callout--{{ .Tone }}{{ end }}" role="status">
{{- if .Title -}}<p class="summer-callout__title">{{ .Title }}</p>{{- end -}}
<p class="summer-callout__text">{{ .Text }}</p>
</div>
{{- end -}}
{{- if .Staging -}}<p>{{ .Staging }}</p>{{- end -}}
{{- if .Render -}}<p>{{ .Render }}</p>{{- end -}}
{{- if .Stats -}}
<dl class="summer-stats">
{{- range .Stats -}}
<div class="summer-stat"><dt class="summer-stat__label">{{ .Label }}</dt><dd class="summer-stat__value">{{ .Value }}</dd></div>
{{- end -}}
</dl>
{{- end -}}
{{- if .Kinds -}}
<div class="summer-table-wrap">
<table class="summer-table">
<thead><tr><th>{{ trans "golem15.seo::lang.crawls.kinds_problem" }}</th><th>{{ trans "golem15.seo::lang.crawls.kinds_severity" }}</th><th class="summer-table__num">{{ trans "golem15.seo::lang.crawls.kinds_count" }}</th></tr></thead>
<tbody>
{{- range .Kinds -}}
<tr><td>{{ .Label }}</td><td>{{ .Severity }}</td><td class="summer-table__num"><a href="{{ .Link }}">{{ .Count }}</a></td></tr>
{{- end -}}
</tbody>
</table>
</div>
{{- else if .NoIssues -}}
<p>{{ trans "golem15.seo::lang.crawls.no_issues" }}</p>
{{- end -}}
{{- end -}}

View File

@@ -0,0 +1,11 @@
# Crawls filters (UI-SPEC C24): status and start date.
scopes:
status:
label: golem15.seo::lang.crawls.status
modelClass: Golem15\SEO\Models\Crawl
nameFrom: status
scope: FilterCrawlStatus
started_at:
label: golem15.seo::lang.crawls.started_at
type: daterange
column: started_at

View File

@@ -0,0 +1,14 @@
# Crawl preview (UI-SPEC C24): read-only, P6 above the fields and Cancel
# while the crawl is queued or running.
name: golem15.seo::lang.crawls.name
form: ~/plugins/golem15/seo/models/crawl/fields.yaml
modelClass: Golem15\SEO\Models\Crawl
defaultRedirect: golem15/seo/crawls
preview:
headerPartial: crawl_summary
recordActions: [cancel]
messages:
preview: golem15.seo::lang.crawls.preview_title

View File

@@ -0,0 +1,22 @@
# Crawls (UI-SPEC C24): newest first; the toolbar starts a crawl and
# deletes the selected ones (a running crawl refuses).
list: ~/plugins/golem15/seo/models/crawl/columns.yaml
modelClass: Golem15\SEO\Models\Crawl
title: golem15.seo::lang.crawls.list_title
recordUrl: golem15/seo/crawls/preview/:id
noRecordsMessage: golem15.seo::lang.crawls.list_empty
recordsPerPage: 20
showCheckboxes: true
showSetup: true
filter: config_filter.yaml
defaultSort:
column: started_at
direction: desc
toolbar:
buttons: [start_crawl, delete]
messages:
empty: golem15.seo::lang.crawls.list_empty
deleteConfirm: golem15.seo::lang.crawls.delete_selected_confirm

505
controllers/issues.go Normal file
View File

@@ -0,0 +1,505 @@
package controllers
import (
"context"
"errors"
"fmt"
"strings"
"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/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// IssuesControllerID is the Site audit (UI-SPEC C23).
const IssuesControllerID = "golem15.seo.issues"
// The Site audit's actions: the toolbar's start, the record actions and
// the bulk ignore.
const (
ActionStartCrawl = "start_crawl"
IssueActionCheck = "recheck"
IssueActionIgnore = "ignore"
IssueActionShow = "unignore"
)
// errIssueReadOnly refuses creating or editing issues: the audit writes
// them.
var errIssueReadOnly = errors.New("golem15.seo: issues are read-only")
// issuesAdminController lists the audit's current issues, errors first,
// and previews one with P7 and its record actions (check again, ignore,
// stop ignoring); the toolbar starts a crawl and the bulk action ignores.
type issuesAdminController struct {
app func() *backpack.App
}
func (issuesAdminController) ID() string { return IssuesControllerID }
func (issuesAdminController) ModelName() string { return `Golem15\SEO\Models\Issue` }
func (issuesAdminController) ConfigDir() string { return "controllers/issues" }
func (issuesAdminController) NewRecord() any { return &models.Issue{} }
func (issuesAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
// FormBeforeCreate refuses: the audit writes issues.
func (issuesAdminController) FormBeforeCreate(context.Context, any) error { return errIssueReadOnly }
// FormBeforeUpdate refuses: issues are never edited.
func (issuesAdminController) FormBeforeUpdate(context.Context, any) error { return errIssueReadOnly }
func (c issuesAdminController) service() *classes.Service {
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return nil
}
return svc
}
// ListExtendQuery narrows the list to the current issues (the newest
// crawl's; before any crawl the CMS-data issues, checked now) and puts
// errors first, then warnings, then notices.
func (c issuesAdminController) ListExtendQuery(ctx context.Context, q *gorm.DB) *gorm.DB {
svc := c.service()
db, err := database(ctx, c.app)
if svc == nil || err != nil {
return q.Where("1 = 0")
}
latest, err := crawl.Current(ctx, svc, db)
if err != nil {
log.Warn("golem15.seo: site audit list", "error", err)
return q.Where("1 = 0")
}
return crawl.CurrentScope(q, latest).Order("golem15_seo_issues.rank ASC")
}
// startCrawl is the toolbar's Start crawl (also on Crawls): it refuses with
// crawls.already_running or crawls.no_site_url as an action error and
// toasts crawls.started.
func startCrawl(app func() *backpack.App) pact.AdminAction {
return pact.AdminAction{
Name: ActionStartCrawl,
Label: "golem15.seo::lang.crawls.start",
Permissions: []string{PermissionManage},
Run: func(ctx context.Context, _ pact.AdminActionInput) (pact.AdminActionResult, error) {
svc, err := classes.NewService(appOf(app))
if err != nil {
return pact.AdminActionResult{}, err
}
db, err := database(ctx, app)
if err != nil {
return pact.AdminActionResult{}, err
}
err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
_, name, _ := svc.Actor(ctx, tx)
_, err := crawl.Start(ctx, svc, tx, models.TriggerAdmin, name)
return err
})
if err != nil {
return pact.AdminActionResult{}, startRefusal(ctx, app, err)
}
return pact.AdminActionResult{Message: "golem15.seo::lang.crawls.started"}, nil
},
}
}
// startRefusal turns a refused start into the admin's action error.
func startRefusal(ctx context.Context, app func() *backpack.App, err error) error {
tr := translator(app)
var running *crawl.AlreadyRunningError
switch {
case errors.As(err, &running):
return &cabana.ForbiddenError{Message: Phrase(ctx, tr, "golem15.seo::lang.crawls.already_running", map[string]string{"time": running.Since().UTC().Format("2006-01-02 15:04 UTC")})}
case errors.Is(err, crawl.ErrNoSiteURL):
return &cabana.ForbiddenError{Message: Phrase(ctx, tr, "golem15.seo::lang.crawls.no_site_url", nil)}
}
return err
}
// AdminActions is the toolbar's Start crawl.
func (c issuesAdminController) AdminActions() []pact.AdminAction {
return []pact.AdminAction{startCrawl(c.app)}
}
// ignored reports whether a record is an ignored issue.
func (c issuesAdminController) ignored(ctx context.Context, record any) (bool, error) {
issue, ok := record.(*models.Issue)
if !ok || issue == nil {
return false, nil
}
db, err := database(ctx, c.app)
if err != nil {
return false, err
}
return crawl.IsIgnored(db, *issue)
}
// AdminRecordActions are the preview's Check this address again and Ignore
// (while open) and Stop ignoring (while ignored).
func (c issuesAdminController) AdminRecordActions() []pact.AdminRecordAction {
open := func(ctx context.Context, record any) (bool, error) {
ig, err := c.ignored(ctx, record)
return !ig, err
}
return []pact.AdminRecordAction{
{
Name: IssueActionCheck,
Label: "golem15.seo::lang.issues.recheck",
Confirm: "golem15.seo::lang.issues.recheck_confirm",
Permissions: []string{PermissionManage},
Applies: open,
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
issue, ok := in.Record.(*models.Issue)
svc := c.service()
if !ok || issue == nil || svc == nil {
return pact.AdminRecordActionResult{}, classes.ErrNotBooted
}
db, err := database(ctx, c.app)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
res, err := crawl.Recheck(ctx, svc, db, *issue)
if err != nil {
if errors.Is(err, crawl.ErrNoSiteURL) {
return pact.AdminRecordActionResult{}, &cabana.ForbiddenError{Message: Phrase(ctx, translator(c.app), "golem15.seo::lang.crawls.no_site_url", nil)}
}
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{Message: RecheckMessage(ctx, svc, res)}, nil
},
},
{
Name: IssueActionIgnore,
Label: "golem15.seo::lang.issues.ignore",
Confirm: "golem15.seo::lang.issues.ignore_confirm",
Permissions: []string{PermissionManage},
Applies: open,
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
return c.ignore(ctx, []any{in.Record})
},
},
{
Name: IssueActionShow,
Label: "golem15.seo::lang.issues.unignore",
Confirm: "golem15.seo::lang.issues.unignore_confirm",
Permissions: []string{PermissionManage},
Applies: c.ignored,
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
issue, ok := in.Record.(*models.Issue)
if !ok || issue == nil {
return pact.AdminRecordActionResult{}, classes.ErrNotBooted
}
db, err := database(ctx, c.app)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
n, err := crawl.Unignore(db, []models.Issue{*issue})
if err != nil {
return pact.AdminRecordActionResult{}, err
}
svc := c.service()
return pact.AdminRecordActionResult{Message: svc.ChoiceCtx(ctx, "golem15.seo::lang.issues.unignored", n, nil)}, nil
},
},
}
}
// RecheckMessage is the toast of a recheck: issues.rechecked with the
// remaining count, or issues.recheck_fixed.
func RecheckMessage(ctx context.Context, svc *classes.Service, res crawl.RecheckResult) string {
if res.Remaining == 0 {
return svc.PhraseCtx(ctx, "golem15.seo::lang.issues.recheck_fixed", nil)
}
return svc.ChoiceCtx(ctx, "golem15.seo::lang.issues.rechecked", res.Remaining, nil)
}
// ignore stores the fingerprints of records as ignored.
func (c issuesAdminController) ignore(ctx context.Context, records []any) (pact.AdminRecordActionResult, error) {
svc := c.service()
db, err := database(ctx, c.app)
if err != nil || svc == nil {
return pact.AdminRecordActionResult{}, errors.Join(err, classes.ErrNotBooted)
}
var list []models.Issue
for _, r := range records {
if issue, ok := r.(*models.Issue); ok && issue != nil {
list = append(list, *issue)
}
}
adminID, _, _ := svc.Actor(ctx, db)
n, err := crawl.Ignore(db, list, adminID)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{Message: svc.ChoiceCtx(ctx, "golem15.seo::lang.issues.ignored", n, nil)}, nil
}
// AdminBulkActions is the list's bulk Ignore issues.
func (c issuesAdminController) AdminBulkActions() []pact.AdminBulkAction {
return []pact.AdminBulkAction{{
Name: IssueActionIgnore,
Label: "golem15.seo::lang.issues.ignore_selected",
Confirm: "golem15.seo::lang.issues.ignore_selected_confirm",
Permissions: []string{PermissionManage},
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
res, err := c.ignore(ctx, in.Records)
if err != nil {
return pact.AdminBulkActionResult{}, err
}
return pact.AdminBulkActionResult{Message: res.Message, Affected: len(in.Records)}, nil
},
}}
}
// ListRowStates marks ignored issues disabled (issues.state_ignored).
func (issuesAdminController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) {
var fps []string
for _, r := range records {
if issue, ok := r.(*models.Issue); ok && issue != nil {
fps = append(fps, issue.Fingerprint)
}
}
set, err := crawl.IgnoredSet(db.WithContext(ctx), fps)
if err != nil {
return nil, err
}
out := make([][]pact.RowState, len(records))
for i, r := range records {
if issue, ok := r.(*models.Issue); ok && issue != nil && set[issue.Fingerprint] {
out[i] = []pact.RowState{pact.RowStateDisabled}
}
}
return out, nil
}
// labelKeys are the phrase keys of the stored severity and source values.
var labelKeys = map[string]string{
models.SeverityError: "golem15.seo::lang.issues.sev_error",
models.SeverityWarning: "golem15.seo::lang.issues.sev_warning",
models.SeverityNotice: "golem15.seo::lang.issues.sev_notice",
models.SourceCrawl: "golem15.seo::lang.issues.source_crawl",
models.SourceCMS: "golem15.seo::lang.issues.source_cms",
}
// FixCell is the Fix column: issues.fix_tool with the tool, or
// issues.fix_code.
func FixCell(ctx context.Context, svc *classes.Service, issue models.Issue) string {
if issue.Fix == models.FixTool {
return svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_tool", map[string]string{"tool": issue.Tool})
}
return svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_code", nil)
}
// ListExtendRecords shows the severity, kind and source labels, the
// one-line summary and the fix route in the request locale.
func (c issuesAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error {
svc := c.service()
if svc == nil {
return nil
}
for _, r := range records {
issue, ok := r.(*models.Issue)
if !ok || issue == nil {
continue
}
issue.Summary = svc.IssueSummary(ctx, issue.Kind, issue.DetailsMap())
issue.FixCell = FixCell(ctx, svc, *issue)
issue.KindCell = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.kind_"+issue.Kind, nil)
if key, ok := labelKeys[issue.Severity]; ok {
issue.Severity = svc.PhraseCtx(ctx, key, nil)
}
if key, ok := labelKeys[issue.Source]; ok {
issue.Source = svc.PhraseCtx(ctx, key, nil)
}
}
return nil
}
// DropdownOptions labels the preview's read-only kind, severity and
// source fields.
func (issuesAdminController) DropdownOptions(field string) []pact.Option {
switch field {
case "kind":
out := make([]pact.Option, 0, len(crawl.Kinds()))
for _, k := range crawl.Kinds() {
out = append(out, pact.Option{Value: k, Label: "golem15.seo::lang.issues.kind_" + k})
}
return out
case "severity":
return []pact.Option{
{Value: models.SeverityError, Label: labelKeys[models.SeverityError]},
{Value: models.SeverityWarning, Label: labelKeys[models.SeverityWarning]},
{Value: models.SeverityNotice, Label: labelKeys[models.SeverityNotice]},
}
case "source":
return []pact.Option{
{Value: models.SourceCrawl, Label: labelKeys[models.SourceCrawl]},
{Value: models.SourceCMS, Label: labelKeys[models.SourceCMS]},
}
}
return nil
}
// FilterOptions serves the severity, kind, source, fix and status filters.
func (c issuesAdminController) FilterOptions(scope string) []pact.Option {
switch scope {
case models.FilterIssueSeverity:
return c.DropdownOptions("severity")
case models.FilterIssueKind:
return c.DropdownOptions("kind")
case models.FilterIssueSource:
return c.DropdownOptions("source")
case models.FilterIssueFix:
return []pact.Option{
{Value: models.FixTool, Label: "golem15.seo::lang.issues.filter_fix_tool"},
{Value: models.FixCode, Label: "golem15.seo::lang.issues.filter_fix_code"},
}
case models.FilterIssueStatus:
return []pact.Option{
{Value: models.IssueOpen, Label: "golem15.seo::lang.issues.status_open"},
{Value: models.IssueIgnored, Label: "golem15.seo::lang.issues.status_ignored"},
}
}
return nil
}
// maxLinkedShown caps P7's linking pages before issues.and_more.
const maxLinkedShown = 10
// PartialData serves P7 issue_details (UI-SPEC C23).
func (c issuesAdminController) PartialData(ctx context.Context, name string, record any) (any, error) {
issue, ok := record.(*models.Issue)
svc := c.service()
if name != "issue_details" || !ok || issue == nil || svc == nil {
return classes.IssueDetailsView{}, nil
}
return IssueDetails(ctx, svc, c.app, *issue), nil
}
// IssueDetails builds P7: the kind's found rows, the linking pages, the
// orphan suggestions, how to fix it and the page address.
func IssueDetails(ctx context.Context, svc *classes.Service, app func() *backpack.App, issue models.Issue) classes.IssueDetailsView {
d := issue.DetailsMap()
view := classes.IssueDetailsView{}
label := func(key string) string { return svc.PhraseCtx(ctx, "golem15.seo::lang.issues."+key, nil) }
view.Rows = append(view.Rows, classes.IssueRow{Label: label("summary"), Value: svc.IssueSummary(ctx, issue.Kind, d)})
add := func(labelKey string, value any, mono bool) {
if value == nil {
return
}
text := fmt.Sprint(value)
if list, ok := value.([]any); ok {
parts := make([]string, 0, len(list))
for _, e := range list {
switch x := e.(type) {
case map[string]any:
var kv []string
for _, k := range []string{"type", "property", "text", "target", "locale", "url", "status", "from", "to"} {
if v, ok := x[k]; ok {
kv = append(kv, fmt.Sprint(v))
}
}
parts = append(parts, strings.Join(kv, " · "))
default:
parts = append(parts, fmt.Sprint(x))
}
}
text = strings.Join(parts, "; ")
}
if strings.TrimSpace(text) == "" {
return
}
view.Rows = append(view.Rows, classes.IssueRow{Label: label(labelKey), Value: text, Mono: mono})
}
add("detail_h1", d["h1"], false)
add("detail_skips", d["skips"], false)
add("detail_missing", d["missing"], false)
add("detail_error", d["error"], true)
add("detail_anchors", d["anchors"], false)
add("detail_alternates", d["alternates"], true)
add("detail_images", d["images"], true)
add("detail_target", d["target"], true)
add("detail_what", d["what"], false)
if from, ok := d["from"].([]any); ok && issue.Kind != classes.IssueImageAlt {
if _, isLevel := d["to"]; !isLevel {
for i, f := range from {
if i >= maxLinkedShown {
break
}
view.LinkedFrom = append(view.LinkedFrom, classes.IssueLink{URL: fmt.Sprint(f)})
}
total := len(from)
if n, ok := d["count"].(float64); ok && int(n) > total {
total = int(n)
}
if total > len(view.LinkedFrom) {
view.More = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.and_more", map[string]string{"count": fmt.Sprint(total - len(view.LinkedFrom))})
}
}
}
if issue.Kind == crawl.KindOrphanPage {
view.Orphan = true
if list, ok := d["suggestions"].([]any); ok {
for _, e := range list {
s, _ := e.(map[string]any)
u := fmt.Sprint(s["url"])
view.Suggestions = append(view.Suggestions, classes.IssueLink{URL: u, Phrase: fmt.Sprint(s["phrase"])})
}
}
}
if issue.Fix == models.FixTool {
view.FixText = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_tool_text", map[string]string{"tool": issue.Tool, "where": fixWhere(ctx, svc, issue)})
} else {
what, _ := d["what"].(string)
view.FixText = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_code_text", map[string]string{"what": what})
}
site := svc.SiteURL()
if strings.HasPrefix(issue.URL, "/") && site != "" {
view.PageURL = site + issue.URL
}
if strings.HasPrefix(issue.Kind, "jsonld_") && view.PageURL != "" {
view.RichResults = true
view.RichURL = "https://search.google.com/test/rich-results?url=" + view.PageURL
}
return view
}
// fixWhere names the admin screen where a CMS tool's value is changed.
func fixWhere(ctx context.Context, svc *classes.Service, issue models.Issue) string {
key := "golem15.seo::lang.issues.where_record"
switch issue.Tool {
case crawl.ToolUpdateRobots:
key = "golem15.seo::lang.issues.where_robots"
case crawl.ToolUpdateSettings:
key = "golem15.seo::lang.issues.where_settings"
case crawl.ToolCreateRedirect, "seo_update_redirect":
key = "golem15.seo::lang.issues.where_redirects"
case crawl.ToolCreatePage, crawl.ToolUpdatePage:
key = "golem15.seo::lang.issues.where_pages"
case "seo_update_rule":
key = "golem15.seo::lang.issues.where_rules"
}
return svc.PhraseCtx(ctx, key, nil)
}
var (
_ pact.AdminController = issuesAdminController{}
_ pact.AdminPermissioned = issuesAdminController{}
_ pact.AdminRecordSource = issuesAdminController{}
_ pact.FormBeforeCreate = issuesAdminController{}
_ pact.FormBeforeUpdate = issuesAdminController{}
_ pact.ListExtendQuery = issuesAdminController{}
_ pact.HasAdminActions = issuesAdminController{}
_ pact.HasAdminRecordActions = issuesAdminController{}
_ pact.HasAdminBulkActions = issuesAdminController{}
_ pact.ListRowStates = issuesAdminController{}
_ pact.ListExtendRecords = issuesAdminController{}
_ pact.DropdownOptionsProvider = issuesAdminController{}
_ pact.FilterOptions = issuesAdminController{}
_ pact.AdminPartialData = issuesAdminController{}
)

View File

@@ -0,0 +1,48 @@
{{- with .Data -}}
{{- if .Rows -}}
<p><strong>{{ trans "golem15.seo::lang.issues.found_heading" }}</strong></p>
<div class="summer-table-wrap">
<table class="summer-table">
<tbody>
{{- range .Rows -}}
<tr><th scope="row">{{ .Label }}</th><td>{{ if .Mono }}<code>{{ .Value }}</code>{{ else }}{{ .Value }}{{ end }}</td></tr>
{{- end -}}
</tbody>
</table>
</div>
{{- end -}}
{{- if .LinkedFrom -}}
<p>{{ trans "golem15.seo::lang.issues.linked_from" }}</p>
<ul>
{{- range .LinkedFrom -}}<li><code>{{ .URL }}</code></li>{{- end -}}
</ul>
{{- if .More -}}<p class="summer-table__sub">{{ .More }}</p>{{- end -}}
{{- end -}}
{{- if .Orphan -}}
<p>{{ trans "golem15.seo::lang.issues.orphan_suggestions" }}</p>
{{- if .Suggestions -}}
<div class="summer-table-wrap">
<table class="summer-table">
<thead><tr><th>{{ trans "golem15.seo::lang.issues.orphan_suggestions_page" }}</th><th>{{ trans "golem15.seo::lang.issues.orphan_suggestions_phrase" }}</th></tr></thead>
<tbody>
{{- range .Suggestions -}}
<tr><td><code>{{ .URL }}</code></td><td>{{ .Phrase }}</td></tr>
{{- end -}}
</tbody>
</table>
</div>
{{- else -}}
<p class="summer-table__sub">{{ trans "golem15.seo::lang.issues.orphan_none" }}</p>
{{- end -}}
{{- end -}}
<div class="summer-callout" role="status">
<p class="summer-callout__title">{{ trans "golem15.seo::lang.issues.fix_heading" }}</p>
<p class="summer-callout__text">{{ .FixText }}</p>
</div>
{{- if .PageURL -}}
<p>{{ trans "golem15.seo::lang.issues.open_page" }}: <code>{{ .PageURL }}</code></p>
{{- end -}}
{{- if .RichResults -}}
<p>{{ trans "golem15.seo::lang.issues.rich_results" }}: <code>{{ .RichURL }}</code></p>
{{- end -}}
{{- end -}}

View File

@@ -0,0 +1,28 @@
# Site audit filters (UI-SPEC C23): severity, kind, source, fix and status
# (Open or Ignored).
scopes:
severity:
label: golem15.seo::lang.issues.filter_severity
modelClass: Golem15\SEO\Models\Issue
nameFrom: severity
scope: FilterIssueSeverity
kind:
label: golem15.seo::lang.issues.filter_kind
modelClass: Golem15\SEO\Models\Issue
nameFrom: kind
scope: FilterIssueKind
source:
label: golem15.seo::lang.issues.filter_source
modelClass: Golem15\SEO\Models\Issue
nameFrom: source
scope: FilterIssueSource
fix:
label: golem15.seo::lang.issues.filter_fix
modelClass: Golem15\SEO\Models\Issue
nameFrom: fix
scope: FilterIssueFix
status:
label: golem15.seo::lang.issues.filter_status
modelClass: Golem15\SEO\Models\Issue
nameFrom: url
scope: FilterIssueStatus

View File

@@ -0,0 +1,14 @@
# Issue preview (UI-SPEC C23): read-only, P7 above the fields and the
# record actions Check this address again, Ignore and Stop ignoring.
name: golem15.seo::lang.issues.name
form: ~/plugins/golem15/seo/models/issue/fields.yaml
modelClass: Golem15\SEO\Models\Issue
defaultRedirect: golem15/seo/issues
preview:
headerPartial: issue_details
recordActions: [recheck, ignore, unignore]
messages:
preview: golem15.seo::lang.issues.preview

View File

@@ -0,0 +1,25 @@
# Site audit (UI-SPEC C23): the current issues, errors first (the
# controller's ListExtendQuery), then by kind; ignored issues badged.
list: ~/plugins/golem15/seo/models/issue/columns.yaml
modelClass: Golem15\SEO\Models\Issue
title: golem15.seo::lang.issues.list_title
recordUrl: golem15/seo/issues/preview/:id
noRecordsMessage: golem15.seo::lang.issues.list_empty
recordsPerPage: 50
showCheckboxes: true
showSetup: true
filter: config_filter.yaml
bulkActions: [ignore]
defaultSort:
column: kind
direction: asc
toolbar:
buttons: [start_crawl]
search:
prompt: backend::lang.list.search_prompt
messages:
empty: golem15.seo::lang.issues.list_empty
rowStateDisabled: golem15.seo::lang.issues.state_ignored