aboutsummaryrefslogtreecommitdiffhomepage
path: root/cmd/mbf
diff options
context:
space:
mode:
authormae <mae@maestoso.online>2026-03-04 22:50:58 -0600
committerOphestra <cat@gensokyo.uk>2026-05-02 05:05:56 +0900
commit1d5d063d6a443c9d4a1206d2743e4824411da956 (patch)
treeb32105cf3896f394be91099cfbfb67785de39385 /cmd/mbf
parente61628a34ecccae0feb5e059a2f808977261fa0e (diff)
cmd/mbf: package status dashboard
This displays package metadata with optional status from a report.
Diffstat (limited to 'cmd/mbf')
-rw-r--r--cmd/mbf/info.go15
-rw-r--r--cmd/mbf/info_test.go23
-rw-r--r--cmd/mbf/internal/pkgserver/api.go202
-rw-r--r--cmd/mbf/internal/pkgserver/api_test.go181
-rw-r--r--cmd/mbf/internal/pkgserver/index.go106
-rw-r--r--cmd/mbf/internal/pkgserver/index_test.go96
-rw-r--r--cmd/mbf/internal/pkgserver/search.go81
-rw-r--r--cmd/mbf/internal/pkgserver/ui/index.html57
-rw-r--r--cmd/mbf/internal/pkgserver/ui/index.ts331
-rw-r--r--cmd/mbf/internal/pkgserver/ui/style.css21
-rw-r--r--cmd/mbf/internal/pkgserver/ui/tsconfig.json8
-rw-r--r--cmd/mbf/internal/pkgserver/ui/ui.go9
-rw-r--r--cmd/mbf/internal/pkgserver/ui/ui_full.go21
-rw-r--r--cmd/mbf/internal/pkgserver/ui/ui_stub.go7
-rw-r--r--cmd/mbf/main.go51
15 files changed, 1188 insertions, 21 deletions
diff --git a/cmd/mbf/info.go b/cmd/mbf/info.go
index f421ce66..15357301 100644
--- a/cmd/mbf/info.go
+++ b/cmd/mbf/info.go
@@ -17,25 +17,12 @@ func commandInfo(
args []string,
w io.Writer,
writeStatus bool,
- reportPath string,
+ r *rosa.Report,
) (err error) {
if len(args) == 0 {
return errors.New("info requires at least 1 argument")
}
- var r *rosa.Report
- if reportPath != "" {
- if r, err = rosa.OpenReport(reportPath); err != nil {
- return err
- }
- defer func() {
- if closeErr := r.Close(); err == nil {
- err = closeErr
- }
- }()
- defer r.HandleAccess(&err)()
- }
-
// recovered by HandleAccess
mustPrintln := func(a ...any) {
if _, _err := fmt.Fprintln(w, a...); _err != nil {
diff --git a/cmd/mbf/info_test.go b/cmd/mbf/info_test.go
index 16e94364..a7219e9c 100644
--- a/cmd/mbf/info_test.go
+++ b/cmd/mbf/info_test.go
@@ -95,7 +95,7 @@ status : not in report
var (
cm *cache
buf strings.Builder
- rp string
+ r *rosa.Report
)
if tc.status != nil || tc.report != "" {
@@ -108,14 +108,25 @@ status : not in report
}
if tc.report != "" {
- rp = filepath.Join(t.TempDir(), "report")
- if err := os.WriteFile(
- rp,
+ pathname := filepath.Join(t.TempDir(), "report")
+ err := os.WriteFile(
+ pathname,
unsafe.Slice(unsafe.StringData(tc.report), len(tc.report)),
0400,
- ); err != nil {
+ )
+ if err != nil {
t.Fatal(err)
}
+
+ r, err = rosa.OpenReport(pathname)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ if err = r.Close(); err != nil {
+ t.Fatal(err)
+ }
+ }()
}
if tc.status != nil {
@@ -157,7 +168,7 @@ status : not in report
tc.args,
&buf,
cm != nil,
- rp,
+ r,
); !reflect.DeepEqual(err, wantErr) {
t.Fatalf("commandInfo: error = %v, want %v", err, wantErr)
}
diff --git a/cmd/mbf/internal/pkgserver/api.go b/cmd/mbf/internal/pkgserver/api.go
new file mode 100644
index 00000000..599ea522
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/api.go
@@ -0,0 +1,202 @@
+// Package pkgserver implements the package metadata service backend.
+package pkgserver
+
+import (
+ "context"
+ "encoding/json"
+ "log"
+ "net/http"
+ "net/url"
+ "path"
+ "strconv"
+ "sync"
+ "time"
+
+ "hakurei.app/internal/info"
+ "hakurei.app/internal/rosa"
+)
+
+// for lazy initialisation of serveInfo
+var (
+ infoPayload struct {
+ // Current package count.
+ Count int `json:"count"`
+ // Hakurei version, set at link time.
+ HakureiVersion string `json:"hakurei_version"`
+ }
+ infoPayloadOnce sync.Once
+)
+
+// handleInfo writes constant system information.
+func handleInfo(w http.ResponseWriter, _ *http.Request) {
+ infoPayloadOnce.Do(func() {
+ infoPayload.Count = int(rosa.PresetUnexportedStart)
+ infoPayload.HakureiVersion = info.Version()
+ })
+ // TODO(mae): cache entire response if no additional fields are planned
+ writeAPIPayload(w, infoPayload)
+}
+
+// newStatusHandler returns a [http.HandlerFunc] that offers status files for
+// viewing or download, if available.
+func (index *packageIndex) newStatusHandler(disposition bool) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ m, ok := index.names[path.Base(r.URL.Path)]
+ if !ok || !m.HasReport {
+ http.NotFound(w, r)
+ return
+ }
+
+ contentType := "text/plain; charset=utf-8"
+ if disposition {
+ contentType = "application/octet-stream"
+
+ // quoting like this is unsound, but okay, because metadata is hardcoded
+ contentDisposition := `attachment; filename="`
+ contentDisposition += m.Name + "-"
+ if m.Version != "" {
+ contentDisposition += m.Version + "-"
+ }
+ contentDisposition += m.ids + `.log"`
+ w.Header().Set("Content-Disposition", contentDisposition)
+ }
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+ if err := func() (err error) {
+ defer index.handleAccess(&err)()
+ _, err = w.Write(m.status)
+ return
+ }(); err != nil {
+ log.Println(err)
+ http.Error(
+ w, "cannot deliver status, contact maintainers",
+ http.StatusInternalServerError,
+ )
+ }
+ }
+}
+
+// handleGet writes a slice of metadata with specified order.
+func (index *packageIndex) handleGet(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ limit, err := strconv.Atoi(q.Get("limit"))
+ if err != nil || limit > 100 || limit < 1 {
+ http.Error(
+ w, "limit must be an integer between 1 and 100",
+ http.StatusBadRequest,
+ )
+ return
+ }
+ i, err := strconv.Atoi(q.Get("index"))
+ if err != nil || i >= len(index.sorts[0]) || i < 0 {
+ http.Error(
+ w, "index must be an integer between 0 and "+
+ strconv.Itoa(int(rosa.PresetUnexportedStart-1)),
+ http.StatusBadRequest,
+ )
+ return
+ }
+ sort, err := strconv.Atoi(q.Get("sort"))
+ if err != nil || sort >= len(index.sorts) || sort < 0 {
+ http.Error(
+ w, "sort must be an integer between 0 and "+
+ strconv.Itoa(sortOrderEnd),
+ http.StatusBadRequest,
+ )
+ return
+ }
+ values := index.sorts[sort][i:min(i+limit, len(index.sorts[sort]))]
+ writeAPIPayload(w, &struct {
+ Values []*metadata `json:"values"`
+ }{values})
+}
+
+func (index *packageIndex) handleSearch(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ limit, err := strconv.Atoi(q.Get("limit"))
+ if err != nil || limit > 100 || limit < 1 {
+ http.Error(
+ w, "limit must be an integer between 1 and 100",
+ http.StatusBadRequest,
+ )
+ return
+ }
+ i, err := strconv.Atoi(q.Get("index"))
+ if err != nil || i >= len(index.sorts[0]) || i < 0 {
+ http.Error(
+ w, "index must be an integer between 0 and "+
+ strconv.Itoa(int(rosa.PresetUnexportedStart-1)),
+ http.StatusBadRequest,
+ )
+ return
+ }
+ search, err := url.QueryUnescape(q.Get("search"))
+ if len(search) > 100 || err != nil {
+ http.Error(
+ w, "search must be a string between 0 and 100 characters long",
+ http.StatusBadRequest,
+ )
+ return
+ }
+ desc := q.Get("desc") == "true"
+ n, res, err := index.performSearchQuery(limit, i, search, desc)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ writeAPIPayload(w, &struct {
+ Count int `json:"count"`
+ Values []searchResult `json:"values"`
+ }{n, res})
+}
+
+// apiVersion is the name of the current API revision, as part of the pattern.
+const apiVersion = "v1"
+
+// registerAPI registers API handler functions.
+func (index *packageIndex) registerAPI(mux *http.ServeMux) {
+ mux.HandleFunc("GET /api/"+apiVersion+"/info", handleInfo)
+ mux.HandleFunc("GET /api/"+apiVersion+"/get", index.handleGet)
+ mux.HandleFunc("GET /api/"+apiVersion+"/search", index.handleSearch)
+ mux.HandleFunc("GET /api/"+apiVersion+"/status/", index.newStatusHandler(false))
+ mux.HandleFunc("GET /status/", index.newStatusHandler(true))
+}
+
+// Register arranges for mux to service API requests.
+func Register(ctx context.Context, mux *http.ServeMux, report *rosa.Report) error {
+ var index packageIndex
+ index.search = make(searchCache)
+ if err := index.populate(report); err != nil {
+ return err
+ }
+ ticker := time.NewTicker(1 * time.Minute)
+ go func() {
+ for {
+ select {
+ case <-ctx.Done():
+ ticker.Stop()
+ return
+ case <-ticker.C:
+ index.search.clean()
+ }
+ }
+ }()
+ index.registerAPI(mux)
+ return nil
+}
+
+// writeAPIPayload sets headers common to API responses and encodes payload as
+// JSON for the response body.
+func writeAPIPayload(w http.ResponseWriter, payload any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+ w.Header().Set("Pragma", "no-cache")
+ w.Header().Set("Expires", "0")
+
+ if err := json.NewEncoder(w).Encode(payload); err != nil {
+ log.Println(err)
+ http.Error(
+ w, "cannot encode payload, contact maintainers",
+ http.StatusInternalServerError,
+ )
+ }
+}
diff --git a/cmd/mbf/internal/pkgserver/api_test.go b/cmd/mbf/internal/pkgserver/api_test.go
new file mode 100644
index 00000000..8449c234
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/api_test.go
@@ -0,0 +1,181 @@
+package pkgserver
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "strconv"
+ "testing"
+
+ "hakurei.app/internal/info"
+ "hakurei.app/internal/rosa"
+)
+
+// prefix is prepended to every API path.
+const prefix = "/api/" + apiVersion + "/"
+
+func TestAPIInfo(t *testing.T) {
+ t.Parallel()
+
+ w := httptest.NewRecorder()
+ handleInfo(w, httptest.NewRequestWithContext(
+ t.Context(),
+ http.MethodGet,
+ prefix+"info",
+ nil,
+ ))
+
+ resp := w.Result()
+ checkStatus(t, resp, http.StatusOK)
+ checkAPIHeader(t, w.Header())
+
+ checkPayload(t, resp, struct {
+ Count int `json:"count"`
+ HakureiVersion string `json:"hakurei_version"`
+ }{int(rosa.PresetUnexportedStart), info.Version()})
+}
+
+func TestAPIGet(t *testing.T) {
+ t.Parallel()
+ const target = prefix + "get"
+
+ index := newIndex(t)
+ newRequest := func(suffix string) *httptest.ResponseRecorder {
+ w := httptest.NewRecorder()
+ index.handleGet(w, httptest.NewRequestWithContext(
+ t.Context(),
+ http.MethodGet,
+ target+suffix,
+ nil,
+ ))
+ return w
+ }
+
+ checkValidate := func(t *testing.T, suffix string, vmin, vmax int, wantErr string) {
+ t.Run("invalid", func(t *testing.T) {
+ t.Parallel()
+
+ w := newRequest("?" + suffix + "=invalid")
+ resp := w.Result()
+ checkError(t, resp, wantErr, http.StatusBadRequest)
+ })
+
+ t.Run("min", func(t *testing.T) {
+ t.Parallel()
+
+ w := newRequest("?" + suffix + "=" + strconv.Itoa(vmin-1))
+ resp := w.Result()
+ checkError(t, resp, wantErr, http.StatusBadRequest)
+
+ w = newRequest("?" + suffix + "=" + strconv.Itoa(vmin))
+ resp = w.Result()
+ checkStatus(t, resp, http.StatusOK)
+ })
+
+ t.Run("max", func(t *testing.T) {
+ t.Parallel()
+
+ w := newRequest("?" + suffix + "=" + strconv.Itoa(vmax+1))
+ resp := w.Result()
+ checkError(t, resp, wantErr, http.StatusBadRequest)
+
+ w = newRequest("?" + suffix + "=" + strconv.Itoa(vmax))
+ resp = w.Result()
+ checkStatus(t, resp, http.StatusOK)
+ })
+ }
+
+ t.Run("limit", func(t *testing.T) {
+ t.Parallel()
+ checkValidate(
+ t, "index=0&sort=0&limit", 1, 100,
+ "limit must be an integer between 1 and 100",
+ )
+ })
+
+ t.Run("index", func(t *testing.T) {
+ t.Parallel()
+ checkValidate(
+ t, "limit=1&sort=0&index", 0, int(rosa.PresetUnexportedStart-1),
+ "index must be an integer between 0 and "+strconv.Itoa(int(rosa.PresetUnexportedStart-1)),
+ )
+ })
+
+ t.Run("sort", func(t *testing.T) {
+ t.Parallel()
+ checkValidate(
+ t, "index=0&limit=1&sort", 0, int(sortOrderEnd),
+ "sort must be an integer between 0 and "+strconv.Itoa(int(sortOrderEnd)),
+ )
+ })
+
+ checkWithSuffix := func(name, suffix string, want []*metadata) {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+
+ w := newRequest(suffix)
+ resp := w.Result()
+ checkStatus(t, resp, http.StatusOK)
+ checkAPIHeader(t, w.Header())
+ checkPayloadFunc(t, resp, func(got *struct {
+ Values []*metadata `json:"values"`
+ }) bool {
+ return slices.EqualFunc(got.Values, want, func(a, b *metadata) bool {
+ return (a.Version == b.Version ||
+ a.Version == rosa.Unversioned ||
+ b.Version == rosa.Unversioned) &&
+ a.HasReport == b.HasReport &&
+ a.Name == b.Name &&
+ a.Description == b.Description &&
+ a.Website == b.Website
+ })
+ })
+
+ })
+ }
+
+ checkWithSuffix("declarationAscending", "?limit=2&index=1&sort=0", []*metadata{
+ {
+ Metadata: rosa.GetMetadata(1),
+ Version: rosa.Std.Version(1),
+ },
+ {
+ Metadata: rosa.GetMetadata(2),
+ Version: rosa.Std.Version(2),
+ },
+ })
+ checkWithSuffix("declarationAscending offset", "?limit=3&index=5&sort=0", []*metadata{
+ {
+ Metadata: rosa.GetMetadata(5),
+ Version: rosa.Std.Version(5),
+ },
+ {
+ Metadata: rosa.GetMetadata(6),
+ Version: rosa.Std.Version(6),
+ },
+ {
+ Metadata: rosa.GetMetadata(7),
+ Version: rosa.Std.Version(7),
+ },
+ })
+ checkWithSuffix("declarationDescending", "?limit=3&index=0&sort=1", []*metadata{
+ {
+ Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 1),
+ Version: rosa.Std.Version(rosa.PresetUnexportedStart - 1),
+ },
+ {
+ Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 2),
+ Version: rosa.Std.Version(rosa.PresetUnexportedStart - 2),
+ },
+ {
+ Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 3),
+ Version: rosa.Std.Version(rosa.PresetUnexportedStart - 3),
+ },
+ })
+ checkWithSuffix("declarationDescending offset", "?limit=1&index=37&sort=1", []*metadata{
+ {
+ Metadata: rosa.GetMetadata(rosa.PresetUnexportedStart - 38),
+ Version: rosa.Std.Version(rosa.PresetUnexportedStart - 38),
+ },
+ })
+}
diff --git a/cmd/mbf/internal/pkgserver/index.go b/cmd/mbf/internal/pkgserver/index.go
new file mode 100644
index 00000000..911183da
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/index.go
@@ -0,0 +1,106 @@
+package pkgserver
+
+import (
+ "cmp"
+ "errors"
+ "slices"
+ "strings"
+
+ "hakurei.app/internal/pkg"
+ "hakurei.app/internal/rosa"
+)
+
+const (
+ declarationAscending = iota
+ declarationDescending
+ nameAscending
+ nameDescending
+ sizeAscending
+ sizeDescending
+
+ sortOrderEnd = iota - 1
+)
+
+// packageIndex refers to metadata by name and various sort orders.
+type packageIndex struct {
+ sorts [sortOrderEnd + 1][rosa.PresetUnexportedStart]*metadata
+ names map[string]*metadata
+ search searchCache
+ // Taken from [rosa.Report] if available.
+ handleAccess func(*error) func()
+}
+
+// metadata holds [rosa.Metadata] extended with additional information.
+type metadata struct {
+ p rosa.PArtifact
+ *rosa.Metadata
+
+ // Populated via [rosa.Toolchain.Version], [rosa.Unversioned] is equivalent
+ // to the zero value. Otherwise, the zero value is invalid.
+ Version string `json:"version,omitempty"`
+ // Output data size, available if present in report.
+ Size int64 `json:"size,omitempty"`
+ // Whether the underlying [pkg.Artifact] is present in the report.
+ HasReport bool `json:"report"`
+
+ // Ident string encoded ahead of time.
+ ids string
+ // Backed by [rosa.Report], access must be prepared by HandleAccess.
+ status []byte
+}
+
+// populate deterministically populates packageIndex, optionally with a report.
+func (index *packageIndex) populate(report *rosa.Report) (err error) {
+ if report != nil {
+ defer report.HandleAccess(&err)()
+ index.handleAccess = report.HandleAccess
+ }
+
+ var work [rosa.PresetUnexportedStart]*metadata
+ index.names = make(map[string]*metadata)
+ ir := pkg.NewIR()
+ for p := range rosa.PresetUnexportedStart {
+ m := metadata{
+ p: p,
+
+ Metadata: rosa.GetMetadata(p),
+ Version: rosa.Std.Version(p),
+ }
+ if m.Version == "" {
+ return errors.New("invalid version from " + m.Name)
+ }
+ if m.Version == rosa.Unversioned {
+ m.Version = ""
+ }
+
+ if report != nil {
+ id := ir.Ident(rosa.Std.Load(p))
+ m.ids = pkg.Encode(id.Value())
+ m.status, m.Size = report.ArtifactOf(id)
+ m.HasReport = m.Size >= 0
+ }
+
+ work[p] = &m
+ index.names[m.Name] = &m
+ }
+
+ index.sorts[declarationAscending] = work
+ index.sorts[declarationDescending] = work
+ slices.Reverse(index.sorts[declarationDescending][:])
+
+ index.sorts[nameAscending] = work
+ slices.SortFunc(index.sorts[nameAscending][:], func(a, b *metadata) int {
+ return strings.Compare(a.Name, b.Name)
+ })
+ index.sorts[nameDescending] = index.sorts[nameAscending]
+ slices.Reverse(index.sorts[nameDescending][:])
+
+ index.sorts[sizeAscending] = work
+ slices.SortFunc(index.sorts[sizeAscending][:], func(a, b *metadata) int {
+ return cmp.Compare(a.Size, b.Size)
+ })
+ index.sorts[sizeDescending] = index.sorts[sizeAscending]
+ slices.Reverse(index.sorts[sizeDescending][:])
+
+ return
+}
diff --git a/cmd/mbf/internal/pkgserver/index_test.go b/cmd/mbf/internal/pkgserver/index_test.go
new file mode 100644
index 00000000..8f3b5530
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/index_test.go
@@ -0,0 +1,96 @@
+package pkgserver
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "reflect"
+ "testing"
+)
+
+// newIndex returns the address of a newly populated packageIndex.
+func newIndex(t *testing.T) *packageIndex {
+ t.Helper()
+
+ var index packageIndex
+ if err := index.populate(nil); err != nil {
+ t.Fatalf("populate: error = %v", err)
+ }
+ return &index
+}
+
+// checkStatus checks response status code.
+func checkStatus(t *testing.T, resp *http.Response, want int) {
+ t.Helper()
+
+ if resp.StatusCode != want {
+ t.Errorf(
+ "StatusCode: %s, want %s",
+ http.StatusText(resp.StatusCode),
+ http.StatusText(want),
+ )
+ }
+}
+
+// checkHeader checks the value of a header entry.
+func checkHeader(t *testing.T, h http.Header, key, want string) {
+ t.Helper()
+
+ if got := h.Get(key); got != want {
+ t.Errorf("%s: %q, want %q", key, got, want)
+ }
+}
+
+// checkAPIHeader checks common entries set for API endpoints.
+func checkAPIHeader(t *testing.T, h http.Header) {
+ t.Helper()
+
+ checkHeader(t, h, "Content-Type", "application/json; charset=utf-8")
+ checkHeader(t, h, "Cache-Control", "no-cache, no-store, must-revalidate")
+ checkHeader(t, h, "Pragma", "no-cache")
+ checkHeader(t, h, "Expires", "0")
+}
+
+// checkPayloadFunc checks the JSON response of an API endpoint by passing it to f.
+func checkPayloadFunc[T any](
+ t *testing.T,
+ resp *http.Response,
+ f func(got *T) bool,
+) {
+ t.Helper()
+
+ var got T
+ r := io.Reader(resp.Body)
+ if testing.Verbose() {
+ var buf bytes.Buffer
+ r = io.TeeReader(r, &buf)
+ defer func() { t.Helper(); t.Log(buf.String()) }()
+ }
+ if err := json.NewDecoder(r).Decode(&got); err != nil {
+ t.Fatalf("Decode: error = %v", err)
+ }
+
+ if !f(&got) {
+ t.Errorf("Body: %#v", got)
+ }
+}
+
+// checkPayload checks the JSON response of an API endpoint.
+func checkPayload[T any](t *testing.T, resp *http.Response, want T) {
+ t.Helper()
+
+ checkPayloadFunc(t, resp, func(got *T) bool {
+ return reflect.DeepEqual(got, &want)
+ })
+}
+
+func checkError(t *testing.T, resp *http.Response, error string, code int) {
+ t.Helper()
+
+ checkStatus(t, resp, code)
+ if got, _ := io.ReadAll(resp.Body); string(got) != fmt.Sprintln(error) {
+ t.Errorf("Body: %q, want %q", string(got), error)
+ }
+}
diff --git a/cmd/mbf/internal/pkgserver/search.go b/cmd/mbf/internal/pkgserver/search.go
new file mode 100644
index 00000000..5756512f
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/search.go
@@ -0,0 +1,81 @@
+package pkgserver
+
+import (
+ "cmp"
+ "maps"
+ "regexp"
+ "slices"
+ "time"
+)
+
+type searchCache map[string]searchCacheEntry
+type searchResult struct {
+ NameIndices [][]int `json:"name_matches"`
+ DescIndices [][]int `json:"desc_matches,omitempty"`
+ Score float64 `json:"score"`
+ *metadata
+}
+type searchCacheEntry struct {
+ query string
+ results []searchResult
+ expiry time.Time
+}
+
+func (index *packageIndex) performSearchQuery(limit int, i int, search string, desc bool) (int, []searchResult, error) {
+ query := search
+ if desc {
+ query += ";withDesc"
+ }
+ entry, ok := index.search[query]
+ if ok && len(entry.results) > 0 {
+ return len(entry.results), entry.results[min(i, len(entry.results)-1):min(i+limit, len(entry.results))], nil
+ }
+
+ regex, err := regexp.Compile(search)
+ if err != nil {
+ return 0, make([]searchResult, 0), err
+ }
+ res := make([]searchResult, 0)
+ for p := range maps.Values(index.names) {
+ nameIndices := regex.FindAllIndex([]byte(p.Name), -1)
+ var descIndices [][]int = nil
+ if desc {
+ descIndices = regex.FindAllIndex([]byte(p.Description), -1)
+ }
+ if nameIndices == nil && descIndices == nil {
+ continue
+ }
+ score := float64(indexsum(nameIndices)) / (float64(len(nameIndices)) + 1)
+ if desc {
+ score += float64(indexsum(descIndices)) / (float64(len(descIndices)) + 1) / 10.0
+ }
+ res = append(res, searchResult{
+ NameIndices: nameIndices,
+ DescIndices: descIndices,
+ Score: score,
+ metadata: p,
+ })
+ }
+ slices.SortFunc(res[:], func(a, b searchResult) int { return -cmp.Compare(a.Score, b.Score) })
+ expiry := time.Now().Add(1 * time.Minute)
+ entry = searchCacheEntry{
+ query: search,
+ results: res,
+ expiry: expiry,
+ }
+ index.search[query] = entry
+
+ return len(res), res[i:min(i+limit, len(entry.results))], nil
+}
+func (s *searchCache) clean() {
+ maps.DeleteFunc(*s, func(_ string, v searchCacheEntry) bool {
+ return v.expiry.Before(time.Now())
+ })
+}
+func indexsum(in [][]int) int {
+ sum := 0
+ for i := 0; i < len(in); i++ {
+ sum += in[i][1] - in[i][0]
+ }
+ return sum
+}
diff --git a/cmd/mbf/internal/pkgserver/ui/index.html b/cmd/mbf/internal/pkgserver/ui/index.html
new file mode 100644
index 00000000..d2d71cbd
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/index.html
@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <link rel="stylesheet" href="style.css">
+ <title>Hakurei PkgServer</title>
+ <script src="index.js"></script>
+</head>
+<body>
+<h1>Hakurei PkgServer</h1>
+<div class="top-controls" id="top-controls-regular">
+ <p>Showing entries <span id="entry-counter"></span>.</p>
+ <span id="search-bar">
+ <label for="search">Search: </label>
+ <input type="text" name="search" id="search"/>
+ <button onclick="doSearch()">Find</button>
+ <label for="include-desc">Include descriptions: </label>
+ <input type="checkbox" name="include-desc" id="include-desc" checked/>
+ </span>
+ <div><label for="count">Entries per page: </label><select name="count" id="count">
+ <option value="10">10</option>
+ <option value="20">20</option>
+ <option value="30">30</option>
+ <option value="50">50</option>
+ </select></div>
+ <div><label for="sort">Sort by: </label><select name="sort" id="sort">
+ <option value="0">Definition (ascending)</option>
+ <option value="1">Definition (descending)</option>
+ <option value="2">Name (ascending)</option>
+ <option value="3">Name (descending)</option>
+ <option value="4">Size (ascending)</option>
+ <option value="5">Size (descending)</option>
+ </select></div>
+</div>
+<div class="top-controls" id="search-top-controls" hidden>
+ <p>Showing search results <span id="search-entry-counter"></span> for query "<span id="search-query"></span>".</p>
+ <button onclick="exitSearch()">Back</button>
+ <div><label for="search-count">Entries per page: </label><select name="search-count" id="search-count">
+ <option value="10">10</option>
+ <option value="20">20</option>
+ <option value="30">30</option>
+ <option value="50">50</option>
+ </select></div>
+ <p>Sorted by best match</p>
+</div>
+<div class="page-controls"><a href="javascript:prevPage()">&laquo; Previous</a> <input type="text" class="page-number" value="1"/> <a href="javascript:nextPage()">Next &raquo;</a></div>
+<table id="pkg-list">
+ <tr><td>Loading...</td></tr>
+</table>
+<div class="page-controls"><a href="javascript:prevPage()">&laquo; Previous</a> <input type="text" class="page-number" value="1"/> <a href="javascript:nextPage()">Next &raquo;</a></div>
+<footer>
+ <p>&copy;<a href="https://hakurei.app/">Hakurei</a> (<span id="hakurei-version">unknown</span>). Licensed under the MIT license.</p>
+</footer>
+<script>main();</script>
+</body>
+</html> \ No newline at end of file
diff --git a/cmd/mbf/internal/pkgserver/ui/index.ts b/cmd/mbf/internal/pkgserver/ui/index.ts
new file mode 100644
index 00000000..0784b81f
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/index.ts
@@ -0,0 +1,331 @@
+interface PackageIndexEntry {
+ name: string
+ size?: number
+ description?: string
+ website?: string
+ version?: string
+ report?: boolean
+}
+
+function entryToHTML(entry: PackageIndexEntry | SearchResult): HTMLTableRowElement {
+ let v = entry.version != null ? `<span>${escapeHtml(entry.version)}</span>` : ""
+ let s = entry.size != null && entry.size > 0 ? `<p>Size: ${toByteSizeString(entry.size)} (${entry.size})</p>` : ""
+ let n: string
+ let d: string
+ if ('name_matches' in entry) {
+ n = `<h2>${nameMatches(entry as SearchResult)} ${v}</h2>`
+ } else {
+ n = `<h2>${escapeHtml(entry.name)} ${v}</h2>`
+ }
+ if ('desc_matches' in entry && STATE.getIncludeDescriptions()) {
+ d = descMatches(entry as SearchResult)
+ } else {
+ d = (entry as PackageIndexEntry).description != null ? `<p>${escapeHtml((entry as PackageIndexEntry).description)}</p>` : ""
+ }
+ let w = entry.website != null ? `<a href="${encodeURI(entry.website)}">Website</a>` : ""
+ let r = entry.report ? `Log (<a href=\"${encodeURI('/api/v1/status/' + entry.name)}\">View</a> | <a href=\"${encodeURI('/status/' + entry.name)}\">Download</a>)` : ""
+ let row = <HTMLTableRowElement>(document.createElement('tr'))
+ row.innerHTML = `<td>
+ ${n}
+ ${d}
+ ${s}
+ ${w}
+ ${r}
+ </td>`
+ return row
+}
+
+function nameMatches(sr: SearchResult): string {
+ return markMatches(sr.name, sr.name_matches)
+}
+
+function descMatches(sr: SearchResult): string {
+ return markMatches(sr.description!, sr.desc_matches)
+}
+
+function markMatches(str: string, indices: [number, number][]): string {
+ if (indices == null) {
+ return str
+ }
+ let out: string = ""
+ let j = 0
+ for (let i = 0; i < str.length; i++) {
+ if (j < indices.length) {
+ if (i === indices[j][0]) {
+ out += `<mark>${escapeHtmlChar(str[i])}`
+ continue
+ }
+ if (i === indices[j][1]) {
+ out += `</mark>${escapeHtmlChar(str[i])}`
+ j++
+ continue
+ }
+ }
+ out += escapeHtmlChar(str[i])
+ }
+ if (indices[j] !== undefined) {
+ out += "</mark>"
+ }
+ return out
+}
+
+function toByteSizeString(bytes: number): string {
+ if (bytes == null) return `unspecified`
+ if (bytes < 1024) return `${bytes}B`
+ if (bytes < Math.pow(1024, 2)) return `${(bytes / 1024).toFixed(2)}kiB`
+ if (bytes < Math.pow(1024, 3)) return `${(bytes / Math.pow(1024, 2)).toFixed(2)}MiB`
+ if (bytes < Math.pow(1024, 4)) return `${(bytes / Math.pow(1024, 3)).toFixed(2)}GiB`
+ if (bytes < Math.pow(1024, 5)) return `${(bytes / Math.pow(1024, 4)).toFixed(2)}TiB`
+ return "not only is it big, it's large"
+}
+
+const API_VERSION = 1
+const ENDPOINT = `/api/v${API_VERSION}`
+
+interface InfoPayload {
+ count?: number
+ hakurei_version?: string
+}
+
+async function infoRequest(): Promise<InfoPayload> {
+ const res = await fetch(`${ENDPOINT}/info`)
+ const payload = await res.json()
+ return payload as InfoPayload
+}
+
+interface GetPayload {
+ values?: PackageIndexEntry[]
+}
+
+enum SortOrders {
+ DeclarationAscending,
+ DeclarationDescending,
+ NameAscending,
+ NameDescending
+}
+
+async function getRequest(limit: number, index: number, sort: SortOrders): Promise<GetPayload> {
+ const res = await fetch(`${ENDPOINT}/get?limit=${limit}&index=${index}&sort=${sort.valueOf()}`)
+ const payload = await res.json()
+ return payload as GetPayload
+}
+
+interface SearchResult extends PackageIndexEntry {
+ name_matches: [number, number][]
+ desc_matches: [number, number][]
+ score: number
+}
+
+interface SearchPayload {
+ count?: number
+ values?: SearchResult[]
+}
+
+async function searchRequest(limit: number, index: number, search: string, desc: boolean): Promise<SearchPayload> {
+ const res = await fetch(`${ENDPOINT}/search?limit=${limit}&index=${index}&search=${encodeURIComponent(search)}&desc=${desc}`)
+ if (!res.ok) {
+ exitSearch()
+ alert("invalid search query!")
+ return Promise.reject(res.statusText)
+ }
+ const payload = await res.json()
+ return payload as SearchPayload
+}
+
+class State {
+ entriesPerPage: number = 10
+ entryIndex: number = 0
+ maxTotal: number = 0
+ maxEntries: number = 0
+ sort: SortOrders = SortOrders.DeclarationAscending
+ search: boolean = false
+
+ getEntriesPerPage(): number {
+ return this.entriesPerPage
+ }
+
+ setEntriesPerPage(entriesPerPage: number) {
+ this.entriesPerPage = entriesPerPage
+ this.setEntryIndex(Math.floor(this.getEntryIndex() / entriesPerPage) * entriesPerPage)
+ }
+
+ getEntryIndex(): number {
+ return this.entryIndex
+ }
+
+ setEntryIndex(entryIndex: number) {
+ this.entryIndex = entryIndex
+ this.updatePage()
+ this.updateRange()
+ this.updateListings()
+ }
+
+ getMaxTotal(): number {
+ return this.maxTotal
+ }
+
+ setMaxTotal(max: number) {
+ this.maxTotal = max
+ }
+
+ getSortOrder(): SortOrders {
+ return this.sort
+ }
+
+ setSortOrder(sortOrder: SortOrders) {
+ this.sort = sortOrder
+ this.setEntryIndex(0)
+ }
+
+ updatePage() {
+ let page = Math.ceil(((this.getEntryIndex() + this.getEntriesPerPage()) - 1) / this.getEntriesPerPage())
+ for (let e of document.getElementsByClassName("page-number")) {
+ (e as HTMLInputElement).value = String(page)
+ }
+ }
+
+ updateRange() {
+ let max = Math.min(this.getEntryIndex() + this.getEntriesPerPage(), this.getMaxTotal())
+ document.getElementById("entry-counter")!.textContent = `${this.getEntryIndex() + 1}-${max} of ${this.getMaxTotal()}`
+ if (this.search) {
+ document.getElementById("search-entry-counter")!.textContent = `${this.getEntryIndex() + 1}-${max} of ${this.maxTotal}/${this.maxEntries}`
+ document.getElementById("search-query")!.innerHTML = `<code>${escapeHtml(this.getSearchQuery())}</code>`
+ }
+ }
+
+ getSearchQuery(): string {
+ let queryString = document.getElementById("search")!;
+ return (queryString as HTMLInputElement).value
+ }
+
+ getIncludeDescriptions(): boolean {
+ let includeDesc = document.getElementById("include-desc")!;
+ return (includeDesc as HTMLInputElement).checked
+ }
+
+ updateListings() {
+ if (this.search) {
+ searchRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSearchQuery(), this.getIncludeDescriptions())
+ .then(res => {
+ let table = document.getElementById("pkg-list")!
+ table.innerHTML = ''
+ for (let row of res.values!) {
+ table.appendChild(entryToHTML(row))
+ }
+ STATE.maxTotal = res.count!
+ STATE.updateRange()
+ if(res.count! < 1) {
+ exitSearch()
+ alert("no results found!")
+ }
+ })
+ } else {
+ getRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSortOrder())
+ .then(res => {
+ let table = document.getElementById("pkg-list")!
+ table.innerHTML = ''
+ for (let row of res.values!) {
+ table.appendChild(entryToHTML(row))
+ }
+ })
+ }
+ }
+}
+
+let STATE: State
+
+
+function lastPageIndex(): number {
+ return Math.floor(STATE.getMaxTotal() / STATE.getEntriesPerPage()) * STATE.getEntriesPerPage()
+}
+
+function setPage(page: number) {
+ STATE.setEntryIndex(Math.max(0, Math.min(STATE.getEntriesPerPage() * (page - 1), lastPageIndex())))
+}
+
+
+function escapeHtml(str?: string): string {
+ let out: string = ''
+ if (str == undefined) return ""
+ for (let i = 0; i < str.length; i++) {
+ out += escapeHtmlChar(str[i])
+ }
+ return out
+}
+
+function escapeHtmlChar(char: string): string {
+ if (char.length != 1) return char
+ switch (char[0]) {
+ case '&':
+ return "&amp;"
+ case '<':
+ return "&lt;"
+ case '>':
+ return "&gt;"
+ case '"':
+ return "&quot;"
+ case "'":
+ return "&apos;"
+ default:
+ return char
+ }
+}
+
+function firstPage() {
+ STATE.setEntryIndex(0)
+}
+
+function prevPage() {
+ let index = STATE.getEntryIndex()
+ STATE.setEntryIndex(Math.max(0, index - STATE.getEntriesPerPage()))
+}
+
+function lastPage() {
+ STATE.setEntryIndex(lastPageIndex())
+}
+
+function nextPage() {
+ let index = STATE.getEntryIndex()
+ STATE.setEntryIndex(Math.min(lastPageIndex(), index + STATE.getEntriesPerPage()))
+}
+
+function doSearch() {
+ document.getElementById("top-controls-regular")!.toggleAttribute("hidden");
+ document.getElementById("search-top-controls")!.toggleAttribute("hidden");
+ STATE.search = true;
+ STATE.setEntryIndex(0);
+}
+
+function exitSearch() {
+ document.getElementById("top-controls-regular")!.toggleAttribute("hidden");
+ document.getElementById("search-top-controls")!.toggleAttribute("hidden");
+ STATE.search = false;
+ STATE.setMaxTotal(STATE.maxEntries)
+ STATE.setEntryIndex(0)
+}
+
+function main() {
+ STATE = new State()
+ infoRequest()
+ .then(res => {
+ STATE.maxEntries = res.count!
+ STATE.setMaxTotal(STATE.maxEntries)
+ document.getElementById("hakurei-version")!.textContent = res.hakurei_version!
+ STATE.updateRange()
+ STATE.updateListings()
+ })
+ for (let e of document.getElementsByClassName("page-number")) {
+ e.addEventListener("change", (_) => {
+ setPage(parseInt((e as HTMLInputElement).value))
+ })
+ }
+ document.getElementById("count")?.addEventListener("change", (event) => {
+ STATE.setEntriesPerPage(parseInt((event.target as HTMLSelectElement).value))
+ })
+ document.getElementById("sort")?.addEventListener("change", (event) => {
+ STATE.setSortOrder(parseInt((event.target as HTMLSelectElement).value))
+ })
+ document.getElementById("search")?.addEventListener("keyup", (event) => {
+ if (event.key === 'Enter') doSearch()
+ })
+} \ No newline at end of file
diff --git a/cmd/mbf/internal/pkgserver/ui/style.css b/cmd/mbf/internal/pkgserver/ui/style.css
new file mode 100644
index 00000000..b4f281ac
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/style.css
@@ -0,0 +1,21 @@
+.page-number {
+ width: 2em;
+ text-align: center;
+}
+.page-number {
+ width: 2em;
+ text-align: center;
+}
+
+@media (prefers-color-scheme: dark) {
+ html {
+ background-color: #2c2c2c;
+ color: ghostwhite;
+ }
+}
+@media (prefers-color-scheme: light) {
+ html {
+ background-color: #d3d3d3;
+ color: black;
+ }
+} \ No newline at end of file
diff --git a/cmd/mbf/internal/pkgserver/ui/tsconfig.json b/cmd/mbf/internal/pkgserver/ui/tsconfig.json
new file mode 100644
index 00000000..24df4936
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "target": "ES2024",
+ "strict": true,
+ "alwaysStrict": true,
+ "outDir": "static"
+ }
+} \ No newline at end of file
diff --git a/cmd/mbf/internal/pkgserver/ui/ui.go b/cmd/mbf/internal/pkgserver/ui/ui.go
new file mode 100644
index 00000000..3fc0d89c
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/ui.go
@@ -0,0 +1,9 @@
+// Package ui holds the static web UI.
+package ui
+
+import "net/http"
+
+// Register arranges for mux to serve the embedded frontend.
+func Register(mux *http.ServeMux) {
+ mux.Handle("GET /", http.FileServer(http.FS(static)))
+}
diff --git a/cmd/mbf/internal/pkgserver/ui/ui_full.go b/cmd/mbf/internal/pkgserver/ui/ui_full.go
new file mode 100644
index 00000000..564787dc
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/ui_full.go
@@ -0,0 +1,21 @@
+//go:build frontend
+
+package ui
+
+import (
+ "embed"
+ "io/fs"
+)
+
+//go:generate tsc
+//go:generate cp index.html style.css static
+//go:embed static
+var _static embed.FS
+
+var static = func() fs.FS {
+ if f, err := fs.Sub(_static, "static"); err != nil {
+ panic(err)
+ } else {
+ return f
+ }
+}()
diff --git a/cmd/mbf/internal/pkgserver/ui/ui_stub.go b/cmd/mbf/internal/pkgserver/ui/ui_stub.go
new file mode 100644
index 00000000..daf010f8
--- /dev/null
+++ b/cmd/mbf/internal/pkgserver/ui/ui_stub.go
@@ -0,0 +1,7 @@
+//go:build !frontend
+
+package ui
+
+import "testing/fstest"
+
+var static fstest.MapFS
diff --git a/cmd/mbf/main.go b/cmd/mbf/main.go
index 0d44bdbc..291e91ef 100644
--- a/cmd/mbf/main.go
+++ b/cmd/mbf/main.go
@@ -20,6 +20,7 @@ import (
"io"
"log"
"net"
+ "net/http"
"os"
"os/signal"
"path/filepath"
@@ -41,6 +42,9 @@ import (
"hakurei.app/internal/pkg"
"hakurei.app/internal/rosa"
"hakurei.app/message"
+
+ "hakurei.app/cmd/mbf/internal/pkgserver"
+ "hakurei.app/cmd/mbf/internal/pkgserver/ui"
)
func main() {
@@ -180,6 +184,7 @@ func main() {
{
var (
+ flagBind string
flagStatus bool
flagReport string
)
@@ -187,9 +192,53 @@ func main() {
"info",
"Display out-of-band metadata of an artifact",
func(args []string) (err error) {
- return commandInfo(&cm, args, os.Stdout, flagStatus, flagReport)
+ const shutdownTimeout = 15 * time.Second
+
+ var r *rosa.Report
+ if flagReport != "" {
+ if r, err = rosa.OpenReport(flagReport); err != nil {
+ return err
+ }
+ defer func() {
+ if closeErr := r.Close(); err == nil {
+ err = closeErr
+ }
+ }()
+ defer r.HandleAccess(&err)()
+ }
+
+ if flagBind == "" {
+ return commandInfo(&cm, args, os.Stdout, flagStatus, r)
+ }
+
+ var mux http.ServeMux
+ ui.Register(&mux)
+ if err = pkgserver.Register(ctx, &mux, r); err != nil {
+ return
+ }
+
+ server := http.Server{Addr: flagBind, Handler: &mux}
+ go func() {
+ <-ctx.Done()
+ cc, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ defer cancel()
+ if _err := server.Shutdown(cc); _err != nil {
+ log.Fatal(_err)
+ }
+ }()
+
+ msg.Verbosef("listening on %q", flagBind)
+ err = server.ListenAndServe()
+ if errors.Is(err, http.ErrServerClosed) {
+ err = nil
+ }
+ return
},
).Flag(
+ &flagBind,
+ "bind", command.StringFlag(""),
+ "TCP address for the server to listen on",
+ ).Flag(
&flagStatus,
"status", command.BoolFlag(false),
"Display cure status if available",