Compare commits

..

No commits in common. "dev" and "main" have entirely different histories.
dev ... main

121 changed files with 3573 additions and 573 deletions

6
.gitignore vendored
View File

@ -1,2 +1,4 @@
.idea/
log/
.idea
log
.DS_Store
config

View File

@ -1,25 +0,0 @@
package com.example.controller;
import com.example.entity.RestBean;
import com.example.entity.vo.request.RegisterServerVO;
import com.example.service.ServerService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/server")
public class ServerController {
@Resource
ServerService service;
@PostMapping("/register")
public RestBean<Void> register(@Valid @RequestBody RegisterServerVO vo) {
return service.registerServer(vo) ?
RestBean.success() : RestBean.failure(401, "注册码填写错误,注册主机失败");
}
}

View File

@ -1,46 +0,0 @@
package com.example.filter;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.example.utils.Const;
import com.example.utils.JwtUtils;
import jakarta.annotation.Resource;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 用于对请求头中Jwt令牌进行校验的工具为当前请求添加用户验证信息
* 并将用户的ID存放在请求对象属性中方便后续使用
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Resource
JwtUtils utils;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authorization = request.getHeader("Authorization");
DecodedJWT jwt = utils.resolveJwt(authorization);
if(jwt != null) {
UserDetails user = utils.toUser(jwt);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
request.setAttribute(Const.ATTR_USER_ID, utils.toId(jwt));
}
filterChain.doFilter(request, response);
}
}

View File

@ -1,9 +0,0 @@
package com.example.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.example.entity.dto.Server;
import com.example.entity.vo.request.RegisterServerVO;
public interface ServerService extends IService<Server> {
boolean registerServer(RegisterServerVO vo);
}

View File

@ -1,47 +0,0 @@
package com.example.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.entity.dto.Server;
import com.example.entity.dto.ServerInfo;
import com.example.entity.vo.request.RegisterServerVO;
import com.example.mapper.ServerInfoMapper;
import com.example.mapper.ServerMapper;
import com.example.service.ServerService;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.Random;
@Service
public class ServerServiceImpl extends ServiceImpl<ServerMapper, Server> implements ServerService {
@Mapper
ServerInfoMapper infoMapper;
@Override
public boolean registerServer(RegisterServerVO vo) {
if(this.verifyAccessToken(vo.getAccessToken())) {
int id = createRandomId();
Server server = new Server(id, "未命名服务器", new Date());
ServerInfo info = new ServerInfo();
BeanUtils.copyProperties(vo, info);
info.setId(id);
baseMapper.insert(server);
infoMapper.insert(info);
return true;
} else {
return false;
}
}
private int createRandomId() {
Random random = new Random();
return random.nextInt(900000) + 100000;
}
private boolean verifyAccessToken(String token) {
return "123456".equals(token);
}
}

View File

@ -1,13 +0,0 @@
package com.example;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class MyProjectBackendApplicationTests {
@Test
void contextLoads() {
}
}

View File

@ -1,36 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.2</version>
<version>3.2.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>itbaima-monitor-client</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>my-project-backend</name>
<description>my-project-backend</description>
<name>itbaima-monitor-client</name>
<description>itbaima-monitor-client</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.37</version>
</dependency>
<dependency>
<groupId>com.github.oshi</groupId>
@ -38,4 +47,22 @@
<version>6.4.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -1,11 +1,13 @@
package com.test;
package com.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MonitorClientApplication {
public static void main(String[] args) {
SpringApplication.run(MonitorClientApplication.class, args);
}
}

View File

