diff --git a/.circleci/config.yml b/.circleci/config.yml
index 55ebd7ea..bf756707 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -13,7 +13,7 @@ version: 2
 jobs:
   full:
     docker:
-      - image: cimg/base:2020.01
+      - image: cimg/base:2024.05
         user: root
     steps:
       - setup_remote_docker
@@ -56,7 +56,7 @@ jobs:
       - run:
           name: Run the tests
           command: |
-            docker-compose \
+            docker compose \
               -f docker/docker-compose.ci.yml \
               -p landoui \
               run lando-ui pytest --junitxml=/test_results/junit.xml
@@ -74,7 +74,7 @@ jobs:
       - store_artifacts:
           path: /tmp/test-reports
 
-      - deploy:
+      - run:
           command: |
             if [[ "x$DOCKERHUB_REPO" != x ]]; then
               docker login -u "$DOCKER_USER" -p "$DOCKER_PASS"
@@ -85,11 +85,11 @@ jobs:
                 docker tag "mozilla/landoui" "${DOCKERHUB_REPO}:${CIRCLE_TAG}"
                 docker push "${DOCKERHUB_REPO}:${CIRCLE_TAG}"
               elif [[ ${CIRCLE_BRANCH} == develop ]]; then
-                docker tag "mozilla/landoui" "${DOCKERHUB_REPO}:develop-latest"
-                docker push "${DOCKERHUB_REPO}:develop-latest"
+                docker tag "mozilla/landoui" "${DOCKERHUB_REPO}:develop-${CIRCLE_SHA1}"
+                docker push "${DOCKERHUB_REPO}:develop-${CIRCLE_SHA1}"
               elif [[ ${CIRCLE_BRANCH} == staging ]]; then
-                docker tag "mozilla/landoui" "${DOCKERHUB_REPO}:staging-latest"
-                docker push "${DOCKERHUB_REPO}:staging-latest"
+                docker tag "mozilla/landoui" "${DOCKERHUB_REPO}:staging-${CIRCLE_SHA1}"
+                docker push "${DOCKERHUB_REPO}:staging-${CIRCLE_SHA1}"
               fi
             fi
 
