Add optional PNG screenshot mode
PNG screenshots are now gated by WEBTERM_SCREENSHOT_MODE. The dashboard selects SVG by default and switches to PNG when enabled, with ETag caching and eviction for both formats. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -97,6 +97,7 @@ go run ./cmd/webterm -- --compose-manifest ./docker-compose.yaml
|
|||||||
- `WEBTERM_DOCKER_USERNAME`: user for Docker exec sessions
|
- `WEBTERM_DOCKER_USERNAME`: user for Docker exec sessions
|
||||||
- `WEBTERM_DOCKER_AUTO_COMMAND`: override auto command (`/bin/bash` default)
|
- `WEBTERM_DOCKER_AUTO_COMMAND`: override auto command (`/bin/bash` default)
|
||||||
- `WEBTERM_SCREENSHOT_FORCE_REDRAW`: force redraw before screenshots (`true/1/yes/on`)
|
- `WEBTERM_SCREENSHOT_FORCE_REDRAW`: force redraw before screenshots (`true/1/yes/on`)
|
||||||
|
- `WEBTERM_SCREENSHOT_MODE`: screenshot format for dashboard thumbnails (`svg` default, set `png` to enable PNG)
|
||||||
- `DOCKER_HOST`: Docker daemon endpoint override
|
- `DOCKER_HOST`: Docker daemon endpoint override
|
||||||
|
|
||||||
## Development (Makefile-first)
|
## Development (Makefile-first)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const (
|
|||||||
DefaultTerminalHeight = 45
|
DefaultTerminalHeight = 45
|
||||||
|
|
||||||
ScreenshotForceRedrawEnv = "WEBTERM_SCREENSHOT_FORCE_REDRAW"
|
ScreenshotForceRedrawEnv = "WEBTERM_SCREENSHOT_FORCE_REDRAW"
|
||||||
|
ScreenshotModeEnv = "WEBTERM_SCREENSHOT_MODE"
|
||||||
DockerUsernameEnv = "WEBTERM_DOCKER_USERNAME"
|
DockerUsernameEnv = "WEBTERM_DOCKER_USERNAME"
|
||||||
DockerAutoCommandEnv = "WEBTERM_DOCKER_AUTO_COMMAND"
|
DockerAutoCommandEnv = "WEBTERM_DOCKER_AUTO_COMMAND"
|
||||||
DockerHostEnv = "DOCKER_HOST"
|
DockerHostEnv = "DOCKER_HOST"
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package webterm
|
||||||
|
|
||||||
|
import "unicode"
|
||||||
|
|
||||||
|
const (
|
||||||
|
coverageSpace = 0
|
||||||
|
coveragePunctLight = 32
|
||||||
|
coveragePunct = 64
|
||||||
|
coverageLower = 96
|
||||||
|
coverageUpper = 112
|
||||||
|
coverageDigit = 104
|
||||||
|
coverageDefault = 96
|
||||||
|
)
|
||||||
|
|
||||||
|
var firaCodeCoverage = map[rune]uint8{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
firaCodeCoverage[' '] = coverageSpace
|
||||||
|
for _, r := range []rune(".,:;'\"") {
|
||||||
|
firaCodeCoverage[r] = coveragePunctLight
|
||||||
|
}
|
||||||
|
for _, r := range []rune("`") {
|
||||||
|
firaCodeCoverage[r] = coveragePunctLight
|
||||||
|
}
|
||||||
|
for _, r := range []rune("+-*/=<>()[]{}") {
|
||||||
|
firaCodeCoverage[r] = coveragePunct
|
||||||
|
}
|
||||||
|
for _, r := range []rune("|!#@$%^&?_") {
|
||||||
|
firaCodeCoverage[r] = coveragePunct
|
||||||
|
}
|
||||||
|
|
||||||
|
// Box drawing block — approximate light line density.
|
||||||
|
for r := rune(0x2500); r <= 0x257F; r++ {
|
||||||
|
firaCodeCoverage[r] = 72
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block elements and shading.
|
||||||
|
firaCodeCoverage['░'] = 64
|
||||||
|
firaCodeCoverage['▒'] = 128
|
||||||
|
firaCodeCoverage['▓'] = 192
|
||||||
|
firaCodeCoverage['█'] = 255
|
||||||
|
firaCodeCoverage['▀'] = 128
|
||||||
|
firaCodeCoverage['▄'] = 128
|
||||||
|
firaCodeCoverage['▌'] = 128
|
||||||
|
firaCodeCoverage['▐'] = 128
|
||||||
|
|
||||||
|
verticalBlocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
||||||
|
for i, r := range verticalBlocks {
|
||||||
|
firaCodeCoverage[r] = uint8((i + 1) * 255 / len(verticalBlocks))
|
||||||
|
}
|
||||||
|
leftBlocks := []rune{'▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'}
|
||||||
|
for i, r := range leftBlocks {
|
||||||
|
firaCodeCoverage[r] = uint8((i + 1) * 255 / len(leftBlocks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func coverageForRune(r rune) uint8 {
|
||||||
|
if value, ok := firaCodeCoverage[r]; ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if unicode.IsSpace(r) {
|
||||||
|
return coverageSpace
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
return coverageDigit
|
||||||
|
case r >= 'A' && r <= 'Z':
|
||||||
|
return coverageUpper
|
||||||
|
case r >= 'a' && r <= 'z':
|
||||||
|
return coverageLower
|
||||||
|
}
|
||||||
|
if unicode.IsPunct(r) || unicode.IsSymbol(r) {
|
||||||
|
return coveragePunct
|
||||||
|
}
|
||||||
|
return coverageDefault
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package webterm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"image/png"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rcarmo/webterm/internal/terminalstate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pngCharWidth = 8
|
||||||
|
pngCellHeight = 17
|
||||||
|
pngPadding = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
func RenderTerminalPNG(
|
||||||
|
buffer [][]terminalstate.Cell,
|
||||||
|
width, height int,
|
||||||
|
background, foreground string,
|
||||||
|
palette map[string]string,
|
||||||
|
) ([]byte, error) {
|
||||||
|
if background == "" {
|
||||||
|
background = "#000000"
|
||||||
|
}
|
||||||
|
if foreground == "" {
|
||||||
|
foreground = "#d3d7cf"
|
||||||
|
}
|
||||||
|
if palette == nil {
|
||||||
|
palette = ansiColors
|
||||||
|
}
|
||||||
|
cellHeight := pngCellHeight
|
||||||
|
imgWidth := width*pngCharWidth + pngPadding*2
|
||||||
|
imgHeight := height*cellHeight + pngPadding*2
|
||||||
|
if imgWidth <= 0 || imgHeight <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
bgColor := mustParseHexColor(background)
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight))
|
||||||
|
draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
for rowIdx := 0; rowIdx < len(buffer); rowIdx++ {
|
||||||
|
row := buffer[rowIdx]
|
||||||
|
rectY := pngPadding + rowIdx*cellHeight
|
||||||
|
for col := 0; col < len(row); col++ {
|
||||||
|
cell := row[col]
|
||||||
|
if cell.Data == "" && cell.BG == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x := pngPadding + col*pngCharWidth
|
||||||
|
fg := colorToHex(cell.FG, true, palette, foreground, background)
|
||||||
|
bg := colorToHex(cell.BG, false, palette, foreground, background)
|
||||||
|
if cell.Reverse {
|
||||||
|
fg, bg = bg, fg
|
||||||
|
}
|
||||||
|
bgColor := mustParseHexColor(bg)
|
||||||
|
coverage := uint8(0)
|
||||||
|
if cell.Data != "" {
|
||||||
|
coverage = coverageForRune(firstRune(cell.Data))
|
||||||
|
}
|
||||||
|
cellColor := blendColors(mustParseHexColor(fg), bgColor, coverage)
|
||||||
|
draw.Draw(
|
||||||
|
img,
|
||||||
|
image.Rect(x, rectY, x+pngCharWidth, rectY+cellHeight),
|
||||||
|
&image.Uniform{cellColor},
|
||||||
|
image.Point{},
|
||||||
|
draw.Src,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := png.Encode(&out, img); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstRune(value string) rune {
|
||||||
|
for _, r := range value {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
func blendColors(fg, bg color.RGBA, coverage uint8) color.RGBA {
|
||||||
|
inv := 255 - uint16(coverage)
|
||||||
|
cov := uint16(coverage)
|
||||||
|
r := (uint16(fg.R)*cov + uint16(bg.R)*inv + 127) / 255
|
||||||
|
g := (uint16(fg.G)*cov + uint16(bg.G)*inv + 127) / 255
|
||||||
|
b := (uint16(fg.B)*cov + uint16(bg.B)*inv + 127) / 255
|
||||||
|
return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseHexColor(value string) color.RGBA {
|
||||||
|
value = strings.TrimSpace(strings.TrimPrefix(value, "#"))
|
||||||
|
if len(value) != 6 {
|
||||||
|
return color.RGBA{A: 255}
|
||||||
|
}
|
||||||
|
r, err := strconv.ParseUint(value[0:2], 16, 8)
|
||||||
|
if err != nil {
|
||||||
|
return color.RGBA{A: 255}
|
||||||
|
}
|
||||||
|
g, err := strconv.ParseUint(value[2:4], 16, 8)
|
||||||
|
if err != nil {
|
||||||
|
return color.RGBA{A: 255}
|
||||||
|
}
|
||||||
|
b, err := strconv.ParseUint(value[4:6], 16, 8)
|
||||||
|
if err != nil {
|
||||||
|
return color.RGBA{A: 255}
|
||||||
|
}
|
||||||
|
return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}
|
||||||
|
}
|
||||||
+147
-4
@@ -52,6 +52,12 @@ type screenshotCacheEntry struct {
|
|||||||
etag string
|
etag string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type screenshotPNGCacheEntry struct {
|
||||||
|
when time.Time
|
||||||
|
png []byte
|
||||||
|
etag string
|
||||||
|
}
|
||||||
|
|
||||||
type wsClient struct {
|
type wsClient struct {
|
||||||
routeKey string
|
routeKey string
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
@@ -164,6 +170,7 @@ type LocalServer struct {
|
|||||||
theme string
|
theme string
|
||||||
fontFamily string
|
fontFamily string
|
||||||
fontSize int
|
fontSize int
|
||||||
|
screenshotMode string
|
||||||
|
|
||||||
sessionManager *SessionManager
|
sessionManager *SessionManager
|
||||||
landingApps []App
|
landingApps []App
|
||||||
@@ -177,6 +184,7 @@ type LocalServer struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
wsClients map[string]*wsClient
|
wsClients map[string]*wsClient
|
||||||
screenshotCache map[string]screenshotCacheEntry
|
screenshotCache map[string]screenshotCacheEntry
|
||||||
|
screenshotPNGCache map[string]screenshotPNGCacheEntry
|
||||||
routeLastActivity map[string]time.Time
|
routeLastActivity map[string]time.Time
|
||||||
routeLastSSE map[string]time.Time
|
routeLastSSE map[string]time.Time
|
||||||
sseSubscribers map[chan string]struct{}
|
sseSubscribers map[chan string]struct{}
|
||||||
@@ -231,6 +239,10 @@ func NewLocalServer(config Config, options ServerOptions) *LocalServer {
|
|||||||
if fontSize <= 0 {
|
if fontSize <= 0 {
|
||||||
fontSize = DefaultFontSize
|
fontSize = DefaultFontSize
|
||||||
}
|
}
|
||||||
|
screenshotMode := strings.ToLower(strings.TrimSpace(os.Getenv(ScreenshotModeEnv)))
|
||||||
|
if screenshotMode != "png" {
|
||||||
|
screenshotMode = "svg"
|
||||||
|
}
|
||||||
apps := append([]App{}, config.Apps...)
|
apps := append([]App{}, config.Apps...)
|
||||||
for _, app := range options.LandingApps {
|
for _, app := range options.LandingApps {
|
||||||
apps = append(apps, app)
|
apps = append(apps, app)
|
||||||
@@ -241,6 +253,7 @@ func NewLocalServer(config Config, options ServerOptions) *LocalServer {
|
|||||||
theme: theme,
|
theme: theme,
|
||||||
fontFamily: options.FontFamily,
|
fontFamily: options.FontFamily,
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
|
screenshotMode: screenshotMode,
|
||||||
|
|
||||||
sessionManager: NewSessionManager(apps),
|
sessionManager: NewSessionManager(apps),
|
||||||
landingApps: append([]App{}, options.LandingApps...),
|
landingApps: append([]App{}, options.LandingApps...),
|
||||||
@@ -253,6 +266,7 @@ func NewLocalServer(config Config, options ServerOptions) *LocalServer {
|
|||||||
},
|
},
|
||||||
wsClients: map[string]*wsClient{},
|
wsClients: map[string]*wsClient{},
|
||||||
screenshotCache: map[string]screenshotCacheEntry{},
|
screenshotCache: map[string]screenshotCacheEntry{},
|
||||||
|
screenshotPNGCache: map[string]screenshotPNGCacheEntry{},
|
||||||
routeLastActivity: map[string]time.Time{},
|
routeLastActivity: map[string]time.Time{},
|
||||||
routeLastSSE: map[string]time.Time{},
|
routeLastSSE: map[string]time.Time{},
|
||||||
sseSubscribers: map[chan string]struct{}{},
|
sseSubscribers: map[chan string]struct{}{},
|
||||||
@@ -812,6 +826,118 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeSVG(responseSVG, responseETag)
|
writeSVG(responseSVG, responseETag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *LocalServer) handleScreenshotPNG(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.screenshotMode != "png" {
|
||||||
|
http.Error(w, "PNG screenshots disabled", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
download := r.URL.Query().Get("download") == "1"
|
||||||
|
routeKey := r.URL.Query().Get("route_key")
|
||||||
|
routeKey, session, ok := s.chooseRouteForScreenshot(routeKey)
|
||||||
|
if !ok && routeKey != "" {
|
||||||
|
if _, exists := s.sessionManager.AppBySlug(routeKey); exists {
|
||||||
|
_ = s.createTerminalSession(routeKey, DefaultTerminalWidth, DefaultTerminalHeight)
|
||||||
|
deadline := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for {
|
||||||
|
session = s.sessionManager.GetSessionByRouteKey(routeKey)
|
||||||
|
if session != nil {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok || session == nil {
|
||||||
|
http.Error(w, "Session not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeNotModified := func(etag string) {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("ETag", etag)
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
}
|
||||||
|
writePNG := func(pngBytes []byte, etag string) {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("ETag", etag)
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
if download {
|
||||||
|
filename := sanitizeFilenameToken(routeKey) + "-screenshot.png"
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||||
|
}
|
||||||
|
_, _ = w.Write(pngBytes)
|
||||||
|
}
|
||||||
|
useConditional := !download
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
cached, hasCached := s.screenshotPNGCache[routeKey]
|
||||||
|
lastActivity := s.routeLastActivity[routeKey]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if hasCached && time.Since(cached.when) < s.screenshotTTL(routeKey) && len(cached.png) > 0 {
|
||||||
|
if useConditional && etagMatches(r.Header.Get("If-None-Match"), cached.etag) {
|
||||||
|
if !lastActivity.After(cached.when) {
|
||||||
|
writeNotModified(cached.etag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
writePNG(cached.png, cached.etag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.screenshotForceRedraw {
|
||||||
|
_ = session.ForceRedraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot := session.GetScreenSnapshot()
|
||||||
|
if hasCached && !snapshot.HasChanges && len(cached.png) > 0 {
|
||||||
|
if useConditional && etagMatches(r.Header.Get("If-None-Match"), cached.etag) {
|
||||||
|
writeNotModified(cached.etag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writePNG(cached.png, cached.etag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
app, _ := s.sessionManager.AppBySlug(routeKey)
|
||||||
|
theme := strings.ToLower(strings.TrimSpace(app.Theme))
|
||||||
|
if theme == "" {
|
||||||
|
theme = strings.ToLower(s.theme)
|
||||||
|
}
|
||||||
|
palette := ThemePalettes[theme]
|
||||||
|
if palette == nil {
|
||||||
|
palette = ThemePalettes["xterm"]
|
||||||
|
}
|
||||||
|
background := palette["background"]
|
||||||
|
if background == "" {
|
||||||
|
background = ThemeBackgrounds["xterm"]
|
||||||
|
}
|
||||||
|
foreground := palette["foreground"]
|
||||||
|
if foreground == "" {
|
||||||
|
foreground = "#e5e5e5"
|
||||||
|
}
|
||||||
|
|
||||||
|
pngBytes, err := RenderTerminalPNG(snapshot.Buffer, snapshot.Width, snapshot.Height, background, foreground, palette)
|
||||||
|
if err != nil || len(pngBytes) == 0 {
|
||||||
|
http.Error(w, "Screenshot render failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash := sha1.Sum(pngBytes)
|
||||||
|
etag := fmt.Sprintf(`"%x"`, hash[:])
|
||||||
|
s.mu.Lock()
|
||||||
|
s.screenshotPNGCache[routeKey] = screenshotPNGCacheEntry{when: time.Now(), png: pngBytes, etag: etag}
|
||||||
|
s.mu.Unlock()
|
||||||
|
if useConditional && etagMatches(r.Header.Get("If-None-Match"), etag) {
|
||||||
|
writeNotModified(etag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writePNG(pngBytes, etag)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *LocalServer) handleCPUSparkline(w http.ResponseWriter, r *http.Request) {
|
func (s *LocalServer) handleCPUSparkline(w http.ResponseWriter, r *http.Request) {
|
||||||
container := r.URL.Query().Get("container")
|
container := r.URL.Query().Get("container")
|
||||||
if strings.TrimSpace(container) == "" {
|
if strings.TrimSpace(container) == "" {
|
||||||
@@ -960,6 +1086,14 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
if s.dockerWatch {
|
if s.dockerWatch {
|
||||||
dockerWatchJS = "true"
|
dockerWatchJS = "true"
|
||||||
}
|
}
|
||||||
|
screenshotEndpoint := "/screenshot.svg"
|
||||||
|
screenshotDownloadQuery := "sanitize_font_urls=1&download=1"
|
||||||
|
screenshotDownloadExt := "svg"
|
||||||
|
if s.screenshotMode == "png" {
|
||||||
|
screenshotEndpoint = "/screenshot.png"
|
||||||
|
screenshotDownloadQuery = "download=1"
|
||||||
|
screenshotDownloadExt = "png"
|
||||||
|
}
|
||||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -1016,6 +1150,9 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
let tiles = %s;
|
let tiles = %s;
|
||||||
const composeMode = %s;
|
const composeMode = %s;
|
||||||
const dockerWatchMode = %s;
|
const dockerWatchMode = %s;
|
||||||
|
const screenshotEndpoint = %q;
|
||||||
|
const screenshotDownloadQuery = %q;
|
||||||
|
const screenshotDownloadExt = %q;
|
||||||
let cardsBySlug = {};
|
let cardsBySlug = {};
|
||||||
|
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
@@ -1035,8 +1172,8 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
function downloadSanitizedScreenshot(slug) {
|
function downloadSanitizedScreenshot(slug) {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = '/screenshot.svg?route_key=' + encodeURIComponent(slug) + '&sanitize_font_urls=1&download=1&_t=' + Date.now();
|
link.href = screenshotEndpoint + '?route_key=' + encodeURIComponent(slug) + '&' + screenshotDownloadQuery + '&_t=' + Date.now();
|
||||||
link.download = slug + '-screenshot.svg';
|
link.download = slug + '-screenshot.' + screenshotDownloadExt;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
@@ -1282,7 +1419,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
screenshotRequestInFlight = true;
|
screenshotRequestInFlight = true;
|
||||||
const url = '/screenshot.svg?route_key=' + encodeURIComponent(slug);
|
const url = screenshotEndpoint + '?route_key=' + encodeURIComponent(slug);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
const headers = {};
|
const headers = {};
|
||||||
@@ -1468,7 +1605,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`, string(tilesJSON), composeModeJS, dockerWatchJS)
|
</html>`, string(tilesJSON), composeModeJS, dockerWatchJS, screenshotEndpoint, screenshotDownloadQuery, screenshotDownloadExt)
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_, _ = io.WriteString(w, html)
|
_, _ = io.WriteString(w, html)
|
||||||
return
|
return
|
||||||
@@ -1604,6 +1741,7 @@ func (s *LocalServer) Handler() http.Handler {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/ws/", s.handleWebSocket)
|
mux.HandleFunc("/ws/", s.handleWebSocket)
|
||||||
mux.HandleFunc("/screenshot.svg", s.handleScreenshot)
|
mux.HandleFunc("/screenshot.svg", s.handleScreenshot)
|
||||||
|
mux.HandleFunc("/screenshot.png", s.handleScreenshotPNG)
|
||||||
mux.HandleFunc("/cpu-sparkline.svg", s.handleCPUSparkline)
|
mux.HandleFunc("/cpu-sparkline.svg", s.handleCPUSparkline)
|
||||||
mux.HandleFunc("/events", s.handleEvents)
|
mux.HandleFunc("/events", s.handleEvents)
|
||||||
mux.HandleFunc("/health", s.handleHealth)
|
mux.HandleFunc("/health", s.handleHealth)
|
||||||
@@ -1633,6 +1771,11 @@ func (s *LocalServer) evictStaleScreenshots(ctx context.Context) {
|
|||||||
delete(s.screenshotCache, key)
|
delete(s.screenshotCache, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for key, entry := range s.screenshotPNGCache {
|
||||||
|
if time.Since(entry.when) > maxScreenshotCacheTTL {
|
||||||
|
delete(s.screenshotPNGCache, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -455,6 +455,38 @@ func TestScreenshotAndETag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScreenshotPNGAndETag(t *testing.T) {
|
||||||
|
t.Setenv(ScreenshotModeEnv, "png")
|
||||||
|
server, httpServer, _ := newServerForTests(t, false)
|
||||||
|
if _, err := server.sessionManager.NewSession("shell", "sid", "shell", 80, 24); err != nil {
|
||||||
|
t.Fatalf("NewSession error = %v", err)
|
||||||
|
}
|
||||||
|
resp, err := http.Get(httpServer.URL + "/screenshot.png?route_key=shell")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("screenshot request error = %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
etag := resp.Header.Get("ETag")
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if etag == "" || len(body) < 8 || string(body[:8]) != "\x89PNG\r\n\x1a\n" {
|
||||||
|
t.Fatalf("expected png body and etag")
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, httpServer.URL+"/screenshot.png?route_key=shell", nil)
|
||||||
|
req.Header.Set("If-None-Match", etag)
|
||||||
|
resp2, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("etag request error = %v", err)
|
||||||
|
}
|
||||||
|
_ = resp2.Body.Close()
|
||||||
|
if resp2.StatusCode != http.StatusNotModified {
|
||||||
|
t.Fatalf("expected 304, got %d", resp2.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestScreenshotCreatesSessionFromRequestedRoute(t *testing.T) {
|
func TestScreenshotCreatesSessionFromRequestedRoute(t *testing.T) {
|
||||||
_, httpServer, _ := newServerForTests(t, false)
|
_, httpServer, _ := newServerForTests(t, false)
|
||||||
resp, err := http.Get(httpServer.URL + "/screenshot.svg?route_key=shell")
|
resp, err := http.Get(httpServer.URL + "/screenshot.svg?route_key=shell")
|
||||||
@@ -502,6 +534,24 @@ func TestDashboardIncludesContextMenuSanitizedDownload(t *testing.T) {
|
|||||||
if !strings.Contains(text, "contextmenu") || !strings.Contains(text, "sanitize_font_urls=1&download=1") {
|
if !strings.Contains(text, "contextmenu") || !strings.Contains(text, "sanitize_font_urls=1&download=1") {
|
||||||
t.Fatalf("expected contextmenu sanitized download wiring in dashboard page")
|
t.Fatalf("expected contextmenu sanitized download wiring in dashboard page")
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(text, "screenshot.svg") {
|
||||||
|
t.Fatalf("expected dashboard to request svg screenshots by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardUsesPNGWhenEnabled(t *testing.T) {
|
||||||
|
t.Setenv(ScreenshotModeEnv, "png")
|
||||||
|
_, httpServer, _ := newServerForTests(t, true)
|
||||||
|
resp, err := http.Get(httpServer.URL + "/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dashboard request error = %v", err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
text := string(body)
|
||||||
|
if !strings.Contains(text, "screenshot.png") {
|
||||||
|
t.Fatalf("expected dashboard to request png screenshots when enabled")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRootTerminalPageAndSparklineValidation(t *testing.T) {
|
func TestRootTerminalPageAndSparklineValidation(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user