Parcourir la source

1, 优化原有webui代码js和go, 使其能够在暴露到公网低网速时正常打开和显示; 2, 编写app-install.service后台服务, 完成服务框架和部分rpc接口开发

niujiuru il y a 5 jours
Parent
commit
029a94da31

+ 9 - 1
app-install.service/main.go

@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"net/http"
 	"os"
+	"path/filepath"
 	"rtu_linux_services/servicelib"
 	"time"
 
@@ -25,6 +26,8 @@ const (
 	listenAddr = "127.0.0.1:7004"
 )
 
+var userAppDir = "" // 存储上传的用户应用安装包信息
+
 type program struct {
 	name        string
 	core_server *jsonrpc2.RPCServer
@@ -41,6 +44,11 @@ func (p *program) Start(s service.Service) error {
 	baseapp.InitLogger(logCfgFile)
 	baseapp.Logger.Infof("[%s] 开始运行, 程序版本: %s, 构建时间: %s", p.name, Version, BuildTime)
 
+	userAppDir = filepath.Join(baseapp.EXEC_DIR, "userapp")
+	if err := os.MkdirAll(userAppDir, 0755); err != nil {
+		return err
+	}
+
 	p.core_server, err = jsonrpc2.NewRPCServer(p.name, baseapp.Logger)
 	if err != nil {
 		return err
@@ -120,7 +128,7 @@ func (p *program) Stop(s service.Service) error {
 }
 
 func main() {
-	baseapp.SetOptDirs(true, false, true, false)
+	baseapp.SetOptDirs(true, false, false, false)
 	baseapp.InitPath()
 
 	svcFlag := flag.String("service", "", "Control the yfkj-app-install service.")

+ 320 - 13
app-install.service/rpc_handler.go

@@ -1,37 +1,344 @@
 package main
 
 import (
+	"archive/tar"
+	"compress/gzip"
 	"context"
+	"crypto/md5"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
 
+	"hnyfkj.com.cn/rtu/linux/baseapp"
 	"hnyfkj.com.cn/rtu/linux/utils/jsonrpc2"
 )
 
+type PkgInfo struct {
+	FileName    string `json:"fileName"`
+	FileSize    int64  `json:"fileSize"`
+	FileMD5Hash string `json:"fileHash"`
+	UploadTime  string `json:"uploadTime"`
+}
+
+type AppInfo struct {
+	Name        string `json:"name"`
+	Version     string `json:"version"`
+	InstallPath string `json:"installPath"`
+	LibraryPath string `json:"libraryPath"`
+	Executable  string `json:"executable"`
+	ReadmeFile  string `json:"readmeFile"`
+	Description string `json:"description"`
+}
+
+type InsInfo struct {
+	Installed   bool   `json:"installed"`
+	InstallTime string `json:"installTime"`
+}
+
+type AppIns struct {
+	Pkg PkgInfo `json:"pkg"` // 应用的安装包
+	App AppInfo `json:"app"` // 应用详细信息
+	Ins InsInfo `json:"ins"` // 应用是否安装
+}
+
+var appMutex sync.Mutex
+
+const ErrConflict jsonrpc2.ErrCode = -32099 // 资源访问冲突, 拒绝执行
+
 // 导入用户安装包
-func pkgImport(
-	ctx context.Context, req *jsonrpc2.Request) *jsonrpc2.Response {
-	return nil
+func pkgImport(ctx context.Context, req *jsonrpc2.Request) *jsonrpc2.Response {
+	appMutex.Lock()
+	defer appMutex.Unlock()
+
+	var params map[string]string
+
+	if err := json.Unmarshal(req.Params, &params); err != nil {
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInvalidParams, err.Error())
+	}
+
+	file, ok := params["file"]
+	if !ok || file == "" {
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInvalidParams, "missing or empty 'file' parameter")
+	}
+
+	if !strings.HasSuffix(strings.ToLower(filepath.Base(file)), ".tar.gz") {
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInvalidParams, "invalid install package format")
+	}
+
+	info, err := os.Stat(file)
+	if err != nil {
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInvalidParams, err.Error())
+	}
+
+	if !info.Mode().IsRegular() {
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInvalidParams, "invalid install package file")
+	}
+
+	if err := os.MkdirAll(userAppDir, 0755); err != nil {
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInternal, err.Error())
+	}
+
+	if hasImportedPackage(userAppDir) {
+		return jsonrpc2.BuildError(req, ErrConflict, "application package already exists")
+	}
+
+	app, err := readAppJSON(file)
+	if err != nil {
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInvalidParams, err.Error())
+	}
+
+	hash, err := fileMD5(file)
+	if err != nil {
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInternal, err.Error())
+	}
+
+	savePkgFile := filepath.Join(userAppDir, filepath.Base(file))
+	if err := os.Rename(file, savePkgFile); err != nil {
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInternal, err.Error())
+	}
+
+	appIns := AppIns{
+		Pkg: PkgInfo{
+			FileName:    filepath.Base(file),
+			FileSize:    info.Size(),
+			FileMD5Hash: hash,
+			UploadTime:  time.Now().Format("2006-01-02 15:04:05"),
+		},
+		App: app,
+		Ins: InsInfo{
+			Installed: false,
+		},
+	}
+
+	data, err := json.MarshalIndent(appIns, "", "  ")
+	if err != nil {
+		os.Remove(savePkgFile)
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInternal, err.Error())
+	}
+
+	appInsFile := filepath.Join(userAppDir, "appins.json")
+	if err := writeFileAtomic(appInsFile, data, 0644); err != nil {
+		os.Remove(savePkgFile)
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInternal, err.Error())
+	}
+
+	baseapp.Logger.Infof("[PkgImport] 导入用户应用安装包成功: %s", savePkgFile)
+
+	return jsonrpc2.BuildResponse(req, "success", nil)
 }
 
 // 删除用户安装包
