feat(frontend): implement basic frontend interface
This commit is contained in:
@@ -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,242 @@
|
||||
<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 { 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);
|
||||
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);
|
||||
afterLogin(res.token, {
|
||||
userID: res.userID,
|
||||
username: res.username,
|
||||
level: res.level,
|
||||
registerDate: res.registerDate
|
||||
});
|
||||
} else {
|
||||
const res = await authApi.register(form.username.trim(), form.password);
|
||||
afterLogin(res.token, {
|
||||
userID: res.userID,
|
||||
username: res.username,
|
||||
level: res.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,309 @@
|
||||
<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 nodeConf = (nodes && nodes.config) || {};
|
||||
const list = [];
|
||||
for (const key of Object.keys(status)) {
|
||||
if (key === 'success') continue;
|
||||
const s = status[key] || {};
|
||||
const infoEntry = info[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,350 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { settingsApi } from '../api/index.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;
|
||||
}
|
||||
|
||||
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.config) || {};
|
||||
for (const s of sections) {
|
||||
const obj = {};
|
||||
for (const f of s.fields) {
|
||||
obj[f.key] = getVal(cfg, fieldPath(f), defaults(f));
|
||||
}
|
||||
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.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>
|
||||
Reference in New Issue
Block a user