admin-console: fast namespace list + async detail loading per ns
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"admin-console/internal/metrics"
|
"admin-console/internal/metrics"
|
||||||
"admin-console/internal/model"
|
"admin-console/internal/model"
|
||||||
@@ -31,6 +33,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
// API
|
// API
|
||||||
mux.HandleFunc("/admin/api/health", s.handleHealth)
|
mux.HandleFunc("/admin/api/health", s.handleHealth)
|
||||||
mux.HandleFunc("/admin/api/namespaces", s.authMiddleware(s.handleNamespaces))
|
mux.HandleFunc("/admin/api/namespaces", s.authMiddleware(s.handleNamespaces))
|
||||||
|
mux.HandleFunc("/admin/api/namespaces/", s.authMiddleware(s.handleNamespaceDetail))
|
||||||
mux.HandleFunc("/admin/api/usage", s.authMiddleware(s.handleUsage))
|
mux.HandleFunc("/admin/api/usage", s.authMiddleware(s.handleUsage))
|
||||||
mux.HandleFunc("/admin/api/users", s.authMiddleware(s.handleUsers))
|
mux.HandleFunc("/admin/api/users", s.authMiddleware(s.handleUsers))
|
||||||
}
|
}
|
||||||
@@ -51,7 +54,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleNamespaces возвращает список клиентских namespace-ов с их метриками.
|
// handleNamespaces возвращает список клиентских namespace-ов (быстро, без счётчиков).
|
||||||
func (s *Server) handleNamespaces(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleNamespaces(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.k8s == nil {
|
if s.k8s == nil {
|
||||||
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
|
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
|
||||||
@@ -65,13 +68,37 @@ func (s *Server) handleNamespaces(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"namespaces": ns})
|
writeJSON(w, http.StatusOK, map[string]any{"namespaces": ns})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleNamespaceDetail возвращает детальную информацию об одном namespace (с CRD-счётчиками).
|
||||||
|
func (s *Server) handleNamespaceDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.k8s == nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// URL: /admin/api/namespaces/{name}
|
||||||
|
name := strings.TrimPrefix(r.URL.Path, "/admin/api/namespaces/")
|
||||||
|
if name == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, model.ErrResp("missing namespace name"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
info, err := s.k8s.GetNamespaceDetail(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, model.ErrResp(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, info)
|
||||||
|
}
|
||||||
|
|
||||||
// handleUsage возвращает агрегированные метрики использования для бухгалтерии.
|
// handleUsage возвращает агрегированные метрики использования для бухгалтерии.
|
||||||
func (s *Server) handleUsage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUsage(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.k8s == nil {
|
if s.k8s == nil {
|
||||||
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
|
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
usage, err := s.k8s.CollectUsage(r.Context())
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
usage, err := s.k8s.CollectUsage(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
|
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
|
||||||
return
|
return
|
||||||
@@ -85,7 +112,9 @@ func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
|
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
users, err := s.k8s.ListUsers(r.Context())
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
users, err := s.k8s.ListUsers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
|
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -96,8 +96,7 @@ func (k *K8sClient) initS3() {
|
|||||||
log.Printf("[admin-console] S3 client ready, bucket=%s", bucket)
|
log.Printf("[admin-console] S3 client ready, bucket=%s", bucket)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListClientNamespaces возвращает namespace-ы с лейблом managed-by=fission-console.
|
// ListClientNamespaces возвращает список namespace-ов БЕЗ CRD-счётчиков (быстро).
|
||||||
// Для каждого считает количество объектов Fission CRD через Dynamic клиент.
|
|
||||||
func (k *K8sClient) ListClientNamespaces(ctx context.Context) ([]model.NamespaceInfo, error) {
|
func (k *K8sClient) ListClientNamespaces(ctx context.Context) ([]model.NamespaceInfo, error) {
|
||||||
nsList, err := k.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
|
nsList, err := k.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
|
||||||
LabelSelector: "managed-by=fission-console",
|
LabelSelector: "managed-by=fission-console",
|
||||||
@@ -108,17 +107,30 @@ func (k *K8sClient) ListClientNamespaces(ctx context.Context) ([]model.Namespace
|
|||||||
|
|
||||||
result := make([]model.NamespaceInfo, 0, len(nsList.Items))
|
result := make([]model.NamespaceInfo, 0, len(nsList.Items))
|
||||||
for _, ns := range nsList.Items {
|
for _, ns := range nsList.Items {
|
||||||
info := model.NamespaceInfo{
|
result = append(result, model.NamespaceInfo{
|
||||||
Name: ns.Name,
|
Name: ns.Name,
|
||||||
UserEmail: ns.Labels["user"],
|
UserEmail: ns.Labels["user"],
|
||||||
CreatedAt: ns.CreationTimestamp.Format(time.RFC3339),
|
CreatedAt: ns.CreationTimestamp.Format(time.RFC3339),
|
||||||
}
|
})
|
||||||
k.fillCRDCounts(ctx, ns.Name, &info)
|
|
||||||
result = append(result, info)
|
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNamespaceDetail возвращает информацию об одном namespace с CRD-счётчиками.
|
||||||
|
func (k *K8sClient) GetNamespaceDetail(ctx context.Context, nsName string) (*model.NamespaceInfo, error) {
|
||||||
|
ns, err := k.cs.CoreV1().Namespaces().Get(ctx, nsName, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info := &model.NamespaceInfo{
|
||||||
|
Name: ns.Name,
|
||||||
|
UserEmail: ns.Labels["user"],
|
||||||
|
CreatedAt: ns.CreationTimestamp.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
k.fillCRDCounts(ctx, nsName, info)
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
// fillCRDCounts заполняет счётчики Fission-ресурсов для одного namespace.
|
// fillCRDCounts заполняет счётчики Fission-ресурсов для одного namespace.
|
||||||
func (k *K8sClient) fillCRDCounts(ctx context.Context, ns string, info *model.NamespaceInfo) {
|
func (k *K8sClient) fillCRDCounts(ctx context.Context, ns string, info *model.NamespaceInfo) {
|
||||||
count := func(gvr schema.GroupVersionResource) int {
|
count := func(gvr schema.GroupVersionResource) int {
|
||||||
@@ -126,8 +138,6 @@ func (k *K8sClient) fillCRDCounts(ctx context.Context, ns string, info *model.Na
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
// List возвращает items и list-metadata с remaining count.
|
|
||||||
// Используем оставшийся счёт для точности.
|
|
||||||
if rem := list.GetRemainingItemCount(); rem != nil {
|
if rem := list.GetRemainingItemCount(); rem != nil {
|
||||||
return int(*rem) + len(list.Items)
|
return int(*rem) + len(list.Items)
|
||||||
}
|
}
|
||||||
@@ -222,10 +232,8 @@ func (k *K8sClient) ListUsers(ctx context.Context) ([]model.UserInfo, error) {
|
|||||||
|
|
||||||
var users []model.UserInfo
|
var users []model.UserInfo
|
||||||
for _, ns := range nsList.Items {
|
for _, ns := range nsList.Items {
|
||||||
// label user содержит email владельца namespace
|
|
||||||
email := ns.Labels["user"]
|
email := ns.Labels["user"]
|
||||||
if email == "" {
|
if email == "" {
|
||||||
// fallback: попробовать прочитать из secret
|
|
||||||
secret, err := k.cs.CoreV1().Secrets(ns.Name).Get(ctx, "fission-console-users", metav1.GetOptions{})
|
secret, err := k.cs.CoreV1().Secrets(ns.Name).Get(ctx, "fission-console-users", metav1.GetOptions{})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for key := range secret.Data {
|
for key := range secret.Data {
|
||||||
|
|||||||
+61
-15
@@ -38,6 +38,9 @@
|
|||||||
.pill-blue { background: #1e3a5f; color: #60a5fa; }
|
.pill-blue { background: #1e3a5f; color: #60a5fa; }
|
||||||
.err { color: #f87171; font-size: 13px; padding: 16px 0; }
|
.err { color: #f87171; font-size: 13px; padding: 16px 0; }
|
||||||
.loader { color: #6b7280; font-size: 13px; padding: 16px 0; }
|
.loader { color: #6b7280; font-size: 13px; padding: 16px 0; }
|
||||||
|
.cnt { color: #6b7280; font-size: 12px; }
|
||||||
|
.cnt.loading { animation: pulse 1.5s infinite; }
|
||||||
|
@keyframes pulse { 0%,100%{opacity:.3} 50%{opacity:1} }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -67,19 +70,28 @@
|
|||||||
<script>
|
<script>
|
||||||
const API = '/admin/api';
|
const API = '/admin/api';
|
||||||
let TOKEN = '';
|
let TOKEN = '';
|
||||||
|
let NS_LIST = [];
|
||||||
|
|
||||||
document.getElementById('loginForm').addEventListener('submit', async e => {
|
document.getElementById('loginForm').addEventListener('submit', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
TOKEN = document.getElementById('tokenInput').value.trim();
|
TOKEN = document.getElementById('tokenInput').value.trim();
|
||||||
const r = await fetch(`${API}/namespaces`, { headers: { Authorization: `Bearer ${TOKEN}` } });
|
try {
|
||||||
if (r.ok) {
|
const r = await fetch(`${API}/namespaces`, { headers: { Authorization: `Bearer ${TOKEN}` } });
|
||||||
document.getElementById('login').style.display = 'none';
|
if (r.ok) {
|
||||||
document.getElementById('app').style.display = 'block';
|
const data = await r.json();
|
||||||
showTab('namespaces', document.querySelector('nav button'));
|
NS_LIST = data.namespaces || [];
|
||||||
} else {
|
document.getElementById('login').style.display = 'none';
|
||||||
|
document.getElementById('app').style.display = 'block';
|
||||||
|
showTab('namespaces', document.querySelector('nav button'));
|
||||||
|
} else {
|
||||||
|
const err = document.getElementById('loginErr');
|
||||||
|
err.style.display = 'block';
|
||||||
|
err.textContent = 'Неверный токен';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
const err = document.getElementById('loginErr');
|
const err = document.getElementById('loginErr');
|
||||||
err.style.display = 'block';
|
err.style.display = 'block';
|
||||||
err.textContent = 'Неверный токен';
|
err.textContent = 'Ошибка соединения: ' + e.message;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,6 +103,12 @@
|
|||||||
|
|
||||||
async function loadTab(tab) {
|
async function loadTab(tab) {
|
||||||
const content = document.getElementById('content');
|
const content = document.getElementById('content');
|
||||||
|
if (tab === 'namespaces') {
|
||||||
|
content.innerHTML = renderNamespaces(NS_LIST);
|
||||||
|
// загружаем CRD-счётчики асинхронно
|
||||||
|
loadAllDetails();
|
||||||
|
return;
|
||||||
|
}
|
||||||
content.innerHTML = '<p class="loader">Загрузка…</p>';
|
content.innerHTML = '<p class="loader">Загрузка…</p>';
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${API}/${tab}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
|
const r = await fetch(`${API}/${tab}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
|
||||||
@@ -103,26 +121,54 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderTab(tab, data) {
|
function renderTab(tab, data) {
|
||||||
if (tab === 'namespaces') return renderNamespaces(data.namespaces);
|
if (tab === 'usage') return renderUsage(data.usage);
|
||||||
if (tab === 'usage') return renderUsage(data.usage);
|
if (tab === 'users') return renderUsers(data.users);
|
||||||
if (tab === 'users') return renderUsers(data.users);
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNamespaces(rows) {
|
function renderNamespaces(rows) {
|
||||||
if (!rows?.length) return '<p class="loader">Нет namespace-ов</p>';
|
if (!rows?.length) return '<p class="loader">Нет namespace-ов</p>';
|
||||||
return `<table>
|
return `<table>
|
||||||
<tr><th>Namespace</th><th>Email</th><th>Функции</th><th>Пакеты</th><th>Создан</th></tr>
|
<tr><th>Namespace</th><th>Email</th><th>Fn</th><th>Pkg</th><th>Env</th><th>HTTP</th><th>Time</th><th>Создан</th></tr>
|
||||||
${rows.map(n => `<tr>
|
${rows.map((n,i) => `<tr>
|
||||||
<td><span class="pill pill-blue">${n.name}</span></td>
|
<td><span class="pill pill-blue">${n.name}</span></td>
|
||||||
<td>${n.user_email || '—'}</td>
|
<td>${n.user_email || '—'}</td>
|
||||||
<td>${n.functions}</td>
|
<td class="cnt loading" id="ns${i}-fn">…</td>
|
||||||
<td>${n.packages}</td>
|
<td class="cnt loading" id="ns${i}-pkg">…</td>
|
||||||
|
<td class="cnt loading" id="ns${i}-env">…</td>
|
||||||
|
<td class="cnt loading" id="ns${i}-http">…</td>
|
||||||
|
<td class="cnt loading" id="ns${i}-time">…</td>
|
||||||
<td>${n.created_at ? n.created_at.slice(0,10) : '—'}</td>
|
<td>${n.created_at ? n.created_at.slice(0,10) : '—'}</td>
|
||||||
</tr>`).join('')}
|
</tr>`).join('')}
|
||||||
</table>`;
|
</table>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAllDetails() {
|
||||||
|
for (let i = 0; i < NS_LIST.length; i++) {
|
||||||
|
loadDetail(i, NS_LIST[i].name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail(idx, name) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/namespaces/${encodeURIComponent(name)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${TOKEN}` }
|
||||||
|
});
|
||||||
|
if (!r.ok) return;
|
||||||
|
const d = await r.json();
|
||||||
|
setCnt(idx, 'fn', d.functions);
|
||||||
|
setCnt(idx, 'pkg', d.packages);
|
||||||
|
setCnt(idx, 'env', d.environments);
|
||||||
|
setCnt(idx, 'http', d.http_triggers);
|
||||||
|
setCnt(idx, 'time', d.time_triggers);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCnt(idx, type, val) {
|
||||||
|
const el = document.getElementById(`ns${idx}-${type}`);
|
||||||
|
if (el) { el.textContent = val ?? 0; el.classList.remove('loading'); }
|
||||||
|
}
|
||||||
|
|
||||||
function renderUsage(rows) {
|
function renderUsage(rows) {
|
||||||
if (!rows?.length) return '<p class="loader">Нет данных</p>';
|
if (!rows?.length) return '<p class="loader">Нет данных</p>';
|
||||||
const btn = `<div style="margin-bottom:12px">
|
const btn = `<div style="margin-bottom:12px">
|
||||||
@@ -144,7 +190,7 @@
|
|||||||
function exportCSV() {
|
function exportCSV() {
|
||||||
const table = document.querySelector('table');
|
const table = document.querySelector('table');
|
||||||
if (!table) return;
|
if (!table) return;
|
||||||
let csv = '\uFEFF'; // BOM для Excel
|
let csv = '\uFEFF';
|
||||||
table.querySelectorAll('tr').forEach(row => {
|
table.querySelectorAll('tr').forEach(row => {
|
||||||
const cells = [];
|
const cells = [];
|
||||||
row.querySelectorAll('th,td').forEach(c => cells.push('"' + c.textContent.replace(/"/g, '""') + '"'));
|
row.querySelectorAll('th,td').forEach(c => cells.push('"' + c.textContent.replace(/"/g, '""') + '"'));
|
||||||
|
|||||||
Reference in New Issue
Block a user