From 6c954c10ae339815546d83776e6b7224183c7afa Mon Sep 17 00:00:00 2001
From: Jana Librova <jlibrova@redhat.com>
Date: Mar 25 2026 07:53:56 +0000
Subject: Improve channelinfo, hosts and hostinfo - hosts status


Fixes: https://pagure.io/koji/issue/4549

---

diff --git a/tests/test_www/test_channelinfo.py b/tests/test_www/test_channelinfo.py
index 9d6fc6b..1205746 100644
--- a/tests/test_www/test_channelinfo.py
+++ b/tests/test_www/test_channelinfo.py
@@ -40,9 +40,12 @@ class TestChannelInfo(unittest.TestCase):
         self.server.getChannel.return_value = {'name': 'test-channel', 'id': self.channel_id}
         self.server.listTasks.return_value = 5
         self.server.listHosts.return_value = [
-            {'id': 1, 'name': 'test-host-1', 'enabled': True, 'ready': True},
-            {'id': 2, 'name': 'test-host-2', 'enabled': False, 'ready': False},
-            {'id': 3, 'name': 'test-host-3', 'enabled': True, 'ready': False}]
+            {'id': 1, 'name': 'test-host-1', 'enabled': True, 'ready': True,
+             'update_ts': 1774025255.931238},
+            {'id': 2, 'name': 'test-host-2', 'enabled': False, 'ready': False,
+             'update_ts': 1774025258.931238},
+            {'id': 3, 'name': 'test-host-3', 'enabled': True, 'ready': False,
+             'update_ts': 1774025253.931238}]
 
         webidx.channelinfo(self.environ, self.channel_id)
         self.server.getChannel.assert_called_once_with(int(self.channel_id))
