Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion cmd/opensave-app/frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -258,5 +258,12 @@
"transfers.empty": "Nothing has moved between your devices since OpenSave started.",
"transfers.now": "Now",
"transfers.recently": "Recently",
"transfers.readError": "Couldn't read transfers: {error}"
"transfers.readError": "Couldn't read transfers: {error}",
"devices.linkLan": "home network",
"devices.linkVpn": "VPN (e.g. Tailscale)",
"devices.linkRelay": "internet relay",
"devices.linkInternet": "internet",
"devices.linkUnmeasured": "{kind} · speed not measured yet",
"devices.linkMeasured": "{kind} · {rate} (measured {when})",
"devices.linkTitle": "Saves go to the fastest connections first, and a device that is behind takes the newer save from the fastest device that has it."
}
9 changes: 8 additions & 1 deletion cmd/opensave-app/frontend/src/locales/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -225,5 +225,12 @@
"transfers.empty": "自 OpenSave 启动以来,设备之间还没有传输过任何内容。",
"transfers.now": "进行中",
"transfers.recently": "最近",
"transfers.readError": "无法读取传输记录:{error}"
"transfers.readError": "无法读取传输记录:{error}",
"devices.linkLan": "家庭网络",
"devices.linkVpn": "VPN(如 Tailscale)",
"devices.linkRelay": "互联网中继",
"devices.linkInternet": "互联网",
"devices.linkUnmeasured": "{kind} · 尚未测速",
"devices.linkMeasured": "{kind} · {rate}(测于 {when})",
"devices.linkTitle": "存档会优先通过最快的连接发送;落后的设备会从拥有较新存档的设备中最快的那台获取。"
}
9 changes: 8 additions & 1 deletion cmd/opensave-app/frontend/src/locales/zh-Hant.json
Original file line number Diff line number Diff line change
Expand Up @@ -225,5 +225,12 @@
"transfers.empty": "自 OpenSave 啟動以來,裝置之間還沒有傳輸過任何內容。",
"transfers.now": "進行中",
"transfers.recently": "最近",
"transfers.readError": "無法讀取傳輸記錄:{error}"
"transfers.readError": "無法讀取傳輸記錄:{error}",
"devices.linkLan": "家用網路",
"devices.linkVpn": "VPN(如 Tailscale)",
"devices.linkRelay": "網際網路中繼",
"devices.linkInternet": "網際網路",
"devices.linkUnmeasured": "{kind} · 尚未測速",
"devices.linkMeasured": "{kind} · {rate}(測於 {when})",
"devices.linkTitle": "存檔會優先透過最快的連線傳送;落後的裝置會從擁有較新存檔的裝置中最快的那臺取得。"
}
16 changes: 16 additions & 0 deletions cmd/opensave-app/frontend/src/views/Devices.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import Globe from 'lucide-svelte/icons/globe';
import Network from 'lucide-svelte/icons/network';
import Download from 'lucide-svelte/icons/download';
import { t } from '../lib/i18n.js';

export let params = {};

Expand Down Expand Up @@ -76,6 +77,16 @@
};

const fmtTime = (t) => (t ? new Date(t).toLocaleString() : 'never');

