1
0
forked from rosa/hakurei

27 Commits

Author SHA1 Message Date
c43a0c41b6 cmd/pkgserver: refer to preset in index
This enables referencing back to internal/rosa through an entry obtained via the index.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-13 20:51:08 -05:00
55827f1a85 cmd/pkgserver: handle unversioned value
This omits the field for an unversioned artifact, and only does so once on startup.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-13 20:51:08 -05:00
721bdddfa1 cmd/pkgserver: determine disposition route in mux
This removes duplicate checks and uses the more sound check in mux.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-13 20:51:08 -05:00
fb18e599dd cmd/pkgserver: format get error messages
This improves source code readability on smaller displays.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-13 20:51:08 -05:00
ec9005c794 cmd/pkgserver: constant string in pattern
This resolves patterns at compile time.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-13 20:51:08 -05:00
c6d35b4003 cmd/pkgserver: satisfy handler signature in method
This is somewhat cleaner.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-13 20:51:08 -05:00
6401533cc2 cmd/pkgserver: log instead of write encoding error
This message is unlikely to be useful to the user, and output may be partially written at this point, causing the error to be even less intelligible.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-13 20:51:08 -05:00
5d6c401beb cmd/pkgserver: appropriately mark test helpers
This improves usefulness of test log messages.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-13 20:51:08 -05:00
0a2d6aec14 cmd/pkgserver: do not omit report field
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-13 20:51:08 -05:00
67b11335d6 cmd/pkgserver: gracefully shut down on signal
Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-13 20:51:08 -05:00
ef3bd1b60a cmd/pkgserver: specify full addr string in flag
This allows greater flexibility.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-13 20:51:08 -05:00
beae7c89db cmd/pkgserver: make report argument optional
This allows serving metadata only without a populated report. This also removes the out-of-bounds read on args when no arguments are passed.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-13 20:51:08 -05:00
ed26d1a1c2 cmd/pkgserver: embed internal/rosa metadata
This change also cleans up and reduces some unnecessary copies.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-13 20:51:08 -05:00
faa0006d47 cmd/pkgserver: do not assume default mux
This helps with testing.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-13 20:51:08 -05:00
796ddbc977 cmd/pkgserver: create index without report
This is useful for testing, where report testdata is not available.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2026-03-13 20:51:08 -05:00
mae
98ab020160 cmd/pkgserver: add sort orders, change pagination rules 2026-03-13 20:51:08 -05:00
mae
26a346036d cmd/pkgserver: add /status endpoint 2026-03-13 20:51:08 -05:00
mae
4ac9c72132 cmd/pkgserver: minimum viable frontend 2026-03-13 20:51:08 -05:00
mae
c39c07d440 cmd/pkgserver: api versioning 2026-03-13 20:51:08 -05:00
mae
b3fa0fe271 cmd/pkgserver: add get endpoint 2026-03-13 20:51:08 -05:00
mae
92a90582bb cmd/pkgserver: add count endpoint and restructure 2026-03-13 20:51:08 -05:00
mae
2e5ac56bdf cmd/pkgserver: add status endpoint 2026-03-13 20:51:08 -05:00
mae
75133e0234 cmd/pkgserver: add createPackageIndex 2026-03-13 20:51:08 -05:00
mae
c120d4de4f cmd/pkgserver: add command handler 2026-03-13 20:51:08 -05:00
mae
d6af8edb4a cmd/pkgserver: replace favicon 2026-03-13 20:51:08 -05:00
mae
da25d609d5 cmd/pkgserver: pagination 2026-03-13 20:51:08 -05:00
mae
95ceed0de0 cmd/pkgserver: basic web ui 2026-03-13 20:51:08 -05:00
19 changed files with 1019 additions and 0 deletions

1
.gitignore vendored
View File

@@ -27,6 +27,7 @@ go.work.sum
# go generate
/cmd/hakurei/LICENSE
/cmd/pkgserver/.sass-cache
/internal/pkg/testdata/testtool
/internal/rosa/hakurei_current.tar.gz

144
cmd/pkgserver/api.go Normal file
View File

@@ -0,0 +1,144 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"path"
"strconv"
"sync"
"hakurei.app/internal/info"
"hakurei.app/internal/pkg"
"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)
}
func (index *packageIndex) newStatusHandler(disposition bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := path.Base(r.URL.Path)
p, ok := rosa.ResolveName(name)
if !ok {
http.NotFound(w, r)
return
}
m := rosa.GetMetadata(p)
pk, ok := index.names[m.Name]
if !ok {
http.NotFound(w, r)
return
}
if len(pk.status) > 0 {
if disposition {
w.Header().Set("Content-Type", "application/octet-stream")
} else {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
}
if disposition {
var version string
if pk.Version != "\u0000" {
version = pk.Version
} else {
version = "unknown"
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s-%s-%s.log\"", pk.Name, version, pkg.Encode(pk.ident.Value())))
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
_, err := io.Copy(w, bytes.NewReader(pk.status))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
} else {
http.NotFound(w, r)
}
}
}
// 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]))]
// TODO(mae): remove count field
writeAPIPayload(w, &struct {
Count int `json:"count"`
Values []*metadata `json:"values"`
}{len(values), values})
}
// 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+"/status/", index.newStatusHandler(false))
mux.HandleFunc("GET /status/", index.newStatusHandler(true))
}
// 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,
)
}
}

