internal/relui: improve task result formatting

Format most result types, somewhat. This implementation should be
reusable when formatting workflow outputs in the future.

For golang/go#53382

Change-Id: I4734cdefff85a37cb1047a6689411139f2ebfbc1
Reviewed-on: https://go-review.googlesource.com/c/build/+/412677
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
Run-TryBot: Alex Rakoczy <alex@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/internal/relui/static/styles.css b/internal/relui/static/styles.css
index b2de074..56a0469 100644
--- a/internal/relui/static/styles.css
+++ b/internal/relui/static/styles.css
@@ -215,6 +215,7 @@
   font-size: 0.8125rem;
   margin: 0;
   padding: 1rem 0;
+  vertical-align: top;
 }
 .TaskList-itemLogLine {
   font-family: monospace;
@@ -235,6 +236,51 @@
   font-size: 0.8125rem;
   font-weight: bold;
 }
+.TaskList-itemActions {
+  width: 12.8125rem;
+}
+.TaskList-itemResult {
+  width: 5rem;
+}
+.TaskList-itemResultDetail {
+  border: 0.0625rem solid #ccc;
+  border-top: 0;
+  max-width: 17.8125rem;
+  vertical-align: top;
+  width: 17.8125rem;
+}
+.TaskList-itemResultDetailList {
+  margin: 0;
+}
+.TaskList-itemResultTerm {
+  background: #fafafa;
+  border-top: 0.0625rem solid #ccc;
+  font-size: 0.75rem;
+  padding: 0.1875rem 0.25rem 0;
+}
+.TaskList-itemResultTerm:nth-of-type(even) {
+  background: #f8f8f8;
+}
+.TaskList-itemResultDefinition {
+  background: #fafafa;
+  font-size: 0.875rem;
+  margin: 0;
+  min-height: 1.875rem;
+  padding: 0.5rem 0.375rem;
+}
+.TaskList-itemResultDefinition:nth-of-type(even) {
+  background: #f8f8f8;
+}
+.TaskList-itemResultDefinition--string {
+  display: block;
+  -webkit-line-clamp: 3;
+  max-height: 7.5rem;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.TaskList-itemResultArtifactSize {
+  float: right;
+}
 .Button {
   background: #375eab;
   border: none;
diff --git a/internal/relui/templates/task_list.html b/internal/relui/templates/task_list.html
index 428fa36..c198478 100644
--- a/internal/relui/templates/task_list.html
+++ b/internal/relui/templates/task_list.html
@@ -18,6 +18,7 @@
       </tr>
     </thead>
     {{range $task := .Tasks}}
+      {{ $resultDetail := unmarshalResultDetail $task.Result.String }}
       <tbody>
         <tr class="TaskList-item TaskList-itemSummary TaskList-expandableItem">
           <td class="TaskList-itemCol TaskList-itemExpand">
@@ -53,7 +54,7 @@
             {{$task.UpdatedAt.UTC.Format "Mon Jan _2 2006 15:04:05"}}
           </td>
           <td class="TaskList-itemCol TaskList-itemResult">
-            {{$task.Result.String}}
+            {{ $resultDetail.Kind }}
           </td>
           <td class="TaskList-itemCol TaskList-itemAction">
             {{if $task.Error.Valid}}
@@ -75,7 +76,7 @@
           </td>
         </tr>
         <tr class="TaskList-itemLogsRow">
-          <td class="TaskList-itemLogs" colspan="7">
+          <td class="TaskList-itemLogs" colspan="5">
             {{if $task.Error.Valid}}
               <div class="TaskList-itemLogLine TaskList-itemLogLineError">
                 {{- $task.Error.Value -}}
@@ -83,12 +84,76 @@
             {{end}}
             {{range $log := index $.TaskLogs $task.Name}}
               <div class="TaskList-itemLogLine">
-                {{- $log.CreatedAt.UTC.Format "2006/01/02 15:04:05"}} {{$log.Body -}}
+                {{- printf "%s %s" ($log.CreatedAt.UTC.Format "2006/01/02 15:04:05") $log.Body -}}
               </div>
             {{end}}
+            {{if $task.Result.Valid}}
+              <div class="TaskList-itemLogLine">
+                {{- $task.Result.String -}}
+              </div>
+            {{end}}
+          </td>
+          <td class="TaskList-itemResultDetail" colspan="2">
+            {{template "itemResult" $resultDetail}}
           </td>
         </tr>
       </tbody>
     {{end}}
   </table>
 {{end}}
+
+{{define "itemResult"}}
+  {{$resultDetail := .}}
+  {{if eq $resultDetail.Kind "Artifact"}}
+    <dl class="TaskList-itemResultDetailList">
+      <dt class="TaskList-itemResultTerm">Name</dt>
+      <dd class="TaskList-itemResultDefinition">
+          {{$resultDetail.Artifact.Target.Name}}
+      </dd>
+      <dt class="TaskList-itemResultTerm">Filename</dt>
+      <dd class="TaskList-itemResultDefinition">
+          {{$resultDetail.Artifact.Filename}}
+      </dd>
+      <dt class="TaskList-itemResultTerm">ScratchPath</dt>
+      <dd class="TaskList-itemResultDefinition">
+          {{$resultDetail.Artifact.ScratchPath}}
+      </dd>
+      <dt class="TaskList-itemResultTerm">StagingPath</dt>
+      <dd class="TaskList-itemResultDefinition">
+          {{$resultDetail.Artifact.StagingPath}}
+      </dd>
+    </dl>
+  {{else if eq $resultDetail.Kind "Artifacts"}}
+    <dt class="TaskList-itemResultTerm">Filenames</dt>
+    <dd class="TaskList-itemResultDefinition">
+        {{range $artifact := $resultDetail.Artifacts}}
+            {{$artifact.Filename}} <span class="TaskList-itemResultArtifactSize">{{prettySize $artifact.Size}}</span>
+        {{end}}
+    </dd>
+  {{else if eq $resultDetail.Kind "Outputs"}}
+    {{range $key, $value := $resultDetail.Outputs}}
+      <dt class="TaskList-itemResultTerm">
+        {{$key}}
+      </dt>
+      <dd class="TaskList-itemResultDefinition">
+        {{template "itemResult" $value}}
+      </dd>
+    {{end}}
+  {{else if eq $resultDetail.Kind "JSON"}}
+    {{range $key, $value := $resultDetail.JSON}}
+      <dt class="TaskList-itemResultTerm">
+        {{$key}}
+        </dt>
+      <dd class="TaskList-itemResultDefinition">
+        {{$value}}
+      </dd>
+    {{end}}
+  {{else if eq $resultDetail.Kind "String"}}
+    <dt class="TaskList-itemResultTerm">
+      String
+    </dt>
+    <dd class="TaskList-itemResultDefinition TaskList-itemResultDefinition--string">
+      {{$resultDetail.String}}
+    </dd>
+  {{end}}
+{{end}}
diff --git a/internal/relui/web.go b/internal/relui/web.go
index 8c765c1..b44de81 100644
--- a/internal/relui/web.go
+++ b/internal/relui/web.go
@@ -19,6 +19,7 @@
 	"net/http"
 	"net/url"
 	"path"
+	"reflect"
 	"strings"
 	"time"
 
@@ -77,8 +78,10 @@
 		header:  header,
 	}
 	helpers := map[string]interface{}{
-		"baseLink":  s.BaseLink,
-		"hasPrefix": strings.HasPrefix,
+		"baseLink":              s.BaseLink,
+		"hasPrefix":             strings.HasPrefix,
+		"prettySize":            prettySize,
+		"unmarshalResultDetail": unmarshalResultDetail,
 	}
 	s.templates = template.Must(template.New("").Funcs(helpers).ParseFS(templates, "templates/*.html"))
 	s.homeTmpl = s.mustLookup("home.html")
@@ -353,3 +356,66 @@
 	}
 	http.Redirect(w, r, s.BaseLink("/"), http.StatusSeeOther)
 }
