Compare commits

..

3 Commits

Author SHA1 Message Date
benvin 7f569cdcdc Merge branch 'master' into benvin/local-terraform-registry
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
2026-06-22 23:48:25 +10:00
unkinben ab44271e82 feat: add local repository type with repo_type field
Introduces repo_type (remote/local) as a separate axis from package_type
so that any package type can be hosted locally. A terraform local repo
is package_type=terraform + repo_type=local.

- Remote model gains RepoType field (defaults to "remote")
- Database schema adds repo_type column with migration for existing DBs
- V1 proxy adds /api/v1/local/{name}/* route for serving local files
- V2 upload via PUT /api/v2/remotes/{name}/files/{ns}/{type}/{file}.zip
  validates filename matches terraform-provider-{type}_{ver}_{os}_{arch}.zip
  and returns 409 on duplicate (no overwrites)
- index.json and {version}.json are computed on-the-fly from uploaded zips
  rather than stored as separate files
- V2 create validates repo_type and requires base_url only for remotes
2026-06-22 22:51:41 +10:00
benvin a481a5c3b7 feat: tree view for cached objects, top-files stats on dashboard (#48)
- Objects page renders paths as a collapsible tree instead of flat list
  with expand/collapse all, aggregated size/hits per directory
- Dashboard gains top-files-by-hits and top-files-by-bandwidth tables
- Backend: new /api/v2/stats/top-files-by-hits and
  /api/v2/stats/top-files-by-bandwidth endpoints
- Raised per_page max to 5000 for objects listing

---------

Co-authored-by: Ben Vincent <ben@unkin.net>
Reviewed-on: #48
2026-06-22 22:49:56 +10:00
17 changed files with 1063 additions and 118 deletions
+84 -2
View File
@@ -6,28 +6,38 @@ import (
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"regexp"
"strings"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
v2 "git.unkin.net/unkin/artifactapi/internal/api/v2"
"git.unkin.net/unkin/artifactapi/internal/database" "git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/provider" "git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/proxy" "git.unkin.net/unkin/artifactapi/internal/proxy"
"git.unkin.net/unkin/artifactapi/internal/storage"
"git.unkin.net/unkin/artifactapi/internal/virtual" "git.unkin.net/unkin/artifactapi/internal/virtual"
"git.unkin.net/unkin/artifactapi/pkg/models"
) )
var semverRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?$`)
type ProxyHandler struct { type ProxyHandler struct {
engine *proxy.Engine engine *proxy.Engine
virtualEngine *virtual.Engine virtualEngine *virtual.Engine
db *database.DB db *database.DB
store *storage.S3
local *v2.LocalHandler
} }
func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *database.DB) *ProxyHandler { func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *database.DB, store *storage.S3, local *v2.LocalHandler) *ProxyHandler {
return &ProxyHandler{engine: engine, virtualEngine: virtualEngine, db: db} return &ProxyHandler{engine: engine, virtualEngine: virtualEngine, db: db, store: store, local: local}
} }
func (h *ProxyHandler) Routes() chi.Router { func (h *ProxyHandler) Routes() chi.Router {
r := chi.NewRouter() r := chi.NewRouter()
r.Get("/remote/{remoteName}/*", h.handleProxy) r.Get("/remote/{remoteName}/*", h.handleProxy)
r.Get("/local/{localName}/*", h.handleLocal)
r.Get("/virtual/{virtualName}/*", h.handleVirtual) r.Get("/virtual/{virtualName}/*", h.handleVirtual)
return r return r
} }
@@ -95,6 +105,78 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) {
w.Write(body) w.Write(body)
} }
func (h *ProxyHandler) handleLocal(w http.ResponseWriter, r *http.Request) {
localName := chi.URLParam(r, "localName")
path := chi.URLParam(r, "*")
remote, err := h.db.GetRemote(r.Context(), localName)
if err != nil {
http.Error(w, fmt.Sprintf("local %q not found", localName), http.StatusNotFound)
return
}
if remote.PackageType == models.PackageTerraform {
if h.serveTerraformMirror(w, r, remote, path) {
return
}
}
h.serveLocalFile(w, r, localName, path)
}
func (h *ProxyHandler) serveTerraformMirror(w http.ResponseWriter, r *http.Request, remote *models.Remote, path string) bool {
parts := strings.Split(path, "/")
if len(parts) < 3 {
return false
}
namespace, typeName := parts[0], parts[1]
tail := parts[2]
if tail == "index.json" {
h.local.ServeTerraformIndex(w, r, remote.Name, namespace, typeName)
return true
}
if strings.HasSuffix(tail, ".json") {
version := strings.TrimSuffix(tail, ".json")
if semverRe.MatchString(version) {
h.local.ServeTerraformVersionDoc(w, r, remote.Name, namespace, typeName, version)
return true
}
}
return false
}
func (h *ProxyHandler) serveLocalFile(w http.ResponseWriter, r *http.Request, repoName, path string) {
file, err := h.db.GetLocalFile(r.Context(), repoName, path)
if err != nil {
slog.Error("local file lookup failed", "repo", repoName, "path", path, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if file == nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
s3Key := storage.BlobKey(file.ContentHash[len("sha256:"):])
reader, info, err := h.store.Download(r.Context(), s3Key)
if err != nil {
slog.Error("local file download failed", "repo", repoName, "path", path, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
defer reader.Close()
w.Header().Set("Content-Type", info.ContentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size))
w.Header().Set("X-Artifact-Source", "local")
w.WriteHeader(http.StatusOK)
io.Copy(w, reader)
}
func scheme(r *http.Request) string { func scheme(r *http.Request) string {
if r.TLS != nil { if r.TLS != nil {
return "https" return "https"
+296
View File
@@ -0,0 +1,296 @@
package v2
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/storage"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
var providerZipRe = regexp.MustCompile(
`^terraform-provider-([a-zA-Z0-9_-]+)_([0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?)_([a-z0-9]+)_([a-z0-9]+)\.zip$`,
)
type LocalHandler struct {
db *database.DB
store *storage.S3
cas *storage.CAS
}
func NewLocalHandler(db *database.DB, store *storage.S3) *LocalHandler {
return &LocalHandler{
db: db,
store: store,
cas: storage.NewCAS(store),
}
}
func (h *LocalHandler) Routes() chi.Router {
r := chi.NewRouter()
r.Put("/*", h.upload)
r.Get("/*", h.download)
r.Delete("/*", h.remove)
return r
}
func (h *LocalHandler) upload(w http.ResponseWriter, r *http.Request) {
repoName := chi.URLParam(r, "name")
filePath := chi.URLParam(r, "*")
if filePath == "" {
http.Error(w, "file path required", http.StatusBadRequest)
return
}
remote, err := h.db.GetRemote(r.Context(), repoName)
if err != nil {
http.Error(w, fmt.Sprintf("remote %q not found", repoName), http.StatusNotFound)
return
}
if remote.RepoType != models.RepoTypeLocal {
http.Error(w, "upload only allowed for local repository types", http.StatusBadRequest)
return
}
if remote.PackageType == models.PackageTerraform {
h.uploadTerraformProvider(w, r, remote, filePath)
return
}
h.uploadGeneric(w, r, remote, filePath)
}
func (h *LocalHandler) uploadTerraformProvider(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string) {
parts := strings.Split(filePath, "/")
if len(parts) != 3 {
http.Error(w, "path must be {namespace}/{type}/{filename}.zip", http.StatusBadRequest)
return
}
namespace, typeName, filename := parts[0], parts[1], parts[2]
m := providerZipRe.FindStringSubmatch(filename)
if m == nil {
http.Error(w, fmt.Sprintf(
"filename %q does not match terraform-provider-{type}_{version}_{os}_{arch}.zip",
filename,
), http.StatusBadRequest)
return
}
fileType, version, os, arch := m[1], m[2], m[3], m[4]
if fileType != typeName {
http.Error(w, fmt.Sprintf(
"provider type in filename %q does not match path type %q",
fileType, typeName,
), http.StatusBadRequest)
return
}
storagePath := fmt.Sprintf("%s/%s/%s", namespace, typeName, filename)
existing, err := h.db.GetLocalFile(r.Context(), remote.Name, storagePath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if existing != nil {
http.Error(w, fmt.Sprintf(
"provider %s/%s version %s for %s_%s already exists; overwrites are not allowed",
namespace, typeName, version, os, arch,
), http.StatusConflict)
return
}
result, err := h.cas.Store(r.Context(), r.Body, "application/zip")
if err != nil {
http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError)
return
}
if err := h.db.UpsertBlob(r.Context(), result.ContentHash, result.S3Key, result.SizeBytes, "application/zip"); err != nil {
http.Error(w, fmt.Sprintf("record blob: %v", err), http.StatusInternalServerError)
return
}
if err := h.db.CreateLocalFile(r.Context(), remote.Name, storagePath, result.ContentHash); err != nil {
if errors.Is(err, database.ErrAlreadyExists) {
http.Error(w, fmt.Sprintf("file %q already exists; overwrites are not allowed", storagePath), http.StatusConflict)
return
}
http.Error(w, fmt.Sprintf("record file: %v", err), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"namespace": namespace,
"type": typeName,
"version": version,
"os": os,
"arch": arch,
"content_hash": result.ContentHash,
"size_bytes": result.SizeBytes,
})
}
func (h *LocalHandler) uploadGeneric(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string) {
existing, err := h.db.GetLocalFile(r.Context(), remote.Name, filePath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if existing != nil {
http.Error(w, fmt.Sprintf("file %q already exists; overwrites are not allowed", filePath), http.StatusConflict)
return
}
contentType := "application/octet-stream"
if ct := r.Header.Get("Content-Type"); ct != "" && ct != "application/octet-stream" {
contentType = ct
}
result, err := h.cas.Store(r.Context(), r.Body, contentType)
if err != nil {
http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError)
return
}
if err := h.db.UpsertBlob(r.Context(), result.ContentHash, result.S3Key, result.SizeBytes, contentType); err != nil {
http.Error(w, fmt.Sprintf("record blob: %v", err), http.StatusInternalServerError)
return
}
if err := h.db.CreateLocalFile(r.Context(), remote.Name, filePath, result.ContentHash); err != nil {
if errors.Is(err, database.ErrAlreadyExists) {
http.Error(w, fmt.Sprintf("file %q already exists; overwrites are not allowed", filePath), http.StatusConflict)
return
}
http.Error(w, fmt.Sprintf("record file: %v", err), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"path": filePath,
"content_hash": result.ContentHash,
"size_bytes": result.SizeBytes,
})
}
func (h *LocalHandler) download(w http.ResponseWriter, r *http.Request) {
repoName := chi.URLParam(r, "name")
filePath := chi.URLParam(r, "*")
file, err := h.db.GetLocalFile(r.Context(), repoName, filePath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if file == nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
s3Key := storage.BlobKey(file.ContentHash[len("sha256:"):])
reader, info, err := h.store.Download(r.Context(), s3Key)
if err != nil {
http.Error(w, fmt.Sprintf("download failed: %v", err), http.StatusInternalServerError)
return
}
defer reader.Close()
w.Header().Set("Content-Type", info.ContentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size))
w.WriteHeader(http.StatusOK)
io.Copy(w, reader)
}
func (h *LocalHandler) remove(w http.ResponseWriter, r *http.Request) {
repoName := chi.URLParam(r, "name")
filePath := chi.URLParam(r, "*")
if err := h.db.DeleteLocalFile(r.Context(), repoName, filePath); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
type terraformIndex struct {
Versions map[string]json.RawMessage `json:"versions"`
}
type terraformVersionDoc struct {
Archives map[string]terraformArchive `json:"archives"`
}
type terraformArchive struct {
URL string `json:"url"`
Hashes []string `json:"hashes,omitempty"`
}
func (h *LocalHandler) ServeTerraformIndex(w http.ResponseWriter, r *http.Request, repoName, namespace, typeName string) {
prefix := fmt.Sprintf("%s/%s/", namespace, typeName)
files, err := h.db.ListLocalFilesByPrefix(r.Context(), repoName, prefix)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
versions := map[string]json.RawMessage{}
for _, f := range files {
filename := strings.TrimPrefix(f.FilePath, prefix)
m := providerZipRe.FindStringSubmatch(filename)
if m == nil {
continue
}
versions[m[2]] = json.RawMessage(`{}`)
}
if len(versions) == 0 {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(terraformIndex{Versions: versions})
}
func (h *LocalHandler) ServeTerraformVersionDoc(w http.ResponseWriter, r *http.Request, repoName, namespace, typeName, version string) {
prefix := fmt.Sprintf("%s/%s/terraform-provider-%s_%s_", namespace, typeName, typeName, version)
files, err := h.db.ListLocalFilesByPrefix(r.Context(), repoName, prefix)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
archives := map[string]terraformArchive{}
for _, f := range files {
filename := strings.TrimPrefix(f.FilePath, fmt.Sprintf("%s/%s/", namespace, typeName))
m := providerZipRe.FindStringSubmatch(filename)
if m == nil || m[2] != version {
continue
}
platform := m[3] + "_" + m[4]
archive := terraformArchive{URL: filename}
if f.ContentHash != "" {
archive.Hashes = []string{"zh:" + strings.TrimPrefix(f.ContentHash, "sha256:")}
}
archives[platform] = archive
}
if len(archives) == 0 {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(terraformVersionDoc{Archives: archives})
}
+1 -1
View File
@@ -28,7 +28,7 @@ func (h *ObjectsHandler) Routes() chi.Router {
func (h *ObjectsHandler) list(w http.ResponseWriter, r *http.Request) { func (h *ObjectsHandler) list(w http.ResponseWriter, r *http.Request) {
remoteName := chi.URLParam(r, "name") remoteName := chi.URLParam(r, "name")
limit, _ := strconv.Atoi(r.URL.Query().Get("per_page")) limit, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
if limit <= 0 || limit > 100 { if limit <= 0 || limit > 5000 {
limit = 50 limit = 50
} }
page, _ := strconv.Atoi(r.URL.Query().Get("page")) page, _ := strconv.Atoi(r.URL.Query().Get("page"))
+11
View File
@@ -58,6 +58,17 @@ func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("invalid package type: %q", remote.PackageType), http.StatusBadRequest) http.Error(w, fmt.Sprintf("invalid package type: %q", remote.PackageType), http.StatusBadRequest)
return return
} }
if remote.RepoType == "" {
remote.RepoType = models.RepoTypeRemote
}
if !remote.RepoType.Valid() {
http.Error(w, fmt.Sprintf("invalid repo type: %q", remote.RepoType), http.StatusBadRequest)
return
}
if remote.RepoType == models.RepoTypeRemote && remote.BaseURL == "" {
http.Error(w, "base_url is required for remote repositories", http.StatusBadRequest)
return
}
if err := h.db.CreateRemote(r.Context(), &remote); err != nil { if err := h.db.CreateRemote(r.Context(), &remote); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
+20
View File
@@ -20,6 +20,8 @@ func (h *StatsHandler) Routes() chi.Router {
r := chi.NewRouter() r := chi.NewRouter()
r.Get("/", h.overview) r.Get("/", h.overview)
r.Get("/top-remotes", h.topRemotes) r.Get("/top-remotes", h.topRemotes)
r.Get("/top-files-by-hits", h.topFilesByHits)
r.Get("/top-files-by-bandwidth", h.topFilesByBandwidth)
return r return r
} }
@@ -40,3 +42,21 @@ func (h *StatsHandler) topRemotes(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, remotes) writeJSON(w, http.StatusOK, remotes)
} }
func (h *StatsHandler) topFilesByHits(w http.ResponseWriter, r *http.Request) {
files, err := h.db.GetTopFilesByHits(r.Context(), 10)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, files)
}
func (h *StatsHandler) topFilesByBandwidth(w http.ResponseWriter, r *http.Request) {
files, err := h.db.GetTopFilesByBandwidth(r.Context(), 10)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, files)
}
+105
View File
@@ -0,0 +1,105 @@
package database
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type LocalFile struct {
ID int64 `json:"id"`
RepoName string `json:"repo_name"`
FilePath string `json:"file_path"`
ContentHash string `json:"content_hash"`
CreatedAt time.Time `json:"created_at"`
}
var ErrAlreadyExists = fmt.Errorf("file already exists")
func (db *DB) CreateLocalFile(ctx context.Context, repoName, filePath, contentHash string) error {
_, err := db.Pool.Exec(ctx, `
INSERT INTO local_files (repo_name, file_path, content_hash)
VALUES ($1, $2, $3)
`, repoName, filePath, contentHash)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrAlreadyExists
}
return err
}
return nil
}
func (db *DB) GetLocalFile(ctx context.Context, repoName, filePath string) (*LocalFile, error) {
row := db.Pool.QueryRow(ctx, `
SELECT id, repo_name, file_path, content_hash, created_at
FROM local_files
WHERE repo_name = $1 AND file_path = $2
`, repoName, filePath)
var f LocalFile
if err := row.Scan(&f.ID, &f.RepoName, &f.FilePath, &f.ContentHash, &f.CreatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &f, nil
}
func (db *DB) ListLocalFiles(ctx context.Context, repoName string, limit, offset int) ([]LocalFile, error) {
rows, err := db.Pool.Query(ctx, `
SELECT id, repo_name, file_path, content_hash, created_at
FROM local_files
WHERE repo_name = $1
ORDER BY file_path
LIMIT $2 OFFSET $3
`, repoName, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var files []LocalFile
for rows.Next() {
var f LocalFile
if err := rows.Scan(&f.ID, &f.RepoName, &f.FilePath, &f.ContentHash, &f.CreatedAt); err != nil {
return nil, err
}
files = append(files, f)
}
return files, rows.Err()
}
func (db *DB) ListLocalFilesByPrefix(ctx context.Context, repoName, prefix string) ([]LocalFile, error) {
rows, err := db.Pool.Query(ctx, `
SELECT id, repo_name, file_path, content_hash, created_at
FROM local_files
WHERE repo_name = $1 AND file_path LIKE $2
ORDER BY file_path
`, repoName, prefix+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var files []LocalFile
for rows.Next() {
var f LocalFile
if err := rows.Scan(&f.ID, &f.RepoName, &f.FilePath, &f.ContentHash, &f.CreatedAt); err != nil {
return nil, err
}
files = append(files, f)
}
return files, rows.Err()
}
func (db *DB) DeleteLocalFile(ctx context.Context, repoName, filePath string) error {
_, err := db.Pool.Exec(ctx, `DELETE FROM local_files WHERE repo_name = $1 AND file_path = $2`, repoName, filePath)
return err
}
+4 -1
View File
@@ -42,7 +42,8 @@ func (db *DB) migrate() error {
CREATE TABLE IF NOT EXISTS remotes ( CREATE TABLE IF NOT EXISTS remotes (
name TEXT PRIMARY KEY, name TEXT PRIMARY KEY,
package_type TEXT NOT NULL, package_type TEXT NOT NULL,
base_url TEXT NOT NULL, repo_type TEXT DEFAULT 'remote',
base_url TEXT NOT NULL DEFAULT '',
description TEXT DEFAULT '', description TEXT DEFAULT '',
username TEXT DEFAULT '', username TEXT DEFAULT '',
password TEXT DEFAULT '', password TEXT DEFAULT '',
@@ -121,6 +122,8 @@ func (db *DB) migrate() error {
); );
CREATE INDEX IF NOT EXISTS idx_access_log_remote_time ON access_log(remote_name, created_at); CREATE INDEX IF NOT EXISTS idx_access_log_remote_time ON access_log(remote_name, created_at);
ALTER TABLE remotes ADD COLUMN IF NOT EXISTS repo_type TEXT DEFAULT 'remote';
`) `)
return err return err
} }
+12 -12
View File
@@ -6,7 +6,7 @@ import (
"git.unkin.net/unkin/artifactapi/pkg/models" "git.unkin.net/unkin/artifactapi/pkg/models"
) )
const remoteCols = `name, package_type, base_url, description, username, password, const remoteCols = `name, package_type, repo_type, base_url, description, username, password,
immutable_ttl, mutable_ttl, check_mutable, immutable_ttl, mutable_ttl, check_mutable,
patterns, blocklist, mutable_patterns, immutable_patterns, patterns, blocklist, mutable_patterns, immutable_patterns,
ban_tags_enabled, ban_tags, ban_tags_enabled, ban_tags,
@@ -15,7 +15,7 @@ const remoteCols = `name, package_type, base_url, description, username, passwor
func scanRemote(scanner interface{ Scan(...any) error }, r *models.Remote) error { func scanRemote(scanner interface{ Scan(...any) error }, r *models.Remote) error {
return scanner.Scan( return scanner.Scan(
&r.Name, &r.PackageType, &r.BaseURL, &r.Description, &r.Username, &r.Password, &r.Name, &r.PackageType, &r.RepoType, &r.BaseURL, &r.Description, &r.Username, &r.Password,
&r.ImmutableTTL, &r.MutableTTL, &r.CheckMutable, &r.ImmutableTTL, &r.MutableTTL, &r.CheckMutable,
&r.Patterns, &r.Blocklist, &r.MutablePatterns, &r.ImmutablePatterns, &r.Patterns, &r.Blocklist, &r.MutablePatterns, &r.ImmutablePatterns,
&r.BanTagsEnabled, &r.BanTags, &r.BanTagsEnabled, &r.BanTags,
@@ -54,15 +54,15 @@ func (db *DB) ListRemotes(ctx context.Context) ([]models.Remote, error) {
func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error { func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error {
_, err := db.Pool.Exec(ctx, ` _, err := db.Pool.Exec(ctx, `
INSERT INTO remotes ( INSERT INTO remotes (
name, package_type, base_url, description, username, password, name, package_type, repo_type, base_url, description, username, password,
immutable_ttl, mutable_ttl, check_mutable, immutable_ttl, mutable_ttl, check_mutable,
patterns, blocklist, mutable_patterns, immutable_patterns, patterns, blocklist, mutable_patterns, immutable_patterns,
ban_tags_enabled, ban_tags, ban_tags_enabled, ban_tags,
quarantine_enabled, quarantine_days, stale_on_error, quarantine_enabled, quarantine_days, stale_on_error,
releases_remote, managed_by releases_remote, managed_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
`, `,
r.Name, r.PackageType, r.BaseURL, r.Description, r.Username, r.Password, r.Name, r.PackageType, r.RepoType, r.BaseURL, r.Description, r.Username, r.Password,
r.ImmutableTTL, r.MutableTTL, r.CheckMutable, r.ImmutableTTL, r.MutableTTL, r.CheckMutable,
r.Patterns, r.Blocklist, r.MutablePatterns, r.ImmutablePatterns, r.Patterns, r.Blocklist, r.MutablePatterns, r.ImmutablePatterns,
r.BanTagsEnabled, r.BanTags, r.BanTagsEnabled, r.BanTags,
@@ -75,15 +75,15 @@ func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error {
func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error { func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error {
_, err := db.Pool.Exec(ctx, ` _, err := db.Pool.Exec(ctx, `
UPDATE remotes SET UPDATE remotes SET
package_type=$2, base_url=$3, description=$4, username=$5, password=$6, package_type=$2, repo_type=$3, base_url=$4, description=$5, username=$6, password=$7,
immutable_ttl=$7, mutable_ttl=$8, check_mutable=$9, immutable_ttl=$8, mutable_ttl=$9, check_mutable=$10,
patterns=$10, blocklist=$11, mutable_patterns=$12, immutable_patterns=$13, patterns=$11, blocklist=$12, mutable_patterns=$13, immutable_patterns=$14,
ban_tags_enabled=$14, ban_tags=$15, ban_tags_enabled=$15, ban_tags=$16,
quarantine_enabled=$16, quarantine_days=$17, stale_on_error=$18, quarantine_enabled=$17, quarantine_days=$18, stale_on_error=$19,
releases_remote=$19, managed_by=$20, updated_at=NOW() releases_remote=$20, managed_by=$21, updated_at=NOW()
WHERE name=$1 WHERE name=$1
`, `,
r.Name, r.PackageType, r.BaseURL, r.Description, r.Username, r.Password, r.Name, r.PackageType, r.RepoType, r.BaseURL, r.Description, r.Username, r.Password,
r.ImmutableTTL, r.MutableTTL, r.CheckMutable, r.ImmutableTTL, r.MutableTTL, r.CheckMutable,
r.Patterns, r.Blocklist, r.MutablePatterns, r.ImmutablePatterns, r.Patterns, r.Blocklist, r.MutablePatterns, r.ImmutablePatterns,
r.BanTagsEnabled, r.BanTags, r.BanTagsEnabled, r.BanTags,
+65
View File
@@ -76,3 +76,68 @@ func (db *DB) GetTopRemotes(ctx context.Context, limit int) ([]RemoteStatRow, er
} }
return result, rows.Err() return result, rows.Err()
} }
type FileStatRow struct {
RemoteName string `json:"remote_name"`
Path string `json:"path"`
AccessCount int64 `json:"access_count"`
SizeBytes int64 `json:"size_bytes"`
}
func (db *DB) GetTopFilesByHits(ctx context.Context, limit int) ([]FileStatRow, error) {
rows, err := db.Pool.Query(ctx, `
SELECT a.remote_name, a.path, a.access_count, b.size_bytes
FROM artifacts a
JOIN blobs b ON a.content_hash = b.content_hash
ORDER BY a.access_count DESC
LIMIT $1
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var result []FileStatRow
for rows.Next() {
var r FileStatRow
if err := rows.Scan(&r.RemoteName, &r.Path, &r.AccessCount, &r.SizeBytes); err != nil {
return nil, err
}
result = append(result, r)
}
return result, rows.Err()
}
type BandwidthStatRow struct {
RemoteName string `json:"remote_name"`
Path string `json:"path"`
Bandwidth int64 `json:"bandwidth"`
Requests int64 `json:"requests"`
}
func (db *DB) GetTopFilesByBandwidth(ctx context.Context, limit int) ([]BandwidthStatRow, error) {
rows, err := db.Pool.Query(ctx, `
SELECT remote_name, path,
COALESCE(SUM(size_bytes), 0) AS bandwidth,
COUNT(*) AS requests
FROM access_log
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY remote_name, path
ORDER BY bandwidth DESC
LIMIT $1
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var result []BandwidthStatRow
for rows.Next() {
var r BandwidthStatRow
if err := rows.Scan(&r.RemoteName, &r.Path, &r.Bandwidth, &r.Requests); err != nil {
return nil, err
}
result = append(result, r)
}
return result, rows.Err()
}
+9 -1
View File
@@ -91,7 +91,9 @@ func (s *Server) routes() chi.Router {
r.Get("/health", s.handleHealth) r.Get("/health", s.handleHealth)
r.Get("/", s.handleRoot) r.Get("/", s.handleRoot)
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db) localHandler := v2.NewLocalHandler(s.db, s.store)
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, localHandler)
r.Mount("/api/v1", proxyHandler.Routes()) r.Mount("/api/v1", proxyHandler.Routes())
remotesHandler := v2.NewRemotesHandler(s.db) remotesHandler := v2.NewRemotesHandler(s.db)
@@ -114,6 +116,12 @@ func (s *Server) routes() chi.Router {
r.Get("/", objHandler.Routes().ServeHTTP) r.Get("/", objHandler.Routes().ServeHTTP)
r.Delete("/*", objHandler.Routes().ServeHTTP) r.Delete("/*", objHandler.Routes().ServeHTTP)
}) })
r.Route("/remotes/{name}/files", func(r chi.Router) {
r.Put("/*", localHandler.Routes().ServeHTTP)
r.Get("/*", localHandler.Routes().ServeHTTP)
r.Delete("/*", localHandler.Routes().ServeHTTP)
})
}) })
return r return r
+33 -1
View File
@@ -1,10 +1,42 @@
package models package models
import "time" import (
"fmt"
"time"
)
type RepoType string
const (
RepoTypeRemote RepoType = "remote"
RepoTypeLocal RepoType = "local"
)
var validRepoTypes = map[RepoType]bool{
RepoTypeRemote: true,
RepoTypeLocal: true,
}
func (r RepoType) Valid() bool {
return validRepoTypes[r]
}
func (r RepoType) String() string {
return string(r)
}
func ParseRepoType(s string) (RepoType, error) {
rt := RepoType(s)
if !rt.Valid() {
return "", fmt.Errorf("unknown repo type: %q", s)
}
return rt, nil
}
type Remote struct { type Remote struct {
Name string `json:"name"` Name string `json:"name"`
PackageType PackageType `json:"package_type"` PackageType PackageType `json:"package_type"`
RepoType RepoType `json:"repo_type"`
BaseURL string `json:"base_url"` BaseURL string `json:"base_url"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Username string `json:"-"` Username string `json:"-"`
+3 -1
View File
@@ -1,4 +1,4 @@
import type { Remote, Virtual, Artifact, OverviewStats, RemoteStatRow, HealthStatus, ProbeResult } from './types'; import type { Remote, Virtual, Artifact, OverviewStats, RemoteStatRow, FileStatRow, BandwidthStatRow, HealthStatus, ProbeResult } from './types';
const BASE = ''; const BASE = '';
@@ -19,6 +19,8 @@ export const api = {
health: () => fetchJSON<HealthStatus>('/api/v2/health'), health: () => fetchJSON<HealthStatus>('/api/v2/health'),
stats: () => fetchJSON<OverviewStats>('/api/v2/stats'), stats: () => fetchJSON<OverviewStats>('/api/v2/stats'),
topRemotes: () => fetchJSON<RemoteStatRow[]>('/api/v2/stats/top-remotes'), topRemotes: () => fetchJSON<RemoteStatRow[]>('/api/v2/stats/top-remotes'),
topFilesByHits: () => fetchJSON<FileStatRow[]>('/api/v2/stats/top-files-by-hits'),
topFilesByBandwidth: () => fetchJSON<BandwidthStatRow[]>('/api/v2/stats/top-files-by-bandwidth'),
listRemotes: () => fetchJSON<Remote[]>('/api/v2/remotes'), listRemotes: () => fetchJSON<Remote[]>('/api/v2/remotes'),
getRemote: (name: string) => fetchJSON<Remote>(`/api/v2/remotes/${name}`), getRemote: (name: string) => fetchJSON<Remote>(`/api/v2/remotes/${name}`),
+15
View File
@@ -1,6 +1,7 @@
export interface Remote { export interface Remote {
name: string; name: string;
package_type: string; package_type: string;
repo_type: string;
base_url: string; base_url: string;
description: string; description: string;
username?: string; username?: string;
@@ -62,6 +63,20 @@ export interface RemoteStatRow {
requests_30d: number; requests_30d: number;
} }
export interface FileStatRow {
remote_name: string;
path: string;
access_count: number;
size_bytes: number;
}
export interface BandwidthStatRow {
remote_name: string;
path: string;
bandwidth: number;
requests: number;
}
export interface HealthStatus { export interface HealthStatus {
status: string; status: string;
postgres: string; postgres: string;
+35
View File
@@ -45,3 +45,38 @@
font-size: 0.9em; font-size: 0.9em;
padding: 32px 0; padding: 32px 0;
} }
.top-files-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-top: 8px;
}
@media (max-width: 1100px) {
.top-files-grid {
grid-template-columns: 1fr;
}
}
.file-link {
display: flex;
flex-direction: column;
gap: 2px;
text-decoration: none;
color: inherit;
}
.file-link:hover .file-path {
text-decoration: underline;
}
.file-remote {
font-size: 0.75em;
color: var(--text-muted);
}
.file-path {
font-size: 0.82em;
word-break: break-all;
}
+79 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { api } from '../api/client'; import { api } from '../api/client';
import type { OverviewStats, RemoteStatRow, HealthStatus } from '../api/types'; import type { OverviewStats, RemoteStatRow, FileStatRow, BandwidthStatRow, HealthStatus } from '../api/types';
import { StatsCard } from '../components/StatsCard'; import { StatsCard } from '../components/StatsCard';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { DataTable } from '../components/DataTable'; import { DataTable } from '../components/DataTable';
@@ -11,14 +11,24 @@ import './Dashboard.css';
export function Dashboard() { export function Dashboard() {
const [stats, setStats] = useState<OverviewStats | null>(null); const [stats, setStats] = useState<OverviewStats | null>(null);
const [topRemotes, setTopRemotes] = useState<RemoteStatRow[]>([]); const [topRemotes, setTopRemotes] = useState<RemoteStatRow[]>([]);
const [topFilesByHits, setTopFilesByHits] = useState<FileStatRow[]>([]);
const [topFilesByBW, setTopFilesByBW] = useState<BandwidthStatRow[]>([]);
const [health, setHealth] = useState<HealthStatus | null>(null); const [health, setHealth] = useState<HealthStatus | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
Promise.all([api.stats(), api.topRemotes(), api.health()]) Promise.all([
.then(([s, tr, h]) => { api.stats(),
api.topRemotes(),
api.topFilesByHits(),
api.topFilesByBandwidth(),
api.health(),
])
.then(([s, tr, tfh, tfb, h]) => {
setStats(s); setStats(s);
setTopRemotes(tr || []); setTopRemotes(tr || []);
setTopFilesByHits(tfh || []);
setTopFilesByBW(tfb || []);
setHealth(h); setHealth(h);
}) })
.catch(e => setError(e.message)); .catch(e => setError(e.message));
@@ -87,6 +97,72 @@ export function Dashboard() {
data={topRemotes} data={topRemotes}
emptyMessage="No remotes configured yet" emptyMessage="No remotes configured yet"
/> />
<div className="top-files-grid">
<div>
<h2 className="section-title">Top Files by Hits</h2>
<DataTable
columns={[
{
key: 'path',
header: 'File',
render: (r: FileStatRow) => (
<Link to={`/remotes/${r.remote_name}/objects`} className="file-link">
<span className="file-remote">{r.remote_name}</span>
<span className="file-path mono">{r.path}</span>
</Link>
),
},
{
key: 'hits',
header: 'Hits',
render: (r: FileStatRow) => formatNumber(r.access_count),
width: '90px',
},
{
key: 'size',
header: 'Size',
render: (r: FileStatRow) => formatBytes(r.size_bytes),
width: '100px',
},
]}
data={topFilesByHits}
emptyMessage="No cached files yet"
/>
</div>
<div>
<h2 className="section-title">Top Files by Bandwidth (30d)</h2>
<DataTable
columns={[
{
key: 'path',
header: 'File',
render: (r: BandwidthStatRow) => (
<Link to={`/remotes/${r.remote_name}/objects`} className="file-link">
<span className="file-remote">{r.remote_name}</span>
<span className="file-path mono">{r.path}</span>
</Link>
),
},
{
key: 'bandwidth',
header: 'Bandwidth',
render: (r: BandwidthStatRow) => formatBytes(r.bandwidth),
width: '110px',
},
{
key: 'requests',
header: 'Requests',
render: (r: BandwidthStatRow) => formatNumber(r.requests),
width: '100px',
},
]}
data={topFilesByBW}
emptyMessage="No access data yet"
/>
</div>
</div>
</div> </div>
); );
} }
+46 -15
View File
@@ -3,13 +3,58 @@
align-items: center; align-items: center;
gap: 12px; gap: 12px;
margin-bottom: 16px; margin-bottom: 16px;
flex-wrap: wrap;
} }
.obj-path { .tree-controls {
display: flex;
gap: 6px;
margin-left: auto;
}
.tree-table .tree-label {
display: inline-flex;
align-items: center;
gap: 4px;
}
.tree-toggle {
display: inline-block;
width: 16px;
font-size: 0.85em;
color: var(--text-muted);
user-select: none;
flex-shrink: 0;
}
.tree-dir-name {
font-weight: 600;
color: var(--text-bright);
}
.tree-file-name {
font-size: 0.85em; font-size: 0.85em;
word-break: break-all; word-break: break-all;
} }
.tree-dir {
cursor: pointer;
}
.tree-dir:hover {
background: var(--bg-elevated);
}
.tree-summary {
font-size: 0.8em;
color: var(--text-muted);
}
.num-cell {
font-family: var(--font-mono);
font-size: 0.85em;
}
.hash-cell { .hash-cell {
font-size: 0.8em; font-size: 0.8em;
color: var(--text-muted); color: var(--text-muted);
@@ -30,20 +75,6 @@
background: rgba(239, 68, 68, 0.15); background: rgba(239, 68, 68, 0.15);
} }
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
}
.page-info {
font-size: 0.85em;
color: var(--text-muted);
font-family: var(--font-mono);
}
.btn-sm { .btn-sm {
padding: 5px 12px; padding: 5px 12px;
font-size: 0.85em; font-size: 0.85em;
+243 -79
View File
@@ -1,25 +1,188 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { api } from '../api/client'; import { api } from '../api/client';
import type { Artifact } from '../api/types'; import type { Artifact } from '../api/types';
import { DataTable } from '../components/DataTable';
import { formatBytes, timeAgo, truncateHash } from '../components/format'; import { formatBytes, timeAgo, truncateHash } from '../components/format';
import './Objects.css'; import './Objects.css';
interface TreeNode {
name: string;
path: string;
children: Map<string, TreeNode>;
artifact?: Artifact;
totalSize: number;
totalHits: number;
fileCount: number;
}
function buildTree(artifacts: Artifact[]): TreeNode {
const root: TreeNode = {
name: '',
path: '',
children: new Map(),
totalSize: 0,
totalHits: 0,
fileCount: 0,
};
for (const a of artifacts) {
const parts = a.path.split('/').filter(Boolean);
let node = root;
let currentPath = '';
for (let i = 0; i < parts.length; i++) {
currentPath += (currentPath ? '/' : '') + parts[i];
const isLeaf = i === parts.length - 1;
if (!node.children.has(parts[i])) {
node.children.set(parts[i], {
name: parts[i],
path: currentPath,
children: new Map(),
totalSize: 0,
totalHits: 0,
fileCount: 0,
});
}
const child = node.children.get(parts[i])!;
child.totalSize += a.size_bytes;
child.totalHits += a.access_count;
child.fileCount += 1;
if (isLeaf) {
child.artifact = a;
}
node = child;
}
root.totalSize += a.size_bytes;
root.totalHits += a.access_count;
root.fileCount += 1;
}
return root;
}
function filterTree(node: TreeNode, query: string): TreeNode | null {
if (!query) return node;
const lower = query.toLowerCase();
if (node.artifact) {
if (node.path.toLowerCase().includes(lower)) return node;
return null;
}
const filtered: Map<string, TreeNode> = new Map();
let totalSize = 0;
let totalHits = 0;
let fileCount = 0;
for (const [key, child] of node.children) {
const result = filterTree(child, query);
if (result) {
filtered.set(key, result);
totalSize += result.totalSize;
totalHits += result.totalHits;
fileCount += result.fileCount;
}
}
if (filtered.size === 0) return null;
return { ...node, children: filtered, totalSize, totalHits, fileCount };
}
interface TreeRowProps {
node: TreeNode;
depth: number;
expanded: Set<string>;
onToggle: (path: string) => void;
onEvict: (path: string) => void;
}
function TreeRow({ node, depth, expanded, onToggle, onEvict }: TreeRowProps) {
const isDir = node.children.size > 0 && !node.artifact;
const isExpanded = expanded.has(node.path);
const sortedChildren = useMemo(() => {
if (!isDir) return [];
return Array.from(node.children.values()).sort((a, b) => {
const aIsDir = a.children.size > 0 && !a.artifact;
const bIsDir = b.children.size > 0 && !b.artifact;
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
}, [node.children, isDir]);
return (
<>
<tr className={isDir ? 'tree-dir' : 'tree-file'} onClick={isDir ? () => onToggle(node.path) : undefined}>
<td>
<span style={{ paddingLeft: depth * 20 }} className="tree-label">
{isDir && (
<span className="tree-toggle">{isExpanded ? '▾' : '▸'}</span>
)}
<span className={isDir ? 'tree-dir-name' : 'mono tree-file-name'}>
{node.name}{isDir ? '/' : ''}
</span>
</span>
</td>
<td className="num-cell">{formatBytes(node.totalSize)}</td>
{node.artifact ? (
<>
<td className="mono hash-cell" title={node.artifact.content_hash}>
{truncateHash(node.artifact.content_hash)}
</td>
<td>{timeAgo(node.artifact.last_accessed_at)}</td>
<td className="num-cell">{node.artifact.access_count}</td>
<td>
<button
className="btn-evict"
onClick={(e) => { e.stopPropagation(); onEvict(node.artifact!.path); }}
>
Evict
</button>
</td>
</>
) : (
<>
<td className="tree-summary">{node.fileCount} files</td>
<td></td>
<td className="num-cell">{node.totalHits}</td>
<td></td>
</>
)}
</tr>
{isDir && isExpanded && sortedChildren.map(child => (
<TreeRow
key={child.path}
node={child}
depth={depth + 1}
expanded={expanded}
onToggle={onToggle}
onEvict={onEvict}
/>
))}
</>
);
}
export function Objects() { export function Objects() {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
const [artifacts, setArtifacts] = useState<Artifact[]>([]); const [artifacts, setArtifacts] = useState<Artifact[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const load = useCallback(() => { const load = useCallback(() => {
if (!name) return; if (!name) return;
setLoading(true); setLoading(true);
api.listObjects(name, page, 50) api.listObjects(name, 1, 5000)
.then(a => setArtifacts(a || [])) .then(a => setArtifacts(a || []))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [name, page]); }, [name]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
@@ -29,9 +192,43 @@ export function Objects() {
load(); load();
}; };
const filtered = filter const tree = useMemo(() => buildTree(artifacts), [artifacts]);
? artifacts.filter(a => a.path.toLowerCase().includes(filter.toLowerCase())) const filtered = useMemo(() => filterTree(tree, filter), [tree, filter]);
: artifacts;
const toggleExpand = useCallback((path: string) => {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
}, []);
const expandAll = useCallback(() => {
const allDirs = new Set<string>();
function walk(node: TreeNode) {
if (node.children.size > 0 && !node.artifact) {
allDirs.add(node.path);
for (const child of node.children.values()) walk(child);
}
}
if (filtered) walk(filtered);
setExpanded(allDirs);
}, [filtered]);
const collapseAll = useCallback(() => {
setExpanded(new Set());
}, []);
const topChildren = useMemo(() => {
if (!filtered) return [];
return Array.from(filtered.children.values()).sort((a, b) => {
const aIsDir = a.children.size > 0 && !a.artifact;
const bIsDir = b.children.size > 0 && !b.artifact;
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
}, [filtered]);
return ( return (
<div> <div>
@@ -47,84 +244,51 @@ export function Objects() {
value={filter} value={filter}
onChange={e => setFilter(e.target.value)} onChange={e => setFilter(e.target.value)}
/> />
<span className="result-count">{filtered.length} objects</span> <span className="result-count">
{filtered ? filtered.fileCount : 0} objects
{filtered && filtered.fileCount > 0 && <> &middot; {formatBytes(filtered.totalSize)}</>}
</span>
<div className="tree-controls">
<button className="btn btn-sm" onClick={expandAll}>Expand All</button>
<button className="btn btn-sm" onClick={collapseAll}>Collapse All</button>
</div>
</div> </div>
{loading ? ( {loading ? (
<div className="loading">Loading...</div> <div className="loading">Loading...</div>
) : ( ) : (
<> <div className="data-table-wrap">
<DataTable <table className="data-table tree-table">
columns={[ <thead>
{ <tr>
key: 'path', <th>Path</th>
header: 'Path', <th style={{ width: 100 }}>Size</th>
render: (a: Artifact) => <span className="mono obj-path">{a.path}</span>, <th style={{ width: 160 }}>Hash</th>
}, <th style={{ width: 120 }}>Last Accessed</th>
{ <th style={{ width: 70 }}>Hits</th>
key: 'size', <th style={{ width: 80 }}></th>
header: 'Size', </tr>
render: (a: Artifact) => formatBytes(a.size_bytes), </thead>
width: '100px', <tbody>
}, {topChildren.length === 0 ? (
{ <tr>
key: 'hash', <td colSpan={6} className="data-table-empty">No cached objects</td>
header: 'Hash', </tr>
render: (a: Artifact) => ( ) : (
<span className="mono hash-cell" title={a.content_hash}> topChildren.map(child => (
{truncateHash(a.content_hash)} <TreeRow
</span> key={child.path}
), node={child}
width: '160px', depth={0}
}, expanded={expanded}
{ onToggle={toggleExpand}
key: 'accessed', onEvict={handleEvict}
header: 'Last Accessed',
render: (a: Artifact) => timeAgo(a.last_accessed_at),
width: '120px',
},
{
key: 'hits',
header: 'Hits',
render: (a: Artifact) => a.access_count,
width: '70px',
},
{
key: 'actions',
header: '',
render: (a: Artifact) => (
<button
className="btn-evict"
onClick={(e) => { e.stopPropagation(); handleEvict(a.path); }}
>
Evict
</button>
),
width: '80px',
},
]}
data={filtered}
emptyMessage="No cached objects"
/> />
))
<div className="pagination"> )}
<button </tbody>
className="btn btn-sm" </table>
disabled={page === 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
>
Previous
</button>
<span className="page-info">Page {page}</span>
<button
className="btn btn-sm"
disabled={artifacts.length < 50}
onClick={() => setPage(p => p + 1)}
>
Next
</button>
</div> </div>
</>
)} )}
</div> </div>
); );