183
cmd/pkgserver/api_test.go Normal file
View File

@@ -0,0 +1,183 @@
package main
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 {
Count int `json:"count"`
Values []*metadata `json:"values"`
}) bool {
return got.Count == len(want) &&
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=0&sort=0", []*metadata{
{
Metadata: rosa.GetMetadata(0),
Version: rosa.Std.Version(0),
},
{
Metadata: rosa.GetMetadata(1),
Version: rosa.Std.Version(1),
},
})
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),
},
})
}

92
cmd/pkgserver/index.go Normal file
View File

@@ -0,0 +1,92 @@
package main
import (
"errors"
"slices"
"strings"
"unique"
"hakurei.app/internal/pkg"
"hakurei.app/internal/rosa"
)
const (
declarationAscending = iota
declarationDescending
nameAscending
nameDescending
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
}
// 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"`
// Whether the underlying [pkg.Artifact] is present in the report.
HasReport bool `json:"report"`
// Ident resolved from underlying [pkg.Artifact].
ident unique.Handle[pkg.ID]
// Backed by [rosa.Report], access must be prepared by HandleAccess.
status []byte
}
// populate deterministically populates packageIndex, optionally with a report.
func (index *packageIndex) populate(cache *pkg.Cache, report *rosa.Report) (err error) {
if report != nil {
defer report.HandleAccess(&err)()
}
var work [rosa.PresetUnexportedStart]*metadata
index.names = make(map[string]*metadata)
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 cache != nil && report != nil {
m.ident = cache.Ident(rosa.Std.Load(p))
status, n := report.ArtifactOf(m.ident)
if n >= 0 {
m.HasReport = true
m.status = status
}
}
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][:])
return
}

102
cmd/pkgserver/main.go Normal file
View File

@@ -0,0 +1,102 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"hakurei.app/command"
"hakurei.app/container/check"
"hakurei.app/internal/pkg"
"hakurei.app/internal/rosa"
"hakurei.app/message"
)
const shutdownTimeout = 15 * time.Second
func main() {
log.SetFlags(0)
log.SetPrefix("pkgserver: ")
var (
flagBaseDir string
flagAddr string
)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
defer stop()
msg := message.New(log.Default())
c := command.New(os.Stderr, log.Printf, "pkgserver", func(args []string) error {
var (
cache *pkg.Cache
report *rosa.Report
)
switch len(args) {
case 0:
break
case 1:
baseDir, err := check.NewAbs(flagBaseDir)
if err != nil {
return err
}
cache, err = pkg.Open(ctx, msg, 0, baseDir)
if err != nil {
return err
}
defer cache.Close()
report, err = rosa.OpenReport(args[0])
if err != nil {
return err
}
default:
return errors.New("pkgserver requires 1 argument")
}
var index packageIndex
if err := index.populate(cache, report); err != nil {
return err
}
var mux http.ServeMux
uiRoutes(&mux)
index.registerAPI(&mux)
server := http.Server{
Addr: flagAddr,
Handler: &mux,
}
go func() {
<-ctx.Done()
c, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := server.Shutdown(c); err != nil {
log.Fatal(err)
}
}()
return server.ListenAndServe()
}).Flag(
&flagBaseDir,
"b", command.StringFlag(""),
"base directory for cache",
).Flag(
&flagAddr,
"addr", command.StringFlag(":8067"),
"TCP network address to listen on",
)
c.MustParse(os.Args[1:], func(err error) {
if errors.Is(err, http.ErrServerClosed) {
os.Exit(0)
}
log.Fatal(err)
})
}

View File

@@ -0,0 +1,96 @@
package main
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, 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)
}
}

48
cmd/pkgserver/ui.go Normal file
View File

