Add screen to browse the server info

GET /config/server/info returns a JSON describing important properties
of the server. Most of them are only needed for PolyGerrit, but some are
relevant for users, e.g. which account visibility is configured or the
server metadata (needed to investigate account and ACL issues).

Pointing users to this REST endpoint and explaining them where in the
returned JSON they can find relevant information is difficult and not
user friendly. Instead have a screen that shows the user relevant part
of the server info in a simple list. Then we can tell users to look at
value Foo at this screen.

The new screen is available at /admin/server-info. This is an example
how it looks like: https://i.imgur.com/WVafczb.png

Bug: Google b/330836100
Release-Notes: Added screen to browse the server info
Change-Id: Iad1c19b7848974c26582433757d8aea2617cbf39
Signed-off-by: Edwin Kempin <ekempin@google.com>
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 044693e..21807a1 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -1084,6 +1084,7 @@
   user: UserConfigInfo;
   default_theme?: string;
   submit_requirement_dashboard_columns?: string[];
+  metadata?: MetadataInfo[];
 }
 
 /**
@@ -1094,6 +1095,16 @@
  */
 export type SshdInfo = {};
 
+/**
+ * The MetadataInfo entity contains contains metadata provided by plugins.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#metadata-info
+ */
+export declare interface MetadataInfo {
+  name: string;
+  value?: string;
+  description?: string;
+}
+
 // Timestamps are given in UTC and have the format
 // "'yyyy-mm-dd hh:mm:ss.fffffffff'"
 // where "'ffffffffff'" represents nanoseconds.
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 21e032b..58827fb 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -17,6 +17,7 @@
 import '../gr-repo-dashboards/gr-repo-dashboards';
 import '../gr-repo-detail-list/gr-repo-detail-list';
 import '../gr-repo-list/gr-repo-list';
+import '../gr-server-info/gr-server-info';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   AccountDetailInfo,
@@ -213,6 +214,7 @@
       ${this.renderGroupMembers()} ${this.renderGroupAuditLog()}
       ${this.renderRepoDetailList()} ${this.renderRepoCommands()}
       ${this.renderRepoAccess()} ${this.renderRepoDashboards()}
+      ${this.renderServerInfo()}
     `;
   }
 
@@ -447,6 +449,18 @@
     `;
   }
 
+  private renderServerInfo() {
+    if (this.view !== GerritView.ADMIN) return nothing;
+    if (this.adminViewState?.adminView !== AdminChildView.SERVER_INFO)
+      return nothing;
+
+    return html`
+      <div class="main table">
+        <gr-server-info class="table"></gr-server-info>
+      </div>
+    `;
+  }
+
   override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('groupId')) {
       this.computeGroupName();
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
index d184f35..cbac9de 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
@@ -84,7 +84,7 @@
       Promise.resolve(createAdminCapabilities())
     );
     await element.reload();
-    assert.equal(element.filteredLinks!.length, 3);
+    assert.equal(element.filteredLinks!.length, 4);
 
     // Repos
     assert.isNotOk(element.filteredLinks![0].subsection);
@@ -98,7 +98,7 @@
 
   test('filteredLinks non admin authenticated', async () => {
     await element.reload();
-    assert.equal(element.filteredLinks!.length, 2);
+    assert.equal(element.filteredLinks!.length, 3);
     // Repos
     assert.isNotOk(element.filteredLinks![0].subsection);
     // Groups
@@ -162,7 +162,7 @@
     );
     await element.reload();
     await element.updateComplete;
-    assert.equal(queryAll<HTMLLIElement>(element, '.sectionTitle').length, 3);
+    assert.equal(queryAll<HTMLLIElement>(element, '.sectionTitle').length, 4);
     assert.equal(
       queryAndAssert<HTMLSpanElement>(element, '.breadcrumbText').innerText,
       'Test Repo'
@@ -189,7 +189,7 @@
     );
     await element.reload();
     await element.updateComplete;
-    assert.equal(element.filteredLinks!.length, 3);
+    assert.equal(element.filteredLinks!.length, 4);
     // Repos
     assert.isNotOk(element.filteredLinks![0].subsection);
     // Groups
@@ -385,6 +385,12 @@
         url: '/admin/plugins',
         view: 'gr-plugin-list' as GerritView,
       },
