From 84bd92e8d6690c16d6c5fc6087674edd007ef484 Mon Sep 17 00:00:00 2001
From: Tin Geber <tin@greenhost.nl>
Date: Mon, 29 May 2023 17:27:43 +0200
Subject: [PATCH] added version info, pretty icons, style tweaks

---
 backend/areas/__init__.py                     | 56 +++++++++++++++++++
 backend/helpers/kubernetes.py                 | 27 +++++++++
 frontend/src/modules/dashboard/Dashboard.tsx  | 15 +++++
 .../DashboardCard/DashboardCard.tsx           |  7 +--
 4 files changed, 101 insertions(+), 4 deletions(-)

diff --git a/backend/areas/__init__.py b/backend/areas/__init__.py
index b90dfee2..f14dde52 100644
--- a/backend/areas/__init__.py
+++ b/backend/areas/__init__.py
@@ -1,6 +1,9 @@
 from flask import Blueprint, jsonify
+import yaml
 
 from config import *
+import helpers.kubernetes as k8s
+import requests
 
 api_v1 = Blueprint("api_v1", __name__, url_prefix="/api/v1")
 
@@ -17,3 +20,56 @@ def api_environment():
         "KRATOS_PUBLIC_URL": KRATOS_PUBLIC_URL,
     }
     return jsonify(environment)
+
+# We want to know if
+# 1. A release has happened recently and is already deployed on this cluster.
+# 2. A release has happened recently but has not yet been deployed on this
+# cluster -- that will then probably happen automatically during the next
+# maintenance window.
+#
+# To get the last release, we get the contents of the `VERSION` file from
+# the main branch. The `VERSION` file is only updated as part of the release
+# process.
+#
+# To find out how long ago the currently running version was deployed, we look
+# at the `lastUpdateTime` of the stackspin `GitRepo` object on the cluster.
+@api_v1.route("/info")
+def api_info():
+    # Get static info from configmap on cluster.
+    static_info = k8s.get_kubernetes_config_map_data(
+        "stackspin-static-info",
+        "flux-system",
+    )
+    results = static_info
+
+    # Get app versions from apps configmaps on cluster.
+    results['appVersions'] = {}
+    apps = k8s.get_kubernetes_config_map_data(
+        "stackspin-apps",
+        "flux-system",
+    )
+    for app, app_data in apps.items():
+        data = yaml.safe_load(app_data)
+        if 'version' in data:
+            results['appVersions'][app] = data['version']
+    apps_custom = k8s.get_kubernetes_config_map_data(
+        "stackspin-apps-custom",
+        "flux-system",
+    )
+    if apps_custom is not None:
+        for app, app_data in apps_custom.items():
+            data = yaml.safe_load(app_data)
+            if 'version' in data:
+                results['appVersions'][app] = data['version']
+
+    # Get latest released version from gitlab.
+    git_release = requests.get("https://open.greenhost.net/stackspin/stackspin/-/raw/main/VERSION").text.rstrip()
+    results['lastRelease'] = git_release
+
+    # Get last update time of stackspin GitRepo object on the cluster; that
+    # tells us when flux last updated the cluster based on changes in the
+    # stackspin git repo.
+    stackspin_repo = k8s.get_gitrepo('stackspin')
+    results['lastUpdated'] = stackspin_repo['status']['artifact']['lastUpdateTime']
+
+    return jsonify(results)
diff --git a/backend/helpers/kubernetes.py b/backend/helpers/kubernetes.py
index cba600ef..1d54caaa 100644
--- a/backend/helpers/kubernetes.py
+++ b/backend/helpers/kubernetes.py
@@ -382,3 +382,30 @@ def get_kustomization(name, namespace='flux-system'):
         # Raise all non-404 errors
         raise error
     return resource
