d9bda6047f
Cascade Main -> Dev / Cascade main -> dev (push) Successful in 4s
Universal: Push Notifications / push-notify (push) Successful in 2s
Generic: Standards Compliance / Secret Scanning (push) Successful in 4s
Generic: Standards Compliance / License Header Validation (push) Successful in 5s
Generic: Standards Compliance / Repository Structure Validation (push) Failing after 3s
Generic: Standards Compliance / Coding Standards Check (push) Successful in 4s
Generic: Standards Compliance / Workflow Configuration Check (push) Failing after 10s
Generic: Standards Compliance / Documentation Quality Check (push) Failing after 7s
Generic: Standards Compliance / README Completeness Check (push) Failing after 6s
Generic: Standards Compliance / Git Repository Hygiene (push) Successful in 8s
Deploy (Prod) / Deploy to Prod (push) Failing after 48s
Generic: Standards Compliance / Script Integrity Validation (push) Successful in 6s
Generic: Standards Compliance / Line Length Check (push) Successful in 6s
Generic: Standards Compliance / File Naming Standards (push) Successful in 3s
Generic: Standards Compliance / Insecure Code Pattern Detection (push) Successful in 4s
Generic: Standards Compliance / Version Consistency Check (push) Successful in 1m9s
Generic: Standards Compliance / Dead Code Detection (push) Successful in 6s
Generic: Standards Compliance / File Size Limits (push) Successful in 3s
Generic: Standards Compliance / Binary File Detection (push) Successful in 4s
Generic: Standards Compliance / TODO/FIXME Tracking (push) Successful in 4s
Generic: Standards Compliance / Code Complexity Analysis (push) Successful in 45s
Generic: Standards Compliance / Code Duplication Detection (push) Successful in 46s
Generic: Standards Compliance / Broken Link Detection (push) Successful in 3s
Generic: Standards Compliance / API Documentation Coverage (push) Successful in 3s
Generic: Standards Compliance / Accessibility Check (push) Successful in 4s
Generic: Standards Compliance / Performance Metrics (push) Successful in 3s
Generic: Standards Compliance / Dependency Vulnerability Scanning (push) Successful in 43s
Generic: Standards Compliance / Unused Dependencies Check (push) Successful in 43s
Generic: Standards Compliance / Terraform Configuration Validation (push) Successful in 7s
Generic: Standards Compliance / Enterprise Readiness Check (push) Successful in 39s
Generic: Standards Compliance / Repository Health Check (push) Successful in 38s
Generic: Standards Compliance / Compliance Summary (push) Has been cancelled
- Audit entries now record the actor (session email or 'admin (basic)'); new audit.actor column with a safe ALTER migration for existing DBs - Styled /login landing page with a 'Sign in with Google' button; adminAuth redirects unauthenticated browsers there - Dashboard chart range selector (24h / 7d / 30d) - Client-side search/filter box on the API keys list Authored-by: Moko Consulting Claude-Session: https://claude.ai/code/session_015UauDPfYC8S2mdUhoSK8zQ Signed-off-by: Jonathan Miller <jmiller@mokoconsulting.tech>
289 lines
8.1 KiB
Go
289 lines
8.1 KiB
Go
// Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
//
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
// FILE INFORMATION
|
|
// REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoTranslate
|
|
// PATH: /source/httpapi/admin.go
|
|
// BRIEF: Admin GUI handlers — dashboard (totals + recent activity) and API-key
|
|
// management (list, create, disable). Rendered server-side from embedded
|
|
// html/template files.
|
|
|
|
package httpapi
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
const dateLayout = "2006-01-02"
|
|
|
|
// parseExpiresDays returns now+days (UTC) for days>0, else the zero time (never).
|
|
func parseExpiresDays(v string) time.Time {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
return time.Now().UTC().Add(time.Duration(n) * 24 * time.Hour)
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
rng := r.URL.Query().Get("range")
|
|
interval, count := time.Hour, 24
|
|
switch rng {
|
|
case "7d":
|
|
interval, count = 6*time.Hour, 28
|
|
case "30d":
|
|
interval, count = 24*time.Hour, 30
|
|
default:
|
|
rng = "24h"
|
|
}
|
|
keys, requests, chars := s.store.Totals()
|
|
series := s.store.UsageSeries(interval, count)
|
|
var maxReq int64
|
|
for _, b := range series {
|
|
if b.Requests > maxReq {
|
|
maxReq = b.Requests
|
|
}
|
|
}
|
|
s.render(w, "dashboard", map[string]any{
|
|
"Title": "Dashboard",
|
|
"Keys": keys,
|
|
"Requests": requests,
|
|
"Chars": chars,
|
|
"Events": s.store.RecentEvents(50),
|
|
"KeyList": s.store.ListKeys(),
|
|
"Upstream": s.cfg.LibreTranslateURL,
|
|
"Public": s.cfg.PublicBaseURL,
|
|
"EngineUp": s.client.Healthy(),
|
|
"Series": series,
|
|
"MaxReq": maxReq,
|
|
"Range": rng,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleKeysList(w http.ResponseWriter, _ *http.Request) {
|
|
s.render(w, "keys", map[string]any{
|
|
"Title": "API Keys",
|
|
"KeyList": s.store.ListKeys(),
|
|
"Default": s.cfg.DefaultRatePerMin,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
label := r.FormValue("label")
|
|
if label == "" {
|
|
label = "unnamed"
|
|
}
|
|
rate := s.cfg.DefaultRatePerMin
|
|
if v := r.FormValue("rate"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
|
rate = n
|
|
}
|
|
}
|
|
plaintext, k, err := s.store.CreateKey(label, rate, parseExpiresDays(r.FormValue("expires_days")))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.store.RecordAudit("key.create", s.currentUser(r), fmt.Sprintf("key #%d (%s)", k.ID, k.Label))
|
|
// Render the list with the one-time plaintext banner. This is the only time
|
|
// the full key is ever shown.
|
|
s.render(w, "keys", map[string]any{
|
|
"Title": "API Keys",
|
|
"KeyList": s.store.ListKeys(),
|
|
"Default": s.cfg.DefaultRatePerMin,
|
|
"NewKey": plaintext,
|
|
"NewKeyLabel": label,
|
|
})
|
|
}
|
|
|
|
// keyID parses the {id} path value, writing an error response on failure.
|
|
func keyID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "bad id", http.StatusBadRequest)
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
func (s *Server) handleKeyDetail(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := keyID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
k, found := s.store.GetKey(id)
|
|
if !found {
|
|
http.Error(w, "key not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
series := s.store.KeyUsageSeries(id, time.Hour, 24)
|
|
var maxReq int64
|
|
for _, b := range series {
|
|
if b.Requests > maxReq {
|
|
maxReq = b.Requests
|
|
}
|
|
}
|
|
s.render(w, "key_detail", map[string]any{
|
|
"Title": "Key: " + k.Label,
|
|
"Key": k,
|
|
"Events": s.store.KeyEvents(id, 100),
|
|
"Series": series,
|
|
"MaxReq": maxReq,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleKeyEdit(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := keyID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
rate := -1
|
|
if v := r.FormValue("rate"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
|
rate = n
|
|
}
|
|
}
|
|
// Expiry: a YYYY-MM-DD date field; empty clears it to "never".
|
|
var expiresAt time.Time
|
|
if v := r.FormValue("expires"); v != "" {
|
|
if t, err := time.Parse(dateLayout, v); err == nil {
|
|
expiresAt = t.UTC()
|
|
}
|
|
}
|
|
if err := s.store.UpdateKey(id, r.FormValue("label"), rate, expiresAt); err != nil {
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
s.store.RecordAudit("key.edit", s.currentUser(r), fmt.Sprintf("key #%d", id))
|
|
http.Redirect(w, r, "/admin/keys/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleKeyDisable(w http.ResponseWriter, r *http.Request) {
|
|
s.setKeyState(w, r, true)
|
|
}
|
|
|
|
func (s *Server) handleKeyEnable(w http.ResponseWriter, r *http.Request) {
|
|
s.setKeyState(w, r, false)
|
|
}
|
|
|
|
func (s *Server) setKeyState(w http.ResponseWriter, r *http.Request, disabled bool) {
|
|
id, ok := keyID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := s.store.SetKeyDisabled(id, disabled); err != nil {
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
action := "key.enable"
|
|
if disabled {
|
|
action = "key.disable"
|
|
}
|
|
s.store.RecordAudit(action, s.currentUser(r), fmt.Sprintf("key #%d", id))
|
|
http.Redirect(w, r, "/admin/keys/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
|
}
|
|
|
|
// handleStatus renders the config/status page: engine reachability, loaded
|
|
// languages, and effective settings.
|
|
func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
|
|
up := s.client.Healthy()
|
|
langCount := 0
|
|
if up {
|
|
if langs, err := s.client.Languages(); err == nil {
|
|
langCount = len(langs)
|
|
}
|
|
}
|
|
nKeys, reqs, chars := s.store.Totals()
|
|
s.render(w, "status", map[string]any{
|
|
"Title": "Status",
|
|
"Upstream": s.cfg.LibreTranslateURL,
|
|
"EngineUp": up,
|
|
"Languages": langCount,
|
|
"Public": s.cfg.PublicBaseURL,
|
|
"RequireKey": s.cfg.RequireKey,
|
|
"DefaultRate": s.cfg.DefaultRatePerMin,
|
|
"StorePath": s.cfg.StorePath,
|
|
"ListenAddr": s.cfg.ListenAddr,
|
|
"KeyCount": nKeys,
|
|
"Requests": reqs,
|
|
"Chars": chars,
|
|
})
|
|
}
|
|
|
|
// handleExportKeys streams all keys as CSV.
|
|
func (s *Server) handleExportKeys(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="mokotranslate-keys.csv"`)
|
|
cw := csv.NewWriter(w)
|
|
_ = cw.Write([]string{"id", "label", "state", "rate_per_min", "requests", "chars", "created", "last_used", "expires_at"})
|
|
for _, k := range s.store.ListKeys() {
|
|
state := "active"
|
|
if k.Disabled {
|
|
state = "disabled"
|
|
} else if k.Expired() {
|
|
state = "expired"
|
|
}
|
|
_ = cw.Write([]string{
|
|
strconv.FormatInt(k.ID, 10), k.Label, state,
|
|
strconv.Itoa(k.RateLimitPerMin),
|
|
strconv.FormatInt(k.Requests, 10), strconv.FormatInt(k.Chars, 10),
|
|
k.Created.Format(time.RFC3339), zeroableTime(k.LastUsed), zeroableTime(k.ExpiresAt),
|
|
})
|
|
}
|
|
cw.Flush()
|
|
}
|
|
|
|
// handleKeyExport streams one key's recent events as CSV.
|
|
func (s *Server) handleKeyExport(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := keyID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="mokotranslate-key-`+strconv.FormatInt(id, 10)+`-events.csv"`)
|
|
cw := csv.NewWriter(w)
|
|
_ = cw.Write([]string{"time", "endpoint", "chars", "status"})
|
|
for _, e := range s.store.KeyEvents(id, 10000) {
|
|
_ = cw.Write([]string{e.Time.Format(time.RFC3339), e.Endpoint, strconv.Itoa(e.Chars), strconv.Itoa(e.Status)})
|
|
}
|
|
cw.Flush()
|
|
}
|
|
|
|
func zeroableTime(t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
return t.Format(time.RFC3339)
|
|
}
|
|
|
|
// handleAudit renders the admin audit log.
|
|
func (s *Server) handleAudit(w http.ResponseWriter, _ *http.Request) {
|
|
s.render(w, "audit", map[string]any{
|
|
"Title": "Audit Log",
|
|
"Entries": s.store.AuditLog(200),
|
|
})
|
|
}
|
|
|
|
// handleLanguages lists the engine's supported languages.
|
|
func (s *Server) handleLanguages(w http.ResponseWriter, _ *http.Request) {
|
|
data := map[string]any{"Title": "Languages"}
|
|
if langs, err := s.client.Languages(); err != nil {
|
|
data["EngineError"] = err.Error()
|
|
} else {
|
|
data["Languages"] = langs
|
|
}
|
|
s.render(w, "languages", data)
|
|
}
|