@@ -0,0 +1,48 @@
package main
import (
"embed"
"net/http"
)
//go:generate sh -c "sass ui/static/dark.scss ui/static/dark.css && sass ui/static/light.scss ui/static/light.css && tsc -p ui/static"
//go:embed ui/*
var content embed.FS
func serveWebUI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-XSS-Protection", "1")
w.Header().Set("X-Frame-Options", "DENY")
http.ServeFileFS(w, r, content, "ui/index.html")
}
func serveStaticContent(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/static/style.css":
darkTheme := r.CookiesNamed("dark_theme")
if len(darkTheme) > 0 && darkTheme[0].Value == "true" {
http.ServeFileFS(w, r, content, "ui/static/dark.css")
} else {
http.ServeFileFS(w, r, content, "ui/static/light.css")
}
break
case "/favicon.ico":
http.ServeFileFS(w, r, content, "ui/static/favicon.ico")
break
case "/static/index.js":
http.ServeFileFS(w, r, content, "ui/static/index.js")
break
default:
http.NotFound(w, r)
}
}
func uiRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /{$}", serveWebUI)
mux.HandleFunc("GET /favicon.ico", serveStaticContent)
mux.HandleFunc("GET /static/", serveStaticContent)
}

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="static/style.css">
<title>Hakurei PkgServer</title>
<script src="static/index.js"></script>
</head>
<body>
<h1>Hakurei PkgServer</h1>
<table id="pkg-list">
<tr><td>Loading...</td></tr>
</table>
<p>Showing entries <span id="entry-counter"></span>.</p>
<span class="bottom-nav"><a href="javascript:prevPage()">&laquo; Previous</a> <span id="page-number">1</span> <a href="javascript:nextPage()">Next &raquo;</a></span>
<span><label for="count">Entries per page: </label><select name="count" id="count">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select></span>
<span><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>
</select></span>
</body>
<footer>
<p>&copy;<a href="https://hakurei.app/">Hakurei</a> (<span id="hakurei-version">unknown</span>). Licensed under the MIT license.</p>
</footer>
</html>

View File

View File

@@ -0,0 +1,6 @@
@use 'common';
html {
background-color: #2c2c2c;
color: ghostwhite; }
/*# sourceMappingURL=dark.css.map */

View File

@@ -0,0 +1,7 @@
{
"version": 3,
"mappings": "AAAA,aAAa;AAEb,IAAK;EACH,gBAAgB,EAAE,OAAO;EACzB,KAAK,EAAE,UAAU",
"sources": ["dark.scss"],
"names": [],
"file": "dark.css"
}

View File

