mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
feat(sync-ui): sync window renderer, snapshots in /tmp/subminer-db-snapshots, docs + changelog
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
type: added
|
||||
area: sync
|
||||
|
||||
- Added a sync window (`subminer sync --ui`, or **Sync Stats & History** in the tray menu) for cross-machine immersion sync: saved devices with per-host direction (two-way/push/pull) and remove, one-click sync with live stage-by-stage progress and merge summaries, connection testing for first-time setup, cancellable runs with a one-click `--force` retry when the running-app guard trips, and manual database snapshots (create/merge/reveal/delete, stored in `/tmp/subminer-db-snapshots/` by default). Hosts with auto-sync enabled are synced in the background on a configurable interval while no mpv session or stats server is writing the database, with results reported as overlay notifications. Hosts synced from the CLI are remembered in `<config dir>/sync-hosts.json` and show up in the window automatically.
|
||||
- Added `subminer sync <host> --check` to test the SSH connection and remote launcher availability without syncing, and `subminer sync --json` for machine-readable NDJSON progress output (the protocol the sync window consumes).
|
||||
@@ -89,6 +89,8 @@ subminer sync macbook --push # merge local data into macbook only
|
||||
subminer sync macbook --pull # merge macbook data into local only
|
||||
subminer sync user@192.168.1.20 # explicit user@host
|
||||
subminer sync macbook --remote-cmd ~/bin/subminer # custom remote launcher path
|
||||
subminer sync macbook --check # test SSH + remote launcher without syncing
|
||||
subminer sync --ui # open the sync window (also in the tray menu)
|
||||
```
|
||||
|
||||
How it works: each side takes a consistent snapshot of its database (`VACUUM INTO`), the snapshots are exchanged over `scp`, and each machine merges the other's snapshot into its own database. The merge is an insert-only union keyed on stable identifiers (session UUIDs, video keys, series title keys, word/kanji identity), so it is safe to re-run at any time — syncing twice changes nothing, and nothing is ever overwritten or summed twice. Lifetime totals and rollup charts are updated incrementally, so history older than the session retention window is preserved on both sides.
|
||||
@@ -106,6 +108,19 @@ subminer sync --merge /tmp/stats.sqlite # merge a snapshot file into the lo
|
||||
|
||||
Unfinished sessions (a crash mid-playback) are skipped until the app finalizes them; they sync on the next run. Word/kanji "known" state from Anki is not part of the database and does not sync — each machine derives it from its own Anki collection.
|
||||
|
||||
`subminer sync <host> --check` verifies a host without touching any data: it probes the SSH connection, locates the remote `subminer` launcher, and reports its version. `--json` switches any sync mode to machine-readable NDJSON progress output (this is what the sync window consumes).
|
||||
|
||||
### Sync window
|
||||
|
||||
`subminer sync --ui` (or **Sync Stats & History** in the tray menu) opens a dedicated window for the same engine:
|
||||
|
||||
- **Devices** — saved hosts with a per-host direction (two-way / push / pull), an auto-sync toggle, last-sync status, and one-click **Sync now** / **Test** / **Remove**. Hosts synced from the command line appear here automatically.
|
||||
- **Add a device** — test SSH + remote launcher availability before saving, with a setup checklist for first-time SSH configuration.
|
||||
- **Activity** — live stage-by-stage progress, remote output, and a merge summary (sessions, words, kanji, rollups) when a run finishes. Runs can be cancelled, and guard failures offer a one-click `--force` retry.
|
||||
- **Snapshots** — create manual database snapshots (stored in `/tmp/subminer-db-snapshots/` by default), merge a snapshot file into the local database, or reveal/delete existing snapshots.
|
||||
|
||||
Hosts with **Auto-sync** enabled are synced in the background on a configurable interval (default every 30 minutes) whenever no mpv session or stats server is using the database; results surface as overlay notifications. Host bookkeeping lives in `<config dir>/sync-hosts.json`.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
@@ -120,51 +135,53 @@ subminer stats -b # start background stats daemon
|
||||
|
||||
## Subcommands
|
||||
|
||||
| Subcommand | Purpose |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login, `--logout`, `--setup`) |
|
||||
| `subminer stats` | Start the stats server (opens the dashboard when `stats.autoOpenBrowser` is on) |
|
||||
| `subminer stats -b` / `-s` | Start/reuse or stop the background stats daemon |
|
||||
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows (`-v` vocab, `-l` lifetime summaries) |
|
||||
| `subminer stats rebuild` / `backfill` | Rebuild or backfill rollup data |
|
||||
| Subcommand | Purpose |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
||||
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login, `--logout`, `--setup`) |
|
||||
| `subminer stats` | Start the stats server (opens the dashboard when `stats.autoOpenBrowser` is on) |
|
||||
| `subminer stats -b` / `-s` | Start/reuse or stop the background stats daemon |
|
||||
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows (`-v` vocab, `-l` lifetime summaries) |
|
||||
| `subminer stats rebuild` / `backfill` | Rebuild or backfill rollup data |
|
||||
| `subminer doctor` | Dependency + config + socket diagnostics (`--refresh-known-words` refreshes the known-word cache) |
|
||||
| `subminer settings` | Open the SubMiner settings window |
|
||||
| `subminer logs -e` | Export a sanitized local-date log ZIP and print its path |
|
||||
| `subminer config path` | Print active config file path |
|
||||
| `subminer config show` | Print active config contents |
|
||||
| `subminer mpv status` | Check mpv socket readiness |
|
||||
| `subminer mpv socket` | Print active socket path |
|
||||
| `subminer mpv idle` | Launch detached idle mpv instance |
|
||||
| `subminer sync <host>` | Two-way stats/history sync with another machine over SSH |
|
||||
| `subminer sync <host> --push` | Merge local stats/history into another machine only |
|
||||
| `subminer sync <host> --pull` | Merge another machine's stats/history into the local database only |
|
||||
| `subminer dictionary <path>` / `dict` | Generate character dictionary ZIP from file/dir target |
|
||||
| `subminer dictionary --candidates <path>` | List AniList candidate matches for character dictionary correction |
|
||||
| `subminer dictionary --select <id> <path>` | Pin an AniList media ID for that target series |
|
||||
| `subminer texthooker` | Launch texthooker-only mode |
|
||||
| `subminer texthooker -o` | Launch texthooker and open it in the default browser |
|
||||
| `subminer app` / `bin` | Pass arguments directly to SubMiner binary (e.g. `subminer app --setup`) |
|
||||
| `subminer settings` | Open the SubMiner settings window |
|
||||
| `subminer logs -e` | Export a sanitized local-date log ZIP and print its path |
|
||||
| `subminer config path` | Print active config file path |
|
||||
| `subminer config show` | Print active config contents |
|
||||
| `subminer mpv status` | Check mpv socket readiness |
|
||||
| `subminer mpv socket` | Print active socket path |
|
||||
| `subminer mpv idle` | Launch detached idle mpv instance |
|
||||
| `subminer sync <host>` | Two-way stats/history sync with another machine over SSH |
|
||||
| `subminer sync <host> --push` | Merge local stats/history into another machine only |
|
||||
| `subminer sync <host> --pull` | Merge another machine's stats/history into the local database only |
|
||||
| `subminer sync <host> --check` | Test SSH connection and remote launcher availability |
|
||||
| `subminer sync --ui` | Open the sync window (saved devices, auto-sync, snapshots) |
|
||||
| `subminer dictionary <path>` / `dict` | Generate character dictionary ZIP from file/dir target |
|
||||
| `subminer dictionary --candidates <path>` | List AniList candidate matches for character dictionary correction |
|
||||
| `subminer dictionary --select <id> <path>` | Pin an AniList media ID for that target series |
|
||||
| `subminer texthooker` | Launch texthooker-only mode |
|
||||
| `subminer texthooker -o` | Launch texthooker and open it in the default browser |
|
||||
| `subminer app` / `bin` | Pass arguments directly to SubMiner binary (e.g. `subminer app --setup`) |
|
||||
|
||||
Use `subminer <subcommand> -h` for command-specific help.
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description |
|
||||
| --------------------- | -------------------------------------------------------------------- |
|
||||
| `-d, --directory` | Video search directory (default: cwd) |
|
||||
| `-r, --recursive` | Search directories recursively |
|
||||
| `-R, --rofi` | Use rofi instead of fzf |
|
||||
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
|
||||
| Flag | Description |
|
||||
| --------------------- | --------------------------------------------------------------------------- |
|
||||
| `-d, --directory` | Video search directory (default: cwd) |
|
||||
| `-r, --recursive` | Search directories recursively |
|
||||
| `-R, --rofi` | Use rofi instead of fzf |
|
||||
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
|
||||
| `-v, --version` | Print the launcher's own version (can differ from the installed app binary) |
|
||||
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
||||
| `--start` | Explicitly start overlay after mpv launches |
|
||||
| `-S, --start-overlay` | Force the visible overlay on start |
|
||||
| `-T, --no-texthooker` | Disable texthooker server |
|
||||
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
|
||||
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
||||
| `--settings` | Open the SubMiner settings window |
|
||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
||||
| `--start` | Explicitly start overlay after mpv launches |
|
||||
| `-S, --start-overlay` | Force the visible overlay on start |
|
||||
| `-T, --no-texthooker` | Disable texthooker server |
|
||||
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
|
||||
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
||||
| `--settings` | Open the SubMiner settings window |
|
||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||
|
||||
App-binary flags such as `--setup`, `--dev`, and `--debug` are not launcher flags - pass them through with `subminer app`, for example `subminer app --setup`.
|
||||
|
||||
|
||||
@@ -293,12 +293,8 @@ test('runSyncCommand --json emits NDJSON progress events for a two-way host sync
|
||||
const events = lines.map((line) => JSON.parse(line));
|
||||
const types = events.map((event) => event.type);
|
||||
assert.ok(types.includes('stage'));
|
||||
assert.ok(
|
||||
events.some((event) => event.type === 'stage' && event.stage === 'snapshot-local'),
|
||||
);
|
||||
assert.ok(
|
||||
events.some((event) => event.type === 'merge-summary' && event.target === 'local'),
|
||||
);
|
||||
assert.ok(events.some((event) => event.type === 'stage' && event.stage === 'snapshot-local'));
|
||||
assert.ok(events.some((event) => event.type === 'merge-summary' && event.target === 'local'));
|
||||
assert.ok(
|
||||
events.some(
|
||||
(event) => event.type === 'remote-output' && event.text.includes('remote summary text'),
|
||||
|
||||
@@ -74,10 +74,7 @@ test('parseCliPrograms captures sync --json and --check flags', () => {
|
||||
});
|
||||
|
||||
test('parseCliPrograms rejects invalid sync --check combinations', () => {
|
||||
assert.throws(
|
||||
() => parseCliPrograms(['sync', '--check'], 'subminer'),
|
||||
/--check requires a host/,
|
||||
);
|
||||
assert.throws(() => parseCliPrograms(['sync', '--check'], 'subminer'), /--check requires a host/);
|
||||
assert.throws(
|
||||
() => parseCliPrograms(['sync', 'media-box', '--check', '--push'], 'subminer'),
|
||||
/--check cannot be combined/,
|
||||
|
||||
@@ -356,7 +356,9 @@ export function parseCliPrograms(
|
||||
throw new Error('Sync --check requires a host.');
|
||||
}
|
||||
if (check && (push || pull || snapshot || merge)) {
|
||||
throw new Error('Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.');
|
||||
throw new Error(
|
||||
'Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.',
|
||||
);
|
||||
}
|
||||
const modes = [Boolean(host), Boolean(snapshot), Boolean(merge)].filter(Boolean).length;
|
||||
if (modes === 0) {
|
||||
|
||||
@@ -43,9 +43,9 @@ export function tableExists(db: Database, tableName: string): boolean {
|
||||
export function readSchemaVersion(db: Database): number | null {
|
||||
if (!tableExists(db, 'imm_schema_version')) return null;
|
||||
const row = db
|
||||
.query<{ schema_version: number }>(
|
||||
'SELECT MAX(schema_version) AS schema_version FROM imm_schema_version',
|
||||
)
|
||||
.query<{
|
||||
schema_version: number;
|
||||
}>('SELECT MAX(schema_version) AS schema_version FROM imm_schema_version')
|
||||
.get();
|
||||
return typeof row?.schema_version === 'number' ? row.schema_version : null;
|
||||
}
|
||||
@@ -126,7 +126,9 @@ function statsDaemonStateCandidates(dbPath: string): string[] {
|
||||
});
|
||||
candidates.add(path.join(configDir, 'stats-daemon.json'));
|
||||
if (process.platform === 'darwin') {
|
||||
candidates.add(path.join(homeDir, 'Library', 'Application Support', 'SubMiner', 'stats-daemon.json'));
|
||||
candidates.add(
|
||||
path.join(homeDir, 'Library', 'Application Support', 'SubMiner', 'stats-daemon.json'),
|
||||
);
|
||||
}
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
+2
-1
@@ -20,9 +20,10 @@
|
||||
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer",
|
||||
"build:stats": "cd stats && bun run build",
|
||||
"dev:stats": "cd stats && bun run dev",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:launcher && bun run build:assets",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
|
||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
|
||||
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap",
|
||||
"changelog:build": "bun run scripts/build-changelog.ts build-release",
|
||||
"changelog:check": "bun run scripts/build-changelog.ts check",
|
||||
"changelog:docs": "bun run scripts/build-changelog.ts docs",
|
||||
|
||||
@@ -9,6 +9,8 @@ const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
|
||||
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
|
||||
const settingsSourceDir = path.join(repoRoot, 'src', 'settings');
|
||||
const settingsOutputDir = path.join(repoRoot, 'dist', 'settings');
|
||||
const syncUiSourceDir = path.join(repoRoot, 'src', 'syncui');
|
||||
const syncUiOutputDir = path.join(repoRoot, 'dist', 'syncui');
|
||||
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
|
||||
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
|
||||
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
|
||||
@@ -41,6 +43,10 @@ function copySettingsAssets() {
|
||||
copyAssets(settingsSourceDir, settingsOutputDir, 'settings');
|
||||
}
|
||||
|
||||
function copySyncUiAssets() {
|
||||
copyAssets(syncUiSourceDir, syncUiOutputDir, 'syncui');
|
||||
}
|
||||
|
||||
function fallbackToMacosSource() {
|
||||
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
|
||||
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
||||
@@ -83,6 +89,7 @@ function buildMacosHelper() {
|
||||
function main() {
|
||||
copyRendererAssets();
|
||||
copySettingsAssets();
|
||||
copySyncUiAssets();
|
||||
buildMacosHelper();
|
||||
}
|
||||
|
||||
|
||||
+2
-5
@@ -535,10 +535,7 @@ import { createConfigSettingsRuntime } from './main/runtime/config-settings-runt
|
||||
import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-settings-window';
|
||||
import { createSyncUiRuntime } from './main/runtime/sync-ui-runtime';
|
||||
import { createSyncAutoScheduler } from './main/runtime/sync-auto-scheduler';
|
||||
import {
|
||||
resolveSyncLauncherCommand,
|
||||
runSyncLauncher,
|
||||
} from './main/runtime/sync-launcher-client';
|
||||
import { resolveSyncLauncherCommand, runSyncLauncher } from './main/runtime/sync-launcher-client';
|
||||
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order';
|
||||
import {
|
||||
isSameYoutubeMediaPath,
|
||||
@@ -2255,7 +2252,7 @@ const openSyncUiWindow = () => openSyncUiWindowHandler();
|
||||
const syncUiRuntime = createSyncUiRuntime({
|
||||
ipcMain,
|
||||
hostsFilePath: path.join(USER_DATA_PATH, 'sync-hosts.json'),
|
||||
snapshotsDir: path.join(USER_DATA_PATH, 'sync-snapshots'),
|
||||
snapshotsDir: path.join(os.tmpdir(), 'subminer-db-snapshots'),
|
||||
getDbPath: () => {
|
||||
const configured = getResolvedConfig().immersionTracking?.dbPath?.trim();
|
||||
return configured || DEFAULT_IMMERSION_DB_PATH;
|
||||
|
||||
@@ -33,7 +33,11 @@ test('tick triggers a due auto-sync host with its saved direction', () => {
|
||||
|
||||
test('tick skips hosts synced more recently than the interval', () => {
|
||||
let state = makeState();
|
||||
state = recordSyncResult(state, 'auto-box', { atMs: 90 * 60_000, status: 'success', detail: null });
|
||||
state = recordSyncResult(state, 'auto-box', {
|
||||
atMs: 90 * 60_000,
|
||||
status: 'success',
|
||||
detail: null,
|
||||
});
|
||||
const triggered: string[] = [];
|
||||
const scheduler = createSyncAutoScheduler({
|
||||
readState: () => state,
|
||||
|
||||
@@ -119,3 +119,29 @@ test('runSyncLauncher surfaces spawn errors', async () => {
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error ?? '', /ENOENT/);
|
||||
});
|
||||
|
||||
test('resolveSyncLauncherCommand prefers the bundled launcher over PATH', async () => {
|
||||
const { resolveSyncLauncherCommand } = await import('./sync-launcher-client');
|
||||
const bundled = resolveSyncLauncherCommand({
|
||||
findCommand: (name: string) =>
|
||||
name === 'bun' ? '/usr/bin/bun' : name === 'subminer' ? '/home/u/.local/bin/subminer' : null,
|
||||
resolveResourcePath: () => '/opt/app/resources/launcher/subminer',
|
||||
existsSync: () => true,
|
||||
});
|
||||
assert.deepEqual(bundled.command, ['/usr/bin/bun', '/opt/app/resources/launcher/subminer']);
|
||||
|
||||
const fallback = resolveSyncLauncherCommand({
|
||||
findCommand: (name: string) => (name === 'subminer' ? '/home/u/.local/bin/subminer' : null),
|
||||
resolveResourcePath: () => '/missing',
|
||||
existsSync: () => false,
|
||||
});
|
||||
assert.deepEqual(fallback.command, ['/home/u/.local/bin/subminer']);
|
||||
|
||||
const none = resolveSyncLauncherCommand({
|
||||
findCommand: () => null,
|
||||
resolveResourcePath: () => '/missing',
|
||||
existsSync: () => false,
|
||||
});
|
||||
assert.equal(none.command, null);
|
||||
assert.match(none.error ?? '', /launcher/i);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn as nodeSpawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { parseSyncProgressLine, type SyncProgressEvent } from '../../shared/sync/sync-events';
|
||||
import { findCommand } from './command-line-launcher-deps';
|
||||
import { resolveLauncherResourcePath } from './command-line-launcher';
|
||||
@@ -28,8 +29,9 @@ export interface SyncLauncherResolution {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// The launcher is a bun script: prefer the PATH-installed `subminer`, fall
|
||||
// back to running the bundled resource through bun directly.
|
||||
// The launcher is a bun script. Prefer the bundled launcher resource (it
|
||||
// always matches this app version's sync protocol) and only fall back to a
|
||||
// PATH-installed `subminer`, which may be older.
|
||||
export function resolveSyncLauncherCommand(
|
||||
deps: {
|
||||
findCommand?: typeof findCommand;
|
||||
@@ -38,14 +40,17 @@ export function resolveSyncLauncherCommand(
|
||||
} = {},
|
||||
): SyncLauncherResolution {
|
||||
const find = deps.findCommand ?? findCommand;
|
||||
const installed = find('subminer', {});
|
||||
if (installed) return { command: [installed], error: null };
|
||||
|
||||
const exists = deps.existsSync ?? fs.existsSync;
|
||||
const resourcePath = deps.resolveResourcePath
|
||||
? deps.resolveResourcePath()
|
||||
: resolveLauncherResourcePath({});
|
||||
const bunPath = find('bun', {});
|
||||
if (bunPath && resourcePath) return { command: [bunPath, resourcePath], error: null };
|
||||
if (bunPath && resourcePath && exists(resourcePath)) {
|
||||
return { command: [bunPath, resourcePath], error: null };
|
||||
}
|
||||
|
||||
const installed = find('subminer', {});
|
||||
if (installed) return { command: [installed], error: null };
|
||||
|
||||
return {
|
||||
command: null,
|
||||
|
||||
@@ -138,13 +138,7 @@ test('run-sync spawns the launcher, forwards progress, and rejects concurrent ru
|
||||
force: true,
|
||||
})) as { started: boolean; runId: number };
|
||||
assert.equal(start.started, true);
|
||||
assert.deepEqual(launcherCalls[0]!.args, [
|
||||
'sync',
|
||||
'media-box',
|
||||
'--push',
|
||||
'--force',
|
||||
'--json',
|
||||
]);
|
||||
assert.deepEqual(launcherCalls[0]!.args, ['sync', 'media-box', '--push', '--force', '--json']);
|
||||
|
||||
const second = (await invoke('sync-ui:run-sync', { host: 'other' })) as {
|
||||
started: boolean;
|
||||
|
||||
@@ -213,9 +213,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
if (result.started) {
|
||||
// Remember the host (and the direction used) before the launcher's own
|
||||
// bookkeeping lands, so it shows up in the UI immediately.
|
||||
writeState(
|
||||
upsertSyncHost(readState(), { host, direction: request.direction }, deps.nowMs()),
|
||||
);
|
||||
writeState(upsertSyncHost(readState(), { host, direction: request.direction }, deps.nowMs()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -298,9 +296,7 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
||||
const channels = IPC_CHANNELS.request;
|
||||
deps.ipcMain.handle(channels.syncUiGetSnapshot, () => getSnapshot());
|
||||
deps.ipcMain.handle(channels.syncUiSaveHost, (_event, update) =>
|
||||
writeState(
|
||||
upsertSyncHost(readState(), update as SyncUiHostUpdateRequest, deps.nowMs()),
|
||||
),
|
||||
writeState(upsertSyncHost(readState(), update as SyncUiHostUpdateRequest, deps.nowMs())),
|
||||
);
|
||||
deps.ipcMain.handle(channels.syncUiRemoveHost, (_event, host) =>
|
||||
writeState(removeSyncHost(readState(), String(host))),
|
||||
|
||||
@@ -29,8 +29,7 @@ const SYNC_UI_IPC_CHANNELS = {
|
||||
} as const;
|
||||
|
||||
const syncUiAPI: SyncUiAPI = {
|
||||
getSnapshot: (): Promise<SyncUiSnapshot> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.getSnapshot),
|
||||
getSnapshot: (): Promise<SyncUiSnapshot> => ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.getSnapshot),
|
||||
saveHost: (update: SyncUiHostUpdateRequest): Promise<SyncHostsState> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.saveHost, update),
|
||||
removeHost: (host: string): Promise<SyncHostsState> =>
|
||||
@@ -44,8 +43,8 @@ const syncUiAPI: SyncUiAPI = {
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.checkHost, host),
|
||||
createSnapshot: (): Promise<SyncUiStartResult> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.createSnapshot),
|
||||
mergeSnapshotFile: (path: string): Promise<SyncUiStartResult> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.mergeSnapshotFile, path),
|
||||
mergeSnapshotFile: (path: string, force?: boolean): Promise<SyncUiStartResult> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.mergeSnapshotFile, path, force === true),
|
||||
deleteSnapshot: (path: string): Promise<SyncUiSnapshotFile[]> =>
|
||||
ipcRenderer.invoke(SYNC_UI_IPC_CHANNELS.deleteSnapshot, path),
|
||||
revealSnapshot: (path: string): Promise<boolean> =>
|
||||
|
||||
@@ -14,10 +14,11 @@ test('parseSyncProgressLine parses known event types', () => {
|
||||
});
|
||||
const parsed = parseSyncProgressLine(summaryLine);
|
||||
assert.equal(parsed?.type, 'merge-summary');
|
||||
assert.deepEqual(
|
||||
parseSyncProgressLine('{"type":"result","ok":true,"error":null}'),
|
||||
{ type: 'result', ok: true, error: null },
|
||||
);
|
||||
assert.deepEqual(parseSyncProgressLine('{"type":"result","ok":true,"error":null}'), {
|
||||
type: 'result',
|
||||
ok: true,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseSyncProgressLine rejects non-events and garbage', () => {
|
||||
|
||||
@@ -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();
|
||||
@@ -73,7 +73,7 @@ export interface SyncUiAPI {
|
||||
cancelRun: () => Promise<boolean>;
|
||||
checkHost: (host: string) => Promise<SyncUiCheckResult>;
|
||||
createSnapshot: () => Promise<SyncUiStartResult>;
|
||||
mergeSnapshotFile: (path: string) => Promise<SyncUiStartResult>;
|
||||
mergeSnapshotFile: (path: string, force?: boolean) => Promise<SyncUiStartResult>;
|
||||
deleteSnapshot: (path: string) => Promise<SyncUiSnapshotFile[]>;
|
||||
revealSnapshot: (path: string) => Promise<boolean>;
|
||||
pickSnapshotFile: () => Promise<string | null>;
|
||||
|
||||
Reference in New Issue
Block a user