+
+// resultDetail contains unmarshalled results from a workflow task, or
+// workflow output. Only one field is expected to be populated.
+//
+// The UI implementation uses Kind to determine which result type to
+// render.
+type resultDetail struct {
+	Artifact  artifact
+	Artifacts []artifact
+	Outputs   map[string]*resultDetail
+	JSON      map[string]interface{}
+	String    string
+	Unknown   interface{}
+}
+
+func (r *resultDetail) Kind() string {
+	v := reflect.ValueOf(r)
+	if v.IsZero() {
+		return ""
+	}
+	v = v.Elem()
+	for i := 0; i < v.NumField(); i++ {
+		if v.Field(i).IsZero() {
+			continue
+		}
+		return v.Type().Field(i).Name
+	}
+	return ""
+}
+
+func (r *resultDetail) UnmarshalJSON(result []byte) error {
+	v := reflect.ValueOf(r).Elem()
+	for i := 0; i < v.NumField(); i++ {
+		f := v.Field(i)
+		if err := json.Unmarshal(result, f.Addr().Interface()); err == nil {
+			if f.IsZero() {
+				continue
+			}
+			return nil
+		}
+	}
+	return errors.New("unknown result type")
+}
+
+func unmarshalResultDetail(result string) *resultDetail {
+	ret := new(resultDetail)
+	if err := json.Unmarshal([]byte(result), &ret); err != nil {
+		ret.String = err.Error()
+	}
+	return ret
+}
+
+func prettySize(size int) string {
+	const mb = 1 << 20
+	if size == 0 {
+		return ""
+	}
+	if size < mb {
+		// All Go releases are >1mb, but handle this case anyway.
+		return fmt.Sprintf("%v bytes", size)
+	}
+	return fmt.Sprintf("%.0fMiB", float64(size)/mb)
+}
diff --git a/internal/relui/web_test.go b/internal/relui/web_test.go
index 7ce6d9a..c3c72f0 100644
--- a/internal/relui/web_test.go
+++ b/internal/relui/web_test.go
@@ -753,3 +753,60 @@
 		})
 	}
 }
