diff --git a/handler/handler.go b/handler/handler.go index f0b8b21..deb2dd5 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -3,19 +3,15 @@ package handler import ( "encoding/json" "fmt" - "html/template" "io" "io/ioutil" "net/http" - "net/url" "reflect" "strings" "github.com/Sirupsen/logrus" "github.com/gorilla/mux" - "github.com/klarna/eremetic/assets" "github.com/klarna/eremetic/database" - "github.com/klarna/eremetic/formatter" "github.com/klarna/eremetic/scheduler" "github.com/klarna/eremetic/types" ) @@ -37,20 +33,6 @@ func Create(scheduler types.Scheduler, database database.TaskDB) Handler { } } -func absURL(r *http.Request, path string) string { - scheme := r.Header.Get("X-Forwarded-Proto") - if scheme == "" { - scheme = "http" - } - - url := url.URL{ - Scheme: scheme, - Host: r.Host, - Path: path, - } - return url.String() -} - // AddTask handles adding a task to the queue func (h Handler) AddTask() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -88,6 +70,27 @@ func (h Handler) AddTask() http.HandlerFunc { } } +// GetFromSandbox fetches a file from the sandbox of the agent that ran the task +func (h Handler) GetFromSandbox(file string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + taskID := vars["taskId"] + task, _ := h.database.ReadTask(taskID) + + status, data := getFile(file, task) + + if status != http.StatusOK { + writeJSON(status, data, w) + return + } + + defer data.Close() + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") + w.WriteHeader(http.StatusOK) + io.Copy(w, data) + } +} + // GetTaskInfo returns information about the given task. func (h Handler) GetTaskInfo() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -119,73 +122,3 @@ func (h Handler) ListRunningTasks() http.HandlerFunc { writeJSON(200, tasks, w) } } - -func handleError(err error, w http.ResponseWriter, message string) { - if err == nil { - return - } - - errorMessage := ErrorDocument{ - err.Error(), - message, - } - - if err = writeJSON(422, errorMessage, w); err != nil { - logrus.WithError(err).WithField("message", message).Panic("Unable to respond") - } -} - -func writeJSON(status int, data interface{}, w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json; charset=UTF-8") - w.WriteHeader(status) - return json.NewEncoder(w).Encode(data) -} - -func renderHTML(w http.ResponseWriter, r *http.Request, task types.EremeticTask, taskID string) { - var templateFile string - - data := make(map[string]interface{}) - funcMap := template.FuncMap{ - "ToLower": strings.ToLower, - "FormatTime": formatter.FormatTime, - } - - if reflect.DeepEqual(task, (types.EremeticTask{})) { - templateFile = "error_404.html" - data["TaskID"] = taskID - } else { - templateFile = "task.html" - data = makeMap(task) - } - - source, _ := assets.Asset(fmt.Sprintf("templates/%s", templateFile)) - tpl, err := template.New(templateFile).Funcs(funcMap).Parse(string(source)) - - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - logrus.WithError(err).WithField("template", templateFile).Error("Unable to render template") - return - } - - err = tpl.Execute(w, data) -} - -func makeMap(task types.EremeticTask) map[string]interface{} { - data := make(map[string]interface{}) - - data["TaskID"] = task.ID - data["CommandEnv"] = task.Environment - data["CommandUser"] = task.User - data["Command"] = task.Command - // TODO: Support more than docker? - data["ContainerImage"] = task.Image - data["FrameworkID"] = task.FrameworkId - data["Hostname"] = task.Hostname - data["Name"] = task.Name - data["SlaveID"] = task.SlaveId - data["Status"] = task.Status - data["CPU"] = fmt.Sprintf("%.2f", task.TaskCPUs) - data["Memory"] = fmt.Sprintf("%.2f", task.TaskMem) - - return data -} diff --git a/handler/handler_test.go b/handler/handler_test.go index cf30b4f..b7bcf52 100644 --- a/handler/handler_test.go +++ b/handler/handler_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "os" + "strconv" "strings" "testing" "time" @@ -210,4 +211,120 @@ func TestHandling(t *testing.T) { So(wr.Code, ShouldEqual, 422) }) }) + + Convey("Get Files from sandbox", t, func() { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, "mocked") + })) + defer s.Close() + + addr := strings.Split(s.Listener.Addr().String(), ":") + ip := addr[0] + port, _ := strconv.ParseInt(addr[1], 10, 32) + id := "eremetic-task.1234" + + task := types.EremeticTask{ + TaskCPUs: 0.2, + TaskMem: 0.5, + Command: "test", + Image: "test", + Status: status, + ID: id, + SandboxPath: "/tmp", + AgentIP: ip, + AgentPort: int32(port), + } + db.PutTask(&task) + wr := httptest.NewRecorder() + m := mux.NewRouter() + + Convey("stdout", func() { + r, _ := http.NewRequest("GET", "/task/eremetic-task.1234/stdout", nil) + m.HandleFunc("/task/{taskId}/stdout", h.GetFromSandbox("stdout")) + m.ServeHTTP(wr, r) + + So(wr.Code, ShouldEqual, http.StatusOK) + So(wr.Header().Get("Content-Type"), ShouldEqual, "text/plain; charset=UTF-8") + + body, _ := ioutil.ReadAll(wr.Body) + So(string(body), ShouldEqual, "mocked") + }) + + Convey("stderr", func() { + r, _ := http.NewRequest("GET", "/task/eremetic-task.1234/stderr", nil) + m.HandleFunc("/task/{taskId}/stderr", h.GetFromSandbox("stderr")) + + m.ServeHTTP(wr, r) + + So(wr.Code, ShouldEqual, http.StatusOK) + So(wr.Header().Get("Content-Type"), ShouldEqual, "text/plain; charset=UTF-8") + + body, _ := ioutil.ReadAll(wr.Body) + So(string(body), ShouldEqual, "mocked") + }) + + Convey("No Sandbox path", func() { + r, _ := http.NewRequest("GET", "/task/eremetic-task.1234/stdout", nil) + m.HandleFunc("/task/{taskId}/stdout", h.GetFromSandbox("stdout")) + + task := types.EremeticTask{ + TaskCPUs: 0.2, + TaskMem: 0.5, + Command: "test", + Image: "test", + Status: status, + ID: id, + SandboxPath: "", + AgentIP: ip, + AgentPort: int32(port), + } + db.PutTask(&task) + + m.ServeHTTP(wr, r) + + So(wr.Code, ShouldEqual, http.StatusNoContent) + }) + + }) + + Convey("renderHTML", t, func() { + id := "eremetic-task.1234" + + task := types.EremeticTask{ + TaskCPUs: 0.2, + TaskMem: 0.5, + Command: "test", + Image: "test", + Status: status, + ID: id, + } + + wr := httptest.NewRecorder() + r, _ := http.NewRequest("GET", "/task/eremetic-task.1234", nil) + + renderHTML(wr, r, task, id) + + body, _ := ioutil.ReadAll(wr.Body) + So(body, ShouldNotBeEmpty) + So(string(body), ShouldContainSubstring, "html") + }) + + Convey("makeMap", t, func() { + task := types.EremeticTask{ + TaskCPUs: 0.2, + TaskMem: 0.5, + Command: "test", + Image: "test", + Status: status, + ID: "eremetic-task.1234", + } + + data := makeMap(task) + So(data, ShouldContainKey, "CPU") + So(data, ShouldContainKey, "Memory") + So(data, ShouldContainKey, "Status") + So(data, ShouldContainKey, "ContainerImage") + So(data, ShouldContainKey, "Command") + So(data, ShouldContainKey, "TaskID") + }) } diff --git a/handler/helpers.go b/handler/helpers.go new file mode 100644 index 0000000..01cf54d --- /dev/null +++ b/handler/helpers.go @@ -0,0 +1,128 @@ +package handler + +import ( + "encoding/json" + "fmt" + "html/template" + "io" + "io/ioutil" + "net/http" + "net/url" + "reflect" + "strings" + + "github.com/Sirupsen/logrus" + "github.com/klarna/eremetic/assets" + "github.com/klarna/eremetic/formatter" + "github.com/klarna/eremetic/types" +) + +// getFile handles the actual fetching of file from the agent. +func getFile(file string, task types.EremeticTask) (int, io.ReadCloser) { + if task.SandboxPath == "" { + return http.StatusNoContent, nil + } + + url := fmt.Sprintf( + "http://%s:%d/files/download?path=%s/%s", + task.AgentIP, + task.AgentPort, + task.SandboxPath, + file, + ) + + logrus.WithField("url", url).Debug("Fetching file from sandbox") + + response, err := http.Get(url) + + if err != nil { + logrus.WithError(err).Errorf("Unable to fetch %s from agent %s.", file, task.SlaveId) + return http.StatusInternalServerError, ioutil.NopCloser(strings.NewReader("Unable to fetch upstream file.")) + } + + return http.StatusOK, response.Body +} + +func handleError(err error, w http.ResponseWriter, message string) { + if err == nil { + return + } + + errorMessage := ErrorDocument{ + err.Error(), + message, + } + + if err = writeJSON(422, errorMessage, w); err != nil { + logrus.WithError(err).WithField("message", message).Panic("Unable to respond") + } +} + +func writeJSON(status int, data interface{}, w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + w.WriteHeader(status) + return json.NewEncoder(w).Encode(data) +} + +func renderHTML(w http.ResponseWriter, r *http.Request, task types.EremeticTask, taskID string) { + var templateFile string + + data := make(map[string]interface{}) + funcMap := template.FuncMap{ + "ToLower": strings.ToLower, + "FormatTime": formatter.FormatTime, + } + + if reflect.DeepEqual(task, (types.EremeticTask{})) { + templateFile = "error_404.html" + data["TaskID"] = taskID + } else { + templateFile = "task.html" + data = makeMap(task) + } + + source, _ := assets.Asset(fmt.Sprintf("templates/%s", templateFile)) + tpl, err := template.New(templateFile).Funcs(funcMap).Parse(string(source)) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + logrus.WithError(err).WithField("template", templateFile).Error("Unable to render template") + return + } + + err = tpl.Execute(w, data) +} + +func makeMap(task types.EremeticTask) map[string]interface{} { + data := make(map[string]interface{}) + + data["TaskID"] = task.ID + data["CommandEnv"] = task.Environment + data["CommandUser"] = task.User + data["Command"] = task.Command + // TODO: Support more than docker? + data["ContainerImage"] = task.Image + data["FrameworkID"] = task.FrameworkId + data["Hostname"] = task.Hostname + data["Name"] = task.Name + data["SlaveID"] = task.SlaveId + data["Status"] = task.Status + data["CPU"] = fmt.Sprintf("%.2f", task.TaskCPUs) + data["Memory"] = fmt.Sprintf("%.2f", task.TaskMem) + + return data +} + +func absURL(r *http.Request, path string) string { + scheme := r.Header.Get("X-Forwarded-Proto") + if scheme == "" { + scheme = "http" + } + + url := url.URL{ + Scheme: scheme, + Host: r.Host, + Path: path, + } + return url.String() +} diff --git a/routes/routes.go b/routes/routes.go index 99d0d86..4830d45 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -32,6 +32,18 @@ func Create(scheduler types.Scheduler, database database.TaskDB) *mux.Router { Pattern: "/task/{taskId}", Handler: h.GetTaskInfo(), }, + types.Route{ + Name: "STDOUT", + Method: "GET", + Pattern: "/task/{taskId}/stdout", + Handler: h.GetFromSandbox("stdout"), + }, + types.Route{ + Name: "STDERR", + Method: "GET", + Pattern: "/task/{taskId}/stderr", + Handler: h.GetFromSandbox("stderr"), + }, types.Route{ Name: "ListRunningTasks", Method: "GET", @@ -63,9 +75,9 @@ func Create(scheduler types.Scheduler, database database.TaskDB) *mux.Router { router.PathPrefix("/static/"). Handler( - http.StripPrefix( - "/static/", http.FileServer( - &assetfs.AssetFS{Asset: assets.Asset, AssetDir: assets.AssetDir, AssetInfo: assets.AssetInfo, Prefix: "static"}))) + http.StripPrefix( + "/static/", http.FileServer( + &assetfs.AssetFS{Asset: assets.Asset, AssetDir: assets.AssetDir, AssetInfo: assets.AssetInfo, Prefix: "static"}))) router.NotFoundHandler = http.HandlerFunc(notFound) diff --git a/scheduler/extractor.go b/scheduler/extractor.go new file mode 100644 index 0000000..97a4ae7 --- /dev/null +++ b/scheduler/extractor.go @@ -0,0 +1,46 @@ +package scheduler + +import ( + "encoding/json" + + "github.com/Sirupsen/logrus" + mesos "github.com/mesos/mesos-go/mesosproto" +) + +type mounts struct { + Mounts []dockerMounts `json:"Mounts"` +} + +type dockerMounts struct { + Source string `json:"Source"` + Destination string `json:"Destination"` + Mode string `json:"Mode"` + RW bool `json:"RW"` +} + +func extractSandboxPath(status *mesos.TaskStatus) (string, error) { + var mounts []mounts + + if len(status.Data) == 0 { + logrus.Debug("No Data in task status.") + return "", nil + } + + err := json.Unmarshal(status.Data, &mounts) + + if err != nil { + logrus.WithError(err).Error("Task status data contained invalid JSON.") + return "", err + } + + for _, m := range mounts { + for _, dm := range m.Mounts { + if dm.Destination == "/mnt/mesos/sandbox" { + return dm.Source, nil + } + } + } + + logrus.Debug("No sandbox mount found in task status data.") + return "", nil +} diff --git a/scheduler/extractor_test.go b/scheduler/extractor_test.go new file mode 100644 index 0000000..dcddba5 --- /dev/null +++ b/scheduler/extractor_test.go @@ -0,0 +1,83 @@ +package scheduler + +import ( + "testing" + + mesos "github.com/mesos/mesos-go/mesosproto" + . "github.com/smartystreets/goconvey/convey" +) + +func mockStatusWithSandbox() *mesos.TaskStatus { + return &mesos.TaskStatus{ + Data: []byte(`[ + { + "Mounts": [ + { + "Source": "/tmp/mesos/slaves//frameworks//executors//runs/", + "Destination": "/mnt/mesos/sandbox", + "Mode": "", + "RW": true + } + ] + } + ]`), + } +} + +func mockStatusWithoutSandbox() *mesos.TaskStatus { + return &mesos.TaskStatus{ + Data: []byte(`[ + { + "Mounts": [ + { + "Source": "/tmp/mesos/", + "Destination": "/mnt/not/the/sandbox", + "Mode": "", + "RW": true + } + ] + } + ]`), + } +} + +func mockStatusNoMounts() *mesos.TaskStatus { + return &mesos.TaskStatus{ + Data: []byte(`[ + { + "Mounts": [] + } + ]`), + } +} + +func TestExtractor(t *testing.T) { + Convey("extractSandboxPath", t, func() { + Convey("Sandbox found", func() { + status := mockStatusWithSandbox() + sandbox, err := extractSandboxPath(status) + So(err, ShouldBeNil) + So(sandbox, ShouldNotBeEmpty) + }) + + Convey("Sandbox not found", func() { + status := mockStatusWithoutSandbox() + sandbox, err := extractSandboxPath(status) + So(sandbox, ShouldBeEmpty) + So(err, ShouldBeNil) + }) + + Convey("No mounts in data", func() { + status := mockStatusWithoutSandbox() + sandbox, err := extractSandboxPath(status) + So(sandbox, ShouldBeEmpty) + So(err, ShouldBeNil) + }) + + Convey("Empty data", func() { + sandbox, err := extractSandboxPath(&mesos.TaskStatus{Data: []byte("")}) + So(sandbox, ShouldBeEmpty) + So(err, ShouldBeNil) + }) + }) +} diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go index 6236f6d..5f427e3 100644 --- a/scheduler/scheduler.go +++ b/scheduler/scheduler.go @@ -162,6 +162,11 @@ loop: func (s *eremeticScheduler) StatusUpdate(driver sched.SchedulerDriver, status *mesos.TaskStatus) { id := status.TaskId.GetValue() + sandboxPath, err := extractSandboxPath(status) + if err != nil { + logrus.WithError(err).Debug("Unable to extract sandbox path") + } + logrus.WithFields(logrus.Fields{ "task_id": id, "status": status.State.String(), @@ -172,6 +177,10 @@ func (s *eremeticScheduler) StatusUpdate(driver sched.SchedulerDriver, status *m logrus.WithError(err).WithField("task_id", id).Debug("Unable to read task from database") } + if task.SandboxPath == "" || (task.SandboxPath != sandboxPath && sandboxPath != "") { + task.SandboxPath = sandboxPath + } + if task.ID == "" { task = types.EremeticTask{ ID: id, diff --git a/scheduler/task.go b/scheduler/task.go index dbb97ae..8d63000 100644 --- a/scheduler/task.go +++ b/scheduler/task.go @@ -26,6 +26,8 @@ func createTaskInfo(task types.EremeticTask, offer *mesos.Offer) (types.Eremetic task.FrameworkId = *offer.FrameworkId.Value task.SlaveId = *offer.SlaveId.Value task.Hostname = *offer.Hostname + task.AgentIP = offer.GetUrl().GetAddress().GetIp() + task.AgentPort = offer.GetUrl().GetAddress().GetPort() var environment []*mesos.Environment_Variable for k, v := range task.Environment { @@ -96,6 +98,5 @@ func createTaskInfo(task types.EremeticTask, offer *mesos.Offer) (types.Eremetic mesosutil.NewScalarResource("mem", task.TaskMem), }, } - return task, taskInfo } diff --git a/static/css/style.css b/static/css/style.css index ff0d352..2daf3ee 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -135,3 +135,22 @@ .optional { font-style: italic; } + +p.gray { + color: #cccccc; +} + +#show_stdout { + color: #777777; + border: 1px solid #bbb; + background-color: #dddddd; + text-align: center; + border-radius: 4px; + margin-bottom: 1em; + cursor: pointer; +} + +#show_stdout:hover { + background-color: #eeeeee; + color: #999999; +} diff --git a/static/js/ansi_up.js b/static/js/ansi_up.js new file mode 100644 index 0000000..74f541a --- /dev/null +++ b/static/js/ansi_up.js @@ -0,0 +1,327 @@ +// ansi_up.js +// version : 1.3.0 +// author : Dru Nelson +// license : MIT +// http://github.com/drudru/ansi_up + +(function (Date, undefined) { + + var ansi_up, + VERSION = "1.3.0", + + // check for nodeJS + hasModule = (typeof module !== 'undefined'), + + // Normal and then Bright + ANSI_COLORS = [ + [ + { color: "0, 0, 0", 'class': "ansi-black" }, + { color: "187, 0, 0", 'class': "ansi-red" }, + { color: "0, 187, 0", 'class': "ansi-green" }, + { color: "187, 187, 0", 'class': "ansi-yellow" }, + { color: "0, 0, 187", 'class': "ansi-blue" }, + { color: "187, 0, 187", 'class': "ansi-magenta" }, + { color: "0, 187, 187", 'class': "ansi-cyan" }, + { color: "255,255,255", 'class': "ansi-white" } + ], + [ + { color: "85, 85, 85", 'class': "ansi-bright-black" }, + { color: "255, 85, 85", 'class': "ansi-bright-red" }, + { color: "0, 255, 0", 'class': "ansi-bright-green" }, + { color: "255, 255, 85", 'class': "ansi-bright-yellow" }, + { color: "85, 85, 255", 'class': "ansi-bright-blue" }, + { color: "255, 85, 255", 'class': "ansi-bright-magenta" }, + { color: "85, 255, 255", 'class': "ansi-bright-cyan" }, + { color: "255, 255, 255", 'class': "ansi-bright-white" } + ] + ], + + // 256 Colors Palette + PALETTE_COLORS; + + function Ansi_Up() { + this.fg = this.bg = this.fg_truecolor = this.bg_truecolor = null; + this.bright = 0; + } + + Ansi_Up.prototype.setup_palette = function() { + PALETTE_COLORS = []; + // Index 0..15 : System color + (function() { + var i, j; + for (i = 0; i < 2; ++i) { + for (j = 0; j < 8; ++j) { + PALETTE_COLORS.push(ANSI_COLORS[i][j]['color']); + } + } + })(); + + // Index 16..231 : RGB 6x6x6 + // https://gist.github.com/jasonm23/2868981#file-xterm-256color-yaml + (function() { + var levels = [0, 95, 135, 175, 215, 255]; + var format = function (r, g, b) { return levels[r] + ', ' + levels[g] + ', ' + levels[b] }; + var r, g, b; + for (r = 0; r < 6; ++r) { + for (g = 0; g < 6; ++g) { + for (b = 0; b < 6; ++b) { + PALETTE_COLORS.push(format.call(this, r, g, b)); + } + } + } + })(); + + // Index 232..255 : Grayscale + (function() { + var level = 8; + var format = function(level) { return level + ', ' + level + ', ' + level }; + var i; + for (i = 0; i < 24; ++i, level += 10) { + PALETTE_COLORS.push(format.call(this, level)); + } + })(); + }; + + Ansi_Up.prototype.escape_for_html = function (txt) { + return txt.replace(/[&<>]/gm, function(str) { + if (str == "&") return "&"; + if (str == "<") return "<"; + if (str == ">") return ">"; + }); + }; + + Ansi_Up.prototype.linkify = function (txt) { + return txt.replace(/(https?:\/\/[^\s]+)/gm, function(str) { + return "" + str + ""; + }); + }; + + Ansi_Up.prototype.ansi_to_html = function (txt, options) { + return this.process(txt, options, true); + }; + + Ansi_Up.prototype.ansi_to_text = function (txt) { + var options = {}; + return this.process(txt, options, false); + }; + + Ansi_Up.prototype.process = function (txt, options, markup) { + var self = this; + var raw_text_chunks = txt.split(/\033\[/); + var first_chunk = raw_text_chunks.shift(); // the first chunk is not the result of the split + + var color_chunks = raw_text_chunks.map(function (chunk) { + return self.process_chunk(chunk, options, markup); + }); + + color_chunks.unshift(first_chunk); + + return color_chunks.join(''); + }; + + Ansi_Up.prototype.process_chunk = function (text, options, markup) { + + // Are we using classes or styles? + options = typeof options == 'undefined' ? {} : options; + var use_classes = typeof options.use_classes != 'undefined' && options.use_classes; + var key = use_classes ? 'class' : 'color'; + + // Each 'chunk' is the text after the CSI (ESC + '[') and before the next CSI/EOF. + // + // This regex matches four groups within a chunk. + // + // The first and third groups match code type. + // We supported only SGR command. It has empty first group and 'm' in third. + // + // The second group matches all of the number+semicolon command sequences + // before the 'm' (or other trailing) character. + // These are the graphics or SGR commands. + // + // The last group is the text (including newlines) that is colored by + // the other group's commands. + var matches = text.match(/^([!\x3c-\x3f]*)([\d;]*)([\x20-\x2c]*[\x40-\x7e])([\s\S]*)/m); + + if (!matches) return text; + + var orig_txt = matches[4]; + var nums = matches[2].split(';'); + + // We currently support only "SGR" (Select Graphic Rendition) + // Simply ignore if not a SGR command. + if (matches[1] !== '' || matches[3] !== 'm') { + return orig_txt; + } + + if (!markup) { + return orig_txt; + } + + var self = this; + + while (nums.length > 0) { + var num_str = nums.shift(); + var num = parseInt(num_str); + + if (isNaN(num) || num === 0) { + self.fg = self.bg = null; + self.bright = 0; + } else if (num === 1) { + self.bright = 1; + } else if (num == 39) { + self.fg = null; + } else if (num == 49) { + self.bg = null; + } else if ((num >= 30) && (num < 38)) { + self.fg = ANSI_COLORS[self.bright][(num % 10)][key]; + } else if ((num >= 90) && (num < 98)) { + self.fg = ANSI_COLORS[1][(num % 10)][key]; + } else if ((num >= 40) && (num < 48)) { + self.bg = ANSI_COLORS[0][(num % 10)][key]; + } else if ((num >= 100) && (num < 108)) { + self.bg = ANSI_COLORS[1][(num % 10)][key]; + } else if (num === 38 || num === 48) { // extend color (38=fg, 48=bg) + (function() { + var is_foreground = (num === 38); + if (nums.length >= 1) { + var mode = nums.shift(); + if (mode === '5' && nums.length >= 1) { // palette color + var palette_index = parseInt(nums.shift()); + if (palette_index >= 0 && palette_index <= 255) { + if (!use_classes) { + if (!PALETTE_COLORS) { + self.setup_palette.call(self); + } + if (is_foreground) { + self.fg = PALETTE_COLORS[palette_index]; + } else { + self.bg = PALETTE_COLORS[palette_index]; + } + } else { + var klass = (palette_index >= 16) + ? ('ansi-palette-' + palette_index) + : ANSI_COLORS[palette_index > 7 ? 1 : 0][palette_index % 8]['class']; + if (is_foreground) { + self.fg = klass; + } else { + self.bg = klass; + } + } + } + } else if(mode === '2' && nums.length >= 3) { // true color + var r = parseInt(nums.shift()); + var g = parseInt(nums.shift()); + var b = parseInt(nums.shift()); + if ((r >= 0 && r <= 255) && (g >= 0 && g <= 255) && (b >= 0 && b <= 255)) { + var color = r + ', ' + g + ', ' + b; + if (!use_classes) { + if (is_foreground) { + self.fg = color; + } else { + self.bg = color; + } + } else { + if (is_foreground) { + self.fg = 'ansi-truecolor'; + self.fg_truecolor = color; + } else { + self.bg = 'ansi-truecolor'; + self.bg_truecolor = color; + } + } + } + } + } + })(); + } + } + + if ((self.fg === null) && (self.bg === null)) { + return orig_txt; + } else { + var styles = []; + var classes = []; + var data = {}; + var render_data = function (data) { + var fragments = []; + var key; + for (key in data) { + if (data.hasOwnProperty(key)) { + fragments.push('data-' + key + '="' + this.escape_for_html(data[key]) + '"'); + } + } + return fragments.length > 0 ? ' ' + fragments.join(' ') : ''; + }; + + if (self.fg) { + if (use_classes) { + classes.push(self.fg + "-fg"); + if (self.fg_truecolor !== null) { + data['ansi-truecolor-fg'] = self.fg_truecolor; + self.fg_truecolor = null; + } + } else { + styles.push("color:rgb(" + self.fg + ")"); + } + } + if (self.bg) { + if (use_classes) { + classes.push(self.bg + "-bg"); + if (self.bg_truecolor !== null) { + data['ansi-truecolor-bg'] = self.bg_truecolor; + self.bg_truecolor = null; + } + } else { + styles.push("background-color:rgb(" + self.bg + ")"); + } + } + if (use_classes) { + return '' + orig_txt + ''; + } else { + return '' + orig_txt + ''; + } + } + }; + + // Module exports + ansi_up = { + + escape_for_html: function (txt) { + var a2h = new Ansi_Up(); + return a2h.escape_for_html(txt); + }, + + linkify: function (txt) { + var a2h = new Ansi_Up(); + return a2h.linkify(txt); + }, + + ansi_to_html: function (txt, options) { + var a2h = new Ansi_Up(); + return a2h.ansi_to_html(txt, options); + }, + + ansi_to_text: function (txt) { + var a2h = new Ansi_Up(); + return a2h.ansi_to_text(txt); + }, + + ansi_to_html_obj: function () { + return new Ansi_Up(); + } + }; + + // CommonJS module is defined + if (hasModule) { + module.exports = ansi_up; + } + /*global ender:false */ + if (typeof window !== 'undefined' && typeof ender === 'undefined') { + window.ansi_up = ansi_up; + } + /*global define:false */ + if (typeof define === "function" && define.amd) { + define("ansi_up", [], function () { + return ansi_up; + }); + } +})(Date); diff --git a/static/js/task_view.js b/static/js/task_view.js new file mode 100644 index 0000000..c22fdcb --- /dev/null +++ b/static/js/task_view.js @@ -0,0 +1,66 @@ +$(document).ready(function() { + "use strict"; + + var taskId = $('body').data('task'); + + function format(data) { + data = data.split("\n"); + + var $el = $('#stdout'), + cls = 'gray', + showNext = false; + $.each(data, function(i, v) { + if (showNext && cls == 'gray') { + cls = ''; + } + if (showNext) { + $el.append($('

