Files
k8s-version-tracker/templates/images.html
T
claude-code 5ac7b2f5c5
CI/CD / lint-test (push) Failing after 1m40s
CI/CD / build-push (push) Has been skipped
fix: add request_upgrades.py to image, add web dashboard
- Dockerfile: add COPY for request_upgrades.py (was missing — root cause
  of Monday upgrade CronJob failing with 'No such file'), add git (needed
  for repo cloning in upgrade workflow), copy web.py + templates/
- requirements.txt: add fastapi, uvicorn, jinja2 for web dashboard
- web.py: FastAPI dashboard with image inventory and upgrade trigger pages
- templates/: base layout, images page (namespace→app→container grouped
  with live version status), upgrade page (outdated list + trigger button
  + job history)
- kubernetes/web-deployment.yaml: Deployment + LoadBalancer at 192.168.87.13
- kubernetes/rbac.yaml: add version-tracker-web ServiceAccount, ClusterRole
  (read pods/deployments/replicasets/statefulsets/daemonsets/jobs/cronjobs),
  and namespace Role to create upgrade Jobs
- kubernetes/upgrade-cronjob.yaml: fix YAML indentation bug on GITEA_USERNAME
  and GITEA_COMMITTER_NAME env vars
2026-07-12 16:15:15 +00:00