-func pkgRemove(
-	ctx context.Context, req *jsonrpc2.Request) *jsonrpc2.Response {
-	return nil
+func pkgRemove(ctx context.Context, req *jsonrpc2.Request) *jsonrpc2.Response {
+	appMutex.Lock()
+	defer appMutex.Unlock()
+
+	appInsFile := filepath.Join(userAppDir, "appins.json")
+
+	data, err := os.ReadFile(appInsFile)
+	if err == nil {
+		var appIns AppIns
+		if err := json.Unmarshal(data, &appIns); err == nil {
+			if appIns.Ins.Installed { // 用户应用已安装
+				return jsonrpc2.BuildError(req, ErrConflict, "application is installed, please uninstall it first")
+			}
+		}
+	} else if !os.IsNotExist(err) {
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInternal, err.Error())
+	}
+
+	if err := os.RemoveAll(userAppDir); err != nil {
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInternal, err.Error())
+	}
+
+	if err := os.MkdirAll(userAppDir, 0755); err != nil {
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInternal, err.Error())
+	}
+
+	baseapp.Logger.Infof("[PkgRemove] 删除用户应用安装包成功")
+
+	return jsonrpc2.BuildResponse(req, "success", nil)
 }
 
 // 安装包应用安装
-func appInstall(
-	ctx context.Context, req *jsonrpc2.Request) *jsonrpc2.Response {
+func appInstall(ctx context.Context, req *jsonrpc2.Request) *jsonrpc2.Response {
+	appMutex.Lock()
+	defer appMutex.Unlock()
+
 	return nil
 }
 
 // 卸载已安装应用
-func appUninstall(
-	ctx context.Context, req *jsonrpc2.Request) *jsonrpc2.Response {
+func appUninstall(ctx context.Context, req *jsonrpc2.Request) *jsonrpc2.Response {
+	appMutex.Lock()
+	defer appMutex.Unlock()
+
 	return nil
 }
 
 // 安装包应用信息
-func getInstallInfo(
-	ctx context.Context, req *jsonrpc2.Request) *jsonrpc2.Response {
-	return nil
+func getInstallInfo(ctx context.Context, req *jsonrpc2.Request) *jsonrpc2.Response {
+	appInsFile := filepath.Join(userAppDir, "appins.json")
+
+	data, err := os.ReadFile(appInsFile)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return jsonrpc2.BuildResponse(req, nil, nil)
+		}
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInternal, err.Error())
+	}
+
+	var appIns AppIns
+	if err := json.Unmarshal(data, &appIns); err != nil {
+		return jsonrpc2.BuildError(req, jsonrpc2.ErrInternal, err.Error())
+	}
+
+	return jsonrpc2.BuildResponse(req, appIns, nil)
+}
+
+func readAppJSON(file string) (AppInfo, error) {
+	var app AppInfo
+
+	f, err := os.Open(file)
+	if err != nil {
+		return app, err
+	}
+	defer f.Close()
+
+	gz, err := gzip.NewReader(f)
+	if err != nil {
+		return app, err
+	}
+	defer gz.Close()
+
+	tr := tar.NewReader(gz)
+
+	for {
+		header, err := tr.Next()
+		if err == io.EOF {
+			break
+		}
+
+		if err != nil {
+			return app, err
+		}
+
+		if header.Typeflag != tar.TypeReg {
+			continue
+		}
+
+		if path.Base(header.Name) != "app.json" {
+			continue
+		}
+
+		if header.Size > 2<<20 { // 2MB 上限
+			return app, fmt.Errorf("app.json 文件过大")
+		}
+
+		data, err := io.ReadAll(io.LimitReader(tr, header.Size))
+		if err != nil {
+			return app, err
+		}
+
+		err = json.Unmarshal(data, &app)
+		if err != nil {
+			return app, err
+		}
+
+		if app.Name == "" || app.Version == "" || app.InstallPath == "" ||
+			app.Executable == "" || app.ReadmeFile == "" {
+			return app, fmt.Errorf("app.json 参数不完整")
+		}
+
+		if !filepath.IsAbs(app.InstallPath) {
+			return app, fmt.Errorf("installPath必须是绝对路径")
+		}
+
+		clean := filepath.Clean(app.InstallPath)
+		if clean != app.InstallPath {
+			return app, fmt.Errorf("installPath路径非法")
+		}
+
+		return app, nil
+	}
+
+	return app, fmt.Errorf("安装包中未找到 app.json")
+}
+
+func fileMD5(file string) (string, error) {
+	f, err := os.Open(file)
+	if err != nil {
+		return "", err
+	}
+	defer f.Close()
+
+	h := md5.New()
+	if _, err := io.Copy(h, f); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(h.Sum(nil)), nil
+}
+
+func isRegularFile(name string) bool {
+	info, err := os.Stat(name)
+	if err != nil {
+		return false
+	}
+	return info.Mode().IsRegular()
+}
+
+func hasImportedPackage(dir string) bool {
+	if isRegularFile(filepath.Join(dir, "appins.json")) {
+		return true
+	}
+
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return false
+	}
+
+	for _, entry := range entries {
+		if entry.IsDir() {
+			continue
+		}
+
+		if strings.HasSuffix(strings.ToLower(entry.Name()), ".tar.gz") {
+			return true
+		}
+	}
+
+	return false
+}
+
+func writeFileAtomic(name string, data []byte, perm os.FileMode) error {
+	tmp := name + ".tmp"
+
+	defer os.Remove(tmp)
+
+	f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
+	if err != nil {
+		return err
+	}
+
+	if _, err := f.Write(data); err != nil {
+		f.Close()
+		return err
+	}
+
+	if err := f.Sync(); err != nil {
+		f.Close()
+		return err
+	}
+
+	if err := f.Close(); err != nil {
+		return err
+	}
+
+	return os.Rename(tmp, name)
 }