// How this device reaches the peer, and how fast it has found that to be.
// linkLabel is given $t, so the line follows a change of language.
const linkKinds = { lan: 'devices.linkLan', vpn: 'devices.linkVpn', relay: 'devices.linkRelay', internet: 'devices.linkInternet' };
const fmtRate = (b) => (b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB/s' : Math.max(1, Math.round(b / 1024)) + ' KB/s');
function linkLabel(link, tr) {
const kind = linkKinds[link.kind] ? tr(linkKinds[link.kind]) : link.kind;
if (!link.bytesPerSec) return tr('devices.linkUnmeasured', { kind });
return tr('devices.linkMeasured', { kind, rate: fmtRate(link.bytesPerSec), when: fmtTime(link.measuredMs) });
}
</script>

<div class="head">
Expand Down Expand Up @@ -105,6 +116,11 @@
· last synced {fmtTime(peer.lastSynced)}
{#if peer.appVersion}· OpenSave {peer.appVersion}{/if}
</div>
{#if peer.link}
<div class="peer-meta" title={$t('devices.linkTitle')}>
{linkLabel(peer.link, $t)}
</div>
{/if}
<!-- Per device, next to how it is reached, because that is the
context the answer depends on. -->
<div class="peer-protection">
Expand Down
59 changes: 59 additions & 0 deletions e2e/batch_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package e2e

import (
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"

"github.com/opensave/opensave/internal/p2p"
"github.com/opensave/opensave/internal/p2p/syncengine"
"github.com/opensave/opensave/testutil"
)

// TestBench_ManySmallFiles times pulling a save of many small files between
// two real daemons, batched and file by file. Opt-in, because it is a
// measurement rather than a check: OPENSAVE_BENCH_FILES=5000 go test ./e2e -run TestBench -v
func TestBench_ManySmallFiles(t *testing.T) {
n, _ := strconv.Atoi(os.Getenv("OPENSAVE_BENCH_FILES"))
if n <= 0 {
t.Skip("set OPENSAVE_BENCH_FILES to run")
}
for _, mode := range []struct {
name string
proto int
}{{"batched", syncengine.ProtoBatchFiles}, {"file-by-file", syncengine.ProtoMultiRoot}} {
t.Run(mode.name, func(t *testing.T) {
restore := p2p.SetServedProto(mode.proto)
defer p2p.SetServedProto(restore)

a := testutil.NewTestDaemon(t, "Bench-A")
b := testutil.NewTestDaemon(t, "Bench-B")
a.PairWith(b)
chunk := strings.Repeat("x", 3500) // a Project Zomboid map chunk is ~3.5KB
for i := 0; i < n; i++ {
a.WriteSave(fmt.Sprintf("map/map_%d_%d.bin", i/100, i%100), chunk+strconv.Itoa(i))
}
restoreSyncOnTrack := suppressSyncOnTrack(a, b)
gameID := a.TrackGame("Bench")
b.API(http.MethodPost, "/api/games", map[string]string{"name": "Bench", "savePath": b.SaveDir}, nil)
restoreSyncOnTrack()

start := time.Now()
b.API(http.MethodPost, "/api/games/"+gameID+"/sync", nil, nil)
count := func() int {
m, _ := filepath.Glob(filepath.Join(b.SaveDir, "map", "*.bin"))
return len(m)
}
if !testutil.WaitFor(60*time.Minute, func() bool { return count() >= n }) {
t.Fatalf("only %d of %d files arrived", count(), n)
}
el := time.Since(start)
t.Logf("%s: %d files in %s = %.2f ms/file", mode.name, n, el.Round(time.Millisecond), float64(el.Microseconds())/1000/float64(n))
})
}
}
53 changes: 53 additions & 0 deletions e2e/presence_timing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package e2e

import (
"os"
"testing"
"time"

"github.com/opensave/opensave/testutil"
)

// TestPresence_GoneDeviceShowsOffline measures how long a device that went
// away keeps being shown online. Opt-in measurement:
// OPENSAVE_PRESENCE_TIMING=1 go test ./e2e -run TestPresence_ -v
func TestPresence_GoneDeviceShowsOffline(t *testing.T) {
if os.Getenv("OPENSAVE_PRESENCE_TIMING") == "" {
t.Skip("set OPENSAVE_PRESENCE_TIMING to run")
}
a := testutil.NewTestDaemon(t, "Presence-A")
b := testutil.NewTestDaemon(t, "Presence-B")
a.PairWith(b)
bID := b.NodeID()

status := func() string {
p, err := a.Daemon.Store.GetPeer(bID)
if err != nil {
return "?"
}
return p.Status
}
if !testutil.WaitFor(60*time.Second, func() bool { return status() == "online" }) {
t.Fatalf("B never showed online on A: %q", status())
}

gone := time.Now()
b.Server.Stop()
b.Daemon.Stop()

last := status()
for time.Since(gone) < 10*time.Minute {
if s := status(); s != last {
t.Logf("%6.1fs after B stopped: %s -> %s", time.Since(gone).Seconds(), last, s)
last = s
}
if last == "offline" {
break
}
time.Sleep(500 * time.Millisecond)
}
t.Logf("B shown online for %.1fs after it stopped", time.Since(gone).Seconds())
if last != "offline" {
t.Fatalf("B still %q 10 minutes after it stopped", last)
}
}
7 changes: 6 additions & 1 deletion internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,17 @@ func (s *Server) peersPayload() map[string]any {
AppVersion string `json:"appVersion,omitempty"`
BuildTimeMs int64 `json:"buildTimeMs,omitempty"`
HasNewerBuild bool `json:"hasNewerBuild,omitempty"`
// Link is how fast this device has found its connection to it,
// and what kind of address it is reached on: what decides the
// order devices are synced in.
Link syncengine.LinkStat `json:"link"`
// What actually protects traffic with this device. Sent per peer
// rather than described once in the interface, because the answer
// differs per pairing and the reader cannot work out which case
// they are in from a general statement.
p2p.PeerProtection
}{Peer: p, PeerProtection: s.Daemon.P2P.PeerProtection(p)}
}{Peer: p, PeerProtection: s.Daemon.P2P.PeerProtection(p),
Link: s.Daemon.P2P.Sync.Link(syncengine.Peer{ID: p.ID, Name: p.Name, Address: p.Address, Port: p.Port, IsWan: p.Address == "relay"})}
if b, ok := builds[p.ID]; ok {
entry.AppVersion = b.AppVersion
entry.BuildTimeMs = b.BuildTimeMs
Expand Down
Loading
Loading