214 lines
9.8 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Image Inventory — K8s Dashboard{% endblock %}
{% block content %}
<div x-data="dashboard()" x-init="init()">
<!-- Header row -->
<div class="flex items-start justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">Image Inventory</h1>
<p class="text-sm text-gray-500 mt-0.5">
All container images deployed to the cluster, grouped by namespace and application.
</p>
</div>
<div class="flex items-center gap-3">
{% if is_checking %}
<span class="flex items-center gap-2 text-blue-400 text-sm bg-blue-950 px-3 py-1.5 rounded-full ring-1 ring-blue-800">
<svg class="animate-spin w-3.5 h-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
</svg>
Checking versions…
</span>
{% elif last_updated %}
<span class="text-xs text-gray-500">Updated {{ last_updated }}</span>
{% endif %}
<button @click="refresh()"
:disabled="refreshing"
class="px-3 py-1.5 text-sm bg-gray-800 hover:bg-gray-700 text-gray-200 rounded border border-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
<span x-text="refreshing ? 'Refreshing…' : '↻ Refresh'"></span>
</button>
</div>
</div>
<!-- Stats bar -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-8">
<div class="bg-gray-900 rounded-lg border border-gray-800 px-4 py-3">
<div class="text-2xl font-bold text-white">{{ stats.total }}</div>
<div class="text-xs text-gray-500 mt-0.5">Total Images</div>
</div>
<div class="bg-red-950 rounded-lg border border-red-800 px-4 py-3">
<div class="text-2xl font-bold text-red-300">{{ stats.outdated }}</div>
<div class="text-xs text-red-500 mt-0.5">Outdated</div>
</div>
<div class="bg-green-950 rounded-lg border border-green-800 px-4 py-3">
<div class="text-2xl font-bold text-green-300">{{ stats.current }}</div>
<div class="text-xs text-green-600 mt-0.5">Up to Date</div>
</div>
<div class="bg-yellow-950 rounded-lg border border-yellow-800 px-4 py-3">
<div class="text-2xl font-bold text-yellow-300">{{ stats.floating }}</div>
<div class="text-xs text-yellow-600 mt-0.5">Floating Tags</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-700 px-4 py-3">
<div class="text-2xl font-bold text-gray-400">{{ stats.local }}</div>
<div class="text-xs text-gray-600 mt-0.5">Local / Skipped</div>
</div>
<div class="bg-blue-950 rounded-lg border border-blue-800 px-4 py-3">
<div class="text-2xl font-bold text-blue-400">{{ stats.pending }}</div>
<div class="text-xs text-blue-700 mt-0.5">Checking…</div>
</div>
</div>
<!-- Namespace sections -->
{% if not namespaces %}
<div class="text-center py-20 text-gray-500">No cluster data available. Click Refresh to load.</div>
{% endif %}
{% for ns, apps in namespaces.items() %}
<div class="mb-6" x-data="{ open: nsOpen('{{ ns }}') }">
<!-- Namespace header -->
<button @click="open = !open; saveNs('{{ ns }}', open)"
class="w-full flex items-center justify-between px-4 py-3 bg-gray-900 border border-gray-800 rounded-lg hover:bg-gray-850 transition-colors group">
<div class="flex items-center gap-3">
<span class="text-blue-400 font-mono text-sm font-medium">{{ ns }}</span>
<span class="text-xs text-gray-600 bg-gray-800 px-2 py-0.5 rounded-full">
{{ apps | length }} app{% if apps | length != 1 %}s{% endif %}
</span>
{% set ns_outdated = namespace_loop.loop(apps) %}
{% set ns_containers = apps | map(attribute='containers') | sum(start=[]) %}
{% set ns_outdated_count = ns_containers | selectattr('status', 'eq', 'outdated') | list | length %}
{% if ns_outdated_count > 0 %}
<span class="text-xs bg-red-950 text-red-400 ring-1 ring-red-800 px-2 py-0.5 rounded-full">
{{ ns_outdated_count }} outdated
</span>
{% endif %}
</div>
<svg class="w-4 h-4 text-gray-600 transition-transform group-hover:text-gray-400"
:class="open ? 'rotate-180' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<!-- Apps grid -->
<div x-show="open" x-cloak class="mt-2 pl-2 border-l-2 border-gray-800 ml-4 space-y-2">
{% for app_data in apps %}
<div class="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"
x-data="{ appOpen: true }">
<!-- App header -->
<button @click="appOpen = !appOpen"
class="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-gray-800 transition-colors text-left">
<!-- Kind badge -->
<span class="text-xs font-mono px-1.5 py-0.5 rounded
{% if app_data.kind == 'Deployment' %}bg-blue-900 text-blue-300{% elif app_data.kind == 'StatefulSet' %}bg-purple-900 text-purple-300{% elif app_data.kind == 'DaemonSet' %}bg-orange-900 text-orange-300{% elif app_data.kind == 'CronJob' %}bg-teal-900 text-teal-300{% else %}bg-gray-700 text-gray-400{% endif %}">
{{ app_data.kind }}
</span>
<span class="text-sm font-medium text-gray-200">{{ app_data.app }}</span>
<span class="text-xs text-gray-600">{{ app_data.pod_count }} pod{% if app_data.pod_count != 1 %}s{% endif %}</span>
<!-- Status summary dots -->
<div class="flex items-center gap-1 ml-auto">
{% for c in app_data.containers %}
<span class="w-2 h-2 rounded-full
{% if c.status == 'outdated' %}bg-red-500{% elif c.status == 'current' %}bg-green-500{% elif c.status == 'floating' %}bg-yellow-500{% elif c.status in ('local','skipped') %}bg-gray-600{% else %}bg-blue-600 animate-pulse{% endif %}"
title="{{ c.name }}: {{ c.status }}">
</span>
{% endfor %}
</div>
<svg class="w-3.5 h-3.5 text-gray-600 flex-shrink-0 transition-transform"
:class="appOpen ? 'rotate-180' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<!-- Container list -->
<div x-show="appOpen" x-cloak>
<table class="w-full text-xs border-t border-gray-800">
<thead>
<tr class="bg-gray-950 text-gray-600">
<th class="text-left px-4 py-2 font-medium w-32">Container</th>
<th class="text-left px-4 py-2 font-medium">Image</th>
<th class="text-left px-4 py-2 font-medium w-28">Current Tag</th>
<th class="text-left px-4 py-2 font-medium w-28">Latest Tag</th>
<th class="text-left px-4 py-2 font-medium w-28">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800">
{% for c in app_data.containers %}
<tr class="hover:bg-gray-800/50 transition-colors">
<td class="px-4 py-2 text-gray-400 font-mono">{{ c.name }}</td>
<td class="px-4 py-2">
<span class="text-gray-300 font-mono break-all">
{% if c.registry != 'docker.io' and c.registry not in ('registry.storedbox.net', '192.168.87.31:5000', '192.168.87.31') %}
<span class="text-gray-500">{{ c.registry }}/</span>
{% endif %}
{% if c.is_local %}
<span class="text-gray-500">{{ c.registry }}/</span>
{% endif %}
{{ c.image_short.rsplit(':', 1)[0] if ':' in c.image_short else c.image_short }}
</span>
</td>
<td class="px-4 py-2 font-mono text-gray-400">{{ c.tag }}</td>
<td class="px-4 py-2 font-mono
{% if c.status == 'outdated' %}text-red-400 font-semibold{% else %}text-gray-500{% endif %}">
{{ c.latest_tag or '—' }}
</td>
<td class="px-4 py-2">
<span class="px-2 py-0.5 rounded-full text-xs font-medium status-{{ c.status }}">
{% if c.status == 'outdated' %}⬆ Outdated
{% elif c.status == 'current' %}✓ Current
{% elif c.status == 'floating' %}~ Floating
{% elif c.status == 'local' %}⌂ Local
{% elif c.status == 'skipped' %} Skipped
{% elif c.status == 'pending' %}… Checking
{% else %}? {{ c.status }}
{% endif %}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<script>
function dashboard() {
return {
refreshing: false,
nsOpen(ns) {
const saved = localStorage.getItem('ns-' + ns);
return saved === null ? true : saved === 'true';
},
saveNs(ns, val) {
localStorage.setItem('ns-' + ns, val);
},
init() {
// Auto-reload if checks are still running
{% if is_checking %}
setTimeout(() => window.location.reload(), 15000);
{% endif %}
},
async refresh() {
this.refreshing = true;
try {
await fetch('/api/refresh', { method: 'POST' });
setTimeout(() => window.location.reload(), 1500);
} catch (e) {
this.refreshing = false;
}
}
}
}
</script>
{% endblock %}