herdr-remote 0.2.18 → 0.2.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/herdr-remote.js +3 -225
- package/dist/agent-status.js +130 -0
- package/dist/binary-plist.js +157 -0
- package/dist/cli.js +255 -0
- package/dist/config.js +270 -0
- package/dist/connector/agent-watch.js +110 -0
- package/dist/connector/font-server.js +165 -0
- package/dist/connector/host-connector.js +430 -0
- package/dist/connector/lock.js +66 -0
- package/dist/connector/main.js +18 -0
- package/dist/connector/sessions.js +366 -0
- package/dist/connector/update-report.js +67 -0
- package/dist/connector/wire.js +42 -0
- package/{src → dist}/exit-codes.js +1 -4
- package/dist/font-subset.js +110 -0
- package/dist/herdr-api.js +246 -0
- package/dist/herdr-command.js +223 -0
- package/dist/herdr-plugin.js +116 -0
- package/dist/herdr-server.js +189 -0
- package/dist/i18n/en.js +223 -0
- package/dist/i18n/index.js +48 -0
- package/dist/i18n/zh.js +222 -0
- package/dist/keepalive/detached.js +78 -0
- package/dist/keepalive/environment.js +44 -0
- package/dist/keepalive/index.js +110 -0
- package/dist/keepalive/launchd.js +117 -0
- package/dist/keepalive/systemd.js +108 -0
- package/dist/keepalive/types.js +1 -0
- package/dist/lib/process.js +12 -0
- package/dist/lifecycle.js +59 -0
- package/dist/net-interfaces.js +73 -0
- package/dist/pasted-files.js +115 -0
- package/dist/paths.js +62 -0
- package/dist/pty-session.js +96 -0
- package/dist/relay-client.js +106 -0
- package/dist/relay-urls.js +99 -0
- package/dist/runtime.js +106 -0
- package/dist/service.js +260 -0
- package/dist/settings-model.js +298 -0
- package/dist/socket-discovery.js +48 -0
- package/dist/supervisor.js +319 -0
- package/dist/terminal-font/deps.js +73 -0
- package/dist/terminal-font/files.js +354 -0
- package/dist/terminal-font/formats.js +335 -0
- package/dist/terminal-font/index.js +187 -0
- package/dist/terminal-font/readers.js +316 -0
- package/dist/terminal-font/terminals.js +128 -0
- package/dist/terminal-palette.js +339 -0
- package/dist/tui/App.js +195 -0
- package/dist/tui/api.js +79 -0
- package/dist/tui/components/TextField.js +50 -0
- package/dist/tui/components/common.js +80 -0
- package/dist/tui/index.js +36 -0
- package/dist/tui/mouse/index.js +164 -0
- package/dist/tui/mouse/protocol.js +96 -0
- package/dist/tui/mouse/source.js +74 -0
- package/dist/tui/screens/About.js +166 -0
- package/dist/tui/screens/Herdr.js +74 -0
- package/dist/tui/screens/Keepalive.js +84 -0
- package/dist/tui/screens/Overview.js +36 -0
- package/dist/tui/screens/Pair.js +74 -0
- package/dist/tui/screens/Relay.js +242 -0
- package/dist/tui/screens/Services.js +63 -0
- package/dist/tui/screens/Wizard.js +235 -0
- package/dist/tui/terminal.js +39 -0
- package/dist/tui/theme.js +33 -0
- package/dist/updater.js +382 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +8 -8
- package/dist/tui.mjs +0 -5775
- package/src/agent-status.js +0 -139
- package/src/binary-plist.js +0 -146
- package/src/config.js +0 -454
- package/src/font-subset.js +0 -110
- package/src/herdr-api.js +0 -244
- package/src/herdr-command.js +0 -231
- package/src/herdr-plugin.js +0 -130
- package/src/herdr-server.js +0 -201
- package/src/host-connector.js +0 -1069
- package/src/i18n/en.js +0 -240
- package/src/i18n/index.js +0 -56
- package/src/i18n/zh.js +0 -239
- package/src/keepalive.js +0 -460
- package/src/lifecycle.js +0 -62
- package/src/net-interfaces.js +0 -76
- package/src/pasted-files.js +0 -176
- package/src/pty-session.js +0 -92
- package/src/service.js +0 -530
- package/src/settings-model.js +0 -323
- package/src/socket-discovery.js +0 -60
- package/src/state.js +0 -47
- package/src/supervisor.js +0 -250
- package/src/terminal-font.js +0 -1204
- package/src/terminal-palette.js +0 -325
- package/src/updater.js +0 -394
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { theme } from '../theme.js';
|
|
5
|
+
import { FieldRow, Message, Panel, Row, Selectable, StatusDot } from '../components/common.js';
|
|
6
|
+
import { TextField } from '../components/TextField.js';
|
|
7
|
+
import { MIN_HERDR_VERSION, getField, getFieldPlaceholder, herdrVersion, saveDraft, setField, } from '../api.js';
|
|
8
|
+
export function HerdrScreen({ ctx }) {
|
|
9
|
+
const { t, draft, editingId } = ctx;
|
|
10
|
+
const [selected, setSelected] = useState('socketPath');
|
|
11
|
+
const [installed, setInstalled] = useState(null);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
setInstalled(herdrVersion());
|
|
14
|
+
}, []);
|
|
15
|
+
const entries = [
|
|
16
|
+
{ id: 'socketPath', kind: 'field', label: t('herdr.socketPath') },
|
|
17
|
+
{ id: 'herdrArgs', kind: 'field', label: t('herdr.args') },
|
|
18
|
+
{ id: 'herdrAutoStart', kind: 'toggle', label: t('field.herdrAutoStart') },
|
|
19
|
+
{ id: 'save', kind: 'action', label: t('common.save') },
|
|
20
|
+
];
|
|
21
|
+
const applyField = (id, value) => {
|
|
22
|
+
const result = setField(draft, id, value);
|
|
23
|
+
if (result.errorKey) {
|
|
24
|
+
ctx.notify(t(result.errorKey), 'error');
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
ctx.updateDraft(result.draft);
|
|
28
|
+
return true;
|
|
29
|
+
};
|
|
30
|
+
const save = () => {
|
|
31
|
+
try {
|
|
32
|
+
const result = saveDraft(draft);
|
|
33
|
+
ctx.reloadConfig();
|
|
34
|
+
ctx.notify(t('common.saved', { path: result.path }), 'success');
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
ctx.notify(t('error.saveFailed', { message: error.message }), 'error');
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const activate = (id) => {
|
|
41
|
+
if (id === 'socketPath' || id === 'herdrArgs') {
|
|
42
|
+
ctx.setEditing(id);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (id === 'herdrAutoStart') {
|
|
46
|
+
applyField(id, draft.herdr.autoStart ? 'off' : 'on');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (id === 'save')
|
|
50
|
+
save();
|
|
51
|
+
};
|
|
52
|
+
useInput((input, key) => {
|
|
53
|
+
const index = entries.findIndex((entry) => entry.id === selected);
|
|
54
|
+
const safeIndex = index >= 0 ? index : 0;
|
|
55
|
+
if (key.upArrow)
|
|
56
|
+
setSelected(entries[(safeIndex - 1 + entries.length) % entries.length].id);
|
|
57
|
+
else if (key.downArrow)
|
|
58
|
+
setSelected(entries[(safeIndex + 1) % entries.length].id);
|
|
59
|
+
else if (key.return)
|
|
60
|
+
activate(selected);
|
|
61
|
+
else if (input === 's')
|
|
62
|
+
save();
|
|
63
|
+
}, { isActive: editingId === null });
|
|
64
|
+
return (_jsxs(Panel, { title: t('herdr.title'), children: [entries.map((entry) => entry.kind === 'toggle' ? (_jsx(FieldRow, { label: entry.label, selected: selected === entry.id, onSelect: () => {
|
|
65
|
+
setSelected(entry.id);
|
|
66
|
+
activate(entry.id);
|
|
67
|
+
}, onHover: () => setSelected(entry.id), children: _jsx(Text, { color: draft.herdr.autoStart ? theme.ok : theme.muted, children: draft.herdr.autoStart ? `[×] ${t('common.on')}` : `[ ] ${t('common.off')}` }) }, entry.id)) : entry.kind === 'field' ? (_jsx(FieldRow, { label: entry.label, selected: selected === entry.id, onSelect: () => activate(entry.id), onHover: () => setSelected(entry.id), children: _jsx(TextField, { value: getField(draft, entry.id), placeholder: ctx.t(getFieldPlaceholder(draft, entry.id)), active: editingId === entry.id, onSubmit: (value) => {
|
|
68
|
+
if (applyField(entry.id, value))
|
|
69
|
+
ctx.setEditing(null);
|
|
70
|
+
}, onCancel: () => ctx.setEditing(null) }) }, entry.id)) : (_jsx(Box, { marginTop: 1, children: _jsx(Selectable, { selected: selected === entry.id, onSelect: () => {
|
|
71
|
+
setSelected(entry.id);
|
|
72
|
+
activate(entry.id);
|
|
73
|
+
}, onHover: () => setSelected(entry.id), children: entry.label }) }, entry.id))), selected === 'herdrAutoStart' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('herdr.autoStartHint') }) })) : null, ctx.dirty ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.warn, children: t('hint.unsavedChanges') }) })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [installed?.version ? (_jsx(Row, { label: t('herdr.version'), children: _jsxs(Box, { children: [_jsx(StatusDot, { level: installed.supported ? 'ok' : 'warn' }), _jsx(Text, { children: ` ${installed.version}` })] }) })) : null, installed?.version && !installed.supported ? (_jsx(Text, { color: theme.warn, children: t('herdr.versionOutdated', { version: installed.version, minimum: MIN_HERDR_VERSION }) })) : null, installed && !installed.ok ? (_jsx(Text, { color: theme.warn, children: t('herdr.cliMissing') })) : null] }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
|
|
74
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { theme } from '../theme.js';
|
|
5
|
+
import { Message, Panel, Row, Selectable, StatusDot } from '../components/common.js';
|
|
6
|
+
import { ChoiceList } from './Relay.js';
|
|
7
|
+
import { keepalive, saveDraft, setField } from '../api.js';
|
|
8
|
+
const MANAGERS = ['auto', 'systemd', 'launchd', 'supervisor', 'none'];
|
|
9
|
+
export function Keepalive({ ctx }) {
|
|
10
|
+
const { t, status, draft, editingId } = ctx;
|
|
11
|
+
const [selected, setSelected] = useState('install');
|
|
12
|
+
const current = status?.keepalive;
|
|
13
|
+
const entries = [
|
|
14
|
+
{ id: 'install', label: t('keepalive.install') },
|
|
15
|
+
{ id: 'restart', label: t('keepalive.restart') },
|
|
16
|
+
{ id: 'uninstall', label: t('keepalive.uninstall') },
|
|
17
|
+
{ id: 'manager', label: `${t('keepalive.manager')}: ${draft.keepalive.manager}` },
|
|
18
|
+
...(process.platform === 'linux' && current?.manager === 'systemd' && !current?.linger
|
|
19
|
+
? [{ id: 'linger', label: t('keepalive.enableLinger') }]
|
|
20
|
+
: []),
|
|
21
|
+
];
|
|
22
|
+
const activate = (id) => {
|
|
23
|
+
if (id === 'manager') {
|
|
24
|
+
ctx.setEditing('keepaliveManager');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
ctx.run(() => {
|
|
28
|
+
if (id === 'install') {
|
|
29
|
+
const result = keepalive.install(ctx.config);
|
|
30
|
+
ctx.notify(t('keepalive.installed', { manager: result.manager }), 'success');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (id === 'uninstall') {
|
|
34
|
+
keepalive.uninstall(ctx.config);
|
|
35
|
+
ctx.notify(t('keepalive.uninstalled'), 'success');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (id === 'restart') {
|
|
39
|
+
keepalive.restart(ctx.config);
|
|
40
|
+
ctx.notify(t('keepalive.restarted'), 'success');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (id === 'linger') {
|
|
44
|
+
const result = keepalive.enableLinger();
|
|
45
|
+
ctx.notify(t('keepalive.lingerDone', { username: result.username }), 'success');
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
useInput((_input, key) => {
|
|
50
|
+
const index = entries.findIndex((entry) => entry.id === selected);
|
|
51
|
+
if (key.upArrow)
|
|
52
|
+
setSelected(entries[(index - 1 + entries.length) % entries.length].id);
|
|
53
|
+
else if (key.downArrow)
|
|
54
|
+
setSelected(entries[(index + 1) % entries.length].id);
|
|
55
|
+
else if (key.return)
|
|
56
|
+
activate(selected);
|
|
57
|
+
}, { isActive: editingId === null });
|
|
58
|
+
if (editingId === 'keepaliveManager') {
|
|
59
|
+
return (_jsx(Panel, { title: t('field.keepalive'), children: _jsx(ChoiceList, { options: MANAGERS.map((manager) => ({ id: manager, label: manager })), current: draft.keepalive.manager, onPick: (manager) => {
|
|
60
|
+
const result = setField(draft, 'keepaliveManager', manager);
|
|
61
|
+
if (result.errorKey) {
|
|
62
|
+
ctx.notify(t(result.errorKey), 'error');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
ctx.updateDraft(result.draft);
|
|
66
|
+
try {
|
|
67
|
+
saveDraft(result.draft);
|
|
68
|
+
ctx.reloadConfig();
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
ctx.notify(t('error.saveFailed', { message: error.message }), 'error');
|
|
72
|
+
}
|
|
73
|
+
ctx.setEditing(null);
|
|
74
|
+
}, onCancel: () => ctx.setEditing(null) }) }));
|
|
75
|
+
}
|
|
76
|
+
return (_jsxs(Panel, { title: t('keepalive.title'), children: [_jsx(Row, { label: t('keepalive.manager'), children: _jsx(Text, { children: current?.manager ?? t('common.unknown') }) }), _jsx(Row, { label: t('keepalive.state'), children: _jsxs(Box, { children: [_jsx(StatusDot, { level: current?.active ? 'ok' : current?.installed ? 'warn' : 'idle' }), _jsxs(Text, { children: [' ', current?.active
|
|
77
|
+
? t('common.running')
|
|
78
|
+
: current?.installed
|
|
79
|
+
? t('common.installed')
|
|
80
|
+
: t('common.notInstalled')] })] }) }), current?.unitPath ? (_jsx(Row, { label: t('keepalive.unitPath'), children: _jsx(Text, { color: theme.muted, children: current.unitPath }) })) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: entries.map((entry) => (_jsx(Selectable, { selected: selected === entry.id, onSelect: () => {
|
|
81
|
+
setSelected(entry.id);
|
|
82
|
+
activate(entry.id);
|
|
83
|
+
}, onHover: () => setSelected(entry.id), children: entry.label }, entry.id))) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [current?.manager === 'systemd' ? (_jsx(Text, { color: current.linger ? theme.muted : theme.warn, children: current.linger ? t('keepalive.lingerEnabled') : t('keepalive.lingerDisabled') })) : null, current?.manager === 'supervisor' ? (_jsx(Text, { color: theme.warn, children: t('keepalive.fallbackNote') })) : null, _jsx(Text, { color: theme.muted, children: t('keepalive.logsHint', { command: status?.logsHint ?? '' }) })] }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
|
|
84
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { theme } from '../theme.js';
|
|
4
|
+
import { Message, Panel, Row, StatusDot } from '../components/common.js';
|
|
5
|
+
import { formatUptime } from '../api.js';
|
|
6
|
+
function level(alive) {
|
|
7
|
+
if (alive === null || alive === undefined)
|
|
8
|
+
return 'idle';
|
|
9
|
+
return alive ? 'ok' : 'bad';
|
|
10
|
+
}
|
|
11
|
+
export function Overview({ ctx }) {
|
|
12
|
+
const { t, status, config } = ctx;
|
|
13
|
+
if (!status) {
|
|
14
|
+
return (_jsx(Panel, { title: t('overview.title'), children: _jsx(Text, { color: theme.muted, children: t('common.loading') }) }));
|
|
15
|
+
}
|
|
16
|
+
const health = status.relay.health || {};
|
|
17
|
+
const relayReachable = health.ok !== false;
|
|
18
|
+
const relayLevel = status.relay.local
|
|
19
|
+
? level(status.relay.alive)
|
|
20
|
+
: relayReachable
|
|
21
|
+
? 'ok'
|
|
22
|
+
: 'bad';
|
|
23
|
+
const keepaliveLevel = status.keepalive.active
|
|
24
|
+
? 'ok'
|
|
25
|
+
: status.keepalive.installed
|
|
26
|
+
? 'warn'
|
|
27
|
+
: 'idle';
|
|
28
|
+
const nothingRunning = !status.host.alive && (status.relay.local ? !status.relay.alive : !relayReachable);
|
|
29
|
+
return (_jsxs(Panel, { title: t('overview.title'), children: [_jsx(Row, { label: t('overview.mode'), children: _jsx(Text, { children: t(`mode.${status.mode}`) }) }), _jsx(Row, { label: t('overview.relay'), children: _jsxs(Box, { children: [_jsx(StatusDot, { level: relayLevel }), _jsxs(Text, { children: [' ', status.relay.local
|
|
30
|
+
? t('overview.relayLocal', { bind: status.relay.bind ?? '', port: status.relay.port })
|
|
31
|
+
: t('overview.relayRemote', { url: status.relay.remoteUrl ?? '' })] }), status.relay.local && status.relay.pid ? (_jsx(Text, { color: theme.muted, children: ` ${t('common.pid', { pid: status.relay.pid })}` })) : null] }) }), _jsx(Row, { label: t('overview.host'), children: _jsxs(Box, { children: [_jsx(StatusDot, { level: level(status.host.alive) }), _jsx(Text, { children: ` ${status.host.alive ? t('common.running') : t('common.stopped')}` }), status.host.pid ? (_jsx(Text, { color: theme.muted, children: ` ${t('common.pid', { pid: status.host.pid })}` })) : null] }) }), _jsx(Row, { label: t('overview.socket'), children: _jsxs(Box, { children: [_jsx(StatusDot, { level: status.host.socketExists ? 'ok' : 'warn' }), _jsx(Text, { color: theme.muted, children: ` ${status.host.socketPath}` }), status.host.socketExists ? null : (_jsx(Text, { color: theme.warn, children: ` ${t('overview.socketMissing')}` }))] }) }), _jsx(Row, { label: t('overview.webUrl'), children: _jsx(Text, { color: theme.accent, children: status.publicUrl }) }), _jsx(Row, { label: t('overview.keepalive'), children: _jsxs(Box, { children: [_jsx(StatusDot, { level: keepaliveLevel }), _jsxs(Text, { children: [` ${status.keepalive.manager} — `, status.keepalive.active
|
|
32
|
+
? t('common.running')
|
|
33
|
+
: status.keepalive.installed
|
|
34
|
+
? t('common.installed')
|
|
35
|
+
: t('common.notInstalled')] })] }) }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: relayReachable ? (_jsxs(_Fragment, { children: [typeof health.hosts === 'number' ? (_jsx(Row, { label: t('overview.hosts'), children: _jsx(Text, { children: health.hosts }) })) : null, typeof health.clients === 'number' ? (_jsx(Row, { label: t('overview.devices'), children: _jsx(Text, { children: health.clients }) })) : null, _jsx(Row, { label: t('overview.uptime'), children: _jsx(Text, { color: theme.muted, children: formatUptime(health.uptimeSeconds, t) }) })] })) : (_jsx(Text, { color: theme.warn, children: t('overview.unreachable', { message: health.message ?? '' }) })) }), nothingRunning ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('overview.notStarted') }) })) : null, config.relay.mode === 'local' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('mode.local.description') }) })) : null, _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
|
|
36
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
4
|
+
import QRCode from 'qrcode';
|
|
5
|
+
import { theme } from '../theme.js';
|
|
6
|
+
import { Menu, Message, Panel } from '../components/common.js';
|
|
7
|
+
import { extractPairingCode, pair } from '../api.js';
|
|
8
|
+
// A QR block needs room for the symbol plus its quiet zone; below this the
|
|
9
|
+
// output wraps into noise, so the URL alone is the honest thing to show.
|
|
10
|
+
const MIN_QR_COLUMNS = 44;
|
|
11
|
+
export function PairScreen({ ctx }) {
|
|
12
|
+
const { t, run } = ctx;
|
|
13
|
+
const { columns } = useWindowSize();
|
|
14
|
+
const [pairing, setPairing] = useState(null);
|
|
15
|
+
const [qr, setQr] = useState(null);
|
|
16
|
+
const [remainingMs, setRemainingMs] = useState(0);
|
|
17
|
+
const [selected, setSelected] = useState('generate');
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!pairing)
|
|
20
|
+
return undefined;
|
|
21
|
+
const tick = () => setRemainingMs(Math.max(0, pairing.expiresAt - Date.now()));
|
|
22
|
+
tick();
|
|
23
|
+
const timer = setInterval(tick, 1000);
|
|
24
|
+
return () => clearInterval(timer);
|
|
25
|
+
}, [pairing]);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!pairing || columns < MIN_QR_COLUMNS) {
|
|
28
|
+
setQr(null);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
let cancelled = false;
|
|
32
|
+
QRCode.toString(pairing.pairUrl, { type: 'terminal', small: true, errorCorrectionLevel: 'L' })
|
|
33
|
+
.then((value) => {
|
|
34
|
+
if (!cancelled)
|
|
35
|
+
setQr(value.replace(/\n$/, ''));
|
|
36
|
+
})
|
|
37
|
+
.catch(() => {
|
|
38
|
+
if (!cancelled)
|
|
39
|
+
setQr(null);
|
|
40
|
+
});
|
|
41
|
+
return () => {
|
|
42
|
+
cancelled = true;
|
|
43
|
+
};
|
|
44
|
+
}, [pairing, columns]);
|
|
45
|
+
const generate = () => run(async () => {
|
|
46
|
+
ctx.notify(t('pair.working'));
|
|
47
|
+
try {
|
|
48
|
+
const result = await pair();
|
|
49
|
+
const code = extractPairingCode(result);
|
|
50
|
+
setPairing({ ...result, code });
|
|
51
|
+
ctx.notify('', 'info');
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const message = error.message;
|
|
55
|
+
setPairing(null);
|
|
56
|
+
throw new Error(/host_offline|no Herdr host|did not register/i.test(message)
|
|
57
|
+
? t('pair.hostOffline')
|
|
58
|
+
: t('pair.failed', { message }));
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
useInput((_input, key) => {
|
|
62
|
+
if (key.return)
|
|
63
|
+
generate();
|
|
64
|
+
}, { isActive: ctx.editingId === null });
|
|
65
|
+
const expired = pairing !== null && remainingMs <= 0;
|
|
66
|
+
const minutes = Math.floor(remainingMs / 60000);
|
|
67
|
+
const seconds = Math.floor((remainingMs % 60000) / 1000);
|
|
68
|
+
return (_jsxs(Panel, { title: t('pair.title'), children: [_jsx(Menu, { items: [{ id: 'generate', label: pairing ? t('pair.regenerate') : t('pair.generate') }], selectedId: selected, onChange: setSelected, onSelect: generate }), pairing ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: `${t('pair.code')} ` }), _jsx(Text, { bold: true, color: expired ? theme.muted : theme.accent, children: pairing.code.split('').join(' ') })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.muted, children: `${t('pair.url')} ` }), _jsx(Text, { children: pairing.pairUrl })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: expired ? theme.bad : theme.muted, children: expired
|
|
69
|
+
? t('pair.expired')
|
|
70
|
+
: t('pair.expires', {
|
|
71
|
+
minutes: `${minutes}:${String(seconds).padStart(2, '0')}`,
|
|
72
|
+
time: new Date(pairing.expiresAt).toLocaleTimeString(),
|
|
73
|
+
}) }) }), !expired && qr ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { children: qr }), _jsx(Text, { color: theme.muted, children: t('pair.qrHint') })] })) : null, !expired && !qr && columns < MIN_QR_COLUMNS ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('pair.qrUnavailable') }) })) : null, _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('pair.instructions') }) })] })) : null, _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
|
|
74
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { theme } from '../theme.js';
|
|
5
|
+
import { FieldRow, Menu, Message, Panel, Row, Selectable } from '../components/common.js';
|
|
6
|
+
import { TextField } from '../components/TextField.js';
|
|
7
|
+
import { bindAddress, fieldsForMode, getField, getFieldPlaceholder, isOfficialRelay, listReachableAddresses, probeRelay, regenerateHostIdentity, relayStartCommand, saveDraft, selectedMode, setField, setRelayPassword, } from '../api.js';
|
|
8
|
+
export function RelayScreen({ ctx }) {
|
|
9
|
+
const { t, draft, editingId } = ctx;
|
|
10
|
+
const [selected, setSelected] = useState('mode');
|
|
11
|
+
const [revealTokens, setRevealTokens] = useState(false);
|
|
12
|
+
const [showEnv, setShowEnv] = useState(false);
|
|
13
|
+
const [password, setPassword] = useState(ctx.runtime.relayPassword || '');
|
|
14
|
+
// The official relay is ours: its address is fixed and it takes no password.
|
|
15
|
+
// Both are therefore facts to *show*, not fields to fill in — an editable box
|
|
16
|
+
// holding a value that must not change is an invitation to break the setup.
|
|
17
|
+
const official = isOfficialRelay(draft);
|
|
18
|
+
const fields = useMemo(() => fieldsForMode(draft.relay.mode).filter((field) => ['mode', 'port', 'lanHost', 'remoteUrl', 'publicUrl'].includes(field.id)), [draft.relay.mode]);
|
|
19
|
+
const entries = [
|
|
20
|
+
...fields.map((field) => ({
|
|
21
|
+
id: field.id,
|
|
22
|
+
// The official relay's address is ours and its browser URL follows from
|
|
23
|
+
// it, so neither is offered for editing: an editable box holding a value
|
|
24
|
+
// that must not change is an invitation to break the setup.
|
|
25
|
+
kind: (official && (field.id === 'remoteUrl' || field.id === 'publicUrl')
|
|
26
|
+
? 'readonly'
|
|
27
|
+
: 'field'),
|
|
28
|
+
fieldKind: field.kind,
|
|
29
|
+
label: t(field.labelKey),
|
|
30
|
+
})),
|
|
31
|
+
// The password only matters when talking to a relay somebody else started;
|
|
32
|
+
// a local relay is configured with our own token automatically, and the
|
|
33
|
+
// official one authenticates workstations by their own host token.
|
|
34
|
+
...(draft.relay.mode === 'remote' && !official
|
|
35
|
+
? [{ id: 'password', kind: 'password', label: t('relay.password') }]
|
|
36
|
+
: []),
|
|
37
|
+
// Saving needs a row of its own. Every field here only edits a draft, and
|
|
38
|
+
// the services restart from what is on disk — with `s` as the sole way to
|
|
39
|
+
// commit, a change could look applied, survive a restart, and never take
|
|
40
|
+
// effect. It is also the only way to save with the mouse.
|
|
41
|
+
{ id: 'save', kind: 'action', label: t('common.save') },
|
|
42
|
+
{ id: 'test', kind: 'action', label: t('relay.test') },
|
|
43
|
+
...(draft.relay.mode === 'remote' && !official
|
|
44
|
+
? [
|
|
45
|
+
{
|
|
46
|
+
id: 'reveal',
|
|
47
|
+
kind: 'action',
|
|
48
|
+
label: revealTokens ? t('relay.hidePassword') : t('relay.showPassword'),
|
|
49
|
+
},
|
|
50
|
+
{ id: 'env', kind: 'action', label: t('relay.envSnippet') },
|
|
51
|
+
]
|
|
52
|
+
: []),
|
|
53
|
+
{ id: 'regenerate', kind: 'action', label: t('relay.regenerate') },
|
|
54
|
+
];
|
|
55
|
+
const addresses = useMemo(() => listReachableAddresses({ includeLoopback: false }), []);
|
|
56
|
+
/**
|
|
57
|
+
* Apply several fields as one edit.
|
|
58
|
+
*
|
|
59
|
+
* Each `setField` has to build on the previous result rather than on `draft`,
|
|
60
|
+
* which is the value captured when this render began. Calling a single-field
|
|
61
|
+
* helper twice in a row silently dropped the first change: the second call
|
|
62
|
+
* started from the stale draft and committed it back over the first.
|
|
63
|
+
*/
|
|
64
|
+
const applyFields = (updates) => {
|
|
65
|
+
let next = draft;
|
|
66
|
+
for (const [id, value] of updates) {
|
|
67
|
+
const result = setField(next, id, value);
|
|
68
|
+
if (result.errorKey) {
|
|
69
|
+
ctx.notify(t(result.errorKey), 'error');
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
next = result.draft;
|
|
73
|
+
}
|
|
74
|
+
ctx.updateDraft(next);
|
|
75
|
+
ctx.notify('', 'info');
|
|
76
|
+
return true;
|
|
77
|
+
};
|
|
78
|
+
const applyField = (id, value) => applyFields([[id, value]]);
|
|
79
|
+
const save = () => {
|
|
80
|
+
try {
|
|
81
|
+
const result = saveDraft(draft);
|
|
82
|
+
ctx.reloadConfig();
|
|
83
|
+
ctx.notify(t('common.saved', { path: result.path }), 'success');
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const problems = error.problems;
|
|
87
|
+
ctx.notify(problems
|
|
88
|
+
? problems.map((key) => t(key)).join(' ')
|
|
89
|
+
: t('error.saveFailed', { message: error.message }), 'error');
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const activate = (id) => {
|
|
93
|
+
const entry = entries.find((candidate) => candidate.id === id);
|
|
94
|
+
if (!entry)
|
|
95
|
+
return;
|
|
96
|
+
if (entry.kind === 'readonly') {
|
|
97
|
+
ctx.notify(t('relay.officialFixed'), 'info');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (entry.kind === 'field' || entry.kind === 'password') {
|
|
101
|
+
ctx.setEditing(id);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (id === 'save') {
|
|
105
|
+
save();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (id === 'reveal') {
|
|
109
|
+
setRevealTokens((current) => !current);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (id === 'env') {
|
|
113
|
+
setShowEnv((current) => !current);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (id === 'regenerate') {
|
|
117
|
+
ctx.run(() => {
|
|
118
|
+
regenerateHostIdentity();
|
|
119
|
+
ctx.reloadConfig();
|
|
120
|
+
ctx.notify(t('relay.regenerated'), 'success');
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (id === 'test') {
|
|
125
|
+
ctx.run(async () => {
|
|
126
|
+
const result = await probeRelay(ctx.config);
|
|
127
|
+
ctx.notify(result.ok
|
|
128
|
+
? t('relay.testOk', { version: result.version ?? '?', hosts: result.hosts ?? 0 })
|
|
129
|
+
: t('relay.testFailed', { message: result.message ?? '' }), result.ok ? 'success' : 'error');
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
useInput((input, key) => {
|
|
134
|
+
const index = entries.findIndex((entry) => entry.id === selected);
|
|
135
|
+
if (key.upArrow)
|
|
136
|
+
setSelected(entries[(index - 1 + entries.length) % entries.length].id);
|
|
137
|
+
else if (key.downArrow)
|
|
138
|
+
setSelected(entries[(index + 1) % entries.length].id);
|
|
139
|
+
else if (key.return)
|
|
140
|
+
activate(selected);
|
|
141
|
+
else if (input === 's')
|
|
142
|
+
save();
|
|
143
|
+
}, { isActive: editingId === null });
|
|
144
|
+
// Choice editors take over the panel so the option list is unambiguous.
|
|
145
|
+
if (editingId === 'mode') {
|
|
146
|
+
return (_jsx(Panel, { title: t('field.mode'), children: _jsx(ChoiceList, { options: [
|
|
147
|
+
...['local', 'lan'].map((mode) => ({
|
|
148
|
+
id: mode,
|
|
149
|
+
label: t(`mode.${mode}`),
|
|
150
|
+
description: t(`mode.${mode}.description`),
|
|
151
|
+
})),
|
|
152
|
+
{
|
|
153
|
+
id: 'official',
|
|
154
|
+
label: t('mode.official'),
|
|
155
|
+
description: t('mode.official.description'),
|
|
156
|
+
},
|
|
157
|
+
{ id: 'remote', label: t('mode.remote'), description: t('mode.remote.description') },
|
|
158
|
+
],
|
|
159
|
+
// Reflects the official relay as its own selection rather than as an
|
|
160
|
+
// indistinguishable "self-hosted" row that happens to hold our URL.
|
|
161
|
+
current: selectedMode(draft), onPick: (choice) => {
|
|
162
|
+
// `official` is a mode the settings model understands: it sets the
|
|
163
|
+
// address along with the mode, and choosing the self-hosted relay
|
|
164
|
+
// clears it again. The screen does not have to keep the two fields
|
|
165
|
+
// in step by hand, which is what let the picker land on "official"
|
|
166
|
+
// while every row below still described a self-hosted relay.
|
|
167
|
+
if (!applyField('mode', choice))
|
|
168
|
+
return;
|
|
169
|
+
if (choice === 'official') {
|
|
170
|
+
// A password typed for somebody else's relay is not a credential
|
|
171
|
+
// the official one wants, and leaving it behind would send it
|
|
172
|
+
// there on the next connection.
|
|
173
|
+
setPassword('');
|
|
174
|
+
setRelayPassword('');
|
|
175
|
+
}
|
|
176
|
+
ctx.setEditing(null);
|
|
177
|
+
}, onCancel: () => ctx.setEditing(null) }) }));
|
|
178
|
+
}
|
|
179
|
+
if (editingId === 'lanHost') {
|
|
180
|
+
const options = addresses.map((address) => ({
|
|
181
|
+
id: address.address,
|
|
182
|
+
label: `${address.address} (${address.name}, ${t(addressKindKey(address.kind))})`,
|
|
183
|
+
}));
|
|
184
|
+
return (_jsx(Panel, { title: t('relay.selectAddress'), children: options.length === 0 ? (_jsx(Text, { color: theme.warn, children: t('relay.noAddresses') })) : (_jsx(ChoiceList, { options: options, current: draft.relay.lanHost || options[0].id, onPick: (address) => {
|
|
185
|
+
applyField('lanHost', address);
|
|
186
|
+
ctx.setEditing(null);
|
|
187
|
+
}, onCancel: () => ctx.setEditing(null) })) }));
|
|
188
|
+
}
|
|
189
|
+
return (_jsxs(Panel, { title: t('relay.title'), children: [draft.relay.mode !== 'remote' ? (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Row, { label: t('relay.listenAddress'), children: _jsx(Text, { color: theme.muted, children: `${bindAddress(draft)}:${draft.relay.port}` }) }), _jsx(Text, { color: theme.muted, children: t('relay.listenAddressHint') })] })) : null, entries.map((entry) => entry.kind === 'action' ? (_jsx(Selectable, { selected: selected === entry.id, onSelect: () => {
|
|
190
|
+
setSelected(entry.id);
|
|
191
|
+
activate(entry.id);
|
|
192
|
+
}, onHover: () => setSelected(entry.id), children: entry.label }, entry.id)) : (_jsx(FieldRow, { label: entry.label, selected: selected === entry.id, onSelect: () => activate(entry.id), onHover: () => setSelected(entry.id), children: entry.kind === 'password' ? (_jsx(TextField, { value: password, placeholder: t('relay.passwordEmpty'), active: editingId === 'password', mask: !revealTokens, onSubmit: (value) => {
|
|
193
|
+
setPassword(value);
|
|
194
|
+
setRelayPassword(value);
|
|
195
|
+
ctx.setEditing(null);
|
|
196
|
+
ctx.notify(t('relay.passwordSaved'), 'success');
|
|
197
|
+
}, onCancel: () => ctx.setEditing(null) })) : entry.kind === 'readonly' ? (
|
|
198
|
+
// Fixed by us, so it is printed rather than offered for editing.
|
|
199
|
+
_jsx(Text, { color: theme.muted, children: `${getField(draft, entry.id) || placeholderText(ctx, entry.id)} (${t('relay.locked')})` })) : entry.fieldKind === 'choice' ? (
|
|
200
|
+
// Choice fields are picked from a list, never typed, so they
|
|
201
|
+
// show the translated label rather than the stored id — and the
|
|
202
|
+
// official relay is named as itself, not as "self-hosted".
|
|
203
|
+
_jsx(Text, { children: t(`mode.${selectedMode(draft)}`) })) : (_jsx(TextField, { value: getField(draft, entry.id), placeholder: placeholderText(ctx, entry.id), active: editingId === entry.id, onSubmit: (value) => {
|
|
204
|
+
if (applyField(entry.id, value))
|
|
205
|
+
ctx.setEditing(null);
|
|
206
|
+
}, onCancel: () => ctx.setEditing(null) })) }, entry.id))), ctx.dirty ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.warn, children: t('hint.unsavedChanges') }) })) : null, draft.relay.mode === 'remote' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: official ? t('relay.officialHint') : t('relay.passwordHint') }) })) : null, _jsx(Box, { marginTop: 1, children: _jsx(Row, { label: t('relay.identity'), children: _jsx(Text, { color: theme.muted, children: ctx.runtime.hostId }) }) }), showEnv ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: theme.border, paddingX: 1, children: [_jsx(Text, { color: theme.muted, children: t('relay.envHint') }), _jsx(Text, { children: relayStartCommand(ctx.config, password) })] })) : null, official ? null : (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('relay.docsHint') }) })), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
|
|
207
|
+
}
|
|
208
|
+
function placeholderText(ctx, id) {
|
|
209
|
+
const raw = getFieldPlaceholder(ctx.draft, id);
|
|
210
|
+
// Placeholders that name a translation key are resolved here so the model
|
|
211
|
+
// stays free of language concerns.
|
|
212
|
+
return raw.startsWith('placeholder.') ? ctx.t(raw) : raw;
|
|
213
|
+
}
|
|
214
|
+
function addressKindKey(kind) {
|
|
215
|
+
return `relay.address${kind.charAt(0).toUpperCase()}${kind.slice(1)}`;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* A modal list of options driven by both arrow keys and the mouse.
|
|
219
|
+
*
|
|
220
|
+
* It owns the cursor so the highlighted row and the keyboard agree; the parent
|
|
221
|
+
* only learns which option was chosen.
|
|
222
|
+
*/
|
|
223
|
+
export function ChoiceList({ options, current, onPick, onCancel, }) {
|
|
224
|
+
const [cursor, setCursor] = useState(() => {
|
|
225
|
+
const index = options.findIndex((option) => option.id === current);
|
|
226
|
+
return index >= 0 ? index : 0;
|
|
227
|
+
});
|
|
228
|
+
const active = options[Math.min(cursor, options.length - 1)];
|
|
229
|
+
useInput((_input, key) => {
|
|
230
|
+
if (key.escape) {
|
|
231
|
+
onCancel();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (key.upArrow)
|
|
235
|
+
setCursor((value) => (value - 1 + options.length) % options.length);
|
|
236
|
+
else if (key.downArrow)
|
|
237
|
+
setCursor((value) => (value + 1) % options.length);
|
|
238
|
+
else if (key.return && active)
|
|
239
|
+
onPick(active.id);
|
|
240
|
+
});
|
|
241
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Menu, { items: options.map((option) => ({ id: option.id, label: option.label })), selectedId: active?.id ?? '', onChange: (id) => setCursor(options.findIndex((option) => option.id === id)), onSelect: onPick }), active?.description ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: active.description }) })) : null] }));
|
|
242
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { theme } from '../theme.js';
|
|
5
|
+
import { Menu, Message, Panel } from '../components/common.js';
|
|
6
|
+
import { readLogTail, restartAll, startAll, stopAll } from '../api.js';
|
|
7
|
+
const LOG_LINES = 8;
|
|
8
|
+
export function Services({ ctx }) {
|
|
9
|
+
const { t, run, status } = ctx;
|
|
10
|
+
const [selected, setSelected] = useState('start');
|
|
11
|
+
const items = [
|
|
12
|
+
{ id: 'start', label: t('services.start') },
|
|
13
|
+
{ id: 'stop', label: t('services.stop') },
|
|
14
|
+
{ id: 'restart', label: t('services.restart') },
|
|
15
|
+
];
|
|
16
|
+
const activate = (id) => {
|
|
17
|
+
// Starting or restarting reads the configuration from disk, so doing it on
|
|
18
|
+
// top of an unsaved draft silently applies the old settings — which reads
|
|
19
|
+
// as "I changed the relay and it never took effect". Stopping is unaffected.
|
|
20
|
+
if (ctx.dirty && id !== 'stop') {
|
|
21
|
+
ctx.notify(t('services.unsavedBlocked'), 'error');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
return run(() => {
|
|
25
|
+
const config = ctx.config;
|
|
26
|
+
if (id === 'start') {
|
|
27
|
+
const result = startAll(config);
|
|
28
|
+
ctx.notify(result.managed
|
|
29
|
+
? t('services.managedNotice', { manager: result.manager ?? '' })
|
|
30
|
+
: t('services.started'), 'success');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (id === 'stop') {
|
|
34
|
+
const result = stopAll(config);
|
|
35
|
+
ctx.notify(result.managed
|
|
36
|
+
? t('services.managedNotice', { manager: result.manager ?? '' })
|
|
37
|
+
: t('services.stopped'), 'success');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const result = restartAll(config);
|
|
41
|
+
ctx.notify(result.managed
|
|
42
|
+
? t('services.managedNotice', { manager: result.manager ?? '' })
|
|
43
|
+
: t('services.restarted'), 'success');
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
useInput((_input, key) => {
|
|
47
|
+
const index = items.findIndex((item) => item.id === selected);
|
|
48
|
+
if (key.upArrow)
|
|
49
|
+
setSelected(items[(index - 1 + items.length) % items.length].id);
|
|
50
|
+
else if (key.downArrow)
|
|
51
|
+
setSelected(items[(index + 1) % items.length].id);
|
|
52
|
+
else if (key.return)
|
|
53
|
+
activate(selected);
|
|
54
|
+
}, { isActive: ctx.editingId === null });
|
|
55
|
+
const relayLog = readLogTail('relay', LOG_LINES);
|
|
56
|
+
const hostLog = readLogTail('host', LOG_LINES);
|
|
57
|
+
return (_jsxs(Panel, { title: t('services.title'), children: [_jsx(Menu, { items: items, selectedId: selected, onChange: setSelected, onSelect: activate }), ctx.dirty ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.warn, children: t('services.unsavedBlocked') }) })) : null, status?.keepalive.installed ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('services.managedNotice', { manager: status.keepalive.manager }) }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.muted, children: t('services.logs') }), _jsx(LogBlock, { title: t('services.logRelay'), lines: relayLog, emptyText: t('services.logEmpty') }), _jsx(LogBlock, { title: t('services.logHost'), lines: hostLog, emptyText: t('services.logEmpty') })] }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
|
|
58
|
+
}
|
|
59
|
+
function LogBlock({ title, lines, emptyText, }) {
|
|
60
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: theme.muted, children: title }), lines.length === 0 ? (_jsx(Text, { color: theme.muted, children: emptyText })) : (lines.map((line, index) => (
|
|
61
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: log lines are positional and may repeat
|
|
62
|
+
_jsx(Text, { color: theme.muted, wrap: "truncate-end", children: line }, index))))] }));
|
|
63
|
+
}
|