Display deployment information dynamically

This CL introduces the concept of a "fidlbolt deployment" as a set of
git hashes and version strings that identify all source code responsible
for processing a fidlbolt request. Currently, this information includes
hashes for fidlbolt, fuchsia, and topaz, and a version for rustfmt.

The script copy_support_files.sh now writes fidlbolt_deployment.json.
The server passes this information with every response, and the frontend
uses it to show links and timestamps at the bottom of the Help window.
It is sent with every response rather than just queried once because
there is no reason to assume users will refresh soon after a deployment.

This CL also renames the fuchsia/ directory to support/ (and similarly
copy_fuchsia_files.sh to copy_support_files.sh) since this directory is
more general with the inclusion of fidlbolt_deployment.json and rustfmt.

Other small changes:

* Add CSS styling for links.

* Use FORCE_COLOR in watch.sh to get npm color output.

* Link to Monorail for bug reports instead of my email.

Change-Id: I5af577522c6b5ef04119913b2781bd7963edfc17
diff --git a/.gitignore b/.gitignore
index 88e9e94..4f61688 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
 node_modules
 elm-stuff
 dist
-fuchsia
+support
diff --git a/Dockerfile b/Dockerfile
index 9a2186b..fa9315c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -22,11 +22,11 @@
 RUN apk add --no-cache ca-certificates
 COPY --from=frontend /app/dist /static
 COPY --from=backend /app/server /server
-COPY fuchsia /fuchsia
+COPY support /support
 
 # Run the web server on container startup.
 CMD ["/server", \
     "-static", "/static", \
-    "-bin", "/fuchsia/bin", \
-    "-etc", "/fuchsia/etc", \
-    "-fidl", "/fuchsia/sdk/fidl:/fuchsia/zircon/system/fidl"]
+    "-bin", "/support/bin", \
+    "-etc", "/support/etc", \
+    "-fidl", "/support/sdk/fidl:/support/zircon/system/fidl"]
diff --git a/Makefile b/Makefile
index 1f9e0d2..ed22aad 100644
--- a/Makefile
+++ b/Makefile
@@ -2,8 +2,9 @@
 PORT ?= 8080
 VERBOSE ?= 0
 
-# Whether to run the server against copied fuchsia files.
-COPY ?= 0
+# If 0, use files directly from $FUCHSIA_DIR.
+# If 1, use files from ./support (see ./copy_support_files.sh).
+SUPPORT ?= 0
 
 # Detect host platform.
 UNAME_S := $(shell uname -s)
@@ -15,14 +16,14 @@
 $(error Unsupported platform $(UNAME_S))
 endif
 
-# Paths to FIDL files and binaries (either copies or in-tree paths).
-ifeq ($(COPY),1)
-ifeq ($(wildcard fuchsia),)
-$(error "No copied fuchsia files found. Run ./copy_fuchsia_files.sh")
+# Set paths to external resources used by the server.
+ifeq ($(SUPPORT),1)
+ifeq ($(wildcard support),)
+$(error "No ./support directory found. Run ./copy_support_files.sh")
 endif
-BIN := ./fuchsia/bin
-ETC := ./fuchsia/etc
-FIDL := ./fuchsia/sdk/fidl:fuchsia/zircon/system/fidl
+BIN := ./support/bin
+ETC := ./support/etc
+FIDL := ./support/sdk/fidl:./support/zircon/system/fidl
 else
 BUILD_DIR := $(FUCHSIA_DIR)/$(file < $(FUCHSIA_DIR)/.fx-build-dir)
 RUST_BIN := $(FUCHSIA_DIR)/prebuilt/third_party/rust/$(HOST_PLATFORM)/bin
@@ -47,8 +48,8 @@
 	@echo "    backend    Build the $(SERVER) server binary."
 	@echo "    run        Run the fidlbolt server on \$$PORT."
 	@echo "               Set VERBOSE=1 to enable verbose logging."
-	@echo "               Set COPY=1 to use files in ./fuchsia/ instead of"
-	@echo "               \$$FUCHSIA_DIR (see ./copy_fuchsia_files.sh)."
+	@echo "               Set SUPPORT=1 to use files in ./support instead of"
+	@echo "               \$$FUCHSIA_DIR (see ./copy_support_files.sh)."
 	@echo "               Note: rebuilds targets only if they do not exist."
 	@echo "               To ensure an up-to-date server: make && make run."
 	@echo "    format     Auto-format all code."
@@ -87,4 +88,4 @@
 clean:
 	rm -rf backend/dist/
 	rm -rf frontend/dist/
-	rm -rf fuchsia/
+	rm -rf support/
diff --git a/README.md b/README.md
index cd509a3..d33e2de 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
 4. Build and run with `make run`.
 5. View the app at `http://localhost:8080`.
 
