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