@ -1,21 +1,15 @@
package com.test.config;
package com.example.config;
import com.test.task.MonitorJobBean;
import com.example.task.MonitorJobBean;
import lombok.extern.slf4j.Slf4j;
import org.quartz.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import oshi.SystemInfo;
@Slf4j
@Configuration
public class QuartzConfiguration {
@Bean
public SystemInfo info(){
return new SystemInfo();
}
@Bean
public JobDetail jobDetailFactoryBean() {
return JobBuilder.newJob(MonitorJobBean.class)
@ -26,10 +20,10 @@ public class QuartzConfiguration {
@Bean
public Trigger cronTriggerFactoryBean(JobDetail detail) {
CronScheduleBuilder cron= CronScheduleBuilder.cronSchedule("*/1 * * * * ?");
CronScheduleBuilder cron = CronScheduleBuilder.cronSchedule("*/10 * * * * ?");
return TriggerBuilder.newTrigger()
.forJob(detail)
.withIdentity("monitor-timer")
.withIdentity("monitor-trigger")
.withSchedule(cron)
.build();
}

View File

@ -0,0 +1,96 @@
package com.example.config;
import com.alibaba.fastjson2.JSONObject;
import com.example.entity.ConnectionConfig;
import com.example.utils.MonitorUtils;
import com.example.utils.NetUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Scanner;
@Slf4j
@Configuration
public class ServerConfiguration implements ApplicationRunner {
@Resource
NetUtils net;
@Resource
MonitorUtils monitor;
@Bean
ConnectionConfig connectionConfig() {
log.info("正在加载服务端连接配置...");
ConnectionConfig config = this.readConfigurationFromFile();
if(config == null)
config = this.registerToServer();
return config;
}
@Override
public void run(ApplicationArguments args) {
log.info("正在向服务端更新基本系统信息...");
net.updateBaseDetails(monitor.monitorBaseDetail());
}
private ConnectionConfig registerToServer() {
Scanner scanner = new Scanner(System.in);
String token, address, ifName;
do {
log.info("请输入需要注册的服务端访问地址,地址类似于 'http://192.168.0.22:8080' 这种写法:");
address = scanner.nextLine();
log.info("请输入服务端生成的用于注册客户端的Token秘钥:");
token = scanner.nextLine();
List<String> ifs = monitor.listNetworkInterfaceName();
if(ifs.size() > 1) {
log.info("检测到您的主机有多个网卡设备: {}", ifs);
do {
log.info("请选择需要监控的设备名称:");
ifName = scanner.nextLine();
} while (!ifs.contains(ifName));
} else {
ifName = ifs.get(0);
}
} while (!net.registerToServer(address, token));
ConnectionConfig config = new ConnectionConfig(address, token, ifName);
this.saveConfigurationToFile(config);
return config;
}
private void saveConfigurationToFile(ConnectionConfig config) {
File dir = new File("config");
if(!dir.exists() && dir.mkdir())
log.info("创建用于保存服务端连接信息的目录已完成");
File file = new File("config/server.json");
try(FileWriter writer = new FileWriter(file)) {
writer.write(JSONObject.from(config).toJSONString());
} catch (IOException e) {
log.error("保存配置文件时出现问题", e);
}
log.info("服务端连接信息已保存成功!");
}
private ConnectionConfig readConfigurationFromFile() {
File configurationFile = new File("config/server.json");
if(configurationFile.exists()) {
try (FileInputStream stream = new FileInputStream(configurationFile)){
String raw = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
return JSONObject.parseObject(raw).to(ConnectionConfig.class);
} catch (IOException e) {
log.error("读取配置文件时出错", e);
}
}
return null;
}
}

View File

@ -1,15 +1,16 @@
package com.test.entity;
package com.example.entity;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain = true)
public class SystemData {
public class BaseDetail {
String osArch;
String osName;
String osVersion;
int osBit;
String cpuName;
int cpuCore;
double memory;
double disk;

View File

@ -0,0 +1,12 @@
package com.example.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class ConnectionConfig {
String address;
String token;
String networkInterface;
}

View File

@ -0,0 +1,22 @@
package com.example.entity;
import com.alibaba.fastjson2.JSONObject;
public record Response(int id, int code, Object data, String message) {
public boolean success() {
return code == 200;
}
public JSONObject asJson() {
return JSONObject.from(data);
}
public String asString() {
return data.toString();
}
public static Response errorResponse(Exception e) {
return new Response(0, 500, null, e.getMessage());
}
}

View File

@ -0,0 +1,17 @@
package com.example.entity;
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain = true)
public class RuntimeDetail {
long timestamp;
double cpuUsage;
double memoryUsage;
double diskUsage;
double networkUpload;
double networkDownload;
double diskRead;
double diskWrite;
}

View File

@ -1,5 +1,9 @@
package com.test.task;
package com.example.task;
import com.example.entity.RuntimeDetail;
import com.example.utils.MonitorUtils;
import com.example.utils.NetUtils;
import jakarta.annotation.Resource;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.scheduling.quartz.QuartzJobBean;
@ -8,8 +12,15 @@ import org.springframework.stereotype.Component;
@Component
public class MonitorJobBean extends QuartzJobBean {
@Resource
MonitorUtils monitor;
@Resource
NetUtils net;
@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
RuntimeDetail runtimeDetail = monitor.monitorRuntimeDetail();
net.updateRuntimeDetails(runtimeDetail);
}
}

View File

@ -0,0 +1,131 @@
package com.example.utils;
import com.example.entity.BaseDetail;
import com.example.entity.ConnectionConfig;
import com.example.entity.RuntimeDetail;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import oshi.SystemInfo;
import oshi.hardware.CentralProcessor;
import oshi.hardware.HWDiskStore;
import oshi.hardware.HardwareAbstractionLayer;
import oshi.hardware.NetworkIF;
import oshi.software.os.OperatingSystem;
import java.io.File;
import java.io.IOException;
import java.util.*;
@Slf4j
@Component
public class MonitorUtils {
@Lazy
@Resource
ConnectionConfig config;
private final SystemInfo info = new SystemInfo();
private final Properties properties = System.getProperties();
public BaseDetail monitorBaseDetail() {
OperatingSystem os = info.getOperatingSystem();
HardwareAbstractionLayer hardware = info.getHardware();
double memory = hardware.getMemory().getTotal() / 1024.0 / 1024 /1024;
double diskSize = Arrays.stream(File.listRoots()).mapToLong(File::getTotalSpace).sum() / 1024.0 / 1024 / 1024;
String ip = Objects.requireNonNull(this.findNetworkInterface(hardware)).getIPv4addr()[0];
return new BaseDetail()
.setOsArch(properties.getProperty("os.arch"))
.setOsName(os.getFamily())
.setOsVersion(os.getVersionInfo().getVersion())
.setOsBit(os.getBitness())
.setCpuName(hardware.getProcessor().getProcessorIdentifier().getName())
.setCpuCore(hardware.getProcessor().getLogicalProcessorCount())
.setMemory(memory)
.setDisk(diskSize)
.setIp(ip);
}
public RuntimeDetail monitorRuntimeDetail() {
double statisticTime = 0.5;
try {
HardwareAbstractionLayer hardware = info.getHardware();
NetworkIF networkInterface = Objects.requireNonNull(this.findNetworkInterface(hardware));
CentralProcessor processor = hardware.getProcessor();
double upload = networkInterface.getBytesSent(), download = networkInterface.getBytesRecv();
double read = hardware.getDiskStores().stream().mapToLong(HWDiskStore::getReadBytes).sum();
double write = hardware.getDiskStores().stream().mapToLong(HWDiskStore::getWriteBytes).sum();
long[] ticks = processor.getSystemCpuLoadTicks();
Thread.sleep((long) (statisticTime * 1000));
networkInterface = Objects.requireNonNull(this.findNetworkInterface(hardware));
upload = (networkInterface.getBytesSent() - upload) / statisticTime;
download = (networkInterface.getBytesRecv() - download) / statisticTime;
read = (hardware.getDiskStores().stream().mapToLong(HWDiskStore::getReadBytes).sum() - read) / statisticTime;
write = (hardware.getDiskStores().stream().mapToLong(HWDiskStore::getWriteBytes).sum() - write) / statisticTime;
double memory = (hardware.getMemory().getTotal() - hardware.getMemory().getAvailable()) / 1024.0 / 1024 / 1024;
double disk = Arrays.stream(File.listRoots())
.mapToLong(file -> file.getTotalSpace() - file.getFreeSpace()).sum() / 1024.0 / 1024 / 1024;
return new RuntimeDetail()
.setCpuUsage(this.calculateCpuUsage(processor, ticks))
.setMemoryUsage(memory)
.setDiskUsage(disk)
.setNetworkUpload(upload / 1024)
.setNetworkDownload(download / 1024)
.setDiskRead(read / 1024/ 1024)
.setDiskWrite(write / 1024 / 1024)
.setTimestamp(new Date().getTime());
} catch (Exception e) {
log.error("读取运行时数据出现问题", e);
}
return null;
}
private double calculateCpuUsage(CentralProcessor processor, long[] prevTicks) {
long[] ticks = processor.getSystemCpuLoadTicks();
long nice = ticks[CentralProcessor.TickType.NICE.getIndex()]
- prevTicks[CentralProcessor.TickType.NICE.getIndex()];
long irq = ticks[CentralProcessor.TickType.IRQ.getIndex()]
- prevTicks[CentralProcessor.TickType.IRQ.getIndex()];
long softIrq = ticks[CentralProcessor.TickType.SOFTIRQ.getIndex()]
- prevTicks[CentralProcessor.TickType.SOFTIRQ.getIndex()];
long steal = ticks[CentralProcessor.TickType.STEAL.getIndex()]
- prevTicks[CentralProcessor.TickType.STEAL.getIndex()];
long cSys = ticks[CentralProcessor.TickType.SYSTEM.getIndex()]
- prevTicks[CentralProcessor.TickType.SYSTEM.getIndex()];
long cUser = ticks[CentralProcessor.TickType.USER.getIndex()]
- prevTicks[CentralProcessor.TickType.USER.getIndex()];
long ioWait = ticks[CentralProcessor.TickType.IOWAIT.getIndex()]
- prevTicks[CentralProcessor.TickType.IOWAIT.getIndex()];
long idle = ticks[CentralProcessor.TickType.IDLE.getIndex()]
- prevTicks[CentralProcessor.TickType.IDLE.getIndex()];
long totalCpu = cUser + nice + cSys + idle + ioWait + irq + softIrq + steal;
return (cSys + cUser) * 1.0 / totalCpu;
}
public List<String> listNetworkInterfaceName() {
HardwareAbstractionLayer hardware = info.getHardware();
return hardware.getNetworkIFs()
.stream()
.map(NetworkIF::getName)
.toList();
}
private NetworkIF findNetworkInterface(HardwareAbstractionLayer hardware) {
try {
String target = config.getNetworkInterface();
List<NetworkIF> ifs = hardware.getNetworkIFs()
.stream()
.filter(inter -> inter.getName().equals(target))
.toList();
if (!ifs.isEmpty()) {
return ifs.get(0);
} else {
throw new IOException("网卡信息错误,找不到网卡: " + target);
}
} catch (IOException e) {
log.error("读取网络接口信息时出错", e);
}
return null;
}
}

View File

@ -0,0 +1,87 @@
package com.example.utils;
import com.alibaba.fastjson2.JSONObject;
import com.example.entity.BaseDetail;
import com.example.entity.ConnectionConfig;
import com.example.entity.Response;
import com.example.entity.RuntimeDetail;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@Slf4j
@Component
public class NetUtils {
private final HttpClient client = HttpClient.newHttpClient();
@Lazy
@Resource
ConnectionConfig config;
public boolean registerToServer(String address, String token) {
log.info("正在像服务端注册,请稍后...");
Response response = this.doGet("/register", address, token);
if(response.success()) {
log.info("客户端注册已完成!");
} else {
log.error("客户端注册失败: {}", response.message());
}
return response.success();
}
private Response doGet(String url) {
return this.doGet(url, config.getAddress(), config.getToken());
}
private Response doGet(String url, String address, String token) {
try {
HttpRequest request = HttpRequest.newBuilder().GET()
.uri(new URI(address + "/monitor" + url))
.header("Authorization", token)
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
return JSONObject.parseObject(response.body()).to(Response.class);
} catch (Exception e) {
log.error("在发起服务端请求时出现问题", e);
return Response.errorResponse(e);
}
}
public void updateBaseDetails(BaseDetail detail) {
Response response = this.doPost("/detail", detail);
if(response.success()) {
log.info("系统基本信息已更新完成");
} else {
log.error("系统基本信息更新失败: {}", response.message());
}
}
public void updateRuntimeDetails(RuntimeDetail detail) {
Response response = this.doPost("/runtime", detail);
if(!response.success()) {
log.warn("更新运行时状态时,接收到服务端的异常响应内容: {}", response.message());
}
}
private Response doPost(String url, Object data) {
try {
String rawData = JSONObject.from(data).toJSONString();
HttpRequest request = HttpRequest.newBuilder().POST(HttpRequest.BodyPublishers.ofString(rawData))
.uri(new URI(config.getAddress() + "/monitor" + url))
.header("Authorization", config.getToken())
.header("Content-Type", "application/json")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
return JSONObject.parseObject(response.body()).to(Response.class);
} catch (Exception e) {
log.error("在发起服务端请求时出现问题", e);
return Response.errorResponse(e);
}
}
}

View File

@ -1,5 +0,0 @@
package com.test.util;
public interface DataGetter<T> {
T getData();
}

View File

@ -1,57 +0,0 @@
package com.test.util;
import com.test.entity.SystemData;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import oshi.SystemInfo;
import oshi.hardware.HWDiskStore;
import oshi.hardware.HardwareAbstractionLayer;
import oshi.hardware.NetworkIF;
import oshi.software.os.OperatingSystem;
import java.io.IOException;
import java.net.NetworkInterface;
import java.util.Properties;
@Slf4j
@Service
public class SystemDataGetter implements DataGetter<SystemData>{
@Resource
SystemInfo info;
private final Properties properties = System.getProperties();
public SystemData getData() {
OperatingSystem os = info.getOperatingSystem();
HardwareAbstractionLayer hardware = info.getHardware();
double memory = hardware.getMemory().getTotal() / 1024.0 / 1024 / 1024;
double diskSize = hardware.getDiskStores().stream().mapToLong(HWDiskStore::getSize).sum() / 1024.0 / 1024 / 1024;
try {
String ip = null;
for (NetworkIF network : hardware.getNetworkIFs()) {
String[] iPv4addr = network.getIPv4addr();
NetworkInterface ni = network.queryNetworkInterface();
if(!ni.isLoopback() && !ni.isPointToPoint() && ni.isUp() && !ni.isVirtual()
&& (ni.getName().startsWith("eth") || ni.getName().startsWith("en"))
&& iPv4addr.length > 0) {
ip = network.getIPv4addr()[0];
break;
}
}
return new SystemData()
.setOsBit(os.getBitness())
.setOsArch(properties.getProperty("os.arch"))
.setOsVersion(os.getVersionInfo().getVersion())
.setOsName(os.getFamily())
.setCpuCore(hardware.getProcessor().getLogicalProcessorCount())
.setMemory(memory)
.setDisk(diskSize)
.setIp(ip);
} catch (IOException e) {
log.error("读取系统网络配置时出错", e);
return null;
}
}
}

View File

@ -0,0 +1 @@

View File

@ -1,109 +0,0 @@
<script setup>
</script>
<template>
<div class="instance-card">
<div style="display: flex;justify-content: space-between">
<div>
<div class="title">
<span class="flag-icon flag-icon-cn"></span>
<span style="margin: 0 10px">柏码后端云服务器</span>
<i class="fa-solid fa-pen-to-square"></i>
</div>
<div class="os">
操作系统: Ubuntu 20.04
</div>
</div>
<div class="status">
<i style="color: #18cb18" class="fa-solid fa-circle-play"></i>
<span style="margin-left: 5px">运行中</span>
</div>
</div>
<el-divider style="margin: 10px 0"/>
<div class="network">
<span style="margin-right: 10px">公网IP: 192.168.0.10</span>
<i style="color: dodgerblue" class="fa-solid fa-copy"></i>
</div>
<div class="hardware">
<i class="fa-solid fa-microchip"></i>
<span style="margin-right: 10px"> 2 CPU</span>
<i class="fa-solid fa-memory"></i>
<span> 4 GB</span>
</div>
<el-divider style="margin: 10px 0"/>
<div class="progress">
<span>CPU: 2.5 %</span>
<el-progress :percentage="2.5" :stroke-width="5" :show-text="false"/>
</div>
<div class="progress" style="margin-top: 7px">
<span>内存: <b>1.2</b> GB</span>
<el-progress :percentage="1.2/4 * 100" :stroke-width="5" :show-text="false"/>
</div>
<div class="network-flow">
<div>网络流量</div>
<div>
<i class="fa-solid fa-arrow-up"></i>
<span> 52 KB/s</span>
<el-divider direction="vertical"/>
<i class="fa-solid fa-arrow-down"></i>
<span> 272 KB/s</span>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.el-progress-bar__outer) {
background-color: #18cb1822;
}
:deep(.el-progress-bar__inner) {
background-color: #18cb18;
}
.dark .instance-card { color: #d9d9d9
}
.instance-card {
width: 320px;
background-color: var(--el-bg-color);
border-radius: 5px;
padding: 15px;
box-sizing: border-box;
color: #6b6b6b;
.os {
font-size: 13px;
color: grey;
}
.status {
font-size: 14px;
}
.title {
font-size: 15px;
font-weight: bold;
}
.network {
font-size: 13px;
}
.hardware {
margin-top: 5px;
font-size: 13px;
}
.progress {
font-size: 12px;
}
.network-flow {
margin-top: 10px;
font-size: 12px;
display: flex;
justify-content: space-between;
}
}
</style>

View File

@ -1,97 +0,0 @@
<template>
<el-container class="main-container">
<el-header class="main-header">
<el-image style="height: 30px"
src="https://element-plus.org/images/element-plus-logo.svg"/>
<div class="tabs">
<tab-item v-for="item in tabs"
:name="item.name" :active="item.id === tab"
@click="changePage(item)"/>
</div>
<el-switch style="margin: 0 20px"
v-model="dark" active-color="#424242"
:active-action-icon="Moon"
:inactive-action-icon="Sunny"/>
<el-dropdown>
<el-avatar class="avatar"
src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"/>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="userLogout">
<el-icon><Back/></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<el-main class="main-content">
<router-view v-slot="{ Component }">
<transition name="el-fade-in-linear" mode="out-in">
<component :is="Component"/>
</transition>
</router-view>
</el-main>
</el-container>
</template>
<script setup>
import { logout } from '@/net'
import router from "@/router";
import {ref} from "vue";
import TabItem from "@/component/TabItem.vue";
import {Back, Moon, Sunny} from "@element-plus/icons-vue";
import {useDark} from "@vueuse/core";
function userLogout() {
logout(() => router.push("/"))
}
const dark = ref(useDark())
const tab = ref(1)
const tabs = [
{id: 1, name: '管理', route: 'manage'},
{id: 2, name: '安全', route: 'security'}
]
function changePage(item) {
tab.value = item.id
router.push({name: item.route})
}
</script>
<style lang="less" scoped>
.main-container {
height: 100vh;
width: 100vw;
.main-header {
height: 55px;
background-color: var(--el-bg-color);
border-bottom: solid 1px var(--el-border-color);
display: flex;
align-items: center;
.tabs {
height: 55px;
gap: 10px;
flex: 1;
display: flex;
align-items: center;
justify-content: right;
}
.avatar {
border: solid 1px var(--el-border-color);
}
}
.main-content {
height: 100%;
background-color: #f5f5f5;
}
}
.dark .main-content {
background-color: #232323;
}
</style>

View File

@ -1,33 +0,0 @@
<script setup>
import PreviewCard from "@/component/PreviewCard.vue";
</script>
<template>
<div class="manage-main">
<div class="title"><i class="fa-solid fa-server"></i> 管理主机列表</div>
<div class="description">在这里管理所有已经注册的主机实例实时监控主机运行状态快速进行管理和操作</div>
<el-divider style="margin: 10px 0"/>
<div style="display: flex;gap: 20px">
<preview-card/>
<preview-card/>
<preview-card/>
</div>
</div>
</template>
<style scoped>
.manage-main {
margin: 0 50px;
.title {
font-size: 22px;
font-weight: bold;
}
.description {
font-size: 15px;
color: grey;
}
}
</style>

View File

@ -1,11 +0,0 @@
<script setup>
</script>
<template>
</template>
<style scoped>
</style>

View File

@ -2,7 +2,9 @@
<module type="GENERAL_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/log" />
</content>
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -9,9 +9,9 @@
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>itbaima-monitor-backend</artifactId>
<artifactId>itbaima-monitor-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>my-project-backend</name>
<name>itbaima-monitor-server</name>
<description>my-project-backend</description>
<properties>
<java.version>17</java.version>
@ -94,6 +94,21 @@
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.1.0</version>
</dependency>
<!-- InfluxDB客户端框架 -->
<dependency>
<groupId>com.influxdb</groupId>
<artifactId>influxdb-client-java</artifactId>
<version>6.6.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
<version>0.1.55</version>
</dependency>
</dependencies>
<profiles>

View File

@ -4,10 +4,10 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MyProjectBackendApplication {
public class MonitorServerApplication {
public static void main(String[] args) {
SpringApplication.run(MyProjectBackendApplication.class, args);
SpringApplication.run(MonitorServerApplication.class, args);
}
}

View File

@ -53,9 +53,12 @@ public class SecurityConfiguration {
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(conf -> conf
.requestMatchers("/terminal/**").permitAll()
.requestMatchers("/api/auth/**", "/error").permitAll()
.requestMatchers("/monitor/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().hasAnyRole(Const.ROLE_DEFAULT)
.requestMatchers("/api/user/sub/**").hasRole(Const.ROLE_ADMIN)
.anyRequest().hasAnyRole(Const.ROLE_ADMIN, Const.ROLE_NORMAL)
)
.formLogin(conf -> conf
.loginProcessingUrl("/api/auth/login")

View File

@ -0,0 +1,13 @@
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfiguration {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}

View File

@ -38,7 +38,7 @@ public class AuthorizeController {
@GetMapping("/ask-code")
@Operation(summary = "请求邮件验证码")
public RestBean<Void> askVerifyCode(@RequestParam @Email String email,
@RequestParam @Pattern(regexp = "(reset)") String type,
@RequestParam @Pattern(regexp = "(reset|modify)") String type,
HttpServletRequest request){
return this.messageHandle(() ->
accountService.registerEmailVerifyCode(type, String.valueOf(email), request.getRemoteAddr()));

View File

@ -0,0 +1,39 @@
package com.example.controller;
import com.example.entity.RestBean;
import com.example.entity.dto.Client;
import com.example.entity.vo.request.ClientDetailVO;
import com.example.entity.vo.request.RuntimeDetailVO;
import com.example.service.ClientService;
import com.example.utils.Const;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/monitor")
public class ClientController {
@Resource
ClientService service;
@GetMapping("/register")
public RestBean<Void> registerClient(@RequestHeader("Authorization") String token) {
return service.verifyAndRegister(token) ?
RestBean.success() : RestBean.failure(401, "客户端注册失败请检查Token是否正确");
}
@PostMapping("/detail")
public RestBean<Void> updateClientDetails(@RequestAttribute(Const.ATTR_CLIENT) Client client,
@RequestBody @Valid ClientDetailVO vo) {
service.updateClientDetail(vo, client);
return RestBean.success();
}
@PostMapping("/runtime")
public RestBean<Void> updateRuntimeDetails(@RequestAttribute(Const.ATTR_CLIENT) Client client,
@RequestBody @Valid RuntimeDetailVO vo) {
service.updateRuntimeDetail(vo, client);
return RestBean.success();
}
}

View File

@ -0,0 +1,166 @@
package com.example.controller;
import com.example.entity.RestBean;
import com.example.entity.dto.Account;
import com.example.entity.vo.request.RenameClientVO;
import com.example.entity.vo.request.RenameNodeVO;
import com.example.entity.vo.request.RuntimeDetailVO;
import com.example.entity.vo.request.SshConnectionVO;
import com.example.entity.vo.response.*;
import com.example.service.AccountService;
import com.example.service.ClientService;
import com.example.utils.Const;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/monitor")
public class MonitorController {
@Resource
ClientService service;
@Resource
AccountService accountService;
@GetMapping("/list")
public RestBean<List<ClientPreviewVO>> listAllClient(@RequestAttribute(Const.ATTR_USER_ID) int userId,
@RequestAttribute(Const.ATTR_USER_ROLE) String userRole) {
List<ClientPreviewVO> clients = service.listClients();
if(this.isAdminAccount(userRole)) {
return RestBean.success(clients);
} else {
List<Integer> ids = this.accountAccessClients(userId);
return RestBean.success(clients.stream()
.filter(vo -> ids.contains(vo.getId()))
.toList());
}
}
@GetMapping("/simple-list")
public RestBean<List<ClientSimpleVO>> simpleClientList(@RequestAttribute(Const.ATTR_USER_ROLE) String userRole) {
if(this.isAdminAccount(userRole)) {
return RestBean.success(service.listSimpleList());
} else {
return RestBean.noPermission();
}
}
@PostMapping("/rename")
public RestBean<Void> renameClient(@RequestBody @Valid RenameClientVO vo,
@RequestAttribute(Const.ATTR_USER_ID) int userId,
@RequestAttribute(Const.ATTR_USER_ROLE) String userRole) {
if(this.permissionCheck(userId, userRole, vo.getId())) {
service.renameClient(vo);
return RestBean.success();
} else {
return RestBean.noPermission();
}
}
@PostMapping("/node")
public RestBean<Void> renameNode(@RequestBody @Valid RenameNodeVO vo,
@RequestAttribute(Const.ATTR_USER_ID) int userId,
@RequestAttribute(Const.ATTR_USER_ROLE) String userRole) {
if(this.permissionCheck(userId, userRole, vo.getId())) {
service.renameNode(vo);
return RestBean.success();
} else {
return RestBean.noPermission();
}
}
@GetMapping("/details")
public RestBean<ClientDetailsVO> details(int clientId,
@RequestAttribute(Const.ATTR_USER_ID) int userId,
@RequestAttribute(Const.ATTR_USER_ROLE) String userRole) {
if(this.permissionCheck(userId, userRole, clientId)) {
return RestBean.success(service.clientDetails(clientId));
} else {
return RestBean.noPermission();
}
}
@GetMapping("/runtime-history")
public RestBean<RuntimeHistoryVO> runtimeDetailsHistory(int clientId,
@RequestAttribute(Const.ATTR_USER_ID) int userId,
@RequestAttribute(Const.ATTR_USER_ROLE) String userRole) {
if(this.permissionCheck(userId, userRole, clientId)) {
return RestBean.success(service.clientRuntimeDetailsHistory(clientId));
} else {
return RestBean.noPermission();
}
}
@GetMapping("/runtime-now")
public RestBean<RuntimeDetailVO> runtimeDetailsNow(int clientId,
@RequestAttribute(Const.ATTR_USER_ID) int userId,
@RequestAttribute(Const.ATTR_USER_ROLE) String userRole) {
if(this.permissionCheck(userId, userRole, clientId)) {
return RestBean.success(service.clientRuntimeDetailsNow(clientId));
} else {
return RestBean.noPermission();
}
}
@GetMapping("/register")
public RestBean<String> registerToken(@RequestAttribute(Const.ATTR_USER_ROLE) String userRole) {
if (this.isAdminAccount(userRole)) {
return RestBean.success(service.registerToken());
} else {
return RestBean.noPermission();
}
}
@GetMapping("/delete")
public RestBean<String> deleteClient(int clientId,
@RequestAttribute(Const.ATTR_USER_ROLE) String userRole) {
if (this.isAdminAccount(userRole)) {
service.deleteClient(clientId);
return RestBean.success();
} else {
return RestBean.noPermission();
}
}
@PostMapping("/ssh-save")
public RestBean<Void> saveSshConnection(@RequestBody @Valid SshConnectionVO vo,
@RequestAttribute(Const.ATTR_USER_ID) int userId,
@RequestAttribute(Const.ATTR_USER_ROLE) String userRole) {
if(this.permissionCheck(userId, userRole, vo.getId())) {
service.saveClientSshConnection(vo);
return RestBean.success();
} else {
return RestBean.noPermission();
}
}
@GetMapping("/ssh")
public RestBean<SshSettingsVO> sshSettings(int clientId,
@RequestAttribute(Const.ATTR_USER_ID) int userId,
@RequestAttribute(Const.ATTR_USER_ROLE) String userRole) {
if(this.permissionCheck(userId, userRole, clientId)) {
return RestBean.success(service.sshSettings(clientId));
} else {
return RestBean.noPermission();
}
}
private List<Integer> accountAccessClients(int uid) {
Account account = accountService.getById(uid);
return account.getClientList();
}
private boolean isAdminAccount(String role) {
role = role.substring(5);
return Const.ROLE_ADMIN.equals(role);
}
private boolean permissionCheck(int uid, String role, int clientId) {
if(this.isAdminAccount(role)) return true;
return this.accountAccessClients(uid).contains(clientId);
}
}

View File

@ -0,0 +1,60 @@
package com.example.controller;
import com.example.entity.RestBean;
import com.example.entity.vo.request.ChangePasswordVO;
import com.example.entity.vo.request.CreateSubAccountVO;
import com.example.entity.vo.request.ModifyEmailVO;
import com.example.entity.vo.response.SubAccountVO;
import com.example.service.AccountService;
import com.example.utils.Const;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/user")
public class UserController {
@Resource
AccountService service;
@PostMapping("/change-password")
public RestBean<Void> changePassword(@RequestBody @Valid ChangePasswordVO vo,
@RequestAttribute(Const.ATTR_USER_ID) int userId) {
return service.changePassword(userId, vo.getPassword(), vo.getNew_password()) ?
RestBean.success() : RestBean.failure(401, "原密码输入错误!");
}
@PostMapping("/modify-email")
public RestBean<Void> modifyEmail(@RequestAttribute(Const.ATTR_USER_ID) int id,
@RequestBody @Valid ModifyEmailVO vo) {
String result = service.modifyEmail(id, vo);
if(result == null) {
return RestBean.success();
} else {
return RestBean.failure(401, result);
}
}
@PostMapping("/sub/create")
public RestBean<Void> createSubAccount(@RequestBody @Valid CreateSubAccountVO vo) {
service.createSubAccount(vo);
return RestBean.success();
}
@GetMapping("/sub/delete")
public RestBean<Void> deleteSubAccount(int uid,
@RequestAttribute(Const.ATTR_USER_ID) int userId) {
if(uid == userId)
return RestBean.failure(401, "非法参数");
service.deleteSubAccount(uid);
return RestBean.success();
}
@GetMapping("/sub/list")
public RestBean<List<SubAccountVO>> subAccountList() {
return RestBean.success(service.listSubAccount());
}
}

View File

@ -34,6 +34,10 @@ public record RestBean<T> (long id, int code, T data, String message) {
return new RestBean<>(requestId(), code, null, message);
}
public static <T> RestBean<T> noPermission() {
return new RestBean<>(requestId(), 401, null, "权限不足,拒绝访问");
}
/**
* 快速将当前实体转换为JSON字符串格式
* @return JSON字符串

View File

@ -1,5 +1,6 @@
package com.example.entity.dto;
import com.alibaba.fastjson2.JSONArray;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@ -7,7 +8,9 @@ import com.example.entity.BaseData;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.Collections;
import java.util.Date;
import java.util.List;
/**
* 数据库中的用户信息
@ -23,4 +26,10 @@ public class Account implements BaseData {
String email;
String role;
Date registerTime;
String clients;
public List<Integer> getClientList() {
if(clients == null) return Collections.emptyList();
return JSONArray.parse(clients).toList(Integer.class);
}
}

View File

@ -2,17 +2,21 @@ package com.example.entity.dto;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.example.entity.BaseData;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.Date;
@Data
@TableName("db_client")
@AllArgsConstructor
@TableName("db_server")
public class Server {
public class Client implements BaseData {
@TableId
int id;
Integer id;
String name;
Date time;
String token;
String location;
String node;
Date registerTime;
}

View File

@ -5,14 +5,15 @@ import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("db_server_info")
public class ServerInfo {
@TableName("db_client_detail")
public class ClientDetail {
@TableId
int id;
Integer id;
String osArch;
String osName;
String osVersion;
int osBit;
String cpuName;
int cpuCore;
double memory;
double disk;

View File

@ -0,0 +1,16 @@
package com.example.entity.dto;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.example.entity.BaseData;
import lombok.Data;
@Data
@TableName("db_client_ssh")
public class ClientSsh implements BaseData {
@TableId
Integer id;
Integer port;
String username;
String password;
}

View File

@ -0,0 +1,30 @@
package com.example.entity.dto;
import com.influxdb.annotations.Column;
import com.influxdb.annotations.Measurement;
import lombok.Data;
import java.time.Instant;
@Data
@Measurement(name = "runtime")
public class RuntimeData {
@Column(tag = true)
int clientId;
@Column(timestamp = true)
Instant timestamp;
@Column
double cpuUsage;
@Column
double memoryUsage;
@Column
double diskUsage;
@Column
double networkUpload;
@Column
double networkDownload;
@Column
double diskRead;
@Column
double diskWrite;
}

View File

@ -0,0 +1,12 @@
package com.example.entity.vo.request;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
@Data
public class ChangePasswordVO {
@Length(min = 6, max = 20)
String password;
@Length(min = 6, max = 20)
String new_password;
}

View File

@ -4,7 +4,7 @@ import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class RegisterServerVO {
public class ClientDetailVO {
@NotNull
String osArch;
@NotNull
@ -14,6 +14,8 @@ public class RegisterServerVO {
@NotNull
int osBit;
@NotNull
String cpuName;
@NotNull
int cpuCore;
@NotNull
double memory;
@ -21,6 +23,4 @@ public class RegisterServerVO {
double disk;
@NotNull
String ip;
@NotNull
String accessToken;
}

View File

@ -0,0 +1,20 @@
package com.example.entity.vo.request;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import java.util.List;
@Data
public class CreateSubAccountVO {
@Length(min = 1, max = 10)
String username;
@Email
String email;
@Length(min = 6, max = 20)
String password;
@Size(min = 1)
List<Integer> clients;
}

View File

@ -1,6 +1,7 @@
package com.example.entity.vo.request;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import org.hibernate.validator.constraints.Length;

View File

@ -0,0 +1,13 @@
package com.example.entity.vo.request;
import jakarta.validation.constraints.Email;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
@Data
public class ModifyEmailVO {
@Email
String email;
@Length(max = 6, min = 6)
String code;
}

View File

@ -0,0 +1,13 @@
package com.example.entity.vo.request;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
@Data
public class RenameClientVO {
@NotNull
int id;
@Length(min = 1, max = 10)
String name;
}

View File

@ -0,0 +1,14 @@
package com.example.entity.vo.request;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
@Data
public class RenameNodeVO {
int id;
@Length(min = 1, max = 10)
String node;
@Pattern(regexp = "(cn|hk|jp|us|sg|kr|de)")
String location;
}

View File

@ -0,0 +1,25 @@
package com.example.entity.vo.request;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class RuntimeDetailVO {
@NotNull
long timestamp;
@NotNull
double cpuUsage;
@NotNull
double memoryUsage;
@NotNull
double diskUsage;
@NotNull
double networkUpload;
@NotNull
double networkDownload;
@NotNull
double diskRead;
@NotNull
double diskWrite;
}

View File

@ -0,0 +1,17 @@
package com.example.entity.vo.request;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
@Data
public class SshConnectionVO {
int id;
int port;
@NotNull
@Length(min = 1)
String username;
@NotNull
@Length(min = 1)
String password;
}

View File

@ -10,6 +10,7 @@ import java.util.Date;
@Data
public class AuthorizeVO {
String username;
String email;
String role;
String token;
Date expire;

View File

@ -0,0 +1,19 @@
package com.example.entity.vo.response;
import lombok.Data;
@Data
public class ClientDetailsVO {
int id;
String name;
boolean online;
String node;
String location;
String ip;
String cpuName;
String osName;
String osVersion;
double memory;
int cpuCore;
double disk;
}

View File

@ -0,0 +1,21 @@
package com.example.entity.vo.response;
import lombok.Data;
@Data
public class ClientPreviewVO {
int id;
boolean online;
String name;
String location;
String osName;
String osVersion;
String ip;
String cpuName;
int cpuCore;
double memory;
double cpuUsage;
double memoryUsage;
double networkUpload;
double networkDownload;
}

View File

@ -0,0 +1,13 @@
package com.example.entity.vo.response;
import lombok.Data;
@Data
public class ClientSimpleVO {
int id;
String name;
String location;
String osName;
String osVersion;
String ip;
}

View File

@ -0,0 +1,14 @@
package com.example.entity.vo.response;
import com.alibaba.fastjson2.JSONObject;
import lombok.Data;
import java.util.LinkedList;
import java.util.List;
@Data
public class RuntimeHistoryVO {
double disk;
double memory;
List<JSONObject> list = new LinkedList<>();
}

View File

@ -0,0 +1,11 @@
package com.example.entity.vo.response;
import lombok.Data;
@Data
public class SshSettingsVO {
String ip;
int port = 22;
String username;
String password;
}

View File

@ -0,0 +1,12 @@
package com.example.entity.vo.response;
import com.alibaba.fastjson2.JSONArray;
import lombok.Data;
@Data
public class SubAccountVO {
int id;
String username;
String email;
JSONArray clientList;
}

View File

@ -0,0 +1,93 @@
package com.example.filter;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.example.entity.RestBean;
import com.example.entity.dto.Account;
import com.example.entity.dto.Client;
import com.example.service.AccountService;
import com.example.service.ClientService;
import com.example.utils.Const;
import com.example.utils.JwtUtils;
import jakarta.annotation.Resource;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.ArrayList;
/**
* 用于对请求头中Jwt令牌进行校验的工具为当前请求添加用户验证信息
* 并将用户的ID存放在请求对象属性中方便后续使用
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Resource
JwtUtils utils;
@Resource
ClientService service;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authorization = request.getHeader("Authorization");
String uri = request.getRequestURI();
if(uri.startsWith("/monitor")) {
if(!uri.endsWith("/register")) {
Client client = service.findClientByToken(authorization);
if(client == null) {
response.setStatus(401);
response.setCharacterEncoding("utf-8");
response.getWriter().write(RestBean.failure(401, "未注册").asJsonString());
return;
} else {
request.setAttribute(Const.ATTR_CLIENT, client);
}
}
} else {
DecodedJWT jwt = utils.resolveJwt(authorization);
if(jwt != null) {
UserDetails user = utils.toUser(jwt);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
request.setAttribute(Const.ATTR_USER_ID, utils.toId(jwt));
request.setAttribute(Const.ATTR_USER_ROLE, new ArrayList<>(user.getAuthorities()).get(0).getAuthority());
if(request.getRequestURI().startsWith("/terminal/") && !accessShell(
(int) request.getAttribute(Const.ATTR_USER_ID),
(String) request.getAttribute(Const.ATTR_USER_ROLE),
Integer.parseInt(request.getRequestURI().substring(10)))) {
response.setStatus(401);
response.setCharacterEncoding("utf-8");
response.getWriter().write(RestBean.failure(401, "无权访问").asJsonString());
return;
}
}
}
filterChain.doFilter(request, response);
}
@Resource
AccountService accountService;
private boolean accessShell(int userId, String userRole, int clientId) {
if(Const.ROLE_ADMIN.equals(userRole.substring(5))) {
return true;
} else {
Account account = accountService.getById(userId);
return account.getClientList().contains(clientId);
}
}
}

View File

@ -29,7 +29,8 @@ public class RequestLogFilter extends OncePerRequestFilter {
@Resource
SnowflakeIdGenerator generator;
private final Set<String> ignores = Set.of("/swagger-ui", "/v3/api-docs");
private final Set<String> ignores = Set.of("/swagger-ui", "/v3/api-docs", "/monitor/runtime",
"/api/monitor/list", "/api/monitor/runtime-now");
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

View File

@ -36,6 +36,10 @@ public class MailQueueListener {
createMessage("您的密码重置邮件",
"你好,您正在执行重置密码操作,验证码: "+code+"有效时间3分钟如非本人操作请无视。",
email);
case "modify" ->
createMessage("您的邮件修改验证邮件",
"您好,您正在绑定新的电子邮件地址,验证码: "+code+"有效时间3分钟如非本人操作请无视",
email);
default -> null;
};
if(message == null) return;

View File

@ -0,0 +1,9 @@
package com.example.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.entity.dto.ClientDetail;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ClientDetailMapper extends BaseMapper<ClientDetail> {
}

View File

@ -1,9 +1,9 @@
package com.example.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.entity.dto.Server;
import com.example.entity.dto.Client;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ServerMapper extends BaseMapper<Server> {
public interface ClientMapper extends BaseMapper<Client> {
}

View File

@ -1,9 +1,9 @@
package com.example.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.entity.dto.ServerInfo;
import com.example.entity.dto.ClientSsh;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ServerInfoMapper extends BaseMapper<ServerInfo> {
public interface ClientSshMapper extends BaseMapper<ClientSsh> {
}

View File

@ -3,12 +3,22 @@ package com.example.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.example.entity.dto.Account;
import com.example.entity.vo.request.ConfirmResetVO;
import com.example.entity.vo.request.CreateSubAccountVO;
import com.example.entity.vo.request.EmailResetVO;
import com.example.entity.vo.request.ModifyEmailVO;
import com.example.entity.vo.response.SubAccountVO;
import org.springframework.security.core.userdetails.UserDetailsService;
import java.util.List;
public interface AccountService extends IService<Account>, UserDetailsService {
Account findAccountByNameOrEmail(String text);
String registerEmailVerifyCode(String type, String email, String address);
String resetEmailAccountPassword(EmailResetVO info);
String resetConfirm(ConfirmResetVO info);
boolean changePassword(int id, String oldPass, String newPass);
void createSubAccount(CreateSubAccountVO vo);
void deleteSubAccount(int uid);
List<SubAccountVO> listSubAccount();
String modifyEmail(int id, ModifyEmailVO vo);
}

View File

@ -0,0 +1,27 @@
package com.example.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.example.entity.dto.Client;
import com.example.entity.vo.request.*;
import com.example.entity.vo.response.*;
import java.util.List;
public interface ClientService extends IService<Client> {
String registerToken();
Client findClientById(int id);
Client findClientByToken(String token);
boolean verifyAndRegister(String token);
void updateClientDetail(ClientDetailVO vo, Client client);
void updateRuntimeDetail(RuntimeDetailVO vo, Client client);
List<ClientPreviewVO> listClients();
List<ClientSimpleVO> listSimpleList();
void renameClient(RenameClientVO vo);
void renameNode(RenameNodeVO vo);
ClientDetailsVO clientDetails(int clientId);
RuntimeHistoryVO clientRuntimeDetailsHistory(int clientId);
RuntimeDetailVO clientRuntimeDetailsNow(int clientId);
void deleteClient(int clientId);
void saveClientSshConnection(SshConnectionVO vo);
SshSettingsVO sshSettings(int clientId);
}

View File

@ -1,10 +1,14 @@
package com.example.service.impl;
import com.alibaba.fastjson2.JSONArray;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.entity.dto.Account;
import com.example.entity.vo.request.ConfirmResetVO;
import com.example.entity.vo.request.CreateSubAccountVO;
import com.example.entity.vo.request.EmailResetVO;
import com.example.entity.vo.request.ModifyEmailVO;
import com.example.entity.vo.response.SubAccountVO;
import com.example.mapper.AccountMapper;
import com.example.service.AccountService;
import com.example.utils.Const;
@ -19,6 +23,8 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@ -116,6 +122,61 @@ public class AccountServiceImpl extends ServiceImpl<AccountMapper, Account> impl
return null;
}
@Override
public boolean changePassword(int id, String oldPass, String newPass) {
Account account = this.getById(id);
String password = account.getPassword();
if(!passwordEncoder.matches(oldPass, password))
return false;
this.update(Wrappers.<Account>update().eq("id", id)
.set("password", passwordEncoder.encode(newPass)));
return true;
}
@Override
public void createSubAccount(CreateSubAccountVO vo) {
Account account = this.findAccountByNameOrEmail(vo.getEmail());
if(account != null)
throw new IllegalArgumentException("该电子邮件已被注册");
account = this.findAccountByNameOrEmail(vo.getUsername());
if(account != null)
throw new IllegalArgumentException("该用户名已被注册");
account = new Account(null, vo.getUsername(), passwordEncoder.encode(vo.getPassword()),
vo.getEmail(), Const.ROLE_NORMAL, new Date(), JSONArray.copyOf(vo.getClients()).toJSONString());
this.save(account);
}
@Override
public void deleteSubAccount(int uid) {
this.removeById(uid);
}
@Override
public List<SubAccountVO> listSubAccount() {
return this.list(Wrappers.<Account>query().eq("role", Const.ROLE_NORMAL))
.stream().map(account -> {
SubAccountVO vo = account.asViewObject(SubAccountVO.class);
vo.setClientList(JSONArray.parse(account.getClients()));
return vo;
}).toList();
}
@Override
public String modifyEmail(int id, ModifyEmailVO vo) {
String code = getEmailVerifyCode(vo.getEmail());
if (code == null) return "请先获取验证码";
if(!code.equals(vo.getCode())) return "验证码错误,请重新输入";
this.deleteEmailVerifyCode(vo.getEmail());
Account account = this.findAccountByNameOrEmail(vo.getEmail());
if(account != null && account.getId() != id) return "该邮箱账号已经被其他账号绑定,无法完成操作";
this.update()
.set("email", vo.getEmail())
.eq("id", id)
.update();
return null;
}
/**
* 移除Redis中存储的邮件验证码
* @param email 电邮
@ -156,22 +217,4 @@ public class AccountServiceImpl extends ServiceImpl<AccountMapper, Account> impl
.eq("email", text)
.one();
}
/**
* 查询指定邮箱的用户是否已经存在
* @param email 邮箱
* @return 是否存在
*/
private boolean existsAccountByEmail(String email){
return this.baseMapper.exists(Wrappers.<Account>query().eq("email", email));
}
/**
* 查询指定用户名的用户是否已经存在
* @param username 用户名
* @return 是否存在
*/
private boolean existsAccountByUsername(String username){
return this.baseMapper.exists(Wrappers.<Account>query().eq("username", username));
}
}

View File

@ -0,0 +1,210 @@
package com.example.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.entity.dto.Client;
import com.example.entity.dto.ClientDetail;
import com.example.entity.dto.ClientSsh;
import com.example.entity.vo.request.*;
import com.example.entity.vo.response.*;
import com.example.mapper.ClientDetailMapper;
import com.example.mapper.ClientMapper;
import com.example.mapper.ClientSshMapper;
import com.example.service.ClientService;
import com.example.utils.InfluxDbUtils;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class ClientServiceImpl extends ServiceImpl<ClientMapper, Client> implements ClientService {
private String registerToken = this.generateNewToken();
private final Map<Integer, Client> clientIdCache = new ConcurrentHashMap<>();
private final Map<String, Client> clientTokenCache = new ConcurrentHashMap<>();
@Resource
ClientDetailMapper detailMapper;
@Resource
InfluxDbUtils influx;
@Resource
ClientSshMapper sshMapper;
@PostConstruct
public void initClientCache() {
clientTokenCache.clear();
clientIdCache.clear();
this.list().forEach(this::addClientCache);
}
@Override
public String registerToken() {
return registerToken;
}
@Override
public Client findClientById(int id) {
return clientIdCache.get(id);
}
@Override
public Client findClientByToken(String token) {
return clientTokenCache.get(token);
}
@Override
public boolean verifyAndRegister(String token) {
if (this.registerToken.equals(token)) {
int id = this.randomClientId();
Client client = new Client(id, "未命名主机", token, "cn", "未命名节点", new Date());
if (this.save(client)) {
registerToken = this.generateNewToken();
this.addClientCache(client);
return true;
}
}
return false;
}
@Override
public void updateClientDetail(ClientDetailVO vo, Client client) {
ClientDetail detail = new ClientDetail();
BeanUtils.copyProperties(vo, detail);
detail.setId(client.getId());
if(Objects.nonNull(detailMapper.selectById(client.getId()))) {
detailMapper.updateById(detail);
} else {
detailMapper.insert(detail);
}
}
private final Map<Integer, RuntimeDetailVO> currentRuntime = new ConcurrentHashMap<>();
@Override
public void updateRuntimeDetail(RuntimeDetailVO vo, Client client) {
currentRuntime.put(client.getId(), vo);
influx.writeRuntimeData(client.getId(), vo);
}
@Override
public List<ClientPreviewVO> listClients() {
return clientIdCache.values().stream().map(client -> {
ClientPreviewVO vo = client.asViewObject(ClientPreviewVO.class);
BeanUtils.copyProperties(detailMapper.selectById(vo.getId()), vo);
RuntimeDetailVO runtime = currentRuntime.get(client.getId());
if(this.isOnline(runtime)) {
BeanUtils.copyProperties(runtime, vo);
vo.setOnline(true);
}
return vo;
}).toList();
}
@Override
public List<ClientSimpleVO> listSimpleList() {
return clientIdCache.values().stream().map(client -> {
ClientSimpleVO vo = client.asViewObject(ClientSimpleVO.class);
BeanUtils.copyProperties(detailMapper.selectById(vo.getId()), vo);
return vo;
}).toList();
}
@Override
public void renameClient(RenameClientVO vo) {
this.update(Wrappers.<Client>update().eq("id", vo.getId()).set("name", vo.getName()));
this.initClientCache();
}
@Override
public void renameNode(RenameNodeVO vo) {
this.update(Wrappers.<Client>update().eq("id", vo.getId())
.set("node", vo.getNode()).set("location", vo.getLocation()));
this.initClientCache();
}
@Override
public ClientDetailsVO clientDetails(int clientId) {
ClientDetailsVO vo = this.clientIdCache.get(clientId).asViewObject(ClientDetailsVO.class);
BeanUtils.copyProperties(detailMapper.selectById(clientId), vo);
vo.setOnline(this.isOnline(currentRuntime.get(clientId)));
return vo;
}
@Override
public RuntimeHistoryVO clientRuntimeDetailsHistory(int clientId) {
RuntimeHistoryVO vo = influx.readRuntimeData(clientId);
ClientDetail detail = detailMapper.selectById(clientId);
BeanUtils.copyProperties(detail, vo);
return vo;
}
@Override
public RuntimeDetailVO clientRuntimeDetailsNow(int clientId) {
return currentRuntime.get(clientId);
}
@Override
public void deleteClient(int clientId) {
this.removeById(clientId);
detailMapper.deleteById(clientId);
this.initClientCache();
currentRuntime.remove(clientId);
}
@Override
public void saveClientSshConnection(SshConnectionVO vo) {
Client client = clientIdCache.get(vo.getId());
if(client == null) return;
ClientSsh ssh = new ClientSsh();
BeanUtils.copyProperties(vo, ssh);
if(Objects.nonNull(sshMapper.selectById(client.getId()))) {
sshMapper.updateById(ssh);
} else {
sshMapper.insert(ssh);
}
}
@Override
public SshSettingsVO sshSettings(int clientId) {
ClientDetail detail = detailMapper.selectById(clientId);
ClientSsh ssh = sshMapper.selectById(clientId);
SshSettingsVO vo;
if(ssh == null) {
vo = new SshSettingsVO();
} else {
vo = ssh.asViewObject(SshSettingsVO.class);
}
vo.setIp(detail.getIp());
return vo;
}
private boolean isOnline(RuntimeDetailVO runtime) {
return runtime != null && System.currentTimeMillis() - runtime.getTimestamp() < 60 * 1000;
}
private void addClientCache(Client client) {
clientIdCache.put(client.getId(), client);
clientTokenCache.put(client.getToken(), client);
}
private int randomClientId() {
return new Random().nextInt(90000000) + 10000000;
}
private String generateNewToken() {
String CHARACTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
SecureRandom random = new SecureRandom();
StringBuilder sb = new StringBuilder(24);
for (int i = 0; i < 24; i++)
sb.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length())));
return sb.toString();
}
}

View File

@ -7,6 +7,8 @@ public final class Const {
//JWT令牌
public final static String JWT_BLACK_LIST = "jwt:blacklist:";
public final static String JWT_FREQUENCY = "jwt:frequency:";
//用户
public final static String USER_BLACK_LIST = "user:blacklist:";
//请求频率限制
public final static String FLOW_LIMIT_COUNTER = "flow:counter:";
public final static String FLOW_LIMIT_BLOCK = "flow:block:";
@ -18,9 +20,11 @@ public final class Const {
public final static int ORDER_CORS = -102;
//请求自定义属性
public final static String ATTR_USER_ID = "userId";
public final static String ATTR_USER_ROLE = "userRole";
public final static String ATTR_CLIENT = "client";
//消息队列
public final static String MQ_MAIL = "mail";
//用户角色
public final static String ROLE_DEFAULT = "user";
public final static String ROLE_ADMIN = "admin";
public final static String ROLE_NORMAL = "user";
}

View File

@ -0,0 +1,74 @@
package com.example.utils;
import com.alibaba.fastjson2.JSONObject;
import com.example.entity.dto.RuntimeData;
import com.example.entity.vo.request.RuntimeDetailVO;
import com.example.entity.vo.response.RuntimeHistoryVO;
import com.influxdb.client.InfluxDBClient;
import com.influxdb.client.InfluxDBClientFactory;
import com.influxdb.client.WriteApiBlocking;
import com.influxdb.client.domain.WritePrecision;
import com.influxdb.query.FluxRecord;
import com.influxdb.query.FluxTable;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.List;
@Component
public class InfluxDbUtils {
@Value("${spring.influx.url}")
String url;
@Value("${spring.influx.user}")
String user;
@Value("${spring.influx.password}")
String password;
private final String BUCKET = "monitor";
private final String ORG = "itbaima";
private InfluxDBClient client;
@PostConstruct
public void init() {
client = InfluxDBClientFactory.create(url, user, password.toCharArray());
}
public void writeRuntimeData(int clientId, RuntimeDetailVO vo) {
RuntimeData data = new RuntimeData();
BeanUtils.copyProperties(vo, data);
data.setTimestamp(new Date(vo.getTimestamp()).toInstant());
data.setClientId(clientId);
WriteApiBlocking writeApi = client.getWriteApiBlocking();
writeApi.writeMeasurement(BUCKET, ORG, WritePrecision.NS, data);
}
public RuntimeHistoryVO readRuntimeData(int clientId) {
RuntimeHistoryVO vo = new RuntimeHistoryVO();
String query = """
from(bucket: "%s")
|> range(start: %s)
|> filter(fn: (r) => r["_measurement"] == "runtime")
|> filter(fn: (r) => r["clientId"] == "%s")
""";
String format = String.format(query, BUCKET, "-1h", clientId);
List<FluxTable> tables = client.getQueryApi().query(format, ORG);
int size = tables.size();
if (size == 0) return vo;
List<FluxRecord> records = tables.get(0).getRecords();
for (int i = 0; i < records.size(); i++) {
JSONObject object = new JSONObject();
object.put("timestamp", records.get(i).getTime());
for (int j = 0; j < size; j++) {
FluxRecord record = tables.get(j).getRecords().get(i);
object.put(record.getField(), record.getValue());
}
vo.getList().add(object);
}
return vo;
}
}

View File

@ -109,6 +109,7 @@ public class JwtUtils {
try {
DecodedJWT verify = jwtVerifier.verify(token);
if(this.isInvalidToken(verify.getId())) return null;
if(this.isInvalidUser(verify.getClaim("id").asInt())) return null;
Map<String, Claim> claims = verify.getClaims();
return new Date().after(claims.get("exp").asDate()) ? null : verify;
} catch (JWTVerificationException e) {
@ -177,6 +178,14 @@ public class JwtUtils {
return true;
}
public void deleteUser(int uid) {
template.opsForValue().set(Const.USER_BLACK_LIST + uid, "", expire, TimeUnit.HOURS);
}
private boolean isInvalidUser(int uid){
return Boolean.TRUE.equals(template.hasKey(Const.USER_BLACK_LIST + uid));
}
/**
* 验证Token是否被列入Redis黑名单
* @param uuid 令牌ID

View File

@ -0,0 +1,155 @@
package com.example.websocket;
import com.example.entity.dto.ClientDetail;
import com.example.entity.dto.ClientSsh;
import com.example.mapper.ClientDetailMapper;
import com.example.mapper.ClientSshMapper;
import com.jcraft.jsch.ChannelShell;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import jakarta.annotation.Resource;
import jakarta.websocket.*;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Slf4j
@Component
@ServerEndpoint("/terminal/{clientId}")
public class TerminalWebSocket {
private static ClientDetailMapper detailMapper;
@Resource
public void setDetailMapper(ClientDetailMapper detailMapper) {
TerminalWebSocket.detailMapper = detailMapper;
}
private static ClientSshMapper sshMapper;
@Resource
public void setSshMapper(ClientSshMapper sshMapper) {
TerminalWebSocket.sshMapper = sshMapper;
}
private static final Map<Session, Shell> sessionMap = new ConcurrentHashMap<>();
private final ExecutorService service = Executors.newSingleThreadExecutor();
@OnOpen
public void onOpen(Session session,
@PathParam(value = "clientId") String clientId) throws Exception {
ClientDetail detail = detailMapper.selectById(clientId);
ClientSsh ssh = sshMapper.selectById(clientId);
if(detail == null || ssh == null) {
session.close(new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT, "无法识别此主机"));
return;
}
if(this.createSshConnection(session, ssh, detail.getIp())) {
log.info("主机 {} 的SSH连接已创建", detail.getIp());
}
}
@OnMessage
public void onMessage(Session session, String message) throws IOException {
Shell shell = sessionMap.get(session);
OutputStream output = shell.output;
output.write(message.getBytes(StandardCharsets.UTF_8));
output.flush();
}
@OnClose
public void onClose(Session session) throws IOException {
Shell shell = sessionMap.get(session);
if(shell != null) {
shell.close();
sessionMap.remove(session);
log.info("主机 {} 的SSH连接已断开", shell.js.getHost());
}
}
@OnError
public void onError(Session session, Throwable error) throws IOException {
log.error("用户WebSocket连接出现错误", error);
session.close();
}
private boolean createSshConnection(Session session, ClientSsh ssh, String ip) throws IOException{
try {
JSch jSch = new JSch();
com.jcraft.jsch.Session js = jSch.getSession(ssh.getUsername(), ip, ssh.getPort());
js.setPassword(ssh.getPassword());
js.setConfig("StrictHostKeyChecking", "no");
js.setTimeout(3000);
js.connect();
ChannelShell channel = (ChannelShell) js.openChannel("shell");
channel.setPtyType("xterm");
channel.connect(1000);
sessionMap.put(session, new Shell(session, js, channel));
return true;
} catch (JSchException e) {
String message = e.getMessage();
if(message.equals("Auth fail")) {
session.close(new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT,
"登录SSH失败用户名或密码错误"));
log.error("连接SSH失败用户名或密码错误登录失败");
} else if(message.contains("Connection refused")) {
session.close(new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT,
"连接被拒绝可能是没有启动SSH服务或是放开端口"));
log.error("连接SSH失败连接被拒绝可能是没有启动SSH服务或是放开端口");
} else {
session.close(new CloseReason(CloseReason.CloseCodes.CANNOT_ACCEPT, message));
log.error("连接SSH时出现错误", e);
}
}
return false;
}
private class Shell {
private final Session session;
private final com.jcraft.jsch.Session js;
private final ChannelShell channel;
private final InputStream input;
private final OutputStream output;
public Shell(Session session, com.jcraft.jsch.Session js, ChannelShell channel) throws IOException {
this.js = js;
this.session = session;
this.channel = channel;
this.input = channel.getInputStream();
this.output = channel.getOutputStream();
service.submit(this::read);
}
private void read() {
try {
byte[] buffer = new byte[1024 * 1024];
int i;
while ((i = input.read(buffer)) != -1) {
String text = new String(Arrays.copyOfRange(buffer, 0, i), StandardCharsets.UTF_8);
session.getBasicRemote().sendText(text);
}
} catch (Exception e) {
log.error("读取SSH输入流时出现问题", e);
}
}
public void close() throws IOException {
input.close();
output.close();
channel.disconnect();
js.disconnect();
service.shutdown();
}
}
}

View File

@ -4,6 +4,10 @@ springdoc:
swagger-ui:
operations-sorter: alpha
spring:
influx:
url: http://localhost:8086
user: admin
password: 12345678
mail:
host: smtp.163.com
username: javastudy111@163.com
@ -32,8 +36,8 @@ spring:
verify:
mail-limit: 60
flow:
period: 3
limit: 50
period: 5
limit: 100
block: 30
cors:
origin: '*'

View File

@ -8,6 +8,10 @@ mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
spring:
influx:
url: http://localhost:8086
user: admin
password: 12345678
mail:
host: smtp.163.com
username: javastudy111@163.com
@ -36,8 +40,8 @@ spring:
verify:
mail-limit: 60
flow:
period: 3
limit: 10
period: 5
limit: 100
block: 30
cors:
origin: '*'

View File

@ -5,6 +5,5 @@
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="all" level="application" />
</component>
</module>

View File

@ -11,10 +11,15 @@
"@element-plus/icons-vue": "^2.1.0",
"@vueuse/core": "^10.3.0",
"axios": "^1.4.0",
"echarts": "^5.4.3",
"element-plus": "^2.3.9",
"flag-icon-css": "^4.1.7",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.0",
"vue": "^3.3.4",
"vue-router": "^4.2.4"
"vue-router": "^4.2.4",
"xterm": "^5.3.0",
"xterm-addon-attach": "^0.9.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
@ -868,6 +873,20 @@
"node": ">=0.4.0"
}
},
"node_modules/echarts": {
"version": "5.4.3",
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.4.3.tgz",
"integrity": "sha512-mYKxLxhzy6zyTi/FaEbJMOZU1ULGEQHaeIeuMR5L+JnJTpz+YR03mnnpBhbR4+UYJAgiXgpyTVLffPAjOTLkZA==",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.4.4"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"node_modules/element-plus": {
"version": "2.3.9",
"resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.3.9.tgz",
@ -1425,13 +1444,12 @@
}
},
"node_modules/needle": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/needle/-/needle-3.2.0.tgz",
"integrity": "sha512-oUvzXnyLiVyVGoianLijF9O/RecZUf7TkBfimjGrLM4eQhXyeJwM6GeAWccwfQ9aa4gMCZKqhAOuLaMIcQxajQ==",
"version": "3.3.1",
"resolved": "https://registry.npmmirror.com/needle/-/needle-3.3.1.tgz",
"integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==",
"dev": true,
"optional": true,
"dependencies": {
"debug": "^3.2.6",
"iconv-lite": "^0.6.3",
"sax": "^1.2.4"
},
@ -1442,16 +1460,6 @@
"node": ">= 4.4.x"
}
},
"node_modules/needle/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"dev": true,
"optional": true,
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
@ -1511,6 +1519,58 @@
"node": ">=6"
}
},
"node_modules/pinia": {
"version": "2.1.7",
"resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.1.7.tgz",
"integrity": "sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==",
"dependencies": {
"@vue/devtools-api": "^6.5.0",
"vue-demi": ">=0.14.5"
},
"peerDependencies": {
"@vue/composition-api": "^1.4.0",
"typescript": ">=4.4.4",
"vue": "^2.6.14 || ^3.3.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/pinia-plugin-persistedstate": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-3.2.0.tgz",
"integrity": "sha512-tZbNGf2vjAQcIm7alK40sE51Qu/m9oWr+rEgNm/2AWr1huFxj72CjvpQcIQzMknDBJEkQznCLAGtJTIcLKrKdw==",
"peerDependencies": {
"pinia": "^2.0.0"
}
},
"node_modules/pinia/node_modules/vue-demi": {
"version": "0.14.6",
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.6.tgz",
"integrity": "sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/pkg-types": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.0.3.tgz",
@ -1900,6 +1960,32 @@
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.5.0.tgz",
"integrity": "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==",
"dev": true
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmmirror.com/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg=="
},
"node_modules/xterm-addon-attach": {
"version": "0.9.0",
"resolved": "https://registry.npmmirror.com/xterm-addon-attach/-/xterm-addon-attach-0.9.0.tgz",
"integrity": "sha512-NykWWOsobVZPPK3P9eFkItrnBK9Lw0f94uey5zhqIVB1bhswdVBfl+uziEzSOhe2h0rT9wD0wOeAYsdSXeavPw==",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/zrender": {
"version": "5.4.4",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.4.4.tgz",
"integrity": "sha512-0VxCNJ7AGOMCWeHVyTrGzUgrK4asT4ml9PEkeGirAkKNYXYzoPJCLvmyfdoOXcjTHPs10OZVMfD1Rwg16AZyYw==",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
}
}
}

View File

@ -11,10 +11,15 @@
"@element-plus/icons-vue": "^2.1.0",
"@vueuse/core": "^10.3.0",
"axios": "^1.4.0",
"echarts": "^5.4.3",
"element-plus": "^2.3.9",
"flag-icon-css": "^4.1.7",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.0",
"vue": "^3.3.4",
"vue-router": "^4.2.4"
"vue-router": "^4.2.4",
"xterm": "^5.3.0",
"xterm-addon-attach": "^0.9.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,53 @@
.is-success {
.el-progress-bar__outer {
background-color: #18cb1822 !important;
}
.el-progress-bar__inner {
background-color: #18cb18 !important;
}
.el-progress-circle__track {
stroke: #18cb1822;
}
.el-progress-circle__path {
stroke: #18cb18 !important;
}
}
.is-warning {
.el-progress-bar__outer {
background-color: #ffa04622;
}
.el-progress-circle__track {
stroke: #ffa04622;
}
.el-progress-circle__path {
stroke: #ffa046 !important;
}
.el-progress-bar__inner {
background-color: #ffa046 !important;
}
}
.is-exception {
.el-progress-bar__outer {
background-color: #ef4e4e22;
}
.el-progress-circle__track {
stroke: #ef4e4e22;
}
.el-progress-circle__path {
stroke: #ef4e4e !important;
}
.el-progress-bar__inner {
background-color: #ef4e4e !important;
}
}

View File

@ -0,0 +1,278 @@
<script setup>
import {computed, reactive, watch} from "vue";
import {get, post} from "@/net";
import {copyIp, cpuNameToImage, fitByUnit, osNameToIcon, percentageToStatus, rename} from "@/tools";
import {ElMessage, ElMessageBox} from "element-plus";
import RuntimeHistory from "@/component/RuntimeHistory.vue";
import {Connection, Delete} from "@element-plus/icons-vue";
const locations = [
{name: 'cn', desc: '中国大陆'},
{name: 'hk', desc: '香港'},
{name: 'jp', desc: '日本'},
{name: 'us', desc: '美国'},
{name: 'sg', desc: '新加坡'},
{name: 'kr', desc: '韩国'},
{name: 'de', desc: '德国'}
]
const props = defineProps({
id: Number,
update: Function
})
const emits = defineEmits(['delete', 'terminal'])
const details = reactive({
base: {},
runtime: {
list: []
},
editNode: false
})
const nodeEdit = reactive({
name: '',
location: ''
})
const enableNodeEdit = () => {
details.editNode = true
nodeEdit.name = details.base.node
nodeEdit.location = details.base.location
}
const submitNodeEdit = () => {
post('/api/monitor/node', {
id: props.id,
node: nodeEdit.name,
location: nodeEdit.location
}, () => {
details.editNode = false
updateDetails()
ElMessage.success('节点信息已更新')
})
}
function deleteClient() {
ElMessageBox.confirm('删除此主机后所有统计数据都将丢失,您确定要这样做吗?', '删除主机', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
get(`/api/monitor/delete?clientId=${props.id}`, () => {
emits('delete')
props.update()
ElMessage.success('主机已成功移除')
})
}).catch(() => {})
}
function updateDetails() {
props.update()
init(props.id)
}
setInterval(() => {
if(props.id !== -1 && details.runtime) {
get(`/api/monitor/runtime-now?clientId=${props.id}`, data => {
if(details.runtime.list.length >= 360)
details.runtime.list.splice(0, 1)
details.runtime.list.push(data)
})
}
}, 10000)
const now = computed(() => details.runtime.list[details.runtime.list.length - 1])
const init = id => {
if(id !== -1) {
details.base = {}
details.runtime = { list: [] }
get(`/api/monitor/details?clientId=${id}`, data => Object.assign(details.base, data))
get(`/api/monitor/runtime-history?clientId=${id}`, data => Object.assign(details.runtime, data))
}
}
watch(() => props.id, init, { immediate: true })
</script>
<template>
<el-scrollbar>
<div class="client-details" v-loading="Object.keys(details.base).length === 0">
<div v-if="Object.keys(details.base).length">
<div style="display: flex;justify-content: space-between">
<div class="title">
<i class="fa-solid fa-server"></i>
服务器信息
</div>
<div>
<el-button :icon="Connection" type="info"
@click="emits('terminal', id)" plain text>SSH远程连接</el-button>
<el-button :icon="Delete" type="danger" style="margin-left: 0"
@click="deleteClient" plain text>删除此主机</el-button>
</div>
</div>
<el-divider style="margin: 10px 0"/>
<div class="details-list">
<div>
<span>服务器ID</span>
<span>{{details.base.id}}</span>
</div>
<div>
<span>服务器名称</span>
<span>{{details.base.name}}</span>&nbsp;
<i @click.stop="rename(details.base.id, details.base.name, updateDetails)"
class="fa-solid fa-pen-to-square interact-item"/>
</div>
<div>
<span>运行状态</span>
<span>
<i style="color: #18cb18" class="fa-solid fa-circle-play" v-if="details.base.online"></i>
<i style="color: #18cb18" class="fa-solid fa-circle-stop" v-else></i>
{{details.base.online ? '运行中' : '离线'}}
</span>
</div>
<div v-if="!details.editNode">
<span>服务器节点</span>
<span :class="`flag-icon flag-icon-${details.base.location}`"></span>&nbsp;
<span>{{details.base.node}}</span>&nbsp;
<i @click.stop="enableNodeEdit"
class="fa-solid fa-pen-to-square interact-item"/>
</div>
<div v-else>
<span>服务器节点</span>
<div style="display: inline-block;height: 15px">
<div style="display: flex">
<el-select v-model="nodeEdit.location" style="width: 80px" size="small">
<el-option v-for="item in locations" :value="item.name">
<span :class="`flag-icon flag-icon-${item.name}`"></span>&nbsp;
{{item.desc}}
</el-option>
</el-select>
<el-input v-model="nodeEdit.name" style="margin-left: 10px"
size="small" placeholder="请输入节点名称..."/>
<div style="margin-left: 10px">
<i @click.stop="submitNodeEdit" class="fa-solid fa-check interact-item"/>
</div>
</div>
</div>
</div>
<div>
<span>公网IP地址</span>
<span>
{{details.base.ip}}
<i class="fa-solid fa-copy interact-item" style="color: dodgerblue" @click.stop="copyIp(details.base.ip)"></i>
</span>
</div>
<div style="display: flex">
<span>处理器</span>
<span>{{details.base.cpuName}}</span>
<el-image style="height: 20px;margin-left: 10px"
:src="`/cpu-icons/${cpuNameToImage(details.base.cpuName)}`"/>
</div>
<div>
<span>硬件配置信息</span>
<span>
<i class="fa-solid fa-microchip"></i>
<span style="margin-right: 10px">{{` ${details.base.cpuCore} CPU 核心数 /`}}</span>
<i class="fa-solid fa-memory"></i>
<span>{{` ${details.base.memory.toFixed(1)} GB 内存容量`}}</span>
</span>
</div>
<div>
<span>操作系统</span>
<i :style="{color: osNameToIcon(details.base.osName).color}"
:class="`fa-brands ${osNameToIcon(details.base.osName).icon}`"></i>
<span style="margin-left: 10px">{{`${details.base.osName} ${details.base.osVersion}`}}</span>
</div>
</div>
<div class="title" style="margin-top: 20px">
<i class="fa-solid fa-gauge-high"></i>
实时监控
</div>
<el-divider style="margin: 10px 0"/>
<div v-if="details.base.online" v-loading="!details.runtime.list.length"
style="min-height: 200px">
<div style="display: flex" v-if="details.runtime.list.length">
<el-progress type="dashboard" :width="100" :percentage="now.cpuUsage * 100"
:status="percentageToStatus(now.cpuUsage * 100)">
<div style="font-size: 17px;font-weight: bold;color: initial">CPU</div>
<div style="font-size: 13px;color: grey;margin-top: 5px">{{ (now.cpuUsage * 100).toFixed(1) }}%</div>
</el-progress>
<el-progress style="margin-left: 20px" type="dashboard" :width="100"
:percentage="now.memoryUsage / details.runtime.memory * 100"
:status="percentageToStatus(now.memoryUsage / details.runtime.memory * 100)">
<div style="font-size: 16px;font-weight: bold;color: initial">内存</div>
<div style="font-size: 13px;color: grey;margin-top: 5px">{{ (now.memoryUsage).toFixed(1) }} GB</div>
</el-progress>
<div style="flex: 1;margin-left: 30px;display: flex;flex-direction: column;height: 80px">
<div style="flex: 1;font-size: 14px">
<div>实时网络速度</div>
<div>
<i style="color: orange" class="fa-solid fa-arrow-up"></i>
<span>{{` ${fitByUnit(now.networkUpload, 'KB')}/s`}}</span>
<el-divider direction="vertical"/>
<i style="color: dodgerblue" class="fa-solid fa-arrow-down"></i>
<span>{{` ${fitByUnit(now.networkDownload, 'KB')}/s`}}</span>
</div>
</div>
<div>
<div style="font-size: 13px;display: flex;justify-content: space-between">
<div>
<i class="fa-solid fa-hard-drive"></i>
<span> 磁盘总容量</span>
</div>
<div>{{now.diskUsage.toFixed(1)}} GB / {{details.runtime.disk.toFixed(1)}} GB</div>
</div>
<el-progress type="line" :show-text="false"
:status="percentageToStatus(now.diskUsage / details.runtime.disk * 100)"
:percentage="now.diskUsage / details.runtime.disk * 100" />
</div>
</div>
</div>
<runtime-history style="margin-top: 20px" :data="details.runtime.list"/>
</div>
<el-empty description="服务器处于离线状态,请检查服务器是否正常运行" v-else/>
</div>
</div>
</el-scrollbar>
</template>
<style scoped>
.interact-item {
transition: .3s;
&:hover {
cursor: pointer;
scale: 1.1;
opacity: 0.8;
}
}
.client-details {
height: 100%;
padding: 20px;
.title {
color: dodgerblue;
font-size: 18px;
font-weight: bold;
}
.details-list {
font-size: 14px;
& div {
margin-bottom: 10px;
& span:first-child {
color: gray;
font-size: 13px;
font-weight: normal;
width: 120px;
display: inline-block;
}
& span {
font-weight: bold;
}
}
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More