Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59c0f5d5d8 | ||
|
|
16455027ec | ||
|
|
4be6d2add0 | ||
|
|
15144fb9d4 | ||
|
|
a89ad8143a | ||
|
|
fb5b46a548 | ||
|
|
f6092cd178 | ||
|
|
3cba5df5c7 | ||
|
|
b2566d45f8 | ||
|
|
8b88377d4f | ||
|
|
f6af57cf58 | ||
|
|
160918a93e | ||
|
|
f622baadbd | ||
|
|
78284ed816 | ||
|
|
d96b90b5bf | ||
|
|
c0eada9bcc | ||
|
|
378727ac57 | ||
|
|
8b43e8b2ea | ||
|
|
786f364743 | ||
|
|
a0e2df615c |
@@ -0,0 +1,9 @@
|
|||||||
|
* text=auto
|
||||||
|
|
||||||
|
# cmd.exe misparses a batch file whose lines end in a bare LF: it loses
|
||||||
|
# characters at the start of later lines, so a working script silently turns
|
||||||
|
# into "command not recognized" errors. Force CRLF on checkout.
|
||||||
|
*.bat text eol=crlf
|
||||||
|
|
||||||
|
# The mirror image: a CR at the end of a shebang or line breaks these.
|
||||||
|
*.sh text eol=lf
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
# Each script builds the Vue console itself and then compiles it into the
|
||||||
|
# binary (web/embed.go), so the uploaded executables are self-contained.
|
||||||
|
- name: Build Linux (amd64)
|
||||||
|
run: ${{ matrix.build_linux }}
|
||||||
|
|
||||||
|
- 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
|
||||||
@@ -6,6 +6,11 @@ bot_node_config.json
|
|||||||
*.exe
|
*.exe
|
||||||
nukumizu-linux-amd64
|
nukumizu-linux-amd64
|
||||||
|
|
||||||
|
# The built web console. web/embed.go compiles it into the binary, and the
|
||||||
|
# build-*.sh / build-*.bat scripts rebuild it before every compile.
|
||||||
|
/web/dist/
|
||||||
|
/frontend/dist/
|
||||||
|
|
||||||
# SQLite database files. They live on a network share (Y:), and git/cloud
|
# SQLite database files. They live on a network share (Y:), and git/cloud
|
||||||
# sync touching them while a WAL database is open corrupts the WAL index and
|
# sync touching them while a WAL database is open corrupts the WAL index and
|
||||||
# crashes the process (EXCEPTION_IN_PAGE_ERROR). Never track these.
|
# crashes the process (EXCEPTION_IN_PAGE_ERROR). Never track these.
|
||||||
|
|||||||
@@ -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`.
|
- **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).
|
- **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.
|
- **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 built bundle is embedded in the binary, so a single executable serves both the API and the console.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -37,7 +38,8 @@ nukumizu-backend/
|
|||||||
│ └── variables.go # Software build metadata (name/version/developer)
|
│ └── variables.go # Software build metadata (name/version/developer)
|
||||||
├── handler/
|
├── handler/
|
||||||
│ ├── user.go # /api/user/login, /api/user/register
|
│ ├── 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
|
│ └── health.go # /health
|
||||||
├── database/
|
├── database/
|
||||||
│ └── user.go # user.db (SQLite) user store
|
│ └── user.go # user.db (SQLite) user store
|
||||||
@@ -49,36 +51,52 @@ nukumizu-backend/
|
|||||||
│ ├── database.go # log.db (SQLite, one table per run)
|
│ ├── database.go # log.db (SQLite, one table per run)
|
||||||
│ ├── logBroadcaster.go # Fan-out to WebSocket clients
|
│ ├── logBroadcaster.go # Fan-out to WebSocket clients
|
||||||
│ └── logSocketHandler.go # /api/system/getLogs WebSocket handler
|
│ └── logSocketHandler.go # /api/system/getLogs WebSocket handler
|
||||||
└── internal/
|
├── web/
|
||||||
├── komari/
|
│ ├── embed.go # Embeds the built console (web/dist) in the binary
|
||||||
│ ├── client.go # Komari HTTP/JSON-RPC client (login, nodes, task exec/poll)
|
│ ├── dist/ # Vite build output — generated, gitignored
|
||||||
│ └── ws.go # Komari status WebSocket (poll + reconnect)
|
│ └── handler.go # Static file serving + SPA fallback
|
||||||
├── node/
|
├── internal/
|
||||||
│ └── tracker.go # Thread-safe server state, status-change detection
|
│ ├── komari/
|
||||||
├── netproxy/
|
│ │ ├── client.go # Komari HTTP/JSON-RPC client (login, nodes, task exec/poll)
|
||||||
│ └── netproxy.go # Unified network proxy for controllers
|
│ │ └── ws.go # Komari status WebSocket (poll + reconnect)
|
||||||
├── template/
|
│ ├── node/
|
||||||
│ └── template.go # Message template renderer ({{ variables }})
|
│ │ └── tracker.go # Thread-safe server state, status-change detection
|
||||||
└── controller/
|
│ ├── netproxy/
|
||||||
├── controller.go # Manager, Controller / BotController interfaces
|
│ │ └── netproxy.go # Unified network proxy for controllers
|
||||||
├── trigger.go # Command parsing, authorization, routing
|
│ ├── template/
|
||||||
├── processor.go # Command handlers
|
│ │ └── template.go # Message template renderer ({{ variables }})
|
||||||
├── utils.go
|
│ └── controller/
|
||||||
└── pipes/
|
│ ├── controller.go # Manager, Controller / BotController interfaces
|
||||||
├── email.go # Email notification pipe
|
│ ├── trigger.go # Command parsing, authorization, routing
|
||||||
├── ntfy.go # ntfy notification pipe
|
│ ├── processor.go # Command handlers
|
||||||
├── webhook.go # Webhook notification pipe
|
│ ├── utils.go
|
||||||
├── qq_napcat/
|
│ └── pipes/
|
||||||
│ ├── qq.go # QQ (NapCat / OneBot 11) bot controller
|
│ ├── email.go # Email notification pipe
|
||||||
│ └── napcat.go # NapCat WebSocket + HTTP API client
|
│ ├── ntfy.go # ntfy notification pipe
|
||||||
└── telegram/
|
│ ├── webhook.go # Webhook notification pipe
|
||||||
├── telegram.go # Telegram bot controller (go-telegram/bot, long polling)
|
│ ├── qq_napcat/
|
||||||
└── send.go # Message sending / splitting (Telegram Markdown)
|
│ │ ├── 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
|
## Requirements
|
||||||
|
|
||||||
- Go **1.25** or newer
|
- 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
|
- 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 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)
|
- For Telegram: a bot token from [@BotFather](https://t.me/BotFather)
|
||||||
@@ -256,7 +274,7 @@ Admins and trusted groups are defined **per bot channel** and map a member ID to
|
|||||||
|
|
||||||
## API
|
## 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
|
### Authentication
|
||||||
|
|
||||||
@@ -273,12 +291,15 @@ Tokens idle for more than 1 hour are expired (cleaned every 10 minutes); any aut
|
|||||||
|
|
||||||
| Endpoint | Method | Permission | Description |
|
| Endpoint | Method | Permission | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `/api/user/login` | POST | None | Log in. Body `{username, password}`. Returns `{token, userID, username, level, registerDate}`. |
|
| `/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 `{token, userID, username, level}`. |
|
| `/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/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/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/exec` | POST | bot / admin | Execute a command. Body `{uuid: [<uuid>...], command}`. Dispatches a Komari task and polls until completion (or timeout). Returns `{taskID, results}`. |
|
| `/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. |
|
||||||
| `/health` | GET | None | Health check. Returns `{status, database}`. |
|
| `/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. |
|
| `/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:
|
Middleware applied to the whole server:
|
||||||
@@ -322,19 +343,36 @@ QQ and Telegram are *interactive* channels. Email, ntfy, and webhook are **statu
|
|||||||
|
|
||||||
## Building
|
## 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 build the Vue console first, then compile the backend with it embedded. They also 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
|
```bash
|
||||||
# Windows (cross-compile to Linux amd64)
|
# Linux / macOS
|
||||||
build-linux-x86_64.bat
|
./build-linux-x86_64.sh
|
||||||
|
./build-win-x86_64.sh
|
||||||
|
|
||||||
# Windows amd64
|
# Windows
|
||||||
|
build-linux-x86_64.bat
|
||||||
build-win-x86_64.bat
|
build-win-x86_64.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The console is **embedded in the binary**. Vite writes it to `web/dist` and `web/embed.go` compiles that directory in with `go:embed`, so the executable serves the whole frontend on its own — copy it anywhere, with neither `frontend/` nor `web/dist` next to it, and `/` still returns the console. The build scripts run `npm ci` when `frontend/node_modules` is missing and `npm run build` on every run, so they need Node.js 22+ on the build machine (not on the machine that runs the binary).
|
||||||
|
|
||||||
|
Building the backend therefore requires the console to have been built at least once: `web/dist` is a generated, gitignored directory, and `go build` fails with `pattern all:dist: no matching files found` until it exists. Any `build-*` script handles that ordering for you.
|
||||||
|
|
||||||
Equivalent manual builds:
|
Equivalent manual builds:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 1. Console (once per frontend change)
|
||||||
|
cd frontend && npm ci && npm run build && cd ..
|
||||||
|
|
||||||
|
# 2. Backend
|
||||||
# Linux / macOS
|
# Linux / macOS
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
go build -ldflags "-X main.CommitHash=$(git rev-parse --short HEAD) -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
go build -ldflags "-X main.CommitHash=$(git rev-parse --short HEAD) -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
@@ -348,6 +386,8 @@ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \
|
|||||||
|
|
||||||
`main.CommitHash` and `main.BuildTime` are surfaced in logs and in the `BOT_STARTED` message.
|
`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 self-contained binaries as artifacts.
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
Both `config.json` and `bot_user_config.json` must exist in the working directory (or be passed explicitly):
|
Both `config.json` and `bot_user_config.json` must exist in the working directory (or be passed explicitly):
|
||||||
@@ -362,6 +402,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.
|
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, so `web/dist` must already exist (run any `build-*` script once, or `npm run build` in `frontend/`) or `go run` fails to compile. 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 the embedded console itself, on the normal listen address — see [Building](#building). Vite is not involved at runtime, so `npm run build` alone does not change what a running binary serves: rebuild the binary to pick up frontend changes.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
See [LICENSE](LICENSE).
|
See [LICENSE](LICENSE).
|
||||||
|
|||||||
+37
-4
@@ -1,13 +1,44 @@
|
|||||||
@echo off
|
@echo off
|
||||||
setlocal enabledelayedexpansion
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
:: Build from the repository root, however the script was invoked.
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
:: The Vue console is built first and embedded into the binary (web\dist, see
|
||||||
|
:: web\embed.go), so the executable serves the whole frontend on its own:
|
||||||
|
:: neither frontend\ nor web\dist\ is needed where it runs.
|
||||||
|
echo Building frontend...
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
:: node_modules is gitignored, so a fresh checkout (CI included) installs from
|
||||||
|
:: the lockfile; a warm tree only rebuilds.
|
||||||
|
if not exist "node_modules" (
|
||||||
|
call npm ci
|
||||||
|
if errorlevel 1 goto :frontend_failed
|
||||||
|
)
|
||||||
|
|
||||||
|
call npm run build
|
||||||
|
if errorlevel 1 goto :frontend_failed
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
:: go:embed on web\dist fails anyway, but this names the real problem.
|
||||||
|
if exist "web\dist\index.html" goto :backend
|
||||||
|
echo Frontend build produced no web\dist\index.html.
|
||||||
|
goto :fail
|
||||||
|
|
||||||
|
:frontend_failed
|
||||||
|
cd ..
|
||||||
|
echo Frontend build failed.
|
||||||
|
goto :fail
|
||||||
|
|
||||||
|
:backend
|
||||||
echo Building for Linux (amd64)...
|
echo Building for Linux (amd64)...
|
||||||
|
|
||||||
:: Get git commit hash (shortened to 7 characters, can also use full)
|
:: 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
|
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
|
||||||
|
|
||||||
:: Get UTC time
|
:: Get UTC time
|
||||||
for /f %%i in ('powershell -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'"') do set BUILD_DATE=%%i
|
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 environment variables for Linux build
|
||||||
set GOOS=linux
|
set GOOS=linux
|
||||||
@@ -25,9 +56,11 @@ echo BuildDate: %BUILD_DATE%
|
|||||||
echo Output: %OUTPUT%
|
echo Output: %OUTPUT%
|
||||||
|
|
||||||
go build -ldflags "%LDFLAGS%" -o "%OUTPUT%" .
|
go build -ldflags "%LDFLAGS%" -o "%OUTPUT%" .
|
||||||
|
if errorlevel 1 goto :fail
|
||||||
|
|
||||||
if %errorlevel% equ 0 (
|
|
||||||
echo Build succeeded: %OUTPUT%
|
echo Build succeeded: %OUTPUT%
|
||||||
) else (
|
exit /b 0
|
||||||
|
|
||||||
|
:fail
|
||||||
echo Build failed.
|
echo Build failed.
|
||||||
)
|
exit /b 1
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Builds the Linux (amd64) binary.
|
||||||
|
#
|
||||||
|
# The Vue console is built first and embedded into the binary (web/dist, see
|
||||||
|
# web/embed.go), so the executable serves the whole frontend on its own —
|
||||||
|
# neither frontend/ nor web/dist/ is needed where it runs.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Build from the repository root, however the script was invoked.
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "Building frontend..."
|
||||||
|
(
|
||||||
|
cd frontend
|
||||||
|
# node_modules is gitignored, so a fresh checkout (CI included) installs
|
||||||
|
# from the lockfile; a warm tree only rebuilds.
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
|
npm ci
|
||||||
|
fi
|
||||||
|
npm run build
|
||||||
|
)
|
||||||
|
|
||||||
|
# go:embed on web/dist fails anyway, but this names the real problem.
|
||||||
|
if [ ! -f web/dist/index.html ]; then
|
||||||
|
echo "Frontend build produced no web/dist/index.html" >&2
|
||||||
|
exit 1
|
||||||
|
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"
|
||||||
+37
-4
@@ -1,13 +1,44 @@
|
|||||||
@echo off
|
@echo off
|
||||||
setlocal enabledelayedexpansion
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
:: Build from the repository root, however the script was invoked.
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
:: The Vue console is built first and embedded into the binary (web\dist, see
|
||||||
|
:: web\embed.go), so the executable serves the whole frontend on its own:
|
||||||
|
:: neither frontend\ nor web\dist\ is needed where it runs.
|
||||||
|
echo Building frontend...
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
:: node_modules is gitignored, so a fresh checkout (CI included) installs from
|
||||||
|
:: the lockfile; a warm tree only rebuilds.
|
||||||
|
if not exist "node_modules" (
|
||||||
|
call npm ci
|
||||||
|
if errorlevel 1 goto :frontend_failed
|
||||||
|
)
|
||||||
|
|
||||||
|
call npm run build
|
||||||
|
if errorlevel 1 goto :frontend_failed
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
:: go:embed on web\dist fails anyway, but this names the real problem.
|
||||||
|
if exist "web\dist\index.html" goto :backend
|
||||||
|
echo Frontend build produced no web\dist\index.html.
|
||||||
|
goto :fail
|
||||||
|
|
||||||
|
:frontend_failed
|
||||||
|
cd ..
|
||||||
|
echo Frontend build failed.
|
||||||
|
goto :fail
|
||||||
|
|
||||||
|
:backend
|
||||||
echo Building for Windows (amd64)...
|
echo Building for Windows (amd64)...
|
||||||
|
|
||||||
:: Get git commit hash (shortened to 7 characters, can also use full)
|
:: 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
|
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
|
||||||
|
|
||||||
:: Get UTC time
|
:: Get UTC time
|
||||||
for /f %%i in ('powershell -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'"') do set BUILD_DATE=%%i
|
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 environment variables for Windows build
|
||||||
set GOOS=windows
|
set GOOS=windows
|
||||||
@@ -25,9 +56,11 @@ echo BuildDate: %BUILD_DATE%
|
|||||||
echo Output: %OUTPUT%
|
echo Output: %OUTPUT%
|
||||||
|
|
||||||
go build -ldflags "%LDFLAGS%" -o "%OUTPUT%" .
|
go build -ldflags "%LDFLAGS%" -o "%OUTPUT%" .
|
||||||
|
if errorlevel 1 goto :fail
|
||||||
|
|
||||||
if %errorlevel% equ 0 (
|
|
||||||
echo Build succeeded: %OUTPUT%
|
echo Build succeeded: %OUTPUT%
|
||||||
) else (
|
exit /b 0
|
||||||
|
|
||||||
|
:fail
|
||||||
echo Build failed.
|
echo Build failed.
|
||||||
)
|
exit /b 1
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Builds the Windows (amd64) binary.
|
||||||
|
#
|
||||||
|
# The Vue console is built first and embedded into the binary (web/dist, see
|
||||||
|
# web/embed.go), so the executable serves the whole frontend on its own —
|
||||||
|
# neither frontend/ nor web/dist/ is needed where it runs.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Build from the repository root, however the script was invoked.
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "Building frontend..."
|
||||||
|
(
|
||||||
|
cd frontend
|
||||||
|
# node_modules is gitignored, so a fresh checkout (CI included) installs
|
||||||
|
# from the lockfile; a warm tree only rebuilds.
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
|
npm ci
|
||||||
|
fi
|
||||||
|
npm run build
|
||||||
|
)
|
||||||
|
|
||||||
|
# go:embed on web/dist fails anyway, but this names the real problem.
|
||||||
|
if [ ! -f web/dist/index.html ]; then
|
||||||
|
echo "Frontend build produced no web/dist/index.html" >&2
|
||||||
|
exit 1
|
||||||
|
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"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
## Ver.0.1.2.4
|
||||||
|
### Bug Fixes
|
||||||
|
- [Integrate frontend build into backend binary]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/cc4aebde6a5a4fba5eeb65ff2feccdd06dc6529d")
|
||||||
|
|
||||||
|
## Ver.0.1.2.3-1c4ad61.pre-release
|
||||||
|
### Features
|
||||||
|
- [Add frontend webpage to make everything easy to control]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/a90b4f5497dfae5f9b6a3132d091a528fdbdf612")
|
||||||
|
- [Add scripts for building frontend and backend for Linux and Windows]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/b970d66bc091b44b29ee0781888eb5b253b78aa6")
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- [Update API response structure to nest payloads under a single "data" key]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/bc7eb9dfdd5b98c0264aa6b5970cf0adb106aeb1")
|
||||||
|
|
||||||
|
|
||||||
|
## Ver.0.1.1.2-15f1ee3.pre-release
|
||||||
|
### Features
|
||||||
|
- [Add per-node notify switch controller]("https://gitea.nanami.tech/NanamiAdmin/Nukumizu/commit/6a0c2d1fc4b0fefa7eebc0b5a25a888817a186ca")
|
||||||
+7
-1
@@ -121,9 +121,15 @@ func UpdateSettings(settingsType string, patch map[string]interface{}) error {
|
|||||||
|
|
||||||
// deepMergeSettings recursively overlays src onto dst. Object values merge
|
// deepMergeSettings recursively overlays src onto dst. Object values merge
|
||||||
// key-by-key so partial updates keep sibling keys untouched; arrays and scalars
|
// key-by-key so partial updates keep sibling keys untouched; arrays and scalars
|
||||||
// always replace the destination value.
|
// 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{}) {
|
func deepMergeSettings(dst, src map[string]interface{}) {
|
||||||
for key, srcVal := range src {
|
for key, srcVal := range src {
|
||||||
|
if srcVal == nil {
|
||||||
|
delete(dst, key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
srcObj, srcIsObj := srcVal.(map[string]interface{})
|
srcObj, srcIsObj := srcVal.(map[string]interface{})
|
||||||
if srcIsObj {
|
if srcIsObj {
|
||||||
if dstObj, ok := dst[key].(map[string]interface{}); ok {
|
if dstObj, ok := dst[key].(map[string]interface{}); ok {
|
||||||
|
|||||||
+31
-3
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -81,12 +82,36 @@ func HashPassword(password string) string {
|
|||||||
return hex.EncodeToString(hash[:])
|
return hex.EncodeToString(hash[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUser inserts a new user into the database.
|
// ErrUsersExist is returned by RegisterFirstUser when the users table is not
|
||||||
func CreateUser(username, password, level string) (int64, error) {
|
// 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)
|
hashedPassword := HashPassword(password)
|
||||||
registerDate := time.Now().Format("2006-01-02 15:04:05")
|
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 (?, ?, ?, ?)",
|
"INSERT INTO users (username, password, level, register_date) VALUES (?, ?, ?, ?)",
|
||||||
username, hashedPassword, level, registerDate,
|
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)
|
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()
|
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,3 @@
|
|||||||
|
node_modules/
|
||||||
|
*.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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # outputs ../web/dist
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite writes to `../web/dist` rather than `frontend/dist` (see `build.outDir` in `vite.config.js`) because the Go backend embeds that directory into the binary — `go:embed` cannot reach outside the package it sits in, so the output has to live under `web/`. The repo's `build-*` scripts run this build for you and compile the backend afterwards; `npm run build` alone does not change what an already-built binary serves.
|
||||||
|
|
||||||
|
## 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,30 @@
|
|||||||
|
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()],
|
||||||
|
build: {
|
||||||
|
// web/embed.go embeds this directory into the Go binary. It has to live
|
||||||
|
// inside the web package: go:embed cannot reach outside its own
|
||||||
|
// directory, so ../web/dist is as close as it gets.
|
||||||
|
outDir: '../web/dist',
|
||||||
|
// The directory is outside the project root, so Vite refuses to empty
|
||||||
|
// it unless told to. Without this, stale hashed assets from earlier
|
||||||
|
// builds pile up in the binary.
|
||||||
|
emptyOutDir: true
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: BACKEND,
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
+2
-2
@@ -14,9 +14,9 @@ type SoftwareInfoStr struct {
|
|||||||
|
|
||||||
var SoftwareInfo = SoftwareInfoStr{
|
var SoftwareInfo = SoftwareInfoStr{
|
||||||
Name: "Nukumizu",
|
Name: "Nukumizu",
|
||||||
Version: "0.1.1",
|
Version: "0.1.2",
|
||||||
Developer: "Madobi Nanami",
|
Developer: "Madobi Nanami",
|
||||||
BuildVer: 2,
|
BuildVer: 4,
|
||||||
CommitHash: "unknown",
|
CommitHash: "unknown",
|
||||||
Description: "Remote server monitoring and command execution subsystem for Komari",
|
Description: "Remote server monitoring and command execution subsystem for Komari",
|
||||||
BuildType: "pre-release",
|
BuildType: "pre-release",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module nukumizu-backend
|
module nukumizu-backend
|
||||||
|
|
||||||
go 1.25.0
|
go 1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-telegram/bot v1.25.0
|
github.com/go-telegram/bot v1.25.0
|
||||||
|
|||||||
+82
-11
@@ -18,6 +18,38 @@ type ServerExecRequest struct {
|
|||||||
Command string `json:"command"`
|
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.
|
// ServerListHandler handles GET /api/server/list.
|
||||||
func ServerListHandler(w http.ResponseWriter, r *http.Request) {
|
func ServerListHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if !utils.Auth(w, r, "GET", "bot") {
|
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.
|
// ServerGetInfoHandler handles GET /api/server/getInfo?uuid=xxx|all.
|
||||||
func ServerGetStatusHandler(w http.ResponseWriter, r *http.Request) {
|
// Returns the static info of the requested server(s) as a uuid-keyed object,
|
||||||
if !utils.Auth(w, r, "GET", "bot") {
|
// mirroring the data the Bot uses for /info.
|
||||||
|
func ServerGetInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !utils.Auth(w, r, "GET", "admin") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,19 +84,56 @@ func ServerGetStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// First try to get data from the local tracker.
|
|
||||||
tracker := node.GetTracker()
|
tracker := node.GetTracker()
|
||||||
if tracker != nil {
|
if tracker == nil {
|
||||||
if n, exists := tracker.GetNode(uuid); exists && n.LatestReport != nil {
|
utils.SendErrorResponse(w, http.StatusInternalServerError, "node tracker not initialized")
|
||||||
utils.SendSuccessResponse(w, "", map[string]interface{}{
|
|
||||||
"uuid": uuid,
|
|
||||||
"report": n.LatestReport,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nodes, found := collectTargetNodes(tracker, uuid)
|
||||||
|
if !found {
|
||||||
|
utils.SendErrorResponse(w, http.StatusNotFound, fmt.Sprintf("server with uuid %s not found", uuid))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.SendErrorResponse(w, http.StatusNotFound, fmt.Sprintf("no recent data for uuid: %s", uuid))
|
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.
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
// SettingsGetHandler handles GET /api/settings/get?type=xxx.
|
// SettingsGetHandler handles GET /api/settings/get?type=xxx.
|
||||||
// type selects which config file to return and may be one of
|
// type selects which config file to return and may be one of
|
||||||
// "global", "bot_user_config" or "bot_node_config"; the returned "config"
|
// "global", "bot_user_config" or "bot_node_config"; the returned data.config
|
||||||
// object has the same layout as the source JSON file.
|
// object has the same layout as the source JSON file.
|
||||||
func SettingsGetHandler(w http.ResponseWriter, r *http.Request) {
|
func SettingsGetHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if !utils.Auth(w, r, "GET", "admin") {
|
if !utils.Auth(w, r, "GET", "admin") {
|
||||||
|
|||||||
+9
-2
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
db "nukumizu-backend/database"
|
db "nukumizu-backend/database"
|
||||||
@@ -98,10 +99,16 @@ func UserRegisterHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := db.CreateUser(req.Username, req.Password, "admin")
|
userID, err := db.RegisterFirstUser(req.Username, req.Password, "admin")
|
||||||
if err != nil {
|
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())
|
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
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"nukumizu-backend/handler"
|
"nukumizu-backend/handler"
|
||||||
"nukumizu-backend/postLog"
|
"nukumizu-backend/postLog"
|
||||||
|
"nukumizu-backend/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetupRouter registers all HTTP routes and returns a configured ServeMux.
|
// SetupRouter registers all HTTP routes and returns a configured ServeMux.
|
||||||
@@ -20,6 +21,7 @@ func SetupRouter() *http.ServeMux {
|
|||||||
|
|
||||||
// Server endpoints (authenticated).
|
// Server endpoints (authenticated).
|
||||||
mux.HandleFunc("/api/server/list", handler.ServerListHandler)
|
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/getStatus", handler.ServerGetStatusHandler)
|
||||||
mux.HandleFunc("/api/server/exec", handler.ServerExecHandler)
|
mux.HandleFunc("/api/server/exec", handler.ServerExecHandler)
|
||||||
|
|
||||||
@@ -37,8 +39,8 @@ func SetupRouter() *http.ServeMux {
|
|||||||
mux.HandleFunc("/api/system/getLogs", logSocketHandler.Handle)
|
mux.HandleFunc("/api/system/getLogs", logSocketHandler.Handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catch-all 404 handler.
|
// Static file serving for the web frontend.
|
||||||
mux.HandleFunc("/", NotFoundHandler)
|
mux.HandleFunc("/", web.ServeStatic)
|
||||||
|
|
||||||
postLog.Info("Router setup completed")
|
postLog.Info("Router setup completed")
|
||||||
return mux
|
return mux
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
@echo off
|
@echo off
|
||||||
setlocal enabledelayedexpansion
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
:: The console is embedded in the binary (web\embed.go), so compiling without
|
||||||
|
:: web\dist fails. Say that plainly rather than leaving go:embed's error.
|
||||||
|
if not exist "web\dist\index.html" (
|
||||||
|
echo The web console is not built: web\dist is missing.
|
||||||
|
echo Run build-win-x86_64.bat once, or "npm run build" in frontend\.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
:: Get git commit hash (shortened to 7 characters, can also use full)
|
:: 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
|
for /f %%i in ('git rev-parse --short HEAD') do set COMMIT=%%i
|
||||||
|
|
||||||
|
|||||||
+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{}) {
|
func SendSuccessResponse(w http.ResponseWriter, message string, data map[string]interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
resp := map[string]interface{}{
|
resp := map[string]interface{}{
|
||||||
@@ -212,9 +214,10 @@ func SendSuccessResponse(w http.ResponseWriter, message string, data map[string]
|
|||||||
if message != "" {
|
if message != "" {
|
||||||
resp["message"] = message
|
resp["message"] = message
|
||||||
}
|
}
|
||||||
for k, v := range data {
|
if data == nil {
|
||||||
resp[k] = v
|
data = map[string]interface{}{}
|
||||||
}
|
}
|
||||||
|
resp["data"] = data
|
||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// distFS holds the built console. Vite writes it to web/dist (see the outDir
|
||||||
|
// in frontend/vite.config.js) and it is compiled into the binary here, so a
|
||||||
|
// running executable serves the whole frontend on its own: neither the
|
||||||
|
// frontend sources nor web/dist need to exist on the machine that runs it.
|
||||||
|
//
|
||||||
|
// The all: prefix also picks up files whose names start with "_" or ".", which
|
||||||
|
// the default pattern skips.
|
||||||
|
//
|
||||||
|
//go:embed all:dist
|
||||||
|
var distFS embed.FS
|
||||||
|
|
||||||
|
// StaticFiles is the built frontend, rooted at the directory Vite writes to,
|
||||||
|
// so paths inside it are relative to that directory, e.g. "index.html" or
|
||||||
|
// "assets/app.js".
|
||||||
|
//
|
||||||
|
// web/dist is a build artifact and is not in a fresh checkout, so the console
|
||||||
|
// has to be built before the backend compiles — any build-*.sh / build-*.bat
|
||||||
|
// does it first, or run `npm run build` in frontend/ yourself. Compiling
|
||||||
|
// without it fails with "pattern all:dist: no matching files found".
|
||||||
|
var StaticFiles fs.FS
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sub, err := fs.Sub(distFS, "dist")
|
||||||
|
if err != nil {
|
||||||
|
panic("web: embedded frontend is unreadable: " + err.Error())
|
||||||
|
}
|
||||||
|
StaticFiles = sub
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// staticFS wraps the embedded frontend for net/http. StaticFiles is already
|
||||||
|
// rooted at the directory Vite writes to, so it is served as-is — no further
|
||||||
|
// fs.Sub is needed. http.FS copies a file that is not an io.Seeker into memory
|
||||||
|
// before serving it, which keeps this working whatever fs.FS StaticFiles is.
|
||||||
|
func staticFS() http.FileSystem {
|
||||||
|
return 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