Compare commits

..
111 changed files with 1026 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
# Handoff: admin-console — задание для DeepSeek
## Контекст
Репозиторий `fission-console`, ветка `restructure/repo-layout`.
Это **Fission Serverless Platform** — кастомная установка. Клиентская консоль (`client-console/`) уже готова и работает в проде. Нужно реализовать **admin-console** (`admin-console/`) — панель для оператора платформы (просмотр всех клиентов, использование ресурсов, данные для бухгалтерии).
---
## Что уже сделано (скелет)
```
admin-console/
cmd/server/main.go — точка входа, порт 8091, требует ADMIN_TOKEN
internal/api/server.go — роуты: /admin/api/namespaces, /usage, /users
internal/metrics/k8s.go — k8s-клиент (in-cluster), заглушки для реальных данных
internal/model/types.go — типы NamespaceInfo, UsageEntry, UserInfo
deploy/admin.yaml — K8s Deployment + ClusterRole (read-only всё)
ui/index.html — базовый UI (тёмная тема, 3 вкладки)
go.mod — module admin-console, зависит от k8s.io/client-go v0.31.0
```
---
## Что нужно реализовать
### 1. `internal/metrics/k8s.go` — подсчёт Fission CRD
Метод `ListClientNamespaces` должен для каждого namespace считать объекты через Dynamic client:
```go
import (
"k8s.io/client-go/dynamic"
"k8s.io/apimachinery/pkg/runtime/schema"
)
dynamicClient, _ := dynamic.NewForConfig(cfg)
gvr := schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"}
list, err := dynamicClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{})
info.Functions = len(list.Items)
```
Аналогично для: `packages`, `environments`, `httptriggers`, `timetriggers`.
Сохрани `dynamicClient` в структуру `K8sClient` и используй его в обоих методах.
### 2. `internal/metrics/k8s.go` — S3-хранилище в `CollectUsage`
Поле `StorageGB` — читать из S3. Конфигурация через env:
- `S3_ENDPOINT` = `https://s3.msk-1.ngcloud.ru`
- `S3_BUCKET` = `sless-functions`
- `S3_ACCESS_KEY`, `S3_SECRET_KEY`
Использовать `github.com/aws/aws-sdk-go-v2`. Для каждого namespace prefix = `{namespace}/`, суммировать размер всех объектов.
```go
import (
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
awscfg "github.com/aws/aws-sdk-go-v2/config"
)
cfg, _ := awscfg.LoadDefaultConfig(ctx,
awscfg.WithRegion("ru-msk-1"),
awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
awscfg.WithEndpointResolverWithOptions(...), // custom endpoint
)
client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true })
paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(namespace + "/"),
})
var totalBytes int64
for paginator.HasMorePages() {
page, _ := paginator.NextPage(ctx)
for _, obj := range page.Contents {
totalBytes += aws.ToInt64(obj.Size)
}
}
entry.StorageGB = float64(totalBytes) / (1024 * 1024 * 1024)
```
Поля `Invocations`, `CPUMilliCores`, `MemoryMB` — оставить 0 (TODO).
### 3. `go.mod` — добавить зависимости
```
github.com/aws/aws-sdk-go-v2 v1.30.0
github.com/aws/aws-sdk-go-v2/config v1.27.0
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.0
github.com/aws/aws-sdk-go-v2/credentials v1.17.0
```
Запустить `go mod tidy` после добавления.
### 4. `deploy/admin.yaml` — добавить env в контейнер
```yaml
env:
- name: ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: fission-admin-token
key: token
- name: S3_ENDPOINT
value: "https://s3.msk-1.ngcloud.ru"
- name: S3_BUCKET
value: "sless-functions"
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: fission-s3-credentials
key: access_key
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: fission-s3-credentials
key: secret_key
```
### 5. `ui/index.html` — доработать UI
- В таблице "Использование" показывать реальное поле `storage_gb` (уже есть в шаблоне).
- Добавить кнопку **"Экспорт CSV"** над таблицей Usage — генерировать CSV из текущих данных и скачивать через `Blob`:
```js
function exportCSV(rows) {
const header = 'Namespace,Email,Функции,Вызовы,Хранилище GB,Период\n';
const body = rows.map(u =>
[u.namespace, u.user_email, u.functions, u.invocations_total,
u.storage_gb.toFixed(3), `${u.period_start}${u.period_end}`].join(',')
).join('\n');
const blob = new Blob([header + body], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `usage-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
}
```
---
## Правила среды
- **Go version**: `1.25.0` (не менять в go.mod)
- **Образ**: `naeel/fission-admin-console:v0.1.0`
- **Build + push**: только через SSH на ВМ `naeel@5.172.178.213` (ключ у оператора)
- **Namespace деплоя**: `fission`
- **S3-секрет** `fission-s3-credentials` уже существует в namespace `fission`
- **Admin-токен** создать вручную перед деплоем:
```bash
kubectl create secret generic fission-admin-token -n fission --from-literal=token=<случайная_строка>
```
- Деплой: `kubectl apply -f ~/terra/fission/admin-console/deploy/admin.yaml`
## Что НЕ трогать
- `client-console/` — рабочий код в проде, **не трогать вообще**
- `helm/`, `terraform/`, `scripts/` — не трогать
- Любой файл вне `admin-console/` — без явного разрешения не трогать
+12
View File
@@ -0,0 +1,12 @@
FROM golang:1.26-alpine AS builder
WORKDIR /build
COPY . .
RUN go mod tidy
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"]
+27
View File
@@ -0,0 +1,27 @@
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)
}
}
+120
View File
@@ -0,0 +1,120 @@
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.1
imagePullPolicy: Always
ports:
- containerPort: 8091
env:
- name: ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: fission-admin-token
key: token
- name: S3_ENDPOINT
value: "https://s3.msk-1.ngcloud.ru"
- name: S3_BUCKET
value: "sless-functions"
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: fission-s3-credentials
key: access_key
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: fission-s3-credentials
key: secret_key
---
apiVersion: v1
kind: Service
metadata:
name: fission-admin-console
namespace: fission
spec:
selector:
app: fission-admin-console
ports:
- port: 8091
targetPort: 8091
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: fission-admin-console
namespace: fission
annotations:
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
ingressClassName: nginx
rules:
- host: fission.kube5s.ru
http:
paths:
- backend:
service:
name: fission-admin-console
port:
number: 8091
path: /admin
pathType: Prefix
tls:
- hosts:
- fission.kube5s.ru
secretName: fission-tls
+15
View File
@@ -0,0 +1,15 @@
module admin-console
go 1.25.0
require (
github.com/aws/aws-sdk-go-v2 v1.30.0
github.com/aws/aws-sdk-go-v2/config v1.27.0
github.com/aws/aws-sdk-go-v2/credentials v1.17.0
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.0
k8s.io/api v0.31.0
k8s.io/apimachinery v0.31.0
k8s.io/client-go v0.31.0
)
+129
View File
@@ -0,0 +1,129 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"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/namespaces/", s.authMiddleware(s.handleNamespaceDetail))
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})
}
// 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 возвращает агрегированные метрики использования для бухгалтерии.
func (s *Server) handleUsage(w http.ResponseWriter, r *http.Request) {
if s.k8s == nil {
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
usage, err := s.k8s.CollectUsage(ctx)
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
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
users, err := s.k8s.ListUsers(ctx)
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)
}
+254
View File
@@ -0,0 +1,254 @@
package metrics
import (
"context"
"fmt"
"log"
"os"
"strings"
"sync"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"admin-console/internal/model"
)
// fission CRD group-version-resources.
var (
gvrFunctions = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"}
gvrPackages = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "packages"}
gvrEnvironments = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "environments"}
gvrHTTPTriggers = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"}
gvrTimeTriggers = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"}
)
// K8sClient — обёртка над kubernetes.Clientset для нужд admin-console.
type K8sClient struct {
cs *kubernetes.Clientset
dc dynamic.Interface
s3 *s3.Client
s3Bucket string
}
// 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
}
dc, err := dynamic.NewForConfig(cfg)
if err != nil {
return nil, fmt.Errorf("dynamic client: %w", err)
}
k := &K8sClient{cs: cs, dc: dc}
// S3 — опционально (не падаем если нет кредов)
k.initS3()
return k, nil
}
// initS3 создаёт S3-клиент из переменных окружения.
func (k *K8sClient) initS3() {
endpoint := os.Getenv("S3_ENDPOINT")
bucket := os.Getenv("S3_BUCKET")
accessKey := os.Getenv("S3_ACCESS_KEY")
secretKey := os.Getenv("S3_SECRET_KEY")
if endpoint == "" || bucket == "" || accessKey == "" || secretKey == "" {
log.Println("[admin-console] S3 env vars not set — storage metrics disabled")
return
}
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithRegion("ru-msk-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
)
if err != nil {
log.Printf("[admin-console] S3 config error: %v", err)
return
}
k.s3 = s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true
})
k.s3Bucket = bucket
log.Printf("[admin-console] S3 client ready, bucket=%s", bucket)
}
// ListClientNamespaces возвращает список namespace-ов БЕЗ CRD-счётчиков (быстро).
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 {
result = append(result, model.NamespaceInfo{
Name: ns.Name,
UserEmail: ns.Labels["user"],
CreatedAt: ns.CreationTimestamp.Format(time.RFC3339),
})
}
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.
func (k *K8sClient) fillCRDCounts(ctx context.Context, ns string, info *model.NamespaceInfo) {
count := func(gvr schema.GroupVersionResource) int {
list, err := k.dc.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{Limit: 1})
if err != nil {
return 0
}
if rem := list.GetRemainingItemCount(); rem != nil {
return int(*rem) + len(list.Items)
}
return len(list.Items)
}
var wg sync.WaitGroup
wg.Add(5)
go func() { defer wg.Done(); info.Functions = count(gvrFunctions) }()
go func() { defer wg.Done(); info.Packages = count(gvrPackages) }()
go func() { defer wg.Done(); info.Environments = count(gvrEnvironments) }()
go func() { defer wg.Done(); info.HTTPTriggers = count(gvrHTTPTriggers) }()
go func() { defer wg.Done(); info.TimeTriggers = count(gvrTimeTriggers) }()
wg.Wait()
}
// 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"),
}
// Считаем функции через Dynamic client.
if list, err := k.dc.Resource(gvrFunctions).Namespace(ns.Name).List(ctx, metav1.ListOptions{Limit: 1}); err == nil {
entry.Functions = len(list.Items)
if rem := list.GetRemainingItemCount(); rem != nil {
entry.Functions += int(*rem)
}
}
// Считаем S3-хранилище.
entry.StorageGB = k.namespaceStorage(ctx, ns.Name)
// Invocations / CPU / Memory — TODO.
result = append(result, entry)
}
return result, nil
}
// namespaceStorage возвращает суммарный размер объектов S3 с префиксом "{ns}/".
func (k *K8sClient) namespaceStorage(ctx context.Context, ns string) float64 {
if k.s3 == nil {
return 0
}
var totalBytes int64
paginator := s3.NewListObjectsV2Paginator(k.s3, &s3.ListObjectsV2Input{
Bucket: aws.String(k.s3Bucket),
Prefix: aws.String(ns + "/"),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
log.Printf("[admin-console] S3 list error ns=%s: %v", ns, err)
return 0
}
for _, obj := range page.Contents {
if obj.Size != nil {
totalBytes += *obj.Size
}
}
}
return float64(totalBytes) / (1024 * 1024 * 1024)
}
// 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 {
email := ns.Labels["user"]
if email == "" {
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
}
+40
View File
@@ -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"`
}
+219
View File
@@ -0,0 +1,219 @@
<!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.1</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; }
.cnt { color: #6b7280; font-size: 12px; }
.cnt.loading { animation: pulse 1.5s infinite; }
@keyframes pulse { 0%,100%{opacity:.3} 50%{opacity:1} }
</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 = '';
let NS_LIST = [];
document.getElementById('loginForm').addEventListener('submit', async e => {
e.preventDefault();
TOKEN = document.getElementById('tokenInput').value.trim();
try {
const r = await fetch(`${API}/namespaces`, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (r.ok) {
const data = await r.json();
NS_LIST = data.namespaces || [];
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');
err.style.display = 'block';
err.textContent = 'Ошибка соединения: ' + e.message;
}
});
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');
if (tab === 'namespaces') {
content.innerHTML = renderNamespaces(NS_LIST);
// загружаем CRD-счётчики асинхронно
loadAllDetails();
return;
}
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 === '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>Fn</th><th>Pkg</th><th>Env</th><th>HTTP</th><th>Time</th><th>Создан</th></tr>
${rows.map((n,i) => `<tr>
<td><span class="pill pill-blue">${n.name}</span></td>
<td>${n.user_email || '—'}</td>
<td class="cnt loading" id="ns${i}-fn">…</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>
</tr>`).join('')}
</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) {
if (!rows?.length) return '<p class="loader">Нет данных</p>';
const btn = `<div style="margin-bottom:12px">
<button onclick="exportCSV()">📥 Экспорт CSV</button>
</div>`;
return btn + `<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 || 0).toFixed(3)}</td>
<td>${u.period_start}${u.period_end}</td>
</tr>`).join('')}
</table>`;
}
function exportCSV() {
const table = document.querySelector('table');
if (!table) return;
let csv = '\uFEFF';
table.querySelectorAll('tr').forEach(row => {
const cells = [];
row.querySelectorAll('th,td').forEach(c => cells.push('"' + c.textContent.replace(/"/g, '""') + '"'));
csv += cells.join(',') + '\n';
});
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `usage-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
}
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>
View File
+46
View File
@@ -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, только текст)
View File
View File
View File

Some files were not shown because too many files have changed in this diff Show More