feat(sync-ui): sync window renderer, snapshots in /tmp/subminer-db-snapshots, docs + changelog

This commit is contained in:
2026-07-11 18:28:21 -07:00
parent 97aaf44b3c
commit 187437b681
22 changed files with 1657 additions and 88 deletions
+122
View File
@@ -0,0 +1,122 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self';"
/>
<title>SubMiner Sync</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="sync-shell">
<header class="sync-header">
<div class="brand-block">
<div class="brand-title">SubMiner</div>
<div class="brand-subtitle">Immersion Sync</div>
</div>
<div class="header-meta">
<div id="runPill" class="run-pill idle" role="status">Idle</div>
<div id="dbPath" class="db-path" title="Local immersion database"></div>
</div>
</header>
<div class="sync-scroll">
<div id="launcherWarning" class="banner banner-warn hidden"></div>
<section class="panel" aria-labelledby="devicesTitle">
<div class="panel-head">
<h2 id="devicesTitle">Devices</h2>
<div class="auto-interval">
<label for="autoIntervalInput">Auto-sync every</label>
<input id="autoIntervalInput" type="number" min="1" max="1440" step="1" />
<span>min</span>
</div>
</div>
<div id="hostList" class="host-list"></div>
<div id="hostListEmpty" class="empty-note hidden">
No saved devices yet — add one below. Hosts you sync from the command line show up here
too.
</div>
</section>
<section class="panel" aria-labelledby="addDeviceTitle">
<div class="panel-head">
<h2 id="addDeviceTitle">Add a device</h2>
</div>
<div class="add-host-row">
<input
id="newHostInput"
class="text-input mono"
type="text"
placeholder="user@hostname or ssh alias"
spellcheck="false"
/>
<input
id="newLabelInput"
class="text-input"
type="text"
placeholder="Label (optional)"
/>
<button id="testHostButton" class="ghost-button" type="button">Test connection</button>
<button id="addHostButton" class="primary-button" type="button">Add device</button>
</div>
<div id="checkResult" class="check-result hidden"></div>
<details class="setup-help">
<summary>Setup checklist</summary>
<ol>
<li>
Both machines need SSH key access:
<code>ssh-copy-id user@hostname</code> (no password prompts).
</li>
<li>
The remote machine needs SubMiner's command-line launcher installed (<code
>subminer</code
>
on its PATH, or in <code>~/.local/bin</code>).
</li>
<li>
Use <em>Test connection</em> to verify both — it checks SSH and the remote launcher
in one go.
</li>
</ol>
</details>
</section>
<section id="activityPanel" class="panel hidden" aria-labelledby="activityTitle">
<div class="panel-head">
<h2 id="activityTitle">Activity</h2>
<button id="cancelButton" class="danger-button hidden" type="button">Cancel</button>
</div>
<div id="stageList" class="stage-list"></div>
<pre id="remoteOutput" class="remote-output hidden"></pre>
<div id="resultCard" class="result-card hidden"></div>
</section>
<section class="panel" aria-labelledby="snapshotsTitle">
<div class="panel-head">
<h2 id="snapshotsTitle">Snapshots</h2>
<div class="panel-actions">
<button id="mergeFileButton" class="ghost-button" type="button">
Merge a snapshot file…
</button>
<button id="createSnapshotButton" class="primary-button" type="button">
Create snapshot
</button>
</div>
</div>
<p class="panel-hint">
A snapshot is a consistent point-in-time copy of the immersion database — handy as a
manual backup before big merges. Stored in
<span id="snapshotsDir" class="mono"></span>
</p>
<div id="snapshotList" class="snapshot-list"></div>
<div id="snapshotListEmpty" class="empty-note hidden">No snapshots yet.</div>
</section>
</div>
</main>
<script type="module" src="syncui.js"></script>
</body>
</html>
+809
View File
@@ -0,0 +1,809 @@
@font-face {
font-family: 'M PLUS 1';
src: url('./fonts/MPLUS1[wght].ttf') format('truetype');
font-weight: 100 900;
font-display: swap;
}
:root {
/* Catppuccin Macchiato */
--ctp-rosewater: #f4dbd6;
--ctp-flamingo: #f0c6c6;
--ctp-pink: #f5bde6;
--ctp-mauve: #c6a0f6;
--ctp-red: #ed8796;
--ctp-maroon: #ee99a0;
--ctp-peach: #f5a97f;
--ctp-yellow: #eed49f;
--ctp-green: #a6da95;
--ctp-teal: #8bd5ca;
--ctp-sky: #91d7e3;
--ctp-sapphire: #7dc4e4;
--ctp-blue: #8aadf4;
--ctp-lavender: #b7bdf8;
--ctp-text: #cad3f5;
--ctp-subtext1: #b8c0e0;
--ctp-subtext0: #a5adcb;
--ctp-overlay2: #939ab7;
--ctp-overlay1: #8087a2;
--ctp-overlay0: #6e738d;
--ctp-surface2: #5b6078;
--ctp-surface1: #494d64;
--ctp-surface0: #363a4f;
--ctp-base: #24273a;
--ctp-mantle: #1e2030;
--ctp-crust: #181926;
/* Semantic */
--bg: var(--ctp-base);
--panel: rgba(36, 39, 58, 0.85);
--panel-elevated: rgba(54, 58, 79, 0.55);
--line: rgba(110, 115, 141, 0.28);
--line-soft: rgba(110, 115, 141, 0.14);
--text: var(--ctp-text);
--muted: var(--ctp-subtext0);
--faint: var(--ctp-overlay1);
--accent: var(--ctp-blue);
--accent-strong: var(--ctp-lavender);
--highlight: var(--ctp-mauve);
--danger: var(--ctp-red);
--ok: var(--ctp-green);
--warn: var(--ctp-peach);
--shadow: rgba(0, 0, 0, 0.42);
--mono:
'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', ui-monospace, 'DejaVu Sans Mono',
monospace;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background:
radial-gradient(1200px 480px at 85% -10%, rgba(138, 173, 244, 0.09), transparent 60%),
radial-gradient(900px 420px at -10% 110%, rgba(198, 160, 246, 0.07), transparent 55%),
var(--ctp-base);
color: var(--text);
font-family:
'M PLUS 1', 'Avenir Next', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button,
input {
font: inherit;
color: inherit;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background-clip: padding-box;
background-color: rgba(110, 115, 141, 0.35);
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(138, 173, 244, 0.45);
}
.mono {
font-family: var(--mono);
font-size: 0.92em;
}
.hidden {
display: none !important;
}
/* ------------------------------------------------------------------ shell */
.sync-shell {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.sync-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 26px 14px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, rgba(30, 32, 48, 0.92), rgba(30, 32, 48, 0.6));
}
.brand-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-strong);
}
.brand-subtitle {
font-size: 22px;
font-weight: 650;
}
.header-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
min-width: 0;
}
.db-path {
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--mono);
font-size: 11px;
color: var(--faint);
}
.run-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--muted);
}
.run-pill::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ctp-overlay0);
}
.run-pill.running {
color: var(--ctp-sky);
border-color: rgba(145, 215, 227, 0.45);
}
.run-pill.running::before {
background: var(--ctp-sky);
box-shadow: 0 0 8px rgba(145, 215, 227, 0.8);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.sync-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 18px;
padding: 20px 26px 30px;
}
/* ------------------------------------------------------------------ panels */
.panel {
border: 1px solid var(--line);
border-radius: 14px;
background: var(--panel);
box-shadow: 0 10px 28px -18px var(--shadow);
padding: 16px 18px 18px;
animation: panel-in 0.35s ease both;
}
.panel:nth-child(2) {
animation-delay: 0.04s;
}
.panel:nth-child(3) {
animation-delay: 0.08s;
}
.panel:nth-child(4) {
animation-delay: 0.12s;
}
.panel:nth-child(5) {
animation-delay: 0.16s;
}
@keyframes panel-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.panel-head h2 {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ctp-subtext1);
}
.panel-actions {
display: flex;
gap: 8px;
}
.panel-hint {
margin: 0 0 12px;
font-size: 12.5px;
color: var(--muted);
}
.banner {
border-radius: 10px;
padding: 10px 14px;
font-size: 13px;
}
.banner-warn {
border: 1px solid rgba(245, 169, 127, 0.4);
background: rgba(245, 169, 127, 0.08);
color: var(--warn);
}
.empty-note {
padding: 14px 4px 4px;
font-size: 13px;
color: var(--faint);
}
/* ------------------------------------------------------------------ buttons */
.primary-button,
.ghost-button,
.danger-button,
.mini-button {
border-radius: 9px;
border: 1px solid transparent;
padding: 7px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease,
color 0.15s ease,
transform 0.05s ease;
}
.primary-button {
background: var(--accent);
color: var(--ctp-crust);
}
.primary-button:hover:not(:disabled) {
background: var(--accent-strong);
}
.ghost-button {
background: transparent;
border-color: var(--line);
color: var(--ctp-subtext1);
}
.ghost-button:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.danger-button {
background: transparent;
border-color: rgba(237, 135, 150, 0.5);
color: var(--danger);
}
.danger-button:hover:not(:disabled) {
background: rgba(237, 135, 150, 0.12);
}
.mini-button {
padding: 4px 10px;
font-size: 12px;
background: transparent;
border-color: var(--line);
color: var(--muted);
}
.mini-button:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.mini-button.danger:hover:not(:disabled) {
border-color: var(--danger);
color: var(--danger);
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ------------------------------------------------------------------ inputs */
.text-input {
border: 1px solid var(--line);
border-radius: 9px;
background: rgba(24, 25, 38, 0.6);
padding: 8px 12px;
font-size: 13.5px;
color: var(--text);
min-width: 0;
}
.text-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(138, 173, 244, 0.18);
}
.auto-interval {
display: flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
color: var(--muted);
}
.auto-interval input {
width: 64px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(24, 25, 38, 0.6);
padding: 4px 8px;
font-size: 13px;
text-align: right;
}
.auto-interval input:focus {
outline: none;
border-color: var(--accent);
}
/* ------------------------------------------------------------------ hosts */
.host-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.host-card {
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid var(--line-soft);
border-radius: 12px;
background: var(--panel-elevated);
padding: 12px 14px;
}
.host-card.syncing {
border-color: rgba(145, 215, 227, 0.45);
}
.host-top {
display: flex;
align-items: center;
gap: 12px;
}
.host-id {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
gap: 10px;
}
.host-name {
font-family: var(--mono);
font-size: 14.5px;
font-weight: 600;
color: var(--ctp-sapphire);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.host-label {
font-size: 12.5px;
color: var(--muted);
white-space: nowrap;
}
.host-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--faint);
min-width: 0;
}
.host-status .detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: none;
background: var(--ctp-overlay0);
}
.status-dot.success {
background: var(--ok);
box-shadow: 0 0 6px rgba(166, 218, 149, 0.7);
}
.status-dot.error {
background: var(--danger);
box-shadow: 0 0 6px rgba(237, 135, 150, 0.7);
}
.host-controls {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.direction-toggle {
display: inline-flex;
border: 1px solid var(--line);
border-radius: 9px;
overflow: hidden;
}
.direction-toggle button {
border: none;
background: transparent;
padding: 5px 11px;
font-size: 12px;
font-weight: 600;
color: var(--muted);
cursor: pointer;
}
.direction-toggle button + button {
border-left: 1px solid var(--line-soft);
}
.direction-toggle button.active {
background: rgba(138, 173, 244, 0.18);
color: var(--accent);
}
.auto-toggle {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
color: var(--muted);
cursor: pointer;
user-select: none;
}
.auto-toggle input {
accent-color: var(--ctp-mauve);
width: 15px;
height: 15px;
cursor: pointer;
}
.host-actions {
margin-left: auto;
display: flex;
gap: 8px;
}
/* ------------------------------------------------------------------ add host */
.add-host-row {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
}
.check-result {
margin-top: 12px;
border-radius: 10px;
border: 1px solid var(--line);
padding: 10px 14px;
font-size: 13px;
}
.check-result.ok {
border-color: rgba(166, 218, 149, 0.4);
background: rgba(166, 218, 149, 0.07);
color: var(--ok);
}
.check-result.fail {
border-color: rgba(237, 135, 150, 0.4);
background: rgba(237, 135, 150, 0.07);
color: var(--danger);
}
.check-result .sub {
margin-top: 3px;
font-size: 12px;
color: var(--muted);
}
.setup-help {
margin-top: 14px;
font-size: 12.5px;
color: var(--muted);
}
.setup-help summary {
cursor: pointer;
font-weight: 600;
color: var(--ctp-subtext1);
}
.setup-help ol {
margin: 8px 0 0;
padding-left: 20px;
line-height: 1.65;
}
.setup-help code {
font-family: var(--mono);
font-size: 11.5px;
background: rgba(24, 25, 38, 0.7);
border: 1px solid var(--line-soft);
border-radius: 5px;
padding: 1px 6px;
color: var(--ctp-peach);
}
/* ------------------------------------------------------------------ activity */
.stage-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.stage-item {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 4px;
font-size: 13.5px;
position: relative;
}
.stage-item + .stage-item::before {
content: '';
position: absolute;
left: 11px;
top: -8px;
width: 2px;
height: 12px;
background: var(--line);
}
.stage-icon {
width: 16px;
height: 16px;
flex: none;
border-radius: 50%;
border: 2px solid var(--line);
display: grid;
place-items: center;
font-size: 10px;
color: var(--ctp-crust);
}
.stage-item.active .stage-icon {
border-color: var(--ctp-sky);
border-top-color: transparent;
animation: spin 0.9s linear infinite;
}
.stage-item.done .stage-icon {
border-color: var(--ok);
background: var(--ok);
}
.stage-item.done .stage-icon::after {
content: '✓';
font-weight: 900;
}
.stage-item.failed .stage-icon {
border-color: var(--danger);
background: var(--danger);
}
.stage-item.failed .stage-icon::after {
content: '✕';
font-weight: 900;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.stage-item.active {
color: var(--ctp-sky);
}
.stage-item.done {
color: var(--muted);
}
.remote-output {
margin: 12px 0 0;
max-height: 160px;
overflow: auto;
border: 1px solid var(--line-soft);
border-radius: 10px;
background: var(--ctp-crust);
padding: 10px 14px;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.55;
color: var(--ctp-subtext1);
white-space: pre-wrap;
}
.result-card {
margin-top: 14px;
border-radius: 12px;
padding: 14px 16px;
border: 1px solid rgba(166, 218, 149, 0.35);
background: rgba(166, 218, 149, 0.06);
}
.result-card.error {
border-color: rgba(237, 135, 150, 0.4);
background: rgba(237, 135, 150, 0.06);
}
.result-title {
font-size: 14px;
font-weight: 700;
color: var(--ok);
margin-bottom: 8px;
}
.result-card.error .result-title {
color: var(--danger);
}
.result-error-detail {
font-family: var(--mono);
font-size: 12px;
color: var(--ctp-subtext1);
white-space: pre-wrap;
}
.count-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px 16px;
}
.count-cell {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
font-size: 12.5px;
color: var(--muted);
border-bottom: 1px dashed var(--line-soft);
padding-bottom: 3px;
}
.count-cell b {
font-family: var(--mono);
font-size: 14px;
color: var(--text);
}
/* ------------------------------------------------------------------ snapshots */
.snapshot-list {
display: flex;
flex-direction: column;
}
.snapshot-row {
display: flex;
align-items: center;
gap: 14px;
padding: 8px 4px;
border-bottom: 1px solid var(--line-soft);
font-size: 13px;
}
.snapshot-row:last-child {
border-bottom: none;
}
.snapshot-name {
flex: 1;
min-width: 0;
font-family: var(--mono);
font-size: 12.5px;
color: var(--ctp-teal);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.snapshot-meta {
flex: none;
font-size: 12px;
color: var(--faint);
font-family: var(--mono);
}
.snapshot-actions {
display: flex;
gap: 6px;
}
+59
View File
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatBytes, formatRelativeTime, summarizeMergeCounts } from './syncui-format';
test('formatBytes renders human readable sizes', () => {
assert.equal(formatBytes(0), '0 B');
assert.equal(formatBytes(532), '532 B');
assert.equal(formatBytes(2048), '2.0 KB');
assert.equal(formatBytes(5 * 1024 * 1024), '5.0 MB');
assert.equal(formatBytes(1.5 * 1024 * 1024 * 1024), '1.5 GB');
});
test('formatRelativeTime renders past timestamps', () => {
const now = 1_700_000_000_000;
assert.equal(formatRelativeTime(null, now), 'never');
assert.equal(formatRelativeTime(now - 20_000, now), 'just now');
assert.equal(formatRelativeTime(now - 5 * 60_000, now), '5 min ago');
assert.equal(formatRelativeTime(now - 3 * 3_600_000, now), '3 h ago');
assert.equal(formatRelativeTime(now - 2 * 86_400_000, now), '2 d ago');
});
test('summarizeMergeCounts lists only non-zero counts', () => {
const summary = {
sessionsMerged: 3,
sessionsAlreadyPresent: 12,
activeSessionsSkipped: 0,
animeAdded: 1,
videosAdded: 2,
wordsAdded: 0,
kanjiAdded: 0,
subtitleLinesAdded: 450,
telemetryRowsAdded: 0,
eventsAdded: 0,
excludedWordsAdded: 0,
dailyRollupsCopied: 0,
monthlyRollupsCopied: 0,
rollupGroupsRecomputed: 4,
};
const lines = summarizeMergeCounts(summary);
assert.deepEqual(lines, [
{ label: 'Sessions merged', value: 3 },
{ label: 'Already present', value: 12 },
{ label: 'Series added', value: 1 },
{ label: 'Videos added', value: 2 },
{ label: 'Subtitle lines', value: 450 },
{ label: 'Rollups recomputed', value: 4 },
]);
const empty = summarizeMergeCounts({
...summary,
sessionsMerged: 0,
sessionsAlreadyPresent: 0,
animeAdded: 0,
videosAdded: 0,
subtitleLinesAdded: 0,
rollupGroupsRecomputed: 0,
});
assert.deepEqual(empty, [{ label: 'Sessions merged', value: 0 }]);
});
+50
View File
@@ -0,0 +1,50 @@
import type { SyncMergeSummary } from '../shared/sync/sync-events';
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${Math.round(bytes)} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(1)} ${units[unitIndex]}`;
}
export function formatRelativeTime(atMs: number | null, nowMs: number): string {
if (atMs === null) return 'never';
const elapsed = Math.max(0, nowMs - atMs);
if (elapsed < 60_000) return 'just now';
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)} min ago`;
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)} h ago`;
return `${Math.floor(elapsed / 86_400_000)} d ago`;
}
export interface MergeCountLine {
label: string;
value: number;
}
const MERGE_COUNT_FIELDS: Array<{ key: keyof SyncMergeSummary; label: string }> = [
{ key: 'sessionsMerged', label: 'Sessions merged' },
{ key: 'sessionsAlreadyPresent', label: 'Already present' },
{ key: 'activeSessionsSkipped', label: 'Active skipped' },
{ key: 'animeAdded', label: 'Series added' },
{ key: 'videosAdded', label: 'Videos added' },
{ key: 'wordsAdded', label: 'Words added' },
{ key: 'kanjiAdded', label: 'Kanji added' },
{ key: 'subtitleLinesAdded', label: 'Subtitle lines' },
{ key: 'excludedWordsAdded', label: 'Excluded words' },
{ key: 'dailyRollupsCopied', label: 'Daily rollups' },
{ key: 'monthlyRollupsCopied', label: 'Monthly rollups' },
{ key: 'rollupGroupsRecomputed', label: 'Rollups recomputed' },
];
// "Sessions merged" always shows (even at 0, it is the headline number);
// everything else appears only when non-zero.
export function summarizeMergeCounts(summary: SyncMergeSummary): MergeCountLine[] {
return MERGE_COUNT_FIELDS.filter(
(field) => field.key === 'sessionsMerged' || (summary[field.key] ?? 0) > 0,
).map((field) => ({ label: field.label, value: summary[field.key] ?? 0 }));
}
+480
View File
@@ -0,0 +1,480 @@
import type {
SyncDirection,
SyncHostEntry,
SyncUiAPI,
SyncUiCheckResult,
SyncUiProgressPayload,
SyncUiSnapshot,
SyncUiStartResult,
} from '../types/sync-ui';
import { formatBytes, formatRelativeTime, summarizeMergeCounts } from './syncui-format';
declare global {
interface Window {
syncUiAPI: SyncUiAPI;
}
}
const api = window.syncUiAPI;
function el<T extends HTMLElement>(id: string): T {
const node = document.getElementById(id);
if (!node) throw new Error(`Missing element #${id}`);
return node as T;
}
const runPill = el<HTMLDivElement>('runPill');
const dbPathEl = el<HTMLDivElement>('dbPath');
const launcherWarning = el<HTMLDivElement>('launcherWarning');
const hostList = el<HTMLDivElement>('hostList');
const hostListEmpty = el<HTMLDivElement>('hostListEmpty');
const autoIntervalInput = el<HTMLInputElement>('autoIntervalInput');
const newHostInput = el<HTMLInputElement>('newHostInput');
const newLabelInput = el<HTMLInputElement>('newLabelInput');
const testHostButton = el<HTMLButtonElement>('testHostButton');
const addHostButton = el<HTMLButtonElement>('addHostButton');
const checkResultEl = el<HTMLDivElement>('checkResult');
const activityPanel = el<HTMLElement>('activityPanel');
const cancelButton = el<HTMLButtonElement>('cancelButton');
const stageList = el<HTMLDivElement>('stageList');
const remoteOutput = el<HTMLPreElement>('remoteOutput');
const resultCard = el<HTMLDivElement>('resultCard');
const snapshotsDirEl = el<HTMLSpanElement>('snapshotsDir');
const snapshotList = el<HTMLDivElement>('snapshotList');
const snapshotListEmpty = el<HTMLDivElement>('snapshotListEmpty');
const createSnapshotButton = el<HTMLButtonElement>('createSnapshotButton');
const mergeFileButton = el<HTMLButtonElement>('mergeFileButton');
let snapshot: SyncUiSnapshot | null = null;
let activeRunId: number | null = null;
let retryWithForce: (() => void) | null = null;
const DIRECTIONS: Array<{ id: SyncDirection; label: string; title: string }> = [
{ id: 'both', label: '⇄ Two-way', title: 'Merge both databases into each other' },
{ id: 'push', label: '⇡ Push', title: 'Only merge this machine into the device' },
{ id: 'pull', label: '⇣ Pull', title: 'Only merge the device into this machine' },
];
function describeRun(kind: string, host: string | null): string {
if (kind === 'host-sync') return `Syncing with ${host ?? 'device'}`;
if (kind === 'merge') return 'Merging snapshot';
if (kind === 'snapshot') return 'Creating snapshot';
return 'Working';
}
function setRunPill(): void {
const running = snapshot?.run.running ?? false;
runPill.classList.toggle('running', running);
runPill.classList.toggle('idle', !running);
runPill.textContent = running
? describeRun(snapshot!.run.kind ?? '', snapshot!.run.host)
: 'Idle';
cancelButton.classList.toggle('hidden', !running);
}
function clearActivity(): void {
stageList.replaceChildren();
remoteOutput.textContent = '';
remoteOutput.classList.add('hidden');
resultCard.replaceChildren();
resultCard.classList.add('hidden');
}
function markStageStates(state: 'done' | 'failed'): void {
for (const item of stageList.querySelectorAll('.stage-item.active')) {
item.classList.remove('active');
item.classList.add(state);
}
}
function addStage(message: string): void {
markStageStates('done');
const item = document.createElement('div');
item.className = 'stage-item active';
const icon = document.createElement('span');
icon.className = 'stage-icon';
const text = document.createElement('span');
text.textContent = message;
item.append(icon, text);
stageList.appendChild(item);
}
function showResult(ok: boolean, error: string | null): void {
markStageStates(ok ? 'done' : 'failed');
resultCard.classList.remove('hidden');
resultCard.classList.toggle('error', !ok);
const title = document.createElement('div');
title.className = 'result-title';
title.textContent = ok ? 'Sync complete' : 'Sync failed';
resultCard.prepend(title);
if (!ok && error) {
const detail = document.createElement('div');
detail.className = 'result-error-detail';
detail.textContent = error;
resultCard.appendChild(detail);
if (retryWithForce && /--force/.test(error)) {
const retry = document.createElement('button');
retry.className = 'ghost-button';
retry.style.marginTop = '10px';
retry.type = 'button';
retry.textContent = 'Retry with --force (skip the running-app check)';
retry.addEventListener('click', () => {
const action = retryWithForce;
retryWithForce = null;
action?.();
});
resultCard.appendChild(retry);
}
}
}
function appendMergeSummary(
target: 'local' | 'remote',
lines: ReturnType<typeof summarizeMergeCounts>,
): void {
resultCard.classList.remove('hidden');
const heading = document.createElement('div');
heading.className = 'result-title';
heading.textContent = target === 'local' ? 'Merged into this machine' : 'Merged into the device';
const grid = document.createElement('div');
grid.className = 'count-grid';
for (const line of lines) {
const cell = document.createElement('div');
cell.className = 'count-cell';
const label = document.createElement('span');
label.textContent = line.label;
const value = document.createElement('b');
value.textContent = String(line.value);
cell.append(label, value);
grid.appendChild(cell);
}
resultCard.append(heading, grid);
}
function handleProgress(payload: SyncUiProgressPayload): void {
if (payload.runId !== activeRunId) {
activeRunId = payload.runId;
clearActivity();
activityPanel.classList.remove('hidden');
}
const event = payload.event;
if (event.type === 'stage') {
addStage(event.message);
} else if (event.type === 'snapshot-created') {
addStage(`Snapshot written to ${event.path}`);
} else if (event.type === 'remote-output') {
remoteOutput.classList.remove('hidden');
remoteOutput.textContent += event.text;
} else if (event.type === 'merge-summary') {
appendMergeSummary(event.target, summarizeMergeCounts(event.summary));
} else if (event.type === 'result') {
showResult(event.ok, event.error);
void refresh();
}
}
async function startRun(action: () => Promise<SyncUiStartResult>): Promise<void> {
const result = await action();
if (!result.started) {
activityPanel.classList.remove('hidden');
clearActivity();
activeRunId = null;
showResult(false, result.reason ?? 'Could not start the sync.');
return;
}
await refresh();
}
function runHostSync(entry: SyncHostEntry): Promise<SyncUiStartResult> {
retryWithForce = () => {
void startRun(() => api.runSync({ host: entry.host, direction: entry.direction, force: true }));
};
return api.runSync({ host: entry.host, direction: entry.direction });
}
function renderHostCard(entry: SyncHostEntry): HTMLDivElement {
const running = snapshot?.run.running ?? false;
const syncingThis = running && snapshot?.run.host === entry.host;
const card = document.createElement('div');
card.className = `host-card${syncingThis ? ' syncing' : ''}`;
const top = document.createElement('div');
top.className = 'host-top';
const id = document.createElement('div');
id.className = 'host-id';
const name = document.createElement('span');
name.className = 'host-name';
name.textContent = entry.host;
id.appendChild(name);
if (entry.label) {
const label = document.createElement('span');
label.className = 'host-label';
label.textContent = entry.label;
id.appendChild(label);
}
const status = document.createElement('div');
status.className = 'host-status';
const dot = document.createElement('span');
dot.className = `status-dot${entry.lastSyncStatus ? ` ${entry.lastSyncStatus}` : ''}`;
const statusText = document.createElement('span');
statusText.className = 'detail';
const when = formatRelativeTime(entry.lastSyncAtMs, Date.now());
statusText.textContent =
entry.lastSyncStatus === null
? 'never synced'
: `${when}${entry.lastSyncDetail ? `${entry.lastSyncDetail}` : ''}`;
statusText.title = entry.lastSyncDetail ?? '';
status.append(dot, statusText);
top.append(id, status);
const controls = document.createElement('div');
controls.className = 'host-controls';
const toggle = document.createElement('div');
toggle.className = 'direction-toggle';
toggle.setAttribute('role', 'group');
toggle.setAttribute('aria-label', `Sync direction for ${entry.host}`);
for (const direction of DIRECTIONS) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = direction.label;
button.title = direction.title;
button.classList.toggle('active', entry.direction === direction.id);
button.addEventListener('click', () => {
void api.saveHost({ host: entry.host, direction: direction.id }).then(refreshFromState);
});
toggle.appendChild(button);
}
const auto = document.createElement('label');
auto.className = 'auto-toggle';
const autoBox = document.createElement('input');
autoBox.type = 'checkbox';
autoBox.checked = entry.autoSync;
autoBox.addEventListener('change', () => {
void api.saveHost({ host: entry.host, autoSync: autoBox.checked }).then(refreshFromState);
});
const autoText = document.createElement('span');
autoText.textContent = 'Auto-sync';
auto.append(autoBox, autoText);
const actions = document.createElement('div');
actions.className = 'host-actions';
const syncButton = document.createElement('button');
syncButton.type = 'button';
syncButton.className = 'primary-button';
syncButton.textContent = syncingThis ? 'Syncing…' : 'Sync now';
syncButton.disabled = running;
syncButton.addEventListener('click', () => {
void startRun(() => runHostSync(entry));
});
const testButton = document.createElement('button');
testButton.type = 'button';
testButton.className = 'mini-button';
testButton.textContent = 'Test';
testButton.addEventListener('click', () => {
testButton.disabled = true;
testButton.textContent = 'Testing…';
void api
.checkHost(entry.host)
.then((result) => renderCheckResult(result))
.finally(() => {
testButton.disabled = false;
testButton.textContent = 'Test';
});
});
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'mini-button danger';
removeButton.textContent = 'Remove';
removeButton.addEventListener('click', () => {
if (!window.confirm(`Remove ${entry.host} from saved devices?`)) return;
void api.removeHost(entry.host).then(refreshFromState);
});
actions.append(syncButton, testButton, removeButton);
controls.append(toggle, auto, actions);
card.append(top, controls);
return card;
}
function renderCheckResult(result: SyncUiCheckResult): void {
checkResultEl.classList.remove('hidden', 'ok', 'fail');
checkResultEl.classList.add(result.ok ? 'ok' : 'fail');
checkResultEl.replaceChildren();
const headline = document.createElement('div');
headline.textContent = result.ok
? `${result.host} is ready to sync`
: `${result.host} is not ready`;
checkResultEl.appendChild(headline);
const sub = document.createElement('div');
sub.className = 'sub';
if (result.ok) {
sub.textContent = `SSH ok · remote launcher: ${result.remoteCommand ?? '?'}${
result.remoteVersion ? ` (${result.remoteVersion})` : ''
}`;
} else {
sub.textContent = result.error ?? (result.sshOk ? 'Remote launcher missing.' : 'SSH failed.');
}
checkResultEl.appendChild(sub);
}
function renderHosts(): void {
const hosts = snapshot?.hosts.hosts ?? [];
hostList.replaceChildren(...hosts.map(renderHostCard));
hostListEmpty.classList.toggle('hidden', hosts.length > 0);
}
function renderSnapshots(): void {
const files = snapshot?.snapshots ?? [];
snapshotsDirEl.textContent = snapshot?.snapshotsDir ?? '';
snapshotList.replaceChildren(
...files.map((file) => {
const row = document.createElement('div');
row.className = 'snapshot-row';
const name = document.createElement('span');
name.className = 'snapshot-name';
name.textContent = file.name;
name.title = file.path;
const meta = document.createElement('span');
meta.className = 'snapshot-meta';
meta.textContent = `${formatBytes(file.sizeBytes)} · ${new Date(
file.modifiedAtMs,
).toLocaleString()}`;
const actions = document.createElement('div');
actions.className = 'snapshot-actions';
const merge = document.createElement('button');
merge.type = 'button';
merge.className = 'mini-button';
merge.textContent = 'Merge';
merge.title = 'Merge this snapshot into the local database';
merge.disabled = snapshot?.run.running ?? false;
merge.addEventListener('click', () => {
retryWithForce = () => {
void startRun(() => api.mergeSnapshotFile(file.path, true));
};
void startRun(() => api.mergeSnapshotFile(file.path));
});
const reveal = document.createElement('button');
reveal.type = 'button';
reveal.className = 'mini-button';
reveal.textContent = 'Reveal';
reveal.addEventListener('click', () => {
void api.revealSnapshot(file.path);
});
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'mini-button danger';
remove.textContent = 'Delete';
remove.addEventListener('click', () => {
if (!window.confirm(`Delete snapshot ${file.name}?`)) return;
void api.deleteSnapshot(file.path).then(refreshFromState);
});
actions.append(merge, reveal, remove);
row.append(name, meta, actions);
return row;
}),
);
snapshotListEmpty.classList.toggle('hidden', files.length > 0);
}
async function refresh(): Promise<void> {
snapshot = await api.getSnapshot();
dbPathEl.textContent = snapshot.dbPath;
dbPathEl.title = snapshot.dbPath;
if (document.activeElement !== autoIntervalInput) {
autoIntervalInput.value = String(snapshot.hosts.autoSyncIntervalMinutes);
}
launcherWarning.classList.toggle('hidden', snapshot.launcherPath !== null);
if (snapshot.launcherPath === null) {
launcherWarning.textContent =
'The subminer command-line launcher was not found. Install it from SubMiner setup ' +
'(or install bun) — syncing runs through the launcher.';
}
setRunPill();
renderHosts();
renderSnapshots();
createSnapshotButton.disabled = snapshot.run.running;
mergeFileButton.disabled = snapshot.run.running;
}
function refreshFromState(): void {
void refresh();
}
addHostButton.addEventListener('click', () => {
const host = newHostInput.value.trim();
if (!host) {
newHostInput.focus();
return;
}
const label = newLabelInput.value.trim();
void api
.saveHost({ host, label: label || null })
.then(() => {
newHostInput.value = '';
newLabelInput.value = '';
checkResultEl.classList.add('hidden');
return refresh();
})
.catch((error: unknown) => {
renderCheckResult({
host,
sshOk: false,
remoteCommand: null,
remoteVersion: null,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
});
});
newHostInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') addHostButton.click();
});
testHostButton.addEventListener('click', () => {
const host = newHostInput.value.trim();
if (!host) {
newHostInput.focus();
return;
}
testHostButton.disabled = true;
testHostButton.textContent = 'Testing…';
void api
.checkHost(host)
.then((result) => renderCheckResult(result))
.finally(() => {
testHostButton.disabled = false;
testHostButton.textContent = 'Test connection';
});
});
autoIntervalInput.addEventListener('change', () => {
const minutes = Number(autoIntervalInput.value);
if (!Number.isFinite(minutes) || minutes < 1) return;
void api.setAutoSyncInterval(Math.floor(minutes)).then(refreshFromState);
});
cancelButton.addEventListener('click', () => {
void api.cancelRun();
});
createSnapshotButton.addEventListener('click', () => {
retryWithForce = null;
void startRun(() => api.createSnapshot());
});
mergeFileButton.addEventListener('click', () => {
void api.pickSnapshotFile().then((path) => {
if (!path) return;
retryWithForce = () => {
void startRun(() => api.mergeSnapshotFile(path, true));
};
void startRun(() => api.mergeSnapshotFile(path));
});
});
api.onProgress(handleProgress);
api.onStateChanged(refreshFromState);
void refresh();