diff --git a/www/kojiweb/index.py b/www/kojiweb/index.py
index 02c2109..ddfe94b 100644
--- a/www/kojiweb/index.py
+++ b/www/kojiweb/index.py
@@ -1749,26 +1749,25 @@ def hosts(environ, state='enabled', start=None, order='name', ready='all', chann
     server = _getServer(environ)
 
     values['order'] = order
+    values['sleep_ts'] = (datetime.datetime.now() - datetime.timedelta(minutes=15)).timestamp()
 
     hosts = server.listHosts()
     values['arches'] = sorted(set(itertools.chain(*[host['arches'].split() for host in hosts])))
 
-    if state == 'enabled':
-        hosts = [x for x in hosts if x['enabled']]
+    if state == 'ready':
+        hosts = [x for x in hosts if x['enabled'] and x['ready']]
+    elif state == 'busy':
+        hosts = [x for x in hosts if x['enabled'] and not x['ready'] and
+                 x['update_ts'] > values['sleep_ts']]
+    elif state == 'problem':
+        hosts = [x for x in hosts if x['enabled'] and not x['ready'] and
+                 x['update_ts'] < values['sleep_ts']]
     elif state == 'disabled':
-        hosts = [x for x in hosts if not x['enabled']]
+        hosts = [x for x in hosts if not x['enabled'] and not x['ready']]
     else:
         state = 'all'
     values['state'] = state
 
-    if ready == 'yes':
-        hosts = [x for x in hosts if x['ready']]
-    elif ready == 'no':
-        hosts = [x for x in hosts if not x['ready']]
-    else:
-        ready = 'all'
-    values['ready'] = ready
-
     if arch != 'all':
         arch = _validate_arch(arch)
         if arch:
@@ -1802,6 +1801,8 @@ def hosts(environ, state='enabled', start=None, order='name', ready='all', chann
 
     for host in hosts:
         host['last_update'] = koji.formatTimeLong(host['update_ts'])
+        if host['update_ts'] is None:
+            host['update_ts'] = 0
 
     # Paginate after retrieving last update info so we can sort on it
     kojiweb.util.paginateList(values, hosts, start, 'hosts', 'host', order)
@@ -1853,6 +1854,7 @@ def hostinfo(environ, hostID=None, userID=None):
         values['perms'] = server.getUserPerms(environ['koji.currentUser']['id'])
     else:
         values['perms'] = []
+    values['sleep_ts'] = (datetime.datetime.now() - datetime.timedelta(minutes=15)).timestamp()
 
     return _genHTML(environ, 'hostinfo.html.j2')
 
@@ -1951,8 +1953,20 @@ def channelinfo(environ, channelID):
 
     values['channel'] = channel
     values['hosts'] = hosts
-    values['enabled_hosts'] = len([h for h in hosts if h['enabled']])
-    values['ready_hosts'] = len([h for h in hosts if h['ready']])
+    values['ready_hosts'] = 0
+    values['busy_hosts'] = 0
+    values['problem_hosts'] = 0
+    values['disabled_hosts'] = 0
+    values['sleep_ts'] = (datetime.datetime.now() - datetime.timedelta(minutes=15)).timestamp()
+    for h in hosts:
+        if h['enabled'] and h['ready']:
+            values['ready_hosts'] += 1
+        elif h['enabled'] and not h['ready'] and values['sleep_ts'] < h['update_ts']:
+            values['busy_hosts'] += 1
+        elif h['enabled'] and not h['ready'] and values['sleep_ts'] < h['update_ts']:
+            values['problem_hosts'] += 1
+        elif not h['enabled'] and not h['ready']:
+            values['disabled_hosts'] += 1
 
     return _genHTML(environ, 'channelinfo.html.j2')
 
diff --git a/www/kojiweb/templates/channelinfo.html.j2 b/www/kojiweb/templates/channelinfo.html.j2
index 565482f..077d4e7 100644
--- a/www/kojiweb/templates/channelinfo.html.j2
+++ b/www/kojiweb/templates/channelinfo.html.j2
@@ -34,21 +34,28 @@
       <table class="data-list">
         <tr class="list-header">
           <th>Hostname</th>
-          <th>Enabled</th>
-          <th>Ready</th>
+          <th>Status</th>
         </tr>
       #for host in hosts
         <tr class="{{ rowToggle(loop) }}">
           <td><a href="hostinfo?hostID={{ host.id }}">{{ host.name }}</a></td>
-          <td class="{{ host.enabled |string |lower }}">{{ util.imageTag('yes') if host.enabled else util.imageTag('no') }}</td>
-          <td class="{{ host.ready |string |lower }}">{{ util.imageTag('yes') if host.ready else util.imageTag('no') }}</td>
+          #if host.enabled and host.ready
+            <td class="true">{{util.imageTag('ready')}}</td>
+          #elif host.enabled and not host.ready and sleep_ts < host.update_ts
+            <td class="buiding">{{util.imageTag('building', 'busy')}}</td>
+          #elif host.enabled and not host.ready and sleep_ts > host.update_ts
+            <td class="false">{{util.imageTag('failed', 'problem')}}</td>
+          #elif not host.enabled and not host.ready
+            <td>{{util.imageTag('canceled', 'disabled')}}</td>
+          #endif
         </tr>
       #endfor
-        <tr>
-          <th>Total</th>
-          <td>{{ enabled_hosts }}</td>
-          <td>{{ ready_hosts }}</td>
-        </tr>
+      </table>
+      <table>
+      <tr><th>Total:</th><td>{{util.imageTag('ready')}} ready hosts:</td><td>{{ ready_hosts }}</td></tr>
+      <tr><td></td><td>{{util.imageTag('building')}} busy hosts:</td><td>{{ busy_hosts }}</td></tr>
+      <tr><td></td><td>{{util.imageTag('failed')}} problem hosts:</td><td>{{ problem_hosts }}</td></tr>
+      <tr><td></td><td>{{util.imageTag('canceled')}} disabled hosts:</td><td>{{ disabled_hosts }}</td></tr>
       </table>
       #else
       No hosts
diff --git a/www/kojiweb/templates/hostinfo.html.j2 b/www/kojiweb/templates/hostinfo.html.j2
index febfa1c..f64be7e 100644
--- a/www/kojiweb/templates/hostinfo.html.j2
+++ b/www/kojiweb/templates/hostinfo.html.j2
@@ -27,24 +27,26 @@
       <th>Comment</th><td class="usertext">{{ host.comment or '' }}</td>
     </tr>
     <tr>
-      #set enabled = host.enabled and 'yes' or 'no'
-      <th>Enabled?</th>
-      <td class="{{ enabled }}">
-        {{ util.imageTag(enabled) }}
-        #if 'admin' in perms
-        #if host.enabled
-        <span class="adminLink">(<a href="disablehost?hostID={{ host.id }}{{ util.authToken() }}">disable</a>)</span>
-        #else
-        <span class="adminLink">(<a href="enablehost?hostID={{ host.id }}{{ util.authToken() }}">enable</a>)</span>
-        #endif
-        #endif
+      <th>State</th>
+      #if host.enabled and host.ready
+        <td class="true">{{util.imageTag('ready')}}
+      #elif host.enabled and not host.ready and sleep_ts < host.update_ts
+        <td class="buiding">{{util.imageTag('building', 'busy')}}
+      #elif host.enabled and not host.ready and sleep_ts > host.update_ts
+        <td class="false">{{util.imageTag('failed', 'problem')}}
+      #elif not host.enabled and not host.ready
+        <td>{{util.imageTag('canceled', 'disabled')}}
+      #endif
+      #if 'admin' in perms
+      #if host.enabled
+      <span class="adminLink">(<a href="disablehost?hostID={{ host.id }}{{ util.authToken() }}">disable host</a>)</span>
+      #else
+      <span class="adminLink">(<a href="enablehost?hostID={{ host.id }}{{ util.authToken() }}">enable host</a>)</span>
+      #endif
+      #endif
       </td>
     </tr>
     <tr>
-      #set ready = host.ready and 'yes' or 'no'
-      <th>Ready?</th><td class="{{ ready }}">{{ util.imageTag(ready) }}</td>
-    </tr>
-    <tr>
       <th>Last Update</th><td>{{ util.formatTime(lastUpdate) }}</td>
     </tr>
     <tr>
diff --git a/www/kojiweb/templates/hosts.html.j2 b/www/kojiweb/templates/hosts.html.j2
index 301dd46..c57f61b 100644
--- a/www/kojiweb/templates/hosts.html.j2
+++ b/www/kojiweb/templates/hosts.html.j2
@@ -1,7 +1,11 @@
 
 #macro headerState(state)
-  #if state == 'enabled'
+  #if state == 'ready'
 Enabled hosts
+  #elif state == 'busy'
+Busy hosts
+  #elif state == 'problem'
+Problem hosts
   #elif state == 'disabled'
 Disabled hosts
   #else
@@ -9,14 +13,6 @@ Hosts
   #endif
 #endmacro
 
-#macro headerReady(ready)
-  #if ready == 'ready'
-which are ready
-  #elif ready == 'notready'
-which are not ready
-  #endif
-#endmacro
-
 #macro headerArch(arch)
   #if arch == 'all'
 on all arches
@@ -33,12 +29,12 @@ in {{ channel }} channel
   #endif
 #endmacro
 
-#set _PASSTHROUGH = ['state', 'order', 'ready', 'channel', 'arch']
+#set _PASSTHROUGH = ['state', 'order', 'channel', 'arch']
 
 #include "header.html.j2"
 # from "macros.html.j2" import rowToggle
 
-  <h4>{{ headerState(state) }} {{ headerReady(ready) }} {{ headerArch(arch) }} {{ headerChannel(channel) }}</h4>
+  <h4>{{ headerState(state) }} {{ headerArch(arch) }} {{ headerChannel(channel) }}</h4>
   <table class="data-list">
     <tr>
       <td colspan="9">
@@ -47,7 +43,9 @@ in {{ channel }} channel
               <strong>State</strong>:
           </td><td>
               <select name="state" class="filterlist" onchange="javascript: window.location = 'hosts?state=' + this.value + '{{ util.passthrough_except('state') }}';">
-                <option value="enabled" {{ 'selected' if state == 'enabled' else '' }}>enabled</option>
+                <option value="ready" {{ 'selected' if state == 'ready' else '' }}>ready</option>
+                <option value="busy" {{ 'selected' if state == 'busy' else '' }}>busy</option>
+                <option value="problem" {{ 'selected' if state == 'problem' else '' }}>problem</option>
                 <option value="disabled" {{ 'selected' if state == 'disabled' else '' }}>disabled</option>
                 <option value="all" {{ 'selected' if state == 'all' else '' }}>all</option>
               </select>
@@ -62,16 +60,7 @@ in {{ channel }} channel
                 #endfor
               </select>
           </td>
-          </tr>
-          <tr><td>
-              <strong>Ready</strong>:
-          </td><td>
-              <select name="ready" class="filterlist" onchange="javascript: window.location = 'hosts?ready=' + this.value + '{{ util.passthrough_except('ready') }}';">
-                <option value="yes" {{ 'selected' if ready == 'yes' else '' }}>yes</option>
-                <option value="no" {{ 'selected' if ready == 'no' else '' }}>no</option>
-                <option value="all" {{ 'selected' if ready== 'all' else '' }}>all</option>
-              </select>
-          </td><td>
+          <td>
               <strong>Arches</strong>:
           </td><td>
               <select name="arch" class="filterlist" onchange="javascript: window.location = 'hosts?arch=' + this.value + '{{ util.passthrough_except('arch') }}';">
@@ -112,8 +101,7 @@ in {{ channel }} channel
       <th><a href="hosts?order={{ util.toggleOrder('name') }}{{ util.passthrough_except('order') }}">Name</a> {{ util.sortImage('name') }}</th>
       <th><a href="hosts?order={{ util.toggleOrder('arches') }}{{ util.passthrough_except('order') }}">Arches</a> {{ util.sortImage('arches') }}</th>
       <th><a href="hosts?order={{ util.toggleOrder('channels') }}{{ util.passthrough_except('order') }}">Channels</a> {{ util.sortImage('channels') }}</th>
-      <th><a href="hosts?order={{ util.toggleOrder('enabled') }}{{ util.passthrough_except('order') }}">Enabled?</a> {{ util.sortImage('enabled') }}</th>
-      <th><a href="hosts?order={{ util.toggleOrder('ready') }}{{ util.passthrough_except('order') }}">Ready?</a> {{ util.sortImage('ready') }}</th>
+      <th><a href="hosts?order={{ util.toggleOrder('state') }}{{ util.passthrough_except('order') }}">State</a> {{ util.sortImage('state') }}</th>
       <th><a href="hosts?order={{ util.toggleOrder('task_load') }}{{ util.passthrough_except('order') }}">Load</a> {{ util.sortImage('task_load') }}</th>
       <th><a href="hosts?order={{ util.toggleOrder('capacity') }}{{ util.passthrough_except('order') }}">Cap.</a> {{ util.sortImage('capacity') }}</th>
       <th><a href="hosts?order={{ util.toggleOrder('last_update') }}{{ util.passthrough_except('order') }}">Last Update</a> {{ util.sortImage('last_update') }}</th>
@@ -129,8 +117,15 @@ in {{ channel }} channel
                 <a href="channelinfo?channelID={{ chan_id }}" class="{{ chan_enabled }}">{{ channame }}</a>
               #endfor
           </td>
-          <td class="{{ 'true' if host.enabled else 'false' }}">{{ util.imageTag('yes') if host.enabled else util.imageTag('no') }}</td>
-          <td class="{{ 'true' if host.ready else 'false' }}">{{ util.imageTag('yes') if host.ready else util.imageTag('no') }}</td>
+          #if host.enabled and host.ready
+            <td class="true">{{util.imageTag('ready')}}</td>
+          #elif host.enabled and not host.ready and sleep_ts < host.update_ts
+            <td class="buiding">{{util.imageTag('building', 'busy')}}</td>
+          #elif host.enabled and not host.ready and sleep_ts > host.update_ts
+            <td class="false">{{util.imageTag('failed', 'problem')}}</td>
+          #elif not host.enabled and not host.ready
+            <td>{{util.imageTag('canceled', 'disabled')}}</td>
+          #endif
           <td>{{ '%.2f' % host.task_load }}</td>
           <td>{{ host.capacity }}</td>
           <td>{{ util.formatTime(host.last_update) }}</td>
diff --git a/www/lib/kojiweb/util.py b/www/lib/kojiweb/util.py
index 2519f9d..2002524 100644
--- a/www/lib/kojiweb/util.py
+++ b/www/lib/kojiweb/util.py
@@ -568,11 +568,13 @@ def stateName(stateID):
 
 
 @safe_return
-def imageTag(name):
+def imageTag(name, title=None):
     """Return an img tag that loads an icon with the given name"""
     name = escapeHTML(name)
+    if not title:
+        title = name
     return '<img class="stateimg" src="%s" title="%s" alt="%s"/>' \
-           % (themePath("images/%s.png" % name), name, name)
+           % (themePath("images/%s.png" % name), title, name)
 
 
 def stateImage(stateID):

