aboutsummaryrefslogtreecommitdiffhomepage
path: root/cmd/mbf/internal/pkgserver/ui/index.ts
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/index.ts
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/index.ts')
-rw-r--r--cmd/mbf/internal/pkgserver/ui/index.ts331
1 files changed, 331 insertions, 0 deletions
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