@meanc/otter 0.0.2 → 0.0.4
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/index.ts +14 -4
- package/package.json +1 -1
- package/src/commands/connections.tsx +147 -0
- package/src/commands/core.tsx +89 -11
- package/src/commands/menu.tsx +1 -1
- package/src/commands/proxy.ts +4 -10
- package/src/commands/rules.ts +132 -0
- package/src/commands/smart.ts +151 -0
- package/src/commands/system.ts +1 -1
- package/src/commands/test.ts +70 -0
- package/src/commands/ui.tsx +44 -1
- package/src/components/TrafficGraph.tsx +70 -0
- package/src/utils/api.ts +32 -0
- package/src/utils/paths.ts +2 -0
package/index.ts
CHANGED
|
@@ -7,15 +7,25 @@ import * as system from './src/commands/system';
|
|
|
7
7
|
import * as ui from './src/commands/ui';
|
|
8
8
|
import * as subscribe from './src/commands/subscribe';
|
|
9
9
|
import * as menu from './src/commands/menu';
|
|
10
|
+
import * as test from './src/commands/test';
|
|
11
|
+
import * as connections from './src/commands/connections';
|
|
12
|
+
import * as rules from './src/commands/rules';
|
|
13
|
+
import * as smart from './src/commands/smart';
|
|
10
14
|
import { BIN_PATH } from './src/utils/paths';
|
|
11
15
|
|
|
12
16
|
const cli = cac('ot');
|
|
13
17
|
|
|
14
18
|
// Core
|
|
15
|
-
cli.command('up', 'Start Clash core').alias('start').action(core.start);
|
|
19
|
+
cli.command('up', 'Start Clash core').alias('start').option('--smart', 'Enable smart mode').action(core.start);
|
|
16
20
|
cli.command('down', 'Stop Clash core').alias('stop').action(core.stop);
|
|
17
21
|
cli.command('status', 'Check status').action(core.status);
|
|
18
22
|
cli.command('log', 'Show logs').action(core.log);
|
|
23
|
+
cli.command('conns', 'Manage connections').alias('connections').action(connections.show);
|
|
24
|
+
cli.command('match <url>', 'Check which rule matches the URL').action(rules.match);
|
|
25
|
+
cli.command('smart', 'Start Smart Pilot mode').action(smart.start);
|
|
26
|
+
|
|
27
|
+
// Test
|
|
28
|
+
cli.command('test [group]', 'Speed test proxies').action(test.test);
|
|
19
29
|
|
|
20
30
|
// Subscribe
|
|
21
31
|
cli.command('sub <cmd> [arg1] [arg2]', 'Manage subscriptions')
|
|
@@ -60,7 +70,7 @@ cli.command('use [node]', 'Switch node')
|
|
|
60
70
|
.option('-g, --global <index>', 'Select by global index')
|
|
61
71
|
.option('-p, --proxy <index>', 'Select by proxy index')
|
|
62
72
|
.action(proxy.use);
|
|
63
|
-
cli.command('test', 'Test latency').action(proxy.test);
|
|
73
|
+
// cli.command('test', 'Test latency').action(proxy.test); // Replaced by src/commands/test.ts
|
|
64
74
|
cli.command('best', 'Select best node').action(proxy.best);
|
|
65
75
|
|
|
66
76
|
// System
|
|
@@ -96,11 +106,11 @@ cli.help((sections) => {
|
|
|
96
106
|
|
|
97
107
|
cli.commands.forEach(cmd => {
|
|
98
108
|
const name = cmd.name.split(' ')[0] || '';
|
|
99
|
-
if (['up', 'down', 'status', 'log', 'start', 'stop'].includes(name)) {
|
|
109
|
+
if (['up', 'down', 'status', 'log', 'start', 'stop', 'conns'].includes(name)) {
|
|
100
110
|
groups['Core Commands'].push(cmd);
|
|
101
111
|
} else if (name === 'sub') {
|
|
102
112
|
groups['Subscription Commands'].push(cmd);
|
|
103
|
-
} else if (['ls', 'use', 'test', 'best'].includes(name)) {
|
|
113
|
+
} else if (['ls', 'use', 'test', 'best', 'match', 'smart'].includes(name)) {
|
|
104
114
|
groups['Proxy Commands'].push(cmd);
|
|
105
115
|
} else if (['on', 'off', 'shell', 'mode'].includes(name)) {
|
|
106
116
|
groups['System Commands'].push(cmd);
|
package/package.json
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { render, Box, Text, useInput, useApp } from 'ink';
|
|
3
|
+
import { ClashAPI } from '../utils/api';
|
|
4
|
+
|
|
5
|
+
const formatSize = (bytes: number) => {
|
|
6
|
+
if (bytes === 0) return '0 B';
|
|
7
|
+
const k = 1024;
|
|
8
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
9
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
10
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const ConnectionsApp = () => {
|
|
14
|
+
const { exit } = useApp();
|
|
15
|
+
const [connections, setConnections] = useState<any[]>([]);
|
|
16
|
+
const [info, setInfo] = useState<any>({});
|
|
17
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
18
|
+
const [message, setMessage] = useState('');
|
|
19
|
+
|
|
20
|
+
const refresh = async () => {
|
|
21
|
+
const data = await ClashAPI.getConnections();
|
|
22
|
+
if (data) {
|
|
23
|
+
// Sort by start time (newest first)
|
|
24
|
+
const sorted = (data.connections || []).sort((a: any, b: any) => {
|
|
25
|
+
return new Date(b.start).getTime() - new Date(a.start).getTime();
|
|
26
|
+
});
|
|
27
|
+
setConnections(sorted);
|
|
28
|
+
setInfo({
|
|
29
|
+
downloadTotal: data.downloadTotal,
|
|
30
|
+
uploadTotal: data.uploadTotal,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
refresh();
|
|
37
|
+
const interval = setInterval(refresh, 1000);
|
|
38
|
+
return () => clearInterval(interval);
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
useInput(async (input, key) => {
|
|
42
|
+
if (input === 'q' || key.escape) {
|
|
43
|
+
exit();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (key.upArrow) {
|
|
48
|
+
setSelectedIndex(prev => Math.max(0, prev - 1));
|
|
49
|
+
}
|
|
50
|
+
if (key.downArrow) {
|
|
51
|
+
setSelectedIndex(prev => Math.min(connections.length - 1, prev + 1));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (input === 'x') {
|
|
55
|
+
if (connections.length === 0) return;
|
|
56
|
+
const conn = connections[selectedIndex];
|
|
57
|
+
setMessage(`Closing connection to ${conn.metadata.host || conn.metadata.destinationIP}...`);
|
|
58
|
+
try {
|
|
59
|
+
await ClashAPI.closeConnection(conn.id);
|
|
60
|
+
setMessage('Connection closed.');
|
|
61
|
+
// Don't wait for refresh interval
|
|
62
|
+
setTimeout(refresh, 100);
|
|
63
|
+
} catch (e: any) {
|
|
64
|
+
setMessage(`Error: ${e.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (input === 'X') {
|
|
69
|
+
setMessage('Closing ALL connections...');
|
|
70
|
+
try {
|
|
71
|
+
await ClashAPI.closeAllConnections();
|
|
72
|
+
setMessage('All connections closed.');
|
|
73
|
+
setTimeout(refresh, 100);
|
|
74
|
+
} catch (e: any) {
|
|
75
|
+
setMessage(`Error: ${e.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const visibleRows = 15;
|
|
81
|
+
let startRow = 0;
|
|
82
|
+
if (selectedIndex > visibleRows / 2) {
|
|
83
|
+
startRow = Math.min(selectedIndex - Math.floor(visibleRows / 2), connections.length - visibleRows);
|
|
84
|
+
}
|
|
85
|
+
startRow = Math.max(0, startRow);
|
|
86
|
+
const endRow = Math.min(startRow + visibleRows, connections.length);
|
|
87
|
+
const visibleConnections = connections.slice(startRow, endRow);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<Box flexDirection="column" padding={1}>
|
|
91
|
+
<Box borderStyle="round" borderColor="blue" flexDirection="row" justifyContent="space-between" paddingX={1} marginBottom={1}>
|
|
92
|
+
<Text color="blue" bold>Active Connections: {connections.length}</Text>
|
|
93
|
+
<Box>
|
|
94
|
+
<Text>Total DL: <Text color="green">{formatSize(info.downloadTotal || 0)}</Text> | </Text>
|
|
95
|
+
<Text>Total UL: <Text color="yellow">{formatSize(info.uploadTotal || 0)}</Text></Text>
|
|
96
|
+
</Box>
|
|
97
|
+
</Box>
|
|
98
|
+
|
|
99
|
+
<Box flexDirection="column" borderStyle="single" borderColor="gray" paddingX={1} height={visibleRows + 2}>
|
|
100
|
+
<Box flexDirection="row" marginBottom={1}>
|
|
101
|
+
<Box width={30}><Text underline>Host/IP</Text></Box>
|
|
102
|
+
<Box width={20}><Text underline>Chains</Text></Box>
|
|
103
|
+
<Box width={15}><Text underline>Rule</Text></Box>
|
|
104
|
+
<Box width={15}><Text underline>Type</Text></Box>
|
|
105
|
+
</Box>
|
|
106
|
+
|
|
107
|
+
{visibleConnections.map((conn, idx) => {
|
|
108
|
+
const realIndex = startRow + idx;
|
|
109
|
+
const isSelected = realIndex === selectedIndex;
|
|
110
|
+
const host = conn.metadata.host || conn.metadata.destinationIP;
|
|
111
|
+
const chains = conn.chains.slice().reverse().join(' :: '); // Show last proxy first? No, usually first is entry.
|
|
112
|
+
// Actually chains usually: [ProxyGroup, Node].
|
|
113
|
+
const chainStr = conn.chains.length > 0 ? conn.chains[0] : 'DIRECT';
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<Box key={conn.id} flexDirection="row">
|
|
117
|
+
<Text color={isSelected ? 'cyan' : 'white'}>{isSelected ? '› ' : ' '}</Text>
|
|
118
|
+
<Box width={28}>
|
|
119
|
+
<Text color={isSelected ? 'cyan' : 'white'} wrap="truncate">
|
|
120
|
+
{host}:{conn.metadata.destinationPort}
|
|
121
|
+
</Text>
|
|
122
|
+
</Box>
|
|
123
|
+
<Box width={20}>
|
|
124
|
+
<Text color="gray" wrap="truncate">{chainStr}</Text>
|
|
125
|
+
</Box>
|
|
126
|
+
<Box width={15}>
|
|
127
|
+
<Text color="gray" wrap="truncate">{conn.rule}</Text>
|
|
128
|
+
</Box>
|
|
129
|
+
<Box width={15}>
|
|
130
|
+
<Text color="gray">{conn.metadata.network} ({conn.metadata.type})</Text>
|
|
131
|
+
</Box>
|
|
132
|
+
</Box>
|
|
133
|
+
);
|
|
134
|
+
})}
|
|
135
|
+
</Box>
|
|
136
|
+
|
|
137
|
+
<Box borderStyle="round" borderColor="gray" paddingX={1} marginTop={1}>
|
|
138
|
+
<Text>{message || 'Arrows: Navigate | [x]: Close Selected | [X]: Close ALL | q: Quit'}</Text>
|
|
139
|
+
</Box>
|
|
140
|
+
</Box>
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const show = async () => {
|
|
145
|
+
const { waitUntilExit } = render(<ConnectionsApp />);
|
|
146
|
+
await waitUntilExit();
|
|
147
|
+
};
|
package/src/commands/core.tsx
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
|
-
import { render, Text, Box, useApp, useInput } from 'ink';
|
|
2
|
+
import { render, Text, Box, useApp, useInput, useStdout } from 'ink';
|
|
3
3
|
import { CoreManager } from '../utils/core';
|
|
4
4
|
import { ClashAPI } from '../utils/api';
|
|
5
5
|
import { SubscriptionManager } from '../utils/subscription';
|
|
6
|
-
import { LOG_FILE } from '../utils/paths';
|
|
6
|
+
import { LOG_FILE, SMART_LOG_FILE, SMART_PID_FILE } from '../utils/paths';
|
|
7
7
|
import { spawn } from 'child_process';
|
|
8
8
|
import fs from 'fs-extra';
|
|
9
9
|
import * as system from './system';
|
|
10
|
+
import { TrafficGraph } from '../components/TrafficGraph';
|
|
10
11
|
|
|
11
12
|
const formatSpeed = (bytes: number) => {
|
|
12
13
|
if (bytes === 0) return '0 B/s';
|
|
@@ -44,10 +45,59 @@ const getSpeedPercent = (bytes: number) => {
|
|
|
44
45
|
return Math.min(1, p);
|
|
45
46
|
};
|
|
46
47
|
|
|
48
|
+
const startSmartPilot = async () => {
|
|
49
|
+
// Check if already running
|
|
50
|
+
if (await fs.pathExists(SMART_PID_FILE)) {
|
|
51
|
+
try {
|
|
52
|
+
const pid = parseInt(await fs.readFile(SMART_PID_FILE, 'utf-8'), 10);
|
|
53
|
+
// Check if process exists
|
|
54
|
+
process.kill(pid, 0);
|
|
55
|
+
console.log('Smart Pilot is already running.');
|
|
56
|
+
return;
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// Process doesn't exist, remove stale PID file
|
|
59
|
+
await fs.remove(SMART_PID_FILE);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await fs.ensureFile(SMART_LOG_FILE);
|
|
64
|
+
const logFd = await fs.open(SMART_LOG_FILE, 'a');
|
|
65
|
+
|
|
66
|
+
const child = spawn(process.argv[0] || 'bun', [process.argv[1] || '', 'smart'], {
|
|
67
|
+
detached: true,
|
|
68
|
+
stdio: ['ignore', logFd, logFd]
|
|
69
|
+
}) as any;
|
|
70
|
+
|
|
71
|
+
if (child.pid) {
|
|
72
|
+
await fs.writeFile(SMART_PID_FILE, child.pid.toString());
|
|
73
|
+
child.unref();
|
|
74
|
+
console.log(`Smart Pilot started with PID ${child.pid}`);
|
|
75
|
+
} else {
|
|
76
|
+
console.error('Failed to start Smart Pilot');
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const stopSmartPilot = async () => {
|
|
81
|
+
if (await fs.pathExists(SMART_PID_FILE)) {
|
|
82
|
+
const pid = parseInt(await fs.readFile(SMART_PID_FILE, 'utf-8'), 10);
|
|
83
|
+
if (!isNaN(pid)) {
|
|
84
|
+
try {
|
|
85
|
+
process.kill(pid);
|
|
86
|
+
console.log('Smart Pilot stopped.');
|
|
87
|
+
} catch (e) {
|
|
88
|
+
// Process might be already gone
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
await fs.remove(SMART_PID_FILE);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
47
94
|
|
|
48
|
-
export const start = async () => {
|
|
95
|
+
export const start = async (options: { smart?: boolean } = {}) => {
|
|
49
96
|
try {
|
|
50
97
|
await CoreManager.start();
|
|
98
|
+
if (options.smart) {
|
|
99
|
+
await startSmartPilot();
|
|
100
|
+
}
|
|
51
101
|
await system.on();
|
|
52
102
|
} catch (error: any) {
|
|
53
103
|
console.error('Error starting core:', error.message);
|
|
@@ -61,6 +111,7 @@ export const stop = async () => {
|
|
|
61
111
|
console.log('System proxy is enabled. Disabling it...');
|
|
62
112
|
await system.off();
|
|
63
113
|
}
|
|
114
|
+
await stopSmartPilot();
|
|
64
115
|
await CoreManager.stop();
|
|
65
116
|
} catch (error: any) {
|
|
66
117
|
console.error('Error stopping core:', error.message);
|
|
@@ -70,8 +121,11 @@ export const stop = async () => {
|
|
|
70
121
|
export const status = async () => {
|
|
71
122
|
const StatusApp = () => {
|
|
72
123
|
const { exit } = useApp();
|
|
124
|
+
const { stdout } = useStdout();
|
|
125
|
+
const [width, setWidth] = useState(stdout.columns);
|
|
73
126
|
const [coreStatus, setCoreStatus] = useState<any>(null);
|
|
74
127
|
const [traffic, setTraffic] = useState({ up: 0, down: 0 });
|
|
128
|
+
const [history, setHistory] = useState<{ up: number[], down: number[] }>({ up: [], down: [] });
|
|
75
129
|
const [subInfo, setSubInfo] = useState<{ active: string | null, count: number } | null>(null);
|
|
76
130
|
const [proxyCount, setProxyCount] = useState<number>(0);
|
|
77
131
|
|
|
@@ -81,6 +135,18 @@ export const status = async () => {
|
|
|
81
135
|
}
|
|
82
136
|
});
|
|
83
137
|
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const onResize = () => setWidth(stdout.columns);
|
|
140
|
+
stdout.on('resize', onResize);
|
|
141
|
+
return () => {
|
|
142
|
+
stdout.off('resize', onResize);
|
|
143
|
+
};
|
|
144
|
+
}, [stdout]);
|
|
145
|
+
|
|
146
|
+
// Calculate dynamic graph width (terminal width - padding)
|
|
147
|
+
// Padding: marginLeft(2) + padding(1) + border(2) approx 6-8 chars
|
|
148
|
+
const graphWidth = Math.max(20, Math.min(width - 8, 120));
|
|
149
|
+
|
|
84
150
|
useEffect(() => {
|
|
85
151
|
let ws: WebSocket | null = null;
|
|
86
152
|
|
|
@@ -110,6 +176,12 @@ export const status = async () => {
|
|
|
110
176
|
ws.onmessage = (event) => {
|
|
111
177
|
const data = JSON.parse(event.data as string);
|
|
112
178
|
setTraffic(data);
|
|
179
|
+
setHistory(prev => {
|
|
180
|
+
// Keep enough history for wide screens
|
|
181
|
+
const newUp = [...prev.up, data.up].slice(-120);
|
|
182
|
+
const newDown = [...prev.down, data.down].slice(-120);
|
|
183
|
+
return { up: newUp, down: newDown };
|
|
184
|
+
});
|
|
113
185
|
};
|
|
114
186
|
} catch (e) { }
|
|
115
187
|
}
|
|
@@ -181,16 +253,22 @@ export const status = async () => {
|
|
|
181
253
|
<Text color="green" bold>Network Traffic</Text>
|
|
182
254
|
|
|
183
255
|
<Box marginLeft={2} marginTop={1} flexDirection="column">
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
<Box width={
|
|
187
|
-
|
|
256
|
+
{/* Upload Section */}
|
|
257
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
258
|
+
<Box flexDirection="row" width={graphWidth} justifyContent="space-between" marginBottom={0}>
|
|
259
|
+
<Text>Upload</Text>
|
|
260
|
+
<Text color="yellow">{formatSpeed(traffic.up)}</Text>
|
|
261
|
+
</Box>
|
|
262
|
+
<TrafficGraph data={history.up} width={graphWidth} height={4} color="yellow" />
|
|
188
263
|
</Box>
|
|
189
264
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
<Box width={
|
|
193
|
-
|
|
265
|
+
{/* Download Section */}
|
|
266
|
+
<Box flexDirection="column">
|
|
267
|
+
<Box flexDirection="row" width={graphWidth} justifyContent="space-between" marginBottom={0}>
|
|
268
|
+
<Text>Download</Text>
|
|
269
|
+
<Text color="green">{formatSpeed(traffic.down)}</Text>
|
|
270
|
+
</Box>
|
|
271
|
+
<TrafficGraph data={history.down} width={graphWidth} height={4} color="green" />
|
|
194
272
|
</Box>
|
|
195
273
|
</Box>
|
|
196
274
|
</Box>
|
package/src/commands/menu.tsx
CHANGED
|
@@ -26,7 +26,7 @@ const Menu: React.FC<MenuProps> = ({ onSelect }) => {
|
|
|
26
26
|
return (
|
|
27
27
|
<Box flexDirection="column" padding={1}>
|
|
28
28
|
<Text color="green">{LOGO}</Text>
|
|
29
|
-
<Text color="gray" italic> v0.0.
|
|
29
|
+
<Text color="gray" italic> v0.0.4</Text>
|
|
30
30
|
|
|
31
31
|
<Box marginTop={1} marginBottom={1}>
|
|
32
32
|
<Text>
|
package/src/commands/proxy.ts
CHANGED
|
@@ -147,7 +147,7 @@ export const test = async () => {
|
|
|
147
147
|
};
|
|
148
148
|
|
|
149
149
|
export const best = async () => {
|
|
150
|
-
console.log(chalk.blue('Testing
|
|
150
|
+
console.log(chalk.blue('Testing Proxy group to find the best one...'));
|
|
151
151
|
|
|
152
152
|
const data = await ClashAPI.getProxies();
|
|
153
153
|
if (!data || !data.proxies) {
|
|
@@ -156,17 +156,11 @@ export const best = async () => {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
const proxies = data.proxies;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (groups.length === 0) {
|
|
162
|
-
console.error(chalk.red('No selector groups found in config.'));
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const mainGroup: any = groups.find((g: any) => ['Proxy', 'GLOBAL'].includes(g.name)) || groups[0];
|
|
159
|
+
// Explicitly look for 'Proxy' group
|
|
160
|
+
const mainGroup: any = proxies['Proxy'];
|
|
167
161
|
|
|
168
162
|
if (!mainGroup) {
|
|
169
|
-
console.error(chalk.red('
|
|
163
|
+
console.error(chalk.red("Group 'Proxy' not found."));
|
|
170
164
|
return;
|
|
171
165
|
}
|
|
172
166
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { ClashAPI } from '../utils/api';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
interface Rule {
|
|
5
|
+
type: string;
|
|
6
|
+
payload: string;
|
|
7
|
+
proxy: string;
|
|
8
|
+
size?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const match = async (urlOrHost: string) => {
|
|
12
|
+
if (!urlOrHost) {
|
|
13
|
+
console.error(chalk.red('Please provide a URL or hostname.'));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Clean input to get hostname
|
|
18
|
+
let hostname = urlOrHost;
|
|
19
|
+
try {
|
|
20
|
+
if (!hostname.startsWith('http')) {
|
|
21
|
+
hostname = 'http://' + hostname;
|
|
22
|
+
}
|
|
23
|
+
hostname = new URL(hostname).hostname;
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.error(chalk.red('Invalid URL or hostname.'));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log(chalk.blue(`Analyzing rules for: ${chalk.bold(hostname)}`));
|
|
30
|
+
|
|
31
|
+
const config = await ClashAPI.getConfigs();
|
|
32
|
+
if (config && config.mode === 'Global') {
|
|
33
|
+
console.log(chalk.yellow('Warning: System is in Global mode. All traffic goes to GLOBAL/Proxy.'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const baseUrl = await (ClashAPI as any).getBaseUrl();
|
|
37
|
+
const headers = await (ClashAPI as any).getHeaders();
|
|
38
|
+
|
|
39
|
+
let rules: Rule[] = [];
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`${baseUrl}/rules`, { headers });
|
|
42
|
+
if (!res.ok) throw new Error(res.statusText);
|
|
43
|
+
const data = await res.json() as { rules: Rule[] };
|
|
44
|
+
rules = data?.rules || [];
|
|
45
|
+
} catch (e: any) {
|
|
46
|
+
console.error(chalk.red(`Failed to fetch rules: ${e.message}`));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(chalk.gray(`Loaded ${rules.length} rules.`));
|
|
51
|
+
|
|
52
|
+
let matchedRule: Rule | null = null;
|
|
53
|
+
|
|
54
|
+
for (const rule of rules) {
|
|
55
|
+
const type = rule.type.toLowerCase();
|
|
56
|
+
const payload = rule.payload;
|
|
57
|
+
const lowerHost = hostname.toLowerCase();
|
|
58
|
+
const lowerPayload = payload.toLowerCase();
|
|
59
|
+
|
|
60
|
+
let isMatch = false;
|
|
61
|
+
|
|
62
|
+
switch (type) {
|
|
63
|
+
case 'domain':
|
|
64
|
+
isMatch = lowerHost === lowerPayload;
|
|
65
|
+
break;
|
|
66
|
+
case 'domainsuffix':
|
|
67
|
+
case 'domain-suffix':
|
|
68
|
+
isMatch = lowerHost === lowerPayload || lowerHost.endsWith('.' + lowerPayload);
|
|
69
|
+
break;
|
|
70
|
+
case 'domainkeyword':
|
|
71
|
+
case 'domain-keyword':
|
|
72
|
+
isMatch = lowerHost.includes(lowerPayload);
|
|
73
|
+
break;
|
|
74
|
+
case 'match':
|
|
75
|
+
isMatch = true;
|
|
76
|
+
break;
|
|
77
|
+
case 'geoip':
|
|
78
|
+
case 'ip-cidr':
|
|
79
|
+
case 'ip-cidr6':
|
|
80
|
+
// Cannot match IP rules without DNS resolution
|
|
81
|
+
// We skip them but maybe log a warning if verbose
|
|
82
|
+
break;
|
|
83
|
+
default:
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (isMatch) {
|
|
88
|
+
matchedRule = rule;
|
|
89
|
+
break; // Clash uses first match
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (matchedRule) {
|
|
94
|
+
console.log('');
|
|
95
|
+
console.log(chalk.green('✔ Match Found!'));
|
|
96
|
+
console.log('─'.repeat(30));
|
|
97
|
+
console.log(`Type: ${chalk.cyan(matchedRule.type)}`);
|
|
98
|
+
console.log(`Payload: ${chalk.yellow(matchedRule.payload || '(Final)')}`);
|
|
99
|
+
console.log(`Proxy: ${chalk.magenta(matchedRule.proxy)}`);
|
|
100
|
+
|
|
101
|
+
// Resolve Proxy Details
|
|
102
|
+
try {
|
|
103
|
+
const proxyData = await ClashAPI.getProxies();
|
|
104
|
+
if (proxyData && proxyData.proxies) {
|
|
105
|
+
const proxyName = matchedRule.proxy;
|
|
106
|
+
const proxyInfo = proxyData.proxies[proxyName];
|
|
107
|
+
|
|
108
|
+
if (proxyInfo) {
|
|
109
|
+
console.log(` └─ Type: ${proxyInfo.type}`);
|
|
110
|
+
if (proxyInfo.now) {
|
|
111
|
+
console.log(` └─ Now: ${chalk.green(proxyInfo.now)}`);
|
|
112
|
+
}
|
|
113
|
+
// If the 'now' node is also a group, we could recurse, but one level is usually enough context
|
|
114
|
+
if (proxyInfo.now && proxyData.proxies[proxyInfo.now]) {
|
|
115
|
+
const nextHop = proxyData.proxies[proxyInfo.now];
|
|
116
|
+
if (nextHop.type === 'Selector' || nextHop.type === 'URLTest') {
|
|
117
|
+
console.log(` └─ Next: ${chalk.green(nextHop.now)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch (e) {
|
|
123
|
+
// Ignore proxy resolution errors
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log('─'.repeat(30));
|
|
127
|
+
} else {
|
|
128
|
+
console.log('');
|
|
129
|
+
console.log(chalk.red('✘ No matching rule found (and no Match rule present?).'));
|
|
130
|
+
console.log('Traffic might fall through to default behavior.');
|
|
131
|
+
}
|
|
132
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { ClashAPI } from '../utils/api';
|
|
2
|
+
import { SubscriptionManager } from '../utils/subscription';
|
|
3
|
+
import * as system from './system';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
|
|
6
|
+
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
|
7
|
+
|
|
8
|
+
const findBestNode = async (nodes: string[]): Promise<{ name: string, delay: number } | null> => {
|
|
9
|
+
if (nodes.length === 0) return null;
|
|
10
|
+
|
|
11
|
+
// Test up to 5 nodes concurrently to save time
|
|
12
|
+
const candidates = nodes.slice(0, 5);
|
|
13
|
+
|
|
14
|
+
const results = await Promise.all(candidates.map(async (node) => {
|
|
15
|
+
try {
|
|
16
|
+
const res = await ClashAPI.getDelay(node) as { delay: number };
|
|
17
|
+
return { name: node, delay: res.delay };
|
|
18
|
+
} catch {
|
|
19
|
+
return { name: node, delay: Infinity };
|
|
20
|
+
}
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Filter valid results and sort
|
|
24
|
+
const valid = results.filter(r => r.delay < 2000).sort((a, b) => a.delay - b.delay);
|
|
25
|
+
|
|
26
|
+
if (valid.length > 0) {
|
|
27
|
+
const best = valid[0];
|
|
28
|
+
if (best) {
|
|
29
|
+
return { name: best.name, delay: best.delay };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const start = async () => {
|
|
36
|
+
console.log(chalk.cyan(chalk.bold('🦦 Otter Smart Pilot Initiated')));
|
|
37
|
+
console.log(chalk.gray('Monitoring network health and managing failover...'));
|
|
38
|
+
console.log(chalk.gray('Press Ctrl+C to stop.'));
|
|
39
|
+
console.log('─'.repeat(40));
|
|
40
|
+
|
|
41
|
+
while (true) {
|
|
42
|
+
try {
|
|
43
|
+
// 1. Health Check
|
|
44
|
+
const data = await ClashAPI.getProxies();
|
|
45
|
+
if (!data || !data.proxies) {
|
|
46
|
+
console.log(chalk.red('Otter Core not reachable. Retrying in 5s...'));
|
|
47
|
+
await sleep(5000);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const proxyGroup = data.proxies['Proxy'];
|
|
52
|
+
if (!proxyGroup) {
|
|
53
|
+
console.log(chalk.red("Group 'Proxy' not found."));
|
|
54
|
+
await sleep(10000);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const currentNode = proxyGroup.now;
|
|
59
|
+
process.stdout.write(`[${new Date().toLocaleTimeString()}] Checking ${currentNode}... `);
|
|
60
|
+
|
|
61
|
+
let isHealthy = false;
|
|
62
|
+
try {
|
|
63
|
+
const res = await ClashAPI.getDelay(currentNode) as { delay: number };
|
|
64
|
+
if (res.delay < 1500) {
|
|
65
|
+
console.log(chalk.green(`OK (${res.delay}ms)`));
|
|
66
|
+
isHealthy = true;
|
|
67
|
+
} else {
|
|
68
|
+
console.log(chalk.yellow(`High Latency (${res.delay}ms)`));
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
console.log(chalk.red('Timeout'));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (isHealthy) {
|
|
75
|
+
await sleep(10000); // Check every 10s if healthy
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 2. Fallback Logic
|
|
80
|
+
console.log(chalk.yellow('⚠ Connection unstable. Initiating fallback protocol...'));
|
|
81
|
+
|
|
82
|
+
const allNodes: string[] = proxyGroup.all;
|
|
83
|
+
|
|
84
|
+
// Regex patterns
|
|
85
|
+
const regions = [
|
|
86
|
+
{ name: 'Hong Kong', regex: /HK|Hong Kong|香港/i },
|
|
87
|
+
{ name: 'Japan', regex: /JP|Japan|日本/i },
|
|
88
|
+
{ name: 'USA', regex: /US|USA|United States|美国/i },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
let fallbackSuccess = false;
|
|
92
|
+
|
|
93
|
+
for (const region of regions) {
|
|
94
|
+
console.log(chalk.blue(`Searching for healthy ${region.name} nodes...`));
|
|
95
|
+
const regionNodes = allNodes.filter(n => region.regex.test(n));
|
|
96
|
+
|
|
97
|
+
const best = await findBestNode(regionNodes);
|
|
98
|
+
if (best) {
|
|
99
|
+
console.log(chalk.green(`✔ Found ${region.name} node: ${best.name} (${best.delay}ms)`));
|
|
100
|
+
await ClashAPI.switchProxy('Proxy', best.name);
|
|
101
|
+
console.log(chalk.green(`Switched to ${best.name}`));
|
|
102
|
+
fallbackSuccess = true;
|
|
103
|
+
break;
|
|
104
|
+
} else {
|
|
105
|
+
console.log(chalk.gray(`✘ No healthy ${region.name} nodes found.`));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (fallbackSuccess) {
|
|
110
|
+
await sleep(5000); // Wait a bit before next check
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 3. Disaster Recovery
|
|
115
|
+
console.log(chalk.red.bold('‼ All fallback regions failed. Initiating EMERGENCY UPDATE...'));
|
|
116
|
+
|
|
117
|
+
console.log(chalk.yellow('1. Disabling System Proxy...'));
|
|
118
|
+
await system.off(true); // silent
|
|
119
|
+
|
|
120
|
+
console.log(chalk.yellow('2. Updating Subscriptions...'));
|
|
121
|
+
const subData = await SubscriptionManager.getData();
|
|
122
|
+
for (const sub of subData.subscriptions) {
|
|
123
|
+
process.stdout.write(`Updating ${sub.name}... `);
|
|
124
|
+
try {
|
|
125
|
+
await SubscriptionManager.update(sub.name);
|
|
126
|
+
console.log(chalk.green('Done'));
|
|
127
|
+
} catch (e: any) {
|
|
128
|
+
console.log(chalk.red('Failed'));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(chalk.yellow('3. Re-enabling System Proxy...'));
|
|
133
|
+
await system.on(true);
|
|
134
|
+
|
|
135
|
+
console.log(chalk.yellow('4. Selecting Best Node...'));
|
|
136
|
+
// Simple best node selection from all nodes
|
|
137
|
+
const bestAny = await findBestNode(allNodes);
|
|
138
|
+
if (bestAny) {
|
|
139
|
+
await ClashAPI.switchProxy('Proxy', bestAny.name);
|
|
140
|
+
console.log(chalk.green(`Recovered! Switched to ${bestAny.name}`));
|
|
141
|
+
} else {
|
|
142
|
+
console.log(chalk.red('Recovery failed. No reachable nodes found. Retrying in 1 minute...'));
|
|
143
|
+
await sleep(60000);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
} catch (e: any) {
|
|
147
|
+
console.error(chalk.red(`Smart Pilot Error: ${e.message}`));
|
|
148
|
+
await sleep(5000);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
package/src/commands/system.ts
CHANGED
|
@@ -134,7 +134,7 @@ export const mode = async (modeName?: string) => {
|
|
|
134
134
|
return;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
const validModes = ['global', 'rule', 'direct'];
|
|
137
|
+
const validModes = ['global', 'rule', 'direct', 'script'];
|
|
138
138
|
const m = modeName.toLowerCase();
|
|
139
139
|
if (!validModes.includes(m)) {
|
|
140
140
|
console.error(chalk.red(`Invalid mode: ${modeName}. Valid modes: ${validModes.join(', ')}`));
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ClashAPI } from '../utils/api';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
export const test = async (target: string = 'Proxy') => {
|
|
5
|
+
console.log(chalk.blue(`Testing latency for: ${target}...`));
|
|
6
|
+
|
|
7
|
+
const data = await ClashAPI.getProxies();
|
|
8
|
+
if (!data || !data.proxies) {
|
|
9
|
+
console.error(chalk.red('Failed to fetch proxies. Is Otter running?'));
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const item = data.proxies[target];
|
|
14
|
+
if (!item) {
|
|
15
|
+
console.error(chalk.red(`Proxy or Group '${target}' not found.`));
|
|
16
|
+
const availableGroups = Object.values(data.proxies)
|
|
17
|
+
.filter((p: any) => p.type === 'Selector')
|
|
18
|
+
.map((p: any) => p.name)
|
|
19
|
+
.join(', ');
|
|
20
|
+
console.log(chalk.gray(`Available groups: ${availableGroups}`));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let nodes: string[] = [];
|
|
25
|
+
if (item.all && Array.isArray(item.all)) {
|
|
26
|
+
nodes = item.all;
|
|
27
|
+
} else {
|
|
28
|
+
// It's a single node
|
|
29
|
+
nodes = [target];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(chalk.bold(`Starting speed test for ${nodes.length} nodes...`));
|
|
33
|
+
console.log('─'.repeat(40));
|
|
34
|
+
|
|
35
|
+
const printResult = (name: string, delay: number | string) => {
|
|
36
|
+
let delayDisplay = '';
|
|
37
|
+
let nameDisplay = name;
|
|
38
|
+
|
|
39
|
+
if (typeof delay === 'number') {
|
|
40
|
+
const ms = delay;
|
|
41
|
+
let color = chalk.green;
|
|
42
|
+
if (ms > 400) color = chalk.yellow;
|
|
43
|
+
if (ms > 1000) color = chalk.red;
|
|
44
|
+
|
|
45
|
+
delayDisplay = color(`${ms}ms`.padEnd(8));
|
|
46
|
+
} else {
|
|
47
|
+
delayDisplay = chalk.gray('Timeout '.padEnd(8));
|
|
48
|
+
nameDisplay = chalk.gray(name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(`${delayDisplay} ${nameDisplay}`);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Process in batches
|
|
55
|
+
const batchSize = 10;
|
|
56
|
+
for (let i = 0; i < nodes.length; i += batchSize) {
|
|
57
|
+
const batch = nodes.slice(i, i + batchSize);
|
|
58
|
+
await Promise.all(batch.map(async (node) => {
|
|
59
|
+
try {
|
|
60
|
+
const res = await ClashAPI.getDelay(node) as { delay: number };
|
|
61
|
+
printResult(node, res.delay);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
printResult(node, 'Timeout');
|
|
64
|
+
}
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
console.log('─'.repeat(40));
|
|
69
|
+
console.log(chalk.green('Test completed.'));
|
|
70
|
+
};
|
package/src/commands/ui.tsx
CHANGED
|
@@ -178,6 +178,49 @@ const TuiApp = () => {
|
|
|
178
178
|
return;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
if (input === 'b') {
|
|
182
|
+
if (groups.length === 0) return;
|
|
183
|
+
const currentGroup = groups[selectedGroupIndex];
|
|
184
|
+
setMessage(`Testing ${currentGroup.name} for best node...`);
|
|
185
|
+
|
|
186
|
+
// Run test in background
|
|
187
|
+
(async () => {
|
|
188
|
+
try {
|
|
189
|
+
const candidates: string[] = currentGroup.all;
|
|
190
|
+
let bestNode = '';
|
|
191
|
+
let minDelay = Infinity;
|
|
192
|
+
|
|
193
|
+
// Test all nodes
|
|
194
|
+
const results = await Promise.all(candidates.map(async (node) => {
|
|
195
|
+
try {
|
|
196
|
+
const res = await ClashAPI.getDelay(node);
|
|
197
|
+
return { node, delay: res.delay };
|
|
198
|
+
} catch {
|
|
199
|
+
return { node, delay: Infinity };
|
|
200
|
+
}
|
|
201
|
+
}));
|
|
202
|
+
|
|
203
|
+
results.forEach(r => {
|
|
204
|
+
if (r.delay < minDelay && r.delay > 0) {
|
|
205
|
+
minDelay = r.delay;
|
|
206
|
+
bestNode = r.node;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (bestNode) {
|
|
211
|
+
await ClashAPI.switchProxy(currentGroup.name, bestNode);
|
|
212
|
+
setMessage(`Auto-selected: ${bestNode} (${minDelay}ms)`);
|
|
213
|
+
await refreshProxies();
|
|
214
|
+
} else {
|
|
215
|
+
setMessage('No reachable nodes found.');
|
|
216
|
+
}
|
|
217
|
+
} catch (e: any) {
|
|
218
|
+
setMessage(`Error: ${e.message}`);
|
|
219
|
+
}
|
|
220
|
+
})();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
181
224
|
if (groups.length === 0) return;
|
|
182
225
|
|
|
183
226
|
const currentGroup = groups[selectedGroupIndex];
|
|
@@ -310,7 +353,7 @@ const TuiApp = () => {
|
|
|
310
353
|
|
|
311
354
|
{/* Footer / Status Bar */}
|
|
312
355
|
<Box borderStyle="round" borderColor="gray" paddingX={1}>
|
|
313
|
-
<Text>{message || 'Arrows: Navigate | Enter: Select | Tab: Switch Panel | [s]: SysProxy | [m]: Mode | q: Quit'}</Text>
|
|
356
|
+
<Text>{message || 'Arrows: Navigate | Enter: Select | Tab: Switch Panel | [s]: SysProxy | [m]: Mode | [b]: Best | q: Quit'}</Text>
|
|
314
357
|
</Box>
|
|
315
358
|
</Box>
|
|
316
359
|
);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
|
|
4
|
+
interface TrafficGraphProps {
|
|
5
|
+
data: number[];
|
|
6
|
+
width?: number;
|
|
7
|
+
height?: number;
|
|
8
|
+
color?: string;
|
|
9
|
+
max?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const formatSize = (bytes: number) => {
|
|
13
|
+
if (bytes === 0) return '0 B';
|
|
14
|
+
const k = 1024;
|
|
15
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
16
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
17
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + (sizes[i] || 'B');
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const TrafficGraph: React.FC<TrafficGraphProps> = ({
|
|
21
|
+
data,
|
|
22
|
+
width = 40,
|
|
23
|
+
height = 4,
|
|
24
|
+
color = 'green',
|
|
25
|
+
max
|
|
26
|
+
}) => {
|
|
27
|
+
// Pad with 0s at the start if data is less than width
|
|
28
|
+
const filledData = [...(new Array(Math.max(0, width - data.length)).fill(0)), ...data].slice(-width);
|
|
29
|
+
|
|
30
|
+
// Calculate max value for scaling
|
|
31
|
+
// Use a minimum max value (e.g. 1KB) to avoid flat lines on 0
|
|
32
|
+
const maxValue = max || Math.max(...filledData, 1024);
|
|
33
|
+
|
|
34
|
+
const rows = [];
|
|
35
|
+
const chars = [' ', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
36
|
+
|
|
37
|
+
for (let y = height - 1; y >= 0; y--) {
|
|
38
|
+
let rowStr = '';
|
|
39
|
+
for (let x = 0; x < width; x++) {
|
|
40
|
+
const value = filledData[x];
|
|
41
|
+
const ratio = value / maxValue;
|
|
42
|
+
const scaledValue = ratio * height;
|
|
43
|
+
|
|
44
|
+
if (scaledValue >= y + 1) {
|
|
45
|
+
rowStr += '█';
|
|
46
|
+
} else if (scaledValue > y) {
|
|
47
|
+
const remainder = scaledValue - y;
|
|
48
|
+
const charIndex = Math.floor(remainder * (chars.length - 1));
|
|
49
|
+
rowStr += chars[charIndex];
|
|
50
|
+
} else {
|
|
51
|
+
rowStr += ' ';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
rows.push(rowStr);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
|
59
|
+
<Box flexDirection="column">
|
|
60
|
+
{rows.map((row, i) => (
|
|
61
|
+
<Text key={i} color={color}>{row}</Text>
|
|
62
|
+
))}
|
|
63
|
+
</Box>
|
|
64
|
+
<Box justifyContent="space-between" marginTop={0}>
|
|
65
|
+
<Text color="gray" dimColor>0</Text>
|
|
66
|
+
<Text color="gray" dimColor>{formatSize(maxValue)}</Text>
|
|
67
|
+
</Box>
|
|
68
|
+
</Box>
|
|
69
|
+
);
|
|
70
|
+
};
|
package/src/utils/api.ts
CHANGED
|
@@ -100,6 +100,38 @@ export class ClashAPI {
|
|
|
100
100
|
if (!res.ok) throw new Error(`Failed to update config: ${res.statusText}`);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
static async getConnections(): Promise<any> {
|
|
104
|
+
const baseUrl = await this.getBaseUrl();
|
|
105
|
+
const headers = await this.getHeaders();
|
|
106
|
+
try {
|
|
107
|
+
const res = await fetch(`${baseUrl}/connections`, { headers });
|
|
108
|
+
if (!res.ok) throw new Error(`API Error: ${res.statusText}`);
|
|
109
|
+
return await res.json();
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
static async closeConnection(id: string) {
|
|
116
|
+
const baseUrl = await this.getBaseUrl();
|
|
117
|
+
const headers = await this.getHeaders();
|
|
118
|
+
const res = await fetch(`${baseUrl}/connections/${id}`, {
|
|
119
|
+
method: 'DELETE',
|
|
120
|
+
headers
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) throw new Error(`Failed to close connection: ${res.statusText}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
static async closeAllConnections() {
|
|
126
|
+
const baseUrl = await this.getBaseUrl();
|
|
127
|
+
const headers = await this.getHeaders();
|
|
128
|
+
const res = await fetch(`${baseUrl}/connections`, {
|
|
129
|
+
method: 'DELETE',
|
|
130
|
+
headers
|
|
131
|
+
});
|
|
132
|
+
if (!res.ok) throw new Error(`Failed to close all connections: ${res.statusText}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
103
135
|
static async getTrafficUrl(): Promise<string> {
|
|
104
136
|
const baseUrl = await this.getBaseUrl();
|
|
105
137
|
return baseUrl.replace('http://', 'ws://') + '/traffic';
|
package/src/utils/paths.ts
CHANGED
|
@@ -4,7 +4,9 @@ import os from 'os';
|
|
|
4
4
|
export const HOME_DIR = path.join(os.homedir(), '.otter');
|
|
5
5
|
export const CONFIG_FILE = path.join(HOME_DIR, 'config.yaml');
|
|
6
6
|
export const PID_FILE = path.join(HOME_DIR, 'otter.pid');
|
|
7
|
+
export const SMART_PID_FILE = path.join(HOME_DIR, 'smart.pid');
|
|
7
8
|
export const LOG_FILE = path.join(HOME_DIR, 'otter.log');
|
|
9
|
+
export const SMART_LOG_FILE = path.join(HOME_DIR, 'smart.log');
|
|
8
10
|
export const BIN_PATH = path.resolve(import.meta.dir, '../../bin/mihomo');
|
|
9
11
|
export const SUBSCRIPTIONS_FILE = path.join(HOME_DIR, 'subscriptions.json');
|
|
10
12
|
export const PROFILES_DIR = path.join(HOME_DIR, 'profiles');
|