Compare commits
1 Commits
v3.6.4
..
bb172276ba
| Author | SHA1 | Date | |
|---|---|---|---|
| bb172276ba |
@@ -1,24 +0,0 @@
|
|||||||
repos:
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
||||||
rev: v5.0.0
|
|
||||||
hooks:
|
|
||||||
- id: trailing-whitespace
|
|
||||||
- id: end-of-file-fixer
|
|
||||||
- id: check-yaml
|
|
||||||
- id: check-added-large-files
|
|
||||||
- id: check-merge-conflict
|
|
||||||
|
|
||||||
- repo: https://github.com/dnephin/pre-commit-golang
|
|
||||||
rev: v0.5.1
|
|
||||||
hooks:
|
|
||||||
- id: go-fmt
|
|
||||||
- id: go-mod-tidy
|
|
||||||
|
|
||||||
- repo: local
|
|
||||||
hooks:
|
|
||||||
- id: go-vet
|
|
||||||
name: go vet
|
|
||||||
entry: go vet ./...
|
|
||||||
language: system
|
|
||||||
types: [go]
|
|
||||||
pass_filenames: false
|
|
||||||
@@ -8,8 +8,6 @@ steps:
|
|||||||
settings:
|
settings:
|
||||||
registry: git.unkin.net
|
registry: git.unkin.net
|
||||||
repo: git.unkin.net/unkin/artifactapi
|
repo: git.unkin.net/unkin/artifactapi
|
||||||
build_args:
|
|
||||||
VERSION: ${CI_COMMIT_TAG}
|
|
||||||
username: droneci
|
username: droneci
|
||||||
password:
|
password:
|
||||||
from_secret: DRONECI_PASSWORD
|
from_secret: DRONECI_PASSWORD
|
||||||
@@ -24,8 +22,6 @@ steps:
|
|||||||
repo: git.unkin.net/unkin/artifactapi-ui
|
repo: git.unkin.net/unkin/artifactapi-ui
|
||||||
dockerfile: ui/Dockerfile.ui
|
dockerfile: ui/Dockerfile.ui
|
||||||
context: ui
|
context: ui
|
||||||
build_args:
|
|
||||||
BASE_PATH: /ui
|
|
||||||
username: droneci
|
username: droneci
|
||||||
password:
|
password:
|
||||||
from_secret: DRONECI_PASSWORD
|
from_secret: DRONECI_PASSWORD
|
||||||
|
|||||||
@@ -3,15 +3,7 @@ when:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: pre-commit
|
- name: pre-commit
|
||||||
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
|
image: golang:1.25
|
||||||
commands:
|
commands:
|
||||||
- uvx pre-commit run --all-files
|
- test -z "$(gofmt -l .)"
|
||||||
backend_options:
|
- go vet ./...
|
||||||
kubernetes:
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: 512Mi
|
|
||||||
cpu: 1
|
|
||||||
limits:
|
|
||||||
memory: 2Gi
|
|
||||||
cpu: 2
|
|
||||||
|
|||||||
+1
-2
@@ -9,8 +9,7 @@ RUN go mod download
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ARG VERSION=dev
|
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o artifactapi ./cmd/artifactapi
|
||||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o artifactapi ./cmd/artifactapi
|
|
||||||
|
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ check-go:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
build: check-go tidy
|
build: check-go tidy
|
||||||
go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY) ./cmd/artifactapi
|
go build -ldflags="-s -w" -o $(BINARY) ./cmd/artifactapi
|
||||||
|
|
||||||
test: check-go
|
test: check-go
|
||||||
go test -race -count=1 ./pkg/... ./internal/...
|
go test -race -count=1 ./pkg/... ./internal/...
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import (
|
|||||||
"git.unkin.net/unkin/artifactapi/internal/tui"
|
"git.unkin.net/unkin/artifactapi/internal/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "dev"
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) > 1 && os.Args[1] == "tui" {
|
if len(os.Args) > 1 && os.Args[1] == "tui" {
|
||||||
endpoint := os.Getenv("ARTIFACTAPI_ENDPOINT")
|
endpoint := os.Getenv("ARTIFACTAPI_ENDPOINT")
|
||||||
@@ -44,7 +42,7 @@ func main() {
|
|||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
srv, err := server.New(cfg, version)
|
srv, err := server.New(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to create server", "error", err)
|
slog.Error("failed to create server", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -37,20 +37,6 @@ func (h *ProxyHandler) Routes() chi.Router {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ProxyHandler) DockerV2Routes() chi.Router {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Get("/", h.handleDockerPing)
|
|
||||||
r.Head("/", h.handleDockerPing)
|
|
||||||
r.Get("/{remoteName}/*", h.handleProxy)
|
|
||||||
r.Head("/{remoteName}/*", h.handleProxy)
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProxyHandler) handleDockerPing(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
remoteName := chi.URLParam(r, "remoteName")
|
remoteName := chi.URLParam(r, "remoteName")
|
||||||
path := chi.URLParam(r, "*")
|
path := chi.URLParam(r, "*")
|
||||||
@@ -67,7 +53,7 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.engine.Fetch(r.Context(), *remote, path, prov, r.Header)
|
result, err := h.engine.Fetch(r.Context(), *remote, path, prov)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var proxyErr *proxy.ProxyError
|
var proxyErr *proxy.ProxyError
|
||||||
if errors.As(err, &proxyErr) {
|
if errors.As(err, &proxyErr) {
|
||||||
|
|||||||
@@ -33,29 +33,29 @@ func (db *DB) InsertRPMMetadata(ctx context.Context, meta *provider.RPMMetadata)
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RPMMetadataRow struct {
|
type RPMMetadataRow struct {
|
||||||
RepoName string
|
RepoName string
|
||||||
FilePath string
|
FilePath string
|
||||||
ContentHash string
|
ContentHash string
|
||||||
Name string
|
Name string
|
||||||
Epoch int
|
Epoch int
|
||||||
Version string
|
Version string
|
||||||
Release string
|
Release string
|
||||||
Arch string
|
Arch string
|
||||||
Summary string
|
Summary string
|
||||||
Description string
|
Description string
|
||||||
RPMSize int64
|
RPMSize int64
|
||||||
InstalledSize int64
|
InstalledSize int64
|
||||||
License string
|
License string
|
||||||
Vendor string
|
Vendor string
|
||||||
Group string
|
Group string
|
||||||
BuildHost string
|
BuildHost string
|
||||||
SourceRPM string
|
SourceRPM string
|
||||||
URL string
|
URL string
|
||||||
Packager string
|
Packager string
|
||||||
Requires json.RawMessage
|
Requires json.RawMessage
|
||||||
Provides json.RawMessage
|
Provides json.RawMessage
|
||||||
Files json.RawMessage
|
Files json.RawMessage
|
||||||
Changelogs json.RawMessage
|
Changelogs json.RawMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) ListRPMMetadataEntries(ctx context.Context, repoName string) ([]provider.RPMMetadata, error) {
|
func (db *DB) ListRPMMetadataEntries(ctx context.Context, repoName string) ([]provider.RPMMetadata, error) {
|
||||||
|
|||||||
+4
-101
@@ -4,12 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.unkin.net/unkin/artifactapi/internal/cache"
|
"git.unkin.net/unkin/artifactapi/internal/cache"
|
||||||
@@ -44,7 +42,7 @@ type FetchResult struct {
|
|||||||
Source string // "cache" or "remote"
|
Source string // "cache" or "remote"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, prov provider.Provider, clientHeaders ...http.Header) (*FetchResult, error) {
|
func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, prov provider.Provider) (*FetchResult, error) {
|
||||||
classifier := NewClassifier(prov)
|
classifier := NewClassifier(prov)
|
||||||
class := classifier.Classify(remote, path)
|
class := classifier.Classify(remote, path)
|
||||||
|
|
||||||
@@ -105,13 +103,8 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fwdHeaders http.Header
|
|
||||||
if len(clientHeaders) > 0 && clientHeaders[0] != nil {
|
|
||||||
fwdHeaders = clientHeaders[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
result, err := e.fetchFromUpstream(ctx, remote, path, prov, class, ttl, fwdHeaders)
|
result, err := e.fetchFromUpstream(ctx, remote, path, prov, class, ttl)
|
||||||
upstreamMS := int(time.Since(start).Milliseconds())
|
upstreamMS := int(time.Since(start).Milliseconds())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if remote.StaleOnError && isNetworkError(err) {
|
if remote.StaleOnError && isNetworkError(err) {
|
||||||
@@ -131,7 +124,7 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification, ttl time.Duration, clientHeaders http.Header) (*FetchResult, error) {
|
func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification, ttl time.Duration) (*FetchResult, error) {
|
||||||
url := prov.UpstreamURL(remote, path)
|
url := prov.UpstreamURL(remote, path)
|
||||||
|
|
||||||
authHeaders, err := prov.AuthHeaders(ctx, remote)
|
authHeaders, err := prov.AuthHeaders(ctx, remote)
|
||||||
@@ -148,37 +141,12 @@ func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, pa
|
|||||||
req.Header.Add(k, v)
|
req.Header.Add(k, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if clientHeaders != nil {
|
|
||||||
if accept := clientHeaders.Get("Accept"); accept != "" {
|
|
||||||
req.Header.Set("Accept", accept)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &UpstreamError{Err: err}
|
return nil, &UpstreamError{Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusUnauthorized {
|
|
||||||
resp.Body.Close()
|
|
||||||
token, err := fetchBearerToken(ctx, resp.Header.Get("Www-Authenticate"), remote)
|
|
||||||
if err == nil && token != "" {
|
|
||||||
req2, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
||||||
req2.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
if clientHeaders != nil {
|
|
||||||
if accept := clientHeaders.Get("Accept"); accept != "" {
|
|
||||||
req2.Header.Set("Accept", accept)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resp, err = http.DefaultClient.Do(req2)
|
|
||||||
if err != nil {
|
|
||||||
return nil, &UpstreamError{Err: err}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, &ProxyError{Status: http.StatusUnauthorized, Message: "upstream returned 401"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
return nil, &ProxyError{Status: resp.StatusCode, Message: fmt.Sprintf("upstream returned %d", resp.StatusCode)}
|
return nil, &ProxyError{Status: resp.StatusCode, Message: fmt.Sprintf("upstream returned %d", resp.StatusCode)}
|
||||||
@@ -199,7 +167,7 @@ func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, pa
|
|||||||
}
|
}
|
||||||
|
|
||||||
contentType := prov.ContentType(path)
|
contentType := prov.ContentType(path)
|
||||||
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
if ct := resp.Header.Get("Content-Type"); ct != "" && contentType == "application/octet-stream" {
|
||||||
contentType = ct
|
contentType = ct
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,71 +319,6 @@ func (r readerAt) ReadAt(p []byte, off int64) (n int, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchBearerToken(ctx context.Context, wwwAuth string, remote models.Remote) (string, error) {
|
|
||||||
if !strings.HasPrefix(wwwAuth, "Bearer ") {
|
|
||||||
return "", fmt.Errorf("not a Bearer challenge")
|
|
||||||
}
|
|
||||||
|
|
||||||
params := map[string]string{}
|
|
||||||
for _, part := range strings.Split(wwwAuth[7:], ",") {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
eq := strings.Index(part, "=")
|
|
||||||
if eq < 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := part[:eq]
|
|
||||||
val := strings.Trim(part[eq+1:], `"`)
|
|
||||||
params[key] = val
|
|
||||||
}
|
|
||||||
|
|
||||||
realm := params["realm"]
|
|
||||||
if realm == "" {
|
|
||||||
return "", fmt.Errorf("no realm in Bearer challenge")
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenURL := realm
|
|
||||||
sep := "?"
|
|
||||||
if s, ok := params["service"]; ok {
|
|
||||||
tokenURL += sep + "service=" + s
|
|
||||||
sep = "&"
|
|
||||||
}
|
|
||||||
if s, ok := params["scope"]; ok {
|
|
||||||
tokenURL += sep + "scope=" + s
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if remote.Username != "" && remote.Password != "" {
|
|
||||||
req.SetBasicAuth(remote.Username, remote.Password)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("token endpoint returned %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var tokenResp struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if tokenResp.Token != "" {
|
|
||||||
return tokenResp.Token, nil
|
|
||||||
}
|
|
||||||
return tokenResp.AccessToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProxyError struct {
|
type ProxyError struct {
|
||||||
Status int
|
Status int
|
||||||
Message string
|
Message string
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import (
|
|||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
version string
|
|
||||||
router chi.Router
|
router chi.Router
|
||||||
db *database.DB
|
db *database.DB
|
||||||
cache *cache.Redis
|
cache *cache.Redis
|
||||||
@@ -46,7 +45,7 @@ type Server struct {
|
|||||||
gc *gc.Collector
|
gc *gc.Collector
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config, version string) (*Server, error) {
|
func New(cfg *config.Config) (*Server, error) {
|
||||||
db, err := database.New(cfg.DatabaseDSN())
|
db, err := database.New(cfg.DatabaseDSN())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("database: %w", err)
|
return nil, fmt.Errorf("database: %w", err)
|
||||||
@@ -69,7 +68,6 @@ func New(cfg *config.Config, version string) (*Server, error) {
|
|||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
version: version,
|
|
||||||
db: db,
|
db: db,
|
||||||
cache: redis,
|
cache: redis,
|
||||||
store: s3,
|
store: s3,
|
||||||
@@ -98,7 +96,6 @@ func (s *Server) routes() chi.Router {
|
|||||||
|
|
||||||
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
|
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
|
||||||
r.Mount("/api/v1", proxyHandler.Routes())
|
r.Mount("/api/v1", proxyHandler.Routes())
|
||||||
r.Mount("/v2", proxyHandler.DockerV2Routes())
|
|
||||||
|
|
||||||
remotesHandler := v2.NewRemotesHandler(s.db)
|
remotesHandler := v2.NewRemotesHandler(s.db)
|
||||||
virtualsHandler := v2.NewVirtualsHandler(s.db)
|
virtualsHandler := v2.NewVirtualsHandler(s.db)
|
||||||
@@ -140,7 +137,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
fmt.Fprintf(w, `{"name":"artifactapi","version":"%s"}`, s.version)
|
fmt.Fprint(w, `{"name":"artifactapi","version":"3.0.0-dev"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) newHTTPServer() *http.Server {
|
func (s *Server) newHTTPServer() *http.Server {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func (e *Engine) fetchMemberIndexes(ctx context.Context, virt models.Virtual, pa
|
|||||||
results[idx] = result{err: fmt.Errorf("local index %q: %w", name, err)}
|
results[idx] = result{err: fmt.Errorf("local index %q: %w", name, err)}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, BaseURL: remote.BaseURL, Body: body}}
|
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, Body: body}}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ func (e *Engine) fetchMemberIndexes(ctx context.Context, virt models.Virtual, pa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, BaseURL: remote.BaseURL, Body: body}}
|
results[idx] = result{index: MemberIndex{RemoteName: name, RepoType: remote.RepoType, Body: body}}
|
||||||
}(i, memberName)
|
}(i, memberName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,27 +54,15 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
|
|||||||
seen[chart][ver.Version] = true
|
seen[chart][ver.Version] = true
|
||||||
|
|
||||||
if proxyBaseURL != "" {
|
if proxyBaseURL != "" {
|
||||||
routePrefix := "remote"
|
|
||||||
if member.RepoType == "local" {
|
|
||||||
routePrefix = "local"
|
|
||||||
}
|
|
||||||
baseHost := extractHost(member.BaseURL)
|
|
||||||
|
|
||||||
for i, u := range ver.URLs {
|
for i, u := range ver.URLs {
|
||||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
||||||
if baseHost != "" && extractHost(u) != baseHost {
|
ver.URLs[i] = fmt.Sprintf("%s/api/v1/remote/%s/%s",
|
||||||
continue
|
|
||||||
}
|
|
||||||
relPath := extractPathRelativeToBase(u, member.BaseURL)
|
|
||||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
|
||||||
strings.TrimRight(proxyBaseURL, "/"),
|
strings.TrimRight(proxyBaseURL, "/"),
|
||||||
routePrefix,
|
|
||||||
member.RemoteName,
|
member.RemoteName,
|
||||||
relPath)
|
extractPath(u))
|
||||||
} else {
|
} else {
|
||||||
ver.URLs[i] = fmt.Sprintf("%s/api/v1/%s/%s/%s",
|
ver.URLs[i] = fmt.Sprintf("%s/api/v1/remote/%s/%s",
|
||||||
strings.TrimRight(proxyBaseURL, "/"),
|
strings.TrimRight(proxyBaseURL, "/"),
|
||||||
routePrefix,
|
|
||||||
member.RemoteName,
|
member.RemoteName,
|
||||||
u)
|
u)
|
||||||
}
|
}
|
||||||
@@ -90,31 +78,6 @@ func (m *HelmMerger) MergeIndexes(members []MemberIndex, proxyBaseURL string) ([
|
|||||||
return yaml.Marshal(merged)
|
return yaml.Marshal(merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractHost(rawURL string) string {
|
|
||||||
idx := strings.Index(rawURL, "://")
|
|
||||||
if idx == -1 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
rest := rawURL[idx+3:]
|
|
||||||
slashIdx := strings.Index(rest, "/")
|
|
||||||
if slashIdx == -1 {
|
|
||||||
return rest
|
|
||||||
}
|
|
||||||
return rest[:slashIdx]
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractPathRelativeToBase(rawURL, baseURL string) string {
|
|
||||||
fullPath := extractPath(rawURL)
|
|
||||||
basePath := extractPath(baseURL)
|
|
||||||
if basePath != "" {
|
|
||||||
basePath = strings.TrimRight(basePath, "/") + "/"
|
|
||||||
if strings.HasPrefix(fullPath, basePath) {
|
|
||||||
return fullPath[len(basePath):]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fullPath
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractPath(rawURL string) string {
|
func extractPath(rawURL string) string {
|
||||||
idx := strings.Index(rawURL, "://")
|
idx := strings.Index(rawURL, "://")
|
||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
type MemberIndex struct {
|
type MemberIndex struct {
|
||||||
RemoteName string
|
RemoteName string
|
||||||
RepoType models.RepoType
|
RepoType models.RepoType
|
||||||
BaseURL string
|
|
||||||
Body []byte
|
Body []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,20 +6,13 @@ COPY package.json package-lock.json* ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ARG BASE_PATH=/
|
|
||||||
ENV BASE_PATH=${BASE_PATH}
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
ARG BASE_PATH=/
|
|
||||||
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
RUN sed -i "s|\${BASE_PATH}|${BASE_PATH}|g" /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
+26
-5
@@ -5,12 +5,33 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location ${BASE_PATH}/ {
|
location /api/ {
|
||||||
rewrite ^${BASE_PATH}(/.*)$ $1 break;
|
proxy_pass http://artifactapi:8000;
|
||||||
try_files $uri $uri/ /index.html;
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = ${BASE_PATH} {
|
location /v2/ {
|
||||||
return 301 ${BASE_PATH}/;
|
proxy_pass http://artifactapi:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://artifactapi:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /metrics {
|
||||||
|
proxy_pass http://artifactapi:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { Routes, Route, NavLink } from 'react-router-dom';
|
|||||||
import { Dashboard } from './pages/Dashboard';
|
import { Dashboard } from './pages/Dashboard';
|
||||||
import { Remotes } from './pages/Remotes';
|
import { Remotes } from './pages/Remotes';
|
||||||
import { RemoteDetail } from './pages/RemoteDetail';
|
import { RemoteDetail } from './pages/RemoteDetail';
|
||||||
import { Locals } from './pages/Locals';
|
|
||||||
import { LocalDetail } from './pages/LocalDetail';
|
|
||||||
import { Virtuals } from './pages/Virtuals';
|
import { Virtuals } from './pages/Virtuals';
|
||||||
import { Objects } from './pages/Objects';
|
import { Objects } from './pages/Objects';
|
||||||
import { Probe } from './pages/Probe';
|
import { Probe } from './pages/Probe';
|
||||||
@@ -20,7 +18,6 @@ export function App() {
|
|||||||
<div className="sidebar-nav">
|
<div className="sidebar-nav">
|
||||||
<NavLink to="/" end>Dashboard</NavLink>
|
<NavLink to="/" end>Dashboard</NavLink>
|
||||||
<NavLink to="/remotes">Remotes</NavLink>
|
<NavLink to="/remotes">Remotes</NavLink>
|
||||||
<NavLink to="/locals">Locals</NavLink>
|
|
||||||
<NavLink to="/virtuals">Virtuals</NavLink>
|
<NavLink to="/virtuals">Virtuals</NavLink>
|
||||||
<NavLink to="/probe">Test Remote</NavLink>
|
<NavLink to="/probe">Test Remote</NavLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,9 +31,6 @@ export function App() {
|
|||||||
<Route path="/remotes" element={<Remotes />} />
|
<Route path="/remotes" element={<Remotes />} />
|
||||||
<Route path="/remotes/:name" element={<RemoteDetail />} />
|
<Route path="/remotes/:name" element={<RemoteDetail />} />
|
||||||
<Route path="/remotes/:name/objects" element={<Objects />} />
|
<Route path="/remotes/:name/objects" element={<Objects />} />
|
||||||
<Route path="/locals" element={<Locals />} />
|
|
||||||
<Route path="/locals/:name" element={<LocalDetail />} />
|
|
||||||
<Route path="/locals/:name/objects" element={<Objects />} />
|
|
||||||
<Route path="/virtuals" element={<Virtuals />} />
|
<Route path="/virtuals" element={<Virtuals />} />
|
||||||
<Route path="/probe" element={<Probe />} />
|
<Route path="/probe" element={<Probe />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
+1
-5
@@ -4,13 +4,9 @@ import { BrowserRouter } from 'react-router-dom';
|
|||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
declare const __BASE_PATH__: string;
|
|
||||||
|
|
||||||
const basename = __BASE_PATH__.replace(/\/+$/, '') || '/';
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter basename={basename}>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useParams, Link } from 'react-router-dom';
|
|
||||||
import { api } from '../api/client';
|
|
||||||
import type { Remote } from '../api/types';
|
|
||||||
import { Badge } from '../components/Badge';
|
|
||||||
import './RemoteDetail.css';
|
|
||||||
|
|
||||||
export function LocalDetail() {
|
|
||||||
const { name } = useParams<{ name: string }>();
|
|
||||||
const [remote, setRemote] = useState<Remote | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!name) return;
|
|
||||||
api.getRemote(name)
|
|
||||||
.then(setRemote)
|
|
||||||
.catch(e => setError(e.message));
|
|
||||||
}, [name]);
|
|
||||||
|
|
||||||
if (error) return <div className="error-banner">{error}</div>;
|
|
||||||
if (!remote) return <div className="loading">Loading...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="detail-header">
|
|
||||||
<Link to="/locals" className="back-link">← Locals</Link>
|
|
||||||
<h1 className="page-title">{remote.name}</h1>
|
|
||||||
<div className="detail-badges">
|
|
||||||
<Badge variant="blue">{remote.package_type}</Badge>
|
|
||||||
<Badge variant="default">local</Badge>
|
|
||||||
{remote.managed_by && <Badge variant="green">managed by {remote.managed_by}</Badge>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{remote.description && (
|
|
||||||
<p className="detail-description">{remote.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="detail-actions">
|
|
||||||
<Link to={`/locals/${remote.name}/objects`} className="btn btn-primary">
|
|
||||||
Browse Files
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { api } from '../api/client';
|
|
||||||
import type { Remote } from '../api/types';
|
|
||||||
import { Badge } from '../components/Badge';
|
|
||||||
import { DataTable } from '../components/DataTable';
|
|
||||||
import './Remotes.css';
|
|
||||||
|
|
||||||
const typeColors: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'> = {
|
|
||||||
docker: 'blue',
|
|
||||||
helm: 'green',
|
|
||||||
rpm: 'yellow',
|
|
||||||
pypi: 'blue',
|
|
||||||
npm: 'red',
|
|
||||||
generic: 'default',
|
|
||||||
alpine: 'green',
|
|
||||||
puppet: 'yellow',
|
|
||||||
terraform: 'blue',
|
|
||||||
goproxy: 'green',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Locals() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [remotes, setRemotes] = useState<Remote[]>([]);
|
|
||||||
const [filter, setFilter] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.listRemotes()
|
|
||||||
.then(r => setRemotes((r || []).filter(x => x.repo_type === 'local')))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filtered = remotes.filter(r => {
|
|
||||||
if (filter && !r.name.toLowerCase().includes(filter.toLowerCase())) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="page-title">Local Repositories</h1>
|
|
||||||
|
|
||||||
<div className="remotes-toolbar">
|
|
||||||
<input
|
|
||||||
className="search-input"
|
|
||||||
placeholder="Filter by name..."
|
|
||||||
value={filter}
|
|
||||||
onChange={e => setFilter(e.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="result-count">{filtered.length} locals</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="loading">Loading...</div>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: 'Name',
|
|
||||||
render: (r: Remote) => <span className="mono">{r.name}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'type',
|
|
||||||
header: 'Type',
|
|
||||||
render: (r: Remote) => (
|
|
||||||
<Badge variant={typeColors[r.package_type] || 'default'}>
|
|
||||||
{r.package_type}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
width: '110px',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'description',
|
|
||||||
header: 'Description',
|
|
||||||
render: (r: Remote) => r.description || <span className="text-muted">—</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'managed',
|
|
||||||
header: 'Managed',
|
|
||||||
render: (r: Remote) =>
|
|
||||||
r.managed_by ? <Badge variant="blue">{r.managed_by}</Badge> : <span className="text-muted">—</span>,
|
|
||||||
width: '100px',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
data={filtered}
|
|
||||||
emptyMessage="No local repositories configured"
|
|
||||||
onRowClick={(r) => navigate(`/locals/${r.name}`)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { useParams, useLocation, 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 { formatBytes, timeAgo, truncateHash } from '../components/format';
|
import { formatBytes, timeAgo, truncateHash } from '../components/format';
|
||||||
@@ -171,9 +171,6 @@ function TreeRow({ node, depth, expanded, onToggle, onEvict }: TreeRowProps) {
|
|||||||
|
|
||||||
export function Objects() {
|
export function Objects() {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
const location = useLocation();
|
|
||||||
const isLocal = location.pathname.startsWith('/locals/');
|
|
||||||
const backLink = isLocal ? `/locals/${name}` : `/remotes/${name}`;
|
|
||||||
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
@@ -236,7 +233,7 @@ export function Objects() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="detail-header">
|
<div className="detail-header">
|
||||||
<Link to={backLink} className="back-link">← {name}</Link>
|
<Link to={`/remotes/${name}`} className="back-link">← {name}</Link>
|
||||||
<h1 className="page-title">Cached Objects</h1>
|
<h1 className="page-title">Cached Objects</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,9 @@ export function Remotes() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const remoteOnly = remotes.filter(r => r.repo_type !== 'local');
|
const types = [...new Set(remotes.map(r => r.package_type))].sort();
|
||||||
const types = [...new Set(remoteOnly.map(r => r.package_type))].sort();
|
|
||||||
|
|
||||||
const filtered = remoteOnly.filter(r => {
|
const filtered = remotes.filter(r => {
|
||||||
if (typeFilter && r.package_type !== typeFilter) return false;
|
if (typeFilter && r.package_type !== typeFilter) return false;
|
||||||
if (filter && !r.name.toLowerCase().includes(filter.toLowerCase())) return false;
|
if (filter && !r.name.toLowerCase().includes(filter.toLowerCase())) return false;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+10
-32
@@ -1,38 +1,21 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { Remote, Virtual } from '../api/types';
|
import type { Virtual } from '../api/types';
|
||||||
import { Badge } from '../components/Badge';
|
import { Badge } from '../components/Badge';
|
||||||
import { DataTable } from '../components/DataTable';
|
import { DataTable } from '../components/DataTable';
|
||||||
import './Virtuals.css';
|
import './Virtuals.css';
|
||||||
|
|
||||||
export function Virtuals() {
|
export function Virtuals() {
|
||||||
const [virtuals, setVirtuals] = useState<Virtual[]>([]);
|
const [virtuals, setVirtuals] = useState<Virtual[]>([]);
|
||||||
const [remoteMap, setRemoteMap] = useState<Record<string, Remote>>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [expanded, setExpanded] = useState<string | null>(null);
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([api.listVirtuals(), api.listRemotes()])
|
api.listVirtuals()
|
||||||
.then(([v, r]) => {
|
.then(v => setVirtuals(v || []))
|
||||||
setVirtuals(v || []);
|
|
||||||
const map: Record<string, Remote> = {};
|
|
||||||
for (const remote of r || []) {
|
|
||||||
map[remote.name] = remote;
|
|
||||||
}
|
|
||||||
setRemoteMap(map);
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function memberLink(name: string) {
|
|
||||||
const remote = remoteMap[name];
|
|
||||||
if (remote?.repo_type === 'local') {
|
|
||||||
return `/locals/${name}`;
|
|
||||||
}
|
|
||||||
return `/remotes/${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Virtual Repositories</h1>
|
<h1 className="page-title">Virtual Repositories</h1>
|
||||||
@@ -57,7 +40,7 @@ export function Virtuals() {
|
|||||||
key: 'members',
|
key: 'members',
|
||||||
header: 'Members',
|
header: 'Members',
|
||||||
render: (v: Virtual) => (
|
render: (v: Virtual) => (
|
||||||
<span className="member-count">{v.members?.length || 0} repos</span>
|
<span className="member-count">{v.members?.length || 0} remotes</span>
|
||||||
),
|
),
|
||||||
width: '110px',
|
width: '110px',
|
||||||
},
|
},
|
||||||
@@ -86,17 +69,12 @@ export function Virtuals() {
|
|||||||
<ul className="member-list">
|
<ul className="member-list">
|
||||||
{virtuals
|
{virtuals
|
||||||
.find(v => v.name === expanded)
|
.find(v => v.name === expanded)
|
||||||
?.members?.map((m, i) => {
|
?.members?.map((m, i) => (
|
||||||
const remote = remoteMap[m];
|
<li key={m}>
|
||||||
const typeLabel = remote?.repo_type === 'local' ? 'local' : 'remote';
|
<span className="member-priority">{i + 1}</span>
|
||||||
return (
|
<a href={`/remotes/${m}`} className="mono">{m}</a>
|
||||||
<li key={m}>
|
</li>
|
||||||
<span className="member-priority">{i + 1}</span>
|
))}
|
||||||
<Link to={memberLink(m)} className="mono">{m}</Link>
|
|
||||||
<Badge variant={typeLabel === 'local' ? 'yellow' : 'default'}>{typeLabel}</Badge>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
const basePath = process.env.BASE_PATH || '/'
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: basePath,
|
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
@@ -14,7 +11,4 @@ export default defineConfig({
|
|||||||
'/metrics': 'http://localhost:8000',
|
'/metrics': 'http://localhost:8000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
define: {
|
|
||||||
'__BASE_PATH__': JSON.stringify(basePath),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user