-If you don't want changes in the fuchsia tree (e.g. rebuilding fidlc or changing FIDL libraries) to affect the running fidlbolt, run `./copy_fuchsia_files.sh` and use `make run COPY=1` instead.
+If you don't want changes in the Fuchsia tree (e.g. rebuilding fidlc or changing FIDL libraries) to affect the running fidlbolt, run `./copy_support_files.sh` and use `make run SUPPORT=1` instead.
 
 ## Contributing
 
@@ -55,7 +55,7 @@
 To build and run a new image locally:
 
 1. Install [Docker][]. On Debian-based systems, use `sudo apt-get install docker-ce`.
-2. Run `./copy_fuchsia_files.sh` to get the latest FIDL files and binaries from your Fuchsia repository.
+2. Run `./copy_support_files.sh` to get the latest FIDL files and binaries from your Fuchsia repository.
 3. `docker image build -t fidlbolt .`
 4. `docker container run --publish 8080:8080 --detach --name fb fidlbolt`
 
diff --git a/backend/server.go b/backend/server.go
index 99571a5..d91ccbb 100644
--- a/backend/server.go
+++ b/backend/server.go
@@ -20,6 +20,8 @@
 
 // A Server handles fidlbolt requests.
 type Server struct {
+	// Deployment information, or nil.
+	deployment *Deployment
 	// External programs used by fidlbolt.
 	fidlFormat, fidlLint, fidlc, fidlgenLlcpp, fidlgenHlcpp, fidlgenRust,
 	fidlgenGo, fidlgenDart, rustfmt program
@@ -35,6 +37,16 @@
 func NewServer(bin, etc, fidl []string) (*Server, error) {
 	var s Server
 	var err error
+	if deploymentFile, err := findFile("fidlbolt_deployment.json", etc); err == nil {
+		deploymentBytes, err := ioutil.ReadFile(deploymentFile)
+		if err != nil {
+			return nil, err
+		}
+		s.deployment = new(Deployment)
+		if err = json.Unmarshal(deploymentBytes, s.deployment); err != nil {
+			return nil, err
+		}
+	}
 	s.fidlFormat, err = findProgram("fidl-format", bin)
 	if err != nil {
 		return nil, err
@@ -100,6 +112,39 @@
 	Content string `json:"content"`
 	// List of annotations to apply to on the input.
 	Annotations []Annotation `json:"annotations"`
+	// Information about the deployment that served this response.
+	Deployment *Deployment `json:"deployment,omitempty"`
+}
+
+// A Deployment describes the provenance of all resources used to service a
+// request, including the fidlbolt server itself, external binaries, FIDL
+// libaries, configuration files, etc.
+type Deployment struct {
+	// This repository.
+	// https://fuchsia.googlesource.com/fidlbolt
+	Fidlbolt Commit `json:"fidlbolt"`
+	// Fuchsia provides most resources used by fidlbolt.
+	// https://fuchsia.googlesource.com/fuchsia
+	Fuchsia Commit `json:"fuchsia"`
+	// Topaz provides the fidlgen_dart binary.
+	// https://fuchsia.googlesource.com/topaz
+	Topaz Commit `json:"topaz"`
+	// Rustfmt provides the rustfmt binary.
+	// https://github.com/rust-lang/rustfmt
+	Rustfmt Version `json:"rustfmt"`
+}
+
+// A Commit designates a commit in a source code repository.
+type Commit struct {
+	// Full hash of the commit.
+	Hash string `json:"hash"`
+	// Unix timestamp of the commit, in seconds.
+	Timestamp uint64 `json:"timestamp"`
+}
+
+// A Version designates a software version by a string like "1.2.3".
+type Version struct {
+	Version string `json:"version"`
 }
 
 // Options are used to configure mode conversions.
@@ -256,6 +301,15 @@
 // Serve processes a request and serves a response. It returns an error if the
 // request could not be processed for some reason (e.g., an IO failure).
 func (s *Server) Serve(ctx context.Context, r *Request) (Response, error) {
+	res, err := s.handle(ctx, r)
+	if err != nil {
+		return Response{}, err
+	}
+	res.Deployment = s.deployment
+	return res, nil
+}
+
+func (s *Server) handle(ctx context.Context, r *Request) (Response, error) {
 	switch r.InputMode {
 	case FIDL:
 		return s.handleFIDL(ctx, r)
diff --git a/copy_fuchsia_files.sh b/copy_fuchsia_files.sh
deleted file mode 100755
index ac74aa0..0000000
--- a/copy_fuchsia_files.sh
+++ /dev/null
@@ -1,63 +0,0 @@
-#!/bin/bash
-
-set -eufo pipefail
-
-usage() {
-    cat <<EOS
-Usage: $0 [-h]
-
-This script copies files needed by fidlbolt from \$FUCHSIA_DIR into ./fuchsia.
-It creates the following directories:
-
-    ./fuchsia/bin                   binaries (fidlc, fidl-format, etc.)
-    ./fuchsia/etc                   configuration files
-    ./fuchsia/sdk/fidl              sdk fidl libraries
-    ./fuchsia/zircon/system/fidl    zircon fidl libraries
-EOS
-}
-
-die() {
-    echo "$0: $*" >&2
-    exit 1
-}
-
-while getopts "h" opt; do
-    case $opt in
-        h) usage; exit 0 ;;
-        *) exit 1 ;;
-    esac
-done
-shift $((OPTIND - 1))
-if [[ $# -gt 0 ]]; then
-    die "unexpected arguments: $*"
-fi
-
-if [[ -z "$FUCHSIA_DIR" ]]; then
-    die "FUCHSIA_DIR not set"
-fi
-
-# Source vars.sh for FUCHSIA_BUILD_DIR, ZIRCON_TOOLS_DIR, and PREBUILT_RUST_DIR.
-# (Reset shell options before sourcing since vars.sh does not expect them.)
-set +ufo pipefail
-source "$FUCHSIA_DIR/tools/devshell/lib/vars.sh" || exit $?
-fx-config-read
-set -ufo pipefail
-
-cd "$(dirname "$0")"
-rm -rf fuchsia/
-mkdir -p fuchsia/{bin,etc,sdk/fidl,zircon/system/fidl}
-cp \
-    "$FUCHSIA_BUILD_DIR/host_x64/"{fidlc,fidlgen_{llcpp,hlcpp,rust,go,dart}} \
-    "$ZIRCON_TOOLS_DIR/fidl-"{format,lint} \
-    "$PREBUILT_RUST_DIR/bin/rustfmt" \
-    fuchsia/bin
-cp \
-    "$FUCHSIA_DIR/rustfmt.toml" \
-    fuchsia/etc
-find "$FUCHSIA_DIR/sdk/fidl" -mindepth 1 -maxdepth 1 -type d -print0 \
-    | xargs -0 -I% cp -r % fuchsia/sdk/fidl
-find "$FUCHSIA_DIR/zircon/system/fidl" -mindepth 1 -maxdepth 1 -type d -print0 \
-    | xargs -0 -I% cp -r % fuchsia/zircon/system/fidl
-# Remove the non-fidl files. This is easier and less error-prone than
-# manipulating paths to call mkdir -p and only copying the .fidl files.
-find fuchsia/{sdk,zircon} -type f -not -name '*.fidl' -delete
diff --git a/copy_support_files.sh b/copy_support_files.sh
new file mode 100755
index 0000000..b74e560
--- /dev/null
+++ b/copy_support_files.sh
@@ -0,0 +1,100 @@
+#!/bin/bash
+
+set -eufo pipefail
+
+usage() {
+    cat <<EOS
+Usage: $0 [-h]
+
+This script copies files needed by fidlbolt from \$FUCHSIA_DIR into ./support.
+It creates the following directories:
+
+    ./support/bin                   binaries (fidlc, fidl-format, etc.)
+    ./support/etc                   configuration files
+    ./support/sdk/fidl              sdk fidl libraries
+    ./support/zircon/system/fidl    zircon fidl libraries
+
+It preserves the directory structure for FIDL libraries, rather than simply
+placing them all in a ./support/fidl directory, so that paths displayed in
+fidlbolt (e.g. import tooltips) resemble Fuchsia source tree paths.
+EOS
+}
+
+die() {
+    echo "$0: $*" >&2
+    exit 1
+}
+
+while getopts "h" opt; do
+    case $opt in
+        h) usage; exit 0 ;;
+        *) exit 1 ;;
+    esac
+done
+shift $((OPTIND - 1))
+if [[ $# -gt 0 ]]; then
+    die "unexpected arguments: $*"
+fi
+
+if [[ -z "$FUCHSIA_DIR" ]]; then
+    die "FUCHSIA_DIR not set"
+fi
+
+# Source vars.sh for FUCHSIA_BUILD_DIR, ZIRCON_TOOLS_DIR, and PREBUILT_RUST_DIR.
+# (Reset shell options before sourcing since vars.sh does not expect them.)
+set +ufo pipefail
+source "$FUCHSIA_DIR/tools/devshell/lib/vars.sh" || exit $?
+fx-config-read
+set -ufo pipefail
+
+cd "$(dirname "$0")"
+rm -rf support/
+mkdir -p support/{bin,etc,sdk/fidl,zircon/system/fidl}
+cp \
+    "$FUCHSIA_BUILD_DIR/host_x64/"{fidlc,fidlgen_{llcpp,hlcpp,rust,go,dart}} \
+    "$ZIRCON_TOOLS_DIR/fidl-"{format,lint} \
+    "$PREBUILT_RUST_DIR/bin/rustfmt" \
+    support/bin
+cp \
+    "$FUCHSIA_DIR/rustfmt.toml" \
+    support/etc
+find "$FUCHSIA_DIR/sdk/fidl" -mindepth 1 -maxdepth 1 -type d -print0 \
+    | xargs -0 -I% cp -r % support/sdk/fidl
+find "$FUCHSIA_DIR/zircon/system/fidl" -mindepth 1 -maxdepth 1 -type d -print0 \
+    | xargs -0 -I% cp -r % support/zircon/system/fidl
+# Remove the non-fidl files. This is easier and less error-prone than
+# manipulating paths to call mkdir -p and only copying the .fidl files.
+find support/{sdk,zircon} -type f -not -name '*.fidl' -delete
+
+hash() {
+    git rev-parse --verify HEAD
+}
+
+timestamp() {
+    git show -s --format=%ct HEAD
+}
+
+rustfmt_version=$(
+    "$PREBUILT_RUST_DIR/bin/rustfmt" --version \
+    | awk 'NR==1{sub(/-.*/, "", $2); print $2}'
+)
+
+cat <<EOS > support/etc/fidlbolt_deployment.json
+{
+    "fidlbolt": {
+        "hash": "$(hash)",
+        "timestamp": $(timestamp)
+    },
+    "fuchsia": {
+        "hash": "$(cd "$FUCHSIA_DIR" && hash)",
+        "timestamp": $(cd "$FUCHSIA_DIR" && timestamp)
+    },
+    "topaz": {
+        "hash": "$(cd "$FUCHSIA_DIR/topaz" && hash)",
+        "timestamp": $(cd "$FUCHSIA_DIR/topaz" && timestamp)
+    },
+    "rustfmt": {
+        "version": "$rustfmt_version"
+    }
+}
+EOS
diff --git a/frontend/src/elm/Deployment.elm b/frontend/src/elm/Deployment.elm
new file mode 100644
index 0000000..80dd4d0
--- /dev/null
+++ b/frontend/src/elm/Deployment.elm
@@ -0,0 +1,242 @@
+-- Copyright 2020 The Fuchsia Authors. All rights reserved.
+-- Use of this source code is governed by a BSD-style license that can be
+-- found in the LICENSE file.
+
+
+module Deployment exposing
+    ( Model
+    , Msg
+    , checkTime
+    , init
+    , initCmd
+    , subscriptions
+    , update
+    , view
+    )
+
+{-| This module produces a view describing the fidlbolt server deployment.
+
+This merits its own module because the deployment information is dynamic, and
+can change from one request to another.
+
+-}
+
+import DateFormat
+import DateFormat.Relative as Relative
+import Html exposing (Html)
+import Html.Attributes as Attributes
+import Json.Decode as Decode
+import Ports
+import Task
+import Time
+
+
+
+------------- MODEL ------------------------------------------------------------
+
+
+type alias Model =
+    { deployment : Maybe Deployment
+    , now : Maybe Time.Posix
+    }
+
+
+type alias Deployment =
+    { fidlbolt : Commit
+    , fuchsia : Commit
+    , topaz : Commit
+    , rustfmt : Version
+    }
+
+
+type alias Commit =
+    { hash : String
+    , timestamp : Time.Posix
+    }
+
+
+type alias Version =
+    { version : String
+    }
+
+
+init : Model
+init =
+    { deployment = Nothing
+    , now = Nothing
+    }
+
+
+initCmd : Cmd Msg
+initCmd =
+    checkTime
+
+
+
+------------- UPDATE -----------------------------------------------------------
+
+
+type Msg
+    = NoOp
+    | SetDeployment Deployment
+    | SetNow Time.Posix
+
+
+update : Msg -> Model -> Model
+update msg model =
+    case msg of
+        NoOp ->
+            model
+
+        SetDeployment deployment ->
+            { model | deployment = Just deployment }
+
+        SetNow now ->
+            { model | now = Just now }
+
+
+checkTime : Cmd Msg
+checkTime =
+    Task.perform SetNow Time.now
+
+
+
+------------- SUBSCRIPTIONS ----------------------------------------------------
+
+
+subscriptions : Sub Msg
+subscriptions =
+    Ports.deploymentUpdated NoOp (Decode.map SetDeployment decodeDeployment)
+
+
+
+------------- VIEW -------------------------------------------------------------
+
+
+view : Model -> Html msg
+view model =
+    case ( model.deployment, model.now ) of
+        ( Just deployment, Just now ) ->
+            deploymentView deployment now
+
+        _ ->
+            emptyView
+
+
+emptyView : Html msg
+emptyView =
+    Html.div []
+        [ Html.p []
+            [ Html.text "(No deployment information)" ]
+        ]
+
+
+deploymentView : Deployment -> Time.Posix -> Html msg
+deploymentView deployment now =
+    let
+        commitView name makeUrl commit =
+            [ Html.text name
+            , Html.text " commit "
+            , Html.a [ Attributes.href (makeUrl commit) ]
+                [ Html.text (String.left 12 commit.hash) ]
+            , Html.text " ("
+            , timestampView now commit.timestamp
+            , Html.text ")"
+            ]
+    in
+    Html.div []
+        [ Html.p []
+            [ Html.text "This instance of fidlbolt was built with:" ]
+        , Html.ul [ Attributes.class "close-list" ]
+            [ Html.li [] (commitView "fidlbolt" fidlboltUrl deployment.fidlbolt)
+            , Html.li [] (commitView "fuchsia" fuchsiaUrl deployment.fuchsia)
+            , Html.li [] (commitView "topaz" topazUrl deployment.topaz)
+            , Html.li [] (versionView "rustfmt" rustfmtUrl deployment.rustfmt)
+            ]
+        ]
+
+
+versionView : String -> (Version -> String) -> Version -> List (Html msg)
+versionView name makeUrl version =
+    [ Html.text name
+    , Html.text " version "
+    , Html.a [ Attributes.href (makeUrl version) ]
+        [ Html.text version.version ]
+    ]
+
+
+timestampView : Time.Posix -> Time.Posix -> Html msg
+timestampView now timestamp =
+    Html.span
+        [ Attributes.title (formatUtc timestamp) ]
+        [ Html.text (Relative.relativeTime now timestamp) ]
+
+
+formatUtc : Time.Posix -> String
+formatUtc =
+    DateFormat.format
+        [ DateFormat.monthNameAbbreviated
+        , DateFormat.text " "
+        , DateFormat.dayOfMonthNumber
+        , DateFormat.text ", "
+        , DateFormat.yearNumber
+        , DateFormat.text ", "
+        , DateFormat.hourNumber
+        , DateFormat.text ":"
+        , DateFormat.minuteFixed
+        , DateFormat.text " "
+        , DateFormat.amPmUppercase
+        , DateFormat.text " UTC"
+        ]
+        Time.utc
+
+
+fidlboltUrl : Commit -> String
+fidlboltUrl commit =
+    "http://fuchsia.googlesource.com/fidlbolt/+/" ++ commit.hash
+
+
+fuchsiaUrl : Commit -> String
+fuchsiaUrl commit =
+    "http://fuchsia.googlesource.com/fuchsia/+/" ++ commit.hash
+
+
+topazUrl : Commit -> String
+topazUrl commit =
+    "https://fuchsia.googlesource.com/topaz/+/" ++ commit.hash
+
+
+rustfmtUrl : Version -> String
+rustfmtUrl version =
+    "https://github.com/rust-lang/rustfmt/releases/tag/v" ++ version.version
+
+
+
+------------- ENCODE / DECODE --------------------------------------------------
+
+
+decodeDeployment : Decode.Decoder Deployment
+decodeDeployment =
+    Decode.map4 Deployment
+        (Decode.field "fidlbolt" decodeCommit)
+        (Decode.field "fuchsia" decodeCommit)
+        (Decode.field "topaz" decodeCommit)
+        (Decode.field "rustfmt" decodeVersion)
+
+
+decodeCommit : Decode.Decoder Commit
+decodeCommit =
+    Decode.map2 Commit
+        (Decode.field "hash" Decode.string)
+        (Decode.field "timestamp" decodeTimestamp)
+
+
+decodeTimestamp : Decode.Decoder Time.Posix
+decodeTimestamp =
+    Decode.map (\t -> Time.millisToPosix (t * 1000)) Decode.int
+
+
+decodeVersion : Decode.Decoder Version
+decodeVersion =
+    Decode.map Version
+        (Decode.field "version" Decode.string)
diff --git a/frontend/src/elm/Main.elm b/frontend/src/elm/Main.elm
index 61ebea2..ba14c5e 100644
--- a/frontend/src/elm/Main.elm
+++ b/frontend/src/elm/Main.elm
@@ -9,6 +9,7 @@
 -}
 
 import Browser
+import Deployment
 import Editors
 import Html exposing (Html)
 import Html.Attributes as Attributes
@@ -41,7 +42,8 @@
 
 
 type alias Model =
-    { editors : Editors.Model
+    { deployment : Deployment.Model
+    , editors : Editors.Model
     , settings : Settings.Model
     , split : SplitPane.Model
     , window : Window.Model Window
@@ -98,14 +100,16 @@
 initCmd : Model -> Cmd Msg
 initCmd model =
     Cmd.batch
-        [ Editors.initCmd model.editors
-        , Settings.initCmd model.settings
+        [ Cmd.map DeploymentMsg Deployment.initCmd
+        , Cmd.map EditorMsg (Editors.initCmd model.editors)
+        , Cmd.map SettingsMsg (Settings.initCmd model.settings)
         ]
 
 
 defaultModel : Settings.Scheme -> Model
 defaultModel scheme =
-    { editors = Editors.init
+    { deployment = Deployment.init
+    , editors = Editors.init
     , split = SplitPane.init SplitPane.Vertical 0.5 splitId
     , settings = Settings.init scheme
     , window = Window.init
@@ -122,7 +126,8 @@
 
 
 type Msg
-    = EditorMsg Editors.Msg
+    = DeploymentMsg Deployment.Msg
+    | EditorMsg Editors.Msg
     | SettingsMsg Settings.Msg
     | SplitMsg SplitPane.Msg
     | WindowMsg (Window.Msg Window)
@@ -131,6 +136,16 @@
 update : Msg -> Model -> ( Model, Cmd Msg )
 update msg model =
     case msg of
+        DeploymentMsg deploymentMsg ->
+            let
+                newDeployment =
+                    Deployment.update deploymentMsg model.deployment
+
+                newModel =
+                    { model | deployment = newDeployment }
+            in
+            ( newModel, Cmd.none )
+
         EditorMsg editorMsg ->
             let
                 ( newEditors, editorCmd ) =
@@ -200,9 +215,19 @@
 
                 newModel =
                     { model | window = newWindow }
+
+                cmds =
+                    [ Ports.setMainTabEnabled (Window.isAbsent newWindow)
+                    , case windowMsg of
+                        Window.Show HelpWindow ->
+                            Cmd.map DeploymentMsg Deployment.checkTime
+
+                        _ ->
+                            Cmd.none
+                    ]
             in
             ( newModel
-            , Ports.setMainTabEnabled (Window.isAbsent newWindow)
+            , Cmd.batch cmds
             )
 
 
@@ -218,7 +243,8 @@
 subscriptions : Model -> Sub Msg
 subscriptions model =
     Sub.batch
-        [ Sub.map SplitMsg (SplitPane.subscriptions model.split)
+        [ Sub.map DeploymentMsg Deployment.subscriptions
+        , Sub.map SplitMsg (SplitPane.subscriptions model.split)
         , Sub.map WindowMsg (Window.subscriptions model.window)
         ]
 
@@ -271,7 +297,7 @@
         HelpWindow ->
             Html.div []
                 [ Html.h2 [] [ Html.text "Help" ]
-                , helpMessage model.split.orientation
+                , helpMessage model.deployment model.split.orientation
                 ]
 
         SettingsWindow ->
@@ -318,8 +344,8 @@
         ]
 
 
-helpMessage : SplitPane.Orientation -> Html msg
-helpMessage orientation =
+helpMessage : Deployment.Model -> SplitPane.Orientation -> Html msg
+helpMessage deployment orientation =
     let
         text =
             Html.text
@@ -414,24 +440,32 @@
 clicking “Reset all data” in Settings.
 """
                 ]
-            ]
-        , Html.li []
-            [ text "To "
-            , bold "close"
-            , text " this help window, press "
-            , kbd [ "Esc" ]
-            , text " or "
-            , kbd [ "Enter" ]
-            , text "."
+            , Html.li []
+                [ text "To "
+                , bold "close"
+                , text " this help window, press "
+                , kbd [ "Esc" ]
+                , text " or "
+                , kbd [ "Enter" ]
+                , text "."
+                ]
             ]
         , Html.p []
-            [ multiline """
-Enjoy! If you find a bug, please report it to mkember@google.com.
-"""
+            [ text "Enjoy! If you find a bug, please "
+            , Html.a [ Attributes.href bugReportUrl ]
+                [ text "report it here" ]
+            , text "."
             ]
+        , Html.hr [] []
+        , Deployment.view deployment
         ]
 
 
+bugReportUrl : String
+bugReportUrl =
+    "https://bugs.fuchsia.dev/p/fuchsia/issues/entry?components=FIDL%3EFidlbolt"
+
+
 
 ------------- ENCODE / DECODE --------------------------------------------------
 
@@ -447,7 +481,8 @@
 
 decode : Decode.Decoder Model
 decode =
-    Decode.map4 Model
+    Decode.map5 Model
+        (Decode.succeed Deployment.init)
         (Decode.field "editors" Editors.decode)
         (Decode.field "settings" Settings.decode)
         (Decode.field "split" (SplitPane.decode splitId))
diff --git a/frontend/src/elm/Ports.elm b/frontend/src/elm/Ports.elm
index 702d0d1..1e1a7c3 100644
--- a/frontend/src/elm/Ports.elm
+++ b/frontend/src/elm/Ports.elm
@@ -6,6 +6,7 @@
 port module Ports exposing
     ( applySettings
     , clearDataAndReload
+    , deploymentUpdated
     , persistModel
     , resizeEditors
     , setMainTabEnabled
@@ -15,17 +16,22 @@
 {-| This module defines ports for communicating with JavaScript.
 -}
 
+import Json.Decode as Decode
 import Json.Encode as Encode
 
 
-{-| Sends a value to JavaScript. This is the only port.
+
+------------- OUTGOING -----------------------------------------------------------
+
+
+{-| Sends a value to JavaScript. This is the only outgoing port.
 -}
-port out : Encode.Value -> Cmd msg
+port toJS : Encode.Value -> Cmd msg
 
 
 command : String -> Encode.Value -> Cmd msg
 command cmd value =
-    out <|
+    toJS <|
         Encode.object
             [ ( "command", Encode.string cmd )
             , ( "payload", value )
@@ -65,3 +71,42 @@
 resizeEditors : Cmd msg
 resizeEditors =
     command "resizeEditors" Encode.null
+
+
+
+------------- INCOMING ---------------------------------------------------------
+
+
+{-| Receives a value from JavaScript. This is the only input port.
+-}
+port fromJS : (Decode.Value -> msg) -> Sub msg
+
+
+subscribe : String -> msg -> Decode.Decoder msg -> Sub msg
+subscribe cmd noOp decodeMsg =
+    let
+        decode =
+            Decode.field "command" Decode.string
+                |> Decode.andThen
+                    (\passedCmd ->
+                        if passedCmd == cmd then
+                            Decode.field "payload" decodeMsg
+
+                        else
+                            Decode.fail "Message is not for us"
+                    )
+
+        handler value =
+            case value |> Decode.decodeValue decode of
+                Err _ ->
+                    noOp
+
+                Ok message ->
+                    message
+    in
+    fromJS handler
+
+
+deploymentUpdated : msg -> Decode.Decoder msg -> Sub msg
+deploymentUpdated =
+    subscribe "deploymentUpdated"
diff --git a/frontend/src/elm/elm.json b/frontend/src/elm/elm.json
index 9e36cc2..808dea1 100644
--- a/frontend/src/elm/elm.json
+++ b/frontend/src/elm/elm.json
@@ -9,10 +9,11 @@
             "elm/browser": "1.0.2",
             "elm/core": "1.0.4",
             "elm/html": "1.0.0",
-            "elm/json": "1.1.3"
+            "elm/json": "1.1.3",
+            "elm/time": "1.0.0",
+            "ryannhg/date-format": "2.3.0"
         },
         "indirect": {
-            "elm/time": "1.0.0",
             "elm/url": "1.0.0",
             "elm/virtual-dom": "1.0.2"
         }
diff --git a/frontend/src/evaluator.ts b/frontend/src/evaluator.ts
index db94fea..9d28ec7 100644
--- a/frontend/src/evaluator.ts
+++ b/frontend/src/evaluator.ts
@@ -38,6 +38,10 @@
   >
 >;
 
+// The server sends deployment information with every response. There is no need
+// to specify its fields here because we just forward it to Elm.
+type Deployment = object;
+
 // Evaluator sends requests to the server to update the output.
 export class Evaluator {
   readonly editors: Editors;
@@ -49,6 +53,7 @@
   lastInput: InputMap;
   active?: Omit<Request, 'content'>;
   loaderTimeout?: Timeout;
+  deploymentCallback?: (deployment: Deployment) => void;
 
   constructor(editors: Editors, saveFn: VoidFunction) {
     this.editors = editors;
@@ -59,6 +64,11 @@
     this.stopFn = undefined;
     this.loaderElement = document.getElementById('Loader') as HTMLElement;
     this.loaderTimeout = undefined;
+    this.deploymentCallback = undefined;
+  }
+
+  onDeloymentUpdated(callback: (deployment: Deployment) => void) {
+    this.deploymentCallback = callback;
   }
 
   setActive(inputMode: InputMode, outputMode: OutputMode, options: Options) {
@@ -157,7 +167,15 @@
     }
 
     this.startLoadingAnimation();
-    const finish = (content: string, anns?: Annotation[], error?: string) => {
+    const finish = (
+      content: string,
+      anns?: Annotation[],
+      deployment?: object,
+      error?: string
+    ) => {
+      if (deployment != undefined && this.deploymentCallback != undefined) {
+        this.deploymentCallback(deployment);
+      }
       this.editors.setOutput(
         request.inputMode,
         request.outputMode,
@@ -181,12 +199,17 @@
         })
       );
     } catch (error) {
-      finish(error.message, [], 'Network Error');
+      finish(error.message, [], undefined, 'Network Error');
       return;
     }
     if (response.ok) {
       const json = await response.json();
-      finish(json.content, json.annotations, json.ok ? undefined : 'Error');
+      finish(
+        json.content,
+        json.annotations,
+        json.deployment,
+        json.ok ? undefined : 'Error'
+      );
     } else {
       let msg;
       if (response.headers.get('Content-Type')?.startsWith('text/plain')) {
@@ -197,7 +220,7 @@
           msg += ' ' + response.statusText;
         }
       }
-      finish(msg, [], 'Server Error');
+      finish(msg, [], undefined, 'Server Error');
     }
   }
 
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 43643a5..cf9dd6e 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -44,7 +44,14 @@
     save('editors', editors.toJSON());
   });
 
-  app.ports.out.subscribe(({command, payload}: ElmMessage) => {
+  evaluator.onDeloymentUpdated(deployment => {
+    app.ports.fromJS.send({
+      command: 'deploymentUpdated',
+      payload: deployment,
+    });
+  });
+
+  app.ports.toJS.subscribe(({command, payload}: ElmMessage) => {
     switch (command) {
       case 'persistModel': {
         save('model', payload);
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 0e4ff32..894c79c 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -11,6 +11,7 @@
   --intense: #fff;
   --intense-hover: #e3e3e3;
   --border: #666;
+  --link: #06d;
 
   font: 16px Helvetica, sans-serif;
   color: var(--fg);
@@ -25,6 +26,7 @@
   --intense: #111;
   --intense-hover: #444;
   --border: #aaa;
+  --link: #6af;
 }
 
 h1 {
@@ -39,6 +41,15 @@
   margin: 30px 0;
 }
 
+a {
+  text-decoration: none;
+  color: var(--link);
+}
+
+a:hover {
+  text-decoration: underline;
+}
+
 p {
   margin: 0 0 1em 0;
   line-height: 1.5;
@@ -56,6 +67,10 @@
   line-height: 1.5;
 }
 
+.close-list li {
+  margin: 0;
+}
+
 kbd {
   display: inline-block;
   margin: 0 1px;
@@ -71,6 +86,13 @@
   white-space: nowrap;
 }
 
+hr {
+  border: none;
+  border-bottom: 1px solid var(--border);
+  margin: 30px auto;
+  padding: 0;
+}
+
 button {
   font: inherit;
   color: inherit;
diff --git a/watch.sh b/watch.sh
index 63d620c..51479dc 100755
--- a/watch.sh
+++ b/watch.sh
@@ -7,7 +7,7 @@
 
 usage() {
     cat <<EOS
-Usage: $0 [-hbc]
+Usage: $0 [-hbs]
 
 This script watches code in frontend/ and backend/ for changes, automatically
 rebuilding and (for backend changes) restarting the server.
@@ -16,7 +16,7 @@
 
     -h  Display this help message.
     -b  Backend-only (do not watch frontend/).
-    -c  Run the server with COPY=1 (see 'make help' for details).
+    -s Run the server with SUPPORT=1 (see 'make help' for details).
 EOS
 }
 
@@ -43,13 +43,13 @@
 }
 
 watch_frontend=true
-copy=0
+support=0
 
-while getopts "hbc" opt; do
+while getopts "hbs" opt; do
     case $opt in
         h) usage; exit 0 ;;
         b) watch_frontend=false ;;
-        c) copy=1 ;;
+        s) support=1 ;;
         *) exit 1 ;;
     esac
 done
@@ -58,20 +58,25 @@
     die "unexpected arguments: $*"
 fi
 
-if ! command -v inotifywait &>/dev/null; then
+if ! command -v inotifywait &> /dev/null; then
     die "inotifywait not found"
 fi
 
 cd "$(dirname "$0")"
 
 if [[ "$watch_frontend" == true ]]; then
-    npm run --prefix frontend watch 2>&1 | prefix_npm &
+    # Unless NO_COLOR is set, pass FORCE_COLOR to npm (which would otherwise
+    # disable color because its output is piped).
+    color=1
+    [[ -n "${NO_COLOR+x}" ]] && color=0
+    FORCE_COLOR=$color npm run --prefix frontend watch 2>&1 | prefix_npm &
 fi
 
 rebuild() {
+    # Kill the "make run" process whose parent is this script.
     pkill -x make -P $$ 2>&1 | prefix_watch || :
     if make backend 2>&1 | prefix_watch; then
-        make run COPY=$copy 2>&1 | prefix_server &
+        make run SUPPORT=$support 2>&1 | prefix_server &
     fi
 }