1
0
Fork 1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-06-02 08:38:17 -04:00

Compare commits

...

1 commit

Author SHA1 Message Date
Andrew Nesbitt
4ea4d47b13
Mount web UI under /ui (closes #123)
The UI now lives under /ui so reverse proxies can apply different
access rules to it (e.g. require auth) while leaving the package
endpoints (/npm, /pypi, /v2, ...) open to build machines.

- GET / redirects to /ui/
- /api/browse and /api/compare move to /ui/api/browse and
  /ui/api/compare since only the browser JS calls them
- /health, /stats, /metrics, /openapi.json and /api/* stay at root
2026-05-23 18:16:28 +01:00
20 changed files with 526 additions and 490 deletions

View file

@ -819,16 +819,16 @@ Response:
## Web Interface ## Web Interface
The proxy serves a web UI at the root URL. No separate frontend build is needed -- templates and assets are embedded in the binary. The proxy serves a web UI under `/ui`. No separate frontend build is needed -- templates and assets are embedded in the binary. `GET /` redirects to `/ui/`. The UI is mounted under its own prefix so a reverse proxy can apply different access rules to it than to the package endpoints (for example, requiring auth for `PathPrefix(/ui)` while leaving `/npm`, `/pypi` etc. open to build machines).
- **Dashboard** (`/`) -- cache stats, popular packages, recently cached artifacts, and vulnerability overview. - **Dashboard** (`/ui/`) -- cache stats, popular packages, recently cached artifacts, and vulnerability overview.
- **Install guide** (`/install`) -- per-ecosystem configuration instructions, so you don't have to look them up here. - **Install guide** (`/ui/install`) -- per-ecosystem configuration instructions, so you don't have to look them up here.
- **Package browser** (`/packages`) -- browse all cached packages with filtering by ecosystem and sorting by hits, size, name, or vulnerability count. - **Package browser** (`/ui/packages`) -- browse all cached packages with filtering by ecosystem and sorting by hits, size, name, or vulnerability count.
- **Search** (`/search?q=...`) -- search cached packages by name. - **Search** (`/ui/search?q=...`) -- search cached packages by name.
- **Package detail** (`/package/{ecosystem}/{name}`) -- metadata, license, vulnerabilities, and version list for a package. You can select two versions to compare. - **Package detail** (`/ui/package/{ecosystem}/{name}`) -- metadata, license, vulnerabilities, and version list for a package. You can select two versions to compare.
- **Version detail** (`/package/{ecosystem}/{name}/{version}`) -- per-version metadata, integrity hash, artifact cache status, and hit counts. - **Version detail** (`/ui/package/{ecosystem}/{name}/{version}`) -- per-version metadata, integrity hash, artifact cache status, and hit counts.
- **Source browser** (`/package/{ecosystem}/{name}/{version}/browse`) -- browse files inside cached archives with syntax highlighting for text files and image previews. - **Source browser** (`/ui/package/{ecosystem}/{name}/{version}/browse`) -- browse files inside cached archives with syntax highlighting for text files and image previews.
- **Version diff** (`/package/{ecosystem}/{name}/compare/{v1}...{v2}`) -- side-by-side diff of two cached versions showing added, removed, and changed files. - **Version diff** (`/ui/package/{ecosystem}/{name}/compare/{v1}...{v2}`) -- side-by-side diff of two cached versions showing added, removed, and changed files.
## Monitoring ## Monitoring

View file

@ -15,7 +15,7 @@ The proxy is a caching HTTP server that sits between package manager clients and
│ │ /cargo/* -> CargoHandler /stats -> statsHandler │ │ │ │ /cargo/* -> CargoHandler /stats -> statsHandler │ │
│ │ /gem/* -> GemHandler /metrics -> prometheus │ │ │ │ /gem/* -> GemHandler /metrics -> prometheus │ │
│ │ ...17 ecosystems /api/* -> APIHandler │ │ │ │ ...17 ecosystems /api/* -> APIHandler │ │
│ │ / -> Web UI │ │ │ │ /ui/* -> Web UI │ │
│ └──────────────────────────────────────────────────────────┘ │ │ └──────────────────────────────────────────────────────────┘ │
│ │ │ │ │ │ │ │ │ │
│ ▼ ▼ ▼ │ │ ▼ ▼ ▼ │
@ -274,7 +274,7 @@ HTTP server setup, web UI, and API handlers.
- Creates and wires together all components - Creates and wires together all components
- Mounts protocol handlers at ecosystem-specific paths - Mounts protocol handlers at ecosystem-specific paths
- Middleware: request ID, real IP, logging, panic recovery, active request tracking - Middleware: request ID, real IP, logging, panic recovery, active request tracking
- Web UI: dashboard, package browser, source browser, version comparison - Web UI under `/ui`: dashboard, package browser, source browser, version comparison
- Templates are embedded in the binary via `//go:embed` - Templates are embedded in the binary via `//go:embed`
- Enrichment API for package metadata, vulnerability scanning, and outdated detection - Enrichment API for package metadata, vulnerability scanning, and outdated detection
- Health, stats, and Prometheus metrics endpoints. `/health` runs an active write → size-check → read → verify → delete probe against the storage backend and returns a structured JSON response (`HealthResponse`) with `"ok"` / `"error"` status per subsystem. Probe results are cached (default 30 s, configurable via `health.storage_probe_interval`) to avoid overwhelming remote backends. - Health, stats, and Prometheus metrics endpoints. `/health` runs an active write → size-check → read → verify → delete probe against the storage backend and returns a structured JSON response (`HealthResponse`) with `"ok"` / `"error"` status per subsystem. Probe results are cached (default 30 s, configurable via `health.storage_probe_interval`) to avoid overwhelming remote backends.

View file

@ -15,135 +15,6 @@ const docTemplate = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "paths": {
"/api/browse/{ecosystem}/{name}/{version}": {
"get": {
"description": "Lists files from the first cached artifact for a package version.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "List files inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Directory path inside the archive",
"name": "path",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/server.BrowseListResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": {
"get": {
"description": "Streams a single file from the cached artifact. The file path may contain slashes.",
"produces": [
"application/octet-stream"
],
"tags": [
"browse"
],
"summary": "Fetch a file inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "File path inside the archive",
"name": "filepath",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/bulk": { "/api/bulk": {
"post": { "post": {
"consumes": [ "consumes": [
@ -189,69 +60,6 @@ const docTemplate = `{
} }
} }
}, },
"/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": {
"get": {
"description": "Returns a structured diff for two cached versions.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "Compare two cached versions",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "From version",
"name": "fromVersion",
"in": "path",
"required": true
},
{
"type": "string",
"description": "To version",
"name": "toVersion",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/outdated": { "/api/outdated": {
"post": { "post": {
"consumes": [ "consumes": [
@ -445,6 +253,198 @@ const docTemplate = `{
} }
} }
} }
},
"/ui/api/browse/{ecosystem}/{name}/{version}": {
"get": {
"description": "Lists files from the first cached artifact for a package version.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "List files inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Directory path inside the archive",
"name": "path",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/server.BrowseListResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/ui/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": {
"get": {
"description": "Streams a single file from the cached artifact. The file path may contain slashes.",
"produces": [
"application/octet-stream"
],
"tags": [
"browse"
],
"summary": "Fetch a file inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "File path inside the archive",
"name": "filepath",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/ui/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": {
"get": {
"description": "Returns a structured diff for two cached versions.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "Compare two cached versions",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "From version",
"name": "fromVersion",
"in": "path",
"required": true
},
{
"type": "string",
"description": "To version",
"name": "toVersion",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
} }
}, },
"definitions": { "definitions": {

View file

@ -8,135 +8,6 @@
}, },
"basePath": "/", "basePath": "/",
"paths": { "paths": {
"/api/browse/{ecosystem}/{name}/{version}": {
"get": {
"description": "Lists files from the first cached artifact for a package version.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "List files inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Directory path inside the archive",
"name": "path",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/server.BrowseListResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": {
"get": {
"description": "Streams a single file from the cached artifact. The file path may contain slashes.",
"produces": [
"application/octet-stream"
],
"tags": [
"browse"
],
"summary": "Fetch a file inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "File path inside the archive",
"name": "filepath",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/bulk": { "/api/bulk": {
"post": { "post": {
"consumes": [ "consumes": [
@ -182,69 +53,6 @@
} }
} }
}, },
"/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": {
"get": {
"description": "Returns a structured diff for two cached versions.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "Compare two cached versions",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "From version",
"name": "fromVersion",
"in": "path",
"required": true
},
{
"type": "string",
"description": "To version",
"name": "toVersion",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/outdated": { "/api/outdated": {
"post": { "post": {
"consumes": [ "consumes": [
@ -438,6 +246,198 @@
} }
} }
} }
},
"/ui/api/browse/{ecosystem}/{name}/{version}": {
"get": {
"description": "Lists files from the first cached artifact for a package version.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "List files inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Directory path inside the archive",
"name": "path",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/server.BrowseListResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/ui/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": {
"get": {
"description": "Streams a single file from the cached artifact. The file path may contain slashes.",
"produces": [
"application/octet-stream"
],
"tags": [
"browse"
],
"summary": "Fetch a file inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "File path inside the archive",
"name": "filepath",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/ui/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": {
"get": {
"description": "Returns a structured diff for two cached versions.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "Compare two cached versions",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "From version",
"name": "fromVersion",
"in": "path",
"required": true
},
{
"type": "string",
"description": "To version",
"name": "toVersion",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
} }
}, },
"definitions": { "definitions": {

View file

@ -119,7 +119,7 @@ type BrowseFileInfo struct {
// @Success 200 {object} BrowseListResponse // @Success 200 {object} BrowseListResponse
// @Failure 404 {object} ErrorResponse // @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse // @Failure 500 {object} ErrorResponse
// @Router /api/browse/{ecosystem}/{name}/{version} [get] // @Router /ui/api/browse/{ecosystem}/{name}/{version} [get]
// handleBrowsePath dispatches /api/browse/{ecosystem}/* to the appropriate browse handler. // handleBrowsePath dispatches /api/browse/{ecosystem}/* to the appropriate browse handler.
// It resolves namespaced package names by consulting the database. // It resolves namespaced package names by consulting the database.
// //
@ -296,7 +296,7 @@ func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, n
// @Failure 400 {object} ErrorResponse // @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse // @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse // @Failure 500 {object} ErrorResponse
// @Router /api/browse/{ecosystem}/{name}/{version}/file/{filepath} [get] // @Router /ui/api/browse/{ecosystem}/{name}/{version}/file/{filepath} [get]
func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, name, version, filePath string) { func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, name, version, filePath string) {
if filePath == "" { if filePath == "" {
badRequest(w, "file path required") badRequest(w, "file path required")
@ -498,7 +498,7 @@ type BrowseSourceData struct {
// @Success 200 {object} map[string]any // @Success 200 {object} map[string]any
// @Failure 404 {object} ErrorResponse // @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse // @Failure 500 {object} ErrorResponse
// @Router /api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion} [get] // @Router /ui/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion} [get]
func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem, name, fromVersion, toVersion string) { func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem, name, fromVersion, toVersion string) {
// Get artifacts for both versions // Get artifacts for both versions
fromPURL := purl.MakePURLString(ecosystem, name, fromVersion) fromPURL := purl.MakePURLString(ecosystem, name, fromVersion)

View file

@ -65,7 +65,7 @@ func TestHandleBrowseList(t *testing.T) {
} }
// Test listing root directory // Test listing root directory
req := httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0", nil) req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -83,7 +83,7 @@ func TestHandleBrowseList(t *testing.T) {
} }
// Test listing subdirectory // Test listing subdirectory
req = httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0?path=lib", nil) req = httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0?path=lib", nil)
w = httptest.NewRecorder() w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -138,7 +138,7 @@ func TestHandleBrowseFile(t *testing.T) {
} }
// Test fetching a file // Test fetching a file
req := httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0/file/README.md", nil) req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/README.md", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -158,7 +158,7 @@ func TestHandleBrowseFile(t *testing.T) {
} }
// Test fetching non-existent file // Test fetching non-existent file
req = httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0/file/nonexistent.txt", nil) req = httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/nonexistent.txt", nil)
w = httptest.NewRecorder() w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -314,7 +314,7 @@ func TestBrowseNonCachedArtifact(t *testing.T) {
} }
// Try to browse // Try to browse
req := httptest.NewRequest("GET", "/api/browse/npm/not-cached/1.0.0", nil) req := httptest.NewRequest("GET", "/ui/api/browse/npm/not-cached/1.0.0", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -368,7 +368,7 @@ func TestHandleBrowseSourcePage(t *testing.T) {
} }
// Test the browse source page loads // Test the browse source page loads
req := httptest.NewRequest("GET", "/package/npm/test-browse/1.0.0/browse", nil) req := httptest.NewRequest("GET", "/ui/package/npm/test-browse/1.0.0/browse", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -501,7 +501,7 @@ func TestHandleCompareDiff(t *testing.T) {
} }
// Test the compare endpoint // Test the compare endpoint
req := httptest.NewRequest("GET", "/api/compare/npm/test-compare/1.0.0/2.0.0", nil) req := httptest.NewRequest("GET", "/ui/api/compare/npm/test-compare/1.0.0/2.0.0", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -572,7 +572,7 @@ func TestHandleComparePage(t *testing.T) {
defer ts.close() defer ts.close()
// Test valid format with ... separator // Test valid format with ... separator
req := httptest.NewRequest("GET", "/package/npm/test/compare/1.0.0...2.0.0", nil) req := httptest.NewRequest("GET", "/ui/package/npm/test/compare/1.0.0...2.0.0", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -591,7 +591,7 @@ func TestHandleComparePage(t *testing.T) {
} }
// Test invalid format (missing separator) // Test invalid format (missing separator)
req = httptest.NewRequest("GET", "/package/npm/test/compare/invalid", nil) req = httptest.NewRequest("GET", "/ui/package/npm/test/compare/invalid", nil)
w = httptest.NewRecorder() w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -600,7 +600,7 @@ func TestHandleComparePage(t *testing.T) {
} }
// Test with only one dot (should fail) // Test with only one dot (should fail)
req = httptest.NewRequest("GET", "/package/npm/test/compare/1.0.0.2.0.0", nil) req = httptest.NewRequest("GET", "/ui/package/npm/test/compare/1.0.0.2.0.0", nil)
w = httptest.NewRecorder() w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)

View file

@ -24,8 +24,17 @@
// - /health - Health check endpoint // - /health - Health check endpoint
// - /stats - Cache statistics (JSON) // - /stats - Cache statistics (JSON)
// - /openapi.json - OpenAPI spec (JSON) // - /openapi.json - OpenAPI spec (JSON)
// - /packages - List all cached packages (HTML) // - /metrics - Prometheus metrics
// - /search - Search packages (HTML) //
// Web UI (HTML), mounted under /ui so reverse proxies can gate it
// separately from the package endpoints:
// - /ui/ - Dashboard
// - /ui/install - Client configuration guide
// - /ui/packages - List all cached packages
// - /ui/search - Search packages
// - /ui/package/... - Package and version detail pages
// - /ui/api/browse/... - Archive browsing (used by the UI)
// - /ui/api/compare/... - Archive diffing (used by the UI)
// //
// API endpoints for enrichment data: // API endpoints for enrichment data:
// - GET /api/package/{ecosystem}/{name} - Package metadata // - GET /api/package/{ecosystem}/{name} - Package metadata
@ -229,19 +238,29 @@ func (s *Server) Start() error {
r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes())) r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes()))
r.Mount("/rpm", http.StripPrefix("/rpm", rpmHandler.Routes())) r.Mount("/rpm", http.StripPrefix("/rpm", rpmHandler.Routes()))
// Health, stats, and static endpoints // Health, stats, and metrics endpoints
r.Get("/health", s.handleHealth) r.Get("/health", s.handleHealth)
r.Get("/stats", s.handleStats) r.Get("/stats", s.handleStats)
r.Get("/openapi.json", s.handleOpenAPIJSON) r.Get("/openapi.json", s.handleOpenAPIJSON)
r.Get("/metrics", func(w http.ResponseWriter, r *http.Request) { r.Get("/metrics", func(w http.ResponseWriter, r *http.Request) {
metrics.Handler().ServeHTTP(w, r) metrics.Handler().ServeHTTP(w, r)
}) })
r.Mount("/static", http.StripPrefix("/static/", staticHandler()))
r.Get("/", s.handleRoot) // Web UI. Mounted under /ui so a reverse proxy can apply different
r.Get("/install", s.handleInstall) // access rules to it than to the package endpoints above (#123).
r.Get("/search", s.handleSearch) r.Route("/ui", func(ui chi.Router) {
r.Get("/packages", s.handlePackagesList) ui.Mount("/static", http.StripPrefix("/ui/static/", staticHandler()))
r.Get("/package/{ecosystem}/*", s.handlePackagePath) ui.Get("/", s.handleRoot)
ui.Get("/install", s.handleInstall)
ui.Get("/search", s.handleSearch)
ui.Get("/packages", s.handlePackagesList)
ui.Get("/package/{ecosystem}/*", s.handlePackagePath)
ui.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath)
ui.Get("/api/compare/{ecosystem}/*", s.handleComparePath)
})
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/", http.StatusFound)
})
// API endpoints for enrichment data // API endpoints for enrichment data
enrichSvc := enrichment.New(s.logger) enrichSvc := enrichment.New(s.logger)
@ -254,10 +273,6 @@ func (s *Server) Start() error {
r.Get("/api/search", apiHandler.HandleSearch) r.Get("/api/search", apiHandler.HandleSearch)
r.Get("/api/packages", apiHandler.HandlePackagesList) r.Get("/api/packages", apiHandler.HandlePackagesList)
// Archive browsing and comparison endpoints also use wildcard for namespaced packages
r.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath)
r.Get("/api/compare/{ecosystem}/*", s.handleComparePath)
// Start background context (used by mirror jobs and cleanup) // Start background context (used by mirror jobs and cleanup)
bgCtx, bgCancel := context.WithCancel(context.Background()) bgCtx, bgCancel := context.WithCancel(context.Background())
s.cancel = bgCancel s.cancel = bgCancel
@ -488,7 +503,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
ecosystem := r.URL.Query().Get("ecosystem") ecosystem := r.URL.Query().Get("ecosystem")
if query == "" { if query == "" {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/ui/", http.StatusSeeOther)
return return
} }

View file

@ -101,14 +101,19 @@ func newTestServer(t *testing.T) *testServer {
r.Get("/health", s.handleHealth) r.Get("/health", s.handleHealth)
r.Get("/stats", s.handleStats) r.Get("/stats", s.handleStats)
r.Get("/openapi.json", s.handleOpenAPIJSON) r.Get("/openapi.json", s.handleOpenAPIJSON)
r.Mount("/static", http.StripPrefix("/static/", staticHandler())) r.Route("/ui", func(ui chi.Router) {
r.Get("/search", s.handleSearch) ui.Mount("/static", http.StripPrefix("/ui/static/", staticHandler()))
r.Get("/package/{ecosystem}/*", s.handlePackagePath) ui.Get("/", s.handleRoot)
r.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath) ui.Get("/install", s.handleInstall)
r.Get("/api/compare/{ecosystem}/*", s.handleComparePath) ui.Get("/search", s.handleSearch)
r.Get("/", s.handleRoot) ui.Get("/packages", s.handlePackagesList)
r.Get("/install", s.handleInstall) ui.Get("/package/{ecosystem}/*", s.handlePackagePath)
r.Get("/packages", s.handlePackagesList) ui.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath)
ui.Get("/api/compare/{ecosystem}/*", s.handleComparePath)
})
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/", http.StatusFound)
})
return &testServer{ return &testServer{
handler: r, handler: r,
@ -274,7 +279,7 @@ func TestDashboard(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/", nil) req := httptest.NewRequest("GET", "/ui/", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -445,8 +450,8 @@ func TestStaticFiles(t *testing.T) {
path string path string
contentTypes []string contentTypes []string
}{ }{
{"/static/tailwind.js", []string{"text/javascript", "application/javascript"}}, {"/ui/static/tailwind.js", []string{"text/javascript", "application/javascript"}},
{"/static/style.css", []string{"text/css"}}, {"/ui/static/style.css", []string{"text/css"}},
} }
for _, tc := range tests { for _, tc := range tests {
@ -497,7 +502,7 @@ func TestCategorizeLicenseCSS(t *testing.T) {
} }
} }
func TestDashboardWithEnrichmentStats(t *testing.T) { func TestRootRedirectsToUI(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
@ -505,6 +510,22 @@ func TestDashboardWithEnrichmentStats(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Errorf("expected status 302, got %d", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/ui/" {
t.Errorf("expected redirect to /ui/, got %q", loc)
}
}
func TestDashboardWithEnrichmentStats(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/ui/", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code) t.Errorf("expected status 200, got %d", w.Code)
} }
@ -512,7 +533,7 @@ func TestDashboardWithEnrichmentStats(t *testing.T) {
body := w.Body.String() body := w.Body.String()
// Dashboard should link to Tailwind JS // Dashboard should link to Tailwind JS
if !strings.Contains(body, "/static/tailwind.js") { if !strings.Contains(body, "/ui/static/tailwind.js") {
t.Error("dashboard should link to Tailwind JS") t.Error("dashboard should link to Tailwind JS")
} }
@ -553,7 +574,7 @@ func TestVersionShowWithHitCount(t *testing.T) {
t.Fatalf("failed to upsert artifact: %v", err) t.Fatalf("failed to upsert artifact: %v", err)
} }
req := httptest.NewRequest("GET", "/package/npm/test/1.0.0", nil) req := httptest.NewRequest("GET", "/ui/package/npm/test/1.0.0", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -605,7 +626,7 @@ func TestSearchWithNullValues(t *testing.T) {
t.Fatalf("failed to upsert artifact: %v", err) t.Fatalf("failed to upsert artifact: %v", err)
} }
req := httptest.NewRequest("GET", "/search?q=test", nil) req := httptest.NewRequest("GET", "/ui/search?q=test", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -697,7 +718,7 @@ func TestSearchRedirectsWhenEmpty(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/search", nil) req := httptest.NewRequest("GET", "/ui/search", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -706,8 +727,8 @@ func TestSearchRedirectsWhenEmpty(t *testing.T) {
} }
loc := w.Header().Get("Location") loc := w.Header().Get("Location")
if loc != "/" { if loc != "/ui/" {
t.Errorf("expected redirect to /, got %q", loc) t.Errorf("expected redirect to /ui/, got %q", loc)
} }
} }
@ -715,7 +736,7 @@ func TestPackageShowPage_NotFoundServer(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/package/npm/nonexistent-srv", nil) req := httptest.NewRequest("GET", "/ui/package/npm/nonexistent-srv", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -728,7 +749,7 @@ func TestVersionShowPage_NotFoundServer(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/package/npm/nonexistent-srv/1.0.0", nil) req := httptest.NewRequest("GET", "/ui/package/npm/nonexistent-srv/1.0.0", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -759,7 +780,7 @@ func TestPackageShowPage_WithLicense(t *testing.T) {
t.Fatalf("failed to upsert version: %v", err) t.Fatalf("failed to upsert version: %v", err)
} }
req := httptest.NewRequest("GET", "/package/npm/show-test-lic", nil) req := httptest.NewRequest("GET", "/ui/package/npm/show-test-lic", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -801,8 +822,8 @@ func TestComposerNamespacedPackageRoutes(t *testing.T) {
url string url string
want string want string
}{ }{
{"package show", "/package/composer/monolog/monolog", "monolog/monolog"}, {"package show", "/ui/package/composer/monolog/monolog", "monolog/monolog"},
{"version show", "/package/composer/symfony/console/6.0.0", "symfony/console"}, {"version show", "/ui/package/composer/symfony/console/6.0.0", "symfony/console"},
} }
for _, tt := range tests { for _, tt := range tests {
@ -859,11 +880,11 @@ func TestNamespacedPackageRoutes(t *testing.T) {
url string url string
want int want int
}{ }{
{"npm scoped package show", "/package/npm/@babel/core", http.StatusOK}, {"npm scoped package show", "/ui/package/npm/@babel/core", http.StatusOK},
{"golang module show", "/package/golang/github.com/stretchr/testify", http.StatusOK}, {"golang module show", "/ui/package/golang/github.com/stretchr/testify", http.StatusOK},
{"oci image show", "/package/oci/library/nginx", http.StatusOK}, {"oci image show", "/ui/package/oci/library/nginx", http.StatusOK},
{"conda package show", "/package/conda/conda-forge/numpy", http.StatusOK}, {"conda package show", "/ui/package/conda/conda-forge/numpy", http.StatusOK},
{"conan package show", "/package/conan/zlib/1.2.13@demo/stable", http.StatusOK}, {"conan package show", "/ui/package/conan/zlib/1.2.13@demo/stable", http.StatusOK},
} }
for _, tt := range tests { for _, tt := range tests {
@ -886,7 +907,7 @@ func TestSearchPage_WithSeededResults(t *testing.T) {
seedTestPackage(t, ts.db, "searchable-pkg") seedTestPackage(t, ts.db, "searchable-pkg")
req := httptest.NewRequest("GET", "/search?q=searchable", nil) req := httptest.NewRequest("GET", "/ui/search?q=searchable", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -934,7 +955,7 @@ func TestSearchPage_PaginationMultiPage(t *testing.T) {
} }
// First page // First page
req := httptest.NewRequest("GET", "/search?q=page-test", nil) req := httptest.NewRequest("GET", "/ui/search?q=page-test", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -948,7 +969,7 @@ func TestSearchPage_PaginationMultiPage(t *testing.T) {
} }
// Second page // Second page
req = httptest.NewRequest("GET", "/search?q=page-test&page=2", nil) req = httptest.NewRequest("GET", "/ui/search?q=page-test&page=2", nil)
w = httptest.NewRecorder() w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -1014,7 +1035,7 @@ func TestSearchPage_EcosystemFilterWithSeededData(t *testing.T) {
} }
// Search with ecosystem filter for npm only // Search with ecosystem filter for npm only
req := httptest.NewRequest("GET", "/search?q=eco-filter&ecosystem=npm", nil) req := httptest.NewRequest("GET", "/ui/search?q=eco-filter&ecosystem=npm", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -1037,7 +1058,7 @@ func TestHandlePackagesListPage(t *testing.T) {
seedTestPackage(t, ts.db, "list-test") seedTestPackage(t, ts.db, "list-test")
req := httptest.NewRequest("GET", "/packages", nil) req := httptest.NewRequest("GET", "/ui/packages", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)

View file

@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}git-pkgs proxy{{end}}</title> <title>{{block "title" .}}git-pkgs proxy{{end}}</title>
<script src="/static/tailwind.js"></script> <script src="/ui/static/tailwind.js"></script>
<script> <script>
tailwind.config = { darkMode: 'class' } tailwind.config = { darkMode: 'class' }
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {

View file

@ -11,7 +11,7 @@
<div> <div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Resources</h3> <h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Resources</h3>
<ul class="space-y-2 text-sm"> <ul class="space-y-2 text-sm">
<li><a href="/install" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Configuration Guide</a></li> <li><a href="/ui/install" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Configuration Guide</a></li>
<li><a href="/health" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Health Check</a></li> <li><a href="/health" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Health Check</a></li>
<li><a href="/stats" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">API Stats</a></li> <li><a href="/stats" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">API Stats</a></li>
<li><a href="/openapi.json" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">OpenAPI Spec</a></li> <li><a href="/openapi.json" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">OpenAPI Spec</a></li>

View file

@ -6,10 +6,10 @@
<svg class="w-8 h-8 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-8 h-8 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg> </svg>
<a href="/" class="text-xl font-semibold hover:text-gray-700 dark:hover:text-gray-300">git-pkgs proxy</a> <a href="/ui/" class="text-xl font-semibold hover:text-gray-700 dark:hover:text-gray-300">git-pkgs proxy</a>
</div> </div>
<div class="flex-1 max-w-md mx-8"> <div class="flex-1 max-w-md mx-8">
<form action="/search" method="get" class="relative"> <form action="/ui/search" method="get" class="relative">
<input <input
type="text" type="text"
name="q" name="q"
@ -22,7 +22,7 @@
</form> </form>
</div> </div>
<nav class="flex items-center gap-6"> <nav class="flex items-center gap-6">
<a href="/install" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Install</a> <a href="/ui/install" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Install</a>
<a href="/health" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Health</a> <a href="/health" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Health</a>
<a href="/stats" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">API</a> <a href="/stats" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">API</a>
<button id="theme-toggle" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"> <button id="theme-toggle" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">

View file

@ -3,11 +3,11 @@
{{define "content"}} {{define "content"}}
<div class="mb-6"> <div class="mb-6">
<nav class="text-sm text-gray-600 dark:text-gray-400 mb-4"> <nav class="text-sm text-gray-600 dark:text-gray-400 mb-4">
<a href="/" class="hover:text-gray-900 dark:hover:text-gray-100">Home</a> <a href="/ui/" class="hover:text-gray-900 dark:hover:text-gray-100">Home</a>
<span class="mx-2">/</span> <span class="mx-2">/</span>
<a href="/package/{{.Ecosystem}}/{{.PackageName}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.PackageName}}</a> <a href="/ui/package/{{.Ecosystem}}/{{.PackageName}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.PackageName}}</a>
<span class="mx-2">/</span> <span class="mx-2">/</span>
<a href="/package/{{.Ecosystem}}/{{.PackageName}}/{{.Version}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.Version}}</a> <a href="/ui/package/{{.Ecosystem}}/{{.PackageName}}/{{.Version}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.Version}}</a>
<span class="mx-2">/</span> <span class="mx-2">/</span>
<span>Browse Source</span> <span>Browse Source</span>
</nav> </nav>
@ -65,7 +65,7 @@ function escapeHTML(str) {
// Load file tree for a directory // Load file tree for a directory
async function loadFileTree(path = '') { async function loadFileTree(path = '') {
try { try {
const url = `/api/browse/${ecosystem}/${packageName}/${version}?path=${encodeURIComponent(path)}`; const url = `/ui/api/browse/${ecosystem}/${packageName}/${version}?path=${encodeURIComponent(path)}`;
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load directory'); if (!response.ok) throw new Error('Failed to load directory');
@ -134,7 +134,7 @@ function renderFileTree(files, basePath) {
// Load and display file content // Load and display file content
async function loadFile(path) { async function loadFile(path) {
try { try {
const url = `/api/browse/${ecosystem}/${packageName}/${version}/file/${path}`; const url = `/ui/api/browse/${ecosystem}/${packageName}/${version}/file/${path}`;
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load file'); if (!response.ok) throw new Error('Failed to load file');

View file

@ -3,9 +3,9 @@
{{define "content"}} {{define "content"}}
<div class="mb-6"> <div class="mb-6">
<nav class="text-sm text-gray-600 dark:text-gray-400 mb-4"> <nav class="text-sm text-gray-600 dark:text-gray-400 mb-4">
<a href="/" class="hover:text-gray-900 dark:hover:text-gray-100">Home</a> <a href="/ui/" class="hover:text-gray-900 dark:hover:text-gray-100">Home</a>
<span class="mx-2">/</span> <span class="mx-2">/</span>
<a href="/package/{{.Ecosystem}}/{{.PackageName}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.PackageName}}</a> <a href="/ui/package/{{.Ecosystem}}/{{.PackageName}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.PackageName}}</a>
<span class="mx-2">/</span> <span class="mx-2">/</span>
<span>Compare Versions</span> <span>Compare Versions</span>
</nav> </nav>
@ -79,7 +79,7 @@ const toVersion = '{{.ToVersion}}';
async function loadDiff() { async function loadDiff() {
try { try {
const url = `/api/compare/${ecosystem}/${packageName}/${fromVersion}/${toVersion}`; const url = `/ui/api/compare/${ecosystem}/${packageName}/${fromVersion}/${toVersion}`;
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {

View file

@ -67,7 +67,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{{template "ecosystem_badge" .Ecosystem}} {{template "ecosystem_badge" .Ecosystem}}
<a href="/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a> <a href="/ui/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a>
</div> </div>
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
{{if .License}}<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-100 text-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-700 dark:bg-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-900 dark:text-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-300">{{.License}}</span>{{end}} {{if .License}}<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-100 text-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-700 dark:bg-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-900 dark:text-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-300">{{.License}}</span>{{end}}
@ -81,7 +81,7 @@
</div> </div>
{{end}} {{end}}
<div class="px-6 py-3 text-center border-t border-gray-200 dark:border-gray-800"> <div class="px-6 py-3 text-center border-t border-gray-200 dark:border-gray-800">
<a href="/packages?sort=hits" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">See all packages →</a> <a href="/ui/packages?sort=hits" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">See all packages →</a>
</div> </div>
{{else}} {{else}}
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">No packages cached yet</div> <div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">No packages cached yet</div>
@ -101,7 +101,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{{template "ecosystem_badge" .Ecosystem}} {{template "ecosystem_badge" .Ecosystem}}
<a href="/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a> <a href="/ui/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a>
<span class="text-gray-500 dark:text-gray-400">@{{.Version}}</span> <span class="text-gray-500 dark:text-gray-400">@{{.Version}}</span>
</div> </div>
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
@ -117,7 +117,7 @@
</div> </div>
{{end}} {{end}}
<div class="px-6 py-3 text-center border-t border-gray-200 dark:border-gray-800"> <div class="px-6 py-3 text-center border-t border-gray-200 dark:border-gray-800">
<a href="/packages?sort=cached_at" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">See all packages →</a> <a href="/ui/packages?sort=cached_at" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">See all packages →</a>
</div> </div>
{{else}} {{else}}
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">No packages cached yet</div> <div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">No packages cached yet</div>

View file

@ -37,7 +37,7 @@
<h2 class="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-100">Need Help?</h2> <h2 class="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-100">Need Help?</h2>
<p class="text-sm text-blue-800 dark:text-blue-200"> <p class="text-sm text-blue-800 dark:text-blue-200">
After configuring your package manager, install packages as usual. The proxy will automatically cache them. After configuring your package manager, install packages as usual. The proxy will automatically cache them.
Check the <a href="/" class="underline hover:no-underline">dashboard</a> to see cached packages. Check the <a href="/ui/" class="underline hover:no-underline">dashboard</a> to see cached packages.
</p> </p>
</div> </div>
{{end}} {{end}}

View file

@ -64,7 +64,7 @@
<div class="px-6 py-3 flex items-center justify-between version-row"> <div class="px-6 py-3 flex items-center justify-between version-row">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<input type="checkbox" class="version-checkbox hidden" data-version="{{.Version}}" /> <input type="checkbox" class="version-checkbox hidden" data-version="{{.Version}}" />
<a href="/package/{{$.Package.Ecosystem}}/{{$.Package.Name}}/{{.Version}}" class="font-mono text-sm hover:text-blue-600 dark:hover:text-blue-400">{{.PURL}}</a> <a href="/ui/package/{{$.Package.Ecosystem}}/{{$.Package.Name}}/{{.Version}}" class="font-mono text-sm hover:text-blue-600 dark:hover:text-blue-400">{{.PURL}}</a>
{{if .Yanked}}<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">yanked</span>{{end}} {{if .Yanked}}<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">yanked</span>{{end}}
</div> </div>
{{if .PublishedAt.Valid}}<span class="text-sm text-gray-500 dark:text-gray-400">{{.PublishedAt.Time.Format "2006-01-02"}}</span>{{end}} {{if .PublishedAt.Valid}}<span class="text-sm text-gray-500 dark:text-gray-400">{{.PublishedAt.Time.Format "2006-01-02"}}</span>{{end}}
@ -123,7 +123,7 @@ document.addEventListener('change', function(e) {
// Navigate to compare page // Navigate to compare page
const v1 = checked[0].dataset.version; const v1 = checked[0].dataset.version;
const v2 = checked[1].dataset.version; const v2 = checked[1].dataset.version;
window.location.href = `/package/${ecosystem}/${packageName}/compare/${v1}...${v2}`; window.location.href = `/ui/package/${ecosystem}/${packageName}/compare/${v1}...${v2}`;
} else if (checked.length > 2) { } else if (checked.length > 2) {
// Uncheck the oldest selection // Uncheck the oldest selection
checked[0].checked = false; checked[0].checked = false;

View file

@ -39,7 +39,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{{template "ecosystem_badge" .Ecosystem}} {{template "ecosystem_badge" .Ecosystem}}
<a href="/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a> <a href="/ui/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a>
{{if .LatestVersion}} {{if .LatestVersion}}
<span class="text-gray-500 dark:text-gray-400">@{{.LatestVersion}}</span> <span class="text-gray-500 dark:text-gray-400">@{{.LatestVersion}}</span>
{{end}} {{end}}
@ -67,7 +67,7 @@
{{else}} {{else}}
<div class="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 p-12 text-center"> <div class="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 p-12 text-center">
<p class="text-gray-500 dark:text-gray-400">No cached packages found{{if .Ecosystem}} in {{.Ecosystem}}{{end}}</p> <p class="text-gray-500 dark:text-gray-400">No cached packages found{{if .Ecosystem}} in {{.Ecosystem}}{{end}}</p>
<a href="/" class="mt-4 inline-block text-sm text-blue-600 dark:text-blue-400 hover:underline">Return to dashboard</a> <a href="/ui/" class="mt-4 inline-block text-sm text-blue-600 dark:text-blue-400 hover:underline">Return to dashboard</a>
</div> </div>
{{end}} {{end}}

View file

@ -28,7 +28,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{{template "ecosystem_badge" .Ecosystem}} {{template "ecosystem_badge" .Ecosystem}}
<a href="/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a> <a href="/ui/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a>
{{if .LatestVersion}} {{if .LatestVersion}}
<span class="text-gray-500 dark:text-gray-400">@{{.LatestVersion}}</span> <span class="text-gray-500 dark:text-gray-400">@{{.LatestVersion}}</span>
{{end}} {{end}}
@ -50,7 +50,7 @@
{{else}} {{else}}
<div class="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 p-12 text-center"> <div class="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 p-12 text-center">
<p class="text-gray-500 dark:text-gray-400">No packages found matching "{{.Query}}"</p> <p class="text-gray-500 dark:text-gray-400">No packages found matching "{{.Query}}"</p>
<a href="/" class="mt-4 inline-block text-sm text-blue-600 dark:text-blue-400 hover:underline">Return to dashboard</a> <a href="/ui/" class="mt-4 inline-block text-sm text-blue-600 dark:text-blue-400 hover:underline">Return to dashboard</a>
</div> </div>
{{end}} {{end}}

View file

@ -3,9 +3,9 @@
{{define "content"}} {{define "content"}}
<div class="mb-6"> <div class="mb-6">
<nav class="text-sm text-gray-600 dark:text-gray-400 mb-4"> <nav class="text-sm text-gray-600 dark:text-gray-400 mb-4">
<a href="/" class="hover:text-gray-900 dark:hover:text-gray-100">Home</a> <a href="/ui/" class="hover:text-gray-900 dark:hover:text-gray-100">Home</a>
<span class="mx-2">/</span> <span class="mx-2">/</span>
<a href="/package/{{.Package.Ecosystem}}/{{.Package.Name}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.Package.Name}}</a> <a href="/ui/package/{{.Package.Ecosystem}}/{{.Package.Name}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.Package.Name}}</a>
</nav> </nav>
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
{{template "ecosystem_badge" .Package.Ecosystem}} {{template "ecosystem_badge" .Package.Ecosystem}}
@ -22,7 +22,7 @@
{{end}} {{end}}
{{if .HasCachedArtifact}} {{if .HasCachedArtifact}}
<div class="mt-4"> <div class="mt-4">
<a href="/package/{{.Package.Ecosystem}}/{{.Package.Name}}/{{.Version.Version}}/browse" <a href="/ui/package/{{.Package.Ecosystem}}/{{.Package.Name}}/{{.Version.Version}}/browse"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"> class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>

View file

@ -182,7 +182,7 @@ func TestInstallPage(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/install", nil) req := httptest.NewRequest("GET", "/ui/install", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -221,7 +221,7 @@ func TestPackageShowPage(t *testing.T) {
t.Fatalf("failed to upsert version: %v", err) t.Fatalf("failed to upsert version: %v", err)
} }
req := httptest.NewRequest("GET", "/package/npm/test-show", nil) req := httptest.NewRequest("GET", "/ui/package/npm/test-show", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -245,7 +245,7 @@ func TestPackageShowPage_NotFound(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/package/npm/nonexistent", nil) req := httptest.NewRequest("GET", "/ui/package/npm/nonexistent", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -258,7 +258,7 @@ func TestVersionShowPage_NotFound(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/package/npm/nonexistent/1.0.0", nil) req := httptest.NewRequest("GET", "/ui/package/npm/nonexistent/1.0.0", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -271,7 +271,7 @@ func TestSearchPage_EmptyQuery(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/search", nil) req := httptest.NewRequest("GET", "/ui/search", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -285,7 +285,7 @@ func TestSearchPage_WithQuery(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/search?q=test", nil) req := httptest.NewRequest("GET", "/ui/search?q=test", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -304,7 +304,7 @@ func TestSearchPage_Pagination(t *testing.T) {
defer ts.close() defer ts.close()
// Page 0 or negative should default to page 1 // Page 0 or negative should default to page 1
req := httptest.NewRequest("GET", "/search?q=test&page=0", nil) req := httptest.NewRequest("GET", "/ui/search?q=test&page=0", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -313,7 +313,7 @@ func TestSearchPage_Pagination(t *testing.T) {
} }
// Non-numeric page should default to page 1 // Non-numeric page should default to page 1
req = httptest.NewRequest("GET", "/search?q=test&page=abc", nil) req = httptest.NewRequest("GET", "/ui/search?q=test&page=abc", nil)
w = httptest.NewRecorder() w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -326,7 +326,7 @@ func TestSearchPage_EcosystemFilter(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/search?q=test&ecosystem=npm", nil) req := httptest.NewRequest("GET", "/ui/search?q=test&ecosystem=npm", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)