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,66 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = path.join(os.homedir(), '.lcluster');
|
|
7
|
+
const USER_TEMPLATES_DIR = path.join(CONFIG_DIR, 'templates');
|
|
8
|
+
const BUILT_IN_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../built-in-templates');
|
|
9
|
+
|
|
10
|
+
export function initTemplatesDir() {
|
|
11
|
+
if (!fs.existsSync(USER_TEMPLATES_DIR)) {
|
|
12
|
+
fs.mkdirSync(USER_TEMPLATES_DIR, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function listTemplates() {
|
|
17
|
+
initTemplatesDir();
|
|
18
|
+
const templates = [];
|
|
19
|
+
|
|
20
|
+
if (fs.existsSync(BUILT_IN_DIR)) {
|
|
21
|
+
const builtIn = fs.readdirSync(BUILT_IN_DIR).filter(f => f.endsWith('.yml'));
|
|
22
|
+
builtIn.forEach(f => templates.push({ name: f, builtin: true }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const user = fs.readdirSync(USER_TEMPLATES_DIR).filter(f => f.endsWith('.yml'));
|
|
26
|
+
user.forEach(f => templates.push({ name: f, builtin: false }));
|
|
27
|
+
|
|
28
|
+
return templates;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getTemplate(name) {
|
|
32
|
+
initTemplatesDir();
|
|
33
|
+
const builtInPath = path.join(BUILT_IN_DIR, name);
|
|
34
|
+
const userPath = path.join(USER_TEMPLATES_DIR, name);
|
|
35
|
+
|
|
36
|
+
if (fs.existsSync(builtInPath)) {
|
|
37
|
+
return fs.readFileSync(builtInPath, 'utf8');
|
|
38
|
+
} else if (fs.existsSync(userPath)) {
|
|
39
|
+
return fs.readFileSync(userPath, 'utf8');
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function saveTemplate(name, content) {
|
|
45
|
+
initTemplatesDir();
|
|
46
|
+
const userPath = path.join(USER_TEMPLATES_DIR, name);
|
|
47
|
+
fs.writeFileSync(userPath, content, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function deleteTemplate(name) {
|
|
51
|
+
initTemplatesDir();
|
|
52
|
+
const template = listTemplates().find(t => t.name === name);
|
|
53
|
+
if (!template) throw new Error('Template not found');
|
|
54
|
+
if (template.builtin) throw new Error('Cannot delete built-in template');
|
|
55
|
+
|
|
56
|
+
const userPath = path.join(USER_TEMPLATES_DIR, name);
|
|
57
|
+
if (fs.existsSync(userPath)) {
|
|
58
|
+
fs.unlinkSync(userPath);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function copyTemplateTo(name, destPath) {
|
|
63
|
+
const content = getTemplate(name);
|
|
64
|
+
if (!content) throw new Error('Template not found');
|
|
65
|
+
fs.writeFileSync(path.join(destPath, 'application.yml'), content, 'utf8');
|
|
66
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import yaml from 'js-yaml';
|
|
2
|
+
|
|
3
|
+
export function validateTemplate(ymlString) {
|
|
4
|
+
try {
|
|
5
|
+
const doc = yaml.load(ymlString);
|
|
6
|
+
if (!doc) return 'Template is empty or invalid.';
|
|
7
|
+
|
|
8
|
+
if (!doc.server || !doc.server.port) {
|
|
9
|
+
return 'Missing required field: server.port';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (!doc.lavalink || !doc.lavalink.server || !doc.lavalink.server.password) {
|
|
13
|
+
return 'Missing required field: lavalink.server.password';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!doc.lavalink.server.sources) {
|
|
17
|
+
return 'Missing required field: lavalink.server.sources';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return null; // Valid
|
|
21
|
+
} catch (e) {
|
|
22
|
+
return `YAML Error: ${e.message}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function extractConfig(ymlString) {
|
|
27
|
+
try {
|
|
28
|
+
const doc = yaml.load(ymlString);
|
|
29
|
+
return {
|
|
30
|
+
port: doc?.server?.port,
|
|
31
|
+
password: doc?.lavalink?.server?.password
|
|
32
|
+
};
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { getTheme } from '../theme/index.js';
|
|
4
|
+
|
|
5
|
+
export default function Border({ title, children, isSelected, flexDirection = 'column', width, height, flexGrow }) {
|
|
6
|
+
const theme = getTheme();
|
|
7
|
+
const color = isSelected ? theme.selectedBorder : theme.border;
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<Box
|
|
11
|
+
borderStyle="round"
|
|
12
|
+
borderColor={color}
|
|
13
|
+
flexDirection={flexDirection}
|
|
14
|
+
width={width}
|
|
15
|
+
height={height}
|
|
16
|
+
flexGrow={flexGrow}
|
|
17
|
+
paddingX={1}
|
|
18
|
+
>
|
|
19
|
+
{title && (
|
|
20
|
+
<Box marginTop={-1} marginLeft={1} marginBottom={1}>
|
|
21
|
+
<Text color={color}> {title} </Text>
|
|
22
|
+
</Box>
|
|
23
|
+
)}
|
|
24
|
+
{children}
|
|
25
|
+
</Box>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { getTheme } from '../theme/index.js';
|
|
4
|
+
|
|
5
|
+
export default function KeyHints({ hints }) {
|
|
6
|
+
const theme = getTheme();
|
|
7
|
+
return (
|
|
8
|
+
<Box marginTop={1}>
|
|
9
|
+
<Text color={theme.textDim}>
|
|
10
|
+
{hints.map((h, i) => (
|
|
11
|
+
<React.Fragment key={i}>
|
|
12
|
+
<Text color={theme.textDim}>[</Text>
|
|
13
|
+
<Text color={theme.text} bold>{h.key}</Text>
|
|
14
|
+
<Text color={theme.textDim}>] {h.label} </Text>
|
|
15
|
+
</React.Fragment>
|
|
16
|
+
))}
|
|
17
|
+
</Text>
|
|
18
|
+
</Box>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
|
|
4
|
+
import { getTheme } from '../theme/index.js';
|
|
5
|
+
|
|
6
|
+
export default function MiniBar({ percent }) {
|
|
7
|
+
const theme = getTheme();
|
|
8
|
+
const totalBlocks = 8;
|
|
9
|
+
const filledBlocks = Math.round((percent / 100) * totalBlocks);
|
|
10
|
+
const emptyBlocks = totalBlocks - filledBlocks;
|
|
11
|
+
|
|
12
|
+
let color = theme.online;
|
|
13
|
+
if (percent > 50 && percent <= 79) color = theme.degraded;
|
|
14
|
+
else if (percent >= 80) color = theme.offline;
|
|
15
|
+
|
|
16
|
+
const filledStr = '▓'.repeat(Math.max(0, filledBlocks));
|
|
17
|
+
const emptyStr = '░'.repeat(Math.max(0, emptyBlocks));
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Text color={color}>{filledStr}{emptyStr}</Text>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { getTheme } from '../theme/index.js';
|
|
4
|
+
import StatusDot from './StatusDot.jsx';
|
|
5
|
+
import MiniBar from './MiniBar.jsx';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
|
|
8
|
+
export default function NodeCard({ node, isSelected }) {
|
|
9
|
+
const theme = getTheme();
|
|
10
|
+
const bg = isSelected ? theme.selected : undefined;
|
|
11
|
+
|
|
12
|
+
const modeIcon = node.mode === 'docker' ? '🐋' : ' ';
|
|
13
|
+
const modeColor = node.mode === 'docker' ? theme.docker : theme.process;
|
|
14
|
+
|
|
15
|
+
let statusColor = theme.offline;
|
|
16
|
+
let statusText = node.status || 'offline';
|
|
17
|
+
if (statusText === 'online') statusColor = theme.online;
|
|
18
|
+
if (statusText === 'degraded' || statusText === 'reconnecting') statusColor = theme.degraded;
|
|
19
|
+
if (statusText === 'waiting') statusColor = theme.textDim;
|
|
20
|
+
|
|
21
|
+
const templateStr = node.mode === 'external' ? '—' : (node.template || 'unknown');
|
|
22
|
+
|
|
23
|
+
// Safe memory calc - max memory is theoretically arbitrary but let's assume 1024mb
|
|
24
|
+
// for the mini bar percentage or just pass the raw mb. The prompt asks for "%" on memory
|
|
25
|
+
// "MEM ▓▓░░░░ 29%" -> node.memory from healthcheck is in MB. We'll fake a % or assume 1GB max.
|
|
26
|
+
const memPercent = Math.min(100, Math.round(((node.memory || 0) / 1024) * 100));
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Box flexDirection="row" paddingX={1} paddingY={1} backgroundColor={bg}>
|
|
30
|
+
<Box width={3}>
|
|
31
|
+
{isSelected ? <Text color={theme.accent}>▶</Text> : <Text> </Text>}
|
|
32
|
+
</Box>
|
|
33
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
34
|
+
{/* Row 1: Status Dot, Name, Uptime */}
|
|
35
|
+
<Box flexDirection="row" justifyContent="space-between">
|
|
36
|
+
<Box>
|
|
37
|
+
<StatusDot status={statusText} />
|
|
38
|
+
<Text color={theme.text} bold> {node.name.padEnd(25)} </Text>
|
|
39
|
+
</Box>
|
|
40
|
+
<Box>
|
|
41
|
+
<Text color={theme.textDim}> ↑ 2d 4h 12m </Text>
|
|
42
|
+
</Box>
|
|
43
|
+
</Box>
|
|
44
|
+
|
|
45
|
+
{/* Row 2: Template, Mode, Status */}
|
|
46
|
+
<Box marginLeft={3} flexDirection="row" justifyContent="space-between">
|
|
47
|
+
<Box>
|
|
48
|
+
<Text color={theme.textDim}>{templateStr} · </Text>
|
|
49
|
+
<Text color={modeColor}>{node.mode} {modeIcon}</Text>
|
|
50
|
+
</Box>
|
|
51
|
+
<Box>
|
|
52
|
+
<StatusDot status={statusText} /> <Text color={statusColor}>{statusText}</Text>
|
|
53
|
+
</Box>
|
|
54
|
+
</Box>
|
|
55
|
+
|
|
56
|
+
{/* Row 3: Stats */}
|
|
57
|
+
<Box marginTop={1} marginLeft={3}>
|
|
58
|
+
<Text color={theme.textDim}>
|
|
59
|
+
♪ <Text color={theme.text}>{node.players || 0}</Text> players ⚡ <Text color={theme.text}>{node.ping || 0}</Text>ms CPU <MiniBar percent={node.cpu || 0} /> <Text color={theme.text}>{node.cpu || 0}</Text>% MEM <MiniBar percent={memPercent} /> <Text color={theme.text}>{memPercent}</Text>%
|
|
60
|
+
</Text>
|
|
61
|
+
</Box>
|
|
62
|
+
</Box>
|
|
63
|
+
</Box>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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 './Border.jsx';
|
|
5
|
+
import NodeCard from './NodeCard.jsx';
|
|
6
|
+
|
|
7
|
+
export default function NodeList({ nodes, onSelectNode, selectedIndex, setSelectedIndex }) {
|
|
8
|
+
const theme = getTheme();
|
|
9
|
+
const visibleCount = 3;
|
|
10
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (selectedIndex < scrollOffset) {
|
|
14
|
+
setScrollOffset(selectedIndex);
|
|
15
|
+
} else if (selectedIndex >= scrollOffset + visibleCount) {
|
|
16
|
+
setScrollOffset(selectedIndex - visibleCount + 1);
|
|
17
|
+
}
|
|
18
|
+
}, [selectedIndex, scrollOffset, visibleCount]);
|
|
19
|
+
|
|
20
|
+
useInput((input, key) => {
|
|
21
|
+
if (key.upArrow) {
|
|
22
|
+
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
|
23
|
+
}
|
|
24
|
+
if (key.downArrow) {
|
|
25
|
+
setSelectedIndex(Math.min(nodes.length - 1, selectedIndex + 1));
|
|
26
|
+
}
|
|
27
|
+
if (key.return) {
|
|
28
|
+
if (nodes[selectedIndex]) {
|
|
29
|
+
onSelectNode(nodes[selectedIndex]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const visibleNodes = nodes.slice(scrollOffset, scrollOffset + visibleCount);
|
|
35
|
+
const hiddenAbove = scrollOffset;
|
|
36
|
+
const hiddenBelow = Math.max(0, nodes.length - (scrollOffset + visibleCount));
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Border title={`nodes (${nodes.length}/10) ─────────────────────────────────────── [↑↓ scroll]`}>
|
|
40
|
+
<Box flexDirection="column" minHeight={visibleCount * 3 + 2}>
|
|
41
|
+
{hiddenAbove > 0 && (
|
|
42
|
+
<Box justifyContent="center">
|
|
43
|
+
<Text color={theme.textDim}>↑ {hiddenAbove} more nodes</Text>
|
|
44
|
+
</Box>
|
|
45
|
+
)}
|
|
46
|
+
|
|
47
|
+
{visibleNodes.map((node, i) => {
|
|
48
|
+
const actualIndex = scrollOffset + i;
|
|
49
|
+
return <NodeCard key={node.name} node={node} isSelected={actualIndex === selectedIndex} />;
|
|
50
|
+
})}
|
|
51
|
+
|
|
52
|
+
{hiddenBelow > 0 && (
|
|
53
|
+
<Box justifyContent="center" marginTop={1}>
|
|
54
|
+
<Text color={theme.textDim}>↓ {hiddenBelow} more nodes</Text>
|
|
55
|
+
</Box>
|
|
56
|
+
)}
|
|
57
|
+
</Box>
|
|
58
|
+
</Border>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { getTheme } from '../theme/index.js';
|
|
4
|
+
import Border from './Border.jsx';
|
|
5
|
+
import StatusDot from './StatusDot.jsx';
|
|
6
|
+
|
|
7
|
+
export default function StatPanel({ nodes, uptime, gatewayPort }) {
|
|
8
|
+
const theme = getTheme();
|
|
9
|
+
|
|
10
|
+
const online = nodes.filter(n => n.status === 'online').length;
|
|
11
|
+
const degraded = nodes.filter(n => n.status === 'degraded').length;
|
|
12
|
+
const offline = nodes.filter(n => n.status === 'offline').length;
|
|
13
|
+
const total = nodes.length;
|
|
14
|
+
|
|
15
|
+
const players = nodes.reduce((sum, n) => sum + (n.players || 0), 0);
|
|
16
|
+
const avgPing = total > 0 ? Math.round(nodes.reduce((sum, n) => sum + (n.ping || 0), 0) / total) : 0;
|
|
17
|
+
|
|
18
|
+
// Build Ping Sparkline
|
|
19
|
+
let globalHist = [];
|
|
20
|
+
for (let i = 0; i < 10; i++) {
|
|
21
|
+
let sum = 0, count = 0;
|
|
22
|
+
nodes.forEach(n => {
|
|
23
|
+
if (n.pingHistory && n.pingHistory.length > i) {
|
|
24
|
+
sum += n.pingHistory[i];
|
|
25
|
+
count++;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
if (count > 0) globalHist.push(Math.round(sum / count));
|
|
29
|
+
}
|
|
30
|
+
const sparkChars = [' ', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
31
|
+
let sparkline = '';
|
|
32
|
+
if (globalHist.length > 0) {
|
|
33
|
+
const maxPing = Math.max(...globalHist, 50);
|
|
34
|
+
sparkline = globalHist.map(p => sparkChars[Math.min(7, Math.floor((p / maxPing) * 8))]).join('');
|
|
35
|
+
}
|
|
36
|
+
sparkline = sparkline.padEnd(10, ' ');
|
|
37
|
+
|
|
38
|
+
const formatUptime = (ms) => {
|
|
39
|
+
const d = Math.floor(ms / (1000 * 60 * 60 * 24));
|
|
40
|
+
const h = Math.floor((ms % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
41
|
+
const m = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
|
|
42
|
+
return `${d}d ${h}h ${m}m`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Border title="quick stats" width="50%">
|
|
47
|
+
<Box flexDirection="row" justifyContent="space-between">
|
|
48
|
+
<Text color={theme.text}> <StatusDot status="online" /> online <Text bold>{online}</Text></Text>
|
|
49
|
+
<Text color={theme.text}> <StatusDot status="degraded" /> warn <Text bold>{degraded}</Text></Text>
|
|
50
|
+
</Box>
|
|
51
|
+
<Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
|
|
52
|
+
<Text color={theme.text}> <StatusDot status="offline" /> offline <Text bold>{offline}</Text></Text>
|
|
53
|
+
<Text color={theme.textDim}> total <Text bold>{total}</Text></Text>
|
|
54
|
+
</Box>
|
|
55
|
+
<Text color={theme.borderDim}>{'─'.repeat(35)}</Text>
|
|
56
|
+
<Box flexDirection="column" marginTop={1}>
|
|
57
|
+
<Text color={theme.text}>players <Text bold>{players} active</Text></Text>
|
|
58
|
+
<Text color={theme.text}>avg ping <Text bold>{avgPing}ms </Text><Text color={theme.accent}>{sparkline}</Text> last 10</Text>
|
|
59
|
+
<Text color={theme.text}>gateway <StatusDot status="online" /> active :{gatewayPort}</Text>
|
|
60
|
+
<Text color={theme.text}>cluster up <Text bold>{formatUptime(uptime)}</Text></Text>
|
|
61
|
+
</Box>
|
|
62
|
+
</Border>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
import { getTheme } from '../theme/index.js';
|
|
4
|
+
|
|
5
|
+
export default function StatusDot({ status }) {
|
|
6
|
+
const theme = getTheme();
|
|
7
|
+
let char = '●';
|
|
8
|
+
let color = theme.online;
|
|
9
|
+
|
|
10
|
+
if (status === 'degraded' || status === 'reconnecting') {
|
|
11
|
+
char = '⚠';
|
|
12
|
+
color = theme.degraded;
|
|
13
|
+
} else if (status === 'offline' || status === 'unreachable') {
|
|
14
|
+
char = '✕';
|
|
15
|
+
color = theme.offline;
|
|
16
|
+
} else if (status === 'waiting') {
|
|
17
|
+
char = '⟳';
|
|
18
|
+
color = theme.textDim;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return <Text color={color}>{char}</Text>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { render, Box, Text, useInput } from 'ink';
|
|
3
|
+
import { getTheme, loadTheme } from './theme/index.js';
|
|
4
|
+
import { loadNodes, getAllNodes } from '../core/registry.js';
|
|
5
|
+
import { startGateway } from '../gateway/server.js';
|
|
6
|
+
import { startHealthCheck } from '../core/healthcheck.js';
|
|
7
|
+
import { initLogBuffer } from '../core/logBuffer.js';
|
|
8
|
+
import Dashboard from './screens/Dashboard.jsx';
|
|
9
|
+
import Logs from './screens/Logs.jsx';
|
|
10
|
+
import Templates from './screens/Templates.jsx';
|
|
11
|
+
// Add Settings as a mock here
|
|
12
|
+
function Settings({ onBack }) {
|
|
13
|
+
const theme = getTheme();
|
|
14
|
+
useInput((i, k) => { if (i === 'q' || k.escape) onBack(); });
|
|
15
|
+
return <Box><Text color={theme.text}>Settings (Press q to back)</Text></Box>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function App() {
|
|
19
|
+
const [screen, setScreen] = useState('dashboard');
|
|
20
|
+
const [targetNode, setTargetNode] = useState(null);
|
|
21
|
+
const [nodes, setNodes] = useState(() => getAllNodes());
|
|
22
|
+
const [gatewayInfo, setGatewayInfo] = useState({ port: '...' });
|
|
23
|
+
const [uptimeStart] = useState(() => new Date());
|
|
24
|
+
|
|
25
|
+
// Rerender loop for nodes
|
|
26
|
+
React.useEffect(() => {
|
|
27
|
+
const timer = setInterval(() => {
|
|
28
|
+
setNodes(getAllNodes());
|
|
29
|
+
}, 1000);
|
|
30
|
+
return () => clearInterval(timer);
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
useInput((input, key) => {
|
|
34
|
+
if (screen === 'dashboard') {
|
|
35
|
+
if (input === 'q') {
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
if (input === 'l') {
|
|
39
|
+
const selected = nodes[0]; // In real impl, get from selectedIndex of NodeList
|
|
40
|
+
if (selected) {
|
|
41
|
+
setTargetNode(selected.name);
|
|
42
|
+
setScreen('logs');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (input === 't') {
|
|
46
|
+
setScreen('templates');
|
|
47
|
+
}
|
|
48
|
+
if (input === 's') {
|
|
49
|
+
setScreen('settings');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (screen === 'logs') return <Logs nodeName={targetNode} onBack={() => setScreen('dashboard')} />;
|
|
55
|
+
if (screen === 'templates') return <Templates onBack={() => setScreen('dashboard')} />;
|
|
56
|
+
if (screen === 'settings') return <Settings onBack={() => setScreen('dashboard')} />;
|
|
57
|
+
|
|
58
|
+
return <Dashboard nodes={nodes} gatewayPort={2333} uptimeStart={uptimeStart} />;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function renderTui() {
|
|
62
|
+
loadTheme();
|
|
63
|
+
loadNodes();
|
|
64
|
+
initLogBuffer();
|
|
65
|
+
startHealthCheck();
|
|
66
|
+
startGateway();
|
|
67
|
+
|
|
68
|
+
render(<App />, { fullscreen: true });
|
|
69
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import fetch from 'node-fetch';
|
|
5
|
+
import { getTheme } from '../theme/index.js';
|
|
6
|
+
import Border from '../components/Border.jsx';
|
|
7
|
+
|
|
8
|
+
export default function DiscordAlerts({ onNext, updateConfig }) {
|
|
9
|
+
const theme = getTheme();
|
|
10
|
+
const [step, setStep] = useState('choice'); // choice, webhook, test
|
|
11
|
+
const [choice, setChoice] = useState(0); // 0 = Yes, 1 = No
|
|
12
|
+
const [webhookUrl, setWebhookUrl] = useState('');
|
|
13
|
+
const [testStatus, setTestStatus] = useState('');
|
|
14
|
+
|
|
15
|
+
useInput(async (input, key) => {
|
|
16
|
+
if (step === 'choice') {
|
|
17
|
+
if (key.upArrow) setChoice(Math.max(0, choice - 1));
|
|
18
|
+
if (key.downArrow) setChoice(Math.min(1, choice + 1));
|
|
19
|
+
if (key.return) {
|
|
20
|
+
if (choice === 0) {
|
|
21
|
+
setStep('webhook');
|
|
22
|
+
} else {
|
|
23
|
+
updateConfig({
|
|
24
|
+
alerts: {
|
|
25
|
+
discord: { enabled: false, webhook: '' },
|
|
26
|
+
desktop: { enabled: true },
|
|
27
|
+
sound: { enabled: false },
|
|
28
|
+
thresholds: { cpu_warn: 90, idle_warn: false }
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
onNext();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} else if (step === 'webhook') {
|
|
35
|
+
if (key.return && webhookUrl.length > 10) {
|
|
36
|
+
setStep('test');
|
|
37
|
+
setTestStatus('Sending test message...');
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(webhookUrl, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
embeds: [{
|
|
45
|
+
title: 'ℹ️ lcluster Test',
|
|
46
|
+
description: 'Webhook configured successfully!',
|
|
47
|
+
color: 0x89B4FA
|
|
48
|
+
}]
|
|
49
|
+
})
|
|
50
|
+
});
|
|
51
|
+
if (res.ok) {
|
|
52
|
+
setTestStatus('Sending test message... ✔ webhook works!');
|
|
53
|
+
setTimeout(() => {
|
|
54
|
+
updateConfig({
|
|
55
|
+
alerts: {
|
|
56
|
+
discord: { enabled: true, webhook: webhookUrl },
|
|
57
|
+
desktop: { enabled: true },
|
|
58
|
+
sound: { enabled: false },
|
|
59
|
+
thresholds: { cpu_warn: 90, idle_warn: false }
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
onNext();
|
|
63
|
+
}, 1500);
|
|
64
|
+
} else {
|
|
65
|
+
setTestStatus('Sending test message... ✕ could not reach webhook. Check the URL.');
|
|
66
|
+
setTimeout(() => setStep('webhook'), 2000);
|
|
67
|
+
}
|
|
68
|
+
} catch (e) {
|
|
69
|
+
setTestStatus('Sending test message... ✕ could not reach webhook. Check the URL.');
|
|
70
|
+
setTimeout(() => setStep('webhook'), 2000);
|
|
71
|
+
}
|
|
72
|
+
} else if (key.escape) {
|
|
73
|
+
setStep('choice');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Box flexDirection="column" width="100%" height="100%" backgroundColor={theme.background}>
|
|
80
|
+
{step === 'choice' && (
|
|
81
|
+
<Border title="discord alerts (optional)" borderColor={theme.border}>
|
|
82
|
+
<Box flexDirection="column" marginX={2} marginY={1}>
|
|
83
|
+
<Text color={theme.text}>Want to get notified in Discord when a node goes down?</Text>
|
|
84
|
+
<Box flexDirection="column" marginTop={1}>
|
|
85
|
+
<Text>{choice === 0 ? <Text color={theme.accent}>▶ </Text> : ' '}Yes — paste a webhook URL</Text>
|
|
86
|
+
<Text>{choice === 1 ? <Text color={theme.accent}>▶ </Text> : ' '}No — skip for now</Text>
|
|
87
|
+
</Box>
|
|
88
|
+
<Box marginTop={1}>
|
|
89
|
+
<Text color={theme.textDim}>You can add this later from the Settings screen.</Text>
|
|
90
|
+
</Box>
|
|
91
|
+
</Box>
|
|
92
|
+
</Border>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
{(step === 'webhook' || step === 'test') && (
|
|
96
|
+
<Border title="discord webhook" borderColor={theme.border}>
|
|
97
|
+
<Box flexDirection="column" marginX={2} marginY={1}>
|
|
98
|
+
<Box>
|
|
99
|
+
<Text color={theme.textDim}>Webhook URL › </Text>
|
|
100
|
+
{step === 'webhook' ? (
|
|
101
|
+
<TextInput value={webhookUrl} onChange={setWebhookUrl} />
|
|
102
|
+
) : (
|
|
103
|
+
<Text>{webhookUrl.substring(0, 30)}...</Text>
|
|
104
|
+
)}
|
|
105
|
+
</Box>
|
|
106
|
+
|
|
107
|
+
{step === 'test' ? (
|
|
108
|
+
<Box marginTop={1}>
|
|
109
|
+
<Text color={theme.text}>{testStatus}</Text>
|
|
110
|
+
</Box>
|
|
111
|
+
) : (
|
|
112
|
+
<Box marginTop={1} flexDirection="column">
|
|
113
|
+
<Text color={theme.textDim}>How to get one:</Text>
|
|
114
|
+
<Text color={theme.textDim}>Discord Server → Settings → Integrations → Webhooks → New</Text>
|
|
115
|
+
</Box>
|
|
116
|
+
)}
|
|
117
|
+
</Box>
|
|
118
|
+
</Border>
|
|
119
|
+
)}
|
|
120
|
+
</Box>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React, { useState } 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 { installSystemd } from '../../system/systemd.js';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
|
|
8
|
+
export default function Done({ onDone, config, nodeConfig, systemContext }) {
|
|
9
|
+
const theme = getTheme();
|
|
10
|
+
const [askSystemd, setAskSystemd] = useState(
|
|
11
|
+
systemContext.os === 'ubuntu' || systemContext.os === 'debian'
|
|
12
|
+
);
|
|
13
|
+
const [sysChoice, setSysChoice] = useState(0); // 0=yes, 1=no
|
|
14
|
+
const [systemdInstalled, setSystemdInstalled] = useState(false);
|
|
15
|
+
|
|
16
|
+
useInput((input, key) => {
|
|
17
|
+
if (askSystemd) {
|
|
18
|
+
if (key.upArrow) setSysChoice(0);
|
|
19
|
+
if (key.downArrow) setSysChoice(1);
|
|
20
|
+
if (key.return) {
|
|
21
|
+
if (sysChoice === 0) {
|
|
22
|
+
const ok = installSystemd();
|
|
23
|
+
setSystemdInstalled(ok);
|
|
24
|
+
}
|
|
25
|
+
setAskSystemd(false);
|
|
26
|
+
}
|
|
27
|
+
} else {
|
|
28
|
+
if (key.return) {
|
|
29
|
+
onDone();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (askSystemd) {
|
|
35
|
+
return (
|
|
36
|
+
<Box flexDirection="column" padding={1} backgroundColor={theme.background}>
|
|
37
|
+
<Border title="auto startup">
|
|
38
|
+
<Box flexDirection="column" marginX={2} marginY={1}>
|
|
39
|
+
<Text color={theme.text}>We detected you are on Ubuntu/Debian.</Text>
|
|
40
|
+
<Text color={theme.text}>Want lcluster to start automatically on boot?</Text>
|
|
41
|
+
<Box marginTop={1} flexDirection="column">
|
|
42
|
+
<Text>{sysChoice === 0 ? <Text color={theme.accent}>▶ </Text> : ' '}Yes — install as systemd service</Text>
|
|
43
|
+
<Text>{sysChoice === 1 ? <Text color={theme.accent}>▶ </Text> : ' '}No — I will start it manually</Text>
|
|
44
|
+
</Box>
|
|
45
|
+
</Box>
|
|
46
|
+
</Border>
|
|
47
|
+
</Box>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Box flexDirection="column" padding={1} backgroundColor={theme.background}>
|
|
53
|
+
<Border title="all done!">
|
|
54
|
+
<Box flexDirection="column" marginX={2} marginY={1}>
|
|
55
|
+
<Text color={theme.online}>✔ Theme saved <Text color={theme.text}>{config.theme}</Text></Text>
|
|
56
|
+
<Text color={theme.online}>✔ Gateway configured <Text color={theme.text}>localhost:{config.gateway?.port || 2333}</Text></Text>
|
|
57
|
+
{nodeConfig && (
|
|
58
|
+
<Text color={theme.online}>✔ Node created <Text color={theme.text}>{nodeConfig.name} ({nodeConfig.mode})</Text></Text>
|
|
59
|
+
)}
|
|
60
|
+
{systemdInstalled && (
|
|
61
|
+
<Text color={theme.online}>✔ Systemd service <Text color={theme.text}>installed</Text></Text>
|
|
62
|
+
)}
|
|
63
|
+
|
|
64
|
+
<Box flexDirection="column" marginTop={1}>
|
|
65
|
+
<Text color={theme.textDim}>Connect your bot to:</Text>
|
|
66
|
+
<Text color={theme.textDim}>host → <Text color={theme.text}>localhost</Text></Text>
|
|
67
|
+
<Text color={theme.textDim}>port → <Text color={theme.text}>{config.gateway?.port || 2333}</Text></Text>
|
|
68
|
+
<Text color={theme.textDim}>password → <Text color={theme.text}>{config.gateway?.password || 'youshallnotpass'}</Text></Text>
|
|
69
|
+
</Box>
|
|
70
|
+
<Box marginTop={1}>
|
|
71
|
+
<Text color={theme.textDim}>Run <Text color={theme.text} bold>lcluster</Text> to open your dashboard.</Text>
|
|
72
|
+
</Box>
|
|
73
|
+
</Box>
|
|
74
|
+
</Border>
|
|
75
|
+
</Box>
|
|
76
|
+
);
|
|
77
|
+
}
|