Add a local web dashboard and "share web"
Daemon now also runs a plain HTTP server bound to 127.0.0.1 only (never the network), serving an embedded vanilla HTML/CSS/JS page plus a small JSON API (status/join/stop/leave/restart) that calls the same internal methods the Unix-socket protocol already uses. No frontend framework or build step — the page is embedded straight into the binary via go:embed, keeping distribution a single file. "share web" ensures the daemon is running, asks it (a new "web" control action) for the dashboard's address, and opens it in the default browser (open/xdg-open), falling back to printing the URL if that fails. RoomStatus/FileStatus gained JSON tags for clean camelCase output, and RoomStatus now carries RelayAddress so the dashboard can show it.
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -56,10 +57,14 @@ USAGE EXAMPLES:
|
|||||||
Restart (or resume a paused) room on its own:
|
Restart (or resume a paused) room on its own:
|
||||||
share restart secret-code
|
share restart secret-code
|
||||||
|
|
||||||
|
Open the local web dashboard in your browser:
|
||||||
|
share web
|
||||||
|
|
||||||
COMMANDS:
|
COMMANDS:
|
||||||
send seed file(s) into a room (see options with: share send -h)
|
send seed file(s) into a room (see options with: share send -h)
|
||||||
relay start your own relay (optional; see options with: share relay -h)
|
relay start your own relay (optional; see options with: share relay -h)
|
||||||
status show the rooms the background daemon knows about
|
status show the rooms the background daemon knows about
|
||||||
|
web open the local web dashboard in your browser
|
||||||
stop stop the daemon, or pause just one room if given a room code
|
stop stop the daemon, or pause just one room if given a room code
|
||||||
leave forget one specific room for good
|
leave forget one specific room for good
|
||||||
restart restart the daemon, or just one room if given a room code
|
restart restart the daemon, or just one room if given a room code
|
||||||
@@ -105,6 +110,8 @@ func Run() error {
|
|||||||
return runRestart(splice(args, i))
|
return runRestart(splice(args, i))
|
||||||
case "status":
|
case "status":
|
||||||
return runStatus(splice(args, i))
|
return runStatus(splice(args, i))
|
||||||
|
case "web":
|
||||||
|
return runWeb(splice(args, i))
|
||||||
case "daemon":
|
case "daemon":
|
||||||
return runDaemon(splice(args, i))
|
return runDaemon(splice(args, i))
|
||||||
}
|
}
|
||||||
@@ -384,6 +391,48 @@ func runStatus(args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runWeb(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("web", flag.ExitOnError)
|
||||||
|
fs.Usage = func() {
|
||||||
|
fmt.Fprintln(os.Stderr, "share web [options]")
|
||||||
|
fs.PrintDefaults()
|
||||||
|
}
|
||||||
|
debug := fs.Bool("debug", false, "toggle debug mode")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := daemon.EnsureRunning(*debug); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
addr, err := daemon.WebAddr()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
url := "http://" + addr + "/"
|
||||||
|
|
||||||
|
if err := openBrowser(url); err != nil {
|
||||||
|
log.Debugf("could not open a browser automatically: %v", err)
|
||||||
|
fmt.Println(url)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Opened %s\n", url)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openBrowser launches the OS's default browser at url. There's no
|
||||||
|
// portable stdlib way to do this — each OS has its own launcher command.
|
||||||
|
func openBrowser(url string) error {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
return exec.Command("open", url).Start()
|
||||||
|
case "linux":
|
||||||
|
return exec.Command("xdg-open", url).Start()
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("don't know how to open a browser on %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func runDaemon(args []string) error {
|
func runDaemon(args []string) error {
|
||||||
fs := flag.NewFlagSet("daemon", flag.ExitOnError)
|
fs := flag.NewFlagSet("daemon", flag.ExitOnError)
|
||||||
fs.Usage = func() {
|
fs.Usage = func() {
|
||||||
|
|||||||
@@ -157,6 +157,19 @@ func Status() ([]swarm.RoomStatus, error) {
|
|||||||
return resp.Rooms, nil
|
return resp.Rooms, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebAddr returns the address (host:port) of the running daemon's local
|
||||||
|
// web dashboard.
|
||||||
|
func WebAddr() (string, error) {
|
||||||
|
if !IsRunning() {
|
||||||
|
return "", fmt.Errorf("daemon is not running")
|
||||||
|
}
|
||||||
|
resp, err := call(request{Action: "web"})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return resp.WebAddr, nil
|
||||||
|
}
|
||||||
|
|
||||||
func call(req request) (response, error) {
|
func call(req request) (response, error) {
|
||||||
path, err := socketPath()
|
path, err := socketPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import "github.com/tzgyn/share/src/swarm"
|
|||||||
// request/response are the only vocabulary spoken over the control
|
// request/response are the only vocabulary spoken over the control
|
||||||
// socket between a CLI invocation and the background daemon.
|
// socket between a CLI invocation and the background daemon.
|
||||||
type request struct {
|
type request struct {
|
||||||
Action string `json:"action"` // "join", "stop-room", "leave", "restart-room", "stop", or "status"
|
Action string `json:"action"` // "join", "stop-room", "leave", "restart-room", "stop", "status", or "web"
|
||||||
Join *joinRequest `json:"join,omitempty"`
|
Join *joinRequest `json:"join,omitempty"`
|
||||||
Code string `json:"code,omitempty"` // "stop-room"/"leave"/"restart-room" only
|
Code string `json:"code,omitempty"` // "stop-room"/"leave"/"restart-room" only
|
||||||
}
|
}
|
||||||
@@ -26,4 +26,5 @@ type response struct {
|
|||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Rooms []swarm.RoomStatus `json:"rooms,omitempty"` // "status" only
|
Rooms []swarm.RoomStatus `json:"rooms,omitempty"` // "status" only
|
||||||
|
WebAddr string `json:"webAddr,omitempty"` // "web" only
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ type server struct {
|
|||||||
// completion goroutine (started in join) knows what to do next.
|
// completion goroutine (started in join) knows what to do next.
|
||||||
leaving map[string]bool
|
leaving map[string]bool
|
||||||
restarting map[string]bool
|
restarting map[string]bool
|
||||||
|
|
||||||
|
// webAddr is set once, before the Accept loop starts, and never
|
||||||
|
// written again — safe to read from any goroutine without a lock.
|
||||||
|
webAddr string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve runs the daemon: it listens on the control socket and services
|
// Serve runs the daemon: it listens on the control socket and services
|
||||||
@@ -61,6 +65,14 @@ func Serve() error {
|
|||||||
}
|
}
|
||||||
log.Infof("daemon listening on %s", path)
|
log.Infof("daemon listening on %s", path)
|
||||||
|
|
||||||
|
webAddr, err := s.startWebServer()
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("could not start web dashboard: %v", err)
|
||||||
|
} else {
|
||||||
|
s.webAddr = webAddr
|
||||||
|
log.Infof("web dashboard on http://%s/", webAddr)
|
||||||
|
}
|
||||||
|
|
||||||
// Resuming persisted rooms happens in the background, not before the
|
// Resuming persisted rooms happens in the background, not before the
|
||||||
// Accept loop below: joining a room can block for a while (e.g. its
|
// Accept loop below: joining a room can block for a while (e.g. its
|
||||||
// relay is slow or unreachable), and this socket already looks
|
// relay is slow or unreachable), and this socket already looks
|
||||||
@@ -164,6 +176,13 @@ func (s *server) handleConn(conn net.Conn) (shutdown bool) {
|
|||||||
case "status":
|
case "status":
|
||||||
respond(conn, response{OK: true, Rooms: s.status()})
|
respond(conn, response{OK: true, Rooms: s.status()})
|
||||||
return false
|
return false
|
||||||
|
case "web":
|
||||||
|
if s.webAddr == "" {
|
||||||
|
respond(conn, response{OK: false, Error: "web dashboard is not running"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
respond(conn, response{OK: true, WebAddr: s.webAddr})
|
||||||
|
return false
|
||||||
default:
|
default:
|
||||||
respond(conn, response{OK: false, Error: fmt.Sprintf("unknown action %q", req.Action)})
|
respond(conn, response{OK: false, Error: fmt.Sprintf("unknown action %q", req.Action)})
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
log "github.com/tzgyn/share/src/logger"
|
||||||
|
"github.com/tzgyn/share/src/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed webui/index.html webui/app.js
|
||||||
|
var webUI embed.FS
|
||||||
|
|
||||||
|
// startWebServer starts the local dashboard: a plain HTTP server bound to
|
||||||
|
// 127.0.0.1 only (never the network — this controls file transfers, so it
|
||||||
|
// must never be reachable by anything but the same machine) on an
|
||||||
|
// OS-assigned free port. It talks to this same server's state directly,
|
||||||
|
// not through the Unix socket — it's just another transport for the same
|
||||||
|
// join/stop/leave/restart/status operations.
|
||||||
|
func (s *server) startWebServer() (string, error) {
|
||||||
|
assets, err := fs.Sub(webUI, "webui")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("could not start web dashboard: %w", err)
|
||||||
|
}
|
||||||
|
addr := l.Addr().String()
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/", http.FileServer(http.FS(assets)))
|
||||||
|
mux.HandleFunc("/api/status", s.handleAPIStatus)
|
||||||
|
mux.HandleFunc("/api/join", s.handleAPIJoin)
|
||||||
|
mux.HandleFunc("/api/stop", s.handleAPIRoomAction(s.stopRoom))
|
||||||
|
mux.HandleFunc("/api/leave", s.handleAPIRoomAction(s.leave))
|
||||||
|
mux.HandleFunc("/api/restart", s.handleAPIRoomAction(s.restartRoom))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := http.Serve(l, mux); err != nil {
|
||||||
|
log.Debugf("web dashboard stopped: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return addr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleAPIStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, s.status())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleAPIJoin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req joinRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, f := range req.Files {
|
||||||
|
if !filepath.IsAbs(f) {
|
||||||
|
http.Error(w, fmt.Sprintf("file path must be absolute: %s", f), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.OutDir != "" && !filepath.IsAbs(req.OutDir) {
|
||||||
|
http.Error(w, fmt.Sprintf("output folder must be absolute: %s", req.OutDir), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Code == "" {
|
||||||
|
req.Code = utils.GetRandomName()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.join(req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"code": req.Code})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAPIRoomAction adapts a single-room server method (which all share
|
||||||
|
// the same "code in, error out" shape) into an HTTP handler.
|
||||||
|
func (s *server) handleAPIRoomAction(action func(code string) error) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := action(body.Code); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
log.Debugf("could not write JSON response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
async function refresh() {
|
||||||
|
const res = await fetch('/api/status');
|
||||||
|
const rooms = await res.json();
|
||||||
|
const container = document.getElementById('rooms');
|
||||||
|
if (!rooms || rooms.length === 0) {
|
||||||
|
container.innerHTML = '<p>No rooms.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = rooms.map(roomHTML).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomHTML(r) {
|
||||||
|
const badge = r.stopped
|
||||||
|
? '<span class="badge stopped">stopped</span>'
|
||||||
|
: `<span class="badge running">running · ${r.peers} peer${r.peers === 1 ? '' : 's'}</span>`;
|
||||||
|
const files = (r.files || []).map(f => `
|
||||||
|
<div class="file">
|
||||||
|
<span>${escapeHTML(f.name)} (${formatBytes(f.size)})</span>
|
||||||
|
<span>${f.haveChunks}/${f.totalChunks} chunks${f.complete ? ' · complete' : ''}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
return `
|
||||||
|
<div class="room">
|
||||||
|
<div class="room-header">
|
||||||
|
<span class="code">${escapeHTML(r.code)}</span>
|
||||||
|
${badge}
|
||||||
|
</div>
|
||||||
|
<div class="relay">relay: ${escapeHTML(r.relayAddress)}</div>
|
||||||
|
${files}
|
||||||
|
<div class="actions">
|
||||||
|
${r.stopped
|
||||||
|
? `<button onclick="act('restart','${escapeAttr(r.code)}')">Restart</button>`
|
||||||
|
: `<button onclick="act('stop','${escapeAttr(r.code)}')">Stop</button>`}
|
||||||
|
<button onclick="act('leave','${escapeAttr(r.code)}')">Leave</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function act(action, code) {
|
||||||
|
const res = await fetch('/api/' + action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Could not ' + action + ': ' + await res.text());
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('joinForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const files = document.getElementById('files').value
|
||||||
|
.split('\n').map(s => s.trim()).filter(Boolean);
|
||||||
|
const body = {
|
||||||
|
code: document.getElementById('code').value.trim(),
|
||||||
|
relay: document.getElementById('relay').value.trim(),
|
||||||
|
files,
|
||||||
|
out: document.getElementById('out').value.trim(),
|
||||||
|
oneTime: document.getElementById('oneTime').checked,
|
||||||
|
};
|
||||||
|
const res = await fetch('/api/join', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Could not join: ' + await res.text());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.code) {
|
||||||
|
alert('Joined room: ' + result.code);
|
||||||
|
}
|
||||||
|
document.getElementById('joinForm').reset();
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeHTML(s) {
|
||||||
|
return (s || '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(s) {
|
||||||
|
return (s || '').replace(/'/g, "\\'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(n) {
|
||||||
|
if (n < 1024) return n + ' B';
|
||||||
|
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||||
|
let i = -1;
|
||||||
|
do {
|
||||||
|
n /= 1024;
|
||||||
|
i++;
|
||||||
|
} while (n >= 1024 && i < units.length - 1);
|
||||||
|
return n.toFixed(1) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 2000);
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>share</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
|
||||||
|
h1 { font-size: 1.25rem; }
|
||||||
|
h3 { font-size: 1rem; margin-top: 0; }
|
||||||
|
.room { border: 1px solid #ddd; border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 0.75rem; }
|
||||||
|
.room-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.code { font-weight: 600; font-family: ui-monospace, monospace; }
|
||||||
|
.badge { font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 999px; white-space: nowrap; }
|
||||||
|
.badge.running { background: #dcf5e0; color: #14631f; }
|
||||||
|
.badge.stopped { background: #f2f2f2; color: #666; }
|
||||||
|
.relay { font-size: 0.85rem; color: #777; margin-top: 0.3rem; }
|
||||||
|
.file { display: flex; justify-content: space-between; font-size: 0.9rem; color: #444; margin-top: 0.4rem; gap: 1rem; }
|
||||||
|
.file span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.actions { margin-top: 0.6rem; }
|
||||||
|
.actions button { margin-right: 0.4rem; }
|
||||||
|
form.join { border: 1px dashed #ccc; border-radius: 8px; padding: 1rem; margin-top: 1.5rem; }
|
||||||
|
input, textarea { width: 100%; box-sizing: border-box; padding: 0.4rem; margin: 0.2rem 0 0.6rem; font: inherit; }
|
||||||
|
label { font-size: 0.85rem; color: #555; }
|
||||||
|
.checkbox-row { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.8rem; }
|
||||||
|
.checkbox-row input { width: auto; margin: 0; }
|
||||||
|
.checkbox-row label { margin: 0; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>share</h1>
|
||||||
|
<div id="rooms"></div>
|
||||||
|
|
||||||
|
<form class="join" id="joinForm">
|
||||||
|
<h3>Join or seed a room</h3>
|
||||||
|
<label>Room code (leave empty to generate one when seeding)</label>
|
||||||
|
<input id="code" placeholder="e.g. 1234-apple-tree-monkey">
|
||||||
|
|
||||||
|
<label>Relay address</label>
|
||||||
|
<input id="relay" placeholder="127.0.0.1:9009">
|
||||||
|
|
||||||
|
<label>Files to seed — one absolute path per line (leave empty to just receive)</label>
|
||||||
|
<textarea id="files" rows="3" placeholder="/Users/me/file.txt"></textarea>
|
||||||
|
|
||||||
|
<label>Output folder (for receiving)</label>
|
||||||
|
<input id="out" placeholder=".">
|
||||||
|
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" id="oneTime">
|
||||||
|
<label for="oneTime">One-time: exit once complete instead of reseeding</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Join</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+10
-10
@@ -143,11 +143,11 @@ func (cl *Client) Wait() error {
|
|||||||
|
|
||||||
// FileStatus summarizes one file's transfer progress within a room.
|
// FileStatus summarizes one file's transfer progress within a room.
|
||||||
type FileStatus struct {
|
type FileStatus struct {
|
||||||
Name string
|
Name string `json:"name"`
|
||||||
Size int64
|
Size int64 `json:"size"`
|
||||||
HaveChunks int64
|
HaveChunks int64 `json:"haveChunks"`
|
||||||
TotalChunks int64
|
TotalChunks int64 `json:"totalChunks"`
|
||||||
Complete bool
|
Complete bool `json:"complete"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoomStatus summarizes one room this client is participating in. A
|
// RoomStatus summarizes one room this client is participating in. A
|
||||||
@@ -155,11 +155,11 @@ type FileStatus struct {
|
|||||||
// but RelayAddress/Files are still filled in from what's on record so
|
// but RelayAddress/Files are still filled in from what's on record so
|
||||||
// the display looks the same whether the room is running or paused.
|
// the display looks the same whether the room is running or paused.
|
||||||
type RoomStatus struct {
|
type RoomStatus struct {
|
||||||
Code string
|
Code string `json:"code"`
|
||||||
RelayAddress string
|
RelayAddress string `json:"relayAddress"`
|
||||||
Stopped bool
|
Stopped bool `json:"stopped"`
|
||||||
Peers int
|
Peers int `json:"peers"`
|
||||||
Files []FileStatus
|
Files []FileStatus `json:"files"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status reports this room's current code, peer count, and per-file
|
// Status reports this room's current code, peer count, and per-file
|
||||||
|
|||||||
Reference in New Issue
Block a user