lcluster 1.0.0
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/README.md +173 -0
- package/built-in-templates/default.yml +14 -0
- package/built-in-templates/high-memory.yml +14 -0
- package/built-in-templates/minimal.yml +14 -0
- package/generate_logos.js +39 -0
- package/package.json +52 -0
- package/src/alerts/desktop.js +15 -0
- package/src/alerts/discord.js +53 -0
- package/src/alerts/index.js +50 -0
- package/src/alerts/sound.js +5 -0
- package/src/cli.js +25 -0
- package/src/core/config.js +17 -0
- package/src/core/events.js +5 -0
- package/src/core/healthcheck.js +126 -0
- package/src/core/loadbalancer.js +26 -0
- package/src/core/logBuffer.js +35 -0
- package/src/core/registry.js +59 -0
- package/src/gateway/guildMap.js +6 -0
- package/src/gateway/proxy.js +30 -0
- package/src/gateway/router.js +6 -0
- package/src/gateway/server.js +35 -0
- package/src/gateway/sessionMap.js +6 -0
- package/src/gateway/v4/rest.js +79 -0
- package/src/gateway/v4/websocket.js +95 -0
- package/src/main.js +133 -0
- package/src/spawner/docker.js +55 -0
- package/src/spawner/process.js +43 -0
- package/src/system/detect.js +74 -0
- package/src/system/systemd.js +33 -0
- package/src/templates/manager.js +66 -0
- package/src/templates/validator.js +36 -0
- package/src/tui/components/Border.jsx +27 -0
- package/src/tui/components/KeyHints.jsx +20 -0
- package/src/tui/components/MiniBar.jsx +22 -0
- package/src/tui/components/NodeCard.jsx +65 -0
- package/src/tui/components/NodeList.jsx +60 -0
- package/src/tui/components/StatPanel.jsx +64 -0
- package/src/tui/components/StatusDot.jsx +22 -0
- package/src/tui/index.jsx +69 -0
- package/src/tui/init/DiscordAlerts.jsx +122 -0
- package/src/tui/init/Done.jsx +77 -0
- package/src/tui/init/GatewaySetup.jsx +59 -0
- package/src/tui/init/NodeSetup.jsx +158 -0
- package/src/tui/init/ThemePicker.jsx +51 -0
- package/src/tui/init/Welcome.jsx +57 -0
- package/src/tui/init/index.jsx +78 -0
- package/src/tui/screens/Dashboard.jsx +51 -0
- package/src/tui/screens/Logs.jsx +59 -0
- package/src/tui/screens/NodeDetail.jsx +57 -0
- package/src/tui/screens/Settings.jsx +154 -0
- package/src/tui/screens/Templates.jsx +42 -0
- package/src/tui/theme/amber.js +16 -0
- package/src/tui/theme/cyberpunk.js +15 -0
- package/src/tui/theme/hacker.js +15 -0
- package/src/tui/theme/index.js +42 -0
- package/src/tui/theme/minimal.js +16 -0
- package/src/tui/theme/neon.js +16 -0
- package/src/tui/theme/ocean.js +15 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import { getTheme } from '../theme/index.js';
|
|
5
|
+
import Border from '../components/Border.jsx';
|
|
6
|
+
|
|
7
|
+
export default function GatewaySetup({ onNext, updateConfig }) {
|
|
8
|
+
const theme = getTheme();
|
|
9
|
+
const [port, setPort] = useState('2333');
|
|
10
|
+
const [password, setPassword] = useState('');
|
|
11
|
+
const [field, setField] = useState(0); // 0=port, 1=password
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
|
|
14
|
+
useInput((input, key) => {
|
|
15
|
+
if (key.return) {
|
|
16
|
+
const p = parseInt(port);
|
|
17
|
+
if (isNaN(p) || p < 1024 || p > 65535) {
|
|
18
|
+
setError('Port must be between 1024 and 65535');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (password.length === 0) {
|
|
22
|
+
setError('Password cannot be empty');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
updateConfig({ gateway: { port: p, password } });
|
|
26
|
+
onNext();
|
|
27
|
+
}
|
|
28
|
+
if (key.tab) {
|
|
29
|
+
setField(field === 0 ? 1 : 0);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Box flexDirection="column" padding={1} backgroundColor={theme.background}>
|
|
35
|
+
<Border title="gateway setup" borderColor={theme.border}>
|
|
36
|
+
<Box flexDirection="column" marginY={1} marginX={2}>
|
|
37
|
+
<Text color={theme.text}>This is the single address your Discord bot will connect to.</Text>
|
|
38
|
+
<Box marginTop={1} flexDirection="column">
|
|
39
|
+
<Box>
|
|
40
|
+
<Text color={theme.textDim}>Port › </Text>
|
|
41
|
+
<TextInput value={port} onChange={setPort} focus={field === 0} />
|
|
42
|
+
</Box>
|
|
43
|
+
<Box>
|
|
44
|
+
<Text color={theme.textDim}>Password › </Text>
|
|
45
|
+
<TextInput value={password} onChange={setPassword} focus={field === 1} mask="•" />
|
|
46
|
+
</Box>
|
|
47
|
+
</Box>
|
|
48
|
+
<Box marginTop={1}>
|
|
49
|
+
<Text color={theme.textDim}>Your bot will connect to: <Text color={theme.accent}>localhost:{port || '2333'}</Text></Text>
|
|
50
|
+
</Box>
|
|
51
|
+
{error && <Text color={theme.offline}>{error}</Text>}
|
|
52
|
+
</Box>
|
|
53
|
+
</Border>
|
|
54
|
+
<Box marginTop={1}>
|
|
55
|
+
<Text color={theme.textDim}>[tab] next field [enter] confirm</Text>
|
|
56
|
+
</Box>
|
|
57
|
+
</Box>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import { getTheme } from '../theme/index.js';
|
|
5
|
+
import Border from '../components/Border.jsx';
|
|
6
|
+
import { listTemplates, getTemplate } from '../../templates/manager.js';
|
|
7
|
+
import { extractConfig } from '../../templates/validator.js';
|
|
8
|
+
|
|
9
|
+
const modes = ['process', 'docker'];
|
|
10
|
+
|
|
11
|
+
export default function NodeSetup({ onNext, systemContext, addInitialNode }) {
|
|
12
|
+
const theme = getTheme();
|
|
13
|
+
const [step, setStep] = useState('choice'); // choice, pick-template, settings, external
|
|
14
|
+
const [choice, setChoice] = useState(0);
|
|
15
|
+
|
|
16
|
+
const [templates] = useState(() => listTemplates());
|
|
17
|
+
const [templateIdx, setTemplateIdx] = useState(0);
|
|
18
|
+
|
|
19
|
+
const [nodeName, setNodeName] = useState('node-main');
|
|
20
|
+
const [nodePort, setNodePort] = useState('2334');
|
|
21
|
+
const [nodeHost, setNodeHost] = useState('localhost');
|
|
22
|
+
const [nodePassword, setNodePassword] = useState('youshallnotpass');
|
|
23
|
+
const [runMode, setRunMode] = useState(systemContext.docker.running ? 1 : 0);
|
|
24
|
+
const [field, setField] = useState(0);
|
|
25
|
+
|
|
26
|
+
useInput((input, key) => {
|
|
27
|
+
if (step === 'choice') {
|
|
28
|
+
if (key.upArrow) setChoice(Math.max(0, choice - 1));
|
|
29
|
+
if (key.downArrow) setChoice(Math.min(3, choice + 1));
|
|
30
|
+
if (key.return) {
|
|
31
|
+
if (choice === 0) setStep('pick-template');
|
|
32
|
+
else if (choice === 1) setStep('settings'); // Skip paste for simplicity in spec mock
|
|
33
|
+
else if (choice === 2) {
|
|
34
|
+
setNodePort('2333');
|
|
35
|
+
setStep('external');
|
|
36
|
+
}
|
|
37
|
+
else if (choice === 3) onNext();
|
|
38
|
+
}
|
|
39
|
+
} else if (step === 'pick-template') {
|
|
40
|
+
if (key.upArrow) setTemplateIdx(Math.max(0, templateIdx - 1));
|
|
41
|
+
if (key.downArrow) setTemplateIdx(Math.min(templates.length - 1, templateIdx + 1));
|
|
42
|
+
if (key.return) {
|
|
43
|
+
const tpl = templates[templateIdx].name;
|
|
44
|
+
const conf = extractConfig(getTemplate(tpl));
|
|
45
|
+
if (conf?.port) setNodePort(String(conf.port));
|
|
46
|
+
setStep('settings');
|
|
47
|
+
}
|
|
48
|
+
} else if (step === 'settings') {
|
|
49
|
+
if (key.tab) setField((field + 1) % 3);
|
|
50
|
+
if (field === 2) {
|
|
51
|
+
if (key.leftArrow) setRunMode(0);
|
|
52
|
+
if (key.rightArrow && systemContext.docker.running) setRunMode(1);
|
|
53
|
+
}
|
|
54
|
+
if (key.return) {
|
|
55
|
+
addInitialNode({
|
|
56
|
+
name: nodeName,
|
|
57
|
+
port: parseInt(nodePort),
|
|
58
|
+
template: choice === 0 ? templates[templateIdx].name : 'custom.yml',
|
|
59
|
+
mode: modes[runMode]
|
|
60
|
+
});
|
|
61
|
+
onNext();
|
|
62
|
+
}
|
|
63
|
+
} else if (step === 'external') {
|
|
64
|
+
if (key.tab) setField((field + 1) % 4);
|
|
65
|
+
if (key.return) {
|
|
66
|
+
addInitialNode({
|
|
67
|
+
name: nodeName,
|
|
68
|
+
host: nodeHost,
|
|
69
|
+
port: parseInt(nodePort),
|
|
70
|
+
password: nodePassword,
|
|
71
|
+
mode: 'external',
|
|
72
|
+
autoConnect: true
|
|
73
|
+
});
|
|
74
|
+
onNext();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Box flexDirection="column" padding={1} backgroundColor={theme.background}>
|
|
81
|
+
{step === 'choice' && (
|
|
82
|
+
<Border title="add your first lavalink node">
|
|
83
|
+
<Box flexDirection="column" marginX={2} marginY={1}>
|
|
84
|
+
<Text color={theme.text}>How do you want to run it?</Text>
|
|
85
|
+
<Box flexDirection="column" marginTop={1}>
|
|
86
|
+
<Text>{choice === 0 ? <Text color={theme.accent}>▶ </Text> : ' '}1. Use a template — lcluster spawns a fresh Lavalink</Text>
|
|
87
|
+
<Text>{choice === 1 ? <Text color={theme.accent}>▶ </Text> : ' '}2. Use my own config — paste my application.yml</Text>
|
|
88
|
+
<Text>{choice === 2 ? <Text color={theme.accent}>▶ </Text> : ' '}3. External node — connect to an already running one</Text>
|
|
89
|
+
<Text>{choice === 3 ? <Text color={theme.accent}>▶ </Text> : ' '}4. Skip for now — I'll add nodes later</Text>
|
|
90
|
+
</Box>
|
|
91
|
+
</Box>
|
|
92
|
+
</Border>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
{step === 'pick-template' && (
|
|
96
|
+
<Border title="choose a template">
|
|
97
|
+
<Box flexDirection="column" marginX={2} marginY={1}>
|
|
98
|
+
{templates.map((t, i) => (
|
|
99
|
+
<Text key={t.name}>
|
|
100
|
+
{templateIdx === i ? <Text color={theme.accent}>▶ </Text> : ' '}
|
|
101
|
+
{t.name} {t.builtin && <Text color={theme.textDim}>(built-in)</Text>}
|
|
102
|
+
</Text>
|
|
103
|
+
))}
|
|
104
|
+
</Box>
|
|
105
|
+
</Border>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{step === 'settings' && (
|
|
109
|
+
<Border title="node settings">
|
|
110
|
+
<Box flexDirection="column" marginX={2} marginY={1}>
|
|
111
|
+
<Box>
|
|
112
|
+
<Text color={theme.textDim}>Node name › </Text>
|
|
113
|
+
<TextInput value={nodeName} onChange={setNodeName} focus={field === 0} />
|
|
114
|
+
</Box>
|
|
115
|
+
<Box>
|
|
116
|
+
<Text color={theme.textDim}>Port › </Text>
|
|
117
|
+
<TextInput value={nodePort} onChange={setNodePort} focus={field === 1} />
|
|
118
|
+
</Box>
|
|
119
|
+
<Box marginTop={1}>
|
|
120
|
+
<Text color={theme.textDim}>Run mode </Text>
|
|
121
|
+
<Text color={field === 2 ? theme.text : theme.textDim}>
|
|
122
|
+
{runMode === 0 ? <Text color={theme.accent}>▶ [ process ]</Text> : ' [ process ]'}
|
|
123
|
+
{runMode === 1 ? <Text color={theme.accent}> ▶ [ docker ]</Text> : (systemContext.docker.running ? ' [ docker ]' : <Text color={theme.textDim}> (docker not available)</Text>)}
|
|
124
|
+
</Text>
|
|
125
|
+
</Box>
|
|
126
|
+
</Box>
|
|
127
|
+
</Border>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
{step === 'external' && (
|
|
131
|
+
<Border title="external node setup">
|
|
132
|
+
<Box flexDirection="column" marginX={2} marginY={1}>
|
|
133
|
+
<Box>
|
|
134
|
+
<Text color={theme.textDim}>Name › </Text>
|
|
135
|
+
<TextInput value={nodeName} onChange={setNodeName} focus={field === 0} />
|
|
136
|
+
</Box>
|
|
137
|
+
<Box>
|
|
138
|
+
<Text color={theme.textDim}>Host › </Text>
|
|
139
|
+
<TextInput value={nodeHost} onChange={setNodeHost} focus={field === 1} />
|
|
140
|
+
</Box>
|
|
141
|
+
<Box>
|
|
142
|
+
<Text color={theme.textDim}>Port › </Text>
|
|
143
|
+
<TextInput value={nodePort} onChange={setNodePort} focus={field === 2} />
|
|
144
|
+
</Box>
|
|
145
|
+
<Box>
|
|
146
|
+
<Text color={theme.textDim}>Password › </Text>
|
|
147
|
+
<TextInput value={nodePassword} onChange={setNodePassword} mask="*" focus={field === 3} />
|
|
148
|
+
</Box>
|
|
149
|
+
<Box marginTop={1} flexDirection="column">
|
|
150
|
+
<Text color={theme.textDim}>lcluster will connect to this node automatically.</Text>
|
|
151
|
+
<Text color={theme.textDim}>Make sure it is running before opening the dashboard.</Text>
|
|
152
|
+
</Box>
|
|
153
|
+
</Box>
|
|
154
|
+
</Border>
|
|
155
|
+
)}
|
|
156
|
+
</Box>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { updateTheme, getTheme } from '../theme/index.js';
|
|
4
|
+
import Border from '../components/Border.jsx';
|
|
5
|
+
|
|
6
|
+
const themes = [
|
|
7
|
+
{ id: 'neon', name: '1. Cyberpunk Neon', desc: 'green glow, dark background' },
|
|
8
|
+
{ id: 'minimal', name: '2. Clean Minimal', desc: 'soft purple, modern and readable' },
|
|
9
|
+
{ id: 'amber', name: '3. Retro Amber', desc: 'old school CRT terminal feel' },
|
|
10
|
+
{ id: 'cyberpunk', name: '4. Cyberpunk', desc: 'neon pink and cyan highlights' },
|
|
11
|
+
{ id: 'hacker', name: '5. Hacker', desc: 'matrix style green on black' },
|
|
12
|
+
{ id: 'ocean', name: '6. Deep Ocean', desc: 'calming deep blues and seafoam' }
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export default function ThemePicker({ onNext, updateConfig }) {
|
|
16
|
+
const [selected, setSelected] = useState(0);
|
|
17
|
+
|
|
18
|
+
useInput((input, key) => {
|
|
19
|
+
if (key.upArrow) setSelected(Math.max(0, selected - 1));
|
|
20
|
+
if (key.downArrow) setSelected(Math.min(themes.length - 1, selected + 1));
|
|
21
|
+
if (key.return) {
|
|
22
|
+
updateConfig({ theme: themes[selected].id });
|
|
23
|
+
onNext();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Re-render theme preview automatically
|
|
28
|
+
updateTheme(themes[selected].id);
|
|
29
|
+
const theme = getTheme();
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Box flexDirection="column" padding={1} backgroundColor={theme.background}>
|
|
33
|
+
<Border title="choose your theme" borderColor={theme.border} padding={1}>
|
|
34
|
+
<Box flexDirection="column" marginY={1} minHeight={5}>
|
|
35
|
+
{themes.map((t, i) => (
|
|
36
|
+
<Box key={t.id} marginX={2}>
|
|
37
|
+
{selected === i ? <Text color={theme.accent}>▶ </Text> : <Text> </Text>}
|
|
38
|
+
<Text color={selected === i ? theme.text : theme.textDim} bold={selected === i}>
|
|
39
|
+
{t.name.padEnd(25)} — {t.desc}
|
|
40
|
+
</Text>
|
|
41
|
+
</Box>
|
|
42
|
+
))}
|
|
43
|
+
</Box>
|
|
44
|
+
<Text color={theme.textDim}> You can change this later in settings.</Text>
|
|
45
|
+
</Border>
|
|
46
|
+
<Box marginTop={1}>
|
|
47
|
+
<Text color={theme.textDim}>[↑↓] select [enter] confirm</Text>
|
|
48
|
+
</Box>
|
|
49
|
+
</Box>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { detectSystem } from '../../system/detect.js';
|
|
4
|
+
|
|
5
|
+
export default function Welcome({ onNext, updateSystemContext }) {
|
|
6
|
+
const [sys, setSys] = useState(null);
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
detectSystem().then(res => {
|
|
10
|
+
setSys(res);
|
|
11
|
+
updateSystemContext(res);
|
|
12
|
+
});
|
|
13
|
+
}, []);
|
|
14
|
+
|
|
15
|
+
useInput((input, key) => {
|
|
16
|
+
if (key.return && sys) {
|
|
17
|
+
if (!sys.port2333.available) {
|
|
18
|
+
// Port taken, cannot block but ask them to change later. Actually spec says:
|
|
19
|
+
// "show error and ask user to choose a different port" -> this is handled in GatewaySetup.
|
|
20
|
+
}
|
|
21
|
+
onNext();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Box flexDirection="column" padding={1}>
|
|
27
|
+
<Box borderStyle="double" paddingX={1} marginBottom={1}>
|
|
28
|
+
<Text color="cyan">
|
|
29
|
+
⬡ welcome to lcluster{'\n'}
|
|
30
|
+
the lavalink cluster manager
|
|
31
|
+
</Text>
|
|
32
|
+
</Box>
|
|
33
|
+
|
|
34
|
+
<Text>Let's get you set up. This will only take a minute.</Text>
|
|
35
|
+
<Box marginY={1}>
|
|
36
|
+
{!sys ? <Text color="yellow">Checking your system...</Text> : (
|
|
37
|
+
<Box flexDirection="column">
|
|
38
|
+
<Text color="green">✔ Node.js {process.version}{' '.padEnd(16)} OK</Text>
|
|
39
|
+
{sys.java.found ?
|
|
40
|
+
<Text color="green">✔ Java {sys.java.version} detected{' '.padEnd(10)} OK</Text> :
|
|
41
|
+
<Text color="yellow">⚠ Java not found (Docker mode only)</Text>
|
|
42
|
+
}
|
|
43
|
+
{sys.docker.found && sys.docker.running ?
|
|
44
|
+
<Text color="green">✔ Docker detected{' '.padEnd(18)} OK</Text> :
|
|
45
|
+
<Text color="yellow">⚠ Docker not running (Process mode only)</Text>
|
|
46
|
+
}
|
|
47
|
+
{sys.port2333.available ?
|
|
48
|
+
<Text color="green">✔ Port 2333 available{' '.padEnd(14)} OK</Text> :
|
|
49
|
+
<Text color="red">✕ Port 2333 taken (Change in next step)</Text>
|
|
50
|
+
}
|
|
51
|
+
</Box>
|
|
52
|
+
)}
|
|
53
|
+
</Box>
|
|
54
|
+
{sys && <Text color="gray">Press [enter] to continue</Text>}
|
|
55
|
+
</Box>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { render, Box } from 'ink';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import yaml from 'js-yaml';
|
|
7
|
+
import Welcome from './Welcome.jsx';
|
|
8
|
+
import ThemePicker from './ThemePicker.jsx';
|
|
9
|
+
import GatewaySetup from './GatewaySetup.jsx';
|
|
10
|
+
import NodeSetup from './NodeSetup.jsx';
|
|
11
|
+
import DiscordAlerts from './DiscordAlerts.jsx';
|
|
12
|
+
import Done from './Done.jsx';
|
|
13
|
+
import { addNode } from '../../core/registry.js';
|
|
14
|
+
import { copyTemplateTo } from '../../templates/manager.js';
|
|
15
|
+
import { updateTheme } from '../theme/index.js';
|
|
16
|
+
|
|
17
|
+
function Wizard({ onComplete }) {
|
|
18
|
+
const [step, setStep] = useState(0);
|
|
19
|
+
const [systemContext, setSystemContext] = useState(null);
|
|
20
|
+
const [config, setConfig] = useState({ theme: 'neon', gateway: { port: 2333, password: 'youshallnotpass' } });
|
|
21
|
+
const [nodeConfig, setNodeConfig] = useState(null);
|
|
22
|
+
|
|
23
|
+
const updateConfig = (subset) => setConfig({ ...config, ...subset });
|
|
24
|
+
|
|
25
|
+
const handleDone = () => {
|
|
26
|
+
// Save config
|
|
27
|
+
const configPath = path.join(os.homedir(), '.lcluster', 'config.yml');
|
|
28
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
29
|
+
|
|
30
|
+
const existing = fs.existsSync(configPath) ? yaml.load(fs.readFileSync(configPath, 'utf8')) : {};
|
|
31
|
+
const finalConf = { ...existing, ...config };
|
|
32
|
+
if (!finalConf.cluster) finalConf.cluster = { startedAt: new Date().toISOString() };
|
|
33
|
+
|
|
34
|
+
fs.writeFileSync(configPath, yaml.dump(finalConf), 'utf8');
|
|
35
|
+
|
|
36
|
+
// Save Node
|
|
37
|
+
if (nodeConfig) {
|
|
38
|
+
addNode({
|
|
39
|
+
name: nodeConfig.name,
|
|
40
|
+
template: nodeConfig.template,
|
|
41
|
+
mode: nodeConfig.mode,
|
|
42
|
+
port: nodeConfig.port,
|
|
43
|
+
status: 'offline',
|
|
44
|
+
players: 0,
|
|
45
|
+
ping: 0,
|
|
46
|
+
cpu: 0,
|
|
47
|
+
memory: 0
|
|
48
|
+
});
|
|
49
|
+
// Copy template
|
|
50
|
+
const NODES_DIR = path.join(os.homedir(), '.lcluster', 'nodes');
|
|
51
|
+
const workingDir = path.join(NODES_DIR, nodeConfig.name);
|
|
52
|
+
if (!fs.existsSync(workingDir)) fs.mkdirSync(workingDir, { recursive: true });
|
|
53
|
+
copyTemplateTo(nodeConfig.template, workingDir);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
onComplete();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Box>
|
|
61
|
+
{step === 0 && <Welcome onNext={() => setStep(1)} updateSystemContext={setSystemContext} />}
|
|
62
|
+
{step === 1 && <ThemePicker onNext={() => setStep(2)} updateConfig={updateConfig} />}
|
|
63
|
+
{step === 2 && <GatewaySetup onNext={() => setStep(3)} updateConfig={updateConfig} />}
|
|
64
|
+
{step === 3 && <NodeSetup onNext={() => setStep(4)} addInitialNode={setNodeConfig} systemContext={systemContext} />}
|
|
65
|
+
{step === 4 && <DiscordAlerts onNext={() => setStep(5)} updateConfig={updateConfig} />}
|
|
66
|
+
{step === 5 && <Done onDone={handleDone} config={config} nodeConfig={nodeConfig} systemContext={systemContext} />}
|
|
67
|
+
</Box>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function initWizard() {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const { unmount } = render(<Wizard onComplete={() => {
|
|
74
|
+
unmount();
|
|
75
|
+
resolve();
|
|
76
|
+
}} />, { fullscreen: true });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { getTheme } from '../theme/index.js';
|
|
4
|
+
import Border from '../components/Border.jsx';
|
|
5
|
+
import NodeList from '../components/NodeList.jsx';
|
|
6
|
+
import StatPanel from '../components/StatPanel.jsx';
|
|
7
|
+
import NodeDetail from './NodeDetail.jsx';
|
|
8
|
+
|
|
9
|
+
export default function Dashboard({ nodes, gatewayPort, uptimeStart, onAction }) {
|
|
10
|
+
const theme = getTheme();
|
|
11
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
12
|
+
const [selectedNode, setSelectedNode] = useState(null);
|
|
13
|
+
|
|
14
|
+
const uptime = Date.now() - new Date(uptimeStart).getTime();
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Box flexDirection="column" paddingX={2} paddingY={1} width="100%" height="100%" backgroundColor={theme.background}>
|
|
18
|
+
<Border borderColor={theme.border}>
|
|
19
|
+
<Box flexDirection="row" justifyContent="space-between" width="100%">
|
|
20
|
+
<Text color={theme.accent}> ⬡ lcluster v1.0.0</Text>
|
|
21
|
+
<Box flexDirection="row" gap={2}>
|
|
22
|
+
<Text color={theme.online}>● {nodes.filter(n => n.status === 'online').length} online</Text>
|
|
23
|
+
<Text color={theme.degraded}>⚠ {nodes.filter(n => n.status === 'degraded' || n.status === 'reconnecting').length} warn</Text>
|
|
24
|
+
</Box>
|
|
25
|
+
<Text color={theme.accent}>gateway :{gatewayPort} <Text color={theme.online}>●</Text></Text>
|
|
26
|
+
</Box>
|
|
27
|
+
</Border>
|
|
28
|
+
|
|
29
|
+
<Box marginY={1}>
|
|
30
|
+
<NodeList
|
|
31
|
+
nodes={nodes}
|
|
32
|
+
selectedIndex={selectedIndex}
|
|
33
|
+
setSelectedIndex={setSelectedIndex}
|
|
34
|
+
onSelectNode={setSelectedNode}
|
|
35
|
+
/>
|
|
36
|
+
</Box>
|
|
37
|
+
|
|
38
|
+
<Box flexDirection="row" gap={2}>
|
|
39
|
+
<StatPanel nodes={nodes} uptime={uptime} gatewayPort={gatewayPort} />
|
|
40
|
+
<NodeDetail node={selectedNode} onClose={() => setSelectedNode(null)} />
|
|
41
|
+
</Box>
|
|
42
|
+
|
|
43
|
+
<Box marginTop={1}>
|
|
44
|
+
<Text color={theme.textDim}>
|
|
45
|
+
[↑↓] navigate [enter] manage [n] new node [t] templates [q] quit{'\n'}
|
|
46
|
+
[r] restart [d] delete [g] gateway [l] logs
|
|
47
|
+
</Text>
|
|
48
|
+
</Box>
|
|
49
|
+
</Box>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { getTheme } from '../theme/index.js';
|
|
4
|
+
import Border from '../components/Border.jsx';
|
|
5
|
+
import { getLogs } from '../../core/logBuffer.js';
|
|
6
|
+
import { events } from '../../core/events.js';
|
|
7
|
+
|
|
8
|
+
export default function Logs({ nodeName, onBack }) {
|
|
9
|
+
const theme = getTheme();
|
|
10
|
+
const [logs, setLogs] = useState([]);
|
|
11
|
+
const [frozen, setFrozen] = useState(false);
|
|
12
|
+
const [scroll, setScroll] = useState(0);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
setLogs(getLogs(nodeName));
|
|
16
|
+
|
|
17
|
+
const handler = (line) => {
|
|
18
|
+
setLogs(prev => {
|
|
19
|
+
const next = [...prev, line];
|
|
20
|
+
if (next.length > 200) next.shift();
|
|
21
|
+
return next;
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
events.on(`log:${nodeName}`, handler);
|
|
26
|
+
return () => events.off(`log:${nodeName}`, handler);
|
|
27
|
+
}, [nodeName]);
|
|
28
|
+
|
|
29
|
+
useInput((input, key) => {
|
|
30
|
+
if (input === 'f') {
|
|
31
|
+
setFrozen(!frozen);
|
|
32
|
+
}
|
|
33
|
+
if (input === 'q' || key.escape) {
|
|
34
|
+
onBack();
|
|
35
|
+
}
|
|
36
|
+
if (frozen) {
|
|
37
|
+
if (key.upArrow) setScroll(s => Math.min(s + 1, Math.max(0, logs.length - 20))); // Assuming 20 lines visible
|
|
38
|
+
if (key.downArrow) setScroll(s => Math.max(0, s - 1));
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Calculate visible logs based on scroll
|
|
43
|
+
const visibleLogs = frozen ? logs.slice(Math.max(0, logs.length - 20 - scroll), logs.length - scroll) : logs.slice(-20);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Box flexDirection="column" width="100%" height="100%" backgroundColor={theme.background}>
|
|
47
|
+
<Border title={`logs: ${nodeName} ${frozen ? '[FROZEN]' : ''}`} borderColor={theme.border} flexGrow={1}>
|
|
48
|
+
<Box flexDirection="column" paddingX={1} flexGrow={1} overflowY="hidden">
|
|
49
|
+
{visibleLogs.map((log, i) => (
|
|
50
|
+
<Text key={i} color={theme.textDim}>{log}</Text>
|
|
51
|
+
))}
|
|
52
|
+
</Box>
|
|
53
|
+
</Border>
|
|
54
|
+
<Box marginTop={1}>
|
|
55
|
+
<Text color={theme.textDim}>[f] freeze/unfreeze [↑↓] scroll (when frozen) [q] back</Text>
|
|
56
|
+
</Box>
|
|
57
|
+
</Box>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { getTheme } from '../theme/index.js';
|
|
4
|
+
import Border from '../components/Border.jsx';
|
|
5
|
+
import StatusDot from '../components/StatusDot.jsx';
|
|
6
|
+
import MiniBar from '../components/MiniBar.jsx';
|
|
7
|
+
|
|
8
|
+
export default function NodeDetail({ node, onClose }) {
|
|
9
|
+
const theme = getTheme();
|
|
10
|
+
|
|
11
|
+
if (!node) return <Border title="node details" width="50%" height={10}></Border>;
|
|
12
|
+
|
|
13
|
+
const modeIcon = node.mode === 'docker' ? '🐋' : ' ';
|
|
14
|
+
const formatUptime = () => {
|
|
15
|
+
// mock for now
|
|
16
|
+
return '6h 12m';
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Border title="node details" width="50%">
|
|
21
|
+
<Box flexDirection="column" paddingX={1}>
|
|
22
|
+
<Box flexDirection="row" justifyContent="space-between">
|
|
23
|
+
<Text color={theme.textDim}>name </Text>
|
|
24
|
+
<Text color={theme.text} bold>{node.name}</Text>
|
|
25
|
+
</Box>
|
|
26
|
+
<Box flexDirection="row" justifyContent="space-between">
|
|
27
|
+
<Text color={theme.textDim}>mode </Text>
|
|
28
|
+
<Text color={node.mode === 'docker' ? theme.docker : theme.process}>{node.mode} {modeIcon}</Text>
|
|
29
|
+
</Box>
|
|
30
|
+
<Box flexDirection="row" justifyContent="space-between" marginTop={1}>
|
|
31
|
+
<Text color={theme.textDim}>ping </Text>
|
|
32
|
+
<Text color={theme.text}>{node.ping || 0}ms <StatusDot status={node.status} /> {node.status}</Text>
|
|
33
|
+
</Box>
|
|
34
|
+
<Box flexDirection="row" justifyContent="space-between">
|
|
35
|
+
<Text color={theme.textDim}>cpu </Text>
|
|
36
|
+
<Text color={theme.text}>{String(node.cpu || 0).padEnd(4)}% <MiniBar percent={node.cpu || 0} /></Text>
|
|
37
|
+
</Box>
|
|
38
|
+
<Box flexDirection="row" justifyContent="space-between">
|
|
39
|
+
<Text color={theme.textDim}>mem </Text>
|
|
40
|
+
<Text color={theme.text}>{String(node.memory || 0).padEnd(4)}mb <MiniBar percent={((node.memory || 0) / 2048) * 100} /></Text>
|
|
41
|
+
</Box>
|
|
42
|
+
<Box flexDirection="row" justifyContent="space-between" marginTop={1}>
|
|
43
|
+
<Text color={theme.textDim}>uptime </Text>
|
|
44
|
+
<Text color={theme.text}>{formatUptime()}</Text>
|
|
45
|
+
</Box>
|
|
46
|
+
<Box flexDirection="row" justifyContent="space-between">
|
|
47
|
+
<Text color={theme.textDim}>status </Text>
|
|
48
|
+
<Text color={theme.text}><StatusDot status={node.status} /> {node.status}</Text>
|
|
49
|
+
</Box>
|
|
50
|
+
|
|
51
|
+
<Box marginTop={1} justifyContent="center">
|
|
52
|
+
<Text color={theme.textDim}>[r] restart [d] delete [l] logs [enter] close</Text>
|
|
53
|
+
</Box>
|
|
54
|
+
</Box>
|
|
55
|
+
</Border>
|
|
56
|
+
);
|
|
57
|
+
}
|