├── .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 |
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 = "| App name | " 57 | txt += "id | " 58 | txt += "pid | " 59 | txt += "status | " 60 | txt += "restart | " 61 | txt += "uptime | " 62 | txt += "cpu | " 63 | txt += "mem | " 64 | txt += "user | " 65 | if (SHOW_ACTIONS) txt += "actions | " 66 | txt += "
| " + stats[i].name + " | " 87 | txt += "" + stats[i].id + " | " 88 | txt += "" + stats[i].pid + " | " 89 | txt += "" + status + " | " 90 | txt += "" + stats[i].restart + " | " 91 | txt += "" + (status == "online" ? uptime_txt : "0") + " | " 92 | txt += "" + stats[i].cpu + "% | " 93 | txt += "" + (stats[i].mem / (1024 * 1024)).toFixed(1) + " MB | " 94 | txt += "" + stats[i].user + " | " 95 | if (SHOW_ACTIONS) { 96 | txt += "" 97 | if (status == "online") { 98 | txt += `` 99 | } else { 100 | txt += `` 101 | } 102 | txt += ` | ` 103 | } 104 | txt += "