Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de2598f449 | ||
|
|
35c276c7a9 | ||
|
|
21ab7b9d6b | ||
|
|
d570a6b8a5 | ||
|
|
190361ab0c | ||
|
|
55758b6280 | ||
|
|
34a8068da5 |
@@ -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/` — без явного разрешения не трогать
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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>
|
||||
@@ -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, только текст)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user