restructure: console→client-console, add admin-console skeleton, move docs to doc/
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
FROM golang:1.26-alpine AS builder
|
||||
WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o admin-console ./cmd/server/
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates
|
||||
COPY --from=builder /build/admin-console /admin-console
|
||||
COPY ui/ /ui/
|
||||
EXPOSE 8091
|
||||
ENTRYPOINT ["/admin-console"]
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"admin-console/internal/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
adminToken := os.Getenv("ADMIN_TOKEN")
|
||||
if adminToken == "" {
|
||||
log.Fatal("ADMIN_TOKEN env variable is required")
|
||||
}
|
||||
|
||||
srv := api.NewServer(adminToken)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
srv.RegisterRoutes(mux)
|
||||
|
||||
addr := ":8091"
|
||||
log.Printf("admin-console starting on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: fission-admin-console
|
||||
namespace: fission
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: fission-admin-console
|
||||
rules:
|
||||
# читать ВСЕ namespaces (для отображения клиентов)
|
||||
- apiGroups: [""]
|
||||
resources: ["namespaces"]
|
||||
verbs: ["get", "list"]
|
||||
# читать секреты в клиентских namespaces (для списка пользователей)
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["get", "list"]
|
||||
# читать pods (для метрик потребления)
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list"]
|
||||
# читать Fission CRD
|
||||
- apiGroups: ["fission.io"]
|
||||
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
|
||||
verbs: ["get", "list"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: fission-admin-console
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: fission-admin-console
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: fission-admin-console
|
||||
namespace: fission
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: fission-admin-console
|
||||
namespace: fission
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: fission-admin-console
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: fission-admin-console
|
||||
spec:
|
||||
serviceAccountName: fission-admin-console
|
||||
containers:
|
||||
- name: fission-admin-console
|
||||
image: naeel/fission-admin-console:v0.1.0
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8091
|
||||
env:
|
||||
- name: ADMIN_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: fission-admin-token
|
||||
key: token
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: fission-admin-console
|
||||
namespace: fission
|
||||
spec:
|
||||
selector:
|
||||
app: fission-admin-console
|
||||
ports:
|
||||
- port: 8091
|
||||
targetPort: 8091
|
||||
@@ -0,0 +1,32 @@
|
||||
module admin-console
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
k8s.io/client-go v0.31.0
|
||||
k8s.io/apimachinery v0.31.0
|
||||
k8s.io/api v0.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/gofuzz v1.2.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/oauth2 v0.21.0 // indirect
|
||||
golang.org/x/term v0.21.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20221116144056-70938f8a5b36 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"admin-console/internal/metrics"
|
||||
"admin-console/internal/model"
|
||||
)
|
||||
|
||||
// Server — HTTP-сервер admin-console.
|
||||
type Server struct {
|
||||
adminToken string
|
||||
k8s *metrics.K8sClient
|
||||
}
|
||||
|
||||
func NewServer(adminToken string) *Server {
|
||||
k8s, err := metrics.NewK8sClient()
|
||||
if err != nil {
|
||||
// вне кластера — работаем без k8s (для локальной разработки)
|
||||
k8s = nil
|
||||
}
|
||||
return &Server{adminToken: adminToken, k8s: k8s}
|
||||
}
|
||||
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
// статика UI
|
||||
mux.Handle("/admin/", http.StripPrefix("/admin/", http.FileServer(http.Dir("ui"))))
|
||||
|
||||
// API
|
||||
mux.HandleFunc("/admin/api/health", s.handleHealth)
|
||||
mux.HandleFunc("/admin/api/namespaces", s.authMiddleware(s.handleNamespaces))
|
||||
mux.HandleFunc("/admin/api/usage", s.authMiddleware(s.handleUsage))
|
||||
mux.HandleFunc("/admin/api/users", s.authMiddleware(s.handleUsers))
|
||||
}
|
||||
|
||||
// authMiddleware проверяет Bearer-токен из заголовка Authorization.
|
||||
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != s.adminToken {
|
||||
writeJSON(w, http.StatusUnauthorized, model.ErrResp("unauthorized"))
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleNamespaces возвращает список клиентских namespace-ов с их метриками.
|
||||
func (s *Server) handleNamespaces(w http.ResponseWriter, r *http.Request) {
|
||||
if s.k8s == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
|
||||
return
|
||||
}
|
||||
ns, err := s.k8s.ListClientNamespaces(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"namespaces": ns})
|
||||
}
|
||||
|
||||
// handleUsage возвращает агрегированные метрики использования для бухгалтерии.
|
||||
func (s *Server) handleUsage(w http.ResponseWriter, r *http.Request) {
|
||||
if s.k8s == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
|
||||
return
|
||||
}
|
||||
usage, err := s.k8s.CollectUsage(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"usage": usage})
|
||||
}
|
||||
|
||||
// handleUsers возвращает список пользователей из secrets в клиентских namespace-ах.
|
||||
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if s.k8s == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
|
||||
return
|
||||
}
|
||||
users, err := s.k8s.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": users})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
|
||||
"admin-console/internal/model"
|
||||
)
|
||||
|
||||
// K8sClient — обёртка над kubernetes.Clientset для нужд admin-console.
|
||||
type K8sClient struct {
|
||||
cs *kubernetes.Clientset
|
||||
}
|
||||
|
||||
// NewK8sClient создаёт клиента из in-cluster конфига, с fallback на ~/.kube/config.
|
||||
func NewK8sClient() (*K8sClient, error) {
|
||||
cfg, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
cfg, err = clientcmd.BuildConfigFromFlags("", clientcmd.RecommendedHomeFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no k8s config: %w", err)
|
||||
}
|
||||
}
|
||||
cs, err := kubernetes.NewForConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &K8sClient{cs: cs}, nil
|
||||
}
|
||||
|
||||
// ListClientNamespaces возвращает namespace-ы с лейблом managed-by=fission-console.
|
||||
// Для каждого считает количество объектов Fission CRD через Dynamic клиент
|
||||
// (пока упрощённо — только через secrets и labels).
|
||||
func (k *K8sClient) ListClientNamespaces(ctx context.Context) ([]model.NamespaceInfo, error) {
|
||||
nsList, err := k.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
|
||||
LabelSelector: "managed-by=fission-console",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]model.NamespaceInfo, 0, len(nsList.Items))
|
||||
for _, ns := range nsList.Items {
|
||||
info := model.NamespaceInfo{
|
||||
Name: ns.Name,
|
||||
UserEmail: ns.Labels["user"],
|
||||
CreatedAt: ns.CreationTimestamp.Format(time.RFC3339),
|
||||
}
|
||||
// TODO: добавить подсчёт Fission CRD через dynamic client
|
||||
result = append(result, info)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CollectUsage возвращает агрегированную статистику по всем клиентским namespace-ам.
|
||||
func (k *K8sClient) CollectUsage(ctx context.Context) ([]model.UsageEntry, error) {
|
||||
nsList, err := k.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
|
||||
LabelSelector: "managed-by=fission-console",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
result := make([]model.UsageEntry, 0, len(nsList.Items))
|
||||
for _, ns := range nsList.Items {
|
||||
entry := model.UsageEntry{
|
||||
Namespace: ns.Name,
|
||||
UserEmail: ns.Labels["user"],
|
||||
PeriodStart: startOfMonth.Format("2006-01-02"),
|
||||
PeriodEnd: now.Format("2006-01-02"),
|
||||
}
|
||||
// TODO: заполнить реальными данными из Prometheus / metrics-server / S3
|
||||
result = append(result, entry)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListUsers собирает пользователей из secret fission-console-users в каждом
|
||||
// клиентском namespace.
|
||||
func (k *K8sClient) ListUsers(ctx context.Context) ([]model.UserInfo, error) {
|
||||
nsList, err := k.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
|
||||
LabelSelector: "managed-by=fission-console",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var users []model.UserInfo
|
||||
for _, ns := range nsList.Items {
|
||||
// label user содержит email владельца namespace
|
||||
email := ns.Labels["user"]
|
||||
if email == "" {
|
||||
// fallback: попробовать прочитать из secret
|
||||
secret, err := k.cs.CoreV1().Secrets(ns.Name).Get(ctx, "fission-console-users", metav1.GetOptions{})
|
||||
if err == nil {
|
||||
for key := range secret.Data {
|
||||
if strings.Contains(key, "@") {
|
||||
email = key
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
users = append(users, model.UserInfo{
|
||||
Email: email,
|
||||
Namespace: ns.Name,
|
||||
CreatedAt: ns.CreationTimestamp.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package model
|
||||
|
||||
// ErrResp — стандартный ответ с ошибкой.
|
||||
func ErrResp(msg string) map[string]string {
|
||||
return map[string]string{"error": msg}
|
||||
}
|
||||
|
||||
// NamespaceInfo — информация об одном клиентском namespace.
|
||||
type NamespaceInfo struct {
|
||||
Name string `json:"name"`
|
||||
UserEmail string `json:"user_email,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
|
||||
// счётчики ресурсов Fission
|
||||
Functions int `json:"functions"`
|
||||
Packages int `json:"packages"`
|
||||
Environments int `json:"environments"`
|
||||
HTTPTriggers int `json:"http_triggers"`
|
||||
TimeTriggers int `json:"time_triggers"`
|
||||
}
|
||||
|
||||
// UsageEntry — строка использования для одного namespace (для бухгалтерии).
|
||||
type UsageEntry struct {
|
||||
Namespace string `json:"namespace"`
|
||||
UserEmail string `json:"user_email,omitempty"`
|
||||
Functions int `json:"functions"`
|
||||
Invocations int64 `json:"invocations_total"` // TODO: из Prometheus/logs
|
||||
StorageGB float64 `json:"storage_gb"` // TODO: из storagesvc/S3
|
||||
CPUMilliCores int64 `json:"cpu_millicores"` // TODO: из metrics-server
|
||||
MemoryMB int64 `json:"memory_mb"` // TODO: из metrics-server
|
||||
PeriodStart string `json:"period_start"`
|
||||
PeriodEnd string `json:"period_end"`
|
||||
}
|
||||
|
||||
// UserInfo — пользователь из secret fission-console-users.
|
||||
type UserInfo struct {
|
||||
Email string `json:"email"`
|
||||
Namespace string `json:"namespace"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Fission Admin Console v0.1.0</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #0f1117; color: #e0e0e0; }
|
||||
header { background: #1a1d27; padding: 16px 24px; border-bottom: 1px solid #2a2d3a;
|
||||
display: flex; align-items: center; gap: 12px; }
|
||||
header h1 { font-size: 18px; font-weight: 600; color: #fff; }
|
||||
header .badge { background: #e53e3e; color: #fff; font-size: 10px; font-weight: 700;
|
||||
padding: 2px 6px; border-radius: 4px; letter-spacing: .5px; }
|
||||
#login { display: flex; align-items: center; justify-content: center; height: calc(100vh - 57px); }
|
||||
#login form { background: #1a1d27; border: 1px solid #2a2d3a; border-radius: 8px;
|
||||
padding: 32px; display: flex; flex-direction: column; gap: 12px; width: 320px; }
|
||||
#login h2 { font-size: 16px; color: #fff; margin-bottom: 4px; }
|
||||
input { background: #0f1117; border: 1px solid #2a2d3a; border-radius: 6px;
|
||||
color: #e0e0e0; padding: 10px 12px; font-size: 14px; outline: none; }
|
||||
input:focus { border-color: #4a90d9; }
|
||||
button { background: #2563eb; color: #fff; border: none; border-radius: 6px;
|
||||
padding: 10px; font-size: 14px; cursor: pointer; font-weight: 600; }
|
||||
button:hover { background: #1d4ed8; }
|
||||
#app { display: none; }
|
||||
nav { background: #1a1d27; border-bottom: 1px solid #2a2d3a;
|
||||
display: flex; gap: 4px; padding: 0 24px; }
|
||||
nav button { background: none; border: none; color: #9ca3af; font-size: 14px;
|
||||
padding: 12px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
|
||||
nav button.active { color: #fff; border-bottom-color: #2563eb; }
|
||||
main { padding: 24px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { color: #6b7280; font-weight: 500; text-align: left; padding: 8px 12px;
|
||||
border-bottom: 1px solid #2a2d3a; }
|
||||
td { padding: 10px 12px; border-bottom: 1px solid #1a1d27; }
|
||||
tr:hover td { background: #1a1d27; }
|
||||
.pill { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; }
|
||||
.pill-blue { background: #1e3a5f; color: #60a5fa; }
|
||||
.err { color: #f87171; font-size: 13px; padding: 16px 0; }
|
||||
.loader { color: #6b7280; font-size: 13px; padding: 16px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Fission Admin Console</h1>
|
||||
<span class="badge">ADMIN</span>
|
||||
</header>
|
||||
|
||||
<div id="login">
|
||||
<form id="loginForm">
|
||||
<h2>Вход в Admin Console</h2>
|
||||
<input type="password" id="tokenInput" placeholder="Admin token" autocomplete="current-password">
|
||||
<button type="submit">Войти</button>
|
||||
<div id="loginErr" class="err" style="display:none"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="app">
|
||||
<nav>
|
||||
<button class="active" onclick="showTab('namespaces', this)">Клиенты</button>
|
||||
<button onclick="showTab('usage', this)">Использование</button>
|
||||
<button onclick="showTab('users', this)">Пользователи</button>
|
||||
</nav>
|
||||
<main id="content"></main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = '/admin/api';
|
||||
let TOKEN = '';
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
TOKEN = document.getElementById('tokenInput').value.trim();
|
||||
const r = await fetch(`${API}/namespaces`, { headers: { Authorization: `Bearer ${TOKEN}` } });
|
||||
if (r.ok) {
|
||||
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 = 'Неверный токен';
|
||||
}
|
||||
});
|
||||
|
||||
function showTab(tab, btn) {
|
||||
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
loadTab(tab);
|
||||
}
|
||||
|
||||
async function loadTab(tab) {
|
||||
const content = document.getElementById('content');
|
||||
content.innerHTML = '<p class="loader">Загрузка…</p>';
|
||||
try {
|
||||
const r = await fetch(`${API}/${tab}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
|
||||
const data = await r.json();
|
||||
if (!r.ok) { content.innerHTML = `<p class="err">${data.error}</p>`; return; }
|
||||
content.innerHTML = renderTab(tab, data);
|
||||
} catch(e) {
|
||||
content.innerHTML = `<p class="err">Ошибка: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTab(tab, data) {
|
||||
if (tab === 'namespaces') return renderNamespaces(data.namespaces);
|
||||
if (tab === 'usage') return renderUsage(data.usage);
|
||||
if (tab === 'users') return renderUsers(data.users);
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderNamespaces(rows) {
|
||||
if (!rows?.length) return '<p class="loader">Нет namespace-ов</p>';
|
||||
return `<table>
|
||||
<tr><th>Namespace</th><th>Email</th><th>Функции</th><th>Пакеты</th><th>Создан</th></tr>
|
||||
${rows.map(n => `<tr>
|
||||
<td><span class="pill pill-blue">${n.name}</span></td>
|
||||
<td>${n.user_email || '—'}</td>
|
||||
<td>${n.functions}</td>
|
||||
<td>${n.packages}</td>
|
||||
<td>${n.created_at ? n.created_at.slice(0,10) : '—'}</td>
|
||||
</tr>`).join('')}
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function renderUsage(rows) {
|
||||
if (!rows?.length) return '<p class="loader">Нет данных</p>';
|
||||
return `<table>
|
||||
<tr><th>Namespace</th><th>Email</th><th>Функции</th><th>Вызовы</th><th>Хранилище GB</th><th>Период</th></tr>
|
||||
${rows.map(u => `<tr>
|
||||
<td><span class="pill pill-blue">${u.namespace}</span></td>
|
||||
<td>${u.user_email || '—'}</td>
|
||||
<td>${u.functions}</td>
|
||||
<td>${u.invocations_total}</td>
|
||||
<td>${u.storage_gb.toFixed(2)}</td>
|
||||
<td>${u.period_start} — ${u.period_end}</td>
|
||||
</tr>`).join('')}
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function renderUsers(rows) {
|
||||
if (!rows?.length) return '<p class="loader">Нет пользователей</p>';
|
||||
return `<table>
|
||||
<tr><th>Email</th><th>Namespace</th><th>Создан</th></tr>
|
||||
${rows.map(u => `<tr>
|
||||
<td>${u.email || '—'}</td>
|
||||
<td><span class="pill pill-blue">${u.namespace}</span></td>
|
||||
<td>${u.created_at ? u.created_at.slice(0,10) : '—'}</td>
|
||||
</tr>`).join('')}
|
||||
</table>`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
# Lyngvo — AI Pronunciation Trainer (Italian) MVP
|
||||
|
||||
## Концепция
|
||||
|
||||
Веб-приложение для тренировки итальянского произношения:
|
||||
|
||||
1. Пользователь вводит текст (слово/фразу)
|
||||
2. Приложение синтезирует эталон (TTS)
|
||||
3. Пользователь записывает свой голос (MediaRecorder)
|
||||
4. Приложение транскрибирует запись (Whisper), сравнивает с оригиналом
|
||||
5. Показывает: similarity score, выделяет пропущенные/лишние слова
|
||||
6. Воспроизводит target → пауза → user (stereo опционально)
|
||||
|
||||
## Tech stack (утверждён)
|
||||
|
||||
- **Frontend:** HTML + JS (чистый, без React)
|
||||
- **Audio:** Web Audio API, MediaRecorder API
|
||||
- **TTS:** Web Speech API (`speechSynthesis`) — бесплатно, итальянский голос есть
|
||||
- **STT:** требуется внешний Whisper API (aillm.ru не поддерживает)
|
||||
- **Бэкенд:** отсутствует (MVP — только статика)
|
||||
- **DevOps:** minikube / k8s кластер `5.172.178.213`, nginx-ingress
|
||||
|
||||
## Что уже сделано
|
||||
|
||||
- DNS: `lang.kube5s.ru` A → `5.172.178.213`
|
||||
- Проект будет в отдельной репе (пока не создана)
|
||||
- Модель aillm.ru: `gpt-oss-120b` — только текст, STT/TTS не поддерживаются
|
||||
|
||||
## Что делать
|
||||
|
||||
1. Создать репу (локально `/home/naeel/lyngvo/` + Gitea на ВМ)
|
||||
2. Развернуть HTML-приложение в кластере (nginx-pod + ingress)
|
||||
3. Реализовать UI: поле ввода, кнопки Generate / Play TTS / Record / Compare
|
||||
4. TTS через Web Speech API
|
||||
5. Запись через MediaRecorder → blob → отправить на Whisper API
|
||||
6. Сравнение текстов (similarity score, highlight diff)
|
||||
7. Playback: target → 300-800ms пауза → user
|
||||
8. Опционально: stereo (left=target, right=user)
|
||||
9. Настроить HTTPS для `lang.kube5s.ru` (cert-manager / Let's Encrypt)
|
||||
|
||||
## Контакты/доступ
|
||||
|
||||
- ВМ: `naeel@5.172.178.213` ключ `~/.ssh/naeel_vm_id_ed25519`
|
||||
- Кластер: kubectl (там же на ВМ)
|
||||
- S3: `sless-functions` на `s3.msk-1.ngcloud.ru`
|
||||
- AI ключ: `sk-ucI5YvOticoOQ9Kuj5K9mQ` (aillm.ru, только текст)
|
||||
|
Before Width: | Height: | Size: 210 B After Width: | Height: | Size: 210 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user