Serve every page under a hash-based Content-Security-Policy, and update KaTeX to 0.16.47

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-04 10:54:04 +02:00
1 parent fd9f665ef8
commit fdd4dcf841
16 files changed
+207 -23

No files matched your search

+8
View File
@@ -188,6 +188,14 @@ APIs change between minors without a deprecation period.
- **Portal sessions end after a day unused** (still at most 14 days in
all), and expired or idle sessions and used or expired key claims are
deleted hourly — they were refused, but never removed.
- **Every page carries a Content-Security-Policy**: scripts only from the
gateway itself and the page's own inline ones, each named by its hash —
no inline handlers, no eval, nothing a model's reply or a model card
could slip into a page. The few `onchange`/`onsubmit` attributes moved
to `/_static/pages.js`, htmx runs with `allowEval: false`, and the live
pages' "only while visible" refresh no longer needs an evaluated filter.
Pages now carry their headers even when answered with an error status.
- **KaTeX 0.16.47** (from 0.16.11), past the advisories fixed in 0.16.21.
### Everything that can be changed on a running cluster, from the console
+2 -4
View File
@@ -214,8 +214,7 @@ func (p *AccountPages) login(w http.ResponseWriter, r *http.Request) {
if !loginTokenOK(r) {
// Not this site's form: another one posting a visitor into an
// account of its choosing, or a form left open past its hour.
w.WriteHeader(http.StatusBadRequest)
render(w, "login", loginData{Email: email, SignupOpen: p.accounts.SignupOpen(), Version: p.version,
renderStatus(w, http.StatusBadRequest, "login", loginData{Email: email, SignupOpen: p.accounts.SignupOpen(), Version: p.version,
Error: "the form had expired; sign in again", CSRF: setLoginToken(w, r)})
return
}
@@ -235,8 +234,7 @@ func (p *AccountPages) login(w http.ResponseWriter, r *http.Request) {
p.log.Error("sign-in failed", "err", err)
d.Error = "something went wrong; try again"
}
w.WriteHeader(http.StatusUnauthorized)
render(w, "login", d)
renderStatus(w, http.StatusUnauthorized, "login", d)
return
}
+1 -2
View File
@@ -93,6 +93,5 @@ func Claim(claims Claims, version string, log *slog.Logger) http.HandlerFunc {
func gone(w http.ResponseWriter, d *ClaimData) {
d.Gone = true
d.Message = store.ErrClaimed.Error()
w.WriteHeader(http.StatusGone)
render(w, "claim", *d)
renderStatus(w, http.StatusGone, "claim", *d)
}
+62
View File
@@ -0,0 +1,62 @@
package web
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"net/http"
"regexp"
"slices"
"strings"
)
// inlineScript is a script element with its code in the page rather than
// in a src: what a policy names by its hash.
var inlineScript = regexp.MustCompile(`(?is)<script(\s[^>]*)?>(.*?)</script>`)
// contentSecurityPolicy is the policy for a rendered page: scripts from this
// server and the page's own inline ones, each by its hash, and nothing else
// — no inline handlers, no eval, no script a reply or a model card manages
// to put in the page. Styles may be inline (the pages and htmx use them),
// and fonts come from Google's for the public site.
//
// Hashed from the page as rendered, not from the templates: html/template
// drops comments inside scripts, so the template's text is not what the
// browser hashes.
func contentSecurityPolicy(page []byte) string {
var hashes []string
for _, m := range inlineScript.FindAllSubmatch(page, -1) {
attrs := strings.ToLower(string(m[1]))
if strings.Contains(attrs, "src=") || (strings.Contains(attrs, "type=") && !strings.Contains(attrs, "javascript")) {
continue // fetched, or data such as application/json
}
sum := sha256.Sum256(m[2])
h := "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'"
if !slices.Contains(hashes, h) {
hashes = append(hashes, h)
}
}
return "default-src 'self'; " +
"script-src 'self'" + prefixSpace(strings.Join(hashes, " ")) + "; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com; " +
"img-src 'self' data: https:; " + // model cards show their images
"connect-src 'self'; " +
"object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
}
func prefixSpace(s string) string {
if s == "" {
return ""
}
return " " + s
}
// securePage sets the headers every page is served with, and writes it.
func securePage(h http.Header, page *bytes.Buffer) {
h.Set("Content-Security-Policy", contentSecurityPolicy(page.Bytes()))
h.Set("X-Content-Type-Options", "nosniff")
if h.Get("Referrer-Policy") == "" {
h.Set("Referrer-Policy", "same-origin")
}
}
+72
View File
@@ -0,0 +1,72 @@
package web
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"io/fs"
"net/http/httptest"
"regexp"
"strings"
"testing"
)
func TestAPageNamesItsInlineScriptsAndNothingElse(t *testing.T) {
page := []byte(`<script src="/_static/x.js"></script><script>alert(1)</script>` +
`<script type="application/json" id="d">{"a":1}</script><script>alert(1)</script>`)
sum := sha256.Sum256([]byte("alert(1)"))
csp := contentSecurityPolicy(page)
want := "script-src 'self' 'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "';"
if !strings.Contains(csp, want) {
t.Errorf("policy %q lacks %q", csp, want)
}
scripts := strings.Split(strings.Split(csp, "script-src")[1], ";")[0]
if strings.Contains(scripts, "unsafe") || strings.Count(scripts, "sha256-") != 1 {
t.Errorf("scripts are this server's and the one inline script, nothing unsafe: %q", scripts)
}
}
func TestRenderedPagesCarryThePolicy(t *testing.T) {
rec := httptest.NewRecorder()
renderStatus(rec, 401, "login", loginData{Error: "no"})
if rec.Code != 401 {
t.Errorf("status %d", rec.Code)
}
csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "frame-ancestors 'none'") {
t.Errorf("policy %q", csp)
}
// Every inline script on the page is in it.
for _, m := range inlineScript.FindAllSubmatch(rec.Body.Bytes(), -1) {
if bytes.Contains(m[1], []byte("src=")) {
continue
}
sum := sha256.Sum256(m[2])
if !strings.Contains(csp, base64.StdEncoding.EncodeToString(sum[:])) {
t.Errorf("an inline script is not in the policy: %.60s", m[2])
}
}
}
func TestNoTemplateUsesWhatThePolicyRefuses(t *testing.T) {
handler := regexp.MustCompile(`\son[a-z]+\s*=\s*["']`)
err := fs.WalkDir(files, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
b, _ := fs.ReadFile(files, path)
if loc := handler.FindIndex(b); loc != nil {
t.Errorf("%s has an inline event handler: %.60s", path, b[loc[0]:])
}
if bytes.Contains(b, []byte("javascript:")) {
t.Errorf("%s has a javascript: URL", path)
}
if bytes.Contains(b, []byte(`hx-trigger="`)) && regexp.MustCompile(`hx-trigger="[^"]*\[`).Match(b) {
t.Errorf("%s has an htmx trigger filter, which is evaluated JavaScript", path)
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
KaTeX 0.16.11 — https://katex.org — MIT License
KaTeX 0.16.47 — https://katex.org — MIT License
Copyright (c) 2013-2020 Khan Academy and other contributors
Vendored rather than loaded from a CDN. This gateway makes no outbound requests
+1 -1
View File
@@ -1 +1 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t(require("katex")):"function"==typeof define&&define.amd?define(["katex"],t):"object"==typeof exports?exports.renderMathInElement=t(require("katex")):e.renderMathInElement=t(e.katex)}("undefined"!=typeof self?self:this,(function(e){return function(){"use strict";var t={771:function(t){t.exports=e}},n={};function r(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={exports:{}};return t[e](i,i.exports,r),i.exports}r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,{a:t}),t},r.d=function(e,t){for(var n in t)r.o(t,n)&&!r.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)};var o={};return function(){r.d(o,{default:function(){return d}});var e=r(771),t=r.n(e);const n=function(e,t,n){let r=n,o=0;const i=e.length;for(;r<t.length;){const n=t[r];if(o<=0&&t.slice(r,r+i)===e)return r;"\\"===n?r++:"{"===n?o++:"}"===n&&o--,r++}return-1},i=/^\\begin{/;var a=function(e,t){let r;const o=[],a=new RegExp("("+t.map((e=>e.left.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"))).join("|")+")");for(;r=e.search(a),-1!==r;){r>0&&(o.push({type:"text",data:e.slice(0,r)}),e=e.slice(r));const a=t.findIndex((t=>e.startsWith(t.left)));if(r=n(t[a].right,e,t[a].left.length),-1===r)break;const l=e.slice(0,r+t[a].right.length),s=i.test(l)?l:e.slice(t[a].left.length,r);o.push({type:"math",data:s,rawData:l,display:t[a].display}),e=e.slice(r+t[a].right.length)}return""!==e&&o.push({type:"text",data:e}),o};const l=function(e,n){const r=a(e,n.delimiters);if(1===r.length&&"text"===r[0].type)return null;const o=document.createDocumentFragment();for(let e=0;e<r.length;e++)if("text"===r[e].type)o.appendChild(document.createTextNode(r[e].data));else{const i=document.createElement("span");let a=r[e].data;n.displayMode=r[e].display;try{n.preProcess&&(a=n.preProcess(a)),t().render(a,i,n)}catch(i){if(!(i instanceof t().ParseError))throw i;n.errorCallback("KaTeX auto-render: Failed to parse `"+r[e].data+"` with ",i),o.appendChild(document.createTextNode(r[e].rawData));continue}o.appendChild(i)}return o},s=function(e,t){for(let n=0;n<e.childNodes.length;n++){const r=e.childNodes[n];if(3===r.nodeType){let o=r.textContent,i=r.nextSibling,a=0;for(;i&&i.nodeType===Node.TEXT_NODE;)o+=i.textContent,i=i.nextSibling,a++;const s=l(o,t);if(s){for(let e=0;e<a;e++)r.nextSibling.remove();n+=s.childNodes.length-1,e.replaceChild(s,r)}else n+=a}else if(1===r.nodeType){const e=" "+r.className+" ";-1===t.ignoredTags.indexOf(r.nodeName.toLowerCase())&&t.ignoredClasses.every((t=>-1===e.indexOf(" "+t+" ")))&&s(r,t)}}};var d=function(e,t){if(!e)throw new Error("No element provided to render");const n={};for(const e in t)t.hasOwnProperty(e)&&(n[e]=t[e]);n.delimiters=n.delimiters||[{left:"$$",right:"$$",display:!0},{left:"\\(",right:"\\)",display:!1},{left:"\\begin{equation}",right:"\\end{equation}",display:!0},{left:"\\begin{align}",right:"\\end{align}",display:!0},{left:"\\begin{alignat}",right:"\\end{alignat}",display:!0},{left:"\\begin{gather}",right:"\\end{gather}",display:!0},{left:"\\begin{CD}",right:"\\end{CD}",display:!0},{left:"\\[",right:"\\]",display:!0}],n.ignoredTags=n.ignoredTags||["script","noscript","style","textarea","pre","code","option"],n.ignoredClasses=n.ignoredClasses||[],n.errorCallback=n.errorCallback||console.error,n.macros=n.macros||{},s(e,n)}}(),o=o.default}()}));
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t(require("katex")):"function"==typeof define&&define.amd?define(["katex"],t):"object"==typeof exports?exports.renderMathInElement=t(require("katex")):e.renderMathInElement=t(e.katex)}("undefined"!=typeof self?self:this,function(e){return function(){"use strict";var t={757:function(t){t.exports=e}},n={};function r(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={exports:{}};return t[e](i,i.exports,r),i.exports}r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,{a:t}),t},r.d=function(e,t){for(var n in t)r.o(t,n)&&!r.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)};var o={};r.d(o,{default:function(){return p}});var i=r(757),a=r.n(i);const l=function(e,t,n){let r=n,o=0;const i=e.length;for(;r<t.length;){const n=t[r];if(o<=0&&t.slice(r,r+i)===e)return r;"\\"===n?r++:"{"===n?o++:"}"===n&&o--,r++}return-1},s=/^\\begin{/;var d=function(e,t){let n;const r=[],o=new RegExp("("+t.map(e=>e.left.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&")).join("|")+")");for(;n=e.search(o),-1!==n;){n>0&&(r.push({type:"text",data:e.slice(0,n)}),e=e.slice(n));const o=t.findIndex(t=>e.startsWith(t.left));if(n=l(t[o].right,e,t[o].left.length),-1===n)break;const i=e.slice(0,n+t[o].right.length),a=s.test(i)?i:e.slice(t[o].left.length,n);r.push({type:"math",data:a,rawData:i,display:t[o].display}),e=e.slice(n+t[o].right.length)}return""!==e&&r.push({type:"text",data:e}),r};const c=function(e,t){const n=d(e,t.delimiters);if(1===n.length&&"text"===n[0].type)return null;const r=document.createDocumentFragment();for(let e=0;e<n.length;e++)if("text"===n[e].type)r.appendChild(document.createTextNode(n[e].data));else{const o=document.createElement("span");let i=n[e].data;t.displayMode=n[e].display;try{t.preProcess&&(i=t.preProcess(i)),a().render(i,o,t)}catch(o){if(!(o instanceof a().ParseError))throw o;t.errorCallback("KaTeX auto-render: Failed to parse `"+n[e].data+"` with ",o),r.appendChild(document.createTextNode(n[e].rawData));continue}r.appendChild(o)}return r},f=function(e,t){for(let o=0;o<e.childNodes.length;o++){const i=e.childNodes[o];if(3===i.nodeType){var n;let a=null!=(n=i.textContent)?n:"",l=i.nextSibling,s=0;for(;l&&l.nodeType===Node.TEXT_NODE;){var r;a+=null!=(r=l.textContent)?r:"",l=l.nextSibling,s++}const d=c(a,t);if(d){for(let e=0;e<s;e++)i.nextSibling.remove();o+=d.childNodes.length-1,e.replaceChild(d,i)}else o+=s}else if(1===i.nodeType){const e=" "+i.className+" ";!t.ignoredTags.has(i.nodeName.toLowerCase())&&t.ignoredClasses.every(t=>!e.includes(" "+t+" "))&&f(i,t)}}};var p=function(e,t){if(!e)throw new Error("No element provided to render");const n={};Object.assign(n,t),n.delimiters=n.delimiters||[{left:"$$",right:"$$",display:!0},{left:"\\(",right:"\\)",display:!1},{left:"\\begin{equation}",right:"\\end{equation}",display:!0},{left:"\\begin{align}",right:"\\end{align}",display:!0},{left:"\\begin{alignat}",right:"\\end{alignat}",display:!0},{left:"\\begin{gather}",right:"\\end{gather}",display:!0},{left:"\\begin{CD}",right:"\\end{CD}",display:!0},{left:"\\[",right:"\\]",display:!0}],n.ignoredTags=new Set((null==t?void 0:t.ignoredTags)||["script","noscript","style","textarea","pre","code","option"]),n.ignoredClasses=n.ignoredClasses||[],n.errorCallback=n.errorCallback||console.error,n.macros=n.macros||{},f(e,n)};return o=o.default}()});
+1 -1
View File
@@ -1 +1 @@
@font-face{font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.16.11"}.katex .katex-mathml{clip:rect(1px,1px,1px,1px);border:0;height:1pLine truncated
@font-face{font-display:block;font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-display:block;font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-ReguLine truncated
+1 -1
View File
@@ -1 +1 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,(function(){return function(){"use strict";var e={d:function(t,r){for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}},t={};e.d(t,{default:function(){return Yn}});class r{constructor(e,t){this.name=void 0,this.position=void 0,this.length=void 0,this.rawMessage=void 0;let n,o,s="KaTeX parse error: "+e;const i=t&&t.loc;if(i&&i.start<=i.end){const e=i.lexer.input;n=i.start,o=i.end,n===e.length?s+=" at end of input: ":s+=" at position "+(n+1)+": ";const t=e.slice(n,o).replace(/[^]/g,"$&\u0332");let r,a;r=n>15?"\u2026"+e.slice(n-15,n):e.slice(0,n),a=o+15<e.length?e.slice(o,o+15)+"\u2026":e.slice(o),s+=r+t+a}const a=new Error(s);return a.name="ParseError",a.__proto__=r.prototype,a.position=n,null!=n&&null!=o&&(a.length=o-n),a.rawMessage=e,a}}r.prototype.__proto__=Error.prototype;var n=r;const o=/([A-Z])/g,s={"&":"&amp;",">":"&gt;","<":"&lt;",'"':"&quot;","'":"&#x27;"},i=/[&><"']/g;const a=function(e){return"ordgroup"===e.type||"color"===e.type?1===e.body.length?a(e.body[0]):e:"font"===e.type?a(e.body):e};var l={contains:function(e,t){return-1!==e.indexOf(t)},deflt:function(e,t){return void 0===e?t:e},escape:function(e){return String(e).replace(i,(e=>s[e]))},hyphenate:function(e){return e.replace(o,"-$1").toLowerCase()},getBaseElem:a,isCharacterBox:function(e){const t=a(e);return"mathord"===t.type||"textord"===t.type||"atom"===t.type},protocolFromUrl:function(e){const t=/^[\x00-\x20]*([^\\/#?]*?)(:|&#0*58|&#x0*3a|&colon)/i.exec(e);return t?":"!==t[2]?null:/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(t[1])?t[1].toLowerCase():null:"_relative"}};const h={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format <type>"},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color <color>",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:e=>"#"+e},macros:{type:"object",cli:"-m, --macro <def>",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(e,t)=>(t.push(e),t)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:e=>Math.max(0,e),cli:"--min-rule-thickness <size>",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:e=>Math.max(0,e),cli:"-s, --max-size <n>",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:e=>Math.max(0,e),cli:"-e, --max-expand <n>",cliProcessor:e=>"Infinity"===e?1/0:parseInt(e)},globalGroup:{type:"boolean",cli:!1}};function c(e){if(e.default)return e.default;const t=e.type,r=Array.isArray(t)?t[0]:t;if("string"!=typeof r)return r.enum[0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}class m{constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0Line truncated
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,function(){return function(){"use strict";var e={d:function(t,r){for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}},t={};e.d(t,{default:function(){return ko}});class r extends Error{constructor(e,t){let n,o,s="KaTeX parse error: "+e;const i=t&&t.loc;if(i&&i.start<=i.end){const e=i.lexer.input;n=i.start,o=i.end,n===e.length?s+=" at end of input: ":s+=" at position "+(n+1)+": ";const t=e.slice(n,o).replace(/[^]/g,"$&\u0332");let r,l;r=n>15?"\u2026"+e.slice(n-15,n):e.slice(0,n),l=o+15<e.length?e.slice(o,o+15)+"\u2026":e.slice(o),s+=r+t+l}super(s),this.name="ParseError",this.position=void 0,this.length=void 0,this.rawMessage=void 0,Object.setPrototypeOf(this,r.prototype),this.position=n,null!=n&&null!=o&&(this.length=o-n),this.rawMessage=e}}var n=r;const o=/([A-Z])/g,s=e=>e.replace(o,"-$1").toLowerCase(),i={"&":"&amp;",">":"&gt;","<":"&lt;",'"':"&quot;","'":"&#x27;"},l=/[&><"']/g,a=e=>String(e).replace(l,e=>i[e]),c=e=>"ordgroup"===e.type||"color"===e.type?1===e.body.length?c(e.body[0]):e:"font"===e.type?c(e.body):e,h=new Set(["mathord","textord","atom"]),m=e=>h.has(c(e).type),u={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format <type>"},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color <color>",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:e=>"#"+e},macros:{type:"object",cli:"-m, --macro <def>",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(e,t)=>(t.push(e),t)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:e=>Math.max(0,e),cli:"--min-rule-thickness <size>",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:e=>Math.max(0,e),cli:"-s, --max-size <n>",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:e=>Math.max(0,e),cli:"-e, --max-expand <n>",cliProcessor:e=>"Infinity"===e?1/0:parseInt(e)},globalGroup:{type:"boolean",cli:!1}};function p(e){if(void 0!==e.default)return e.default;return function(e){if("string"!=typeof e)return e.enum[0];switch(e){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{};default:throw new Error("Unexpected schema type; settings must declare an explicit default.")}}(Array.isArray(e.type)?e.type[0]:e.type)}function d(e,t,r,n){const o=r[t];e[t]=void 0!==o?n.processor?n.processor(o):o:p(n)}class g{constructor(e){void 0===e&&(e={}),this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0,this.errorColor=void 0,this.macros=void 0,this.minRuleThickness=void 0,this.colorIsTextColor=void 0,this.strict=void 0,this.trust=void 0,this.maxSize=void 0,this.maxExpand=void 0,this.globalGroup=void 0,e=e||{};for(const t of Object.keys(u))Line truncated
+28
View File
@@ -0,0 +1,28 @@
// What the pages did in inline handlers, which the Content-Security-Policy
// does not run: a form sent when a choice changes, a confirmation before a
// removal, a field filled from the option chosen, and the live parts of the
// cluster and node pages refreshed only while the tab is in view.
(function () {
document.addEventListener('change', function (e) {
var t = e.target;
if (t.matches && t.matches('[data-autosubmit]') && t.form) {
t.form.submit();
}
if (t.matches && t.matches('[data-suggest-into]') && t.form) {
var f = t.form.elements[t.getAttribute('data-suggest-into')];
var o = t.selectedOptions[0];
if (f && o) { f.value = o.dataset.suggest || ''; }
}
});
document.addEventListener('submit', function (e) {
var msg = e.target.getAttribute && e.target.getAttribute('data-confirm');
if (msg && !window.confirm(msg)) { e.preventDefault(); }
});
// htmx's trigger filters are evaluated JavaScript; this is the same
// "only while visible" without them.
document.addEventListener('htmx:beforeRequest', function (e) {
if (document.visibilityState !== 'visible' && e.detail.elt.hasAttribute('data-visible-only')) {
e.preventDefault();
}
});
})();
+5 -5
View File
@@ -4,6 +4,7 @@
{{/* Only while something is downloading. A reload at any other time would
take with it whatever the operator was typing into the form below. */}}
{{if .RefreshSec}}<meta http-equiv="refresh" content="{{.RefreshSec}}">{{end}}
<script defer src="/_static/pages.js"></script>
<title>Mirror · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
</head><body><div class="wrap">
<header>
@@ -77,7 +78,7 @@ reachable from this machine.</p>
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="model" value="{{.Name}}">
<input type="hidden" name="replicas" value="{{.Replicas}}">
<select name="mode" class="mode" onchange="this.form.submit()" aria-label="How {{.Name}} runs">
<select name="mode" class="mode" data-autosubmit aria-label="How {{.Name}} runs">
<option value="always"{{if eq .Mode "always"}} selected{{end}}>always on</option>
<option value="ondemand"{{if eq .Mode "ondemand"}} selected{{end}}>on demand</option>
<option value="stopped"{{if eq .Mode "stopped"}} selected{{end}}>stopped</option>
@@ -114,7 +115,7 @@ reachable from this machine.</p>
{{if and $.HasCatalog (not .UsedBy)}}{{if .Serve}}<a href="/admin/mirror">close</a>
{{else}}<a href="/admin/mirror?serve={{.Model}}:{{.Version}}">serve</a>{{end}}{{end}}
<form method="post" action="/admin/mirror/{{.Model}}/{{.Version}}/remove"
class="inline" onsubmit="return confirm('Remove {{.Model}}/{{.Version}} and every file in it?{{if .UsedBy}} It is in use by the catalog.{{end}}')">
class="inline" data-confirm="Remove {{.Model}}/{{.Version}} and every file in it?{{if .UsedBy}} It is in use by the catalog.{{end}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="link danger">remove</button>
</form>
@@ -288,7 +289,7 @@ document.addEventListener('click', function (e) {
{{if .Of}}<input type="hidden" name="of" value="{{.Of}}">{{end}}
<input type="hidden" name="x" value="1">{{end}}
<input type="search" name="q" value="{{.Query}}" placeholder="Search Hugging Face — qwen coder, gemma, bge…" aria-label="Search Hugging Face">
<label class="check"><input type="checkbox" name="here" value="1" onchange="this.form.submit()"{{if .Explore}}{{if .Explore.Here}} checked{{end}}{{end}}> only what these nodes can run</label>
<label class="check"><input type="checkbox" name="here" value="1" data-autosubmit{{if .Explore}}{{if .Explore.Here}} checked{{end}}{{end}}> only what these nodes can run</label>
<button type="submit">Search</button>
</form>
{{end}}
@@ -579,8 +580,7 @@ computes once it has it.</p>
<input type="hidden" name="open" value="{{$row.Open}}">
<input type="hidden" name="op" value="add">
<div class="field-row">
<label>Adapter file <select name="source" required
onchange="this.form.elements.name.value = this.selectedOptions[0].dataset.suggest">
<label>Adapter file <select name="source" required data-suggest-into="name">
{{range .AdapterChoices}}<option value="{{.URL}}" data-suggest="{{.Suggest}}">{{.Label}}</option>{{end}}
</select></label>
<label>Asked for as <input type="text" name="name" value="{{(index .AdapterChoices 0).Suggest}}" required></label>
+1 -1
View File
@@ -6,7 +6,7 @@
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>People · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
<meta name="htmx-config" content='{"scrollIntoViewOnBoost":false,"defaultFocusScroll":false}'>
<meta name="htmx-config" content='{"scrollIntoViewOnBoost":false,"defaultFocusScroll":false,"allowEval":false}'>
<script src="/_static/htmx-2.0.11.min.js" defer></script>
</head><body><div class="wrap">
<header>
+2 -1
View File
@@ -5,6 +5,7 @@
<link rel="stylesheet" href="/_static/katex/katex.min.css">
<script defer src="/_static/katex/katex.min.js"></script>
<script defer src="/_static/katex/auto-render.min.js"></script>
<script defer src="/_static/pages.js"></script>
<meta name="csrf" content="{{.CSRF}}">
</head><body><div class="wrap">
<header>
@@ -19,7 +20,7 @@
message rather than trusting the page. */}}
<form method="get" action="/chat" class="chat-context">
<label class="inline-label">Using
<select id="context" name="context" onchange="this.form.submit()">
<select id="context" name="context" data-autosubmit>
{{range .Contexts}}<option value="{{.Value}}"{{if eq .Value $.Context}} selected{{end}}>{{.Label}}</option>{{end}}
</select></label>
<noscript><button type="submit" class="link">switch</button></noscript>
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<noscript><meta http-equiv="refresh" content="{{.RefreshSec}}"></noscript>
<meta name="htmx-config" content='{"allowEval":false}'>
<script src="/_static/htmx-2.0.11.min.js"></script>
<script defer src="/_static/pages.js"></script>
<title>llmgw — cluster</title><link rel="stylesheet" href="{{stylesheet}}">
</head><body><div class="wrap">
<header>
@@ -19,7 +21,7 @@
every few seconds threw away the scroll position of anyone reading the
bottom of it. Paused while the tab is hidden. Without script, the meta
refresh in the head does the old thing. */}}
<div id="live" hx-get="{{if .OnAdminListener}}/{{else}}/admin{{end}}" hx-trigger="every {{.RefreshSec}}s [document.visibilityState === 'visible']" hx-select="#live" hx-swap="outerHTML">
<div id="live" hx-get="{{if .OnAdminListener}}/{{else}}/admin{{end}}" hx-trigger="every {{.RefreshSec}}s" data-visible-only hx-select="#live" hx-swap="outerHTML">
{{if .HasCharts}}
<div class="tiles">
<div class="tile"><div class="v">{{.Requests24h}}</div><div class="k">requests · 24h</div>{{.RequestSpark}}</div>
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<noscript><meta http-equiv="refresh" content="{{.RefreshSec}}"></noscript>
<meta name="htmx-config" content='{"allowEval":false}'>
<script src="/_static/htmx-2.0.11.min.js"></script>
<script defer src="/_static/pages.js"></script>
<title>llmgw — {{.Node.ID}}</title><link rel="stylesheet" href="{{stylesheet}}">
</head><body><div class="wrap">
<header>
@@ -14,7 +16,7 @@
</header>
{{/* Refreshed in place, like the cluster page. */}}
<div id="live" hx-get="{{.Self}}" hx-trigger="every {{.RefreshSec}}s [document.visibilityState === 'visible']" hx-select="#live" hx-swap="outerHTML">
<div id="live" hx-get="{{.Self}}" hx-trigger="every {{.RefreshSec}}s" data-visible-only hx-select="#live" hx-swap="outerHTML">
{{with .Node}}
<div class="tiles">
{{with .Load}}
+16 -4
View File
@@ -14,6 +14,7 @@
package web
import (
"bytes"
"crypto/sha256"
"embed"
"encoding/hex"
@@ -260,12 +261,23 @@ func (n noDirs) Open(name string) (fs.File, error) {
}
func render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
// The status is already written by then, so there is nothing honest to
// send; the page simply ends short.
renderStatus(w, http.StatusOK, name, data)
}
// renderStatus is render with a status other than 200.
func renderStatus(w http.ResponseWriter, code int, name string, data any) {
// Rendered whole before anything is sent: the policy names the page's
// inline scripts by their hashes, and a template that fails halfway is
// an error page rather than half a page.
var page bytes.Buffer
if err := tmpl.ExecuteTemplate(&page, name, data); err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
securePage(w.Header(), &page)
w.WriteHeader(code)
_, _ = page.WriteTo(w)
}
// humanLatency writes a mean latency the way a person reads one: 5 ms, 8.8 s,