mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 16:40:29 +00:00
ui fixes for repo page, fix scanner priority, cleanup goreleaser scripts
This commit is contained in:
@@ -63,6 +63,7 @@ publishers:
|
||||
cmd: ./scripts/publish-artifact.sh
|
||||
env:
|
||||
- APP_PASSWORD={{ .Env.APP_PASSWORD }}
|
||||
- TANGLED_REPO_DID={{ .Env.TANGLED_REPO_DID }}
|
||||
- REPO_URL={{ .Env.REPO_URL }}
|
||||
- TAG={{ .Tag }}
|
||||
- ARTIFACT_PATH={{ abs .ArtifactPath }}
|
||||
|
||||
@@ -1,155 +1,37 @@
|
||||
# Tangled Workflow: Release Credential Helper
|
||||
#
|
||||
# This workflow builds cross-platform binaries for the credential helper.
|
||||
# Creates tarballs for curl/bash installation and provides instructions
|
||||
# for updating the Homebrew formula.
|
||||
# Builds cross-platform binaries using GoReleaser and publishes
|
||||
# artifacts to the repo owner's PDS as sh.tangled.repo.artifact records.
|
||||
#
|
||||
# Triggers on version tags (v*) pushed to the repository.
|
||||
#
|
||||
# Required secrets: PUBLISH_APP_PASSWORD (ATProto app password for artifact publishing)
|
||||
|
||||
when:
|
||||
- event: ["manual"]
|
||||
tag: ["v*"]
|
||||
|
||||
engine: "nixery"
|
||||
|
||||
dependencies:
|
||||
nixpkgs:
|
||||
- go_1_24 # Go 1.24+ for building
|
||||
- goreleaser # For building multi-platform binaries
|
||||
- curl # Required by go generate for downloading vendor assets
|
||||
- gnugrep # Required for tag detection
|
||||
- gnutar # Required for creating tarballs
|
||||
- gzip # Required for compressing tarballs
|
||||
- coreutils # Required for sha256sum
|
||||
engine: kubernetes
|
||||
image: golang:1.25-trixie
|
||||
architecture: amd64
|
||||
|
||||
environment:
|
||||
CGO_ENABLED: "0" # Build static binaries
|
||||
CGO_ENABLED: "0"
|
||||
REPO_RKEY: "3m2pjukohu322"
|
||||
|
||||
steps:
|
||||
- name: Get tag for current commit
|
||||
- name: Install tools
|
||||
command: |
|
||||
go install github.com/bluesky-social/goat@latest
|
||||
go install github.com/goreleaser/goreleaser/v2@latest
|
||||
apt-get update && apt-get install -y jq xxd
|
||||
|
||||
- name: Build and publish release
|
||||
command: |
|
||||
# Fetch tags (shallow clone doesn't include them by default)
|
||||
git fetch --tags
|
||||
|
||||
# Find the tag that points to the current commit
|
||||
TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]' | head -n1)
|
||||
# REPO_URL is built from Tangled-provided env vars
|
||||
export REPO_URL="at://${TANGLED_REPO_DID}/sh.tangled.repo/${REPO_RKEY}"
|
||||
export APP_PASSWORD="${PUBLISH_APP_PASSWORD}"
|
||||
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "Error: No version tag found for current commit"
|
||||
echo "Available tags:"
|
||||
git tag
|
||||
echo "Current commit:"
|
||||
git rev-parse HEAD
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building version: $TAG"
|
||||
echo "$TAG" > .version
|
||||
|
||||
# Also get the commit hash for reference
|
||||
COMMIT_HASH=$(git rev-parse HEAD)
|
||||
echo "Commit: $COMMIT_HASH"
|
||||
|
||||
- name: Build binaries with GoReleaser
|
||||
command: |
|
||||
VERSION=$(cat .version)
|
||||
export VERSION
|
||||
|
||||
# Build for all platforms using GoReleaser
|
||||
goreleaser build --clean --snapshot --config .goreleaser.yaml
|
||||
|
||||
# List what was built
|
||||
echo "Built artifacts:"
|
||||
if [ -d "dist" ]; then
|
||||
ls -lh dist/
|
||||
else
|
||||
echo "Error: dist/ directory was not created by GoReleaser"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Package artifacts
|
||||
command: |
|
||||
VERSION=$(cat .version)
|
||||
VERSION_NO_V=${VERSION#v} # Remove 'v' prefix for filenames
|
||||
|
||||
cd dist
|
||||
|
||||
# Create tarballs for each platform
|
||||
# GoReleaser creates directories like: credential-helper_{os}_{arch}_v{goversion}
|
||||
|
||||
# Darwin x86_64
|
||||
if [ -d "credential-helper_darwin_amd64_v1" ]; then
|
||||
tar czf "docker-credential-atcr_${VERSION_NO_V}_Darwin_x86_64.tar.gz" \
|
||||
-C credential-helper_darwin_amd64_v1 docker-credential-atcr
|
||||
echo "Created: docker-credential-atcr_${VERSION_NO_V}_Darwin_x86_64.tar.gz"
|
||||
fi
|
||||
|
||||
# Darwin arm64
|
||||
for dir in credential-helper_darwin_arm64*; do
|
||||
if [ -d "$dir" ]; then
|
||||
tar czf "docker-credential-atcr_${VERSION_NO_V}_Darwin_arm64.tar.gz" \
|
||||
-C "$dir" docker-credential-atcr
|
||||
echo "Created: docker-credential-atcr_${VERSION_NO_V}_Darwin_arm64.tar.gz"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Linux x86_64
|
||||
if [ -d "credential-helper_linux_amd64_v1" ]; then
|
||||
tar czf "docker-credential-atcr_${VERSION_NO_V}_Linux_x86_64.tar.gz" \
|
||||
-C credential-helper_linux_amd64_v1 docker-credential-atcr
|
||||
echo "Created: docker-credential-atcr_${VERSION_NO_V}_Linux_x86_64.tar.gz"
|
||||
fi
|
||||
|
||||
# Linux arm64
|
||||
for dir in credential-helper_linux_arm64*; do
|
||||
if [ -d "$dir" ]; then
|
||||
tar czf "docker-credential-atcr_${VERSION_NO_V}_Linux_arm64.tar.gz" \
|
||||
-C "$dir" docker-credential-atcr
|
||||
echo "Created: docker-credential-atcr_${VERSION_NO_V}_Linux_arm64.tar.gz"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Tarballs ready:"
|
||||
ls -lh *.tar.gz 2>/dev/null || echo "Warning: No tarballs created"
|
||||
|
||||
- name: Generate checksums
|
||||
command: |
|
||||
VERSION=$(cat .version)
|
||||
VERSION_NO_V=${VERSION#v}
|
||||
|
||||
cd dist
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "SHA256 Checksums"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Generate checksums file
|
||||
sha256sum docker-credential-atcr_${VERSION_NO_V}_*.tar.gz 2>/dev/null | tee checksums.txt || echo "No checksums generated"
|
||||
|
||||
- name: Next steps
|
||||
command: |
|
||||
VERSION=$(cat .version)
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Release $VERSION is ready!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Distribution tarballs are in: dist/"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo ""
|
||||
echo "1. Upload tarballs to your hosting/CDN (or GitHub releases)"
|
||||
echo ""
|
||||
echo "2. For Homebrew users, update the formula:"
|
||||
echo " ./scripts/update-homebrew-formula.sh $VERSION"
|
||||
echo " # Then update Formula/docker-credential-atcr.rb and push to homebrew-tap"
|
||||
echo ""
|
||||
echo "3. For curl/bash installation, users can download directly:"
|
||||
echo " curl -L <your-cdn>/docker-credential-atcr_<version>_<os>_<arch>.tar.gz | tar xz"
|
||||
echo " sudo mv docker-credential-atcr /usr/local/bin/"
|
||||
goreleaser release --clean
|
||||
|
||||
@@ -91,6 +91,10 @@ legal:
|
||||
company_name: ""
|
||||
# Governing law jurisdiction for legal terms.
|
||||
jurisdiction: ""
|
||||
# AI-powered image advisor settings.
|
||||
ai:
|
||||
# Anthropic API key for AI Image Advisor. Also reads CLAUDE_API_KEY env var as fallback.
|
||||
api_key: ""
|
||||
# Stripe billing integration (requires -tags billing build).
|
||||
billing:
|
||||
# Stripe secret key. Can also be set via STRIPE_SECRET_KEY env var (takes precedence). Billing is enabled automatically when set.
|
||||
@@ -100,9 +104,9 @@ billing:
|
||||
# ISO 4217 currency code (e.g. "usd").
|
||||
currency: usd
|
||||
# Redirect URL after successful checkout. Use {base_url} placeholder.
|
||||
success_url: '{base_url}/settings#storage'
|
||||
success_url: '{base_url}/settings#billing'
|
||||
# Redirect URL after cancelled checkout. Use {base_url} placeholder.
|
||||
cancel_url: '{base_url}/settings#storage'
|
||||
cancel_url: '{base_url}/settings#billing'
|
||||
# Subscription tiers ordered by rank (lowest to highest).
|
||||
tiers:
|
||||
- # Tier name. Position in list determines rank (0-based).
|
||||
@@ -119,6 +123,9 @@ billing:
|
||||
max_webhooks: 1
|
||||
# Allow all webhook trigger types (not just first-scan).
|
||||
webhook_all_triggers: false
|
||||
# Enable AI Image Advisor for this tier.
|
||||
ai_advisor: false
|
||||
# Show supporter badge on user profiles for subscribers at this tier.
|
||||
supporter_badge: false
|
||||
- # Tier name. Position in list determines rank (0-based).
|
||||
name: Supporter
|
||||
@@ -133,7 +140,10 @@ billing:
|
||||
# Maximum webhooks for this tier (-1 = unlimited).
|
||||
max_webhooks: 1
|
||||
# Allow all webhook trigger types (not just first-scan).
|
||||
webhook_all_triggers: false
|
||||
webhook_all_triggers: true
|
||||
# Enable AI Image Advisor for this tier.
|
||||
ai_advisor: true
|
||||
# Show supporter badge on user profiles for subscribers at this tier.
|
||||
supporter_badge: true
|
||||
- # Tier name. Position in list determines rank (0-based).
|
||||
name: bosun
|
||||
@@ -149,18 +159,9 @@ billing:
|
||||
max_webhooks: 10
|
||||
# Allow all webhook trigger types (not just first-scan).
|
||||
webhook_all_triggers: true
|
||||
# Enable AI Image Advisor for this tier.
|
||||
ai_advisor: true
|
||||
# Show supporter badge on user profiles for subscribers at this tier.
|
||||
supporter_badge: true
|
||||
# - # Tier name. Position in list determines rank (0-based).
|
||||
# name: quartermaster
|
||||
# # Short description shown on the plan card.
|
||||
# description: Maximum storage for power users
|
||||
# # List of features included in this tier.
|
||||
# features: []
|
||||
# # Stripe price ID for monthly billing. Empty = free tier.
|
||||
# stripe_price_monthly: price_xxx
|
||||
# # Stripe price ID for yearly billing.
|
||||
# stripe_price_yearly: price_yyy
|
||||
# # Maximum webhooks for this tier (-1 = unlimited).
|
||||
# max_webhooks: -1
|
||||
# # Allow all webhook trigger types (not just first-scan).
|
||||
# webhook_all_triggers: true
|
||||
# Show supporter badge on hold owner profiles.
|
||||
owner_badge: true
|
||||
|
||||
47
lexicons/io/atcr/hold/stats/daily.json
Normal file
47
lexicons/io/atcr/hold/stats/daily.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "io.atcr.hold.stats.daily",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"key": "any",
|
||||
"description": "Daily repository statistics stored in the hold's embedded PDS. Tracks pull/push counts per owner+repository+date combination. Record key is deterministic: base32(sha256(ownerDID + \"/\" + repository + \"/\" + date)[:16]). Complements cumulative io.atcr.hold.stats records by providing daily granularity for trend charts.",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["ownerDid", "repository", "date", "pullCount", "pushCount", "updatedAt"],
|
||||
"properties": {
|
||||
"ownerDid": {
|
||||
"type": "string",
|
||||
"format": "did",
|
||||
"description": "DID of the image owner (e.g., did:plc:xyz123)"
|
||||
},
|
||||
"repository": {
|
||||
"type": "string",
|
||||
"description": "Repository name (e.g., myapp)",
|
||||
"maxLength": 256
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"description": "Date in YYYY-MM-DD format (UTC)",
|
||||
"maxLength": 10
|
||||
},
|
||||
"pullCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Number of manifest downloads on this date"
|
||||
},
|
||||
"pushCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Number of manifest uploads on this date"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string",
|
||||
"format": "datetime",
|
||||
"description": "RFC3339 timestamp of when this record was last updated"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ type Config struct {
|
||||
Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."`
|
||||
CredentialHelper CredentialHelperConfig `yaml:"credential_helper" comment:"Credential helper download settings."`
|
||||
Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."`
|
||||
AI AIConfig `yaml:"ai" comment:"AI-powered image advisor settings."`
|
||||
Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."`
|
||||
Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
|
||||
}
|
||||
@@ -140,6 +141,12 @@ type LegalConfig struct {
|
||||
Jurisdiction string `yaml:"jurisdiction" comment:"Governing law jurisdiction for legal terms."`
|
||||
}
|
||||
|
||||
// AIConfig defines AI-powered image advisor settings
|
||||
type AIConfig struct {
|
||||
// Anthropic API key for the AI Image Advisor feature.
|
||||
APIKey string `yaml:"api_key" comment:"Anthropic API key for AI Image Advisor. Also reads CLAUDE_API_KEY env var as fallback."`
|
||||
}
|
||||
|
||||
// setDefaults registers all default values on the given Viper instance.
|
||||
func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("version", "0.1")
|
||||
@@ -189,6 +196,9 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("log_shipper.batch_size", 100)
|
||||
v.SetDefault("log_shipper.flush_interval", "5s")
|
||||
|
||||
// AI defaults
|
||||
v.SetDefault("ai.api_key", "")
|
||||
|
||||
// Legal defaults
|
||||
v.SetDefault("legal.company_name", "")
|
||||
v.SetDefault("legal.jurisdiction", "")
|
||||
@@ -213,8 +223,8 @@ func ExampleYAML() ([]byte, error) {
|
||||
|
||||
// Populate example billing tiers so operators see the structure
|
||||
cfg.Billing.Currency = "usd"
|
||||
cfg.Billing.SuccessURL = "{base_url}/settings#storage"
|
||||
cfg.Billing.CancelURL = "{base_url}/settings#storage"
|
||||
cfg.Billing.SuccessURL = "{base_url}/settings#billing"
|
||||
cfg.Billing.CancelURL = "{base_url}/settings#billing"
|
||||
cfg.Billing.OwnerBadge = true
|
||||
cfg.Billing.Tiers = []billing.BillingTierConfig{
|
||||
{Name: "deckhand", Description: "Get started with basic storage", MaxWebhooks: 1},
|
||||
@@ -255,6 +265,11 @@ func LoadConfig(yamlPath string) (*Config, error) {
|
||||
cfg.Legal.CompanyName = cfg.Server.ClientName
|
||||
}
|
||||
|
||||
// Post-load: AI API key fallback to CLAUDE_API_KEY env
|
||||
if cfg.AI.APIKey == "" {
|
||||
cfg.AI.APIKey = os.Getenv("CLAUDE_API_KEY")
|
||||
}
|
||||
|
||||
// Validation
|
||||
if cfg.Server.DefaultHoldDID == "" {
|
||||
return nil, fmt.Errorf("server.default_hold_did is required (env: ATCR_SERVER_DEFAULT_HOLD_DID)")
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
description: Cache AI image advisor suggestions per manifest digest
|
||||
query: |
|
||||
CREATE TABLE IF NOT EXISTS advisor_suggestions (
|
||||
manifest_digest TEXT PRIMARY KEY,
|
||||
suggestions_json TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
13
pkg/appview/db/migrations/0020_add_subject_digest.yaml
Normal file
13
pkg/appview/db/migrations/0020_add_subject_digest.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
description: Add subject_digest column to manifests for tracking OCI referrers (attestations, signatures)
|
||||
query: |
|
||||
ALTER TABLE manifests ADD COLUMN subject_digest TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_manifests_subject_digest ON manifests(subject_digest);
|
||||
UPDATE manifests SET subject_digest = 'backfill'
|
||||
WHERE artifact_type = 'unknown'
|
||||
AND media_type NOT LIKE '%index%'
|
||||
AND media_type NOT LIKE '%manifest.list%'
|
||||
AND id IN (
|
||||
SELECT m.id FROM manifests m
|
||||
JOIN manifest_references mr ON mr.digest = m.digest
|
||||
WHERE mr.is_attestation = 1
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
description: Add daily repository stats table for pull/push trend tracking
|
||||
query: |
|
||||
CREATE TABLE IF NOT EXISTS repository_stats_daily (
|
||||
did TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
pull_count INTEGER NOT NULL DEFAULT 0,
|
||||
push_count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(did, repository, date),
|
||||
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_repo_stats_daily_date ON repository_stats_daily(date DESC);
|
||||
@@ -25,6 +25,7 @@ type Manifest struct {
|
||||
ConfigDigest string
|
||||
ConfigSize int64
|
||||
ArtifactType string // container-image, helm-chart, unknown
|
||||
SubjectDigest string // digest of the parent manifest (for attestations/referrers)
|
||||
CreatedAt time.Time
|
||||
// Annotations removed - now stored in repository_annotations table
|
||||
}
|
||||
@@ -92,6 +93,15 @@ type RepositoryStats struct {
|
||||
LastPush *time.Time `json:"last_push,omitempty"`
|
||||
}
|
||||
|
||||
// DailyStats represents daily pull/push statistics for a repository
|
||||
type DailyStats struct {
|
||||
DID string `json:"did"`
|
||||
Repository string `json:"repository"`
|
||||
Date string `json:"date"`
|
||||
PullCount int `json:"pull_count"`
|
||||
PushCount int `json:"push_count"`
|
||||
}
|
||||
|
||||
// RepositoryWithStats combines repository data with statistics
|
||||
type RepositoryWithStats struct {
|
||||
Repository
|
||||
|
||||
@@ -627,24 +627,28 @@ func InsertManifest(db DBTX, manifest *Manifest) (int64, error) {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO manifests
|
||||
(did, repository, digest, hold_endpoint, schema_version, media_type,
|
||||
config_digest, config_size, artifact_type, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
config_digest, config_size, artifact_type, subject_digest, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(did, repository, digest) DO UPDATE SET
|
||||
hold_endpoint = excluded.hold_endpoint,
|
||||
schema_version = excluded.schema_version,
|
||||
media_type = excluded.media_type,
|
||||
config_digest = excluded.config_digest,
|
||||
config_size = excluded.config_size,
|
||||
artifact_type = excluded.artifact_type
|
||||
artifact_type = excluded.artifact_type,
|
||||
subject_digest = excluded.subject_digest
|
||||
WHERE excluded.hold_endpoint != manifests.hold_endpoint
|
||||
OR excluded.schema_version != manifests.schema_version
|
||||
OR excluded.media_type != manifests.media_type
|
||||
OR excluded.config_digest IS NOT manifests.config_digest
|
||||
OR excluded.config_size IS NOT manifests.config_size
|
||||
OR excluded.artifact_type != manifests.artifact_type
|
||||
OR excluded.subject_digest IS NOT manifests.subject_digest
|
||||
`, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint,
|
||||
manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest,
|
||||
manifest.ConfigSize, manifest.ArtifactType, manifest.CreatedAt)
|
||||
manifest.ConfigSize, manifest.ArtifactType,
|
||||
sql.NullString{String: manifest.SubjectDigest, Valid: manifest.SubjectDigest != ""},
|
||||
manifest.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -776,11 +780,27 @@ func CountTags(db DBTX, did, repository string) (int, error) {
|
||||
// Single-arch tags will have empty Platforms slice (platform is obvious for single-arch)
|
||||
// Attestation references (unknown/unknown platforms) are filtered out but tracked via HasAttestations
|
||||
func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int) ([]TagWithPlatforms, error) {
|
||||
rows, err := db.Query(`
|
||||
return getTagsWithPlatformsFiltered(db, did, repository, "", limit, offset)
|
||||
}
|
||||
|
||||
// getTagsWithPlatformsFiltered is the shared implementation for GetTagsWithPlatforms and GetTagByName.
|
||||
// If tagName is non-empty, only that specific tag is returned.
|
||||
func getTagsWithPlatformsFiltered(db DBTX, did, repository, tagName string, limit, offset int) ([]TagWithPlatforms, error) {
|
||||
var tagFilter string
|
||||
var args []any
|
||||
if tagName != "" {
|
||||
tagFilter = "AND tag = ?"
|
||||
args = append(args, did, repository, tagName, limit, offset)
|
||||
} else {
|
||||
args = append(args, did, repository, limit, offset)
|
||||
}
|
||||
|
||||
query := `
|
||||
WITH paged_tags AS (
|
||||
SELECT id, did, repository, tag, digest, created_at
|
||||
FROM tags
|
||||
WHERE did = ? AND repository = ?
|
||||
` + tagFilter + `
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
)
|
||||
@@ -806,9 +826,9 @@ func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int) ([
|
||||
JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository
|
||||
LEFT JOIN manifest_references mr ON m.id = mr.manifest_id
|
||||
LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = t.did AND child_m.repository = t.repository
|
||||
ORDER BY t.created_at DESC, mr.reference_index
|
||||
`, did, repository, limit, offset)
|
||||
ORDER BY t.created_at DESC, mr.reference_index`
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -878,15 +898,17 @@ func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int) ([
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteManifest deletes a manifest and its associated layers
|
||||
// If repository is empty, deletes all manifests matching did and digest
|
||||
// DeleteManifest deletes a manifest and its associated layers.
|
||||
// Also deletes any attestation manifests that reference this manifest via subject_digest.
|
||||
// If repository is empty, deletes all manifests matching did and digest.
|
||||
func DeleteManifest(db DBTX, did, repository, digest string) error {
|
||||
var err error
|
||||
if repository == "" {
|
||||
// Delete by DID + digest only (used when repository is unknown, e.g., Jetstream DELETE events)
|
||||
// Delete attestation children first, then the manifest itself
|
||||
_, _ = db.Exec(`DELETE FROM manifests WHERE did = ? AND subject_digest = ?`, did, digest)
|
||||
_, err = db.Exec(`DELETE FROM manifests WHERE did = ? AND digest = ?`, did, digest)
|
||||
} else {
|
||||
// Delete specific manifest
|
||||
_, _ = db.Exec(`DELETE FROM manifests WHERE did = ? AND repository = ? AND subject_digest = ?`, did, repository, digest)
|
||||
_, err = db.Exec(`DELETE FROM manifests WHERE did = ? AND repository = ? AND digest = ?`, did, repository, digest)
|
||||
}
|
||||
return err
|
||||
@@ -1114,6 +1136,8 @@ func GetTopLevelManifests(db DBTX, did, repository string, limit, offset int) ([
|
||||
LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
|
||||
LEFT JOIN manifest_references mr ON m.id = mr.manifest_id
|
||||
WHERE m.did = ? AND m.repository = ?
|
||||
AND m.subject_digest IS NULL
|
||||
AND m.artifact_type != 'unknown'
|
||||
AND (
|
||||
-- Include manifest lists
|
||||
m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
|
||||
@@ -1414,6 +1438,7 @@ func GetAllUntaggedManifestDigests(db DBTX, did, repository string) ([]string, e
|
||||
FROM manifests m
|
||||
LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
|
||||
WHERE m.did = ? AND m.repository = ?
|
||||
AND m.subject_digest IS NULL
|
||||
AND (
|
||||
m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
|
||||
OR
|
||||
@@ -2435,3 +2460,108 @@ func IsHoldCaptain(db DBTX, userDID string, managedHolds []string) (bool, error)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetTagByName returns a single tag with platform information by tag name.
|
||||
// Returns nil, nil if the tag doesn't exist.
|
||||
func GetTagByName(db DBTX, did, repository, tagName string) (*TagWithPlatforms, error) {
|
||||
tags, err := getTagsWithPlatformsFiltered(db, did, repository, tagName, 1, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &tags[0], nil
|
||||
}
|
||||
|
||||
// GetAllTagNames returns all tag names for a repository, ordered by most recent first.
|
||||
func GetAllTagNames(db DBTX, did, repository string) ([]string, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT tag FROM tags
|
||||
WHERE did = ? AND repository = ?
|
||||
ORDER BY created_at DESC
|
||||
`, did, repository)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return names, rows.Err()
|
||||
}
|
||||
|
||||
// GetLayerCountForManifest returns the number of layers for a manifest identified by digest.
|
||||
func GetLayerCountForManifest(db DBTX, did, repository, digest string) (int, error) {
|
||||
var count int
|
||||
err := db.QueryRow(`
|
||||
SELECT COUNT(*) FROM layers l
|
||||
JOIN manifests m ON l.manifest_id = m.id
|
||||
WHERE m.did = ? AND m.repository = ? AND m.digest = ?
|
||||
`, did, repository, digest).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetAdvisorSuggestions returns cached AI advisor suggestions for a manifest digest.
|
||||
// Returns sql.ErrNoRows if no cached suggestions exist.
|
||||
func GetAdvisorSuggestions(db DBTX, manifestDigest string) (suggestionsJSON string, createdAt time.Time, err error) {
|
||||
err = db.QueryRow(
|
||||
`SELECT suggestions_json, created_at FROM advisor_suggestions WHERE manifest_digest = ?`,
|
||||
manifestDigest,
|
||||
).Scan(&suggestionsJSON, &createdAt)
|
||||
return
|
||||
}
|
||||
|
||||
// UpsertAdvisorSuggestions caches AI advisor suggestions for a manifest digest.
|
||||
func UpsertAdvisorSuggestions(db DBTX, manifestDigest, suggestionsJSON string) error {
|
||||
_, err := db.Exec(
|
||||
`INSERT OR REPLACE INTO advisor_suggestions (manifest_digest, suggestions_json, created_at) VALUES (?, ?, CURRENT_TIMESTAMP)`,
|
||||
manifestDigest, suggestionsJSON,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertDailyStats inserts or updates daily repository stats
|
||||
func UpsertDailyStats(db DBTX, stats *DailyStats) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO repository_stats_daily (did, repository, date, pull_count, push_count)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(did, repository, date) DO UPDATE SET
|
||||
pull_count = excluded.pull_count,
|
||||
push_count = excluded.push_count
|
||||
WHERE excluded.pull_count != repository_stats_daily.pull_count
|
||||
OR excluded.push_count != repository_stats_daily.push_count
|
||||
`, stats.DID, stats.Repository, stats.Date, stats.PullCount, stats.PushCount)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetDailyStats retrieves daily stats for a repository within a date range
|
||||
// startDate and endDate should be in YYYY-MM-DD format
|
||||
func GetDailyStats(db DBTX, did, repository, startDate, endDate string) ([]DailyStats, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT did, repository, date, pull_count, push_count
|
||||
FROM repository_stats_daily
|
||||
WHERE did = ? AND repository = ? AND date >= ? AND date <= ?
|
||||
ORDER BY date ASC
|
||||
`, did, repository, startDate, endDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stats []DailyStats
|
||||
for rows.Next() {
|
||||
var s DailyStats
|
||||
if err := rows.Scan(&s.DID, &s.Repository, &s.Date, &s.PullCount, &s.PushCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats = append(stats, s)
|
||||
}
|
||||
return stats, rows.Err()
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS manifests (
|
||||
config_digest TEXT,
|
||||
config_size INTEGER,
|
||||
artifact_type TEXT NOT NULL DEFAULT 'container-image', -- container-image, helm-chart, unknown
|
||||
subject_digest TEXT, -- digest of the parent manifest (for attestations/referrers)
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
UNIQUE(did, repository, digest),
|
||||
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
||||
@@ -38,6 +39,7 @@ CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
|
||||
CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_manifests_artifact_type ON manifests(artifact_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_manifests_subject_digest ON manifests(subject_digest);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS repository_annotations (
|
||||
did TEXT NOT NULL,
|
||||
@@ -167,6 +169,17 @@ CREATE TABLE IF NOT EXISTS repository_stats (
|
||||
CREATE INDEX IF NOT EXISTS idx_repository_stats_did ON repository_stats(did);
|
||||
CREATE INDEX IF NOT EXISTS idx_repository_stats_pull_count ON repository_stats(pull_count DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS repository_stats_daily (
|
||||
did TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
pull_count INTEGER NOT NULL DEFAULT 0,
|
||||
push_count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(did, repository, date),
|
||||
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_repo_stats_daily_date ON repository_stats_daily(date DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stars (
|
||||
starrer_did TEXT NOT NULL,
|
||||
owner_did TEXT NOT NULL,
|
||||
@@ -273,3 +286,9 @@ CREATE TABLE IF NOT EXISTS scans (
|
||||
PRIMARY KEY(hold_did, manifest_digest)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_did);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS advisor_suggestions (
|
||||
manifest_digest TEXT PRIMARY KEY,
|
||||
suggestions_json TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -40,9 +40,10 @@ type BaseUIHandler struct {
|
||||
OAuthStore *db.OAuthStore
|
||||
|
||||
// Config
|
||||
DefaultHoldDID string
|
||||
CompanyName string
|
||||
Jurisdiction string
|
||||
ClientName string // Full name: "AT Container Registry"
|
||||
ClientShortName string // Short name: "ATCR"
|
||||
DefaultHoldDID string
|
||||
CompanyName string
|
||||
Jurisdiction string
|
||||
ClientName string // Full name: "AT Container Registry"
|
||||
ClientShortName string // Short name: "ATCR"
|
||||
AIAdvisorEnabled bool // True when Claude API key is configured
|
||||
}
|
||||
|
||||
@@ -10,13 +10,14 @@ import (
|
||||
|
||||
// PageData contains common fields shared across all page templates
|
||||
type PageData struct {
|
||||
User *db.User // Logged-in user (nil if not logged in)
|
||||
Query string // Search query from URL parameter
|
||||
RegistryURL string // Docker registry domain (e.g., "buoy.cr")
|
||||
SiteURL string // Website domain (e.g., "seamark.dev")
|
||||
ClientName string // Brand name for templates (e.g., "AT Container Registry")
|
||||
ClientShortName string // Brand name for templates (e.g., "ATCR")
|
||||
OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman")
|
||||
User *db.User // Logged-in user (nil if not logged in)
|
||||
Query string // Search query from URL parameter
|
||||
RegistryURL string // Docker registry domain (e.g., "buoy.cr")
|
||||
SiteURL string // Website domain (e.g., "seamark.dev")
|
||||
ClientName string // Brand name for templates (e.g., "AT Container Registry")
|
||||
ClientShortName string // Brand name for templates (e.g., "ATCR")
|
||||
OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman")
|
||||
AIAdvisorEnabled bool // True when AI Image Advisor is available
|
||||
}
|
||||
|
||||
// NewPageData creates a PageData struct with common fields populated from the request
|
||||
@@ -27,13 +28,14 @@ func NewPageData(r *http.Request, h *BaseUIHandler) PageData {
|
||||
ociClient = user.OciClient
|
||||
}
|
||||
return PageData{
|
||||
User: user,
|
||||
Query: r.URL.Query().Get("q"),
|
||||
RegistryURL: h.RegistryURL,
|
||||
SiteURL: h.SiteURL,
|
||||
ClientName: h.ClientName,
|
||||
ClientShortName: h.ClientShortName,
|
||||
OciClient: ociClient,
|
||||
User: user,
|
||||
Query: r.URL.Query().Get("q"),
|
||||
RegistryURL: h.RegistryURL,
|
||||
SiteURL: h.SiteURL,
|
||||
ClientName: h.ClientName,
|
||||
ClientShortName: h.ClientShortName,
|
||||
OciClient: ociClient,
|
||||
AIAdvisorEnabled: h.AIAdvisorEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -210,9 +210,9 @@ func (h *ManifestDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
fromDigest := r.URL.Query().Get("from")
|
||||
toDigest := r.URL.Query().Get("to")
|
||||
if fromDigest == "" || toDigest == "" {
|
||||
fromParam := r.URL.Query().Get("from")
|
||||
toParam := r.URL.Query().Get("to")
|
||||
if fromParam == "" || toParam == "" {
|
||||
RenderNotFound(w, r, &h.BaseUIHandler)
|
||||
return
|
||||
}
|
||||
@@ -234,6 +234,26 @@ func (h *ManifestDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
owner.Handle = resolvedHandle
|
||||
}
|
||||
|
||||
// Resolve from/to params — accept either digests (sha256:...) or tag names
|
||||
fromDigest := fromParam
|
||||
toDigest := toParam
|
||||
if !strings.HasPrefix(fromDigest, "sha256:") {
|
||||
tag, err := db.GetTagByName(h.ReadOnlyDB, owner.DID, repo, fromParam)
|
||||
if err != nil || tag == nil {
|
||||
RenderNotFound(w, r, &h.BaseUIHandler)
|
||||
return
|
||||
}
|
||||
fromDigest = tag.Digest
|
||||
}
|
||||
if !strings.HasPrefix(toDigest, "sha256:") {
|
||||
tag, err := db.GetTagByName(h.ReadOnlyDB, owner.DID, repo, toParam)
|
||||
if err != nil || tag == nil {
|
||||
RenderNotFound(w, r, &h.BaseUIHandler)
|
||||
return
|
||||
}
|
||||
toDigest = tag.Digest
|
||||
}
|
||||
|
||||
// Fetch both manifests
|
||||
type manifestData struct {
|
||||
manifest *db.ManifestWithMetadata
|
||||
|
||||
@@ -45,9 +45,9 @@ func TestComputeLayerDiff_SharedPrefixThenDivergence(t *testing.T) {
|
||||
digest string
|
||||
}{
|
||||
{"shared", "sha256:base"},
|
||||
{"removed", "sha256:old"}, // no command, different digest → -/+
|
||||
{"removed", "sha256:old"}, // no command, different digest → -/+
|
||||
{"added", "sha256:new1"},
|
||||
{"added", "sha256:new2"}, // extra layer in to
|
||||
{"added", "sha256:new2"}, // extra layer in to
|
||||
}
|
||||
|
||||
for i, e := range expected {
|
||||
|
||||
@@ -91,8 +91,29 @@ func (h *DigestContentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := h.Templates.ExecuteTemplate(w, "digest-content", data); err != nil {
|
||||
slog.Warn("Failed to render digest content", "error", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
// Support rendering individual sections for repo page tabs
|
||||
section := r.URL.Query().Get("section")
|
||||
switch section {
|
||||
case "layers":
|
||||
if err := h.Templates.ExecuteTemplate(w, "layers-section", data); err != nil {
|
||||
slog.Warn("Failed to render layers section", "error", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
case "vulns":
|
||||
if err := h.Templates.ExecuteTemplate(w, "vulns-section", data); err != nil {
|
||||
slog.Warn("Failed to render vulns section", "error", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
case "sbom":
|
||||
if err := h.Templates.ExecuteTemplate(w, "sbom-section", data); err != nil {
|
||||
slog.Warn("Failed to render sbom section", "error", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
default:
|
||||
if err := h.Templates.ExecuteTemplate(w, "digest-content", data); err != nil {
|
||||
slog.Warn("Failed to render digest content", "error", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
754
pkg/appview/handlers/image_advisor.go
Normal file
754
pkg/appview/handlers/image_advisor.go
Normal file
@@ -0,0 +1,754 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
"atcr.io/pkg/appview/storage"
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// ImageAdvisorHandler returns AI-powered suggestions for improving a container image.
|
||||
// Returns an HTML fragment (image-advisor-results partial) via HTMX.
|
||||
type ImageAdvisorHandler struct {
|
||||
BaseUIHandler
|
||||
ClaudeAPIKey string
|
||||
}
|
||||
|
||||
type advisorSuggestion struct {
|
||||
Action string `json:"action"`
|
||||
Category string `json:"category"`
|
||||
Impact string `json:"impact"`
|
||||
Effort string `json:"effort"`
|
||||
CVEsFixed int `json:"cves_fixed"`
|
||||
SizeSavedMB int `json:"size_saved_mb"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
type imageAdvisorData struct {
|
||||
Suggestions []advisorSuggestion
|
||||
Error string
|
||||
}
|
||||
|
||||
// OCI config types for full image config parsing
|
||||
type advisorOCIConfig struct {
|
||||
Architecture string `json:"architecture"`
|
||||
OS string `json:"os"`
|
||||
Config advisorOCIContainerConfig `json:"config"`
|
||||
History []advisorOCIHistory `json:"history"`
|
||||
}
|
||||
|
||||
type advisorOCIContainerConfig struct {
|
||||
Env []string `json:"Env"`
|
||||
Cmd []string `json:"Cmd"`
|
||||
Entrypoint []string `json:"Entrypoint"`
|
||||
WorkingDir string `json:"WorkingDir"`
|
||||
ExposedPorts map[string]struct{} `json:"ExposedPorts"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
User string `json:"User"`
|
||||
}
|
||||
|
||||
type advisorOCIHistory struct {
|
||||
CreatedBy string `json:"created_by"`
|
||||
EmptyLayer bool `json:"empty_layer"`
|
||||
}
|
||||
|
||||
// SPDX types for SBOM parsing
|
||||
type advisorSPDX struct {
|
||||
Packages []advisorSPDXPackage `json:"packages"`
|
||||
}
|
||||
|
||||
type advisorSPDXPackage struct {
|
||||
SPDXID string `json:"SPDXID"`
|
||||
Name string `json:"name"`
|
||||
VersionInfo string `json:"versionInfo"`
|
||||
Supplier string `json:"supplier"`
|
||||
}
|
||||
|
||||
// advisorReportData holds all fetched data for prompt generation
|
||||
type advisorReportData struct {
|
||||
Handle string
|
||||
Repository string
|
||||
Digest string
|
||||
Platform string
|
||||
|
||||
Config *advisorOCIConfig
|
||||
Layers []db.Layer
|
||||
ScanRecord *atproto.ScanRecord
|
||||
VulnReport *grypeReport
|
||||
SBOM *advisorSPDX
|
||||
}
|
||||
|
||||
func (h *ImageAdvisorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ClaudeAPIKey == "" {
|
||||
h.renderResults(w, imageAdvisorData{Error: "AI advisor is not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
identifier := chi.URLParam(r, "handle")
|
||||
wildcard := strings.TrimPrefix(chi.URLParam(r, "*"), "/")
|
||||
digest := r.URL.Query().Get("digest")
|
||||
|
||||
if wildcard == "" || digest == "" {
|
||||
h.renderResults(w, imageAdvisorData{Error: "Missing required parameters"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the logged-in user owns this image
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
h.renderResults(w, imageAdvisorData{Error: "Login required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check billing access
|
||||
if h.BillingManager != nil && !h.BillingManager.HasAIAdvisor(user.DID) {
|
||||
h.renderResults(w, imageAdvisorData{Error: "upgrade_required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check user preference
|
||||
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
||||
profile, err := storage.GetProfile(r.Context(), client)
|
||||
if err == nil && profile != nil && profile.AIAdvisorEnabled != nil && !*profile.AIAdvisorEnabled {
|
||||
h.renderResults(w, imageAdvisorData{Error: "AI advisor is disabled in your settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve identity
|
||||
did, resolvedHandle, _, err := atproto.ResolveIdentity(r.Context(), identifier)
|
||||
if err != nil {
|
||||
h.renderResults(w, imageAdvisorData{Error: "Could not resolve identity"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.DID != did {
|
||||
h.renderResults(w, imageAdvisorData{Error: "You can only generate suggestions for your own images"})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch manifest
|
||||
manifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, wildcard, digest)
|
||||
if err != nil {
|
||||
h.renderResults(w, imageAdvisorData{Error: "Manifest not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// For manifest lists, the caller should pass a platform-specific child digest.
|
||||
// If they somehow pass the list digest itself, resolve to the first platform.
|
||||
if manifest.IsManifestList {
|
||||
if len(manifest.Platforms) == 0 {
|
||||
h.renderResults(w, imageAdvisorData{Error: "No platforms found in manifest list"})
|
||||
return
|
||||
}
|
||||
childDigest := manifest.Platforms[0].Digest
|
||||
childManifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, wildcard, childDigest)
|
||||
if err != nil {
|
||||
h.renderResults(w, imageAdvisorData{Error: "Could not resolve platform manifest"})
|
||||
return
|
||||
}
|
||||
manifest = childManifest
|
||||
digest = childDigest
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if cachedJSON, _, err := db.GetAdvisorSuggestions(h.ReadOnlyDB, digest); err == nil {
|
||||
suggestions, err := parseAdvisorResponse(cachedJSON)
|
||||
if err == nil {
|
||||
slog.Debug("Serving cached advisor suggestions", "digest", digest)
|
||||
h.renderResults(w, imageAdvisorData{Suggestions: suggestions})
|
||||
return
|
||||
}
|
||||
slog.Debug("Cached advisor data unparseable, fetching fresh", "digest", digest)
|
||||
}
|
||||
|
||||
// Resolve hold
|
||||
hold, err := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint)
|
||||
if err != nil {
|
||||
h.renderResults(w, imageAdvisorData{Error: "Could not resolve hold endpoint"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build report data
|
||||
report := &advisorReportData{
|
||||
Handle: resolvedHandle,
|
||||
Repository: wildcard,
|
||||
Digest: digest,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Fetch full OCI image config
|
||||
config, err := fetchAdvisorImageConfig(ctx, hold.URL, digest)
|
||||
if err != nil {
|
||||
slog.Debug("Failed to fetch image config for advisor", "error", err)
|
||||
} else {
|
||||
report.Config = config
|
||||
report.Platform = config.OS + "/" + config.Architecture
|
||||
}
|
||||
|
||||
// Fetch layers for size info
|
||||
dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.ID)
|
||||
if err != nil {
|
||||
slog.Debug("Failed to fetch layers for advisor", "error", err)
|
||||
}
|
||||
report.Layers = dbLayers
|
||||
|
||||
// Fetch scan data (scan record + vuln blob + SBOM blob)
|
||||
scanRecord, vulnReport, sbom := fetchAdvisorScanData(ctx, hold.URL, hold.DID, digest)
|
||||
report.ScanRecord = scanRecord
|
||||
report.VulnReport = vulnReport
|
||||
report.SBOM = sbom
|
||||
|
||||
// Generate prompt
|
||||
var promptBuf strings.Builder
|
||||
generateAdvisorPrompt(&promptBuf, report)
|
||||
|
||||
// Call Claude API
|
||||
responseText, err := callClaudeAPI(ctx, h.ClaudeAPIKey, promptBuf.String())
|
||||
if err != nil {
|
||||
slog.Warn("Claude API call failed", "error", err)
|
||||
h.renderResults(w, imageAdvisorData{Error: "AI service request failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
suggestions, err := parseAdvisorResponse(responseText)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to parse advisor response", "error", err, "response", responseText)
|
||||
h.renderResults(w, imageAdvisorData{Error: "Failed to parse AI response"})
|
||||
return
|
||||
}
|
||||
|
||||
// Cache the response
|
||||
if err := db.UpsertAdvisorSuggestions(h.DB, digest, responseText); err != nil {
|
||||
slog.Warn("Failed to cache advisor suggestions", "error", err)
|
||||
}
|
||||
|
||||
h.renderResults(w, imageAdvisorData{Suggestions: suggestions})
|
||||
}
|
||||
|
||||
func (h *ImageAdvisorHandler) renderResults(w http.ResponseWriter, data imageAdvisorData) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := h.Templates.ExecuteTemplate(w, "image-advisor-results", data); err != nil {
|
||||
slog.Warn("Failed to render image advisor results", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAdvisorImageConfig fetches the full OCI image config from the hold.
|
||||
func fetchAdvisorImageConfig(ctx context.Context, holdURL, manifestDigest string) (*advisorOCIConfig, error) {
|
||||
reqURL := fmt.Sprintf("%s%s?digest=%s",
|
||||
strings.TrimSuffix(holdURL, "/"),
|
||||
atproto.HoldGetImageConfig,
|
||||
url.QueryEscape(manifestDigest),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("hold returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var record struct {
|
||||
ConfigJSON string `json:"configJson"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var config advisorOCIConfig
|
||||
if err := json.Unmarshal([]byte(record.ConfigJSON), &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// fetchAdvisorScanData fetches the scan record plus vuln and SBOM blobs.
|
||||
func fetchAdvisorScanData(ctx context.Context, holdURL, holdDID, digest string) (*atproto.ScanRecord, *grypeReport, *advisorSPDX) {
|
||||
rkey := strings.TrimPrefix(digest, "sha256:")
|
||||
|
||||
scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
|
||||
strings.TrimSuffix(holdURL, "/"),
|
||||
url.QueryEscape(holdDID),
|
||||
url.QueryEscape(atproto.ScanCollection),
|
||||
url.QueryEscape(rkey),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", scanURL, nil)
|
||||
if err != nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Value json.RawMessage `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
var scanRecord atproto.ScanRecord
|
||||
if err := json.Unmarshal(envelope.Value, &scanRecord); err != nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Fetch vuln report blob
|
||||
var vulnReport *grypeReport
|
||||
if scanRecord.VulnReportBlob != nil && scanRecord.VulnReportBlob.Ref.String() != "" {
|
||||
vulnReport = fetchAdvisorBlob[grypeReport](ctx, holdURL, holdDID, scanRecord.VulnReportBlob.Ref.String())
|
||||
}
|
||||
|
||||
// Fetch SBOM blob
|
||||
var sbom *advisorSPDX
|
||||
if scanRecord.SbomBlob != nil && scanRecord.SbomBlob.Ref.String() != "" {
|
||||
sbom = fetchAdvisorBlob[advisorSPDX](ctx, holdURL, holdDID, scanRecord.SbomBlob.Ref.String())
|
||||
}
|
||||
|
||||
return &scanRecord, vulnReport, sbom
|
||||
}
|
||||
|
||||
// fetchAdvisorBlob fetches and JSON-decodes a blob from the hold.
|
||||
func fetchAdvisorBlob[T any](ctx context.Context, holdURL, holdDID, cid string) *T {
|
||||
blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
|
||||
strings.TrimSuffix(holdURL, "/"),
|
||||
url.QueryEscape(holdDID),
|
||||
url.QueryEscape(cid),
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", blobURL, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result T
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &result
|
||||
}
|
||||
|
||||
// generateAdvisorPrompt writes the system+data prompt for the AI advisor.
|
||||
func generateAdvisorPrompt(w io.Writer, r *advisorReportData) {
|
||||
// Data block
|
||||
ref := r.Handle + "/" + r.Repository
|
||||
totalSize := int64(0)
|
||||
for _, l := range r.Layers {
|
||||
totalSize += l.Size
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "image: %s\ndigest: %s\n", ref, r.Digest)
|
||||
if r.Platform != "" {
|
||||
fmt.Fprintf(w, "platform: %s\n", r.Platform)
|
||||
}
|
||||
fmt.Fprintf(w, "total_size: %s\nlayers: %d\n", advisorHumanSize(totalSize), len(r.Layers))
|
||||
|
||||
if r.Config != nil {
|
||||
c := r.Config.Config
|
||||
user := c.User
|
||||
if user == "" {
|
||||
user = "root"
|
||||
}
|
||||
fmt.Fprintf(w, "user: %s\n", user)
|
||||
if c.WorkingDir != "" {
|
||||
fmt.Fprintf(w, "workdir: %s\n", c.WorkingDir)
|
||||
}
|
||||
if len(c.Entrypoint) > 0 {
|
||||
fmt.Fprintf(w, "entrypoint: %s\n", strings.Join(c.Entrypoint, " "))
|
||||
}
|
||||
if len(c.Cmd) > 0 {
|
||||
fmt.Fprintf(w, "cmd: %s\n", strings.Join(c.Cmd, " "))
|
||||
}
|
||||
if len(c.ExposedPorts) > 0 {
|
||||
ports := make([]string, 0, len(c.ExposedPorts))
|
||||
for p := range c.ExposedPorts {
|
||||
ports = append(ports, p)
|
||||
}
|
||||
fmt.Fprintf(w, "ports: %s\n", strings.Join(ports, ","))
|
||||
}
|
||||
if len(c.Env) > 0 {
|
||||
fmt.Fprintln(w, "env:")
|
||||
for _, env := range c.Env {
|
||||
parts := strings.SplitN(env, "=", 2)
|
||||
if advisorShouldRedact(parts[0]) {
|
||||
fmt.Fprintf(w, " - %s=[REDACTED]\n", parts[0])
|
||||
} else {
|
||||
fmt.Fprintf(w, " - %s\n", env)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(c.Labels) > 0 {
|
||||
fmt.Fprintln(w, "labels:")
|
||||
keys := make([]string, 0, len(c.Labels))
|
||||
for k := range c.Labels {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
v := c.Labels[k]
|
||||
if len(v) > 80 {
|
||||
v = v[:77] + "..."
|
||||
}
|
||||
fmt.Fprintf(w, " %s: %s\n", k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// History with layer sizes
|
||||
fmt.Fprintln(w, "history:")
|
||||
layerIdx := 0
|
||||
for _, h := range r.Config.History {
|
||||
cmd := advisorCleanCommand(h.CreatedBy)
|
||||
if len(cmd) > 100 {
|
||||
cmd = cmd[:97] + "..."
|
||||
}
|
||||
if !h.EmptyLayer && layerIdx < len(r.Layers) {
|
||||
fmt.Fprintf(w, " - [%s] %s\n", advisorHumanSize(r.Layers[layerIdx].Size), cmd)
|
||||
layerIdx++
|
||||
} else {
|
||||
fmt.Fprintf(w, " - %s\n", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vulnerability summary
|
||||
if r.ScanRecord != nil {
|
||||
sr := r.ScanRecord
|
||||
fmt.Fprintf(w, "vulns: {critical: %d, high: %d, medium: %d, low: %d, total: %d}\n",
|
||||
sr.Critical, sr.High, sr.Medium, sr.Low, sr.Total)
|
||||
}
|
||||
|
||||
// Fixable critical/high vulns
|
||||
if r.VulnReport != nil {
|
||||
type pkgInfo struct {
|
||||
version string
|
||||
typ string
|
||||
fixes map[string]bool
|
||||
cves []string
|
||||
maxSev int
|
||||
}
|
||||
pkgs := map[string]*pkgInfo{}
|
||||
|
||||
for _, m := range r.VulnReport.Matches {
|
||||
sev := m.Vulnerability.Metadata.Severity
|
||||
if sev != "Critical" && sev != "High" {
|
||||
continue
|
||||
}
|
||||
key := m.Package.Name
|
||||
p, ok := pkgs[key]
|
||||
if !ok {
|
||||
p = &pkgInfo{version: m.Package.Version, typ: m.Package.Type, fixes: map[string]bool{}, maxSev: 5}
|
||||
pkgs[key] = p
|
||||
}
|
||||
p.cves = append(p.cves, m.Vulnerability.ID)
|
||||
for _, f := range m.Vulnerability.Fix.Versions {
|
||||
p.fixes[f] = true
|
||||
}
|
||||
if s := advisorSeverityRank(sev); s < p.maxSev {
|
||||
p.maxSev = s
|
||||
}
|
||||
}
|
||||
|
||||
if len(pkgs) > 0 {
|
||||
fmt.Fprintln(w, "fixable_critical_high:")
|
||||
type entry struct {
|
||||
name string
|
||||
info *pkgInfo
|
||||
}
|
||||
sorted := make([]entry, 0, len(pkgs))
|
||||
for n, p := range pkgs {
|
||||
sorted = append(sorted, entry{n, p})
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
if sorted[i].info.maxSev != sorted[j].info.maxSev {
|
||||
return sorted[i].info.maxSev < sorted[j].info.maxSev
|
||||
}
|
||||
return len(sorted[i].info.cves) > len(sorted[j].info.cves)
|
||||
})
|
||||
|
||||
for _, e := range sorted {
|
||||
fixes := make([]string, 0, len(e.info.fixes))
|
||||
for f := range e.info.fixes {
|
||||
fixes = append(fixes, f)
|
||||
}
|
||||
sort.Strings(fixes)
|
||||
fmt.Fprintf(w, " - pkg: %s@%s (%s) cves: %d fix: %s\n",
|
||||
e.name, e.info.version, e.info.typ, len(e.info.cves), strings.Join(fixes, ","))
|
||||
}
|
||||
}
|
||||
|
||||
// Unfixable counts
|
||||
unfixable := map[string]int{}
|
||||
for _, m := range r.VulnReport.Matches {
|
||||
if len(m.Vulnerability.Fix.Versions) == 0 {
|
||||
unfixable[m.Vulnerability.Metadata.Severity]++
|
||||
}
|
||||
}
|
||||
if len(unfixable) > 0 {
|
||||
fmt.Fprintf(w, "unfixable:")
|
||||
for _, sev := range []string{"Critical", "High", "Medium", "Low", "Negligible", "Unknown"} {
|
||||
if c, ok := unfixable[sev]; ok {
|
||||
fmt.Fprintf(w, " %s=%d", strings.ToLower(sev), c)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
// SBOM summary
|
||||
if r.SBOM != nil {
|
||||
typeCounts := map[string]int{}
|
||||
total := 0
|
||||
for _, p := range r.SBOM.Packages {
|
||||
if strings.HasPrefix(p.SPDXID, "SPDXRef-DocumentRoot") || p.SPDXID == "SPDXRef-DOCUMENT" {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
pkgType := advisorExtractPackageType(p.Supplier)
|
||||
if pkgType == "" {
|
||||
pkgType = "other"
|
||||
}
|
||||
typeCounts[pkgType]++
|
||||
}
|
||||
fmt.Fprintf(w, "sbom_packages: %d", total)
|
||||
for t, c := range typeCounts {
|
||||
fmt.Fprintf(w, " %s=%d", t, c)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
if r.VulnReport != nil {
|
||||
vulnPkgs := map[string]int{}
|
||||
for _, m := range r.VulnReport.Matches {
|
||||
vulnPkgs[m.Package.Name]++
|
||||
}
|
||||
type pv struct {
|
||||
name string
|
||||
count int
|
||||
}
|
||||
sorted := make([]pv, 0, len(vulnPkgs))
|
||||
for n, c := range vulnPkgs {
|
||||
sorted = append(sorted, pv{n, c})
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].count > sorted[j].count })
|
||||
if len(sorted) > 10 {
|
||||
sorted = sorted[:10]
|
||||
}
|
||||
fmt.Fprintln(w, "top_vulnerable_packages:")
|
||||
for _, p := range sorted {
|
||||
fmt.Fprintf(w, " - %s: %d\n", p.name, p.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// callClaudeAPI sends the prompt to Claude Haiku using tool use and returns the structured JSON.
|
||||
func callClaudeAPI(ctx context.Context, apiKey, prompt string) (string, error) {
|
||||
reqBody := map[string]any{
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"max_tokens": 2048,
|
||||
"system": "Analyze the container image data. Provide actionable suggestions sorted by impact (highest first).",
|
||||
"tools": []map[string]any{{
|
||||
"name": "suggest_fixes",
|
||||
"description": "Return actionable suggestions for improving a container image, sorted by impact.",
|
||||
"input_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"suggestions": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{"type": "string", "description": "Specific actionable step"},
|
||||
"category": map[string]any{"type": "string", "enum": []string{"vulnerability", "size", "cache", "security", "best-practice"}},
|
||||
"impact": map[string]any{"type": "string", "enum": []string{"high", "medium", "low"}},
|
||||
"effort": map[string]any{"type": "string", "enum": []string{"low", "medium", "high"}},
|
||||
"cves_fixed": map[string]any{"type": "integer", "description": "Number of CVEs fixed, or 0"},
|
||||
"size_saved_mb": map[string]any{"type": "integer", "description": "Estimated MB saved, or 0"},
|
||||
"detail": map[string]any{"type": "string", "description": "One sentence with specific package names, versions, or commands"},
|
||||
},
|
||||
"required": []string{"action", "category", "impact", "effort", "cves_fixed", "size_saved_mb", "detail"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"suggestions"},
|
||||
},
|
||||
}},
|
||||
"tool_choice": map[string]any{"type": "tool", "name": "suggest_fixes"},
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": prompt},
|
||||
},
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
req.Header.Set("content-type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("API request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("API returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var apiResp struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return "", fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
for _, c := range apiResp.Content {
|
||||
if c.Type == "tool_use" && c.Name == "suggest_fixes" {
|
||||
return string(c.Input), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no tool_use content in response")
|
||||
}
|
||||
|
||||
// parseAdvisorResponse parses the JSON suggestions (from API or cache) into suggestions.
|
||||
func parseAdvisorResponse(jsonStr string) ([]advisorSuggestion, error) {
|
||||
var result struct {
|
||||
Suggestions []advisorSuggestion `json:"suggestions"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Suggestions, nil
|
||||
}
|
||||
|
||||
// Prompt helper functions
|
||||
|
||||
func advisorHumanSize(bytes int64) string {
|
||||
const (
|
||||
KB = 1024
|
||||
MB = 1024 * KB
|
||||
GB = 1024 * MB
|
||||
)
|
||||
switch {
|
||||
case bytes >= GB:
|
||||
return fmt.Sprintf("%.1f GB", float64(bytes)/float64(GB))
|
||||
case bytes >= MB:
|
||||
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB))
|
||||
case bytes >= KB:
|
||||
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
func advisorCleanCommand(cmd string) string {
|
||||
cmd = strings.TrimPrefix(cmd, "/bin/sh -c ")
|
||||
cmd = strings.TrimPrefix(cmd, "#(nop) ")
|
||||
return strings.TrimSpace(cmd)
|
||||
}
|
||||
|
||||
func advisorShouldRedact(envName string) bool {
|
||||
upper := strings.ToUpper(envName)
|
||||
for _, suffix := range []string{"_KEY", "_SECRET", "_PASSWORD", "_TOKEN", "_CREDENTIALS", "_API_KEY"} {
|
||||
if strings.HasSuffix(upper, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func advisorSeverityRank(s string) int {
|
||||
switch s {
|
||||
case "Critical":
|
||||
return 0
|
||||
case "High":
|
||||
return 1
|
||||
case "Medium":
|
||||
return 2
|
||||
case "Low":
|
||||
return 3
|
||||
case "Negligible":
|
||||
return 4
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
func advisorExtractPackageType(supplier string) string {
|
||||
s := strings.ToLower(supplier)
|
||||
switch {
|
||||
case strings.Contains(s, "npmjs") || strings.Contains(s, "npm"):
|
||||
return "npm"
|
||||
case strings.Contains(s, "pypi") || strings.Contains(s, "python"):
|
||||
return "python"
|
||||
case strings.Contains(s, "rubygems"):
|
||||
return "gem"
|
||||
case strings.Contains(s, "golang") || strings.Contains(s, "go"):
|
||||
return "go"
|
||||
case strings.Contains(s, "debian") || strings.Contains(s, "ubuntu"):
|
||||
return "deb"
|
||||
case strings.Contains(s, "alpine"):
|
||||
return "apk"
|
||||
case strings.Contains(s, "redhat") || strings.Contains(s, "fedora") || strings.Contains(s, "centos"):
|
||||
return "rpm"
|
||||
case strings.Contains(s, "maven") || strings.Contains(s, "java"):
|
||||
return "java"
|
||||
case strings.Contains(s, "nuget") || strings.Contains(s, ".net"):
|
||||
return "nuget"
|
||||
case strings.Contains(s, "cargo") || strings.Contains(s, "rust"):
|
||||
return "rust"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,22 @@ import (
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/holdclient"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
"atcr.io/pkg/appview/readme"
|
||||
"atcr.io/pkg/appview/storage"
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// SelectedTagData holds all data for the currently selected tag on the repo page.
|
||||
type SelectedTagData struct {
|
||||
Info *db.TagWithPlatforms
|
||||
LayerCount int
|
||||
CompressedSize int64 // total across all platforms
|
||||
ScanBatchParams []template.HTML
|
||||
}
|
||||
|
||||
// RepositoryPageHandler handles the public repository page
|
||||
type RepositoryPageHandler struct {
|
||||
BaseUIHandler
|
||||
@@ -62,19 +72,93 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch latest tag for pull command
|
||||
latestTag, err := db.GetLatestTag(h.ReadOnlyDB, owner.DID, repository)
|
||||
// Fetch all tag names for the selector dropdown
|
||||
allTags, err := db.GetAllTagNames(h.ReadOnlyDB, owner.DID, repository)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
slog.Warn("Failed to fetch tag names", "error", err)
|
||||
}
|
||||
|
||||
// Determine artifact type from latest tag
|
||||
artifactType := "container-image"
|
||||
latestTagName := ""
|
||||
if latestTag != nil {
|
||||
latestTagName = latestTag.Tag
|
||||
artifactType = latestTag.ArtifactType
|
||||
// Determine which tag to show
|
||||
selectedTagName := r.URL.Query().Get("tag")
|
||||
if selectedTagName == "" {
|
||||
// Default: "latest" if it exists, otherwise most recent
|
||||
for _, t := range allTags {
|
||||
if t == "latest" {
|
||||
selectedTagName = "latest"
|
||||
break
|
||||
}
|
||||
}
|
||||
if selectedTagName == "" && len(allTags) > 0 {
|
||||
selectedTagName = allTags[0] // most recent (already sorted DESC)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the selected tag's full data
|
||||
var selectedTag *SelectedTagData
|
||||
var artifactType = "container-image"
|
||||
if selectedTagName != "" {
|
||||
tagData, err := db.GetTagByName(h.ReadOnlyDB, owner.DID, repository, selectedTagName)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch selected tag", "error", err, "tag", selectedTagName)
|
||||
}
|
||||
if tagData != nil {
|
||||
artifactType = tagData.ArtifactType
|
||||
|
||||
// Ensure single-arch tags have a one-element Platforms slice
|
||||
platforms := tagData.Platforms
|
||||
if len(platforms) == 0 {
|
||||
platforms = []db.PlatformInfo{{
|
||||
Digest: tagData.Digest,
|
||||
HoldEndpoint: tagData.HoldEndpoint,
|
||||
CompressedSize: tagData.CompressedSize,
|
||||
}}
|
||||
}
|
||||
tagData.Platforms = platforms
|
||||
|
||||
// Compute total compressed size across all platforms
|
||||
var totalSize int64
|
||||
if tagData.IsMultiArch {
|
||||
for _, p := range platforms {
|
||||
totalSize += p.CompressedSize
|
||||
}
|
||||
} else {
|
||||
totalSize = tagData.CompressedSize
|
||||
}
|
||||
|
||||
// Get layer count from image config (includes empty layers)
|
||||
layerCountDigest := tagData.Digest
|
||||
if tagData.IsMultiArch && len(platforms) > 0 && platforms[0].Digest != "" {
|
||||
layerCountDigest = platforms[0].Digest
|
||||
}
|
||||
var layerCount int
|
||||
holdEndpoint := platforms[0].HoldEndpoint
|
||||
hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, holdEndpoint)
|
||||
if holdErr == nil {
|
||||
config, cfgErr := holdclient.FetchImageConfig(r.Context(), hold.URL, layerCountDigest)
|
||||
if cfgErr == nil {
|
||||
layerCount = len(config.History)
|
||||
} else {
|
||||
slog.Warn("Failed to fetch image config for layer count", "error", cfgErr)
|
||||
}
|
||||
}
|
||||
// Fall back to DB count (non-empty layers only) if hold unavailable
|
||||
if layerCount == 0 {
|
||||
layerCount, err = db.GetLayerCountForManifest(h.ReadOnlyDB, owner.DID, repository, layerCountDigest)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch layer count", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build scan batch params for first platform only (summary card)
|
||||
scanBatchParams := buildScanBatchParams(platforms[:1])
|
||||
|
||||
selectedTag = &SelectedTagData{
|
||||
Info: tagData,
|
||||
LayerCount: layerCount,
|
||||
CompressedSize: totalSize,
|
||||
ScanBatchParams: scanBatchParams,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create repository summary
|
||||
@@ -97,13 +181,18 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
||||
repo.Version = metadata["org.opencontainers.image.version"]
|
||||
}
|
||||
|
||||
// Fetch star count
|
||||
// Fetch stats
|
||||
stats, err := db.GetRepositoryStats(h.ReadOnlyDB, owner.DID, repository)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch repository stats", "error", err)
|
||||
stats = &db.RepositoryStats{StarCount: 0}
|
||||
}
|
||||
|
||||
tagCount, err := db.CountTags(h.ReadOnlyDB, owner.DID, repository)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch tag count", "error", err)
|
||||
}
|
||||
|
||||
// Check if current user has starred this repo
|
||||
isStarred := false
|
||||
user := middleware.GetUser(r)
|
||||
@@ -193,9 +282,10 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
||||
Meta *PageMeta
|
||||
Owner *db.User
|
||||
Repository *db.Repository
|
||||
LatestTag string
|
||||
StarCount int
|
||||
PullCount int
|
||||
AllTags []string
|
||||
SelectedTag *SelectedTagData
|
||||
Stats *db.RepositoryStats
|
||||
TagCount int
|
||||
IsStarred bool
|
||||
IsOwner bool
|
||||
ReadmeHTML template.HTML
|
||||
@@ -206,9 +296,10 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
||||
Meta: meta,
|
||||
Owner: owner,
|
||||
Repository: repo,
|
||||
LatestTag: latestTagName,
|
||||
StarCount: stats.StarCount,
|
||||
PullCount: stats.PullCount,
|
||||
AllTags: allTags,
|
||||
SelectedTag: selectedTag,
|
||||
Stats: stats,
|
||||
TagCount: tagCount,
|
||||
IsStarred: isStarred,
|
||||
IsOwner: isOwner,
|
||||
ReadmeHTML: readmeHTML,
|
||||
@@ -216,12 +307,57 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
||||
ArtifactType: artifactType,
|
||||
}
|
||||
|
||||
// If the owner has disabled AI advisor in their profile, hide the button
|
||||
if isOwner && data.AIAdvisorEnabled && user != nil {
|
||||
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
||||
if profile, err := storage.GetProfile(r.Context(), client); err == nil && profile != nil {
|
||||
if profile.AIAdvisorEnabled != nil && !*profile.AIAdvisorEnabled {
|
||||
data.AIAdvisorEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this is an HTMX request for the tag section, render just the partial
|
||||
if r.Header.Get("HX-Request") == "true" && r.URL.Query().Get("tag") != "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.Templates.ExecuteTemplate(w, "repo-tag-section", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// buildScanBatchParams builds HTMX query params for batch scan-result requests
|
||||
// from a list of platform infos.
|
||||
func buildScanBatchParams(platforms []db.PlatformInfo) []template.HTML {
|
||||
holdDigests := make(map[string][]string)
|
||||
seen := make(map[string]bool)
|
||||
for _, p := range platforms {
|
||||
if p.Digest != "" && p.HoldEndpoint != "" && !seen[p.Digest] {
|
||||
seen[p.Digest] = true
|
||||
hex := strings.TrimPrefix(p.Digest, "sha256:")
|
||||
holdDigests[p.HoldEndpoint] = append(holdDigests[p.HoldEndpoint], hex)
|
||||
}
|
||||
}
|
||||
var params []template.HTML
|
||||
for hold, digests := range holdDigests {
|
||||
for i := 0; i < len(digests); i += 50 {
|
||||
end := i + 50
|
||||
if end > len(digests) {
|
||||
end = len(digests)
|
||||
}
|
||||
params = append(params, template.HTML(
|
||||
"holdEndpoint="+url.QueryEscape(hold)+"&digests="+strings.Join(digests[i:end], ",")))
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// RepositoryTagsHandler returns the tags+manifests HTMX partial for a repository
|
||||
type RepositoryTagsHandler struct {
|
||||
BaseUIHandler
|
||||
|
||||
@@ -138,6 +138,8 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
DefaultHold string
|
||||
AutoRemoveUntagged bool
|
||||
OciClient string
|
||||
AIAdvisorEnabled bool
|
||||
HasAIAdvisorAccess bool // billing tier grants access
|
||||
}
|
||||
ActiveHold *HoldDisplay
|
||||
OtherHolds []HoldDisplay
|
||||
@@ -160,6 +162,10 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
data.Profile.DefaultHold = profile.DefaultHold
|
||||
data.Profile.AutoRemoveUntagged = profile.AutoRemoveUntagged
|
||||
data.Profile.OciClient = profile.OciClient
|
||||
data.Profile.AIAdvisorEnabled = profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled
|
||||
if h.BillingManager != nil {
|
||||
data.Profile.HasAIAdvisorAccess = h.BillingManager.HasAIAdvisor(user.DID)
|
||||
}
|
||||
|
||||
if err := h.Templates.ExecuteTemplate(w, "settings", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -477,6 +483,43 @@ func (h *UpdateOciClientHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// UpdateAIAdvisorHandler handles toggling the AI Image Advisor setting
|
||||
type UpdateAIAdvisorHandler struct {
|
||||
BaseUIHandler
|
||||
}
|
||||
|
||||
func (h *UpdateAIAdvisorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
||||
|
||||
profile, err := storage.GetProfile(r.Context(), client)
|
||||
if err != nil || profile == nil {
|
||||
http.Error(w, "Failed to fetch profile", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Toggle: nil/true → false, false → nil (default enabled)
|
||||
if profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled {
|
||||
f := false
|
||||
profile.AIAdvisorEnabled = &f
|
||||
} else {
|
||||
profile.AIAdvisorEnabled = nil
|
||||
}
|
||||
profile.UpdatedAt = time.Now()
|
||||
|
||||
if err := storage.UpdateProfile(r.Context(), client, profile); err != nil {
|
||||
http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// refreshCaptainRecord fetches a hold's captain record via XRPC and caches it locally.
|
||||
// This ensures badge tiers and other captain metadata are available immediately
|
||||
// without waiting for Jetstream or the next backfill cycle.
|
||||
|
||||
@@ -94,7 +94,7 @@ func (h *SubscriptionPortalHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
|
||||
if r.TLS == nil {
|
||||
scheme = "http"
|
||||
}
|
||||
returnURL := scheme + "://" + h.SiteURL + "/settings#storage"
|
||||
returnURL := scheme + "://" + h.SiteURL + "/settings#billing"
|
||||
|
||||
resp, err := h.BillingManager.GetBillingPortalURL(user.DID, returnURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -129,10 +129,9 @@ func (h *UpgradeBannerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
} else if currentManifest.IsManifestList || newerManifest.IsManifestList {
|
||||
// One is multi-arch, the other isn't — can't compare meaningfully
|
||||
// Still show a basic banner without layer/vuln details
|
||||
}
|
||||
// If one is multi-arch and the other isn't, we can't match platforms —
|
||||
// fall through and show a basic banner without layer/vuln details.
|
||||
|
||||
// Fetch layers for both
|
||||
currentDBLayers, _ := db.GetLayersForManifest(h.ReadOnlyDB, currentManifestForLayers.ID)
|
||||
|
||||
@@ -83,6 +83,7 @@ func (b *BackfillWorker) Start(ctx context.Context) error {
|
||||
atproto.SailorProfileCollection, // io.atcr.sailor.profile
|
||||
atproto.RepoPageCollection, // io.atcr.repo.page
|
||||
atproto.StatsCollection, // io.atcr.hold.stats (from holds)
|
||||
atproto.DailyStatsCollection, // io.atcr.hold.stats.daily (from holds)
|
||||
atproto.CaptainCollection, // io.atcr.hold.captain (from holds)
|
||||
atproto.CrewCollection, // io.atcr.hold.crew (from holds)
|
||||
atproto.ScanCollection, // io.atcr.hold.scan (from holds)
|
||||
|
||||
@@ -289,6 +289,9 @@ func (p *Processor) ProcessRecord(ctx context.Context, did, collection, rkey str
|
||||
case atproto.StatsCollection:
|
||||
return p.ProcessStats(ctx, did, data, isDelete)
|
||||
|
||||
case atproto.DailyStatsCollection:
|
||||
return p.ProcessDailyStats(ctx, did, data, isDelete)
|
||||
|
||||
case atproto.CaptainCollection:
|
||||
if isDelete {
|
||||
return db.DeleteCaptainRecord(p.db, did)
|
||||
@@ -355,6 +358,11 @@ func (p *Processor) ProcessManifest(ctx context.Context, did string, recordData
|
||||
manifest.ConfigSize = manifestRecord.Config.Size
|
||||
}
|
||||
|
||||
// Track subject digest for attestation/referrer manifests
|
||||
if manifestRecord.Subject != nil {
|
||||
manifest.SubjectDigest = manifestRecord.Subject.Digest
|
||||
}
|
||||
|
||||
// Insert manifest
|
||||
manifestID, err := db.InsertManifest(p.db, manifest)
|
||||
if err != nil {
|
||||
@@ -825,6 +833,40 @@ func (p *Processor) ProcessStats(ctx context.Context, holdDID string, recordData
|
||||
})
|
||||
}
|
||||
|
||||
// ProcessDailyStats handles daily stats record events from hold PDSes
|
||||
// This is called when Jetstream receives a daily stats create/update/delete event from a hold
|
||||
func (p *Processor) ProcessDailyStats(ctx context.Context, holdDID string, recordData []byte, isDelete bool) error {
|
||||
var record atproto.DailyStatsRecord
|
||||
if err := json.Unmarshal(recordData, &record); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal daily stats record: %w", err)
|
||||
}
|
||||
|
||||
if isDelete {
|
||||
// Daily stats deletions are rare — just log and skip
|
||||
slog.Debug("Daily stats record deleted",
|
||||
"holdDID", holdDID,
|
||||
"ownerDID", record.OwnerDID,
|
||||
"repository", record.Repository,
|
||||
"date", record.Date)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure the owner user exists (FK constraint)
|
||||
if record.OwnerDID != "" {
|
||||
if err := p.EnsureUserExists(ctx, record.OwnerDID); err != nil {
|
||||
return fmt.Errorf("failed to ensure daily stats owner user exists: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return db.UpsertDailyStats(p.db, &db.DailyStats{
|
||||
DID: record.OwnerDID,
|
||||
Repository: record.Repository,
|
||||
Date: record.Date,
|
||||
PullCount: int(record.PullCount),
|
||||
PushCount: int(record.PushCount),
|
||||
})
|
||||
}
|
||||
|
||||
// ProcessCaptain handles captain record events from hold PDSes
|
||||
// This is called when Jetstream receives a captain create/update/delete event from a hold
|
||||
// The holdDID is the DID of the hold PDS (event.DID), and the record contains ownership info
|
||||
|
||||
@@ -57,6 +57,7 @@ func setupTestDB(t *testing.T) *sql.DB {
|
||||
config_digest TEXT,
|
||||
config_size INTEGER,
|
||||
artifact_type TEXT NOT NULL DEFAULT 'container-image',
|
||||
subject_digest TEXT,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
UNIQUE(did, repository, digest)
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
|
||||
<symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol>
|
||||
<symbol id="bold" viewBox="0 0 24 24"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></symbol>
|
||||
<symbol id="book-open" viewBox="0 0 24 24"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></symbol>
|
||||
<symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
|
||||
<symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol>
|
||||
<symbol id="chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
|
||||
@@ -18,13 +19,16 @@
|
||||
<symbol id="container" viewBox="0 0 24 24"><path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z"/><path d="M10 21.9V14L2.1 9.1"/><path d="m10 14 11.9-6.9"/><path d="M14 19.8v-8.1"/><path d="M18 17.5V9.4"/></symbol>
|
||||
<symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
|
||||
<symbol id="cpu" viewBox="0 0 24 24"><path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/></symbol>
|
||||
<symbol id="credit-card" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></symbol>
|
||||
<symbol id="database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></symbol>
|
||||
<symbol id="download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol>
|
||||
<symbol id="external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
|
||||
<symbol id="eye" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="file-plus" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M9 15h6"/><path d="M12 18v-6"/></symbol>
|
||||
<symbol id="file-text" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol>
|
||||
<symbol id="file-x" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="m14.5 12.5-5 5"/><path d="m9.5 12.5 5 5"/></symbol>
|
||||
<symbol id="fingerprint" viewBox="0 0 24 24"><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 16h.01"/><path d="M21.8 16c.2-2 .131-5.354 0-6"/><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M9 6.8a6 6 0 0 1 9 5.2v2"/></symbol>
|
||||
<symbol id="git-compare" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></symbol>
|
||||
<symbol id="git-merge" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></symbol>
|
||||
<symbol id="github" viewBox="0 0 24 24"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></symbol>
|
||||
<symbol id="hard-drive" viewBox="0 0 24 24"><path d="M10 16h.01"/><path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M21.946 12.013H2.054"/><path d="M6 16h.01"/></symbol>
|
||||
@@ -50,9 +54,12 @@
|
||||
<symbol id="settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol>
|
||||
<symbol id="ship" viewBox="0 0 24 24"><path d="M12 10.189V14"/><path d="M12 2v3"/><path d="M19 13V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6"/><path d="M19.38 20A11.6 11.6 0 0 0 21 14l-8.188-3.639a2 2 0 0 0-1.624 0L3 14a11.6 11.6 0 0 0 2.81 7.76"/><path d="M2 21c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1s1.2 1 2.5 1c2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/></symbol>
|
||||
<symbol id="sparkle" viewBox="0 0 24 24"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/></symbol>
|
||||
<symbol id="sparkles" viewBox="0 0 24 24"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/></symbol>
|
||||
<symbol id="star" viewBox="0 0 24 24"><path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/></symbol>
|
||||
<symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></symbol>
|
||||
<symbol id="sun-moon" viewBox="0 0 24 24"><path d="M12 2v2"/><path d="M14.837 16.385a6 6 0 1 1-7.223-7.222c.624-.147.97.66.715 1.248a4 4 0 0 0 5.26 5.259c.589-.255 1.396.09 1.248.715"/><path d="M16 12a4 4 0 0 0-4-4"/><path d="m19 5-1.256 1.256"/><path d="M20 12h2"/></symbol>
|
||||
<symbol id="tag" viewBox="0 0 24 24"><path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/></symbol>
|
||||
<symbol id="terminal" viewBox="0 0 24 24"><path d="M12 19h8"/><path d="m4 17 6-6-6-6"/></symbol>
|
||||
<symbol id="trash-2" viewBox="0 0 24 24"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></symbol>
|
||||
<symbol id="triangle-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 15 KiB |
@@ -44,6 +44,7 @@ type UIDependencies struct {
|
||||
ClientShortName string // Short name: "ATCR"
|
||||
BillingManager *billing.Manager // Stripe billing manager (nil if not configured)
|
||||
WebhookDispatcher *webhooks.Dispatcher // Webhook dispatcher (nil if not configured)
|
||||
ClaudeAPIKey string // Anthropic API key for AI advisor (empty = disabled)
|
||||
}
|
||||
|
||||
// RegisterUIRoutes registers all web UI and API routes on the provided router
|
||||
@@ -78,6 +79,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
Jurisdiction: deps.LegalConfig.Jurisdiction,
|
||||
ClientName: deps.ClientName,
|
||||
ClientShortName: deps.ClientShortName,
|
||||
AIAdvisorEnabled: deps.ClaudeAPIKey != "",
|
||||
}
|
||||
|
||||
// OAuth login routes (public)
|
||||
@@ -158,6 +160,9 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
|
||||
router.Get("/api/digest-content/{handle}/*", (&uihandlers.DigestContentHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
router.Get("/api/upgrade-banner/{handle}/*", (&uihandlers.UpgradeBannerHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
router.Get("/api/image-advisor/{handle}/*", middleware.RequireAuth(deps.SessionStore, deps.Database)(
|
||||
&uihandlers.ImageAdvisorHandler{BaseUIHandler: base, ClaudeAPIKey: deps.ClaudeAPIKey},
|
||||
).ServeHTTP)
|
||||
|
||||
// Diff page: /diff/{handle}/{repo}?from=...&to=...
|
||||
router.Get("/diff/{handle}/*", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
|
||||
@@ -177,6 +182,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
r.Post("/api/profile/auto-remove-untagged", (&uihandlers.UpdateAutoRemoveUntaggedHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
r.Post("/api/profile/oci-client", (&uihandlers.UpdateOciClientHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
r.Post("/api/profile/ai-advisor", (&uihandlers.UpdateAIAdvisorHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
|
||||
// Subscription management
|
||||
r.Get("/settings/subscription/checkout", (&uihandlers.SubscriptionCheckoutHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
|
||||
@@ -144,6 +144,9 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
|
||||
})
|
||||
|
||||
slog.Info("Configuration loaded successfully from environment")
|
||||
if cfg.AI.APIKey != "" {
|
||||
slog.Info("AI Image Advisor enabled")
|
||||
}
|
||||
|
||||
s := &AppViewServer{
|
||||
Config: cfg,
|
||||
@@ -330,6 +333,7 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
|
||||
ClientShortName: cfg.Server.ClientShortName,
|
||||
BillingManager: s.BillingManager,
|
||||
WebhookDispatcher: s.WebhookDispatcher,
|
||||
ClaudeAPIKey: cfg.AI.APIKey,
|
||||
LegalConfig: routes.LegalConfig{
|
||||
CompanyName: cfg.Legal.CompanyName,
|
||||
Jurisdiction: cfg.Legal.Jurisdiction,
|
||||
|
||||
80
pkg/appview/templates/components/pull-command-switcher.html
Normal file
80
pkg/appview/templates/components/pull-command-switcher.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{{ define "pull-command-switcher" }}
|
||||
{{/*
|
||||
Pull command with inline OCI client switcher.
|
||||
Expects dict with: RegistryURL, OwnerHandle, RepoName, Tag, ArtifactType, OciClient, IsLoggedIn
|
||||
|
||||
For helm charts, shows helm command only (no switcher).
|
||||
For container images, shows a client dropdown that updates the command.
|
||||
Logged-in users: saves to profile via HTMX POST.
|
||||
Anonymous users: saves to localStorage.
|
||||
*/}}
|
||||
{{ if eq .ArtifactType "helm-chart" }}
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-base-content/70">Pull this chart</p>
|
||||
{{ if .Tag }}
|
||||
{{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName " --version " .Tag) }}
|
||||
{{ else }}
|
||||
{{ template "docker-command" (print "helm pull oci://" .RegistryURL "/" .OwnerHandle "/" .RepoName) }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="space-y-2" id="pull-cmd-container">
|
||||
<p class="text-sm font-medium text-base-content/70">Pull this image</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<select id="oci-client-switcher" class="select select-xs select-bordered w-auto"
|
||||
onchange="updatePullCommand(this.value)">
|
||||
<option value="docker"{{ if or (eq .OciClient "") (eq .OciClient "docker") }} selected{{ end }}>docker</option>
|
||||
<option value="podman"{{ if eq .OciClient "podman" }} selected{{ end }}>podman</option>
|
||||
<option value="nerdctl"{{ if eq .OciClient "nerdctl" }} selected{{ end }}>nerdctl</option>
|
||||
<option value="buildah"{{ if eq .OciClient "buildah" }} selected{{ end }}>buildah</option>
|
||||
<option value="crane"{{ if eq .OciClient "crane" }} selected{{ end }}>crane</option>
|
||||
</select>
|
||||
<div id="pull-cmd-display" class="flex-1 min-w-0">
|
||||
{{ if .Tag }}
|
||||
{{ template "docker-command" (print (ociClientName .OciClient) " pull " .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Tag) }}
|
||||
{{ else }}
|
||||
{{ template "docker-command" (print (ociClientName .OciClient) " pull " .RegistryURL "/" .OwnerHandle "/" .RepoName ":latest") }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var registryURL = {{ .RegistryURL }};
|
||||
var ownerHandle = {{ .OwnerHandle }};
|
||||
var repoName = {{ .RepoName }};
|
||||
var tag = {{ if .Tag }}{{ .Tag }}{{ else }}"latest"{{ end }};
|
||||
var isLoggedIn = {{ .IsLoggedIn }};
|
||||
|
||||
// Restore from localStorage for anonymous users
|
||||
if (!isLoggedIn) {
|
||||
var saved = localStorage.getItem('oci-client');
|
||||
if (saved) {
|
||||
var sel = document.getElementById('oci-client-switcher');
|
||||
if (sel) {
|
||||
sel.value = saved;
|
||||
updatePullCommand(saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.updatePullCommand = function(client) {
|
||||
var cmd = client + ' pull ' + registryURL + '/' + ownerHandle + '/' + repoName + ':' + tag;
|
||||
var container = document.getElementById('pull-cmd-display');
|
||||
if (!container) return;
|
||||
var code = container.querySelector('code');
|
||||
if (code) code.textContent = cmd;
|
||||
var btn = container.querySelector('[data-cmd]');
|
||||
if (btn) btn.dataset.cmd = cmd;
|
||||
|
||||
// Persist preference
|
||||
if (isLoggedIn) {
|
||||
htmx.ajax('POST', '/api/profile/oci-client', {values: {oci_client: client}, swap: 'none'});
|
||||
} else {
|
||||
localStorage.setItem('oci-client', client);
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
@@ -72,6 +72,8 @@
|
||||
htmx.ajax('GET', '/api/digest-content/{{ .Owner.Handle }}/{{ .Repository }}?digest=' + encodeURIComponent(digest), {target: target, swap: 'innerHTML'}).then(function() {
|
||||
loading.classList.add('hidden');
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<div class="space-y-8">
|
||||
<!-- Repository Header -->
|
||||
<div class="card bg-base-100 shadow-sm p-6 space-y-6 w-full">
|
||||
<div class="space-y-6">
|
||||
<!-- Static Header: Identity + Metadata (does not change with tag) -->
|
||||
<div class="card bg-base-100 shadow-sm p-6 space-y-4 w-full">
|
||||
<div class="flex gap-4 items-start">
|
||||
{{ template "repo-avatar" (dict "IconURL" .Repository.IconURL "RepositoryName" .Repository.Name "IsOwner" .IsOwner) }}
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -26,16 +26,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Star Button, Pull Count and Metadata Row -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
{{ template "star" (dict "IsStarred" .IsStarred "StarCount" .StarCount "Interactive" true "Handle" .Owner.Handle "Repository" .Repository.Name) }}
|
||||
{{ template "pull-count" (dict "PullCount" .PullCount) }}
|
||||
<!-- Metadata Row -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
{{ template "star" (dict "IsStarred" .IsStarred "StarCount" .Stats.StarCount "Interactive" true "Handle" .Owner.Handle "Repository" .Repository.Name) }}
|
||||
{{ template "pull-count" (dict "PullCount" .Stats.PullCount) }}
|
||||
{{ if .TagCount }}
|
||||
<span class="flex items-center gap-1 text-sm text-base-content/70" title="{{ .TagCount }} tags">
|
||||
{{ icon "tag" "size-4" }} {{ .TagCount }}
|
||||
</span>
|
||||
{{ end }}
|
||||
{{ if .Stats.LastPush }}
|
||||
<span class="text-sm text-base-content/50" title="Last pushed {{ (derefTime .Stats.LastPush).Format "2006-01-02T15:04:05Z07:00" }}">
|
||||
Updated {{ timeAgoShort (derefTime .Stats.LastPush) }}
|
||||
</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- Metadata Section -->
|
||||
{{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2 ml-auto">
|
||||
{{ if .Repository.Version }}
|
||||
<span class="badge badge-md badge-primary badge-outline" title="Version">
|
||||
{{ .Repository.Version }}
|
||||
@@ -55,309 +64,134 @@
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ if .Repository.SourceURL }}
|
||||
<a href="{{ .Repository.SourceURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-sm" aria-label="View source code (opens in new tab)">
|
||||
Source
|
||||
<a href="{{ .Repository.SourceURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-sm flex items-center gap-1" aria-label="View source code (opens in new tab)">
|
||||
{{ icon "external-link" "size-3" }} Source
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if .Repository.DocumentationURL }}
|
||||
<a href="{{ .Repository.DocumentationURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-sm" aria-label="View documentation (opens in new tab)">
|
||||
Documentation
|
||||
<a href="{{ .Repository.DocumentationURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-sm flex items-center gap-1" aria-label="View documentation (opens in new tab)">
|
||||
{{ icon "book-open" "size-3" }} Docs
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Pull Command -->
|
||||
<div class="space-y-2">
|
||||
{{ if eq .ArtifactType "helm-chart" }}
|
||||
<p class="font-semibold">Pull this chart</p>
|
||||
{{ if .LatestTag }}
|
||||
{{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " .LatestTag) }}
|
||||
{{ else }}
|
||||
{{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name) }}
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<p class="font-semibold">Pull this image</p>
|
||||
{{ if .LatestTag }}
|
||||
{{ template "docker-command" (print (ociClientName .OciClient) " pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .LatestTag) }}
|
||||
{{ else }}
|
||||
{{ template "docker-command" (print (ociClientName .OciClient) " pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":latest") }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<!-- Tag Selector (stays in DOM, never swapped) -->
|
||||
{{ if .SelectedTag }}
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
{{ icon "tag" "size-4 text-base-content/60" }}
|
||||
<select id="tag-selector" class="select select-sm select-bordered font-mono"
|
||||
hx-get="/r/{{ .Owner.Handle }}/{{ .Repository.Name }}"
|
||||
hx-target="#tag-content"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
hx-include="this"
|
||||
name="tag">
|
||||
{{ range .AllTags }}
|
||||
<option value="{{ . }}"{{ if eq . $.SelectedTag.Info.Tag.Tag }} selected{{ end }}>{{ . }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-base-300">
|
||||
<nav class="flex gap-0" role="tablist">
|
||||
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors cursor-pointer"
|
||||
data-tab="overview"
|
||||
role="tab"
|
||||
onclick="switchRepoTab('overview')">
|
||||
Overview
|
||||
</button>
|
||||
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors cursor-pointer"
|
||||
data-tab="artifacts"
|
||||
role="tab"
|
||||
id="artifacts-tab-btn"
|
||||
onclick="switchRepoTab('artifacts')">
|
||||
Artifacts
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Panels -->
|
||||
<!-- Overview Panel -->
|
||||
<div id="tab-overview" class="repo-panel">
|
||||
<!-- View mode -->
|
||||
<div id="overview-view" class="card bg-base-100 shadow-sm p-6 space-y-4 min-w-0">
|
||||
{{ if .IsOwner }}
|
||||
<div class="flex justify-end">
|
||||
<button class="btn btn-sm btn-ghost gap-1" onclick="toggleOverviewEditor(true)">
|
||||
{{ icon "pencil" "size-4" }}
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div id="overview-rendered" class="prose prose-sm max-w-none">
|
||||
{{ if .ReadmeHTML }}
|
||||
{{ .ReadmeHTML }}
|
||||
{{ else }}
|
||||
<p class="text-base-content/60">No description available</p>
|
||||
{{ if .SelectedTag.Info.IsMultiArch }}
|
||||
<div id="platform-badges" class="flex flex-wrap items-center gap-1">
|
||||
{{ range .SelectedTag.Info.Platforms }}
|
||||
<span class="badge badge-sm badge-outline font-mono">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit mode (hidden, owner only) -->
|
||||
{{ if .IsOwner }}
|
||||
<div id="overview-edit" class="card bg-base-100 shadow-sm p-6 hidden">
|
||||
<!-- Write/Preview tabs -->
|
||||
<div class="border-b border-base-300 mb-4">
|
||||
<nav class="flex gap-0" role="tablist">
|
||||
<button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
|
||||
data-tab="write" onclick="switchEditorTab('write')">
|
||||
Write
|
||||
</button>
|
||||
<button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-base-content/60"
|
||||
data-tab="preview" onclick="switchEditorTab('preview')">
|
||||
Preview
|
||||
</button>
|
||||
</nav>
|
||||
{{ else }}
|
||||
{{ $p := index .SelectedTag.Info.Platforms 0 }}
|
||||
{{ if $p.OS }}
|
||||
<div id="platform-badges">
|
||||
<span class="badge badge-sm badge-outline font-mono">{{ $p.OS }}/{{ $p.Architecture }}{{ if $p.Variant }}/{{ $p.Variant }}{{ end }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Write panel -->
|
||||
<div id="editor-write" class="editor-panel">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-wrap gap-1 mb-2 p-1 bg-base-200 rounded-lg">
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('heading')" title="Heading">
|
||||
{{ icon "heading" "size-4" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('bold')" title="Bold">
|
||||
{{ icon "bold" "size-4" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('italic')" title="Italic">
|
||||
{{ icon "italic" "size-4" }}
|
||||
</button>
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('link')" title="Link">
|
||||
{{ icon "link" "size-4" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('image')" title="Image">
|
||||
{{ icon "image" "size-4" }}
|
||||
</button>
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ul')" title="Bulleted list">
|
||||
{{ icon "list" "size-4" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ol')" title="Numbered list">
|
||||
{{ icon "list-ordered" "size-4" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('code')" title="Code">
|
||||
{{ icon "code" "size-4" }}
|
||||
</button>
|
||||
</div>
|
||||
<textarea id="md-editor"
|
||||
class="textarea textarea-bordered w-full font-mono text-sm leading-relaxed"
|
||||
rows="20"
|
||||
placeholder="Write your repository description in Markdown...">{{ .RawDescription }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Preview panel -->
|
||||
<div id="editor-preview" class="editor-panel hidden">
|
||||
<div id="preview-content" class="prose prose-sm max-w-none min-h-[20rem] p-4 border border-base-300 rounded-lg">
|
||||
<p class="text-base-content/60">Nothing to preview</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button class="btn btn-sm btn-ghost" onclick="toggleOverviewEditor(false)">Cancel</button>
|
||||
<button class="btn btn-sm btn-primary" id="save-overview-btn" onclick="saveOverview()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var textarea = document.getElementById('md-editor');
|
||||
if (!textarea) return;
|
||||
|
||||
var ownerDID = {{ .Owner.DID }};
|
||||
var repoName = {{ .Repository.Name }};
|
||||
|
||||
window.toggleOverviewEditor = function(show) {
|
||||
document.getElementById('overview-view').classList.toggle('hidden', show);
|
||||
document.getElementById('overview-edit').classList.toggle('hidden', !show);
|
||||
if (show) textarea.focus();
|
||||
};
|
||||
|
||||
window.switchEditorTab = function(tab) {
|
||||
document.querySelectorAll('.editor-panel').forEach(function(p) { p.classList.add('hidden'); });
|
||||
document.getElementById(tab === 'write' ? 'editor-write' : 'editor-preview').classList.remove('hidden');
|
||||
|
||||
document.querySelectorAll('.editor-tab').forEach(function(t) {
|
||||
var active = t.dataset.tab === tab;
|
||||
t.classList.toggle('border-primary', active);
|
||||
t.classList.toggle('text-primary', active);
|
||||
t.classList.toggle('border-transparent', !active);
|
||||
t.classList.toggle('text-base-content/60', !active);
|
||||
});
|
||||
|
||||
if (tab === 'preview') {
|
||||
var content = textarea.value;
|
||||
var previewEl = document.getElementById('preview-content');
|
||||
if (!content.trim()) {
|
||||
previewEl.innerHTML = '<p class="text-base-content/60">Nothing to preview</p>';
|
||||
return;
|
||||
}
|
||||
var form = new FormData();
|
||||
form.append('markdown', content);
|
||||
fetch('/api/repo-page/preview', { method: 'POST', body: form })
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(html) { previewEl.innerHTML = html; });
|
||||
}
|
||||
};
|
||||
|
||||
window.insertMd = function(type) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
var selected = textarea.value.substring(start, end);
|
||||
var before = textarea.value.substring(0, start);
|
||||
var after = textarea.value.substring(end);
|
||||
var insert, cursorStart, cursorEnd;
|
||||
|
||||
switch (type) {
|
||||
case 'heading':
|
||||
insert = '## ' + (selected || 'Heading');
|
||||
cursorStart = start + 3;
|
||||
cursorEnd = start + insert.length;
|
||||
break;
|
||||
case 'bold':
|
||||
insert = '**' + (selected || 'bold text') + '**';
|
||||
cursorStart = start + 2;
|
||||
cursorEnd = start + insert.length - 2;
|
||||
break;
|
||||
case 'italic':
|
||||
insert = '_' + (selected || 'italic text') + '_';
|
||||
cursorStart = start + 1;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
break;
|
||||
case 'link':
|
||||
insert = '[' + (selected || 'link text') + '](url)';
|
||||
cursorStart = start + insert.length - 4;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
break;
|
||||
case 'image':
|
||||
insert = '';
|
||||
cursorStart = start + insert.length - 4;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
break;
|
||||
case 'ul':
|
||||
insert = '- ' + (selected || 'list item');
|
||||
cursorStart = start + 2;
|
||||
cursorEnd = start + insert.length;
|
||||
break;
|
||||
case 'ol':
|
||||
insert = '1. ' + (selected || 'list item');
|
||||
cursorStart = start + 3;
|
||||
cursorEnd = start + insert.length;
|
||||
break;
|
||||
case 'code':
|
||||
if (selected && selected.indexOf('\n') !== -1) {
|
||||
insert = '```\n' + selected + '\n```';
|
||||
cursorStart = start + 4;
|
||||
cursorEnd = start + 4 + selected.length;
|
||||
} else {
|
||||
insert = '`' + (selected || 'code') + '`';
|
||||
cursorStart = start + 1;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.value = before + insert + after;
|
||||
textarea.focus();
|
||||
textarea.selectionStart = cursorStart;
|
||||
textarea.selectionEnd = cursorEnd;
|
||||
};
|
||||
|
||||
window.saveOverview = function() {
|
||||
var btn = document.getElementById('save-overview-btn');
|
||||
btn.classList.add('btn-disabled');
|
||||
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Saving...';
|
||||
|
||||
var form = new FormData();
|
||||
form.append('did', ownerDID);
|
||||
form.append('repository', repoName);
|
||||
form.append('description', textarea.value);
|
||||
|
||||
fetch('/api/repo-page', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'HX-Request': 'true' }
|
||||
})
|
||||
.then(function(r) {
|
||||
if (!r.ok) return r.text().then(function(t) { throw new Error(t); });
|
||||
return r.text();
|
||||
})
|
||||
.then(function(html) {
|
||||
document.getElementById('overview-rendered').innerHTML = html;
|
||||
toggleOverviewEditor(false);
|
||||
if (typeof showToast === 'function') showToast('Overview saved', 'success');
|
||||
})
|
||||
.catch(function(err) {
|
||||
if (typeof showToast === 'function') showToast(err.message || 'Failed to save', 'error');
|
||||
})
|
||||
.finally(function() {
|
||||
btn.classList.remove('btn-disabled');
|
||||
btn.innerHTML = 'Save';
|
||||
});
|
||||
};
|
||||
|
||||
// Ctrl+S / Cmd+S to save
|
||||
textarea.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
saveOverview();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ if .SelectedTag.Info.HasAttestations }}
|
||||
<button class="badge badge-sm badge-soft badge-success cursor-pointer hover:opacity-80"
|
||||
hx-get="/api/attestation-details?digest={{ .SelectedTag.Info.Digest | urlquery }}&did={{ .Owner.DID | urlquery }}&repo={{ .Repository.Name | urlquery }}"
|
||||
hx-target="#attestation-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('attestation-detail-modal').showModal()">
|
||||
{{ icon "shield-check" "size-3" }} Attested
|
||||
</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<!-- Tags Panel -->
|
||||
<div id="tab-artifacts" class="repo-panel hidden">
|
||||
<div id="tags-content">
|
||||
<div class="flex justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<!-- Tag-Scoped Content (swapped via HTMX on tag change) -->
|
||||
{{ template "repo-tag-section" . }}
|
||||
|
||||
<!-- Inline Editor (hidden, owner only) — outside HTMX-swapped section so edits survive tag changes -->
|
||||
{{ if .IsOwner }}
|
||||
<div id="overview-edit" class="card bg-base-100 shadow-sm p-6 hidden">
|
||||
<!-- Write/Preview tabs -->
|
||||
<div class="border-b border-base-300 mb-4">
|
||||
<nav class="flex gap-0" role="tablist">
|
||||
<button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary"
|
||||
data-tab="write" onclick="switchEditorTab('write')">
|
||||
Write
|
||||
</button>
|
||||
<button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-base-content/60"
|
||||
data-tab="preview" onclick="switchEditorTab('preview')">
|
||||
Preview
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Write panel -->
|
||||
<div id="editor-write" class="editor-panel">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-wrap gap-1 mb-2 p-1 bg-base-200 rounded-lg">
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('heading')" title="Heading">
|
||||
{{ icon "heading" "size-4" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('bold')" title="Bold">
|
||||
{{ icon "bold" "size-4" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('italic')" title="Italic">
|
||||
{{ icon "italic" "size-4" }}
|
||||
</button>
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('link')" title="Link">
|
||||
{{ icon "link" "size-4" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('image')" title="Image">
|
||||
{{ icon "image" "size-4" }}
|
||||
</button>
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ul')" title="Bulleted list">
|
||||
{{ icon "list" "size-4" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('ol')" title="Numbered list">
|
||||
{{ icon "list-ordered" "size-4" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" onclick="insertMd('code')" title="Code">
|
||||
{{ icon "code" "size-4" }}
|
||||
</button>
|
||||
</div>
|
||||
<textarea id="md-editor"
|
||||
class="textarea textarea-bordered w-full font-mono text-sm leading-relaxed"
|
||||
rows="20"
|
||||
placeholder="Write your repository description in Markdown...">{{ .RawDescription }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Preview panel -->
|
||||
<div id="editor-preview" class="editor-panel hidden">
|
||||
<div id="preview-content" class="prose prose-sm max-w-none min-h-[20rem] p-4 border border-base-300 rounded-lg">
|
||||
<p class="text-base-content/60">Nothing to preview</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button class="btn btn-sm btn-ghost" onclick="toggleOverviewEditor(false)">Cancel</button>
|
||||
<button class="btn btn-sm btn-primary" id="save-overview-btn" onclick="saveOverview()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -412,73 +246,292 @@
|
||||
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
||||
</dialog>
|
||||
|
||||
{{ if .IsOwner }}
|
||||
<script>
|
||||
(function() {
|
||||
var validTabs = ['overview', 'artifacts'];
|
||||
var tagsLoading = false;
|
||||
var textarea = document.getElementById('md-editor');
|
||||
if (!textarea) return;
|
||||
|
||||
function loadTags() {
|
||||
if (tagsLoading) return;
|
||||
tagsLoading = true;
|
||||
var target = document.getElementById('tags-content');
|
||||
fetch('/api/repo-tags/{{ .Owner.Handle }}/{{ .Repository.Name }}')
|
||||
var ownerDID = {{ .Owner.DID }};
|
||||
var repoName = {{ .Repository.Name }};
|
||||
|
||||
window.toggleOverviewEditor = function(show) {
|
||||
document.getElementById('overview-view').classList.toggle('hidden', show);
|
||||
document.getElementById('overview-edit').classList.toggle('hidden', !show);
|
||||
if (show) textarea.focus();
|
||||
};
|
||||
|
||||
window.switchEditorTab = function(tab) {
|
||||
document.querySelectorAll('.editor-panel').forEach(function(p) { p.classList.add('hidden'); });
|
||||
document.getElementById(tab === 'write' ? 'editor-write' : 'editor-preview').classList.remove('hidden');
|
||||
|
||||
document.querySelectorAll('.editor-tab').forEach(function(t) {
|
||||
var active = t.dataset.tab === tab;
|
||||
t.classList.toggle('border-primary', active);
|
||||
t.classList.toggle('text-primary', active);
|
||||
t.classList.toggle('border-transparent', !active);
|
||||
t.classList.toggle('text-base-content/60', !active);
|
||||
});
|
||||
|
||||
if (tab === 'preview') {
|
||||
var content = textarea.value;
|
||||
var previewEl = document.getElementById('preview-content');
|
||||
if (!content.trim()) {
|
||||
previewEl.innerHTML = '<p class="text-base-content/60">Nothing to preview</p>';
|
||||
return;
|
||||
}
|
||||
var form = new FormData();
|
||||
form.append('markdown', content);
|
||||
fetch('/api/repo-page/preview', { method: 'POST', body: form })
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(html) { previewEl.innerHTML = html; });
|
||||
}
|
||||
};
|
||||
|
||||
window.insertMd = function(type) {
|
||||
var start = textarea.selectionStart;
|
||||
var end = textarea.selectionEnd;
|
||||
var selected = textarea.value.substring(start, end);
|
||||
var before = textarea.value.substring(0, start);
|
||||
var after = textarea.value.substring(end);
|
||||
var insert, cursorStart, cursorEnd;
|
||||
|
||||
switch (type) {
|
||||
case 'heading':
|
||||
insert = '## ' + (selected || 'Heading');
|
||||
cursorStart = start + 3;
|
||||
cursorEnd = start + insert.length;
|
||||
break;
|
||||
case 'bold':
|
||||
insert = '**' + (selected || 'bold text') + '**';
|
||||
cursorStart = start + 2;
|
||||
cursorEnd = start + insert.length - 2;
|
||||
break;
|
||||
case 'italic':
|
||||
insert = '_' + (selected || 'italic text') + '_';
|
||||
cursorStart = start + 1;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
break;
|
||||
case 'link':
|
||||
insert = '[' + (selected || 'link text') + '](url)';
|
||||
cursorStart = start + insert.length - 4;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
break;
|
||||
case 'image':
|
||||
insert = '';
|
||||
cursorStart = start + insert.length - 4;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
break;
|
||||
case 'ul':
|
||||
insert = '- ' + (selected || 'list item');
|
||||
cursorStart = start + 2;
|
||||
cursorEnd = start + insert.length;
|
||||
break;
|
||||
case 'ol':
|
||||
insert = '1. ' + (selected || 'list item');
|
||||
cursorStart = start + 3;
|
||||
cursorEnd = start + insert.length;
|
||||
break;
|
||||
case 'code':
|
||||
if (selected && selected.indexOf('\n') !== -1) {
|
||||
insert = '```\n' + selected + '\n```';
|
||||
cursorStart = start + 4;
|
||||
cursorEnd = start + 4 + selected.length;
|
||||
} else {
|
||||
insert = '`' + (selected || 'code') + '`';
|
||||
cursorStart = start + 1;
|
||||
cursorEnd = start + insert.length - 1;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.value = before + insert + after;
|
||||
textarea.focus();
|
||||
textarea.selectionStart = cursorStart;
|
||||
textarea.selectionEnd = cursorEnd;
|
||||
};
|
||||
|
||||
window.saveOverview = function() {
|
||||
var btn = document.getElementById('save-overview-btn');
|
||||
btn.classList.add('btn-disabled');
|
||||
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Saving...';
|
||||
|
||||
var form = new FormData();
|
||||
form.append('did', ownerDID);
|
||||
form.append('repository', repoName);
|
||||
form.append('description', textarea.value);
|
||||
|
||||
fetch('/api/repo-page', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'HX-Request': 'true' }
|
||||
})
|
||||
.then(function(r) {
|
||||
if (!r.ok) return r.text().then(function(t) { throw new Error(t); });
|
||||
return r.text();
|
||||
})
|
||||
.then(function(html) {
|
||||
document.getElementById('overview-rendered').innerHTML = html;
|
||||
toggleOverviewEditor(false);
|
||||
if (typeof showToast === 'function') showToast('Overview saved', 'success');
|
||||
})
|
||||
.catch(function(err) {
|
||||
if (typeof showToast === 'function') showToast(err.message || 'Failed to save', 'error');
|
||||
})
|
||||
.finally(function() {
|
||||
btn.classList.remove('btn-disabled');
|
||||
btn.innerHTML = 'Save';
|
||||
});
|
||||
};
|
||||
|
||||
// Ctrl+S / Cmd+S to save
|
||||
textarea.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
saveOverview();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
<script>
|
||||
// Global helpers (tags sort/filter)
|
||||
window.sortTags = function(method) {
|
||||
var container = document.getElementById('tags-list');
|
||||
if (!container) return;
|
||||
var entries = Array.from(container.querySelectorAll('.artifact-entry'));
|
||||
entries.sort(function(a, b) {
|
||||
switch (method) {
|
||||
case 'oldest': return parseInt(a.dataset.created) - parseInt(b.dataset.created);
|
||||
case 'az': return a.dataset.tag.localeCompare(b.dataset.tag);
|
||||
case 'za': return b.dataset.tag.localeCompare(a.dataset.tag);
|
||||
default: return parseInt(b.dataset.created) - parseInt(a.dataset.created);
|
||||
}
|
||||
});
|
||||
entries.forEach(function(el) { container.appendChild(el); });
|
||||
};
|
||||
|
||||
window.filterTags = function(query) {
|
||||
var q = query.toLowerCase();
|
||||
document.querySelectorAll('#tags-list .artifact-entry').forEach(function(el) {
|
||||
el.style.display = (!q || el.dataset.tag.toLowerCase().includes(q)) ? '' : 'none';
|
||||
});
|
||||
};
|
||||
|
||||
// Tab controller — reads config from #tag-content data attributes.
|
||||
// Re-runs on initial load and after every HTMX swap of #tag-content.
|
||||
(function() {
|
||||
var validTabs = ['overview', 'layers', 'vulns', 'sbom', 'artifacts'];
|
||||
var loaded = {};
|
||||
|
||||
function lazyLoad(id, url) {
|
||||
if (loaded[id]) return;
|
||||
loaded[id] = true;
|
||||
var target = document.getElementById(id);
|
||||
if (!target) return;
|
||||
fetch(url)
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(html) {
|
||||
target.innerHTML = html;
|
||||
htmx.process(target);
|
||||
// innerHTML doesn't execute <script> tags — re-create them
|
||||
target.querySelectorAll('script').forEach(function(old) {
|
||||
var s = document.createElement('script');
|
||||
s.textContent = old.textContent;
|
||||
old.parentNode.replaceChild(s, old);
|
||||
});
|
||||
if (typeof htmx !== 'undefined') htmx.process(target);
|
||||
});
|
||||
}
|
||||
|
||||
function contentUrl(section) {
|
||||
var el = document.getElementById('tag-content');
|
||||
if (!el) return null;
|
||||
var owner = el.dataset.owner;
|
||||
var repo = el.dataset.repo;
|
||||
var digest = el.dataset.digest;
|
||||
if (!digest) return null;
|
||||
return '/api/digest-content/' + owner + '/' + repo + '?digest=' + encodeURIComponent(digest) + '§ion=' + section;
|
||||
}
|
||||
|
||||
function tagsUrl() {
|
||||
var el = document.getElementById('tag-content');
|
||||
if (!el) return null;
|
||||
return '/api/repo-tags/' + el.dataset.owner + '/' + el.dataset.repo;
|
||||
}
|
||||
|
||||
window.switchRepoTab = function(tabId) {
|
||||
document.querySelectorAll('.repo-panel').forEach(function(p) {
|
||||
window._activeRepoTab = tabId;
|
||||
var section = document.getElementById('tag-content');
|
||||
if (!section) return;
|
||||
|
||||
section.querySelectorAll('.repo-panel').forEach(function(p) {
|
||||
p.classList.add('hidden');
|
||||
});
|
||||
var panel = document.getElementById('tab-' + tabId);
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
|
||||
document.querySelectorAll('.repo-tab').forEach(function(tab) {
|
||||
section.querySelectorAll('.repo-tab').forEach(function(tab) {
|
||||
if (tab.dataset.tab === tabId) {
|
||||
tab.classList.add('border-primary', 'text-primary');
|
||||
tab.classList.remove('border-transparent', 'text-base-content/60', 'hover:text-base-content');
|
||||
tab.classList.remove('border-transparent', 'text-base-content/60');
|
||||
} else {
|
||||
tab.classList.remove('border-primary', 'text-primary');
|
||||
tab.classList.add('border-transparent', 'text-base-content/60', 'hover:text-base-content');
|
||||
tab.classList.add('border-transparent', 'text-base-content/60');
|
||||
}
|
||||
});
|
||||
|
||||
history.replaceState(null, '', '#' + tabId);
|
||||
if (tabId === 'artifacts') loadTags();
|
||||
var url = new URL(window.location);
|
||||
url.hash = tabId;
|
||||
history.replaceState(null, '', url.toString());
|
||||
|
||||
if (tabId === 'artifacts') { var u = tagsUrl(); if (u) lazyLoad('artifacts-content', u); }
|
||||
if (tabId === 'layers') { var u = contentUrl('layers'); if (u) lazyLoad('layers-content', u); }
|
||||
if (tabId === 'vulns') { var u = contentUrl('vulns'); if (u) lazyLoad('vulns-content', u); }
|
||||
if (tabId === 'sbom') { var u = contentUrl('sbom'); if (u) lazyLoad('sbom-content', u); }
|
||||
};
|
||||
|
||||
window.sortTags = function(method) {
|
||||
var container = document.getElementById('tags-list');
|
||||
if (!container) return;
|
||||
var entries = Array.from(container.querySelectorAll('.artifact-entry'));
|
||||
entries.sort(function(a, b) {
|
||||
switch (method) {
|
||||
case 'oldest': return parseInt(a.dataset.created) - parseInt(b.dataset.created);
|
||||
case 'az': return a.dataset.tag.localeCompare(b.dataset.tag);
|
||||
case 'za': return b.dataset.tag.localeCompare(a.dataset.tag);
|
||||
default: return parseInt(b.dataset.created) - parseInt(a.dataset.created);
|
||||
}
|
||||
});
|
||||
entries.forEach(function(el) { container.appendChild(el); });
|
||||
};
|
||||
function initTabs() {
|
||||
// Reset lazy-load tracking (new tag = new content)
|
||||
loaded = {};
|
||||
|
||||
window.filterTags = function(query) {
|
||||
var q = query.toLowerCase();
|
||||
document.querySelectorAll('#tags-list .artifact-entry').forEach(function(el) {
|
||||
el.style.display = (!q || el.dataset.tag.toLowerCase().includes(q)) ? '' : 'none';
|
||||
});
|
||||
};
|
||||
// Prefetch on hover
|
||||
var tagsBtn = document.getElementById('artifacts-tab-btn');
|
||||
if (tagsBtn) tagsBtn.addEventListener('mouseenter', function() { var u = tagsUrl(); if (u) lazyLoad('artifacts-content', u); }, { once: true });
|
||||
var layersBtn = document.getElementById('layers-tab-btn');
|
||||
if (layersBtn) layersBtn.addEventListener('mouseenter', function() { var u = contentUrl('layers'); if (u) lazyLoad('layers-content', u); }, { once: true });
|
||||
var vulnsBtn = document.getElementById('vulns-tab-btn');
|
||||
if (vulnsBtn) vulnsBtn.addEventListener('mouseenter', function() { var u = contentUrl('vulns'); if (u) lazyLoad('vulns-content', u); }, { once: true });
|
||||
var sbomBtn = document.getElementById('sbom-tab-btn');
|
||||
if (sbomBtn) sbomBtn.addEventListener('mouseenter', function() { var u = contentUrl('sbom'); if (u) lazyLoad('sbom-content', u); }, { once: true });
|
||||
|
||||
// Prefetch on hover
|
||||
document.getElementById('artifacts-tab-btn').addEventListener('mouseenter', loadTags, { once: true });
|
||||
// Pick tab: persisted > hash > default
|
||||
var initTab = window._activeRepoTab || window.location.hash.replace('#', '') || 'overview';
|
||||
if (validTabs.indexOf(initTab) === -1) initTab = 'overview';
|
||||
switchRepoTab(initTab);
|
||||
}
|
||||
|
||||
// Initialize tab from hash
|
||||
var hash = window.location.hash.replace('#', '') || 'overview';
|
||||
if (validTabs.indexOf(hash) === -1) hash = 'overview';
|
||||
switchRepoTab(hash);
|
||||
// Run on initial page load
|
||||
initTabs();
|
||||
|
||||
// Keyboard shortcuts: first letter of each tab name
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' ||
|
||||
e.target.tagName === 'SELECT' || e.target.isContentEditable) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
var map = { o: 'overview', l: 'layers', v: 'vulns', s: 'sbom', a: 'artifacts' };
|
||||
var tab = map[e.key.toLowerCase()];
|
||||
if (tab && validTabs.indexOf(tab) !== -1) switchRepoTab(tab);
|
||||
});
|
||||
|
||||
// Re-run after HTMX swaps tag section content (tag dropdown change)
|
||||
document.body.addEventListener('htmx:afterSettle', function(evt) {
|
||||
if (evt.detail.target && evt.detail.target.id === 'tag-content') {
|
||||
initTabs();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="user">
|
||||
{{ icon "user" "size-4" }} User
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="billing">
|
||||
{{ icon "credit-card" "size-4" }} Billing
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage">
|
||||
{{ icon "hard-drive" "size-4" }} Storage
|
||||
</button>
|
||||
@@ -41,6 +44,7 @@
|
||||
<aside class="hidden lg:block w-56 shrink-0">
|
||||
<ul class="menu bg-base-200 rounded-box w-full">
|
||||
<li data-tab="user"><a href="#user">{{ icon "user" "size-4" }} User</a></li>
|
||||
<li data-tab="billing"><a href="#billing">{{ icon "credit-card" "size-4" }} Billing</a></li>
|
||||
<li data-tab="storage"><a href="#storage">{{ icon "hard-drive" "size-4" }} Storage</a></li>
|
||||
<li data-tab="devices"><a href="#devices">{{ icon "terminal" "size-4" }} Devices</a></li>
|
||||
<li data-tab="webhooks"><a href="#webhooks">{{ icon "webhook" "size-4" }} Webhooks</a></li>
|
||||
@@ -79,14 +83,39 @@
|
||||
<option value="crane"{{ if eq $oci "crane" }} selected{{ end }}>crane</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- AI Image Advisor Toggle -->
|
||||
{{ if .AIAdvisorEnabled }}
|
||||
<div class="divider my-2"></div>
|
||||
<div class="flex items-start gap-3">
|
||||
{{ if .Profile.HasAIAdvisorAccess }}
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" class="toggle toggle-primary mt-0.5"
|
||||
hx-post="/api/profile/ai-advisor"
|
||||
hx-trigger="change"
|
||||
hx-swap="none"
|
||||
{{ if .Profile.AIAdvisorEnabled }}checked{{ end }}>
|
||||
<div>
|
||||
<span class="font-medium">AI Image Advisor</span>
|
||||
<p class="text-xs text-base-content/60">Analyze your container images for optimization suggestions using AI.</p>
|
||||
</div>
|
||||
</label>
|
||||
{{ else }}
|
||||
<div>
|
||||
<span class="font-medium text-base-content/50">AI Image Advisor</span>
|
||||
<p class="text-xs text-base-content/50">Analyze your container images for optimization suggestions using AI.</p>
|
||||
<p class="text-xs text-primary mt-1">
|
||||
<a href="/settings#billing" onclick="switchSettingsTab('billing')">Upgrade your plan</a> to enable this feature.
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- STORAGE TAB -->
|
||||
<div id="tab-storage" class="settings-panel hidden space-y-4">
|
||||
<!-- Available Plans -->
|
||||
{{ template "subscription_plans" .Subscription }}
|
||||
|
||||
<!-- Holds -->
|
||||
{{ if .AllHolds }}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
@@ -132,6 +161,11 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- BILLING TAB -->
|
||||
<div id="tab-billing" class="settings-panel hidden space-y-4">
|
||||
{{ template "subscription_plans" .Subscription }}
|
||||
</div>
|
||||
|
||||
<!-- DEVICES TAB -->
|
||||
<div id="tab-devices" class="settings-panel hidden space-y-6">
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-6">
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var showEmpty = localStorage.getItem('showEmptyLayers') !== 'false';
|
||||
var showEmpty = localStorage.getItem('showEmptyLayers') === 'true';
|
||||
document.querySelectorAll('.show-empty-layers-cb').forEach(function(cb) { cb.checked = showEmpty; });
|
||||
|
||||
window.toggleEmptyLayers = function(show) {
|
||||
@@ -105,7 +105,7 @@
|
||||
}
|
||||
|
||||
function applyLayerVisibility() {
|
||||
var show = localStorage.getItem('showEmptyLayers') !== 'false';
|
||||
var show = localStorage.getItem('showEmptyLayers') === 'true';
|
||||
document.querySelectorAll('.layers-table tr[data-empty="true"]').forEach(function(row) {
|
||||
row.style.display = show ? '' : 'none';
|
||||
});
|
||||
|
||||
75
pkg/appview/templates/partials/image-advisor-results.html
Normal file
75
pkg/appview/templates/partials/image-advisor-results.html
Normal file
@@ -0,0 +1,75 @@
|
||||
{{ define "image-advisor-results" }}
|
||||
{{ if eq .Error "upgrade_required" }}
|
||||
<div class="alert alert-info text-sm">
|
||||
{{ icon "sparkles" "size-4" }}
|
||||
<span>AI Image Advisor is a paid feature. <a href="/settings#billing" class="link link-primary font-medium">Upgrade your plan</a> to unlock image analysis.</span>
|
||||
</div>
|
||||
{{ else if .Error }}
|
||||
<div class="alert alert-warning text-sm">
|
||||
{{ icon "alert-triangle" "size-4" }}
|
||||
<span>{{ .Error }}</span>
|
||||
</div>
|
||||
{{ else if .Suggestions }}
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300">
|
||||
<div class="card-body p-4 space-y-3">
|
||||
<h3 class="text-sm font-semibold flex items-center gap-2">
|
||||
{{ icon "sparkle" "size-4" }}
|
||||
AI Suggestions ({{ len .Suggestions }})
|
||||
</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-xs w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Category</th>
|
||||
<th>Impact</th>
|
||||
<th>Effort</th>
|
||||
<th class="w-1/2">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Suggestions }}
|
||||
<tr>
|
||||
<td class="font-medium text-sm">{{ .Action }}</td>
|
||||
<td>
|
||||
<span class="badge badge-sm badge-ghost whitespace-nowrap">{{ .Category }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ if eq .Impact "high" }}
|
||||
<span class="badge badge-sm badge-error">high</span>
|
||||
{{ else if eq .Impact "medium" }}
|
||||
<span class="badge badge-sm badge-warning">medium</span>
|
||||
{{ else }}
|
||||
<span class="badge badge-sm badge-info">low</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td>
|
||||
{{ if eq .Effort "low" }}
|
||||
<span class="badge badge-sm badge-success">low</span>
|
||||
{{ else if eq .Effort "medium" }}
|
||||
<span class="badge badge-sm badge-warning">medium</span>
|
||||
{{ else }}
|
||||
<span class="badge badge-sm badge-ghost">high</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="text-xs max-w-xs">
|
||||
{{ .Detail }}
|
||||
{{ if gt .CVEsFixed 0 }}
|
||||
<span class="badge badge-xs badge-outline badge-error ml-1">{{ .CVEsFixed }} CVEs</span>
|
||||
{{ end }}
|
||||
{{ if gt .SizeSavedMB 0 }}
|
||||
<span class="badge badge-xs badge-outline badge-info ml-1">-{{ .SizeSavedMB }}MB</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs opacity-50">Generated by Claude Haiku. Suggestions are advisory only.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="text-sm opacity-60">No suggestions generated.</p>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
119
pkg/appview/templates/partials/layers-section.html
Normal file
119
pkg/appview/templates/partials/layers-section.html
Normal file
@@ -0,0 +1,119 @@
|
||||
{{ define "layers-section" }}
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Layers ({{ len .Layers }})</h2>
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" class="checkbox checkbox-xs show-empty-layers-cb" onchange="toggleEmptyLayers(this.checked)">
|
||||
<span>Show empty layers</span>
|
||||
</label>
|
||||
</div>
|
||||
{{ if .Layers }}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-xs w-full layers-table">
|
||||
<thead>
|
||||
<tr class="text-xs">
|
||||
<th class="w-8">#</th>
|
||||
<th>Command</th>
|
||||
<th class="text-right w-24">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Layers }}
|
||||
<tr data-empty="{{ .EmptyLayer }}" data-no-command="{{ and (not .Command) (not .EmptyLayer) }}">
|
||||
<td class="font-mono text-xs">{{ .Index }}</td>
|
||||
<td>
|
||||
{{ if .Command }}
|
||||
<code class="font-mono text-xs break-all line-clamp-2" title="{{ .Command }}">{{ .Command }}</code>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="text-right text-sm whitespace-nowrap">{{ humanizeBytes .Size }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var showEmpty = localStorage.getItem('showEmptyLayers') === 'true';
|
||||
document.querySelectorAll('.show-empty-layers-cb').forEach(function(cb) { cb.checked = showEmpty; });
|
||||
|
||||
window.toggleEmptyLayers = function(show) {
|
||||
localStorage.setItem('showEmptyLayers', show);
|
||||
document.querySelectorAll('.show-empty-layers-cb').forEach(function(cb) { cb.checked = show; });
|
||||
applyLayerVisibility();
|
||||
};
|
||||
|
||||
function collapseNoHistoryLayers() {
|
||||
document.querySelectorAll('.layers-table').forEach(function(table) {
|
||||
var tbody = table.querySelector('tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
var rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
var i = 0;
|
||||
while (i < rows.length) {
|
||||
if (rows[i].dataset.noCommand === 'true') {
|
||||
var start = i;
|
||||
while (i < rows.length && rows[i].dataset.noCommand === 'true') {
|
||||
rows[i].classList.add('no-history-row', 'hidden');
|
||||
i++;
|
||||
}
|
||||
var count = i - start;
|
||||
if (count > 1) {
|
||||
var totalBytes = 0;
|
||||
for (var k = start; k < start + count; k++) {
|
||||
var sizeCell = rows[k].querySelector('td:last-child');
|
||||
if (sizeCell) {
|
||||
var txt = sizeCell.textContent.trim();
|
||||
var match = txt.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i);
|
||||
if (match) {
|
||||
var val = parseFloat(match[1]);
|
||||
var unit = match[2].toUpperCase();
|
||||
var multipliers = {'B':1,'KB':1024,'MB':1048576,'GB':1073741824,'TB':1099511627776};
|
||||
totalBytes += val * (multipliers[unit] || 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
var sizeStr = '';
|
||||
if (totalBytes < 1024) sizeStr = totalBytes + ' B';
|
||||
else if (totalBytes < 1048576) sizeStr = (totalBytes/1024).toFixed(1) + ' KB';
|
||||
else if (totalBytes < 1073741824) sizeStr = (totalBytes/1048576).toFixed(1) + ' MB';
|
||||
else sizeStr = (totalBytes/1073741824).toFixed(1) + ' GB';
|
||||
|
||||
var startIdx = rows[start].querySelector('td').textContent.trim();
|
||||
var endIdx = rows[i - 1].querySelector('td').textContent.trim();
|
||||
var summary = document.createElement('tr');
|
||||
summary.className = 'no-history-summary cursor-pointer hover:bg-base-200';
|
||||
summary.innerHTML = '<td colspan="2" class="text-sm py-2">Layers ' + startIdx + '-' + endIdx + ' contain no history <span class="text-xs ml-2">(' + count + ' layers, click to expand)</span></td><td class="text-right text-sm whitespace-nowrap">' + sizeStr + '</td>';
|
||||
summary.onclick = function() {
|
||||
summary.remove();
|
||||
for (var j = start; j < start + count; j++) {
|
||||
rows[j].classList.remove('hidden');
|
||||
}
|
||||
};
|
||||
tbody.insertBefore(summary, rows[start]);
|
||||
} else {
|
||||
rows[start].classList.remove('hidden');
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyLayerVisibility() {
|
||||
var show = localStorage.getItem('showEmptyLayers') === 'true';
|
||||
document.querySelectorAll('.layers-table tr[data-empty="true"]').forEach(function(row) {
|
||||
row.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
collapseNoHistoryLayers();
|
||||
applyLayerVisibility();
|
||||
})();
|
||||
</script>
|
||||
{{ else }}
|
||||
<p class="text-base-content">No layer information available</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
210
pkg/appview/templates/partials/repo-tag-section.html
Normal file
210
pkg/appview/templates/partials/repo-tag-section.html
Normal file
@@ -0,0 +1,210 @@
|
||||
{{ define "repo-tag-section" }}
|
||||
<div id="tag-content" data-owner="{{ .Owner.Handle }}" data-repo="{{ .Repository.Name }}"{{ if .SelectedTag }} data-digest="{{ if .SelectedTag.Info.IsMultiArch }}{{ (index .SelectedTag.Info.Platforms 0).Digest }}{{ else }}{{ .SelectedTag.Info.Digest }}{{ end }}"{{ end }}>
|
||||
{{ if .SelectedTag }}
|
||||
<!-- Pull Command with Client Switcher -->
|
||||
{{ template "pull-command-switcher" (dict "RegistryURL" .RegistryURL "OwnerHandle" .Owner.Handle "RepoName" .Repository.Name "Tag" .SelectedTag.Info.Tag.Tag "ArtifactType" .ArtifactType "OciClient" .OciClient "IsLoggedIn" (ne .User nil)) }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<!-- Size & Layers -->
|
||||
<div class="card bg-base-100 border border-base-300 p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Image Size</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Layers</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-lg font-bold">{{ humanizeBytes .SelectedTag.CompressedSize }}</span>
|
||||
<span class="text-lg font-bold">{{ .SelectedTag.LayerCount }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-base-content/50 mt-1">
|
||||
Pushed {{ timeAgoShort .SelectedTag.Info.CreatedAt }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vulnerabilities (first platform only for summary) -->
|
||||
<div class="card bg-base-100 border border-base-300 p-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-base-content/50 mb-2">Vulnerabilities</div>
|
||||
<div id="vuln-summary-card">
|
||||
{{ $firstPlatform := index .SelectedTag.Info.Platforms 0 }}
|
||||
<span id="scan-badge-{{ trimPrefix "sha256:" $firstPlatform.Digest }}"></span>
|
||||
<span id="vuln-loading-text" class="text-sm text-base-content/40">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pulls -->
|
||||
<div class="card bg-base-100 border border-base-300 p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Pulls</span>
|
||||
</div>
|
||||
<div class="text-lg font-bold">{{ .Stats.PullCount }} <span class="text-sm font-normal text-base-content/50">total</span></div>
|
||||
<div class="text-xs text-base-content/50 mt-1">
|
||||
{{ if .Stats.LastPull }}Last pull {{ timeAgoShort (derefTime .Stats.LastPull) }}{{ else }}No pulls yet{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scan batch triggers for selected tag -->
|
||||
{{ range .SelectedTag.ScanBatchParams }}
|
||||
<div hx-get="/api/scan-results?{{ . }}"
|
||||
hx-trigger="load delay:500ms"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="var el=document.getElementById('vuln-loading-text');if(el)el.remove()"
|
||||
style="display:none"></div>
|
||||
{{ end }}
|
||||
|
||||
{{ else }}
|
||||
<!-- No tags exist -->
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<p class="text-lg">No tags yet</p>
|
||||
<p class="text-sm mt-1">Push an image to get started.</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-base-300 mt-6">
|
||||
<nav class="flex gap-0" role="tablist">
|
||||
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
|
||||
data-tab="overview"
|
||||
role="tab"
|
||||
onclick="switchRepoTab('overview')">
|
||||
Overview
|
||||
</button>
|
||||
{{ if .SelectedTag }}
|
||||
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
|
||||
data-tab="layers"
|
||||
role="tab"
|
||||
id="layers-tab-btn"
|
||||
onclick="switchRepoTab('layers')">
|
||||
Layers
|
||||
</button>
|
||||
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
|
||||
data-tab="vulns"
|
||||
role="tab"
|
||||
id="vulns-tab-btn"
|
||||
onclick="switchRepoTab('vulns')">
|
||||
Vulnerabilities
|
||||
</button>
|
||||
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
|
||||
data-tab="sbom"
|
||||
role="tab"
|
||||
id="sbom-tab-btn"
|
||||
onclick="switchRepoTab('sbom')">
|
||||
SBOM
|
||||
</button>
|
||||
{{ end }}
|
||||
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer"
|
||||
data-tab="artifacts"
|
||||
role="tab"
|
||||
id="artifacts-tab-btn"
|
||||
onclick="switchRepoTab('artifacts')">
|
||||
Artifacts
|
||||
</button>
|
||||
{{ if and .SelectedTag (gt (len .AllTags) 1) }}
|
||||
<div class="ml-auto flex items-center">
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm gap-1">
|
||||
{{ icon "git-compare" "size-4" }} Diff
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-10 w-56 p-2 shadow max-h-60 overflow-y-auto">
|
||||
{{ range .AllTags }}
|
||||
{{ if ne . $.SelectedTag.Info.Tag.Tag }}
|
||||
<li><a href="/diff/{{ $.Owner.Handle }}/{{ $.Repository.Name }}?from={{ $.SelectedTag.Info.Digest }}&to={{ . }}">{{ . }}</a></li>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Panels -->
|
||||
<div id="tab-overview" class="repo-panel">
|
||||
<div id="overview-view" class="card bg-base-100 shadow-sm p-6 space-y-4 min-w-0">
|
||||
{{ if and .AIAdvisorEnabled .User .IsOwner .SelectedTag }}
|
||||
<div id="ai-advisor-section">
|
||||
<button id="ai-advisor-btn" class="btn btn-sm btn-outline gap-1"
|
||||
hx-get="/api/image-advisor/{{ .Owner.Handle }}/{{ .Repository.Name }}?digest={{ if .SelectedTag.Info.IsMultiArch }}{{ (index .SelectedTag.Info.Platforms 0).Digest }}{{ else }}{{ .SelectedTag.Info.Digest }}{{ end }}"
|
||||
hx-target="#ai-advisor-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#ai-advisor-spinner"
|
||||
hx-on::after-request="this.disabled=true">
|
||||
{{ icon "sparkles" "size-4" }}
|
||||
Analyze Image
|
||||
</button>
|
||||
<span id="ai-advisor-spinner" class="htmx-indicator">
|
||||
{{ icon "loader" "size-4 animate-spin" }}
|
||||
</span>
|
||||
<div id="ai-advisor-results" class="mt-4"></div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if and .IsOwner .ReadmeHTML }}
|
||||
<div class="flex justify-end">
|
||||
<button class="btn btn-sm btn-ghost gap-1" onclick="toggleOverviewEditor(true)">
|
||||
{{ icon "pencil" "size-4" }}
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div id="overview-rendered" class="prose prose-sm max-w-none">
|
||||
{{ if .ReadmeHTML }}
|
||||
{{ .ReadmeHTML }}
|
||||
{{ else }}
|
||||
{{ if .IsOwner }}
|
||||
<div class="text-center py-12">
|
||||
{{ icon "file-text" "size-12 text-base-content/20 mx-auto" }}
|
||||
<p class="text-base-content/60 mt-4">No README provided</p>
|
||||
<p class="text-base-content/40 text-sm mt-1">Add a README to help users understand this image.</p>
|
||||
<button class="btn btn-primary btn-sm mt-4" onclick="toggleOverviewEditor(true)">
|
||||
{{ icon "pencil" "size-4" }} Add README
|
||||
</button>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="text-center py-12">
|
||||
{{ icon "file-text" "size-12 text-base-content/20 mx-auto" }}
|
||||
<p class="text-base-content/60 mt-4">No README provided</p>
|
||||
<p class="text-base-content/40 text-sm mt-1">Image metadata is shown above.</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ if .SelectedTag }}
|
||||
<div id="tab-layers" class="repo-panel hidden">
|
||||
<div id="layers-content">
|
||||
<div class="flex justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-vulns" class="repo-panel hidden">
|
||||
<div id="vulns-content">
|
||||
<div class="flex justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-sbom" class="repo-panel hidden">
|
||||
<div id="sbom-content">
|
||||
<div class="flex justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div id="tab-artifacts" class="repo-panel hidden">
|
||||
<div id="artifacts-content">
|
||||
<div class="flex justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
9
pkg/appview/templates/partials/sbom-section.html
Normal file
9
pkg/appview/templates/partials/sbom-section.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{{ define "sbom-section" }}
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
{{ if .SbomData }}
|
||||
{{ template "sbom-details" .SbomData }}
|
||||
{{ else }}
|
||||
<p class="text-base-content">No SBOM data available</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
9
pkg/appview/templates/partials/vulns-section.html
Normal file
9
pkg/appview/templates/partials/vulns-section.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{{ define "vulns-section" }}
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
||||
{{ if .VulnData }}
|
||||
{{ template "vuln-details" .VulnData }}
|
||||
{{ else }}
|
||||
<p class="text-base-content">No vulnerability scan data available</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -222,6 +222,13 @@ func Templates(overrides *BrandingOverrides) (*template.Template, error) {
|
||||
return licenses.ParseLicenses(licensesStr)
|
||||
},
|
||||
|
||||
"derefTime": func(t *time.Time) time.Time {
|
||||
if t == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return *t
|
||||
},
|
||||
|
||||
"sub": func(a, b int) int {
|
||||
return a - b
|
||||
},
|
||||
|
||||
@@ -1843,6 +1843,338 @@ func (t *StatsRecord) UnmarshalCBOR(r io.Reader) (err error) {
|
||||
|
||||
return nil
|
||||
}
|
||||
func (t *DailyStatsRecord) MarshalCBOR(w io.Writer) error {
|
||||
if t == nil {
|
||||
_, err := w.Write(cbg.CborNull)
|
||||
return err
|
||||
}
|
||||
|
||||
cw := cbg.NewCborWriter(w)
|
||||
|
||||
if _, err := cw.Write([]byte{167}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// t.Date (string) (string)
|
||||
if len("date") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"date\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("date"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("date")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(t.Date) > 8192 {
|
||||
return xerrors.Errorf("Value in field t.Date was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Date))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string(t.Date)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// t.Type (string) (string)
|
||||
if len("$type") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"$type\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("$type")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(t.Type) > 8192 {
|
||||
return xerrors.Errorf("Value in field t.Type was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string(t.Type)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// t.OwnerDID (string) (string)
|
||||
if len("ownerDid") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"ownerDid\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("ownerDid"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("ownerDid")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(t.OwnerDID) > 8192 {
|
||||
return xerrors.Errorf("Value in field t.OwnerDID was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.OwnerDID))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string(t.OwnerDID)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// t.PullCount (int64) (int64)
|
||||
if len("pullCount") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"pullCount\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pullCount"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("pullCount")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if t.PullCount >= 0 {
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.PullCount)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.PullCount-1)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// t.PushCount (int64) (int64)
|
||||
if len("pushCount") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"pushCount\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pushCount"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("pushCount")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if t.PushCount >= 0 {
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.PushCount)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.PushCount-1)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// t.UpdatedAt (string) (string)
|
||||
if len("updatedAt") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"updatedAt\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("updatedAt"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("updatedAt")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(t.UpdatedAt) > 8192 {
|
||||
return xerrors.Errorf("Value in field t.UpdatedAt was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.UpdatedAt))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string(t.UpdatedAt)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// t.Repository (string) (string)
|
||||
if len("repository") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"repository\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repository"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("repository")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(t.Repository) > 8192 {
|
||||
return xerrors.Errorf("Value in field t.Repository was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repository))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string(t.Repository)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *DailyStatsRecord) UnmarshalCBOR(r io.Reader) (err error) {
|
||||
*t = DailyStatsRecord{}
|
||||
|
||||
cr := cbg.NewCborReader(r)
|
||||
|
||||
maj, extra, err := cr.ReadHeader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err == io.EOF {
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
}()
|
||||
|
||||
if maj != cbg.MajMap {
|
||||
return fmt.Errorf("cbor input should be of type map")
|
||||
}
|
||||
|
||||
if extra > cbg.MaxLength {
|
||||
return fmt.Errorf("DailyStatsRecord: map struct too large (%d)", extra)
|
||||
}
|
||||
|
||||
n := extra
|
||||
|
||||
nameBuf := make([]byte, 10)
|
||||
for i := uint64(0); i < n; i++ {
|
||||
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !ok {
|
||||
// Field doesn't exist on this type, so ignore it
|
||||
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch string(nameBuf[:nameLen]) {
|
||||
// t.Date (string) (string)
|
||||
case "date":
|
||||
|
||||
{
|
||||
sval, err := cbg.ReadStringWithMax(cr, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Date = string(sval)
|
||||
}
|
||||
// t.Type (string) (string)
|
||||
case "$type":
|
||||
|
||||
{
|
||||
sval, err := cbg.ReadStringWithMax(cr, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Type = string(sval)
|
||||
}
|
||||
// t.OwnerDID (string) (string)
|
||||
case "ownerDid":
|
||||
|
||||
{
|
||||
sval, err := cbg.ReadStringWithMax(cr, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.OwnerDID = string(sval)
|
||||
}
|
||||
// t.PullCount (int64) (int64)
|
||||
case "pullCount":
|
||||
{
|
||||
maj, extra, err := cr.ReadHeader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var extraI int64
|
||||
switch maj {
|
||||
case cbg.MajUnsignedInt:
|
||||
extraI = int64(extra)
|
||||
if extraI < 0 {
|
||||
return fmt.Errorf("int64 positive overflow")
|
||||
}
|
||||
case cbg.MajNegativeInt:
|
||||
extraI = int64(extra)
|
||||
if extraI < 0 {
|
||||
return fmt.Errorf("int64 negative overflow")
|
||||
}
|
||||
extraI = -1 - extraI
|
||||
default:
|
||||
return fmt.Errorf("wrong type for int64 field: %d", maj)
|
||||
}
|
||||
|
||||
t.PullCount = int64(extraI)
|
||||
}
|
||||
// t.PushCount (int64) (int64)
|
||||
case "pushCount":
|
||||
{
|
||||
maj, extra, err := cr.ReadHeader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var extraI int64
|
||||
switch maj {
|
||||
case cbg.MajUnsignedInt:
|
||||
extraI = int64(extra)
|
||||
if extraI < 0 {
|
||||
return fmt.Errorf("int64 positive overflow")
|
||||
}
|
||||
case cbg.MajNegativeInt:
|
||||
extraI = int64(extra)
|
||||
if extraI < 0 {
|
||||
return fmt.Errorf("int64 negative overflow")
|
||||
}
|
||||
extraI = -1 - extraI
|
||||
default:
|
||||
return fmt.Errorf("wrong type for int64 field: %d", maj)
|
||||
}
|
||||
|
||||
t.PushCount = int64(extraI)
|
||||
}
|
||||
// t.UpdatedAt (string) (string)
|
||||
case "updatedAt":
|
||||
|
||||
{
|
||||
sval, err := cbg.ReadStringWithMax(cr, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.UpdatedAt = string(sval)
|
||||
}
|
||||
// t.Repository (string) (string)
|
||||
case "repository":
|
||||
|
||||
{
|
||||
sval, err := cbg.ReadStringWithMax(cr, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Repository = string(sval)
|
||||
}
|
||||
|
||||
default:
|
||||
// Field doesn't exist on this type, so ignore it
|
||||
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
func (t *ScanRecord) MarshalCBOR(w io.Writer) error {
|
||||
if t == nil {
|
||||
_, err := w.Write(cbg.CborNull)
|
||||
|
||||
@@ -32,6 +32,7 @@ func main() {
|
||||
atproto.LayerRecord{},
|
||||
atproto.TangledProfileRecord{},
|
||||
atproto.StatsRecord{},
|
||||
atproto.DailyStatsRecord{},
|
||||
atproto.ScanRecord{},
|
||||
atproto.ImageConfigRecord{},
|
||||
); err != nil {
|
||||
|
||||
@@ -44,6 +44,10 @@ const (
|
||||
// Stored in hold's embedded PDS to track pull/push counts per owner+repo
|
||||
StatsCollection = "io.atcr.hold.stats"
|
||||
|
||||
// DailyStatsCollection is the collection name for daily repository statistics
|
||||
// Stored in hold's embedded PDS to track daily pull/push counts per owner+repo+date
|
||||
DailyStatsCollection = "io.atcr.hold.stats.daily"
|
||||
|
||||
// ScanCollection is the collection name for vulnerability scan results
|
||||
// Stored in hold's embedded PDS to track scan results per manifest
|
||||
ScanCollection = "io.atcr.hold.scan"
|
||||
@@ -353,6 +357,10 @@ type SailorProfileRecord struct {
|
||||
// Defaults to "docker" if empty.
|
||||
OciClient string `json:"ociClient,omitempty"`
|
||||
|
||||
// AIAdvisorEnabled controls whether the AI Image Advisor feature is active for this user.
|
||||
// nil = default (enabled if user has billing access), false = explicitly disabled.
|
||||
AIAdvisorEnabled *bool `json:"aiAdvisorEnabled,omitempty"`
|
||||
|
||||
// CreatedAt timestamp
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
@@ -775,6 +783,42 @@ func StatsRecordKey(ownerDID, repository string) string {
|
||||
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16]))
|
||||
}
|
||||
|
||||
// DailyStatsRecord represents daily repository statistics stored in the hold's PDS
|
||||
// Collection: io.atcr.hold.stats.daily
|
||||
// Stored in the hold's embedded PDS for tracking daily pull/push counts
|
||||
// Uses CBOR encoding for efficient storage in hold's carstore
|
||||
// RKey is deterministic: base32(sha256(ownerDID + "/" + repository + "/" + date)[:16])
|
||||
type DailyStatsRecord struct {
|
||||
Type string `json:"$type" cborgen:"$type"`
|
||||
OwnerDID string `json:"ownerDid" cborgen:"ownerDid"` // DID of the image owner
|
||||
Repository string `json:"repository" cborgen:"repository"` // Repository name
|
||||
Date string `json:"date" cborgen:"date"` // YYYY-MM-DD format
|
||||
PullCount int64 `json:"pullCount" cborgen:"pullCount"` // Number of manifest downloads on this date
|
||||
PushCount int64 `json:"pushCount" cborgen:"pushCount"` // Number of manifest uploads on this date
|
||||
UpdatedAt string `json:"updatedAt" cborgen:"updatedAt"` // RFC3339 timestamp
|
||||
}
|
||||
|
||||
// NewDailyStatsRecord creates a new daily stats record
|
||||
func NewDailyStatsRecord(ownerDID, repository, date string) *DailyStatsRecord {
|
||||
return &DailyStatsRecord{
|
||||
Type: DailyStatsCollection,
|
||||
OwnerDID: ownerDID,
|
||||
Repository: repository,
|
||||
Date: date,
|
||||
PullCount: 0,
|
||||
PushCount: 0,
|
||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// DailyStatsRecordKey generates a deterministic record key for daily stats
|
||||
// Uses base32 encoding of first 16 bytes of SHA-256 hash of "ownerDID/repository/date"
|
||||
func DailyStatsRecordKey(ownerDID, repository, date string) string {
|
||||
combined := ownerDID + "/" + repository + "/" + date
|
||||
hash := sha256.Sum256([]byte(combined))
|
||||
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16]))
|
||||
}
|
||||
|
||||
// CrewRecordKey generates a deterministic rkey from member DID
|
||||
// Uses same pattern as StatsRecordKey for consistency
|
||||
// This enables O(1) crew membership lookups via getRecord instead of O(n) pagination
|
||||
|
||||
@@ -139,6 +139,29 @@ func (m *Manager) GetWebhookLimits(userDID string) (int, bool) {
|
||||
return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers
|
||||
}
|
||||
|
||||
// HasAIAdvisor returns whether a user has access to the AI Image Advisor based on their subscription tier.
|
||||
// Hold captains always have access.
|
||||
func (m *Manager) HasAIAdvisor(userDID string) bool {
|
||||
if m.isCaptain(userDID) {
|
||||
return true
|
||||
}
|
||||
if !m.Enabled() {
|
||||
return false
|
||||
}
|
||||
|
||||
info, err := m.GetSubscriptionInfo(userDID)
|
||||
if err != nil || info == nil {
|
||||
return m.cfg.Tiers[0].AIAdvisor
|
||||
}
|
||||
|
||||
rank := info.TierRank
|
||||
if rank >= 0 && rank < len(m.cfg.Tiers) {
|
||||
return m.cfg.Tiers[rank].AIAdvisor
|
||||
}
|
||||
|
||||
return m.cfg.Tiers[0].AIAdvisor
|
||||
}
|
||||
|
||||
// GetSupporterBadge returns the supporter badge tier name for a user based on their subscription.
|
||||
// Returns the tier name if the user's current tier has supporter badges enabled, empty string otherwise.
|
||||
// Hold captains get a "Captain" badge.
|
||||
@@ -203,6 +226,7 @@ func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error)
|
||||
// Dynamic features: hold-derived first, then webhook limits, then static config
|
||||
features := m.aggregateHoldFeatures(i)
|
||||
features = append(features, webhookFeatures(tier.MaxWebhooks, tier.WebhookAllTriggers)...)
|
||||
features = append(features, aiAdvisorFeatures(tier.AIAdvisor)...)
|
||||
if tier.SupporterBadge {
|
||||
features = append(features, "Supporter badge")
|
||||
}
|
||||
@@ -689,6 +713,14 @@ func webhookFeatures(maxWebhooks int, allTriggers bool) []string {
|
||||
return features
|
||||
}
|
||||
|
||||
// aiAdvisorFeatures generates feature bullet strings for AI advisor access.
|
||||
func aiAdvisorFeatures(enabled bool) []string {
|
||||
if enabled {
|
||||
return []string{"AI Image Advisor"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatBytes formats bytes as a human-readable string (e.g. "5.0 GB").
|
||||
func formatBytes(b int64) string {
|
||||
const unit = 1024
|
||||
|
||||
@@ -36,6 +36,15 @@ func (m *Manager) GetWebhookLimits(userDID string) (int, bool) {
|
||||
return 1, false
|
||||
}
|
||||
|
||||
// HasAIAdvisor returns whether a user has access to the AI Image Advisor.
|
||||
// Hold captains always have access. Default is false when billing is not compiled in.
|
||||
func (m *Manager) HasAIAdvisor(userDID string) bool {
|
||||
if m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetSubscriptionInfo returns an error when billing is not compiled in.
|
||||
func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) {
|
||||
return nil, ErrBillingDisabled
|
||||
|
||||
@@ -51,6 +51,9 @@ type BillingTierConfig struct {
|
||||
// Whether all webhook trigger types are available (not just first-scan).
|
||||
WebhookAllTriggers bool `yaml:"webhook_all_triggers" comment:"Allow all webhook trigger types (not just first-scan)."`
|
||||
|
||||
// Whether AI Image Advisor is available for this tier.
|
||||
AIAdvisor bool `yaml:"ai_advisor" comment:"Enable AI Image Advisor for this tier."`
|
||||
|
||||
// Whether this tier earns a supporter badge on user profiles.
|
||||
SupporterBadge bool `yaml:"supporter_badge" comment:"Show supporter badge on user profiles for subscribers at this tier."`
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
|
||||
<symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol>
|
||||
<symbol id="bold" viewBox="0 0 24 24"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"/></symbol>
|
||||
<symbol id="book-open" viewBox="0 0 24 24"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></symbol>
|
||||
<symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
|
||||
<symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol>
|
||||
<symbol id="chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
|
||||
@@ -18,13 +19,16 @@
|
||||
<symbol id="container" viewBox="0 0 24 24"><path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z"/><path d="M10 21.9V14L2.1 9.1"/><path d="m10 14 11.9-6.9"/><path d="M14 19.8v-8.1"/><path d="M18 17.5V9.4"/></symbol>
|
||||
<symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
|
||||
<symbol id="cpu" viewBox="0 0 24 24"><path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/></symbol>
|
||||
<symbol id="credit-card" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></symbol>
|
||||
<symbol id="database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></symbol>
|
||||
<symbol id="download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol>
|
||||
<symbol id="external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
|
||||
<symbol id="eye" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="file-plus" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M9 15h6"/><path d="M12 18v-6"/></symbol>
|
||||
<symbol id="file-text" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol>
|
||||
<symbol id="file-x" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="m14.5 12.5-5 5"/><path d="m9.5 12.5 5 5"/></symbol>
|
||||
<symbol id="fingerprint" viewBox="0 0 24 24"><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 16h.01"/><path d="M21.8 16c.2-2 .131-5.354 0-6"/><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M9 6.8a6 6 0 0 1 9 5.2v2"/></symbol>
|
||||
<symbol id="git-compare" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></symbol>
|
||||
<symbol id="git-merge" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></symbol>
|
||||
<symbol id="github" viewBox="0 0 24 24"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></symbol>
|
||||
<symbol id="hard-drive" viewBox="0 0 24 24"><path d="M10 16h.01"/><path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M21.946 12.013H2.054"/><path d="M6 16h.01"/></symbol>
|
||||
@@ -50,9 +54,12 @@
|
||||
<symbol id="settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol>
|
||||
<symbol id="ship" viewBox="0 0 24 24"><path d="M12 10.189V14"/><path d="M12 2v3"/><path d="M19 13V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6"/><path d="M19.38 20A11.6 11.6 0 0 0 21 14l-8.188-3.639a2 2 0 0 0-1.624 0L3 14a11.6 11.6 0 0 0 2.81 7.76"/><path d="M2 21c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1s1.2 1 2.5 1c2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/></symbol>
|
||||
<symbol id="sparkle" viewBox="0 0 24 24"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/></symbol>
|
||||
<symbol id="sparkles" viewBox="0 0 24 24"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/></symbol>
|
||||
<symbol id="star" viewBox="0 0 24 24"><path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/></symbol>
|
||||
<symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></symbol>
|
||||
<symbol id="sun-moon" viewBox="0 0 24 24"><path d="M12 2v2"/><path d="M14.837 16.385a6 6 0 1 1-7.223-7.222c.624-.147.97.66.715 1.248a4 4 0 0 0 5.26 5.259c.589-.255 1.396.09 1.248.715"/><path d="M16 12a4 4 0 0 0-4-4"/><path d="m19 5-1.256 1.256"/><path d="M20 12h2"/></symbol>
|
||||
<symbol id="tag" viewBox="0 0 24 24"><path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/></symbol>
|
||||
<symbol id="terminal" viewBox="0 0 24 24"><path d="M12 19h8"/><path d="m4 17 6-6-6-6"/></symbol>
|
||||
<symbol id="trash-2" viewBox="0 0 24 24"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></symbol>
|
||||
<symbol id="triangle-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 15 KiB |
@@ -227,6 +227,11 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
|
||||
Architecture string `json:"architecture"`
|
||||
} `json:"platform"`
|
||||
} `json:"manifests"`
|
||||
Subject *struct {
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
MediaType string `json:"mediaType"`
|
||||
} `json:"subject"`
|
||||
} `json:"manifest"`
|
||||
}
|
||||
|
||||
@@ -401,8 +406,8 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue scan job if scanner is connected (skip manifest lists — children get their own jobs)
|
||||
if h.scanBroadcaster != nil && !isMultiArch {
|
||||
// Enqueue scan job if scanner is connected (skip manifest lists and attestations — no scannable content)
|
||||
if h.scanBroadcaster != nil && !isMultiArch && req.Manifest.Subject == nil {
|
||||
tier := "deckhand"
|
||||
if stats != nil && stats.Tier != "" {
|
||||
tier = stats.Tier
|
||||
@@ -456,6 +461,11 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
|
||||
statsUpdated = true
|
||||
}
|
||||
|
||||
// Also increment daily stats for trend tracking
|
||||
if err := h.pds.IncrementDailyStats(ctx, req.UserDID, req.Repository, operation); err != nil {
|
||||
slog.Warn("Failed to increment daily stats", "operation", operation, "error", err)
|
||||
}
|
||||
|
||||
// Return response
|
||||
resp := map[string]any{
|
||||
"success": statsUpdated || layersCreated > 0 || postCreated,
|
||||
|
||||
@@ -41,13 +41,16 @@ type ScanBroadcaster struct {
|
||||
rescanInterval time.Duration // Minimum interval between re-scans (0 = disabled)
|
||||
stopCh chan struct{} // Signal to stop background goroutines
|
||||
wg sync.WaitGroup // Wait for background goroutines to finish
|
||||
userIdx int // Round-robin index through DIDs for proactive scanning
|
||||
predecessorCache map[string]bool // holdDID → "has this hold been migrated (has successor)?"
|
||||
relayEndpoint string // Relay URL for listReposByCollection
|
||||
|
||||
// Relay-based manifest DID discovery
|
||||
relayEndpoint string // Relay URL for listReposByCollection
|
||||
manifestDIDs []string // Cached list of DIDs with manifest records
|
||||
manifestDIDsMu sync.RWMutex // Protects manifestDIDs
|
||||
// Work queues for proactive scanning (populated by discovery/stale goroutines)
|
||||
unscannedQueue chan *scanCandidate // Medium priority: manifests with no scan record
|
||||
staleQueue chan *scanCandidate // Low priority: scan records older than rescanInterval
|
||||
inflight map[string]struct{} // Manifest digests currently queued or being scanned
|
||||
inflightMu sync.Mutex
|
||||
completionSignal chan struct{} // Signaled when a scan job completes (wakes dispatchLoop)
|
||||
discoverNow chan struct{} // Signaled to trigger an early discovery pass
|
||||
}
|
||||
|
||||
// ScanSubscriber represents a connected scanner WebSocket client
|
||||
@@ -139,6 +142,11 @@ func NewScanBroadcaster(holdDID, holdEndpoint, secret, relayEndpoint, dbPath str
|
||||
stopCh: make(chan struct{}),
|
||||
predecessorCache: make(map[string]bool),
|
||||
relayEndpoint: relayEndpoint,
|
||||
unscannedQueue: make(chan *scanCandidate, 500),
|
||||
staleQueue: make(chan *scanCandidate, 200),
|
||||
inflight: make(map[string]struct{}),
|
||||
completionSignal: make(chan struct{}, 1),
|
||||
discoverNow: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
if err := sb.initSchema(); err != nil {
|
||||
@@ -149,11 +157,12 @@ func NewScanBroadcaster(holdDID, holdEndpoint, secret, relayEndpoint, dbPath str
|
||||
sb.wg.Add(1)
|
||||
go sb.reDispatchLoop()
|
||||
|
||||
// Start proactive scan loop if rescan interval is configured
|
||||
// Start proactive scan loops if rescan interval is configured
|
||||
if rescanInterval > 0 {
|
||||
sb.wg.Add(2)
|
||||
go sb.proactiveScanLoop()
|
||||
go sb.refreshManifestDIDsLoop()
|
||||
sb.wg.Add(3)
|
||||
go sb.discoveryLoop()
|
||||
go sb.staleScanLoop()
|
||||
go sb.dispatchLoop()
|
||||
slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval, "relayEndpoint", relayEndpoint)
|
||||
}
|
||||
|
||||
@@ -181,6 +190,11 @@ func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret, relayEndpoint strin
|
||||
stopCh: make(chan struct{}),
|
||||
predecessorCache: make(map[string]bool),
|
||||
relayEndpoint: relayEndpoint,
|
||||
unscannedQueue: make(chan *scanCandidate, 500),
|
||||
staleQueue: make(chan *scanCandidate, 200),
|
||||
inflight: make(map[string]struct{}),
|
||||
completionSignal: make(chan struct{}, 1),
|
||||
discoverNow: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
if err := sb.initSchema(); err != nil {
|
||||
@@ -190,9 +204,10 @@ func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret, relayEndpoint strin
|
||||
go sb.reDispatchLoop()
|
||||
|
||||
if rescanInterval > 0 {
|
||||
sb.wg.Add(2)
|
||||
go sb.proactiveScanLoop()
|
||||
go sb.refreshManifestDIDsLoop()
|
||||
sb.wg.Add(3)
|
||||
go sb.discoveryLoop()
|
||||
go sb.staleScanLoop()
|
||||
go sb.dispatchLoop()
|
||||
slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval, "relayEndpoint", relayEndpoint)
|
||||
}
|
||||
|
||||
@@ -238,17 +253,22 @@ func (sb *ScanBroadcaster) Enqueue(job *ScanJobEvent) error {
|
||||
job.HoldDID = sb.holdDID
|
||||
job.HoldEndpoint = sb.holdEndpoint
|
||||
|
||||
// Track in-flight to prevent duplicate proactive scans
|
||||
sb.addInflight(job.ManifestDigest)
|
||||
|
||||
// Insert into database
|
||||
result, err := sb.db.Exec(`
|
||||
INSERT INTO scan_jobs (manifest_digest, repository, tag, user_did, user_handle, hold_did, hold_endpoint, tier, config_json, layers_json, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
|
||||
`, job.ManifestDigest, job.Repository, job.Tag, job.UserDID, job.UserHandle, job.HoldDID, job.HoldEndpoint, job.Tier, string(job.Config), string(job.Layers))
|
||||
if err != nil {
|
||||
sb.removeInflight(job.ManifestDigest)
|
||||
return fmt.Errorf("failed to insert scan job: %w", err)
|
||||
}
|
||||
|
||||
seq, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
sb.removeInflight(job.ManifestDigest)
|
||||
return fmt.Errorf("failed to get job seq: %w", err)
|
||||
}
|
||||
job.Seq = seq
|
||||
@@ -294,6 +314,9 @@ func (sb *ScanBroadcaster) Subscribe(conn *websocket.Conn, cursor int64) *ScanSu
|
||||
// Drain pending and timed-out jobs from database
|
||||
go sb.drainPendingJobs(sub, cursor)
|
||||
|
||||
// Trigger early discovery pass so missed scans are found quickly
|
||||
sb.triggerDiscovery()
|
||||
|
||||
return sub
|
||||
}
|
||||
|
||||
@@ -309,10 +332,11 @@ func (sb *ScanBroadcaster) Unsubscribe(sub *ScanSubscriber) {
|
||||
}
|
||||
}
|
||||
|
||||
// Mark assigned jobs as pending again so they can be re-dispatched
|
||||
// Mark assigned/processing jobs as pending again so they can be re-dispatched.
|
||||
// Including 'processing' handles scanner crashes mid-scan.
|
||||
_, err := sb.db.Exec(`
|
||||
UPDATE scan_jobs SET status = 'pending', assigned_to = NULL, assigned_at = NULL
|
||||
WHERE assigned_to = ? AND status IN ('pending', 'assigned')
|
||||
WHERE assigned_to = ? AND status IN ('pending', 'assigned', 'processing')
|
||||
`, sub.id)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unassign jobs from disconnected scanner",
|
||||
@@ -542,6 +566,10 @@ func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage)
|
||||
"error", err)
|
||||
}
|
||||
|
||||
// Remove from in-flight tracking and wake dispatch loop
|
||||
sb.removeInflight(manifestDigest)
|
||||
sb.signalCompletion()
|
||||
|
||||
slog.Info("Scan job completed",
|
||||
"seq", msg.Seq,
|
||||
"repository", repository,
|
||||
@@ -592,6 +620,10 @@ func (sb *ScanBroadcaster) handleError(sub *ScanSubscriber, msg ScannerMessage)
|
||||
"error", err)
|
||||
}
|
||||
|
||||
// Remove from in-flight tracking and wake dispatch loop
|
||||
sb.removeInflight(manifestDigest)
|
||||
sb.signalCompletion()
|
||||
|
||||
slog.Warn("Scan job failed",
|
||||
"seq", msg.Seq,
|
||||
"subscriberId", sub.id,
|
||||
@@ -768,52 +800,89 @@ func (sb *ScanBroadcaster) ValidateScannerSecret(secret string) bool {
|
||||
return sb.secret != "" && secret == sb.secret
|
||||
}
|
||||
|
||||
// refreshManifestDIDsLoop periodically queries the relay to discover all DIDs
|
||||
// with io.atcr.manifest records. The cached list is used by the proactive scan loop.
|
||||
func (sb *ScanBroadcaster) refreshManifestDIDsLoop() {
|
||||
// scanCandidate is a manifest that needs scanning, with its scan freshness.
|
||||
type scanCandidate struct {
|
||||
manifest atproto.ManifestRecord // May be zero-value for stale candidates (resolved lazily)
|
||||
manifestDigest string // Always set; used for dedup and lazy resolution
|
||||
userDID string
|
||||
userHandle string
|
||||
scannedAt time.Time // zero value = never scanned
|
||||
}
|
||||
|
||||
// discoveryLoop fetches DIDs from the relay, walks each user's PDS to find manifests
|
||||
// with no scan record, and pushes them to the unscannedQueue. Runs on startup (after
|
||||
// settle), then every 4 hours. Scanner reconnect triggers an early pass via discoverNow.
|
||||
func (sb *ScanBroadcaster) discoveryLoop() {
|
||||
defer sb.wg.Done()
|
||||
|
||||
// Wait for the system to settle before first refresh
|
||||
// Wait for system to settle
|
||||
select {
|
||||
case <-sb.stopCh:
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
case <-time.After(45 * time.Second):
|
||||
}
|
||||
|
||||
// Initial refresh
|
||||
sb.refreshManifestDIDs()
|
||||
slog.Info("Discovery loop started")
|
||||
sb.runDiscoveryPass()
|
||||
|
||||
ticker := time.NewTicker(30 * time.Minute)
|
||||
ticker := time.NewTicker(4 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sb.stopCh:
|
||||
slog.Info("Manifest DID refresh loop stopped")
|
||||
slog.Info("Discovery loop stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
sb.refreshManifestDIDs()
|
||||
sb.runDiscoveryPass()
|
||||
case <-sb.discoverNow:
|
||||
slog.Info("Discovery loop: early pass triggered (scanner reconnect)")
|
||||
sb.runDiscoveryPass()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refreshManifestDIDs queries the relay for all DIDs that have io.atcr.manifest records.
|
||||
// On success, atomically replaces the cached DID list. On failure, retains the previous list.
|
||||
func (sb *ScanBroadcaster) refreshManifestDIDs() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
// runDiscoveryPass fetches the DID list from relay and walks each user's PDS.
|
||||
func (sb *ScanBroadcaster) runDiscoveryPass() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
client := atproto.NewClient(sb.relayEndpoint, "", "")
|
||||
// Fetch DID list from relay
|
||||
userDIDs := sb.fetchManifestDIDs(ctx)
|
||||
if len(userDIDs) == 0 {
|
||||
slog.Debug("Discovery: no manifest DIDs from relay")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Discovery: starting pass", "users", len(userDIDs))
|
||||
found := 0
|
||||
|
||||
for _, userDID := range userDIDs {
|
||||
select {
|
||||
case <-sb.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
n := sb.discoverUnscannedForUser(ctx, userDID)
|
||||
found += n
|
||||
}
|
||||
|
||||
slog.Info("Discovery: pass complete", "users", len(userDIDs), "unscannedFound", found)
|
||||
}
|
||||
|
||||
// fetchManifestDIDs queries the relay for all DIDs with io.atcr.manifest records.
|
||||
func (sb *ScanBroadcaster) fetchManifestDIDs(ctx context.Context) []string {
|
||||
client := atproto.NewClient(sb.relayEndpoint, "", "")
|
||||
var allDIDs []string
|
||||
var cursor string
|
||||
|
||||
for {
|
||||
result, err := client.ListReposByCollection(ctx, atproto.ManifestCollection, 1000, cursor)
|
||||
if err != nil {
|
||||
slog.Warn("Proactive scan: failed to list repos from relay",
|
||||
slog.Warn("Discovery: failed to list repos from relay",
|
||||
"relay", sb.relayEndpoint, "error", err)
|
||||
return // Keep existing cached list
|
||||
return allDIDs // Return what we have so far
|
||||
}
|
||||
|
||||
for _, repo := range result.Repos {
|
||||
@@ -826,115 +895,28 @@ func (sb *ScanBroadcaster) refreshManifestDIDs() {
|
||||
cursor = result.Cursor
|
||||
}
|
||||
|
||||
sb.manifestDIDsMu.Lock()
|
||||
sb.manifestDIDs = allDIDs
|
||||
sb.manifestDIDsMu.Unlock()
|
||||
|
||||
slog.Info("Proactive scan: refreshed manifest DID list from relay",
|
||||
"count", len(allDIDs), "relay", sb.relayEndpoint)
|
||||
return allDIDs
|
||||
}
|
||||
|
||||
// proactiveScanLoop periodically finds manifests needing scanning and enqueues jobs.
|
||||
// It fetches manifest records from users' PDS (the source of truth) and creates scan
|
||||
// jobs for manifests that haven't been scanned recently.
|
||||
func (sb *ScanBroadcaster) proactiveScanLoop() {
|
||||
defer sb.wg.Done()
|
||||
|
||||
// Wait for the system to settle and DID list to populate
|
||||
select {
|
||||
case <-sb.stopCh:
|
||||
return
|
||||
case <-time.After(45 * time.Second):
|
||||
}
|
||||
|
||||
// Run immediately on startup, then every 60s
|
||||
slog.Info("Proactive scan loop started")
|
||||
sb.tryEnqueueProactiveScan()
|
||||
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sb.stopCh:
|
||||
slog.Info("Proactive scan loop stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
sb.tryEnqueueProactiveScan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tryEnqueueProactiveScan finds the next manifest needing a scan and enqueues it.
|
||||
// Only enqueues one job per call to avoid flooding the scanner.
|
||||
// Uses the cached DID list from the relay (refreshed by refreshManifestDIDsLoop).
|
||||
func (sb *ScanBroadcaster) tryEnqueueProactiveScan() {
|
||||
if !sb.hasConnectedScanners() {
|
||||
slog.Debug("Proactive scan: no scanners connected, skipping")
|
||||
return
|
||||
}
|
||||
if sb.hasActiveJobs() {
|
||||
slog.Debug("Proactive scan: active jobs in queue, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Read cached DID list from relay discovery
|
||||
sb.manifestDIDsMu.RLock()
|
||||
userDIDs := sb.manifestDIDs
|
||||
sb.manifestDIDsMu.RUnlock()
|
||||
|
||||
if len(userDIDs) == 0 {
|
||||
slog.Debug("Proactive scan: no manifest DIDs cached from relay, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
// Round-robin through DIDs, trying each until we find work or exhaust the list
|
||||
for attempts := 0; attempts < len(userDIDs); attempts++ {
|
||||
idx := sb.userIdx % len(userDIDs)
|
||||
sb.userIdx++
|
||||
userDID := userDIDs[idx]
|
||||
|
||||
if sb.tryEnqueueForUser(ctx, userDID) {
|
||||
return // Enqueued one job, done for this tick
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scanCandidate is a manifest that needs scanning, with its scan freshness.
|
||||
type scanCandidate struct {
|
||||
manifest atproto.ManifestRecord
|
||||
userDID string
|
||||
userHandle string
|
||||
scannedAt time.Time // zero value = never scanned
|
||||
}
|
||||
|
||||
// tryEnqueueForUser fetches manifests from a user's PDS and enqueues a scan for the
|
||||
// one that most needs it: never-scanned manifests first, then the stalest scan.
|
||||
// Returns true if a job was enqueued.
|
||||
func (sb *ScanBroadcaster) tryEnqueueForUser(ctx context.Context, userDID string) bool {
|
||||
// Resolve user DID to PDS endpoint and handle
|
||||
// discoverUnscannedForUser fetches manifests from a user's PDS and pushes any
|
||||
// without scan records to the unscannedQueue. Returns count of candidates found.
|
||||
func (sb *ScanBroadcaster) discoverUnscannedForUser(ctx context.Context, userDID string) int {
|
||||
did, userHandle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, userDID)
|
||||
if err != nil {
|
||||
slog.Debug("Proactive scan: failed to resolve user identity",
|
||||
slog.Debug("Discovery: failed to resolve user identity",
|
||||
"userDID", userDID, "error", err)
|
||||
return false
|
||||
return 0
|
||||
}
|
||||
|
||||
// Collect all scannable manifests with their scan age
|
||||
var unscanned []scanCandidate
|
||||
var oldest *scanCandidate
|
||||
|
||||
found := 0
|
||||
client := atproto.NewClient(pdsEndpoint, did, "")
|
||||
var cursor string
|
||||
for {
|
||||
records, nextCursor, err := client.ListRecordsForRepo(ctx, did, atproto.ManifestCollection, 100, cursor)
|
||||
if err != nil {
|
||||
slog.Debug("Proactive scan: failed to list manifest records",
|
||||
slog.Debug("Discovery: failed to list manifest records",
|
||||
"userDID", did, "pds", pdsEndpoint, "error", err)
|
||||
return false
|
||||
return found
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
@@ -943,7 +925,7 @@ func (sb *ScanBroadcaster) tryEnqueueForUser(ctx context.Context, userDID string
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this manifest belongs to us (directly or via successor)
|
||||
// Check if this manifest belongs to us
|
||||
holdDID := manifest.HoldDID
|
||||
if holdDID == "" {
|
||||
holdDID = manifest.HoldEndpoint // Legacy field
|
||||
@@ -952,52 +934,38 @@ func (sb *ScanBroadcaster) tryEnqueueForUser(ctx context.Context, userDID string
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip manifest lists (no layers to scan)
|
||||
if len(manifest.Layers) == 0 {
|
||||
// Skip manifest lists and attestations (no scannable content)
|
||||
if len(manifest.Layers) == 0 || manifest.Subject != nil || manifest.Config == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if config is nil
|
||||
if manifest.Config == nil {
|
||||
// Check if already in-flight
|
||||
if !sb.addInflight(manifest.Digest) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check scan status
|
||||
_, scanRecord, err := sb.pds.GetScanRecord(ctx, manifest.Digest)
|
||||
if err != nil {
|
||||
// No scan record — never scanned
|
||||
unscanned = append(unscanned, scanCandidate{
|
||||
manifest: manifest,
|
||||
userDID: did,
|
||||
userHandle: userHandle,
|
||||
})
|
||||
// Check scan status — only interested in never-scanned
|
||||
_, _, err := sb.pds.GetScanRecord(ctx, manifest.Digest)
|
||||
if err == nil {
|
||||
// Has a scan record — not our concern (stale loop handles rescans)
|
||||
sb.removeInflight(manifest.Digest)
|
||||
continue
|
||||
}
|
||||
|
||||
scannedAt, err := time.Parse(time.RFC3339, scanRecord.ScannedAt)
|
||||
if err != nil {
|
||||
// Can't parse timestamp — treat as never scanned
|
||||
unscanned = append(unscanned, scanCandidate{
|
||||
manifest: manifest,
|
||||
userDID: did,
|
||||
userHandle: userHandle,
|
||||
})
|
||||
continue
|
||||
// Never scanned — push to queue
|
||||
candidate := &scanCandidate{
|
||||
manifest: manifest,
|
||||
manifestDigest: manifest.Digest,
|
||||
userDID: did,
|
||||
userHandle: userHandle,
|
||||
}
|
||||
|
||||
// Skip if scanned recently
|
||||
if time.Since(scannedAt) < sb.rescanInterval {
|
||||
continue
|
||||
}
|
||||
|
||||
// Stale scan — track the oldest
|
||||
if oldest == nil || scannedAt.Before(oldest.scannedAt) {
|
||||
oldest = &scanCandidate{
|
||||
manifest: manifest,
|
||||
userDID: did,
|
||||
userHandle: userHandle,
|
||||
scannedAt: scannedAt,
|
||||
}
|
||||
select {
|
||||
case sb.unscannedQueue <- candidate:
|
||||
found++
|
||||
case <-sb.stopCh:
|
||||
sb.removeInflight(manifest.Digest)
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1007,46 +975,302 @@ func (sb *ScanBroadcaster) tryEnqueueForUser(ctx context.Context, userDID string
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
// Prefer never-scanned, then oldest stale scan
|
||||
var pick *scanCandidate
|
||||
if len(unscanned) > 0 {
|
||||
pick = &unscanned[0]
|
||||
} else if oldest != nil {
|
||||
pick = oldest
|
||||
return found
|
||||
}
|
||||
|
||||
// staleScanLoop walks local scan records to find stale scans (older than rescanInterval)
|
||||
// and pushes them to the staleQueue. No PDS calls needed — all data is local.
|
||||
// After a full pass, sleeps for rescanInterval/2 before repeating.
|
||||
func (sb *ScanBroadcaster) staleScanLoop() {
|
||||
defer sb.wg.Done()
|
||||
|
||||
// Short initial delay to let system settle
|
||||
select {
|
||||
case <-sb.stopCh:
|
||||
return
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
|
||||
if pick == nil {
|
||||
return false
|
||||
slog.Info("Stale scan loop started")
|
||||
|
||||
for {
|
||||
sb.runStalePass()
|
||||
|
||||
sleepDuration := sb.rescanInterval / 2
|
||||
if sleepDuration < 1*time.Hour {
|
||||
sleepDuration = 1 * time.Hour
|
||||
}
|
||||
|
||||
select {
|
||||
case <-sb.stopCh:
|
||||
slog.Info("Stale scan loop stopped")
|
||||
return
|
||||
case <-time.After(sleepDuration):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runStalePass walks all scan records and pushes stale ones to the staleQueue.
|
||||
func (sb *ScanBroadcaster) runStalePass() {
|
||||
ri := sb.pds.RecordsIndex()
|
||||
if ri == nil {
|
||||
slog.Debug("Stale scan: no records index available")
|
||||
return
|
||||
}
|
||||
|
||||
configJSON, _ := json.Marshal(pick.manifest.Config)
|
||||
layersJSON, _ := json.Marshal(pick.manifest.Layers)
|
||||
ctx := context.Background()
|
||||
found := 0
|
||||
var cursor string
|
||||
|
||||
for {
|
||||
records, nextCursor, err := ri.ListRecords(atproto.ScanCollection, 100, cursor, true) // oldest first
|
||||
if err != nil {
|
||||
slog.Error("Stale scan: failed to list scan records", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
select {
|
||||
case <-sb.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// The rkey is the manifest digest (without sha256: prefix)
|
||||
manifestDigest := "sha256:" + record.Rkey
|
||||
|
||||
// Check if already in-flight
|
||||
if !sb.addInflight(manifestDigest) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch the actual scan record to check staleness
|
||||
_, scanRecord, err := sb.pds.GetScanRecord(ctx, manifestDigest)
|
||||
if err != nil {
|
||||
sb.removeInflight(manifestDigest)
|
||||
continue
|
||||
}
|
||||
|
||||
scannedAt, err := time.Parse(time.RFC3339, scanRecord.ScannedAt)
|
||||
if err != nil {
|
||||
sb.removeInflight(manifestDigest)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if scanned recently
|
||||
if time.Since(scannedAt) < sb.rescanInterval {
|
||||
sb.removeInflight(manifestDigest)
|
||||
continue
|
||||
}
|
||||
|
||||
candidate := &scanCandidate{
|
||||
manifestDigest: manifestDigest,
|
||||
userDID: scanRecord.UserDID,
|
||||
scannedAt: scannedAt,
|
||||
}
|
||||
|
||||
select {
|
||||
case sb.staleQueue <- candidate:
|
||||
found++
|
||||
case <-sb.stopCh:
|
||||
sb.removeInflight(manifestDigest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if nextCursor == "" || len(records) == 0 {
|
||||
break
|
||||
}
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
if found > 0 {
|
||||
slog.Info("Stale scan: pass complete", "staleCandidates", found)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchLoop pops candidates from the work queues with strict priority
|
||||
// (unscanned before stale) and enqueues them as scan jobs. Throttled to one
|
||||
// proactive job at a time via hasActiveJobs().
|
||||
func (sb *ScanBroadcaster) dispatchLoop() {
|
||||
defer sb.wg.Done()
|
||||
|
||||
// Wait for system to settle
|
||||
select {
|
||||
case <-sb.stopCh:
|
||||
return
|
||||
case <-time.After(15 * time.Second):
|
||||
}
|
||||
|
||||
slog.Info("Dispatch loop started")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sb.stopCh:
|
||||
slog.Info("Dispatch loop stopped")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Wait until there's capacity (no active proactive jobs)
|
||||
if !sb.waitForCapacity() {
|
||||
return // stopCh closed
|
||||
}
|
||||
|
||||
// Wait until at least one scanner is connected
|
||||
if !sb.hasConnectedScanners() {
|
||||
select {
|
||||
case <-sb.stopCh:
|
||||
return
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Pop from highest-priority non-empty queue
|
||||
var candidate *scanCandidate
|
||||
|
||||
select {
|
||||
case c := <-sb.unscannedQueue:
|
||||
candidate = c
|
||||
default:
|
||||
// No unscanned; try stale
|
||||
select {
|
||||
case c := <-sb.staleQueue:
|
||||
candidate = c
|
||||
default:
|
||||
// Both queues empty — wait for completion signal or timeout
|
||||
select {
|
||||
case <-sb.stopCh:
|
||||
return
|
||||
case <-sb.completionSignal:
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
sb.dispatchCandidate(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForCapacity blocks until there are no active proactive scan jobs.
|
||||
// Returns false if stopCh is closed.
|
||||
func (sb *ScanBroadcaster) waitForCapacity() bool {
|
||||
for {
|
||||
if !sb.hasActiveJobs() {
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case <-sb.stopCh:
|
||||
return false
|
||||
case <-sb.completionSignal:
|
||||
// Job completed, check again
|
||||
case <-time.After(5 * time.Second):
|
||||
// Periodic check as fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchCandidate resolves manifest details if needed and enqueues a scan job.
|
||||
func (sb *ScanBroadcaster) dispatchCandidate(candidate *scanCandidate) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// For stale candidates, verify the scan is still stale (may have been
|
||||
// scanned by a push-triggered job while sitting in the queue)
|
||||
if !candidate.scannedAt.IsZero() {
|
||||
_, scanRecord, err := sb.pds.GetScanRecord(ctx, candidate.manifestDigest)
|
||||
if err == nil {
|
||||
scannedAt, parseErr := time.Parse(time.RFC3339, scanRecord.ScannedAt)
|
||||
if parseErr == nil && time.Since(scannedAt) < sb.rescanInterval {
|
||||
// Recently scanned, skip
|
||||
sb.removeInflight(candidate.manifestDigest)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve manifest details if not already present (stale candidates lack Config/Layers)
|
||||
if candidate.manifest.Config == nil {
|
||||
if !sb.resolveManifestForCandidate(ctx, candidate) {
|
||||
sb.removeInflight(candidate.manifestDigest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
configJSON, _ := json.Marshal(candidate.manifest.Config)
|
||||
layersJSON, _ := json.Marshal(candidate.manifest.Layers)
|
||||
|
||||
reason := "never scanned"
|
||||
if !pick.scannedAt.IsZero() {
|
||||
reason = fmt.Sprintf("last scanned %s ago", time.Since(pick.scannedAt).Truncate(time.Minute))
|
||||
if !candidate.scannedAt.IsZero() {
|
||||
reason = fmt.Sprintf("last scanned %s ago", time.Since(candidate.scannedAt).Truncate(time.Minute))
|
||||
}
|
||||
|
||||
slog.Info("Enqueuing proactive scan",
|
||||
"manifestDigest", pick.manifest.Digest,
|
||||
"repository", pick.manifest.Repository,
|
||||
"userDID", pick.userDID,
|
||||
slog.Info("Dispatching proactive scan",
|
||||
"manifestDigest", candidate.manifestDigest,
|
||||
"repository", candidate.manifest.Repository,
|
||||
"userDID", candidate.userDID,
|
||||
"reason", reason)
|
||||
|
||||
if err := sb.Enqueue(&ScanJobEvent{
|
||||
ManifestDigest: pick.manifest.Digest,
|
||||
Repository: pick.manifest.Repository,
|
||||
UserDID: pick.userDID,
|
||||
UserHandle: pick.userHandle,
|
||||
ManifestDigest: candidate.manifestDigest,
|
||||
Repository: candidate.manifest.Repository,
|
||||
UserDID: candidate.userDID,
|
||||
UserHandle: candidate.userHandle,
|
||||
Tier: "deckhand",
|
||||
Config: configJSON,
|
||||
Layers: layersJSON,
|
||||
}); err != nil {
|
||||
slog.Error("Proactive scan: failed to enqueue",
|
||||
"manifest", pick.manifest.Digest, "error", err)
|
||||
slog.Error("Dispatch: failed to enqueue",
|
||||
"manifest", candidate.manifestDigest, "error", err)
|
||||
// removeInflight not needed — Enqueue already cleans up on error
|
||||
}
|
||||
}
|
||||
|
||||
// resolveManifestForCandidate fetches the manifest from the user's PDS to populate
|
||||
// Config and Layers fields needed for the scan job.
|
||||
func (sb *ScanBroadcaster) resolveManifestForCandidate(ctx context.Context, candidate *scanCandidate) bool {
|
||||
did, userHandle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, candidate.userDID)
|
||||
if err != nil {
|
||||
slog.Debug("Dispatch: failed to resolve user identity",
|
||||
"userDID", candidate.userDID, "error", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
if candidate.userHandle == "" {
|
||||
candidate.userHandle = userHandle
|
||||
}
|
||||
|
||||
client := atproto.NewClient(pdsEndpoint, did, "")
|
||||
var cursor string
|
||||
for {
|
||||
records, nextCursor, err := client.ListRecordsForRepo(ctx, did, atproto.ManifestCollection, 100, cursor)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
var manifest atproto.ManifestRecord
|
||||
if err := json.Unmarshal(record.Value, &manifest); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if manifest.Digest == candidate.manifestDigest {
|
||||
candidate.manifest = manifest
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if nextCursor == "" || len(records) == 0 {
|
||||
break
|
||||
}
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
slog.Debug("Dispatch: manifest not found on user's PDS",
|
||||
"manifestDigest", candidate.manifestDigest,
|
||||
"userDID", candidate.userDID)
|
||||
return false
|
||||
}
|
||||
|
||||
// isOurManifest checks if a manifest's holdDID matches this hold directly,
|
||||
@@ -1161,3 +1385,37 @@ func generateSubscriberID() string {
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// addInflight marks a manifest digest as in-flight. Returns false if already present.
|
||||
func (sb *ScanBroadcaster) addInflight(digest string) bool {
|
||||
sb.inflightMu.Lock()
|
||||
defer sb.inflightMu.Unlock()
|
||||
if _, ok := sb.inflight[digest]; ok {
|
||||
return false
|
||||
}
|
||||
sb.inflight[digest] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
// removeInflight removes a manifest digest from the in-flight set.
|
||||
func (sb *ScanBroadcaster) removeInflight(digest string) {
|
||||
sb.inflightMu.Lock()
|
||||
defer sb.inflightMu.Unlock()
|
||||
delete(sb.inflight, digest)
|
||||
}
|
||||
|
||||
// signalCompletion non-blocking signal to wake the dispatch loop.
|
||||
func (sb *ScanBroadcaster) signalCompletion() {
|
||||
select {
|
||||
case sb.completionSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// triggerDiscovery non-blocking signal to trigger an early discovery pass.
|
||||
func (sb *ScanBroadcaster) triggerDiscovery() {
|
||||
select {
|
||||
case sb.discoverNow <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ func init() {
|
||||
lexutil.RegisterType(atproto.LayerCollection, &atproto.LayerRecord{})
|
||||
lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{})
|
||||
lexutil.RegisterType(atproto.StatsCollection, &atproto.StatsRecord{})
|
||||
lexutil.RegisterType(atproto.DailyStatsCollection, &atproto.DailyStatsRecord{})
|
||||
lexutil.RegisterType(atproto.ScanCollection, &atproto.ScanRecord{})
|
||||
lexutil.RegisterType(atproto.ImageConfigCollection, &atproto.ImageConfigRecord{})
|
||||
}
|
||||
|
||||
@@ -75,6 +75,215 @@ func (p *HoldPDS) IncrementStats(ctx context.Context, ownerDID, repository, oper
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementDailyStats increments the daily pull or push count for a repository
|
||||
// Creates a new daily record if none exists for the current date, updates existing otherwise
|
||||
// On first daily record for a repo, seeds it with existing cumulative stats so trend charts
|
||||
// have a starting data point for pre-daily-tracking history
|
||||
func (p *HoldPDS) IncrementDailyStats(ctx context.Context, ownerDID, repository, operation string) error {
|
||||
if operation != "pull" && operation != "push" {
|
||||
return fmt.Errorf("invalid operation: %s (must be 'pull' or 'push')", operation)
|
||||
}
|
||||
|
||||
date := time.Now().UTC().Format("2006-01-02")
|
||||
rkey := atproto.DailyStatsRecordKey(ownerDID, repository, date)
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
|
||||
// Try to get existing daily record for today
|
||||
_, existing, err := p.GetDailyStats(ctx, ownerDID, repository, date)
|
||||
if err != nil {
|
||||
// No daily record for today — check if we need to seed from cumulative stats
|
||||
seedPull, seedPush := p.getSeedCounts(ctx, ownerDID, repository)
|
||||
|
||||
record := atproto.NewDailyStatsRecord(ownerDID, repository, date)
|
||||
if operation == "pull" {
|
||||
record.PullCount = 1
|
||||
} else {
|
||||
record.PushCount = 1
|
||||
}
|
||||
record.UpdatedAt = now
|
||||
|
||||
// If there are existing cumulative counts but no daily records yet,
|
||||
// create a seed record for the previous day with the historical totals
|
||||
if seedPull > 0 || seedPush > 0 {
|
||||
if err := p.seedDailyStats(ctx, ownerDID, repository, date, seedPull, seedPush); err != nil {
|
||||
slog.Warn("Failed to seed daily stats from cumulative",
|
||||
"ownerDID", ownerDID,
|
||||
"repository", repository,
|
||||
"error", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, _, err := p.repomgr.PutRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create daily stats record: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("Created daily stats record",
|
||||
"ownerDID", ownerDID,
|
||||
"repository", repository,
|
||||
"date", date,
|
||||
"operation", operation)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Record exists — increment
|
||||
if operation == "pull" {
|
||||
existing.PullCount++
|
||||
} else {
|
||||
existing.PushCount++
|
||||
}
|
||||
existing.UpdatedAt = now
|
||||
|
||||
_, err = p.repomgr.UpdateRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update daily stats record: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("Updated daily stats record",
|
||||
"ownerDID", ownerDID,
|
||||
"repository", repository,
|
||||
"date", date,
|
||||
"operation", operation,
|
||||
"pullCount", existing.PullCount,
|
||||
"pushCount", existing.PushCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getSeedCounts returns the cumulative pull/push counts from io.atcr.hold.stats
|
||||
// minus any already-tracked daily counts. Returns (0, 0) if no seeding is needed.
|
||||
func (p *HoldPDS) getSeedCounts(ctx context.Context, ownerDID, repository string) (int64, int64) {
|
||||
// Check if any daily records already exist for this repo
|
||||
dailyStats, err := p.ListDailyStatsForRepo(ctx, ownerDID, repository)
|
||||
if err == nil && len(dailyStats) > 0 {
|
||||
// Daily records already exist — no seeding needed
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Get cumulative stats
|
||||
_, cumulative, err := p.GetStats(ctx, ownerDID, repository)
|
||||
if err != nil || cumulative == nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
return cumulative.PullCount, cumulative.PushCount
|
||||
}
|
||||
|
||||
// seedDailyStats creates a seed daily record with historical cumulative totals
|
||||
// dated to the day before the first real daily record
|
||||
func (p *HoldPDS) seedDailyStats(ctx context.Context, ownerDID, repository, currentDate string, pullCount, pushCount int64) error {
|
||||
// Parse current date and go back one day for the seed record
|
||||
t, err := time.Parse("2006-01-02", currentDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse date: %w", err)
|
||||
}
|
||||
seedDate := t.AddDate(0, 0, -1).Format("2006-01-02")
|
||||
rkey := atproto.DailyStatsRecordKey(ownerDID, repository, seedDate)
|
||||
|
||||
record := atproto.NewDailyStatsRecord(ownerDID, repository, seedDate)
|
||||
record.PullCount = pullCount
|
||||
record.PushCount = pushCount
|
||||
record.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
|
||||
_, _, err = p.repomgr.PutRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create seed daily stats record: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Seeded daily stats from cumulative totals",
|
||||
"ownerDID", ownerDID,
|
||||
"repository", repository,
|
||||
"seedDate", seedDate,
|
||||
"pullCount", pullCount,
|
||||
"pushCount", pushCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDailyStats retrieves the daily stats record for a repository on a specific date
|
||||
func (p *HoldPDS) GetDailyStats(ctx context.Context, ownerDID, repository, date string) (cid.Cid, *atproto.DailyStatsRecord, error) {
|
||||
rkey := atproto.DailyStatsRecordKey(ownerDID, repository, date)
|
||||
|
||||
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, atproto.DailyStatsCollection, rkey, cid.Undef)
|
||||
if err != nil {
|
||||
return cid.Undef, nil, err
|
||||
}
|
||||
|
||||
dailyRecord, ok := val.(*atproto.DailyStatsRecord)
|
||||
if !ok {
|
||||
return cid.Undef, nil, fmt.Errorf("unexpected type for daily stats record: %T", val)
|
||||
}
|
||||
|
||||
return recordCID, dailyRecord, nil
|
||||
}
|
||||
|
||||
// ListDailyStatsForRepo returns all daily stats records for a specific owner+repo
|
||||
func (p *HoldPDS) ListDailyStatsForRepo(ctx context.Context, ownerDID, repository string) ([]*atproto.DailyStatsRecord, error) {
|
||||
session, err := p.carstore.ReadOnlySession(p.uid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get read-only session: %w", err)
|
||||
}
|
||||
|
||||
head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get repo head: %w", err)
|
||||
}
|
||||
|
||||
if !head.Defined() {
|
||||
return []*atproto.DailyStatsRecord{}, nil
|
||||
}
|
||||
|
||||
r, err := repo.OpenRepo(ctx, session, head)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open repo: %w", err)
|
||||
}
|
||||
|
||||
var stats []*atproto.DailyStatsRecord
|
||||
|
||||
err = r.ForEach(ctx, atproto.DailyStatsCollection, func(k string, v cid.Cid) error {
|
||||
parts := strings.Split(k, "/")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
actualCollection := strings.Join(parts[:len(parts)-1], "/")
|
||||
if actualCollection != atproto.DailyStatsCollection {
|
||||
return repo.ErrDoneIterating
|
||||
}
|
||||
|
||||
_, recBytes, err := r.GetRecordBytes(ctx, k)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get daily stats record bytes", "key", k, "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if recBytes == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var dailyRecord atproto.DailyStatsRecord
|
||||
if err := dailyRecord.UnmarshalCBOR(bytes.NewReader(*recBytes)); err != nil {
|
||||
slog.Warn("Failed to unmarshal daily stats record", "key", k, "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if dailyRecord.OwnerDID == ownerDID && dailyRecord.Repository == repository {
|
||||
stats = append(stats, &dailyRecord)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, repo.ErrDoneIterating) {
|
||||
// Expected
|
||||
} else if strings.Contains(err.Error(), "not found") {
|
||||
return []*atproto.DailyStatsRecord{}, nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to iterate daily stats records: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetStats retrieves the stats record for a repository
|
||||
// Returns nil, nil if no stats record exists
|
||||
func (p *HoldPDS) GetStats(ctx context.Context, ownerDID, repository string) (cid.Cid, *atproto.StatsRecord, error) {
|
||||
|
||||
@@ -122,6 +122,8 @@ func (wp *WorkerPool) worker(ctx context.Context, id int) {
|
||||
// container images so Syft/Grype can't analyze their layers.
|
||||
var unscannableConfigTypes = map[string]bool{
|
||||
"application/vnd.cncf.helm.config.v1+json": true, // Helm charts
|
||||
"application/vnd.in-toto+json": true, // In-toto attestations
|
||||
"application/vnd.dsse.envelope.v1+json": true, // DSSE envelopes (SLSA)
|
||||
}
|
||||
|
||||
func (wp *WorkerPool) processJob(ctx context.Context, job *scanner.ScanJob) (*scanner.ScanResult, error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
goat account login -u evan.jarrett.net -p "${APP_PASSWORD}"
|
||||
goat account login -u "${TANGLED_REPO_DID}" -p "${APP_PASSWORD}"
|
||||
TAG_HASH=$(git rev-parse "$TAG") &&
|
||||
TAG_BYTES=$(echo -n "$TAG_HASH" | xxd -r -p | base64 | tr -d '=') &&
|
||||
BLOB_OUTPUT=$(goat blob upload "$ARTIFACT_PATH") &&
|
||||
|
||||
Reference in New Issue
Block a user