diff --git a/landoui/app.py b/landoui/app.py
index b086ec78..3d754a8e 100644
--- a/landoui/app.py
+++ b/landoui/app.py
@@ -54,6 +54,7 @@ def create_app(
     use_https: bool,
     enable_asset_pipeline: bool,
     lando_api_url: str,
+    treestatus_url: str,
     debug: bool = False,
 ) -> Flask:
     """
@@ -85,6 +86,7 @@ def create_app(
     initialize_sentry(version_info["version"])
 
     set_config_param(app, "LANDO_API_URL", lando_api_url)
+    set_config_param(app, "TREESTATUS_URL", treestatus_url)
     set_config_param(
         app, "BUGZILLA_URL", _lookup_service_url(lando_api_url, "bugzilla")
     )
@@ -121,10 +123,12 @@ def create_app(
     from landoui.pages import pages
     from landoui.revisions import revisions
     from landoui.dockerflow import dockerflow
+    from landoui.treestatus import treestatus_blueprint
 
     app.register_blueprint(pages)
     app.register_blueprint(revisions)
     app.register_blueprint(dockerflow)
+    app.register_blueprint(treestatus_blueprint)
 
     # Register template helpers
     from landoui.template_helpers import template_helpers
diff --git a/landoui/assets_app.py b/landoui/assets_app.py
index 5ed12db0..66eec5c0 100644
--- a/landoui/assets_app.py
+++ b/landoui/assets_app.py
@@ -22,5 +22,6 @@
     use_https=False,
     enable_asset_pipeline=True,
     lando_api_url="http://lando-api.test",
+    treestatus_url="http://treestatus.test",
     debug=True,
 )
diff --git a/landoui/assets_src/css/pages/TreestatusPage.scss b/landoui/assets_src/css/pages/TreestatusPage.scss
new file mode 100644
index 00000000..e1669371
--- /dev/null
+++ b/landoui/assets_src/css/pages/TreestatusPage.scss
@@ -0,0 +1,27 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+@import "../colors";
+
+.recent-changes-update-hidden {
+    // Hide the hidden elements in the recent changes view.
+    display: none;
+}
+
+.log-update-hidden {
+    // Hide the hidden elements in the log update view.
+    display: none;
+}
+
+.select-trees-box {
+    // Remove the spaces between tree boxes.
+    margin-bottom: 0rem !important;
+}
+
+.tree-category-header {
+    // Add some padding category header elements.
+    padding-top: 1rem;
+}
diff --git a/landoui/assets_src/js/components/Stack.js b/landoui/assets_src/js/components/Stack.js
index eb45a76e..6e92e7d8 100644
--- a/landoui/assets_src/js/components/Stack.js
+++ b/landoui/assets_src/js/components/Stack.js
@@ -15,5 +15,13 @@ $.fn.stack = function() {
       window.location.href = '/' + e.target.id;
       $radio.attr({'disabled': true});
     });
+
+    // Show the uplift request form modal when the "Request Uplift" button is clicked.
+    $('.uplift-request-open').on("click", function () {
+        $('.uplift-request-modal').addClass("is-active");
+    });
+    $('.uplift-request-close').on("click", function () {
+        $('.uplift-request-modal').removeClass("is-active");
+    });
   });
 };
diff --git a/landoui/assets_src/js/components/Treestatus.js b/landoui/assets_src/js/components/Treestatus.js
new file mode 100644
index 00000000..e49d28d7
--- /dev/null
+++ b/landoui/assets_src/js/components/Treestatus.js
@@ -0,0 +1,80 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+'use strict';
+
+$.fn.treestatus = function() {
+    return this.each(function() {
+        // Register an on-click handler for each log update edit button.
+        $('.log-update-edit').on("click", function () {
+            // Toggle the elements from hidden/visible.
+            var closest_form = $(this).closest('.log-update-form');
+
+            closest_form.find('.log-update-hidden').toggle();
+            closest_form.find('.log-update-visible').toggle();
+        });
+
+        // Register an on-click handler for each recent changes edit button.
+        $('.recent-changes-edit').on("click", function () {
+            // Toggle the elements from hidden/visible.
+            var closest_form = $(this).closest('.recent-changes-form');
+
+            closest_form.find('.recent-changes-update-hidden').toggle();
+            closest_form.find('.recent-changes-update-visible').toggle();
+        });
+
+        // Toggle selected on all trees.
+        $('.select-all-trees').on("click", function () {
+            var checkboxes = $('.tree-select-checkbox');
+            checkboxes.prop('checked', true);
+            checkboxes.trigger('change');
+        });
+
+        // Toggle un-selected on all trees.
+        $('.unselect-all-trees').on("click", function () {
+            var checkboxes = $('.tree-select-checkbox');
+            checkboxes.prop('checked', false);
+            checkboxes.trigger('change');
+        });
+
+        // Update the select trees list after update.
+        var set_update_trees_list = function () {
+            // Clear the current state of the update form tree list.
+            var trees_list = $('.update-trees-list')
+            trees_list.empty();
+
+            // Get all the checked boxes in the select trees view.
+            $('.tree-select-checkbox:checked').each(function () {
+                var checkbox = $(this);
+
+                // Add a new `li` element for each selected tree.
+                trees_list.append(
+                    $('<li></li>').text(checkbox.val())
+                );
+            });
+        };
+
+        // Show the update trees modal when "Update trees" is clicked.
+        $('.update-trees-button').on("click", function () {
+            $('.update-trees-modal').toggle();
+        });
+
+        // Close the update trees modal when the close button is clicked.
+        $('.update-trees-modal-close').on("click", function () {
+            $('.update-trees-modal').toggle();
+        });
+
+        // Add a tree to the list of trees on the update form when checkbox set.
+        $('.tree-select-checkbox').on("change", function () {
+            set_update_trees_list();
+
+            var checked_trees = $('.tree-select-checkbox:checked');
+            // Disaable the "Update trees" button when no trees are selected.
+            var is_tree_select_disabled = checked_trees.length > 0 ? false : true;
+            $('.update-trees-button').prop('disabled', is_tree_select_disabled);
+        });
+    });
+};
diff --git a/landoui/assets_src/js/main.js b/landoui/assets_src/js/main.js
index fbecdfd7..2d58e003 100644
--- a/landoui/assets_src/js/main.js
+++ b/landoui/assets_src/js/main.js
@@ -12,6 +12,7 @@ $(document).ready(function() {
   let $stack = $('.StackPage-stack');
   let $secRequestSubmitted = $('.StackPage-secRequestSubmitted');
   let $flashMessages = $('.FlashMessages');
+  let $treestatus = $('.Treestatus');
 
   // Initialize components
   $('.Navbar').landoNavbar();
@@ -20,4 +21,5 @@ $(document).ready(function() {
   $stack.stack();
   $secRequestSubmitted.secRequestSubmitted();
   $flashMessages.flashMessages();
+  $treestatus.treestatus();
 });
diff --git a/landoui/dev_app.py b/landoui/dev_app.py
index e9dcc5b1..3c6ab66a 100644
--- a/landoui/dev_app.py
+++ b/landoui/dev_app.py
@@ -22,6 +22,7 @@ def create_dev_app(**kwargs):
         "use_https": True,
         "enable_asset_pipeline": True,
         "lando_api_url": None,
+        "treestatus_url": None,
     }
 
     # These are parameters that should be converted to a boolean value.
diff --git a/landoui/forms.py b/landoui/forms.py
index 49cdce2e..ce9e9191 100644
--- a/landoui/forms.py
+++ b/landoui/forms.py
@@ -1,8 +1,11 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import enum
 import json
 
+from dataclasses import dataclass
 from json.decoder import JSONDecodeError
 from typing import (
     Optional,
@@ -13,13 +16,19 @@
 from wtforms import (
     BooleanField,
     Field,
+    FieldList,
     HiddenField,
     SelectField,
     StringField,
+    SubmitField,
     TextAreaField,
     ValidationError,
 )
-from wtforms.validators import InputRequired, optional, Regexp
+from wtforms.validators import (
+    InputRequired,
+    Regexp,
+    optional,
+)
 
 
 class JSONDecodable:
@@ -120,3 +129,264 @@ class UserSettingsForm(FlaskForm):
         ],
     )
     reset_phab_api_token = BooleanField("Delete", default="")
+
+
+class Status(enum.Enum):
+    """Allowable statuses of a tree."""
+
+    OPEN = "open"
+    CLOSED = "closed"
+    APPROVAL_REQUIRED = "approval required"
+
+    @classmethod
+    def to_choices(cls) -> list[tuple[str, str]]:
+        """Return a list of choices for display."""
+        return [(choice.value, choice.value.capitalize()) for choice in list(cls)]
+
+
+class ReasonCategory(enum.Enum):
+    """Allowable reasons for a Tree closure."""
+
+    NO_CATEGORY = ""
+    JOB_BACKLOG = "backlog"
+    CHECKIN_COMPILE_FAILURE = "checkin_compilation"
+    CHECKIN_TEST_FAILURE = "checkin_test"
+    PLANNED_CLOSURE = "planned"
+    MERGES = "merges"
+    WAITING_FOR_COVERAGE = "waiting_for_coverage"
+    INFRASTRUCTURE_RELATED = "infra"
+    OTHER = "other"
+
+    @classmethod
+    def to_choices(cls) -> list[tuple[str, str]]:
+        """Return a list of choices for display."""
+        return [(choice.value, choice.to_display()) for choice in list(cls)]
+
+    def to_display(self) -> str:
+        """Return a human-readable version of the category."""
+        return {
+            ReasonCategory.NO_CATEGORY: "No Category",
+            ReasonCategory.JOB_BACKLOG: "Job Backlog",
+            ReasonCategory.CHECKIN_COMPILE_FAILURE: "Check-in compilation failure",
+            ReasonCategory.CHECKIN_TEST_FAILURE: "Check-in test failure",
+            ReasonCategory.PLANNED_CLOSURE: "Planned closure",
+            ReasonCategory.MERGES: "Merges",
+            ReasonCategory.WAITING_FOR_COVERAGE: "Waiting for coverage",
+            ReasonCategory.INFRASTRUCTURE_RELATED: "Infrastructure related",
+            ReasonCategory.OTHER: "Other",
+        }[self]
+
+    @classmethod
+    def is_valid_for_backend(cls, value) -> bool:
+        """Return `True` if `value` is a valid `ReasonCategory` to be submitted.
+
+        All `ReasonCategory` members are valid except for `NO_CATEGORY` as that is
+        implied by an empty `tags` key in the backend.
+        """
+        try:
+            category = cls(value)
+        except ValueError:
+            return False
+
+        if category == ReasonCategory.NO_CATEGORY:
+            return False
+
+        return True
+
+
+def build_update_json_body(
+    reason: Optional[str], reason_category: Optional[str]
+) -> dict:
+    """Return a `dict` for use as a JSON body in a log/change update."""
+    json_body = {}
+
+    json_body["reason"] = reason
+
+    if reason_category and ReasonCategory.is_valid_for_backend(reason_category):
+        json_body["tags"] = [reason_category]
+
+    return json_body
+
+
+class TreeStatusUpdateTreesForm(FlaskForm):
+    """Form used to update the state of a selection of trees."""
+
+    trees = FieldList(
+        StringField(
+            "Trees",
+            validators=[
+                InputRequired("A selection of trees is required to update statuses.")
+            ],
+        )
+    )
+
+    status = SelectField(
+        "Status",
+        choices=Status.to_choices(),
+        validators=[InputRequired("A status is required.")],
+    )
+
+    reason = StringField("Reason")
+
+    reason_category = SelectField(
+        "Reason Category",
+        choices=ReasonCategory.to_choices(),
+        default=ReasonCategory.NO_CATEGORY.value,
+    )
+
+    remember = BooleanField(
+        "Remember this change",
+        default=True,
+    )
+
+    message_of_the_day = StringField("Message of the day")
+
+    def validate_trees(self, field):
+        """Validate that at least 1 tree was selected."""
+        if not field.entries:
+            raise ValidationError(
+                "A selection of trees is required to update statuses."
+            )
+
+    def validate_reason(self, field):
+        """Validate that the reason field is required for non-open statuses."""
+        reason_is_empty = not field.data
+
+        if Status(self.status.data) == Status.CLOSED and reason_is_empty:
+            raise ValidationError("Reason description is required to close trees.")
+
+    def validate_reason_category(self, field):
+        """Validate that the reason category field is required for non-open statuses."""
+        try:
+            category_is_empty = (
+                not field.data
+                or ReasonCategory(field.data) == ReasonCategory.NO_CATEGORY
+            )
+        except ValueError:
+            raise ValidationError("Reason category is an invalid value.")
+
+        if Status(self.status.data) == Status.CLOSED and category_is_empty:
+            raise ValidationError("Reason category is required to close trees.")
+
+    def to_submitted_json(self) -> dict:
+        """Convert a validated form to JSON for submission to LandoAPI."""
+        # Avoid setting tags for invalid values.
+        tags = (
+            [self.reason_category.data]
+            if ReasonCategory.is_valid_for_backend(self.reason_category.data)
+            else []
+        )
+
+        return {
+            "trees": self.trees.data,
+            "status": self.status.data,
+            "reason": self.reason.data,
+            "message_of_the_day": self.message_of_the_day.data,
+            "tags": tags,
+            "remember": self.remember.data,
+        }
+
+
+class TreeCategory(enum.Enum):
+    """Categories of the various trees.
+
+    Note: the definition order is in order of importance for display in the UI.
+    Note: this class also exists in Lando-UI, and should be updated in both places.
+    """
+
+    DEVELOPMENT = "development"
+    RELEASE_STABILIZATION = "release_stabilization"
+    TRY = "try"
+    COMM_REPOS = "comm_repos"
+    OTHER = "other"
+
+    @classmethod
+    def sort_trees(cls, item: dict) -> int:
+        """Key function for sorting tree `dict`s according to category order."""
+        return [choice.value for choice in list(cls)].index(item["category"])
+
+    @classmethod
+    def to_choices(cls) -> list[tuple[str, str]]:
+        """Return a list of choices for display."""
+        return [(choice.value, choice.to_display()) for choice in list(cls)]
+
+    def to_display(self) -> str:
+        """Return a human readable version of the category."""
+        return " ".join(word.capitalize() for word in self.value.split("_"))
+
+
+class TreeStatusNewTreeForm(FlaskForm):
+    """Add a new tree to Treestatus."""
+
+    tree = StringField(
+        "Tree",
+        validators=[InputRequired("A tree name is required.")],
+    )
+
+    category = SelectField(
+        "Tree category",
+        choices=TreeCategory.to_choices(),
+        default=TreeCategory.OTHER.value,
+    )
+
+
+@dataclass
+class RecentChangesAction:
+    method: str
+    request_args: dict
+    message: str
+
+
+class TreeStatusRecentChangesForm(FlaskForm):
+    """Modify a recent status change."""
+
+    id = HiddenField("Id")
+
+    reason = StringField("Reason")
+
+    reason_category = SelectField(
+        "Reason Category",
+        choices=ReasonCategory.to_choices(),
+    )
+
+    restore = SubmitField("Restore")
+
+    update = SubmitField("Update")
+
+    discard = SubmitField("Discard")
+
+    def to_action(self) -> RecentChangesAction:
+        """Return a `RecentChangesAction` describing interaction with Lando-API."""
+        if self.update.data:
+            # Update is a PATCH with any changed attributes passed in the body.
+            return RecentChangesAction(
+                method="PATCH",
+                request_args={
+                    "json": build_update_json_body(
+                        self.reason.data, self.reason_category.data
+                    )
+                },
+                message="Status change updated.",
+            )
+
+        revert = 1 if self.restore.data else 0
+        message = f"Status change {'restored' if self.restore.data else 'discarded'}."
+
+        return RecentChangesAction(
+            method="DELETE",
+            request_args={"params": {"revert": revert}},
+            message=message,
+        )
+
+
+class TreeStatusLogUpdateForm(FlaskForm):
+    """Modify a log entry."""
+
+    id = HiddenField("Id")
+
+    reason = StringField("Reason")
+
+    reason_category = SelectField(
+        "Reason Category",
+        choices=ReasonCategory.to_choices(),
+    )
diff --git a/landoui/landoapi.py b/landoui/landoapi.py
index 127e9739..e1580844 100644
--- a/landoui/landoapi.py
+++ b/landoui/landoapi.py
@@ -22,8 +22,8 @@
 logger = logging.getLogger(__name__)
 
 
-class LandoAPI:
-    """Client for Lando API."""
+class API:
+    """Common components of a Lando-based API."""
 
     def __init__(
         self,
@@ -31,13 +31,17 @@ def __init__(
         *,
         phabricator_api_token: Optional[str] = None,
         auth0_access_token: Optional[str] = None,
-        session: Optional[requests.Session] = None
+        session: Optional[requests.Session] = None,
     ):
         self.url = url + "/" if url[-1] == "/" else url + "/"
         self.phabricator_api_token = phabricator_api_token
         self.auth0_access_token = auth0_access_token
         self.session = session or self.create_session()
 
+    @property
+    def service_name(self):
+        raise NotImplementedError
+
     @staticmethod
     def create_session() -> requests.Session:
         return requests.Session()
@@ -82,7 +86,7 @@ def request(
             response = self.session.request(method, self.url + url_path, **kwargs)
 
             logger.debug(
-                "lando-api response",
+                f"{self.service_name} response",
                 extra={
                     "status_code": response.status_code,
                     "content_type": response.headers.get("Content-Type"),
@@ -106,8 +110,16 @@ def request(
         LandoAPIError.raise_if_error(response, data)
         return data
 
+
+class LandoAPI(API):
+    """Client for LandoAPI."""
+
+    @property
+    def service_name(self) -> str:
+        return "LandoAPI"
+
     @classmethod
-    def from_environment(cls, token: Optional[str] = None) -> LandoAPI:
+    def from_environment(cls, token: Optional[str] = None) -> API:
         """Build a `LandoAPI` object from the environment."""
         if not token:
             token = get_phabricator_api_token()
@@ -119,6 +131,22 @@ def from_environment(cls, token: Optional[str] = None) -> LandoAPI:
         )
 
 
+class TreestatusAPI(API):
+    """Client for Treestatus."""
+
+    @property
+    def service_name(self) -> str:
+        return "Treestatus"
+
+    @classmethod
+    def from_environment(cls) -> API:
+        """Build a `TreestatusAPI` object from the environment."""
+        return cls(
+            current_app.config["TREESTATUS_URL"],
+            auth0_access_token=session.get("access_token"),
+        )
+
+
 class LandoAPIException(Exception):
     """Exception from LandoAPI."""
 
diff --git a/landoui/template_helpers.py b/landoui/template_helpers.py
index b472f923..c9443e18 100644
--- a/landoui/template_helpers.py
+++ b/landoui/template_helpers.py
@@ -11,8 +11,12 @@
 
 from typing import Optional
 
-from flask import Blueprint, current_app, escape
-from landoui.forms import UserSettingsForm
+from flask import Blueprint, current_app, escape, session
+from landoui.forms import (
+    ReasonCategory,
+    TreeCategory,
+    UserSettingsForm,
+)
 
 from landoui import helpers
 
@@ -28,6 +32,25 @@ def is_user_authenticated() -> bool:
     return helpers.is_user_authenticated()
 
 
+TREESTATUS_USER_GROUPS = {
+    "mozilliansorg_treestatus_admins",
+    "mozilliansorg_treestatus_users",
+}
+
+
+@template_helpers.app_template_global()
+def is_treestatus_user() -> bool:
+    if not is_user_authenticated():
+        return False
+
+    try:
+        groups = session["userinfo"]["https://sso.mozilla.com/claim/groups"]
+    except KeyError:
+        return False
+
+    return not TREESTATUS_USER_GROUPS.isdisjoint(groups)
+
+
 @template_helpers.app_template_global()
 def user_has_phabricator_token() -> bool:
     return helpers.get_phabricator_api_token() is not None
@@ -92,6 +115,16 @@ def reviewer_to_status_badge_class(reviewer: dict) -> str:
     ]
 
 
+@template_helpers.app_template_filter()
+def treestatus_to_status_badge_class(tree_status: str) -> str:
+    """Convert Tree statuses into status badges."""
+    return {
+        "open": "Badge Badge--positive",
+        "closed": "Badge Badge--negative",
+        "approval required": "Badge Badge--warning",
+    }.get(tree_status, "Badge Badge--warning")
+
+
 @template_helpers.app_template_filter()
 def reviewer_to_action_text(reviewer: dict) -> str:
     options = {
@@ -130,6 +163,23 @@ def tostatusbadgename(status: dict) -> str:
     return mapping.get(status["status"].lower(), status["status"].capitalize())
 
 
+@template_helpers.app_template_filter()
+def reason_category_to_display(reason_category_str: str) -> str:
+    try:
+        return ReasonCategory(reason_category_str).to_display()
+    except ValueError:
+        # Return the bare string, in case of older data.
+        return reason_category_str
+
+
+@template_helpers.app_template_filter()
+def tree_category_to_display(tree_category_str: str) -> str:
+    try:
+        return TreeCategory(tree_category_str).to_display()
+    except ValueError:
+        return tree_category_str
+
+
 @template_helpers.app_template_filter()
 def avatar_url(url: str) -> str:
     # If a user doesn't have a gravatar image for their auth0 email address,
@@ -324,6 +374,9 @@ def message_type_to_notification_class(flash_message_category: str) -> str:
     See https://bulma.io/documentation/elements/notification/ for the list of
     Bulma notification states.
     """
