mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-03 02:26:43 +09:00
Introduce sk1 connection string format and GUI plugin
Switch connection string encoding from the old base64 blob to a readable sk1:// URL format (host/token[?exp&insecure]) across agent, backend, and frontend. Update encode/decode logic and extensive tests to validate port preservation, insecure/http handling, expiry parsing, and input validation; adjust agent pairing UI to accept the new scheme. Add ServerKit Agent GUI plugin: backend blueprint endpoints for capabilities, frame capture and synthetic desktop, plugin metadata; frontend plugin components (ServerGui, Launcher, SyntheticDesktop), styles, and manifest entry to enable streaming screenshots with a synthetic fallback. Also add Windows service stop waiting to avoid start races after pairing, and document SERVERKIT_PUBLIC_URL in env examples.
This commit is contained in:
1 parent
3c4a2c0c96
commit
4db9300001
21 files changed
+1444
-178
No files matched your search
@@ -30,6 +30,13 @@ DATABASE_URL=sqlite:///serverkit.db
|
||||
# CORS Origins - your domain(s), comma-separated
|
||||
CORS_ORIGINS=http://localhost,https://your-domain.com
|
||||
|
||||
# Public URL that agents should dial back to. Set this whenever the panel
|
||||
# sits behind a reverse proxy / tunnel / custom domain — the connection
|
||||
# string the panel issues to agents will embed this URL instead of the
|
||||
# request's Host header (which would otherwise be the internal IP/port).
|
||||
# Leave commented out for plain localhost development.
|
||||
# SERVERKIT_PUBLIC_URL=https://panel.your-domain.com
|
||||
|
||||
# Ports (for Docker)
|
||||
PORT=80
|
||||
SSL_PORT=443
|
||||
|
||||
@@ -131,6 +131,13 @@ func (p *pairer) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
if err := runServiceCmd("stop"); err != nil {
|
||||
p.log.Info("Service stop reported error (likely already stopped)", "error", err)
|
||||
}
|
||||
// sc.exe returns once SCM accepts the stop request, not when the
|
||||
// service is fully stopped. Issuing sc start while the service is
|
||||
// still STOP_PENDING fails with 1056 ("instance already running"),
|
||||
// so wait until SCM reports STOPPED before starting.
|
||||
if err := waitForServiceStopped(15 * time.Second); err != nil {
|
||||
p.log.Info("Service did not reach STOPPED before start", "error", err)
|
||||
}
|
||||
if err := runServiceCmd("start"); err != nil {
|
||||
p.log.Error("Failed to start service after pairing", "error", err)
|
||||
p.mu.Lock()
|
||||
@@ -213,8 +220,8 @@ type connStringRequest struct {
|
||||
ConnectionString string `json:"connection_string"`
|
||||
}
|
||||
|
||||
// handleConnectionString accepts a single sk_conn_v1.<...> blob, decodes
|
||||
// it into a panel URL + registration token, and runs the legacy
|
||||
// handleConnectionString accepts a single sk1://… connection string,
|
||||
// decodes it into a panel URL + registration token, and runs the legacy
|
||||
// /api/v1/servers/register flow against the panel — the same one the
|
||||
// `serverkit-agent register` CLI uses. We reuse the existing pairer
|
||||
// state machine ("enrolling" -> "claimed") so the React wizard's polling
|
||||
|
||||
@@ -49,6 +49,28 @@ func waitForServiceRunning(timeout time.Duration) error {
|
||||
timeout, strings.TrimSpace(lastOut))
|
||||
}
|
||||
|
||||
// waitForServiceStopped polls `sc query` until the service reports state
|
||||
// 1 (STOPPED) or the deadline passes. `sc stop` returns as soon as SCM
|
||||
// accepts the stop request — issuing `sc start` while the service is
|
||||
// still STOP_PENDING returns error 1056 ("instance already running"),
|
||||
// which is exactly what users hit on the post-pair restart.
|
||||
func waitForServiceStopped(timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastOut string
|
||||
for time.Now().Before(deadline) {
|
||||
cmd := exec.Command("sc.exe", "query", "ServerKitAgent")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
out, err := cmd.CombinedOutput()
|
||||
lastOut = string(out)
|
||||
if err == nil && strings.Contains(lastOut, "STOPPED") {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("service did not reach STOPPED within %s (last sc query: %s)",
|
||||
timeout, strings.TrimSpace(lastOut))
|
||||
}
|
||||
|
||||
// openTarget hands a path or URL to Explorer / the default browser via
|
||||
// rundll32 + url.dll, which is the canonical "open this thing" entry point
|
||||
// on Windows. Faster than shelling out to cmd /c start.
|
||||
|
||||
@@ -1,92 +1,102 @@
|
||||
// Package connstring decodes the panel's "connection string" — a single
|
||||
// base64-packed blob the user copies from the ServerKit panel and pastes
|
||||
// into the agent's pairing wizard. It bundles the panel URL plus a
|
||||
// single-use registration token, replacing the older flow where the user
|
||||
// typed those into separate fields.
|
||||
// URL-shaped string the user copies from the ServerKit panel and pastes
|
||||
// into the agent's pairing wizard. It bundles the panel host plus a
|
||||
// single-use registration token plus an optional expiry, replacing the
|
||||
// older flow where the user typed those into separate fields.
|
||||
//
|
||||
// Format: ``sk_conn_v1.<base64url(json_payload)>``
|
||||
// Format: ``sk1://<host>[:<port>]/<token>[?exp=<ISO8601>][&insecure=1]``
|
||||
//
|
||||
// The version prefix exists so older agents reject newer payloads cleanly
|
||||
// instead of mis-parsing them. Today's payload is intentionally tiny —
|
||||
// just the URL, the token, and an optional expiry the agent treats as
|
||||
// advisory (the panel is the authoritative source of truth on whether the
|
||||
// token is still good).
|
||||
// The ``sk1://`` scheme self-identifies the format (you can recognise a
|
||||
// ServerKit connection string at a glance) and gives us a version
|
||||
// lever: any future format change goes out as ``sk2://``. The host is
|
||||
// visible up front so the user can sanity-check which panel they're
|
||||
// pointing at before pasting. ``insecure=1`` flips the implied scheme
|
||||
// from https to http for dev / local-network use; absent it, https is
|
||||
// implied. Older agents reject newer-versioned payloads cleanly with
|
||||
// ErrUnknownVersion instead of mis-parsing them.
|
||||
package connstring
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Prefix is the version tag that introduces every v1 connection string.
|
||||
Prefix = "sk_conn_v1."
|
||||
// SchemePrefix is the scheme + separator that introduces every v1
|
||||
// connection string. Used to fail fast on legacy or unrelated input
|
||||
// before we try to parse it as a URL.
|
||||
SchemePrefix = "sk1://"
|
||||
)
|
||||
|
||||
// Decoded is the payload encoded inside a connection string.
|
||||
// Decoded is the data extracted from a connection string.
|
||||
type Decoded struct {
|
||||
URL string `json:"url"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"-"` // parsed from the raw payload below
|
||||
// Raw expiry kept verbatim so we can show the user the panel's exact
|
||||
// formatting (or "never") without re-stringifying.
|
||||
ExpiresAtRaw string `json:"expires_at,omitempty"`
|
||||
URL string // reconstructed http/https URL of the panel
|
||||
Token string // single-use registration token
|
||||
ExpiresAt time.Time // zero value means "never" or "unparseable"
|
||||
ExpiresAtRaw string // panel's exact expiry string (or "" if none)
|
||||
}
|
||||
|
||||
// ErrUnknownVersion is returned when the prefix is missing or names a
|
||||
// version this build doesn't understand. Callers should surface this as
|
||||
// "your panel is newer than this agent" rather than a generic decode error
|
||||
// — it's almost always the cause.
|
||||
// "your panel is newer than this agent" rather than a generic decode
|
||||
// error — it's almost always the cause.
|
||||
var ErrUnknownVersion = errors.New("connstring: unknown version prefix")
|
||||
|
||||
// Decode parses a connection string. Whitespace surrounding the input is
|
||||
// stripped (paste-from-clipboard often picks up a trailing newline).
|
||||
// Decode parses a connection string. Whitespace surrounding the input
|
||||
// is stripped (paste-from-clipboard often picks up a trailing newline).
|
||||
func Decode(s string) (*Decoded, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, errors.New("connstring: empty input")
|
||||
}
|
||||
if !strings.HasPrefix(s, Prefix) {
|
||||
if !strings.HasPrefix(s, SchemePrefix) {
|
||||
return nil, ErrUnknownVersion
|
||||
}
|
||||
payload := strings.TrimPrefix(s, Prefix)
|
||||
|
||||
// We use the URL-safe alphabet without padding on the panel side; allow
|
||||
// either padded or unpadded inputs here so a hand-edited string still
|
||||
// decodes.
|
||||
raw, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
raw, err = base64.URLEncoding.DecodeString(payload)
|
||||
return nil, fmt.Errorf("connstring: parse: %w", err)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connstring: base64 decode: %w", err)
|
||||
if u.Scheme != "sk1" {
|
||||
return nil, ErrUnknownVersion
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, errors.New("connstring: missing host")
|
||||
}
|
||||
|
||||
// Parse twice — once into the public Decoded struct (gets URL+Token),
|
||||
// then into a small auxiliary struct to capture the expiry as both a
|
||||
// time.Time and the original string.
|
||||
var out Decoded
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("connstring: json decode: %w", err)
|
||||
token := strings.TrimPrefix(u.Path, "/")
|
||||
if token == "" {
|
||||
return nil, errors.New("connstring: missing token")
|
||||
}
|
||||
if out.URL == "" || out.Token == "" {
|
||||
return nil, errors.New("connstring: payload missing url or token")
|
||||
// Tokens are url-safe by construction (panel uses secrets.token_urlsafe);
|
||||
// a stray slash means the user mangled the string. Better to fail
|
||||
// loudly than silently truncate.
|
||||
if strings.Contains(token, "/") {
|
||||
return nil, errors.New("connstring: token must not contain '/'")
|
||||
}
|
||||
|
||||
var aux struct {
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
q := u.Query()
|
||||
scheme := "https"
|
||||
switch q.Get("insecure") {
|
||||
case "1", "true", "yes":
|
||||
scheme = "http"
|
||||
}
|
||||
_ = json.Unmarshal(raw, &aux)
|
||||
out.ExpiresAtRaw = aux.ExpiresAt
|
||||
if aux.ExpiresAt != "" {
|
||||
if t, perr := time.Parse(time.RFC3339, aux.ExpiresAt); perr == nil {
|
||||
|
||||
out := &Decoded{
|
||||
URL: scheme + "://" + u.Host,
|
||||
Token: token,
|
||||
}
|
||||
if exp := q.Get("exp"); exp != "" {
|
||||
out.ExpiresAtRaw = exp
|
||||
// Best-effort parse — if the panel sends a format we don't
|
||||
// understand we still surface the raw value, and the panel
|
||||
// remains the source of truth on whether the token is good.
|
||||
if t, perr := time.Parse(time.RFC3339, exp); perr == nil {
|
||||
out.ExpiresAt = t
|
||||
}
|
||||
}
|
||||
|
||||
return &out, nil
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,22 +1,13 @@
|
||||
package connstring
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// helper: encode the panel's payload format so the test stays readable
|
||||
// even as we tweak fields. Mirrors backend/app/services/connection_string.py
|
||||
// — keep the two in sync.
|
||||
func encode(payload string) string {
|
||||
return Prefix + base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||
}
|
||||
|
||||
func TestDecode_Roundtrip(t *testing.T) {
|
||||
s := encode(`{"url":"https://panel.example.com","token":"sk_reg_abc","expires_at":"2026-05-08T17:00:00Z"}`)
|
||||
got, err := Decode(s)
|
||||
got, err := Decode("sk1://panel.example.com/sk_reg_abc?exp=2026-05-08T17:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
@@ -34,24 +25,61 @@ func TestDecode_Roundtrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode_PreservesPort(t *testing.T) {
|
||||
got, err := Decode("sk1://panel.example.com:9443/t")
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.URL != "https://panel.example.com:9443" {
|
||||
t.Errorf("url = %q", got.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode_HonoursInsecureFlag(t *testing.T) {
|
||||
// http panels (typically dev / local-network) round-trip via the
|
||||
// insecure=1 flag. Without honouring it, the agent would default
|
||||
// to https and fail TLS against an http-only backend.
|
||||
got, err := Decode("sk1://localhost:47927/t?insecure=1")
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.URL != "http://localhost:47927" {
|
||||
t.Errorf("url = %q (want http scheme)", got.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode_NoExpiry(t *testing.T) {
|
||||
got, err := Decode("sk1://panel.example.com/t")
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.ExpiresAtRaw != "" {
|
||||
t.Errorf("expected no raw expiry, got %q", got.ExpiresAtRaw)
|
||||
}
|
||||
if !got.ExpiresAt.IsZero() {
|
||||
t.Errorf("expected zero ExpiresAt, got %v", got.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode_TrimsWhitespace(t *testing.T) {
|
||||
s := encode(`{"url":"https://x","token":"t"}`)
|
||||
// Clipboard pastes often have a trailing newline; verify Decode
|
||||
// doesn't reject a string just because of that.
|
||||
if _, err := Decode("\n " + s + " \n"); err != nil {
|
||||
if _, err := Decode("\n sk1://panel.example.com/t \n"); err != nil {
|
||||
t.Fatalf("decode with whitespace: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode_RejectsUnknownVersion(t *testing.T) {
|
||||
_, err := Decode("sk_conn_v9.aaaa")
|
||||
if !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("want ErrUnknownVersion, got %v", err)
|
||||
cases := []string{
|
||||
"sk2://panel.example.com/t",
|
||||
"sk_conn_v1.eyJ1cmwiOiJodHRwczovL3gifQ", // legacy format
|
||||
"not-a-connection-string",
|
||||
}
|
||||
|
||||
_, err = Decode("not-a-connection-string")
|
||||
if !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("want ErrUnknownVersion for missing prefix, got %v", err)
|
||||
for _, in := range cases {
|
||||
_, err := Decode(in)
|
||||
if !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("Decode(%q): want ErrUnknownVersion, got %v", in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,39 +92,33 @@ func TestDecode_RejectsEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode_RejectsMissingFields(t *testing.T) {
|
||||
func TestDecode_RejectsMissingToken(t *testing.T) {
|
||||
cases := []string{
|
||||
`{"token":"t"}`,
|
||||
`{"url":"https://x"}`,
|
||||
`{}`,
|
||||
"sk1://panel.example.com",
|
||||
"sk1://panel.example.com/",
|
||||
}
|
||||
for _, payload := range cases {
|
||||
_, err := Decode(encode(payload))
|
||||
for _, in := range cases {
|
||||
_, err := Decode(in)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for payload %q", payload)
|
||||
t.Errorf("Decode(%q): want error, got nil", in)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing url or token") {
|
||||
t.Errorf("payload %q: unexpected error %v", payload, err)
|
||||
if !strings.Contains(err.Error(), "token") {
|
||||
t.Errorf("Decode(%q): unexpected error %v", in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode_RejectsCorruptBase64(t *testing.T) {
|
||||
// Garbage after the prefix isn't valid base64 in either alphabet.
|
||||
_, err := Decode(Prefix + "!!!not-base64!!!")
|
||||
if err == nil {
|
||||
t.Errorf("want error for corrupt base64")
|
||||
func TestDecode_RejectsTokenWithSlash(t *testing.T) {
|
||||
_, err := Decode("sk1://panel.example.com/sk_reg/abc")
|
||||
if err == nil || !strings.Contains(err.Error(), "/") {
|
||||
t.Errorf("expected error about '/', got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode_AcceptsPaddedBase64(t *testing.T) {
|
||||
// Some clipboards (notably pasting through certain terminals) round-
|
||||
// trip the URL-safe alphabet through a padded form. Decode tolerates
|
||||
// either, so a hand-edited string still works.
|
||||
payload := []byte(`{"url":"https://x","token":"t"}`)
|
||||
padded := Prefix + base64.URLEncoding.EncodeToString(payload)
|
||||
if _, err := Decode(padded); err != nil {
|
||||
t.Errorf("padded decode: %v", err)
|
||||
func TestDecode_RejectsMissingHost(t *testing.T) {
|
||||
_, err := Decode("sk1:///t")
|
||||
if err == nil || !strings.Contains(err.Error(), "host") {
|
||||
t.Errorf("expected host error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ function FormStage({ onConnectionString, onPairCode, busy, defaultName }) {
|
||||
setError('Paste the connection string from your panel.');
|
||||
return;
|
||||
}
|
||||
if (!trimmed.startsWith('sk_conn_')) {
|
||||
if (!trimmed.startsWith('sk1://')) {
|
||||
setError('That doesn’t look like a connection string. Generate one from the panel’s Add Server screen.');
|
||||
return;
|
||||
}
|
||||
@@ -103,7 +103,7 @@ function FormStage({ onConnectionString, onPairCode, busy, defaultName }) {
|
||||
<textarea
|
||||
className="field__input field__input--mono"
|
||||
rows={4}
|
||||
placeholder="sk_conn_v1...."
|
||||
placeholder="sk1://panel.example.com/sk_reg_..."
|
||||
value={connString}
|
||||
onChange={(e) => setConnString(e.target.value)}
|
||||
autoFocus
|
||||
|
||||
@@ -37,3 +37,9 @@ SERVERKIT_GITHUB_REPO=jhd3197/ServerKit
|
||||
# SERVERKIT_BACKUP_DIR=./dev-data/backups
|
||||
# SERVERKIT_CACHE_DIR=./dev-data/cache
|
||||
# SERVERKIT_QUARANTINE_DIR=./dev-data/quarantine
|
||||
|
||||
# Public URL agents should dial back to. Set this whenever the panel sits
|
||||
# behind a reverse proxy / tunnel / custom domain — the connection string
|
||||
# the panel issues to agents will embed this URL instead of the request's
|
||||
# Host header (which would otherwise be the internal IP/port).
|
||||
# SERVERKIT_PUBLIC_URL=https://panel.your-domain.com
|
||||
@@ -0,0 +1,3 @@
|
||||
from .blueprint import gui_bp
|
||||
|
||||
__all__ = ["gui_bp"]
|
||||
@@ -0,0 +1,199 @@
|
||||
"""ServerKit Agent GUI plugin — panel-side blueprint.
|
||||
|
||||
Acts as a thin proxy between the frontend and the agent's gui:* actions.
|
||||
No frame data is stored; everything is forwarded through agent_registry.send_command.
|
||||
"""
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
|
||||
from app.services.agent_registry import agent_registry
|
||||
from app.models.server import Server
|
||||
from app.models.user import User
|
||||
|
||||
gui_bp = Blueprint("server_gui", __name__)
|
||||
|
||||
DEFAULT_FRAME_TIMEOUT = 8.0
|
||||
MAX_FRAME_TIMEOUT = 15.0
|
||||
|
||||
|
||||
def _current_user():
|
||||
return User.query.get(get_jwt_identity())
|
||||
|
||||
|
||||
def _server_or_404(server_id: str):
|
||||
server = Server.query.get(server_id)
|
||||
if not server:
|
||||
return None, (jsonify({"error": "Server not found"}), 404)
|
||||
return server, None
|
||||
|
||||
|
||||
@gui_bp.route("/<server_id>/capabilities", methods=["GET"])
|
||||
@jwt_required()
|
||||
def capabilities(server_id):
|
||||
"""Ask the agent what it can capture (display server, resolution, fps cap)."""
|
||||
user = _current_user()
|
||||
server, err = _server_or_404(server_id)
|
||||
if err:
|
||||
return err
|
||||
|
||||
if server.status != "online":
|
||||
return jsonify({
|
||||
"capability": "none",
|
||||
"reason": "agent_offline",
|
||||
"synthetic_fallback": True,
|
||||
})
|
||||
|
||||
result = agent_registry.send_command(
|
||||
server_id=server_id,
|
||||
action="gui:capabilities",
|
||||
params={},
|
||||
timeout=5.0,
|
||||
user_id=user.id if user else None,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
# Agent doesn't implement gui:capabilities — that's fine, fall back.
|
||||
return jsonify({
|
||||
"capability": "none",
|
||||
"reason": result.get("error", "unsupported"),
|
||||
"synthetic_fallback": True,
|
||||
})
|
||||
|
||||
data = result.get("data") or {}
|
||||
data.setdefault("synthetic_fallback", data.get("capability") in (None, "none"))
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@gui_bp.route("/<server_id>/frame", methods=["GET"])
|
||||
@jwt_required()
|
||||
def frame(server_id):
|
||||
"""Capture and return a single frame.
|
||||
|
||||
Query params:
|
||||
scale float 0.1..1.0 server-side downscale before encoding
|
||||
quality int 10..95 JPEG quality (PNG ignores this)
|
||||
format png|jpeg encoding hint
|
||||
"""
|
||||
user = _current_user()
|
||||
server, err = _server_or_404(server_id)
|
||||
if err:
|
||||
return err
|
||||
|
||||
if server.status != "online":
|
||||
return jsonify({"error": "agent offline", "code": "AGENT_OFFLINE"}), 503
|
||||
|
||||
try:
|
||||
scale = float(request.args.get("scale", "0.75"))
|
||||
quality = int(request.args.get("quality", "70"))
|
||||
except ValueError:
|
||||
return jsonify({"error": "scale/quality must be numeric"}), 400
|
||||
|
||||
scale = max(0.1, min(scale, 1.0))
|
||||
quality = max(10, min(quality, 95))
|
||||
fmt = request.args.get("format", "jpeg").lower()
|
||||
if fmt not in ("jpeg", "png"):
|
||||
fmt = "jpeg"
|
||||
|
||||
result = agent_registry.send_command(
|
||||
server_id=server_id,
|
||||
action="gui:screenshot",
|
||||
params={"scale": scale, "quality": quality, "format": fmt},
|
||||
timeout=DEFAULT_FRAME_TIMEOUT,
|
||||
user_id=user.id if user else None,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
return jsonify({
|
||||
"error": result.get("error", "capture failed"),
|
||||
"code": result.get("code", "CAPTURE_FAILED"),
|
||||
}), 502
|
||||
|
||||
data = result.get("data") or {}
|
||||
# Expected agent shape:
|
||||
# { "image_base64": "...", "format": "jpeg", "width": 1920, "height": 1080,
|
||||
# "captured_at": "2026-05-01T12:34:56Z" }
|
||||
if "image_base64" not in data:
|
||||
return jsonify({"error": "agent returned no frame"}), 502
|
||||
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@gui_bp.route("/<server_id>/synthetic", methods=["GET"])
|
||||
@jwt_required()
|
||||
def synthetic(server_id):
|
||||
"""Return data the frontend uses to render the headless 'fake desktop'.
|
||||
|
||||
No new agent action — we reuse data the agent already exposes via
|
||||
existing actions. Cheap and always available.
|
||||
"""
|
||||
user = _current_user()
|
||||
server, err = _server_or_404(server_id)
|
||||
if err:
|
||||
return err
|
||||
|
||||
if server.status != "online":
|
||||
return jsonify({
|
||||
"windows": [],
|
||||
"taskbar": [],
|
||||
"drives": [],
|
||||
"offline": True,
|
||||
})
|
||||
|
||||
sysinfo = agent_registry.send_command(
|
||||
server_id=server_id,
|
||||
action="system:info",
|
||||
params={},
|
||||
timeout=5.0,
|
||||
user_id=user.id if user else None,
|
||||
)
|
||||
procs = agent_registry.send_command(
|
||||
server_id=server_id,
|
||||
action="system:processes",
|
||||
params={"limit": 12},
|
||||
timeout=5.0,
|
||||
user_id=user.id if user else None,
|
||||
)
|
||||
|
||||
info = (sysinfo.get("data") or {}) if sysinfo.get("success") else {}
|
||||
plist = (procs.get("data") or []) if procs.get("success") else []
|
||||
|
||||
windows = [
|
||||
{
|
||||
"id": "system",
|
||||
"title": f"System — {info.get('hostname', server.name)}",
|
||||
"icon": "monitor",
|
||||
"body": {
|
||||
"OS": f"{info.get('os', 'Unknown')} {info.get('os_version', '')}".strip(),
|
||||
"Arch": info.get("architecture", "?"),
|
||||
"CPU": info.get("cpu_model", "?"),
|
||||
"Cores": info.get("cpu_cores", "?"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "processes",
|
||||
"title": "Top processes",
|
||||
"icon": "activity",
|
||||
"body": [
|
||||
{"name": p.get("name"), "cpu": p.get("cpu_percent"), "mem": p.get("memory_percent")}
|
||||
for p in plist[:8]
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
taskbar = [
|
||||
{"id": p.get("pid"), "name": p.get("name", "?")}
|
||||
for p in plist[:6]
|
||||
]
|
||||
|
||||
drives = [
|
||||
{"path": d.get("mountpoint"), "used": d.get("used_percent")}
|
||||
for d in (info.get("disks") or [])
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
"windows": windows,
|
||||
"taskbar": taskbar,
|
||||
"drives": drives,
|
||||
"hostname": info.get("hostname") or server.name,
|
||||
"offline": False,
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "serverkit-gui",
|
||||
"display_name": "ServerKit Agent GUI",
|
||||
"version": "0.1.0",
|
||||
"description": "Agent-powered desktop view for managed servers. Streams live Windows/Linux screenshots when available, with a synthetic desktop fallback for headless hosts.",
|
||||
"author": "Juan Denis",
|
||||
"homepage": "https://github.com/jhd3197/serverkit-gui",
|
||||
"repository": "https://github.com/jhd3197/serverkit-gui",
|
||||
"license": "MIT",
|
||||
"category": "monitoring",
|
||||
"entry_point": "blueprint:gui_bp",
|
||||
"url_prefix": "/api/v1/server-gui",
|
||||
"frontend_entry": "components/ServerGui.jsx",
|
||||
"requires_agent_actions": [
|
||||
"gui:screenshot",
|
||||
"gui:capabilities"
|
||||
],
|
||||
"minimum_agent_version": "0.4.0",
|
||||
"permissions": [
|
||||
"agent.command:gui:screenshot",
|
||||
"agent.command:gui:capabilities"
|
||||
],
|
||||
"contributions": {
|
||||
"widgets": [
|
||||
{
|
||||
"slot": "global",
|
||||
"component": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,98 +1,115 @@
|
||||
"""
|
||||
Connection string codec.
|
||||
|
||||
A connection string is a single pasteable blob the user copies from the
|
||||
panel and pastes into the agent's pairing wizard. It bundles the panel
|
||||
URL + a single-use registration token + an optional expiry, replacing
|
||||
the older flow where the user typed the URL and token into separate
|
||||
fields.
|
||||
A connection string is a single pasteable token the user copies from
|
||||
the panel and pastes into the agent's pairing wizard. It bundles the
|
||||
panel host + a single-use registration token + an optional expiry,
|
||||
replacing the older flow where the user typed the URL and token into
|
||||
separate fields.
|
||||
|
||||
Format: ``sk_conn_v1.<base64url(json_payload)>``
|
||||
Format: ``sk1://<host>[:<port>]/<token>[?exp=<ISO8601>][&insecure=1]``
|
||||
|
||||
The version prefix is intentional — if we ever need to add fields the
|
||||
agent must understand (a different signing scheme, a fleet ID, …) the
|
||||
agent can refuse to decode unknown versions instead of silently
|
||||
mis-parsing. Today's payload is deliberately tiny:
|
||||
|
||||
{
|
||||
"url": "https://panel.example.com",
|
||||
"token": "sk_reg_xxx",
|
||||
"expires_at": "2026-05-08T17:00:00Z" # or null for "never"
|
||||
}
|
||||
The ``sk1://`` scheme self-identifies the format (you can recognise a
|
||||
ServerKit connection string at a glance) and gives us a version lever:
|
||||
any future format change goes out as ``sk2://``. The host is visible up
|
||||
front so the user can sanity-check which panel they're pointing at
|
||||
before pasting. ``insecure=1`` flips the implied scheme from https to
|
||||
http for dev / local-network use; absent it, https is implied.
|
||||
|
||||
Note: the panel never *reads* connection strings — the agent does.
|
||||
``decode`` is exported only for tests and for symmetry, so the codec
|
||||
has a single owner.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
|
||||
VERSION_PREFIX = "sk_conn_v1."
|
||||
SCHEME_PREFIX = "sk1://"
|
||||
|
||||
|
||||
def encode(url: str, token: str, expires_at: Optional[datetime]) -> str:
|
||||
"""Pack the three fields into a single sk_conn_v1.<...> string.
|
||||
"""Pack panel URL + token (+ optional expiry) into a single
|
||||
``sk1://<host>/<token>[?…]`` string.
|
||||
|
||||
expires_at=None means the token has no expiry (panel-side feature
|
||||
knob — the registration token is single-use either way, so a missing
|
||||
expiry is safe).
|
||||
The url's scheme (http vs https) is preserved via the ``insecure``
|
||||
query param: omitted means https, ``insecure=1`` means http. We
|
||||
only allow http/https because those are the only schemes agents
|
||||
speak; a typo'd input fails loudly here rather than producing a
|
||||
broken connection string.
|
||||
"""
|
||||
if not url or not token:
|
||||
raise ValueError("url and token are required")
|
||||
if "/" in token:
|
||||
raise ValueError("token must not contain '/'")
|
||||
|
||||
payload = {
|
||||
"url": url.rstrip("/"),
|
||||
"token": token,
|
||||
"expires_at": expires_at.isoformat() + "Z" if expires_at else None,
|
||||
}
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
encoded = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
return VERSION_PREFIX + encoded
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
raise ValueError(f"url must include scheme and host: {url!r}")
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError(f"url scheme must be http or https: {parsed.scheme!r}")
|
||||
|
||||
host = parsed.netloc
|
||||
|
||||
# Build the query manually to keep ISO timestamps human-readable —
|
||||
# urlencode would percent-encode the colons in `2126-04-10T02:27:24Z`,
|
||||
# which is correct but uglier and harder to eyeball.
|
||||
query_parts = []
|
||||
if expires_at is not None:
|
||||
query_parts.append(f"exp={expires_at.isoformat()}Z")
|
||||
if parsed.scheme == "http":
|
||||
query_parts.append("insecure=1")
|
||||
suffix = ("?" + "&".join(query_parts)) if query_parts else ""
|
||||
|
||||
return f"{SCHEME_PREFIX}{host}/{token}{suffix}"
|
||||
|
||||
|
||||
def decode(s: str) -> dict:
|
||||
"""Reverse of encode. Raises ValueError on any parse / version error.
|
||||
"""Reverse of encode. Raises ValueError on any parse error.
|
||||
|
||||
Returns a dict with keys ``url``, ``token``, ``expires_at`` (the
|
||||
latter as a datetime or None).
|
||||
Returns a dict with keys ``url`` (reconstructed http/https URL),
|
||||
``token``, ``expires_at`` (datetime or None).
|
||||
"""
|
||||
if not isinstance(s, str) or not s.startswith(VERSION_PREFIX):
|
||||
raise ValueError("not a v1 connection string")
|
||||
if not isinstance(s, str):
|
||||
raise ValueError("connection string must be a string")
|
||||
s = s.strip()
|
||||
if not s:
|
||||
raise ValueError("connection string is empty")
|
||||
if not s.startswith(SCHEME_PREFIX):
|
||||
raise ValueError(
|
||||
f"not a sk1 connection string (expected prefix {SCHEME_PREFIX!r})"
|
||||
)
|
||||
|
||||
body = s[len(VERSION_PREFIX):]
|
||||
# urlsafe_b64decode requires correct padding; we strip it on encode
|
||||
# to keep the string short, so re-pad here.
|
||||
pad = "=" * (-len(body) % 4)
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(body + pad)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"invalid base64: {exc}") from exc
|
||||
parsed = urlparse(s)
|
||||
if parsed.scheme != "sk1":
|
||||
raise ValueError(f"unexpected scheme: {parsed.scheme!r}")
|
||||
if not parsed.netloc:
|
||||
raise ValueError("missing host")
|
||||
if not parsed.path or parsed.path == "/":
|
||||
raise ValueError("missing token")
|
||||
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise ValueError(f"invalid json: {exc}") from exc
|
||||
token = parsed.path.lstrip("/")
|
||||
# Defensive: a stray slash in the path means the user mangled the
|
||||
# string or we somehow encoded a token containing a slash. Either
|
||||
# way the safe move is to fail rather than silently truncate.
|
||||
if "/" in token:
|
||||
raise ValueError("token must not contain '/'")
|
||||
|
||||
url = data.get("url")
|
||||
token = data.get("token")
|
||||
if not isinstance(url, str) or not isinstance(token, str):
|
||||
raise ValueError("missing url or token")
|
||||
qs = parse_qs(parsed.query)
|
||||
insecure_raw = qs.get("insecure", [""])[0]
|
||||
scheme = "http" if insecure_raw in ("1", "true", "yes") else "https"
|
||||
url = f"{scheme}://{parsed.netloc}"
|
||||
|
||||
expires_raw = data.get("expires_at")
|
||||
expires_at: Optional[datetime] = None
|
||||
if expires_raw is not None:
|
||||
if not isinstance(expires_raw, str):
|
||||
raise ValueError("expires_at must be ISO 8601 string or null")
|
||||
exp_raw = qs.get("exp", [""])[0]
|
||||
if exp_raw:
|
||||
# Tolerate trailing Z (UTC marker) which datetime.fromisoformat
|
||||
# didn't accept until 3.11.
|
||||
cleaned = expires_raw[:-1] if expires_raw.endswith("Z") else expires_raw
|
||||
# didn't accept until Python 3.11.
|
||||
cleaned = exp_raw[:-1] if exp_raw.endswith("Z") else exp_raw
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(cleaned)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"invalid expires_at: {exc}") from exc
|
||||
raise ValueError(f"invalid exp: {exc}") from exc
|
||||
|
||||
return {"url": url, "token": token, "expires_at": expires_at}
|
||||
@@ -10,7 +10,8 @@ from app.services import connection_string as cs
|
||||
def test_encode_decode_round_trip():
|
||||
expires = datetime(2026, 5, 8, 17, 0, 0)
|
||||
s = cs.encode(url='https://panel.example.com', token='sk_reg_abc', expires_at=expires)
|
||||
assert s.startswith('sk_conn_v1.')
|
||||
assert s.startswith('sk1://')
|
||||
assert s == 'sk1://panel.example.com/sk_reg_abc?exp=2026-05-08T17:00:00Z'
|
||||
|
||||
decoded = cs.decode(s)
|
||||
assert decoded['url'] == 'https://panel.example.com'
|
||||
@@ -19,48 +20,91 @@ def test_encode_decode_round_trip():
|
||||
|
||||
|
||||
def test_encode_strips_trailing_slash_from_url():
|
||||
# urlparse already discards the trailing slash on a host-only URL,
|
||||
# so the reconstructed URL never has one. Lock that in.
|
||||
s = cs.encode(url='https://panel.example.com/', token='t', expires_at=None)
|
||||
assert cs.decode(s)['url'] == 'https://panel.example.com'
|
||||
|
||||
|
||||
def test_encode_with_no_expiry():
|
||||
s = cs.encode(url='https://panel.example.com', token='t', expires_at=None)
|
||||
assert s == 'sk1://panel.example.com/t'
|
||||
decoded = cs.decode(s)
|
||||
assert decoded['expires_at'] is None
|
||||
|
||||
|
||||
def test_encode_preserves_port():
|
||||
s = cs.encode(url='https://panel.example.com:9443', token='t', expires_at=None)
|
||||
assert s == 'sk1://panel.example.com:9443/t'
|
||||
assert cs.decode(s)['url'] == 'https://panel.example.com:9443'
|
||||
|
||||
|
||||
def test_encode_marks_http_as_insecure():
|
||||
# http panels (typically dev / local-network) emit insecure=1 so the
|
||||
# decoder can reconstruct the right scheme. Without this, an http
|
||||
# panel would round-trip as https and the agent would fail TLS.
|
||||
s = cs.encode(url='http://localhost:47927', token='t', expires_at=None)
|
||||
assert s == 'sk1://localhost:47927/t?insecure=1'
|
||||
assert cs.decode(s)['url'] == 'http://localhost:47927'
|
||||
|
||||
|
||||
def test_encode_combines_expiry_and_insecure():
|
||||
expires = datetime(2026, 5, 8, 17, 0, 0)
|
||||
s = cs.encode(url='http://localhost:47927', token='t', expires_at=expires)
|
||||
assert s == 'sk1://localhost:47927/t?exp=2026-05-08T17:00:00Z&insecure=1'
|
||||
decoded = cs.decode(s)
|
||||
assert decoded['url'] == 'http://localhost:47927'
|
||||
assert decoded['expires_at'] == expires
|
||||
|
||||
|
||||
def test_decode_strips_whitespace():
|
||||
# Clipboard pastes pick up trailing newlines all the time.
|
||||
s = cs.encode(url='https://x', token='t', expires_at=None)
|
||||
assert cs.decode('\n ' + s + ' \n')['token'] == 't'
|
||||
|
||||
|
||||
def test_decode_rejects_missing_version_prefix():
|
||||
# Plain base64 without our prefix should be rejected — protects
|
||||
# agents from silently accepting a future format they don't understand.
|
||||
with pytest.raises(ValueError, match='v1'):
|
||||
with pytest.raises(ValueError, match='sk1'):
|
||||
cs.decode('not_a_connection_string')
|
||||
|
||||
|
||||
def test_decode_rejects_unknown_version():
|
||||
with pytest.raises(ValueError, match='v1'):
|
||||
cs.decode('sk_conn_v2.abc')
|
||||
def test_decode_rejects_old_v1_format():
|
||||
# The pre-sk1 base64-blob format must not silently decode — agents
|
||||
# speaking the new protocol need to reject old payloads cleanly so
|
||||
# the user knows to regenerate from a freshly-deployed panel.
|
||||
with pytest.raises(ValueError, match='sk1'):
|
||||
cs.decode('sk_conn_v1.eyJ1cmwiOiJodHRwczovL3gifQ')
|
||||
|
||||
|
||||
def test_decode_rejects_bad_base64():
|
||||
with pytest.raises(ValueError, match='base64'):
|
||||
cs.decode('sk_conn_v1.!!!not_base64!!!')
|
||||
def test_decode_rejects_unknown_scheme():
|
||||
with pytest.raises(ValueError, match='sk1'):
|
||||
cs.decode('sk2://panel.example.com/token')
|
||||
|
||||
|
||||
def test_decode_rejects_missing_fields():
|
||||
import base64
|
||||
import json
|
||||
|
||||
raw = json.dumps({'url': 'https://x'}).encode() # no token
|
||||
body = base64.urlsafe_b64encode(raw).rstrip(b'=').decode()
|
||||
with pytest.raises(ValueError, match='missing url or token'):
|
||||
cs.decode('sk_conn_v1.' + body)
|
||||
def test_decode_rejects_missing_host():
|
||||
with pytest.raises(ValueError, match='host'):
|
||||
cs.decode('sk1:///token')
|
||||
|
||||
|
||||
def test_encode_rejects_empty_inputs():
|
||||
def test_decode_rejects_missing_token():
|
||||
with pytest.raises(ValueError, match='token'):
|
||||
cs.decode('sk1://panel.example.com')
|
||||
with pytest.raises(ValueError, match='token'):
|
||||
cs.decode('sk1://panel.example.com/')
|
||||
|
||||
|
||||
def test_decode_rejects_token_with_path_separator():
|
||||
# Tokens are url-safe by construction (secrets.token_urlsafe), so a
|
||||
# slash in the path means a mangled string — better to fail loudly.
|
||||
with pytest.raises(ValueError, match='/'):
|
||||
cs.decode('sk1://panel.example.com/sk_reg/abc')
|
||||
|
||||
|
||||
def test_decode_rejects_empty():
|
||||
with pytest.raises(ValueError):
|
||||
cs.encode(url='', token='t', expires_at=None)
|
||||
cs.decode('')
|
||||
with pytest.raises(ValueError):
|
||||
cs.encode(url='https://x', token='', expires_at=None)
|
||||
cs.decode(' ')
|
||||
|
||||
|
||||
def test_decode_handles_iso_with_z_suffix():
|
||||
@@ -70,3 +114,25 @@ def test_decode_handles_iso_with_z_suffix():
|
||||
expires = datetime(2026, 5, 8, 17, 0, 0)
|
||||
s = cs.encode(url='https://x', token='t', expires_at=expires)
|
||||
assert cs.decode(s)['expires_at'] == expires
|
||||
|
||||
|
||||
def test_encode_rejects_empty_inputs():
|
||||
with pytest.raises(ValueError):
|
||||
cs.encode(url='', token='t', expires_at=None)
|
||||
with pytest.raises(ValueError):
|
||||
cs.encode(url='https://x', token='', expires_at=None)
|
||||
|
||||
|
||||
def test_encode_rejects_non_http_scheme():
|
||||
with pytest.raises(ValueError, match='http or https'):
|
||||
cs.encode(url='ftp://panel.example.com', token='t', expires_at=None)
|
||||
|
||||
|
||||
def test_encode_rejects_url_without_scheme():
|
||||
with pytest.raises(ValueError, match='scheme and host'):
|
||||
cs.encode(url='panel.example.com', token='t', expires_at=None)
|
||||
|
||||
|
||||
def test_encode_rejects_token_with_slash():
|
||||
with pytest.raises(ValueError, match='/'):
|
||||
cs.encode(url='https://x', token='bad/token', expires_at=None)
|
||||
@@ -25,7 +25,7 @@ def test_create_server_without_name_returns_connection_string(client, auth_heade
|
||||
|
||||
data = resp.get_json()
|
||||
assert 'connection_string' in data
|
||||
assert data['connection_string'].startswith('sk_conn_v1.')
|
||||
assert data['connection_string'].startswith('sk1://')
|
||||
assert data['name'].startswith('Pending pairing (')
|
||||
|
||||
# Token in the connection string matches the registration_token in the response
|
||||
@@ -115,7 +115,7 @@ def test_regenerate_token_returns_connection_string(client, auth_headers, app):
|
||||
)
|
||||
assert regen_resp.status_code == 200
|
||||
body = regen_resp.get_json()
|
||||
assert body['connection_string'].startswith('sk_conn_v1.')
|
||||
assert body['connection_string'].startswith('sk1://')
|
||||
|
||||
new_token = body['registration_token']
|
||||
assert new_token != original_token
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
"version": "1.0.0",
|
||||
"frontend_entry": "",
|
||||
"path": "serverkit-git"
|
||||
},
|
||||
{
|
||||
"name": "serverkit-gui",
|
||||
"slug": "serverkit-gui",
|
||||
"display_name": "ServerKit Agent GUI",
|
||||
"version": "0.1.0",
|
||||
"frontend_entry": "components/ServerGui.jsx",
|
||||
"path": "serverkit-gui",
|
||||
"styles": [
|
||||
"plugins/serverkit-gui/styles/server-gui.css"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import SyntheticDesktop from './SyntheticDesktop.jsx';
|
||||
|
||||
const FRAME_INTERVAL_MS_DEFAULT = 700;
|
||||
const STORAGE_KEY = 'sk-gui:prefs';
|
||||
|
||||
const MODES = {
|
||||
AUTO: 'auto',
|
||||
SCREENSHOT: 'screenshot',
|
||||
SYNTHETIC: 'synthetic',
|
||||
};
|
||||
|
||||
function loadPrefs() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function savePrefs(prefs) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level desktop view. Picks between screenshot streaming and synthetic
|
||||
* desktop based on user preference and what the agent reports.
|
||||
*/
|
||||
export default function ServerGui({ api, serverId }) {
|
||||
const initial = loadPrefs();
|
||||
const [mode, setMode] = useState(initial.mode || MODES.AUTO);
|
||||
const [intervalMs, setIntervalMs] = useState(initial.intervalMs || FRAME_INTERVAL_MS_DEFAULT);
|
||||
const [scale, setScale] = useState(initial.scale ?? 0.75);
|
||||
const [quality, setQuality] = useState(initial.quality ?? 70);
|
||||
const [caps, setCaps] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const baseUrl = `/api/v1/server-gui/${serverId}`;
|
||||
|
||||
// Persist prefs whenever any of them changes.
|
||||
useEffect(() => {
|
||||
savePrefs({ mode, intervalMs, scale, quality });
|
||||
}, [mode, intervalMs, scale, quality]);
|
||||
|
||||
const fetchJson = useCallback(async (path) => {
|
||||
if (api && typeof api.request === 'function') {
|
||||
return api.request(path);
|
||||
}
|
||||
const token = localStorage.getItem('access_token');
|
||||
const r = await fetch(path, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}, [api]);
|
||||
|
||||
// Probe capabilities once — drives auto mode and the toolbar caption.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCaps(null);
|
||||
setError(null);
|
||||
fetchJson(`${baseUrl}/capabilities`)
|
||||
.then(d => { if (!cancelled) setCaps(d); })
|
||||
.catch(e => { if (!cancelled) setError(e.message); });
|
||||
return () => { cancelled = true; };
|
||||
}, [baseUrl, fetchJson]);
|
||||
|
||||
// Resolve the active mode: AUTO defers to capabilities.
|
||||
const effectiveMode = (() => {
|
||||
if (mode === MODES.SCREENSHOT) return MODES.SCREENSHOT;
|
||||
if (mode === MODES.SYNTHETIC) return MODES.SYNTHETIC;
|
||||
if (!caps) return null;
|
||||
return caps.capability === 'none' ? MODES.SYNTHETIC : MODES.SCREENSHOT;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="sk-gui">
|
||||
<Toolbar
|
||||
caps={caps}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
intervalMs={intervalMs}
|
||||
onIntervalChange={setIntervalMs}
|
||||
scale={scale}
|
||||
onScaleChange={setScale}
|
||||
quality={quality}
|
||||
onQualityChange={setQuality}
|
||||
effectiveMode={effectiveMode}
|
||||
/>
|
||||
|
||||
{error && <div className="sk-gui__banner sk-gui__banner--error">{error}</div>}
|
||||
|
||||
{!caps && !error && (
|
||||
<div className="sk-gui__loading">Probing display capability…</div>
|
||||
)}
|
||||
|
||||
{effectiveMode === MODES.SCREENSHOT && caps && (
|
||||
<ScreenshotView
|
||||
baseUrl={baseUrl}
|
||||
fetchJson={fetchJson}
|
||||
intervalMs={intervalMs}
|
||||
scale={scale}
|
||||
quality={quality}
|
||||
/>
|
||||
)}
|
||||
|
||||
{effectiveMode === MODES.SYNTHETIC && (
|
||||
<SyntheticDesktop
|
||||
api={api}
|
||||
serverId={serverId}
|
||||
fetchJson={fetchJson}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
caps, mode, onModeChange,
|
||||
intervalMs, onIntervalChange,
|
||||
scale, onScaleChange,
|
||||
quality, onQualityChange,
|
||||
effectiveMode,
|
||||
}) {
|
||||
const screenshotDisabled = caps && caps.capability === 'none';
|
||||
|
||||
return (
|
||||
<div className="sk-gui__toolbar">
|
||||
<div className="sk-gui__mode">
|
||||
<ModeBtn
|
||||
label="Auto"
|
||||
active={mode === MODES.AUTO}
|
||||
onClick={() => onModeChange(MODES.AUTO)}
|
||||
/>
|
||||
<ModeBtn
|
||||
label="Screenshot"
|
||||
active={mode === MODES.SCREENSHOT}
|
||||
disabled={screenshotDisabled}
|
||||
title={screenshotDisabled ? `Unavailable: ${caps.reason || 'no display'}` : ''}
|
||||
onClick={() => onModeChange(MODES.SCREENSHOT)}
|
||||
/>
|
||||
<ModeBtn
|
||||
label="Synthetic"
|
||||
active={mode === MODES.SYNTHETIC}
|
||||
onClick={() => onModeChange(MODES.SYNTHETIC)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="sk-gui__cap">
|
||||
{caps ? (
|
||||
<>
|
||||
{caps.capability === 'none'
|
||||
? `headless${caps.reason ? ` · ${caps.reason}` : ''}`
|
||||
: `${caps.capability}${caps.resolution ? ` · ${caps.resolution}` : ''}`}
|
||||
{effectiveMode && effectiveMode !== mode && mode === MODES.AUTO && (
|
||||
<span className="sk-gui__cap-active"> → {effectiveMode}</span>
|
||||
)}
|
||||
</>
|
||||
) : '—'}
|
||||
</span>
|
||||
|
||||
{effectiveMode === MODES.SCREENSHOT && (
|
||||
<>
|
||||
<label className="sk-gui__field">
|
||||
Rate
|
||||
<select value={intervalMs} onChange={e => onIntervalChange(Number(e.target.value))}>
|
||||
<option value={2000}>0.5 fps</option>
|
||||
<option value={1000}>1 fps</option>
|
||||
<option value={700}>1.5 fps</option>
|
||||
<option value={500}>2 fps</option>
|
||||
<option value={300}>3 fps</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="sk-gui__field">
|
||||
Scale
|
||||
<select value={scale} onChange={e => onScaleChange(Number(e.target.value))}>
|
||||
<option value={0.5}>50%</option>
|
||||
<option value={0.75}>75%</option>
|
||||
<option value={1}>100%</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="sk-gui__field">
|
||||
Quality
|
||||
<input
|
||||
type="range"
|
||||
min="20"
|
||||
max="95"
|
||||
value={quality}
|
||||
onChange={e => onQualityChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="sk-gui__field-num">{quality}</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeBtn({ label, active, disabled, title, onClick }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`sk-gui__mode-btn ${active ? 'sk-gui__mode-btn--active' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenshotView({ baseUrl, fetchJson, intervalMs, scale, quality }) {
|
||||
const [frame, setFrame] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const inflight = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return undefined;
|
||||
let cancelled = false;
|
||||
const timer = { id: null };
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled || inflight.current) return;
|
||||
inflight.current = true;
|
||||
try {
|
||||
const data = await fetchJson(
|
||||
`${baseUrl}/frame?scale=${scale}&quality=${quality}&format=jpeg`
|
||||
);
|
||||
if (cancelled) return;
|
||||
setFrame(data);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setError(e.message);
|
||||
} finally {
|
||||
inflight.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
timer.id = setInterval(tick, intervalMs);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer.id) clearInterval(timer.id);
|
||||
};
|
||||
}, [baseUrl, fetchJson, intervalMs, scale, quality, paused]);
|
||||
|
||||
const imgSrc = frame
|
||||
? `data:image/${frame.format || 'jpeg'};base64,${frame.image_base64}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="sk-gui__viewport">
|
||||
<button
|
||||
className="sk-gui__viewport-btn"
|
||||
onClick={() => setPaused(p => !p)}
|
||||
>
|
||||
{paused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
{error && <div className="sk-gui__banner sk-gui__banner--error">{error}</div>}
|
||||
{imgSrc ? (
|
||||
<img className="sk-gui__frame" src={imgSrc} alt="Remote desktop frame" />
|
||||
) : (
|
||||
<div className="sk-gui__loading">Waiting for first frame…</div>
|
||||
)}
|
||||
{frame?.captured_at && (
|
||||
<div className="sk-gui__stamp">
|
||||
{frame.width}×{frame.height} · {new Date(frame.captured_at).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import ServerGui from './ServerGui.jsx';
|
||||
|
||||
/**
|
||||
* Floating launcher that appears only on /servers/:id pages.
|
||||
* Opens the streaming desktop in a modal.
|
||||
*
|
||||
* This is a temporary integration until the PluginLoader exposes a
|
||||
* `serverDetailTab` extension point — at which point we'll mount
|
||||
* ServerGui directly as a tab.
|
||||
*/
|
||||
const SERVER_DETAIL_RE = /^\/servers\/([^/]+)(\/[^/]*)?$/;
|
||||
|
||||
export default function ServerGuiLauncher({ api }) {
|
||||
const location = useLocation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const match = SERVER_DETAIL_RE.exec(location.pathname);
|
||||
const serverId = match ? match[1] : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) setOpen(false);
|
||||
}, [serverId]);
|
||||
|
||||
if (!serverId) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="sk-gui-launcher"
|
||||
onClick={() => setOpen(true)}
|
||||
title="Open desktop view"
|
||||
>
|
||||
<DesktopIcon />
|
||||
<span>Desktop</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="sk-gui-modal" onClick={() => setOpen(false)}>
|
||||
<div className="sk-gui-modal__inner" onClick={e => e.stopPropagation()}>
|
||||
<header className="sk-gui-modal__header">
|
||||
<h3>Desktop view</h3>
|
||||
<button
|
||||
className="sk-gui-modal__close"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="sk-gui-modal__body">
|
||||
<ServerGui api={api} serverId={serverId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DesktopIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="4" width="20" height="14" rx="2" />
|
||||
<line x1="8" y1="22" x2="16" y2="22" />
|
||||
<line x1="12" y1="18" x2="12" y2="22" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Fake desktop UI rendered from agent data, used when the host has no
|
||||
* display server. Each "window" is just data the agent already exposes.
|
||||
*/
|
||||
export default function SyntheticDesktop({ api, serverId, fetchJson }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = () => {
|
||||
fetchJson(`/api/v1/server-gui/${serverId}/synthetic`)
|
||||
.then(d => { if (!cancelled) setData(d); })
|
||||
.catch(err => { if (!cancelled) setError(err.message); });
|
||||
};
|
||||
load();
|
||||
const id = setInterval(load, 4000);
|
||||
return () => { cancelled = true; clearInterval(id); };
|
||||
}, [serverId, fetchJson]);
|
||||
|
||||
if (error) return <div className="sk-gui__banner sk-gui__banner--error">{error}</div>;
|
||||
if (!data) return <div className="sk-gui__loading">Loading synthetic desktop…</div>;
|
||||
|
||||
return (
|
||||
<div className="sk-synth">
|
||||
<div className="sk-synth__wallpaper">
|
||||
<div className="sk-synth__hostname">{data.hostname || 'host'}</div>
|
||||
<div className="sk-synth__windows">
|
||||
{data.windows?.map(w => <SynthWindow key={w.id} win={w} />)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sk-synth__taskbar">
|
||||
<span className="sk-synth__start">≡</span>
|
||||
{data.taskbar?.map(t => (
|
||||
<span key={t.id} className="sk-synth__task" title={t.name}>
|
||||
{t.name}
|
||||
</span>
|
||||
))}
|
||||
<span className="sk-synth__clock">
|
||||
{new Date().toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SynthWindow({ win }) {
|
||||
return (
|
||||
<div className="sk-synth__window">
|
||||
<div className="sk-synth__titlebar">
|
||||
<span className="sk-synth__title">{win.title}</span>
|
||||
<span className="sk-synth__controls">— □ ×</span>
|
||||
</div>
|
||||
<div className="sk-synth__body">
|
||||
<WindowBody body={win.body} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowBody({ body }) {
|
||||
if (Array.isArray(body)) {
|
||||
return (
|
||||
<ul className="sk-synth__list">
|
||||
{body.map((row, i) => (
|
||||
<li key={i}>
|
||||
<span>{row.name}</span>
|
||||
<span>{row.cpu != null ? `${row.cpu.toFixed?.(1) ?? row.cpu}% cpu` : ''}</span>
|
||||
<span>{row.mem != null ? `${row.mem.toFixed?.(1) ?? row.mem}% mem` : ''}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (body && typeof body === 'object') {
|
||||
return (
|
||||
<dl className="sk-synth__kv">
|
||||
{Object.entries(body).map(([k, v]) => (
|
||||
<React.Fragment key={k}>
|
||||
<dt>{k}</dt>
|
||||
<dd>{String(v)}</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
return <div>{String(body ?? '')}</div>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Plugin entry — discovered by ServerKit's PluginLoader.
|
||||
// Exports the default widget component used as a global drop-in.
|
||||
//
|
||||
// We currently render a floating launcher that pops the Agent GUI viewer in a
|
||||
// modal scoped to whichever server detail page is open. Embedding inside the
|
||||
// server detail page as a real tab requires a tiny core change to ServerKit
|
||||
// (extension points in PluginLoader); see README "Roadmap".
|
||||
|
||||
import ServerGuiLauncher from './components/ServerGuiLauncher.jsx';
|
||||
import './styles/server-gui.css';
|
||||
|
||||
export default ServerGuiLauncher;
|
||||
|
||||
// Also export the inline tab component so a future PluginLoader extension
|
||||
// point can mount it directly inside ServerDetail.
|
||||
export { default as ServerGuiTab } from './components/ServerGui.jsx';
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "serverkit-gui",
|
||||
"display_name": "ServerKit Agent GUI",
|
||||
"version": "0.1.0",
|
||||
"description": "Agent-powered desktop view for managed servers. Streams live Windows/Linux screenshots when available, with a synthetic desktop fallback for headless hosts.",
|
||||
"author": "Juan Denis",
|
||||
"homepage": "https://github.com/jhd3197/serverkit-gui",
|
||||
"repository": "https://github.com/jhd3197/serverkit-gui",
|
||||
"license": "MIT",
|
||||
"category": "monitoring",
|
||||
"entry_point": "blueprint:gui_bp",
|
||||
"url_prefix": "/api/v1/server-gui",
|
||||
"frontend_entry": "components/ServerGui.jsx",
|
||||
"requires_agent_actions": [
|
||||
"gui:screenshot",
|
||||
"gui:capabilities"
|
||||
],
|
||||
"minimum_agent_version": "0.4.0",
|
||||
"permissions": [
|
||||
"agent.command:gui:screenshot",
|
||||
"agent.command:gui:capabilities"
|
||||
],
|
||||
"contributions": {
|
||||
"widgets": [
|
||||
{
|
||||
"slot": "global",
|
||||
"component": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
/* ServerKit Agent GUI plugin styles.
|
||||
Uses ServerKit's design system variables when available; otherwise falls
|
||||
back to safe defaults so the plugin renders even outside the panel theme. */
|
||||
|
||||
.sk-gui-launcher,
|
||||
.sk-gui-modal,
|
||||
.sk-gui,
|
||||
.sk-synth {
|
||||
--sk-gui-bg: var(--card-bg, #1a1d24);
|
||||
--sk-gui-fg: var(--text-color, #e6e8eb);
|
||||
--sk-gui-muted: var(--text-muted, #8a8f99);
|
||||
--sk-gui-accent: var(--primary-color, #6366f1);
|
||||
--sk-gui-border: var(--border-color, #2a2e36);
|
||||
--sk-gui-radius: 8px;
|
||||
--sk-gui-pad: 12px;
|
||||
}
|
||||
|
||||
.sk-gui-launcher {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 50;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--sk-gui-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.sk-gui-launcher:hover { filter: brightness(1.1); }
|
||||
.sk-gui-launcher svg { display: block; }
|
||||
|
||||
.sk-gui-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.sk-gui-modal__inner {
|
||||
background: var(--sk-gui-bg);
|
||||
color: var(--sk-gui-fg);
|
||||
border-radius: var(--sk-gui-radius);
|
||||
width: min(1280px, 100%);
|
||||
max-height: calc(100vh - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.sk-gui-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--sk-gui-border);
|
||||
}
|
||||
|
||||
.sk-gui-modal__header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.sk-gui-modal__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--sk-gui-muted);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sk-gui-modal__close:hover { color: var(--sk-gui-fg); }
|
||||
|
||||
.sk-gui-modal__body {
|
||||
padding: var(--sk-gui-pad);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sk-gui {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sk-gui-pad);
|
||||
}
|
||||
|
||||
.sk-gui__loading {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--sk-gui-muted);
|
||||
}
|
||||
|
||||
.sk-gui__banner {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sk-gui__banner--info {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: var(--sk-gui-fg);
|
||||
}
|
||||
|
||||
.sk-gui__banner--error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.sk-gui__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.sk-gui__mode {
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
background: var(--sk-gui-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.sk-gui__mode-btn {
|
||||
padding: 4px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--sk-gui-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sk-gui__mode-btn:hover:not(:disabled) { color: var(--sk-gui-fg); }
|
||||
|
||||
.sk-gui__mode-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sk-gui__mode-btn--active {
|
||||
background: var(--sk-gui-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sk-gui__cap-active {
|
||||
color: var(--sk-gui-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sk-gui__viewport-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 2;
|
||||
padding: 4px 10px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sk-gui__viewport-btn:hover { background: rgba(0, 0, 0, 0.8); }
|
||||
|
||||
.sk-gui__cap {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--sk-gui-muted);
|
||||
}
|
||||
|
||||
.sk-gui__btn {
|
||||
padding: 6px 12px;
|
||||
background: var(--sk-gui-border);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--sk-gui-fg);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sk-gui__btn:hover { border-color: var(--sk-gui-accent); }
|
||||
|
||||
.sk-gui__field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--sk-gui-muted);
|
||||
}
|
||||
|
||||
.sk-gui__field select,
|
||||
.sk-gui__field input[type="range"] {
|
||||
background: var(--sk-gui-border);
|
||||
color: var(--sk-gui-fg);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sk-gui__field-num {
|
||||
font-family: ui-monospace, monospace;
|
||||
color: var(--sk-gui-fg);
|
||||
min-width: 22px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sk-gui__viewport {
|
||||
position: relative;
|
||||
background: #000;
|
||||
border-radius: 6px;
|
||||
min-height: 360px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sk-gui__frame {
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 220px);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sk-gui__stamp {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 8px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #cbd5e1;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sk-synth {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 540px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #1e293b, #0f172a);
|
||||
color: var(--sk-gui-fg);
|
||||
}
|
||||
|
||||
.sk-synth__wallpaper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sk-synth__hostname {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: var(--sk-gui-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sk-synth__windows {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.sk-synth__window {
|
||||
background: var(--sk-gui-bg);
|
||||
border: 1px solid var(--sk-gui-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.sk-synth__titlebar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sk-synth__title { font-weight: 600; }
|
||||
.sk-synth__controls {
|
||||
color: var(--sk-gui-muted);
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.sk-synth__body {
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sk-synth__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sk-synth__list li {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--sk-gui-border);
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.sk-synth__list li:last-child { border-bottom: none; }
|
||||
|
||||
.sk-synth__kv {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sk-synth__kv dt { color: var(--sk-gui-muted); }
|
||||
|
||||
.sk-synth__kv dd {
|
||||
margin: 0;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.sk-synth__taskbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border-top: 1px solid var(--sk-gui-border);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sk-synth__start {
|
||||
font-size: 18px;
|
||||
color: var(--sk-gui-accent);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.sk-synth__task {
|
||||
padding: 3px 8px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sk-synth__clock {
|
||||
margin-left: auto;
|
||||
color: var(--sk-gui-muted);
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
@@ -8,6 +8,14 @@ export default defineConfig(({ mode }) => {
|
||||
const frontendPort = Number(env.SERVERKIT_FRONTEND_PORT) || 41921
|
||||
const apiTarget = (env.VITE_API_URL || 'http://localhost:47927/api/v1').replace(/\/api\/v1\/?$/, '')
|
||||
|
||||
const rawAllowedHosts = (env.VITE_ALLOWED_HOSTS || '').trim()
|
||||
let allowedHosts
|
||||
if (rawAllowedHosts === 'all') {
|
||||
allowedHosts = true
|
||||
} else if (rawAllowedHosts) {
|
||||
allowedHosts = rawAllowedHosts.split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
@@ -22,6 +30,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
server: {
|
||||
port: frontendPort,
|
||||
...(allowedHosts !== undefined ? { allowedHosts } : {}),
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: apiTarget,
|
||||
|
||||
Reference in new issue
Block a user