diff options
| author | mae <mae@maestoso.online> | 2026-03-04 22:50:58 -0600 |
|---|---|---|
| committer | Ophestra <cat@gensokyo.uk> | 2026-05-02 05:05:56 +0900 |
| commit | 1d5d063d6a443c9d4a1206d2743e4824411da956 (patch) | |
| tree | b32105cf3896f394be91099cfbfb67785de39385 /cmd/mbf/internal/pkgserver/ui | |
| parent | e61628a34ecccae0feb5e059a2f808977261fa0e (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.html | 57 | ||||
| -rw-r--r-- | cmd/mbf/internal/pkgserver/ui/index.ts | 331 | ||||
| -rw-r--r-- | cmd/mbf/internal/pkgserver/ui/style.css | 21 | ||||
| -rw-r--r-- | cmd/mbf/internal/pkgserver/ui/tsconfig.json | 8 | ||||
| -rw-r--r-- | cmd/mbf/internal/pkgserver/ui/ui.go | 9 | ||||
| -rw-r--r-- | cmd/mbf/internal/pkgserver/ui/ui_full.go | 21 | ||||
| -rw-r--r-- | cmd/mbf/internal/pkgserver/ui/ui_stub.go | 7 |
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()">« Previous</a> <input type="text" class="page-number" value="1"/> <a href="javascript:nextPage()">Next »</a></div> +<table id="pkg-list"> + <tr><td>Loading...</td></tr> +</table> +<div class="page-controls"><a href="javascript:prevPage()">« Previous</a> <input type="text" class="page-number" value="1"/> <a href="javascript:nextPage()">Next »</a></div> +<footer> + <p>©<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 "&" + case '<': + return "<" + case '>': + return ">" + case '"': + return """ + case "'": + return "'" + 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 |
