1
0
Fork 1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-06-02 00:38:16 -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
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.
- **Install guide** (`/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.
- **Search** (`/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.
- **Version detail** (`/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.
- **Version diff** (`/package/{ecosystem}/{name}/compare/{v1}...{v2}`) -- side-by-side diff of two cached versions showing added, removed, and changed files.
- **Dashboard** (`/ui/`) -- cache stats, popular packages, recently cached artifacts, and vulnerability overview.
- **Install guide** (`/ui/install`) -- per-ecosystem configuration instructions, so you don't have to look them up here.
- **Package browser** (`/ui/packages`) -- browse all cached packages with filtering by ecosystem and sorting by hits, size, name, or vulnerability count.
- **Search** (`/ui/search?q=...`) -- search cached packages by name.
- **Package detail** (`/ui/package/{ecosystem}/{name}`) -- metadata, license, vulnerabilities, and version list for a package. You can select two versions to compare.
- **Version detail** (`/ui/package/{ecosystem}/{name}/{version}`) -- per-version metadata, integrity hash, artifact cache status, and hit counts.
- **Source browser** (`/ui/package/{ecosystem}/{name}/{version}/browse`) -- browse files inside cached archives with syntax highlighting for text files and image previews.
- **Version diff** (`/ui/package/{ecosystem}/{name}/compare/{v1}...{v2}`) -- side-by-side diff of two cached versions showing added, removed, and changed files.
## 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 │ │
│ │ /gem/* -> GemHandler /metrics -> prometheus │ │
│ │ ...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
- Mounts protocol handlers at ecosystem-specific paths
- 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`
- 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.

View file

@ -15,135 +15,6 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"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": {
"post": {
"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": {
"post": {
"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": {

View file

@ -8,135 +8,6 @@
},
"basePath": "/",
"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": {
"post": {
"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": {
"post": {
"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": {

View file

@ -119,7 +119,7 @@ type BrowseFileInfo struct {
// @Success 200 {object} BrowseListResponse
// @Failure 404 {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.
// 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 404 {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) {
if filePath == "" {
badRequest(w, "file path required")
@ -498,7 +498,7 @@ type BrowseSourceData struct {
// @Success 200 {object} map[string]any
// @Failure 404 {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) {
// Get artifacts for both versions
fromPURL := purl.MakePURLString(ecosystem, name, fromVersion)

View file

@ -65,7 +65,7 @@ func TestHandleBrowseList(t *testing.T) {
}
// 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()
ts.handler.ServeHTTP(w, req)
@ -83,7 +83,7 @@ func TestHandleBrowseList(t *testing.T) {
}
// 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()
ts.handler.ServeHTTP(w, req)
@ -138,7 +138,7 @@ func TestHandleBrowseFile(t *testing.T) {
}
// 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()
ts.handler.ServeHTTP(w, req)
@ -158,7 +158,7 @@ func TestHandleBrowseFile(t *testing.T) {
}
// 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()
ts.handler.ServeHTTP(w, req)
@ -314,7 +314,7 @@ func TestBrowseNonCachedArtifact(t *testing.T) {
}
// 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()
ts.handler.ServeHTTP(w, req)
@ -368,7 +368,7 @@ func TestHandleBrowseSourcePage(t *testing.T) {
}
// 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()
ts.handler.ServeHTTP(w, req)
@ -501,7 +501,7 @@ func TestHandleCompareDiff(t *testing.T) {
}
// 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()
ts.handler.ServeHTTP(w, req)
@ -572,7 +572,7 @@ func TestHandleComparePage(t *testing.T) {
defer ts.close()
// 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()
ts.handler.ServeHTTP(w, req)
@ -591,7 +591,7 @@ func TestHandleComparePage(t *testing.T) {
}
// 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()
ts.handler.ServeHTTP(w, req)
@ -600,7 +600,7 @@ func TestHandleComparePage(t *testing.T) {
}
// 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()
ts.handler.ServeHTTP(w, req)

View file

@ -21,11 +21,20 @@
// - /rpm/* - RPM/Yum repository protocol
//
// Additional endpoints:
// - /health - Health check endpoint
// - /stats - Cache statistics (JSON)
// - /health - Health check endpoint
// - /stats - Cache statistics (JSON)
// - /openapi.json - OpenAPI spec (JSON)
// - /packages - List all cached packages (HTML)
// - /search - Search packages (HTML)
// - /metrics - Prometheus metrics
//
// 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:
// - 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("/rpm", http.StripPrefix("/rpm", rpmHandler.Routes()))
// Health, stats, and static endpoints
// Health, stats, and metrics endpoints
r.Get("/health", s.handleHealth)
r.Get("/stats", s.handleStats)
r.Get("/openapi.json", s.handleOpenAPIJSON)
r.Get("/metrics", func(w http.ResponseWriter, r *http.Request) {
metrics.Handler().ServeHTTP(w, r)
})
r.Mount("/static", http.StripPrefix("/static/", staticHandler()))
r.Get("/", s.handleRoot)
r.Get("/install", s.handleInstall)
r.Get("/search", s.handleSearch)
r.Get("/packages", s.handlePackagesList)
r.Get("/package/{ecosystem}/*", s.handlePackagePath)
// Web UI. Mounted under /ui so a reverse proxy can apply different
// access rules to it than to the package endpoints above (#123).
r.Route("/ui", func(ui chi.Router) {
ui.Mount("/static", http.StripPrefix("/ui/static/", staticHandler()))
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
enrichSvc := enrichment.New(s.logger)
@ -254,10 +273,6 @@ func (s *Server) Start() error {
r.Get("/api/search", apiHandler.HandleSearch)
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)
bgCtx, bgCancel := context.WithCancel(context.Background())
s.cancel = bgCancel
@ -488,7 +503,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
ecosystem := r.URL.Query().Get("ecosystem")
if query == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
http.Redirect(w, r, "/ui/", http.StatusSeeOther)
return
}

View file

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

View file

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

View file

@ -11,7 +11,7 @@
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Resources</h3>
<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="/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>

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">
<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>
<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 class="flex-1 max-w-md mx-8">
<form action="/search" method="get" class="relative">
<form action="/ui/search" method="get" class="relative">
<input
type="text"
name="q"
@ -22,7 +22,7 @@
</form>
</div>
<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="/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">

View file

@ -3,11 +3,11 @@
{{define "content"}}
<div class="mb-6">
<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>
<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>
<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>Browse Source</span>
</nav>
@ -65,7 +65,7 @@ function escapeHTML(str) {
// Load file tree for a directory
async function loadFileTree(path = '') {
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);
if (!response.ok) throw new Error('Failed to load directory');
@ -134,7 +134,7 @@ function renderFileTree(files, basePath) {
// Load and display file content
async function loadFile(path) {
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);
if (!response.ok) throw new Error('Failed to load file');

View file

@ -3,9 +3,9 @@
{{define "content"}}
<div class="mb-6">
<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>
<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>Compare Versions</span>
</nav>
@ -79,7 +79,7 @@ const toVersion = '{{.ToVersion}}';
async function loadDiff() {
try {
const url = `/api/compare/${ecosystem}/${packageName}/${fromVersion}/${toVersion}`;
const url = `/ui/api/compare/${ecosystem}/${packageName}/${fromVersion}/${toVersion}`;
const response = await fetch(url);
if (!response.ok) {

View file

@ -67,7 +67,7 @@
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
{{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 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}}
@ -81,7 +81,7 @@
</div>
{{end}}
<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>
{{else}}
<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="flex items-center gap-2">
{{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>
</div>
<div class="flex items-center gap-2 mt-1">
@ -117,7 +117,7 @@
</div>
{{end}}
<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>
{{else}}
<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>
<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.
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>
</div>
{{end}}

View file

@ -64,7 +64,7 @@
<div class="px-6 py-3 flex items-center justify-between version-row">
<div class="flex items-center gap-3">
<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}}
</div>
{{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
const v1 = checked[0].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) {
// Uncheck the oldest selection
checked[0].checked = false;

View file

@ -39,7 +39,7 @@
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
{{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}}
<span class="text-gray-500 dark:text-gray-400">@{{.LatestVersion}}</span>
{{end}}
@ -67,7 +67,7 @@
{{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">
<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>
{{end}}

View file

@ -28,7 +28,7 @@
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
{{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}}
<span class="text-gray-500 dark:text-gray-400">@{{.LatestVersion}}</span>
{{end}}
@ -50,7 +50,7 @@
{{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">
<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>
{{end}}

View file

@ -3,9 +3,9 @@
{{define "content"}}
<div class="mb-6">
<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>
<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>
<div class="flex items-center gap-3 mb-2">
{{template "ecosystem_badge" .Package.Ecosystem}}
@ -22,7 +22,7 @@
{{end}}
{{if .HasCachedArtifact}}
<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">
<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>

View file

@ -182,7 +182,7 @@ func TestInstallPage(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/install", nil)
req := httptest.NewRequest("GET", "/ui/install", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@ -221,7 +221,7 @@ func TestPackageShowPage(t *testing.T) {
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()
ts.handler.ServeHTTP(w, req)
@ -245,7 +245,7 @@ func TestPackageShowPage_NotFound(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/package/npm/nonexistent", nil)
req := httptest.NewRequest("GET", "/ui/package/npm/nonexistent", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@ -258,7 +258,7 @@ func TestVersionShowPage_NotFound(t *testing.T) {
ts := newTestServer(t)
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()
ts.handler.ServeHTTP(w, req)
@ -271,7 +271,7 @@ func TestSearchPage_EmptyQuery(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/search", nil)
req := httptest.NewRequest("GET", "/ui/search", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@ -285,7 +285,7 @@ func TestSearchPage_WithQuery(t *testing.T) {
ts := newTestServer(t)
defer ts.close()
req := httptest.NewRequest("GET", "/search?q=test", nil)
req := httptest.NewRequest("GET", "/ui/search?q=test", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@ -304,7 +304,7 @@ func TestSearchPage_Pagination(t *testing.T) {
defer ts.close()
// 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()
ts.handler.ServeHTTP(w, req)
@ -313,7 +313,7 @@ func TestSearchPage_Pagination(t *testing.T) {
}
// 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()
ts.handler.ServeHTTP(w, req)
@ -326,7 +326,7 @@ func TestSearchPage_EcosystemFilter(t *testing.T) {
ts := newTestServer(t)
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()
ts.handler.ServeHTTP(w, req)