+
+func TestResultDetail(t *testing.T) {
+	cases := []struct {
+		desc     string
+		input    string
+		want     *resultDetail
+		wantKind string
+	}{
+		{
+			desc:     "string",
+			input:    `"hello"`,
+			want:     &resultDetail{String: "hello"},
+			wantKind: "String",
+		},
+		{
+			desc:     "nested json string",
+			input:    `{"SomeOutput": "hello"}`,
+			want:     &resultDetail{Outputs: map[string]*resultDetail{"SomeOutput": {String: "hello"}}},
+			wantKind: "Outputs",
+		},
+		{
+			desc:     "nested json complex",
+			input:    `{"SomeOutput": {"Filename": "go.exe"}}`,
+			want:     &resultDetail{Outputs: map[string]*resultDetail{"SomeOutput": {Artifact: artifact{Filename: "go.exe"}}}},
+			wantKind: "Outputs",
+		},
+		{
+			desc:     "nested json slice",
+			input:    `{"SomeOutput": [{"Filename": "go.exe"}]}`,
+			want:     &resultDetail{Outputs: map[string]*resultDetail{"SomeOutput": {Artifacts: []artifact{{Filename: "go.exe"}}}}},
+			wantKind: "Outputs",
+		},
+		{
+			desc:     "nested json output",
+			input:    `{"SomeOutput": {"OtherOutput": "go.exe"}}`,
+			want:     &resultDetail{Outputs: map[string]*resultDetail{"SomeOutput": {Outputs: map[string]*resultDetail{"OtherOutput": {String: "go.exe"}}}}},
+			wantKind: "Outputs",
+		},
+		{
+			desc:  "null json",
+			input: `null`,
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.desc, func(t *testing.T) {
+			got := unmarshalResultDetail(c.input)
+
+			if got.Kind() != c.wantKind {
+				t.Errorf("got.Kind() = %q, wanted %q", got.Kind(), c.wantKind)
+			}
+			if diff := cmp.Diff(c.want, got); diff != "" {
+				t.Errorf("unmarshalResultDetail mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+
+}