213 lines
9.8 KiB
HTML
213 lines
9.8 KiB
HTML
{% 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_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 %}
|