Add read-only task context inheritance, ordered task-level LLM failover, live Worker controls, cascading task cleanup, FIFO task concurrency, cancellation diagnostics, UI updates, tests, build script, and changelog.
576 lines
18 KiB
Go
576 lines
18 KiB
Go
package db
|
||
|
||
import (
|
||
"fmt"
|
||
"net"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// TaskScope is one row of a task's test scope — the coverage denominator and the
|
||
// per-task authorization edge. Rows come either from insertAssets (source='auto',
|
||
// conservative, one per explicitly-inserted asset) or from the add_task_scope tool
|
||
// (source='agent', for company / whole-root-domain / subdomain / ip).
|
||
type TaskScope struct {
|
||
ID int64 `json:"id"`
|
||
TaskID int64 `json:"task_id"`
|
||
Kind string `json:"kind"` // company|root_domain|subdomain|ip|cidr
|
||
CompanyID *int64 `json:"company_id,omitempty"`
|
||
Domain string `json:"domain,omitempty"`
|
||
Net string `json:"net,omitempty"`
|
||
Source string `json:"source"`
|
||
Reason string `json:"reason,omitempty"`
|
||
}
|
||
|
||
// stripHostPort drops a trailing :port from a host:port / ip:port / [ipv6]:port
|
||
// value, returning the bare host. Bare hosts, bare IPs (v4 or v6, whose colons make
|
||
// them ambiguous), and anything not in host:port form are returned unchanged. Scope
|
||
// is host/net based, so the port from a target like "10.0.188.136:3000" or
|
||
// "api.example.com:8080" is simply discarded rather than baked into the key.
|
||
func stripHostPort(v string) string {
|
||
v = strings.TrimSpace(v)
|
||
if host, _, err := net.SplitHostPort(v); err == nil {
|
||
return host
|
||
}
|
||
return v
|
||
}
|
||
|
||
// ipToHostCIDR turns a bare IP into its single-host CIDR (/32 or /128). "" if invalid.
|
||
func ipToHostCIDR(ip string) string {
|
||
ip = strings.TrimSpace(ip)
|
||
p := net.ParseIP(ip)
|
||
if p == nil {
|
||
return ""
|
||
}
|
||
if p.To4() != nil {
|
||
return ip + "/32"
|
||
}
|
||
return ip + "/128"
|
||
}
|
||
|
||
// upsertTaskScope inserts one scope row idempotently (uq_task_scope). A duplicate is
|
||
// silently ignored. taskID<=0 or empty kind → no-op.
|
||
func (s *AssetStore) upsertTaskScope(ts TaskScope) error {
|
||
if ts.TaskID <= 0 || ts.Kind == "" {
|
||
return nil
|
||
}
|
||
var domainVal, netVal, companyVal any
|
||
if ts.Domain != "" {
|
||
domainVal = ts.Domain
|
||
}
|
||
if ts.Net != "" {
|
||
netVal = ts.Net
|
||
}
|
||
if ts.CompanyID != nil && *ts.CompanyID > 0 {
|
||
companyVal = *ts.CompanyID
|
||
}
|
||
src := ts.Source
|
||
if src == "" {
|
||
src = "auto"
|
||
}
|
||
_, err := s.db.Exec(`
|
||
INSERT INTO task_scope(task_id, kind, company_id, domain, net, source, reason)
|
||
VALUES ($1,$2,$3,$4,$5::cidr,$6,NULLIF($7,''))
|
||
ON CONFLICT DO NOTHING`, ts.TaskID, ts.Kind, companyVal, domainVal, netVal, src, ts.Reason)
|
||
return err
|
||
}
|
||
|
||
// AddAutoScope records the conservative task scope implied by ONE explicitly-inserted
|
||
// asset item (source='auto'). MUST be called only from insertAssets' top-level loop —
|
||
// never from a db-layer side effect (linkHostAssets), so派生资产不会盲目扩大范围。
|
||
// Rule: scope granularity follows the asset's own type. taskID<=0 → no-op.
|
||
func (s *AssetStore) AddAutoScope(taskID int64, assetType, domain, rawURL, ip string) error {
|
||
if taskID <= 0 {
|
||
return nil
|
||
}
|
||
switch assetType {
|
||
case "root_domain":
|
||
if d := DomainKey(domain); d != "" {
|
||
return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "root_domain", Domain: d})
|
||
}
|
||
case "subdomain":
|
||
if d := DomainKey(domain); d != "" {
|
||
return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "subdomain", Domain: d})
|
||
}
|
||
case "service", "endpoint":
|
||
host := domain
|
||
if host == "" && rawURL != "" {
|
||
host, _, _ = parseURL(normalizeURL(rawURL))
|
||
}
|
||
host = DomainKey(host)
|
||
if host != "" && net.ParseIP(host) == nil {
|
||
return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "subdomain", Domain: host})
|
||
}
|
||
// IP-literal host or no host → fall back to ip scope if we have one.
|
||
if c := ipToHostCIDR(ip); c != "" {
|
||
return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "ip", Net: c})
|
||
}
|
||
case "ip":
|
||
if c := ipToHostCIDR(ip); c != "" {
|
||
return s.upsertTaskScope(TaskScope{TaskID: taskID, Kind: "ip", Net: c})
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// AddAgentScope parses an agent-provided (kind,value) and records it (source='agent').
|
||
// company: value = company name or id (must already exist). root_domain/subdomain:
|
||
// value = a domain. ip/cidr: value = an IP or CIDR (bare IP → /32,/128).
|
||
func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason string) (TaskScope, error) {
|
||
ts := TaskScope{TaskID: taskID, Kind: kind, Source: "agent", Reason: reason}
|
||
if taskID <= 0 {
|
||
return ts, fmt.Errorf("需要 task_id")
|
||
}
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return ts, fmt.Errorf("value 不能为空")
|
||
}
|
||
switch kind {
|
||
case "company":
|
||
if s.company == nil {
|
||
return ts, fmt.Errorf("company store 未启用")
|
||
}
|
||
var comp *Company
|
||
var err error
|
||
if id, e := strconv.ParseInt(value, 10, 64); e == nil {
|
||
comp, err = s.company.GetCompany(id)
|
||
} else {
|
||
comp, err = s.company.GetCompanyByName(value)
|
||
}
|
||
if err != nil {
|
||
return ts, err
|
||
}
|
||
if comp == nil {
|
||
return ts, fmt.Errorf("company 不存在: %s(先用 list_companies 确认,或建好企业)", value)
|
||
}
|
||
ts.CompanyID = &comp.ID
|
||
case "root_domain":
|
||
d := DomainKey(stripHostPort(value))
|
||
root, _ := RootDomain(d)
|
||
if root == "" {
|
||
root = d
|
||
}
|
||
if root == "" {
|
||
return ts, fmt.Errorf("无效根域: %s", value)
|
||
}
|
||
ts.Domain = root
|
||
case "subdomain":
|
||
d := DomainKey(stripHostPort(value))
|
||
if d == "" {
|
||
return ts, fmt.Errorf("无效子域: %s", value)
|
||
}
|
||
ts.Domain = d
|
||
case "ip", "cidr":
|
||
v := value
|
||
if !strings.Contains(v, "/") {
|
||
v = ipToHostCIDR(stripHostPort(v))
|
||
ts.Kind = "ip"
|
||
} else {
|
||
ts.Kind = "cidr"
|
||
}
|
||
if v == "" {
|
||
return ts, fmt.Errorf("无效 ip/cidr: %s", value)
|
||
}
|
||
if _, _, err := net.ParseCIDR(v); err != nil {
|
||
return ts, fmt.Errorf("无效 ip/cidr: %s", value)
|
||
}
|
||
ts.Net = v
|
||
default:
|
||
return ts, fmt.Errorf("不支持的 kind: %s(company/root_domain/subdomain/ip/cidr)", kind)
|
||
}
|
||
if err := s.upsertTaskScope(ts); err != nil {
|
||
return ts, err
|
||
}
|
||
return ts, nil
|
||
}
|
||
|
||
// ListTaskScope returns all scope rows for a task.
|
||
func (s *AssetStore) ListTaskScope(taskID int64) ([]TaskScope, error) {
|
||
rows, err := s.db.Query(`
|
||
SELECT id, kind, COALESCE(company_id,0), COALESCE(domain,''), COALESCE(net::text,''), source, COALESCE(reason,'')
|
||
FROM task_scope WHERE task_id=$1 ORDER BY id`, taskID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []TaskScope{}
|
||
for rows.Next() {
|
||
var t TaskScope
|
||
var cid int64
|
||
if err := rows.Scan(&t.ID, &t.Kind, &cid, &t.Domain, &t.Net, &t.Source, &t.Reason); err != nil {
|
||
return nil, err
|
||
}
|
||
t.TaskID = taskID
|
||
if cid > 0 {
|
||
t.CompanyID = &cid
|
||
}
|
||
out = append(out, t)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// CoverageAsset is one in-scope asset (used for the untested backlog sample).
|
||
type CoverageAsset struct {
|
||
ID int64 `json:"id"`
|
||
Type string `json:"type"`
|
||
Label string `json:"label"`
|
||
}
|
||
|
||
// CoverageByType is per-asset-type coverage: total in scope vs tested.
|
||
type CoverageByType struct {
|
||
Type string `json:"type"`
|
||
Total int `json:"total"`
|
||
Tested int `json:"tested"`
|
||
}
|
||
|
||
// Coverage is a task's rough asset test coverage — a reference figure for the agent,
|
||
// NOT a precise metric. Denominator = assets matching any active task_scope row;
|
||
// Tested = those anchored to at least one fact node in the exploration.
|
||
type Coverage struct {
|
||
ScopeRows int `json:"scope_rows"` // 0 → 范围未锚定
|
||
Denominator int `json:"denominator"` // 范围内资产数
|
||
Tested int `json:"tested"` // 已测(约)
|
||
Pct *float64 `json:"pct"` // 覆盖度;分母 0 时 null
|
||
ByType []CoverageByType `json:"by_type"` // 按资产类型的 总数/已测
|
||
}
|
||
|
||
// task_scope→assets match predicate, reused by the count / by-type / untested queries. $1=taskID.
|
||
const covTargetCTE = `
|
||
target AS (
|
||
SELECT DISTINCT a.id, a.type,
|
||
COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
|
||
FROM assets a
|
||
JOIN task_scope ts ON ts.task_id = $1 AND (
|
||
(ts.kind='company' AND a.company_id = ts.company_id)
|
||
OR (ts.kind='root_domain' AND a.root_domain = ts.domain)
|
||
OR (ts.kind='subdomain' AND a.domain = ts.domain)
|
||
OR (ts.kind IN ('ip','cidr') AND a.ip IS NOT NULL AND a.ip !~ '[^0-9a-fA-F:.]' AND ts.net >>= a.ip::inet)
|
||
)
|
||
),
|
||
tested AS (
|
||
SELECT DISTINCT ea.asset_id
|
||
FROM exploration_anchors ea
|
||
JOIN exploration_nodes en ON en.id = ea.node_id
|
||
WHERE en.exploration_id = $2 AND en.kind = 'fact'
|
||
)`
|
||
|
||
// TaskCoverage computes rough per-type coverage for a task. taskID indexes
|
||
// task_scope + assets; expID indexes the fact anchors. Reference figure only.
|
||
func (s *AssetStore) TaskCoverage(taskID, expID int64) (*Coverage, error) {
|
||
cov := &Coverage{ByType: []CoverageByType{}}
|
||
_ = s.db.QueryRow(`SELECT count(*) FROM task_scope WHERE task_id=$1`, taskID).Scan(&cov.ScopeRows)
|
||
rows, err := s.db.Query(`WITH `+covTargetCTE+`
|
||
SELECT t.type, count(*) AS total,
|
||
count(*) FILTER (WHERE t.id IN (SELECT asset_id FROM tested)) AS tested
|
||
FROM target t GROUP BY t.type ORDER BY t.type`, taskID, expID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var bt CoverageByType
|
||
if err := rows.Scan(&bt.Type, &bt.Total, &bt.Tested); err != nil {
|
||
return nil, err
|
||
}
|
||
cov.ByType = append(cov.ByType, bt)
|
||
cov.Denominator += bt.Total
|
||
cov.Tested += bt.Tested
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
if cov.Denominator > 0 {
|
||
p := float64(cov.Tested) / float64(cov.Denominator)
|
||
cov.Pct = &p
|
||
}
|
||
return cov, nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Coverage graph — a force-directed view of a task's in-scope assets.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// CoverageGraphNode is one node of the asset coverage graph. Node identity is a
|
||
// string key: an asset row is "a:<id>", a company is "c:<id>", and a root domain
|
||
// with no asset row of its own is the synthetic "r:<domain>". Out-of-scope
|
||
// connector nodes (roots pulled in only to link subdomains, and the companies
|
||
// above them) carry InScope=false and are rendered gray.
|
||
type CoverageGraphNode struct {
|
||
Key string `json:"key"`
|
||
Kind string `json:"kind"` // company|root_domain|subdomain|ip|service|app|endpoint
|
||
Label string `json:"label"`
|
||
Tested bool `json:"tested"`
|
||
InScope bool `json:"in_scope"`
|
||
AssetID int64 `json:"asset_id,omitempty"` // 0 for company / synthetic root
|
||
CompanyID int64 `json:"company_id,omitempty"`
|
||
Domain string `json:"domain,omitempty"`
|
||
RootDomain string `json:"root_domain,omitempty"`
|
||
IP string `json:"ip,omitempty"`
|
||
URL string `json:"url,omitempty"`
|
||
Port int `json:"port,omitempty"`
|
||
ServiceType string `json:"service_type,omitempty"`
|
||
AppName string `json:"app_name,omitempty"`
|
||
PageTitle string `json:"page_title,omitempty"`
|
||
StatusCode int `json:"status_code,omitempty"`
|
||
}
|
||
|
||
// CoverageGraphEdge is a child→parent containment link (endpoint→service→
|
||
// subdomain/ip→root_domain→company, app→company).
|
||
type CoverageGraphEdge struct {
|
||
Src string `json:"src"`
|
||
Dst string `json:"dst"`
|
||
}
|
||
|
||
// CoverageGraphData is the whole graph for one task.
|
||
type CoverageGraphData struct {
|
||
Nodes []CoverageGraphNode `json:"nodes"`
|
||
Edges []CoverageGraphEdge `json:"edges"`
|
||
}
|
||
|
||
func assetKey(id int64) string { return "a:" + strconv.FormatInt(id, 10) }
|
||
func companyKey(id int64) string { return "c:" + strconv.FormatInt(id, 10) }
|
||
|
||
// hostPortOf returns the (host, port) a service / endpoint node hangs off — the
|
||
// domain if set, otherwise the URL host, otherwise the IP.
|
||
func hostPortOf(n *CoverageGraphNode) (string, int) {
|
||
host, port := n.Domain, n.Port
|
||
if host == "" && n.URL != "" {
|
||
h, p, _ := parseURL(normalizeURL(n.URL))
|
||
host = h
|
||
if port == 0 {
|
||
port = p
|
||
}
|
||
}
|
||
if host == "" {
|
||
host = n.IP
|
||
}
|
||
return host, port
|
||
}
|
||
|
||
// BuildCoverageGraph assembles the full coverage graph for a task and its direct
|
||
// read-only sources: every in-scope asset plus connector root domains/companies,
|
||
// with current-or-source fact anchors reflected in Tested. The legacy expID
|
||
// argument is retained for API compatibility; the task registry is authoritative.
|
||
func (s *AssetStore) BuildCoverageGraph(taskID, _ int64) (*CoverageGraphData, error) {
|
||
g := &CoverageGraphData{Nodes: []CoverageGraphNode{}, Edges: []CoverageGraphEdge{}}
|
||
if taskID <= 0 {
|
||
return g, nil
|
||
}
|
||
rows, err := s.db.Query(`WITH `+contextCoverageCTE+`
|
||
SELECT a.id, a.type, COALESCE(a.company_id,0),
|
||
COALESCE(a.domain,''), COALESCE(a.root_domain,''), COALESCE(a.ip,''),
|
||
COALESCE(a.url,''), COALESCE(a.port,0), COALESCE(a.service_type,''),
|
||
COALESCE(a.app_name,''), COALESCE(a.page_title,''), COALESCE(a.status_code,0),
|
||
(a.id IN (SELECT asset_id FROM tested)) AS tested
|
||
FROM assets a JOIN target t ON t.id = a.id
|
||
ORDER BY a.id`, taskID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
byKey := map[string]*CoverageGraphNode{}
|
||
rootByDomain := map[string]string{} // root domain → node key
|
||
subByDomain := map[string]string{} // subdomain → node key
|
||
ipByAddr := map[string]string{} // ip literal → node key
|
||
svcByHostPort := map[string]string{}
|
||
svcByHost := map[string]string{}
|
||
companyIDs := map[int64]bool{} // referenced company ids (need a node)
|
||
|
||
add := func(n CoverageGraphNode) *CoverageGraphNode {
|
||
if _, ok := byKey[n.Key]; ok {
|
||
return byKey[n.Key]
|
||
}
|
||
g.Nodes = append(g.Nodes, n)
|
||
p := &g.Nodes[len(g.Nodes)-1]
|
||
byKey[n.Key] = p
|
||
return p
|
||
}
|
||
|
||
for rows.Next() {
|
||
var n CoverageGraphNode
|
||
var companyID int64
|
||
if err := rows.Scan(&n.AssetID, &n.Kind, &companyID,
|
||
&n.Domain, &n.RootDomain, &n.IP, &n.URL, &n.Port, &n.ServiceType,
|
||
&n.AppName, &n.PageTitle, &n.StatusCode, &n.Tested); err != nil {
|
||
return nil, err
|
||
}
|
||
n.Key = assetKey(n.AssetID)
|
||
n.CompanyID = companyID
|
||
n.InScope = true
|
||
n.Label = coverageNodeLabel(&n)
|
||
p := add(n)
|
||
switch n.Kind {
|
||
case "root_domain":
|
||
if n.Domain != "" {
|
||
rootByDomain[n.Domain] = p.Key
|
||
}
|
||
case "subdomain":
|
||
if n.Domain != "" {
|
||
subByDomain[n.Domain] = p.Key
|
||
}
|
||
case "ip":
|
||
if n.IP != "" {
|
||
ipByAddr[n.IP] = p.Key
|
||
}
|
||
case "service":
|
||
host, port := hostPortOf(p)
|
||
if host != "" {
|
||
svcByHost[host] = p.Key
|
||
svcByHostPort[host+"|"+strconv.Itoa(port)] = p.Key
|
||
}
|
||
}
|
||
if companyID > 0 {
|
||
companyIDs[companyID] = true
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Connector root domains: any subdomain whose root domain is not itself an
|
||
// in-scope node. Pull the real asset row if one exists (so the drawer shows
|
||
// real detail), else synthesize a bare "r:<domain>" placeholder. Both gray.
|
||
missingRoots := map[string]bool{}
|
||
for _, n := range g.Nodes {
|
||
if n.Kind == "subdomain" && n.RootDomain != "" {
|
||
if _, ok := rootByDomain[n.RootDomain]; !ok {
|
||
missingRoots[n.RootDomain] = true
|
||
}
|
||
}
|
||
}
|
||
for root := range missingRoots {
|
||
var id, companyID int64
|
||
err := s.db.QueryRow(`SELECT id, COALESCE(company_id,0) FROM assets
|
||
WHERE type='root_domain' AND domain=$1 LIMIT 1`, root).Scan(&id, &companyID)
|
||
var node CoverageGraphNode
|
||
if err == nil && id > 0 {
|
||
node = CoverageGraphNode{Key: assetKey(id), Kind: "root_domain", AssetID: id,
|
||
CompanyID: companyID, Domain: root, Label: root}
|
||
if companyID > 0 {
|
||
companyIDs[companyID] = true
|
||
}
|
||
} else {
|
||
node = CoverageGraphNode{Key: "r:" + root, Kind: "root_domain", Domain: root, Label: root}
|
||
}
|
||
add(node)
|
||
rootByDomain[root] = node.Key
|
||
}
|
||
|
||
// Company nodes for every referenced company id — always gray context.
|
||
for id := range companyIDs {
|
||
key := companyKey(id)
|
||
if _, ok := byKey[key]; ok {
|
||
continue
|
||
}
|
||
var name string
|
||
if err := s.db.QueryRow(`SELECT name FROM companies WHERE id=$1`, id).Scan(&name); err != nil {
|
||
continue
|
||
}
|
||
add(CoverageGraphNode{Key: key, Kind: "company", CompanyID: id,
|
||
Label: name, AssetID: 0})
|
||
}
|
||
|
||
// Derived containment edges (only when the parent node exists).
|
||
link := func(childKey, parentKey string) {
|
||
if parentKey == "" || parentKey == childKey {
|
||
return
|
||
}
|
||
if _, ok := byKey[parentKey]; !ok {
|
||
return
|
||
}
|
||
g.Edges = append(g.Edges, CoverageGraphEdge{Src: childKey, Dst: parentKey})
|
||
}
|
||
firstOf := func(keys ...string) string {
|
||
for _, k := range keys {
|
||
if k != "" {
|
||
if _, ok := byKey[k]; ok {
|
||
return k
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
for i := range g.Nodes {
|
||
n := &g.Nodes[i]
|
||
switch n.Kind {
|
||
case "subdomain":
|
||
link(n.Key, rootByDomain[n.RootDomain])
|
||
case "root_domain", "app", "ip":
|
||
if n.CompanyID > 0 {
|
||
link(n.Key, companyKey(n.CompanyID))
|
||
}
|
||
case "service":
|
||
link(n.Key, firstOf(subByDomain[n.Domain], ipByAddr[n.IP], rootByDomain[n.RootDomain]))
|
||
case "endpoint":
|
||
host, port := hostPortOf(n)
|
||
parent := firstOf(
|
||
svcByHostPort[host+"|"+strconv.Itoa(port)], svcByHost[host],
|
||
subByDomain[host], subByDomain[n.Domain], ipByAddr[host], ipByAddr[n.IP],
|
||
rootByDomain[n.RootDomain])
|
||
link(n.Key, parent)
|
||
}
|
||
}
|
||
return g, nil
|
||
}
|
||
|
||
// coverageNodeLabel picks the human label for a coverage-graph node.
|
||
func coverageNodeLabel(n *CoverageGraphNode) string {
|
||
switch n.Kind {
|
||
case "endpoint", "service":
|
||
if n.URL != "" {
|
||
return n.URL
|
||
}
|
||
case "app":
|
||
if n.AppName != "" {
|
||
return n.AppName
|
||
}
|
||
}
|
||
for _, v := range []string{n.URL, n.Domain, n.IP, n.AppName, n.RootDomain} {
|
||
if v != "" {
|
||
return v
|
||
}
|
||
}
|
||
return n.Key
|
||
}
|
||
|
||
// ListUntestedAssets returns a task's in-scope, not-yet-tested assets, optionally
|
||
// filtered by asset type, paginated. Returns the page + the total count. limit<=0 → 10.
|
||
func (s *AssetStore) ListUntestedAssets(taskID, expID int64, typ string, limit, offset int) ([]CoverageAsset, int, error) {
|
||
if limit <= 0 {
|
||
limit = 10
|
||
}
|
||
if offset < 0 {
|
||
offset = 0
|
||
}
|
||
typeFilter := ""
|
||
args := []any{taskID, expID}
|
||
if typ != "" {
|
||
typeFilter = " AND t.type = $3"
|
||
args = append(args, typ)
|
||
}
|
||
var total int
|
||
_ = s.db.QueryRow(`WITH `+covTargetCTE+`
|
||
SELECT count(*) FROM target t WHERE t.id NOT IN (SELECT asset_id FROM tested)`+typeFilter, args...).Scan(&total)
|
||
pageArgs := append(append([]any{}, args...), limit, offset)
|
||
limPos := strconv.Itoa(len(args) + 1)
|
||
offPos := strconv.Itoa(len(args) + 2)
|
||
rows, err := s.db.Query(`WITH `+covTargetCTE+`
|
||
SELECT t.id, t.type, t.label FROM target t
|
||
WHERE t.id NOT IN (SELECT asset_id FROM tested)`+typeFilter+`
|
||
ORDER BY t.id LIMIT $`+limPos+` OFFSET $`+offPos, pageArgs...)
|
||
if err != nil {
|
||
return nil, total, err
|
||
}
|
||
defer rows.Close()
|
||
out := []CoverageAsset{}
|
||
for rows.Next() {
|
||
var a CoverageAsset
|
||
if err := rows.Scan(&a.ID, &a.Type, &a.Label); err != nil {
|
||
return nil, total, err
|
||
}
|
||
out = append(out, a)
|
||
}
|
||
return out, total, rows.Err()
|
||
}
|