+
+def get_gitrepo(name, namespace='flux-system'):
+    """
+    Returns all info on a Flux GitRepo.
+
+    :param name: Name of the gitrepo
+    :type name: string
+    :param namespace: Namespace of the gitrepo
+    :type namespace: string
+    :return: gitrepo as returned by the API
+    :rtype: dict
+    """
+    api = client.CustomObjectsApi()
+    try:
+        resource = api.get_namespaced_custom_object(
+            group="source.toolkit.fluxcd.io",
+            version="v1beta2",
+            name=name,
+            namespace=namespace,
+            plural="gitrepositories",
+        )
+    except client.exceptions.ApiException as error:
+        if error.status == 404:
+            return None
+        # Raise all non-404 errors
+        raise error
+    return resource
diff --git a/frontend/src/modules/dashboard/Dashboard.tsx b/frontend/src/modules/dashboard/Dashboard.tsx
index 04f16bc4..1616b7ab 100644
--- a/frontend/src/modules/dashboard/Dashboard.tsx
+++ b/frontend/src/modules/dashboard/Dashboard.tsx
@@ -9,12 +9,20 @@
 import React, { useEffect } from 'react';
 import { useApps } from 'src/services/apps';
 import { AppStatusEnum } from 'src/services/apps/types';
+import { ExclamationCircleIcon, CheckCircleIcon, NewspaperIcon } from '@heroicons/react/outline';
 import systemInfo from './systemInfo.json';
 import { DashboardCard, DashboardUtility } from './components';
 import { DASHBOARD_QUICK_ACCESS, HIDDEN_APPS, UTILITY_APPS } from './consts';
 
 const appVersion = systemInfo.appVersions;
 
+const versionStatusIcon =
+  systemInfo.lastRelease > systemInfo.version ? (
+    <ExclamationCircleIcon className="h-4 w-4 text-yellow-500" />
+  ) : (
+    <CheckCircleIcon className="h-4 w-4 text-primary-800" />
+  );
+
 export const Dashboard: React.FC = () => {
   const host = window.location.hostname;
   const splitedDomain = host.split('.');
@@ -33,6 +41,13 @@ export const Dashboard: React.FC = () => {
       <div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8">
         <div className="mt-6 pb-5 border-b border-gray-200 sm:flex sm:items-center sm:justify-between">
           <h1 className="text-3xl leading-6 font-bold text-gray-900">Dashboard</h1>
+          <div className="system-status flex items-center text-sm font-medium text-gray-500 gap-1">
+            <p className="">Version {systemInfo.version} </p>
+            <span>{versionStatusIcon}</span>
+            <a className="hover:text-primary-500 underline" href={systemInfo.releaseNotesUrl}>
+              (Changelog)
+            </a>
+          </div>
         </div>
       </div>
 
diff --git a/frontend/src/modules/dashboard/components/DashboardCard/DashboardCard.tsx b/frontend/src/modules/dashboard/components/DashboardCard/DashboardCard.tsx
index cf6464b3..ca3b4b92 100644
--- a/frontend/src/modules/dashboard/components/DashboardCard/DashboardCard.tsx
+++ b/frontend/src/modules/dashboard/components/DashboardCard/DashboardCard.tsx
@@ -3,7 +3,6 @@ import { Modal } from 'src/components';
 import ReactMarkdown from 'react-markdown';
 import matter from 'gray-matter';
 import { QuestionMarkCircleIcon } from '@heroicons/react/outline';
-// import { array } from 'yup';
 
 type DashboardCardProps = {
   app: any;
@@ -31,7 +30,7 @@ export const DashboardCard = ({ app, version }: DashboardCardProps) => {
   return (
     <>
       <div
-        className="bg-white relative overflow-hidden shadow rounded-lg divide-y divide-gray-100 mb-4 md:mb-0"
+        className="bg-white overflow-hidden shadow rounded-lg divide-y divide-gray-100 mb-4 md:mb-0 flex flex-col justify-between relative"
         key={app.name}
       >
         <div className="flex items-end justify-end absolute top-2 right-2">
@@ -47,11 +46,11 @@ export const DashboardCard = ({ app, version }: DashboardCardProps) => {
               alt={app.name}
             />
 
-            <div>
+            <div className="flex justify-between gap-1 items-center">
               <h2 className="text-xl leading-8 font-bold">{app.name}</h2>
+              <p className="text-xs text-gray-400">{version}</p>
             </div>
           </div>
-          <p className="text-xs">{version} is the version</p>
           <p className="text-gray-500 mt-2 text-sm leading-5 font-normal">{appDescription.data.tileExcerpt}</p>
         </div>
         <div className="px-2.5 py-2.5 sm:px-4 flex justify-end">
-- 
GitLab