-    return {"info": "is-info", "success": "is-success", "warning": "is-warning"}.get(
-        flash_message_category, "is-info"
-    )
+    return {
+        "error": "is-danger",
+        "info": "is-info",
+        "success": "is-success",
+        "warning": "is-warning",
+    }.get(flash_message_category, "is-info")
diff --git a/landoui/templates/partials/navbar.html b/landoui/templates/partials/navbar.html
index 4a33835c..79557138 100644
--- a/landoui/templates/partials/navbar.html
+++ b/landoui/templates/partials/navbar.html
@@ -25,6 +25,12 @@
     <div class="navbar-end">
       <div class="navbar-item">
         <div class="field is-grouped">
+          <p class="control">
+            <a class="button" href="{{ url_for("treestatus.treestatus") }}">
+              <span class="icon"><i class="fa fa-tree"></i></span>
+              <span>Treestatus</span>
+            </a>
+          </p>
           <p class="control">
             <a class="button" href="{{ config['PHABRICATOR_URL'] }}">
               <span class="icon">
diff --git a/landoui/templates/stack/partials/landing-preview.html b/landoui/templates/stack/partials/landing-preview.html
index 7a1ace6c..0d27edf9 100644
--- a/landoui/templates/stack/partials/landing-preview.html
+++ b/landoui/templates/stack/partials/landing-preview.html
@@ -9,10 +9,11 @@ <h3 class="StackPage-landingPreview-sectionLabel">Landing is Blocked</h3>
   <div class="StackPage-landingPreview-section StackPage-landingPreview-blocker">
     Reason for blockage is unknown
   </div>
