fixes and improvements for new server settings menu
This commit is contained in:
parent
902f3606c2
commit
66f9c0bc1e
27 changed files with 2244 additions and 40 deletions
|
|
@ -27,6 +27,23 @@ const hostStatsPollInterval = 5 * time.Second
|
|||
|
||||
const defaultAdminAPIAddr = "127.0.0.1:2599"
|
||||
|
||||
// bootID is a random value generated once per process start, exposed via
|
||||
// GET /api/session -- see that handler's doc comment for why (the
|
||||
// Restart/Update polling flow's way of detecting a genuinely new admin
|
||||
// process, not just a slow-to-respond old one).
|
||||
var bootID = newBootID()
|
||||
|
||||
func newBootID() string {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
// crypto/rand failing is effectively unheard of on any real target
|
||||
// this binary runs on; falling back to the wall clock still gives a
|
||||
// value that changes across restarts, which is all this is for.
|
||||
return fmt.Sprintf("t%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
|
|
@ -95,6 +112,18 @@ type uiConfig struct {
|
|||
// permissions, and the session/login response tells the frontend to hide
|
||||
// the "Third-party marks" nav entry and its routes.
|
||||
HideThirdPartyVerification bool
|
||||
// IdentityDir mirrors config.IdentityDir -- must point at the same
|
||||
// directory owpengram-server reads, so an identity edit here is visible
|
||||
// over /owpengram/server-info immediately (see internal/identity).
|
||||
IdentityDir string
|
||||
// RepoRoot is where Server Settings' Restart/Update/.env-editing (see
|
||||
// internal/procctl) operate: bin/, logs/, .env, .env.example and
|
||||
// .server_panel.json are all expected directly under it, exactly as
|
||||
// tui-panel/server-panel.py expects. Defaults to the process's current
|
||||
// working directory, which is correct whenever this binary is launched
|
||||
// from (or by something that cd'd into) the repo root -- true both for a
|
||||
// manual run and for how the TUI itself launches it.
|
||||
RepoRoot string
|
||||
}
|
||||
|
||||
// loadConfig 通过 internal/config.Load() 加载 .env 配置文件与环境变量,
|
||||
|
|
@ -121,6 +150,11 @@ func loadConfig() (uiConfig, error) {
|
|||
}
|
||||
sum := sha256.Sum256([]byte(appCfg.AdminSessionKey))
|
||||
|
||||
repoRoot, err := os.Getwd()
|
||||
if err != nil {
|
||||
return uiConfig{}, fmt.Errorf("resolve repo root: %w", err)
|
||||
}
|
||||
|
||||
return uiConfig{
|
||||
Addr: appCfg.AdminUIAddr,
|
||||
PostgresDSN: appCfg.PostgresDSN,
|
||||
|
|
@ -132,6 +166,8 @@ func loadConfig() (uiConfig, error) {
|
|||
Permissions: appCfg.AdminUIPermissions,
|
||||
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
|
||||
BlobDir: appCfg.BlobDir,
|
||||
IdentityDir: appCfg.IdentityDir,
|
||||
RepoRoot: repoRoot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,13 @@ const (
|
|||
// curates the icon catalogue and strips granted marks.
|
||||
permissionBotVerificationReview = "botverification.review"
|
||||
permissionBotVerificationManage = "botverification.manage"
|
||||
// permissionServerManage gates the whole Server Settings panel: identity
|
||||
// (name/description/icon), .env editing, and Restart/Update -- all of it
|
||||
// meaningfully more sensitive than any domain-data action above (.env
|
||||
// editing exposes every secret the deployment holds; Restart/Update runs
|
||||
// git/go and bounces the live MTProto process), so it is one right, not
|
||||
// split into review/manage like the sections above.
|
||||
permissionServerManage = "server.manage"
|
||||
)
|
||||
|
||||
type permissionsKey struct{}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import (
|
|||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/hoststats"
|
||||
"telesrv/internal/identity"
|
||||
"telesrv/internal/procctl"
|
||||
)
|
||||
|
||||
//go:embed web/dist
|
||||
|
|
@ -31,6 +33,8 @@ type server struct {
|
|||
hostStats *hoststats.Poller
|
||||
web fs.FS
|
||||
webServer http.Handler
|
||||
identity *identity.Store
|
||||
serverCtl *procctl.Manager
|
||||
}
|
||||
|
||||
func newServer(cfg uiConfig, read *readStore, hostStats *hoststats.Poller) (*server, error) {
|
||||
|
|
@ -44,6 +48,8 @@ func newServer(cfg uiConfig, read *readStore, hostStats *hoststats.Poller) (*ser
|
|||
hostStats: hostStats,
|
||||
web: web,
|
||||
webServer: http.FileServer(http.FS(web)),
|
||||
identity: identity.NewStore(cfg.IdentityDir),
|
||||
serverCtl: procctl.NewManager(cfg.RepoRoot),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -163,6 +169,20 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/upsert-verification-icon", s.botVerificationManage(s.handleUpsertVerificationIconAPI))
|
||||
mux.Handle("POST /api/actions/set-verification-icon-active", s.botVerificationManage(s.handleSetVerificationIconActiveAPI))
|
||||
mux.Handle("POST /api/actions/revoke-custom-verification", s.botVerificationManage(s.handleRevokeCustomVerificationAPI))
|
||||
// Server Settings -- see serversettings.go. Everything here operates
|
||||
// directly on local files/processes (no RPC hop to owpengram-server's
|
||||
// in-process admin API), so it works even for actions (Restart/Update)
|
||||
// that owpengram-server could never safely perform on itself.
|
||||
mux.Handle("GET /api/server/identity", s.serverManage(s.handleServerIdentityAPI))
|
||||
mux.Handle("GET /api/server/icon", s.serverManage(s.handleServerIconAPI))
|
||||
mux.Handle("POST /api/actions/set-server-identity", s.serverManage(s.handleSetServerIdentityAPI))
|
||||
mux.Handle("POST /api/actions/upload-server-icon", s.serverManage(s.handleUploadServerIconAPI))
|
||||
mux.Handle("POST /api/actions/remove-server-icon", s.serverManage(s.handleRemoveServerIconAPI))
|
||||
mux.Handle("GET /api/server/env", s.serverManage(s.handleServerEnvAPI))
|
||||
mux.Handle("POST /api/actions/update-server-env", s.serverManage(s.handleUpdateServerEnvAPI))
|
||||
mux.Handle("GET /api/server/status", s.serverManage(s.handleServerStatusAPI))
|
||||
mux.Handle("POST /api/actions/restart-server", s.serverManage(s.handleRestartServerAPI))
|
||||
mux.Handle("POST /api/actions/update-server", s.serverManage(s.handleUpdateServerAPI))
|
||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeAPIError(w, http.StatusNotFound, "api route not found")
|
||||
})
|
||||
|
|
@ -274,6 +294,12 @@ func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
|
|||
"actor": actorFromContext(r.Context()),
|
||||
"permissions": permissionsFromContext(r.Context()).List(),
|
||||
"hide_third_party_verification": s.cfg.HideThirdPartyVerification,
|
||||
// boot_id is random per process start (see main.go) -- Server
|
||||
// Settings' Restart/Update flow polls this after triggering an
|
||||
// action and reloads the page once it changes, which is how it
|
||||
// tells "the old admin process died and a new one answered" apart
|
||||
// from "the old one is just slow to respond".
|
||||
"boot_id": bootID,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
264
cmd/telesrv-admin/serversettings.go
Normal file
264
cmd/telesrv-admin/serversettings.go
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
)
|
||||
|
||||
// serverManage gates the whole Server Settings surface -- see
|
||||
// permissionServerManage's doc comment in security.go for why this is one
|
||||
// right rather than split review/manage like other sections.
|
||||
func (s *server) serverManage(handler http.HandlerFunc) http.Handler {
|
||||
return s.requireAuthAPI(s.requirePermission(permissionServerManage, handler))
|
||||
}
|
||||
|
||||
// serverCommandResult builds the same admin.CommandResult shape every other
|
||||
// action returns, without going through internal/admin's runCommand +
|
||||
// Postgres audit log: everything in this file operates on local files/
|
||||
// processes directly (see routes() in server.go for why), so there is no
|
||||
// owpengram-server-side admin_commands row to write. The actor/reason are
|
||||
// still in meta for structured logging if that's ever added; today they are
|
||||
// simply not persisted anywhere.
|
||||
func serverCommandResult(meta admin.CommandMeta, action string, err error, message string, details map[string]any) admin.CommandResult {
|
||||
status := "completed"
|
||||
errText := ""
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
errText = err.Error()
|
||||
if message == "" {
|
||||
message = "command failed"
|
||||
}
|
||||
}
|
||||
return admin.CommandResult{
|
||||
CommandID: meta.CommandID,
|
||||
Action: action,
|
||||
Status: status,
|
||||
DryRun: meta.DryRun,
|
||||
Message: message,
|
||||
Details: details,
|
||||
Error: errText,
|
||||
}
|
||||
}
|
||||
|
||||
// --- identity (name/description/icon) ---------------------------------
|
||||
|
||||
func (s *server) handleServerIdentityAPI(w http.ResponseWriter, r *http.Request) {
|
||||
info, err := s.identity.Get()
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
// handleServerIconAPI serves the icon's raw bytes for the panel's own
|
||||
// preview -- separate from owpengram-server's public /owpengram/server-icon
|
||||
// (same underlying file, different process/auth: this one is behind the
|
||||
// admin session, not open to clients).
|
||||
func (s *server) handleServerIconAPI(w http.ResponseWriter, r *http.Request) {
|
||||
data, ext, ok := s.identity.Icon()
|
||||
if !ok {
|
||||
writeAPIError(w, http.StatusNotFound, "no icon configured")
|
||||
return
|
||||
}
|
||||
contentType := map[string]string{
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".webp": "image/webp", ".gif": "image/gif",
|
||||
}[ext]
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
type setServerIdentityAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetServerIdentityAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setServerIdentityAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-server-identity")
|
||||
details := map[string]any{"name": body.Name, "description": body.Description}
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_identity", nil, "server identity validated", details))
|
||||
return
|
||||
}
|
||||
err := s.identity.SetText(body.Name, body.Description)
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.set_identity", err, "server identity updated", details))
|
||||
}
|
||||
|
||||
var allowedServerIconExts = map[string]bool{
|
||||
".png": true, ".jpg": true, ".jpeg": true, ".webp": true, ".gif": true,
|
||||
}
|
||||
|
||||
const maxServerIconBytes = 2 << 20 // 2 MiB
|
||||
|
||||
type uploadServerIconAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
|
||||
// handleUploadServerIconAPI takes multipart/form-data (a "metadata" JSON
|
||||
// field + a "file" field), the same shape handleSetAccountAvatarAPI uses --
|
||||
// deliberately not JSON+base64 like the other Server Settings actions:
|
||||
// base64 inflates a file ~33%, and decodeAction's plain io.LimitReader caps
|
||||
// the request body at 1MiB regardless of maxServerIconBytes, so a real
|
||||
// multi-hundred-KB icon would fail decoding ("unexpected EOF" from the
|
||||
// truncated body) before this handler ever saw it. Multipart sidesteps that
|
||||
// entirely -- the size cap below is enforced on the actual file bytes.
|
||||
func (s *server) handleUploadServerIconAPI(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxServerIconBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
var body uploadServerIconAPIRequest
|
||||
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "icon file is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if !allowedServerIconExts[ext] {
|
||||
writeAPIError(w, http.StatusBadRequest, "unsupported icon extension")
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxServerIconBytes+1))
|
||||
if err != nil || len(data) == 0 || len(data) > maxServerIconBytes {
|
||||
writeAPIError(w, http.StatusBadRequest, "icon file is empty or too large (max 2MiB)")
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "upload-server-icon")
|
||||
details := map[string]any{"bytes": len(data), "ext": ext}
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.upload_icon", nil, "server icon validated", details))
|
||||
return
|
||||
}
|
||||
setErr := s.identity.SetIcon(data, ext)
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.upload_icon", setErr, "server icon updated", details))
|
||||
}
|
||||
|
||||
type removeServerIconAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
|
||||
func (s *server) handleRemoveServerIconAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body removeServerIconAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "remove-server-icon")
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.remove_icon", nil, "server icon removal validated", nil))
|
||||
return
|
||||
}
|
||||
err := s.identity.RemoveIcon()
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.remove_icon", err, "server icon removed", nil))
|
||||
}
|
||||
|
||||
// --- .env editing --------------------------------------------------------
|
||||
|
||||
func (s *server) handleServerEnvAPI(w http.ResponseWriter, r *http.Request) {
|
||||
groups, err := s.serverCtl.ReadEnvGroups()
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, groups)
|
||||
}
|
||||
|
||||
type updateServerEnvAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Values map[string]string `json:"values"`
|
||||
}
|
||||
|
||||
func (s *server) handleUpdateServerEnvAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body updateServerEnvAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "update-server-env")
|
||||
details := map[string]any{"keys_changed": len(body.Values)}
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update_env", nil, "would update .env -- takes effect on next Restart/Update", details))
|
||||
return
|
||||
}
|
||||
err := s.serverCtl.WriteEnvValues(body.Values)
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update_env", err, ".env updated -- restart the server for changes to take effect", details))
|
||||
}
|
||||
|
||||
// --- status / restart / update -------------------------------------------
|
||||
|
||||
func (s *server) handleServerStatusAPI(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.serverCtl.Status())
|
||||
}
|
||||
|
||||
type restartServerAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
|
||||
func (s *server) handleRestartServerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body restartServerAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "restart-server")
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.restart", nil, "restart validated -- rebuilds and relaunches bin/owpengram-server", nil))
|
||||
return
|
||||
}
|
||||
log, err := s.serverCtl.Restart(r.Context())
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.restart", err, "server restarted", map[string]any{"log": log}))
|
||||
}
|
||||
|
||||
type updateServerAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
|
||||
func (s *server) handleUpdateServerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body updateServerAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "update-server")
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update", nil, "update validated -- git pull, rebuild both binaries, relaunch bin/owpengram-server (admin panel binary is rebuilt but not self-restarted)", nil))
|
||||
return
|
||||
}
|
||||
log, err := s.serverCtl.Update(r.Context())
|
||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.update", err, "server updated", map[string]any{"log": log}))
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-C0CaRjmH.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-C0CaRjmH.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-Bt9UBcEE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
|
||||
<script type="module" crossorigin src="/assets/index-C0CaRjmH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dx2zWoFE.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ import type {
|
|||
CollectibleUsernameDetail,
|
||||
CollectibleUsernameListResponse,
|
||||
CommandResult,
|
||||
EnvGroup,
|
||||
ServerIdentity,
|
||||
ServerStatus,
|
||||
GroupMessageDetail,
|
||||
GroupMessageListResponse,
|
||||
MessageDetail,
|
||||
|
|
@ -232,6 +235,11 @@ export const api = {
|
|||
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
|
||||
gifCatalog: () => request<GifCatalogListResponse>("/api/gif-catalog"),
|
||||
createGifCatalogEntry: (form: FormData) => request<CommandResult>("/api/actions/create-gif-catalog-entry", { method: "POST", body: form }),
|
||||
serverIdentity: () => request<ServerIdentity>("/api/server/identity"),
|
||||
uploadServerIcon: (form: FormData) => request<CommandResult>("/api/actions/upload-server-icon", { method: "POST", body: form }),
|
||||
serverIconURL: () => `/api/server/icon?t=${Date.now()}`,
|
||||
serverEnv: () => request<EnvGroup[]>("/api/server/env"),
|
||||
serverStatus: () => request<ServerStatus>("/api/server/status"),
|
||||
action: (path: string, payload: Record<string, unknown>) => request<CommandResult>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
LogOut,
|
||||
Megaphone,
|
||||
MessageSquareText,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Smile,
|
||||
|
|
@ -18,7 +19,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api } from "../api";
|
||||
import { permissionBotVerificationReview, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
|
||||
import { permissionBotVerificationReview, permissionServerManage, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
|
||||
import { type Navigate, type RouteState, routeTitle } from "../routing";
|
||||
import { ThemeSwitch } from "../theme";
|
||||
import { AppLink } from "./AppLink";
|
||||
|
|
@ -57,6 +58,7 @@ export function Shell({
|
|||
// Same reasoning for the third-party queue, which has its own right: the two
|
||||
// sections are granted independently, so one entry can be visible without the other.
|
||||
const canReviewBotVerification = useCan(permissionBotVerificationReview);
|
||||
const canManageServer = useCan(permissionServerManage);
|
||||
// Third-party verification is additionally hidden by default (not fully
|
||||
// finished) regardless of what the session was granted -- see permissions.tsx.
|
||||
const thirdPartyVerificationHidden = useThirdPartyVerificationHidden();
|
||||
|
|
@ -135,7 +137,13 @@ export function Shell({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canManageServer && (
|
||||
<NavLink icon={<Settings size={16} />} href="/server-settings" route={route} navigate={navigate}>{"Server Settings"}</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
<div className="sidebar-status">
|
||||
<span className="sidebar-label">{"Version: O7"}</span>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="workspace">
|
||||
<header className="topbar">
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { MessageDetailPage } from "./MessageDetailPage";
|
|||
import { MessagesPage } from "./MessagesPage";
|
||||
import { StickerSetsPage } from "./StickerSetsPage";
|
||||
import { GifCatalogPage } from "./GifCatalogPage";
|
||||
import { ServerSettingsPage } from "./ServerSettingsPage";
|
||||
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
|
||||
import { ModerationCasesPage } from "./ModerationCasesPage";
|
||||
import { StoragePage } from "./StoragePage";
|
||||
|
|
@ -27,6 +28,7 @@ import {
|
|||
PermissionGate,
|
||||
ThirdPartyVerificationHiddenGate,
|
||||
permissionBotVerificationReview,
|
||||
permissionServerManage,
|
||||
permissionVerificationReview
|
||||
} from "../permissions";
|
||||
|
||||
|
|
@ -124,6 +126,13 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (route.path === "/gif-catalog") {
|
||||
return <GifCatalogPage />;
|
||||
}
|
||||
if (route.path === "/server-settings") {
|
||||
return (
|
||||
<PermissionGate permission={permissionServerManage}>
|
||||
<ServerSettingsPage />
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
|
||||
return (
|
||||
<MessageDetailPage
|
||||
|
|
|
|||
443
cmd/telesrv-admin/web/src/pages/ServerSettingsPage.tsx
Normal file
443
cmd/telesrv-admin/web/src/pages/ServerSettingsPage.tsx
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
import { ChevronDown, ImageOff, ImagePlus, Loader2, RefreshCw, Trash2, Upload, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, LoadingSurface, Metric, PageFrame, SectionHead } from "../components/ui";
|
||||
import type { EnvGroup, ServerIdentity, ServerStatus } from "../types";
|
||||
|
||||
// Server Settings: the web-panel equivalent of tui-panel/server-panel.py's
|
||||
// menu -- admin-editable server name/description/icon (served to clients
|
||||
// over /owpengram/server-info + /owpengram/server-icon), .env editing, and
|
||||
// Restart/Update. See cmd/telesrv-admin/serversettings.go for the backend.
|
||||
export function ServerSettingsPage() {
|
||||
return (
|
||||
<PageFrame title={"Server Settings"} eyebrow={"Identity, .env, and process control -- mirrors the TUI panel"}>
|
||||
<div className="stacked-sections">
|
||||
<IdentitySection />
|
||||
<ServerControlSection />
|
||||
<EnvSection />
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Identity ---------------------------------------------------------
|
||||
|
||||
function IdentitySection() {
|
||||
const [identity, setIdentity] = useState<ServerIdentity | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [iconModalOpen, setIconModalOpen] = useState(false);
|
||||
const [iconBust, setIconBust] = useState(0);
|
||||
const [iconFailed, setIconFailed] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
const info = await api.serverIdentity();
|
||||
setIdentity(info);
|
||||
setName(info.name);
|
||||
setDescription(info.description);
|
||||
setIconFailed(false);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
return (
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Server identity"} text={"Shown to clients auto-fetching this server's key -- takes effect immediately, no restart needed."} />
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{!identity ? (
|
||||
<LoadingSurface label={"Loading identity..."} />
|
||||
) : (
|
||||
<div className="card-body">
|
||||
<div className="entity-head-main">
|
||||
<div className="avatar-edit-slot">
|
||||
{identity.icon_ext && !iconFailed ? (
|
||||
<img
|
||||
className="avatar-photo-img"
|
||||
src={api.serverIconURL() + `&b=${iconBust}`}
|
||||
alt=""
|
||||
style={{ width: 56, height: 56 }}
|
||||
onError={() => setIconFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="avatar-fallback server-icon-fallback" style={{ width: 56, height: 56 }}>
|
||||
<ImageOff size={20} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="icon-btn avatar-edit-btn"
|
||||
type="button"
|
||||
aria-label={"Change server icon"}
|
||||
title={"Change server icon"}
|
||||
onClick={() => setIconModalOpen(true)}
|
||||
>
|
||||
<ImagePlus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="server-identity-fields">
|
||||
<label className="form-field"><span>{"Name"}</span><input value={name} maxLength={128} onChange={(event) => setName(event.target.value)} /></label>
|
||||
<label className="form-field"><span>{"Description"}</span><input value={description} maxLength={512} onChange={(event) => setDescription(event.target.value)} /></label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gift-table-actions">
|
||||
<ActionButton
|
||||
tone="neutral"
|
||||
label={"Save identity"}
|
||||
path="/api/actions/set-server-identity"
|
||||
payload={() => ({ name, description })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{iconModalOpen && (
|
||||
<ServerIconModal
|
||||
hasIcon={!!identity?.icon_ext}
|
||||
onClose={() => setIconModalOpen(false)}
|
||||
onDone={() => { setIconBust((n) => n + 1); setIconFailed(false); void load(); }}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerIconModal({ hasIcon, onClose, onDone }: { hasIcon: boolean; onClose: () => void; onDone: () => void }) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewURL, setPreviewURL] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setPreviewURL("");
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewURL(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
async function submitUpload() {
|
||||
if (!file) {
|
||||
setError("Choose an image file first.");
|
||||
return;
|
||||
}
|
||||
if (!reason.trim()) {
|
||||
setError("Please enter an operation reason");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true }));
|
||||
form.set("file", file, file.name);
|
||||
const result = await api.uploadServerIcon(form);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
onDone();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRemove() {
|
||||
if (!reason.trim()) {
|
||||
setError("Please enter an operation reason");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.action("/api/actions/remove-server-icon", { command_id: "", reason: reason.trim(), confirm: true });
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
onDone();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={"Change server icon"}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div className="eyebrow">{"Server identity"}</div>
|
||||
<h2>{"Change server icon"}</h2>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
|
||||
</div>
|
||||
<div className="command-body">
|
||||
<label className={`gift-file-picker ${file ? "has-file" : ""}`}>
|
||||
<input type="file" accept=".png,.jpg,.jpeg,.webp,.gif,image/png,image/jpeg,image/webp,image/gif" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||
{previewURL ? <img className="gift-file-icon" src={previewURL} alt="" style={{ objectFit: "cover" }} /> : <ImagePlus size={22} />}
|
||||
<span className="gift-file-copy"><span className="gift-field-label">{"New icon"}</span><strong>{file ? file.name : "Choose a PNG, JPEG, WebP, or GIF image"}</strong></span>
|
||||
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
|
||||
</label>
|
||||
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why the server icon is changing"} onChange={(event) => setReason(event.target.value)} /></label>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
|
||||
{hasIcon && (
|
||||
<button className="btn danger icon-text" type="button" onClick={() => void submitRemove()} disabled={busy}>
|
||||
{busy ? <Loader2 className="spin" size={15} /> : <Trash2 size={15} />}
|
||||
{"Remove icon"}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn primary icon-text" type="button" onClick={() => void submitUpload()} disabled={busy}>
|
||||
{busy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
|
||||
{"Upload icon"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
// --- Server control (status / restart / update) --------------------------
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// useAdminRestartWatcher backs the "the admin panel is bouncing itself"
|
||||
// flow after Restart/Update: those actions ask owpengram-server to relaunch
|
||||
// the admin process once *it* is back up (see internal/procctl's
|
||||
// PendingAdminRestart), so from the browser's side this just means polling
|
||||
// /api/session until a *different* boot_id answers -- proof a genuinely new
|
||||
// process is up, not just that the old one is still slow -- then reloading
|
||||
// the page. A timeout surfaces as a message with a manual reload button
|
||||
// instead of spinning forever if something went wrong server-side.
|
||||
function useAdminRestartWatcher() {
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
const cancelled = useRef(false);
|
||||
|
||||
const watch = useCallback(async (timeoutMs = 150000) => {
|
||||
cancelled.current = false;
|
||||
setTimedOut(false);
|
||||
setWaiting(true);
|
||||
let baseline = "";
|
||||
try {
|
||||
baseline = (await api.session()).boot_id ?? "";
|
||||
} catch {
|
||||
// Falls through to polling anyway -- worst case it reloads on the
|
||||
// first boot_id it manages to read, which is still correct.
|
||||
}
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (cancelled.current) return;
|
||||
await sleep(1500);
|
||||
try {
|
||||
const session = await api.session();
|
||||
if (session.boot_id && session.boot_id !== baseline) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Expected mid-bounce: the old process is dying or the new one
|
||||
// hasn't opened its listener yet. Keep polling.
|
||||
}
|
||||
}
|
||||
setWaiting(false);
|
||||
setTimedOut(true);
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
cancelled.current = true;
|
||||
setWaiting(false);
|
||||
setTimedOut(false);
|
||||
}, []);
|
||||
|
||||
return { waiting, timedOut, watch, dismiss };
|
||||
}
|
||||
|
||||
function RestartOverlay({ label, timedOut, onDismiss }: { label: string; timedOut: boolean; onDismiss: () => void }) {
|
||||
return createPortal(
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="modal command-modal restart-overlay" role="dialog" aria-modal="true" aria-label={label}>
|
||||
{timedOut ? (
|
||||
<div className="command-body restart-overlay-body">
|
||||
<Alert>{"The admin panel did not come back within the expected time. It may still be building/restarting -- reload manually in a bit, or check the server logs."}</Alert>
|
||||
<div className="gift-table-actions restart-overlay-actions">
|
||||
<button className="btn" type="button" onClick={onDismiss}>{"Dismiss"}</button>
|
||||
<button className="btn primary" type="button" onClick={() => window.location.reload()}>{"Reload now"}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="command-body restart-overlay-body">
|
||||
<Loader2 className="spin" size={28} />
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function ServerControlSection() {
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [overlayLabel, setOverlayLabel] = useState("");
|
||||
const restartWatcher = useAdminRestartWatcher();
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
setStatus(await api.serverStatus());
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
return (
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={"Process control"}
|
||||
text={"Restart rebuilds and relaunches owpengram-server. Update also runs git pull first and rebuilds both binaries. Either way the admin panel bounces onto its (possibly rebuilt) binary too, a few seconds after the server comes back -- this page reloads itself once that's done."}
|
||||
action={<button className="btn icon-text" type="button" onClick={() => void load()}><RefreshCw size={15} /> {"Refresh status"}</button>}
|
||||
/>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="card-body">
|
||||
{status && (
|
||||
<div className="metric-row">
|
||||
<Metric label={"owpengram-server"} value={status.ServerAlive ? `running (pid ${status.ServerPID})` : "stopped"} tone={status.ServerAlive ? "good" : "danger"} />
|
||||
<Metric label={"admin panel"} value={status.AdminAlive ? `running (pid ${status.AdminPID})` : "stopped"} tone={status.AdminAlive ? "good" : "danger"} />
|
||||
</div>
|
||||
)}
|
||||
<div className="gift-table-actions">
|
||||
<ActionButton
|
||||
tone="warn"
|
||||
label={"Restart server"}
|
||||
path="/api/actions/restart-server"
|
||||
payload={() => ({})}
|
||||
onDone={() => {
|
||||
setOverlayLabel("Restarting owpengram-server and the admin panel...");
|
||||
void restartWatcher.watch();
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
tone="danger"
|
||||
label={"Update (git pull + rebuild + restart)"}
|
||||
path="/api/actions/update-server"
|
||||
payload={() => ({})}
|
||||
onDone={() => {
|
||||
setOverlayLabel("Pulling, rebuilding, and restarting owpengram-server and the admin panel...");
|
||||
void restartWatcher.watch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{restartWatcher.waiting && <RestartOverlay label={overlayLabel} timedOut={false} onDismiss={restartWatcher.dismiss} />}
|
||||
{restartWatcher.timedOut && <RestartOverlay label={overlayLabel} timedOut={true} onDismiss={restartWatcher.dismiss} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- .env editor -----------------------------------------------------
|
||||
|
||||
function EnvSection() {
|
||||
const [groups, setGroups] = useState<EnvGroup[]>([]);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [open, setOpen] = useState<Record<string, boolean>>({});
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
const g = await api.serverEnv();
|
||||
setGroups(g);
|
||||
const next: Record<string, string> = {};
|
||||
for (const group of g) {
|
||||
for (const field of group.fields) {
|
||||
next[field.key] = field.value;
|
||||
}
|
||||
}
|
||||
setValues(next);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const fieldCount = useMemo(() => groups.reduce((sum, g) => sum + g.fields.length, 0), [groups]);
|
||||
|
||||
return (
|
||||
<section className="section-block">
|
||||
<SectionHead title={"Environment (.env)"} text={`${fieldCount} setting(s) across ${groups.length} group(s). Changes take effect on the next Restart/Update.`} />
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="env-groups">
|
||||
{groups.map((group) => {
|
||||
const isOpen = !!open[group.title];
|
||||
return (
|
||||
<div key={group.title} className={`env-group ${isOpen ? "open" : ""}`}>
|
||||
<button
|
||||
className="env-group-toggle"
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => setOpen((prev) => ({ ...prev, [group.title]: !prev[group.title] }))}
|
||||
>
|
||||
<span className="env-group-toggle-text">
|
||||
<span className="env-group-toggle-title">{group.title}</span>
|
||||
<span className="env-group-toggle-count">{`${group.fields.length} field${group.fields.length === 1 ? "" : "s"}`}</span>
|
||||
</span>
|
||||
<ChevronDown size={16} className="env-group-chevron" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="env-group-body">
|
||||
{group.description && <p className="env-group-desc">{group.description}</p>}
|
||||
{group.fields.map((field) => (
|
||||
<label key={field.key} className="form-field env-field">
|
||||
<span className="mono">{field.key}</span>
|
||||
{field.description && <span className="env-field-desc">{field.description}</span>}
|
||||
<input
|
||||
type={field.sensitive ? "password" : "text"}
|
||||
value={values[field.key] ?? ""}
|
||||
placeholder={field.default_value}
|
||||
onChange={(event) => setValues((prev) => ({ ...prev, [field.key]: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="gift-table-actions env-save-row">
|
||||
<ActionButton
|
||||
tone="warn"
|
||||
label={"Save .env changes"}
|
||||
path="/api/actions/update-server-env"
|
||||
payload={() => ({ values })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,9 @@ export const permissionVerificationRevoke = "verification.revoke";
|
|||
// verifier roster, the icon catalogue and taking a granted mark away.
|
||||
export const permissionBotVerificationReview = "botverification.review";
|
||||
export const permissionBotVerificationManage = "botverification.manage";
|
||||
// Server Settings: identity, .env, restart/update. One right, not
|
||||
// review/manage -- see the constant's doc comment in security.go.
|
||||
export const permissionServerManage = "server.manage";
|
||||
|
||||
// GET /api/session is read once at boot; the panel keeps the answer here so a
|
||||
// section the session may not use is hidden instead of rendered into a 403. This
|
||||
|
|
|
|||
|
|
@ -31,5 +31,6 @@ export function routeTitle(pathname: string): string {
|
|||
if (pathname.startsWith("/messages")) return "Message Audit";
|
||||
if (pathname.startsWith("/stickers")) return "Stickers";
|
||||
if (pathname.startsWith("/gif-catalog")) return "GIFs";
|
||||
if (pathname.startsWith("/server-settings")) return "Server Settings";
|
||||
return "Operations Console";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -928,3 +928,134 @@
|
|||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* --- Server Settings ---------------------------------------------------- */
|
||||
|
||||
/* Bare .card-body (outside .action-groups, which scopes its own flex rules)
|
||||
just needs a sensible vertical rhythm below a SectionHead -- used as-is by
|
||||
IdentitySection/ServerControlSection on the Server Settings page. */
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.server-identity-fields {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.server-icon-fallback {
|
||||
color: var(--muted);
|
||||
background: var(--panel-subtle);
|
||||
border: 1px dashed var(--line-strong);
|
||||
}
|
||||
|
||||
.env-groups {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.env-group {
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.env-group-toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
background: var(--panel-subtle);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
|
||||
.env-group-toggle:hover {
|
||||
background: var(--brand-tint);
|
||||
}
|
||||
|
||||
.env-group-toggle-text {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.env-group-toggle-title {
|
||||
color: var(--heading);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.env-group-toggle-count {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.env-group-chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
.env-group.open .env-group-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.env-group-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.env-group-desc {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.env-field .env-field-desc {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.env-save-row {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.restart-overlay {
|
||||
width: min(440px, 100%);
|
||||
}
|
||||
|
||||
.restart-overlay-body {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.restart-overlay-body p {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.restart-overlay-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -580,6 +580,10 @@ export type AdminSession = {
|
|||
// 404, so this is a UI convenience on top of a real enforcement, not the
|
||||
// enforcement itself.
|
||||
hide_third_party_verification?: boolean;
|
||||
// Random per admin-process-start value -- see the Go handler's doc
|
||||
// comment. Used by Server Settings' Restart/Update flow to detect a
|
||||
// genuinely new admin process after asking it to bounce.
|
||||
boot_id?: string;
|
||||
};
|
||||
|
||||
export type AdminLoginResult = AdminSession & {
|
||||
|
|
@ -829,3 +833,31 @@ export type GroupMessageListResponse = {
|
|||
limit: number;
|
||||
rows: GroupMessageRow[];
|
||||
};
|
||||
|
||||
export type ServerIdentity = {
|
||||
name: string;
|
||||
description: string;
|
||||
icon_ext?: string;
|
||||
};
|
||||
|
||||
export type EnvField = {
|
||||
key: string;
|
||||
default_value: string;
|
||||
description: string;
|
||||
enabled_by_default: boolean;
|
||||
sensitive: boolean;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type EnvGroup = {
|
||||
title: string;
|
||||
description: string;
|
||||
fields: EnvField[];
|
||||
};
|
||||
|
||||
export type ServerStatus = {
|
||||
ServerPID: number;
|
||||
ServerAlive: boolean;
|
||||
AdminPID: number;
|
||||
AdminAlive: boolean;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue