73 | {!metrics ? (
74 |
75 |
76 |
Loading...
77 |
78 | ) : (
79 |
80 | {/* CPU */}
81 |
82 |
83 | CPU
84 |
85 | {cpuUsage.toFixed(1)}%
86 |
87 |
88 |
89 |
= 80 ? 'bg-red-500' : cpuUsage >= 60 ? 'bg-yellow-500' : 'bg-green-500'
92 | }`}
93 | style={{ width: `${Math.min(cpuUsage, 100)}%` }}
94 | />
95 |
96 |
97 |
98 | {/* Memory */}
99 |
100 |
101 | Memory
102 |
103 | {memPercent.toFixed(1)}%
104 |
105 |
106 |
107 |
= 80 ? 'bg-red-500' : memPercent >= 60 ? 'bg-yellow-500' : 'bg-green-500'
110 | }`}
111 | style={{ width: `${Math.min(memPercent, 100)}%` }}
112 | />
113 |
114 |
115 | {formatMemory(memUsed)} / {formatMemory(memTotal)}
116 |
117 |
118 |
119 | {/* View Details Button */}
120 |
126 |
127 | )}
128 |
129 |
130 | );
131 | }
132 |
133 |
134 |
--------------------------------------------------------------------------------
/backend/src/routes/servers.routes.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import * as storageService from '../services/storage.service.js';
3 | import sshPool from '../services/ssh.service.js';
4 | import { authenticateToken } from '../middleware/auth.middleware.js';
5 |
6 | const router = express.Router();
7 |
8 | // All routes require authentication
9 | router.use(authenticateToken);
10 |
11 | // Get all servers
12 | router.get('/', async (req, res) => {
13 | try {
14 | const servers = await storageService.getServers();
15 |
16 | // Don't send passwords/keys to frontend
17 | const sanitizedServers = servers.map(server => ({
18 | id: server.id,
19 | name: server.name,
20 | host: server.host,
21 | port: server.port,
22 | username: server.username,
23 | group: server.group,
24 | status: server.status,
25 | createdAt: server.createdAt,
26 | updatedAt: server.updatedAt,
27 | lastSeen: server.lastSeen
28 | }));
29 |
30 | res.json(sanitizedServers);
31 | } catch (error) {
32 | res.status(500).json({ error: error.message });
33 | }
34 | });
35 |
36 | // Get server by ID
37 | router.get('/:id', async (req, res) => {
38 | try {
39 | const server = await storageService.getServerById(req.params.id);
40 |
41 | if (!server) {
42 | return res.status(404).json({ error: 'Server not found' });
43 | }
44 |
45 | // Don't send passwords/keys to frontend
46 | const sanitizedServer = {
47 | id: server.id,
48 | name: server.name,
49 | host: server.host,
50 | port: server.port,
51 | username: server.username,
52 | group: server.group,
53 | status: server.status,
54 | createdAt: server.createdAt,
55 | updatedAt: server.updatedAt,
56 | lastSeen: server.lastSeen
57 | };
58 |
59 | res.json(sanitizedServer);
60 | } catch (error) {
61 | res.status(500).json({ error: error.message });
62 | }
63 | });
64 |
65 | // Add new server
66 | router.post('/', async (req, res) => {
67 | try {
68 | const { name, host, port, username, password, privateKey, group } = req.body;
69 |
70 | if (!name || !host || !username) {
71 | return res.status(400).json({ error: 'Name, host, and username are required' });
72 | }
73 |
74 | if (!password && !privateKey) {
75 | return res.status(400).json({ error: 'Password or private key is required' });
76 | }
77 |
78 | const server = await storageService.addServer({
79 | name,
80 | host,
81 | port: port || 22,
82 | username,
83 | password,
84 | privateKey,
85 | group: group || 'default'
86 | });
87 |
88 | // Test connection
89 | const testResult = await sshPool.testConnection(server);
90 |
91 | if (!testResult.success) {
92 | // Connection failed, but server is still saved
93 | return res.status(201).json({
94 | ...server,
95 | password: undefined,
96 | privateKey: undefined,
97 | connectionTest: testResult
98 | });
99 | }
100 |
101 | res.status(201).json({
102 | id: server.id,
103 | name: server.name,
104 | host: server.host,
105 | port: server.port,
106 | username: server.username,
107 | group: server.group,
108 | status: 'online',
109 | connectionTest: testResult
110 | });
111 | } catch (error) {
112 | res.status(500).json({ error: error.message });
113 | }
114 | });
115 |
116 | // Update server
117 | router.put('/:id', async (req, res) => {
118 | try {
119 | const { name, host, port, username, password, privateKey, group } = req.body;
120 |
121 | const updates = {};
122 | if (name !== undefined) updates.name = name;
123 | if (host !== undefined) updates.host = host;
124 | if (port !== undefined) updates.port = port;
125 | if (username !== undefined) updates.username = username;
126 | if (password !== undefined) updates.password = password;
127 | if (privateKey !== undefined) updates.privateKey = privateKey;
128 | if (group !== undefined) updates.group = group;
129 |
130 | const server = await storageService.updateServer(req.params.id, updates);
131 |
132 | // Remove existing connection to force reconnect with new credentials
133 | sshPool.removeConnection(req.params.id);
134 |
135 | res.json({
136 | id: server.id,
137 | name: server.name,
138 | host: server.host,
139 | port: server.port,
140 | username: server.username,
141 | group: server.group,
142 | status: server.status
143 | });
144 | } catch (error) {
145 | res.status(500).json({ error: error.message });
146 | }
147 | });
148 |
149 | // Delete server
150 | router.delete('/:id', async (req, res) => {
151 | try {
152 | await storageService.deleteServer(req.params.id);
153 |
154 | // Remove SSH connection
155 | sshPool.removeConnection(req.params.id);
156 |
157 | res.json({ success: true, message: 'Server deleted' });
158 | } catch (error) {
159 | res.status(500).json({ error: error.message });
160 | }
161 | });
162 |
163 | // Test server connection
164 | router.post('/:id/test', async (req, res) => {
165 | try {
166 | const server = await storageService.getServerById(req.params.id);
167 |
168 | if (!server) {
169 | return res.status(404).json({ error: 'Server not found' });
170 | }
171 |
172 | const result = await sshPool.testConnection(server);
173 | res.json(result);
174 | } catch (error) {
175 | res.status(500).json({ error: error.message });
176 | }
177 | });
178 |
179 | export default router;
180 |
181 |
182 |
183 |
184 |
--------------------------------------------------------------------------------
/backend/src/utils/commands.js:
--------------------------------------------------------------------------------
1 | // Linux commands untuk monitoring
2 | export const COMMANDS = {
3 | // CPU Info
4 | CPU_INFO: "top -bn1 | grep 'Cpu(s)' | sed 's/.*, *\\([0-9.]*\\)%* id.*/\\1/' | awk '{print 100 - $1}'",
5 | CPU_LOAD: "uptime | awk -F'load average:' '{print $2}' | awk '{print $1, $2, $3}'",
6 | CPU_CORES: "nproc",
7 | CPU_DETAILED: "top -bn2 -d 0.5 | grep '^%Cpu'",
8 |
9 | // Memory Info
10 | MEMORY_INFO: "free -m | grep Mem | awk '{print $2,$3,$4,$5,$6,$7}'",
11 | MEMORY_DETAILED: "free -h",
12 | SWAP_INFO: "free -m | grep Swap | awk '{print $2,$3,$4}'",
13 |
14 | // Disk Info
15 | DISK_INFO: "df -h | grep -E '^/dev/' | awk '{print $1,$2,$3,$4,$5,$6}'",
16 | DISK_USAGE: "df -h",
17 | DISK_IO: "iostat -dx 1 2 | tail -n +4",
18 |
19 | // Network Info
20 | NETWORK_INTERFACES: "ip -o link show | awk '{print $2}' | sed 's/://g'",
21 | NETWORK_STATS: "cat /proc/net/dev | tail -n +3",
22 | NETWORK_INFO: "ip -s link",
23 |
24 | // System Info
25 | SYSTEM_INFO: "uname -a",
26 | HOSTNAME: "hostname",
27 | UPTIME: "uptime -p",
28 | UPTIME_SECONDS: "awk '{print $1}' /proc/uptime",
29 |
30 | // Process Info
31 | PROCESS_COUNT: "ps aux | wc -l",
32 | TOP_PROCESSES: "ps aux --sort=-%mem | head -11",
33 | TOP_CPU_PROCESSES: "ps aux --sort=-%cpu | head -6 | tail -5",
34 | TOP_MEM_PROCESSES: "ps aux --sort=-%mem | head -6 | tail -5",
35 |
36 | // Docker (if available)
37 | DOCKER_CHECK: "command -v docker >/dev/null 2>&1 && echo 'true' || echo 'false'",
38 | DOCKER_PS: "docker ps --format '{{.ID}}|{{.Names}}|{{.Status}}|{{.Image}}'",
39 | DOCKER_STATS: "docker stats --no-stream --format '{{.Container}}|{{.CPUPerc}}|{{.MemUsage}}|{{.NetIO}}'",
40 |
41 | // GPU (Nvidia)
42 | NVIDIA_CHECK: "command -v nvidia-smi >/dev/null 2>&1 && echo 'true' || echo 'false'",
43 | NVIDIA_SMI: "nvidia-smi --query-gpu=index,name,temperature.gpu,utilization.gpu,memory.used,memory.total --format=csv,noheader,nounits"
44 | };
45 |
46 | // Parse command outputs
47 | export function parseCpuInfo(output) {
48 | const usage = parseFloat(output.trim());
49 | return {
50 | usage: isNaN(usage) ? 0 : Math.round(usage * 10) / 10,
51 | timestamp: Date.now()
52 | };
53 | }
54 |
55 | export function parseCpuLoad(output) {
56 | const parts = output.trim().replace(/,/g, '').split(/\s+/);
57 | return {
58 | load1: parseFloat(parts[0]) || 0,
59 | load5: parseFloat(parts[1]) || 0,
60 | load15: parseFloat(parts[2]) || 0
61 | };
62 | }
63 |
64 | export function parseMemoryInfo(output) {
65 | const parts = output.trim().split(/\s+/);
66 | return {
67 | total: parseInt(parts[0]) || 0,
68 | used: parseInt(parts[1]) || 0,
69 | free: parseInt(parts[2]) || 0,
70 | shared: parseInt(parts[3]) || 0,
71 | buffers: parseInt(parts[4]) || 0,
72 | available: parseInt(parts[5]) || 0,
73 | timestamp: Date.now()
74 | };
75 | }
76 |
77 | export function parseSwapInfo(output) {
78 | const parts = output.trim().split(/\s+/);
79 | return {
80 | total: parseInt(parts[0]) || 0,
81 | used: parseInt(parts[1]) || 0,
82 | free: parseInt(parts[2]) || 0
83 | };
84 | }
85 |
86 | export function parseDiskInfo(output) {
87 | const lines = output.trim().split('\n');
88 | return lines.map(line => {
89 | const parts = line.trim().split(/\s+/);
90 | return {
91 | filesystem: parts[0],
92 | size: parts[1],
93 | used: parts[2],
94 | available: parts[3],
95 | usePercent: parseInt(parts[4]) || 0,
96 | mountPoint: parts[5]
97 | };
98 | });
99 | }
100 |
101 | export function parseNetworkStats(output) {
102 | const lines = output.trim().split('\n');
103 | const interfaces = [];
104 |
105 | for (const line of lines) {
106 | const parts = line.trim().split(/\s+/);
107 | if (parts.length >= 17) {
108 | const interfaceName = parts[0].replace(':', '');
109 | interfaces.push({
110 | interface: interfaceName,
111 | rxBytes: parseInt(parts[1]) || 0,
112 | rxPackets: parseInt(parts[2]) || 0,
113 | txBytes: parseInt(parts[9]) || 0,
114 | txPackets: parseInt(parts[10]) || 0,
115 | timestamp: Date.now()
116 | });
117 | }
118 | }
119 |
120 | return interfaces;
121 | }
122 |
123 | export function parseTopProcesses(output) {
124 | const lines = output.trim().split('\n').slice(1); // Skip header
125 | return lines.map(line => {
126 | const parts = line.trim().split(/\s+/);
127 | return {
128 | user: parts[0],
129 | pid: parts[1],
130 | cpu: parseFloat(parts[2]) || 0,
131 | mem: parseFloat(parts[3]) || 0,
132 | command: parts.slice(10).join(' ')
133 | };
134 | }).slice(0, 5);
135 | }
136 |
137 | export function parseDockerContainers(output) {
138 | if (!output || output.trim() === '') return [];
139 |
140 | const lines = output.trim().split('\n');
141 | return lines.map(line => {
142 | const parts = line.split('|');
143 | return {
144 | id: parts[0],
145 | name: parts[1],
146 | status: parts[2],
147 | image: parts[3]
148 | };
149 | });
150 | }
151 |
152 | export function parseDockerStats(output) {
153 | if (!output || output.trim() === '') return [];
154 |
155 | const lines = output.trim().split('\n');
156 | return lines.map(line => {
157 | const parts = line.split('|');
158 | return {
159 | container: parts[0],
160 | cpu: parts[1],
161 | memory: parts[2],
162 | network: parts[3]
163 | };
164 | });
165 | }
166 |
167 | export function parseNvidiaSmi(output) {
168 | if (!output || output.trim() === '') return [];
169 |
170 | const lines = output.trim().split('\n');
171 | return lines.map(line => {
172 | const parts = line.split(',').map(p => p.trim());
173 | return {
174 | index: parts[0],
175 | name: parts[1],
176 | temperature: parseInt(parts[2]) || 0,
177 | utilization: parseInt(parts[3]) || 0,
178 | memoryUsed: parseInt(parts[4]) || 0,
179 | memoryTotal: parseInt(parts[5]) || 0
180 | };
181 | });
182 | }
183 |
184 |
185 |
--------------------------------------------------------------------------------
/backend/src/services/ssh.service.js:
--------------------------------------------------------------------------------
1 | import { Client } from 'ssh2';
2 |
3 | class SSHConnection {
4 | constructor(serverConfig) {
5 | this.config = serverConfig;
6 | this.client = null;
7 | this.connected = false;
8 | this.connecting = false;
9 | }
10 |
11 | async connect() {
12 | // If already connected and connection is alive, reuse it
13 | if (this.connected && this.client) {
14 | return true;
15 | }
16 |
17 | if (this.connecting) {
18 | // Wait for existing connection attempt
19 | await new Promise(resolve => setTimeout(resolve, 100));
20 | return this.connected;
21 | }
22 |
23 | // Clean up any existing client
24 | if (this.client) {
25 | try {
26 | this.client.end();
27 | } catch (e) {
28 | // Ignore cleanup errors
29 | }
30 | this.client = null;
31 | }
32 |
33 | this.connecting = true;
34 |
35 | return new Promise((resolve, reject) => {
36 | this.client = new Client();
37 |
38 | const timeout = setTimeout(() => {
39 | this.disconnect();
40 | this.connecting = false;
41 | reject(new Error('Connection timeout'));
42 | }, 15000); // 15 second timeout
43 |
44 | this.client.on('ready', () => {
45 | clearTimeout(timeout);
46 | this.connected = true;
47 | this.connecting = false;
48 | resolve(true);
49 | });
50 |
51 | this.client.on('error', (err) => {
52 | clearTimeout(timeout);
53 | this.connected = false;
54 | this.connecting = false;
55 | console.error(`SSH connection error for ${this.config.host}:`, err.message);
56 | // Clean up on error
57 | this.disconnect();
58 | reject(err);
59 | });
60 |
61 | this.client.on('close', () => {
62 | console.log(`SSH connection closed for ${this.config.host}`);
63 | this.connected = false;
64 | this.connecting = false;
65 | });
66 |
67 | this.client.on('end', () => {
68 | console.log(`SSH connection ended for ${this.config.host}`);
69 | this.connected = false;
70 | this.connecting = false;
71 | });
72 |
73 | try {
74 | const connectionConfig = {
75 | host: this.config.host,
76 | port: this.config.port || 22,
77 | username: this.config.username,
78 | readyTimeout: 15000,
79 | // Automatically accept all host keys (like StrictHostKeyChecking=no)
80 | hostHash: 'sha256',
81 | hostVerifier: () => {
82 | // Always accept - similar to ssh -o StrictHostKeyChecking=no
83 | return true;
84 | },
85 | // Add keepalive to maintain connection
86 | keepaliveInterval: 10000,
87 | keepaliveCountMax: 3,
88 | // Add debug for troubleshooting
89 | debug: (msg) => {
90 | if (msg.includes('fingerprint') || msg.includes('key')) {
91 | console.log('SSH Debug:', msg);
92 | }
93 | }
94 | };
95 |
96 | // Only include the authentication method that's actually provided
97 | if (this.config.password) {
98 | connectionConfig.password = this.config.password;
99 | console.log(`Connecting to ${this.config.host} with password auth`);
100 | } else if (this.config.privateKey) {
101 | connectionConfig.privateKey = this.config.privateKey;
102 | console.log(`Connecting to ${this.config.host} with private key auth (key length: ${this.config.privateKey.length})`);
103 | } else {
104 | clearTimeout(timeout);
105 | this.connecting = false;
106 | reject(new Error('No authentication method provided'));
107 | return;
108 | }
109 |
110 | this.client.connect(connectionConfig);
111 | } catch (error) {
112 | clearTimeout(timeout);
113 | this.connecting = false;
114 | reject(error);
115 | }
116 | });
117 | }
118 |
119 | async executeCommand(command) {
120 | if (!this.connected) {
121 | try {
122 | await this.connect();
123 | } catch (err) {
124 | // Connection failed, ensure cleanup
125 | this.disconnect();
126 | throw err;
127 | }
128 | }
129 |
130 | return new Promise((resolve, reject) => {
131 | this.client.exec(command, (err, stream) => {
132 | if (err) {
133 | reject(err);
134 | return;
135 | }
136 |
137 | let stdout = '';
138 | let stderr = '';
139 |
140 | stream.on('close', (code) => {
141 | if (code !== 0 && stderr) {
142 | reject(new Error(stderr));
143 | } else {
144 | resolve(stdout);
145 | }
146 | });
147 |
148 | stream.on('data', (data) => {
149 | stdout += data.toString();
150 | });
151 |
152 | stream.stderr.on('data', (data) => {
153 | stderr += data.toString();
154 | });
155 | });
156 | });
157 | }
158 |
159 | disconnect() {
160 | if (this.client) {
161 | this.client.end();
162 | this.connected = false;
163 | this.client = null;
164 | }
165 | }
166 | }
167 |
168 | class SSHConnectionPool {
169 | constructor() {
170 | this.connections = new Map();
171 | }
172 |
173 | getConnection(serverId, serverConfig) {
174 | if (!this.connections.has(serverId)) {
175 | this.connections.set(serverId, new SSHConnection(serverConfig));
176 | }
177 | return this.connections.get(serverId);
178 | }
179 |
180 | removeConnection(serverId) {
181 | const connection = this.connections.get(serverId);
182 | if (connection) {
183 | connection.disconnect();
184 | this.connections.delete(serverId);
185 | }
186 | }
187 |
188 | disconnectAll() {
189 | for (const [serverId, connection] of this.connections) {
190 | connection.disconnect();
191 | }
192 | this.connections.clear();
193 | }
194 |
195 | async testConnection(serverConfig) {
196 | const testConnection = new SSHConnection(serverConfig);
197 | try {
198 | await testConnection.connect();
199 | const result = await testConnection.executeCommand('echo "test"');
200 | testConnection.disconnect();
201 | return { success: true, message: 'Connection successful' };
202 | } catch (error) {
203 | testConnection.disconnect();
204 | return { success: false, message: error.message };
205 | }
206 | }
207 | }
208 |
209 | // Singleton instance
210 | const sshPool = new SSHConnectionPool();
211 |
212 | export default sshPool;
213 |
214 |
215 |
216 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/dropdown-menu.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
3 | import { Check, ChevronRight, Circle } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const DropdownMenu = DropdownMenuPrimitive.Root
8 |
9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
10 |
11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
12 |
13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
14 |
15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
16 |
17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
18 |
19 | const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
20 |
28 | {children}
29 |
30 |
31 | ))
32 | DropdownMenuSubTrigger.displayName =
33 | DropdownMenuPrimitive.SubTrigger.displayName
34 |
35 | const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
36 |
43 | ))
44 | DropdownMenuSubContent.displayName =
45 | DropdownMenuPrimitive.SubContent.displayName
46 |
47 | const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
48 |
49 |
58 |
59 | ))
60 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
61 |
62 | const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
63 |
svg]:size-4 [&>svg]:shrink-0",
67 | inset && "pl-8",
68 | className
69 | )}
70 | {...props} />
71 | ))
72 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
73 |
74 | const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
75 |
83 |
84 |
85 |
86 |
87 |
88 | {children}
89 |
90 | ))
91 | DropdownMenuCheckboxItem.displayName =
92 | DropdownMenuPrimitive.CheckboxItem.displayName
93 |
94 | const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
95 |
102 |
103 |
104 |
105 |
106 |
107 | {children}
108 |
109 | ))
110 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
111 |
112 | const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
113 |
117 | ))
118 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
119 |
120 | const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
121 |
125 | ))
126 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
127 |
128 | const DropdownMenuShortcut = ({
129 | className,
130 | ...props
131 | }) => {
132 | return (
133 |
136 | );
137 | }
138 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
139 |
140 | export {
141 | DropdownMenu,
142 | DropdownMenuTrigger,
143 | DropdownMenuContent,
144 | DropdownMenuItem,
145 | DropdownMenuCheckboxItem,
146 | DropdownMenuRadioItem,
147 | DropdownMenuLabel,
148 | DropdownMenuSeparator,
149 | DropdownMenuShortcut,
150 | DropdownMenuGroup,
151 | DropdownMenuPortal,
152 | DropdownMenuSub,
153 | DropdownMenuSubContent,
154 | DropdownMenuSubTrigger,
155 | DropdownMenuRadioGroup,
156 | }
157 |
--------------------------------------------------------------------------------
/PWA_SETUP.md:
--------------------------------------------------------------------------------
1 | # 📱 PWA Setup - Your App is Now Installable on Android!
2 |
3 | ## ✅ What Was Done
4 |
5 | Your web app is now a **Progressive Web App (PWA)**!
6 |
7 | ### **Files Created:**
8 |
9 | ```
10 | frontend/
11 | ├── public/
12 | │ ├── manifest.json ✅ PWA manifest
13 | │ ├── sw.js ✅ Service worker
14 | │ ├── icon.svg ✅ App icon (SVG)
15 | │ └── icon-192.png.txt ⏳ Placeholder (need real PNG)
16 | └── index.html ✅ Updated with PWA meta tags
17 | ```
18 |
19 | ---
20 |
21 | ## 🎨 Create App Icons (Required)
22 |
23 | You need to create PNG icons from the SVG:
24 |
25 | ### **Option 1: Use Online Tool (Easiest)**
26 | 1. Go to https://realfavicongenerator.net/
27 | 2. Upload `frontend/public/icon.svg`
28 | 3. Download the generated icons
29 | 4. Copy `icon-192.png` and `icon-512.png` to `frontend/public/`
30 |
31 | ### **Option 2: Use GIMP/Photoshop**
32 | 1. Open `frontend/public/icon.svg`
33 | 2. Export as PNG:
34 | - 192x192 → `icon-192.png`
35 | - 512x512 → `icon-512.png`
36 |
37 | ### **Option 3: Use ImageMagick (Command Line)**
38 | ```bash
39 | cd /home/wimboro/server-monitoring/frontend/public
40 |
41 | # Install ImageMagick if needed
42 | sudo apt install imagemagick
43 |
44 | # Convert SVG to PNG
45 | convert -background none -resize 192x192 icon.svg icon-192.png
46 | convert -background none -resize 512x512 icon.svg icon-512.png
47 | ```
48 |
49 | ---
50 |
51 | ## 🚀 Build & Deploy
52 |
53 | ### **1. Build Production**
54 |
55 | ```bash
56 | cd /home/wimboro/server-monitoring
57 |
58 | # Build frontend
59 | cd frontend
60 | npm run build
61 |
62 | # Copy dist to backend (already configured)
63 | # Your backend already serves frontend from dist/
64 | ```
65 |
66 | ### **2. Test PWA**
67 |
68 | ```bash
69 | # Start backend (if not running)
70 | cd /home/wimboro/server-monitoring/backend
71 | npm run dev
72 |
73 | # Access from Android device
74 | # Open Chrome on your Android phone
75 | # Go to: http://192.168.2.131:3000
76 | ```
77 |
78 | ---
79 |
80 | ## 📱 Install on Android
81 |
82 | ### **Steps:**
83 |
84 | 1. **Open Chrome** on your Android device
85 | 2. **Navigate** to `http://192.168.2.131:3000` (or your domain)
86 | 3. **Tap the menu** (3 dots) in Chrome
87 | 4. **Select "Install app"** or "Add to Home screen"
88 | 5. **Confirm** installation
89 | 6. **Find the icon** on your home screen
90 | 7. **Tap to launch** - runs like a native app!
91 |
92 | ### **What You Get:**
93 |
94 | ✅ **Standalone app** - runs fullscreen (no browser UI)
95 | ✅ **Home screen icon** - looks like a native app
96 | ✅ **Offline support** - caches assets for faster loading
97 | ✅ **Background sync** - (future feature)
98 | ✅ **Push notifications** - (future feature)
99 |
100 | ---
101 |
102 | ## 🔍 Verify PWA Features
103 |
104 | ### **Chrome DevTools (Desktop):**
105 |
106 | ```bash
107 | # 1. Open Chrome DevTools (F12)
108 | # 2. Go to "Application" tab
109 | # 3. Check:
110 | # - Manifest: Should show your app details
111 | # - Service Workers: Should be registered
112 | # - Cache Storage: Should show cached files
113 | ```
114 |
115 | ### **Lighthouse (Desktop):**
116 |
117 | ```bash
118 | # 1. Open Chrome DevTools (F12)
119 | # 2. Go to "Lighthouse" tab
120 | # 3. Select "Progressive Web App"
121 | # 4. Click "Generate report"
122 | # 5. Should score 90+ for PWA
123 | ```
124 |
125 | ---
126 |
127 | ## ✨ PWA Features Implemented
128 |
129 | | Feature | Status | Notes |
130 | |---------|--------|-------|
131 | | **Installable** | ✅ | manifest.json configured |
132 | | **Offline** | ✅ | Service worker caches assets |
133 | | **Standalone** | ✅ | Runs fullscreen |
134 | | **Icons** | ⏳ | Need to create PNG files |
135 | | **Theme Color** | ✅ | Blue (#2563eb) |
136 | | **Splash Screen** | ✅ | Auto-generated by browser |
137 | | **Background Sync** | 📝 | Code ready, not active yet |
138 | | **Push Notifications** | 📝 | Code ready, not active yet |
139 |
140 | ---
141 |
142 | ## 🎯 Testing Checklist
143 |
144 | - [ ] Create PNG icons (192x192 & 512x512)
145 | - [ ] Build frontend (`npm run build`)
146 | - [ ] Start backend
147 | - [ ] Open on Android Chrome
148 | - [ ] Check "Install app" option appears
149 | - [ ] Install app
150 | - [ ] Verify icon on home screen
151 | - [ ] Launch from home screen
152 | - [ ] Verify runs fullscreen
153 | - [ ] Test offline (airplane mode)
154 |
155 | ---
156 |
157 | ## 🌐 Deploying PWA
158 |
159 | Your app is accessible at:
160 | - **Local:** http://localhost:3000
161 | - **Network:** http://192.168.2.131:3000
162 | - **Domain:** https://server.hiddencyber.online ✅ Already setup!
163 |
164 | ### **For Public Access:**
165 |
166 | Your Cloudflare Tunnel is already configured!
167 |
168 | ```bash
169 | # Check if tunnel is running
170 | ps aux | grep cloudflared
171 |
172 | # If not, start it
173 | cloudflared tunnel run server-monitoring
174 | ```
175 |
176 | Then users can install from: **https://server.hiddencyber.online**
177 |
178 | ---
179 |
180 | ## 📊 Comparison: PWA vs React Native
181 |
182 | | Aspect | PWA (Done!) | React Native | Winner |
183 | |--------|-------------|--------------|--------|
184 | | **Development Time** | 1 day ✅ | 4-5 weeks | 🏆 PWA |
185 | | **Code Reuse** | 100% | 70-80% | 🏆 PWA |
186 | | **SSH Support** | ✅ (via backend) | ❌ (no libraries) | 🏆 PWA |
187 | | **Installation** | From browser | From Play Store | Tie |
188 | | **Offline** | ✅ Partial | ✅ Full | React Native |
189 | | **Performance** | Good | Better | React Native |
190 | | **Updates** | Instant | Store approval | 🏆 PWA |
191 | | **Hosting Cost** | $10/mo | $0/mo | React Native |
192 |
193 | ---
194 |
195 | ## 💡 Tips for Best PWA Experience
196 |
197 | ### **1. HTTPS Required**
198 | - ✅ You're using Cloudflare Tunnel (HTTPS)
199 | - PWAs require HTTPS (except localhost)
200 |
201 | ### **2. Icon Design**
202 | - Use simple, bold design
203 | - High contrast
204 | - Recognizable at small sizes
205 | - Follows material design guidelines
206 |
207 | ### **3. Offline Strategy**
208 | - Critical assets cached
209 | - API calls always fresh
210 | - Graceful offline fallback
211 |
212 | ### **4. Performance**
213 | - Service worker caches files
214 | - Faster subsequent loads
215 | - Lighthouse score 90+
216 |
217 | ---
218 |
219 | ## 🚀 Next Steps
220 |
221 | ### **Immediate:**
222 | 1. **Create PNG icons** (5 minutes)
223 | 2. **Build & test** (5 minutes)
224 | 3. **Install on Android** (1 minute)
225 | 4. **Done!** ✅
226 |
227 | ### **Future Enhancements:**
228 | 1. **Push Notifications**
229 | - Alert when server goes down
230 | - Weekly usage reports
231 |
232 | 2. **Background Sync**
233 | - Queue actions when offline
234 | - Sync when online
235 |
236 | 3. **Advanced Caching**
237 | - Cache monitoring data
238 | - Offline dashboard view
239 |
240 | 4. **Share Target**
241 | - Share SSH configs to app
242 | - Import server lists
243 |
244 | ---
245 |
246 | ## 🎉 Congratulations!
247 |
248 | Your server monitoring app is now:
249 | - ✅ **Installable on Android**
250 | - ✅ **Works offline**
251 | - ✅ **Runs fullscreen**
252 | - ✅ **No React Native needed!**
253 | - ✅ **100% code reuse**
254 | - ✅ **1 day implementation**
255 |
256 | **This is the BEST solution for your use case!**
257 |
258 | ---
259 |
260 | ## 📞 Questions?
261 |
262 | ### **"Do I still need React Native?"**
263 | **No!** PWA gives you 90% of native features with 0% of the complexity.
264 |
265 | ### **"Can users install from Play Store?"**
266 | **Yes!** You can use **Trusted Web Activity (TWA)** to publish PWA to Play Store later.
267 |
268 | ### **"Does it work on iOS?"**
269 | **Yes!** PWAs work on iOS Safari too (iOS 11.3+).
270 |
271 | ### **"What about SSH support?"**
272 | **Works perfectly!** Your backend handles SSH, PWA is just the UI.
273 |
274 | ---
275 |
276 | **Enjoy your installable Android app!** 🚀📱
277 |
--------------------------------------------------------------------------------
/frontend/src/pages/Dashboard.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { Plus, Moon, Sun, LogOut, Settings } from 'lucide-react';
3 | import { useNavigate } from 'react-router-dom';
4 | import { serversAPI } from '../services/api';
5 | import socketService from '../services/socket';
6 | import { useAuthStore, useServersStore, useMonitoringStore, useThemeStore } from '../store/useStore';
7 | import ServerCardSimple from '../components/ServerCardSimple';
8 | import AddServerModal from '../components/AddServerModal';
9 |
10 | export default function Dashboard() {
11 | const navigate = useNavigate();
12 | const { logout } = useAuthStore();
13 | const { servers, setServers, addServer, removeServer } = useServersStore();
14 | const { metrics, setMetrics, clearMetrics } = useMonitoringStore();
15 | const { theme, toggleTheme } = useThemeStore();
16 |
17 | const [showAddModal, setShowAddModal] = useState(false);
18 | const [loading, setLoading] = useState(true);
19 |
20 | // Load servers
21 | useEffect(() => {
22 | loadServers();
23 | }, []);
24 |
25 | // Start monitoring for all servers
26 | useEffect(() => {
27 | if (servers.length > 0) {
28 | servers.forEach(server => {
29 | socketService.startMonitoring(server.id, (error, data) => {
30 | if (error) {
31 | console.error(`Monitoring error for ${server.id}:`, error);
32 | } else {
33 | setMetrics(server.id, data);
34 | }
35 | });
36 | });
37 | }
38 |
39 | return () => {
40 | socketService.stopAllMonitoring();
41 | };
42 | }, [servers.length]);
43 |
44 | const loadServers = async () => {
45 | try {
46 | const response = await serversAPI.getAll();
47 | setServers(response.data);
48 | } catch (error) {
49 | console.error('Failed to load servers:', error);
50 | } finally {
51 | setLoading(false);
52 | }
53 | };
54 |
55 | const handleAddServer = async (serverData) => {
56 | const response = await serversAPI.create(serverData);
57 | addServer(response.data);
58 |
59 | // Start monitoring the new server
60 | socketService.startMonitoring(response.data.id, (error, data) => {
61 | if (error) {
62 | console.error(`Monitoring error for ${response.data.id}:`, error);
63 | } else {
64 | setMetrics(response.data.id, data);
65 | }
66 | });
67 | };
68 |
69 | const handleDeleteServer = async (serverId) => {
70 | if (!confirm('Are you sure you want to delete this server?')) {
71 | return;
72 | }
73 |
74 | try {
75 | await serversAPI.delete(serverId);
76 | removeServer(serverId);
77 | socketService.stopMonitoring(serverId);
78 | clearMetrics(serverId);
79 | } catch (error) {
80 | console.error('Failed to delete server:', error);
81 | alert('Failed to delete server: ' + error.message);
82 | }
83 | };
84 |
85 | const handleRefresh = async (serverId) => {
86 | // Restart monitoring
87 | socketService.stopMonitoring(serverId);
88 | setTimeout(() => {
89 | socketService.startMonitoring(serverId, (error, data) => {
90 | if (error) {
91 | console.error(`Monitoring error for ${serverId}:`, error);
92 | } else {
93 | setMetrics(serverId, data);
94 | }
95 | });
96 | }, 500);
97 | };
98 |
99 | const handleLogout = () => {
100 | socketService.stopAllMonitoring();
101 | socketService.disconnect();
102 | logout();
103 | navigate('/login');
104 | };
105 |
106 | return (
107 |
108 | {/* Header */}
109 |
110 |
111 |
112 |
113 |
114 | Server Monitoring Dashboard
115 |
116 |
117 | Monitor your servers in real-time via SSH
118 |
119 |
120 |
121 |
128 |
135 |
142 |
143 |
144 |
145 |
146 |
147 | {/* Main Content */}
148 |
149 | {/* Add Server Button */}
150 |
151 |
158 |
159 |
160 | {/* Servers Grid */}
161 | {loading ? (
162 |
163 |
164 |
Loading servers...
165 |
166 | ) : servers.length === 0 ? (
167 |
168 |
171 |
172 | No servers yet
173 |
174 |
175 | Add your first server to start monitoring
176 |
177 |
183 |
184 | ) : (
185 |
186 | {servers.map(server => (
187 | navigate(`/server/${server.id}`)}
192 | />
193 | ))}
194 |
195 | )}
196 |
197 |
198 | {/* Add Server Modal */}
199 |
setShowAddModal(false)}
202 | onAdd={handleAddServer}
203 | />
204 |
205 | );
206 | }
207 |
208 |
209 |
--------------------------------------------------------------------------------
/frontend/src/components/AddServerModal.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { X, Server } from 'lucide-react';
3 |
4 | export default function AddServerModal({ isOpen, onClose, onAdd }) {
5 | const [formData, setFormData] = useState({
6 | name: '',
7 | host: '',
8 | port: '22',
9 | username: '',
10 | password: '',
11 | privateKey: '',
12 | group: 'default'
13 | });
14 | const [authType, setAuthType] = useState('password');
15 | const [loading, setLoading] = useState(false);
16 | const [error, setError] = useState('');
17 |
18 | const handleChange = (e) => {
19 | setFormData({
20 | ...formData,
21 | [e.target.name]: e.target.value
22 | });
23 | };
24 |
25 | const handleSubmit = async (e) => {
26 | e.preventDefault();
27 | setError('');
28 | setLoading(true);
29 |
30 | try {
31 | const serverData = {
32 | name: formData.name,
33 | host: formData.host,
34 | port: parseInt(formData.port),
35 | username: formData.username,
36 | group: formData.group
37 | };
38 |
39 | if (authType === 'password') {
40 | serverData.password = formData.password;
41 | } else {
42 | serverData.privateKey = formData.privateKey;
43 | }
44 |
45 | await onAdd(serverData);
46 |
47 | // Reset form
48 | setFormData({
49 | name: '',
50 | host: '',
51 | port: '22',
52 | username: '',
53 | password: '',
54 | privateKey: '',
55 | group: 'default'
56 | });
57 | setAuthType('password');
58 | onClose();
59 | } catch (err) {
60 | setError(err.response?.data?.error || err.message || 'Failed to add server');
61 | } finally {
62 | setLoading(false);
63 | }
64 | };
65 |
66 | if (!isOpen) return null;
67 |
68 | return (
69 |
70 |
71 | {/* Header */}
72 |
73 |
74 |
75 |
76 | Add New Server
77 |
78 |
79 |
85 |
86 |
87 | {/* Form */}
88 |
233 |
234 |
235 | );
236 | }
237 |
238 |
239 |
240 |
--------------------------------------------------------------------------------
/frontend/src/pages/Settings.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { Lock, ArrowLeftCircle, ShieldCheck } from 'lucide-react';
4 | import { authAPI } from '../services/api';
5 |
6 | export default function Settings() {
7 | const navigate = useNavigate();
8 | const [formData, setFormData] = useState({
9 | currentPassword: '',
10 | newPassword: '',
11 | confirmPassword: ''
12 | });
13 | const [errors, setErrors] = useState({});
14 | const [status, setStatus] = useState({ type: '', message: '' });
15 | const [submitting, setSubmitting] = useState(false);
16 |
17 | const handleChange = (event) => {
18 | const { name, value } = event.target;
19 | setFormData((prev) => ({ ...prev, [name]: value }));
20 | setStatus({ type: '', message: '' });
21 | };
22 |
23 | const validate = () => {
24 | const newErrors = {};
25 |
26 | if (!formData.currentPassword) {
27 | newErrors.currentPassword = 'Masukkan password saat ini';
28 | }
29 |
30 | if (!formData.newPassword) {
31 | newErrors.newPassword = 'Masukkan password baru';
32 | } else if (formData.newPassword.length < 6) {
33 | newErrors.newPassword = 'Password baru minimal 6 karakter';
34 | }
35 |
36 | if (!formData.confirmPassword) {
37 | newErrors.confirmPassword = 'Konfirmasi password baru';
38 | } else if (formData.newPassword !== formData.confirmPassword) {
39 | newErrors.confirmPassword = 'Password baru dan konfirmasi tidak sama';
40 | }
41 |
42 | setErrors(newErrors);
43 | return Object.keys(newErrors).length === 0;
44 | };
45 |
46 | const handleSubmit = async (event) => {
47 | event.preventDefault();
48 | if (!validate()) return;
49 |
50 | setSubmitting(true);
51 | setStatus({ type: '', message: '' });
52 |
53 | try {
54 | await authAPI.changePassword(formData.currentPassword, formData.newPassword);
55 | setStatus({ type: 'success', message: 'Password berhasil diubah' });
56 | setFormData({
57 | currentPassword: '',
58 | newPassword: '',
59 | confirmPassword: ''
60 | });
61 | } catch (error) {
62 | const message = error.response?.data?.message || error.response?.data?.error || 'Gagal mengubah password';
63 | setStatus({ type: 'error', message });
64 | } finally {
65 | setSubmitting(false);
66 | }
67 | };
68 |
69 | return (
70 |
71 |
72 |
73 |
74 |
Pengaturan
75 |
Kelola keamanan akun dan preferensi
76 |
77 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 | Ubah Password Login
94 |
95 |
96 | Pastikan password baru mudah diingat namun sulit ditebak
97 |
98 |
99 |
100 |
101 | {status.message && (
102 |
109 | {status.message}
110 |
111 | )}
112 |
113 |
195 |
196 |
197 |
198 | );
199 | }
200 |
--------------------------------------------------------------------------------
/backend/src/server.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import cors from 'cors';
3 | import { createServer } from 'http';
4 | import { Server } from 'socket.io';
5 | import rateLimit from 'express-rate-limit';
6 | import path from 'path';
7 | import { fileURLToPath } from 'url';
8 | import jwt from 'jsonwebtoken';
9 |
10 | // ES modules fix for __dirname
11 | const __filename = fileURLToPath(import.meta.url);
12 | const __dirname = path.dirname(__filename);
13 |
14 | // Import routes
15 | import authRoutes from './routes/auth.routes.js';
16 | import serversRoutes from './routes/servers.routes.js';
17 | import monitoringRoutes from './routes/monitoring.routes.js';
18 |
19 | // Import services
20 | import monitoringService from './services/monitoring.service.js';
21 | import * as storageService from './services/storage.service.js';
22 | import sshPool from './services/ssh.service.js';
23 | import config from './config/env.js';
24 |
25 | const app = express();
26 | const httpServer = createServer(app);
27 | const io = new Server(httpServer, {
28 | cors: {
29 | origin: config.corsOrigin.length > 0 ? config.corsOrigin : '*',
30 | methods: ['GET', 'POST', 'PUT', 'DELETE']
31 | },
32 | pingInterval: config.socketPingInterval,
33 | pingTimeout: config.socketPingTimeout
34 | });
35 |
36 | const PORT = config.port;
37 |
38 | // Middleware
39 | if (config.corsOrigin.length > 0) {
40 | app.use(cors({ origin: config.corsOrigin, credentials: true }));
41 | } else {
42 | app.use(cors());
43 | }
44 | app.use(express.json());
45 |
46 | // Rate limiting
47 | const limiter = rateLimit({
48 | windowMs: 15 * 60 * 1000, // 15 minutes
49 | max: 100 // limit each IP to 100 requests per windowMs
50 | });
51 | app.use('/api/auth/login', limiter);
52 |
53 | // API Routes
54 | app.use('/api/auth', authRoutes);
55 | app.use('/api/servers', serversRoutes);
56 | app.use('/api/monitoring', monitoringRoutes);
57 |
58 | // Health check
59 | app.get('/api/health', (req, res) => {
60 | res.json({ status: 'ok', timestamp: new Date().toISOString() });
61 | });
62 |
63 | // Serve static frontend files (production build)
64 | const frontendPath = path.join(__dirname, '../../frontend/dist');
65 | app.use(express.static(frontendPath));
66 |
67 | // Serve index.html for all non-API routes (SPA fallback)
68 | app.get('*', (req, res) => {
69 | // Don't serve index.html for API routes
70 | if (req.path.startsWith('/api')) {
71 | return res.status(404).json({ error: 'API endpoint not found' });
72 | }
73 | res.sendFile(path.join(frontendPath, 'index.html'));
74 | });
75 |
76 | // WebSocket connection handling
77 | io.on('connection', (socket) => {
78 | console.log('Client connected:', socket.id);
79 |
80 | // Track monitoring sessions for this socket
81 | const monitoringSessions = new Set();
82 | let isAuthenticated = false;
83 |
84 | // Start monitoring a server
85 | socket.on('start-monitoring', async ({ serverId, token }) => {
86 | try {
87 | if (!isAuthenticated) {
88 | if (!token) {
89 | socket.emit('error', { message: 'Authentication required' });
90 | return;
91 | }
92 |
93 | try {
94 | jwt.verify(token, config.jwtSecret);
95 | isAuthenticated = true;
96 | } catch (error) {
97 | socket.emit('error', { message: 'Invalid or expired token' });
98 | return;
99 | }
100 | }
101 |
102 | if (!serverId) {
103 | socket.emit('error', { message: 'Server id is required' });
104 | return;
105 | }
106 |
107 | // Check if server exists
108 | const server = await storageService.getServerById(serverId);
109 | if (!server) {
110 | socket.emit('error', { message: 'Server not found' });
111 | return;
112 | }
113 |
114 | console.log(`Starting monitoring for server ${serverId}`);
115 |
116 | // Join room for this server
117 | socket.join(`server-${serverId}`);
118 | monitoringSessions.add(serverId);
119 |
120 | // Start monitoring
121 | monitoringService.startMonitoring(
122 | serverId,
123 | (error, metrics) => {
124 | if (error) {
125 | io.to(`server-${serverId}`).emit('monitoring-error', {
126 | serverId,
127 | error: error.message
128 | });
129 | } else {
130 | io.to(`server-${serverId}`).emit('monitoring-data', metrics);
131 | }
132 | },
133 | 5000 // 5 second interval (prevent rate limiting & reduce server load)
134 | );
135 |
136 | socket.emit('monitoring-started', { serverId });
137 | } catch (error) {
138 | socket.emit('error', { message: error.message });
139 | }
140 | });
141 |
142 | // Stop monitoring a server
143 | socket.on('stop-monitoring', ({ serverId }) => {
144 | if (!isAuthenticated) {
145 | socket.emit('error', { message: 'Authentication required' });
146 | return;
147 | }
148 |
149 | console.log(`Stopping monitoring for server ${serverId}`);
150 | socket.leave(`server-${serverId}`);
151 | monitoringSessions.delete(serverId);
152 |
153 | // Check if any other clients are monitoring this server
154 | const room = io.sockets.adapter.rooms.get(`server-${serverId}`);
155 | if (!room || room.size === 0) {
156 | monitoringService.stopMonitoring(serverId);
157 | }
158 | });
159 |
160 | // SSH Terminal handlers
161 | const terminalSessions = new Map();
162 |
163 | socket.on('terminal-start', async ({ serverId, token }) => {
164 | try {
165 | if (!isAuthenticated) {
166 | if (!token) {
167 | socket.emit('terminal-error', {
168 | serverId,
169 | error: 'Authentication required'
170 | });
171 | return;
172 | }
173 |
174 | try {
175 | jwt.verify(token, config.jwtSecret);
176 | isAuthenticated = true;
177 | } catch (error) {
178 | socket.emit('terminal-error', {
179 | serverId,
180 | error: 'Invalid or expired token'
181 | });
182 | return;
183 | }
184 | }
185 |
186 | const server = await storageService.getServerById(serverId);
187 | if (!server) {
188 | socket.emit('terminal-error', {
189 | serverId,
190 | error: 'Server not found'
191 | });
192 | return;
193 | }
194 |
195 | const connection = sshPool.getConnection(serverId, server);
196 | await connection.connect();
197 |
198 | connection.client.shell({
199 | term: 'xterm-256color',
200 | rows: 24,
201 | cols: 80
202 | }, (err, stream) => {
203 | if (err) {
204 | socket.emit('terminal-error', {
205 | serverId,
206 | error: err.message
207 | });
208 | return;
209 | }
210 |
211 | terminalSessions.set(serverId, stream);
212 | socket.emit('terminal-ready', { serverId });
213 |
214 | stream.on('data', (data) => {
215 | socket.emit('terminal-data', {
216 | serverId,
217 | output: data.toString()
218 | });
219 | });
220 |
221 | stream.on('close', () => {
222 | terminalSessions.delete(serverId);
223 | socket.emit('terminal-error', {
224 | serverId,
225 | error: 'Terminal session closed'
226 | });
227 | });
228 |
229 | stream.stderr.on('data', (data) => {
230 | socket.emit('terminal-data', {
231 | serverId,
232 | output: data.toString()
233 | });
234 | });
235 | });
236 | } catch (error) {
237 | socket.emit('terminal-error', {
238 | serverId,
239 | error: error.message
240 | });
241 | }
242 | });
243 |
244 | socket.on('terminal-input', ({ serverId, input }) => {
245 | const stream = terminalSessions.get(serverId);
246 | if (stream) {
247 | stream.write(input);
248 | }
249 | });
250 |
251 | socket.on('terminal-resize', ({ serverId, cols, rows }) => {
252 | const stream = terminalSessions.get(serverId);
253 | if (stream && stream.setWindow) {
254 | stream.setWindow(rows, cols);
255 | }
256 | });
257 |
258 | socket.on('terminal-stop', ({ serverId }) => {
259 | const stream = terminalSessions.get(serverId);
260 | if (stream) {
261 | stream.end();
262 | terminalSessions.delete(serverId);
263 | }
264 | });
265 |
266 | // Handle disconnection
267 | socket.on('disconnect', () => {
268 | console.log('Client disconnected:', socket.id);
269 |
270 | // Stop monitoring for all sessions this client had
271 | for (const serverId of monitoringSessions) {
272 | const room = io.sockets.adapter.rooms.get(`server-${serverId}`);
273 | if (!room || room.size === 0) {
274 | monitoringService.stopMonitoring(serverId);
275 | }
276 | }
277 | monitoringSessions.clear();
278 |
279 | // Close all terminal sessions
280 | for (const [serverId, stream] of terminalSessions) {
281 | stream.end();
282 | }
283 | terminalSessions.clear();
284 | });
285 | });
286 |
287 | // Graceful shutdown
288 | process.on('SIGTERM', () => {
289 | console.log('SIGTERM received, shutting down gracefully...');
290 |
291 | // Stop all monitoring
292 | monitoringService.stopAllMonitoring();
293 |
294 | // Disconnect all SSH connections
295 | sshPool.disconnectAll();
296 |
297 | // Close HTTP server
298 | httpServer.close(() => {
299 | console.log('Server closed');
300 | process.exit(0);
301 | });
302 | });
303 |
304 | process.on('SIGINT', () => {
305 | console.log('SIGINT received, shutting down gracefully...');
306 |
307 | monitoringService.stopAllMonitoring();
308 | sshPool.disconnectAll();
309 |
310 | httpServer.close(() => {
311 | console.log('Server closed');
312 | process.exit(0);
313 | });
314 | });
315 |
316 | // Start server
317 | httpServer.listen(PORT, '0.0.0.0', () => {
318 | console.log(`🚀 Server monitoring backend running on port ${PORT}`);
319 | console.log(`📊 WebSocket server ready for connections`);
320 | if (config.corsOrigin.length > 0) {
321 | console.log(`🌐 Allowed origins: ${config.corsOrigin.join(', ')}`);
322 | } else {
323 | console.warn('⚠️ CORS_ORIGIN is not set. Accepting requests from any origin.');
324 | }
325 | });
326 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Server Monitoring Dashboard
2 |
3 | Aplikasi web monitoring server berbasis SSH yang modern dan real-time, terinspirasi dari SwiftServer.
4 |
5 | ## Dukung Proyek
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | ## 🚀 Fitur Utama
26 |
27 | ### ✅ Monitoring Real-time
28 | - **CPU**: Usage, load average, history chart
29 | - **Memory**: RAM & Swap usage dengan breakdown detail
30 | - **Disk**: Monitor semua partisi dengan usage percentage
31 | - **Network**: Traffic upload/download per interface
32 | - **Processes**: Top CPU & Memory processes
33 | - **Docker**: Container monitoring (optional)
34 | - **GPU**: NVIDIA GPU monitoring (optional)
35 |
36 | ### 🖥️ Multi-Server Support
37 | - Monitor unlimited servers secara bersamaan
38 | - **Simple dashboard view**: Hanya CPU & Memory di card
39 | - **Detailed view**: Klik server untuk lihat semua metrics lengkap
40 | - Independent SSH connections per server
41 | - Grid layout responsive (1-3 columns)
42 |
43 | ### 🔐 Keamanan
44 | - Single-user authentication (password lokal)
45 | - JWT-based session management
46 | - Encrypted password storage (AES-256)
47 | - Server credentials stored in encrypted fields inside SQLite database (`data/database.sqlite`)
48 | - **Private keys stored as .pem files** dengan permissions 0600 (owner-only)
49 | - Private key files tersimpan di `data/ssh-keys/` (excluded from git)
50 | - No agent installation on servers
51 | - SSH connection only
52 | - Atomic file operations untuk data persistence
53 |
54 | ### ⚡ Real-time Updates
55 | - WebSocket untuk live data streaming
56 | - Auto-refresh setiap 5 detik
57 | - Connection status indicator
58 | - Error handling & reconnection
59 | - Sequential SSH command execution
60 |
61 | ### 🎨 Modern UI
62 | - Dark mode support
63 | - Responsive design (desktop & mobile)
64 | - Beautiful charts & visualizations
65 | - Tailwind CSS styling
66 | - Two-level view: Simple cards + Detailed page
67 |
68 | ## 📋 Requirements
69 |
70 | - **Node.js** v18 atau lebih baru
71 | - **npm** atau **yarn**
72 | - **SSH access** ke servers yang akan dimonitor
73 |
74 | ## 🛠️ Installation
75 |
76 | ### 1. Clone repository
77 | ```bash
78 | git clone
79 | cd server-monitoring
80 | ```
81 |
82 | ### 2. Install dependencies
83 | ```bash
84 | npm run install:all
85 | ```
86 |
87 | Atau manual:
88 | ```bash
89 | # Install root dependencies
90 | npm install
91 |
92 | # Install backend dependencies
93 | cd backend
94 | npm install
95 |
96 | # Install frontend dependencies
97 | cd ../frontend
98 | npm install
99 | ```
100 |
101 | ### 3. Configuration
102 |
103 | Salin file contoh environment dan sesuaikan nilainya.
104 |
105 | ```bash
106 | cp backend/.env.example backend/.env
107 | cp frontend/.env.example frontend/.env
108 | ```
109 |
110 | #### Backend (`backend/.env`)
111 |
112 | Variable penting:
113 |
114 | | Key | Keterangan |
115 | | --- | --- |
116 | | `NODE_ENV` | Gunakan `production` saat deploy |
117 | | `PORT` | Port HTTP backend (default `3000`) |
118 | | `JWT_SECRET` | String panjang random (≥32 char) untuk menandatangani token |
119 | | `ENCRYPTION_KEY` | String panjang random (≥32 char) untuk enkripsi credential |
120 | | `DEFAULT_ADMIN_PASSWORD` | Password awal login (wajib diganti setelah deploy) |
121 | | `CORS_ORIGIN` | Daftar origin yang diizinkan (pisahkan dengan koma) |
122 | | `SOCKET_PING_INTERVAL` / `SOCKET_PING_TIMEOUT` | (Opsional) tuning koneksi WebSocket |
123 |
124 | Generate secret secara cepat:
125 |
126 | ```bash
127 | node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
128 | ```
129 |
130 | #### Frontend (`frontend/.env`)
131 |
132 | | Key | Keterangan |
133 | | --- | --- |
134 | | `VITE_API_URL` | URL backend API (sertakan suffix `/api`) |
135 | | `VITE_SOCKET_URL` | URL untuk koneksi Socket.io (tanpa `/socket.io`) |
136 |
137 | ### 4. Run Application
138 |
139 | #### Development (both frontend & backend)
140 | ```bash
141 | npm run dev
142 | ```
143 |
144 | Atau jalankan terpisah:
145 |
146 | **Backend** (Terminal 1):
147 | ```bash
148 | cd backend
149 | npm run dev
150 | ```
151 |
152 | **Frontend** (Terminal 2):
153 | ```bash
154 | cd frontend
155 | npm run dev
156 | ```
157 |
158 | Aplikasi akan berjalan di:
159 | - **Frontend**: http://localhost:5173
160 | - **Backend API**: http://localhost:3000
161 | - **WebSocket**: ws://localhost:3000
162 |
163 | ## 🔑 Default Login
164 |
165 | - **Password awal**: Nilai `DEFAULT_ADMIN_PASSWORD` pada `backend/.env`
166 |
167 | **PENTING**: Ganti password dari halaman **Settings → Ubah Password** segera setelah deploy!
168 |
169 | ## 📖 Usage Guide
170 |
171 | ### 1. Login
172 | Akses http://localhost:5173 dan login menggunakan password yang Anda set di `DEFAULT_ADMIN_PASSWORD`
173 |
174 | ### 2. Add Server
175 | - Klik tombol **"Add Server"**
176 | - Isi informasi server:
177 | - **Name**: Nama server (e.g., "Production Server 1")
178 | - **Host**: IP address atau hostname
179 | - **Port**: SSH port (default: 22)
180 | - **Username**: SSH username
181 | - **Authentication**: Pilih Password atau Private Key
182 | - **Group**: Kategori server (optional)
183 | - Klik **"Add Server"**
184 |
185 | ### 3. Monitor Server
186 | Setelah server ditambahkan, monitoring akan dimulai secara otomatis:
187 | - CPU usage & load average
188 | - Memory & Swap usage
189 | - Disk partitions
190 | - Network interfaces
191 | - Top processes
192 |
193 | Data akan di-update setiap 5 detik via WebSocket
194 |
195 | ### 4. Manage Servers
196 | - **Refresh**: Reload data server
197 | - **Edit**: Update credentials atau info server
198 | - **Delete**: Hapus server dari monitoring
199 |
200 | ## 🏗️ Architecture
201 |
202 | ```
203 | ┌─────────────────┐
204 | │ React Frontend │ ← Browser (localhost:5173)
205 | │ (Vite) │
206 | └────────┬────────┘
207 | │ HTTP REST API + WebSocket
208 | ┌────────▼────────┐
209 | │ Express.js API │ ← Backend (localhost:3000)
210 | │ + Socket.io │
211 | └────────┬────────┘
212 | │ SSH (Port 22)
213 | ┌────────▼────────┐
214 | │ Target Servers │ ← Your servers
215 | │ (Linux) │
216 | └─────────────────┘
217 | ```
218 |
219 | ## 📁 Project Structure
220 |
221 | ```
222 | server-monitoring/
223 | ├── backend/
224 | │ ├── src/
225 | │ │ ├── server.js # Main server
226 | │ │ ├── routes/ # API routes
227 | │ │ ├── services/ # Business logic
228 | │ │ ├── middleware/ # Auth middleware
229 | │ │ └── utils/ # Utilities
230 | │ ├── data/ # SQLite database & SSH key storage
231 | │ └── package.json
232 | ├── frontend/
233 | │ ├── src/
234 | │ │ ├── pages/ # React pages
235 | │ │ ├── components/ # React components
236 | │ │ ├── services/ # API & Socket services
237 | │ │ ├── store/ # State management (Zustand)
238 | │ │ └── App.jsx
239 | │ └── package.json
240 | ├── package.json # Root package
241 | └── README.md
242 | ```
243 |
244 | ## 🔧 Tech Stack
245 |
246 | ### Backend
247 | - **Node.js** + **Express.js** - REST API
248 | - **Socket.io** - WebSocket real-time
249 | - **ssh2** - SSH client
250 | - **bcryptjs** - Password hashing
251 | - **jsonwebtoken** - JWT authentication
252 | - **better-sqlite3** - Embedded SQLite database access
253 |
254 | ### Frontend
255 | - **React 18** - UI library
256 | - **Vite** - Build tool
257 | - **Tailwind CSS** - Styling
258 | - **Recharts** - Charts & graphs
259 | - **Zustand** - State management
260 | - **Axios** - HTTP client
261 | - **Socket.io-client** - WebSocket client
262 | - **React Router** - Routing
263 | - **Lucide React** - Icons
264 |
265 | ## 🐛 Troubleshooting
266 |
267 | ### Port sudah digunakan?
268 | Jika port 3000 atau 5173 sudah digunakan, edit:
269 | - Backend port: `backend/.env` → `PORT=3001`
270 | - Frontend port: `frontend/vite.config.js` → `server.port: 5174`
271 | - Update juga: `frontend/.env` → `VITE_API_URL` dan `VITE_SOCKET_URL`
272 |
273 | ### SSH Connection Failed
274 | - Pastikan port SSH (22) terbuka di firewall
275 | - Verifikasi username & password/key correct
276 | - Test manual SSH: `ssh username@host -p 22`
277 |
278 | ### WebSocket Not Connecting
279 | - Pastikan backend server running
280 | - Check browser console untuk errors
281 | - Verify CORS settings di backend
282 |
283 | ### Data Not Updating
284 | - Check SSH connection status
285 | - Verify Linux commands available di server (`top`, `free`, `df`, `iostat`)
286 | - Check backend logs untuk errors
287 |
288 | ### Module not found?
289 | Jalankan ulang:
290 | ```bash
291 | npm run install:all
292 | ```
293 |
294 | ## 📝 Linux Commands Used
295 |
296 | Aplikasi ini menggunakan command standar Linux:
297 | - `top` - CPU usage
298 | - `free -m` - Memory info
299 | - `df -h` - Disk usage
300 | - `iostat` - Disk I/O
301 | - `ip` / `ifconfig` - Network info
302 | - `/proc/net/dev` - Network statistics
303 | - `ps aux` - Process list
304 | - `uptime` - System uptime
305 | - `hostname` - Hostname
306 | - `uname` - System info
307 |
308 | Optional (jika tersedia):
309 | - `docker ps` - Docker containers
310 | - `docker stats` - Container stats
311 | - `nvidia-smi` - GPU info (Nvidia)
312 |
313 | ## 🚀 Production Deployment
314 |
315 | ### Backend
316 | 1. Set `NODE_ENV=production`
317 | 2. Ganti `JWT_SECRET` dan `ENCRYPTION_KEY`
318 | 3. Setup reverse proxy (nginx/apache)
319 | 4. Enable HTTPS
320 | 5. Setup process manager (PM2):
321 | ```bash
322 | npm install -g pm2
323 | cd backend
324 | pm2 start src/server.js --name server-monitoring
325 | pm2 save
326 | pm2 startup
327 | ```
328 |
329 | ### Frontend
330 | 1. Build production:
331 | ```bash
332 | cd frontend
333 | npm run build
334 | ```
335 | 2. Deploy `dist/` folder ke web server
336 |
337 | ## 📄 License
338 |
339 | MIT License
340 |
341 | ## 🤝 Contributing
342 |
343 | Contributions are welcome! Feel free to open issues or pull requests.
344 |
345 | ## 📧 Support
346 |
347 | Jika ada pertanyaan atau issue, silakan buat issue di repository ini.
348 |
349 | ---
350 |
351 | **Happy Monitoring! 🎉**
352 |
--------------------------------------------------------------------------------
/frontend/src/components/EditServerModal.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { X } from 'lucide-react';
3 |
4 | export default function EditServerModal({ server, onClose, onSave }) {
5 | const [formData, setFormData] = useState({
6 | name: '',
7 | host: '',
8 | port: 22,
9 | username: '',
10 | authMethod: 'password',
11 | password: '',
12 | privateKey: '',
13 | group: 'default'
14 | });
15 | const [errors, setErrors] = useState({});
16 | const [loading, setLoading] = useState(false);
17 |
18 | useEffect(() => {
19 | if (server) {
20 | setFormData({
21 | name: server.name || '',
22 | host: server.host || '',
23 | port: server.port || 22,
24 | username: server.username || '',
25 | authMethod: server.privateKeyPath ? 'privateKey' : 'password',
26 | password: '',
27 | privateKey: '',
28 | group: server.group || 'default'
29 | });
30 | }
31 | }, [server]);
32 |
33 | const validateForm = () => {
34 | const newErrors = {};
35 |
36 | if (!formData.name.trim()) {
37 | newErrors.name = 'Server name is required';
38 | }
39 |
40 | if (!formData.host.trim()) {
41 | newErrors.host = 'Host is required';
42 | }
43 |
44 | if (!formData.username.trim()) {
45 | newErrors.username = 'Username is required';
46 | }
47 |
48 | if (!formData.port || formData.port < 1 || formData.port > 65535) {
49 | newErrors.port = 'Port must be between 1 and 65535';
50 | }
51 |
52 | // Only validate password/key if provided (for update, they're optional)
53 | if (formData.authMethod === 'password' && formData.password && formData.password.length < 6) {
54 | newErrors.password = 'Password must be at least 6 characters';
55 | }
56 |
57 | if (formData.authMethod === 'privateKey' && formData.privateKey && !formData.privateKey.includes('BEGIN')) {
58 | newErrors.privateKey = 'Invalid private key format';
59 | }
60 |
61 | setErrors(newErrors);
62 | return Object.keys(newErrors).length === 0;
63 | };
64 |
65 | const handleSubmit = async (e) => {
66 | e.preventDefault();
67 |
68 | if (!validateForm()) {
69 | return;
70 | }
71 |
72 | setLoading(true);
73 |
74 | try {
75 | // Only include password/privateKey if they were changed
76 | const updateData = {
77 | name: formData.name,
78 | host: formData.host,
79 | port: parseInt(formData.port),
80 | username: formData.username,
81 | group: formData.group
82 | };
83 |
84 | // Add password or privateKey only if provided
85 | if (formData.authMethod === 'password' && formData.password) {
86 | updateData.password = formData.password;
87 | updateData.privateKey = undefined; // Clear private key
88 | } else if (formData.authMethod === 'privateKey' && formData.privateKey) {
89 | updateData.privateKey = formData.privateKey;
90 | updateData.password = undefined; // Clear password
91 | }
92 |
93 | await onSave(updateData);
94 | onClose();
95 | } catch (error) {
96 | setErrors({ submit: error.response?.data?.message || 'Failed to update server' });
97 | } finally {
98 | setLoading(false);
99 | }
100 | };
101 |
102 | return (
103 |
104 |
105 | {/* Header */}
106 |
107 |
Edit Server
108 |
114 |
115 |
116 | {/* Form */}
117 |
266 |
267 |
268 | );
269 | }
270 |
--------------------------------------------------------------------------------
/backend/src/services/storage.service.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs/promises';
2 | import path from 'path';
3 | import { fileURLToPath } from 'url';
4 | import bcrypt from 'bcryptjs';
5 | import { encrypt, decrypt } from '../utils/encryption.js';
6 | import { savePrivateKey, readPrivateKey, deletePrivateKey } from '../utils/sshKeyManager.js';
7 | import config from '../config/env.js';
8 | import { initializeDatabase, getDb } from '../db/index.js';
9 |
10 | const __filename = fileURLToPath(import.meta.url);
11 | const __dirname = path.dirname(__filename);
12 |
13 | const DATA_DIR = path.join(__dirname, '../../data');
14 | const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
15 | const SERVERS_FILE = path.join(DATA_DIR, 'servers.json');
16 |
17 | async function ensureDataDir() {
18 | try {
19 | await fs.access(DATA_DIR);
20 | } catch {
21 | await fs.mkdir(DATA_DIR, { recursive: true });
22 | }
23 | }
24 |
25 | async function migrateLegacyData() {
26 | const db = getDb();
27 |
28 | // Migrate config.json if database is empty
29 | const configCount = db.prepare('SELECT COUNT(*) as count FROM config WHERE id = 1').get().count;
30 | if (configCount === 0) {
31 | try {
32 | const configData = JSON.parse(await fs.readFile(CONFIG_FILE, 'utf8'));
33 | db.prepare(`
34 | INSERT INTO config (id, password_hash, created_at, refresh_interval, theme)
35 | VALUES (1, @password_hash, @created_at, @refresh_interval, @theme)
36 | `).run({
37 | password_hash: configData.passwordHash,
38 | created_at: configData.createdAt || new Date().toISOString(),
39 | refresh_interval: configData.settings?.refreshInterval ?? 5000,
40 | theme: configData.settings?.theme ?? 'dark'
41 | });
42 | console.log('Migrated legacy config.json into SQLite database.');
43 | } catch (error) {
44 | if (error.code !== 'ENOENT') {
45 | console.error('Failed to migrate config.json:', error.message);
46 | }
47 | }
48 | }
49 |
50 | // Migrate servers.json if database has no servers yet
51 | const serverCount = db.prepare('SELECT COUNT(*) as count FROM servers').get().count;
52 | if (serverCount === 0) {
53 | try {
54 | const serversData = JSON.parse(await fs.readFile(SERVERS_FILE, 'utf8'));
55 | if (Array.isArray(serversData.servers) && serversData.servers.length > 0) {
56 | const insert = db.prepare(`
57 | INSERT OR REPLACE INTO servers (
58 | id, name, host, port, username, password, private_key_path,
59 | group_name, status, created_at, updated_at, last_seen
60 | ) VALUES (
61 | @id, @name, @host, @port, @username, @password, @private_key_path,
62 | @group_name, @status, @created_at, @updated_at, @last_seen
63 | )
64 | `);
65 |
66 | for (const server of serversData.servers) {
67 | insert.run({
68 | id: server.id,
69 | name: server.name || 'Unnamed Server',
70 | host: server.host,
71 | port: server.port || 22,
72 | username: server.username,
73 | password: server.password || null,
74 | private_key_path: server.privateKeyPath || null,
75 | group_name: server.group || 'default',
76 | status: server.status || 'unknown',
77 | created_at: server.createdAt || new Date().toISOString(),
78 | updated_at: server.updatedAt || server.createdAt || new Date().toISOString(),
79 | last_seen: server.lastSeen || null
80 | });
81 | }
82 |
83 | console.log(`Migrated ${serversData.servers.length} servers from legacy servers.json into SQLite.`);
84 | }
85 | } catch (error) {
86 | if (error.code !== 'ENOENT') {
87 | console.error('Failed to migrate servers.json:', error.message);
88 | }
89 | }
90 | }
91 | }
92 |
93 | async function ensureConfigRow() {
94 | const db = getDb();
95 | const row = db.prepare('SELECT * FROM config WHERE id = 1').get();
96 | if (row) {
97 | return;
98 | }
99 |
100 | const passwordHash = await bcrypt.hash(config.defaultAdminPassword, 10);
101 | const now = new Date().toISOString();
102 |
103 | db.prepare(`
104 | INSERT INTO config (id, password_hash, created_at, refresh_interval, theme)
105 | VALUES (1, @password_hash, @created_at, @refresh_interval, @theme)
106 | `).run({
107 | password_hash: passwordHash,
108 | created_at: now,
109 | refresh_interval: 5000,
110 | theme: 'dark'
111 | });
112 |
113 | console.warn('⚠️ Admin password initialised from DEFAULT_ADMIN_PASSWORD. Update it immediately after first login.');
114 | if (config.defaultAdminPassword === 'admin') {
115 | console.warn('⚠️ DEFAULT_ADMIN_PASSWORD is set to the default value. Set a strong password in production environments.');
116 | }
117 | }
118 |
119 | const initialization = (async () => {
120 | await ensureDataDir();
121 | await initializeDatabase();
122 | await migrateLegacyData();
123 | await ensureConfigRow();
124 | })();
125 |
126 | async function ensureInitialized() {
127 | return initialization;
128 | }
129 |
130 | // Config Management
131 | export async function getConfig() {
132 | await ensureInitialized();
133 | const db = getDb();
134 | const row = db
135 | .prepare('SELECT password_hash, created_at, refresh_interval, theme FROM config WHERE id = 1')
136 | .get();
137 |
138 | if (!row) {
139 | await ensureConfigRow();
140 | return getConfig();
141 | }
142 |
143 | return {
144 | passwordHash: row.password_hash,
145 | createdAt: row.created_at,
146 | settings: {
147 | refreshInterval: row.refresh_interval,
148 | theme: row.theme
149 | }
150 | };
151 | }
152 |
153 | export async function updateConfig(updates) {
154 | await ensureInitialized();
155 | const current = await getConfig();
156 | const merged = {
157 | passwordHash: updates.passwordHash ?? current.passwordHash,
158 | createdAt: current.createdAt,
159 | settings: {
160 | refreshInterval: updates.settings?.refreshInterval ?? current.settings.refreshInterval,
161 | theme: updates.settings?.theme ?? current.settings.theme
162 | }
163 | };
164 |
165 | const db = getDb();
166 | db.prepare(`
167 | UPDATE config
168 | SET password_hash = @password_hash,
169 | refresh_interval = @refresh_interval,
170 | theme = @theme
171 | WHERE id = 1
172 | `).run({
173 | password_hash: merged.passwordHash,
174 | refresh_interval: merged.settings.refreshInterval,
175 | theme: merged.settings.theme
176 | });
177 |
178 | return merged;
179 | }
180 |
181 | export async function setPassword(newPassword) {
182 | const passwordHash = await bcrypt.hash(newPassword, 10);
183 | return await updateConfig({ passwordHash });
184 | }
185 |
186 | export async function verifyPassword(password) {
187 | await ensureInitialized();
188 | const db = getDb();
189 | const row = db.prepare('SELECT password_hash FROM config WHERE id = 1').get();
190 | if (!row) {
191 | await ensureConfigRow();
192 | return verifyPassword(password);
193 | }
194 | return await bcrypt.compare(password, row.password_hash);
195 | }
196 |
197 | function safeDecrypt(value, context) {
198 | if (!value) {
199 | return undefined;
200 | }
201 |
202 | try {
203 | return decrypt(value);
204 | } catch (error) {
205 | console.error(`Failed to decrypt ${context}:`, error.message);
206 | return undefined;
207 | }
208 | }
209 |
210 | function mapServerRow(row) {
211 | return {
212 | id: row.id,
213 | name: row.name,
214 | host: row.host,
215 | port: row.port,
216 | username: row.username,
217 | password: safeDecrypt(row.password, `password for ${row.id}`),
218 | privateKeyPath: row.private_key_path || undefined,
219 | group: row.group_name,
220 | status: row.status,
221 | createdAt: row.created_at,
222 | updatedAt: row.updated_at,
223 | lastSeen: row.last_seen || undefined
224 | };
225 | }
226 |
227 | async function attachPrivateKey(server) {
228 | if (!server.privateKeyPath) {
229 | return { ...server, privateKey: undefined };
230 | }
231 |
232 | try {
233 | const privateKey = await readPrivateKey(server.privateKeyPath);
234 | return { ...server, privateKey };
235 | } catch (error) {
236 | console.error(`Failed to load private key for ${server.name || server.id}:`, error.message);
237 | return { ...server, privateKey: undefined };
238 | }
239 | }
240 |
241 | // Server Management
242 | export async function getServers() {
243 | await ensureInitialized();
244 | const db = getDb();
245 | const rows = db
246 | .prepare('SELECT * FROM servers ORDER BY datetime(created_at) ASC, id ASC')
247 | .all();
248 |
249 | const mapped = rows.map(mapServerRow);
250 | return await Promise.all(mapped.map(attachPrivateKey));
251 | }
252 |
253 | export async function getServerById(id) {
254 | await ensureInitialized();
255 | const db = getDb();
256 | const row = db.prepare('SELECT * FROM servers WHERE id = ?').get(id);
257 | if (!row) {
258 | return undefined;
259 | }
260 | const server = mapServerRow(row);
261 | return await attachPrivateKey(server);
262 | }
263 |
264 | export async function addServer(serverData) {
265 | await ensureInitialized();
266 | const db = getDb();
267 |
268 | const serverId = `srv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
269 |
270 | let privateKeyPath;
271 | if (serverData.privateKey) {
272 | privateKeyPath = await savePrivateKey(serverId, serverData.privateKey);
273 | console.log(`Private key saved to: ${privateKeyPath}`);
274 | }
275 |
276 | const now = new Date().toISOString();
277 |
278 | db.prepare(`
279 | INSERT INTO servers (
280 | id, name, host, port, username, password, private_key_path,
281 | group_name, status, created_at, updated_at, last_seen
282 | ) VALUES (
283 | @id, @name, @host, @port, @username, @password, @private_key_path,
284 | @group_name, @status, @created_at, @updated_at, @last_seen
285 | )
286 | `).run({
287 | id: serverId,
288 | name: serverData.name,
289 | host: serverData.host,
290 | port: serverData.port || 22,
291 | username: serverData.username,
292 | password: serverData.password ? encrypt(serverData.password) : null,
293 | private_key_path: privateKeyPath || null,
294 | group_name: serverData.group || 'default',
295 | status: 'unknown',
296 | created_at: now,
297 | updated_at: now,
298 | last_seen: null
299 | });
300 |
301 | return {
302 | id: serverId,
303 | name: serverData.name,
304 | host: serverData.host,
305 | port: serverData.port || 22,
306 | username: serverData.username,
307 | password: serverData.password,
308 | privateKey: serverData.privateKey,
309 | privateKeyPath,
310 | group: serverData.group || 'default',
311 | status: 'unknown',
312 | createdAt: now,
313 | updatedAt: now
314 | };
315 | }
316 |
317 | export async function updateServer(id, updates) {
318 | await ensureInitialized();
319 | const db = getDb();
320 | const existing = await getServerById(id);
321 |
322 | if (!existing) {
323 | throw new Error('Server not found');
324 | }
325 |
326 | let privateKeyPath = existing.privateKeyPath;
327 | let privateKey = existing.privateKey;
328 |
329 | if (updates.privateKey !== undefined) {
330 | if (updates.privateKey) {
331 | privateKeyPath = await savePrivateKey(id, updates.privateKey);
332 | privateKey = updates.privateKey;
333 | console.log(`Private key updated for server ${existing.name || id}: ${privateKeyPath}`);
334 | } else if (privateKeyPath) {
335 | await deletePrivateKey(privateKeyPath);
336 | console.log(`Private key deleted for server ${existing.name || id}: ${privateKeyPath}`);
337 | privateKeyPath = undefined;
338 | privateKey = undefined;
339 | }
340 | }
341 |
342 | let password = existing.password;
343 | if (updates.password !== undefined) {
344 | password = updates.password || undefined;
345 | }
346 |
347 | const now = new Date().toISOString();
348 |
349 | const payload = {
350 | id,
351 | name: updates.name ?? existing.name,
352 | host: updates.host ?? existing.host,
353 | port: updates.port ?? existing.port,
354 | username: updates.username ?? existing.username,
355 | password: password ? encrypt(password) : null,
356 | private_key_path: privateKeyPath || null,
357 | group_name: updates.group ?? existing.group,
358 | updated_at: now
359 | };
360 |
361 | db.prepare(`
362 | UPDATE servers
363 | SET name = @name,
364 | host = @host,
365 | port = @port,
366 | username = @username,
367 | password = @password,
368 | private_key_path = @private_key_path,
369 | group_name = @group_name,
370 | updated_at = @updated_at
371 | WHERE id = @id
372 | `).run(payload);
373 |
374 | return {
375 | id,
376 | name: payload.name,
377 | host: payload.host,
378 | port: payload.port,
379 | username: payload.username,
380 | password,
381 | privateKey,
382 | privateKeyPath,
383 | group: payload.group_name,
384 | status: existing.status,
385 | createdAt: existing.createdAt,
386 | updatedAt: now,
387 | lastSeen: existing.lastSeen
388 | };
389 | }
390 |
391 | export async function deleteServer(id) {
392 | await ensureInitialized();
393 | const db = getDb();
394 | const existing = await getServerById(id);
395 |
396 | if (!existing) {
397 | throw new Error('Server not found');
398 | }
399 |
400 | if (existing.privateKeyPath) {
401 | await deletePrivateKey(existing.privateKeyPath);
402 | console.log(`Private key deleted: ${existing.privateKeyPath}`);
403 | }
404 |
405 | db.prepare('DELETE FROM servers WHERE id = ?').run(id);
406 | return true;
407 | }
408 |
409 | export async function updateServerStatus(id, status) {
410 | await ensureInitialized();
411 | const db = getDb();
412 | const now = new Date().toISOString();
413 | db.prepare(`
414 | UPDATE servers
415 | SET status = @status,
416 | last_seen = @last_seen
417 | WHERE id = @id
418 | `).run({
419 | id,
420 | status,
421 | last_seen: now
422 | });
423 | }
424 |
--------------------------------------------------------------------------------