-{% elif dryrun['blocker'] %}
+{% elif dryrun['blocker'] or dryrun['blockers'] %}
+  {% set blocker = dryrun['blockers'][0] if 'blockers' in dryrun else dryrun['blocker'] %}
   <h3 class="StackPage-landingPreview-sectionLabel">Landing is Blocked</h3>
   <div class="StackPage-landingPreview-section StackPage-landingPreview-blocker">
-    {{ dryrun['blocker']|escape_html|linkify_faq|safe }}
+    {{ blocker|escape_html|linkify_faq|safe }}
   </div>
 {% elif series %}
   <h3 class="StackPage-landingPreview-sectionLabel">
diff --git a/landoui/templates/stack/partials/revision-status.html b/landoui/templates/stack/partials/revision-status.html
index 01fb7115..8d09f975 100644
--- a/landoui/templates/stack/partials/revision-status.html
+++ b/landoui/templates/stack/partials/revision-status.html
@@ -4,7 +4,8 @@
     file, You can obtain one at https://mozilla.org/MPL/2.0/.
 #}
 
-{% if revision['blocked_reason'] %}
+{% if revision['blocked_reasons'] or revision['blocked_reason'] %}
+{% set blocked_reason = revision['blocked_reasons'][0] if 'blocked_reasons' in revision else revision['blocked_reason']%}
 <div class="StackPage-blockerReason">
   <div class="Badge Badge--negative">
     Blocked
@@ -13,7 +14,7 @@
     </span>
   </div>
   <div class="StackPage-blockerReason-tooltip">
-    {{revision['blocked_reason']|escape_html|linkify_faq|safe}}
+    {{blocked_reason|escape_html|linkify_faq|safe}}
   </div>
 </div>
 {% endif %}
diff --git a/landoui/templates/stack/stack.html b/landoui/templates/stack/stack.html
index 388b29ab..7702777c 100644
--- a/landoui/templates/stack/stack.html
+++ b/landoui/templates/stack/stack.html
@@ -104,23 +104,30 @@ <h2>Landing is blocked:</h2>
   {% endif %}
   </div>
 
-  <div class="StackPage-actions">
-  {% if not is_user_authenticated() %}
-    <button disabled>
-      <div class="StackPage-actions-headline">Preview Landing</div>
-      <div class="StackPage-actions-subtitle">You must log in first</div>
-    </button>
-  {% elif not user_has_phabricator_token() and (not series or dryrun is none) %}
-    <button disabled>
-      <div class="StackPage-actions-headline">Landing Blocked</div>
-      <div class="StackPage-actions-subtitle">This revision is blocked from landing</div>
-    </button>
-  {% else %}
-    <button class="StackPage-preview-button">
-      <div class="StackPage-actions-headline">Preview Landing</div>
-    </button>
-  {% endif %}
+  {% set can_uplift_revision = is_user_authenticated() and user_has_phabricator_token() %}
 
+  <div class="StackPage-actions">
+      {% if not is_user_authenticated() %}
+        <button disabled>
+          <div class="StackPage-actions-headline">Preview Landing</div>
+          <div class="StackPage-actions-subtitle">You must log in first</div>
+        </button>
+      {% elif not series or dryrun is none %}
+        <button disabled>
+          <div class="StackPage-actions-headline">Landing Blocked</div>
+          <div class="StackPage-actions-subtitle">This revision is blocked from landing</div>
+        </button>
+      {% else %}
+        <button class="StackPage-preview-button">
+          <div class="StackPage-actions-headline">Preview Landing</div>
+        </button>
+      {% endif %}
+      {% if can_uplift_revision %}
+        <button class="button uplift-request-open is-normal">
+            <span class="icon"><i class="fa fa-arrow-circle-up"></i></span>
+            <div class="StackPage-actions-headline">Request Uplift</span>
+        </button>
+      {% endif %}
   </div>
 
   {% if is_user_authenticated() %}
@@ -148,23 +155,6 @@ <h2>Landing is blocked:</h2>
           </button>
           <button class="StackPage-landingPreview-close button">Cancel</button>
         </form>
-        {% if user_has_phabricator_token() %}
-            <form class="StackPage-landingPreview-uplift" action="{{ url_for('revisions.uplift') }}" method="post">
-              {{ uplift_request_form.csrf_token }}
-              <input type="hidden" name="revision_id" value="{{ revision_id }}" />
-              {{ uplift_request_form.repository }}
-              <button
-                class="button"
-                title="Create Phabricator review requests for the selected patch stack to land in the specified uplift train." >
-                Request uplift
-              </button>
-            </form>
-            <a class="button" target="_blank" href="https://wiki.mozilla.org/index.php?title=Release_Management/Requesting_an_Uplift">
-              <span class="icon">
-                <i class="fa fa-question-circle"></i>
-              </span>
-            </a>
-        {% endif %}
       </footer>
     </div>
   </div>
@@ -187,5 +177,60 @@ <h2>Landing is blocked:</h2>
     </div>
   </div>
 
+  {% if can_uplift_revision %}
+  <div class="uplift-request-modal modal">
+    <form action="{{ url_for('revisions.uplift') }}" method="post">
+
+    <div class="modal-background"></div>
+
+    <div class="modal-card">
+        {{ uplift_request_form.csrf_token }}
+
+        <header class="modal-card-head">
+            <p class="modal-card-title">Request uplift</p>
+            <button class="uplift-request-close delete" aria-label="close" type="button"></button>
+        </header>
+
+        <section class="modal-card-body">
+          <input type="hidden" name="revision_id" value="{{ revision_id }}" />
+
+          <p class="block">
+            Select the repository you wish to uplift this revision to.
+            Once you submit the request, you will be redirected to the new uplift revision on Phabricator.
+            Scroll to the bottom of the page, select "Change uplift request form" and complete the form.
+            Your revision will be reviewed and landed by a release manager.
+          </p>
+
+          <div class="block">
+              <a class="button" target="_blank" type="button" href="https://wiki.mozilla.org/index.php?title=Release_Management/Requesting_an_Uplift">
+                <span class="icon">
+                  <i class="fa fa-question-circle"></i>
+                </span>
+                <span>Uplift request documentation</span>
+              </a>
+          </div>
+
+          <div class="field">
+              <label class="label">Uplift repository</label>
+              <div class="control">
+                  <div class="select">
+                      {{ uplift_request_form.repository }}
+                  </div>
+              </div>
+          </div>
+        </section>
+
+        <footer class="modal-card-foot">
+          <button
+            class="button is-success"
+            title="Create Phabricator review requests for the selected patch stack to land in the specified uplift train." >
+            Create uplift request
+          </button>
+        </footer>
+
+        </form>
+    </div>
+  {% endif %}
+
 </main>
 {% endblock %}
