├── .gitignore ├── screenshot.png ├── log_data.go ├── go.mod ├── Dockerfile ├── static ├── index.html ├── styles.css └── script.js ├── go.sum ├── .github └── workflows │ └── release.yml ├── LICENSE ├── README.md ├── main.go ├── pm2.go └── server.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | pm2-web -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doorbash/pm2-web/HEAD/screenshot.png -------------------------------------------------------------------------------- /log_data.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type LogData struct { 4 | Type string 5 | Data interface{} 6 | Time int64 7 | } 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/doorbash/pm2-web 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d 7 | github.com/gorilla/websocket v1.4.2 8 | github.com/jessevdk/go-flags v1.5.0 9 | ) 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | RUN apk add --update nodejs npm 3 | RUN npm i pm2 -g 4 | ADD pm2-web /pm2-web 5 | ADD static /static 6 | EXPOSE 3030 7 | CMD ["pm2-runtime", "--output", "/dev/stdout", "--error", "/dev/stderr", "./pm2-web", "--", "--time", "--app-name", "--actions", ":3030"] -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | PM2-Web 10 | 11 | 12 | 13 |
14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d h1:lBXNCxVENCipq4D1Is42JVOP4eQjlB8TQ6H69Yx5J9Q= 2 | github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= 3 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 4 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 5 | github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= 6 | github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= 7 | golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4 h1:EZ2mChiOa8udjfp6rRmswTbtZN/QzUQp4ptM4rnjHvc= 8 | golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 9 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | generate: 9 | name: Generate cross-platform builds 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout the repository 13 | uses: actions/checkout@v2 14 | 15 | - name: Generate build files 16 | uses: thatisuday/go-build-action@v1 17 | with: 18 | platforms: "linux/amd64, darwin/amd64, windows/amd64" 19 | name: "pm2-web" 20 | compress: "false" 21 | dest: "dist" 22 | 23 | - name: Upload build-artifacts 24 | uses: skx/github-action-publish-binaries@master 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | with: 28 | args: "./dist/*" 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Milad Doorbash 2 | 3 | MIT License: 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /static/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: black; 3 | color: #FFF; 4 | font-family: sans-serif; 5 | font-size: 16px; 6 | overflow-x: hidden; 7 | display: flex; 8 | flex-direction: column; 9 | margin: 0; 10 | padding: 0; 11 | } 12 | 13 | #logs { 14 | margin-top: 10px; 15 | margin-bottom: 0; 16 | margin-left: 10px; 17 | width: 100%; 18 | overflow-y: auto; 19 | position: absolute; 20 | top: 0; 21 | bottom: 0; 22 | left: 0; 23 | right: 0; 24 | } 25 | 26 | p.log { 27 | margin: 0px; 28 | display: block; 29 | font-family: monospace; 30 | white-space: pre; 31 | } 32 | 33 | #stats { 34 | margin-top: 10px; 35 | margin-bottom: 0; 36 | margin-left: 10px; 37 | margin-right: 0; 38 | overflow-y: auto; 39 | display: block; 40 | font-family: monospace; 41 | white-space: pre; 42 | } 43 | 44 | table { 45 | border-collapse: collapse; 46 | } 47 | 48 | td { 49 | padding-top: 4px; 50 | padding-bottom: 4px; 51 | padding-left: 8px; 52 | padding-right: 8px; 53 | border: 1px solid white; 54 | } 55 | 56 | .table_title { 57 | color: #55ffff; 58 | } 59 | 60 | .disable-scrollbars::-webkit-scrollbar { 61 | width: 0px; 62 | background: transparent; 63 | /* Chrome/Safari/Webkit */ 64 | } 65 | 66 | .disable-scrollbars { 67 | scrollbar-width: none; 68 | /* Firefox */ 69 | -ms-overflow-style: none; 70 | /* IE 10+ */ 71 | } 72 | 73 | .unselectable { 74 | -moz-user-select: -moz-none; 75 | -khtml-user-select: none; 76 | -webkit-user-select: none; 77 | -o-user-select: none; 78 | user-select: none; 79 | } 80 | 81 | .button { 82 | background-color: transparent; 83 | color: #00cc00; 84 | border: none; 85 | cursor: pointer; 86 | font-size: 16px; 87 | } 88 | 89 | .button:hover{ 90 | color: #00ff00; 91 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pm2-web 2 | A simple web based monitor for PM2 3 | 4 | 5 | 6 | ## Build 7 | ``` 8 | go build 9 | ``` 10 | 11 | ## Usage 12 | ``` 13 | ./pm2-web [OPTIONS] address 14 | ``` 15 | 16 | **Options:** 17 | ``` 18 | -u, --username= BasicAuth username 19 | -p, --password= BasicAuth password 20 | -l, --log-buffer-size= Log buffer size (default: 200) 21 | -i, --interval= PM2 process-list update interval in seconds (default: 10) 22 | --time Show log time 23 | --app-id Show app id 24 | --app-name Show app name 25 | --actions Show start, stop and restart buttons 26 | ``` 27 | 28 | ## Example 29 | 30 | ### Run without authentication: 31 | 32 | ``` 33 | ./pm2-web --time --app-name --actions localhost:3030 34 | ``` 35 | 36 | **or using PM2:** 37 | ``` 38 | pm2 start --name pm2-web ./pm2-web -- --time --app-name --actions localhost:3030 39 | ``` 40 | 41 | ### Run with authentication: 42 | 43 | ``` 44 | ./pm2-web -u admin -p 1234 --time --app-name --actions localhost:3030 45 | ``` 46 | 47 | **or using PM2:** 48 | ``` 49 | pm2 start --name pm2-web ./pm2-web -- -u admin -p 1234 --time --app-name --actions localhost:3030 50 | ``` 51 | 52 | ### Run behind reverse proxy: 53 | 54 | **Nginx configuration:** 55 | ``` 56 | server { 57 | listen 80; 58 | listen 443 ssl; 59 | server_name yourdomain.com; 60 | 61 | ssl_certificate /path/to/your/cert.crt; 62 | ssl_certificate_key /path/to/your/cert.key; 63 | 64 | location /pm2/logs { 65 | proxy_pass http://127.0.0.1:3030/logs; 66 | proxy_http_version 1.1; 67 | proxy_set_header Upgrade $http_upgrade; 68 | proxy_set_header Connection "Upgrade"; 69 | proxy_set_header Host $host; 70 | } 71 | 72 | location /pm2/action { 73 | proxy_pass http://127.0.0.1:3030/action; 74 | } 75 | 76 | location /pm2/ { 77 | rewrite ^/pm2/(.*)$ /$1 break; 78 | proxy_pass http://127.0.0.1:3030; 79 | proxy_set_header Host $host; 80 | } 81 | 82 | location /pm2 { 83 | rewrite ^/pm2$ /pm2/ redirect; 84 | } 85 | } 86 | ``` 87 | 88 | ## Licecnse 89 | MIT 90 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "container/list" 5 | "fmt" 6 | "log" 7 | "os" 8 | "time" 9 | 10 | "github.com/jessevdk/go-flags" 11 | ) 12 | 13 | type Options struct { 14 | Username string `short:"u" long:"username" description:"BasicAuth username" required:"false" default:""` 15 | Password string `short:"p" long:"password" description:"BasicAuth password" required:"false" default:""` 16 | LogBufferSize int `short:"l" long:"log-buffer-size" description:"Log buffer size" required:"false" default:"200"` 17 | Interval int `short:"i" long:"interval" description:"PM2 process-list update interval in seconds" required:"false" default:"10"` 18 | TimeEnabled bool `long:"time" description:"Show log time" required:"false"` 19 | AppIdEnabled bool `long:"app-id" description:"Show app id" required:"false"` 20 | AppNameEnabled bool `long:"app-name" description:"Show app name" required:"false"` 21 | ActionsEnabled bool `long:"actions" description:"Show start, stop and restart buttons"` 22 | } 23 | 24 | func (o *Options) Valid() bool { 25 | if o.LogBufferSize < 0 { 26 | return false 27 | } 28 | if opts.Interval < 0 { 29 | return false 30 | } 31 | return true 32 | } 33 | 34 | var ( 35 | opts Options 36 | newClientsChan chan chan LogData = make(chan chan LogData, 100) 37 | removedClientsChan chan chan LogData = make(chan chan LogData, 100) 38 | logsChan chan LogData = make(chan LogData, 100) 39 | statsChan chan LogData = make(chan LogData) 40 | logBuffer *list.List = list.New() 41 | stats LogData 42 | ) 43 | 44 | func main() { 45 | parser := flags.NewParser(&opts, flags.Default) 46 | 47 | parser.Usage = "[OPTIONS] address" 48 | 49 | args, err := parser.Parse() 50 | 51 | if err != nil { 52 | log.Fatalln(err) 53 | } 54 | 55 | if len(args) == 0 { 56 | parser.WriteHelp(os.Stdout) 57 | return 58 | } 59 | 60 | if !opts.Valid() { 61 | log.Fatalln("bad options") 62 | } 63 | 64 | go func() { 65 | var clients map[chan LogData]bool = make(map[chan LogData]bool) 66 | for { 67 | select { 68 | case client := <-newClientsChan: 69 | clients[client] = true 70 | if stats.Type != "" { 71 | select { 72 | case client <- stats: 73 | default: 74 | } 75 | } 76 | for e := logBuffer.Front(); e != nil; e = e.Next() { 77 | select { 78 | case client <- e.Value.(LogData): 79 | default: 80 | } 81 | } 82 | fmt.Printf("Num connected clients : %d \r\n", len(clients)) 83 | case client := <-removedClientsChan: 84 | delete(clients, client) 85 | close(client) 86 | fmt.Printf("Num connected clients : %d \r\n", len(clients)) 87 | case logData := <-logsChan: 88 | for logBuffer.Len() >= opts.LogBufferSize { 89 | logBuffer.Remove(logBuffer.Front()) 90 | } 91 | logBuffer.PushBack(logData) 92 | for client := range clients { 93 | select { 94 | case client <- logData: 95 | default: 96 | } 97 | } 98 | case stats = <-statsChan: 99 | for client := range clients { 100 | select { 101 | case client <- stats: 102 | default: 103 | } 104 | } 105 | } 106 | } 107 | }() 108 | 109 | pm2 := NewPM2(time.Duration(opts.Interval)*time.Second, &statsChan, &logsChan).Start() 110 | 111 | if err := NewHTTPServer(args[0], &opts, pm2, &newClientsChan, &removedClientsChan).ListenAndServe(); err != nil { 112 | log.Fatalln(err) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /pm2.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "os/exec" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type JsonObject = map[string]interface{} 14 | 15 | type PM2 struct { 16 | Interval time.Duration 17 | statsChan *chan LogData 18 | logsChan *chan LogData 19 | } 20 | 21 | func NewPM2(interval time.Duration, statsChan *chan LogData, logsChan *chan LogData) *PM2 { 22 | return &PM2{ 23 | Interval: interval, 24 | statsChan: statsChan, 25 | logsChan: logsChan, 26 | } 27 | } 28 | 29 | func (p *PM2) Start() *PM2 { 30 | go p.logs() 31 | go p.jlist() 32 | return p 33 | } 34 | 35 | func (p *PM2) logs() { 36 | for { 37 | cmd := exec.Command("pm2", "logs", "--format", "--timestamp") 38 | cmdReader, err := cmd.StdoutPipe() 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | scanner := bufio.NewScanner(cmdReader) 43 | if err := cmd.Start(); err != nil { 44 | log.Fatal(err) 45 | } 46 | for scanner.Scan() { 47 | data := scanner.Text() 48 | if !strings.HasPrefix(data, "timestamp=") { 49 | continue 50 | } 51 | idx1 := strings.Index(data, " ") 52 | if idx1 < 0 || !strings.HasPrefix(data[idx1+1:], "app=") { 53 | continue 54 | } 55 | idx2 := idx1 + strings.Index(data[idx1+1:], " ") + 1 56 | if idx2 < 0 || !strings.HasPrefix(data[idx2+1:], "id=") { 57 | continue 58 | } 59 | idx3 := idx2 + strings.Index(data[idx2+1:], " ") + 1 60 | if idx3 < 0 || !strings.HasPrefix(data[idx3+1:], "type=") { 61 | continue 62 | } 63 | idx4 := idx3 + strings.Index(data[idx3+1:], " ") + 1 64 | if idx4 < 0 || !strings.HasPrefix(data[idx4+1:], "message=") { 65 | continue 66 | } 67 | var jM JsonObject = make(JsonObject) 68 | jM["time"] = fmt.Sprintf("%s%c%s", data[10:20], ' ', data[21:idx1]) 69 | jM["app"] = data[idx1+5 : idx2] 70 | jM["id"] = data[idx2+4 : idx3] 71 | jM["type"] = data[idx3+6 : idx4] 72 | jM["message"] = data[idx4+9:] 73 | logData := LogData{Type: "log", Data: jM, Time: time.Now().UnixNano() / 1e6} 74 | *p.logsChan <- logData 75 | } 76 | } 77 | } 78 | 79 | func (p *PM2) getJlist() { 80 | cmd := exec.Command("pm2", "jlist") 81 | data, err := cmd.Output() 82 | // fmt.Println(string(data)) 83 | if err != nil { 84 | log.Fatal(err) 85 | } 86 | var sObject []JsonObject 87 | json.Unmarshal(data, &sObject) 88 | var oObject []JsonObject = make([]JsonObject, len(sObject)) 89 | for i := range sObject { 90 | oObject[i] = make(JsonObject) 91 | oObject[i]["name"] = sObject[i]["name"] 92 | oObject[i]["id"] = sObject[i]["pm_id"] 93 | oObject[i]["pid"] = sObject[i]["pid"] 94 | oObject[i]["uptime"] = sObject[i]["pm2_env"].(JsonObject)["pm_uptime"] 95 | oObject[i]["status"] = sObject[i]["pm2_env"].(JsonObject)["status"] 96 | oObject[i]["restart"] = sObject[i]["pm2_env"].(JsonObject)["restart_time"] 97 | oObject[i]["user"] = sObject[i]["pm2_env"].(JsonObject)["username"] 98 | oObject[i]["cpu"] = sObject[i]["monit"].(JsonObject)["cpu"] 99 | oObject[i]["mem"] = sObject[i]["monit"].(JsonObject)["memory"] 100 | } 101 | select { 102 | case *p.statsChan <- LogData{Type: "stats", Data: oObject, Time: time.Now().UnixNano() / 1e6}: 103 | case <-time.After(3 * time.Second): 104 | } 105 | } 106 | 107 | func (p *PM2) jlist() { 108 | for { 109 | p.getJlist() 110 | time.Sleep(p.Interval) 111 | } 112 | } 113 | 114 | func (p *PM2) Action(id string, command string) error { 115 | cmd := exec.Command("pm2", command, id) 116 | _, err := cmd.Output() 117 | if err == nil { 118 | go p.getJlist() 119 | } 120 | return err 121 | } 122 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "text/template" 8 | "time" 9 | 10 | "github.com/goji/httpauth" 11 | "github.com/gorilla/websocket" 12 | ) 13 | 14 | type HttpServer struct { 15 | Addr string 16 | upgrader websocket.Upgrader 17 | options *Options 18 | newClients *chan chan LogData 19 | removedClients *chan chan LogData 20 | pm2 *PM2 21 | } 22 | 23 | func (h *HttpServer) JsHandler(w http.ResponseWriter, r *http.Request) { 24 | templ, err := template.ParseFiles("./static/script.js") 25 | if err != nil { 26 | w.WriteHeader(http.StatusInternalServerError) 27 | fmt.Fprintln(w, err) 28 | return 29 | } 30 | w.Header().Add("Content-Type", "text/javascript") 31 | err = templ.Execute(w, h.options) 32 | if err != nil { 33 | fmt.Println(err) 34 | } 35 | } 36 | 37 | func (h *HttpServer) LogsHandler(w http.ResponseWriter, r *http.Request) { 38 | conn, err := h.upgrader.Upgrade(w, r, nil) 39 | if err != nil { 40 | fmt.Println(err) 41 | w.WriteHeader(http.StatusInternalServerError) 42 | fmt.Fprint(w, http.StatusText(http.StatusInternalServerError)) 43 | return 44 | } 45 | clientChan := make(chan LogData, 100) 46 | *h.newClients <- clientChan 47 | fmt.Printf("Client connected : %s \r\n", conn.RemoteAddr().String()) 48 | for data := range clientChan { 49 | conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) 50 | if err := conn.WriteJSON(data); err != nil { 51 | conn.Close() 52 | *h.removedClients <- clientChan 53 | fmt.Printf("Client disconnected : %s \r\n", conn.RemoteAddr().String()) 54 | } 55 | } 56 | } 57 | 58 | func (h *HttpServer) ActionsHandler(w http.ResponseWriter, r *http.Request) { 59 | q := r.URL.Query() 60 | op := q.Get("op") 61 | id := q.Get("id") 62 | 63 | if op == "" { 64 | w.WriteHeader(http.StatusBadRequest) 65 | w.Write([]byte("Url Param 'op' is missing")) 66 | return 67 | } 68 | 69 | if id == "" { 70 | w.WriteHeader(http.StatusBadRequest) 71 | w.Write([]byte("Url Param 'id' is missing")) 72 | return 73 | } 74 | 75 | var err error 76 | switch op { 77 | case "start", "stop", "restart": 78 | err = h.pm2.Action(id, op) 79 | default: 80 | err = errors.New("bad op") 81 | } 82 | 83 | if err != nil { 84 | w.WriteHeader(http.StatusInternalServerError) 85 | w.Write([]byte(fmt.Sprintf("error: %s\n", err.Error()))) 86 | } else { 87 | w.WriteHeader(http.StatusOK) 88 | w.Write([]byte(http.StatusText(http.StatusOK))) 89 | } 90 | } 91 | 92 | func (h *HttpServer) middleware(f http.HandlerFunc) http.Handler { 93 | if h.options.Username == "" { 94 | return f 95 | } else { 96 | return httpauth.SimpleBasicAuth(h.options.Username, h.options.Password)(f) 97 | } 98 | } 99 | 100 | func NewHTTPServer( 101 | addr string, 102 | options *Options, 103 | pm2 *PM2, 104 | newClients, 105 | removedClients *chan chan LogData, 106 | ) *HttpServer { 107 | h := &HttpServer{ 108 | upgrader: websocket.Upgrader{ 109 | ReadBufferSize: 1024, 110 | WriteBufferSize: 1024, 111 | CheckOrigin: func(r *http.Request) bool { return true }, 112 | }, 113 | Addr: addr, 114 | options: options, 115 | pm2: pm2, 116 | newClients: newClients, 117 | removedClients: removedClients, 118 | } 119 | 120 | http.Handle("/", h.middleware(http.FileServer(http.Dir("./static")).ServeHTTP)) 121 | http.Handle("/script.js", h.middleware(h.JsHandler)) 122 | http.Handle("/logs", h.middleware(h.LogsHandler)) 123 | if options.ActionsEnabled { 124 | http.Handle("/action", h.middleware(h.ActionsHandler)) 125 | } 126 | 127 | return h 128 | } 129 | 130 | func (s *HttpServer) ListenAndServe() error { 131 | return http.ListenAndServe(s.Addr, nil) 132 | } 133 | -------------------------------------------------------------------------------- /static/script.js: -------------------------------------------------------------------------------- 1 | const SHOW_ACTIONS = {{.ActionsEnabled }}; 2 | const SHOW_TIME = {{.TimeEnabled }}; 3 | const SHOW_ID = {{.AppIdEnabled }}; 4 | const SHOW_APP_NAME = {{.AppNameEnabled }}; 5 | 6 | let host = window.document.location.host.replace(/:.*/, ''); 7 | let pathname = window.location.pathname 8 | document.title = "PM2 | " + host 9 | let statsHeight = 0; 10 | 11 | function connect() { 12 | let socket = new WebSocket(location.protocol.replace("http", "ws") + "//" + host + (location.port ? ':' + location.port : '') + pathname + "logs") 13 | socket.onopen = () => { 14 | console.log("ws open") 15 | } 16 | 17 | socket.onclose = event => { 18 | console.log("ws close", event) 19 | 20 | setTimeout(() => { 21 | connect() 22 | }, 1000) 23 | } 24 | 25 | socket.onerror = err => { 26 | console.error('ws error: ', err.message, 'Closing socket'); 27 | socket.close(); 28 | } 29 | 30 | socket.onmessage = message => { 31 | let data = JSON.parse(message.data); 32 | if (data.Type == "log") { 33 | let log = data.Data; 34 | if (log.type !== "out" && log.type !== "error") return; 35 | let div = document.getElementById("logs"); 36 | let lines = div.getElementsByClassName('log') 37 | while (lines.length > 999) lines[0].remove(); 38 | let p = document.createElement("p"); 39 | p.setAttribute("class", "log"); 40 | let span = document.createElement("span"); 41 | span.setAttribute("style", "color: " + (log.type == "out" ? "#00bb00" : "#d00000" + ";")); 42 | if (SHOW_TIME) span.appendChild(document.createTextNode("[" + new Date(log.time).toLocaleString() + "] ")); 43 | if (SHOW_ID) span.appendChild(document.createTextNode(log.id + " ")); 44 | if (SHOW_APP_NAME) span.appendChild(document.createTextNode(log.app + " ")); 45 | p.appendChild(span); 46 | p.appendChild(document.createTextNode("> " + log.message)); 47 | let isScrolledToBottom = div.scrollHeight - div.clientHeight <= div.scrollTop + div.offsetHeight * 0.25 48 | div.appendChild(p); 49 | if (isScrolledToBottom && !getSelectedText()) { 50 | div.scrollTop = div.scrollHeight - div.clientHeight 51 | } 52 | } else if (data.Type == "stats") { 53 | let stats = data.Data; 54 | let txt = "" 55 | txt += ""; 56 | txt += "" 57 | txt += "" 58 | txt += "" 59 | txt += "" 60 | txt += "" 61 | txt += "" 62 | txt += "" 63 | txt += "" 64 | txt += "" 65 | if (SHOW_ACTIONS) txt += "" 66 | txt += "" 67 | for (var i in stats) { 68 | let uptime = Math.floor((data.Time - stats[i].uptime) / 1000); 69 | let uptime_txt = uptime % 60 + "s"; 70 | uptime = Math.floor(uptime / 60); 71 | if (uptime > 0) { 72 | uptime_txt = uptime % 60 + "m" 73 | uptime = Math.floor(uptime / 60); 74 | if (uptime > 0) { 75 | uptime_txt = uptime % 24 + "h" 76 | uptime = Math.floor(uptime / 24); 77 | if (uptime > 0) { 78 | uptime_txt = uptime + "d" 79 | } 80 | } 81 | } 82 | 83 | let status = stats[i].status; 84 | 85 | txt += "" 86 | txt += "" 87 | txt += "" 88 | txt += "" 89 | txt += "" 90 | txt += "" 91 | txt += "" 92 | txt += "" 93 | txt += "" 94 | txt += "" 95 | if (SHOW_ACTIONS) { 96 | txt += "` 103 | } 104 | txt += "" 105 | } 106 | txt += "
App nameidpidstatusrestartuptimecpumemuseractions
" + stats[i].name + "" + stats[i].id + "" + stats[i].pid + "" + status + "" + stats[i].restart + "" + (status == "online" ? uptime_txt : "0") + "" + stats[i].cpu + "%" + (stats[i].mem / (1024 * 1024)).toFixed(1) + " MB" + stats[i].user + "" 97 | if (status == "online") { 98 | txt += `` 99 | } else { 100 | txt += `` 101 | } 102 | txt += `
" 107 | document.getElementById("stats").innerHTML = txt; 108 | let div = document.getElementById("logs"); 109 | statsHeight = document.getElementById("stats").offsetHeight; 110 | div.style.top = (statsHeight + 10) + "px"; 111 | let isScrolledToBottom = div.scrollHeight - div.clientHeight <= div.scrollTop + div.offsetHeight * 0.25 112 | if (isScrolledToBottom && !getSelectedText()) { 113 | div.scrollTop = div.scrollHeight - div.clientHeight 114 | } 115 | } 116 | } 117 | } 118 | connect() 119 | 120 | function getSelectedText() { 121 | var text = ""; 122 | if (typeof window.getSelection != "undefined") { 123 | text = window.getSelection().toString(); 124 | } else if (typeof document.selection != "undefined" && document.selection.type == "Text") { 125 | text = document.selection.createRange().text; 126 | } 127 | return text; 128 | } 129 | 130 | function pm2Action(type, id) { 131 | const xmlHttp = new XMLHttpRequest(); 132 | let url = location.protocol + "//" + host + (location.port ? ':' + location.port : '') + pathname + `action?op=${type}&id=${id}` 133 | xmlHttp.open("GET", url); 134 | xmlHttp.send(); 135 | } 136 | 137 | window.onresize = function () { 138 | let div = document.getElementById("logs"); 139 | statsHeight = document.getElementById("stats").offsetHeight; 140 | div.style.top = (statsHeight + 10) + "px"; 141 | if (!getSelectedText()) { 142 | div.scrollTop = div.scrollHeight - div.clientHeight 143 | } 144 | } --------------------------------------------------------------------------------