aboutsummaryrefslogtreecommitdiffhomepage
path: root/cmd/mbf/internal/pkgserver/ui
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/internal/pkgserver/ui
parente61628a34ecccae0feb5e059a2f808977261fa0e (diff)
cmd/mbf: package status dashboard
This displays package metadata with optional status from a report.
Diffstat (limited to 'cmd/mbf/internal/pkgserver/ui')
-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
7 files changed, 454 insertions, 0 deletions
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