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:
@@ -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},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
350
controllers/crawls.go
Normal 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{}
|
||||
)
|
||||
31
controllers/crawls/_crawl_summary.htm
Normal file
31
controllers/crawls/_crawl_summary.htm
Normal 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 -}}
|
||||
11
controllers/crawls/config_filter.yaml
Normal file
11
controllers/crawls/config_filter.yaml
Normal 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
|
||||
14
controllers/crawls/config_form.yaml
Normal file
14
controllers/crawls/config_form.yaml
Normal 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
|
||||
22
controllers/crawls/config_list.yaml
Normal file
22
controllers/crawls/config_list.yaml
Normal 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
505
controllers/issues.go
Normal 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{}
|
||||
)
|
||||
48
controllers/issues/_issue_details.htm
Normal file
48
controllers/issues/_issue_details.htm
Normal 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 -}}
|
||||
28
controllers/issues/config_filter.yaml
Normal file
28
controllers/issues/config_filter.yaml
Normal 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
|
||||
14
controllers/issues/config_form.yaml
Normal file
14
controllers/issues/config_form.yaml
Normal 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
|
||||
25
controllers/issues/config_list.yaml
Normal file
25
controllers/issues/config_list.yaml
Normal 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
|
||||
Reference in New Issue
Block a user