mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
feat(sync-ui): sync window renderer, snapshots in /tmp/subminer-db-snapshots, docs + changelog
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }]);
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user