@@ -0,0 +1,6 @@
@use 'common';
html {
background-color: #2c2c2c;
color: ghostwhite;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,134 @@
class PackageIndexEntry {
name;
description;
website;
version;
report;
}
function toHTML(entry) {
let v = entry.version != null ? `<span>${escapeHtml(entry.version)}</span>` : "";
let d = entry.description != null ? `<p>${escapeHtml(entry.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 = (document.createElement('tr'));
row.innerHTML = `<td>
<h2>${escapeHtml(entry.name)} ${v}</h2>
${d}
${w}
${r}
</td>`;
return row;
}
const API_VERSION = 1;
const ENDPOINT = `/api/v${API_VERSION}`;
class InfoPayload {
count;
hakurei_version;
}
async function infoRequest() {
const res = await fetch(`${ENDPOINT}/info`);
const payload = await res.json();
return payload;
}
class GetPayload {
count;
values;
}
var SortOrders;
(function (SortOrders) {
SortOrders[SortOrders["DeclarationAscending"] = 0] = "DeclarationAscending";
SortOrders[SortOrders["DeclarationDescending"] = 1] = "DeclarationDescending";
SortOrders[SortOrders["NameAscending"] = 2] = "NameAscending";
SortOrders[SortOrders["NameDescending"] = 3] = "NameDescending";
})(SortOrders || (SortOrders = {}));
async function getRequest(limit, index, sort) {
const res = await fetch(`${ENDPOINT}/get?limit=${limit}&index=${index}&sort=${sort.valueOf()}`);
const payload = await res.json();
return payload;
}
class State {
entriesPerPage = 10;
entryIndex = 0;
maxEntries = 0;
sort = SortOrders.DeclarationAscending;
getEntriesPerPage() {
return this.entriesPerPage;
}
setEntriesPerPage(entriesPerPage) {
this.entriesPerPage = entriesPerPage;
this.setEntryIndex(Math.floor(this.getEntryIndex() / entriesPerPage) * entriesPerPage);
}
getEntryIndex() {
return this.entryIndex;
}
setEntryIndex(entryIndex) {
this.entryIndex = entryIndex;
this.updatePage();
this.updateRange();
this.updateListings();
}
getMaxEntries() {
return this.maxEntries;
}
setMaxEntries(max) {
this.maxEntries = max;
}
getSortOrder() {
return this.sort;
}
setSortOrder(sortOrder) {
this.sort = sortOrder;
this.setEntryIndex(0);
}
updatePage() {
let page = Math.ceil(((this.getEntryIndex() + this.getEntriesPerPage()) - 1) / this.getEntriesPerPage());
document.getElementById("page-number").innerText = String(page);
}
updateRange() {
let max = Math.min(this.getEntryIndex() + this.getEntriesPerPage(), this.getMaxEntries());
document.getElementById("entry-counter").innerText = `${this.getEntryIndex() + 1}-${max} of ${this.getMaxEntries()}`;
}
updateListings() {
getRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSortOrder())
.then(res => {
let table = document.getElementById("pkg-list");
table.innerHTML = '';
for (let i = 0; i < res.count; i++) {
table.appendChild(toHTML(res.values[i]));
}
});
}
}
let STATE;
function prevPage() {
let index = STATE.getEntryIndex();
STATE.setEntryIndex(Math.max(0, index - STATE.getEntriesPerPage()));
}
function nextPage() {
let index = STATE.getEntryIndex();
STATE.setEntryIndex(Math.min((Math.ceil(STATE.getMaxEntries() / STATE.getEntriesPerPage()) * STATE.getEntriesPerPage()) - STATE.getEntriesPerPage(), index + STATE.getEntriesPerPage()));
}
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
document.addEventListener("DOMContentLoaded", () => {
STATE = new State();
infoRequest()
.then(res => {
STATE.setMaxEntries(res.count);
document.getElementById("hakurei-version").innerText = res.hakurei_version;
STATE.updateRange();
STATE.updateListings();
});
document.getElementById("count").addEventListener("change", (event) => {
STATE.setEntriesPerPage(parseInt(event.target.value));
});
document.getElementById("sort").addEventListener("change", (event) => {
STATE.setSortOrder(parseInt(event.target.value));
});
});

View File

@@ -0,0 +1,143 @@
class PackageIndexEntry {
name: string
description: string | null
website: string | null
version: string | null
report: boolean
}
function toHTML(entry: PackageIndexEntry): HTMLTableRowElement {
let v = entry.version != null ? `<span>${escapeHtml(entry.version)}</span>` : ""
let d = entry.description != null ? `<p>${escapeHtml(entry.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>
<h2>${escapeHtml(entry.name)} ${v}</h2>
${d}
${w}
${r}
</td>`
return row
}
const API_VERSION = 1
const ENDPOINT = `/api/v${API_VERSION}`
class 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
}
class GetPayload {
count: number
values: PackageIndexEntry[]
}
enum SortOrders {
DeclarationAscending = 0,
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
}
class State {
entriesPerPage: number = 10
entryIndex: number = 0
maxEntries: number = 0
sort: SortOrders = SortOrders.DeclarationAscending
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()
}
getMaxEntries(): number {
return this.maxEntries
}
setMaxEntries(max: number) {
this.maxEntries = 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())
document.getElementById("page-number").innerText = String(page)
}
updateRange() {
let max = Math.min(this.getEntryIndex() + this.getEntriesPerPage(), this.getMaxEntries())
document.getElementById("entry-counter").innerText = `${this.getEntryIndex() + 1}-${max} of ${this.getMaxEntries()}`
}
updateListings() {
getRequest(this.getEntriesPerPage(), this.getEntryIndex(), this.getSortOrder())
.then(res => {
let table = document.getElementById("pkg-list")
table.innerHTML = ''
for(let i = 0; i < res.count; i++) {
table.appendChild(toHTML(res.values[i]))
}
})
}
}
let STATE: State
function prevPage() {
let index = STATE.getEntryIndex()
STATE.setEntryIndex(Math.max(0, index - STATE.getEntriesPerPage()))
}
function nextPage() {
let index = STATE.getEntryIndex()
STATE.setEntryIndex(Math.min((Math.ceil(STATE.getMaxEntries() / STATE.getEntriesPerPage()) * STATE.getEntriesPerPage()) - STATE.getEntriesPerPage(), index + STATE.getEntriesPerPage()))
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
document.addEventListener("DOMContentLoaded", () => {
STATE = new State()
infoRequest()
.then(res => {
STATE.setMaxEntries(res.count)
document.getElementById("hakurei-version").innerText = res.hakurei_version
STATE.updateRange()
STATE.updateListings()
})
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))
})
})

View File

@@ -0,0 +1,6 @@
@use 'common';
html {
background-color: #d3d3d3;
color: black; }
/*# sourceMappingURL=light.css.map */

View File

@@ -0,0 +1,7 @@
{
"version": 3,
"mappings": "AAAA,aAAa;AAEb,IAAK;EACH,gBAAgB,EAAE,OAAO;EACzB,KAAK,EAAE,KAAK",
"sources": ["light.scss"],
"names": [],
"file": "light.css"
}

View File

@@ -0,0 +1,6 @@
@use 'common';
html {
background-color: #d3d3d3;
color: black;
}

View File

@@ -0,0 +1,5 @@
{
"compilerOptions": {
"target": "ES2024"
}
}