').append(ansi_up.ansi_to_html(v))); + } else { + $el.append($('

', { text: v, class: cls })); + } + + if (v.indexOf("Starting task") == 0) { + showNext = true; + } + }) + $el.find('.gray').hide(); + $('#show_stdout').text($el.find('.gray').length + ' lines hidden. Click to show.'); + } + + function getLogs(logfile) { + var $el = $("#" + logfile); + if (!$el) { + return; + } + $.ajax({ + method: 'GET', + url: '/task/' + taskId + '/' + logfile, + success: function(data) { + if (data.length == 0) { + $('div.logs').hide(); + return; + } + $el.text(''); + if (logfile === 'stdout') { + format(data); + } else { + $.each(data.split("\n"), function(i, v) { + $el.append($('

', { text: v, class: 'gray' })); + }); + }; + }, + error: function(xhr, e) { + $el.text(e) + } + }); + } + + $('body').on('click', '#show_stdout', function(e) { + e.preventDefault(); + $('#stdout p.gray').show(); + $('#show_stdout').remove(); + }) + + getLogs('stdout'); + getLogs('stderr'); +}) diff --git a/templates/task.html b/templates/task.html index 177a4aa..b4a824d 100644 --- a/templates/task.html +++ b/templates/task.html @@ -7,9 +7,11 @@ + + Task {{.Name}} | Eremetic - +

+
+
+

STDOUT

+
+
+ no content +
+
+
+
+

STDERR

+
+ no content +
+
diff --git a/types/task.go b/types/task.go index 34a3a9c..84f5d24 100644 --- a/types/task.go +++ b/types/task.go @@ -31,6 +31,9 @@ type EremeticTask struct { Retry int `json:"retry"` CallbackURI string `json:"callback_uri"` URIs []string `json:"uris"` + SandboxPath string `json:"sandbox_path"` + AgentIP string `json:"agent_ip"` + AgentPort int32 `json:"agent_port"` } func NewEremeticTask(request Request, name string) (EremeticTask, error) {