최초 배포
107
README.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# snStatus - Synology NAS System Monitor
|
||||||
|
|
||||||
|
> 🖥️ **실시간 시스템 모니터링 & Docker 컨테이너 관리 웹 애플리케이션**
|
||||||
|
|
||||||
|
Synology NAS를 위한 현대적이고 직관적인 시스템 모니터링 대시보드입니다. PWA(Progressive Web App)를 지원하여 앱처럼 설치할 수 있으며, 시스템 리소스와 Docker 컨테이너를 언제 어디서나 손쉽게 관리할 수 있습니다.
|
||||||
|
|
||||||
|
** 해당 프로젝트는 바이브 코딩을 활용하여 제작 되었습니다.**
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 주요 기능
|
||||||
|
|
||||||
|
### 📊 실시간 시스템 모니터링
|
||||||
|
- **리소스 대시보드** : CPU, 메모리, 디스크 사용량 및 네트워크 트래픽 실시간 시각화
|
||||||
|
- **인터랙티브 차트** : 다양한 기간(1h, 6h, 12h, 24h) 선택 및 상세 툴팁 제공
|
||||||
|
- **직관적인 UI** : 글래스모피즘(Glassmorphism) 디자인으로 세련된 정보 표시
|
||||||
|
|
||||||
|
### 🐳 강력한 Docker 컨테이너 관리
|
||||||
|
- **실시간 리소스 모니터링**
|
||||||
|
- **컨테이너 제어** : 시작, 중지, 재시작, 강제 종료 등 원격 제어 지원
|
||||||
|
- **웹 터미널** : 브라우저에서 컨테이너 내부 쉘(bash/sh) 직접 접속
|
||||||
|
- **실시간 로그** : 컨테이너 로그 스트리밍 확인
|
||||||
|
|
||||||
|
### 🔔 스마트 알림 시스템
|
||||||
|
- **임계치 알림** : CPU, 메모리, 디스크 사용률이 설정된 한계 초과 시 알림
|
||||||
|
- **Docker 이벤트** : 컨테이너 비정상 종료 시 즉시 알림
|
||||||
|
- **PWA 푸시 알림** : 앱을 닫아도 백그라운드에서 시스템 경고 수신
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 설치 및 실행
|
||||||
|
|
||||||
|
### 사전 요구사항
|
||||||
|
- **Docker** & **Docker Compose**
|
||||||
|
- **HTTPS 환경 (PWA 설치를 위한 필수 조건)**
|
||||||
|
|
||||||
|
### 설치 방법
|
||||||
|
git clone을 수행하지 않고 직접 docker-compose.yaml을 작성하여 실행하여도 됩니다.
|
||||||
|
만약 직접 작성하여 실행을 하실 경우 3 부터 시작하세요.
|
||||||
|
|
||||||
|
1. **프로젝트 클론**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/ElppaDev/snStatus.git
|
||||||
|
cd snStatus
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Docker Compose 직접 작성**
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: sa8001/snstatus-backend:v0.1.5
|
||||||
|
container_name: snstatus-backend
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
privileged: true
|
||||||
|
volumes:
|
||||||
|
- /:/host:ro
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./data:/app/data
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: sa8001/snstatus-frontend:v0.1.5
|
||||||
|
container_name: snstatus-frontend
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3005:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Docker Compose 실행**
|
||||||
|
```bash
|
||||||
|
mkdir data
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **접속**
|
||||||
|
- 브라우저에서 `http://<NAS-IP>:3005` 접속
|
||||||
|
- PWA 앱 설치 및 푸시 알림을 사용하기 위해서는 HTTPS를 통하여 접속하여야 합니다.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 모바일 화면
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
### PC 화면
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
12
backend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8001
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
30
backend/convert_icons.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const sharp = require('sharp');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// /app/data 디렉토리 사용
|
||||||
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
|
const svgPath = path.join(DATA_DIR, 'icon.svg');
|
||||||
|
|
||||||
|
// icon.svg가 존재하는지 확인
|
||||||
|
if (!fs.existsSync(svgPath)) {
|
||||||
|
console.error(`File not found: ${svgPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgBuffer = fs.readFileSync(svgPath);
|
||||||
|
|
||||||
|
async function convert() {
|
||||||
|
try {
|
||||||
|
console.log('Converting icons from:', svgPath);
|
||||||
|
await sharp(svgBuffer).resize(64, 64).png().toFile(path.join(DATA_DIR, 'favicon.png'));
|
||||||
|
await sharp(svgBuffer).resize(192, 192).png().toFile(path.join(DATA_DIR, 'pwa-192x192.png'));
|
||||||
|
await sharp(svgBuffer).resize(512, 512).png().toFile(path.join(DATA_DIR, 'pwa-512x512.png'));
|
||||||
|
await sharp(svgBuffer).resize(180, 180).png().toFile(path.join(DATA_DIR, 'apple-touch-icon.png'));
|
||||||
|
console.log('Conversion complete. Files saved to:', DATA_DIR);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error converting icons:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
convert();
|
||||||
BIN
backend/data/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
13
backend/data/config.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"passwordHash": "d7fe5080df575511eb7b3c5e50a99c0402952461f5e202e8c90461c1e52d208a9c14329317d5b39ec85de62caca369114295d559466e044d9f8b3a5e94b5ebd3",
|
||||||
|
"salt": "ced784250b4ed141397cc2115e864dac",
|
||||||
|
"retentionHours": 24,
|
||||||
|
"alertThresholds": {
|
||||||
|
"cpu": 80,
|
||||||
|
"memory": 80,
|
||||||
|
"disk": 90
|
||||||
|
},
|
||||||
|
"containerAlertEnabled": false,
|
||||||
|
"alertCooldownSeconds": 300
|
||||||
|
}
|
||||||
30
backend/data/convert_icons_exec.cjs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const sharp = require('sharp');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// 이미 /app/data 내에서 실행되므로 현재 디렉토리가 data 폴더임
|
||||||
|
const DATA_DIR = __dirname;
|
||||||
|
const svgPath = path.join(DATA_DIR, 'icon.svg');
|
||||||
|
|
||||||
|
// icon.svg가 존재하는지 확인
|
||||||
|
if (!fs.existsSync(svgPath)) {
|
||||||
|
console.error(`File not found: ${svgPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgBuffer = fs.readFileSync(svgPath);
|
||||||
|
|
||||||
|
async function convert() {
|
||||||
|
try {
|
||||||
|
console.log('Converting icons from:', svgPath);
|
||||||
|
await sharp(svgBuffer).resize(64, 64).png().toFile(path.join(DATA_DIR, 'favicon.png'));
|
||||||
|
await sharp(svgBuffer).resize(192, 192).png().toFile(path.join(DATA_DIR, 'pwa-192x192.png'));
|
||||||
|
await sharp(svgBuffer).resize(512, 512).png().toFile(path.join(DATA_DIR, 'pwa-512x512.png'));
|
||||||
|
await sharp(svgBuffer).resize(180, 180).png().toFile(path.join(DATA_DIR, 'apple-touch-icon.png'));
|
||||||
|
console.log('Conversion complete. Files saved to:', DATA_DIR);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error converting icons:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
convert();
|
||||||
BIN
backend/data/favicon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
1
backend/data/history.json
Normal file
49
backend/data/icon.svg
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#2563eb"/>
|
||||||
|
<stop offset="1" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="dropshadow" height="130%">
|
||||||
|
<feGaussianBlur in="SourceAlpha" stdDeviation="15"/>
|
||||||
|
<feOffset dx="0" dy="10" result="offsetblur"/>
|
||||||
|
<feComponentTransfer>
|
||||||
|
<feFuncA type="linear" slope="0.3"/>
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background: Royal Blue (Adjusted from Deep Navy) -->
|
||||||
|
<rect width="512" height="512" fill="#1e3a8a"/>
|
||||||
|
|
||||||
|
<!-- Main Hexagon Shape (Scaled Down to 85% and Centered using Matrix) -->
|
||||||
|
<!-- Matrix calc: Scale 0.85. Offset = (512 - 512*0.85)/2 = 38.4 -->
|
||||||
|
<g filter="url(#dropshadow)" transform="matrix(0.85, 0, 0, 0.85, 38.4, 38.4)">
|
||||||
|
<path d="M256 50
|
||||||
|
L433 152
|
||||||
|
L433 357
|
||||||
|
L256 460
|
||||||
|
L79 357
|
||||||
|
L79 152
|
||||||
|
Z"
|
||||||
|
fill="url(#paint0_linear)"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="20"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- Internal Cube Lines (Y shape) -->
|
||||||
|
<path d="M256 460 L256 255" stroke="white" stroke-width="20" stroke-linecap="round"/>
|
||||||
|
<path d="M256 255 L433 152" stroke="white" stroke-width="20" stroke-linecap="round"/>
|
||||||
|
<path d="M79 152 L256 255" stroke="white" stroke-width="20" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Tech Accent Dots -->
|
||||||
|
<circle cx="256" cy="255" r="30" fill="white"/>
|
||||||
|
<circle cx="256" cy="110" r="15" fill="white" fill-opacity="0.6"/>
|
||||||
|
<circle cx="380" cy="320" r="15" fill="white" fill-opacity="0.6"/>
|
||||||
|
<circle cx="132" cy="320" r="15" fill="white" fill-opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
BIN
backend/data/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
backend/data/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
1
backend/data/subscriptions.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
1
backend/data/vapid.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"publicKey":"BFOD5bzNh9Dch_Rjt8cpWc2IfSktAoEUx0virpARRvPfpmsuvQka3ppCa0WajNFaIJS7Z62VNUAeo_6yMBvLVZA","privateKey":"ymBn65iLw_M19FJPdzJ7FqA7jgHVBCfSV6vSdM_8GIw"}
|
||||||
21
backend/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "snstatus-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "System monitoring backend for Synology NAS",
|
||||||
|
"main": "server.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dockerode": "^4.0.0",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"socket.io": "^4.7.2",
|
||||||
|
"systeminformation": "^5.21.22",
|
||||||
|
"web-push": "^3.6.0"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC"
|
||||||
|
}
|
||||||
785
backend/server.js
Normal file
@@ -0,0 +1,785 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import si from 'systeminformation';
|
||||||
|
import Docker from 'dockerode';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { Server } from 'socket.io';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import webpush from 'web-push';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const httpServer = createServer(app);
|
||||||
|
const io = new Server(httpServer, {
|
||||||
|
cors: {
|
||||||
|
origin: "*",
|
||||||
|
methods: ["GET", "POST"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const PORT = 8001;
|
||||||
|
|
||||||
|
// Docker Connection
|
||||||
|
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Helper: Read Synology Info from Host
|
||||||
|
const getSynologyInfo = () => {
|
||||||
|
try {
|
||||||
|
const versionPath = '/host/etc/VERSION';
|
||||||
|
const synoInfoPath = '/host/etc/synoinfo.conf';
|
||||||
|
|
||||||
|
let dsmVersion = 'Unknown';
|
||||||
|
let model = 'Synology NAS';
|
||||||
|
|
||||||
|
if (fs.existsSync(versionPath)) {
|
||||||
|
const versionContent = fs.readFileSync(versionPath, 'utf8');
|
||||||
|
const productVersion = versionContent.match(/productversion="([^"]+)"/);
|
||||||
|
const buildNumber = versionContent.match(/buildnumber="([^"]+)"/);
|
||||||
|
if (productVersion && buildNumber) {
|
||||||
|
dsmVersion = `DSM ${productVersion[1]}-${buildNumber[1]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(synoInfoPath)) {
|
||||||
|
// Try to read model from synoinfo (unique="synology_geminilake_920+") or similar
|
||||||
|
// Or usually /proc/sys/kernel/syno_hw_version if available
|
||||||
|
// Simpler fallback: check hostname or use generic if deep parsing fails.
|
||||||
|
// Actually synoinfo.conf has 'upnpmodelname="DS920+"' usually.
|
||||||
|
const infoContent = fs.readFileSync(synoInfoPath, 'utf8');
|
||||||
|
const modelMatch = infoContent.match(/upnpmodelname="([^"]+)"/);
|
||||||
|
if (modelMatch) {
|
||||||
|
model = modelMatch[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { model, dsmVersion };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to read host files:', e);
|
||||||
|
return { model: 'Docker Container', dsmVersion: 'Unknown' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Docker Config & Security ---
|
||||||
|
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||||
|
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
if (!fs.existsSync(DATA_DIR)) {
|
||||||
|
try { fs.mkdirSync(DATA_DIR, { recursive: true }); }
|
||||||
|
catch (e) { console.error('Failed to create data directory:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
let dockerConfig = {
|
||||||
|
enabled: false,
|
||||||
|
passwordHash: null,
|
||||||
|
salt: null,
|
||||||
|
retentionHours: 24,
|
||||||
|
alertThresholds: { cpu: 80, memory: 80, disk: 90 },
|
||||||
|
containerAlertEnabled: false,
|
||||||
|
alertCooldownSeconds: 300 // 5 minutes default
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveDockerConfig = () => {
|
||||||
|
try { fs.writeFileSync(CONFIG_FILE, JSON.stringify(dockerConfig, null, 2)); }
|
||||||
|
catch (e) { console.error('Failed to save docker config:', e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDockerConfig = () => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CONFIG_FILE)) {
|
||||||
|
const data = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||||
|
dockerConfig = JSON.parse(data);
|
||||||
|
// Merge defaults
|
||||||
|
if (!dockerConfig.alertThresholds) dockerConfig.alertThresholds = { cpu: 90, mem: 90, disk: 90 };
|
||||||
|
// Restore history limit from saved retention hours
|
||||||
|
// Note: historyRetentionHours and HISTORY_LIMIT are defined later in the file
|
||||||
|
// This function is called AFTER those variables are initialized (line 438)
|
||||||
|
if (dockerConfig.retentionHours) {
|
||||||
|
historyRetentionHours = dockerConfig.retentionHours;
|
||||||
|
HISTORY_LIMIT = historyRetentionHours * 60;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
saveDockerConfig();
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('Failed to load docker config:', e); }
|
||||||
|
};
|
||||||
|
// I will Stub loadDockerConfig here and call the real logic later?
|
||||||
|
// No, I should move History Vars up too.
|
||||||
|
|
||||||
|
// --- Helper: Secure Hash ---
|
||||||
|
const hashPassword = (password, salt = null) => {
|
||||||
|
if (!password) return null;
|
||||||
|
try {
|
||||||
|
if (!salt) salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
|
||||||
|
return { hash, salt };
|
||||||
|
} catch (e) { console.error("Hashing error:", e); return null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Push Notification System Variables ---
|
||||||
|
const SUBSCRIPTIONS_FILE = path.join(process.cwd(), 'data', 'subscriptions.json');
|
||||||
|
let subscriptions = [];
|
||||||
|
let vapidKeys = { publicKey: '', privateKey: '' };
|
||||||
|
let alertState = { cpu: 0, memory: 0, disk: 0 };
|
||||||
|
|
||||||
|
const initPushNotifications = () => {
|
||||||
|
// Ensure data directory exists
|
||||||
|
if (!fs.existsSync(process.cwd() + '/data')) {
|
||||||
|
fs.mkdirSync(process.cwd() + '/data', { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize VAPID keys
|
||||||
|
if (!fs.existsSync(process.cwd() + '/data/vapid.json')) {
|
||||||
|
vapidKeys = webpush.generateVAPIDKeys();
|
||||||
|
fs.writeFileSync(process.cwd() + '/data/vapid.json', JSON.stringify(vapidKeys));
|
||||||
|
} else {
|
||||||
|
vapidKeys = JSON.parse(fs.readFileSync(process.cwd() + '/data/vapid.json', 'utf8'));
|
||||||
|
}
|
||||||
|
webpush.setVapidDetails('mailto:admin@example.com', vapidKeys.publicKey, vapidKeys.privateKey);
|
||||||
|
|
||||||
|
// Initialize subscriptions - CREATE FILE IF MISSING
|
||||||
|
if (fs.existsSync(SUBSCRIPTIONS_FILE)) {
|
||||||
|
try {
|
||||||
|
subscriptions = JSON.parse(fs.readFileSync(SUBSCRIPTIONS_FILE, 'utf8'));
|
||||||
|
console.log('[PUSH] Loaded', subscriptions.length, 'existing subscriptions');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PUSH] Error loading subscriptions:', e);
|
||||||
|
subscriptions = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[PUSH] Creating new subscriptions file');
|
||||||
|
subscriptions = [];
|
||||||
|
fs.writeFileSync(SUBSCRIPTIONS_FILE, JSON.stringify(subscriptions));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSubscriptions = () => {
|
||||||
|
try { fs.writeFileSync(SUBSCRIPTIONS_FILE, JSON.stringify(subscriptions)); }
|
||||||
|
catch (e) { console.error('Failed to save subscriptions:', e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendNotification = (payload) => {
|
||||||
|
const notificationPayload = JSON.stringify(payload);
|
||||||
|
console.log('[PUSH] Sending notification to', subscriptions.length, 'subscribers');
|
||||||
|
console.log('[PUSH] Payload:', payload);
|
||||||
|
|
||||||
|
if (subscriptions.length === 0) {
|
||||||
|
console.warn('[PUSH] No subscriptions available!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptions.forEach((subscription, index) => {
|
||||||
|
console.log(`[PUSH] Sending to subscription ${index + 1}/${subscriptions.length}`);
|
||||||
|
webpush.sendNotification(subscription, notificationPayload)
|
||||||
|
.then(() => {
|
||||||
|
console.log(`[PUSH] Successfully sent to subscription ${index + 1}`);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(`[PUSH] Error sending to subscription ${index + 1}:`, error);
|
||||||
|
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||||
|
console.log('[PUSH] Removing expired subscription');
|
||||||
|
subscriptions.splice(index, 1);
|
||||||
|
saveSubscriptions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkAlerts = (stats) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const thresholds = dockerConfig.alertThresholds || { cpu: 90, memory: 90, disk: 90 };
|
||||||
|
const COOLDOWN = (dockerConfig.alertCooldownSeconds || 300) * 1000; // Convert seconds to ms
|
||||||
|
|
||||||
|
if (stats.cpu > thresholds.cpu) {
|
||||||
|
if (now - alertState.cpu > COOLDOWN) {
|
||||||
|
console.log('[ALERT] CPU alert sent:', stats.cpu + '%');
|
||||||
|
sendNotification({ title: 'snStatus 알림', body: `CPU 사용률 높음: ${stats.cpu}%` });
|
||||||
|
alertState.cpu = now;
|
||||||
|
}
|
||||||
|
} else { alertState.cpu = 0; }
|
||||||
|
|
||||||
|
if (stats.memory > thresholds.memory) {
|
||||||
|
if (now - alertState.memory > COOLDOWN) {
|
||||||
|
console.log('[ALERT] Memory alert sent:', stats.memory + '%');
|
||||||
|
sendNotification({ title: 'snStatus 알림', body: `메모리 사용률 높음: ${stats.memory}%` });
|
||||||
|
alertState.memory = now;
|
||||||
|
}
|
||||||
|
} else { alertState.memory = 0; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// VAPID Public Key Endpoint
|
||||||
|
app.get('/api/notifications/vapidPublicKey', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!vapidKeys || !vapidKeys.publicKey) {
|
||||||
|
return res.status(500).json({ error: 'VAPID keys not initialized' });
|
||||||
|
}
|
||||||
|
res.json({ publicKey: vapidKeys.publicKey });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting VAPID key:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get VAPID key' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to push notifications
|
||||||
|
app.post('/api/notifications/subscribe', async (req, res) => {
|
||||||
|
const subscription = req.body;
|
||||||
|
if (!subscription || !subscription.endpoint) {
|
||||||
|
return res.status(400).json({ error: 'Invalid subscription' });
|
||||||
|
}
|
||||||
|
// Remove existing subscription with same endpoint
|
||||||
|
subscriptions = subscriptions.filter(sub => sub.endpoint !== subscription.endpoint);
|
||||||
|
subscriptions.push(subscription);
|
||||||
|
saveSubscriptions();
|
||||||
|
console.log('Push subscription added:', subscription.endpoint);
|
||||||
|
res.status(201).json({ message: 'Subscription added' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unsubscribe from push notifications
|
||||||
|
app.post('/api/notifications/unsubscribe', async (req, res) => {
|
||||||
|
const { endpoint } = req.body;
|
||||||
|
subscriptions = subscriptions.filter(sub => sub.endpoint !== endpoint);
|
||||||
|
saveSubscriptions();
|
||||||
|
console.log('Push subscription removed:', endpoint);
|
||||||
|
res.json({ message: 'Unsubscribed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- System Info APIs ---
|
||||||
|
// Global state for network speed calculation
|
||||||
|
let previousNetStats = {};
|
||||||
|
let previousTime = Date.now();
|
||||||
|
|
||||||
|
// Function to read and parse /host/proc/net/dev
|
||||||
|
const getHostNetworkStats = () => {
|
||||||
|
try {
|
||||||
|
// Use /sys/class/net mounted from host to bypass container network namespace isolation.
|
||||||
|
// This is the correct way to see host interfaces (eth0, eth1, etc.) from a bridge container.
|
||||||
|
const netDir = '/host/sys/class/net';
|
||||||
|
|
||||||
|
let interfaces = [];
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(netDir)) {
|
||||||
|
interfaces = fs.readdirSync(netDir);
|
||||||
|
} else {
|
||||||
|
console.warn("Host sysfs not found at /host/sys/class/net");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} catch (e) { return []; }
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const currentStats = {};
|
||||||
|
const timeDiff = (timestamp - previousTime) / 1000;
|
||||||
|
let result = [];
|
||||||
|
|
||||||
|
for (const iface of interfaces) {
|
||||||
|
// Filter unwanted interfaces
|
||||||
|
if (iface === 'lo' ||
|
||||||
|
iface.startsWith('veth') ||
|
||||||
|
iface.startsWith('docker') ||
|
||||||
|
iface.startsWith('br-') ||
|
||||||
|
iface.startsWith('cali') ||
|
||||||
|
iface.startsWith('flannel') ||
|
||||||
|
iface.startsWith('cni') ||
|
||||||
|
iface.startsWith('sit') ||
|
||||||
|
iface.startsWith('tun') ||
|
||||||
|
iface.startsWith('tap')) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rxPath = path.join(netDir, iface, 'statistics', 'rx_bytes');
|
||||||
|
const txPath = path.join(netDir, iface, 'statistics', 'tx_bytes');
|
||||||
|
|
||||||
|
if (!fs.existsSync(rxPath) || !fs.existsSync(txPath)) continue;
|
||||||
|
|
||||||
|
const rxBytes = parseInt(fs.readFileSync(rxPath, 'utf8').trim());
|
||||||
|
const txBytes = parseInt(fs.readFileSync(txPath, 'utf8').trim());
|
||||||
|
|
||||||
|
currentStats[iface] = { rx: rxBytes, tx: txBytes };
|
||||||
|
|
||||||
|
let rx_sec = 0;
|
||||||
|
let tx_sec = 0;
|
||||||
|
|
||||||
|
if (previousNetStats[iface] && timeDiff > 0) {
|
||||||
|
const prevRx = previousNetStats[iface].rx;
|
||||||
|
const prevTx = previousNetStats[iface].tx;
|
||||||
|
|
||||||
|
// Simple difference (handle counter wrap if needed, but unlikely for 64bit counters in JS for short periods)
|
||||||
|
if (rxBytes >= prevRx) rx_sec = (rxBytes - prevRx) / timeDiff;
|
||||||
|
if (txBytes >= prevTx) tx_sec = (txBytes - prevTx) / timeDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
iface: iface,
|
||||||
|
rx_sec: rx_sec,
|
||||||
|
tx_sec: tx_sec
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousNetStats = currentStats;
|
||||||
|
previousTime = timestamp;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read host network stats from /sys:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get('/api/stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [cpu, mem, fsData, osInfo, system] = await Promise.all([
|
||||||
|
si.currentLoad(),
|
||||||
|
si.mem(),
|
||||||
|
si.fsSize(),
|
||||||
|
si.osInfo(),
|
||||||
|
si.system()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get Host Network Stats
|
||||||
|
const network = getHostNetworkStats();
|
||||||
|
|
||||||
|
// Get Host Info
|
||||||
|
const hostInfo = getSynologyInfo();
|
||||||
|
|
||||||
|
// Disk Filtering Logic
|
||||||
|
const filteredStorage = fsData.filter(disk => {
|
||||||
|
let mountPoint = disk.mount;
|
||||||
|
if (mountPoint.startsWith('/host')) mountPoint = mountPoint.substring(5);
|
||||||
|
if (mountPoint.includes('@') || mountPoint.includes('docker') || mountPoint.includes('container') ||
|
||||||
|
mountPoint.includes('appdata') || mountPoint.includes('tmp') ||
|
||||||
|
disk.fs.startsWith('overlay') || disk.fs.startsWith('tmpfs') || disk.fs.startsWith('shm')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /^\/volume\d+$/.test(mountPoint);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
cpu: {
|
||||||
|
load: Math.round(cpu.currentLoad),
|
||||||
|
cores: cpu.cpus.map(c => Math.round(c.load)),
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
total: mem.total,
|
||||||
|
used: mem.used,
|
||||||
|
percentage: Math.round((mem.active / mem.total) * 100),
|
||||||
|
},
|
||||||
|
storage: filteredStorage.map(disk => ({
|
||||||
|
fs: disk.fs,
|
||||||
|
type: disk.type,
|
||||||
|
size: disk.size,
|
||||||
|
used: disk.used,
|
||||||
|
use: Math.round(disk.use),
|
||||||
|
mount: disk.mount,
|
||||||
|
})),
|
||||||
|
network: network, // Use custom stats
|
||||||
|
system: {
|
||||||
|
model: hostInfo.model !== 'Docker Container' ? hostInfo.model : system.model, // Prefer host info
|
||||||
|
manufacturer: system.manufacturer,
|
||||||
|
os: 'DSM', // Hardcode or derive
|
||||||
|
release: hostInfo.dsmVersion,
|
||||||
|
kernel: osInfo.kernel,
|
||||||
|
hostname: osInfo.hostname,
|
||||||
|
uptime: osInfo.uptime
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(stats);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching system stats:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to retrieve system statistics' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- History Data ---
|
||||||
|
const HISTORY_FILE = path.join(process.cwd(), 'data', 'history.json');
|
||||||
|
let historyRetentionHours = 24; // Default 24 hours
|
||||||
|
let HISTORY_LIMIT = historyRetentionHours * 60; // points (1 min interval)
|
||||||
|
let historyData = [];
|
||||||
|
|
||||||
|
const saveHistory = () => {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(HISTORY_FILE, JSON.stringify(historyData));
|
||||||
|
} catch (e) { console.error('Failed to save history:', e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadHistory = () => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(HISTORY_FILE)) {
|
||||||
|
const data = fs.readFileSync(HISTORY_FILE, 'utf8');
|
||||||
|
historyData = JSON.parse(data);
|
||||||
|
// Ensure data respects current limit (if limit changed while offline? Unlikely but good practice)
|
||||||
|
if (historyData.length > HISTORY_LIMIT) {
|
||||||
|
historyData = historyData.slice(historyData.length - HISTORY_LIMIT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('Failed to load history:', e); }
|
||||||
|
};
|
||||||
|
loadHistory();
|
||||||
|
|
||||||
|
// Retention Settings API
|
||||||
|
app.get('/api/settings/retention', (req, res) => {
|
||||||
|
res.json({ hours: historyRetentionHours });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/settings/retention', (req, res) => {
|
||||||
|
const { hours } = req.body;
|
||||||
|
if (!hours || hours < 1) {
|
||||||
|
return res.status(400).json({ error: 'Invalid retention period' });
|
||||||
|
}
|
||||||
|
historyRetentionHours = parseInt(hours);
|
||||||
|
HISTORY_LIMIT = historyRetentionHours * 60;
|
||||||
|
|
||||||
|
// Trim existing data if needed
|
||||||
|
if (historyData.length > HISTORY_LIMIT) {
|
||||||
|
historyData = historyData.slice(historyData.length - HISTORY_LIMIT);
|
||||||
|
}
|
||||||
|
saveHistory(); // Save truncated data
|
||||||
|
|
||||||
|
// Save preference to config
|
||||||
|
dockerConfig.retentionHours = historyRetentionHours;
|
||||||
|
saveDockerConfig();
|
||||||
|
|
||||||
|
console.log(`History retention updated to ${historyRetentionHours} hours (${HISTORY_LIMIT} points)`);
|
||||||
|
res.json({ message: 'Retention updated', hours: historyRetentionHours });
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateHistory = async () => {
|
||||||
|
try {
|
||||||
|
const [cpu, mem] = await Promise.all([
|
||||||
|
si.currentLoad(),
|
||||||
|
si.mem()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Capture Network Stats for History
|
||||||
|
// Note: This relies on the shared getHostNetworkStats state.
|
||||||
|
// It captures the "current rate" since the last poll (whether by UI or this loop).
|
||||||
|
const netStats = getHostNetworkStats();
|
||||||
|
let totalRx = 0;
|
||||||
|
let totalTx = 0;
|
||||||
|
netStats.forEach(net => {
|
||||||
|
// Include all interfaces or filter? Let's include all physical-ish ones found by our filter
|
||||||
|
totalRx += net.rx_sec;
|
||||||
|
totalTx += net.tx_sec;
|
||||||
|
});
|
||||||
|
|
||||||
|
const point = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
cpu: Math.round(cpu.currentLoad),
|
||||||
|
memory: Math.round((mem.active / mem.total) * 100),
|
||||||
|
network: {
|
||||||
|
rx: totalRx,
|
||||||
|
tx: totalTx
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
historyData.push(point);
|
||||||
|
if (historyData.length > HISTORY_LIMIT) {
|
||||||
|
historyData.shift(); // Remove oldest
|
||||||
|
}
|
||||||
|
|
||||||
|
saveHistory();
|
||||||
|
checkAlerts(point);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to update history:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update history every 1 minute
|
||||||
|
setInterval(updateHistory, 60 * 1000);
|
||||||
|
// Initial run
|
||||||
|
updateHistory();
|
||||||
|
|
||||||
|
app.get('/api/history', (req, res) => {
|
||||||
|
res.json(historyData);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Config saved via helper
|
||||||
|
// NOTE: loadDockerConfig normally called here.
|
||||||
|
// But check scope: HISTORY_LIMIT is defined above at 220.
|
||||||
|
// Our new loadDockerConfig at top CANNOT see HISTORY_LIMIT.
|
||||||
|
// So we must fix loadDockerConfig logic.
|
||||||
|
// I'll handle that separately. For now, deleting this block.
|
||||||
|
|
||||||
|
// Helper: Secure Hash (Scrypt + Salt)
|
||||||
|
|
||||||
|
|
||||||
|
loadDockerConfig(); // Initial Load
|
||||||
|
|
||||||
|
// Docker Config API
|
||||||
|
app.get('/api/docker/config', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
enabled: dockerConfig.enabled,
|
||||||
|
alertThresholds: dockerConfig.alertThresholds || { cpu: 90, memory: 90, disk: 90 },
|
||||||
|
containerAlertEnabled: dockerConfig.containerAlertEnabled !== undefined ? dockerConfig.containerAlertEnabled : true,
|
||||||
|
alertCooldownSeconds: dockerConfig.alertCooldownSeconds || 300
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/docker/config', (req, res) => {
|
||||||
|
const { enabled, password } = req.body;
|
||||||
|
const isEnabling = !!enabled;
|
||||||
|
|
||||||
|
// Case 1: Disabling (Require Verification)
|
||||||
|
if (!isEnabling && dockerConfig.enabled) {
|
||||||
|
if (!password) return res.status(400).json({ error: 'Password is required to disable Docker features.' });
|
||||||
|
|
||||||
|
if (dockerConfig.passwordHash) {
|
||||||
|
let match = false;
|
||||||
|
|
||||||
|
if (!dockerConfig.salt) {
|
||||||
|
// Legacy SHA256 Check
|
||||||
|
const legacyHash = crypto.createHash('sha256').update(password).digest('hex');
|
||||||
|
match = (legacyHash === dockerConfig.passwordHash);
|
||||||
|
} else {
|
||||||
|
// Modern Scrypt Check
|
||||||
|
try {
|
||||||
|
const { hash } = hashPassword(password, dockerConfig.salt);
|
||||||
|
match = crypto.timingSafeEqual(
|
||||||
|
Buffer.from(hash, 'hex'),
|
||||||
|
Buffer.from(dockerConfig.passwordHash, 'hex')
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ error: 'Verification error.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match) return res.status(401).json({ error: '패스워드가 올바르지 않습니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: Enabling (Require Password if no existing hash)
|
||||||
|
if (isEnabling && !password && !dockerConfig.passwordHash) {
|
||||||
|
return res.status(400).json({ error: 'Password is required to enable Docker features.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
dockerConfig.enabled = isEnabling;
|
||||||
|
|
||||||
|
// If Enabling and password provided, update/set password
|
||||||
|
if (isEnabling && password) {
|
||||||
|
const result = hashPassword(password);
|
||||||
|
if (result) {
|
||||||
|
dockerConfig.passwordHash = result.hash;
|
||||||
|
dockerConfig.salt = result.salt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDockerConfig();
|
||||||
|
console.log(`Docker feature ${dockerConfig.enabled ? 'Enabled' : 'Disabled'}`);
|
||||||
|
res.json({ message: 'Configuration saved', enabled: dockerConfig.enabled });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify Password API
|
||||||
|
app.post('/api/docker/verify', (req, res) => {
|
||||||
|
const { password } = req.body;
|
||||||
|
if (!password) return res.status(400).json({ success: false });
|
||||||
|
|
||||||
|
// Validate if config exists
|
||||||
|
if (!dockerConfig.passwordHash || !dockerConfig.salt) {
|
||||||
|
return res.status(500).json({ success: false, error: 'Security configuration not found. Please reset in Settings.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hash } = hashPassword(password, dockerConfig.salt);
|
||||||
|
|
||||||
|
// Constant-time comparison to prevent timing attacks
|
||||||
|
try {
|
||||||
|
const match = crypto.timingSafeEqual(
|
||||||
|
Buffer.from(hash, 'hex'),
|
||||||
|
Buffer.from(dockerConfig.passwordHash, 'hex')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ success: false, error: '패스워드가 올바르지 않습니다.' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
res.status(401).json({ success: false, error: 'Verification failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Docker APIs ---
|
||||||
|
// Helper: Calculate CPU Percent from Docker Stats
|
||||||
|
const calculateCPUPercent = (stats) => {
|
||||||
|
if (!stats || !stats.cpu_stats || !stats.precpu_stats) return 0;
|
||||||
|
|
||||||
|
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||||
|
const systemCpuDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||||
|
const numberCpus = stats.cpu_stats.online_cpus || (stats.cpu_stats.cpu_usage.percpu_usage ? stats.cpu_stats.cpu_usage.percpu_usage.length : 1);
|
||||||
|
|
||||||
|
if (systemCpuDelta > 0 && cpuDelta > 0) {
|
||||||
|
return (cpuDelta / systemCpuDelta) * numberCpus * 100.0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Docker APIs ---
|
||||||
|
app.get('/api/docker/containers', async (req, res) => {
|
||||||
|
// Check if enabled
|
||||||
|
if (!dockerConfig.enabled) {
|
||||||
|
return res.status(403).json({ error: 'Docker features are disabled.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const containers = await docker.listContainers({ all: true });
|
||||||
|
|
||||||
|
// Fetch stats for running containers
|
||||||
|
const containersWithStats = await Promise.all(containers.map(async (containerInfo) => {
|
||||||
|
if (containerInfo.State !== 'running') {
|
||||||
|
return { ...containerInfo, stats: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const container = docker.getContainer(containerInfo.Id);
|
||||||
|
const stats = await container.stats({ stream: false });
|
||||||
|
|
||||||
|
const cpuPercent = calculateCPUPercent(stats);
|
||||||
|
const memoryUsage = stats.memory_stats.usage || 0;
|
||||||
|
const memoryLimit = stats.memory_stats.limit || 0;
|
||||||
|
const memoryPercent = memoryLimit > 0 ? (memoryUsage / memoryLimit) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...containerInfo,
|
||||||
|
stats: {
|
||||||
|
cpu: cpuPercent,
|
||||||
|
memory: memoryUsage,
|
||||||
|
memoryLimit: memoryLimit,
|
||||||
|
memoryPercent: memoryPercent
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to fetch stats for ${containerInfo.Names[0]}:`, e.message);
|
||||||
|
return { ...containerInfo, stats: null };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(containersWithStats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching containers:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch containers. Is Docker Sock mounted?' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Container Control APIs
|
||||||
|
app.post('/api/docker/containers/:id/:action', async (req, res) => {
|
||||||
|
const { id, action } = req.params;
|
||||||
|
try {
|
||||||
|
const container = docker.getContainer(id);
|
||||||
|
let data;
|
||||||
|
switch (action) {
|
||||||
|
case 'start': data = await container.start(); break;
|
||||||
|
case 'stop': data = await container.stop(); break;
|
||||||
|
case 'restart': data = await container.restart(); break;
|
||||||
|
default: return res.status(400).json({ error: 'Invalid action' });
|
||||||
|
}
|
||||||
|
res.json({ message: `Container ${action} successful`, data });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error performing ${action} on container ${id}:`, error);
|
||||||
|
res.status(500).json({ error: `Failed to ${action} container: ${error.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save Alert Thresholds
|
||||||
|
app.post('/api/settings/thresholds', (req, res) => {
|
||||||
|
const { thresholds, containerAlertEnabled, alertCooldownSeconds } = req.body;
|
||||||
|
if (thresholds) {
|
||||||
|
dockerConfig.alertThresholds = thresholds;
|
||||||
|
if (containerAlertEnabled !== undefined) {
|
||||||
|
dockerConfig.containerAlertEnabled = containerAlertEnabled;
|
||||||
|
}
|
||||||
|
if (alertCooldownSeconds !== undefined) {
|
||||||
|
dockerConfig.alertCooldownSeconds = Math.max(10, Math.min(3600, alertCooldownSeconds));
|
||||||
|
}
|
||||||
|
saveDockerConfig();
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: 'Invalid thresholds' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Container Logs API
|
||||||
|
app.get('/api/docker/containers/:id/logs', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const container = docker.getContainer(id);
|
||||||
|
const logs = await container.logs({ stdout: true, stderr: true, tail: 100, timestamps: true });
|
||||||
|
res.send(logs.toString('utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching logs:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch logs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const monitorDockerEvents = () => {
|
||||||
|
docker.getEvents((err, stream) => {
|
||||||
|
if (err) return console.error('Error getting docker events:', err);
|
||||||
|
|
||||||
|
stream.on('data', chunk => {
|
||||||
|
if (!dockerConfig.enabled) return; // Ignore if disabled
|
||||||
|
if (!dockerConfig.containerAlertEnabled) return; // Ignore if container alerts disabled
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(chunk.toString());
|
||||||
|
// Filter for container stop/die
|
||||||
|
if (event.Type === 'container' && (event.Action === 'die' || event.Action === 'stop')) {
|
||||||
|
// Check if it's "unexpected"?
|
||||||
|
// Usually we alert on any stop if the monitoring is strictly keeping uptime.
|
||||||
|
// But if user stops it manually via UI, we might get an event too.
|
||||||
|
// However, UI stop calls API -> API stops container.
|
||||||
|
// It is hard to distinguish "crash" from "manual stop" without state tracking.
|
||||||
|
// For now, user asked "컨테이너가 까지게 되면", effectively "stops".
|
||||||
|
// We will alert.
|
||||||
|
const containerName = event.Actor?.Attributes?.name || event.id.substring(0, 12);
|
||||||
|
sendNotification({
|
||||||
|
title: 'snStatus 알림',
|
||||||
|
body: `컨테이너 종료: ${containerName}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing docker event:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
initPushNotifications();
|
||||||
|
if (dockerConfig.enabled) monitorDockerEvents(); // Start if enabled, or always start?
|
||||||
|
// Better always start listening, but filter inside.
|
||||||
|
// Actually monitorDockerEvents is a stream. If I call it multiple times it duplicates.
|
||||||
|
// Just call it once.
|
||||||
|
monitorDockerEvents();
|
||||||
|
|
||||||
|
|
||||||
|
// --- WebSocket for Terminal & Logs ---
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
let stream = null;
|
||||||
|
socket.on('attach-terminal', async (containerId) => {
|
||||||
|
try {
|
||||||
|
const container = docker.getContainer(containerId);
|
||||||
|
const exec = await container.exec({
|
||||||
|
AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true, Cmd: ['/bin/sh']
|
||||||
|
});
|
||||||
|
stream = await exec.start({ hijack: true, stdin: true });
|
||||||
|
stream.on('data', (chunk) => socket.emit('terminal-output', chunk.toString('utf8')));
|
||||||
|
socket.on('terminal-input', (data) => { if (stream) stream.write(data); });
|
||||||
|
socket.on('resize-terminal', ({ cols, rows }) => { if (exec) exec.resize({ w: cols, h: rows }).catch(() => { }); });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error attaching to container:', err);
|
||||||
|
socket.emit('terminal-output', '\r\nError: Failed to attach to container. ' + err.message + '\r\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => { if (stream) stream.end(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
httpServer.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
2
build.sh
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
docker build --no-cache -t sa8001/snstatus-frontend:$1 ./frontend
|
||||||
|
docker build --no-cache -t sa8001/snstatus-backend:$1 ./backend
|
||||||
25
docker-compose-dev.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: snstatus-backend
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
privileged: true
|
||||||
|
volumes:
|
||||||
|
- /:/host:ro
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./backend/data:/app/data
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
container_name: snstatus-frontend
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3005:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
25
docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: sa8001/snstatus-backend:v0.1.5
|
||||||
|
container_name: snstatus-backend
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
privileged: true
|
||||||
|
volumes:
|
||||||
|
- /:/host:ro
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./data:/app/data
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: sa8001/snstatus-frontend:v0.1.5
|
||||||
|
container_name: snstatus-frontend
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3005:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
15
frontend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Build Stage
|
||||||
|
FROM node:18-alpine as build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production Stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
# Copy custom nginx config if needed (optional but good for SPA)
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
12
frontend/backend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8001
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
30
frontend/backend/convert_icons.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const sharp = require('sharp');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// /app/data 디렉토리 사용
|
||||||
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
|
const svgPath = path.join(DATA_DIR, 'icon.svg');
|
||||||
|
|
||||||
|
// icon.svg가 존재하는지 확인
|
||||||
|
if (!fs.existsSync(svgPath)) {
|
||||||
|
console.error(`File not found: ${svgPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgBuffer = fs.readFileSync(svgPath);
|
||||||
|
|
||||||
|
async function convert() {
|
||||||
|
try {
|
||||||
|
console.log('Converting icons from:', svgPath);
|
||||||
|
await sharp(svgBuffer).resize(64, 64).png().toFile(path.join(DATA_DIR, 'favicon.png'));
|
||||||
|
await sharp(svgBuffer).resize(192, 192).png().toFile(path.join(DATA_DIR, 'pwa-192x192.png'));
|
||||||
|
await sharp(svgBuffer).resize(512, 512).png().toFile(path.join(DATA_DIR, 'pwa-512x512.png'));
|
||||||
|
await sharp(svgBuffer).resize(180, 180).png().toFile(path.join(DATA_DIR, 'apple-touch-icon.png'));
|
||||||
|
console.log('Conversion complete. Files saved to:', DATA_DIR);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error converting icons:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
convert();
|
||||||
BIN
frontend/backend/data/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
13
frontend/backend/data/config.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"passwordHash": "d7fe5080df575511eb7b3c5e50a99c0402952461f5e202e8c90461c1e52d208a9c14329317d5b39ec85de62caca369114295d559466e044d9f8b3a5e94b5ebd3",
|
||||||
|
"salt": "ced784250b4ed141397cc2115e864dac",
|
||||||
|
"retentionHours": 24,
|
||||||
|
"alertThresholds": {
|
||||||
|
"cpu": 80,
|
||||||
|
"memory": 80,
|
||||||
|
"disk": 90
|
||||||
|
},
|
||||||
|
"containerAlertEnabled": false,
|
||||||
|
"alertCooldownSeconds": 300
|
||||||
|
}
|
||||||
30
frontend/backend/data/convert_icons_exec.cjs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const sharp = require('sharp');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// 이미 /app/data 내에서 실행되므로 현재 디렉토리가 data 폴더임
|
||||||
|
const DATA_DIR = __dirname;
|
||||||
|
const svgPath = path.join(DATA_DIR, 'icon.svg');
|
||||||
|
|
||||||
|
// icon.svg가 존재하는지 확인
|
||||||
|
if (!fs.existsSync(svgPath)) {
|
||||||
|
console.error(`File not found: ${svgPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgBuffer = fs.readFileSync(svgPath);
|
||||||
|
|
||||||
|
async function convert() {
|
||||||
|
try {
|
||||||
|
console.log('Converting icons from:', svgPath);
|
||||||
|
await sharp(svgBuffer).resize(64, 64).png().toFile(path.join(DATA_DIR, 'favicon.png'));
|
||||||
|
await sharp(svgBuffer).resize(192, 192).png().toFile(path.join(DATA_DIR, 'pwa-192x192.png'));
|
||||||
|
await sharp(svgBuffer).resize(512, 512).png().toFile(path.join(DATA_DIR, 'pwa-512x512.png'));
|
||||||
|
await sharp(svgBuffer).resize(180, 180).png().toFile(path.join(DATA_DIR, 'apple-touch-icon.png'));
|
||||||
|
console.log('Conversion complete. Files saved to:', DATA_DIR);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error converting icons:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
convert();
|
||||||
BIN
frontend/backend/data/favicon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
1
frontend/backend/data/history.json
Normal file
49
frontend/backend/data/icon.svg
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#2563eb"/>
|
||||||
|
<stop offset="1" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="dropshadow" height="130%">
|
||||||
|
<feGaussianBlur in="SourceAlpha" stdDeviation="15"/>
|
||||||
|
<feOffset dx="0" dy="10" result="offsetblur"/>
|
||||||
|
<feComponentTransfer>
|
||||||
|
<feFuncA type="linear" slope="0.3"/>
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background: Royal Blue (Adjusted from Deep Navy) -->
|
||||||
|
<rect width="512" height="512" fill="#1e3a8a"/>
|
||||||
|
|
||||||
|
<!-- Main Hexagon Shape (Scaled Down to 85% and Centered using Matrix) -->
|
||||||
|
<!-- Matrix calc: Scale 0.85. Offset = (512 - 512*0.85)/2 = 38.4 -->
|
||||||
|
<g filter="url(#dropshadow)" transform="matrix(0.85, 0, 0, 0.85, 38.4, 38.4)">
|
||||||
|
<path d="M256 50
|
||||||
|
L433 152
|
||||||
|
L433 357
|
||||||
|
L256 460
|
||||||
|
L79 357
|
||||||
|
L79 152
|
||||||
|
Z"
|
||||||
|
fill="url(#paint0_linear)"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="20"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- Internal Cube Lines (Y shape) -->
|
||||||
|
<path d="M256 460 L256 255" stroke="white" stroke-width="20" stroke-linecap="round"/>
|
||||||
|
<path d="M256 255 L433 152" stroke="white" stroke-width="20" stroke-linecap="round"/>
|
||||||
|
<path d="M79 152 L256 255" stroke="white" stroke-width="20" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Tech Accent Dots -->
|
||||||
|
<circle cx="256" cy="255" r="30" fill="white"/>
|
||||||
|
<circle cx="256" cy="110" r="15" fill="white" fill-opacity="0.6"/>
|
||||||
|
<circle cx="380" cy="320" r="15" fill="white" fill-opacity="0.6"/>
|
||||||
|
<circle cx="132" cy="320" r="15" fill="white" fill-opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/backend/data/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
frontend/backend/data/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
1
frontend/backend/data/subscriptions.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
1
frontend/backend/data/vapid.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"publicKey":"BFOD5bzNh9Dch_Rjt8cpWc2IfSktAoEUx0virpARRvPfpmsuvQka3ppCa0WajNFaIJS7Z62VNUAeo_6yMBvLVZA","privateKey":"ymBn65iLw_M19FJPdzJ7FqA7jgHVBCfSV6vSdM_8GIw"}
|
||||||
21
frontend/backend/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "snstatus-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "System monitoring backend for Synology NAS",
|
||||||
|
"main": "server.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dockerode": "^4.0.0",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"socket.io": "^4.7.2",
|
||||||
|
"systeminformation": "^5.21.22",
|
||||||
|
"web-push": "^3.6.0"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC"
|
||||||
|
}
|
||||||
785
frontend/backend/server.js
Normal file
@@ -0,0 +1,785 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import si from 'systeminformation';
|
||||||
|
import Docker from 'dockerode';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { Server } from 'socket.io';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import webpush from 'web-push';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const httpServer = createServer(app);
|
||||||
|
const io = new Server(httpServer, {
|
||||||
|
cors: {
|
||||||
|
origin: "*",
|
||||||
|
methods: ["GET", "POST"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const PORT = 8001;
|
||||||
|
|
||||||
|
// Docker Connection
|
||||||
|
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Helper: Read Synology Info from Host
|
||||||
|
const getSynologyInfo = () => {
|
||||||
|
try {
|
||||||
|
const versionPath = '/host/etc/VERSION';
|
||||||
|
const synoInfoPath = '/host/etc/synoinfo.conf';
|
||||||
|
|
||||||
|
let dsmVersion = 'Unknown';
|
||||||
|
let model = 'Synology NAS';
|
||||||
|
|
||||||
|
if (fs.existsSync(versionPath)) {
|
||||||
|
const versionContent = fs.readFileSync(versionPath, 'utf8');
|
||||||
|
const productVersion = versionContent.match(/productversion="([^"]+)"/);
|
||||||
|
const buildNumber = versionContent.match(/buildnumber="([^"]+)"/);
|
||||||
|
if (productVersion && buildNumber) {
|
||||||
|
dsmVersion = `DSM ${productVersion[1]}-${buildNumber[1]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(synoInfoPath)) {
|
||||||
|
// Try to read model from synoinfo (unique="synology_geminilake_920+") or similar
|
||||||
|
// Or usually /proc/sys/kernel/syno_hw_version if available
|
||||||
|
// Simpler fallback: check hostname or use generic if deep parsing fails.
|
||||||
|
// Actually synoinfo.conf has 'upnpmodelname="DS920+"' usually.
|
||||||
|
const infoContent = fs.readFileSync(synoInfoPath, 'utf8');
|
||||||
|
const modelMatch = infoContent.match(/upnpmodelname="([^"]+)"/);
|
||||||
|
if (modelMatch) {
|
||||||
|
model = modelMatch[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { model, dsmVersion };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to read host files:', e);
|
||||||
|
return { model: 'Docker Container', dsmVersion: 'Unknown' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Docker Config & Security ---
|
||||||
|
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||||
|
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
if (!fs.existsSync(DATA_DIR)) {
|
||||||
|
try { fs.mkdirSync(DATA_DIR, { recursive: true }); }
|
||||||
|
catch (e) { console.error('Failed to create data directory:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
let dockerConfig = {
|
||||||
|
enabled: false,
|
||||||
|
passwordHash: null,
|
||||||
|
salt: null,
|
||||||
|
retentionHours: 24,
|
||||||
|
alertThresholds: { cpu: 80, memory: 80, disk: 90 },
|
||||||
|
containerAlertEnabled: false,
|
||||||
|
alertCooldownSeconds: 300 // 5 minutes default
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveDockerConfig = () => {
|
||||||
|
try { fs.writeFileSync(CONFIG_FILE, JSON.stringify(dockerConfig, null, 2)); }
|
||||||
|
catch (e) { console.error('Failed to save docker config:', e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDockerConfig = () => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CONFIG_FILE)) {
|
||||||
|
const data = fs.readFileSync(CONFIG_FILE, 'utf8');
|
||||||
|
dockerConfig = JSON.parse(data);
|
||||||
|
// Merge defaults
|
||||||
|
if (!dockerConfig.alertThresholds) dockerConfig.alertThresholds = { cpu: 90, mem: 90, disk: 90 };
|
||||||
|
// Restore history limit from saved retention hours
|
||||||
|
// Note: historyRetentionHours and HISTORY_LIMIT are defined later in the file
|
||||||
|
// This function is called AFTER those variables are initialized (line 438)
|
||||||
|
if (dockerConfig.retentionHours) {
|
||||||
|
historyRetentionHours = dockerConfig.retentionHours;
|
||||||
|
HISTORY_LIMIT = historyRetentionHours * 60;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
saveDockerConfig();
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('Failed to load docker config:', e); }
|
||||||
|
};
|
||||||
|
// I will Stub loadDockerConfig here and call the real logic later?
|
||||||
|
// No, I should move History Vars up too.
|
||||||
|
|
||||||
|
// --- Helper: Secure Hash ---
|
||||||
|
const hashPassword = (password, salt = null) => {
|
||||||
|
if (!password) return null;
|
||||||
|
try {
|
||||||
|
if (!salt) salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
|
||||||
|
return { hash, salt };
|
||||||
|
} catch (e) { console.error("Hashing error:", e); return null; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Push Notification System Variables ---
|
||||||
|
const SUBSCRIPTIONS_FILE = path.join(process.cwd(), 'data', 'subscriptions.json');
|
||||||
|
let subscriptions = [];
|
||||||
|
let vapidKeys = { publicKey: '', privateKey: '' };
|
||||||
|
let alertState = { cpu: 0, memory: 0, disk: 0 };
|
||||||
|
|
||||||
|
const initPushNotifications = () => {
|
||||||
|
// Ensure data directory exists
|
||||||
|
if (!fs.existsSync(process.cwd() + '/data')) {
|
||||||
|
fs.mkdirSync(process.cwd() + '/data', { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize VAPID keys
|
||||||
|
if (!fs.existsSync(process.cwd() + '/data/vapid.json')) {
|
||||||
|
vapidKeys = webpush.generateVAPIDKeys();
|
||||||
|
fs.writeFileSync(process.cwd() + '/data/vapid.json', JSON.stringify(vapidKeys));
|
||||||
|
} else {
|
||||||
|
vapidKeys = JSON.parse(fs.readFileSync(process.cwd() + '/data/vapid.json', 'utf8'));
|
||||||
|
}
|
||||||
|
webpush.setVapidDetails('mailto:admin@example.com', vapidKeys.publicKey, vapidKeys.privateKey);
|
||||||
|
|
||||||
|
// Initialize subscriptions - CREATE FILE IF MISSING
|
||||||
|
if (fs.existsSync(SUBSCRIPTIONS_FILE)) {
|
||||||
|
try {
|
||||||
|
subscriptions = JSON.parse(fs.readFileSync(SUBSCRIPTIONS_FILE, 'utf8'));
|
||||||
|
console.log('[PUSH] Loaded', subscriptions.length, 'existing subscriptions');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PUSH] Error loading subscriptions:', e);
|
||||||
|
subscriptions = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[PUSH] Creating new subscriptions file');
|
||||||
|
subscriptions = [];
|
||||||
|
fs.writeFileSync(SUBSCRIPTIONS_FILE, JSON.stringify(subscriptions));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSubscriptions = () => {
|
||||||
|
try { fs.writeFileSync(SUBSCRIPTIONS_FILE, JSON.stringify(subscriptions)); }
|
||||||
|
catch (e) { console.error('Failed to save subscriptions:', e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendNotification = (payload) => {
|
||||||
|
const notificationPayload = JSON.stringify(payload);
|
||||||
|
console.log('[PUSH] Sending notification to', subscriptions.length, 'subscribers');
|
||||||
|
console.log('[PUSH] Payload:', payload);
|
||||||
|
|
||||||
|
if (subscriptions.length === 0) {
|
||||||
|
console.warn('[PUSH] No subscriptions available!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptions.forEach((subscription, index) => {
|
||||||
|
console.log(`[PUSH] Sending to subscription ${index + 1}/${subscriptions.length}`);
|
||||||
|
webpush.sendNotification(subscription, notificationPayload)
|
||||||
|
.then(() => {
|
||||||
|
console.log(`[PUSH] Successfully sent to subscription ${index + 1}`);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(`[PUSH] Error sending to subscription ${index + 1}:`, error);
|
||||||
|
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||||
|
console.log('[PUSH] Removing expired subscription');
|
||||||
|
subscriptions.splice(index, 1);
|
||||||
|
saveSubscriptions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkAlerts = (stats) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const thresholds = dockerConfig.alertThresholds || { cpu: 90, memory: 90, disk: 90 };
|
||||||
|
const COOLDOWN = (dockerConfig.alertCooldownSeconds || 300) * 1000; // Convert seconds to ms
|
||||||
|
|
||||||
|
if (stats.cpu > thresholds.cpu) {
|
||||||
|
if (now - alertState.cpu > COOLDOWN) {
|
||||||
|
console.log('[ALERT] CPU alert sent:', stats.cpu + '%');
|
||||||
|
sendNotification({ title: 'snStatus 알림', body: `CPU 사용률 높음: ${stats.cpu}%` });
|
||||||
|
alertState.cpu = now;
|
||||||
|
}
|
||||||
|
} else { alertState.cpu = 0; }
|
||||||
|
|
||||||
|
if (stats.memory > thresholds.memory) {
|
||||||
|
if (now - alertState.memory > COOLDOWN) {
|
||||||
|
console.log('[ALERT] Memory alert sent:', stats.memory + '%');
|
||||||
|
sendNotification({ title: 'snStatus 알림', body: `메모리 사용률 높음: ${stats.memory}%` });
|
||||||
|
alertState.memory = now;
|
||||||
|
}
|
||||||
|
} else { alertState.memory = 0; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// VAPID Public Key Endpoint
|
||||||
|
app.get('/api/notifications/vapidPublicKey', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!vapidKeys || !vapidKeys.publicKey) {
|
||||||
|
return res.status(500).json({ error: 'VAPID keys not initialized' });
|
||||||
|
}
|
||||||
|
res.json({ publicKey: vapidKeys.publicKey });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting VAPID key:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get VAPID key' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to push notifications
|
||||||
|
app.post('/api/notifications/subscribe', async (req, res) => {
|
||||||
|
const subscription = req.body;
|
||||||
|
if (!subscription || !subscription.endpoint) {
|
||||||
|
return res.status(400).json({ error: 'Invalid subscription' });
|
||||||
|
}
|
||||||
|
// Remove existing subscription with same endpoint
|
||||||
|
subscriptions = subscriptions.filter(sub => sub.endpoint !== subscription.endpoint);
|
||||||
|
subscriptions.push(subscription);
|
||||||
|
saveSubscriptions();
|
||||||
|
console.log('Push subscription added:', subscription.endpoint);
|
||||||
|
res.status(201).json({ message: 'Subscription added' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unsubscribe from push notifications
|
||||||
|
app.post('/api/notifications/unsubscribe', async (req, res) => {
|
||||||
|
const { endpoint } = req.body;
|
||||||
|
subscriptions = subscriptions.filter(sub => sub.endpoint !== endpoint);
|
||||||
|
saveSubscriptions();
|
||||||
|
console.log('Push subscription removed:', endpoint);
|
||||||
|
res.json({ message: 'Unsubscribed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- System Info APIs ---
|
||||||
|
// Global state for network speed calculation
|
||||||
|
let previousNetStats = {};
|
||||||
|
let previousTime = Date.now();
|
||||||
|
|
||||||
|
// Function to read and parse /host/proc/net/dev
|
||||||
|
const getHostNetworkStats = () => {
|
||||||
|
try {
|
||||||
|
// Use /sys/class/net mounted from host to bypass container network namespace isolation.
|
||||||
|
// This is the correct way to see host interfaces (eth0, eth1, etc.) from a bridge container.
|
||||||
|
const netDir = '/host/sys/class/net';
|
||||||
|
|
||||||
|
let interfaces = [];
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(netDir)) {
|
||||||
|
interfaces = fs.readdirSync(netDir);
|
||||||
|
} else {
|
||||||
|
console.warn("Host sysfs not found at /host/sys/class/net");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} catch (e) { return []; }
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const currentStats = {};
|
||||||
|
const timeDiff = (timestamp - previousTime) / 1000;
|
||||||
|
let result = [];
|
||||||
|
|
||||||
|
for (const iface of interfaces) {
|
||||||
|
// Filter unwanted interfaces
|
||||||
|
if (iface === 'lo' ||
|
||||||
|
iface.startsWith('veth') ||
|
||||||
|
iface.startsWith('docker') ||
|
||||||
|
iface.startsWith('br-') ||
|
||||||
|
iface.startsWith('cali') ||
|
||||||
|
iface.startsWith('flannel') ||
|
||||||
|
iface.startsWith('cni') ||
|
||||||
|
iface.startsWith('sit') ||
|
||||||
|
iface.startsWith('tun') ||
|
||||||
|
iface.startsWith('tap')) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rxPath = path.join(netDir, iface, 'statistics', 'rx_bytes');
|
||||||
|
const txPath = path.join(netDir, iface, 'statistics', 'tx_bytes');
|
||||||
|
|
||||||
|
if (!fs.existsSync(rxPath) || !fs.existsSync(txPath)) continue;
|
||||||
|
|
||||||
|
const rxBytes = parseInt(fs.readFileSync(rxPath, 'utf8').trim());
|
||||||
|
const txBytes = parseInt(fs.readFileSync(txPath, 'utf8').trim());
|
||||||
|
|
||||||
|
currentStats[iface] = { rx: rxBytes, tx: txBytes };
|
||||||
|
|
||||||
|
let rx_sec = 0;
|
||||||
|
let tx_sec = 0;
|
||||||
|
|
||||||
|
if (previousNetStats[iface] && timeDiff > 0) {
|
||||||
|
const prevRx = previousNetStats[iface].rx;
|
||||||
|
const prevTx = previousNetStats[iface].tx;
|
||||||
|
|
||||||
|
// Simple difference (handle counter wrap if needed, but unlikely for 64bit counters in JS for short periods)
|
||||||
|
if (rxBytes >= prevRx) rx_sec = (rxBytes - prevRx) / timeDiff;
|
||||||
|
if (txBytes >= prevTx) tx_sec = (txBytes - prevTx) / timeDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
iface: iface,
|
||||||
|
rx_sec: rx_sec,
|
||||||
|
tx_sec: tx_sec
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousNetStats = currentStats;
|
||||||
|
previousTime = timestamp;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read host network stats from /sys:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get('/api/stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [cpu, mem, fsData, osInfo, system] = await Promise.all([
|
||||||
|
si.currentLoad(),
|
||||||
|
si.mem(),
|
||||||
|
si.fsSize(),
|
||||||
|
si.osInfo(),
|
||||||
|
si.system()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get Host Network Stats
|
||||||
|
const network = getHostNetworkStats();
|
||||||
|
|
||||||
|
// Get Host Info
|
||||||
|
const hostInfo = getSynologyInfo();
|
||||||
|
|
||||||
|
// Disk Filtering Logic
|
||||||
|
const filteredStorage = fsData.filter(disk => {
|
||||||
|
let mountPoint = disk.mount;
|
||||||
|
if (mountPoint.startsWith('/host')) mountPoint = mountPoint.substring(5);
|
||||||
|
if (mountPoint.includes('@') || mountPoint.includes('docker') || mountPoint.includes('container') ||
|
||||||
|
mountPoint.includes('appdata') || mountPoint.includes('tmp') ||
|
||||||
|
disk.fs.startsWith('overlay') || disk.fs.startsWith('tmpfs') || disk.fs.startsWith('shm')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /^\/volume\d+$/.test(mountPoint);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
cpu: {
|
||||||
|
load: Math.round(cpu.currentLoad),
|
||||||
|
cores: cpu.cpus.map(c => Math.round(c.load)),
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
total: mem.total,
|
||||||
|
used: mem.used,
|
||||||
|
percentage: Math.round((mem.active / mem.total) * 100),
|
||||||
|
},
|
||||||
|
storage: filteredStorage.map(disk => ({
|
||||||
|
fs: disk.fs,
|
||||||
|
type: disk.type,
|
||||||
|
size: disk.size,
|
||||||
|
used: disk.used,
|
||||||
|
use: Math.round(disk.use),
|
||||||
|
mount: disk.mount,
|
||||||
|
})),
|
||||||
|
network: network, // Use custom stats
|
||||||
|
system: {
|
||||||
|
model: hostInfo.model !== 'Docker Container' ? hostInfo.model : system.model, // Prefer host info
|
||||||
|
manufacturer: system.manufacturer,
|
||||||
|
os: 'DSM', // Hardcode or derive
|
||||||
|
release: hostInfo.dsmVersion,
|
||||||
|
kernel: osInfo.kernel,
|
||||||
|
hostname: osInfo.hostname,
|
||||||
|
uptime: osInfo.uptime
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(stats);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching system stats:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to retrieve system statistics' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- History Data ---
|
||||||
|
const HISTORY_FILE = path.join(process.cwd(), 'data', 'history.json');
|
||||||
|
let historyRetentionHours = 24; // Default 24 hours
|
||||||
|
let HISTORY_LIMIT = historyRetentionHours * 60; // points (1 min interval)
|
||||||
|
let historyData = [];
|
||||||
|
|
||||||
|
const saveHistory = () => {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(HISTORY_FILE, JSON.stringify(historyData));
|
||||||
|
} catch (e) { console.error('Failed to save history:', e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadHistory = () => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(HISTORY_FILE)) {
|
||||||
|
const data = fs.readFileSync(HISTORY_FILE, 'utf8');
|
||||||
|
historyData = JSON.parse(data);
|
||||||
|
// Ensure data respects current limit (if limit changed while offline? Unlikely but good practice)
|
||||||
|
if (historyData.length > HISTORY_LIMIT) {
|
||||||
|
historyData = historyData.slice(historyData.length - HISTORY_LIMIT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('Failed to load history:', e); }
|
||||||
|
};
|
||||||
|
loadHistory();
|
||||||
|
|
||||||
|
// Retention Settings API
|
||||||
|
app.get('/api/settings/retention', (req, res) => {
|
||||||
|
res.json({ hours: historyRetentionHours });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/settings/retention', (req, res) => {
|
||||||
|
const { hours } = req.body;
|
||||||
|
if (!hours || hours < 1) {
|
||||||
|
return res.status(400).json({ error: 'Invalid retention period' });
|
||||||
|
}
|
||||||
|
historyRetentionHours = parseInt(hours);
|
||||||
|
HISTORY_LIMIT = historyRetentionHours * 60;
|
||||||
|
|
||||||
|
// Trim existing data if needed
|
||||||
|
if (historyData.length > HISTORY_LIMIT) {
|
||||||
|
historyData = historyData.slice(historyData.length - HISTORY_LIMIT);
|
||||||
|
}
|
||||||
|
saveHistory(); // Save truncated data
|
||||||
|
|
||||||
|
// Save preference to config
|
||||||
|
dockerConfig.retentionHours = historyRetentionHours;
|
||||||
|
saveDockerConfig();
|
||||||
|
|
||||||
|
console.log(`History retention updated to ${historyRetentionHours} hours (${HISTORY_LIMIT} points)`);
|
||||||
|
res.json({ message: 'Retention updated', hours: historyRetentionHours });
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateHistory = async () => {
|
||||||
|
try {
|
||||||
|
const [cpu, mem] = await Promise.all([
|
||||||
|
si.currentLoad(),
|
||||||
|
si.mem()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Capture Network Stats for History
|
||||||
|
// Note: This relies on the shared getHostNetworkStats state.
|
||||||
|
// It captures the "current rate" since the last poll (whether by UI or this loop).
|
||||||
|
const netStats = getHostNetworkStats();
|
||||||
|
let totalRx = 0;
|
||||||
|
let totalTx = 0;
|
||||||
|
netStats.forEach(net => {
|
||||||
|
// Include all interfaces or filter? Let's include all physical-ish ones found by our filter
|
||||||
|
totalRx += net.rx_sec;
|
||||||
|
totalTx += net.tx_sec;
|
||||||
|
});
|
||||||
|
|
||||||
|
const point = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
cpu: Math.round(cpu.currentLoad),
|
||||||
|
memory: Math.round((mem.active / mem.total) * 100),
|
||||||
|
network: {
|
||||||
|
rx: totalRx,
|
||||||
|
tx: totalTx
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
historyData.push(point);
|
||||||
|
if (historyData.length > HISTORY_LIMIT) {
|
||||||
|
historyData.shift(); // Remove oldest
|
||||||
|
}
|
||||||
|
|
||||||
|
saveHistory();
|
||||||
|
checkAlerts(point);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to update history:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update history every 1 minute
|
||||||
|
setInterval(updateHistory, 60 * 1000);
|
||||||
|
// Initial run
|
||||||
|
updateHistory();
|
||||||
|
|
||||||
|
app.get('/api/history', (req, res) => {
|
||||||
|
res.json(historyData);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Config saved via helper
|
||||||
|
// NOTE: loadDockerConfig normally called here.
|
||||||
|
// But check scope: HISTORY_LIMIT is defined above at 220.
|
||||||
|
// Our new loadDockerConfig at top CANNOT see HISTORY_LIMIT.
|
||||||
|
// So we must fix loadDockerConfig logic.
|
||||||
|
// I'll handle that separately. For now, deleting this block.
|
||||||
|
|
||||||
|
// Helper: Secure Hash (Scrypt + Salt)
|
||||||
|
|
||||||
|
|
||||||
|
loadDockerConfig(); // Initial Load
|
||||||
|
|
||||||
|
// Docker Config API
|
||||||
|
app.get('/api/docker/config', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
enabled: dockerConfig.enabled,
|
||||||
|
alertThresholds: dockerConfig.alertThresholds || { cpu: 90, memory: 90, disk: 90 },
|
||||||
|
containerAlertEnabled: dockerConfig.containerAlertEnabled !== undefined ? dockerConfig.containerAlertEnabled : true,
|
||||||
|
alertCooldownSeconds: dockerConfig.alertCooldownSeconds || 300
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/docker/config', (req, res) => {
|
||||||
|
const { enabled, password } = req.body;
|
||||||
|
const isEnabling = !!enabled;
|
||||||
|
|
||||||
|
// Case 1: Disabling (Require Verification)
|
||||||
|
if (!isEnabling && dockerConfig.enabled) {
|
||||||
|
if (!password) return res.status(400).json({ error: 'Password is required to disable Docker features.' });
|
||||||
|
|
||||||
|
if (dockerConfig.passwordHash) {
|
||||||
|
let match = false;
|
||||||
|
|
||||||
|
if (!dockerConfig.salt) {
|
||||||
|
// Legacy SHA256 Check
|
||||||
|
const legacyHash = crypto.createHash('sha256').update(password).digest('hex');
|
||||||
|
match = (legacyHash === dockerConfig.passwordHash);
|
||||||
|
} else {
|
||||||
|
// Modern Scrypt Check
|
||||||
|
try {
|
||||||
|
const { hash } = hashPassword(password, dockerConfig.salt);
|
||||||
|
match = crypto.timingSafeEqual(
|
||||||
|
Buffer.from(hash, 'hex'),
|
||||||
|
Buffer.from(dockerConfig.passwordHash, 'hex')
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ error: 'Verification error.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match) return res.status(401).json({ error: '패스워드가 올바르지 않습니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: Enabling (Require Password if no existing hash)
|
||||||
|
if (isEnabling && !password && !dockerConfig.passwordHash) {
|
||||||
|
return res.status(400).json({ error: 'Password is required to enable Docker features.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
dockerConfig.enabled = isEnabling;
|
||||||
|
|
||||||
|
// If Enabling and password provided, update/set password
|
||||||
|
if (isEnabling && password) {
|
||||||
|
const result = hashPassword(password);
|
||||||
|
if (result) {
|
||||||
|
dockerConfig.passwordHash = result.hash;
|
||||||
|
dockerConfig.salt = result.salt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveDockerConfig();
|
||||||
|
console.log(`Docker feature ${dockerConfig.enabled ? 'Enabled' : 'Disabled'}`);
|
||||||
|
res.json({ message: 'Configuration saved', enabled: dockerConfig.enabled });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify Password API
|
||||||
|
app.post('/api/docker/verify', (req, res) => {
|
||||||
|
const { password } = req.body;
|
||||||
|
if (!password) return res.status(400).json({ success: false });
|
||||||
|
|
||||||
|
// Validate if config exists
|
||||||
|
if (!dockerConfig.passwordHash || !dockerConfig.salt) {
|
||||||
|
return res.status(500).json({ success: false, error: 'Security configuration not found. Please reset in Settings.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hash } = hashPassword(password, dockerConfig.salt);
|
||||||
|
|
||||||
|
// Constant-time comparison to prevent timing attacks
|
||||||
|
try {
|
||||||
|
const match = crypto.timingSafeEqual(
|
||||||
|
Buffer.from(hash, 'hex'),
|
||||||
|
Buffer.from(dockerConfig.passwordHash, 'hex')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ success: false, error: '패스워드가 올바르지 않습니다.' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
res.status(401).json({ success: false, error: 'Verification failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Docker APIs ---
|
||||||
|
// Helper: Calculate CPU Percent from Docker Stats
|
||||||
|
const calculateCPUPercent = (stats) => {
|
||||||
|
if (!stats || !stats.cpu_stats || !stats.precpu_stats) return 0;
|
||||||
|
|
||||||
|
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||||
|
const systemCpuDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||||
|
const numberCpus = stats.cpu_stats.online_cpus || (stats.cpu_stats.cpu_usage.percpu_usage ? stats.cpu_stats.cpu_usage.percpu_usage.length : 1);
|
||||||
|
|
||||||
|
if (systemCpuDelta > 0 && cpuDelta > 0) {
|
||||||
|
return (cpuDelta / systemCpuDelta) * numberCpus * 100.0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Docker APIs ---
|
||||||
|
app.get('/api/docker/containers', async (req, res) => {
|
||||||
|
// Check if enabled
|
||||||
|
if (!dockerConfig.enabled) {
|
||||||
|
return res.status(403).json({ error: 'Docker features are disabled.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const containers = await docker.listContainers({ all: true });
|
||||||
|
|
||||||
|
// Fetch stats for running containers
|
||||||
|
const containersWithStats = await Promise.all(containers.map(async (containerInfo) => {
|
||||||
|
if (containerInfo.State !== 'running') {
|
||||||
|
return { ...containerInfo, stats: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const container = docker.getContainer(containerInfo.Id);
|
||||||
|
const stats = await container.stats({ stream: false });
|
||||||
|
|
||||||
|
const cpuPercent = calculateCPUPercent(stats);
|
||||||
|
const memoryUsage = stats.memory_stats.usage || 0;
|
||||||
|
const memoryLimit = stats.memory_stats.limit || 0;
|
||||||
|
const memoryPercent = memoryLimit > 0 ? (memoryUsage / memoryLimit) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...containerInfo,
|
||||||
|
stats: {
|
||||||
|
cpu: cpuPercent,
|
||||||
|
memory: memoryUsage,
|
||||||
|
memoryLimit: memoryLimit,
|
||||||
|
memoryPercent: memoryPercent
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to fetch stats for ${containerInfo.Names[0]}:`, e.message);
|
||||||
|
return { ...containerInfo, stats: null };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(containersWithStats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching containers:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch containers. Is Docker Sock mounted?' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Container Control APIs
|
||||||
|
app.post('/api/docker/containers/:id/:action', async (req, res) => {
|
||||||
|
const { id, action } = req.params;
|
||||||
|
try {
|
||||||
|
const container = docker.getContainer(id);
|
||||||
|
let data;
|
||||||
|
switch (action) {
|
||||||
|
case 'start': data = await container.start(); break;
|
||||||
|
case 'stop': data = await container.stop(); break;
|
||||||
|
case 'restart': data = await container.restart(); break;
|
||||||
|
default: return res.status(400).json({ error: 'Invalid action' });
|
||||||
|
}
|
||||||
|
res.json({ message: `Container ${action} successful`, data });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error performing ${action} on container ${id}:`, error);
|
||||||
|
res.status(500).json({ error: `Failed to ${action} container: ${error.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save Alert Thresholds
|
||||||
|
app.post('/api/settings/thresholds', (req, res) => {
|
||||||
|
const { thresholds, containerAlertEnabled, alertCooldownSeconds } = req.body;
|
||||||
|
if (thresholds) {
|
||||||
|
dockerConfig.alertThresholds = thresholds;
|
||||||
|
if (containerAlertEnabled !== undefined) {
|
||||||
|
dockerConfig.containerAlertEnabled = containerAlertEnabled;
|
||||||
|
}
|
||||||
|
if (alertCooldownSeconds !== undefined) {
|
||||||
|
dockerConfig.alertCooldownSeconds = Math.max(10, Math.min(3600, alertCooldownSeconds));
|
||||||
|
}
|
||||||
|
saveDockerConfig();
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: 'Invalid thresholds' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Container Logs API
|
||||||
|
app.get('/api/docker/containers/:id/logs', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const container = docker.getContainer(id);
|
||||||
|
const logs = await container.logs({ stdout: true, stderr: true, tail: 100, timestamps: true });
|
||||||
|
res.send(logs.toString('utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching logs:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch logs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const monitorDockerEvents = () => {
|
||||||
|
docker.getEvents((err, stream) => {
|
||||||
|
if (err) return console.error('Error getting docker events:', err);
|
||||||
|
|
||||||
|
stream.on('data', chunk => {
|
||||||
|
if (!dockerConfig.enabled) return; // Ignore if disabled
|
||||||
|
if (!dockerConfig.containerAlertEnabled) return; // Ignore if container alerts disabled
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(chunk.toString());
|
||||||
|
// Filter for container stop/die
|
||||||
|
if (event.Type === 'container' && (event.Action === 'die' || event.Action === 'stop')) {
|
||||||
|
// Check if it's "unexpected"?
|
||||||
|
// Usually we alert on any stop if the monitoring is strictly keeping uptime.
|
||||||
|
// But if user stops it manually via UI, we might get an event too.
|
||||||
|
// However, UI stop calls API -> API stops container.
|
||||||
|
// It is hard to distinguish "crash" from "manual stop" without state tracking.
|
||||||
|
// For now, user asked "컨테이너가 까지게 되면", effectively "stops".
|
||||||
|
// We will alert.
|
||||||
|
const containerName = event.Actor?.Attributes?.name || event.id.substring(0, 12);
|
||||||
|
sendNotification({
|
||||||
|
title: 'snStatus 알림',
|
||||||
|
body: `컨테이너 종료: ${containerName}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing docker event:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
initPushNotifications();
|
||||||
|
if (dockerConfig.enabled) monitorDockerEvents(); // Start if enabled, or always start?
|
||||||
|
// Better always start listening, but filter inside.
|
||||||
|
// Actually monitorDockerEvents is a stream. If I call it multiple times it duplicates.
|
||||||
|
// Just call it once.
|
||||||
|
monitorDockerEvents();
|
||||||
|
|
||||||
|
|
||||||
|
// --- WebSocket for Terminal & Logs ---
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
let stream = null;
|
||||||
|
socket.on('attach-terminal', async (containerId) => {
|
||||||
|
try {
|
||||||
|
const container = docker.getContainer(containerId);
|
||||||
|
const exec = await container.exec({
|
||||||
|
AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true, Cmd: ['/bin/sh']
|
||||||
|
});
|
||||||
|
stream = await exec.start({ hijack: true, stdin: true });
|
||||||
|
stream.on('data', (chunk) => socket.emit('terminal-output', chunk.toString('utf8')));
|
||||||
|
socket.on('terminal-input', (data) => { if (stream) stream.write(data); });
|
||||||
|
socket.on('resize-terminal', ({ cols, rows }) => { if (exec) exec.resize({ w: cols, h: rows }).catch(() => { }); });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error attaching to container:', err);
|
||||||
|
socket.emit('terminal-output', '\r\nError: Failed to attach to container. ' + err.message + '\r\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => { if (stream) stream.end(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
httpServer.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
2
frontend/build.sh
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
docker build --no-cache -t sa8001/snstatus-frontend:$1 ./frontend
|
||||||
|
docker build --no-cache -t sa8001/snstatus-backend:$1 ./backend
|
||||||
13
frontend/data/config.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"passwordHash": "8930e0e9ba435b538c8789a5eb7b3262ce73b08fad336b9dbc12599599d8885973d640923bc34c789f598124f7ad238de560bc06e88b2ce02ff0819f27a5b590",
|
||||||
|
"salt": "688750cfc766f9b5672be297ee45bd0e",
|
||||||
|
"retentionHours": 24,
|
||||||
|
"alertThresholds": {
|
||||||
|
"cpu": 80,
|
||||||
|
"memory": 80,
|
||||||
|
"disk": 90
|
||||||
|
},
|
||||||
|
"containerAlertEnabled": true,
|
||||||
|
"alertCooldownSeconds": 60
|
||||||
|
}
|
||||||
1
frontend/data/history.json
Normal file
1
frontend/data/subscriptions.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[{"endpoint":"https://fcm.googleapis.com/fcm/send/eZsR0N81Ung:APA91bHCYWk4RCoTebqCmyx-ll84oAF7LVDlF3ok1WDmLTVZP7vkD6B3hHmqvsb0zEVAFQ7XxQjFTjaUEPJ3rFX2p8vOGOYrTo-BYp4Q0hRDb76PQsixyoZmpGGhWXGTsTkMuJcKm9zS","expirationTime":null,"keys":{"p256dh":"BJMqcs9HG0iPmG1Y32gIefoQZFFRizyikKnSllNkuXa7QdKG7_XGlt8-klGVg5hTNOy1KQznuNZszLKrMIpVvyE","auth":"_yxFem3vuCmVuC4W0jA3ng"}}]
|
||||||
1
frontend/data/vapid.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"publicKey":"BNIK7MBUGgvdkwfxDckbbCEbFXtk6DWJg8bQ12SaDIiqWHpGJxx5hzjCEnqvAzNv7maZ_Mw2fTV8bJL22vRAtAU","privateKey":"xqscc6KwgAo2GCa5X4AJIhGiyZsr5lfC2BRSVCLzWrM"}
|
||||||
25
frontend/docker-compose-dev.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: snstatus-backend
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
privileged: true
|
||||||
|
volumes:
|
||||||
|
- /:/host:ro
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./backend/data:/app/data
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
container_name: snstatus-frontend
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3005:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
25
frontend/docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: sa8001/snstatus-backend:v0.1.5
|
||||||
|
container_name: snstatus-backend
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
privileged: true
|
||||||
|
volumes:
|
||||||
|
- /:/host:ro
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./data:/app/data
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: sa8001/snstatus-frontend:v0.1.5
|
||||||
|
container_name: snstatus-frontend
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3005:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
34
frontend/index.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#1a1a1a" />
|
||||||
|
<meta name="description" content="Synology NAS System Status Monitor" />
|
||||||
|
|
||||||
|
<!-- PWA Manifest -->
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
|
||||||
|
<!-- iOS PWA Support -->
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="snStatus" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|
||||||
|
<!-- Android PWA Support -->
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
|
||||||
|
<title>snStatus - Nas Monitor</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="background-color: #1a1a1a; margin: 0;">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
28
frontend/nginx.conf
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy WebSocket requests
|
||||||
|
location /socket.io/ {
|
||||||
|
proxy_pass http://backend:8001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "snstatus-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.309.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.21.1",
|
||||||
|
"recharts": "^2.10.3",
|
||||||
|
"socket.io-client": "^4.7.2",
|
||||||
|
"xterm": "^5.3.0",
|
||||||
|
"xterm-addon-fit": "^0.8.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"vite": "^5.0.8",
|
||||||
|
"vite-plugin-pwa": "^0.19.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
frontend/public/favicon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
47
frontend/public/icon.svg
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#764ba2"/>
|
||||||
|
<stop offset="1" stop-color="#667eea"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="dropshadow" height="130%">
|
||||||
|
<feGaussianBlur in="SourceAlpha" stdDeviation="15"/>
|
||||||
|
<feOffset dx="0" dy="10" result="offsetblur"/>
|
||||||
|
<feComponentTransfer>
|
||||||
|
<feFuncA type="linear" slope="0.3"/>
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Safe Area Padding applied (approx 10-15%) -->
|
||||||
|
<g filter="url(#dropshadow)">
|
||||||
|
<!-- Main Hexagon Shape -->
|
||||||
|
<!-- Points calculated for 512x512 canvas with padding -->
|
||||||
|
<path d="M256 50
|
||||||
|
L433 152
|
||||||
|
L433 357
|
||||||
|
L256 460
|
||||||
|
L79 357
|
||||||
|
L79 152
|
||||||
|
Z"
|
||||||
|
fill="url(#paint0_linear)"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="20"
|
||||||
|
stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- Internal Cube Lines (Y shape) -->
|
||||||
|
<path d="M256 460 L256 255" stroke="white" stroke-width="20" stroke-linecap="round"/>
|
||||||
|
<path d="M256 255 L433 152" stroke="white" stroke-width="20" stroke-linecap="round"/>
|
||||||
|
<path d="M79 152 L256 255" stroke="white" stroke-width="20" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Tech Accent Dots -->
|
||||||
|
<circle cx="256" cy="255" r="30" fill="white"/>
|
||||||
|
<circle cx="256" cy="110" r="15" fill="white" fill-opacity="0.6"/>
|
||||||
|
<circle cx="380" cy="320" r="15" fill="white" fill-opacity="0.6"/>
|
||||||
|
<circle cx="132" cy="320" r="15" fill="white" fill-opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
66
frontend/public/push-handler.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Push notification event handler
|
||||||
|
// This will be injected into the Service Worker
|
||||||
|
|
||||||
|
self.addEventListener('push', function (event) {
|
||||||
|
console.log('[SW Push] Push event received');
|
||||||
|
|
||||||
|
let notificationData = {
|
||||||
|
title: 'snStatus Alert',
|
||||||
|
body: 'System alert',
|
||||||
|
icon: '/pwa-192x192.png',
|
||||||
|
badge: '/pwa-192x192.png',
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
requireInteraction: true,
|
||||||
|
tag: 'snstatus-alert',
|
||||||
|
data: {
|
||||||
|
url: '/'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.data) {
|
||||||
|
try {
|
||||||
|
const data = event.data.json();
|
||||||
|
console.log('[SW Push] Data:', data);
|
||||||
|
if (data.title) notificationData.title = data.title;
|
||||||
|
if (data.body) notificationData.body = data.body;
|
||||||
|
if (data.icon) notificationData.icon = data.icon;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[SW Push] Parse error:', e);
|
||||||
|
notificationData.body = event.data.text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(notificationData.title, {
|
||||||
|
body: notificationData.body,
|
||||||
|
icon: notificationData.icon,
|
||||||
|
badge: notificationData.badge,
|
||||||
|
vibrate: notificationData.vibrate,
|
||||||
|
requireInteraction: notificationData.requireInteraction,
|
||||||
|
tag: notificationData.tag,
|
||||||
|
data: notificationData.data
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', function (event) {
|
||||||
|
console.log('[SW] Notification clicked');
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||||
|
.then(function (clientList) {
|
||||||
|
// Focus existing window if available
|
||||||
|
for (let i = 0; i < clientList.length; i++) {
|
||||||
|
const client = clientList[i];
|
||||||
|
if (client.url === '/' && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Open new window
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow('/');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
BIN
frontend/public/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
frontend/public/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
50
frontend/public/sw-custom.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Custom Service Worker for Push Notifications
|
||||||
|
self.addEventListener('push', function (event) {
|
||||||
|
console.log('[SW] Push event received:', event);
|
||||||
|
|
||||||
|
let notificationData = {
|
||||||
|
title: 'snStatus Alert',
|
||||||
|
body: 'System alert triggered',
|
||||||
|
icon: '/pwa-192x192.png',
|
||||||
|
badge: '/pwa-192x192.png',
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
requireInteraction: true,
|
||||||
|
tag: 'snstatus-alert'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.data) {
|
||||||
|
try {
|
||||||
|
const data = event.data.json();
|
||||||
|
console.log('[SW] Push data:', data);
|
||||||
|
notificationData.title = data.title || notificationData.title;
|
||||||
|
notificationData.body = data.body || notificationData.body;
|
||||||
|
notificationData.icon = data.icon || notificationData.icon;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[SW] Error parsing push data:', e);
|
||||||
|
notificationData.body = event.data.text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promiseChain = self.registration.showNotification(
|
||||||
|
notificationData.title,
|
||||||
|
{
|
||||||
|
body: notificationData.body,
|
||||||
|
icon: notificationData.icon,
|
||||||
|
badge: notificationData.badge,
|
||||||
|
vibrate: notificationData.vibrate,
|
||||||
|
requireInteraction: notificationData.requireInteraction,
|
||||||
|
tag: notificationData.tag
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
event.waitUntil(promiseChain);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', function (event) {
|
||||||
|
console.log('[SW] Notification clicked');
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
clients.openWindow('/')
|
||||||
|
);
|
||||||
|
});
|
||||||
34
frontend/src/App.jsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import { SettingsProvider } from './context/SettingsContext';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import DockerManager from './pages/DockerManager';
|
||||||
|
import Settings from './pages/Settings';
|
||||||
|
|
||||||
|
import History from './pages/History';
|
||||||
|
|
||||||
|
import DockerAuth from './components/DockerAuth';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<SettingsProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="history" element={<History />} />
|
||||||
|
<Route path="docker" element={
|
||||||
|
<DockerAuth>
|
||||||
|
<DockerManager />
|
||||||
|
</DockerAuth>
|
||||||
|
} />
|
||||||
|
<Route path="settings" element={<Settings />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</SettingsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
663
frontend/src/components/AlertSettingsModal.jsx
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Bell, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
const AlertSettingsModal = ({ isOpen, onClose, onSave }) => {
|
||||||
|
const [thresholds, setThresholds] = useState({ cpu: 90, memory: 90, disk: 90 });
|
||||||
|
const [containerAlertEnabled, setContainerAlertEnabled] = useState(true);
|
||||||
|
const [alertCooldownSeconds, setAlertCooldownSeconds] = useState(300);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [notifPermission, setNotifPermission] = useState(Notification.permission);
|
||||||
|
const [hasActiveSubscription, setHasActiveSubscription] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
// Update permission state
|
||||||
|
setNotifPermission(Notification.permission);
|
||||||
|
|
||||||
|
// Check for active subscription
|
||||||
|
const checkSubscription = async () => {
|
||||||
|
try {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
setHasActiveSubscription(!!subscription);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to check subscription:', e);
|
||||||
|
setHasActiveSubscription(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkSubscription();
|
||||||
|
|
||||||
|
// Fetch current settings from backend
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/docker/config');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.alertThresholds) {
|
||||||
|
setThresholds(prev => ({ ...prev, ...data.alertThresholds }));
|
||||||
|
}
|
||||||
|
if (data.containerAlertEnabled !== undefined) {
|
||||||
|
setContainerAlertEnabled(data.containerAlertEnabled);
|
||||||
|
}
|
||||||
|
if (data.alertCooldownSeconds !== undefined) {
|
||||||
|
setAlertCooldownSeconds(data.alertCooldownSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch alert settings:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchSettings();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const enableNotifications = async () => {
|
||||||
|
console.log('=== 알림 권한 요청 시작 ===');
|
||||||
|
console.log('Notification support:', 'Notification' in window);
|
||||||
|
console.log('ServiceWorker support:', 'serviceWorker' in navigator);
|
||||||
|
console.log('Secure context:', window.isSecureContext);
|
||||||
|
console.log('Current URL:', window.location.href);
|
||||||
|
console.log('User Agent:', navigator.userAgent);
|
||||||
|
|
||||||
|
// Check if browser supports notifications
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
alert('이 브라우저는 알림을 지원하지 않습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Service Worker is supported
|
||||||
|
if (!('serviceWorker' in navigator)) {
|
||||||
|
console.error('Service Worker not supported');
|
||||||
|
alert('이 브라우저는 Service Worker를 지원하지 않습니다.\n\n브라우저 정보:\n- User Agent: ' + navigator.userAgent.substring(0, 100) + '...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're on a secure context (HTTPS or localhost)
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
alert('푸시 알림은 HTTPS 또는 localhost 환경에서만 사용할 수 있습니다.\n현재 연결이 안전하지 않습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request notification permission
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
console.log('Notification permission:', permission);
|
||||||
|
setNotifPermission(permission);
|
||||||
|
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
alert('알림 권한이 거부되었습니다.\n브라우저 설정에서 알림 권한을 허용해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for service worker to be ready
|
||||||
|
console.log('Waiting for service worker...');
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
console.log('Service Worker ready:', registration);
|
||||||
|
|
||||||
|
// Get VAPID public key
|
||||||
|
console.log('Fetching VAPID key...');
|
||||||
|
const keyRes = await fetch('/api/notifications/vapidPublicKey');
|
||||||
|
if (!keyRes.ok) {
|
||||||
|
throw new Error(`VAPID 키 가져오기 실패: ${keyRes.status}`);
|
||||||
|
}
|
||||||
|
const { publicKey } = await keyRes.json();
|
||||||
|
console.log('VAPID key received');
|
||||||
|
|
||||||
|
// Subscribe to push notifications
|
||||||
|
console.log('Subscribing to push notifications...');
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: publicKey
|
||||||
|
});
|
||||||
|
console.log('Push subscription created:', subscription);
|
||||||
|
|
||||||
|
// Send subscription to backend
|
||||||
|
console.log('Sending subscription to backend...');
|
||||||
|
const response = await fetch('/api/notifications/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(subscription)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`구독 등록 실패: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Subscription saved successfully');
|
||||||
|
alert('푸시 알림이 설정되었습니다!');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('알림 설정 오류:', e);
|
||||||
|
|
||||||
|
// Provide detailed error messages
|
||||||
|
let errorMsg = '알림 설정에 실패했습니다.\n\n';
|
||||||
|
|
||||||
|
if (e.name === 'NotAllowedError') {
|
||||||
|
errorMsg += '알림 권한이 거부되었습니다. 브라우저 설정에서 알림을 허용해주세요.';
|
||||||
|
} else if (e.message.includes('VAPID')) {
|
||||||
|
errorMsg += 'VAPID 키를 가져오는데 실패했습니다. 서버 연결을 확인해주세요.';
|
||||||
|
} else if (e.message.includes('구독')) {
|
||||||
|
errorMsg += '푸시 알림 구독에 실패했습니다. 서버 연결을 확인해주세요.';
|
||||||
|
} else {
|
||||||
|
errorMsg += `오류: ${e.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMsg += '\n\n콘솔(F12)에서 자세한 오류를 확인할 수 있습니다.';
|
||||||
|
alert(errorMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableNotifications = async () => {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
// Unsubscribe from push notifications
|
||||||
|
await subscription.unsubscribe();
|
||||||
|
|
||||||
|
// Notify backend
|
||||||
|
await fetch('/api/notifications/unsubscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ endpoint: subscription.endpoint })
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Notifications] Unsubscribed from push notifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Unregister Service Worker completely
|
||||||
|
// This is essential for mobile devices where dev tools aren't available
|
||||||
|
await registration.unregister();
|
||||||
|
console.log('[Service Worker] Unregistered successfully');
|
||||||
|
|
||||||
|
setNotifPermission('default');
|
||||||
|
alert('푸시 알림이 해제되었습니다.\n\n페이지를 새로고침하여 Service Worker를 업데이트합니다.');
|
||||||
|
|
||||||
|
// Reload page to download and register new Service Worker
|
||||||
|
window.location.reload();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Notifications] 알림 해제 오류:', e);
|
||||||
|
alert('알림 해제 중 오류가 발생했습니다.\n\n페이지를 새로고침 후 다시 시도해주세요.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
// Validate inputs
|
||||||
|
if (!thresholds.cpu || !thresholds.memory || !thresholds.disk || !alertCooldownSeconds) {
|
||||||
|
alert('모든 값을 입력해주세요.\n\nCPU, 메모리, 디스크 임계값과 알림 재전송 대기 시간을 입력해야 합니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Save thresholds
|
||||||
|
const thresholdRes = await fetch('/api/settings/thresholds', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
thresholds,
|
||||||
|
containerAlertEnabled,
|
||||||
|
alertCooldownSeconds
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (thresholdRes.ok) {
|
||||||
|
alert('알림 설정이 저장되었습니다.');
|
||||||
|
if (onSave) onSave();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
alert('설정 저장 실패');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save alert settings:', e);
|
||||||
|
alert('설정 저장 중 오류 발생');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div className="modal-title-group">
|
||||||
|
<Bell size={24} className="modal-icon" />
|
||||||
|
<h2>알림 설정</h2>
|
||||||
|
</div>
|
||||||
|
<button className="modal-close-btn" onClick={onClose}>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="alert-settings-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<Bell size={20} />
|
||||||
|
<h3>푸시 알림 권한</h3>
|
||||||
|
</div>
|
||||||
|
<p className="section-desc">
|
||||||
|
{notifPermission === 'granted'
|
||||||
|
? '알림이 활성화되었습니다. 해제하려면 아래 버튼을 클릭하세요.'
|
||||||
|
: '리소스 임계값 초과 및 컨테이너 종료 시 푸시 알림을 받으려면 권한을 허용해야 합니다.'}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
{!hasActiveSubscription ? (
|
||||||
|
<button className="permission-btn" onClick={enableNotifications}>
|
||||||
|
알림 권한 받기
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="permission-btn" style={{ background: 'var(--success-color)', opacity: 0.8, cursor: 'default' }} disabled>
|
||||||
|
✓ 알림 활성화됨
|
||||||
|
</button>
|
||||||
|
<button className="permission-btn" style={{ background: 'var(--error-color)' }} onClick={disableNotifications}>
|
||||||
|
알림 해제
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="alert-settings-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<AlertTriangle size={20} />
|
||||||
|
<h3>리소스 사용률 임계값</h3>
|
||||||
|
</div>
|
||||||
|
<p className="section-desc">설정된 사용률을 초과하면 푸시 알림을 받습니다.</p>
|
||||||
|
|
||||||
|
<div className="threshold-grid">
|
||||||
|
<div className="threshold-item">
|
||||||
|
<label>CPU 사용률 (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={thresholds.cpu}
|
||||||
|
onChange={(e) => setThresholds({ ...thresholds, cpu: parseInt(e.target.value) || '' })}
|
||||||
|
className="threshold-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="threshold-item">
|
||||||
|
<label>메모리 사용률 (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={thresholds.memory}
|
||||||
|
onChange={(e) => setThresholds({ ...thresholds, memory: parseInt(e.target.value) || '' })}
|
||||||
|
className="threshold-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="threshold-item">
|
||||||
|
<label>디스크 사용률 (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={thresholds.disk}
|
||||||
|
onChange={(e) => setThresholds({ ...thresholds, disk: parseInt(e.target.value) || '' })}
|
||||||
|
className="threshold-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="threshold-item">
|
||||||
|
<label>알림 재전송 대기 시간 (초)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="60"
|
||||||
|
max="3600"
|
||||||
|
value={alertCooldownSeconds}
|
||||||
|
onChange={(e) => setAlertCooldownSeconds(parseInt(e.target.value) || '')}
|
||||||
|
className="threshold-input"
|
||||||
|
/>
|
||||||
|
<small style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
|
||||||
|
동일한 알림이 다시 전송되기까지의 대기 시간 (최소 10초, 기본 300초)
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="alert-settings-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<Bell size={20} />
|
||||||
|
<h3>컨테이너 알림</h3>
|
||||||
|
</div>
|
||||||
|
<p className="section-desc">Docker 컨테이너가 중지되거나 종료될 때 알림을 받습니다.</p>
|
||||||
|
|
||||||
|
<div className="toggle-container">
|
||||||
|
<label className="toggle-label">
|
||||||
|
<span>컨테이너 종료 알림</span>
|
||||||
|
<div className="toggle-wrapper">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={containerAlertEnabled}
|
||||||
|
onChange={(e) => setContainerAlertEnabled(e.target.checked)}
|
||||||
|
className="toggle-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider"></span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="modal-btn modal-btn-secondary" onClick={onClose}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="modal-btn modal-btn-primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-icon {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-group h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-settings-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-item label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-input {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-container {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label span {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 50px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-checkbox {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-checkbox:checked + .toggle-slider {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-checkbox:checked + .toggle-slider:before {
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-primary {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-primary:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-btn:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.threshold-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
max-height: 95vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlertSettingsModal;
|
||||||
141
frontend/src/components/DockerAuth.jsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useSettings } from '../context/SettingsContext';
|
||||||
|
import { Lock } from 'lucide-react';
|
||||||
|
|
||||||
|
const DockerAuth = ({ children }) => {
|
||||||
|
const { dockerEnabled } = useSettings();
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
|
||||||
|
// If disabled globablly (from backend), show message
|
||||||
|
if (!dockerEnabled) {
|
||||||
|
return (
|
||||||
|
<div className="docker-auth-container glass-card centered">
|
||||||
|
<Lock size={48} className="auth-icon locked" />
|
||||||
|
<h2>기능 비활성화</h2>
|
||||||
|
<p>Docker 관리 기능이 현재 비활성화되어 있습니다.</p>
|
||||||
|
<p className="sub-text">설정 페이지에서 기능을 활성화해주세요.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setChecking(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/docker/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok && data.success) {
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
} else {
|
||||||
|
setError('비밀번호가 올바르지 않습니다.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('서버 연결 실패');
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="docker-auth-container">
|
||||||
|
<div className="glass-card auth-card">
|
||||||
|
<div className="auth-header">
|
||||||
|
<Lock size={32} className="auth-icon" />
|
||||||
|
<h2>Docker 보안 접근</h2>
|
||||||
|
</div>
|
||||||
|
<p className="auth-desc">이 기능에 접근하려면 비밀번호를 입력하세요.</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleLogin} className="auth-form">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="비밀번호"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="auth-input"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && <p className="auth-error">{error}</p>}
|
||||||
|
<button type="submit" className="auth-btn" disabled={checking}>
|
||||||
|
{checking ? '확인 중...' : '잠금 해제'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.docker-auth-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.auth-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.auth-icon { color: var(--accent-color); }
|
||||||
|
.auth-icon.locked { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.auth-desc { color: var(--text-secondary); margin-bottom: 2rem; }
|
||||||
|
.sub-text { font-size: 0.9rem; color: var(--text-secondary); margin-top: 0.5rem; }
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.auth-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.auth-btn {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.auth-btn:hover { opacity: 0.9; }
|
||||||
|
.auth-error { color: var(--error-color); font-size: 0.9rem; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DockerAuth;
|
||||||
367
frontend/src/components/Layout.jsx
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
|
import { useSettings } from '../context/SettingsContext';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
LayoutGrid,
|
||||||
|
Container,
|
||||||
|
Settings,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const Layout = () => {
|
||||||
|
const { theme, toggleTheme, dockerEnabled, isSidebarCollapsed, setIsSidebarCollapsed } = useSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-container layout-container">
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
<nav className={`glass-panel sidebar ${isSidebarCollapsed ? 'collapsed' : ''}`}>
|
||||||
|
<div className="logo-section">
|
||||||
|
<Activity className="icon" size={24} style={{ marginRight: isSidebarCollapsed ? 0 : '0.4rem', color: 'var(--accent-color)' }} />
|
||||||
|
{!isSidebarCollapsed && <h1 className="logo-text">snStatus</h1>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="nav-links">
|
||||||
|
<li>
|
||||||
|
<NavLink to="/" className={({ isActive }) => isActive ? 'nav-item active' : 'nav-item'} title="대시보드">
|
||||||
|
<LayoutGrid size={20} />
|
||||||
|
{!isSidebarCollapsed && <span>대시보드</span>}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NavLink to="/history" className={({ isActive }) => isActive ? 'nav-item active' : 'nav-item'} title="히스토리">
|
||||||
|
<Activity size={20} />
|
||||||
|
{!isSidebarCollapsed && <span>히스토리</span>}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
{dockerEnabled && (
|
||||||
|
<li>
|
||||||
|
<NavLink to="/docker" className={({ isActive }) => isActive ? 'nav-item active' : 'nav-item'} title="도커">
|
||||||
|
<Container size={20} />
|
||||||
|
{!isSidebarCollapsed && <span>도커</span>}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li>
|
||||||
|
<NavLink to="/settings" className={({ isActive }) => isActive ? 'nav-item active' : 'nav-item'} title="설정">
|
||||||
|
<Settings size={20} />
|
||||||
|
{!isSidebarCollapsed && <span>설정</span>}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<div className="theme-toggle-container">
|
||||||
|
<button onClick={toggleTheme} className="glass-card btn-icon theme-btn" title={theme === 'light' ? '다크 모드로 전환' : '라이트 모드로 전환'}>
|
||||||
|
{theme === 'light' ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
|
{!isSidebarCollapsed && <span className="theme-text">{theme === 'light' ? '라이트 모드' : '다크 모드'}</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="collapse-btn"
|
||||||
|
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||||
|
title={isSidebarCollapsed ? "메뉴 펼치기" : "메뉴 접기"}
|
||||||
|
>
|
||||||
|
{isSidebarCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile Bottom Navigation */}
|
||||||
|
<nav className="glass-panel bottom-nav">
|
||||||
|
<NavLink to="/" className={({ isActive }) => isActive ? 'nav-item active' : 'nav-item'}>
|
||||||
|
<LayoutGrid size={20} />
|
||||||
|
<span>홈</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/history" className={({ isActive }) => isActive ? 'nav-item active' : 'nav-item'}>
|
||||||
|
<Activity size={20} />
|
||||||
|
<span>히스토리</span>
|
||||||
|
</NavLink>
|
||||||
|
{dockerEnabled && (
|
||||||
|
<NavLink to="/docker" className={({ isActive }) => isActive ? 'nav-item active' : 'nav-item'}>
|
||||||
|
<Container size={20} />
|
||||||
|
<span>도커</span>
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
<NavLink to="/settings" className={({ isActive }) => isActive ? 'nav-item active' : 'nav-item'}>
|
||||||
|
<Settings size={20} />
|
||||||
|
<span>설정</span>
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="main-content">
|
||||||
|
{/* Mobile Header */}
|
||||||
|
<header className="mobile-header glass-panel">
|
||||||
|
<Activity size={20} style={{ color: 'var(--accent-color)' }} />
|
||||||
|
<h1 className="logo-text">snStatus</h1>
|
||||||
|
</header>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.layout-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
height: 100vh;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Sidebar Styles (Desktop) --- */
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-radius: 0 !important;
|
||||||
|
border-right: 1px solid var(--glass-border);
|
||||||
|
border-left: none;
|
||||||
|
border-top: none;
|
||||||
|
border-bottom: none;
|
||||||
|
z-index: 100;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: 72px;
|
||||||
|
padding: 1.25rem 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
height: 32px; /* Fixed height to prevent jump */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .logo-section {
|
||||||
|
justify-content: center;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Refined Logo Styling */
|
||||||
|
/* Refined Logo Styling */
|
||||||
|
.logo-section h1 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
background: var(--brand-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
display: inline-block;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .nav-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 12px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer Area */
|
||||||
|
.sidebar-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle-container {
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .theme-btn {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn:hover {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
padding-left: 0.8rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.collapse-btn:hover {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .collapse-btn {
|
||||||
|
justify-content: center;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Main Content --- */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 2rem;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile Elements (Default Hidden on Desktop) --- */
|
||||||
|
.bottom-nav {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 64px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav .nav-item {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent !important; /* Override active bg */
|
||||||
|
color: var(--text-secondary);
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav .nav-item.active {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
.bottom-nav .nav-item.active svg {
|
||||||
|
stroke-width: 2.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
.mobile-header h1 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile Media Queries --- */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 1rem;
|
||||||
|
padding-bottom: 80px; /* Space for bottom nav */
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
91
frontend/src/components/TerminalComponent.jsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { Terminal } from 'xterm';
|
||||||
|
import { FitAddon } from 'xterm-addon-fit';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
import 'xterm/css/xterm.css';
|
||||||
|
|
||||||
|
const TerminalComponent = ({ containerId }) => {
|
||||||
|
const terminalRef = useRef(null);
|
||||||
|
const socketRef = useRef(null);
|
||||||
|
const termRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize Socket
|
||||||
|
// Use relative path if proxied, or absolute for dev
|
||||||
|
// Since we are proxying /api, we might need to be careful with socket.io path
|
||||||
|
// Default socket.io path is /socket.io
|
||||||
|
// If nginx proxies /socket.io -> backend:8001/socket.io, it should work.
|
||||||
|
socketRef.current = io({
|
||||||
|
path: '/socket.io',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize xterm
|
||||||
|
const term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
theme: {
|
||||||
|
background: '#1e1e1e',
|
||||||
|
foreground: '#f0f0f0',
|
||||||
|
},
|
||||||
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||||
|
fontSize: 14,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
|
||||||
|
if (terminalRef.current) {
|
||||||
|
term.open(terminalRef.current);
|
||||||
|
fitAddon.fit();
|
||||||
|
termRef.current = term;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach to container
|
||||||
|
socketRef.current.emit('attach-terminal', containerId);
|
||||||
|
|
||||||
|
// Handle Socket Events
|
||||||
|
socketRef.current.on('terminal-output', (data) => {
|
||||||
|
term.write(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Terminal Input
|
||||||
|
term.onData((data) => {
|
||||||
|
socketRef.current.emit('terminal-input', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Resize
|
||||||
|
const handleResize = () => {
|
||||||
|
fitAddon.fit();
|
||||||
|
if (terminalRef.current) {
|
||||||
|
const { cols, rows } = term;
|
||||||
|
socketRef.current.emit('resize-terminal', { cols, rows });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Initial resize
|
||||||
|
setTimeout(() => handleResize(), 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
if (socketRef.current) socketRef.current.disconnect();
|
||||||
|
if (termRef.current) termRef.current.dispose();
|
||||||
|
};
|
||||||
|
}, [containerId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={terminalRef}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: '#1e1e1e',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: '0.5rem'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TerminalComponent;
|
||||||
104
frontend/src/context/SettingsContext.jsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { createContext, useState, useEffect, useContext } from 'react';
|
||||||
|
|
||||||
|
const SettingsContext = createContext();
|
||||||
|
|
||||||
|
export const useSettings = () => useContext(SettingsContext);
|
||||||
|
|
||||||
|
export const SettingsProvider = ({ children }) => {
|
||||||
|
// Theme State
|
||||||
|
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light');
|
||||||
|
|
||||||
|
// Dashboard Chart Type State ('bar' or 'line')
|
||||||
|
const [chartType, setChartType] = useState(() => localStorage.getItem('chartType') || 'bar');
|
||||||
|
|
||||||
|
// Docker Feature Config (Global Security Setting)
|
||||||
|
const [dockerEnabled, setDockerEnabled] = useState(false);
|
||||||
|
|
||||||
|
// Fetch Global Docker Config on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/docker/config');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setDockerEnabled(data.enabled);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch docker config:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Network Interface Visibility State (Array of hidden interface names)
|
||||||
|
const [hiddenInterfaces, setHiddenInterfaces] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('hiddenInterfaces');
|
||||||
|
return saved !== null ? JSON.parse(saved) : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Effects to save to localStorage and apply theme
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('chartType', chartType);
|
||||||
|
}, [chartType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('hiddenInterfaces', JSON.stringify(hiddenInterfaces));
|
||||||
|
}, [hiddenInterfaces]);
|
||||||
|
|
||||||
|
// Sidebar Collapsed State (true/false)
|
||||||
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('isSidebarCollapsed');
|
||||||
|
return saved !== null ? JSON.parse(saved) : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('isSidebarCollapsed', JSON.stringify(isSidebarCollapsed));
|
||||||
|
}, [isSidebarCollapsed]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme(prev => prev === 'light' ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleInterfaceVisibility = (ifaceName) => {
|
||||||
|
setHiddenInterfaces(prev => {
|
||||||
|
if (prev.includes(ifaceName)) {
|
||||||
|
return prev.filter(name => name !== ifaceName); // Show (remove from hidden)
|
||||||
|
} else {
|
||||||
|
return [...prev, ifaceName]; // Hide (add to hidden)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to refresh config manually (e.g. after Settings change)
|
||||||
|
const refreshDockerConfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/docker/config');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setDockerEnabled(data.enabled);
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContext.Provider value={{
|
||||||
|
theme,
|
||||||
|
toggleTheme,
|
||||||
|
chartType,
|
||||||
|
setChartType,
|
||||||
|
dockerEnabled,
|
||||||
|
refreshDockerConfig, // Expose for Settings page
|
||||||
|
hiddenInterfaces,
|
||||||
|
toggleInterfaceVisibility,
|
||||||
|
isSidebarCollapsed,
|
||||||
|
setIsSidebarCollapsed
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</SettingsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
334
frontend/src/index.css
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
:root {
|
||||||
|
/* Light Mode Colors */
|
||||||
|
--bg-primary: #e0e5ec;
|
||||||
|
--bg-gradient: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
--text-primary: #2d3748;
|
||||||
|
--text-secondary: #4a5568;
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.45);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.4);
|
||||||
|
--glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
|
||||||
|
--card-bg: rgba(255, 255, 255, 0.25);
|
||||||
|
|
||||||
|
/* Red Accent */
|
||||||
|
--accent-color: #ef4444;
|
||||||
|
--accent-glow: rgba(239, 68, 68, 0.3);
|
||||||
|
|
||||||
|
/* Volume Orange */
|
||||||
|
--volume-color: #f97316;
|
||||||
|
|
||||||
|
/* Text Gradient (Force Orange) */
|
||||||
|
--brand-gradient: linear-gradient(135deg, #2d3748 0%, #f97316 100%);
|
||||||
|
|
||||||
|
--success-color: #38a169;
|
||||||
|
--warning-color: #dd6b20;
|
||||||
|
--error-color: #e53e3e;
|
||||||
|
--cpu-color: #3b82f6;
|
||||||
|
/* Blue for CPU */
|
||||||
|
--sidebar-bg: rgba(255, 255, 255, 0.3);
|
||||||
|
|
||||||
|
/* Font */
|
||||||
|
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
/* Dark Mode Colors - Neutral/Darker */
|
||||||
|
--bg-primary: #0a0a0a;
|
||||||
|
--bg-gradient: linear-gradient(135deg, #1a1a1a 0%, #000000 100%);
|
||||||
|
--text-primary: #f7fafc;
|
||||||
|
--text-secondary: #a0aec0;
|
||||||
|
--glass-bg: rgba(0, 0, 0, 0.6);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
--card-bg: rgba(30, 30, 30, 0.4);
|
||||||
|
|
||||||
|
/* Red Accent in Dark */
|
||||||
|
--accent-color: #f87171;
|
||||||
|
--accent-glow: rgba(248, 113, 113, 0.3);
|
||||||
|
|
||||||
|
--volume-color: #fb923c;
|
||||||
|
|
||||||
|
/* Force Light Mode Gradient in Dark Mode as requested (Orange-ish) */
|
||||||
|
--brand-gradient: linear-gradient(135deg, #2d3748 0%, #f97316 100%);
|
||||||
|
|
||||||
|
--sidebar-bg: rgba(10, 10, 10, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
background: var(--bg-gradient);
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
transition: background 0.3s ease, color 0.3s ease;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo/Brand Styles - STRONG GRADIENT */
|
||||||
|
.logo-text {
|
||||||
|
background: linear-gradient(135deg, #dc2626 0%, #ff8c00 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Model name within system-info-card - STRONG GRADIENT */
|
||||||
|
.sys-details h3 {
|
||||||
|
background: linear-gradient(135deg, #dc2626 0%, #ff8c00 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-icon svg {
|
||||||
|
background: linear-gradient(135deg, #dc2626 0%, #ff8c00 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode icon background */
|
||||||
|
.sys-icon {
|
||||||
|
background: rgba(240, 240, 240, 0.95);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.3rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode icon background */
|
||||||
|
[data-theme='dark'] .sys-icon {
|
||||||
|
background: rgba(30, 30, 30, 0.95);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item .label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism Utilities */
|
||||||
|
.glass-panel {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: rgba(125, 125, 125, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar Styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(156, 163, 175, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(156, 163, 175, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page Containers - Standardized Layout */
|
||||||
|
.page-container,
|
||||||
|
.dashboard-container,
|
||||||
|
.settings-container,
|
||||||
|
.history-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem !important;
|
||||||
|
/* Force consistent gap */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
|
/* Container 설정 */
|
||||||
|
.page-container,
|
||||||
|
.dashboard-container,
|
||||||
|
.settings-container,
|
||||||
|
.history-container {
|
||||||
|
padding: 0 !important;
|
||||||
|
gap: 1rem !important;
|
||||||
|
width: 100% !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모든 카드를 동일하게 - CLASS 통일 */
|
||||||
|
.glass-card,
|
||||||
|
.stat-card,
|
||||||
|
.settings-card,
|
||||||
|
.chart-section,
|
||||||
|
.system-info-card,
|
||||||
|
.glass-panel,
|
||||||
|
.full-width,
|
||||||
|
.resource-card,
|
||||||
|
.widget-grid>*,
|
||||||
|
.stats-grid>*,
|
||||||
|
.settings-grid>* {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
min-width: 100% !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 1.5rem !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container children - no margins */
|
||||||
|
.page-container>*,
|
||||||
|
.dashboard-container>*,
|
||||||
|
.settings-container>*,
|
||||||
|
.history-container>* {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* settings-container 위 여백 제거 */
|
||||||
|
.settings-container {
|
||||||
|
padding-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Charts container */
|
||||||
|
.charts-container {
|
||||||
|
width: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Widget grids - single column with exact gap */
|
||||||
|
.widget-grid,
|
||||||
|
.stats-grid,
|
||||||
|
.settings-grid {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
gap: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* mobile-header: 높이 40px, 간격 최소화 */
|
||||||
|
.mobile-header {
|
||||||
|
height: 40px !important;
|
||||||
|
padding: 0.75rem 1.5rem !important;
|
||||||
|
margin-bottom: 0.5rem !important;
|
||||||
|
/* 간격 더 줄임 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Spinner Animation */
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin-animation {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container-center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 200px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
13
frontend/src/main.jsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
// vite-plugin-pwa handles Service Worker registration automatically
|
||||||
|
// Service Worker will be registered at /sw.js by the plugin
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
502
frontend/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useOutletContext } from 'react-router-dom';
|
||||||
|
import { useSettings } from '../context/SettingsContext';
|
||||||
|
import {
|
||||||
|
HardDrive,
|
||||||
|
Cpu,
|
||||||
|
MemoryStick,
|
||||||
|
Network,
|
||||||
|
Server,
|
||||||
|
Activity,
|
||||||
|
Clock,
|
||||||
|
Loader2
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
BarChart, Bar, LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
// API URL
|
||||||
|
const API_URL = '/api/stats';
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
const { chartType, hiddenInterfaces } = useSettings(); // Use Global Setting
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchHistory = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/history');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Get last 60 minutes (60 points)
|
||||||
|
// Backend sends { timestamp, cpu, memory, network: {rx, tx} }
|
||||||
|
// Frontend needs { time, cpu, memory, rx, tx }
|
||||||
|
const formattedHistory = data.slice(-60).map(item => ({
|
||||||
|
time: new Date(item.timestamp).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' }),
|
||||||
|
cpu: item.cpu,
|
||||||
|
memory: item.memory,
|
||||||
|
rx: item.network ? item.network.rx : 0,
|
||||||
|
tx: item.network ? item.network.tx : 0
|
||||||
|
}));
|
||||||
|
setHistory(formattedHistory);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch initial history:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchHistory();
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_URL);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Network response was not ok');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setStats(data);
|
||||||
|
|
||||||
|
// Update history for Line chart
|
||||||
|
setHistory(prev => {
|
||||||
|
const now = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
|
||||||
|
// Calculate Total Network Traffic
|
||||||
|
let totalRx = 0;
|
||||||
|
let totalTx = 0;
|
||||||
|
if (data.network) {
|
||||||
|
data.network.forEach(net => {
|
||||||
|
if (!hiddenInterfaces?.includes(net.iface)) {
|
||||||
|
totalRx += (net.rx_sec || 0);
|
||||||
|
totalTx += (net.tx_sec || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPoint = {
|
||||||
|
time: now,
|
||||||
|
cpu: data.cpu.load,
|
||||||
|
memory: data.memory.percentage,
|
||||||
|
rx: totalRx,
|
||||||
|
tx: totalTx
|
||||||
|
};
|
||||||
|
const newHistory = [...prev, newPoint];
|
||||||
|
// Keep last 1 hour (assuming 5s updates + initial 1m data mixed)
|
||||||
|
// 1 hour = 720 points at 5s interval.
|
||||||
|
if (newHistory.length > 720) newHistory.shift();
|
||||||
|
return newHistory;
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fetch error:', err);
|
||||||
|
setError('Failed to connect to server. Ensure Backend is running.');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStats();
|
||||||
|
const interval = setInterval(fetchStats, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div className="loading-container-center">
|
||||||
|
<Loader2 className="spin-animation" size={48} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard-container">
|
||||||
|
{/*
|
||||||
|
Row 1: System Info Widget
|
||||||
|
*/}
|
||||||
|
<div className="glass-card system-info-card">
|
||||||
|
<div className="sys-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="nasGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#dc2626" />
|
||||||
|
<stop offset="100%" stopColor="#ff8c00" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{/* NAS 외곽 케이스 */}
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="1.5" stroke="url(#nasGradient)" strokeWidth="1.8" fill="none" />
|
||||||
|
{/* 상단 LED 패널 라인 */}
|
||||||
|
<line x1="4" y1="7.5" x2="20" y2="7.5" stroke="url(#nasGradient)" strokeWidth="1" />
|
||||||
|
{/* Drive Bay 1 */}
|
||||||
|
<rect x="5" y="9" width="6" height="9" rx="0.8" stroke="url(#nasGradient)" strokeWidth="1.3" fill="none" />
|
||||||
|
{/* Drive Bay 2 */}
|
||||||
|
<rect x="13" y="9" width="6" height="9" rx="0.8" stroke="url(#nasGradient)" strokeWidth="1.3" fill="none" />
|
||||||
|
{/* LED 표시등 1 */}
|
||||||
|
<circle cx="7" cy="10.5" r="0.8" fill="url(#nasGradient)" />
|
||||||
|
{/* LED 표시등 2 */}
|
||||||
|
<circle cx="15" cy="10.5" r="0.8" fill="url(#nasGradient)" />
|
||||||
|
{/* HDD 슬롯 라인 Bay 1 */}
|
||||||
|
<line x1="6.5" y1="12.5" x2="9.5" y2="12.5" stroke="url(#nasGradient)" strokeWidth="0.6" opacity="0.6" />
|
||||||
|
<line x1="6.5" y1="14.5" x2="9.5" y2="14.5" stroke="url(#nasGradient)" strokeWidth="0.6" opacity="0.6" />
|
||||||
|
{/* HDD 슬롯 라인 Bay 2 */}
|
||||||
|
<line x1="14.5" y1="12.5" x2="17.5" y2="12.5" stroke="url(#nasGradient)" strokeWidth="0.6" opacity="0.6" />
|
||||||
|
<line x1="14.5" y1="14.5" x2="17.5" y2="14.5" stroke="url(#nasGradient)" strokeWidth="0.6" opacity="0.6" />
|
||||||
|
{/* 전원 버튼 */}
|
||||||
|
<circle cx="6" cy="6" r="0.6" stroke="url(#nasGradient)" strokeWidth="0.8" fill="none" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="sys-details">
|
||||||
|
<h3 className="logo-text">{stats?.system?.model || 'Synology NAS'}</h3>
|
||||||
|
<p className="sys-subtitle">시스템 모니터</p>
|
||||||
|
</div>
|
||||||
|
<div className="sys-meta">
|
||||||
|
<div className="meta-item">
|
||||||
|
<span className="label">OS</span>
|
||||||
|
<span className="value">{stats?.system?.os}</span>
|
||||||
|
</div>
|
||||||
|
<div className="meta-item">
|
||||||
|
<span className="label">Version</span>
|
||||||
|
<span className="value">{stats?.system?.release}</span>
|
||||||
|
</div>
|
||||||
|
<div className="meta-item">
|
||||||
|
<span className="label">Kernel</span>
|
||||||
|
<span className="value">{stats?.system?.kernel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="glass-panel error-banner" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid var(--error-color)', color: 'var(--error-color)', padding: '1rem', marginBottom: '1.5rem', borderRadius: '12px' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Row 2: CPU & Memory (Half Width Each)
|
||||||
|
*/}
|
||||||
|
<div className="resource-row">
|
||||||
|
{/* CPU Widget */}
|
||||||
|
<div className="glass-card resource-card">
|
||||||
|
<div className="card-header-simple">
|
||||||
|
<div className="header-left">
|
||||||
|
<Cpu size={20} className="card-icon" />
|
||||||
|
<h3>CPU</h3>
|
||||||
|
</div>
|
||||||
|
<span className="percentage-value" style={{ color: 'var(--cpu-color)' }}>{stats?.cpu?.load}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chartType === 'line' ? (
|
||||||
|
<div className="chart-wrapper">
|
||||||
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
|
<LineChart data={history}>
|
||||||
|
<Line type="monotone" dataKey="cpu" stroke="var(--cpu-color)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||||
|
<YAxis domain={[0, 100]} hide />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="progress-container">
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{
|
||||||
|
width: `${stats?.cpu?.load}%`,
|
||||||
|
backgroundColor: stats?.cpu?.load > 80 ? 'var(--error-color)' : 'var(--cpu-color)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Memory Widget */}
|
||||||
|
<div className="glass-card resource-card">
|
||||||
|
<div className="card-header-simple">
|
||||||
|
<div className="header-left">
|
||||||
|
<MemoryStick size={20} className="card-icon" />
|
||||||
|
<h3>메모리</h3>
|
||||||
|
</div>
|
||||||
|
<span className="percentage-value" style={{ color: 'var(--success-color)' }}>{stats?.memory?.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chartType === 'line' ? (
|
||||||
|
<div className="chart-wrapper">
|
||||||
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
|
<LineChart data={history}>
|
||||||
|
<Line type="monotone" dataKey="memory" stroke="var(--success-color)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||||
|
<YAxis domain={[0, 100]} hide />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="progress-container">
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{
|
||||||
|
width: `${stats?.memory?.percentage}%`,
|
||||||
|
backgroundColor: stats?.memory?.percentage > 80 ? 'var(--error-color)' : 'var(--success-color)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Row 3: Disk Usage
|
||||||
|
*/}
|
||||||
|
<div className="glass-card disk-card">
|
||||||
|
<div className="card-header-simple mb-3">
|
||||||
|
<div className="header-left">
|
||||||
|
<HardDrive size={20} className="card-icon" />
|
||||||
|
<h3>볼륨 사용량</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="disk-grid">
|
||||||
|
{stats?.storage?.length === 0 && <p className="no-data">볼륨 정보를 찾을 수 없습니다.</p>}
|
||||||
|
{stats?.storage?.map((disk, idx) => (
|
||||||
|
<div key={idx} className="disk-item">
|
||||||
|
<div className="disk-labels">
|
||||||
|
<span className="disk-name">{disk.mount}</span>
|
||||||
|
<span className="disk-stats">{(disk.used / 1024 / 1024 / 1024).toFixed(0)} GB / {(disk.size / 1024 / 1024 / 1024).toFixed(0)} GB</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress-container small">
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{
|
||||||
|
width: `${disk.use}%`,
|
||||||
|
backgroundColor: disk.use > 80 ? 'var(--error-color)' : 'var(--volume-color)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="disk-percent">{disk.use}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network Widget */}
|
||||||
|
<div className="glass-card network-card">
|
||||||
|
<div className="card-header-simple mb-3">
|
||||||
|
<div className="header-left">
|
||||||
|
<Network size={20} className="card-icon" />
|
||||||
|
<h3>네트워크 (Total)</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-wrapper" style={{ height: '200px', width: '100%' }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={history}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorRx" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--success-color)" stopOpacity={0.8} />
|
||||||
|
<stop offset="95%" stopColor="var(--success-color)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="colorTx" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--cpu-color)" stopOpacity={0.8} />
|
||||||
|
<stop offset="95%" stopColor="var(--cpu-color)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="rgba(255,255,255,0.1)" />
|
||||||
|
<XAxis dataKey="time" hide />
|
||||||
|
<YAxis
|
||||||
|
width={80}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
if (value < 1024) return `${value.toFixed(0)} B/s`;
|
||||||
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(0)} KB/s`;
|
||||||
|
return `${(value / 1024 / 1024).toFixed(1)} MB/s`;
|
||||||
|
}}
|
||||||
|
style={{ fontSize: '0.75rem', fill: 'var(--text-secondary)' }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: 'var(--card-bg)', borderColor: 'var(--glass-border)', color: 'var(--text-primary)' }}
|
||||||
|
formatter={(value, name) => {
|
||||||
|
let formatted;
|
||||||
|
if (value < 1024) formatted = `${value.toFixed(0)} B/s`;
|
||||||
|
else if (value < 1024 * 1024) formatted = `${(value / 1024).toFixed(1)} KB/s`;
|
||||||
|
else formatted = `${(value / 1024 / 1024).toFixed(2)} MB/s`;
|
||||||
|
return [formatted, name];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area name="Download" type="monotone" dataKey="rx" stroke="var(--success-color)" fillOpacity={1} fill="url(#colorRx)" strokeWidth={2} isAnimationActive={false} />
|
||||||
|
<Area name="Upload" type="monotone" dataKey="tx" stroke="var(--cpu-color)" fillOpacity={1} fill="url(#colorTx)" strokeWidth={2} isAnimationActive={false} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optional: Simple List below chart for current values per interface */}
|
||||||
|
<div className="network-list" style={{ marginTop: '1rem', borderTop: '1px solid var(--glass-border)', paddingTop: '1rem' }}>
|
||||||
|
{stats?.network?.filter(net => !hiddenInterfaces?.includes(net.iface)).map((net, idx) => {
|
||||||
|
const formatSpeed = (bytes) => {
|
||||||
|
if (!bytes) return '0 B/s';
|
||||||
|
if (bytes < 1024) return `${bytes.toFixed(0)} B/s`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB/s`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB/s`;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div key={idx} className="network-row-simple">
|
||||||
|
<span className="iface-name">{net.iface}</span>
|
||||||
|
<div className="io-stats">
|
||||||
|
<span style={{ color: 'var(--success-color)' }}>↓ {formatSpeed(net.rx_sec)}</span>
|
||||||
|
<span style={{ marginLeft: '1rem', color: 'var(--cpu-color)' }}>↑ {formatSpeed(net.tx_sec)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
/* System Info Card Update - Fix Dark Mode Issue */
|
||||||
|
.system-info-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
/* Remove hardcoded background to inherit glass-card style */
|
||||||
|
}
|
||||||
|
.sys-icon {
|
||||||
|
padding: 0.3rem;
|
||||||
|
background: rgba(255,255,255,0.1); /* More subtle */
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.sys-details {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.model-text-gradient {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.sys-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.meta-item .label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.meta-item .value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sys-meta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resource Row (CPU/Mem) */
|
||||||
|
.resource-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.card-header-simple {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.card-icon { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.percentage-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: width 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disk Card */
|
||||||
|
.mb-3 { margin-bottom: 1rem; }
|
||||||
|
.disk-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.disk-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr 50px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.disk-labels {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.disk-name { font-weight: 600; font-size: 0.95rem; }
|
||||||
|
.disk-stats { font-size: 0.8rem; color: var(--text-secondary); }
|
||||||
|
.disk-percent {
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.progress-container.small { height: 8px; }
|
||||||
|
|
||||||
|
/* Network */
|
||||||
|
.network-row-simple {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
.network-row-simple:last-child { border-bottom: none; }
|
||||||
|
.io-stats {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.resource-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.sys-card { flex-direction: column; align-items: flex-start; }
|
||||||
|
.sys-meta { width: 100%; justify-content: space-between; margin-top: 1rem; }
|
||||||
|
.disk-item { grid-template-columns: 1fr; gap: 0.5rem; }
|
||||||
|
.disk-percent { text-align: left; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
374
frontend/src/pages/DockerManager.jsx
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
Terminal as TerminalIcon,
|
||||||
|
MoreHorizontal,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCw,
|
||||||
|
FileText,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
Loader2
|
||||||
|
} from 'lucide-react';
|
||||||
|
import TerminalComponent from '../components/TerminalComponent';
|
||||||
|
|
||||||
|
const API_DOCKER_CONTAINERS = '/api/docker/containers';
|
||||||
|
|
||||||
|
const formatBytes = (bytes, decimals = 2) => {
|
||||||
|
if (!+bytes) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DockerManager = () => {
|
||||||
|
const [containers, setContainers] = useState([]);
|
||||||
|
// ... (rest of state items are same)
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [activeTerminal, setActiveTerminal] = useState(null); // containerId
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showLogs, setShowLogs] = useState(null); // containerId
|
||||||
|
const [logsContent, setLogsContent] = useState('');
|
||||||
|
const [actionLoading, setActionLoading] = useState(null); // containerId being acted upon
|
||||||
|
|
||||||
|
const fetchContainers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_DOCKER_CONTAINERS);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch containers');
|
||||||
|
const data = await response.json();
|
||||||
|
setContainers(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError('Could not load Docker containers.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ... (rest of useEffect and handlers existing)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchContainers();
|
||||||
|
// Poll for updates every 5 seconds to keep stats fresh
|
||||||
|
const interval = setInterval(fetchContainers, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Docker Actions
|
||||||
|
const handleContainerAction = async (id, action) => {
|
||||||
|
setActionLoading(id);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/docker/containers/${id}/${action}`, { method: 'POST' });
|
||||||
|
if (!response.ok) throw new Error(`Failed to ${action} container`);
|
||||||
|
await fetchContainers(); // Refresh list
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFetchLogs = async (id) => {
|
||||||
|
setLogsContent('Loading logs...');
|
||||||
|
setShowLogs(id);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/docker/containers/${id}/logs`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch logs');
|
||||||
|
const data = await response.text();
|
||||||
|
setLogsContent(data);
|
||||||
|
} catch (err) {
|
||||||
|
setLogsContent(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="docker-container">
|
||||||
|
{/* ... (header code) */}
|
||||||
|
<header className="page-header">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<h2>도커 컨테이너</h2>
|
||||||
|
</div>
|
||||||
|
{!activeTerminal && (
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||||
|
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Search size={16} style={{ position: 'absolute', left: '10px', color: 'var(--text-secondary)' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="검색..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem 0.5rem 2.2rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
color: 'inherit',
|
||||||
|
outline: 'none',
|
||||||
|
width: '200px',
|
||||||
|
fontSize: '0.9rem'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={fetchContainers} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<RefreshCw size={16} /> 새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="glass-panel error-banner" style={{ color: 'var(--error-color)', padding: '1rem', marginBottom: '1rem' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Terminal View */}
|
||||||
|
{activeTerminal ? (
|
||||||
|
<div className="terminal-view glass-panel" style={{ height: '70vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div className="terminal-header" style={{ padding: '0.5rem 1rem', borderBottom: '1px solid var(--glass-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>터미널 - {containers.find(c => c.Id === activeTerminal)?.Names[0] || activeTerminal}</span>
|
||||||
|
<button className="btn" onClick={() => setActiveTerminal(null)} style={{ background: 'rgba(255,255,255,0.1)' }}>닫기</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, position: 'relative' }}>
|
||||||
|
<TerminalComponent containerId={activeTerminal} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="containers-grid">
|
||||||
|
{loading && containers.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
gridColumn: '1 / -1',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '200px',
|
||||||
|
color: 'var(--text-secondary)'
|
||||||
|
}}>
|
||||||
|
<Loader2 className="spin-animation" size={48} />
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
.spin-animation { animation: spin 1s linear infinite; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
) : containers
|
||||||
|
.filter(c =>
|
||||||
|
c.Names[0].toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
c.Image.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
.map(container => (
|
||||||
|
<div key={container.Id} className="glass-card container-card">
|
||||||
|
<div className="card-header-compact">
|
||||||
|
<div className={`status-indicator ${container.State}`} />
|
||||||
|
<h3 className="container-name" title={container.Names[0]}>{container.Names[0].replace(/^\//, '')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="container-info">
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="label">이미지:</span>
|
||||||
|
<span className="value" title={container.Image}>{container.Image.length > 20 ? container.Image.substring(0, 20) + '...' : container.Image}</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="label">상태:</span>
|
||||||
|
<span className={`value state-${container.State}`}>{container.State}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Display */}
|
||||||
|
{container.stats && container.State === 'running' && (
|
||||||
|
<>
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="label">CPU:</span>
|
||||||
|
<span className="value" style={{ color: '#3b82f6', fontWeight: 'bold' }}>
|
||||||
|
{container.stats.cpu.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="label">Memory:</span>
|
||||||
|
<span className="value" style={{ color: '#3b82f6', fontWeight: 'bold' }}>
|
||||||
|
{formatBytes(container.stats.memory)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="card-actions">
|
||||||
|
{container.State === 'running' ? (
|
||||||
|
<>
|
||||||
|
<button className="btn-icon-action stop" title="중지" onClick={() => handleContainerAction(container.Id, 'stop')} disabled={actionLoading === container.Id}>
|
||||||
|
<Square size={18} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
<button className="btn-icon-action restart" title="재시작" onClick={() => handleContainerAction(container.Id, 'restart')} disabled={actionLoading === container.Id}>
|
||||||
|
<RotateCw size={18} />
|
||||||
|
</button>
|
||||||
|
<button className="btn-icon-action terminal" title="터미널" onClick={() => setActiveTerminal(container.Id)}>
|
||||||
|
<TerminalIcon size={18} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button className="btn-icon-action start" title="시작" onClick={() => handleContainerAction(container.Id, 'start')} disabled={actionLoading === container.Id}>
|
||||||
|
<Play size={18} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn-icon-action logs" title="로그" onClick={() => handleFetchLogs(container.Id)}>
|
||||||
|
<FileText size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logs Modal */}
|
||||||
|
{showLogs && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="glass-panel modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>로그</h3>
|
||||||
|
<button className="btn-icon" onClick={() => setShowLogs(null)}><X size={20} /></button>
|
||||||
|
</div>
|
||||||
|
<pre className="logs-viewer">
|
||||||
|
{logsContent}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.containers-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.container-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.card-header-compact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.status-indicator {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.status-indicator.running { background: var(--success-color); box-shadow: 0 0 8px var(--success-color); }
|
||||||
|
.status-indicator.exited { background: var(--error-color); }
|
||||||
|
|
||||||
|
.container-name {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.container-info {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.label { color: var(--text-secondary); }
|
||||||
|
.state-running { color: var(--success-color); }
|
||||||
|
.state-exited { color: var(--error-color); }
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
.btn-icon-action {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-icon-action:hover:not(:disabled) {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
.btn-icon-action:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-icon-action.start { color: var(--success-color); }
|
||||||
|
.btn-icon-action.stop { color: var(--error-color); }
|
||||||
|
.btn-icon-action.restart { color: var(--warning-color); }
|
||||||
|
.btn-icon-action.terminal { color: var(--accent-color); }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
width: 90vw;
|
||||||
|
max-width: 90vw;
|
||||||
|
height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.logs-viewer {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #f0f0f0;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
border-bottom-left-radius: 16px;
|
||||||
|
border-bottom-right-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DockerManager;
|
||||||
259
frontend/src/pages/History.jsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||||
|
import { History as HistoryIcon, Clock, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const History = () => {
|
||||||
|
const [data, setData] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [timeRange, setTimeRange] = useState('24h'); // '1h', '6h', '12h', '24h'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchHistory = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/history');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch history');
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
setLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('History fetch error:', err);
|
||||||
|
setError('Failed to load history data');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchHistory();
|
||||||
|
const interval = setInterval(fetchHistory, 60000); // 1 min update
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!data.length) return [];
|
||||||
|
const now = Date.now();
|
||||||
|
const ranges = {
|
||||||
|
'1h': 60 * 60 * 1000,
|
||||||
|
'6h': 6 * 60 * 60 * 1000,
|
||||||
|
'12h': 12 * 60 * 60 * 1000,
|
||||||
|
'24h': 24 * 60 * 60 * 1000,
|
||||||
|
'7d': 7 * 24 * 60 * 60 * 1000, // Add 7d support if retention allows
|
||||||
|
};
|
||||||
|
const rangeMs = ranges[timeRange] || ranges['24h'];
|
||||||
|
const cutoff = now - rangeMs;
|
||||||
|
|
||||||
|
return data
|
||||||
|
.filter(item => item.timestamp >= cutoff)
|
||||||
|
.map(item => ({
|
||||||
|
...item,
|
||||||
|
rx: item.network ? item.network.rx : 0,
|
||||||
|
tx: item.network ? item.network.tx : 0,
|
||||||
|
time: new Date(item.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}));
|
||||||
|
}, [data, timeRange]);
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div className="loading-container-center">
|
||||||
|
<Loader2 className="spin-animation" size={48} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatBytes = (bytes) => {
|
||||||
|
if (bytes === 0) return '0 B/s';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label, type }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="custom-tooltip" style={{ background: 'var(--card-bg)', border: '1px solid var(--glass-border)', padding: '10px', borderRadius: '8px' }}>
|
||||||
|
<p style={{ margin: '0 0 5px 0', fontWeight: 'bold', fontSize: '0.9rem' }}>{label}</p>
|
||||||
|
{payload.map((entry, index) => (
|
||||||
|
<p key={index} style={{ margin: 0, color: entry.color, fontSize: '0.85rem' }}>
|
||||||
|
{entry.name}: {type === 'network' ? formatBytes(entry.value) : `${entry.value}%`}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard-container">
|
||||||
|
<div className="glass-card full-width">
|
||||||
|
<div className="card-header-simple mb-3 history-header">
|
||||||
|
<div className="header-left">
|
||||||
|
<HistoryIcon size={24} className="card-icon" style={{ color: 'var(--accent-color)' }} />
|
||||||
|
<h3>시스템 히스토리</h3>
|
||||||
|
</div>
|
||||||
|
<div className="range-controls">
|
||||||
|
{['1h', '6h', '12h', '24h', '7d'].map(range => (
|
||||||
|
<button
|
||||||
|
key={range}
|
||||||
|
className={`range-btn ${timeRange === range ? 'active' : ''}`}
|
||||||
|
onClick={() => setTimeRange(range)}
|
||||||
|
>
|
||||||
|
{range}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="error-message">{error}</div>
|
||||||
|
) : (
|
||||||
|
<div className="charts-container">
|
||||||
|
<div className="chart-section">
|
||||||
|
<h4 className="chart-title"><span className="dot cpu"></span> CPU 사용량</h4>
|
||||||
|
<div style={{ width: '100%', height: 300 }}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<AreaChart data={filteredData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorCpu" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#3182ce" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#3182ce" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||||
|
<XAxis dataKey="time" stroke="var(--text-secondary)" tick={{ fill: 'var(--text-secondary)', fontSize: 12 }} minTickGap={30} />
|
||||||
|
<YAxis stroke="var(--text-secondary)" tick={{ fill: 'var(--text-secondary)', fontSize: 12 }} domain={[0, 100]} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Area type="monotone" dataKey="cpu" name="CPU" stroke="#3182ce" fillOpacity={1} fill="url(#colorCpu)" strokeWidth={2} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-section">
|
||||||
|
<h4 className="chart-title"><span className="dot mem"></span> 메모리 사용량</h4>
|
||||||
|
<div style={{ width: '100%', height: 300 }}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<AreaChart data={filteredData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorMem" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#38a169" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#38a169" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||||
|
<XAxis dataKey="time" stroke="var(--text-secondary)" tick={{ fill: 'var(--text-secondary)', fontSize: 12 }} minTickGap={30} />
|
||||||
|
<YAxis stroke="var(--text-secondary)" tick={{ fill: 'var(--text-secondary)', fontSize: 12 }} domain={[0, 100]} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Area type="monotone" dataKey="memory" name="Memory" stroke="#38a169" fillOpacity={1} fill="url(#colorMem)" strokeWidth={2} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-section">
|
||||||
|
<h4 className="chart-title">
|
||||||
|
<span className="dot" style={{ background: 'var(--success-color)' }}></span> Download
|
||||||
|
<span className="dot" style={{ background: 'var(--cpu-color)', marginLeft: '10px' }}></span> Upload
|
||||||
|
</h4>
|
||||||
|
<div style={{ width: '100%', height: 300 }}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<AreaChart data={filteredData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorRx" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--success-color)" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="var(--success-color)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="colorTx" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--cpu-color)" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="var(--cpu-color)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||||
|
<XAxis dataKey="time" stroke="var(--text-secondary)" tick={{ fill: 'var(--text-secondary)', fontSize: 12 }} minTickGap={30} />
|
||||||
|
<YAxis
|
||||||
|
stroke="var(--text-secondary)"
|
||||||
|
tick={{ fill: 'var(--text-secondary)', fontSize: 12 }}
|
||||||
|
tickFormatter={(val) => {
|
||||||
|
if (val === 0) return '0 B/s';
|
||||||
|
if (val < 1024) return `${val.toFixed(0)} B/s`;
|
||||||
|
if (val < 1024 * 1024) return `${(val / 1024).toFixed(0)} KB/s`;
|
||||||
|
return `${(val / 1024 / 1024).toFixed(1)} MB/s`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip type="network" />} />
|
||||||
|
<Area type="monotone" dataKey="rx" name="Download" stroke="var(--success-color)" fillOpacity={1} fill="url(#colorRx)" strokeWidth={2} />
|
||||||
|
<Area type="monotone" dataKey="tx" name="Upload" stroke="var(--cpu-color)" fillOpacity={1} fill="url(#colorTx)" strokeWidth={2} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.full-width { width: 100%; padding: 2rem; }
|
||||||
|
.dashboard-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
.history-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.range-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.range-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.range-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.05); }
|
||||||
|
.range-btn.active {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.charts-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.chart-title {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
||||||
|
.dot.cpu { background: #3182ce; }
|
||||||
|
.dot.mem { background: #38a169; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.full-width { padding: 1rem; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default History;
|
||||||
576
frontend/src/pages/Settings.jsx
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useSettings } from '../context/SettingsContext';
|
||||||
|
import {
|
||||||
|
BarChart2,
|
||||||
|
Activity,
|
||||||
|
Layout,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Network,
|
||||||
|
Clock,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
Bell,
|
||||||
|
Loader2
|
||||||
|
} from 'lucide-react';
|
||||||
|
import AlertSettingsModal from '../components/AlertSettingsModal';
|
||||||
|
|
||||||
|
const Settings = () => {
|
||||||
|
const { chartType, setChartType, dockerEnabled, refreshDockerConfig, hiddenInterfaces, toggleInterfaceVisibility, theme, toggleTheme } = useSettings();
|
||||||
|
const [interfaces, setInterfaces] = useState([]);
|
||||||
|
const [retentionHours, setRetentionHours] = useState(24);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch current interfaces
|
||||||
|
const fetchInterfaces = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/stats');
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.network) {
|
||||||
|
setInterfaces(data.network.map(n => n.iface));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch stats:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchInterfaces();
|
||||||
|
|
||||||
|
// Fetch retention settings
|
||||||
|
const fetchRetention = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/retention');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setRetentionHours(data.hours);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch retention settings:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchRetention();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveRetention = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/retention', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ hours: retentionHours })
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
alert('설정이 저장되었습니다.');
|
||||||
|
} else {
|
||||||
|
alert('설정 저장 실패');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save settings:', error);
|
||||||
|
alert('설정 저장 중 오류 발생');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [showAlertModal, setShowAlertModal] = useState(false);
|
||||||
|
const [passwordInput, setPasswordInput] = useState('');
|
||||||
|
const [showPasswordInput, setShowPasswordInput] = useState(false);
|
||||||
|
|
||||||
|
const handleDockerToggle = () => {
|
||||||
|
// Whether enabling or disabling, require password interaction
|
||||||
|
setShowPasswordInput(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDockerChange = async () => {
|
||||||
|
if (!passwordInput) return alert('비밀번호를 입력해주세요.');
|
||||||
|
|
||||||
|
const targetState = !dockerEnabled; // Toggle intent
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/docker/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled: targetState, password: passwordInput })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
await refreshDockerConfig();
|
||||||
|
setShowPasswordInput(false);
|
||||||
|
setPasswordInput('');
|
||||||
|
alert(`Docker 기능이 ${targetState ? '활성화' : '비활성화'}되었습니다.`);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.error || '설정 실패');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('서버 요청 실패');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-container .page-header h2 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.settings-container {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div className="settings-container">
|
||||||
|
<header className="page-header desktop-only">
|
||||||
|
<h1>설정</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="settings-grid">
|
||||||
|
{/* Theme Settings */}
|
||||||
|
<div className="glass-card settings-card">
|
||||||
|
<div className="card-header">
|
||||||
|
{theme === 'dark' ? <Moon className="card-icon" /> : <Sun className="card-icon" />}
|
||||||
|
<h3>테마 설정</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div className="setting-label">
|
||||||
|
<span className="setting-title">화면 모드</span>
|
||||||
|
<span className="setting-desc">라이트 모드와 다크 모드를 전환합니다.</span>
|
||||||
|
</div>
|
||||||
|
<div className="setting-control">
|
||||||
|
<button className="toggle-option active" onClick={toggleTheme} style={{ width: 'auto', padding: '0.5rem 1.5rem' }}>
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<>
|
||||||
|
<Moon size={16} /> 다크 모드
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sun size={16} /> 라이트 모드
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart Settings */}
|
||||||
|
<div className="glass-card settings-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<SettingsIcon className="card-icon" />
|
||||||
|
<h3>대시보드 설정</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div className="setting-label">
|
||||||
|
<span className="setting-title">차트 시각화</span>
|
||||||
|
<span className="setting-desc">시스템 리소스 표시 방식을 선택하세요.</span>
|
||||||
|
</div>
|
||||||
|
<div className="setting-control toggle-group">
|
||||||
|
<button
|
||||||
|
className={`toggle-option ${chartType === 'bar' ? 'active' : ''}`}
|
||||||
|
onClick={() => setChartType('bar')}
|
||||||
|
>
|
||||||
|
<BarChart2 size={16} /> 막대 차트
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`toggle-option ${chartType === 'line' ? 'active' : ''}`}
|
||||||
|
onClick={() => setChartType('line')}
|
||||||
|
>
|
||||||
|
<Activity size={16} /> 라인 차트
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Docker Security Settings */}
|
||||||
|
<div className="glass-card settings-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<Layout className="card-icon" />
|
||||||
|
<h3>Docker 보안 설정</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div className="setting-label">
|
||||||
|
<span className="setting-title">도커 관리 기능</span>
|
||||||
|
<span className="setting-desc">기능 활성화 시 비밀번호 설정이 필요합니다.</span>
|
||||||
|
</div>
|
||||||
|
<div className="setting-control column-control">
|
||||||
|
<div className="toggle-row">
|
||||||
|
<button
|
||||||
|
className={`toggle-switch ${dockerEnabled ? 'on' : 'off'}`}
|
||||||
|
onClick={handleDockerToggle}
|
||||||
|
>
|
||||||
|
<div className="knob" />
|
||||||
|
<span className="status-text">{dockerEnabled ? '활성화' : '비활성화'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Prompt */}
|
||||||
|
{showPasswordInput && (
|
||||||
|
<div className="password-setup-box">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder={dockerEnabled ? "비밀번호를 입력하여 비활성화" : "설정할 비밀번호 입력"}
|
||||||
|
value={passwordInput}
|
||||||
|
onChange={(e) => setPasswordInput(e.target.value)}
|
||||||
|
className="password-input"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="btn-row">
|
||||||
|
<button className="confirm-btn" onClick={confirmDockerChange}>
|
||||||
|
{dockerEnabled ? '해제 확인' : '활성화 확인'}
|
||||||
|
</button>
|
||||||
|
<button className="cancel-btn" onClick={() => { setShowPasswordInput(false); setPasswordInput(''); }}>취소</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network Interface Settings */}
|
||||||
|
<div className="glass-card settings-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<Network className="card-icon" />
|
||||||
|
<h3>네트워크 인터페이스</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div className="setting-label">
|
||||||
|
<span className="setting-title">인터페이스 표시 설정</span>
|
||||||
|
<span className="setting-desc">대시보드에 표시할 네트워크 인터페이스를 선택하세요.</span>
|
||||||
|
</div>
|
||||||
|
<div className="interfaces-grid">
|
||||||
|
{interfaces.map(iface => (
|
||||||
|
<button
|
||||||
|
key={iface}
|
||||||
|
className={`interface-toggle ${!hiddenInterfaces.includes(iface) ? 'active' : ''}`}
|
||||||
|
onClick={() => toggleInterfaceVisibility(iface)}
|
||||||
|
>
|
||||||
|
<span className="iface-name">{iface}</span>
|
||||||
|
<div className={`status-dot ${!hiddenInterfaces.includes(iface) ? 'active' : ''}`} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{interfaces.length === 0 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '1rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<Loader2 className="spin-animation" size={24} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notification Settings */}
|
||||||
|
<div className="glass-card settings-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<Bell className="card-icon" />
|
||||||
|
<h3>알림 설정</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div className="setting-label">
|
||||||
|
<span className="setting-title">푸시 알림 및 임계값 설정</span>
|
||||||
|
<span className="setting-desc">리소스 사용률 임계값과 컨테이너 종료 알림을 설정합니다.</span>
|
||||||
|
</div>
|
||||||
|
<div className="setting-control">
|
||||||
|
<button className="save-btn" onClick={() => setShowAlertModal(true)}>
|
||||||
|
설정 열기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alert Settings Modal */}
|
||||||
|
<AlertSettingsModal
|
||||||
|
isOpen={showAlertModal}
|
||||||
|
onClose={() => setShowAlertModal(false)}
|
||||||
|
onSave={() => {
|
||||||
|
// Optionally refresh settings or show success message
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Data Retention Settings */}
|
||||||
|
<div className="glass-card settings-card">
|
||||||
|
<div className="card-header">
|
||||||
|
<Clock className="card-icon" />
|
||||||
|
<h3>데이터 보관 설정</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-item">
|
||||||
|
<div className="setting-label">
|
||||||
|
<span className="setting-title">히스토리 보관 기간</span>
|
||||||
|
<span className="setting-desc">시스템 리소스 사용 기록을 보관할 기간을 설정합니다. (시간 단위)</span>
|
||||||
|
</div>
|
||||||
|
<div className="setting-control retention-control">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="168"
|
||||||
|
value={retentionHours}
|
||||||
|
onChange={(e) => setRetentionHours(parseInt(e.target.value) || 0)}
|
||||||
|
className="number-input"
|
||||||
|
/>
|
||||||
|
<span className="unit-text">시간</span>
|
||||||
|
<button
|
||||||
|
className="save-btn"
|
||||||
|
onClick={saveRetention}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.settings-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.card-icon { color: var(--accent-color); }
|
||||||
|
|
||||||
|
.setting-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.setting-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.setting-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.setting-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Group for Charts */
|
||||||
|
.toggle-group {
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.toggle-option {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-option.active {
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--accent-color);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch for Docker */
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 120px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.toggle-switch.on { background: var(--success-color); }
|
||||||
|
.toggle-switch.off { background: var(--text-secondary); }
|
||||||
|
|
||||||
|
.knob {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.toggle-switch.on .knob { transform: translateX(84px); }
|
||||||
|
.toggle-switch.off .knob { transform: translateX(0); }
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
position: absolute;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.toggle-switch.on .status-text { padding-right: 28px; }
|
||||||
|
.toggle-switch.off .status-text { padding-left: 28px; }
|
||||||
|
|
||||||
|
/* Interfaces Grid */
|
||||||
|
.interfaces-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.interface-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.interface-toggle:hover {
|
||||||
|
background: rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.interface-toggle.active {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
color: var(--accent-color);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
.iface-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.status-dot.active {
|
||||||
|
background: var(--success-color);
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 0 8px var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Retention Control */
|
||||||
|
.retention-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.number-input {
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
width: 80px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.unit-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.save-btn {
|
||||||
|
background: var(--accent-color);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.save-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.save-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password Setup UI */
|
||||||
|
.column-control {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.toggle-row { width: 100%; display: flex; align-items: center; }
|
||||||
|
.password-setup-box {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
.password-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.btn-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.confirm-btn {
|
||||||
|
background: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cancel-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-5px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
81
frontend/src/sw-custom.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { precacheAndRoute } from 'workbox-precaching';
|
||||||
|
import { registerRoute } from 'workbox-routing';
|
||||||
|
import { CacheFirst } from 'workbox-strategies';
|
||||||
|
|
||||||
|
// Precache all assets
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
|
||||||
|
// Cache Google Fonts
|
||||||
|
registerRoute(
|
||||||
|
/^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||||
|
new CacheFirst({
|
||||||
|
cacheName: 'google-fonts-cache',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== PUSH NOTIFICATION HANDLER =====
|
||||||
|
self.addEventListener('push', function (event) {
|
||||||
|
console.log('[SW] Push event received');
|
||||||
|
|
||||||
|
let notificationData = {
|
||||||
|
title: 'snStatus 알림',
|
||||||
|
body: '시스템 알림',
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
requireInteraction: true,
|
||||||
|
tag: 'snstatus-alert',
|
||||||
|
data: {
|
||||||
|
url: '/'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.data) {
|
||||||
|
try {
|
||||||
|
const data = event.data.json();
|
||||||
|
console.log('[SW] Push data:', data);
|
||||||
|
if (data.title) notificationData.title = data.title;
|
||||||
|
if (data.body) notificationData.body = data.body;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[SW] Parse error:', e);
|
||||||
|
notificationData.body = event.data.text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ALWAYS show notification, regardless of page visibility
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(notificationData.title, {
|
||||||
|
body: notificationData.body,
|
||||||
|
vibrate: notificationData.vibrate,
|
||||||
|
requireInteraction: notificationData.requireInteraction,
|
||||||
|
tag: notificationData.tag,
|
||||||
|
data: notificationData.data,
|
||||||
|
// Force notification to show even when page is visible
|
||||||
|
silent: false
|
||||||
|
}).then(() => {
|
||||||
|
console.log('[SW] Notification displayed successfully');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', function (event) {
|
||||||
|
console.log('[SW] Notification clicked');
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||||
|
.then(function (clientList) {
|
||||||
|
// Focus existing window if available
|
||||||
|
for (let i = 0; i < clientList.length; i++) {
|
||||||
|
const client = clientList[i];
|
||||||
|
if (client.url.includes('/') && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Open new window
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow('/');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[SW] Service Worker with push notification support loaded');
|
||||||
70
frontend/vite.config.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'src',
|
||||||
|
filename: 'sw-custom.js',
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
injectRegister: 'auto',
|
||||||
|
devOptions: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff,woff2}'],
|
||||||
|
// Simplified for faster builds
|
||||||
|
maximumFileSizeToCacheInBytes: 3000000
|
||||||
|
},
|
||||||
|
includeAssets: ['pwa-192x192.png', 'pwa-512x512.png'],
|
||||||
|
manifest: {
|
||||||
|
name: 'snStatus',
|
||||||
|
short_name: 'snStatus',
|
||||||
|
theme_color: '#1e3a8a',
|
||||||
|
background_color: '#1e3a8a', // Deep Navy for splash
|
||||||
|
display: 'standalone',
|
||||||
|
start_url: '/',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'pwa-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any maskable'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
// Optimize for faster builds on NAS
|
||||||
|
sourcemap: false, // Disable source maps
|
||||||
|
minify: 'esbuild', // Use faster esbuild minifier
|
||||||
|
target: 'es2015', // Less transformation needed
|
||||||
|
cssCodeSplit: false, // Single CSS file
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: undefined // Disable code splitting for faster build
|
||||||
|
}
|
||||||
|
},
|
||||||
|
chunkSizeWarningLimit: 2000 // Suppress warnings
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://backend:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
BIN
img/01.jpg
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
img/02.jpg
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
img/03.jpg
Normal file
|
After Width: | Height: | Size: 285 KiB |
BIN
img/04.jpg
Normal file
|
After Width: | Height: | Size: 351 KiB |
BIN
img/05.jpg
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
img/06.jpg
Normal file
|
After Width: | Height: | Size: 692 KiB |
BIN
img/07.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
img/08.png
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
img/09.png
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
img/10.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
img/11.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
img/12.png
Normal file
|
After Width: | Height: | Size: 317 KiB |