10 Commits

Author SHA1 Message Date
mae
52a4e5b87d cmd/pkgserver: minimum viable frontend 2026-03-10 03:32:17 -05:00
mae
35d76c5d2b cmd/pkgserver: api versioning 2026-03-10 17:28:49 +09:00
mae
dfd3301a33 cmd/pkgserver: add get endpoint 2026-03-09 18:18:51 -05:00
mae
a4ce41ea9a cmd/pkgserver: add count endpoint and restructure 2026-03-09 15:41:21 -05:00
mae
773e43a215 cmd/pkgserver: add status endpoint 2026-03-09 04:09:18 -05:00
mae
f150e1fdd6 cmd/pkgserver: add createPackageIndex 2026-03-09 01:27:46 -05:00
mae
dec7010c35 cmd/pkgserver: add command handler 2026-03-08 22:28:08 -05:00
mae
69bd88282c cmd/pkgserver: replace favicon 2026-03-05 01:12:17 -06:00
mae
ca2053d3ba cmd/pkgserver: pagination 2026-03-05 00:32:25 -06:00
mae
8d0aa1127c cmd/pkgserver: basic web ui 2026-03-04 22:50:58 -06:00
17 changed files with 684 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

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

@@ -0,0 +1,129 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"path"
"strconv"
"strings"
"hakurei.app/internal/info"
"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) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if index == nil {
http.Error(w, "index is nil", http.StatusInternalServerError)
return
}
base := path.Base(r.URL.Path)
name := strings.TrimSuffix(base, ".log")
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 {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
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) {
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))
}
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)
}
}

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

@@ -0,0 +1,85 @@
package main
import (
"cmp"
"fmt"
"slices"
"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"`
Status string `json:"report,omitempty"`
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
var statusUrl string
if n < 1 {
status = nil
statusUrl = ""
} else {
status = st
statusUrl = fmt.Sprintf("/api/%s/status/%s.log", ApiVersion, m.Name)
}
entry := PackageIndexEntry{
Name: m.Name,
Description: m.Description,
Website: m.Website,
Version: v,
Status: statusUrl,
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
View 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)
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
View 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)
}

View File

@@ -0,0 +1,27 @@
<!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>
</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,136 @@
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 != null ? `<a href="${encodeURI(entry.report)}">Log</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 res_1 = await res.json();
return res_1;
}
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 res_1 = await res.json();
return res_1;
}
class State {
entriesPerPage = 10;
currentPage = 1;
entryIndex = 0;
maxEntries = 100;
getEntriesPerPage() {
return this.entriesPerPage;
}
setEntriesPerPage(entriesPerPage) {
this.entriesPerPage = entriesPerPage;
if (this.currentPage > this.getMaxPage()) {
this.setCurrentPage(this.getMaxPage());
}
}
getCurrentPage() {
return this.currentPage;
}
setCurrentPage(page) {
this.currentPage = page;
this.setEntryIndex((this.getCurrentPage() - 1) * this.getEntriesPerPage());
document.getElementById("page-number").innerText = String(this.getCurrentPage());
}
getEntryIndex() {
return this.entryIndex;
}
setEntryIndex(entryIndex) {
this.entryIndex = entryIndex;
this.updateRange();
this.updateListings();
}
getMaxEntries() {
return this.maxEntries;
}
setMaxEntries(max) {
this.maxEntries = max;
}
getMaxPage() {
return Math.ceil(this.getMaxEntries() / this.getEntriesPerPage());
}
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.entryIndex, 0)
.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 current = STATE.getCurrentPage();
if (current > 1) {
STATE.setCurrentPage(STATE.getCurrentPage() - 1);
}
}
function nextPage() {
let current = STATE.getCurrentPage();
if (current < STATE.getMaxPage()) {
STATE.setCurrentPage(STATE.getCurrentPage() + 1);
}
}
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));
});
});

View File

@@ -0,0 +1,144 @@
class PackageIndexEntry {
name: string
description: string | null
website: string | null
version: string | null
report: string | null
}
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 != null ? `<a href="${encodeURI(entry.report)}">Log</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 res_1 = await res.json()
return res_1 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 res_1 = await res.json()
return res_1 as GetPayload
}
class State {
entriesPerPage: number = 10
currentPage: number = 1
entryIndex: number = 0
maxEntries: number = 0
getEntriesPerPage(): number {
return this.entriesPerPage
}
setEntriesPerPage(entriesPerPage: number) {
this.entriesPerPage = entriesPerPage
if (this.currentPage > this.getMaxPage()) {
this.setCurrentPage(this.getMaxPage())
}
}
getCurrentPage(): number {
return this.currentPage
}
setCurrentPage(page: number) {
this.currentPage = page
this.setEntryIndex((this.getCurrentPage() - 1) * this.getEntriesPerPage())
document.getElementById("page-number").innerText = String(this.getCurrentPage())
}
getEntryIndex(): number {
return this.entryIndex
}
setEntryIndex(entryIndex: number) {
this.entryIndex = entryIndex
this.updateRange()
this.updateListings()
}
getMaxEntries(): number {
return this.maxEntries
}
setMaxEntries(max: number) {
this.maxEntries = max
}
getMaxPage(): number {
return Math.ceil(this.getMaxEntries() / this.getEntriesPerPage())
}
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.entryIndex, 0)
.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 current = STATE.getCurrentPage()
if (current > 1) {
STATE.setCurrentPage(STATE.getCurrentPage() - 1)
}
}
function nextPage() {
let current = STATE.getCurrentPage()
if (current < STATE.getMaxPage()) {
STATE.setCurrentPage(STATE.getCurrentPage() + 1)
}
}
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))
})
})

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"
}
}