+      {
+        name: 'Server Info',
+        section: 'Server Info',
+        url: '/admin/server-info',
+        view: 'gr-server-info' as GerritView,
+      },
     ];
     const expectedSubsectionLinks = [
       {
@@ -532,6 +538,11 @@
                   Plugins
                 </a>
               </li>
+              <li class="sectionTitle">
+                <a class="title" href="/admin/server-info" rel="noopener">
+                  Server Info
+                </a>
+              </li>
             </ul>
           </gr-page-nav>
           <div class="main table">
diff --git a/polygerrit-ui/app/elements/admin/gr-server-info/gr-server-info.ts b/polygerrit-ui/app/elements/admin/gr-server-info/gr-server-info.ts
new file mode 100644
index 0000000..67843a9
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-server-info/gr-server-info.ts
@@ -0,0 +1,142 @@
+/**
+ * @license
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {MetadataInfo, ServerInfo} from '../../../types/common';
+import {configModelToken} from '../../../models/config/config-model';
+import {customElement, state} from 'lit/decorators.js';
+import {css, html, LitElement} from 'lit';
+import {fireTitleChange} from '../../../utils/event-util';
+import {map} from 'lit/directives/map.js';
+import {resolve} from '../../../models/dependency';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {tableStyles} from '../../../styles/gr-table-styles';
+
+@customElement('gr-server-info')
+export class GrServerInfo extends LitElement {
+  @state() serverInfo?: ServerInfo;
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      serverInfo => {
+        this.serverInfo = serverInfo;
+      }
+    );
+  }
+
+  static override get styles() {
+    return [
+      tableStyles,
+      sharedStyles,
+      css`
+        .genericList tr td:last-of-type {
+          text-align: left;
+        }
+        .genericList tr th:last-of-type {
+          text-align: left;
+        }
+        .metadataDescription,
+        .metadataName,
+        .metadataValue {
+          white-space: nowrap;
+        }
+        .placeholder {
+          color: var(--deemphasized-text-color);
+        }
+      `,
+    ];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    fireTitleChange('Server Info');
+  }
+
+  override render() {
+    return html`
+      <main class="gr-form-styles read-only">
+        <table id="list" class="genericList">
+          <tbody>
+            <tr class="headerRow">
+              <th class="metadataName topHeader">Name</th>
+              <th class="metadataValue topHeader">Value</th>
+              <th class="metadataDescription topHeader">Description</th>
+            </tr>
+          </tbody>
+          ${this.renderServerInfoTable()}
+        </table>
+      </main>
+    `;
+  }
+
+  private renderServerInfoTable() {
+    return html`
+      <tbody>
+        ${map(this.getServerInfoAsMetadataInfos(), metadata =>
+          this.renderServerInfo(metadata)
+        )}
+      </tbody>
+    `;
+  }
+
+  private renderServerInfo(metadata: MetadataInfo) {
+    return html`
+      <tr class="table">
+        <td class="metadataName">${metadata.name}</td>
+        <td class="metadataValue">
+          ${metadata.value
+            ? metadata.value
+            : html`<span class="placeholder">--</span>`}
+        </td>
+        <td class="metadataDescription">
+          ${metadata.description ? metadata.description : ''}
+        </td>
+      </tr>
+    `;
+  }
+
+  private getServerInfoAsMetadataInfos() {
+    let metadataList = new Array<MetadataInfo>();
+
+    if (this.serverInfo?.accounts?.visibility) {
+      const accountsVisibilityMetadata = {
+        name: 'accounts.visibility',
+        value: this.serverInfo.accounts.visibility,
+        description:
+          "Controls visibility of other users' dashboard pages and completion suggestions to web users.",
+      };
+      metadataList.push(accountsVisibilityMetadata);
+    }
+
+    if (this.serverInfo?.metadata) {
+      metadataList = metadataList.concat(this.serverInfo.metadata);
+    }
+
+    return metadataList;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-server-info': GrServerInfo;
+  }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 30f4bf8..28ca5fd 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -46,6 +46,7 @@
   AdminViewModel,
   AdminViewState,
   PLUGIN_LIST_ROUTE,
+  SERVER_INFO_ROUTE,
 } from '../../../models/views/admin';
 import {
   AgreementViewModel,
@@ -178,6 +179,9 @@
   // Matches /admin/repos/$REPO,tags with optional filter and offset.
   TAG_LIST: /^\/admin\/repos\/(.+),tags\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
 
+  // Matches /admin/server-info.
+  SERVER_INFO: /^\/admin\/server-info$/,
+
   QUERY: /^\/q\/(.+?)(,(\d+))?$/,
 
   /**
@@ -930,6 +934,13 @@
       this.handlePluginScreen(ctx)
     );
 
+    this.mapRouteState(
+      SERVER_INFO_ROUTE,
+      this.adminViewModel,
+      'handleServerInfoRoute',
+      true
+    );
+
     this.mapRoute(
       RoutePattern.DOCUMENTATION_SEARCH_FILTER,
       'handleDocumentationSearchRoute',
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index 6f4e528..4a78140 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -161,6 +161,7 @@
       'handlePluginListRoute',
       'handleRepoCommandsRoute',
       'handleRepoEditFileRoute',
+      'handleServerInfoRoute',
       'handleSettingsLegacyRoute',
       'handleSettingsRoute',
     ];
diff --git a/polygerrit-ui/app/models/views/admin.ts b/polygerrit-ui/app/models/views/admin.ts
index 02b5796..164858d 100644
--- a/polygerrit-ui/app/models/views/admin.ts
+++ b/polygerrit-ui/app/models/views/admin.ts
@@ -59,10 +59,22 @@
   },
 };
 
+export const SERVER_INFO_ROUTE: Route<AdminViewState> = {
+  urlPattern: /^\/admin\/server-info$/,
+  createState: () => {
+    const state: AdminViewState = {
+      view: GerritView.ADMIN,
+      adminView: AdminChildView.SERVER_INFO,
+    };
+    return state;
+  },
+};
+
 export enum AdminChildView {
   REPOS = 'gr-repo-list',
   GROUPS = 'gr-admin-group-list',
   PLUGINS = 'gr-plugin-list',
+  SERVER_INFO = 'gr-server-info',
 }
 const ADMIN_LINKS: NavLink[] = [
   {
@@ -84,6 +96,12 @@
     url: createAdminUrl({adminView: AdminChildView.PLUGINS}),
     view: 'gr-plugin-list' as GerritView,
   },
+  {
+    name: 'Server Info',
+    section: 'Server Info',
+    url: createAdminUrl({adminView: AdminChildView.SERVER_INFO}),
+    view: 'gr-server-info' as GerritView,
+  },
 ];
 
 export interface AdminLink {
@@ -277,6 +295,8 @@
       return `${getBaseUrl()}/admin/groups`;
     case AdminChildView.PLUGINS:
       return `${getBaseUrl()}/admin/plugins`;
+    case AdminChildView.SERVER_INFO:
+      return `${getBaseUrl()}/admin/server-info`;
   }
 }
 
diff --git a/polygerrit-ui/app/models/views/admin_test.ts b/polygerrit-ui/app/models/views/admin_test.ts
index 5d142bf..1cd1897 100644
--- a/polygerrit-ui/app/models/views/admin_test.ts
+++ b/polygerrit-ui/app/models/views/admin_test.ts
@@ -55,6 +55,11 @@
       assert.isNotOk(res.links[2].subsection);
     }
 
+    if (expected.serverInfoShown) {
+      assert.equal(res.links[3].name, 'Server Info');
+      assert.isNotOk(res.links[3].subsection);
+    }
+
     if (expected.projectPageShown) {
       assert.isOk(res.links[0].subsection);
       assert.equal(res.links[0].subsection!.children!.length, 6);
@@ -116,6 +121,7 @@
         groupListShown: false,
         groupPageShown: false,
         pluginListShown: false,
+        serverInfoShown: false,
       };
     });
 
@@ -162,7 +168,7 @@
 
     setup(() => {
       expected = {
-        totalLength: 2,
+        totalLength: 3,
         pluginListShown: false,
       };
       capabilityStub.returns(Promise.resolve({}));
@@ -203,9 +209,10 @@
     setup(() => {
       capabilityStub.returns(Promise.resolve({viewPlugins: true}));
       expected = {
-        totalLength: 3,
+        totalLength: 4,
         groupListShown: true,
         pluginListShown: true,
+        serverInfoShown: true,
       };
     });
 
@@ -312,7 +319,7 @@
       ];
       menuLinkStub.returns(generatedLinks);
       expected = Object.assign(expected, {
-        totalLength: 4,
+        totalLength: 5,
         pluginGeneratedLinks: generatedLinks,
       });
       await testAdminLinks(account, options, expected);
@@ -339,7 +346,7 @@
       ];
       menuLinkStub.returns(generatedLinks);
       expected = Object.assign(expected, {
-        totalLength: 3,
+        totalLength: 4,
         pluginGeneratedLinks: [generatedLinks[0]],
       });
       await testAdminLinks(account, options, expected);
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index 05927f3..09ba661 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -18,6 +18,7 @@
   PLUGIN_SCREEN = 'plugin-screen',
   REPO = 'repo',
   SEARCH = 'search',
+  SERVER_INFO = 'server-info',
   SETTINGS = 'settings',
 }
 
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index e12e1a8..8465e7a 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -81,6 +81,7 @@
   LabelTypeInfoValues,
   LabelValueToDescriptionMap,
   MaxObjectSizeLimitInfo,
+  MetadataInfo,
   NumericChangeId,
   ParentCommitInfo,
   PARENT,
@@ -185,6 +186,7 @@
   LabelTypeInfoValues,
   LabelValueToDescriptionMap,
   MaxObjectSizeLimitInfo,
+  MetadataInfo,
   NumericChangeId,
   ParentCommitInfo,
   PatchRange,
@@ -381,17 +383,7 @@
   capabilities?: AccountCapabilityInfo;
   groups: GroupInfo[];
   external_ids: AccountExternalIdInfo[];
-  metadata: AccountMetadataInfo[];
-}
-
-/**
- * The `AccountMetadataInfo` entity contains account metadata.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-metadata-info
- */
-export interface AccountMetadataInfo {
-  name: string;
-  value?: string;
-  description?: string;
+  metadata: MetadataInfo[];
 }
 
 /**