+ 2 - 0
web-ui.service/main.go

@@ -78,6 +78,8 @@ func main() {
 	servicelib.WriteVersionFile(baseapp.EXEC_DIR, Version) //-> for 升级
 
 	initCPUUsage()
+	startSystemInfoCache()
+	startServiceCache()
 
 	prg := &program{
 		name: "WebUI",

+ 65 - 10
web-ui.service/page1_handler.go

@@ -19,17 +19,20 @@ var lastCPU struct {
 	mu          sync.Mutex
 }
 
+var systemInfoCache struct {
+	sync.RWMutex
+	data map[string]any
+}
+
 func initCPUUsage() {
 	getCPUUsage()
 }
 
-func systemInfoHandler(w http.ResponseWriter, r *http.Request) {
-	w.Header().Set("Content-Type", "application/json; charset=utf-8")
-
+func updateSystemInfoCache() {
 	memPercent, memUsed, memTotal := getMemInfo()
 	diskPercent, diskUsed, diskTotal := getDiskInfo()
 
-	json.NewEncoder(w).Encode(map[string]any{
+	data := map[string]any{
 		"cpu_usage": getCPUUsage(),
 		"cpu_cores": getCPUCore(),
 		"cpu_freq":  getCPUFreq(),
@@ -42,7 +45,33 @@ func systemInfoHandler(w http.ResponseWriter, r *http.Request) {
 		"disk_percent": diskPercent,
 		"disk_used":    diskUsed,
 		"disk_total":   diskTotal,
-	})
+	}
+
+	systemInfoCache.Lock()
+	systemInfoCache.data = data
+	systemInfoCache.Unlock()
+}
+
+func startSystemInfoCache() {
+	updateSystemInfoCache()
+	go func() {
+		ticker := time.NewTicker(5 * time.Second)
+		defer ticker.Stop()
+
+		for range ticker.C {
+			updateSystemInfoCache()
+		}
+	}()
+}
+
+func systemInfoHandler(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+
+	systemInfoCache.RLock()
+	data := systemInfoCache.data
+	systemInfoCache.RUnlock()
+
+	json.NewEncoder(w).Encode(data)
 }
 
 func getCPUUsage() int {
@@ -315,12 +344,12 @@ var services = []struct {
 	{"yfkj-local-startup.service", "/opt/yfkj/local-startup.service", "startup.sh"},
 }
 
-func serviceStatusHandler(w http.ResponseWriter, r *http.Request) {
-	if r.Method != http.MethodGet {
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-		return
-	}
+var serviceStatusCache struct {
+	sync.RWMutex
+	data map[string]string
+}
 
+func updateServiceStatus() {
 	data := make(map[string]string, len(services)*2)
 
 	for i, s := range services {
@@ -351,6 +380,32 @@ func serviceStatusHandler(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
+	serviceStatusCache.Lock()
+	serviceStatusCache.data = data
+	serviceStatusCache.Unlock()
+}
+
+func startServiceCache() {
+	updateServiceStatus()
+	go func() {
+		ticker := time.NewTicker(5 * time.Second)
+		defer ticker.Stop()
+		for range ticker.C {
+			updateServiceStatus()
+		}
+	}()
+}
+
+func serviceStatusHandler(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	serviceStatusCache.RLock()
+	data := serviceStatusCache.data
+	serviceStatusCache.RUnlock()
+
 	w.Header().Set("Content-Type", "application/json")
 	json.NewEncoder(w).Encode(data)
 }

+ 96 - 38
web-ui.service/web/static/js/page1.js

@@ -3,14 +3,22 @@ let memChart       = null;
 let diskChart      = null;
 let sysStatusTimer = null;
 let resizeHandler  = null;
+let sysAbortController = null;
 
 function getSystemInfo(){
-  fetch("/api/system/info")
+  sysAbortController?.abort();
+  sysAbortController = new AbortController();
+ 
+  fetch("/api/system/info", {signal:sysAbortController.signal} )
   .then(r=>{
     if(!r.ok) throw new Error("HTTP "+r.status);
     return r.json();
   })
   .then(d=>{
+    if(!cpuChart || !memChart || !diskChart){
+        return;
+    }
+
     cpuChart.setOption({
       graphic:[{
         type:"text",
@@ -22,7 +30,7 @@ function getSystemInfo(){
           fontSize:15
         }
       }],
-      series:[{data:[{value:d.cpu_usage}]}]
+      series:[{data:[{value:d.cpu_usage ?? 0}]}]
     });
 
     memChart.setOption({
@@ -36,7 +44,7 @@ function getSystemInfo(){
           fontSize:15
         }
       }],
-      series:[{data:[{value:d.mem_percent}]}]
+      series:[{data:[{value:d.mem_percent ?? 0}]}]
     });
 
     diskChart.setOption({
@@ -55,13 +63,17 @@ function getSystemInfo(){
       },
       series:[{
         data:[
-          {value:d.disk_percent,name:"已使用"},
+          {value:d.disk_percent ?? 0,name:"已使用"},
           {value:100-d.disk_percent,name:"剩余"}
         ]
       }]
     });
   })
-  .catch(e=>console.error("system info failed:",e));
+  .catch(e=>{
+    if(e.name !== "AbortError"){
+      console.error("system info failed:",e);
+    }
+  });
 }
 
 function gaugeOption(){
@@ -260,40 +272,58 @@ function switchAutoUpgrade(action)
   });
 }
 
+function initChart(id, option)
+{
+  let dom=document.getElementById(id);
+  if(!dom){
+    return null;
+  }
+
+  let chart=echarts.init(dom);
+  chart.setOption(option);
+
+  return chart;
+}
+
 function initPage1(){
-  cpuChart=echarts.init(document.getElementById("cpu-chart"));
-  memChart=echarts.init(document.getElementById("mem-chart"));
-  diskChart=echarts.init(document.getElementById("disk-chart"));
-
-  cpuChart.setOption(gaugeOption());
-  memChart.setOption(gaugeOption());
-
-  diskChart.setOption({
-    title:{
-      text:"0%",
-      subtext:"-- / --",
-      left:"center",
-      top:"38%",
-      textStyle:{
+  cpuChart = initChart(
+    "cpu-chart",
+    gaugeOption()
+  );
+
+  memChart = initChart(
+    "mem-chart",
+    gaugeOption()
+  );
+
+  diskChart = initChart(
+    "disk-chart",
+    {
+      title:
+      {
+        text:"0%",
+        subtext:"-- / --",
+        left:"center",
+        top:"38%",
+        textStyle:{
         fontSize:38,
         fontWeight:"bold"
+        },
+        subtextStyle:{
+          fontSize:15
+        }
       },
-      subtextStyle:{
-        fontSize:15
-      }
-    },
-    series:[{
-      type:"pie",
-      radius:["65%","85%"],
-      label:{show:false},
-      data:[
-        {value:0,name:"已使用"},
-        {value:100,name:"剩余"}
-      ]
-    }]
-  });
-
-  startSysStatusRefresh();
+      series:[{
+        type:"pie",
+        radius:["65%","85%"],
+        label:{show:false},
+        data:[
+          {value:0,name:"已使用"},
+          {value:100,name:"剩余"}
+        ]
+      }]
+    }
+  );
 
   resizeHandler = resizeSysCharts;
 
@@ -302,12 +332,40 @@ function initPage1(){
     resizeHandler
   );
 
-  setTimeout(()=>{
-    resizeSysCharts();
-  },100);
+  waitChartResize();
+
+  startSysStatusRefresh();
+}
+
+function waitChartResize(count=0){
+  resizeSysCharts();
+
+  let dom = document.getElementById("cpu-chart");
+  if(dom && dom.clientWidth > 0 && dom.clientHeight > 0){
+    return;
+  }
+
+  dom = document.getElementById("mem-chart");
+  if(dom && dom.clientWidth > 0 && dom.clientHeight > 0){
+    return;
+  }
+
+  dom = document.getElementById("disk-chart");
+  if(dom && dom.clientWidth > 0 && dom.clientHeight > 0){
+    return;
+  }
+
+  if(count < 50){
+    setTimeout(()=>{
+      waitChartResize(count + 1);
+    },100);
+  }
 }
 
 function exitPage1(){
+  sysAbortController?.abort();
+  sysAbortController=null;
+
   stopSysStatusRefresh();
 
   if(resizeHandler){

+ 54 - 2
web-ui.service/web_ui.go

@@ -1,10 +1,12 @@
 package main
 
 import (
+	"compress/gzip"
 	"context"
 	"embed"
 	"io/fs"
 	"net/http"
+	"strings"
 	"time"
 )
 
@@ -17,6 +19,56 @@ type WebUI struct {
 	server *http.Server
 }
 
+type gzipResponseWriter struct {
+	http.ResponseWriter
+	writer *gzip.Writer
+}
+
+func (w gzipResponseWriter) Write(b []byte) (int, error) {
+	return w.writer.Write(b)
+}
+
+func gzipHandler(h http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == http.MethodHead ||
+			!strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
+			h.ServeHTTP(w, r)
+			return
+		}
+
+		path := strings.ToLower(r.URL.Path)
+
+		if !(strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".js") ||
+			strings.HasSuffix(path, ".css")) {
+			h.ServeHTTP(w, r)
+			return
+		}
+
+		gz, err := gzip.NewWriterLevel(w, gzip.DefaultCompression)
+		if err != nil {
+			h.ServeHTTP(w, r)
+			return
+		}
+		defer gz.Close()
+
+		w.Header().Set("Content-Encoding", "gzip")
+		w.Header().Set("Vary", "Accept-Encoding")
+		w.Header().Del("Content-Length")
+
+		h.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, writer: gz}, r)
+	})
+}
+
+func staticHandler(h http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		path := r.URL.Path
+		if strings.HasPrefix(path, "/static/") {
+			w.Header().Set("Cache-Control", "public, max-age=86400")
+		}
+		h.ServeHTTP(w, r)
+	})
+}
+
 func NewWebUI(addr string) (*WebUI, error) {
 	sub, err := fs.Sub(webRootFS, "web")
 	if err != nil {
@@ -29,7 +81,7 @@ func NewWebUI(addr string) (*WebUI, error) {
 
 	mux.Handle(
 		"/static/",
-		static,
+		staticHandler(static),
 	)
 
 	registerRoutes(mux)
@@ -39,7 +91,7 @@ func NewWebUI(addr string) (*WebUI, error) {
 		mux: mux,
 		server: &http.Server{
 			Addr:    addr,
-			Handler: mux,
+			Handler: gzipHandler(mux),
 		},
 	}, nil
 }