diff --git a/landoui/templates/treestatus/log.html b/landoui/templates/treestatus/log.html
new file mode 100644
index 00000000..8926fd11
--- /dev/null
+++ b/landoui/templates/treestatus/log.html
@@ -0,0 +1,69 @@
+{#
+    This Source Code Form is subject to the terms of the Mozilla Public
+    License, v. 2.0. If a copy of the MPL was not distributed with this
+    file, You can obtain one at https://mozilla.org/MPL/2.0/.
+#}
+
+{% extends "partials/layout.html" %}
+{% block page_title %}Treestatus{% endblock %}
+
+{% block main %}
+<main class="Treestatus container fullhd">
+    {% include "treestatus/recent_changes.html" %}
+
+    <h1>Treestatus: {{ tree }}</h1>
+    <p>Current status: {{ tree }} is <span class="{{ current_log.status | treestatus_to_status_badge_class }}">{{ current_log.status }}</span></p>
+
+    <div class="block">
+        <a href="{{ url_for("treestatus.treestatus") }}"><button class="button">Show All Trees</button></a>
+    </div>
+    <div class="container">
+        {% for log_update_form, log in logs %}
+            <form action="{{ url_for("treestatus.update_log", id=log.id) }}" method="post">
+                {{ log_update_form.csrf_token }}
+
+                <div class="box log-update-form">
+                    <div class="columns">
+                        <div class="column is-2">
+                            <span class="{{ log.status | treestatus_to_status_badge_class }}">{{ log.status }}</span>
+                        </div>
+                        <div class="column is-expanded">
+                            <div class="block"><b>{{ log.when }}</b></div>
+                            <div class="block"><h2 class="subtitle">{{ log.who }}</h2></div>
+                            <div class="block">
+                                {% if log.reason %}
+                                    <div class="log-update-visible">
+                                        <p>{{ log_update_form.reason.label }}: <b>{{ log.reason }}</b></p>
+                                        <p>{{ log_update_form.reason_category.label }}: <b>{{ log_update_form.reason_category.data | reason_category_to_display }}</b></p>
+                                    </div>
+                                {% endif %}
+                                <div class="log-update-hidden">
+                                    <div class="field">
+                                        <label class="label">{{ log_update_form.reason.label }}</label>
+                                        {{ log_update_form.reason(id=id_string, class="input") }}
+                                    </div>
+                                    <div class="field">
+                                        <label class="label">{{ log_update_form.reason_category.label }}</label>
+                                        {{ log_update_form.reason_category(id=id_string, class="select") }}
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                        <div class="column is-narrow">
+                            {% if is_user_authenticated() %}
+                                <div class="log-update-visible">
+                                    <button class="button is-small is-primary log-update-edit" type="button">Edit</button>
+                                </div>
+                            {% endif %}
+                            <div class="log-update-hidden">
+                                <button class="button delete is-normal log-update-edit" type="button"></button>
+                                <button class="button is-small is-primary">Update</button>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </form>
+        {% endfor %}
+    </div>
+</main>
+{% endblock %}
diff --git a/landoui/templates/treestatus/new_tree.html b/landoui/templates/treestatus/new_tree.html
new file mode 100644
index 00000000..2af79743
--- /dev/null
+++ b/landoui/templates/treestatus/new_tree.html
@@ -0,0 +1,35 @@
+{#
+    This Source Code Form is subject to the terms of the Mozilla Public
+    License, v. 2.0. If a copy of the MPL was not distributed with this
+    file, You can obtain one at https://mozilla.org/MPL/2.0/.
+#}
+
+{% extends "partials/layout.html" %}
+{% block page_title %}Treestatus{% endblock %}
+
+{% block main %}
+<main class="Treestatus container fullhd">
+    {% include "treestatus/recent_changes.html" %}
+
+    <h1>Treestatus: New tree</h1>
+    <form action="{{ url_for("treestatus.new_tree") }}" method="post">
+        {{ treestatus_new_tree_form.csrf_token }}
+        <div class="box">
+            <div class="field">
+                <label class="label">{{ treestatus_new_tree_form.tree.label }}</label>
+                <div class="control is-expanded">
+                    {{ treestatus_new_tree_form.tree(class="input", placeholder="New tree name") }}
+                </div>
+                <p class="help">The status will be "open" by default.</p>
+            </div>
+            <div class="field">
+                <label class="label">{{ treestatus_new_tree_form.category.label }}</label>
+                <div class="control is-expanded">
+                    <div class="select is-fullwidth">{{ treestatus_new_tree_form.category() }}</div>
+                </div>
+            </div>
+            <button class="button is-primary">Add tree</button>
+        </div>
+    </form>
+</main>
+{% endblock %}
diff --git a/landoui/templates/treestatus/recent_changes.html b/landoui/templates/treestatus/recent_changes.html
new file mode 100644
index 00000000..b2b1ed8a
--- /dev/null
+++ b/landoui/templates/treestatus/recent_changes.html
@@ -0,0 +1,65 @@
+{#
+    This Source Code Form is subject to the terms of the Mozilla Public
+    License, v. 2.0. If a copy of the MPL was not distributed with this
+    file, You can obtain one at https://mozilla.org/MPL/2.0/.
+#}
+
+{# Only render the recent changes header if there are any changes available. #}
+{% if recent_changes_stack and is_user_authenticated() %}
+<h1>Recent changes</h1>
+<div class="container">
+    {% for status_change_form, status_change_data in recent_changes_stack %}
+    <div class="box">
+        <form class="recent-changes-form" action="{{ url_for("treestatus.update_change", id=status_change_data.id) }}" method="post">
+            {{ status_change_form.csrf_token }}
+
+            <div class="columns">
+                <div class="column">
+                    <p>At {{ status_change_data.when }}, <b>{{ status_change_data.who }}</b> changed trees:</p>
+                    <div class="block content">
+                        <ul>
+                            {% for tree in status_change_data.trees %}
+                            <li><b>{{ tree.tree }}</b>
+                                 from <span class="{{ tree.last_state.status | treestatus_to_status_badge_class }}">
+                                    {{ tree.last_state.status }}
+                                </span>
+                                 to <span class="{{ tree.last_state.current_status | treestatus_to_status_badge_class }}">
+                                    {{ tree.last_state.current_status }}
+                                </span>
+                            </li>
+                            {% endfor %}
+                        </ul>
+                    </div>
+                    <div class="recent-changes-update-visible">
+                        <p>{{ status_change_form.reason.label }}: <b>{{ status_change_form.reason.data }}</b></p>
+                        <p>{{ status_change_form.reason_category.label }}: <b>{{ status_change_form.reason_category.data | reason_category_to_display }}</b></p>
+                    </div>
+                    <div class="recent-changes-update-hidden">
+                        <div class="field">
+                            <label class="label">{{ status_change_form.reason.label }}</label>
+                            <div class="control">{{ status_change_form.reason }}</div>
+                        </div>
+                        <div class="field">
+                            <label class="label">{{ status_change_form.reason_category.label }}</label>
+                            <div class="control">{{ status_change_form.reason_category }}</div>
+                        </div>
+                    </div>
+                </div>
+                <div class="column is-narrow">
+                    <div class="recent-changes-update-visible">
+                        {{ status_change_form.restore(class_="button is-success is-small") }}
+                        <button class="button is-info is-small recent-changes-edit" type="button">Edit</button>
+                        {{ status_change_form.discard(class_="button is-danger is-small") }}
+                    </div>
+                    <div class="recent-changes-update-hidden">
+                        <button class="button delete is-normal recent-changes-edit" type="button"></button>
+                        {{ status_change_form.update(class_="button is-info is-small") }}
+                    </div>
+                </div>
+            </div>
+        </form>
+    </div>
+    {% endfor %}
+</div>
+</br>
+{% endif %}
diff --git a/landoui/templates/treestatus/trees.html b/landoui/templates/treestatus/trees.html
new file mode 100644
index 00000000..30439f19
--- /dev/null
+++ b/landoui/templates/treestatus/trees.html
@@ -0,0 +1,81 @@
+{#
+    This Source Code Form is subject to the terms of the Mozilla Public
+    License, v. 2.0. If a copy of the MPL was not distributed with this
+    file, You can obtain one at https://mozilla.org/MPL/2.0/.
+#}
+
+{% extends "partials/layout.html" %}
+{% block page_title %}Treestatus{% endblock %}
+
+{% block main %}
+<main class="Treestatus container fullhd">
+    <h1>Treestatus</h1>
+    <p>Current status of Mozilla's version-control repositories.</p>
+
+    {% if is_treestatus_user() %}
+    {% include "treestatus/recent_changes.html" %}
+    {% endif %}
+
+    <h1>Trees</h1>
+    {#
+        The main Treestatus page is a table that presents the trees.
+        Trees can be selected here for updating, or for deletion.
+    #}
+    <form method="post">
+        {{ treestatus_update_trees_form.csrf_token }}
+
+        {% if is_treestatus_user() %}
+        <div class="block">
+            <a href="{{ url_for("treestatus.new_tree") }}">
+                <button class="button" title="New Tree" type="button">New Tree</button>
+            </a>
+            <button class="button select-all-trees" type="button">Select all trees</button>
+            <button class="button unselect-all-trees" type="button">Unselect all trees</button>
+            <button class="button is-primary update-trees-button" title="Update Tree" type="button" disabled="disabled">Update trees</button>
+        </div>
+        {% endif %}
+
+        {# Create a namespace to track the current category across the loop iterations. #}
+        {% set ns = namespace(current_category="") %}
+
+        {% for tree_option in treestatus_update_trees_form.trees %}
+            {% set tree = trees[tree_option.data] %}
+
+            {# Check if the category has changed and display a header for new category. #}
+            {% if tree.category != ns.current_category %}
+                {% set ns.current_category = tree.category %}
+                <h4 class="subtitle is-4 tree-category-header">{{ ns.current_category | tree_category_to_display }}</h1>
+            {% endif %}
+
+            <div class="select-trees-box box">
+                <div class="columns">
+                    {% if is_treestatus_user() %}
+                        <div class="column is-1">
+                            <input class="tree-select-checkbox" type="checkbox" name="{{ tree_option.id }}" value="{{ tree_option.data }}">
+                        </div>
+                    {% endif %}
+                    <div class="column is-2">
+                        <span class="{{ tree.status | treestatus_to_status_badge_class }}">{{ tree.status }}</span>
+                    </div>
+                    <div class="column">
+                        <a href="{{ tree_option.data }}"><h2 class="subtitle is-4">{{ tree_option.data }}</h2></a>
+                    </div>
+                    <div class="column">
+                        {% if tree.reason %}
+                            <p>Reason: <b>{{ tree.reason }}</b></p>
+                            <p>Reason category: <b>{{ tree.tags[0] | reason_category_to_display }}</b></p>
+                        {% endif %}
+                    </div>
+                    <div class="column">
+                        {% if tree.message_of_the_day %}
+                            {{ tree.message_of_the_day }}
+                        {% endif %}
+                    </div>
+                </div>
+            </div>
+        {% endfor %}
+
+        {% include "treestatus/update_trees.html" %}
+    </form>
+</main>
+{% endblock %}
diff --git a/landoui/templates/treestatus/update_trees.html b/landoui/templates/treestatus/update_trees.html
new file mode 100644
index 00000000..8420bb69
--- /dev/null
+++ b/landoui/templates/treestatus/update_trees.html
@@ -0,0 +1,57 @@
+{#
+    This Source Code Form is subject to the terms of the Mozilla Public
+    License, v. 2.0. If a copy of the MPL was not distributed with this
+    file, You can obtain one at https://mozilla.org/MPL/2.0/.
+#}
+
+{% block main %}
+<div class="modal update-trees-modal">
+    <div class="modal-background"></div>
+    <div class="modal-card">
+        <header class="modal-card-head">
+            <p class="modal-card-title">Update trees</p>
+        </header>
+        <section class="modal-card-body">
+            {{ treestatus_update_trees_form.csrf_token }}
+            <div class="block content">
+                <div>You are about to update the following trees:</div>
+                <ul class="update-trees-list"></ul>
+            </div>
+            <div class="field">
+                <label class="label">{{ treestatus_update_trees_form.status.label }}</label>
+                <div class="control is-expanded">
+                    <div class="select is-fullwidth">{{ treestatus_update_trees_form.status() }}</div>
+                </div>
+            </div>
+            <div class="field">
+                <label class="label">{{ treestatus_update_trees_form.reason_category.label }}</label>
+                <div class="control is-expanded">
+                    <div class="select is-fullwidth">{{ treestatus_update_trees_form.reason_category() }}</div>
+                </div>
+            </div>
+            <div class="field">
+                <label class="label">{{ treestatus_update_trees_form.reason.label }}</label>
+                <div class="control">{{ treestatus_update_trees_form.reason(class="input") }}</div>
+            </div>
+            <div class="field">
+                <label class="checkbox label">
+                    {{ treestatus_update_trees_form.remember.label }}
+                </label>
+                <div class="control">
+                    {{ treestatus_update_trees_form.remember(input="class", checked=True) }}
+                    Remember this change to undo later.
+                </div>
+            </div>
+            <div class="field">
+                <label class="label">{{ treestatus_update_trees_form.message_of_the_day.label }}</label>
+                <div class="control">{{ treestatus_update_trees_form.message_of_the_day(class="input") }}</div>
+                <p class="help">Optional</p>
+            </div>
+        </section>
+        <footer class="modal-card-foot">
+            <button class="button is-primary" title="Update Tree">Update trees</button>
+        </footer>
+    </div>
+    <button class="modal-close is-large update-trees-modal-close" aria-label="close" type="button"></button>
+</div>
+{% endblock %}
diff --git a/landoui/treestatus.py b/landoui/treestatus.py
new file mode 100644
index 00000000..553fc1a1
--- /dev/null
+++ b/landoui/treestatus.py
@@ -0,0 +1,359 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import logging
+
+from flask import (
+    Blueprint,
+    flash,
+    redirect,
+    render_template,
+    request,
+    url_for,
+)
+
+from landoui.helpers import (
+    is_user_authenticated,
+    set_last_local_referrer,
+)
+from landoui.landoapi import (
+    TreestatusAPI,
+    LandoAPIError,
+)
+from landoui.forms import (
+    ReasonCategory,
+    TreeCategory,
+    TreeStatusLogUpdateForm,
+    TreeStatusNewTreeForm,
+    TreeStatusRecentChangesForm,
+    TreeStatusUpdateTreesForm,
+    build_update_json_body,
+)
+
+logger = logging.getLogger(__name__)
+
+treestatus_blueprint = Blueprint("treestatus", __name__)
+treestatus_blueprint.before_request(set_last_local_referrer)
+
+
+def get_recent_changes_stack(api: TreestatusAPI) -> list[dict]:
+    """Retrieve recent changes stack data with error handling."""
+    try:
+        response = api.request(
+            "GET",
+            "stack",
+        )
+    except LandoAPIError as exc:
+        if not exc.detail:
+            raise exc
+
+        flash(f"Could not retrieve recent changes stack: {exc.detail}.", "error")
+        return []
+
+    return response["result"]
+
+
+def build_recent_changes_stack(
+    recent_changes_data: list[dict],
+) -> list[tuple[TreeStatusRecentChangesForm, dict]]:
+    """Build the recent changes stack object."""
+    return [
+        (
+            TreeStatusRecentChangesForm(
+                id=change["id"],
+                reason=change["reason"],
+                reason_category=(
+                    change["trees"][0]["last_state"]["current_tags"][0]
+                    if change["trees"][0]["last_state"]["current_tags"]
+                    else ReasonCategory.NO_CATEGORY.value
+                ),
+            ),
+            change,
+        )
+        for change in recent_changes_data
+    ]
+
+
+@treestatus_blueprint.route("/treestatus/", methods=["GET", "POST"])
+def treestatus():
+    """Display the status of all the current trees.
+
+    This view is the main landing page for Treestatus. The view is a list of all trees
+    and their current statuses. The view of all trees is a form where each tree can be
+    selected, and clicking "Update trees" opens a modal which presents the tree updating
+    form.
+    """
+    api = TreestatusAPI.from_environment()
+
+    treestatus_update_trees_form = TreeStatusUpdateTreesForm()
+    if treestatus_update_trees_form.validate_on_submit():
+        # Submit the form.
+        return update_treestatus(api, treestatus_update_trees_form)
+
+    if (
+        treestatus_update_trees_form.is_submitted()
+        and not treestatus_update_trees_form.validate()
+    ):
+        # Flash form submission errors.
+        for errors in treestatus_update_trees_form.errors.values():
+            for error in errors:
+                flash(error, "warning")
+
+    trees_response = api.request("GET", "trees")
+    trees = trees_response.get("result")
+
+    if not treestatus_update_trees_form.trees.entries:
+        ordered_tree_choices = sorted(trees.values(), key=TreeCategory.sort_trees)
+        for tree in ordered_tree_choices:
+            treestatus_update_trees_form.trees.append_entry(tree["tree"])
+
+    recent_changes_data = get_recent_changes_stack(api)
+    recent_changes_stack = build_recent_changes_stack(recent_changes_data)
+
+    return render_template(
+        "treestatus/trees.html",
+        recent_changes_stack=recent_changes_stack,
+        trees=trees,
+        treestatus_update_trees_form=treestatus_update_trees_form,
+    )
+
+
+def update_treestatus(api: TreestatusAPI, update_trees_form: TreeStatusUpdateTreesForm):
+    """Handler for the tree status updating form.
+
+    This function handles form submission for the status updating form. Validate
+    the form submission and submit a request to LandoAPI, redirecting to the main
+    Treestatus page on success. Display an error message and return to the form if
+    the status updating rules were broken or the API returned an error.
+    """
+    if not is_user_authenticated():
+        flash("Authentication is required to update tree statuses.")
+        return redirect(request.referrer, code=401)
+
+    logger.info(f"Requesting tree status update.")
+
+    try:
+        api.request(
+            "PATCH",
+            "trees",
+            require_auth0=True,
+            json=update_trees_form.to_submitted_json(),
+        )
+    except LandoAPIError as exc:
+        logger.info("Request to update trees status failed.")
+        if not exc.detail:
+            raise exc
+
+        flash(f"Could not update trees: {exc.detail}. Please try again later.", "error")
+        return redirect(request.referrer), 303
+
+    # Redirect to the main Treestatus page.
+    logger.info("Tree statuses updated successfully.")
+    flash("Tree statuses updated successfully.")
+    return redirect(url_for("treestatus.treestatus"))
+
+
+@treestatus_blueprint.route("/treestatus/new_tree/", methods=["GET", "POST"])
+def new_tree():
+    """View for the new tree form."""
+    api = TreestatusAPI.from_environment()
+    treestatus_new_tree_form = TreeStatusNewTreeForm()
+
+    if treestatus_new_tree_form.validate_on_submit():
+        return new_tree_handler(api, treestatus_new_tree_form)
+
+    recent_changes_data = get_recent_changes_stack(api)
+    recent_changes_stack = build_recent_changes_stack(recent_changes_data)
+
+    return render_template(
+        "treestatus/new_tree.html",
+        treestatus_new_tree_form=treestatus_new_tree_form,
+        recent_changes_stack=recent_changes_stack,
+    )
+
+
+def new_tree_handler(api: TreestatusAPI, form: TreeStatusNewTreeForm):
+    """Handler for the new tree form."""
+    if not is_user_authenticated():
+        flash("Authentication is required to create new trees.")
+        return redirect(request.referrer, code=401)
+
+    # Retrieve data from the form.
+    tree = form.tree.data
+    tree_category = form.category.data
+
+    logger.info(f"Requesting new tree {tree}.")
+
+    try:
+        api.request(
+            "PUT",
+            f"trees/{tree}",
+            require_auth0=True,
+            json={
+                "tree": tree,
+                "category": tree_category,
+                # Trees are open on creation.
+                "status": "open",
+                "reason": "",
+                "message_of_the_day": "",
+            },
+        )
+    except LandoAPIError as exc:
+        logger.info(f"Could not create new tree {tree}.")
+
+        if not exc.detail:
+            raise exc
+
+        flash(
+            f"Could not create new tree: {exc.detail}. Please try again later.", "error"
+        )
+        return redirect(request.referrer), 303
+
+    logger.info(f"New tree {tree} created successfully.")
+    flash(f"New tree {tree} created successfully.")
+    return redirect(url_for("treestatus.treestatus"))
+
+
+@treestatus_blueprint.route("/treestatus/<tree>/", methods=["GET"])
+def treestatus_tree(tree: str):
+    """Display the log of statuses for an individual tree."""
+    api = TreestatusAPI.from_environment()
+
+    try:
+        logs_response = api.request("GET", f"trees/{tree}/logs")
+    except LandoAPIError as exc:
+        if not exc.detail or not exc.status_code:
+            raise
+
+        # Avoid displaying an error for a 404.
+        if exc.status_code != 404:
+            error = f"Error received from LandoAPI: {exc.detail}"
+            logger.info(error)
+            flash(error, "error")
+
+        return redirect(request.referrer, code=exc.status_code)
+
+    logs = logs_response.get("result")
+    if not logs:
+        logger.info(f"Could not retrieve logs for tree {tree}.")
+        flash(
+            f"Could not retrieve status logs for {tree} from Lando, try again later.",
+            "error",
+        )
+        return redirect(request.referrer)
+
+    current_log = logs[0]
+
+    logs = [
+        (
+            TreeStatusLogUpdateForm(
+                reason=log["reason"],
+                reason_category=log["tags"][0]
+                if log["tags"]
+                else ReasonCategory.NO_CATEGORY.value,
+            ),
+            log,
+        )
+        for log in logs
+    ]
+
+    recent_changes_data = get_recent_changes_stack(api)
+    recent_changes_stack = build_recent_changes_stack(recent_changes_data)
+
+    return render_template(
+        "treestatus/log.html",
+        current_log=current_log,
+        logs=logs,
+        recent_changes_stack=recent_changes_stack,
+        tree=tree,
+    )
+
+
+@treestatus_blueprint.route("/treestatus/stack/<int:id>", methods=["POST"])
+def update_change(id: int):
+    """Handler for stack updates.
+
+    This function handles form submissions for updates to entries in the recent changes
+    stack. This includes pressing the "restore" or "discard" buttons, as well as updates
+    to the reason and reason category after pressing "edit" and "update".
+    """
+    if not is_user_authenticated():
+        flash("Authentication is required to update stack entries.")
+        return redirect(request.referrer, code=401)
+
+    api = TreestatusAPI.from_environment()
+    recent_changes_form = TreeStatusRecentChangesForm()
+
+    action = recent_changes_form.to_action()
+
+    logger.info(f"Requesting stack update for stack id {id}.")
+
+    try:
+        api.request(
+            action.method,
+            f"stack/{id}",
+            require_auth0=True,
+            **action.request_args,
+        )
+    except LandoAPIError as exc:
+        logger.info(f"Stack entry {id} failed to update.")
+
+        if not exc.detail:
+            raise exc
+
+        flash(
+            f"Could not modify recent change: {exc.detail}. Please try again later.",
+            "error",
+        )
+        return redirect(request.referrer), 303
+
+    logger.info(f"Stack entry {id} updated.")
+    flash(action.message)
+    return redirect(request.referrer)
+
+
+@treestatus_blueprint.route("/treestatus/log/<int:id>", methods=["POST"])
+def update_log(id: int):
+    """Handler for log updates.
+
+    This function handles form submissions for updates to individual log entries
+    in the per-tree log view.
+    """
+    if not is_user_authenticated():
+        flash("Authentication is required to update log entries.")
+        return redirect(request.referrer, code=401)
+
+    api = TreestatusAPI.from_environment()
+
+    log_update_form = TreeStatusLogUpdateForm()
+
+    reason = log_update_form.reason.data
+    reason_category = log_update_form.reason_category.data
+
+    json_body = build_update_json_body(reason, reason_category)
+
+    logger.info(f"Requesting log update for log id {id}.")
+
+    try:
+        api.request(
+            "PATCH",
+            f"log/{id}",
+            require_auth0=True,
+            json=json_body,
+        )
+    except LandoAPIError as exc:
+        logger.info(f"Log entry {id} failed to update.")
+
+        if not exc.detail:
+            raise exc
+
+        flash(
+            f"Could not modify log entry: {exc.detail}. Please try again later.",
+            "error",
+        )
+        return redirect(request.referrer), 303
+
+    logger.info(f"Log entry {id} updated.")
+    flash("Log entry updated.")
+    return redirect(request.referrer)
diff --git a/landoui/wsgi.py b/landoui/wsgi.py
index d3ebcf5e..4995b80f 100644
--- a/landoui/wsgi.py
+++ b/landoui/wsgi.py
@@ -18,5 +18,6 @@
     use_https=str2bool(os.getenv("USE_HTTPS", 1)),
     enable_asset_pipeline=True,
     lando_api_url=os.getenv("LANDO_API_URL"),
+    treestatus_url=os.getenv("TREESTATUS_URL"),
     debug=str2bool(os.getenv("DEBUG", 0)),
 )
diff --git a/tests/conftest.py b/tests/conftest.py
index 5ed04241..7c15ad29 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -31,6 +31,7 @@ def docker_env_vars(monkeypatch):
     monkeypatch.setenv("SESSION_COOKIE_SECURE", "0")
     monkeypatch.setenv("USE_HTTPS", "0")
     monkeypatch.setenv("LANDO_API_URL", "http://lando-api.test:8888")
+    monkeypatch.setenv("TREESTATUS_URL", "http://treestatus.test:8888")
     monkeypatch.setenv("SENTRY_DSN", "")
     monkeypatch.setenv("LOG_LEVEL", "DEBUG")
 
@@ -42,7 +43,13 @@ def api_url():
 
 
 @pytest.fixture
-def app(versionfile, docker_env_vars, api_url):
+def treestatus_url():
+    """A string holding the Treestatus API base URL. Useful for request mocking."""
+    return "http://treestatus.test"
+
+
+@pytest.fixture
+def app(versionfile, docker_env_vars, api_url, treestatus_url):
     app = create_app(
         version_path=versionfile.strpath,
         secret_key=str(binascii.b2a_hex(os.urandom(15))),
@@ -52,6 +59,7 @@ def app(versionfile, docker_env_vars, api_url):
         use_https=False,
         enable_asset_pipeline=False,
         lando_api_url=api_url,
+        treestatus_url=treestatus_url,
         debug=True,
     )
 
diff --git a/tests/test_treestatus.py b/tests/test_treestatus.py
new file mode 100644
index 00000000..fd0bb8d1
--- /dev/null
+++ b/tests/test_treestatus.py
@@ -0,0 +1,233 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import pytest
+
+from wtforms.validators import ValidationError
+from landoui.forms import (
+    TreeStatusRecentChangesForm,
+    TreeStatusUpdateTreesForm,
+)
+from landoui.treestatus import (
+    build_recent_changes_stack,
+    build_update_json_body,
+)
+
+
+def test_build_recent_changes_stack(app):
+    recent_changes_data = [
+        {
+            "id": None,
+            "reason": "reason 2",
+            "trees": [{"last_state": {"current_tags": ["category 1"]}}],
+            "who": "user2",
+            "when": "now",
+        },
+        {
+            "id": 2,
+            "reason": "reason 2",
+            "trees": [{"last_state": {"current_tags": ["category 2"]}}],
+            "who": "user2",
+            "when": "now",
+        },
+        {
+            "id": 3,
+            "reason": "reason 3",
+            "trees": [{"last_state": {"current_tags": []}}],
+            "who": "user3",
+            "when": "now",
+        },
+    ]
+
+    recent_changes_stack = build_recent_changes_stack(recent_changes_data)
+
+    for form, data in recent_changes_stack:
+        assert form.id.data == data["id"]
+        assert form.reason.data == data["reason"]
+
+        if form.id.data == 3:
+            assert (
+                form.reason_category.data == ""
+            ), "Empty tags should set field to an empty string."
+        else:
+            assert (
+                form.reason_category.data
+                == data["trees"][0]["last_state"]["current_tags"][0]
+            )
+
+
+def test_build_update_json_body():
+    assert build_update_json_body(None, None) == {
+        "reason": None,
+    }
+
+    assert build_update_json_body("blah", None) == {
+        "reason": "blah",
+    }
+
+    assert build_update_json_body("blah", "") == {
+        "reason": "blah",
+    }, "`tags` should not be set for empty reason category."
+
+    assert build_update_json_body("blah", "asdf") == {
+        "reason": "blah",
+    }, "`tags` should not be set for invalid reason category."
+
+    assert build_update_json_body("blah", "backlog") == {
+        "reason": "blah",
+        "tags": ["backlog"],
+    }, "`tags` should be set for valid reason category."
+
+
+def test_update_form_validate_trees(app):
+    form = TreeStatusUpdateTreesForm()
+
+    # At least one tree is required.
+    with pytest.raises(ValidationError):
+        form.validate_trees(form.trees)
+
+    form.trees.append_entry("autoland")
+    assert (
+        form.validate_trees(form.trees) is None
+    ), "Form with a tree entry should be valid."
+
+
+def test_update_form_validate_reason(app):
+    form = TreeStatusUpdateTreesForm(
+        status="open",
+    )
+    try:
+        form.validate_reason(form.reason)
+    except ValidationError as exc:
+        assert False, f"No validation required for `open` status: {exc}."
+
+    form = TreeStatusUpdateTreesForm(
+        status="approval required",
+    )
+    try:
+        form.validate_reason(form.reason)
+    except ValidationError as exc:
+        assert False, f"No validation required for `approval required` status: {exc}."
+
+    # `closed` status requires a reason.
+    form = TreeStatusUpdateTreesForm(
+        status="closed",
+    )
+    with pytest.raises(ValidationError):
+        form.validate_reason(form.reason)
+
+    form = TreeStatusUpdateTreesForm(
+        status="closed",
+        reason="some reason",
+    )
+    try:
+        form.validate_reason(form.reason)
+    except ValidationError as exc:
+        assert False, f"`closed` status with a reason should be valid: {exc}"
+
+
+def test_update_form_validate_reason_category(app):
+    form = TreeStatusUpdateTreesForm(
+        status="open",
+    )
+    try:
+        form.validate_reason_category(form.reason_category)
+    except ValidationError as exc:
+        assert False, f"No validation required for `open` status: {exc}."
+
+    form = TreeStatusUpdateTreesForm(
+        status="approval required",
+    )
+    try:
+        form.validate_reason_category(form.reason_category)
+    except ValidationError as exc:
+        assert False, f"No validation required for `approval required` status: {exc}."
+
+    # `closed` status requires a reason category.
+    form = TreeStatusUpdateTreesForm(
+        status="closed",
+    )
+    with pytest.raises(ValidationError):
+        form.validate_reason_category(form.reason_category)
+
+    # `closed` status requires a non-empty reason category.
+    form = TreeStatusUpdateTreesForm(
+        status="closed",
+        reason_category="",
+    )
+    with pytest.raises(ValidationError):
+        form.validate_reason_category(form.reason_category)
+
+    # `closed` status requires a valid reason category.
+    form = TreeStatusUpdateTreesForm(
+        status="closed",
+        reason_category="blah",
+    )
+    with pytest.raises(ValidationError):
+        form.validate_reason_category(form.reason_category)
+
+    form = TreeStatusUpdateTreesForm(
+        status="closed",
+        reason_category="backlog",
+    )
+    try:
+        form.validate_reason_category(form.reason_category)
+    except ValidationError as exc:
+        assert False, f"`closed` status with valid reason category is valid: {exc}."
+
+
+def test_update_form_to_submitted_json(app):
+    form = TreeStatusUpdateTreesForm(
+        status="open",
+        reason="reason",
+        message_of_the_day="",
+        remember=True,
+        reason_category="backlog",
+    )
+
+    form.trees.append_entry("autoland")
+    form.trees.append_entry("mozilla-central")
+
+    assert form.to_submitted_json() == {
+        "status": "open",
+        "reason": "reason",
+        "message_of_the_day": "",
+        "remember": True,
+        "tags": ["backlog"],
+        "trees": ["autoland", "mozilla-central"],
+    }, "JSON format should match expected."
+
+
+def test_recent_changes_action(app):
+    form = TreeStatusRecentChangesForm(
+        reason="reason",
+        category="backlog",
+    )
+
+    form.update.data = True
+    action = form.to_action()
+
+    assert action.method == "PATCH", "Updates should use HTTP `PATCH`."
+    assert "json" in action.request_args, "Updates should send content in JSON body."
+    assert action.message == "Status change updated."
+
+    form.update.data = False
+    form.restore.data = True
+    action = form.to_action()
+
+    assert action.method == "DELETE", "Restores should use HTTP `DELETE`."
+    assert (
+        "params" in action.request_args
+    ), "Restores should send content in query string."
+    assert action.message == "Status change restored."
+
+    form.restore.data = False
+    form.discard.data = True
+    action = form.to_action()
+
+    assert action.method == "DELETE", "Discards should use HTTP `DELETE`."
+    assert (
+        "params" in action.request_args
+    ), "Discards should send content in query string."
+    assert action.message == "Status change discarded."