Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a89ad8143a | ||
|
|
fb5b46a548 | ||
|
|
f6092cd178 | ||
|
|
3cba5df5c7 | ||
|
|
b2566d45f8 | ||
|
|
8b88377d4f | ||
|
|
f6af57cf58 | ||
|
|
160918a93e | ||
|
|
f622baadbd | ||
|
|
78284ed816 | ||
|
|
d96b90b5bf | ||
|
|
c0eada9bcc | ||
|
|
378727ac57 | ||
|
|
8b43e8b2ea | ||
|
|
786f364743 | ||
|
|
a0e2df615c | ||
|
|
4eb9abff49 | ||
|
|
15f1ee38db |
@@ -0,0 +1,77 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same ref makes an in-flight build obsolete.
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
# Let both platforms finish, so a failure on one still reports the other.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# The .sh scripts are run through `bash` because a checkout made on
|
||||
# Windows does not carry the executable bit, so ./build-*.sh would
|
||||
# come back as "Permission denied" on the Linux runner.
|
||||
- os: ubuntu-latest
|
||||
build_linux: bash build-linux-x86_64.sh
|
||||
- os: windows-latest
|
||||
build_windows: ./build-win-x86_64.bat
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
# --frontend builds the Vue app inside the script. The second call omits
|
||||
# it: the frontend is platform independent and dist/ is already built.
|
||||
- name: Build Linux (amd64)
|
||||
run: ${{ matrix.build_linux }} --frontend
|
||||
|
||||
- name: Build Windows (amd64)
|
||||
run: ${{ matrix.build_windows }}
|
||||
|
||||
- name: Upload binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nukumizu-binaries-${{ matrix.os }}
|
||||
path: |
|
||||
nukumizu-linux-amd64
|
||||
nukumizu-windows-amd64.exe
|
||||
if-no-files-found: error
|
||||
|
||||
# The binaries read frontend/dist at runtime (os.DirFS in web/embed.go),
|
||||
# they do not embed it. Uploaded once — the frontend is platform
|
||||
# independent, so both matrix legs produce the same files.
|
||||
- name: Upload frontend bundle
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nukumizu-frontend
|
||||
path: frontend/dist
|
||||
if-no-files-found: error
|
||||
@@ -15,6 +15,7 @@ Nukumizu connects to a Komari Dashboard instance, keeps an in-memory view of eve
|
||||
- **Customizable message templates** — every bot/notification message is rendered from a template in `config.json`.
|
||||
- **Storage** — SQLite (pure-Go driver) for `user.db` and `log.db`; safe on network shares (WAL disabled).
|
||||
- **Dashboard API** — token-authenticated REST API plus a live log-streaming WebSocket.
|
||||
- **Web console** — a Vue 3 admin UI for browsing nodes, editing `config.json`, managing bot trust, and tailing logs. The Go server serves the built bundle from `frontend/dist`.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -37,7 +38,8 @@ nukumizu-backend/
|
||||
│ └── variables.go # Software build metadata (name/version/developer)
|
||||
├── handler/
|
||||
│ ├── user.go # /api/user/login, /api/user/register
|
||||
│ ├── server.go # /api/server/list, getStatus, exec
|
||||
│ ├── server.go # /api/server/list, getInfo, getStatus, exec
|
||||
│ ├── settings.go # /api/settings/get, set
|
||||
│ └── health.go # /health
|
||||
├── database/
|
||||
│ └── user.go # user.db (SQLite) user store
|
||||
@@ -49,36 +51,51 @@ nukumizu-backend/
|
||||
│ ├── database.go # log.db (SQLite, one table per run)
|
||||
│ ├── logBroadcaster.go # Fan-out to WebSocket clients
|
||||
│ └── logSocketHandler.go # /api/system/getLogs WebSocket handler
|
||||
└── internal/
|
||||
├── komari/
|
||||
│ ├── client.go # Komari HTTP/JSON-RPC client (login, nodes, task exec/poll)
|
||||
│ └── ws.go # Komari status WebSocket (poll + reconnect)
|
||||
├── node/
|
||||
│ └── tracker.go # Thread-safe server state, status-change detection
|
||||
├── netproxy/
|
||||
│ └── netproxy.go # Unified network proxy for controllers
|
||||
├── template/
|
||||
│ └── template.go # Message template renderer ({{ variables }})
|
||||
└── controller/
|
||||
├── controller.go # Manager, Controller / BotController interfaces
|
||||
├── trigger.go # Command parsing, authorization, routing
|
||||
├── processor.go # Command handlers
|
||||
├── utils.go
|
||||
└── pipes/
|
||||
├── email.go # Email notification pipe
|
||||
├── ntfy.go # ntfy notification pipe
|
||||
├── webhook.go # Webhook notification pipe
|
||||
├── qq_napcat/
|
||||
│ ├── qq.go # QQ (NapCat / OneBot 11) bot controller
|
||||
│ └── napcat.go # NapCat WebSocket + HTTP API client
|
||||
└── telegram/
|
||||
├── telegram.go # Telegram bot controller (go-telegram/bot, long polling)
|
||||
└── send.go # Message sending / splitting (Telegram Markdown)
|
||||
├── web/
|
||||
│ ├── embed.go # Locates the built console (frontend/dist)
|
||||
│ └── handler.go # Static file serving + SPA fallback
|
||||
├── internal/
|
||||
│ ├── komari/
|
||||
│ │ ├── client.go # Komari HTTP/JSON-RPC client (login, nodes, task exec/poll)
|
||||
│ │ └── ws.go # Komari status WebSocket (poll + reconnect)
|
||||
│ ├── node/
|
||||
│ │ └── tracker.go # Thread-safe server state, status-change detection
|
||||
│ ├── netproxy/
|
||||
│ │ └── netproxy.go # Unified network proxy for controllers
|
||||
│ ├── template/
|
||||
│ │ └── template.go # Message template renderer ({{ variables }})
|
||||
│ └── controller/
|
||||
│ ├── controller.go # Manager, Controller / BotController interfaces
|
||||
│ ├── trigger.go # Command parsing, authorization, routing
|
||||
│ ├── processor.go # Command handlers
|
||||
│ ├── utils.go
|
||||
│ └── pipes/
|
||||
│ ├── email.go # Email notification pipe
|
||||
│ ├── ntfy.go # ntfy notification pipe
|
||||
│ ├── webhook.go # Webhook notification pipe
|
||||
│ ├── qq_napcat/
|
||||
│ │ ├── qq.go # QQ (NapCat / OneBot 11) bot controller
|
||||
│ │ └── napcat.go # NapCat WebSocket + HTTP API client
|
||||
│ └── telegram/
|
||||
│ ├── telegram.go # Telegram bot controller (go-telegram/bot, long polling)
|
||||
│ └── send.go # Message sending / splitting (Telegram Markdown)
|
||||
└── frontend/ # Vue 3 admin console (Vite)
|
||||
├── index.html
|
||||
├── vite.config.js # Dev server; proxies /api to the backend
|
||||
└── src/
|
||||
├── main.js # Bootstrap: theme + runtime flags
|
||||
├── App.vue # Root component + toast host
|
||||
├── api/index.js # Wrappers around the REST endpoints
|
||||
├── router/index.js # Routes and the login guard
|
||||
├── utils/ # http/auth/theme/toast/format/runtime helpers
|
||||
├── components/ # Modal, Toggle, editors, top bar, side bar
|
||||
└── views/ # Login, Overview, Trusted, Settings, Logs
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go **1.25** or newer
|
||||
- [Node.js](https://nodejs.org) **22** or newer — only needed to build the web console; a prebuilt binary does not require it
|
||||
- A running [Komari](https://www.komari.wiki) Dashboard instance reachable from this host
|
||||
- For QQ: a [NapCat](https://napneko.github.io/) instance exposing an OneBot 11 WebSocket + HTTP endpoint
|
||||
- For Telegram: a bot token from [@BotFather](https://t.me/BotFather)
|
||||
@@ -256,7 +273,7 @@ Admins and trusted groups are defined **per bot channel** and map a member ID to
|
||||
|
||||
## API
|
||||
|
||||
All responses follow the envelope `{"success": true|false, "message": "...", ...data}`. `message` is empty on success unless noted.
|
||||
Success responses follow the envelope `{"success": true, "message": "...", "data": {...}}`, with the payload nested under a single `data` key. Error responses use `{"success": false, "message": "..."}`. `message` may be omitted on success when there is nothing to report.
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -273,12 +290,15 @@ Tokens idle for more than 1 hour are expired (cleaned every 10 minutes); any aut
|
||||
|
||||
| Endpoint | Method | Permission | Description |
|
||||
|---|---|---|---|
|
||||
| `/api/user/login` | POST | None | Log in. Body `{username, password}`. Returns `{token, userID, username, level, registerDate}`. |
|
||||
| `/api/user/register` | POST | None | Register the first user. Body `{username, password}`. Only allowed while no user exists; otherwise `403`. Returns `{token, userID, username, level}`. |
|
||||
| `/api/user/login` | POST | None | Log in. Body `{username, password}`. Returns `data: {token, userID, username, level, registerDate}`. |
|
||||
| `/api/user/register` | POST | None | Register the first user. Body `{username, password}`. Only allowed while no user exists; otherwise `403`. Returns `data: {token, userID, username, level}`. |
|
||||
| `/api/server/list` | GET | bot / admin | List all monitored servers. |
|
||||
| `/api/server/getStatus` | GET | bot / admin | Recent live status for a server. Query `?uuid=<uuid>`. Returns `{uuid, report}` or `404`. |
|
||||
| `/api/server/exec` | POST | bot / admin | Execute a command. Body `{uuid: [<uuid>...], command}`. Dispatches a Komari task and polls until completion (or timeout). Returns `{taskID, results}`. |
|
||||
| `/health` | GET | None | Health check. Returns `{status, database}`. |
|
||||
| `/api/server/getInfo` | GET | admin | Static server info (mirrors the Bot's `/info`). Query `?uuid=<uuid>` (or `all`). Returns `data: {<uuid>: {uuid, name, info}}` — one entry per requested server. `404` for an unknown single uuid. |
|
||||
| `/api/server/getStatus` | GET | admin | Live server status (mirrors the Bot's `/status`). Query `?uuid=<uuid>` (or `all`). Returns `data: {<uuid>: {uuid, name, online, report}}`; `report` is `null` when the node has not reported yet. `404` for an unknown single uuid. |
|
||||
| `/api/server/exec` | POST | bot / admin | Execute a command. Body `{uuid: [<uuid>...], command}`. Dispatches a Komari task and polls until completion (or timeout). Returns `data: {taskID, results}`. |
|
||||
| `/api/settings/get` | GET | admin | `?type=global\|bot_user_config\|bot_node_config` | Returns `data: {config}`, where `config` is the selected config file's content (same layout as the JSON file). |
|
||||
| `/api/settings/set` | POST | admin | `?type=<same types>` + JSON body of partial updates, e.g. `{"system":{"debugMode":true}}` | Deep-merges the body into the selected config file, persists it, and reloads it in memory. Only the given keys change; arrays replace. |
|
||||
| `/health` | GET | None | Health check. Returns `data: {status, database}`. |
|
||||
| `/api/system/getLogs` | WebSocket | None | Streams logs. Sends the last 100 buffered entries, then live `{level, content, timestamp}` events. |
|
||||
|
||||
Middleware applied to the whole server:
|
||||
@@ -322,16 +342,33 @@ QQ and Telegram are *interactive* channels. Email, ntfy, and webhook are **statu
|
||||
|
||||
## Building
|
||||
|
||||
Requires Go 1.25+. Repo includes helper scripts that bake the current git commit and build time into the binary via `-ldflags`:
|
||||
Requires Go 1.25+ and — to build the web console — Node.js 22+.
|
||||
|
||||
Helper scripts in the repo root bake the current git commit and build time into the binary via `-ldflags`. The name states the **target** platform, and each target has a Windows (`.bat`) and a Linux/macOS (`.sh`) flavor: run the flavor for the host you are building on, since every script cross-compiles to its target.
|
||||
|
||||
| Script | Output |
|
||||
|---|---|
|
||||
| `build-linux-x86_64.sh` / `build-linux-x86_64.bat` | `nukumizu-linux-amd64` |
|
||||
| `build-win-x86_64.sh` / `build-win-x86_64.bat` | `nukumizu-windows-amd64.exe` |
|
||||
|
||||
```bash
|
||||
# Windows (cross-compile to Linux amd64)
|
||||
build-linux-x86_64.bat
|
||||
# Linux / macOS
|
||||
./build-linux-x86_64.sh
|
||||
./build-win-x86_64.sh
|
||||
|
||||
# Windows amd64
|
||||
# Windows
|
||||
build-linux-x86_64.bat
|
||||
build-win-x86_64.bat
|
||||
```
|
||||
|
||||
Pass `--frontend` to build the Vue console first (`npm ci` + `npm run build` inside `frontend/`); without it only the backend is compiled:
|
||||
|
||||
```bash
|
||||
./build-linux-x86_64.sh --frontend
|
||||
```
|
||||
|
||||
The console is **not embedded in the binary** — `web/` reads `frontend/dist` from disk at runtime. A binary built without `--frontend` still starts and serves the API, but `/` answers `500 index.html not found` until a built `frontend/dist` sits in the working directory. Build it once with `--frontend`, then re-run any of the four scripts without the flag.
|
||||
|
||||
Equivalent manual builds:
|
||||
|
||||
```bash
|
||||
@@ -348,6 +385,8 @@ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \
|
||||
|
||||
`main.CommitHash` and `main.BuildTime` are surfaced in logs and in the `BOT_STARTED` message.
|
||||
|
||||
CI (`.github/workflows/build.yml`) runs both flavors — `.sh` on `ubuntu-latest`, `.bat` on `windows-latest` — and uploads the two binaries plus the frontend bundle as artifacts.
|
||||
|
||||
## Running
|
||||
|
||||
Both `config.json` and `bot_user_config.json` must exist in the working directory (or be passed explicitly):
|
||||
@@ -362,6 +401,28 @@ run.bat
|
||||
|
||||
On startup the program logs in to Komari, loads node state, connects the status WebSocket, then starts each enabled controller and the HTTP server on `listenAddr:listenPort`. Press `Ctrl+C` for a graceful shutdown.
|
||||
|
||||
### Frontend development
|
||||
|
||||
`run.bat` only runs the Go backend — it does not build the console. While working on the frontend, run the two sides separately:
|
||||
|
||||
```bash
|
||||
# Terminal 1 — backend (API + WebSocket) on :8080
|
||||
run.bat
|
||||
|
||||
# Terminal 2 — Vite dev server with hot reload on :5173
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:5173. The dev server proxies `/api` — including the log WebSocket — to `http://127.0.0.1:8080`; point it elsewhere with `NUKUMIZU_API` if the backend listens on another address:
|
||||
|
||||
```bash
|
||||
NUKUMIZU_API=http://192.168.1.10:8080 npm run dev
|
||||
```
|
||||
|
||||
For a production build the Go server serves `frontend/dist` itself, on the normal listen address — see [Building](#building).
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE).
|
||||
|
||||
+54
-33
@@ -1,33 +1,54 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo Building for Linux (amd64)...
|
||||
|
||||
:: Get git commit hash (shortened to 7 characters, can also use full)
|
||||
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
|
||||
|
||||
:: Get UTC time
|
||||
for /f %%i in ('powershell -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'"') do set BUILD_DATE=%%i
|
||||
|
||||
:: Set environment variables for Linux build
|
||||
set GOOS=linux
|
||||
set GOARCH=amd64
|
||||
set CGO_ENABLED=0
|
||||
|
||||
:: Build -ldflags
|
||||
set LDFLAGS=-X main.BuildTime=%BUILD_DATE% -X main.CommitHash=%COMMIT%
|
||||
|
||||
:: Set output file name
|
||||
set OUTPUT=nukumizu-linux-amd64
|
||||
|
||||
echo Commit: %COMMIT%
|
||||
echo BuildDate: %BUILD_DATE%
|
||||
echo Output: %OUTPUT%
|
||||
|
||||
go build -ldflags "%LDFLAGS%" -o "%OUTPUT%" .
|
||||
|
||||
if %errorlevel% equ 0 (
|
||||
echo Build succeeded: %OUTPUT%
|
||||
) else (
|
||||
echo Build failed.
|
||||
)
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
:: Build from the repository root, however the script was invoked.
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: Optional first argument: --frontend also builds the Vue frontend, which web/
|
||||
:: serves at runtime from frontend/dist. Omitted, only the backend is compiled.
|
||||
set BUILD_FRONTEND=0
|
||||
if /i "%~1"=="--frontend" set BUILD_FRONTEND=1
|
||||
|
||||
if not "%BUILD_FRONTEND%"=="1" goto :backend
|
||||
|
||||
echo Building frontend...
|
||||
cd frontend
|
||||
call npm ci
|
||||
if errorlevel 1 goto :fail
|
||||
call npm run build
|
||||
if errorlevel 1 goto :fail
|
||||
cd ..
|
||||
|
||||
:backend
|
||||
echo Building for Linux (amd64)...
|
||||
|
||||
:: Get git commit hash (shortened to 7 characters, can also use full)
|
||||
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
|
||||
|
||||
:: Get UTC time
|
||||
for /f %%i in ('powershell -Command "(Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')"') do set BUILD_DATE=%%i
|
||||
|
||||
:: Set environment variables for Linux build
|
||||
set GOOS=linux
|
||||
set GOARCH=amd64
|
||||
set CGO_ENABLED=0
|
||||
|
||||
:: Build -ldflags
|
||||
set LDFLAGS=-X main.BuildTime=%BUILD_DATE% -X main.CommitHash=%COMMIT%
|
||||
|
||||
:: Set output file name
|
||||
set OUTPUT=nukumizu-linux-amd64
|
||||
|
||||
echo Commit: %COMMIT%
|
||||
echo BuildDate: %BUILD_DATE%
|
||||
echo Output: %OUTPUT%
|
||||
|
||||
go build -ldflags "%LDFLAGS%" -o "%OUTPUT%" .
|
||||
if errorlevel 1 goto :fail
|
||||
|
||||
echo Build succeeded: %OUTPUT%
|
||||
exit /b 0
|
||||
|
||||
:fail
|
||||
echo Build failed.
|
||||
exit /b 1
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Builds the Linux (amd64) binary.
|
||||
# ./build-linux-x86_64.sh backend only
|
||||
# ./build-linux-x86_64.sh --frontend also build the Vue frontend
|
||||
#
|
||||
# The frontend is needed at runtime: web/ serves it from frontend/dist.
|
||||
set -euo pipefail
|
||||
|
||||
# Build from the repository root, however the script was invoked.
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BUILD_FRONTEND=0
|
||||
if [ "${1:-}" = "--frontend" ]; then
|
||||
BUILD_FRONTEND=1
|
||||
fi
|
||||
|
||||
if [ "$BUILD_FRONTEND" = "1" ]; then
|
||||
echo "Building frontend..."
|
||||
(
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
)
|
||||
fi
|
||||
|
||||
echo "Building for Linux (amd64)..."
|
||||
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=amd64
|
||||
export CGO_ENABLED=0
|
||||
|
||||
OUTPUT=nukumizu-linux-amd64
|
||||
|
||||
echo "Commit: $COMMIT"
|
||||
echo "BuildDate: $BUILD_DATE"
|
||||
echo "Output: $OUTPUT"
|
||||
|
||||
go build -ldflags "-X main.BuildTime=$BUILD_DATE -X main.CommitHash=$COMMIT" -o "$OUTPUT" .
|
||||
|
||||
echo "Build succeeded: $OUTPUT"
|
||||
+54
-33
@@ -1,33 +1,54 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo Building for Windows (amd64)...
|
||||
|
||||
:: Get git commit hash (shortened to 7 characters, can also use full)
|
||||
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
|
||||
|
||||
:: Get UTC time
|
||||
for /f %%i in ('powershell -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'"') do set BUILD_DATE=%%i
|
||||
|
||||
:: Set environment variables for Windows build
|
||||
set GOOS=windows
|
||||
set GOARCH=amd64
|
||||
set CGO_ENABLED=0
|
||||
|
||||
:: Build -ldflags
|
||||
set LDFLAGS=-X main.BuildTime=%BUILD_DATE% -X main.CommitHash=%COMMIT%
|
||||
|
||||
:: Set output file name
|
||||
set OUTPUT=nukumizu-windows-amd64.exe
|
||||
|
||||
echo Commit: %COMMIT%
|
||||
echo BuildDate: %BUILD_DATE%
|
||||
echo Output: %OUTPUT%
|
||||
|
||||
go build -ldflags "%LDFLAGS%" -o "%OUTPUT%" .
|
||||
|
||||
if %errorlevel% equ 0 (
|
||||
echo Build succeeded: %OUTPUT%
|
||||
) else (
|
||||
echo Build failed.
|
||||
)
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
:: Build from the repository root, however the script was invoked.
|
||||
cd /d "%~dp0"
|
||||
|
||||
:: Optional first argument: --frontend also builds the Vue frontend, which web/
|
||||
:: serves at runtime from frontend/dist. Omitted, only the backend is compiled.
|
||||
set BUILD_FRONTEND=0
|
||||
if /i "%~1"=="--frontend" set BUILD_FRONTEND=1
|
||||
|
||||
if not "%BUILD_FRONTEND%"=="1" goto :backend
|
||||
|
||||
echo Building frontend...
|
||||
cd frontend
|
||||
call npm ci
|
||||
if errorlevel 1 goto :fail
|
||||
call npm run build
|
||||
if errorlevel 1 goto :fail
|
||||
cd ..
|
||||
|
||||
:backend
|
||||
echo Building for Windows (amd64)...
|
||||
|
||||
:: Get git commit hash (shortened to 7 characters, can also use full)
|
||||
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
|
||||
|
||||
:: Get UTC time
|
||||
for /f %%i in ('powershell -Command "(Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')"') do set BUILD_DATE=%%i
|
||||
|
||||
:: Set environment variables for Windows build
|
||||
set GOOS=windows
|
||||
set GOARCH=amd64
|
||||
set CGO_ENABLED=0
|
||||
|
||||
:: Build -ldflags
|
||||
set LDFLAGS=-X main.BuildTime=%BUILD_DATE% -X main.CommitHash=%COMMIT%
|
||||
|
||||
:: Set output file name
|
||||
set OUTPUT=nukumizu-windows-amd64.exe
|
||||
|
||||
echo Commit: %COMMIT%
|
||||
echo BuildDate: %BUILD_DATE%
|
||||
echo Output: %OUTPUT%
|
||||
|
||||
go build -ldflags "%LDFLAGS%" -o "%OUTPUT%" .
|
||||
if errorlevel 1 goto :fail
|
||||
|
||||
echo Build succeeded: %OUTPUT%
|
||||
exit /b 0
|
||||
|
||||
:fail
|
||||
echo Build failed.
|
||||
exit /b 1
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Builds the Windows (amd64) binary.
|
||||
# ./build-win-x86_64.sh backend only
|
||||
# ./build-win-x86_64.sh --frontend also build the Vue frontend
|
||||
#
|
||||
# The frontend is needed at runtime: web/ serves it from frontend/dist.
|
||||
set -euo pipefail
|
||||
|
||||
# Build from the repository root, however the script was invoked.
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BUILD_FRONTEND=0
|
||||
if [ "${1:-}" = "--frontend" ]; then
|
||||
BUILD_FRONTEND=1
|
||||
fi
|
||||
|
||||
if [ "$BUILD_FRONTEND" = "1" ]; then
|
||||
echo "Building frontend..."
|
||||
(
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
)
|
||||
fi
|
||||
|
||||
echo "Building for Windows (amd64)..."
|
||||
|
||||
COMMIT=$(git rev-parse --short HEAD)
|
||||
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
export GOOS=windows
|
||||
export GOARCH=amd64
|
||||
export CGO_ENABLED=0
|
||||
|
||||
OUTPUT=nukumizu-windows-amd64.exe
|
||||
|
||||
echo "Commit: $COMMIT"
|
||||
echo "BuildDate: $BUILD_DATE"
|
||||
echo "Output: $OUTPUT"
|
||||
|
||||
go build -ldflags "-X main.BuildTime=$BUILD_DATE -X main.CommitHash=$COMMIT" -o "$OUTPUT" .
|
||||
|
||||
echo "Build succeeded: $OUTPUT"
|
||||
+49
-28
@@ -5,25 +5,45 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// botNodeConfigMu guards botNodeConfig, the in-memory mirror of
|
||||
// bot_node_config.json.
|
||||
var (
|
||||
botNodeConfigMu sync.RWMutex
|
||||
botNodeConfig BotNodeMembers
|
||||
)
|
||||
// LoadBotNodeConfig reads and parses bot_node_config.json and stores it as the
|
||||
// global C_botNodeConfig singleton, mirroring LoadGlobalConfig. Unlike the
|
||||
// other config files this one is auto-generated and optional: a missing or
|
||||
// empty file yields an empty map, so every node falls back to its default
|
||||
// enableStatusNotify value (true).
|
||||
func LoadBotNodeConfig(configPath string) error {
|
||||
cfg := BotNodeMembers{}
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
C_botNodeConfig = cfg
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read bot node config file: %w", err)
|
||||
}
|
||||
if len(bytes.TrimSpace(data)) > 0 {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return fmt.Errorf("failed to parse bot node config file: %w", err)
|
||||
}
|
||||
}
|
||||
C_botNodeConfig = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// NodeStatusNotifyEnabled reports whether the node identified by uuid should
|
||||
// broadcast status-change notifications, per bot_node_config.json. The flag is
|
||||
// allow-list only: a node notifies when its entry has enableStatusNotify set to
|
||||
// true, and stays silent when the entry is absent or not enabled.
|
||||
// broadcast status-change notifications, per bot_node_config.json.
|
||||
// enableStatusNotify defaults to true: a node notifies unless its entry
|
||||
// explicitly sets the flag to false.
|
||||
func NodeStatusNotifyEnabled(uuid string) bool {
|
||||
botNodeConfigMu.RLock()
|
||||
opts, ok := botNodeConfig[uuid]
|
||||
botNodeConfigMu.RUnlock()
|
||||
return ok && opts.EnableStatusNotify
|
||||
if C_botNodeConfig == nil {
|
||||
return true
|
||||
}
|
||||
opts, ok := C_botNodeConfig[uuid]
|
||||
if !ok || opts.EnableStatusNotify == nil {
|
||||
return true
|
||||
}
|
||||
return *opts.EnableStatusNotify
|
||||
}
|
||||
|
||||
// LoadGlobalConfig reads and parses the configuration file, applies defaults,
|
||||
@@ -135,17 +155,22 @@ func LoadBotUserConfig(configPath string) (*BotUserConfig, error) {
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// SaveBotNodeConfig persists the given node UUIDs to the bot node config file
|
||||
// and refreshes the in-memory mirror read by NodeStatusNotifyEnabled. Entries
|
||||
// already present in the file are always preserved: a UUID Komari no longer
|
||||
// reports on a given fetch is kept rather than deleted, so per-node settings
|
||||
// for stale nodes survive a node-list refresh that does not include them. UUIDs
|
||||
// seen for the first time are added disabled (enableStatusNotify defaults to
|
||||
// false); enable a node's status notifications by setting the flag to true in
|
||||
// the file. The resulting JSON has its object keys emitted in sorted order by
|
||||
// encoding/json, keeping the file deterministic across writes. The path is
|
||||
// supplied by the caller (typically global.ConfigPath.BotNodeConfig).
|
||||
// SaveBotNodeConfig persists the given node UUIDs to the bot node config file,
|
||||
// which is a plain registry of the nodes Komari reports (read back at startup
|
||||
// by LoadBotNodeConfig). Entries already present are always preserved: a UUID
|
||||
// Komari no longer reports on a given fetch is kept rather than deleted, and
|
||||
// any enableStatusNotify a user set by hand is left untouched. UUIDs seen for
|
||||
// the first time are added as an empty object, i.e. no parameter is written, so
|
||||
// they inherit the enableStatusNotify default (true). The resulting JSON has
|
||||
// its object keys emitted in sorted order by encoding/json, keeping the file
|
||||
// deterministic across writes. The path is supplied by the caller (typically
|
||||
// global.ConfigPath.BotNodeConfig).
|
||||
func SaveBotNodeConfig(configPath string, uuids []string) error {
|
||||
// Serialize against admin edits of the same file via UpdateSettings so the
|
||||
// two read-modify-write paths cannot drop each other's changes.
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
|
||||
// Start from whatever is already on disk so nothing is dropped. An empty or
|
||||
// missing file is treated as an empty map.
|
||||
members := make(BotNodeMembers)
|
||||
@@ -172,10 +197,6 @@ func SaveBotNodeConfig(configPath string, uuids []string) error {
|
||||
if err := os.WriteFile(configPath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write bot node config %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
botNodeConfigMu.Lock()
|
||||
botNodeConfig = members
|
||||
botNodeConfigMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"nukumizu-backend/global"
|
||||
)
|
||||
|
||||
// Settings types accepted by the /api/settings/get and /api/settings/set
|
||||
// endpoints. Each maps 1:1 to a JSON configuration file on disk.
|
||||
const (
|
||||
SettingGlobal = "global"
|
||||
SettingBotUserConfig = "bot_user_config"
|
||||
SettingBotNodeConfig = "bot_node_config"
|
||||
)
|
||||
|
||||
// ErrUnsupportedSettingsType is returned when a settings type is not one of the
|
||||
// accepted constants above.
|
||||
var ErrUnsupportedSettingsType = errors.New("unsupported settings type")
|
||||
|
||||
// settingsLock serializes read-modify-write access to the on-disk configuration
|
||||
// files so concurrent admin edits (UpdateSettings) and the node tracker's
|
||||
// background save (SaveBotNodeConfig) cannot lose each other's updates.
|
||||
var settingsLock sync.RWMutex
|
||||
|
||||
// settingsPath resolves a settings type to its JSON configuration file path.
|
||||
func settingsPath(settingsType string) (string, error) {
|
||||
switch settingsType {
|
||||
case SettingGlobal:
|
||||
return global.ConfigPath.Global, nil
|
||||
case SettingBotUserConfig:
|
||||
return global.ConfigPath.BotUserConfig, nil
|
||||
case SettingBotNodeConfig:
|
||||
return global.ConfigPath.BotNodeConfig, nil
|
||||
default:
|
||||
return "", ErrUnsupportedSettingsType
|
||||
}
|
||||
}
|
||||
|
||||
// IsValidSettingsType reports whether the given string is one of the accepted
|
||||
// settings types.
|
||||
func IsValidSettingsType(settingsType string) bool {
|
||||
_, err := settingsPath(settingsType)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetSettings returns the raw JSON of the file backing the given settings type,
|
||||
// byte-for-byte the same content as the source file. bot_node_config.json is
|
||||
// optional and auto-generated, so a missing or empty file yields an empty
|
||||
// object.
|
||||
func GetSettings(settingsType string) ([]byte, error) {
|
||||
path, err := settingsPath(settingsType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settingsLock.RLock()
|
||||
defer settingsLock.RUnlock()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) && settingsType == SettingBotNodeConfig {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read %s settings file: %w", settingsType, err)
|
||||
}
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// UpdateSettings merges the given partial update into the JSON file backing the
|
||||
// given settings type and persists the result back to disk. Because the merge is
|
||||
// recursive, a payload such as {"system":{"debugMode":true}} only touches the
|
||||
// nested keys it names and leaves every sibling key untouched. After the file is
|
||||
// written the matching in-memory singleton is reloaded so runtime code observes
|
||||
// the new values.
|
||||
func UpdateSettings(settingsType string, patch map[string]interface{}) error {
|
||||
path, err := settingsPath(settingsType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
|
||||
// Start from whatever is already on disk so nothing is dropped. A missing or
|
||||
// empty file is treated as an empty object.
|
||||
current := map[string]interface{}{}
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if len(bytes.TrimSpace(data)) > 0 {
|
||||
if err := json.Unmarshal(data, ¤t); err != nil {
|
||||
return fmt.Errorf("failed to parse existing %s settings file %s: %w", settingsType, path, err)
|
||||
}
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to read existing %s settings file %s: %w", settingsType, path, err)
|
||||
}
|
||||
|
||||
deepMergeSettings(current, patch)
|
||||
|
||||
data, err = json.MarshalIndent(current, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal %s settings: %w", settingsType, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write %s settings file %s: %w", settingsType, path, err)
|
||||
}
|
||||
|
||||
return reloadSettings(settingsType, path)
|
||||
}
|
||||
|
||||
// deepMergeSettings recursively overlays src onto dst. Object values merge
|
||||
// key-by-key so partial updates keep sibling keys untouched; arrays and scalars
|
||||
// always replace the destination value. A JSON null in the payload removes that
|
||||
// key from dst, giving clients a way to delete entries (members, nodes, map
|
||||
// rows) through /api/settings/set.
|
||||
func deepMergeSettings(dst, src map[string]interface{}) {
|
||||
for key, srcVal := range src {
|
||||
if srcVal == nil {
|
||||
delete(dst, key)
|
||||
continue
|
||||
}
|
||||
srcObj, srcIsObj := srcVal.(map[string]interface{})
|
||||
if srcIsObj {
|
||||
if dstObj, ok := dst[key].(map[string]interface{}); ok {
|
||||
deepMergeSettings(dstObj, srcObj)
|
||||
} else {
|
||||
dst[key] = srcVal
|
||||
}
|
||||
continue
|
||||
}
|
||||
dst[key] = srcVal
|
||||
}
|
||||
}
|
||||
|
||||
// reloadSettings refreshes the in-memory singleton for the given settings type
|
||||
// so the running program observes the values just persisted to disk.
|
||||
func reloadSettings(settingsType, path string) error {
|
||||
switch settingsType {
|
||||
case SettingGlobal:
|
||||
_, err := LoadGlobalConfig(path)
|
||||
return err
|
||||
case SettingBotUserConfig:
|
||||
_, err := LoadBotUserConfig(path)
|
||||
return err
|
||||
case SettingBotNodeConfig:
|
||||
return LoadBotNodeConfig(path)
|
||||
default:
|
||||
return ErrUnsupportedSettingsType
|
||||
}
|
||||
}
|
||||
+12
-8
@@ -175,14 +175,18 @@ var C_botUserConfig *BotUserConfig
|
||||
// file is auto-populated by the node tracker for every node Komari reports;
|
||||
// per-node options are edited by hand in the JSON file.
|
||||
type BotNodeOptions struct {
|
||||
// EnableStatusNotify is an allow-list switch: a node broadcasts status-change
|
||||
// notifications only when this is true. Nodes default to false (silent)
|
||||
// unless explicitly enabled.
|
||||
EnableStatusNotify bool `json:"enableStatusNotify"`
|
||||
// EnableStatusNotify controls whether this node broadcasts status-change
|
||||
// notifications. It defaults to true: only an explicit false in the JSON
|
||||
// file disables a node's notifications. The pointer (rather than a plain
|
||||
// bool) lets an absent field be told apart from an explicit false, and
|
||||
// omitempty keeps untouched nodes stored as {}.
|
||||
EnableStatusNotify *bool `json:"enableStatusNotify,omitempty"`
|
||||
}
|
||||
|
||||
// BotNodeMembers maps a node UUID (as reported by Komari) to its per-node
|
||||
// options, e.g. bot_node_config.json:
|
||||
//
|
||||
// {"<uuid1>": {"enableStatusNotify": true}, "<uuid2>": {}}
|
||||
type BotNodeMembers map[string]BotNodeOptions
|
||||
// options.
|
||||
type BotNodeMembers map[string]BotNodeOptions
|
||||
|
||||
// C_botNodeConfig is the global singleton mirroring bot_node_config.json,
|
||||
// populated by LoadBotNodeConfig.
|
||||
var C_botNodeConfig BotNodeMembers
|
||||
+31
-3
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -81,12 +82,36 @@ func HashPassword(password string) string {
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// CreateUser inserts a new user into the database.
|
||||
func CreateUser(username, password, level string) (int64, error) {
|
||||
// ErrUsersExist is returned by RegisterFirstUser when the users table is not
|
||||
// empty. Registration is only ever allowed for the very first user.
|
||||
var ErrUsersExist = errors.New("registration rejected: only the first user can be registered via this endpoint")
|
||||
|
||||
// RegisterFirstUser atomically creates the first user, but only while the users
|
||||
// table is empty. The emptiness check and the insert run inside a single
|
||||
// transaction. Because InitUserDB caps the pool at one connection, a concurrent
|
||||
// registration blocks at Begin until the in-flight transaction commits, so it
|
||||
// cannot observe the table as empty between the check and the insert. This
|
||||
// closes the check-then-insert race two simultaneous first-run registrations
|
||||
// would otherwise hit.
|
||||
func RegisterFirstUser(username, password, level string) (int64, error) {
|
||||
hashedPassword := HashPassword(password)
|
||||
registerDate := time.Now().Format("2006-01-02 15:04:05")
|
||||
|
||||
result, err := UserDB.Exec(
|
||||
tx, err := UserDB.Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var count int
|
||||
if err := tx.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("failed to check existing users: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return 0, ErrUsersExist
|
||||
}
|
||||
|
||||
result, err := tx.Exec(
|
||||
"INSERT INTO users (username, password, level, register_date) VALUES (?, ?, ?, ?)",
|
||||
username, hashedPassword, level, registerDate,
|
||||
)
|
||||
@@ -94,6 +119,9 @@ func CreateUser(username, password, level string) (int64, error) {
|
||||
return 0, fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("failed to commit user creation: %w", err)
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// initTempDB opens a fresh user database in a temp directory and registers a
|
||||
// cleanup that closes it when the test finishes.
|
||||
func initTempDB(t *testing.T) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "user.db")
|
||||
if err := InitUserDB(path); err != nil {
|
||||
t.Fatalf("InitUserDB: %v", err)
|
||||
}
|
||||
t.Cleanup(CloseUserDB)
|
||||
}
|
||||
|
||||
func TestRegisterFirstUserClosedAfterFirst(t *testing.T) {
|
||||
initTempDB(t)
|
||||
|
||||
if _, err := RegisterFirstUser("alice", "password1", "admin"); err != nil {
|
||||
t.Fatalf("first registration should succeed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := RegisterFirstUser("bob", "password2", "admin"); !errors.Is(err, ErrUsersExist) {
|
||||
t.Fatalf("second registration error = %v, want ErrUsersExist", err)
|
||||
}
|
||||
|
||||
count, err := GetUserCount()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserCount: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("user count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterFirstUserConcurrentOnlyOneWins(t *testing.T) {
|
||||
initTempDB(t)
|
||||
|
||||
const n = 8
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, n)
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, err := RegisterFirstUser("user"+string(rune('a'+i)), "password", "admin")
|
||||
results <- err
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
successes := 0
|
||||
for err := range results {
|
||||
switch {
|
||||
case err == nil:
|
||||
successes++
|
||||
case errors.Is(err, ErrUsersExist):
|
||||
// Expected for every registration that lost the race.
|
||||
default:
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("concurrent registrations: %d succeeded, want exactly 1", successes)
|
||||
}
|
||||
|
||||
count, err := GetUserCount()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserCount: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("user count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
.DS_Store
|
||||
@@ -0,0 +1,62 @@
|
||||
# Nukumizu Console
|
||||
|
||||
Web console for the Nukumizu backend — a remote-server monitor and alert bot. Built with Vue 3 + Vite, no UI framework; styling is hand-rolled on CSS custom properties with a light/dark theme.
|
||||
|
||||
## Features
|
||||
|
||||
- **Sign in / first-run admin** — login, or create the very first admin while the database is still empty (the backend rejects further registrations once any user exists).
|
||||
- **Nodes (overview)** — live server cards from the backend status/info APIs: online state, CPU / RAM / disk load, plus the per-node *status notify* switch persisted to `bot_node_config.json`. Searchable, auto-refreshes.
|
||||
- **Bot trust** — manage QQ (NapCat) and Telegram admins & trusted groups in `bot_user_config.json`: add / remove members and toggle each member's `status notify`, `startup notify`, and `command replies`.
|
||||
- **Settings** — structured forms for the global `config.json` (system, debug, Komari dashboard, controller methods, message templates, storage paths). Each card saves only its own section.
|
||||
- **System logs** — live WebSocket log stream (`/api/system/getLogs`) with severity filter chips, search, pause/resume and export.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
The dev server proxies `/api` (and the log websocket) to the backend. By default it targets `http://127.0.0.1:8080`; override with:
|
||||
|
||||
```bash
|
||||
# Windows PowerShell
|
||||
$env:NUKUMIZU_API = "http://192.168.20.4:8080"; npm run dev
|
||||
```
|
||||
|
||||
Production build (static assets only):
|
||||
|
||||
```bash
|
||||
npm run build # outputs dist/
|
||||
npm run preview
|
||||
```
|
||||
|
||||
The backend does not serve static files, so put `dist/` behind any static server and proxy `/api` (and `ws://…/api/system/getLogs`) to the Nukumizu backend.
|
||||
|
||||
## API contract notes
|
||||
|
||||
- Auth uses two headers on every request: `X-Token` (from login) and `X-Timestamp` — a **Unix timestamp in seconds** (not milliseconds) with a ±30 min tolerance (skipped when `system.debugMode` is on).
|
||||
- Token & user are kept in `localStorage`. On a `401` the session is cleared and you are returned to the login page.
|
||||
- The panel talks to `/api/server/getStatus`, `/api/server/getInfo`, `/api/settings/get`, `/api/settings/set` and `/api/user/login|register`. All management endpoints require an `admin` token.
|
||||
- `/api/settings/set` is a deep merge: sending a JSON `null` for a key removes it (used when deleting a trusted member).
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── index.html
|
||||
├── vite.config.js
|
||||
└── src/
|
||||
├── main.js / App.vue
|
||||
├── router/ # routes + auth guard
|
||||
├── api/index.js # authApi, serverApi, settingsApi
|
||||
├── utils/ # auth, http client, theme, toasts, formatting
|
||||
├── styles/ # theme.css (tokens) + ui.css (primitives)
|
||||
├── components/ # TopBar, SideBar, Modal, Toggle, TagsEditor, HeadersEditor
|
||||
└── views/ # Login, Layout, Overview, Trusted, Settings, Logs
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
- The backend's `/api/system/getLogs` websocket is currently unauthenticated — anyone who can reach the port can read logs. Consider gating it in a future backend change.
|
||||
- Registering more than one user is intentionally impossible; the backend only accepts the very first registration.
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Nukumizu Console</title>
|
||||
<script>
|
||||
(function () {
|
||||
var saved = localStorage.getItem('nukumizu_theme');
|
||||
var dark = saved === 'dark' || (saved !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1707
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "nukumizu-console",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Web console for Nukumizu — remote server monitor & alert bot",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"axios": "^1.7.9",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { toasts } from './utils/toast.js';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const glyph = { success: '✓', error: '✕', info: 'i', warn: '!' };
|
||||
|
||||
function onAuthExpired() {
|
||||
if (router.currentRoute.value.name !== 'Login') {
|
||||
router.push({ name: 'Login' });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('auth:expired', onAuthExpired));
|
||||
onBeforeUnmount(() => window.removeEventListener('auth:expired', onAuthExpired));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<div class="toasts" aria-live="polite">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
class="toast"
|
||||
:class="[t.type, { leaving: t.leaving }]"
|
||||
role="status"
|
||||
>
|
||||
<span class="t-icon">{{ glyph[t.type] || 'i' }}</span>
|
||||
<span class="t-msg">{{ t.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
import http from '../utils/http.js';
|
||||
|
||||
export const authApi = {
|
||||
login: (username, password) => http.post('/user/login', { username, password }),
|
||||
register: (username, password) => http.post('/user/register', { username, password })
|
||||
};
|
||||
|
||||
// /api/server/getStatus?uuid=all → { success, message, data: { "<uuid>": { uuid, name, online, report } } }
|
||||
export const serverApi = {
|
||||
statusAll: () => http.get('/server/getStatus?uuid=all'),
|
||||
infoAll: () => http.get('/server/getInfo?uuid=all')
|
||||
};
|
||||
|
||||
// /api/settings/get?type=… / /api/settings/set?type=…
|
||||
// get → { success, message, data: { config } }.
|
||||
// `type` is one of global | bot_user_config | bot_node_config.
|
||||
// For set, pass a partial object; a JSON null value removes that key.
|
||||
export const settingsApi = {
|
||||
get: (type) => http.get(`/settings/get?type=${encodeURIComponent(type)}`),
|
||||
set: (type, patch) => http.post(`/settings/set?type=${encodeURIComponent(type)}`, patch)
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, default: () => ({}) }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const rows = reactive([]);
|
||||
|
||||
let suppress = false;
|
||||
|
||||
function sync() {
|
||||
rows.length = 0;
|
||||
for (const [k, v] of Object.entries(props.modelValue || {})) {
|
||||
if (k !== '') rows.push({ k, v: v === null || v === undefined ? '' : String(v) });
|
||||
}
|
||||
if (!rows.length) rows.push({ k: '', v: '' });
|
||||
}
|
||||
|
||||
function commit() {
|
||||
const obj = {};
|
||||
for (const r of rows) {
|
||||
const key = r.k.trim();
|
||||
if (key) obj[key] = r.v;
|
||||
}
|
||||
// Emitting updates the parent model, which flows back through the prop and
|
||||
// would otherwise rebuild the rows mid-typing (and drop focus). Suppress
|
||||
// that self-echo for this tick.
|
||||
suppress = true;
|
||||
emit('update:modelValue', obj);
|
||||
setTimeout(() => { suppress = false; }, 0);
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
if (rows.length && !rows[rows.length - 1].k.trim()) return;
|
||||
rows.push({ k: '', v: '' });
|
||||
}
|
||||
|
||||
function removeRow(i) {
|
||||
rows.splice(i, 1);
|
||||
if (!rows.length) rows.push({ k: '', v: '' });
|
||||
commit();
|
||||
}
|
||||
|
||||
// Re-sync only when the value is replaced externally (e.g. after reload).
|
||||
watch(() => props.modelValue, () => {
|
||||
if (!suppress) sync();
|
||||
}, { deep: false });
|
||||
|
||||
sync();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kv-editor">
|
||||
<div v-for="(r, i) in rows" :key="i" class="kv-row">
|
||||
<input v-model="r.k" class="input kv-key mono" placeholder="Header name" @input="commit" />
|
||||
<input v-model="r.v" class="input kv-val mono" placeholder="Value" @input="commit" />
|
||||
<button type="button" class="icon-btn danger" title="Remove header" @click="removeRow(i)">
|
||||
<i class="fas fa-minus" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost btn-sm kv-add" @click="addRow">
|
||||
<i class="fas fa-plus" /> Add header
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kv-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kv-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr 34px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kv-key {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.kv-add {
|
||||
align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
wide: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade-switch">
|
||||
<div v-if="open" class="modal-mask" @click.self="emit('close')">
|
||||
<div class="modal" :class="{ wide }" role="dialog" aria-modal="true" @keydown.esc="emit('close')">
|
||||
<div class="modal-head">
|
||||
<h3>{{ title }}</h3>
|
||||
<button class="icon-btn" title="Close" @click="emit('close')">
|
||||
<i class="fas fa-xmark" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.foot" class="modal-foot">
|
||||
<slot name="foot" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
const groups = [
|
||||
{
|
||||
label: 'Operations',
|
||||
items: [
|
||||
{ name: 'Overview', to: '/overview', title: 'Nodes', icon: 'fa-server' },
|
||||
{ name: 'Trusted', to: '/trusted', title: 'Trust', icon: 'fa-shield-halved' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
items: [
|
||||
{ name: 'Settings', to: '/settings', title: 'Settings', icon: 'fa-sliders' },
|
||||
{ name: 'Logs', to: '/logs', title: 'Logs', icon: 'fa-terminal' }
|
||||
]
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<nav class="nav">
|
||||
<div v-for="group in groups" :key="group.label" class="group">
|
||||
<p class="group-label">{{ group.label }}</p>
|
||||
<router-link
|
||||
v-for="item in group.items"
|
||||
:key="item.name"
|
||||
:to="item.to"
|
||||
class="nav-item"
|
||||
active-class="active"
|
||||
>
|
||||
<i class="fas" :class="item.icon" aria-hidden="true" />
|
||||
<span>{{ item.title }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="side-foot">
|
||||
<span class="foot-dot" />
|
||||
<span>Nukumizu Console</span>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 52px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 232px;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 16px 12px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.group-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
margin: 4px 10px 6px;
|
||||
}
|
||||
|
||||
.group + .group {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 12px;
|
||||
border-radius: var(--r-m);
|
||||
color: var(--text-2);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.14s ease, color 0.14s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item i {
|
||||
width: 17px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-3);
|
||||
transition: color 0.14s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item:hover i {
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item.active i {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -12px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 3px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.side-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 10px 6px 2px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.foot-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--ok);
|
||||
box-shadow: 0 0 0 3px var(--ok-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: 'Type and press Enter to add' }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const text = ref('');
|
||||
|
||||
function add() {
|
||||
const items = text.value
|
||||
.split(/[,\n]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!items.length) return;
|
||||
const next = [...props.modelValue];
|
||||
for (const it of items) {
|
||||
if (!next.includes(it)) next.push(it);
|
||||
}
|
||||
emit('update:modelValue', next);
|
||||
text.value = '';
|
||||
}
|
||||
|
||||
function removeAt(i) {
|
||||
const next = props.modelValue.slice();
|
||||
next.splice(i, 1);
|
||||
emit('update:modelValue', next);
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
add();
|
||||
} else if (e.key === 'Backspace' && !text.value && props.modelValue.length) {
|
||||
removeAt(props.modelValue.length - 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tags-editor">
|
||||
<div v-if="modelValue.length" class="tags">
|
||||
<span v-for="(t, i) in modelValue" :key="i" class="tag">
|
||||
{{ t }}
|
||||
<button type="button" class="tag-x" @click="removeAt(i)"><i class="fas fa-xmark" /></button>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="text"
|
||||
class="input tags-input"
|
||||
:placeholder="modelValue.length ? placeholder : placeholder"
|
||||
@keydown="onKeydown"
|
||||
@blur="add"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tags-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-s);
|
||||
padding: 8px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.tags-editor:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px 3px 10px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag-x {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tag-x:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
|
||||
.tags-input {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 2px 4px;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.tags-input:focus {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
label: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="switch" :class="{ disabled }">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue"
|
||||
:disabled="disabled"
|
||||
@change="emit('update:modelValue', $event.target.checked)"
|
||||
/>
|
||||
<span class="track" />
|
||||
<span v-if="label" class="switch-label">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.switch.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { auth, clearSession } from '../utils/auth.js';
|
||||
import { currentTheme, toggleTheme } from '../utils/theme.js';
|
||||
import { toast } from '../utils/toast.js';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const isDark = ref(currentTheme() === 'dark');
|
||||
const user = computed(() => auth.user || {});
|
||||
const initial = computed(() => ((user.value.username || '?').slice(0, 1) || '?').toUpperCase());
|
||||
|
||||
function onToggleTheme() {
|
||||
isDark.value = toggleTheme() === 'dark';
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearSession();
|
||||
toast.info('Signed out');
|
||||
router.push({ name: 'Login' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="mark">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 3 6.5v3.1C3 15.2 6.9 20 12 21c5.1-1 9-5.8 9-11.4V6.5L12 2Zm0 7.2V19c-3.7-.7-6.6-4.4-6.6-8.2V7.6L12 4.4l6.6 3.2v3.2c0 1.9-.5 3.7-1.4 5.1H12Z" fill="currentColor"/></svg>
|
||||
</span>
|
||||
<span class="wordmark">
|
||||
<strong>Nukumizu</strong>
|
||||
<small>Console</small>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<button class="icon-btn" :title="isDark ? 'Switch to light' : 'Switch to dark'" @click="onToggleTheme">
|
||||
<i class="fas" :class="isDark ? 'fa-sun' : 'fa-moon'" />
|
||||
</button>
|
||||
|
||||
<div class="user-chip">
|
||||
<span class="avatar">{{ initial }}</span>
|
||||
<div class="who">
|
||||
<span class="name">{{ user.username || '—' }}</span>
|
||||
<span class="level">{{ user.level || 'admin' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="icon-btn" title="Sign out" @click="logout">
|
||||
<i class="fas fa-arrow-right-from-bracket" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 52px;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 18px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(150deg, var(--accent), var(--accent-strong));
|
||||
color: #fff;
|
||||
box-shadow: 0 3px 10px -3px color-mix(in srgb, var(--accent) 60%, transparent);
|
||||
}
|
||||
|
||||
.mark svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wordmark strong {
|
||||
font-family: var(--font-display);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.wordmark small {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-2);
|
||||
font-size: 15px;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 6px 5px 5px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.15;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.who .name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.who .level {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import router from './router/index.js';
|
||||
import { initTheme } from './utils/theme.js';
|
||||
import { isLoggedIn } from './utils/auth.js';
|
||||
import { loadDebugMode } from './utils/runtime.js';
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||
import './styles/theme.css';
|
||||
import './styles/ui.css';
|
||||
|
||||
initTheme();
|
||||
|
||||
// A persisted session can fetch the runtime flags right away; a fresh visitor
|
||||
// has no token yet and picks them up after signing in.
|
||||
if (isLoggedIn()) {
|
||||
loadDebugMode();
|
||||
}
|
||||
|
||||
createApp(App).use(router).mount('#app');
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { isLoggedIn } from '../utils/auth.js';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/Login.vue'),
|
||||
meta: { public: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../views/Layout.vue'),
|
||||
redirect: { name: 'Overview' },
|
||||
children: [
|
||||
{
|
||||
path: 'overview',
|
||||
name: 'Overview',
|
||||
component: () => import('../views/Overview.vue'),
|
||||
meta: { title: 'Nodes' }
|
||||
},
|
||||
{
|
||||
path: 'trusted',
|
||||
name: 'Trusted',
|
||||
component: () => import('../views/Trusted.vue'),
|
||||
meta: { title: 'Bot trust' }
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: () => import('../views/Settings.vue'),
|
||||
meta: { title: 'Settings' }
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'Logs',
|
||||
component: () => import('../views/Logs.vue'),
|
||||
meta: { title: 'Logs' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' }
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (!to.meta.public && !isLoggedIn()) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } };
|
||||
}
|
||||
if (to.name === 'Login' && isLoggedIn()) {
|
||||
return { name: 'Overview' };
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,95 @@
|
||||
/* Nukumizu Console — design tokens. Light is the default, dark is applied by
|
||||
setting data-theme="dark" on <html>. The look is a restrained, Apple-school
|
||||
console: neutral warm-gray fields, one accent reserved for interaction, and
|
||||
color used only where it carries meaning (health, log severity). */
|
||||
|
||||
:root {
|
||||
--font-ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||
|
||||
--bg: #eef0f3;
|
||||
--bg-grad: linear-gradient(180deg, #f4f5f7 0%, #eaedf1 100%);
|
||||
--surface: #ffffff;
|
||||
--surface-2: #fafbfc;
|
||||
--surface-3: #eef0f3;
|
||||
--surface-hover: #e7eaee;
|
||||
|
||||
--text: #1b1e24;
|
||||
--text-2: rgba(27, 30, 36, 0.68);
|
||||
--text-3: rgba(27, 30, 36, 0.46);
|
||||
|
||||
--line: rgba(20, 24, 32, 0.09);
|
||||
--line-strong: rgba(20, 24, 32, 0.16);
|
||||
|
||||
--accent: #006ee0;
|
||||
--accent-strong: #0059b8;
|
||||
--accent-soft: rgba(0, 110, 224, 0.09);
|
||||
--accent-contrast: #ffffff;
|
||||
|
||||
--ok: #1f9d58;
|
||||
--ok-soft: rgba(31, 157, 88, 0.12);
|
||||
--warn: #c07b06;
|
||||
--warn-soft: rgba(192, 123, 6, 0.13);
|
||||
--bad: #d73a49;
|
||||
--bad-soft: rgba(215, 58, 73, 0.1);
|
||||
--muted: #8a919c;
|
||||
|
||||
--log-debug: #188a4b;
|
||||
--log-info: #0a62d0;
|
||||
--log-warn: #b5720a;
|
||||
--log-error: #cc3340;
|
||||
--log-fatal: #a31b28;
|
||||
|
||||
--r-s: 7px;
|
||||
--r-m: 11px;
|
||||
--r-l: 16px;
|
||||
--r-pill: 999px;
|
||||
|
||||
--shadow-1: 0 1px 2px rgba(16, 22, 30, 0.05);
|
||||
--shadow-2: 0 1px 1px rgba(16, 22, 30, 0.04), 0 6px 24px -8px rgba(16, 22, 30, 0.14);
|
||||
--shadow-3: 0 2px 4px rgba(16, 22, 30, 0.06), 0 18px 48px -12px rgba(16, 22, 30, 0.22);
|
||||
|
||||
--nav-bg: rgba(15, 17, 21, 0.86);
|
||||
--nav-text: rgba(255, 255, 255, 0.72);
|
||||
--nav-text-hover: #ffffff;
|
||||
--nav-line: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--bg: #0b0c10;
|
||||
--bg-grad: linear-gradient(180deg, #0e1014 0%, #090a0e 100%);
|
||||
--surface: #15171d;
|
||||
--surface-2: #1b1e25;
|
||||
--surface-3: #24272f;
|
||||
--surface-hover: #2b2f38;
|
||||
|
||||
--text: #eceef2;
|
||||
--text-2: rgba(236, 238, 242, 0.66);
|
||||
--text-3: rgba(236, 238, 242, 0.44);
|
||||
|
||||
--line: rgba(255, 255, 255, 0.08);
|
||||
--line-strong: rgba(255, 255, 255, 0.14);
|
||||
|
||||
--accent: #2f8dff;
|
||||
--accent-strong: #57a3ff;
|
||||
--accent-soft: rgba(47, 141, 255, 0.16);
|
||||
|
||||
--ok: #3ecf83;
|
||||
--ok-soft: rgba(62, 207, 131, 0.16);
|
||||
--warn: #e3a23a;
|
||||
--warn-soft: rgba(227, 162, 58, 0.16);
|
||||
--bad: #ff6b76;
|
||||
--bad-soft: rgba(255, 107, 118, 0.15);
|
||||
--muted: #6b7280;
|
||||
|
||||
--log-debug: #43cf86;
|
||||
--log-info: #4b9dff;
|
||||
--log-warn: #efb458;
|
||||
--log-error: #ff7b84;
|
||||
--log-fatal: #ff525e;
|
||||
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-2: 0 1px 1px rgba(0, 0, 0, 0.4), 0 8px 28px -10px rgba(0, 0, 0, 0.6);
|
||||
--shadow-3: 0 2px 4px rgba(0, 0, 0, 0.4), 0 22px 56px -16px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
/* Nukumizu Console — shared UI primitives. Components may add scoped styles,
|
||||
but everything reusable lives here. */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
background: var(--bg-grad) fixed;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
transition: background 0.25s ease, color 0.25s ease;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--line-strong);
|
||||
border-radius: 999px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted);
|
||||
background-clip: content-box;
|
||||
border: 3px solid transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* --- Page scaffolding --------------------------------------------------- */
|
||||
|
||||
.page {
|
||||
padding: 34px 40px 60px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.page-head .eyebrow {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.page-head h1 {
|
||||
font-size: 30px;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.page-head .lead {
|
||||
margin-top: 6px;
|
||||
color: var(--text-2);
|
||||
font-size: 14px;
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-2);
|
||||
margin: 26px 0 12px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
/* --- Buttons ------------------------------------------------------------ */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 8px 15px;
|
||||
border-radius: var(--r-m);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
border: 1px solid transparent;
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
transition: background 0.15s ease, border-color 0.15s ease, transform 0.08s ease, opacity 0.15s ease, box-shadow 0.15s ease;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--bad-soft);
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: color-mix(in srgb, var(--bad) 16%, transparent);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 11px;
|
||||
font-size: 13px;
|
||||
border-radius: var(--r-s);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: var(--text-2);
|
||||
font-size: 15px;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
background: var(--bad-soft);
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
/* --- Cards -------------------------------------------------------------- */
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-l);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.card + .card {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
/* Cards inside a grid are spaced by `gap`. Without this the stacked-card rule
|
||||
above would inset every card but the first, leaving the first one sticking
|
||||
up by 18px. */
|
||||
.grid-2 > .card,
|
||||
.grid-3 > .card {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.card-head h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card-head .hint {
|
||||
color: var(--text-3);
|
||||
font-size: 12.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* --- Forms -------------------------------------------------------------- */
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field > label {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.field > label .required {
|
||||
color: var(--bad);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.field .help {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.input, .select, .textarea {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-s);
|
||||
color: var(--text);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.input:hover, .select:hover, .textarea:hover {
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.input:focus, .select:focus, .textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
resize: vertical;
|
||||
min-height: 84px;
|
||||
line-height: 1.55;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.select {
|
||||
appearance: none;
|
||||
background-image: linear-gradient(45deg, transparent 50%, var(--text-2) 50%),
|
||||
linear-gradient(135deg, var(--text-2) 50%, transparent 50%);
|
||||
background-position: calc(100% - 17px) 55%, calc(100% - 12px) 55%;
|
||||
background-size: 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field input[type="color"] {
|
||||
padding: 2px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px 20px;
|
||||
}
|
||||
|
||||
.form-grid .span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* --- Switch ------------------------------------------------------------- */
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.switch .track {
|
||||
width: 40px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
position: relative;
|
||||
transition: background 0.18s ease, border-color 0.18s ease;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.switch .track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.switch input:checked + .track {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.switch input:checked + .track::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.switch input:focus-visible + .track {
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.switch .switch-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.switch input:checked ~ .switch-label {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* --- Badges / dots ------------------------------------------------------- */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.badge-online, .b-online .dot { background: var(--ok-soft); color: var(--ok); }
|
||||
.badge-online .dot { background: var(--ok); }
|
||||
.badge-offline, .b-offline .dot { background: var(--surface-3); color: var(--text-3); }
|
||||
.badge-offline .dot { background: var(--muted); }
|
||||
.badge-warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.badge-danger { background: var(--bad-soft); color: var(--bad); }
|
||||
.badge-accent { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
/* --- Stat / metric ------------------------------------------------------- */
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.metric .m-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric .m-value {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.metric .m-value small {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* --- Meter (soft progress) ----------------------------------------------- */
|
||||
|
||||
.meter {
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meter > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.meter > i.warn { background: var(--warn); }
|
||||
.meter > i.bad { background: var(--bad); }
|
||||
.meter > i.ok { background: var(--ok); }
|
||||
|
||||
/* --- Server list --------------------------------------------------------- */
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.grid-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.grid-2, .grid-3 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.server-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 18px 18px 16px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.server-card .row-1 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.server-card .row-2 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.server-card .sv-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.server-card .sv-uuid {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.server-card .row-3 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* --- Members / options list ---------------------------------------------- */
|
||||
|
||||
.opt-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.opt-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.opt-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.opt-row .who {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.opt-row .who .id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.opt-row .who .sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.opt-row .toggles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 9px;
|
||||
border-radius: var(--r-pill);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-2);
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* --- Empty / skeleton ----------------------------------------------------- */
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 48px 20px;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty .e-icon {
|
||||
font-size: 30px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* --- Toolbar --------------------------------------------------------------- */
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.toolbar .spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.search .icon {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-3);
|
||||
pointer-events: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search input {
|
||||
width: 100%;
|
||||
padding: 8px 12px 8px 32px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-pill);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.search input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
/* --- Modal ------------------------------------------------------------------ */
|
||||
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 10vh 20px 40px;
|
||||
background: rgba(12, 14, 18, 0.5);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
animation: fade-in 0.18s ease;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-l);
|
||||
box-shadow: var(--shadow-3);
|
||||
animation: rise 0.22s cubic-bezier(0.2, 0.9, 0.3, 1);
|
||||
}
|
||||
|
||||
.modal.wide { max-width: 640px; }
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px 14px;
|
||||
}
|
||||
|
||||
.modal-head h3 { font-size: 17px; }
|
||||
|
||||
.modal-body {
|
||||
padding: 4px 20px 8px;
|
||||
}
|
||||
|
||||
.modal-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px 20px 18px;
|
||||
}
|
||||
|
||||
/* --- Toasts ------------------------------------------------------------------ */
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 12px 15px;
|
||||
border-radius: var(--r-m);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-3);
|
||||
font-size: 13.5px;
|
||||
animation: toast-in 0.28s cubic-bezier(0.2, 0.9, 0.3, 1);
|
||||
}
|
||||
|
||||
.toast .t-icon {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.toast.success .t-icon { background: var(--ok); }
|
||||
.toast.error .t-icon { background: var(--bad); }
|
||||
.toast.info .t-icon { background: var(--accent); }
|
||||
.toast.warn .t-icon { background: var(--warn); }
|
||||
|
||||
.toast .t-msg {
|
||||
flex: 1;
|
||||
line-height: 1.45;
|
||||
padding-top: 2px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.toast.leaving { animation: toast-out 0.25s ease forwards; }
|
||||
|
||||
/* --- Animations -------------------------------------------------------------- */
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(-16px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes toast-out {
|
||||
to { opacity: 0; transform: translateY(-14px) scale(0.97); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--line-strong);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
/* route transition */
|
||||
.fade-switch-enter-active, .fade-switch-leave-active {
|
||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.fade-switch-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
.fade-switch-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { reactive } from 'vue';
|
||||
|
||||
const TOKEN_KEY = 'nukumizu_token';
|
||||
const USER_KEY = 'nukumizu_user';
|
||||
|
||||
function readUser() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(USER_KEY)) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = reactive({
|
||||
token: localStorage.getItem(TOKEN_KEY) || '',
|
||||
user: readUser()
|
||||
});
|
||||
|
||||
export function setSession(token, user) {
|
||||
auth.token = token;
|
||||
auth.user = user;
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
auth.token = '';
|
||||
auth.user = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return auth.token;
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!auth.token;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Severity levels as sent by the backend log websocket (see postLog package):
|
||||
// 0=DEBUG 1=INFO 2=WARN 3=ERROR 4=FATAL
|
||||
export const LOG_LEVELS = [
|
||||
{ key: 'DEBUG', value: 0 },
|
||||
{ key: 'INFO', value: 1 },
|
||||
{ key: 'WARN', value: 2 },
|
||||
{ key: 'ERROR', value: 3 },
|
||||
{ key: 'FATAL', value: 4 }
|
||||
];
|
||||
|
||||
export const levelOf = (value) => LOG_LEVELS.find((l) => l.value === value) || { key: 'LOG', value };
|
||||
|
||||
export function formatBytes(n, digits = 1) {
|
||||
if (n === null || n === undefined || Number.isNaN(n)) return '-';
|
||||
if (n < 1024) return `${Math.round(n)} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let v = n;
|
||||
let u = -1;
|
||||
do {
|
||||
v /= 1024;
|
||||
u += 1;
|
||||
} while (v >= 1024 && u < units.length - 1);
|
||||
return `${v.toFixed(digits)} ${units[u]}`;
|
||||
}
|
||||
|
||||
export function formatPercent(used, total) {
|
||||
if (!total) return '—';
|
||||
return `${Math.min(100, Math.max(0, Math.round((used / total) * 100)))}%`;
|
||||
}
|
||||
|
||||
export function shortUUID(uuid = '') {
|
||||
if (!uuid) return '';
|
||||
if (uuid.length <= 13) return uuid;
|
||||
return `${uuid.slice(0, 8)}…${uuid.slice(-4)}`;
|
||||
}
|
||||
|
||||
// Online/offline counts + aggregate online ratio. Report arrays carry the
|
||||
// report; when absent the server has no live telemetry yet.
|
||||
export function sumUp(entries) {
|
||||
const total = entries.length;
|
||||
const online = entries.filter((e) => e.online).length;
|
||||
return { total, online, offline: total - online };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import axios from 'axios';
|
||||
import { getToken, clearSession } from './auth.js';
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers['X-Token'] = token;
|
||||
}
|
||||
// Backend expects a Unix timestamp in *seconds* with a ±30 min tolerance.
|
||||
config.headers['X-Timestamp'] = Math.floor(Date.now() / 1000);
|
||||
return config;
|
||||
});
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
const status = error.response ? error.response.status : 0;
|
||||
const payload = error.response && error.response.data;
|
||||
const message = (payload && payload.message) || error.message || 'Network error';
|
||||
|
||||
if (status === 401) {
|
||||
clearSession();
|
||||
window.dispatchEvent(new CustomEvent('auth:expired'));
|
||||
}
|
||||
|
||||
const err = new Error(message);
|
||||
err.status = status;
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
export default http;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ref } from 'vue';
|
||||
import { settingsApi } from '../api/index.js';
|
||||
|
||||
// Runtime flags mirrored from the backend config so any view can read them
|
||||
// without refetching the whole config.
|
||||
|
||||
// debugMode mirrors system.debugMode from the global config (config.json).
|
||||
export const debugMode = ref(false);
|
||||
|
||||
// loadDebugMode reads the global config and mirrors system.debugMode into the
|
||||
// `debugMode` global. The endpoint needs an admin session, so callers only
|
||||
// invoke it once logged in; a failed request keeps the current value.
|
||||
export async function loadDebugMode() {
|
||||
try {
|
||||
const res = await settingsApi.get('global');
|
||||
const config = (res && res.data && res.data.config) || {};
|
||||
const system = config.system || {};
|
||||
debugMode.value = !!system.debugMode;
|
||||
if (debugMode.value) {
|
||||
console.log('Debug mode enabled');
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: the flag simply keeps its current value.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
const KEY = 'nukumizu_theme';
|
||||
|
||||
function systemPrefersDark() {
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
export function applyTheme(theme) {
|
||||
const dark = theme === 'dark' || (theme !== 'light' && systemPrefersDark());
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
return dark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function currentTheme() {
|
||||
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
const saved = localStorage.getItem(KEY);
|
||||
applyTheme(saved || 'system');
|
||||
}
|
||||
|
||||
export function setTheme(theme) {
|
||||
localStorage.setItem(KEY, theme);
|
||||
return applyTheme(theme);
|
||||
}
|
||||
|
||||
export function toggleTheme() {
|
||||
const next = currentTheme() === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem(KEY, next);
|
||||
applyTheme(next);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { reactive } from 'vue';
|
||||
|
||||
let seq = 0;
|
||||
|
||||
export const toasts = reactive([]);
|
||||
|
||||
function dismiss(id) {
|
||||
const idx = toasts.findIndex((t) => t.id === id);
|
||||
if (idx !== -1) toasts[idx].leaving = true;
|
||||
setTimeout(() => {
|
||||
const i = toasts.findIndex((t) => t.id === id);
|
||||
if (i !== -1) toasts.splice(i, 1);
|
||||
}, 240);
|
||||
}
|
||||
|
||||
function push(type, message, timeout) {
|
||||
const id = ++seq;
|
||||
toasts.push({ id, type, message, leaving: false });
|
||||
if (timeout > 0) {
|
||||
setTimeout(() => dismiss(id), timeout);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success(message, timeout = 3600) {
|
||||
return push('success', message, timeout);
|
||||
},
|
||||
error(message, timeout = 5200) {
|
||||
return push('error', message, timeout);
|
||||
},
|
||||
info(message, timeout = 3200) {
|
||||
return push('info', message, timeout);
|
||||
},
|
||||
warn(message, timeout = 4200) {
|
||||
return push('warn', message, timeout);
|
||||
},
|
||||
dismiss
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import TopBar from '../components/TopBar.vue';
|
||||
import SideBar from '../components/SideBar.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<TopBar />
|
||||
<div class="app-body">
|
||||
<SideBar />
|
||||
<main class="content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-switch" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 232px;
|
||||
padding-top: 52px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<script setup>
|
||||
import { reactive, ref, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { authApi } from '../api/index.js';
|
||||
import { setSession } from '../utils/auth.js';
|
||||
import { loadDebugMode } from '../utils/runtime.js';
|
||||
import { toast } from '../utils/toast.js';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const mode = ref('login');
|
||||
const loading = ref(false);
|
||||
const showPw = ref(false);
|
||||
const form = reactive({ username: '', password: '' });
|
||||
const errMsg = ref('');
|
||||
|
||||
const canSubmit = computed(() => form.username.trim().length > 0 && form.password.length >= 6);
|
||||
|
||||
function goTo(target) {
|
||||
mode.value = target;
|
||||
errMsg.value = '';
|
||||
}
|
||||
|
||||
function afterLogin(token, user) {
|
||||
setSession(token, user);
|
||||
// The settings endpoint requires the token we just stored.
|
||||
loadDebugMode();
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/overview';
|
||||
router.push(redirect);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value || loading.value) return;
|
||||
loading.value = true;
|
||||
errMsg.value = '';
|
||||
try {
|
||||
if (mode.value === 'login') {
|
||||
const res = await authApi.login(form.username.trim(), form.password);
|
||||
const d = res.data;
|
||||
afterLogin(d.token, {
|
||||
userID: d.userID,
|
||||
username: d.username,
|
||||
level: d.level,
|
||||
registerDate: d.registerDate
|
||||
});
|
||||
} else {
|
||||
const res = await authApi.register(form.username.trim(), form.password);
|
||||
const d = res.data;
|
||||
afterLogin(d.token, {
|
||||
userID: d.userID,
|
||||
username: d.username,
|
||||
level: d.level
|
||||
});
|
||||
toast.success('Admin created — welcome to Nukumizu');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mode.value === 'register' && e.status === 403) {
|
||||
errMsg.value = 'An admin already exists — registration is closed. Please sign in.';
|
||||
mode.value = 'login';
|
||||
} else {
|
||||
errMsg.value = e.message || 'Operation failed';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth">
|
||||
<div class="auth-inner">
|
||||
<div class="brand">
|
||||
<span class="mark">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 3 6.5v3.1C3 15.2 6.9 20 12 21c5.1-1 9-5.8 9-11.4V6.5L12 2Zm0 7.2V19c-3.7-.7-6.6-4.4-6.6-8.2V7.6L12 4.4l6.6 3.2v3.2c0 1.9-.5 3.7-1.4 5.1H12Z" fill="currentColor"/></svg>
|
||||
</span>
|
||||
<h1>Nukumizu</h1>
|
||||
<p>Remote server monitoring & alert bot — admin console</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="tabs">
|
||||
<button :class="{ on: mode === 'login' }" @click="goTo('login')">Sign in</button>
|
||||
<button :class="{ on: mode === 'register' }" @click="goTo('register')">Create admin</button>
|
||||
</div>
|
||||
|
||||
<form class="fields" @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" v-model="form.username" class="input" autocomplete="username" autofocus />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<div class="pw">
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
class="input"
|
||||
:type="showPw ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
placeholder="At least 6 characters"
|
||||
/>
|
||||
<button type="button" class="icon-btn eye" tabindex="-1" @click="showPw = !showPw">
|
||||
<i class="fas" :class="showPw ? 'fa-eye-slash' : 'fa-eye'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errMsg" class="form-err">{{ errMsg }}</p>
|
||||
|
||||
<button class="btn btn-primary submit" type="submit" :disabled="!canSubmit || loading">
|
||||
<span v-if="loading" class="spinner" style="width:14px;height:14px;border-width:2px" />
|
||||
<span>{{ mode === 'login' ? 'Sign in' : 'Create & enter' }}</span>
|
||||
</button>
|
||||
|
||||
<p v-if="mode === 'register'" class="hint">
|
||||
Only available while no user exists yet. The first account becomes the admin.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px 20px;
|
||||
background:
|
||||
radial-gradient(900px 480px at 15% -10%, var(--accent-soft), transparent 60%),
|
||||
radial-gradient(700px 420px at 110% 110%, color-mix(in srgb, var(--ok) 8%, transparent), transparent 60%),
|
||||
var(--bg-grad) fixed;
|
||||
}
|
||||
|
||||
.auth-inner {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 15px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(150deg, var(--accent), var(--accent-strong));
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 30px -8px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.mark svg { width: 32px; height: 32px; }
|
||||
|
||||
.brand h1 {
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand p {
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-l);
|
||||
box-shadow: var(--shadow-2);
|
||||
padding: 8px 24px 24px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin: 0 -24px 20px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
position: relative;
|
||||
padding: 14px 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.tabs button.on { color: var(--accent); }
|
||||
|
||||
.tabs button.on::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pw { position: relative; }
|
||||
|
||||
.pw .input { padding-right: 40px; }
|
||||
|
||||
.pw .eye {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.form-err {
|
||||
font-size: 13px;
|
||||
color: var(--bad);
|
||||
background: var(--bad-soft);
|
||||
border-radius: var(--r-s);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.submit { width: 100%; padding: 11px; }
|
||||
|
||||
.hint {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,272 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { LOG_LEVELS } from '../utils/fmt.js';
|
||||
|
||||
const MAX_LOGS = 1200;
|
||||
|
||||
const logs = ref([]);
|
||||
const pending = ref([]);
|
||||
const status = ref('connecting');
|
||||
const paused = ref(false);
|
||||
const query = ref('');
|
||||
const enabled = reactive(Object.fromEntries(LOG_LEVELS.map((l) => [l.key, true])));
|
||||
let ws = null;
|
||||
let closedManually = false;
|
||||
let reconnectTimer = null;
|
||||
|
||||
const levelOf = (value) => LOG_LEVELS.find((l) => l.value === value) || { key: 'LOG', value };
|
||||
|
||||
const visible = computed(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
return logs.value.filter((m) => {
|
||||
if (!enabled[levelOf(m.level).key]) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
String(m.content || '').toLowerCase().includes(q) ||
|
||||
levelOf(m.level).key.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (status.value) {
|
||||
case 'open': return { label: 'Connected', cls: 'ok' };
|
||||
case 'connecting': return { label: 'Reconnecting', cls: 'warn' };
|
||||
default: return { label: 'Disconnected', cls: 'muted' };
|
||||
}
|
||||
});
|
||||
|
||||
function wsUrl() {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${window.location.host}/api/system/getLogs`;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (ws) {
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
}
|
||||
status.value = 'connecting';
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl());
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
status.value = 'open';
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(ev.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (typeof msg.level !== 'number') return;
|
||||
push(msg);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
status.value = 'closed';
|
||||
if (!closedManually) scheduleReconnect();
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (closedManually) return;
|
||||
reconnectTimer = setTimeout(connect, 1800);
|
||||
}
|
||||
|
||||
function push(msg) {
|
||||
if (paused.value) {
|
||||
pending.value.push(msg);
|
||||
if (pending.value.length > MAX_LOGS) pending.value.shift();
|
||||
return;
|
||||
}
|
||||
logs.value.unshift(msg);
|
||||
if (logs.value.length > MAX_LOGS) logs.value.pop();
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
paused.value = !paused.value;
|
||||
if (!paused.value) {
|
||||
const buffered = pending.value.splice(0);
|
||||
logs.value.unshift(...buffered);
|
||||
if (logs.value.length > MAX_LOGS) logs.value.length = MAX_LOGS;
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = [];
|
||||
pending.value = [];
|
||||
}
|
||||
|
||||
function exportLogs() {
|
||||
const lines = visible.value
|
||||
.map((m) => `[${m.timestamp}] [${levelOf(m.level).key.padEnd(5)}] ${m.content}`)
|
||||
.join('\n');
|
||||
const blob = new Blob([lines], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `nukumizu-logs-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.log`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function toggleLevel(key) {
|
||||
enabled[key] = !enabled[key];
|
||||
}
|
||||
|
||||
onMounted(connect);
|
||||
onBeforeUnmount(() => {
|
||||
closedManually = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (ws) ws.close();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow">Telemetry</p>
|
||||
<h1>System logs</h1>
|
||||
<p class="lead">Live log stream from the server. Newest entries appear first.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<span class="conn" :class="'conn-' + statusText.cls">
|
||||
<span class="dot" />{{ statusText.label }}
|
||||
</span>
|
||||
<button class="btn btn-ghost" title="Reconnect" @click="connect"><i class="fas fa-rotate-right" /></button>
|
||||
<button class="btn btn-ghost" title="Export visible logs" @click="exportLogs"><i class="fas fa-download" /></button>
|
||||
<button class="btn btn-ghost" title="Clear view" @click="clearLogs"><i class="fas fa-eraser" /></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<i class="fas fa-magnifying-glass icon" />
|
||||
<input v-model="query" type="text" placeholder="Filter logs…" />
|
||||
</div>
|
||||
|
||||
<div class="level-chips">
|
||||
<button
|
||||
v-for="l in LOG_LEVELS"
|
||||
:key="l.key"
|
||||
class="chip"
|
||||
:class="['lv-' + l.key.toLowerCase(), { off: !enabled[l.key] }]"
|
||||
@click="toggleLevel(l.key)"
|
||||
>
|
||||
{{ l.key }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="spacer" />
|
||||
|
||||
<button class="btn" :class="paused ? 'btn-primary' : 'btn-ghost'" @click="togglePause">
|
||||
<i class="fas" :class="paused ? 'fa-play' : 'fa-pause'" />
|
||||
{{ paused ? `Resume${pending.length ? ` (${pending.length})` : ''}` : 'Pause' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card log-card">
|
||||
<div v-if="visible.length === 0" class="empty">
|
||||
<i class="fas fa-terminal e-icon" />
|
||||
<p>No matching log entries.</p>
|
||||
</div>
|
||||
<div v-else class="log-list">
|
||||
<div v-for="(m, i) in visible" :key="m.timestamp + '-' + i" class="log-line">
|
||||
<span class="t mono">{{ m.timestamp }}</span>
|
||||
<span class="lv mono" :class="'lv-' + levelOf(m.level).key.toLowerCase()">
|
||||
{{ levelOf(m.level).key }}
|
||||
</span>
|
||||
<span class="msg">{{ m.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.conn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
padding: 5px 12px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.conn-ok { color: var(--ok); }
|
||||
.conn-ok .dot { background: var(--ok); }
|
||||
.conn-warn { color: var(--warn); }
|
||||
.conn-warn .dot { background: var(--warn); }
|
||||
.conn-muted { color: var(--text-3); }
|
||||
.conn-muted .dot { background: var(--muted); }
|
||||
|
||||
.level-chips { display: flex; gap: 6px; }
|
||||
|
||||
.chip { cursor: pointer; transition: opacity 0.15s ease; }
|
||||
.chip.off { opacity: 0.38; text-decoration: line-through; }
|
||||
|
||||
.lv-debug { color: var(--log-debug); }
|
||||
.lv-info { color: var(--log-info); }
|
||||
.lv-warn { color: var(--log-warn); }
|
||||
.lv-error { color: var(--log-error); }
|
||||
.lv-fatal { color: var(--log-fatal); }
|
||||
|
||||
.log-card { overflow: hidden; }
|
||||
|
||||
.log-list {
|
||||
max-height: calc(100vh - 300px);
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.log-line {
|
||||
display: grid;
|
||||
grid-template-columns: 170px 72px 1fr;
|
||||
gap: 14px;
|
||||
padding: 7px 16px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
border-bottom: 1px solid var(--line);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.log-line:hover { background: var(--surface-2); }
|
||||
|
||||
.log-line .t { color: var(--text-3); white-space: nowrap; }
|
||||
|
||||
.log-line .lv {
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.log-line .lv.lv-debug { background: var(--ok-soft); }
|
||||
.log-line .lv.lv-info { background: var(--accent-soft); }
|
||||
.log-line .lv.lv-warn { background: var(--warn-soft); }
|
||||
.log-line .lv.lv-error { background: var(--bad-soft); }
|
||||
.log-line .lv.lv-fatal { background: var(--bad-soft); }
|
||||
|
||||
.log-line .msg { color: var(--text); word-break: break-word; white-space: pre-wrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,310 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { serverApi, settingsApi } from '../api/index.js';
|
||||
import { toast } from '../utils/toast.js';
|
||||
import { formatBytes, shortUUID } from '../utils/fmt.js';
|
||||
import Toggle from '../components/Toggle.vue';
|
||||
|
||||
function percent(m) {
|
||||
return m && m.total ? Math.min(100, Math.max(0, Math.round((m.used / m.total) * 100))) : 0;
|
||||
}
|
||||
|
||||
const query = ref('');
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
const entries = ref([]);
|
||||
const saving = reactive(new Set());
|
||||
let timer = null;
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (!q) return entries.value;
|
||||
return entries.value.filter((e) =>
|
||||
(e.name || '').toLowerCase().includes(q) || (e.uuid || '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const stats = computed(() => {
|
||||
let online = 0;
|
||||
for (const e of entries.value) if (e.online) online += 1;
|
||||
return { total: entries.value.length, online, offline: entries.value.length - online };
|
||||
});
|
||||
|
||||
function meterClass(pct) {
|
||||
if (pct >= 90) return 'bad';
|
||||
if (pct >= 75) return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [status, info, nodes] = await Promise.all([
|
||||
serverApi.statusAll(),
|
||||
serverApi.infoAll(),
|
||||
settingsApi.get('bot_node_config')
|
||||
]);
|
||||
const statusData = (status && status.data) || {};
|
||||
const infoData = (info && info.data) || {};
|
||||
const nodeConf = (nodes && nodes.data && nodes.data.config) || {};
|
||||
const list = [];
|
||||
for (const key of Object.keys(statusData)) {
|
||||
const s = statusData[key] || {};
|
||||
const infoEntry = infoData[key] || {};
|
||||
const conf = nodeConf[key] || {};
|
||||
list.push({
|
||||
uuid: s.uuid || key,
|
||||
name: s.name || shortUUID(s.uuid || key),
|
||||
online: !!s.online,
|
||||
report: s.report || null,
|
||||
info: infoEntry.info || null,
|
||||
notify: conf.enableStatusNotify !== false
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => (a.online === b.online ? a.name.localeCompare(b.name) : b.online ? 1 : -1));
|
||||
entries.value = list;
|
||||
failed.value = false;
|
||||
} catch (e) {
|
||||
failed.value = true;
|
||||
if (e.status !== 0) toast.error(e.message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleNotify(entry) {
|
||||
const next = !entry.notify;
|
||||
const prev = entry.notify;
|
||||
entry.notify = next;
|
||||
saving.add(entry.uuid);
|
||||
try {
|
||||
const patch = {};
|
||||
patch[entry.uuid] = { enableStatusNotify: next };
|
||||
await settingsApi.set('bot_node_config', patch);
|
||||
toast.success(`${entry.name}: status notify ${next ? 'on' : 'off'}`);
|
||||
} catch (e) {
|
||||
entry.notify = prev;
|
||||
toast.error('Save failed: ' + e.message);
|
||||
} finally {
|
||||
saving.delete(entry.uuid);
|
||||
}
|
||||
}
|
||||
|
||||
function copyUUID(uuid) {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(uuid).then(() => toast.success('UUID copied'), () => {});
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
timer = setInterval(load, 15000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow">Overview</p>
|
||||
<h1>Nodes</h1>
|
||||
<p class="lead">Monitored servers with live load, online state, and the per-node status-notify switch.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="btn btn-ghost" title="Refresh" :disabled="loading" @click="load">
|
||||
<i class="fas fa-rotate" :class="{ 'fa-spin': loading }" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<i class="fas fa-magnifying-glass icon" />
|
||||
<input v-model="query" type="text" placeholder="Search name or UUID…" />
|
||||
</div>
|
||||
<div class="badge badge-accent"><span class="dot" /> {{ stats.total }} total</div>
|
||||
<div class="badge badge-online"><span class="dot" /> {{ stats.online }} online</div>
|
||||
<div class="badge badge-offline"><span class="dot" /> {{ stats.offline }} offline</div>
|
||||
<span class="spacer" />
|
||||
<span class="refresh-hint"><i class="fas fa-circle-info" /> Auto-refreshes every 15s</span>
|
||||
</div>
|
||||
|
||||
<div v-if="failed && !loading" class="card empty">
|
||||
<i class="fas fa-plug-circle-xmark e-icon" />
|
||||
<p>Cannot reach the backend service</p>
|
||||
<button class="btn btn-primary" @click="load">Retry</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="card empty">
|
||||
<span class="spinner" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="entries.length === 0" class="card empty">
|
||||
<i class="fas fa-server e-icon" />
|
||||
<p>No monitored nodes yet</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filtered.length === 0" class="card empty">
|
||||
<i class="fas fa-magnifying-glass e-icon" />
|
||||
<p>No nodes match “{{ query }}”</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid-2">
|
||||
<article
|
||||
v-for="e in filtered"
|
||||
:key="e.uuid"
|
||||
class="card server-card"
|
||||
:class="{ dim: !e.online }"
|
||||
>
|
||||
<div class="row-1">
|
||||
<span class="sv-icon"><i class="fas fa-server" /></span>
|
||||
<div class="sv-main">
|
||||
<p class="sv-name" :title="e.name">{{ e.name }}</p>
|
||||
<button class="sv-uuid" type="button" :title="'Copy: ' + e.uuid" @click="copyUUID(e.uuid)">
|
||||
{{ shortUUID(e.uuid) }}
|
||||
<i class="fas fa-copy" />
|
||||
</button>
|
||||
</div>
|
||||
<span class="badge" :class="e.online ? 'badge-online' : 'badge-offline'">
|
||||
<span class="dot" />{{ e.online ? 'Online' : 'Offline' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="e.online && e.report">
|
||||
<div class="meter-row">
|
||||
<span class="mr-label">CPU</span>
|
||||
<div class="meter"><i :class="meterClass(e.report.cpu.usage)" :style="{ width: Math.min(100, e.report.cpu.usage) + '%' }" /></div>
|
||||
<span class="mr-val">{{ e.report.cpu.usage.toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="meter-row">
|
||||
<span class="mr-label">RAM</span>
|
||||
<div class="meter"><i :class="meterClass(percent(e.report.ram))" :style="{ width: percent(e.report.ram) + '%' }" /></div>
|
||||
<span class="mr-val">{{ formatBytes(e.report.ram.used) }} / {{ formatBytes(e.report.ram.total) }}</span>
|
||||
</div>
|
||||
<div class="meter-row">
|
||||
<span class="mr-label">Disk</span>
|
||||
<div class="meter"><i :class="meterClass(percent(e.report.disk))" :style="{ width: percent(e.report.disk) + '%' }" /></div>
|
||||
<span class="mr-val">{{ formatBytes(e.report.disk.used) }} / {{ formatBytes(e.report.disk.total) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="e.online">
|
||||
<p class="no-report">Online, waiting for the first status report…</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="no-report">{{ e.report && e.report.message ? e.report.message : 'Currently offline' }}</p>
|
||||
</template>
|
||||
|
||||
<footer class="row-3">
|
||||
<div class="spec" v-if="e.info && e.info.cpu">
|
||||
<span>{{ e.info.os ? e.info.os.os || '—' : '—' }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ e.info.cpu.cpu_cores ? e.info.cpu.cpu_cores + ' cores' : '' }}</span>
|
||||
<span>·</span>
|
||||
<span class="mono">{{ e.info.ipv4 || (e.info.ipv6 ? 'IPv6' : '—') }}</span>
|
||||
</div>
|
||||
<div class="notify">
|
||||
<Toggle
|
||||
:model-value="e.notify"
|
||||
label="Status notify"
|
||||
:disabled="saving.has(e.uuid)"
|
||||
@update:model-value="toggleNotify(e)"
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.server-card { transition: opacity 0.25s ease; }
|
||||
.server-card.dim .sv-icon,
|
||||
.server-card.dim .sv-name { opacity: 0.75; }
|
||||
|
||||
.sv-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: none;
|
||||
border-radius: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.server-card.dim .sv-icon {
|
||||
background: var(--surface-3);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.sv-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sv-uuid {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sv-uuid:hover { color: var(--accent); }
|
||||
|
||||
.meter-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mr-label { font-size: 12px; color: var(--text-3); font-weight: 600; }
|
||||
|
||||
.mr-val {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 90px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.no-report {
|
||||
font-size: 13px;
|
||||
color: var(--text-3);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.row-3 {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 13px;
|
||||
}
|
||||
|
||||
.spec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notify { display: flex; align-items: center; }
|
||||
|
||||
.refresh-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,372 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { settingsApi } from '../api/index.js';
|
||||
import { debugMode } from '../utils/runtime.js';
|
||||
import { toast } from '../utils/toast.js';
|
||||
import Toggle from '../components/Toggle.vue';
|
||||
import TagsEditor from '../components/TagsEditor.vue';
|
||||
import HeadersEditor from '../components/HeadersEditor.vue';
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: 'system',
|
||||
title: 'System',
|
||||
hint: 'HTTP listener and global runtime switches.',
|
||||
root: ['system'],
|
||||
fields: [
|
||||
{ key: 'debugMode', type: 'bool', label: 'Debug mode', help: 'Skipped X-Timestamp checks and verbose debug logging.' },
|
||||
{ key: 'listenAddr', type: 'text', label: 'Listen address' },
|
||||
{ key: 'listenPort', type: 'text', label: 'Listen port' },
|
||||
{ key: 'networkProxy', type: 'text', label: 'Network proxy', placeholder: 'http://host:port' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'debug',
|
||||
title: 'Debug logging',
|
||||
hint: 'Per-module verbosity for the bot pipes.',
|
||||
root: ['debug'],
|
||||
fields: [
|
||||
{ key: 'showNapcatMsg', type: 'bool', label: 'NapCat messages' },
|
||||
{ key: 'showNapcatAction', type: 'bool', label: 'NapCat actions' },
|
||||
{ key: 'showTelegramMsg', type: 'bool', label: 'Telegram messages' },
|
||||
{ key: 'showTriggerCmdEcho', type: 'bool', label: 'Trigger command echo' },
|
||||
{ key: 'showKomariTaskEcho', type: 'bool', label: 'Komari task echo' },
|
||||
{ key: 'napcatIgnoreSelfMsg', type: 'bool', label: 'Ignore NapCat self messages' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'komari',
|
||||
title: 'Komari dashboard',
|
||||
hint: 'Connection the monitor reads node data from. Takes effect on restart.',
|
||||
root: ['komari'],
|
||||
fields: [
|
||||
{ key: 'dashboardURL', type: 'text', label: 'Dashboard URL' },
|
||||
{ key: 'account.username', lp: ['account', 'username'], type: 'text', label: 'Account' },
|
||||
{ key: 'account.password', lp: ['account', 'password'], type: 'password', label: 'Password' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'controllerMessage',
|
||||
title: 'Message templates',
|
||||
hint: 'Templates rendered for bot pushes. Placeholders like {{ serverName }} stay as-is.',
|
||||
root: ['controllerMessage'],
|
||||
fields: [
|
||||
{ key: 'BOT_STARTED', type: 'textarea', label: 'BOT_STARTED' },
|
||||
{ key: 'BOT_HELP', type: 'textarea', label: 'BOT_HELP' },
|
||||
{ key: 'TG_BOT_START', type: 'textarea', label: 'TG_BOT_START' },
|
||||
{ key: 'SERVER_STATUS_CHANGED', type: 'textarea', label: 'SERVER_STATUS_CHANGED' },
|
||||
{ key: 'SERVER_LIST', type: 'textarea', label: 'SERVER_LIST' },
|
||||
{ key: 'SERVER_EXECUTE_RESULT', type: 'textarea', label: 'SERVER_EXECUTE_RESULT' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'qq',
|
||||
title: 'QQ controller (NapCat)',
|
||||
root: ['controllerMethod', 'qq(napcat)'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'napcatAddr', type: 'text', label: 'NapCat address' },
|
||||
{ key: 'napcatPort', type: 'text', label: 'NapCat port' },
|
||||
{ key: 'napcatToken', type: 'password', label: 'NapCat token' },
|
||||
{ key: 'botQQID', type: 'number', label: 'Bot QQ ID' },
|
||||
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'telegram',
|
||||
title: 'Telegram controller',
|
||||
root: ['controllerMethod', 'telegram'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'botToken', type: 'password', label: 'Bot token' },
|
||||
{ key: 'listenMethod', type: 'select', label: 'Listen method', options: ['global', 'at'] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: 'Email notifications',
|
||||
root: ['controllerMethod', 'email'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'smtpHost', type: 'text', label: 'SMTP host' },
|
||||
{ key: 'smtpPort', type: 'number', label: 'SMTP port' },
|
||||
{ key: 'username', type: 'text', label: 'Username' },
|
||||
{ key: 'password', type: 'password', label: 'Password' },
|
||||
{ key: 'from', type: 'text', label: 'From address' },
|
||||
{ key: 'to', type: 'tags', label: 'Recipients', placeholder: 'Type an address and press Enter' },
|
||||
{ key: 'useTLS', type: 'bool', label: 'Use TLS' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ntfy',
|
||||
title: 'Ntfy notifications',
|
||||
root: ['controllerMethod', 'ntfy'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'server', type: 'text', label: 'Server' },
|
||||
{ key: 'topic', type: 'text', label: 'Topic' },
|
||||
{ key: 'token', type: 'password', label: 'Token' },
|
||||
{ key: 'priority', type: 'select', label: 'Priority', options: ['min', 'low', 'default', 'high', 'urgent', 'max'] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'webhook',
|
||||
title: 'Webhook notifications',
|
||||
root: ['controllerMethod', 'webhook'],
|
||||
fields: [
|
||||
{ key: 'enabled', type: 'bool', label: 'Enabled' },
|
||||
{ key: 'networkUseProxy', type: 'bool', label: 'Use network proxy' },
|
||||
{ key: 'url', type: 'text', label: 'URL' },
|
||||
{ key: 'method', type: 'select', label: 'Method', options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
|
||||
{ key: 'headers', type: 'headers', label: 'Headers' },
|
||||
{ key: 'template', type: 'textarea', label: 'Payload template' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'paths',
|
||||
title: 'Storage paths',
|
||||
hint: 'Where runtime data lives. Takes effect on restart.',
|
||||
root: [],
|
||||
fields: [
|
||||
{ key: 'dataPath', type: 'text', label: 'Data path' },
|
||||
{ key: 'dbPath', type: 'text', label: 'Database path' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
const vals = reactive({});
|
||||
const saving = reactive(new Set());
|
||||
|
||||
function fieldPath(f) {
|
||||
return f.lp || [f.key];
|
||||
}
|
||||
|
||||
function getVal(obj, path, fb) {
|
||||
let cur = obj;
|
||||
for (const k of path) {
|
||||
if (cur === null || cur === undefined || typeof cur !== 'object') return fb;
|
||||
cur = cur[k];
|
||||
}
|
||||
return cur === undefined || cur === null ? fb : cur;
|
||||
}
|
||||
|
||||
// hasVal reports whether a path actually resolves in the loaded config. A
|
||||
// missing key and a key whose value equals the fallback are indistinguishable
|
||||
// from getVal's return value alone, so misses are detected separately.
|
||||
function hasVal(obj, path) {
|
||||
let cur = obj;
|
||||
for (const k of path) {
|
||||
if (cur === null || cur === undefined || typeof cur !== 'object') return false;
|
||||
cur = cur[k];
|
||||
}
|
||||
return cur !== undefined && cur !== null;
|
||||
}
|
||||
|
||||
function defaults(f) {
|
||||
switch (f.type) {
|
||||
case 'bool': return false;
|
||||
case 'number': return 0;
|
||||
case 'tags': return [];
|
||||
case 'headers': return {};
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await settingsApi.get('global');
|
||||
const cfg = (res && res.data && res.data.config) || {};
|
||||
for (const s of sections) {
|
||||
const obj = {};
|
||||
for (const f of s.fields) {
|
||||
// Mirror wrapRoot() on save: prepend the section's root path so
|
||||
// the value is read from the same place it is written to.
|
||||
const path = [...s.root, ...fieldPath(f)];
|
||||
obj[f.key] = getVal(cfg, path, defaults(f));
|
||||
if (debugMode.value) {
|
||||
console.log(`[Settings] Loaded ${s.id}.${f.key}:`, obj[f.key]);
|
||||
if (!hasVal(cfg, path)) {
|
||||
console.warn(`[Settings] ${s.id}.${f.key} missing at "${path.join('.')}" — using default`);
|
||||
}
|
||||
}
|
||||
}
|
||||
vals[s.id] = obj;
|
||||
}
|
||||
failed.value = false;
|
||||
} catch (e) {
|
||||
failed.value = true;
|
||||
if (e.status) toast.error('Failed to load settings: ' + e.message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(f, v) {
|
||||
switch (f.type) {
|
||||
case 'number': {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
case 'tags': return Array.isArray(v) ? v : [];
|
||||
case 'headers': return v && typeof v === 'object' ? v : {};
|
||||
default: return v === null || v === undefined ? '' : v;
|
||||
}
|
||||
}
|
||||
|
||||
function nest(obj) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const path = k.split('.');
|
||||
let o = out;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const seg = path[i];
|
||||
if (!o[seg]) o[seg] = {};
|
||||
o = o[seg];
|
||||
}
|
||||
o[path[path.length - 1]] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function wrapRoot(section, obj) {
|
||||
const root = section.root;
|
||||
if (!root.length) return obj;
|
||||
const out = {};
|
||||
let o = out;
|
||||
for (let i = 0; i < root.length - 1; i += 1) {
|
||||
o[root[i]] = {};
|
||||
o = o[root[i]];
|
||||
}
|
||||
o[root[root.length - 1]] = obj;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function save(section) {
|
||||
const source = vals[section.id];
|
||||
const obj = {};
|
||||
for (const f of section.fields) {
|
||||
obj[f.key] = normalize(f, source[f.key]);
|
||||
}
|
||||
const patch = wrapRoot(section, nest(obj));
|
||||
saving.add(section.id);
|
||||
try {
|
||||
await settingsApi.set('global', patch);
|
||||
toast.success(`${section.title} saved`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error('Failed to save: ' + e.message);
|
||||
} finally {
|
||||
saving.delete(section.id);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow">Configuration</p>
|
||||
<h1>System settings</h1>
|
||||
<p class="lead">Edit the global <span class="mono">config.json</span>. Every card saves only its own section — other fields stay untouched.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="btn btn-ghost" @click="load"><i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="failed" class="card empty">
|
||||
<i class="fas fa-plug-circle-xmark e-icon" />
|
||||
<p>Failed to load settings</p>
|
||||
<button class="btn btn-primary" @click="load">Retry</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="card empty"><span class="spinner" /></div>
|
||||
|
||||
<div v-else>
|
||||
<div v-for="s in sections" :key="s.id" class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>{{ s.title }}</h3>
|
||||
<p v-if="s.hint" class="hint">{{ s.hint }}</p>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" :disabled="saving.has(s.id)" @click="save(s)">
|
||||
<span v-if="saving.has(s.id)" class="spinner" style="width:12px;height:12px" />
|
||||
<i v-else class="fas fa-check" /> Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="form-grid">
|
||||
<template v-for="f in s.fields" :key="f.key">
|
||||
<div v-if="f.type === 'bool'" class="bool-cell">
|
||||
<Toggle :model-value="vals[s.id][f.key]" :label="f.label" @update:model-value="vals[s.id][f.key] = $event" />
|
||||
<p v-if="f.help" class="field-help">{{ f.help }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="f.type === 'tags'" class="field span-2">
|
||||
<label>{{ f.label }}</label>
|
||||
<TagsEditor v-model="vals[s.id][f.key]" :placeholder="f.placeholder" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="f.type === 'headers'" class="field span-2">
|
||||
<label>{{ f.label }}</label>
|
||||
<HeadersEditor v-model="vals[s.id][f.key]" />
|
||||
</div>
|
||||
|
||||
<div v-else class="field span-2">
|
||||
<label>{{ f.label }}</label>
|
||||
<textarea
|
||||
v-if="f.type === 'textarea'"
|
||||
v-model="vals[s.id][f.key]"
|
||||
class="textarea"
|
||||
rows="4"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<select
|
||||
v-else-if="f.type === 'select'"
|
||||
v-model="vals[s.id][f.key]"
|
||||
class="select"
|
||||
>
|
||||
<option v-for="opt in f.options" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
v-model="vals[s.id][f.key]"
|
||||
class="input"
|
||||
:type="f.type === 'password' ? 'password' : 'text'"
|
||||
:placeholder="f.placeholder || ''"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bool-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
padding-left: 50px;
|
||||
max-width: 340px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { settingsApi } from '../api/index.js';
|
||||
import { toast } from '../utils/toast.js';
|
||||
import Toggle from '../components/Toggle.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
|
||||
const CHANNELS = [
|
||||
{ key: 'qq(napcat)', label: 'QQ (NapCat)', hint: 'Members are identified by QQ number / group number' },
|
||||
{ key: 'telegram', label: 'Telegram', hint: 'Members are identified by @username or numeric ID / group ID' }
|
||||
];
|
||||
|
||||
const KINDS = [
|
||||
{ key: 'admins', label: 'Admins' },
|
||||
{ key: 'trustedGroups', label: 'Trusted groups' }
|
||||
];
|
||||
|
||||
const OPTIONS = [
|
||||
{ key: 'event_status_notify', label: 'Status notify' },
|
||||
{ key: 'event_bot_started', label: 'Startup notify' },
|
||||
{ key: 'event_reply', label: 'Command replies' }
|
||||
];
|
||||
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
const rows = ref([]);
|
||||
const saving = reactive(new Set());
|
||||
|
||||
const addModal = reactive({ open: false, ch: CHANNELS[0].key, kind: 'admins', id: '' });
|
||||
const removing = reactive({ open: false, ch: '', kind: '', id: '' });
|
||||
|
||||
const rowsOf = (ch, kind) => rows.value
|
||||
.filter((r) => r.ch === ch && r.kind === kind)
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
const summary = computed(() => {
|
||||
const out = {};
|
||||
for (const c of CHANNELS) {
|
||||
out[c.key] = { admins: rowsOf(c.key, 'admins').length, trustedGroups: rowsOf(c.key, 'trustedGroups').length };
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const defaultOpts = () => ({
|
||||
event_status_notify: true,
|
||||
event_bot_started: true,
|
||||
event_reply: true
|
||||
});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await settingsApi.get('bot_user_config');
|
||||
const cfg = (res && res.data && res.data.config) || {};
|
||||
const flat = [];
|
||||
for (const c of CHANNELS) {
|
||||
const chData = cfg[c.key] || {};
|
||||
for (const k of KINDS) {
|
||||
const map = chData[k.key] || {};
|
||||
for (const [id, opts] of Object.entries(map)) {
|
||||
flat.push({
|
||||
ch: c.key,
|
||||
kind: k.key,
|
||||
id,
|
||||
opts: {
|
||||
event_status_notify: opts.event_status_notify !== false,
|
||||
event_bot_started: opts.event_bot_started !== false,
|
||||
event_reply: opts.event_reply !== false
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.value = flat;
|
||||
failed.value = false;
|
||||
} catch (e) {
|
||||
failed.value = true;
|
||||
if (e.status) toast.error('Failed to load: ' + e.message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function patchRow(row) {
|
||||
const p = {};
|
||||
p[row.ch] = { [row.kind]: { [row.id]: { ...row.opts } } };
|
||||
return p;
|
||||
}
|
||||
|
||||
async function applyPatch(patch, okMsg, rollbackFn) {
|
||||
try {
|
||||
await settingsApi.set('bot_user_config', patch);
|
||||
if (okMsg) toast.success(okMsg);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (rollbackFn) rollbackFn();
|
||||
toast.error('Save failed: ' + e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOpt(row, key, value) {
|
||||
const prev = row.opts[key];
|
||||
row.opts[key] = value;
|
||||
applyPatch(patchRow(row), undefined, () => { row.opts[key] = prev; });
|
||||
}
|
||||
|
||||
function openAdd(ch, kind) {
|
||||
addModal.ch = ch;
|
||||
addModal.kind = kind;
|
||||
addModal.id = '';
|
||||
addModal.open = true;
|
||||
}
|
||||
|
||||
async function confirmAdd() {
|
||||
const id = addModal.id.trim();
|
||||
if (!id) return toast.warn('Enter a member ID');
|
||||
const exists = rows.value.some((r) => r.ch === addModal.ch && r.kind === addModal.kind && r.id === id);
|
||||
if (exists) {
|
||||
toast.warn('That member already exists');
|
||||
return;
|
||||
}
|
||||
const row = { ch: addModal.ch, kind: addModal.kind, id, opts: defaultOpts() };
|
||||
if (await applyPatch(patchRow(row), 'Member added')) {
|
||||
rows.value.push(row);
|
||||
addModal.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
function askRemove(row) {
|
||||
removing.ch = row.ch;
|
||||
removing.kind = row.kind;
|
||||
removing.id = row.id;
|
||||
removing.open = true;
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
const { ch, kind, id } = removing;
|
||||
const p = {};
|
||||
p[ch] = { [kind]: { [id]: null } };
|
||||
if (await applyPatch(p, 'Member removed')) {
|
||||
rows.value = rows.value.filter((r) => !(r.ch === ch && r.kind === kind && r.id === id));
|
||||
removing.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow">Bot trust</p>
|
||||
<h1>Trust management</h1>
|
||||
<p class="lead">Which admins and groups each bot channel answers to, plus per-member notification and reply switches.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="btn btn-ghost" @click="load">
|
||||
<i class="fas fa-rotate" :class="{ 'fa-spin': loading }" /> Reload
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="failed" class="card empty">
|
||||
<i class="fas fa-plug-circle-xmark e-icon" />
|
||||
<p>Failed to load, please check the backend connection</p>
|
||||
<button class="btn btn-primary" @click="load">Retry</button>
|
||||
</div>
|
||||
|
||||
<div v-for="c in CHANNELS" :key="c.key" class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3>{{ c.label }}</h3>
|
||||
<p class="hint">{{ c.hint }}</p>
|
||||
</div>
|
||||
<span class="chip">
|
||||
{{ summary[c.key].admins }} admins · {{ summary[c.key].trustedGroups }} groups
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div v-for="k in KINDS" :key="k.key" class="kind-block">
|
||||
<div class="kind-head">
|
||||
<span class="kind-title">{{ k.label }}</span>
|
||||
<button class="btn btn-ghost btn-sm" @click="openAdd(c.key, k.key)">
|
||||
<i class="fas fa-plus" /> Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="empty" style="padding:24px"><span class="spinner" /></div>
|
||||
<div v-else-if="rowsOf(c.key, k.key).length === 0" class="empty" style="padding:22px">
|
||||
No {{ k.label.toLowerCase() }} yet
|
||||
</div>
|
||||
<div v-else class="opt-list">
|
||||
<div v-for="r in rowsOf(c.key, k.key)" :key="r.id" class="opt-row">
|
||||
<div class="who">
|
||||
<span class="id mono">{{ r.id }}</span>
|
||||
</div>
|
||||
<div class="toggles">
|
||||
<div v-for="o in OPTIONS" :key="o.key" class="oc">
|
||||
<span class="oc-label">{{ o.label }}</span>
|
||||
<Toggle
|
||||
:model-value="r.opts[o.key]"
|
||||
@update:model-value="toggleOpt(r, o.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
<button class="icon-btn danger" title="Remove" @click="askRemove(r)">
|
||||
<i class="fas fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal :open="addModal.open" title="Add member" @close="addModal.open = false">
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>Channel</label>
|
||||
<select v-model="addModal.ch" class="select">
|
||||
<option v-for="c in CHANNELS" :key="c.key" :value="c.key">{{ c.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Type</label>
|
||||
<select v-model="addModal.kind" class="select">
|
||||
<option v-for="k in KINDS" :key="k.key" :value="k.key">{{ k.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field span-2">
|
||||
<label>Member ID</label>
|
||||
<input
|
||||
v-model="addModal.id"
|
||||
class="input mono"
|
||||
placeholder="QQ / group number, or Telegram @username / numeric ID"
|
||||
@keydown.enter="confirmAdd"
|
||||
/>
|
||||
<p class="help">New members start with every notification enabled; adjust them in the list.</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #foot>
|
||||
<button class="btn btn-ghost" @click="addModal.open = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="confirmAdd">Add</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal :open="removing.open" title="Remove member" @close="removing.open = false">
|
||||
<p style="line-height:1.6">
|
||||
Remove <strong class="mono">{{ removing.id }}</strong> from
|
||||
<strong>{{ KINDS.find((k) => k.key === removing.kind)?.label }}</strong>?
|
||||
</p>
|
||||
<template #foot>
|
||||
<button class="btn btn-ghost" @click="removing.open = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="confirmRemove">Remove</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kind-block + .kind-block { margin-top: 20px; }
|
||||
|
||||
.kind-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.kind-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.toggles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.oc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.oc-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
// Backend the dev server proxies /api (and the log websocket) to.
|
||||
const BACKEND = process.env.NUKUMIZU_API || 'http://127.0.0.1:8080';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: BACKEND,
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
module nukumizu-backend
|
||||
|
||||
go 1.25.0
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/go-telegram/bot v1.25.0
|
||||
|
||||
+84
-13
@@ -18,6 +18,38 @@ type ServerExecRequest struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// ServerInfoValue is the per-server payload returned by GET /api/server/getInfo,
|
||||
// mirroring the static server info the Bot prints for /info.
|
||||
type ServerInfoValue struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Info *node.Info `json:"info"`
|
||||
}
|
||||
|
||||
// ServerStatusValue is the per-server payload returned by GET /api/server/getStatus,
|
||||
// mirroring the live status the Bot prints for /status. Report is null when the
|
||||
// node is known but has not delivered a status report yet.
|
||||
type ServerStatusValue struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Online bool `json:"online"`
|
||||
Report *node.Report `json:"report"`
|
||||
}
|
||||
|
||||
// collectTargetNodes resolves the uuid query parameter into the list of nodes
|
||||
// the caller asked for. uuid "all" selects every tracked node; any other uuid
|
||||
// selects that single node. A boolean reports whether the uuid was found.
|
||||
func collectTargetNodes(tracker *node.Tracker, uuid string) ([]*node.Node, bool) {
|
||||
if uuid == "all" {
|
||||
return tracker.GetAllNodes(), true
|
||||
}
|
||||
n, exists := tracker.GetNode(uuid)
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
return []*node.Node{n}, true
|
||||
}
|
||||
|
||||
// ServerListHandler handles GET /api/server/list.
|
||||
func ServerListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !utils.Auth(w, r, "GET", "bot") {
|
||||
@@ -38,9 +70,11 @@ func ServerListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// ServerGetStatusHandler handles GET /api/server/getStatus?uuid=xxx.
|
||||
func ServerGetStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !utils.Auth(w, r, "GET", "bot") {
|
||||
// ServerGetInfoHandler handles GET /api/server/getInfo?uuid=xxx|all.
|
||||
// Returns the static info of the requested server(s) as a uuid-keyed object,
|
||||
// mirroring the data the Bot uses for /info.
|
||||
func ServerGetInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !utils.Auth(w, r, "GET", "admin") {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,19 +84,56 @@ func ServerGetStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// First try to get data from the local tracker.
|
||||
tracker := node.GetTracker()
|
||||
if tracker != nil {
|
||||
if n, exists := tracker.GetNode(uuid); exists && n.LatestReport != nil {
|
||||
utils.SendSuccessResponse(w, "", map[string]interface{}{
|
||||
"uuid": uuid,
|
||||
"report": n.LatestReport,
|
||||
})
|
||||
return
|
||||
}
|
||||
if tracker == nil {
|
||||
utils.SendErrorResponse(w, http.StatusInternalServerError, "node tracker not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
utils.SendErrorResponse(w, http.StatusNotFound, fmt.Sprintf("no recent data for uuid: %s", uuid))
|
||||
nodes, found := collectTargetNodes(tracker, uuid)
|
||||
if !found {
|
||||
utils.SendErrorResponse(w, http.StatusNotFound, fmt.Sprintf("server with uuid %s not found", uuid))
|
||||
return
|
||||
}
|
||||
|
||||
result := make(map[string]interface{}, len(nodes))
|
||||
for _, n := range nodes {
|
||||
result[n.UUID] = ServerInfoValue{UUID: n.UUID, Name: n.Name, Info: n.Info}
|
||||
}
|
||||
utils.SendSuccessResponse(w, "", result)
|
||||
}
|
||||
|
||||
// ServerGetStatusHandler handles GET /api/server/getStatus?uuid=xxx|all.
|
||||
// Returns the live status of the requested server(s) as a uuid-keyed object,
|
||||
// mirroring the data the Bot uses for /status.
|
||||
func ServerGetStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !utils.Auth(w, r, "GET", "admin") {
|
||||
return
|
||||
}
|
||||
|
||||
uuid := r.URL.Query().Get("uuid")
|
||||
if uuid == "" {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "missing uuid parameter")
|
||||
return
|
||||
}
|
||||
|
||||
tracker := node.GetTracker()
|
||||
if tracker == nil {
|
||||
utils.SendErrorResponse(w, http.StatusInternalServerError, "node tracker not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
nodes, found := collectTargetNodes(tracker, uuid)
|
||||
if !found {
|
||||
utils.SendErrorResponse(w, http.StatusNotFound, fmt.Sprintf("server with uuid %s not found", uuid))
|
||||
return
|
||||
}
|
||||
|
||||
result := make(map[string]interface{}, len(nodes))
|
||||
for _, n := range nodes {
|
||||
result[n.UUID] = ServerStatusValue{UUID: n.UUID, Name: n.Name, Online: n.Online, Report: n.LatestReport}
|
||||
}
|
||||
utils.SendSuccessResponse(w, "", result)
|
||||
}
|
||||
|
||||
// ServerExecHandler handles POST /api/server/exec.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nukumizu-backend/internal/node"
|
||||
"nukumizu-backend/utils"
|
||||
)
|
||||
|
||||
// adminGet builds an authenticated GET request for the given path using an
|
||||
// admin token and a fresh X-Timestamp.
|
||||
func adminGet(t *testing.T, path string) *http.Request {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-Token", "test-admin-token")
|
||||
req.Header.Set("X-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
return req
|
||||
}
|
||||
|
||||
// seedTracker initializes the global node tracker with two nodes, one online
|
||||
// with a status report and one offline that has not reported yet.
|
||||
func seedTracker(t *testing.T) {
|
||||
t.Helper()
|
||||
node.InitTracker()
|
||||
|
||||
tracker := node.GetTracker()
|
||||
tracker.UpdateNodeList(map[string]node.NodeListEntry{
|
||||
"u1": {
|
||||
Name: "alpha",
|
||||
Info: &node.Info{},
|
||||
},
|
||||
"u2": {
|
||||
Name: "beta",
|
||||
Info: &node.Info{},
|
||||
},
|
||||
})
|
||||
|
||||
report := node.Report{}
|
||||
report.CPU.Usage = 12.5
|
||||
report.RAM.Total = 1024
|
||||
report.RAM.Used = 512
|
||||
tracker.UpdateStatus([]string{"u1"}, map[string]node.Report{"u1": report})
|
||||
}
|
||||
|
||||
func setupAdminToken() {
|
||||
utils.AddToken("test-admin-token", 1, "admin", "tester")
|
||||
}
|
||||
|
||||
func decodeResponse(t *testing.T, w *httptest.ResponseRecorder) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
var body map[string]json.RawMessage
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response: %v; body=%s", err, w.Body.String())
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestServerGetInfoAll(t *testing.T) {
|
||||
setupAdminToken()
|
||||
seedTracker(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ServerGetInfoHandler(w, adminGet(t, "/api/server/getInfo?uuid=all"))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
body := decodeResponse(t, w)
|
||||
for _, uuid := range []string{"u1", "u2"} {
|
||||
if _, ok := body[uuid]; !ok {
|
||||
t.Errorf("response missing uuid %q: %s", uuid, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
var one struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Info *node.Info `json:"info"`
|
||||
}
|
||||
if err := json.Unmarshal(body["u1"], &one); err != nil {
|
||||
t.Fatalf("decode u1: %v", err)
|
||||
}
|
||||
if one.UUID != "u1" || one.Name != "alpha" {
|
||||
t.Errorf("u1 = %+v", one)
|
||||
}
|
||||
if one.Info == nil {
|
||||
t.Error("expected static info present for u1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerGetInfoSingleAndMissing(t *testing.T) {
|
||||
setupAdminToken()
|
||||
seedTracker(t)
|
||||
|
||||
// Single existing uuid: response is keyed by that uuid (uniform shape).
|
||||
w := httptest.NewRecorder()
|
||||
ServerGetInfoHandler(w, adminGet(t, "/api/server/getInfo?uuid=u1"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := decodeResponse(t, w)
|
||||
if _, ok := body["u1"]; !ok {
|
||||
t.Errorf("single-uuid response missing key u1: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// Unknown uuid yields 404.
|
||||
w2 := httptest.NewRecorder()
|
||||
ServerGetInfoHandler(w2, adminGet(t, "/api/server/getInfo?uuid=ghost"))
|
||||
if w2.Code != http.StatusNotFound {
|
||||
t.Errorf("missing uuid status = %d, want 404", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerGetStatusAll(t *testing.T) {
|
||||
setupAdminToken()
|
||||
seedTracker(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ServerGetStatusHandler(w, adminGet(t, "/api/server/getStatus?uuid=all"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
body := decodeResponse(t, w)
|
||||
|
||||
var online struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Online bool `json:"online"`
|
||||
Report *node.Report `json:"report"`
|
||||
}
|
||||
if err := json.Unmarshal(body["u1"], &online); err != nil {
|
||||
t.Fatalf("decode u1: %v", err)
|
||||
}
|
||||
if !online.Online || online.Report == nil {
|
||||
t.Errorf("u1 should be online with a report: %+v", online)
|
||||
}
|
||||
|
||||
var offline struct {
|
||||
Online bool `json:"online"`
|
||||
Report *node.Report `json:"report"`
|
||||
}
|
||||
if err := json.Unmarshal(body["u2"], &offline); err != nil {
|
||||
t.Fatalf("decode u2: %v", err)
|
||||
}
|
||||
if offline.Online {
|
||||
t.Error("u2 should be offline")
|
||||
}
|
||||
if offline.Report != nil {
|
||||
t.Errorf("u2 report should be null, got %+v", offline.Report)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"nukumizu-backend/config"
|
||||
"nukumizu-backend/postLog"
|
||||
"nukumizu-backend/utils"
|
||||
)
|
||||
|
||||
// SettingsGetHandler handles GET /api/settings/get?type=xxx.
|
||||
// type selects which config file to return and may be one of
|
||||
// "global", "bot_user_config" or "bot_node_config"; the returned data.config
|
||||
// object has the same layout as the source JSON file.
|
||||
func SettingsGetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !utils.Auth(w, r, "GET", "admin") {
|
||||
return
|
||||
}
|
||||
|
||||
settingsType := r.URL.Query().Get("type")
|
||||
if settingsType == "" {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "missing type parameter")
|
||||
return
|
||||
}
|
||||
if !config.IsValidSettingsType(settingsType) {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid type parameter: "+settingsType)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := config.GetSettings(settingsType)
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
utils.SendSuccessResponse(w, "", map[string]interface{}{
|
||||
"config": json.RawMessage(data),
|
||||
})
|
||||
}
|
||||
|
||||
// SettingsSetHandler handles POST /api/settings/set?type=xxx.
|
||||
// type selects which config file to update and may be one of "global",
|
||||
// "bot_user_config" or "bot_node_config". The JSON body is a partial config
|
||||
// object whose keys map directly to config entries, e.g.
|
||||
//
|
||||
// {"system": {"debugMode": true}}
|
||||
//
|
||||
// Multiple entries may be given at once; only the provided keys are changed.
|
||||
func SettingsSetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !utils.Auth(w, r, "POST", "admin") {
|
||||
return
|
||||
}
|
||||
|
||||
settingsType := r.URL.Query().Get("type")
|
||||
if settingsType == "" {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "missing type parameter")
|
||||
return
|
||||
}
|
||||
if !config.IsValidSettingsType(settingsType) {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid type parameter: "+settingsType)
|
||||
return
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.UseNumber() // Keep numeric values verbatim (e.g. QQ IDs) instead of float64.
|
||||
var patch map[string]interface{}
|
||||
if err := dec.Decode(&patch); err != nil {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "invalid request body: expected a JSON object")
|
||||
return
|
||||
}
|
||||
if patch == nil {
|
||||
utils.SendErrorResponse(w, http.StatusBadRequest, "request body must be a JSON object")
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.UpdateSettings(settingsType, patch); err != nil {
|
||||
postLog.Error("Failed to update " + settingsType + " settings: " + err.Error())
|
||||
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
utils.SendSuccessResponse(w, "settings updated successfully", map[string]interface{}{
|
||||
"type": settingsType,
|
||||
})
|
||||
}
|
||||
+9
-2
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
db "nukumizu-backend/database"
|
||||
@@ -98,10 +99,16 @@ func UserRegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := db.CreateUser(req.Username, req.Password, "admin")
|
||||
userID, err := db.RegisterFirstUser(req.Username, req.Password, "admin")
|
||||
if err != nil {
|
||||
// A concurrent registration may have won between the count check above
|
||||
// and this insert; both map to the same "registration is closed" answer.
|
||||
if errors.Is(err, db.ErrUsersExist) {
|
||||
utils.SendErrorResponse(w, http.StatusForbidden, "registration is closed: users already exist")
|
||||
return
|
||||
}
|
||||
postLog.Error("Failed to register user: " + err.Error())
|
||||
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to register user, username may already exist")
|
||||
utils.SendErrorResponse(w, http.StatusInternalServerError, "failed to register user")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
db "nukumizu-backend/database"
|
||||
)
|
||||
|
||||
// initHandlerUserDB opens a fresh user database in a temp directory for the
|
||||
// register handler tests.
|
||||
func initHandlerUserDB(t *testing.T) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "user.db")
|
||||
if err := db.InitUserDB(path); err != nil {
|
||||
t.Fatalf("InitUserDB: %v", err)
|
||||
}
|
||||
t.Cleanup(db.CloseUserDB)
|
||||
}
|
||||
|
||||
func registerRequest(t *testing.T, username, password string) *http.Request {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(map[string]string{"username": username, "password": password})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/user/register", bytes.NewReader(body))
|
||||
req.Header.Set("X-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
return req
|
||||
}
|
||||
|
||||
// TestRegisterOnlyFirstUser exercises the API rule: the database accepts
|
||||
// exactly the first registration and rejects every later one.
|
||||
func TestRegisterOnlyFirstUser(t *testing.T) {
|
||||
initHandlerUserDB(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
UserRegisterHandler(w, registerRequest(t, "alice", "password1"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("first register status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
UserRegisterHandler(w2, registerRequest(t, "bob", "password2"))
|
||||
if w2.Code != http.StatusForbidden {
|
||||
t.Fatalf("second register status = %d, body = %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,13 @@ func main() {
|
||||
log.Fatalf("Failed to load bot user config: %v", err)
|
||||
}
|
||||
|
||||
// Load the node registry config. Unlike the other files it is optional:
|
||||
// missing or empty bot_node_config.json simply means every node keeps its
|
||||
// default enableStatusNotify (true).
|
||||
if err := config.LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil {
|
||||
log.Fatalf("Failed to load bot node config: %v", err)
|
||||
}
|
||||
|
||||
// Initialize logging.
|
||||
postLog.SetDebugMode(cfg.System.DebugMode)
|
||||
postLog.InitLogBroadcaster()
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"nukumizu-backend/handler"
|
||||
"nukumizu-backend/postLog"
|
||||
"nukumizu-backend/web"
|
||||
)
|
||||
|
||||
// SetupRouter registers all HTTP routes and returns a configured ServeMux.
|
||||
@@ -20,9 +21,14 @@ func SetupRouter() *http.ServeMux {
|
||||
|
||||
// Server endpoints (authenticated).
|
||||
mux.HandleFunc("/api/server/list", handler.ServerListHandler)
|
||||
mux.HandleFunc("/api/server/getInfo", handler.ServerGetInfoHandler)
|
||||
mux.HandleFunc("/api/server/getStatus", handler.ServerGetStatusHandler)
|
||||
mux.HandleFunc("/api/server/exec", handler.ServerExecHandler)
|
||||
|
||||
// Settings endpoints (admin only).
|
||||
mux.HandleFunc("/api/settings/get", handler.SettingsGetHandler)
|
||||
mux.HandleFunc("/api/settings/set", handler.SettingsSetHandler)
|
||||
|
||||
// Health check endpoint.
|
||||
mux.HandleFunc("/health", handler.HealthHandler)
|
||||
|
||||
@@ -33,8 +39,8 @@ func SetupRouter() *http.ServeMux {
|
||||
mux.HandleFunc("/api/system/getLogs", logSocketHandler.Handle)
|
||||
}
|
||||
|
||||
// Catch-all 404 handler.
|
||||
mux.HandleFunc("/", NotFoundHandler)
|
||||
// Static file serving for the web frontend.
|
||||
mux.HandleFunc("/", web.ServeStatic)
|
||||
|
||||
postLog.Info("Router setup completed")
|
||||
return mux
|
||||
|
||||
+6
-3
@@ -203,7 +203,9 @@ func StartTokenCleaner() {
|
||||
}()
|
||||
}
|
||||
|
||||
// SendSuccessResponse sends a standardized JSON success response.
|
||||
// SendSuccessResponse sends a standardized JSON success response. Every payload
|
||||
// is nested under a single "data" key, so success responses use the envelope
|
||||
// {"success": true, "message": "...", "data": {...}}.
|
||||
func SendSuccessResponse(w http.ResponseWriter, message string, data map[string]interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
resp := map[string]interface{}{
|
||||
@@ -212,9 +214,10 @@ func SendSuccessResponse(w http.ResponseWriter, message string, data map[string]
|
||||
if message != "" {
|
||||
resp["message"] = message
|
||||
}
|
||||
for k, v := range data {
|
||||
resp[k] = v
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
resp["data"] = data
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// StaticFiles is the built frontend, rooted at the directory Vite writes to
|
||||
// (frontend/dist), so paths inside it are relative to that directory, e.g.
|
||||
// "index.html" or "assets/app.js". The path is relative to the working
|
||||
// directory, so the server is expected to run from the repository root.
|
||||
var StaticFiles fs.FS
|
||||
|
||||
func init() {
|
||||
dir := filepath.Join("frontend", "dist")
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
StaticFiles = os.DirFS(dir)
|
||||
return
|
||||
}
|
||||
|
||||
StaticFiles = os.DirFS(".")
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var staticFS http.FileSystem
|
||||
|
||||
// This init runs after embed.go's, which sets StaticFiles (Go initializes a
|
||||
// package's files in lexical file-name order). StaticFiles is already rooted
|
||||
// at frontend/dist, so it is served as-is — no further fs.Sub is needed.
|
||||
func init() {
|
||||
staticFS = http.FS(StaticFiles)
|
||||
}
|
||||
|
||||
func ServeStatic(w http.ResponseWriter, r *http.Request) {
|
||||
urlPath := r.URL.Path
|
||||
|
||||
if urlPath == "/" || urlPath == "/index.html" || strings.HasPrefix(urlPath, "/assets/") {
|
||||
if urlPath == "/" {
|
||||
urlPath = "/index.html"
|
||||
}
|
||||
|
||||
filePath := strings.TrimPrefix(urlPath, "/")
|
||||
f, err := staticFS.Open(filePath)
|
||||
if err != nil {
|
||||
serveIndexHTML(w)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
serveIndexHTML(w)
|
||||
return
|
||||
}
|
||||
|
||||
if stat.IsDir() {
|
||||
serveIndexHTML(w)
|
||||
return
|
||||
}
|
||||
|
||||
setContentType(w, path.Ext(filePath))
|
||||
http.ServeContent(w, r, filePath, stat.ModTime(), f)
|
||||
return
|
||||
}
|
||||
|
||||
serveIndexHTML(w)
|
||||
}
|
||||
|
||||
func serveIndexHTML(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
data, err := fs.ReadFile(StaticFiles, "index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "index.html not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func setContentType(w http.ResponseWriter, ext string) {
|
||||
switch ext {
|
||||
case ".html":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
case ".css":
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
case ".js":
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
case ".json":
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
case ".png":
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
case ".jpg", ".jpeg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
case ".gif":
|
||||
w.Header().Set("Content-Type", "image/gif")
|
||||
case ".svg":
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
case ".ico":
|
||||
w.Header().Set("Content-Type", "image/x-icon")
|
||||
case ".woff":
|
||||
w.Header().Set("Content-Type", "font/woff")
|
||||
case ".woff2":
|
||||
w.Header().Set("Content-Type", "font/woff2")
|
||||
case ".ttf":
|
||||
w.Header().Set("Content-Type", "font/ttf")
|
||||
case ".eot":
|
||||
w.Header().Set("Content-Type", "application/vnd.ms-fontobject")
|
||||
default:
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user