diff --git a/composer.json b/composer.json
index 40dfe121f197928ac3f3c36fac71df9ab4e6797b..afcb2e146280082eae871d406f624b45817aefab 100644
--- a/composer.json
+++ b/composer.json
@@ -118,6 +118,7 @@
         "drupal/metatag": "^1.7",
         "drupal/migrate_plus": "4.0",
         "drupal/migrate_tools": "4.0",
+        "drupal/module_filter": "^3.1",
         "drupal/paragraphs": "1.5",
         "drupal/pathauto": "1.0",
         "drupal/redis": "1.0",
diff --git a/composer.lock b/composer.lock
index bec7d5c7b6c14a46e2391bb39cf75806572efc71..3218f444dd0bc41c39a1f27a7aac14aad931ff25 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
         "This file is @generated automatically"
     ],
-    "content-hash": "c7f966be2bf443d90f7fc6bea9496524",
+    "content-hash": "7c8d97cc8cd3a2ebf93eacc429204dd6",
     "packages": [
         {
             "name": "alchemy/zippy",
@@ -4498,7 +4498,8 @@
                     }
                 },
                 "patches_applied": {
-                    "2809699": "https://www.drupal.org/files/issues/2018-10-26/menu_block-label_configuration-2809699-82.patch"
+                    "2809699": "https://www.drupal.org/files/issues/2018-10-26/menu_block-label_configuration-2809699-82.patch",
+                    "2811337": "https://www.drupal.org/files/issues/menu_block-2_level_menu_block_not_limited_to_active_parent-2811337-58.patch"
                 }
             },
             "notification-url": "https://packages.drupal.org/8/downloads",
@@ -4797,6 +4798,61 @@
                 "irc": "irc://irc.freenode.org/drupal-migrate"
             }
         },
+        {
+            "name": "drupal/module_filter",
+            "version": "3.1.0",
+            "source": {
+                "type": "git",
+                "url": "https://git.drupal.org/project/module_filter",
+                "reference": "8.x-3.1"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://ftp.drupal.org/files/projects/module_filter-8.x-3.1.zip",
+                "reference": "8.x-3.1",
+                "shasum": "39d627ce60280ae54bcf9beae217b85cce1969e4"
+            },
+            "require": {
+                "drupal/core": "~8.0"
+            },
+            "type": "drupal-module",
+            "extra": {
+                "branch-alias": {
+                    "dev-3.x": "3.x-dev"
+                },
+                "drupal": {
+                    "version": "8.x-3.1",
+                    "datestamp": "1507650844",
+                    "security-coverage": {
+                        "status": "covered",
+                        "message": "Covered by Drupal's security advisory policy"
+                    }
+                }
+            },
+            "notification-url": "https://packages.drupal.org/8/downloads",
+            "license": [
+                "GPL-2.0-or-later"
+            ],
+            "authors": [
+                {
+                    "name": "greenSkin",
+                    "homepage": "https://www.drupal.org/user/173855"
+                },
+                {
+                    "name": "realityloop",
+                    "homepage": "https://www.drupal.org/user/139189"
+                },
+                {
+                    "name": "shumushin",
+                    "homepage": "https://www.drupal.org/user/22093"
+                }
+            ],
+            "description": "Filter the modules list.",
+            "homepage": "https://www.drupal.org/project/module_filter",
+            "support": {
+                "source": "http://cgit.drupalcode.org/module_filter"
+            }
+        },
         {
             "name": "drupal/paragraphs",
             "version": "1.5.0",
diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json
index 1fb1be93a1399097b7f69e1523bcd905879fe68e..efec831bfe34379ba81e61398ac313a046249ff6 100644
--- a/vendor/composer/installed.json
+++ b/vendor/composer/installed.json
@@ -4945,6 +4945,63 @@
             "irc": "irc://irc.freenode.org/drupal-migrate"
         }
     },
+    {
+        "name": "drupal/module_filter",
+        "version": "3.1.0",
+        "version_normalized": "3.1.0.0",
+        "source": {
+            "type": "git",
+            "url": "https://git.drupal.org/project/module_filter",
+            "reference": "8.x-3.1"
+        },
+        "dist": {
+            "type": "zip",
+            "url": "https://ftp.drupal.org/files/projects/module_filter-8.x-3.1.zip",
+            "reference": "8.x-3.1",
+            "shasum": "39d627ce60280ae54bcf9beae217b85cce1969e4"
+        },
+        "require": {
+            "drupal/core": "~8.0"
+        },
+        "type": "drupal-module",
+        "extra": {
+            "branch-alias": {
+                "dev-3.x": "3.x-dev"
+            },
+            "drupal": {
+                "version": "8.x-3.1",
+                "datestamp": "1507650844",
+                "security-coverage": {
+                    "status": "covered",
+                    "message": "Covered by Drupal's security advisory policy"
+                }
+            }
+        },
+        "installation-source": "dist",
+        "notification-url": "https://packages.drupal.org/8/downloads",
+        "license": [
+            "GPL-2.0-or-later"
+        ],
+        "authors": [
+            {
+                "name": "greenSkin",
+                "homepage": "https://www.drupal.org/user/173855"
+            },
+            {
+                "name": "realityloop",
+                "homepage": "https://www.drupal.org/user/139189"
+            },
+            {
+                "name": "shumushin",
+                "homepage": "https://www.drupal.org/user/22093"
+            }
+        ],
+        "description": "Filter the modules list.",
+        "homepage": "https://www.drupal.org/project/module_filter",
+        "support": {
+            "source": "http://cgit.drupalcode.org/module_filter"
+        }
+    },
     {
         "name": "drupal/paragraphs",
         "version": "1.5.0",
diff --git a/web/modules/module_filter/.gitignore b/web/modules/module_filter/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..485dee64bcfb48793379b200a1afd14e85a8aaf4
--- /dev/null
+++ b/web/modules/module_filter/.gitignore
@@ -0,0 +1 @@
+.idea
diff --git a/web/modules/module_filter/LICENSE.txt b/web/modules/module_filter/LICENSE.txt
new file mode 100644
index 0000000000000000000000000000000000000000..d159169d1050894d3ea3b98e1c965c4058208fe1
--- /dev/null
+++ b/web/modules/module_filter/LICENSE.txt
@@ -0,0 +1,339 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users.  This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it.  (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.)  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+  To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have.  You must make sure that they, too, receive or can get the
+source code.  And you must show them these terms so they know their
+rights.
+
+  We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+  Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software.  If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+  Finally, any free program is threatened constantly by software
+patents.  We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary.  To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                    GNU GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License.  The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language.  (Hereinafter, translation is included without limitation in
+the term "modification".)  Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+  1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+  2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) You must cause the modified files to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    b) You must cause any work that you distribute or publish, that in
+    whole or in part contains or is derived from the Program or any
+    part thereof, to be licensed as a whole at no charge to all third
+    parties under the terms of this License.
+
+    c) If the modified program normally reads commands interactively
+    when run, you must cause it, when started running for such
+    interactive use in the most ordinary way, to print or display an
+    announcement including an appropriate copyright notice and a
+    notice that there is no warranty (or else, saying that you provide
+    a warranty) and that users may redistribute the program under
+    these conditions, and telling the user how to view a copy of this
+    License.  (Exception: if the Program itself is interactive but
+    does not normally print such an announcement, your work based on
+    the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+    a) Accompany it with the complete corresponding machine-readable
+    source code, which must be distributed under the terms of Sections
+    1 and 2 above on a medium customarily used for software interchange; or,
+
+    b) Accompany it with a written offer, valid for at least three
+    years, to give any third party, for a charge no more than your
+    cost of physically performing source distribution, a complete
+    machine-readable copy of the corresponding source code, to be
+    distributed under the terms of Sections 1 and 2 above on a medium
+    customarily used for software interchange; or,
+
+    c) Accompany it with the information you received as to the offer
+    to distribute corresponding source code.  (This alternative is
+    allowed only for noncommercial distribution and only if you
+    received the program in object code or executable form with such
+    an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it.  For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable.  However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License.  Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+  5. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Program or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+  6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+  7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded.  In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+  9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation.  If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+  10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission.  For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this.  Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+                            NO WARRANTY
+
+  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software; you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation; either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License along
+    with this program; if not, write to the Free Software Foundation, Inc.,
+    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+    Gnomovision version 69, Copyright (C) year name of author
+    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+  `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+  <signature of Ty Coon>, 1 April 1989
+  Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs.  If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/web/modules/module_filter/config/install/module_filter.settings.yml b/web/modules/module_filter/config/install/module_filter.settings.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a23be2632a53958ae3b7b1f1246df81fb6e7dc34
--- /dev/null
+++ b/web/modules/module_filter/config/install/module_filter.settings.yml
@@ -0,0 +1 @@
+tabs: true
diff --git a/web/modules/module_filter/css/module_filter.css b/web/modules/module_filter/css/module_filter.css
new file mode 100644
index 0000000000000000000000000000000000000000..4e3dbc67dcf3b8465e4556f650a81c6b64a12307
--- /dev/null
+++ b/web/modules/module_filter/css/module_filter.css
@@ -0,0 +1,32 @@
+.winnow-input {
+  position: relative;
+  display: inline-block;
+}
+.winnow-input input.form-search {
+  padding-right: 2em;
+}
+.winnow-clear {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  right: 2px;
+  left: auto;
+  text-align: left;
+  text-indent: -9999px;
+}
+.winnow-clear:after {
+  position: absolute;
+  right: 0;
+  padding: 0 0.5em;
+  height: 100%;
+  content: '✕';
+  color: #aaa;
+  font: 1em/2.2em arial, sans-serif;
+  text-decoration: none;
+  text-shadow: 0 1px 0 #fff;
+  text-indent: 0;
+  opacity: 0.7;
+}
+.winnow-clear:hover:after {
+  opacity: 1;
+}
diff --git a/web/modules/module_filter/css/module_filter.modules.css b/web/modules/module_filter/css/module_filter.modules.css
new file mode 100644
index 0000000000000000000000000000000000000000..42f0b7330da2d3bf5bdafc7d394fb22c8594abcb
--- /dev/null
+++ b/web/modules/module_filter/css/module_filter.modules.css
@@ -0,0 +1,5 @@
+#system-modules .table-filter .module-filter-status .form-item,
+#system-modules-uninstall .table-filter .module-filter-status .form-item {
+  display: inline-block;
+  padding-right: 1em;
+}
diff --git a/web/modules/module_filter/css/module_filter.modules_tabs.css b/web/modules/module_filter/css/module_filter.modules_tabs.css
new file mode 100644
index 0000000000000000000000000000000000000000..9b6a4eab8824cc9047d9079c00f5b68a913b46f1
--- /dev/null
+++ b/web/modules/module_filter/css/module_filter.modules_tabs.css
@@ -0,0 +1,134 @@
+.winnow-input {
+  display: block;
+}
+.modules-tabs {
+  position: relative;
+  overflow: hidden;
+  margin: 10px 0;
+  background: #E6E5E1;
+  border: 1px solid #bdbdbd;
+  border-radius: 4px;
+}
+.modules-tabs__menu {
+  float: left; /* LTR */
+  width: 240px;
+  margin: 0 -100% -1px 0; /* LTR */
+  padding: 0;
+  border-bottom: 1px solid #ccc;
+  line-height: 1;
+}
+[dir="rtl"] .modules-tabs__menu {
+  float: right;
+  margin: 0 0 -1px -100%;
+}
+.modules-tabs__menu-item {
+  background: #eee;
+  border-top: 1px solid #ccc;
+}
+.modules-tabs__menu-item:first-child {
+  border-top: 0 none;
+}
+.modules-tabs__menu-item.tab__new {
+  margin-bottom: 10px;
+  border-bottom: 1px solid #ccc;
+}
+.modules-tabs__menu-item.suggest {
+  background: #F9F9F9;
+}
+.modules-tabs__menu-item.disabled {
+  background: #ccc;
+  border-top-color: #bbb;
+  border-bottom-color: #bbb;
+}
+.modules-tabs__menu-item a {
+  display: block;
+  text-decoration: none;
+  padding: 10px;
+}
+.modules-tabs__menu-item a:hover,
+.modules-tabs__menu-item a:focus {
+  background: #D5D5D5;
+  text-decoration: none;
+  outline: 0;
+}
+.modules-tabs__menu-item.is-selected a,
+.modules-tabs__menu-item.is-selected a:hover,
+.modules-tabs__menu-item.is-selected a:focus,
+.modules-tabs__menu-item.is-selected a:active {
+  background-color: #FCFCFA;
+  margin-right: -1px;
+}
+.modules-tabs__menu-item.disabled a,
+.modules-tabs__menu-item.disabled span{
+  color: #999;
+}
+.modules-tabs__menu-item.disabled a {
+  pointer-events: none;
+  cursor: default;
+}
+.modules-tabs__menu-item .summary {
+  padding-top: 0.4em;
+  color: #666;
+  font-size: 0.846em;
+  line-height: 1em;
+}
+.modules-tabs__menu-item ul.enabling {
+  display: block;
+  color: green;
+  font-size: 0.75em;
+  font-weight: bold;
+}
+.modules-tabs__menu-item ul.enabling:before {
+  content: '+';
+}
+.modules-tabs__menu-item span.result {
+  float: right;
+  margin-top: 2px;
+  color: #999;
+  font-size: 10px;
+}
+.modules-tabs__menu-item.disabled span.result {
+  display: none;
+}
+.modules-tabs__pane {
+  margin: 0 0 0 240px; /* LTR */
+  padding: 10px 15px;
+  background: #FCFCFA;
+  border-left: 1px solid #A6A5A1; /* LTR */
+}
+[dir="rtl"] .modules-tabs__pane {
+  margin: 0 240px 0 0;
+  border-left: none;
+  border-right: 1px solid #A6A5A1;
+}
+.modules-tabs__pane:after {
+  content: '';
+  display: table;
+  clear: both;
+}
+.modules-tabs__pane input.table-filter-text {
+  width: 100%;
+}
+.modules-tabs__pane table {
+  table-layout: fixed;
+}
+.modules-tabs__pane table col.checkbox {
+  width: 8%;
+}
+.modules-tabs__pane table col.name {
+  width: 25%;
+}
+.modules-tabs__pane table col.version {
+  width: 10%;
+}
+.modules-tabs__pane table col.links {
+  width: 15%;
+}
+.modules-tabs__pane table tr.enabling {
+  background-color: #dfd;
+}
+.modules-tabs__pane table td details summary {
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
diff --git a/web/modules/module_filter/css/module_filter.update_status.css b/web/modules/module_filter/css/module_filter.update_status.css
new file mode 100644
index 0000000000000000000000000000000000000000..29042d887a20e8581cc9e93e363db5a49dcbc67a
--- /dev/null
+++ b/web/modules/module_filter/css/module_filter.update_status.css
@@ -0,0 +1,8 @@
+#module-filter-update-status-form .table-filter {
+  float: right;
+  text-align: right;
+}
+#module-filter-update-status-form .table-filter .module-filter-status .form-item {
+  display: inline-block;
+  padding-left: 1em;
+}
diff --git a/web/modules/module_filter/js/jquery.winnow.js b/web/modules/module_filter/js/jquery.winnow.js
new file mode 100644
index 0000000000000000000000000000000000000000..32123fdd2d31159374ec8ab9bb99fa9506ad0f75
--- /dev/null
+++ b/web/modules/module_filter/js/jquery.winnow.js
@@ -0,0 +1,361 @@
+/**
+ * jQuery plugin
+ * Filter out elements based on user input.
+ */
+
+(function ($) {
+
+  var now = Date.now || function() {
+      return new Date().getTime();
+    };
+
+  function debounce(func, wait, immediate) {
+    var timeout;
+    var args;
+    var context;
+    var timestamp;
+    var result;
+
+    var later = function() {
+      var last = now() - timestamp;
+
+      if (last < wait && last >= 0) {
+        timeout = setTimeout(later, wait - last);
+      }
+      else {
+        timeout = null;
+        if (!immediate) {
+          result = func.apply(context, args);
+          if (!timeout) {
+            args = context = null;
+          }
+        }
+      }
+    };
+
+    return function() {
+      context = this;
+      args = arguments;
+      timestamp = now();
+      var callNow = immediate && !timeout;
+      if (!timeout) {
+        timeout = setTimeout(later, wait);
+      }
+      if (callNow) {
+        result = func.apply(context, args);
+        args = context = null;
+      }
+
+      return result;
+    }
+  }
+
+  function explode(string) {
+    return string.match(/(\w*\:(\w+|"[^"]+")*)|\w+|"[^"]+"/g);
+  }
+
+  function preventEnterKey(e) {
+    if (e.which === 13) {
+      e.preventDefault();
+      e.stopPropagation();
+    }
+  };
+
+  var Winnow = function(element, selector, options) {
+    var self = this;
+
+    self.element = element;
+    self.selector = selector;
+    self.text = '';
+    self.queries = [];
+    self.results = [];
+    self.state = {};
+
+    self.options = $.extend({
+      delay: 500,
+      striping: false,
+      selector: '',
+      textSelector: null,
+      emptyMessage: '',
+      clearLabel: 'clear',
+      rules: [],
+      buildIndex: [],
+      additionalOperators: {}
+    }, $.fn.winnow.defaults, options);
+    if (self.options.wrapper === undefined) {
+      self.options.wrapper = $(self.selector).parent();
+    }
+
+    self.element.wrap('<div class="winnow-input"></div>');
+
+    // Add clear button.
+    self.clearButton = $('<a href="#" class="winnow-clear">' + self.options.clearLabel + '</a>');
+    self.clearButton.css({
+      'display': 'inline-block',
+      'margin-left': '0.75em'
+    });
+    if (self.element.val() == '') {
+      self.clearButton.hide();
+    }
+    self.clearButton.click(function(e) {
+      e.preventDefault();
+
+      self.clearFilter();
+    });
+    self.element.after(self.clearButton);
+
+    self.element.on({
+      keyup: debounce(function() {
+        var value = self.element.val();
+        if (!value || explode(value).pop().slice(-1) !== ':') {
+          // Only filter if we aren't using the operator autocomplete.
+          self.filter();
+        }
+      }, self.options.delay),
+      keydown: preventEnterKey
+    });
+    self.element.on({
+      keyup: function() {
+        // Show/hide the clear button.
+        if (self.element.val() != '') {
+          self.clearButton.show();
+        }
+        else {
+          self.clearButton.hide();
+        }
+      }
+    });
+
+    // Autocomplete operators. When last query is ":", return list of available
+    // operators with the exception of "text".
+    if (typeof self.element.autocomplete === 'function') {
+      var operators = Object.keys(self.getOperators());
+      var source = [];
+      for (var i in operators) {
+        if (operators[i] != 'text') {
+          source.push({
+            label: operators[i],
+            value: operators[i] + ':'
+          });
+        }
+      }
+
+      self.element.autocomplete({
+        'search': function(event) {
+          if (explode(event.target.value).pop() != ':') {
+            return false;
+          }
+        },
+        'source': function(request, response) {
+          return response(source);
+        },
+        'select': function(event, ui) {
+          var terms = explode(event.target.value);
+          // Remove the current input.
+          terms.pop();
+          // Add the selected item.
+          terms.push(ui.item.value);
+          event.target.value = terms.join(' ');
+          // Return false to tell jQuery UI that we've filled in the value already.
+          return false;
+        },
+        'focus': function() {
+          return false;
+        }
+      });
+    }
+
+    self.element.data('winnow', self);
+  };
+
+  Winnow.prototype.setQueries = function(string) {
+    var self = this;
+    var strings = explode(string);
+
+    self.text = string;
+    self.queries = [];
+
+    for (var i in strings) {
+      if (strings.hasOwnProperty(i)) {
+        var query = { operator: 'text', string: strings[i] };
+        var operators = self.getOperators();
+
+        if (query.string.indexOf(':') > 0) {
+          var parts = query.string.split(':', 2);
+          var operator = parts.shift();
+          if (operators[operator] !== undefined) {
+            query.operator = operator;
+            query.string = parts.shift();
+          }
+        }
+
+        if (query.string.charAt(0) == '"') {
+          // Remove wrapping double quotes.
+          query.string = query.string.replace(/^"|"$/g, '');
+        }
+
+        query.string = query.string.toLowerCase();
+
+        self.queries.push(query);
+      }
+    }
+  };
+
+  Winnow.prototype.buildIndex = function() {
+    var self = this;
+    this.index = [];
+
+    $(self.selector, self.wrapper).each(function(i) {
+      var text = (self.options.textSelector) ? $(self.options.textSelector, this).text() : $(this).text();
+      var item = {
+        key: i,
+        element: $(this),
+        text: text.toLowerCase()
+      };
+
+      for (var j in self.options.buildIndex) {
+        item = $.extend(self.options.buildIndex[j].apply(this, [item]), item);
+      }
+
+      $(this).data('winnowIndex', i);
+      self.index.push(item);
+    });
+
+    return self.trigger('finishIndexing', [ self ]);
+  };
+
+  Winnow.prototype.bind = function() {
+    var args = arguments;
+    args[0] = 'winnow:' + args[0];
+
+    return this.element.bind.apply(this.element, args);
+  };
+
+  Winnow.prototype.trigger = function(event) {
+    var args = arguments;
+    args[0] = 'winnow:' + args[0];
+
+    return this.element.trigger.apply(this.element, args);
+  };
+
+  Winnow.prototype.filter = function() {
+    var self = this;
+
+    self.results = [];
+    self.setQueries(self.element.val());
+
+    if (self.index === undefined) {
+      self.buildIndex();
+    }
+
+    var start = self.trigger('start');
+
+    $.each(self.index, function(key, item) {
+      var $item = item.element;
+      var operatorMatch = true;
+
+      if (self.text != '') {
+        operatorMatch = false;
+        for (var i in self.queries) {
+          var query = self.queries[i];
+          var operators = self.getOperators();
+
+          if (operators[query.operator] !== undefined) {
+            result = operators[query.operator].apply($item, [query.string, item]);
+            if (!result) {
+              // Is not a text match so continue to next query string.
+              continue;
+            }
+          }
+
+          operatorMatch = true;
+          break;
+        }
+      }
+
+      if (operatorMatch && self.processRules(item) !== false) {
+        // Item is a match.
+        $item.show();
+        self.results.push(item);
+        return true;
+      }
+
+      // By reaching here, the $item is not a match so we hide it.
+      $item.hide();
+    });
+
+    var finish = self.trigger('finish', [ self.results ]);
+
+    if (self.options.striping) {
+      stripe();
+    }
+
+    if (self.options.emptyMessage) {
+      if (self.results.length > 0) {
+        self.options.wrapper.children('.winnow-no-results').remove();
+      }
+      else if (!self.options.wrapper.children('.winnow-no-results').length) {
+        self.options.wrapper.append($('<p class="winnow-no-results"></p>').text(self.options.emptyMessage));
+      }
+    }
+  };
+
+  Winnow.prototype.getOperators = function() {
+    return $.extend({}, {
+      text: function(string, item) {
+        if (item.text.indexOf(string) >= 0) {
+          return true;
+        }
+      }
+    }, this.options.additionalOperators);
+  };
+
+  Winnow.prototype.processRules = function(item) {
+    var self = this;
+    var $item = item.element;
+    var result = true;
+
+    if (self.options.rules.length > 0) {
+      for (var i in self.options.rules) {
+        result = self.options.rules[i].apply($item, [item]);
+        if (result === false) {
+          break;
+        }
+      }
+    }
+
+    return result;
+  };
+
+  Winnow.prototype.stripe = function() {
+    var flip = { even: 'odd', odd: 'even' };
+    var stripe = 'odd';
+
+    $.each(this.index, function(key, item) {
+      if (!item.element.is(':visible')) {
+        item.element.removeClass('odd even').addClass(stripe);
+        stripe = flip[stripe];
+      }
+    });
+  };
+
+  Winnow.prototype.clearFilter = function() {
+    this.element.val('');
+    this.filter();
+    this.clearButton.hide();
+    this.element.focus();
+  };
+
+  $.fn.winnow = function(selector, options) {
+    var $input = this.not('.winnow-processed').addClass('winnow-processed');
+
+    $input.each(function() {
+      var winnow = new Winnow($input, selector, options);
+    });
+
+    return this;
+  };
+
+  $.fn.winnow.defaults = {};
+
+})(jQuery);
diff --git a/web/modules/module_filter/js/module_filter.js b/web/modules/module_filter/js/module_filter.js
new file mode 100644
index 0000000000000000000000000000000000000000..25c436ea009d1fa811c794146b6f7738498083bf
--- /dev/null
+++ b/web/modules/module_filter/js/module_filter.js
@@ -0,0 +1,45 @@
+(function($, Drupal) {
+
+  'use strict';
+
+  Drupal.ModuleFilter = Drupal.ModuleFilter || {};
+
+  Drupal.ModuleFilter.localStorage = {
+    getItem: function(key) {
+      if (typeof Storage !== 'undefined') {
+        return localStorage.getItem('moduleFilter.' + key);
+      }
+
+      return null;
+    },
+    getBoolean: function(key) {
+      var item = Drupal.ModuleFilter.localStorage.getItem(key);
+
+      if (item != null) {
+        return (item == 'true');
+      }
+
+      return null;
+    },
+    setItem: function(key, data) {
+      if (typeof Storage !== 'undefined') {
+        localStorage.setItem('moduleFilter.' + key, data)
+      }
+    },
+    removeItem: function(key) {
+      if (typeof Storage !== 'undefined') {
+        localStorage.removeItem('moduleFilter.' + key);
+      }
+    }
+  };
+
+  /**
+   * Filter enhancements.
+   */
+  Drupal.behaviors.moduleFilter = {
+    attach: function(context) {
+
+    }
+  };
+
+})(jQuery, Drupal);
diff --git a/web/modules/module_filter/js/module_filter.modules.js b/web/modules/module_filter/js/module_filter.modules.js
new file mode 100644
index 0000000000000000000000000000000000000000..0e06711492901fa5d8ed34eb8f4c836329880f43
--- /dev/null
+++ b/web/modules/module_filter/js/module_filter.modules.js
@@ -0,0 +1,174 @@
+/**
+ * @file
+ * Module filter behaviors.
+ */
+
+(function($, Drupal) {
+
+  'use strict';
+
+  Drupal.ModuleFilter = Drupal.ModuleFilter || {};
+  var ModuleFilter = Drupal.ModuleFilter;
+
+  /**
+   * Filter enhancements.
+   */
+  Drupal.behaviors.moduleFilterModules = {
+    attach: function(context, settings) {
+      var $input = $('input.table-filter-text', context).once('module-filter');
+      if ($input.length) {
+        ModuleFilter.input = $input;
+        ModuleFilter.selector = 'tbody tr';
+        ModuleFilter.wrapperId = ModuleFilter.input.attr('data-table');
+        ModuleFilter.wrapper = $(ModuleFilter.wrapperId);
+        var $enabled = $('.table-filter [name="checkboxes[enabled]"]', ModuleFilter.wrapper);
+        var $disabled = $('.table-filter [name="checkboxes[disabled]"]', ModuleFilter.wrapper);
+        var $unavailable = $('.table-filter [name="checkboxes[unavailable]"]', ModuleFilter.wrapper);
+
+        var showEnabled = ModuleFilter.localStorage.getBoolean('modules.enabled');
+        if (showEnabled == null) {
+          showEnabled = $enabled.is(':checked');
+        }
+        $enabled.prop('checked', showEnabled);
+        var showDisabled = ModuleFilter.localStorage.getBoolean('modules.disabled');
+        if (showDisabled == null) {
+          showDisabled = $disabled.is(':checked');
+        }
+        $disabled.prop('checked', showDisabled);
+        var showUnavailable = ModuleFilter.localStorage.getBoolean('modules.unavailable');
+        if (showUnavailable == null) {
+          showUnavailable = $unavailable.is(':checked');
+        }
+        $unavailable.prop('checked', showUnavailable);
+
+        ModuleFilter.wrapper.children('details').wrapAll('<div class="modules-wrapper"></div>');
+        ModuleFilter.modulesWrapper = $('.modules-wrapper', ModuleFilter.wrapper);
+
+        ModuleFilter.input.winnow(ModuleFilter.wrapperId + ' ' + ModuleFilter.selector, {
+          textSelector: 'td.module .module-name',
+          emptyMessage: Drupal.t('No results'),
+          clearLabel: Drupal.t('clear'),
+          wrapper: ModuleFilter.modulesWrapper,
+          buildIndex: [
+            function(item) {
+              var $checkbox = $('td.checkbox :checkbox', item.element);
+              if ($checkbox.length > 0) {
+                item.status = $checkbox.is(':checked');
+                item.disabled = $checkbox.is(':disabled');
+              }
+              else {
+                item.status = false;
+                item.disabled = true;
+              }
+              return item;
+            }
+          ],
+          additionalOperators: {
+            description: function(string, item) {
+              if (item.description == undefined) {
+                // Soft cache.
+                item.description = $('.module-description', item.element).text().toLowerCase();
+              }
+
+              if (item.description.indexOf(string) >= 0) {
+                return true;
+              }
+            },
+            requiredBy: function(string, item) {
+              if (item.requiredBy === undefined) {
+                // Soft cache.
+                item.requiredBy = [];
+                $('.admin-requirements.required-by li', item.element).each(function() {
+                  var moduleName = $(this)
+                    .text()
+                    .toLowerCase()
+                    .replace(/\([a-z]*\)/g, '');
+                  item.requiredBy.push($.trim(moduleName));
+                });
+              }
+
+              if (item.requiredBy.length) {
+                for (var i in item.requiredBy) {
+                  if (item.requiredBy[i].indexOf(string) >= 0) {
+                    return true;
+                  }
+                }
+              }
+            },
+            requires: function(string, item) {
+              if (item.requires === undefined) {
+                // Soft cache.
+                item.requires = [];
+                $('.admin-requirements.requires li', item.element).each(function() {
+                  var moduleName = $(this)
+                    .text()
+                    .toLowerCase()
+                    .replace(/\([a-z]*\)/g, '');
+                  item.requires.push($.trim(moduleName));
+                });
+              }
+
+              if (item.requires.length) {
+                for (var i in item.requires) {
+                  if (item.requires[i].indexOf(string) >= 0) {
+                    return true;
+                  }
+                }
+              }
+            }
+          },
+          rules: [
+            function(item) {
+              if (showEnabled) {
+                if (item.status === true && item.disabled === true) {
+                  return true;
+                }
+              }
+              if (showDisabled) {
+                if (item.status === false && item.disabled === false) {
+                  return true;
+                }
+              }
+              if (showUnavailable) {
+                if (item.status === false && item.disabled === true) {
+                  return true;
+                }
+              }
+
+              return false;
+            }
+          ]
+        }).focus();
+        ModuleFilter.winnow = ModuleFilter.input.data('winnow');
+
+        var $details = ModuleFilter.modulesWrapper.children('details');
+        ModuleFilter.input.bind('winnow:finish', function() {
+          Drupal.announce(
+            Drupal.formatPlural(
+              ModuleFilter.modulesWrapper.find(ModuleFilter.selector + ':visible').length,
+              '1 module is available in the modified list.',
+              '@count modules are available in the modified list.'
+            )
+          );
+        });
+
+        $enabled.change(function(e, el) {
+          showEnabled = $(this).is(':checked');
+          ModuleFilter.localStorage.setItem('modules.enabled', showEnabled);
+          ModuleFilter.winnow.filter();
+        });
+        $disabled.change(function() {
+          showDisabled = $disabled.is(':checked');
+          ModuleFilter.localStorage.setItem('modules.disabled', showDisabled);
+          ModuleFilter.winnow.filter();
+        });
+        $unavailable.change(function() {
+          showUnavailable = $unavailable.is(':checked');
+          ModuleFilter.localStorage.setItem('modules.unavailable', showUnavailable);
+          ModuleFilter.winnow.filter();
+        });
+      }
+    }
+  };
+
+})(jQuery, Drupal);
diff --git a/web/modules/module_filter/js/module_filter.modules_bare.js b/web/modules/module_filter/js/module_filter.modules_bare.js
new file mode 100644
index 0000000000000000000000000000000000000000..fbaf9ce1242a5d78e600d9ad300959363d5d69bf
--- /dev/null
+++ b/web/modules/module_filter/js/module_filter.modules_bare.js
@@ -0,0 +1,41 @@
+(function($, Drupal) {
+
+  'use strict';
+
+  Drupal.ModuleFilter = Drupal.ModuleFilter || {};
+  var ModuleFilter = Drupal.ModuleFilter;
+
+  /**
+   * Filter enhancements.
+   */
+  Drupal.behaviors.moduleFilterModulesBare = {
+    attach: function(context) {
+      if (ModuleFilter.input != undefined) {
+        var $details = ModuleFilter.modulesWrapper.children('details');
+
+        ModuleFilter.input.bind('winnow:start', function() {
+          // Note that we first open all <details> to be able to use ':visible'.
+          // Mark the <details> elements that were closed before filtering, so
+          // they can be reclosed when filtering is removed.
+          $details.show().not('[open]').attr('data-module_filter-state', 'forced-open');
+        });
+        ModuleFilter.input.bind('winnow:finish', function() {
+          // Hide the package <details> if they don't have any visible rows.
+          // Note that we first show() all <details> to be able to use ':visible'.
+          $details.attr('open', true).each(function(index, element) {
+            var $group = $(element);
+            var $visibleRows = $group.find('tbody tr:visible');
+            $group.toggle($visibleRows.length > 0);
+          });
+
+          // Return <details> elements that had been closed before filtering
+          // to a closed state.
+          $details.filter('[data-module_filter-state="forced-open"]')
+            .removeAttr('data-module_filter-state')
+            .attr('open', false);
+        });
+      }
+    }
+  };
+
+})(jQuery, Drupal);
diff --git a/web/modules/module_filter/js/module_filter.modules_tabs.js b/web/modules/module_filter/js/module_filter.modules_tabs.js
new file mode 100644
index 0000000000000000000000000000000000000000..103e2cbcf78e0c0678bd61484294e51039accfbe
--- /dev/null
+++ b/web/modules/module_filter/js/module_filter.modules_tabs.js
@@ -0,0 +1,480 @@
+(function($) {
+
+  // 'use strict';
+
+  Drupal.ModuleFilter = Drupal.ModuleFilter || {};
+  var ModuleFilter = Drupal.ModuleFilter;
+
+  var Tabs = function(tabs, $pane) {
+    var $tabs = $('<ul class="modules-tabs__menu"></ul>');
+
+    // Add our three special tabs.
+    var all = new Tab(Drupal.t('All modules'), 'all');
+    var recentModules = new Tab(Drupal.t('Recently enabled'), 'recent');
+    var newModules = new Tab(Drupal.t('Newly available'), 'new');
+    tabs = $.extend({
+      "all": all,
+      "recent": recentModules,
+      "new": newModules
+    }, tabs);
+
+    for (var i in tabs) {
+      $tabs.append(tabs[i].element);
+    }
+
+    $pane.wrap('<div class="modules-tabs clearfix"></div>');
+    $pane.before($tabs);
+    $pane.addClass('modules-tabs__pane');
+
+    this.tabs = tabs;
+    this.element = $tabs;
+    this.activeTab = null;
+
+    // Handle "recent" and "new" tabs when they contain no items.
+    // Todo: move this somewhere else.
+    var $rows = $(ModuleFilter.selector, ModuleFilter.wrapper);
+    if (!$rows.filter('.recent').length) {
+      var recentModules = this.tabs['recent'];
+      if (recentModules) {
+        recentModules.element.addClass('disabled');
+        recentModules.setSummary(Drupal.t('No modules installed or uninstalled within the last week.'));
+        recentModules.showSummary();
+      }
+    }
+    if (!$rows.filter('.new').length) {
+      var newModules = this.tabs['new'];
+      if (newModules) {
+        newModules.element.addClass('disabled');
+        newModules.setSummary(Drupal.t('No modules added within the last week.'));
+        newModules.showSummary();
+      }
+    }
+
+    // Add counts of how many modules are enabled out of the total for the tab.
+    // Todo: move this somewhere else.
+    var $elements = $(ModuleFilter.selector, ModuleFilter.wrapper);
+    var enabled;
+    var total;
+    for (var i in this.tabs) {
+      if (this.tabs[i].element.hasClass('disabled')) {
+        continue;
+      }
+
+      switch (i) {
+        case 'all':
+          var $all = $elements.find('td.checkbox :checkbox');
+          enabled = $all.filter(':checked').length;
+          total = $all.length;
+          break;
+
+        case 'recent':
+          var $recent = $elements.filter('.recent').find('td.checkbox :checkbox');
+          enabled = $recent.filter(':checked').length;
+          total = $recent.length;
+          break;
+
+        case 'new':
+          var $new = $elements.filter('.new').find('td.checkbox :checkbox');
+          enabled = $new.filter(':checked').length;
+          total = $new.length;
+          break;
+
+        default:
+          var $package = $elements.filter('.package__' + i).find(' td.checkbox :checkbox');
+          enabled = $package.filter(':checked').length;
+          total = $package.length;
+          break;
+      }
+
+      if (total) {
+        var enabledCount = Drupal.t('@enabled of @total', { '@enabled': enabled, '@total': total });
+        this.tabs[i].setSummary(enabledCount, 'enabledCount');
+      }
+    }
+  };
+
+  Tabs.prototype.getActive = function() {
+    if (this.activeTab) {
+      return this.activeTab;
+    }
+  };
+
+  Tabs.prototype.setActive = function(tab) {
+    if (this.activeTab) {
+      this.activeTab.hideSummary();
+      this.activeTab.element.removeClass('is-selected');
+    }
+
+    this.activeTab = tab;
+    this.activeTab.element.addClass('is-selected');
+    this.activeTab.showSummary();
+
+    return this.activeTab;
+  };
+
+  Tabs.prototype.get = function(packageId) {
+    if (this.tabs[packageId]) {
+      return this.tabs[packageId];
+    }
+  };
+
+  Tabs.prototype.resetResults = function() {
+    for (var i in this.tabs) {
+      this.tabs[i].resetResults();
+    }
+  };
+
+  Tabs.prototype.showResults = function() {
+    var staticTabs = [ 'all', 'recent', 'new' ];
+
+    for (var i in this.tabs) {
+      var count = this.tabs[i].results.length;
+
+      if (count > 0 || i == this.activeTab.packageId || staticTabs.indexOf(i) >= 0) {
+        this.tabs[i].showResults();
+        this.tabs[i].element.show();
+      }
+      else {
+        this.tabs[i].element.hide();
+      }
+    }
+  };
+
+  Tabs.prototype.hideResults = function() {
+    for (var i in this.tabs) {
+      this.tabs[i].hideResults();
+      this.tabs[i].element.show();
+    }
+  };
+
+  var Tab = function(name, packageId) {
+    this.name = name;
+    this.packageId = packageId;
+    this.element = $('<li class="modules-tabs__menu-item tab__' + this.packageId + '"></li>');
+    this.results = [];
+    this.link = $('<a href="#' + this.packageId + '"><strong>' + this.name + '</strong></a>');
+    this.element.append(this.link);
+    this.link.append('<span class="result"></span>');
+    this.summary = null;
+  };
+
+  Tab.prototype.select = function() {
+    ModuleFilter.tabs.setActive(this);
+
+    if (ModuleFilter.winnow) {
+      ModuleFilter.winnow.filter();
+    }
+  };
+
+  Tab.prototype.resetResults = function() {
+    this.results = [];
+  };
+
+  Tab.prototype.showResults = function() {
+    $('span.result', this.element).text(this.results.length);
+  };
+
+  Tab.prototype.hideResults = function() {
+    $('span.result', this.element).empty();
+  };
+
+  Tab.prototype.setSummary = function(summary, key, persistent) {
+    if (!this.summary) {
+      this.summary = new Summary();
+      this.link.append(this.summary.element);
+    }
+
+    this.summary.set(summary, key, persistent);
+  };
+
+  Tab.prototype.showSummary = function() {
+    this.toggleSummary(true);
+  };
+
+  Tab.prototype.hideSummary = function() {
+    this.toggleSummary(false);
+  };
+
+  Tab.prototype.toggleSummary = function(display) {
+    if (this.summary) {
+      this.summary.toggle(Boolean(display));
+    }
+  };
+
+  Tab.prototype.toggleEnabling = function(name) {
+    this.enabling = this.enabling || {};
+    if (this.enabling[name] != undefined) {
+      delete this.enabling[name];
+    }
+    else {
+      this.enabling[name] = name;
+    }
+
+    var enabling = [];
+    for (var i in this.enabling) {
+      enabling.push(this.enabling[i]);
+    }
+
+    $('ul.enabling', this.element).remove();
+    if (enabling.length) {
+      enabling.sort();
+
+      var $list = $('<ul class="item-list__comma-list enabling"></ul>');
+      $list.append('<li>' + enabling.join('</li><li>') + '</li>');
+      this.setSummary($list, 'enabling', true);
+    }
+    else {
+      this.setSummary('', 'enabling');
+    }
+  };
+
+  var Summary = function() {
+    this.element = $('<div class="summary"></div>');
+    this.element.hide();
+    this.items = {};
+  };
+
+  Summary.prototype.show = function() {
+    this.toggle(true);
+  };
+
+  Summary.prototype.hide = function() {
+    this.toggle(false);
+  };
+
+  Summary.prototype.toggle = function(display) {
+    display = Boolean(display);
+
+    this.element.children(':not(.persistent)').toggle(display);
+
+    if (!display && !this.element.children('.persistent').length) {
+      this.element.hide();
+    }
+    else {
+      this.element.show();
+    }
+  };
+
+  Summary.prototype.set = function(summary, key, persistent) {
+    if (!key) {
+      key = 'default';
+    }
+
+    var empty = false;
+    if (typeof summary == 'string' || typeof summary == 'boolean') {
+      if (!summary) {
+        empty = true;
+      }
+    }
+    else if (typeof summary == 'object') {
+      // Assume the object is a jQuery object.
+      if (!summary.length) {
+        empty = true;
+      }
+    }
+    if (empty) {
+      if (this.items[key] != undefined) {
+        this.items[key].remove();
+        delete this.items[key];
+      }
+
+      if (!Object.keys(this.items).length) {
+        // Hide the summary when there are no items.
+        this.hide();
+      }
+      return;
+    }
+
+    if (persistent == undefined) {
+      persistent = false;
+    }
+
+    if (this.items[key] === undefined) {
+      var $element = $('<span></span>');
+      this.element.append($element);
+      this.items[key] = $element;
+    }
+
+    this.items[key].empty().append(summary);
+    this.items[key].toggleClass('persistent', persistent);
+
+    if (persistent) {
+      // Make sure the summary element is visible.
+      this.show();
+    }
+  };
+
+  Drupal.behaviors.moduleFilterModulesTabs = {
+    attach: function(context) {
+      if (ModuleFilter.input != undefined) {
+        var tabs = {};
+
+        function buildTable() {
+          // Build our unified table.
+          var $originalTable = $('table', ModuleFilter.wrapper).first();
+          var $table = $('<table></table>');
+          if ($originalTable.hasClass('responsive-enabled')) {
+            $table.addClass('responsive-enabled');
+          }
+          var striping = $originalTable.attr('data-striping')
+          if (striping) {
+            $table.attr('data-striping', striping);
+          }
+
+          // Because the table headers are visually hidden, we use col to set
+          // the column widths.
+          var $colgroup = $('<colgroup></colgroup>');
+          $('thead th', $originalTable).each(function() {
+            $colgroup.append('<col class="' + $(this).attr('class') + '">');
+          });
+          $('col', $colgroup).removeClass('visually-hidden');
+          $table.append($colgroup);
+          $table.append($('thead', $originalTable));
+          $table.append('<tbody></tbody>');
+
+          ModuleFilter.modulesWrapper.children('details').each(function() {
+            var $details = $(this);
+            var packageName = $details.children('summary').text();
+            var packageId = packageName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+
+            if (tabs[packageId] == undefined) {
+              tabs[packageId] = new Tab(packageName, packageId);
+            }
+
+            $('.details-wrapper tbody tr', $details).each(function() {
+              var $row = $(this);
+              $row.addClass('package__' + packageId);
+              $row.data('moduleFilter.packageId', packageId);
+
+              $row.hover(function() {
+                tabs[packageId].element.addClass('suggest');
+              }, function() {
+                tabs[packageId].element.removeClass('suggest');
+              });
+
+              $('td.checkbox input', $row).change(function() {
+                $row.toggleClass('enabling', $(this).is(':checked'));
+
+                var packageId = $row.data('moduleFilter.packageId');
+                if (packageId && tabs[packageId]) {
+                  tabs[packageId].toggleEnabling($('td.module label', $row).text());
+                }
+              });
+
+              $('tbody', $table).append($row);
+            });
+          });
+
+          // Remove package detail elements.
+          ModuleFilter.modulesWrapper.children('details').remove();
+
+          // Sort rows by module name.
+          var $rows = $('tbody tr', $table);
+          $rows.sort(function(a, b) {
+            var aname = $('td.module label', a).text();
+            var bname = $('td.module label', b).text();
+
+            if (aname == bname) {
+              return 0;
+            }
+
+            return aname > bname ? 1 : -1;
+          });
+          $rows.detach().appendTo($('tbody', $table));
+
+          // Add the unified table.
+          ModuleFilter.modulesWrapper.append($table);
+        }
+
+        function selectTabByHash() {
+          var hash = window.location.hash;
+          hash = hash.substring(hash.indexOf('#') + 1);
+
+          var tab = ModuleFilter.tabs.get(hash);
+          if (tab) {
+            tab.select();
+          }
+          else {
+            tab = ModuleFilter.tabs.get('all');
+            if (tab) {
+              tab.select();
+            }
+          }
+
+          ModuleFilter.input.focus();
+        }
+
+        buildTable();
+        ModuleFilter.tabs = new Tabs(tabs, ModuleFilter.wrapper);
+
+        ModuleFilter.winnow.options.rules.push(function(item) {
+          var activeTab = ModuleFilter.tabs.getActive();
+
+          // Update tab results. The results are updated prior to hiding the
+          // items not visible in the active tab.
+          var allTab = ModuleFilter.tabs.get('all');
+          allTab.results.push(item);
+          if (item.element.hasClass('recent')) {
+            var recentTab = ModuleFilter.tabs.get('recent');
+            recentTab.results.push(item);
+          }
+          if (item.element.hasClass('new')) {
+            var newTab = ModuleFilter.tabs.get('new');
+            newTab.results.push(item);
+          }
+          if (item.tab != undefined && item.tab) {
+            item.tab.results.push(item);
+          }
+
+          // For tabs other than "all", evaluate whether the item should
+          // be shown.
+          if (activeTab && activeTab.packageId != 'all') {
+            switch (activeTab.packageId) {
+              case 'recent':
+                if (item.element.hasClass('recent')) {
+                  return true;
+                }
+                break;
+
+              case 'new':
+                if (item.element.hasClass('new')) {
+                  return true;
+                }
+                break;
+
+              default:
+                if (item.element.hasClass('package__' + activeTab.packageId)) {
+                  return true;
+                }
+                break;
+            }
+
+            return false;
+          }
+        });
+        ModuleFilter.winnow.bind('finishIndexing', function(e, winnow) {
+          $.each(winnow.index, function(key, item) {
+            var packageId = item.element.data('moduleFilter.packageId');
+            if (packageId) {
+              item.tab = ModuleFilter.tabs.get(packageId);
+            }
+          });
+        });
+        ModuleFilter.winnow.bind('start', function() {
+          ModuleFilter.tabs.resetResults();
+        });
+        ModuleFilter.winnow.bind('finish', function() {
+          if (ModuleFilter.input.val() != '') {
+            ModuleFilter.tabs.showResults();
+          }
+          else {
+            ModuleFilter.tabs.hideResults();
+          }
+        });
+
+        $(window).bind('hashchange.moduleFilter', selectTabByHash).triggerHandler('hashchange.moduleFilter');
+      }
+    }
+  };
+
+})(jQuery);
diff --git a/web/modules/module_filter/js/module_filter.modules_uninstall.js b/web/modules/module_filter/js/module_filter.modules_uninstall.js
new file mode 100644
index 0000000000000000000000000000000000000000..0c6e5a42763f52c69b27ab98a3fa065552390431
--- /dev/null
+++ b/web/modules/module_filter/js/module_filter.modules_uninstall.js
@@ -0,0 +1,56 @@
+/**
+ * @file
+ * Module filter behaviors.
+ */
+
+(function($, Drupal) {
+
+  'use strict';
+
+  /**
+   * Filter enhancements.
+   */
+  Drupal.behaviors.moduleFilterModulesUninstall = {
+    attach: function(context, settings) {
+      var $input = $('input.table-filter-text', context).once('module-filter');
+      if ($input.length) {
+        var wrapperId = $input.attr('data-table');
+        var $wrapper = $(wrapperId);
+        var selector = 'tbody tr';
+
+        $wrapper.children('details').wrapAll('<div class="modules-uninstall-wrapper"></div>');
+        var $modulesWrapper = $('.modules-uninstall-wrapper', $wrapper);
+
+        $input.winnow(wrapperId + ' ' + selector, {
+          textSelector: 'td .module-name',
+          emptyMessage: Drupal.t('No results'),
+          clearLabel: Drupal.t('clear'),
+          wrapper: $modulesWrapper,
+          additionalOperators: {
+            description: function(string, item) {
+              if (item.description == undefined) {
+                // Soft cache.
+                item.description = $('.module-description', item.element).text().toLowerCase();
+              }
+
+              if (item.description.indexOf(string) >= 0) {
+                return true;
+              }
+            }
+          }
+        }).focus();
+
+        $input.bind('winnow:finish', function() {
+          Drupal.announce(
+            Drupal.formatPlural(
+              $modulesWrapper.find(selector + ':visible').length,
+              '1 module is available in the modified list.',
+              '@count modules are available in the modified list.'
+            )
+          );
+        });
+      }
+    }
+  };
+
+})(jQuery, Drupal);
diff --git a/web/modules/module_filter/js/module_filter.permissions.js b/web/modules/module_filter/js/module_filter.permissions.js
new file mode 100644
index 0000000000000000000000000000000000000000..2a2a9c149e4614da14f95b7c2f1a4d019f57d1e0
--- /dev/null
+++ b/web/modules/module_filter/js/module_filter.permissions.js
@@ -0,0 +1,71 @@
+(function($) {
+
+  'use strict';
+
+  /**
+   * Filter enhancements.
+   */
+  Drupal.behaviors.moduleFilterPermissions = {
+    attach: function(context) {
+      var $input = $('input.table-filter-text', context).once('module-filter');
+      if ($input.length) {
+        var wrapperId = $input.attr('data-table');
+        var selector = 'tbody tr';
+        var lastModuleItem;
+
+        // Move location of filter input to before the permissions table.
+        $(wrapperId).parent().prepend($input.closest('.table-filter'));
+
+        $input.winnow(wrapperId + ' ' + selector, {
+          textSelector: 'td.module',
+          buildIndex: [
+            function(item) {
+              item.isModule = item.text != '';
+
+              if (item.isModule) {
+                item.children = [];
+                lastModuleItem = item;
+              }
+              else {
+                item.parent = lastModuleItem;
+                lastModuleItem.children.push(item);
+              }
+
+              return item;
+            }
+          ],
+          additionalOperators: {
+            perm: function(string, item) {
+              if (!item.isModule) {
+                if (item.permission == undefined) {
+                  item.permission = $('.permission .title', item.element).text().toLowerCase();
+                }
+
+                if (item.permission.indexOf(string) >= 0) {
+                  return true;
+                }
+              }
+            }
+          }
+        });
+
+        var winnow = $input.data('winnow');
+        $input.bind('winnow:finish', function() {
+          if (winnow.results.length > 0) {
+            for (var i in winnow.results) {
+              if (winnow.results[i].isModule) {
+                for (var k in winnow.results[i].children) {
+                  winnow.results[i].children[k].element.show();
+                }
+              }
+              else {
+                winnow.results[i].parent.element.show();
+              }
+            }
+          }
+        });
+      }
+    }
+  };
+
+})(jQuery);
diff --git a/web/modules/module_filter/js/module_filter.update_status.js b/web/modules/module_filter/js/module_filter.update_status.js
new file mode 100644
index 0000000000000000000000000000000000000000..6ef7802b7d1743e2db2cc31f72c8d695cc955801
--- /dev/null
+++ b/web/modules/module_filter/js/module_filter.update_status.js
@@ -0,0 +1,101 @@
+/**
+ * @file
+ * Module filter behaviors.
+ */
+
+(function($, Drupal) {
+
+  'use strict';
+
+  Drupal.ModuleFilter = Drupal.ModuleFilter || {};
+
+  /**
+   * Filter enhancements.
+   */
+  Drupal.behaviors.moduleFilterUpdateStatus = {
+    attach: function(context, settings) {
+      var $input = $('input.table-filter-text').once('module-filter');
+      if ($input.length) {
+        var selector = 'tbody tr';
+        var wrapperId = $input.attr('data-table');
+        var $wrapper = $(wrapperId);
+
+        var $show = $('.table-filter input[name="show"]', $wrapper);
+        var show = Drupal.ModuleFilter.localStorage.getItem('updateStatus.show') || 'all';
+
+        $input.winnow(wrapperId + ' ' + selector, {
+          textSelector: 'td .project-update__title a',
+          emptyMessage: Drupal.t('No results'),
+          clearLabel: Drupal.t('clear'),
+          wrapper: $wrapper,
+          buildIndex: [
+            function(item) {
+              if (item.element.is('.color-success')) {
+                item.state = 'ok';
+              }
+              else if (item.element.is('.color-warning')) {
+                item.state = 'warning';
+              }
+              else if (item.element.is('.color-error')) {
+                item.state = 'error';
+              }
+
+              return item;
+            }
+          ],
+          rules: [
+            function(item) {
+              switch (show) {
+                case 'all':
+                  return true;
+
+                case 'updates':
+                  if (item.state == 'warning' || item.state == 'error') {
+                    return true;
+                  }
+                  break;
+
+                case 'ignore':
+                  if (item.state == 'ignored') {
+                    return true;
+                  }
+                  break;
+              }
+
+              return false;
+            }
+          ]
+        }).focus();
+        Drupal.ModuleFilter.winnow = $input.data('winnow');
+
+        var $titles = $('h3', $wrapper);
+        $input.bind('winnow:finish', function() {
+          $titles.each(function(index, element) {
+            var $title = $(element);
+            var $table = $title.next();
+            if ($table.is('table')) {
+              var $visibleRows = $table.find(selector + ':visible');
+              $title.toggle($visibleRows.length > 0);
+            }
+          });
+
+          Drupal.announce(
+            Drupal.formatPlural(
+              $wrapper.find(selector + ':visible').length,
+              '1 project is available in the modified list.',
+              '@count projects are available in the modified list.'
+            )
+          );
+        });
+
+        $show.change(function() {
+          show = $(this).val();
+          Drupal.ModuleFilter.localStorage.setItem('updateStatus.show', show);
+          Drupal.ModuleFilter.winnow.filter();
+        });
+        $show.filter('[value="' + show + '"]').prop('checked', true).trigger('change');
+      }
+    }
+  };
+
+})(jQuery, Drupal);
diff --git a/web/modules/module_filter/module_filter.info.yml b/web/modules/module_filter/module_filter.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ab442fa9cbe4db9bbd7933d2521999377e13ee82
--- /dev/null
+++ b/web/modules/module_filter/module_filter.info.yml
@@ -0,0 +1,13 @@
+name: Module filter
+type: module
+description: Filter the modules list.
+package: Administration
+# core: 8.x
+
+configure: module_filter.settings
+
+# Information added by Drupal.org packaging script on 2017-10-10
+version: '8.x-3.1'
+core: '8.x'
+project: 'module_filter'
+datestamp: 1507650850
diff --git a/web/modules/module_filter/module_filter.install b/web/modules/module_filter/module_filter.install
new file mode 100644
index 0000000000000000000000000000000000000000..3af5125d56e10aa09c5273e972d69c7eb28d51b1
--- /dev/null
+++ b/web/modules/module_filter/module_filter.install
@@ -0,0 +1,22 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the module_filter module.
+ */
+
+/**
+ * Implements hook_install().
+ */
+function module_filter_install() {
+  $state = \Drupal::state();
+  $state->set('module_filter.recent', ['module_filter' => REQUEST_TIME]);
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function module_filter_uninstall() {
+  $state = \Drupal::state();
+  $state->delete('module_filter.recent');
+}
diff --git a/web/modules/module_filter/module_filter.libraries.yml b/web/modules/module_filter/module_filter.libraries.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5a604c5fa1eaace1f7b505fd076a920a49b144e1
--- /dev/null
+++ b/web/modules/module_filter/module_filter.libraries.yml
@@ -0,0 +1,61 @@
+winnow:
+  version: VERSION
+  js:
+    js/jquery.winnow.js: {}
+  dependencies:
+    - core/jquery
+    - core/jquery.ui.autocomplete
+filter:
+  version: VERSION
+  js:
+    js/module_filter.js: {}
+  css:
+    theme:
+      css/module_filter.css: {}
+  dependencies:
+    - module_filter/winnow
+modules:
+  version: VERSION
+  js:
+    js/module_filter.modules.js: {}
+  css:
+    theme:
+      css/module_filter.modules.css: {}
+  dependencies:
+    - module_filter/filter
+modules.bare:
+  version: VERSION
+  js:
+    js/module_filter.modules_bare.js: {}
+  dependencies:
+    - module_filter/modules
+modules.tabs:
+  version: VERSION
+  js:
+    js/module_filter.modules_tabs.js: {}
+  css:
+    theme:
+      css/module_filter.modules_tabs.css: {}
+  dependencies:
+    - module_filter/modules
+modules.uninstall:
+  version: VERSION
+  js:
+    js/module_filter.modules_uninstall.js: {}
+  dependencies:
+    - module_filter/filter
+update.status:
+  version: VERSION
+  js:
+    js/module_filter.update_status.js: {}
+  css:
+    theme:
+      css/module_filter.update_status.css: {}
+  dependencies:
+    - module_filter/filter
+permissions:
+  version: VERSION
+  js:
+    js/module_filter.permissions.js: {}
+  dependencies:
+    - module_filter/filter
diff --git a/web/modules/module_filter/module_filter.links.menu.yml b/web/modules/module_filter/module_filter.links.menu.yml
new file mode 100644
index 0000000000000000000000000000000000000000..aaea139d4a6d52aa8c376db1710f4b816318526c
--- /dev/null
+++ b/web/modules/module_filter/module_filter.links.menu.yml
@@ -0,0 +1,6 @@
+module_filter.config_admin_menu_item:
+  title: 'Module filter'
+  description: 'Settings for the Module Filter module.'
+  menu_name: admin
+  parent: system.admin_config_ui
+  route_name: module_filter.settings
diff --git a/web/modules/module_filter/module_filter.module b/web/modules/module_filter/module_filter.module
new file mode 100644
index 0000000000000000000000000000000000000000..18c009bb80107b6256c3522ccac079f65a8c7354
--- /dev/null
+++ b/web/modules/module_filter/module_filter.module
@@ -0,0 +1,202 @@
+<?php
+
+/**
+ * @file
+ * Provides a filtering mechanism to various admin pages.
+ */
+
+use Drupal\Core\Render\Element;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function module_filter_form_system_modules_alter(&$form, FormStateInterface $form_state, $form_id) {
+  $config = \Drupal::config('module_filter.settings');
+
+  $key = array_search('system/drupal.system.modules', $form['#attached']['library']);
+  if ($key !== FALSE) {
+    unset($form['#attached']['library'][$key]);
+  }
+  $form['#attached']['library'][] = $config->get('tabs') ? 'module_filter/modules.tabs' : 'module_filter/modules.bare';
+  unset($form['filters']['text']['#description']);
+  $form['filters']['text']['#placeholder'] = t('Filter by name');
+  if (!empty($_GET['filter'])) {
+    $form['filters']['text']['#default_value'] = $_GET['filter'];
+  }
+
+  $status_defaults = [
+    ((isset($_GET['enabled'])) ? $_GET['enabled'] : 1) ? 'enabled' : '',
+    ((isset($_GET['disabled'])) ? $_GET['disabled'] : 1) ? 'disabled' : '',
+    ((isset($_GET['unavailable'])) ? $_GET['unavailable'] : 1) ? 'unavailable' : '',
+  ];
+  $form['filters']['status'] = [
+    '#type' => 'container',
+    '#attributes' => [
+      'class' => [
+        'module-filter-status',
+      ],
+    ],
+    'checkboxes' => [
+      '#type' => 'checkboxes',
+      '#default_value' => array_filter($status_defaults),
+      '#options' => [
+        'enabled' => t('Enabled'),
+        'disabled' => t('Disabled'),
+        'unavailable' => t('Unavailable'),
+      ],
+    ],
+  ];
+
+  $state = \Drupal::state();
+  $recent = $state->get('module_filter.recent') ?: [];
+
+  // Remove recent items older than a week.
+  $recent = array_filter($recent, function ($val) {
+    return !($val < REQUEST_TIME - 60 * 60 * 24 * 7);
+  });
+  $state->set('module_filter.recent', $recent);
+
+  if (!empty($recent)) {
+    foreach ($recent as $module => $time) {
+      foreach (Element::children($form['modules']) as $package) {
+        if (isset($form['modules'][$package][$module])) {
+          $form['modules'][$package][$module]['#attributes']['class'][] = 'recent';
+          break;
+        }
+      }
+    }
+  }
+
+  $modules = system_rebuild_module_data();
+
+  foreach ($modules as $name => $module) {
+    if ($name == 'module_filter') {
+      $ctime = filectime($module->getPathname());
+      if (($ctime - strtotime('-1 week')) > 0) {
+        foreach (Element::children($form['modules']) as $package) {
+          if (isset($form['modules'][$package][$name])) {
+            $form['modules'][$package][$name]['#attributes']['class'][] = 'new';
+            break;
+          }
+        }
+      }
+    }
+  }
+
+  $form['#submit'][] = 'module_filter_system_modules_recent_enabled_submit';
+  $form['#submit'][] = 'module_filter_system_modules_redirect_submit';
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function module_filter_form_system_modules_confirm_form_alter(&$form, FormStateInterface $form_state, $form_id) {
+  $form['filters']['text'] = [
+    '#type' => 'value',
+    '#value' => isset($_GET['filter']) ? $_GET['filter'] : '',
+  ];
+  $form['#submit'][] = 'module_filter_system_modules_redirect_submit';
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function module_filter_form_system_modules_uninstall_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
+  $key = array_search('system/drupal.system.modules', $form['#attached']['library']);
+  if ($key !== FALSE) {
+    unset($form['#attached']['library'][$key]);
+  }
+  $form['#attached']['library'][] = 'module_filter/modules.uninstall';
+  unset($form['filters']['text']['#description']);
+  $form['filters']['text']['#placeholder'] = t('Filter by name');
+  if (!empty($_GET['filter'])) {
+    $form['filters']['text']['#default_value'] = $_GET['filter'];
+  }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function module_filter_form_user_admin_permissions_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
+  $form['filters'] = [
+    '#type' => 'container',
+    '#attributes' => [
+      'class' => ['table-filter', 'js-show'],
+    ],
+  ];
+  $form['filters']['text'] = [
+    '#type' => 'search',
+    '#title' => t('Filter modules'),
+    '#title_display' => 'invisible',
+    '#size' => 30,
+    '#placeholder' => t('Filter by name'),
+    '#attributes' => [
+      'class' => ['table-filter-text'],
+      'data-table' => '#permissions',
+      'autocomplete' => 'off',
+    ],
+    '#weight' => -1000,
+  ];
+  if (!empty($_GET['filter'])) {
+    $form['filters']['text']['#default_value'] = $_GET['filter'];
+  }
+  $form['#attached']['library'][] = 'module_filter/permissions';
+}
+
+/**
+ * Implements hook_theme_registry_alter().
+ */
+function module_filter_theme_registry_alter(&$theme_registry) {
+  // We need to alter the system-modules-details template so we can add
+  // applicable requires and required-by classes.
+  $theme_registry['system_modules_details']['path'] = drupal_get_path('module', 'module_filter') . '/templates';
+}
+
+/**
+ * Implements hook_preprocess_HOOK().
+ */
+function module_filter_preprocess_system_modules_details(&$variables) {
+}
+
+/**
+ * Form submit callback to track recently enabled modules.
+ */
+function module_filter_system_modules_recent_enabled_submit($form, FormStateInterface $form_state) {
+  $state = \Drupal::state();
+  $recent = $state->get('module_filter.recent') ?: [];
+
+  // Drupal 8.3.0 simplified the module form structure which requires checking
+  // the version of Drupal and building the $modules array accordingly.
+  // @see https://www.drupal.org/node/2851653
+  $modules = [];
+  if (version_compare(\DRUPAL::VERSION, '8.3.0', '<')) {
+    foreach ($form_state->getValue('modules') as $package) {
+      $modules += $package;
+    }
+  }
+  else {
+    $modules = $form_state->getValue('modules');
+  }
+
+  foreach (Element::children($form['modules']) as $package) {
+    foreach ($modules as $module => $details) {
+      if (isset($form['modules'][$package][$module]) && $form['modules'][$package][$module]['enable']['#default_value'] != $details['enable']) {
+        $recent[$module] = REQUEST_TIME;
+      }
+    }
+  }
+
+  $state->set('module_filter.recent', $recent);
+}
+
+/**
+ * Form submit callback for remembering the filter value.
+ */
+function module_filter_system_modules_redirect_submit($form, FormStateInterface $form_state) {
+  if ($text = $form_state->getValue('text')) {
+    /** @var \Drupal\Core\Url $redirect */
+    $route_name = ($redirect = $form_state->getRedirect()) ? $redirect->getRouteName() : 'system.modules_list';
+    $form_state->setRedirect($route_name, ['filter' => $text]);
+  }
+}
diff --git a/web/modules/module_filter/module_filter.permissions.yml b/web/modules/module_filter/module_filter.permissions.yml
new file mode 100644
index 0000000000000000000000000000000000000000..fca0cb0082f5a3268f83a75de2c05fa1f8f3e6a5
--- /dev/null
+++ b/web/modules/module_filter/module_filter.permissions.yml
@@ -0,0 +1,3 @@
+administer module_filter:
+  title: 'Administer Module Filter'
+  description: 'Configure how Module Filter performs.'
diff --git a/web/modules/module_filter/module_filter.routing.yml b/web/modules/module_filter/module_filter.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..524b2cb4f195be7a4caf4ccc4a2ca30d1875be70
--- /dev/null
+++ b/web/modules/module_filter/module_filter.routing.yml
@@ -0,0 +1,7 @@
+module_filter.settings:
+  path: '/admin/config/user-interface/module-filter'
+  defaults:
+    _form: '\Drupal\module_filter\Form\ModuleFilterSettingsForm'
+    _title: 'Module filter settings'
+  requirements:
+    _permission: 'administer module_filter'
diff --git a/web/modules/module_filter/module_filter.services.yml b/web/modules/module_filter/module_filter.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9bd8edffa42cedc91400621963626a34353cb2ee
--- /dev/null
+++ b/web/modules/module_filter/module_filter.services.yml
@@ -0,0 +1,5 @@
+services:
+  module_filter.route_subscriber:
+    class: Drupal\module_filter\Routing\RouteSubscriber
+    tags:
+      - { name: event_subscriber }
diff --git a/web/modules/module_filter/src/Controller/ModuleFilterUpdateController.php b/web/modules/module_filter/src/Controller/ModuleFilterUpdateController.php
new file mode 100644
index 0000000000000000000000000000000000000000..7abffa8b1cbef4916c07cf862e6ed4dffa1d52eb
--- /dev/null
+++ b/web/modules/module_filter/src/Controller/ModuleFilterUpdateController.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Drupal\module_filter\Controller;
+
+use Drupal\update\Controller\UpdateController;
+
+/**
+ * A wrapper controller for injecting the filter into the update status page.
+ */
+class ModuleFilterUpdateController extends UpdateController {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function updateStatus() {
+    $build = [
+      '#type' => 'container',
+      '#attributes' => [
+        'id' => 'update-status',
+      ],
+    ];
+    $build['module_filter'] = $this->formBuilder()->getForm('Drupal\module_filter\Form\ModuleFilterUpdateStatusForm');
+    $build['update_report'] = parent::updateStatus();
+    return $build;
+  }
+
+}
diff --git a/web/modules/module_filter/src/Form/ModuleFilterSettingsForm.php b/web/modules/module_filter/src/Form/ModuleFilterSettingsForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..fb1ec4b6523d5e420c5729e2286cc09e674c2e01
--- /dev/null
+++ b/web/modules/module_filter/src/Form/ModuleFilterSettingsForm.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Drupal\module_filter\Form;
+
+use Drupal\Core\Form\ConfigFormBase;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Settings form for Module Filter.
+ */
+class ModuleFilterSettingsForm extends ConfigFormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'module_filter_settings_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $config = $this->config('module_filter.settings');
+    $form = parent::buildForm($form, $form_state);
+
+    $form['modules'] = [
+      '#type' => 'fieldset',
+      '#title' => $this->t('Extend'),
+      '#description' => $this->t('These are settings pertaining to the Extend pages of the site.'),
+      '#collapsible' => FALSE,
+    ];
+    $form['modules']['tabs'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Enhance the Extend page with tabs'),
+      '#description' => $this->t('Provides many enhancements to the Extend page including the use of tabs for packages.'),
+      '#default_value' => $config->get('tabs'),
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $values = $form_state->getValues();
+    $this->config('module_filter.settings')
+      ->set('tabs', $values['tabs'])
+      ->save();
+
+    parent::submitForm($form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEditableConfigNames() {
+    return ['module_filter.settings'];
+  }
+
+}
diff --git a/web/modules/module_filter/src/Form/ModuleFilterUpdateStatusForm.php b/web/modules/module_filter/src/Form/ModuleFilterUpdateStatusForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..eebca5332b1b0c53894c13901de654cf9ed07cc4
--- /dev/null
+++ b/web/modules/module_filter/src/Form/ModuleFilterUpdateStatusForm.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace Drupal\module_filter\Form;
+
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * A form for filtering the update status report page.
+ */
+class ModuleFilterUpdateStatusForm extends FormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'module_filter_update_status_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $form['filters'] = [
+      '#type' => 'container',
+      '#attributes' => [
+        'class' => ['table-filter', 'js-show'],
+      ],
+    ];
+
+    $form['filters']['text'] = [
+      '#type' => 'search',
+      '#title' => $this->t('Filter projects'),
+      '#title_display' => 'invisible',
+      '#size' => 30,
+      '#placeholder' => $this->t('Filter by name'),
+      '#attributes' => [
+        'class' => ['table-filter-text'],
+        'data-table' => '#update-status',
+        'autocomplete' => 'off',
+      ],
+      '#attached' => [
+        'library' => [
+          'module_filter/update.status',
+        ],
+      ],
+    ];
+    if (!empty($_GET['filter'])) {
+      $form['filters']['text']['#default_value'] = $_GET['filter'];
+    }
+
+    $form['filters']['radios'] = [
+      '#type' => 'container',
+      '#attributes' => [
+        'class' => [
+          'module-filter-status',
+        ],
+      ],
+      'show' => [
+        '#type' => 'radios',
+        '#default_value' => 'all',
+        '#options' => [
+          'all' => $this->t('All'),
+          'updates' => $this->t('Update available'),
+          'security' => $this->t('Security update'),
+        ],
+      ],
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {}
+
+}
diff --git a/web/modules/module_filter/src/Routing/RouteSubscriber.php b/web/modules/module_filter/src/Routing/RouteSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..91454769f45e4616b4d5752c1b1dc0bfa685688c
--- /dev/null
+++ b/web/modules/module_filter/src/Routing/RouteSubscriber.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Drupal\module_filter\Routing;
+
+use Drupal\Core\Routing\RouteSubscriberBase;
+use Symfony\Component\Routing\RouteCollection;
+
+/**
+ * Listens to the dynamic route events.
+ */
+class RouteSubscriber extends RouteSubscriberBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alterRoutes(RouteCollection $collection) {
+    if ($route = $collection->get('update.status')) {
+      $route->setDefault('_controller', 'Drupal\module_filter\Controller\ModuleFilterUpdateController::updateStatus');
+    }
+  }
+
+}
diff --git a/web/modules/module_filter/templates/system-modules-details.html.twig b/web/modules/module_filter/templates/system-modules-details.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..144f8609e5487256c7727390f3066ba652c8f703
--- /dev/null
+++ b/web/modules/module_filter/templates/system-modules-details.html.twig
@@ -0,0 +1,76 @@
+{#
+/**
+ * @file
+ * Default theme implementation for the modules listing page.
+ *
+ * Displays a list of all packages in a project.
+ *
+ * Available variables:
+ * - modules: Contains multiple module instances. Each module contains:
+ *   - attributes: Attributes on the row.
+ *   - checkbox: A checkbox for enabling the module.
+ *   - name: The human-readable name of the module.
+ *   - id: A unique identifier for interacting with the details element.
+ *   - enable_id: A unique identifier for interacting with the checkbox element.
+ *   - description: The description of the module.
+ *   - machine_name: The module's machine name.
+ *   - version: Information about the module version.
+ *   - requires: A list of modules that this module requires.
+ *   - required_by: A list of modules that require this module.
+ *   - links: A list of administration links provided by the module.
+ *
+ * @see template_preprocess_system_modules_details()
+ *
+ * @ingroup themeable
+ */
+#}
+<table class="responsive-enabled" data-striping="1">
+  <thead>
+  <tr>
+    <th class="checkbox visually-hidden">{{ 'Installed'|t }}</th>
+    <th class="name visually-hidden">{{ 'Name'|t }}</th>
+    <th class="description visually-hidden priority-low">{{ 'Description'|t }}</th>
+  </tr>
+  </thead>
+  <tbody>
+  {% for module in modules %}
+    {% set zebra = cycle(['odd', 'even'], loop.index0) %}
+    <tr{{ module.attributes.addClass(zebra) }}>
+      <td class="checkbox">
+        {{ module.checkbox }}
+      </td>
+      <td class="module">
+        <label id="{{ module.id }}" for="{{ module.enable_id }}" class="module-name table-filter-text-source">{{ module.name }}</label>
+      </td>
+      <td class="description expand priority-low">
+        <details class="js-form-wrapper form-wrapper" id="{{ module.enable_id }}-description">
+          <summary aria-controls="{{ module.enable_id }}-description" role="button" aria-expanded="false"><span class="text module-description">{{ module.description }}</span></summary>
+          <div class="details-wrapper">
+            <div class="details-description">
+              <div class="requirements">
+                <div class="admin-requirements">{{ 'Machine name: <span dir="ltr" class="table-filter-text-source">@machine-name</span>'|t({'@machine-name': module.machine_name }) }}</div>
+                {% if module.version %}
+                  <div class="admin-requirements">{{ 'Version: @module-version'|t({'@module-version': module.version }) }}</div>
+                {% endif %}
+                {% if module.requires %}
+                  <div class="admin-requirements requires">{{ 'Requires: @module-list'|t({'@module-list': module.requires }) }}</div>
+                {% endif %}
+                {% if module.required_by %}
+                  <div class="admin-requirements required-by">{{ 'Required by: @module-list'|t({'@module-list': module.required_by }) }}</div>
+                {% endif %}
+              </div>
+              {% if module.links %}
+                <div class="links">
+                  {% for link_type in ['help', 'permissions', 'configure'] %}
+                    {{ module.links[link_type] }}
+                  {% endfor %}
+                </div>
+              {% endif %}
+            </div>
+          </div>
+        </details>
+      </td>
+    </tr>
+  {% endfor %}
+  </tbody>
+</table>