forked from rosa/hakurei
Compare commits
12 Commits
staging
...
b104ad6e2d
| Author | SHA1 | Date | |
|---|---|---|---|
| b104ad6e2d | |||
| 469bd1ee99 | |||
| 52a4e5b87d | |||
|
35d76c5d2b
|
|||
|
dfd3301a33
|
|||
|
a4ce41ea9a
|
|||
|
773e43a215
|
|||
|
f150e1fdd6
|
|||
|
dec7010c35
|
|||
|
69bd88282c
|
|||
|
ca2053d3ba
|
|||
|
8d0aa1127c
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
144
cmd/pkgserver/api.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"hakurei.app/internal/info"
|
||||
"hakurei.app/internal/pkg"
|
||||
"hakurei.app/internal/rosa"
|
||||
)
|
||||
|
||||
type InfoPayload struct {
|
||||
Count int `json:"count"`
|
||||
HakureiVersion string `json:"hakurei_version"`
|
||||
}
|
||||
|
||||
func NewInfoPayload(index *PackageIndex) InfoPayload {
|
||||
count := len(index.sorts[0])
|
||||
return InfoPayload{
|
||||
Count: count,
|
||||
HakureiVersion: info.Version(),
|
||||
}
|
||||
}
|
||||
|
||||
func serveInfo(index *PackageIndex) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Content-Type", "text/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
WritePayload(w, NewInfoPayload(index))
|
||||
}
|
||||
}
|
||||
|
||||
func serveStatus(index *PackageIndex, cache *pkg.Cache) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
download := path.Dir(r.URL.Path) == "/status"
|
||||
if index == nil {
|
||||
http.Error(w, "index is nil", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
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 download {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
}
|
||||
if download {
|
||||
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")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err := io.Copy(w, bytes.NewReader(pk.status))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
} else {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type GetPayload struct {
|
||||
Count int `json:"count"`
|
||||
Values []PackageIndexEntry `json:"values"`
|
||||
}
|
||||
|
||||
func NewGetPayload(values []*PackageIndexEntry) GetPayload {
|
||||
count := len(values)
|
||||
v := make([]PackageIndexEntry, count)
|
||||
for i, _ := range values {
|
||||
v[i] = *values[i]
|
||||
}
|
||||
return GetPayload{
|
||||
Count: count,
|
||||
Values: v,
|
||||
}
|
||||
}
|
||||
|
||||
func serveGet(index *PackageIndex) func(http.ResponseWriter, *http.Request) {
|
||||
return func(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, fmt.Sprintf("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, fmt.Sprintf("index must be an integer between 0 and %d", len(index.sorts[0])-1), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sort, err := strconv.Atoi(q.Get("sort"))
|
||||
if err != nil || sort >= len(index.sorts) || sort < 0 {
|
||||
http.Error(w, fmt.Sprintf("sort must be an integer between 0 and %d", len(index.sorts)-1), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
values := index.sorts[sort][i:min(i+limit, len(index.sorts[sort]))]
|
||||
WritePayload(w, NewGetPayload(values))
|
||||
}
|
||||
}
|
||||
|
||||
const ApiVersion = "v1"
|
||||
|
||||
func apiRoutes(index *PackageIndex, cache *pkg.Cache) {
|
||||
http.HandleFunc(fmt.Sprintf("GET /api/%s/info", ApiVersion), serveInfo(index))
|
||||
http.HandleFunc(fmt.Sprintf("GET /api/%s/get", ApiVersion), serveGet(index))
|
||||
http.HandleFunc(fmt.Sprintf("GET /api/%s/status/", ApiVersion), serveStatus(index, cache))
|
||||
http.HandleFunc("GET /status/", serveStatus(index, cache))
|
||||
}
|
||||
|
||||
func WritePayload(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")
|
||||
err := json.NewEncoder(w).Encode(payload)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
84
cmd/pkgserver/index.go
Normal file
84
cmd/pkgserver/index.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
"unique"
|
||||
|
||||
"hakurei.app/internal/pkg"
|
||||
"hakurei.app/internal/rosa"
|
||||
)
|
||||
|
||||
type SortOrders int
|
||||
|
||||
const (
|
||||
DeclarationAscending SortOrders = iota
|
||||
DeclarationDescending
|
||||
NameAscending
|
||||
NameDescending
|
||||
limitSortOrders
|
||||
)
|
||||
|
||||
type PackageIndex struct {
|
||||
sorts [limitSortOrders][rosa.PresetUnexportedStart]*PackageIndexEntry
|
||||
names map[string]*PackageIndexEntry
|
||||
}
|
||||
|
||||
type PackageIndexEntry struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Version string `json:"version"`
|
||||
HasReport bool `json:"report,omitempty"`
|
||||
ident unique.Handle[pkg.ID] `json:"-"`
|
||||
status []byte `json:"-"`
|
||||
}
|
||||
|
||||
func createPackageIndex(cache *pkg.Cache, report *rosa.Report) (_ *PackageIndex, err error) {
|
||||
index := new(PackageIndex)
|
||||
index.names = make(map[string]*PackageIndexEntry, rosa.PresetUnexportedStart)
|
||||
work := make([]PackageIndexEntry, rosa.PresetUnexportedStart)
|
||||
defer report.HandleAccess(&err)()
|
||||
for p := range rosa.PresetUnexportedStart {
|
||||
m := rosa.GetMetadata(p)
|
||||
v := rosa.Std.Version(p)
|
||||
a := rosa.Std.Load(p)
|
||||
id := cache.Ident(a)
|
||||
st, n := report.ArtifactOf(id)
|
||||
var status []byte
|
||||
if n < 1 {
|
||||
status = nil
|
||||
} else {
|
||||
status = st
|
||||
}
|
||||
entry := PackageIndexEntry{
|
||||
Name: m.Name,
|
||||
Description: m.Description,
|
||||
Website: m.Website,
|
||||
Version: v,
|
||||
HasReport: len(status) > 0,
|
||||
ident: id,
|
||||
status: status,
|
||||
}
|
||||
work[p] = entry
|
||||
index.names[m.Name] = &entry
|
||||
}
|
||||
for i, p := range work {
|
||||
index.sorts[DeclarationAscending][i] = &p
|
||||
}
|
||||
slices.Reverse(work)
|
||||
for i, p := range work {
|
||||
index.sorts[DeclarationDescending][i] = &p
|
||||
}
|
||||
slices.SortFunc(work, func(a PackageIndexEntry, b PackageIndexEntry) int {
|
||||
return cmp.Compare(a.Name, b.Name)
|
||||
})
|
||||
for i, p := range work {
|
||||
index.sorts[NameAscending][i] = &p
|
||||
}
|
||||
slices.Reverse(work)
|
||||
for i, p := range work {
|
||||
index.sorts[NameDescending][i] = &p
|
||||
}
|
||||
return index, err
|
||||
}
|
||||
72
cmd/pkgserver/main.go
Normal file
72
cmd/pkgserver/main.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"hakurei.app/command"
|
||||
"hakurei.app/container/check"
|
||||
"hakurei.app/internal/pkg"
|
||||
"hakurei.app/internal/rosa"
|
||||
"hakurei.app/message"
|
||||
)
|
||||
|
||||
//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"
|
||||
func main() {
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("pkgserver: ")
|
||||
|
||||
var (
|
||||
flagBaseDir string
|
||||
flagPort int
|
||||
)
|
||||
|
||||
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 {
|
||||
reportPath := args[0]
|
||||
baseDir, err := check.NewAbs(flagBaseDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cache, err := pkg.Open(ctx, msg, 0, baseDir)
|
||||
defer cache.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
report, err := rosa.OpenReport(reportPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
index, err := createPackageIndex(cache, report)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uiRoutes()
|
||||
apiRoutes(index, cache)
|
||||
err = http.ListenAndServe(fmt.Sprintf(":%d", flagPort), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}).Flag(
|
||||
&flagBaseDir,
|
||||
"b", command.StringFlag(""),
|
||||
"base directory for cache",
|
||||
).Flag(
|
||||
&flagPort,
|
||||
"p", command.IntFlag(8067),
|
||||
"http listen port",
|
||||
)
|
||||
c.MustParse(os.Args[1:], func(e error) {
|
||||
log.Fatal(e)
|
||||
})
|
||||
|
||||
}
|
||||
47
cmd/pkgserver/ui.go
Normal file
47
cmd/pkgserver/ui.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//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() {
|
||||
http.HandleFunc("GET /{$}", serveWebUI)
|
||||
http.HandleFunc("GET /favicon.ico", serveStaticContent)
|
||||
http.HandleFunc("GET /static/", serveStaticContent)
|
||||
}
|
||||
33
cmd/pkgserver/ui/index.html
Normal file
33
cmd/pkgserver/ui/index.html
Normal 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()">« Previous</a> <span id="page-number">1</span> <a href="javascript:nextPage()">Next »</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>©<a href="https://hakurei.app/">Hakurei</a> (<span id="hakurei-version">unknown</span>). Licensed under the MIT license.</p>
|
||||
</footer>
|
||||
</html>
|
||||
0
cmd/pkgserver/ui/static/_common.scss
Normal file
0
cmd/pkgserver/ui/static/_common.scss
Normal file
6
cmd/pkgserver/ui/static/dark.css
Normal file
6
cmd/pkgserver/ui/static/dark.css
Normal file
@@ -0,0 +1,6 @@
|
||||
@use 'common';
|
||||
html {
|
||||
background-color: #2c2c2c;
|
||||
color: ghostwhite; }
|
||||
|
||||
/*# sourceMappingURL=dark.css.map */
|
||||
7
cmd/pkgserver/ui/static/dark.css.map
Normal file
7
cmd/pkgserver/ui/static/dark.css.map
Normal 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"
|
||||
}
|
||||
6
cmd/pkgserver/ui/static/dark.scss
Normal file
6
cmd/pkgserver/ui/static/dark.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
@use 'common';
|
||||
|
||||
html {
|
||||
background-color: #2c2c2c;
|
||||
color: ghostwhite;
|
||||
}
|
||||
BIN
cmd/pkgserver/ui/static/favicon.ico
Normal file
BIN
cmd/pkgserver/ui/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
134
cmd/pkgserver/ui/static/index.js
Normal file
134
cmd/pkgserver/ui/static/index.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
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));
|
||||
});
|
||||
});
|
||||
143
cmd/pkgserver/ui/static/index.ts
Normal file
143
cmd/pkgserver/ui/static/index.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
})
|
||||
6
cmd/pkgserver/ui/static/light.css
Normal file
6
cmd/pkgserver/ui/static/light.css
Normal file
@@ -0,0 +1,6 @@
|
||||
@use 'common';
|
||||
html {
|
||||
background-color: #d3d3d3;
|
||||
color: black; }
|
||||
|
||||
/*# sourceMappingURL=light.css.map */
|
||||
7
cmd/pkgserver/ui/static/light.css.map
Normal file
7
cmd/pkgserver/ui/static/light.css.map
Normal 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"
|
||||
}
|
||||
6
cmd/pkgserver/ui/static/light.scss
Normal file
6
cmd/pkgserver/ui/static/light.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
@use 'common';
|
||||
|
||||
html {
|
||||
background-color: #d3d3d3;
|
||||
color: black;
|
||||
}
|
||||
5
cmd/pkgserver/ui/static/tsconfig.json
Normal file
5
cmd/pkgserver/ui/static/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user