实现 LightOps 运维面板基础功能
This commit is contained in:
161
web/src/App.svelte
Normal file
161
web/src/App.svelte
Normal file
@@ -0,0 +1,161 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, post, setToken } from './lib/api';
|
||||
import Dashboard from './pages/Dashboard.svelte';
|
||||
import Nodes from './pages/Nodes.svelte';
|
||||
import NodeDetail from './pages/NodeDetail.svelte';
|
||||
import Files from './pages/Files.svelte';
|
||||
import Terminal from './pages/Terminal.svelte';
|
||||
import Logs from './pages/Logs.svelte';
|
||||
import Services from './pages/Services.svelte';
|
||||
import Nginx from './pages/Nginx.svelte';
|
||||
import Docker from './pages/Docker.svelte';
|
||||
import Apps from './pages/Apps.svelte';
|
||||
import AppStore from './pages/AppStore.svelte';
|
||||
import StoreAppDetail from './pages/StoreAppDetail.svelte';
|
||||
import AppDetail from './pages/AppDetail.svelte';
|
||||
import AppManage from './pages/AppManage.svelte';
|
||||
import Audit from './pages/Audit.svelte';
|
||||
import Tasks from './pages/Tasks.svelte';
|
||||
import Alerts from './pages/Alerts.svelte';
|
||||
import Settings from './pages/Settings.svelte';
|
||||
import Users from './pages/Users.svelte';
|
||||
|
||||
let path = location.pathname;
|
||||
let me: any = null;
|
||||
let loading = true;
|
||||
let error = '';
|
||||
let username = 'admin';
|
||||
let password = '';
|
||||
|
||||
const nav = [
|
||||
['/dashboard', '总览'],
|
||||
['/store', '软件商店'],
|
||||
['/alerts', '告警'],
|
||||
['/tasks', '任务'],
|
||||
['/users', '用户'],
|
||||
['/audit', '日志'],
|
||||
['/settings', '设置']
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
addEventListener('popstate', () => (path = location.pathname));
|
||||
await loadMe();
|
||||
});
|
||||
|
||||
async function loadMe() {
|
||||
loading = true;
|
||||
try {
|
||||
me = await api('/api/auth/me');
|
||||
if (path === '/' || path === '/login' || path === '/init') go('/dashboard');
|
||||
} catch {
|
||||
me = null;
|
||||
if (path !== '/init') go('/login');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function go(next: string) {
|
||||
history.pushState({}, '', next);
|
||||
path = next;
|
||||
}
|
||||
|
||||
async function login(init = false) {
|
||||
error = '';
|
||||
try {
|
||||
const data: any = await post(init ? '/api/auth/init' : '/api/auth/login', { username, password });
|
||||
setToken(data.token);
|
||||
me = data.user;
|
||||
go('/dashboard');
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
setToken(null);
|
||||
me = null;
|
||||
go('/login');
|
||||
}
|
||||
|
||||
$: nodeId = path.match(/^\/nodes\/([^/]+)/)?.[1] || '';
|
||||
$: nodeAppId = path.match(/^\/nodes\/[^/]+\/apps\/(.+)$/)?.[1] || '';
|
||||
$: storeSlug = path.match(/^\/store\/([^/]+)$/)?.[1] || '';
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<main class="center">加载中...</main>
|
||||
{:else if !me && (path === '/login' || path === '/init')}
|
||||
<main class="auth-shell">
|
||||
<section class="auth-card">
|
||||
<p class="eyebrow">LightOps</p>
|
||||
<h1>{path === '/init' ? '初始化管理员' : '登录主控端'}</h1>
|
||||
<input bind:value={username} placeholder="用户名" />
|
||||
<input bind:value={password} placeholder="密码" type="password" on:keydown={(e) => e.key === 'Enter' && login(path === '/init')} />
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<button on:click={() => login(path === '/init')}>{path === '/init' ? '创建管理员' : '登录'}</button>
|
||||
<button class="ghost" on:click={() => go(path === '/init' ? '/login' : '/init')}>
|
||||
{path === '/init' ? '已有账号,去登录' : '首次使用,初始化'}
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
{:else}
|
||||
<div class="app-shell">
|
||||
<aside>
|
||||
<button class="brand" on:click={() => go('/dashboard')}>LightOps</button>
|
||||
{#each nav as item}
|
||||
<button class:active={path.startsWith(item[0])} on:click={() => go(item[0])}>{item[1]}</button>
|
||||
{/each}
|
||||
</aside>
|
||||
<section class="main">
|
||||
<header>
|
||||
<span>{me?.username}</span>
|
||||
<button class="ghost" on:click={logout}>退出</button>
|
||||
</header>
|
||||
{#if path.startsWith('/nodes/') && path.endsWith('/files')}
|
||||
<Files id={nodeId} />
|
||||
{:else if path.startsWith('/nodes/') && path.endsWith('/terminal')}
|
||||
<Terminal id={nodeId} />
|
||||
{:else if path.startsWith('/nodes/') && path.endsWith('/logs')}
|
||||
<Logs id={nodeId} />
|
||||
{:else if path.startsWith('/nodes/') && path.endsWith('/services')}
|
||||
<Services id={nodeId} />
|
||||
{:else if path.startsWith('/nodes/') && path.endsWith('/nginx')}
|
||||
<Nginx id={nodeId} />
|
||||
{:else if path.startsWith('/nodes/') && path.endsWith('/docker')}
|
||||
<Docker id={nodeId} />
|
||||
{:else if path.startsWith('/nodes/') && path.endsWith('/tasks')}
|
||||
<Tasks id={nodeId} />
|
||||
{:else if path.startsWith('/nodes/') && path.endsWith('/apps/manage')}
|
||||
<AppManage id={nodeId} />
|
||||
{:else if path.startsWith('/nodes/') && path.includes('/apps/') && nodeAppId !== 'manage'}
|
||||
<AppDetail id={nodeId} appId={decodeURIComponent(nodeAppId)} />
|
||||
{:else if path.startsWith('/nodes/') && path.endsWith('/apps')}
|
||||
<Apps id={nodeId} />
|
||||
{:else if path.startsWith('/nodes/')}
|
||||
<NodeDetail id={nodeId} />
|
||||
{:else if path === '/apps'}
|
||||
<Apps />
|
||||
{:else if path === '/store'}
|
||||
<AppStore />
|
||||
{:else if storeSlug}
|
||||
<StoreAppDetail slug={decodeURIComponent(storeSlug)} />
|
||||
{:else if path === '/nodes'}
|
||||
<Nodes />
|
||||
{:else if path === '/audit'}
|
||||
<Audit />
|
||||
{:else if path === '/tasks'}
|
||||
<Tasks />
|
||||
{:else if path === '/alerts'}
|
||||
<Alerts />
|
||||
{:else if path === '/users'}
|
||||
<Users />
|
||||
{:else if path === '/settings'}
|
||||
<Settings />
|
||||
{:else}
|
||||
<Dashboard />
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
46
web/src/lib/api.ts
Normal file
46
web/src/lib/api.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type ApiResponse<T> = {
|
||||
success: boolean;
|
||||
data: T | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
const TOKEN_KEY = 'lightops_token';
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string | null) {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('content-type', 'application/json');
|
||||
const token = getToken();
|
||||
if (token) headers.set('authorization', `Bearer ${token}`);
|
||||
const res = await fetch(path, { ...options, headers });
|
||||
const body = (await res.json()) as ApiResponse<T>;
|
||||
if (!res.ok || !body.success) {
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return body.data as T;
|
||||
}
|
||||
|
||||
export async function post<T>(path: string, body: unknown): Promise<T> {
|
||||
return api<T>(path, { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export async function del<T>(path: string): Promise<T> {
|
||||
return api<T>(path, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function wsUrl(path: string) {
|
||||
const token = getToken();
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = new URL(path, `${proto}//${location.host}`);
|
||||
if (token) url.searchParams.set('token', token);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
28
web/src/lib/confirm.ts
Normal file
28
web/src/lib/confirm.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type ConfirmPayload = {
|
||||
confirmed_at: string;
|
||||
target: string;
|
||||
level: 'normal' | 'high';
|
||||
};
|
||||
|
||||
export function requireConfirm(message: string) {
|
||||
return window.confirm(message);
|
||||
}
|
||||
|
||||
export function requireDangerConfirm(action: string, target: string, level: 'normal' | 'high' = 'high') {
|
||||
const cleanTarget = target.trim();
|
||||
if (!cleanTarget) return null;
|
||||
const typed = window.prompt(`高风险操作:${action}\n请输入目标名称以确认:${cleanTarget}`);
|
||||
if (typed !== cleanTarget) {
|
||||
window.alert('确认内容不匹配,操作已取消。');
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
confirmed_at: new Date().toISOString(),
|
||||
target: cleanTarget,
|
||||
level
|
||||
} satisfies ConfirmPayload;
|
||||
}
|
||||
|
||||
export function withConfirm<T extends Record<string, unknown>>(body: T, confirm: ConfirmPayload | null) {
|
||||
return confirm ? { ...body, confirm_target: confirm.target, confirm } : body;
|
||||
}
|
||||
7
web/src/main.ts
Normal file
7
web/src/main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import App from './App.svelte';
|
||||
import './styles.css';
|
||||
|
||||
new App({
|
||||
target: document.getElementById('app')!
|
||||
});
|
||||
|
||||
244
web/src/pages/Alerts.svelte
Normal file
244
web/src/pages/Alerts.svelte
Normal file
@@ -0,0 +1,244 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, post, del } from '../lib/api';
|
||||
import { requireDangerConfirm, withConfirm } from '../lib/confirm';
|
||||
|
||||
let events: any[] = [];
|
||||
let rules: any[] = [];
|
||||
let deliveries: any[] = [];
|
||||
let agents: any[] = [];
|
||||
let status = 'open';
|
||||
let agentId = '';
|
||||
let severity = '';
|
||||
let error = '';
|
||||
let loading = true;
|
||||
let rule = {
|
||||
name: 'CPU 使用率过高',
|
||||
metric: 'cpu_usage',
|
||||
operator: '>=',
|
||||
threshold: 90,
|
||||
severity: 'warning',
|
||||
enabled: true,
|
||||
notify_recovery: true,
|
||||
silence_until: ''
|
||||
};
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set('status', status);
|
||||
if (agentId) params.set('agent_id', agentId);
|
||||
if (severity) params.set('severity', severity);
|
||||
events = await api(`/api/alert-events?${params}`);
|
||||
rules = await api('/api/alert-rules');
|
||||
deliveries = await api('/api/notification-deliveries?limit=80');
|
||||
agents = await api('/api/agents');
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createRule() {
|
||||
const confirmInfo = requireDangerConfirm('创建告警规则', rule.name, 'normal');
|
||||
if (!confirmInfo) return;
|
||||
await post('/api/alert-rules', withConfirm(rule, confirmInfo));
|
||||
await load();
|
||||
}
|
||||
|
||||
async function silenceRule(item: any, minutes: number) {
|
||||
const until = new Date(Date.now() + minutes * 60 * 1000).toISOString();
|
||||
const confirmInfo = requireDangerConfirm(`静默告警规则 ${minutes} 分钟`, item.name, 'normal');
|
||||
if (!confirmInfo) return;
|
||||
await api(`/api/alert-rules/${encodeURIComponent(item.id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(withConfirm({ ...item, silence_until: until }, confirmInfo))
|
||||
});
|
||||
await load();
|
||||
}
|
||||
|
||||
async function clearSilence(item: any) {
|
||||
const confirmInfo = requireDangerConfirm('取消告警静默', item.name, 'normal');
|
||||
if (!confirmInfo) return;
|
||||
await api(`/api/alert-rules/${encodeURIComponent(item.id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(withConfirm({ ...item, silence_until: '' }, confirmInfo))
|
||||
});
|
||||
await load();
|
||||
}
|
||||
|
||||
async function toggleRule(item: any) {
|
||||
const confirmInfo = requireDangerConfirm(item.enabled ? '停用告警规则' : '启用告警规则', item.name, 'normal');
|
||||
if (!confirmInfo) return;
|
||||
await api(`/api/alert-rules/${encodeURIComponent(item.id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(withConfirm({ ...item, enabled: !item.enabled }, confirmInfo))
|
||||
});
|
||||
await load();
|
||||
}
|
||||
|
||||
async function deleteRule(item: any) {
|
||||
if (!requireDangerConfirm('删除告警规则', item.name)) return;
|
||||
await del(`/api/alert-rules/${encodeURIComponent(item.id)}`);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function ack(event: any) {
|
||||
const confirmInfo = requireDangerConfirm('确认告警', event.id, 'normal');
|
||||
if (!confirmInfo) return;
|
||||
await post(`/api/alert-events/${encodeURIComponent(event.id)}/ack`, withConfirm({}, confirmInfo));
|
||||
await load();
|
||||
}
|
||||
|
||||
function metricLabel(value: string) {
|
||||
return (
|
||||
{
|
||||
cpu_usage: 'CPU 使用率',
|
||||
memory_usage: '内存使用率',
|
||||
disk_usage: '磁盘使用率',
|
||||
ssl_days_remaining: 'SSL 证书剩余天数',
|
||||
app_health: '应用健康'
|
||||
} as Record<string, string>
|
||||
)[value] || value;
|
||||
}
|
||||
|
||||
function metricUnit(value: string) {
|
||||
if (value === 'ssl_days_remaining') return ' 天';
|
||||
if (value === 'app_health') return '';
|
||||
return '%';
|
||||
}
|
||||
|
||||
function statusLabel(value: string) {
|
||||
return ({ open: '触发中', ack: '已确认', resolved: '已恢复' } as Record<string, string>)[value] || value;
|
||||
}
|
||||
|
||||
function deliveryStatus(value: string) {
|
||||
return ({ sent: '已发送', recovery_sent: '恢复通知已发送', failed: '失败' } as Record<string, string>)[value] || value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<p class="eyebrow">监控告警</p>
|
||||
<h1>告警中心</h1>
|
||||
<p class="muted">基于 Agent 心跳指标生成告警事件,默认支持 CPU、内存、磁盘阈值。</p>
|
||||
</div>
|
||||
<button on:click={load} disabled={loading}>{loading ? '刷新中...' : '刷新'}</button>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
|
||||
<div class="log-toolbar">
|
||||
<select bind:value={status} on:change={load}>
|
||||
<option value="open">触发中</option>
|
||||
<option value="ack">已确认</option>
|
||||
<option value="resolved">已恢复</option>
|
||||
<option value="">全部</option>
|
||||
</select>
|
||||
<select bind:value={agentId} on:change={load}>
|
||||
<option value="">全部主机</option>
|
||||
{#each agents as agent}
|
||||
<option value={agent.id}>{agent.hostname || agent.name || agent.id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={severity} on:change={load}>
|
||||
<option value="">全部级别</option>
|
||||
<option value="warning">warning</option>
|
||||
<option value="critical">critical</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h2>告警事件</h2>
|
||||
<div class="table">
|
||||
{#each events as event}
|
||||
<div class="row alert-row">
|
||||
<strong>{event.message}</strong>
|
||||
<span>{event.severity}</span>
|
||||
<span>{statusLabel(event.status)}</span>
|
||||
<span>{event.agent_id}</span>
|
||||
<span>{event.last_seen_at}</span>
|
||||
{#if event.status === 'open'}<button on:click={() => ack(event)}>确认</button>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h2>新增规则</h2>
|
||||
<div class="nginx-create">
|
||||
<div class="form-grid">
|
||||
<label>名称<input bind:value={rule.name} /></label>
|
||||
<label>指标
|
||||
<select bind:value={rule.metric}>
|
||||
<option value="cpu_usage">CPU 使用率</option>
|
||||
<option value="memory_usage">内存使用率</option>
|
||||
<option value="disk_usage">磁盘使用率</option>
|
||||
<option value="ssl_days_remaining">SSL 证书剩余天数</option>
|
||||
<option value="app_health">应用健康</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>阈值<input bind:value={rule.threshold} type="number" min="1" max="1000" /></label>
|
||||
<label>条件
|
||||
<select bind:value={rule.operator}>
|
||||
<option value=">=">大于等于</option>
|
||||
<option value=">">大于</option>
|
||||
<option value="<=">小于等于</option>
|
||||
<option value="<">小于</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>级别
|
||||
<select bind:value={rule.severity}>
|
||||
<option value="warning">warning</option>
|
||||
<option value="critical">critical</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>主机范围
|
||||
<select bind:value={rule.agent_id}>
|
||||
<option value="">全部主机</option>
|
||||
{#each agents as agent}
|
||||
<option value={agent.id}>{agent.hostname || agent.name || agent.id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="check"><input type="checkbox" bind:checked={rule.enabled} /> 启用</label>
|
||||
<label class="check"><input type="checkbox" bind:checked={rule.notify_recovery} /> 发送恢复通知</label>
|
||||
</div>
|
||||
<button on:click={createRule}>创建规则</button>
|
||||
</div>
|
||||
|
||||
<h2>规则列表</h2>
|
||||
<div class="table">
|
||||
{#each rules as item}
|
||||
<div class="row alert-row">
|
||||
<strong>{item.name}</strong>
|
||||
<span>{metricLabel(item.metric)} {item.operator || '>='} {item.threshold}{metricUnit(item.metric)}</span>
|
||||
<span>{item.severity}</span>
|
||||
<span>{item.enabled ? '启用' : '停用'}{item.silence_until ? ` · 静默到 ${item.silence_until}` : ''}</span>
|
||||
<button class="ghost" on:click={() => toggleRule(item)}>{item.enabled ? '停用' : '启用'}</button>
|
||||
<button class="ghost" on:click={() => silenceRule(item, 30)}>静默 30 分钟</button>
|
||||
{#if item.silence_until}<button class="ghost" on:click={() => clearSilence(item)}>取消静默</button>{/if}
|
||||
<button class="danger" on:click={() => deleteRule(item)}>删除</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h2>通知投递历史</h2>
|
||||
<div class="table">
|
||||
{#each deliveries as item}
|
||||
<div class="row alert-row">
|
||||
<strong>{deliveryStatus(item.status)}</strong>
|
||||
<span>{item.channel}</span>
|
||||
<span>{item.agent_id || '-'}</span>
|
||||
<span>{item.message || item.event_id}</span>
|
||||
<span>{item.error || item.created_at}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if deliveries.length === 0}
|
||||
<div class="empty-state"><p class="muted">暂无通知投递记录。</p></div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
387
web/src/pages/AppDetail.svelte
Normal file
387
web/src/pages/AppDetail.svelte
Normal file
@@ -0,0 +1,387 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, post, del } from '../lib/api';
|
||||
import { requireDangerConfirm, withConfirm } from '../lib/confirm';
|
||||
export let id: string;
|
||||
export let appId: string;
|
||||
|
||||
let detail: any = null;
|
||||
let logs = '';
|
||||
let error = '';
|
||||
let proxyVisible = false;
|
||||
let proxySiteName = '';
|
||||
let proxyServerName = '';
|
||||
let proxyUpstream = 'http://127.0.0.1:3000';
|
||||
let proxyIssueSsl = false;
|
||||
let proxySslEmail = '';
|
||||
let creatingProxy = false;
|
||||
let healthResult: any = null;
|
||||
let checkingHealth = false;
|
||||
let healthKind = 'auto';
|
||||
let healthUrl = '';
|
||||
let healthHost = '127.0.0.1';
|
||||
let healthPort = '';
|
||||
let backingUp = false;
|
||||
let backupResult: any = null;
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
detail = await api(`/api/agents/${id}/apps/${encodeURIComponent(appId)}`);
|
||||
healthResult = detail.runtime_info?.latest_health?.result || healthResult;
|
||||
}
|
||||
|
||||
async function act(action: string, dangerous = false) {
|
||||
error = '';
|
||||
const confirmInfo = dangerous ? requireDangerConfirm(`${actionLabel(action)}应用`, detail.application.display_name) : null;
|
||||
if (dangerous && !confirmInfo) return;
|
||||
try {
|
||||
if (action === 'delete') {
|
||||
await del(`/api/agents/${id}/apps/${encodeURIComponent(appId)}`);
|
||||
} else {
|
||||
await post(`/api/agents/${id}/apps/${encodeURIComponent(appId)}/${action}`, withConfirm({}, confirmInfo));
|
||||
}
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
error = '';
|
||||
try {
|
||||
const data: any = await api(`/api/agents/${id}/apps/${encodeURIComponent(appId)}/logs?lines=200`);
|
||||
logs = data.content || (data.lines || []).join('\n');
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function runHealthCheck() {
|
||||
error = '';
|
||||
checkingHealth = true;
|
||||
healthResult = null;
|
||||
try {
|
||||
const params: any = { timeout_secs: 5 };
|
||||
if (healthKind !== 'auto') params.kind = healthKind;
|
||||
if (healthUrl.trim()) params.url = healthUrl.trim();
|
||||
if (healthHost.trim()) params.host = healthHost.trim();
|
||||
if (healthPort.trim()) {
|
||||
const port = Number(healthPort.trim());
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
error = 'TCP 端口必须是 1-65535 的整数';
|
||||
return;
|
||||
}
|
||||
params.port = port;
|
||||
}
|
||||
healthResult = await post(`/api/agents/${id}/apps/${encodeURIComponent(appId)}/health`, params);
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
checkingHealth = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function backupApp() {
|
||||
error = '';
|
||||
backupResult = null;
|
||||
backingUp = true;
|
||||
try {
|
||||
backupResult = await post(`/api/agents/${id}/apps/${encodeURIComponent(appId)}/backup`, {});
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
backingUp = false;
|
||||
}
|
||||
}
|
||||
|
||||
function go(path: string) {
|
||||
history.pushState({}, '', path);
|
||||
dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
function openFiles(path: string) {
|
||||
go(`/nodes/${id}/files?path=${encodeURIComponent(path)}`);
|
||||
}
|
||||
|
||||
function parentDir(path: string) {
|
||||
const index = path.lastIndexOf('/');
|
||||
return index > 0 ? path.slice(0, index) : '/';
|
||||
}
|
||||
|
||||
function openLogs(path?: string) {
|
||||
go(path ? `/nodes/${id}/logs?path=${encodeURIComponent(path)}` : `/nodes/${id}/logs`);
|
||||
}
|
||||
|
||||
function openProxyWizard() {
|
||||
const app = detail.application;
|
||||
proxyVisible = true;
|
||||
proxySiteName = normalizeSiteName(app.domains?.[0] || app.display_name || app.name);
|
||||
proxyServerName = app.domains?.[0] || `${proxySiteName}.example.com`;
|
||||
proxyUpstream = `http://127.0.0.1:${app.ports?.[0] || 3000}`;
|
||||
}
|
||||
|
||||
async function createProxySite() {
|
||||
const site = proxySiteName.trim();
|
||||
const serverNameValue = proxyServerName.trim();
|
||||
const upstreamValue = proxyUpstream.trim();
|
||||
if (!site || !serverNameValue || !upstreamValue) {
|
||||
error = '请填写站点名、域名和上游地址';
|
||||
return;
|
||||
}
|
||||
const confirmInfo = requireDangerConfirm('创建应用反代站点', site);
|
||||
if (!confirmInfo) return;
|
||||
error = '';
|
||||
creatingProxy = true;
|
||||
try {
|
||||
await post(`/api/agents/${id}/nginx/sites`, withConfirm({
|
||||
name: site,
|
||||
server_name: serverNameValue,
|
||||
mode: 'proxy',
|
||||
upstream: upstreamValue,
|
||||
source: 'application',
|
||||
app_id: detail.application.id
|
||||
}, confirmInfo));
|
||||
await post(`/api/agents/${id}/nginx/sites/${encodeURIComponent(site)}/enable`, withConfirm({}, confirmInfo));
|
||||
await post(`/api/agents/${id}/nginx/reload`, withConfirm({}, confirmInfo));
|
||||
if (proxyIssueSsl) {
|
||||
await post(`/api/agents/${id}/nginx/ssl/issue`, withConfirm({
|
||||
domains: serverNameValue.split(/[\s,]+/).filter(Boolean),
|
||||
email: proxySslEmail || null
|
||||
}, confirmInfo));
|
||||
}
|
||||
logs = `反代站点 ${site} 已创建,上游 ${upstreamValue}`;
|
||||
proxyVisible = false;
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
creatingProxy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSiteName(value: string) {
|
||||
return (value || 'lightops-app')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64) || 'lightops-app';
|
||||
}
|
||||
|
||||
function actionLabel(action: string) {
|
||||
return ({
|
||||
start: '启动',
|
||||
stop: '停止',
|
||||
restart: '重启',
|
||||
reload: '重载',
|
||||
delete: '删除',
|
||||
'app.start': '启动',
|
||||
'app.stop': '停止',
|
||||
'app.restart': '重启',
|
||||
'app.reload': '重载',
|
||||
'app.logs': '查看日志',
|
||||
'app.health': '健康检查',
|
||||
'app.backup': '备份',
|
||||
'app.manage_custom': '纳管应用',
|
||||
'app.create_systemd_service': '创建 systemd 服务',
|
||||
'app.unmanage': '取消纳管'
|
||||
} as Record<string, string>)[action] || action;
|
||||
}
|
||||
|
||||
function statusLabel(value: string) {
|
||||
return ({
|
||||
Running: '运行中',
|
||||
Stopped: '已停止',
|
||||
Failed: '失败',
|
||||
Enabled: '已启用',
|
||||
Disabled: '已禁用',
|
||||
Installing: '安装中',
|
||||
Updating: '更新中',
|
||||
Unknown: '未知'
|
||||
} as Record<string, string>)[value] || value || '-';
|
||||
}
|
||||
|
||||
function riskLabel(value: string) {
|
||||
return ({ normal: '普通', high: '高风险' } as Record<string, string>)[value] || value || '-';
|
||||
}
|
||||
|
||||
function typeLabel(value: string) {
|
||||
return ({
|
||||
WebApp: 'Web 应用',
|
||||
Service: '服务',
|
||||
Database: '数据库',
|
||||
Runtime: '运行时',
|
||||
Tool: '工具',
|
||||
Container: '容器',
|
||||
ComposeProject: 'Compose 项目',
|
||||
StaticSite: '静态站点',
|
||||
ReverseProxy: '反向代理',
|
||||
Custom: '自定义'
|
||||
} as Record<string, string>)[value] || value || '-';
|
||||
}
|
||||
|
||||
function providerLabel(value: string) {
|
||||
return ({
|
||||
Systemd: 'systemd',
|
||||
Docker: 'Docker',
|
||||
DockerCompose: 'Docker Compose',
|
||||
Apt: 'APT',
|
||||
Dnf: 'DNF',
|
||||
Pacman: 'Pacman',
|
||||
Snap: 'Snap',
|
||||
Flatpak: 'Flatpak',
|
||||
Binary: '二进制',
|
||||
PM2: 'PM2',
|
||||
Supervisor: 'Supervisor',
|
||||
NginxSite: 'Nginx 站点',
|
||||
LightOpsManaged: 'LightOps 纳管',
|
||||
Custom: '自定义'
|
||||
} as Record<string, string>)[value] || value || '-';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if detail}
|
||||
<section class="panel">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<p class="eyebrow">{providerLabel(detail.application.provider)} / {typeLabel(detail.application.app_type)}</p>
|
||||
<h1>{detail.application.display_name}</h1>
|
||||
<p class="muted">{detail.application.description || detail.application.name}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if detail.available_actions?.includes('start')}<button on:click={() => act('start')}>启动</button>{/if}
|
||||
{#if detail.available_actions?.includes('restart')}<button on:click={() => act('restart', true)}>重启</button>{/if}
|
||||
{#if detail.available_actions?.includes('stop')}<button class="danger" on:click={() => act('stop', true)}>停止</button>{/if}
|
||||
<button class="ghost" on:click={loadLogs}>日志</button>
|
||||
{#if detail.available_actions?.includes('health')}
|
||||
<button class="ghost" on:click={runHealthCheck} disabled={checkingHealth}>{checkingHealth ? '检查中...' : '健康检查'}</button>
|
||||
{/if}
|
||||
{#if detail.available_actions?.includes('backup')}
|
||||
<button class="ghost" on:click={backupApp} disabled={backingUp}>{backingUp ? '备份中...' : '备份'}</button>
|
||||
{/if}
|
||||
<button class="ghost" on:click={openProxyWizard}>创建反代</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="info-card"><span>状态</span><b>{statusLabel(detail.application.status)}</b></div>
|
||||
<div class="info-card"><span>版本</span><b>{detail.application.version || '-'}</b></div>
|
||||
<div class="info-card"><span>运行用户</span><b>{detail.application.run_user || '-'}</b></div>
|
||||
<div class="info-card"><span>风险</span><b>{riskLabel(detail.risk_level)}</b></div>
|
||||
</div>
|
||||
|
||||
<div class="panel inner">
|
||||
<div class="title-row compact">
|
||||
<div>
|
||||
<h2>健康检查</h2>
|
||||
<p class="muted">优先检测域名 HTTP,其次检测本机监听端口 TCP,用于快速判断应用是否可访问。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<select bind:value={healthKind}>
|
||||
<option value="auto">自动</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="tcp">TCP</option>
|
||||
</select>
|
||||
<input bind:value={healthUrl} placeholder="HTTP URL,可选" />
|
||||
<input bind:value={healthHost} placeholder="TCP 主机" />
|
||||
<input bind:value={healthPort} placeholder="TCP 端口" />
|
||||
<button class="ghost" on:click={runHealthCheck} disabled={checkingHealth}>{checkingHealth ? '检查中...' : '立即检查'}</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if healthResult}
|
||||
<div class:ok={healthResult.ok} class:bad={!healthResult.ok} class="health-result">
|
||||
<b>{healthResult.ok ? '可用' : '异常'}</b>
|
||||
<span>{healthResult.kind?.toUpperCase()} {healthResult.target}</span>
|
||||
<span>耗时 {healthResult.latency_ms ?? '-'} ms</span>
|
||||
{#if healthResult.status_code}<span>HTTP {healthResult.status_code}</span>{/if}
|
||||
{#if healthResult.error}<span>{healthResult.error}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if backupResult}
|
||||
<div class="panel inner">
|
||||
<h2>备份结果</h2>
|
||||
<p>备份文件:{backupResult.archive_path}</p>
|
||||
<p>大小:{backupResult.size || 0} B</p>
|
||||
<p>包含路径:{(backupResult.paths || []).join(', ') || '-'}</p>
|
||||
<button class="ghost" on:click={() => openFiles(parentDir(backupResult.archive_path))}>在文件管理中打开</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="split">
|
||||
<div class="panel inner">
|
||||
<h2>路径信息</h2>
|
||||
<p>安装目录:{detail.application.install_path || '-'}</p>
|
||||
<p>工作目录:{detail.application.work_dir || '-'}</p>
|
||||
<p>配置:{(detail.application.config_paths || []).join(', ') || '-'}</p>
|
||||
<p>日志:{(detail.application.log_paths || []).join(', ') || '-'}</p>
|
||||
<p>数据:{(detail.application.data_paths || []).join(', ') || '-'}</p>
|
||||
{#if detail.application.install_path}
|
||||
<button class="ghost" on:click={() => openFiles(detail.application.install_path)}>打开安装目录</button>
|
||||
{/if}
|
||||
{#if detail.application.work_dir}
|
||||
<button class="ghost" on:click={() => openFiles(detail.application.work_dir)}>打开工作目录</button>
|
||||
{/if}
|
||||
{#each detail.application.config_paths || [] as configPath}
|
||||
<button class="ghost" on:click={() => openFiles(configPath)}>编辑配置:{configPath}</button>
|
||||
{/each}
|
||||
{#each detail.application.log_paths || [] as logPath}
|
||||
<button class="ghost" on:click={() => openLogs(logPath)}>查看日志:{logPath}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="panel inner">
|
||||
<h2>关联资源</h2>
|
||||
<p>服务:{detail.application.service_name || '-'}</p>
|
||||
<p>容器:{detail.application.container_id || '-'}</p>
|
||||
<p>Compose:{detail.application.compose_project || '-'}</p>
|
||||
<p>Nginx:{detail.application.nginx_site || '-'}</p>
|
||||
<p>端口:{(detail.application.ports || []).join(', ') || '-'}</p>
|
||||
<p>域名:{(detail.application.domains || []).join(', ') || '-'}</p>
|
||||
<div class="actions">
|
||||
{#if detail.application.service_name}<button class="ghost" on:click={() => go(`/nodes/${id}/services`)}>跳转服务</button>{/if}
|
||||
{#if detail.application.container_id || detail.application.compose_project}<button class="ghost" on:click={() => go(`/nodes/${id}/docker`)}>跳转 Docker</button>{/if}
|
||||
{#if detail.application.nginx_site}<button class="ghost" on:click={() => go(`/nodes/${id}/nginx`)}>跳转 Nginx</button>{/if}
|
||||
<button class="ghost" on:click={() => go(`/nodes/${id}/terminal`)}>打开终端</button>
|
||||
</div>
|
||||
{#if (detail.application.domains || []).length > 0}
|
||||
<div class="actions">
|
||||
{#each detail.application.domains as domain}
|
||||
<a class="button-link" href={`http://${domain}`} target="_blank" rel="noreferrer">访问 {domain}</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if proxyVisible}
|
||||
<div class="panel inner">
|
||||
<h2>创建应用反代站点</h2>
|
||||
<p class="muted">用于把域名反向代理到当前应用监听端口。保存前会执行 nginx -t,失败会回滚。</p>
|
||||
<div class="form-grid">
|
||||
<label>站点名<input bind:value={proxySiteName} /></label>
|
||||
<label>域名<input bind:value={proxyServerName} /></label>
|
||||
<label>上游地址<input bind:value={proxyUpstream} /></label>
|
||||
<label>证书邮箱<input bind:value={proxySslEmail} placeholder="可选,用于 certbot" /></label>
|
||||
<label class="check"><input type="checkbox" bind:checked={proxyIssueSsl} /> 同时申请免费 SSL 证书</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button on:click={createProxySite} disabled={creatingProxy}>{creatingProxy ? '创建中...' : '创建并启用反代'}</button>
|
||||
<button class="ghost" on:click={() => (proxyVisible = false)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="panel inner">
|
||||
<h2>最近操作</h2>
|
||||
<div class="table">
|
||||
{#each detail.recent_actions || [] as item}
|
||||
<div class="row"><strong>{actionLabel(item.action)}</strong><span>{item.status === 'success' ? '成功' : item.status === 'failed' ? '失败' : item.status}</span><span>{item.error || '-'}</span><span>{item.created_at}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre class="log-output">{logs}</pre>
|
||||
</section>
|
||||
{/if}
|
||||
72
web/src/pages/AppManage.svelte
Normal file
72
web/src/pages/AppManage.svelte
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { post } from '../lib/api';
|
||||
export let id: string;
|
||||
|
||||
let appType = 'Binary';
|
||||
let name = '';
|
||||
let workDir = '/opt';
|
||||
let startCommand = '';
|
||||
let runUser = 'root';
|
||||
let ports = '';
|
||||
let logPaths = '';
|
||||
let createSystemd = true;
|
||||
let enable = true;
|
||||
let start = true;
|
||||
let error = '';
|
||||
let result = '';
|
||||
|
||||
async function submit() {
|
||||
error = '';
|
||||
result = '';
|
||||
try {
|
||||
const payload = {
|
||||
app_type: appType,
|
||||
name,
|
||||
work_dir: workDir,
|
||||
start_command: startCommand,
|
||||
run_user: runUser,
|
||||
ports: ports.split(',').map((p) => Number(p.trim())).filter(Boolean),
|
||||
log_paths: logPaths.split(',').map((p) => p.trim()).filter(Boolean),
|
||||
create_systemd: createSystemd,
|
||||
enable,
|
||||
start
|
||||
};
|
||||
const data: any = await post(`/api/agents/${id}/apps/${createSystemd ? 'create-systemd-service' : 'manage-custom'}`, payload);
|
||||
result = `已纳管:${data.application?.display_name || name}`;
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<p class="eyebrow">LightOps 纳管</p>
|
||||
<h1>纳管应用</h1>
|
||||
<div class="form-grid">
|
||||
<label>应用类型
|
||||
<select bind:value={appType}>
|
||||
<option value="Binary">二进制程序</option>
|
||||
<option value="Shell">Shell 脚本</option>
|
||||
<option value="Node.js">Node.js 应用</option>
|
||||
<option value="Python">Python 应用</option>
|
||||
<option value="Java">Java 应用</option>
|
||||
<option value="Go">Go 应用</option>
|
||||
<option value="Existing systemd">现有 systemd 服务</option>
|
||||
<option value="Existing Docker">现有 Docker 容器</option>
|
||||
<option value="Directory">现有目录</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>应用名称<input bind:value={name} placeholder="alist" /></label>
|
||||
<label>工作目录<input bind:value={workDir} placeholder="/opt/alist" /></label>
|
||||
<label>启动命令<input bind:value={startCommand} placeholder="/opt/alist/alist server" /></label>
|
||||
<label>运行用户<input bind:value={runUser} placeholder="root" /></label>
|
||||
<label>端口<input bind:value={ports} placeholder="5244,3000" /></label>
|
||||
<label>日志路径<input bind:value={logPaths} placeholder="/var/log/alist.log" /></label>
|
||||
<label class="check"><input type="checkbox" bind:checked={createSystemd} /> 生成 systemd 服务</label>
|
||||
<label class="check"><input type="checkbox" bind:checked={enable} /> 开机自启</label>
|
||||
<label class="check"><input type="checkbox" bind:checked={start} /> 创建后启动</label>
|
||||
</div>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
{#if result}<p class="good">{result}</p>{/if}
|
||||
<button on:click={submit}>创建并纳管</button>
|
||||
</section>
|
||||
313
web/src/pages/AppStore.svelte
Normal file
313
web/src/pages/AppStore.svelte
Normal file
@@ -0,0 +1,313 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, del, post } from '../lib/api';
|
||||
import { requireDangerConfirm, withConfirm } from '../lib/confirm';
|
||||
|
||||
let agents: any[] = [];
|
||||
let apps: any[] = [];
|
||||
let installs: any[] = [];
|
||||
let selectedAgent = '';
|
||||
let q = '';
|
||||
let category = '';
|
||||
let installing = '';
|
||||
let operating = '';
|
||||
let logTitle = '';
|
||||
let logText = '';
|
||||
let error = '';
|
||||
let message = '';
|
||||
let forms: Record<string, any> = {};
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
error = '';
|
||||
try {
|
||||
agents = await api('/api/agents');
|
||||
if (!selectedAgent && agents[0]) selectedAgent = agents[0].id;
|
||||
await Promise.all([loadApps(), loadInstalls()]);
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApps() {
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set('q', q);
|
||||
if (category) params.set('category', category);
|
||||
apps = await api(`/api/app-store?${params}`);
|
||||
for (const app of apps) {
|
||||
initForm(app);
|
||||
}
|
||||
}
|
||||
|
||||
function initForm(app: any) {
|
||||
forms[app.slug] ||= {
|
||||
project: app.slug,
|
||||
port: app.default_port,
|
||||
data_dir: `/opt/lightops-store/${app.slug}/${app.data_dir_suffix || 'data'}`,
|
||||
fields: {}
|
||||
};
|
||||
forms[app.slug].fields ||= {};
|
||||
for (const field of app.fields || []) {
|
||||
if (forms[app.slug].fields[field.key] === undefined) {
|
||||
forms[app.slug].fields[field.key] = field.default ?? (field.type === 'bool' ? false : '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInstalls() {
|
||||
installs = await api('/api/app-store/installations');
|
||||
}
|
||||
|
||||
async function install(app: any) {
|
||||
if (!selectedAgent) {
|
||||
error = '请先选择安装主机';
|
||||
return;
|
||||
}
|
||||
const form = forms[app.slug] || {};
|
||||
const confirmInfo = requireDangerConfirm('安装软件商店应用', app.name);
|
||||
if (!confirmInfo) return;
|
||||
installing = app.slug;
|
||||
error = '';
|
||||
message = '';
|
||||
try {
|
||||
const data: any = await post(
|
||||
`/api/agents/${selectedAgent}/app-store/${encodeURIComponent(app.slug)}/install`,
|
||||
withConfirm(
|
||||
{
|
||||
project: form.project || app.slug,
|
||||
port: Number(form.port || app.default_port),
|
||||
data_dir: form.data_dir || `/opt/lightops-store/${form.project || app.slug}/${app.data_dir_suffix || 'data'}`,
|
||||
fields: form.fields || {}
|
||||
},
|
||||
confirmInfo
|
||||
)
|
||||
);
|
||||
message = `${app.name} 已安装,项目:${data.result?.work_dir || form.project || app.slug},${data.access?.hint || `访问端口:${form.port || app.default_port}`}`;
|
||||
await loadInstalls();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
installing = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function operateInstallation(item: any, action: string, label: string, dangerous = false) {
|
||||
const key = `${item.id}:${action}`;
|
||||
const confirmInfo = dangerous ? requireDangerConfirm(label, item.name) : null;
|
||||
if (dangerous && !confirmInfo) return;
|
||||
operating = key;
|
||||
error = '';
|
||||
message = '';
|
||||
try {
|
||||
if (action === 'uninstall') {
|
||||
await del(`/api/app-store/installations/${encodeURIComponent(item.id)}`);
|
||||
} else {
|
||||
await post(
|
||||
`/api/app-store/installations/${encodeURIComponent(item.id)}/${action}`,
|
||||
dangerous ? withConfirm({}, confirmInfo) : {}
|
||||
);
|
||||
}
|
||||
message = `${item.name} ${label}已完成`;
|
||||
await loadInstalls();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
operating = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function readLogs(item: any) {
|
||||
const key = `${item.id}:logs`;
|
||||
operating = key;
|
||||
error = '';
|
||||
logTitle = `${item.name} / ${item.project}`;
|
||||
logText = '正在读取日志...';
|
||||
try {
|
||||
const data: any = await api(`/api/app-store/installations/${encodeURIComponent(item.id)}/logs?tail=300`);
|
||||
logText = [data.stdout, data.stderr].filter(Boolean).join('\n') || '暂无日志输出';
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
logText = '';
|
||||
} finally {
|
||||
operating = '';
|
||||
}
|
||||
}
|
||||
|
||||
function categoryLabel(value: string) {
|
||||
return (
|
||||
{
|
||||
storage: '存储',
|
||||
monitoring: '监控',
|
||||
dev: '开发',
|
||||
database: '数据库',
|
||||
web: '网站'
|
||||
} as Record<string, string>
|
||||
)[value] || value;
|
||||
}
|
||||
|
||||
function statusLabel(value: string) {
|
||||
return (
|
||||
{
|
||||
installing: '安装中',
|
||||
installed: '已安装',
|
||||
stopped: '已停止',
|
||||
uninstalled: '已卸载',
|
||||
failed: '失败'
|
||||
} as Record<string, string>
|
||||
)[value] || value;
|
||||
}
|
||||
|
||||
function goDocker(agentId: string) {
|
||||
history.pushState({}, '', `/nodes/${agentId}/docker`);
|
||||
dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
function openDetail(slug: string) {
|
||||
history.pushState({}, '', `/store/${encodeURIComponent(slug)}`);
|
||||
dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<p class="eyebrow">一键部署</p>
|
||||
<h1>软件商店</h1>
|
||||
<p class="muted">基于 Docker Compose 的轻量软件商店。应用安装动作会下发到指定 Agent 执行,不在 Agent 保存复杂数据库。</p>
|
||||
</div>
|
||||
<button on:click={load}>刷新</button>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
{#if message}<p class="success">{message}</p>{/if}
|
||||
|
||||
<div class="filter-bar store-filter">
|
||||
<select bind:value={selectedAgent}>
|
||||
<option value="">选择安装主机</option>
|
||||
{#each agents as agent}
|
||||
<option value={agent.id}>{agent.hostname || agent.name || agent.id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input bind:value={q} placeholder="搜索软件,例如 alist、redis、git" on:keydown={(e) => e.key === 'Enter' && loadApps()} />
|
||||
<select bind:value={category} on:change={loadApps}>
|
||||
<option value="">全部分类</option>
|
||||
<option value="web">网站</option>
|
||||
<option value="storage">存储</option>
|
||||
<option value="monitoring">监控</option>
|
||||
<option value="database">数据库</option>
|
||||
<option value="dev">开发</option>
|
||||
</select>
|
||||
<button class="ghost" on:click={loadApps}>筛选</button>
|
||||
</div>
|
||||
|
||||
<div class="store-grid">
|
||||
{#each apps as app}
|
||||
<article class="store-card">
|
||||
<div>
|
||||
<span class="store-pill">{categoryLabel(app.category)}</span>
|
||||
<h2>{app.name}</h2>
|
||||
<p>{app.description}</p>
|
||||
<p class="muted">镜像:{app.image}</p>
|
||||
<div class="permission-list">
|
||||
{#each app.tags || [] as tag}
|
||||
<span>{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid store-form">
|
||||
<label>项目名<input bind:value={forms[app.slug].project} /></label>
|
||||
<label>宿主机端口<input type="number" bind:value={forms[app.slug].port} min="1" max="65535" /></label>
|
||||
<label class="wide">数据目录<input bind:value={forms[app.slug].data_dir} /></label>
|
||||
{#each app.fields || [] as field}
|
||||
{#if field.type === 'bool'}
|
||||
<label class="check">
|
||||
<input type="checkbox" bind:checked={forms[app.slug].fields[field.key]} />
|
||||
<span>{field.label}{field.required ? ' *' : ''}</span>
|
||||
{#if field.help}<small>{field.help}</small>{/if}
|
||||
</label>
|
||||
{:else if field.type === 'select'}
|
||||
<label>
|
||||
{field.label}{field.required ? ' *' : ''}
|
||||
<select bind:value={forms[app.slug].fields[field.key]}>
|
||||
{#each field.options || [] as option}
|
||||
<option value={option}>{option}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if field.help}<small>{field.help}</small>{/if}
|
||||
</label>
|
||||
{:else}
|
||||
<label>
|
||||
{field.label}{field.required ? ' *' : ''}
|
||||
{#if field.type === 'password'}
|
||||
<input
|
||||
type="password"
|
||||
bind:value={forms[app.slug].fields[field.key]}
|
||||
min={field.min ?? undefined}
|
||||
max={field.max ?? undefined}
|
||||
placeholder={field.placeholder || ''}
|
||||
/>
|
||||
{:else if field.type === 'number' || field.type === 'port'}
|
||||
<input
|
||||
type="number"
|
||||
bind:value={forms[app.slug].fields[field.key]}
|
||||
min={field.min ?? (field.type === 'port' ? 1 : undefined)}
|
||||
max={field.max ?? (field.type === 'port' ? 65535 : undefined)}
|
||||
placeholder={field.placeholder || ''}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={forms[app.slug].fields[field.key]}
|
||||
minlength={field.min ?? undefined}
|
||||
maxlength={field.max ?? undefined}
|
||||
placeholder={field.placeholder || ''}
|
||||
/>
|
||||
{/if}
|
||||
{#if field.help}<small>{field.help}</small>{/if}
|
||||
</label>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button on:click={() => install(app)} disabled={installing === app.slug || !selectedAgent}>
|
||||
{installing === app.slug ? '安装中...' : '安装'}
|
||||
</button>
|
||||
<button class="ghost" on:click={() => openDetail(app.slug)}>详情</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h2>安装记录</h2>
|
||||
<div class="table">
|
||||
{#each installs as item}
|
||||
<div class="row store-install-row">
|
||||
<strong>{item.name}</strong>
|
||||
<span>{statusLabel(item.status)}</span>
|
||||
<span>{item.project}</span>
|
||||
<span>{item.agent_id}</span>
|
||||
<span>{item.error || '-'}</span>
|
||||
<div class="store-actions">
|
||||
<button class="ghost" on:click={() => operateInstallation(item, 'start', '启动')} disabled={operating !== '' || item.status === 'uninstalled'}>启动</button>
|
||||
<button class="ghost" on:click={() => operateInstallation(item, 'stop', '停止', true)} disabled={operating !== '' || item.status === 'uninstalled'}>停止</button>
|
||||
<button class="ghost" on:click={() => operateInstallation(item, 'restart', '重启')} disabled={operating !== '' || item.status === 'uninstalled'}>重启</button>
|
||||
<button class="ghost" on:click={() => readLogs(item)} disabled={operating !== '' || item.status === 'uninstalled'}>日志</button>
|
||||
<button class="ghost" on:click={() => operateInstallation(item, 'update', '更新', true)} disabled={operating !== '' || item.status === 'uninstalled'}>更新</button>
|
||||
<button class="danger" on:click={() => operateInstallation(item, 'uninstall', '卸载', true)} disabled={operating !== '' || item.status === 'uninstalled'}>卸载</button>
|
||||
<button class="ghost" on:click={() => goDocker(item.agent_id)}>Docker</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if installs.length === 0}
|
||||
<div class="empty-state"><p class="muted">暂无安装记录。</p></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if logText}
|
||||
<h2>安装应用日志</h2>
|
||||
<p class="muted">{logTitle}</p>
|
||||
<pre class="log-output">{logText}</pre>
|
||||
{/if}
|
||||
</section>
|
||||
173
web/src/pages/Apps.svelte
Normal file
173
web/src/pages/Apps.svelte
Normal file
@@ -0,0 +1,173 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, post } from '../lib/api';
|
||||
export let id: string | undefined = undefined;
|
||||
|
||||
let apps: any[] = [];
|
||||
let agents: any[] = [];
|
||||
let error = '';
|
||||
let q = '';
|
||||
let status = '';
|
||||
let provider = '';
|
||||
let selectedAgent = id || '';
|
||||
|
||||
onMount(async () => {
|
||||
agents = await api('/api/agents');
|
||||
await load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedAgent && !id) params.set('agent_id', selectedAgent);
|
||||
if (q) params.set('q', q);
|
||||
if (status) params.set('status', status);
|
||||
if (provider) params.set('provider', provider);
|
||||
const path = id ? `/api/agents/${id}/apps?${params}` : `/api/apps?${params}`;
|
||||
apps = await api(path);
|
||||
}
|
||||
|
||||
async function discover(agentId = id || selectedAgent) {
|
||||
error = '';
|
||||
if (!agentId) {
|
||||
error = '请选择主机后刷新发现';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await post(`/api/agents/${agentId}/apps/discover`, {});
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function open(app: any) {
|
||||
history.pushState({}, '', `/nodes/${app.agent_id}/apps/${encodeURIComponent(app.id)}`);
|
||||
dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
function manage() {
|
||||
const agentId = id || selectedAgent || agents[0]?.id;
|
||||
if (!agentId) return;
|
||||
history.pushState({}, '', `/nodes/${agentId}/apps/manage`);
|
||||
dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
function statusLabel(value: string) {
|
||||
return ({
|
||||
Running: '运行中',
|
||||
Stopped: '已停止',
|
||||
Failed: '失败',
|
||||
Enabled: '已启用',
|
||||
Disabled: '已禁用',
|
||||
Installing: '安装中',
|
||||
Updating: '更新中',
|
||||
Unknown: '未知'
|
||||
} as Record<string, string>)[value] || value || '-';
|
||||
}
|
||||
|
||||
function typeLabel(value: string) {
|
||||
return ({
|
||||
WebApp: 'Web 应用',
|
||||
Service: '服务',
|
||||
Database: '数据库',
|
||||
Runtime: '运行时',
|
||||
Tool: '工具',
|
||||
Container: '容器',
|
||||
ComposeProject: 'Compose 项目',
|
||||
StaticSite: '静态站点',
|
||||
ReverseProxy: '反向代理',
|
||||
Custom: '自定义'
|
||||
} as Record<string, string>)[value] || value || '-';
|
||||
}
|
||||
|
||||
function providerLabel(value: string) {
|
||||
return ({
|
||||
Systemd: 'systemd',
|
||||
Docker: 'Docker',
|
||||
DockerCompose: 'Docker Compose',
|
||||
Apt: 'APT',
|
||||
Dnf: 'DNF',
|
||||
Pacman: 'Pacman',
|
||||
Snap: 'Snap',
|
||||
Flatpak: 'Flatpak',
|
||||
Binary: '二进制',
|
||||
PM2: 'PM2',
|
||||
Supervisor: 'Supervisor',
|
||||
NginxSite: 'Nginx 站点',
|
||||
LightOpsManaged: 'LightOps 纳管',
|
||||
Custom: '自定义'
|
||||
} as Record<string, string>)[value] || value || '-';
|
||||
}
|
||||
|
||||
function healthLabel(app: any) {
|
||||
const health = app.latest_health;
|
||||
if (!health) return '-';
|
||||
const result = health.result || {};
|
||||
if (result.ok === true) return `可用 ${result.latency_ms ?? '-'}ms`;
|
||||
if (result.ok === false) return '异常';
|
||||
return health.status === 'success' ? '已检查' : '失败';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<p class="eyebrow">{id ? '当前主机应用' : '全局应用视图'}</p>
|
||||
<h1>应用管理</h1>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button on:click={() => discover()}>重新发现</button>
|
||||
<button class="ghost" on:click={manage}>纳管应用</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
{#if !id}
|
||||
<select bind:value={selectedAgent} on:change={load}>
|
||||
<option value="">全部主机</option>
|
||||
{#each agents as agent}
|
||||
<option value={agent.id}>{agent.hostname}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<input bind:value={q} placeholder="搜索应用名" on:keydown={(e) => e.key === 'Enter' && load()} />
|
||||
<select bind:value={status} on:change={load}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="Running">运行中</option>
|
||||
<option value="Stopped">已停止</option>
|
||||
<option value="Enabled">已启用</option>
|
||||
<option value="Disabled">已禁用</option>
|
||||
<option value="Failed">失败</option>
|
||||
<option value="Unknown">未知</option>
|
||||
</select>
|
||||
<select bind:value={provider} on:change={load}>
|
||||
<option value="">全部来源</option>
|
||||
<option value="Systemd">systemd</option>
|
||||
<option value="Docker">Docker</option>
|
||||
<option value="NginxSite">Nginx 站点</option>
|
||||
<option value="Apt">APT</option>
|
||||
<option value="LightOpsManaged">LightOps 纳管</option>
|
||||
</select>
|
||||
<button class="ghost" on:click={load}>筛选</button>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
|
||||
<div class="app-table">
|
||||
<div class="app-row apps-row app-head">
|
||||
<span>应用</span><span>状态</span><span>健康</span><span>类型</span><span>来源</span><span>端口</span><span>域名</span><span>主机</span>
|
||||
</div>
|
||||
{#each apps as app}
|
||||
<button class="app-row apps-row" on:click={() => open(app)}>
|
||||
<strong>{app.display_name || app.name}</strong>
|
||||
<span class:good={app.status === 'Running' || app.status === 'Enabled'} class:bad={app.status === 'Failed'}>{statusLabel(app.status)}</span>
|
||||
<span class:good={app.latest_health?.result?.ok === true} class:bad={app.latest_health?.result?.ok === false}>{healthLabel(app)}</span>
|
||||
<span>{typeLabel(app.app_type)}</span>
|
||||
<span>{providerLabel(app.provider)}</span>
|
||||
<span>{(app.ports || []).join(', ') || '-'}</span>
|
||||
<span>{(app.domains || []).join(', ') || '-'}</span>
|
||||
<span>{app.agent_id}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
149
web/src/pages/Audit.svelte
Normal file
149
web/src/pages/Audit.svelte
Normal file
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
let logs: any[] = [];
|
||||
let loading = true;
|
||||
let error = '';
|
||||
let q = '';
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
logs = await api('/api/audit-logs?limit=200');
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function actionLabel(action: string) {
|
||||
return ({
|
||||
'auth.init': '初始化管理员',
|
||||
'auth.login': '用户登录',
|
||||
'agent.register': 'Agent 注册',
|
||||
'agent.delete': '删除 Agent',
|
||||
'agent_token.create': '创建 Agent 注册 Token',
|
||||
'terminal.open': '打开终端',
|
||||
'file.write': '写入文件',
|
||||
'file.delete': '删除文件',
|
||||
'file.rename': '重命名文件',
|
||||
'file.upload.chunk': '上传文件分片',
|
||||
'file.chmod': '修改文件权限',
|
||||
'service.start': '启动服务',
|
||||
'service.stop': '停止服务',
|
||||
'service.restart': '重启服务',
|
||||
'nginx.site.create': '创建 Nginx 站点',
|
||||
'nginx.site.update': '更新 Nginx 站点',
|
||||
'nginx.site.restore_backup': '恢复 Nginx 站点备份',
|
||||
'nginx.site.enable': '启用 Nginx 站点',
|
||||
'nginx.site.disable': '禁用 Nginx 站点',
|
||||
'nginx.reload': '重载 Nginx',
|
||||
'nginx.ssl.issue': '申请 SSL 证书',
|
||||
'nginx.ssl.renew': '续期 SSL 证书',
|
||||
'nginx.ssl.auto_renew': '启用 SSL 自动续期',
|
||||
'docker.container.stop': '停止 Docker 容器',
|
||||
'docker.container.restart': '重启 Docker 容器',
|
||||
'docker.container.delete': '删除 Docker 容器',
|
||||
'docker.container.run': '创建 Docker 容器',
|
||||
'docker.container.exec': '进入 Docker 容器终端',
|
||||
'docker.image.pull': '拉取 Docker 镜像',
|
||||
'docker.image.delete': '删除 Docker 镜像',
|
||||
'docker.volume.delete': '删除 Docker 数据卷',
|
||||
'docker.compose.start': '启动 Compose 项目',
|
||||
'docker.compose.stop': '停止 Compose 项目',
|
||||
'docker.compose.restart': '重启 Compose 项目',
|
||||
'docker.compose.deploy': '部署 Compose 项目',
|
||||
'app.discover': '发现应用',
|
||||
'app.start': '启动应用',
|
||||
'app.stop': '停止应用',
|
||||
'app.restart': '重启应用',
|
||||
'app.reload': '重载应用',
|
||||
'app.logs': '查看应用日志',
|
||||
'app.manage_custom': '纳管应用',
|
||||
'app.create_systemd_service': '创建应用 systemd 服务',
|
||||
'app.unmanage': '取消纳管应用',
|
||||
'app_store.preflight': '软件商店安装前检测',
|
||||
'app_store.install': '软件商店安装应用',
|
||||
'app_store.start': '软件商店启动应用',
|
||||
'app_store.stop': '软件商店停止应用',
|
||||
'app_store.restart': '软件商店重启应用',
|
||||
'app_store.logs': '软件商店查看日志',
|
||||
'app_store.update': '软件商店更新应用',
|
||||
'app_store.uninstall': '软件商店卸载应用',
|
||||
'notification.webhook.test': '测试 Webhook 通知',
|
||||
'system.backup.create': '创建主控备份',
|
||||
'system.backup.restore': '恢复主控备份',
|
||||
'system.backup.delete': '删除主控备份'
|
||||
} as Record<string, string>)[action] || action;
|
||||
}
|
||||
|
||||
function logLevel(log: any) {
|
||||
if (!log.success) return '错误';
|
||||
if (String(log.action || '').includes('delete') || String(log.action || '').includes('stop')) return '风险';
|
||||
return '信息';
|
||||
}
|
||||
|
||||
function logText(log: any) {
|
||||
return `${actionLabel(log.action)} ${log.action || ''} ${log.target || ''} ${log.agent_id || ''} ${log.params_summary || ''} ${log.error || ''}`.toLowerCase();
|
||||
}
|
||||
|
||||
$: filteredLogs = logs.filter((log) => !q || logText(log).includes(q.toLowerCase()));
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<p class="eyebrow">操作日志</p>
|
||||
<h1>日志</h1>
|
||||
<p class="muted">记录登录、Agent 注册、终端、文件、服务、Nginx、Docker、应用等关键操作。</p>
|
||||
</div>
|
||||
<button on:click={load} disabled={loading}>{loading ? '刷新中...' : '刷新'}</button>
|
||||
</div>
|
||||
|
||||
<div class="log-toolbar">
|
||||
<input bind:value={q} placeholder="搜索动作、主机、目标、参数或错误" />
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
|
||||
<div class="audit-log-stream">
|
||||
{#each filteredLogs as log}
|
||||
<article class:error-line={!log.success} class:risk-line={logLevel(log) === '风险'} class="audit-log-line">
|
||||
<div class="audit-log-time">
|
||||
<span>{log.created_at}</span>
|
||||
<b>{logLevel(log)}</b>
|
||||
</div>
|
||||
<div class="audit-log-body">
|
||||
<div class="audit-log-main">
|
||||
<strong>{actionLabel(log.action)}</strong>
|
||||
<em>{log.success ? '成功' : '失败'}</em>
|
||||
</div>
|
||||
<div class="audit-log-meta">
|
||||
<span>用户 {log.user_id || '-'}</span>
|
||||
<span>主机 {log.agent_id || '-'}</span>
|
||||
<span>目标 {log.target || '-'}</span>
|
||||
<span>动作 {log.action}</span>
|
||||
</div>
|
||||
{#if log.params_summary}
|
||||
<pre>{log.params_summary}</pre>
|
||||
{/if}
|
||||
{#if log.error}
|
||||
<p class="error">{log.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !loading && filteredLogs.length === 0}
|
||||
<div class="empty-state">
|
||||
<h2>没有日志</h2>
|
||||
<p class="muted">暂无匹配的操作记录。</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
249
web/src/pages/Dashboard.svelte
Normal file
249
web/src/pages/Dashboard.svelte
Normal file
@@ -0,0 +1,249 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, post } from '../lib/api';
|
||||
|
||||
let agents: any[] = [];
|
||||
let metricsByAgent: Record<string, any> = {};
|
||||
let tokenResult: any = null;
|
||||
let error = '';
|
||||
let loading = true;
|
||||
let q = '';
|
||||
let health = 'all';
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
agents = await api('/api/agents');
|
||||
const pairs = await Promise.all(
|
||||
agents.map(async (agent) => {
|
||||
try {
|
||||
const metrics: any[] = await api(`/api/agents/${agent.id}/metrics?limit=2`);
|
||||
return [agent.id, metrics] as const;
|
||||
} catch {
|
||||
return [agent.id, []] as const;
|
||||
}
|
||||
})
|
||||
);
|
||||
metricsByAgent = Object.fromEntries(pairs);
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createToken() {
|
||||
error = '';
|
||||
try {
|
||||
tokenResult = await post('/api/agent-tokens', { name: '默认' });
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function openNode(id: string) {
|
||||
history.pushState({}, '', `/nodes/${id}`);
|
||||
dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
function percent(used = 0, total = 0) {
|
||||
if (!total) return 0;
|
||||
return Math.min(100, Math.max(0, (used / total) * 100));
|
||||
}
|
||||
|
||||
function size(bytes = 0) {
|
||||
if (!bytes) return '-';
|
||||
if (bytes > 1024 * 1024 * 1024) return `${Math.round(bytes / 1024 / 1024 / 1024)} GB`;
|
||||
return `${Math.round(bytes / 1024 / 1024)} MB`;
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return status === 'online' ? '在线' : '离线';
|
||||
}
|
||||
|
||||
function uptime(seconds = 0) {
|
||||
if (!seconds) return '-';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
if (days > 0) return `${days} 天 ${hours} 小时`;
|
||||
return `${hours} 小时`;
|
||||
}
|
||||
|
||||
function healthState(agent: any) {
|
||||
const metric = latestMetric(agent);
|
||||
if (agent.status !== 'online') return 'offline';
|
||||
if ((metric.cpu_usage || 0) >= 85) return 'warning';
|
||||
if (percent(metric.memory_used, metric.memory_total) >= 85) return 'warning';
|
||||
if (percent(metric.disk_used, metric.disk_total) >= 90) return 'warning';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
function healthLabel(state: string) {
|
||||
return (
|
||||
{
|
||||
healthy: '正常',
|
||||
warning: '资源偏高',
|
||||
offline: '离线'
|
||||
} as Record<string, string>
|
||||
)[state] || '未知';
|
||||
}
|
||||
|
||||
function ringStyle(value = 0) {
|
||||
const safe = Math.min(100, Math.max(0, value));
|
||||
return `--value:${safe}`;
|
||||
}
|
||||
|
||||
function latestMetric(agent: any) {
|
||||
return metricsByAgent[agent.id]?.[0] || {};
|
||||
}
|
||||
|
||||
function previousMetric(agent: any) {
|
||||
return metricsByAgent[agent.id]?.[1] || {};
|
||||
}
|
||||
|
||||
function netRate(agent: any, key: 'network_rx' | 'network_tx') {
|
||||
const latest = latestMetric(agent);
|
||||
const previous = previousMetric(agent);
|
||||
if (!latest.created_at || !previous.created_at) return 0;
|
||||
const seconds = (Date.parse(latest.created_at) - Date.parse(previous.created_at)) / 1000;
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return 0;
|
||||
const delta = (latest[key] || 0) - (previous[key] || 0);
|
||||
if (delta < 0) return 0;
|
||||
return delta / seconds;
|
||||
}
|
||||
|
||||
function speed(bytesPerSecond = 0) {
|
||||
if (!bytesPerSecond) return '0 B/s';
|
||||
if (bytesPerSecond >= 1024 * 1024) return `${(bytesPerSecond / 1024 / 1024).toFixed(1)} MB/s`;
|
||||
if (bytesPerSecond >= 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`;
|
||||
return `${bytesPerSecond.toFixed(0)} B/s`;
|
||||
}
|
||||
|
||||
$: onlineCount = agents.filter((a) => a.status === 'online').length;
|
||||
$: offlineCount = agents.length - onlineCount;
|
||||
$: latestSeen = agents
|
||||
.map((agent) => agent.last_seen_at)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.at(-1);
|
||||
$: filteredAgents = agents
|
||||
.filter((agent) => {
|
||||
const text = `${agent.hostname || ''} ${agent.name || ''} ${agent.ip || ''} ${agent.os || ''}`.toLowerCase();
|
||||
return !q || text.includes(q.toLowerCase());
|
||||
})
|
||||
.filter((agent) => health === 'all' || healthState(agent) === health)
|
||||
.sort((a, b) => {
|
||||
const rank = { warning: 0, offline: 1, healthy: 2 } as Record<string, number>;
|
||||
return rank[healthState(a)] - rank[healthState(b)] || String(a.hostname || '').localeCompare(String(b.hostname || ''));
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="overview-shell">
|
||||
<div class="probe-hero">
|
||||
<div>
|
||||
<p class="eyebrow">探针总览</p>
|
||||
<h1>{agents.length} 台主机</h1>
|
||||
<p class="muted">这里是全站唯一主入口。点击某台服务器后,再进入文件、终端、服务、Nginx、Docker、应用等运维功能。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button on:click={load} disabled={loading}>{loading ? '刷新中...' : '刷新状态'}</button>
|
||||
<button class="ghost" on:click={createToken}>注册新主机</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
|
||||
<div class="probe-stats">
|
||||
<div class="stat-card"><b>{agents.length}</b><span>主机总数</span></div>
|
||||
<div class="stat-card"><b>{onlineCount}</b><span>在线主机</span></div>
|
||||
<div class="stat-card warn"><b>{offlineCount}</b><span>离线主机</span></div>
|
||||
<div class="stat-card compact"><b>{latestSeen || '-'}</b><span>最近心跳</span></div>
|
||||
</div>
|
||||
|
||||
<div class="probe-toolbar">
|
||||
<input bind:value={q} placeholder="搜索主机名、IP、系统" />
|
||||
<div class="segmented">
|
||||
<button class:active={health === 'all'} on:click={() => (health = 'all')}>全部</button>
|
||||
<button class:active={health === 'healthy'} on:click={() => (health = 'healthy')}>正常</button>
|
||||
<button class:active={health === 'warning'} on:click={() => (health = 'warning')}>告警</button>
|
||||
<button class:active={health === 'offline'} on:click={() => (health = 'offline')}>离线</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if tokenResult}
|
||||
<div class="panel">
|
||||
<h2>Agent 安装命令</h2>
|
||||
<p class="muted">在被管理主机上执行下面命令,Agent 会主动连接主控端。</p>
|
||||
<textarea readonly rows="4">{tokenResult.install_command}</textarea>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="node-grid">
|
||||
{#each filteredAgents as agent}
|
||||
{@const metric = latestMetric(agent)}
|
||||
{@const state = healthState(agent)}
|
||||
<button class="node-card" on:click={() => openNode(agent.id)}>
|
||||
<div class="node-title">
|
||||
<span class:online={agent.status === 'online'} class="dot"></span>
|
||||
<div>
|
||||
<strong>{agent.hostname || agent.name || agent.id}</strong>
|
||||
<small>{agent.ip || '未知 IP'} · {agent.os || '-'} / {agent.arch || '-'}</small>
|
||||
</div>
|
||||
<em class:good={state === 'healthy'} class:bad={state === 'offline'} class:warn-text={state === 'warning'}>{healthLabel(state)}</em>
|
||||
</div>
|
||||
<div class="node-status-line">
|
||||
<span>{statusLabel(agent.status)}</span>
|
||||
<span>运行 {uptime(metric.uptime)}</span>
|
||||
<span>负载 {metric.load_avg || '-'}</span>
|
||||
</div>
|
||||
<div class="probe-rings">
|
||||
<div class="ring-item">
|
||||
<div class="ring" style={ringStyle(metric.cpu_usage || 0)}>
|
||||
<b>{(metric.cpu_usage || 0).toFixed(0)}%</b>
|
||||
</div>
|
||||
<span>CPU</span>
|
||||
</div>
|
||||
<div class="ring-item">
|
||||
<div class="ring" style={ringStyle(percent(metric.memory_used, metric.memory_total))}>
|
||||
<b>{percent(metric.memory_used, metric.memory_total).toFixed(0)}%</b>
|
||||
</div>
|
||||
<span>内存</span>
|
||||
<small>{size(metric.memory_used)} / {size(metric.memory_total)}</small>
|
||||
</div>
|
||||
<div class="ring-item">
|
||||
<div class="ring" style={ringStyle(percent(metric.disk_used, metric.disk_total))}>
|
||||
<b>{percent(metric.disk_used, metric.disk_total).toFixed(0)}%</b>
|
||||
</div>
|
||||
<span>磁盘</span>
|
||||
<small>{size(metric.disk_used)} / {size(metric.disk_total)}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="node-meta">
|
||||
<span>下行 {speed(netRate(agent, 'network_rx'))}</span>
|
||||
<span>上行 {speed(netRate(agent, 'network_tx'))}</span>
|
||||
<span>Agent {agent.version || '-'}</span>
|
||||
<span>最后在线 {agent.last_seen_at || '-'}</span>
|
||||
<span>点击进入运维</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !loading && agents.length > 0 && filteredAgents.length === 0}
|
||||
<div class="panel empty-state">
|
||||
<h2>没有匹配的主机</h2>
|
||||
<p class="muted">调整搜索关键字或健康状态筛选。</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !loading && agents.length === 0}
|
||||
<div class="panel empty-state">
|
||||
<h2>还没有主机</h2>
|
||||
<p class="muted">先生成一次性 Token,在服务器上安装 Agent 后,这里会出现探针卡片。</p>
|
||||
<button on:click={createToken}>生成一次性 Token</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
559
web/src/pages/Docker.svelte
Normal file
559
web/src/pages/Docker.svelte
Normal file
@@ -0,0 +1,559 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { api, post, del, wsUrl } from '../lib/api';
|
||||
import { requireConfirm, requireDangerConfirm, withConfirm } from '../lib/confirm';
|
||||
export let id: string;
|
||||
let status: any = {};
|
||||
let containers: any[] = [];
|
||||
let images: any[] = [];
|
||||
let volumes: any[] = [];
|
||||
let composeProjects: any[] = [];
|
||||
let logs = '';
|
||||
let selectedContainer = '';
|
||||
let containerDetail: any = null;
|
||||
let containerStats: any = null;
|
||||
let execContainer: any = null;
|
||||
let execEl: HTMLDivElement;
|
||||
let execTerm: Terminal | null = null;
|
||||
let execWs: WebSocket | null = null;
|
||||
let imageName = '';
|
||||
let pulling = false;
|
||||
let runImage = 'nginx:latest';
|
||||
let runName = '';
|
||||
let runPorts = '8080:80';
|
||||
let runVolumes = '';
|
||||
let runEnv = '';
|
||||
let runRestart = 'unless-stopped';
|
||||
let runCommand = '';
|
||||
let running = false;
|
||||
let composeProject = 'lightops-app';
|
||||
let composeWorkDir = '/opt/lightops-compose/lightops-app';
|
||||
let composeContent = `services:
|
||||
app:
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- "8080:80"
|
||||
restart: unless-stopped
|
||||
`;
|
||||
let deployingCompose = false;
|
||||
let proxyVisible = false;
|
||||
let proxyTarget = '';
|
||||
let proxySiteName = '';
|
||||
let proxyServerName = '';
|
||||
let proxyUpstream = 'http://127.0.0.1:3000';
|
||||
let proxyIssueSsl = false;
|
||||
let proxySslEmail = '';
|
||||
let creatingProxy = false;
|
||||
let error = '';
|
||||
onMount(load);
|
||||
onDestroy(closeExec);
|
||||
async function load() {
|
||||
error = '';
|
||||
try {
|
||||
status = await api(`/api/agents/${id}/docker/status`);
|
||||
containers = ((await api(`/api/agents/${id}/docker/containers`)) as any).containers || [];
|
||||
images = ((await api(`/api/agents/${id}/docker/images`)) as any).images || [];
|
||||
volumes = ((await api(`/api/agents/${id}/docker/volumes`)) as any).volumes || [];
|
||||
composeProjects = ((await api(`/api/agents/${id}/docker/compose/projects`)) as any).projects || [];
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
async function act(cid: string, action: string) {
|
||||
const label = ({ start: '启动', stop: '停止', restart: '重启' } as Record<string, string>)[action] || action;
|
||||
if (action === 'start') {
|
||||
if (!requireConfirm(`确认${label}容器 ${cid}?`)) return;
|
||||
await post(`/api/agents/${id}/docker/containers/${cid}/${action}`, {});
|
||||
} else {
|
||||
const confirmInfo = requireDangerConfirm(`${label}容器`, cid);
|
||||
if (!confirmInfo) return;
|
||||
await post(`/api/agents/${id}/docker/containers/${cid}/${action}`, withConfirm({}, confirmInfo));
|
||||
}
|
||||
await load();
|
||||
}
|
||||
async function rm(cid: string) {
|
||||
if (!requireDangerConfirm('删除容器', cid)) return;
|
||||
await del(`/api/agents/${id}/docker/containers/${cid}`);
|
||||
await load();
|
||||
}
|
||||
async function showLogs(cid: string) {
|
||||
error = '';
|
||||
try {
|
||||
const data: any = await api(`/api/agents/${id}/docker/containers/${cid}/logs`);
|
||||
logs = data.stdout || data.stderr || '';
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function showContainerDetail(cid: string) {
|
||||
error = '';
|
||||
selectedContainer = cid;
|
||||
containerDetail = null;
|
||||
containerStats = null;
|
||||
try {
|
||||
const [detail, stats]: any[] = await Promise.all([
|
||||
api(`/api/agents/${id}/docker/containers/${cid}/inspect`),
|
||||
api(`/api/agents/${id}/docker/containers/${cid}/stats`)
|
||||
]);
|
||||
containerDetail = detail.detail || detail;
|
||||
containerStats = stats.stats || stats;
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function openExec(container: any) {
|
||||
closeExec();
|
||||
execContainer = container;
|
||||
await tick();
|
||||
execTerm = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
theme: { background: '#08110f' }
|
||||
});
|
||||
execTerm.open(execEl);
|
||||
execTerm.writeln(`正在进入容器 ${container.names || container.id} ...\r\n`);
|
||||
execWs = new WebSocket(wsUrl(`/api/agents/${id}/docker/containers/${encodeURIComponent(container.id)}/exec`));
|
||||
execWs.onopen = () => execTerm?.writeln('Docker exec 终端已连接,默认 shell: sh\r\n');
|
||||
execWs.onmessage = (ev) => execTerm?.write(ev.data);
|
||||
execWs.onclose = () => execTerm?.writeln('\r\n连接已关闭');
|
||||
execWs.onerror = () => execTerm?.writeln('\r\n连接失败或无权限');
|
||||
execTerm.onData((data) => execWs?.readyState === WebSocket.OPEN && execWs.send(data));
|
||||
}
|
||||
|
||||
function closeExec() {
|
||||
execWs?.close();
|
||||
execWs = null;
|
||||
execTerm?.dispose();
|
||||
execTerm = null;
|
||||
execContainer = null;
|
||||
}
|
||||
async function rmi(imageId: string) {
|
||||
if (!requireDangerConfirm('删除镜像', imageId)) return;
|
||||
await del(`/api/agents/${id}/docker/images/${encodeURIComponent(imageId)}`);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function removeVolume(name: string) {
|
||||
if (!requireDangerConfirm('删除 Docker 数据卷', name)) return;
|
||||
await del(`/api/agents/${id}/docker/volumes/${encodeURIComponent(name)}`);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function pullImage() {
|
||||
const image = imageName.trim();
|
||||
if (!image) {
|
||||
error = '请输入镜像名称,例如 nginx:latest';
|
||||
return;
|
||||
}
|
||||
if (!requireConfirm(`确认拉取镜像 ${image}?大镜像可能需要较长时间。`)) return;
|
||||
error = '';
|
||||
logs = '';
|
||||
pulling = true;
|
||||
try {
|
||||
const data: any = await post(`/api/agents/${id}/docker/images/pull`, { image });
|
||||
logs = data.stdout || data.stderr || '镜像拉取完成';
|
||||
imageName = '';
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
pulling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runContainer() {
|
||||
const image = runImage.trim();
|
||||
if (!image) {
|
||||
error = '请输入镜像名称';
|
||||
return;
|
||||
}
|
||||
const confirmInfo = requireDangerConfirm('创建并启动容器', runName.trim() || image);
|
||||
if (!confirmInfo) return;
|
||||
error = '';
|
||||
logs = '';
|
||||
running = true;
|
||||
try {
|
||||
const data: any = await post(`/api/agents/${id}/docker/containers/run`, withConfirm({
|
||||
image,
|
||||
name: runName.trim() || null,
|
||||
ports: parsePorts(runPorts),
|
||||
volumes: parseVolumes(runVolumes),
|
||||
env: parseEnv(runEnv),
|
||||
restart: runRestart,
|
||||
detach: true,
|
||||
command: runCommand.trim() || null
|
||||
}, confirmInfo));
|
||||
logs = data.stdout || data.stderr || '容器已创建';
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deployCompose() {
|
||||
const project = composeProject.trim();
|
||||
const workDir = composeWorkDir.trim();
|
||||
if (!project || !workDir || !composeContent.trim()) {
|
||||
error = '请填写项目名、工作目录和 Compose 内容';
|
||||
return;
|
||||
}
|
||||
const confirmInfo = requireDangerConfirm('部署 Compose 项目', project);
|
||||
if (!confirmInfo) return;
|
||||
error = '';
|
||||
logs = '';
|
||||
deployingCompose = true;
|
||||
try {
|
||||
const data: any = await post(`/api/agents/${id}/docker/compose/deploy`, withConfirm({
|
||||
project,
|
||||
work_dir: workDir,
|
||||
content: composeContent
|
||||
}, confirmInfo));
|
||||
logs = data.stdout || data.stderr || 'Compose 项目已部署';
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
deployingCompose = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function composeAct(project: string, action: string) {
|
||||
const label = ({ start: '启动', stop: '停止', restart: '重启' } as Record<string, string>)[action] || action;
|
||||
const confirmInfo = action === 'start' ? null : requireDangerConfirm(`${label} Compose 项目`, project);
|
||||
if (action !== 'start' && !confirmInfo) return;
|
||||
if (action === 'start' && !requireConfirm(`确认${label} Compose 项目 ${project}?`)) return;
|
||||
await post(`/api/agents/${id}/docker/compose/projects/${encodeURIComponent(project)}/${action}`, withConfirm({}, confirmInfo));
|
||||
await load();
|
||||
}
|
||||
|
||||
async function showComposeLogs(project: string) {
|
||||
error = '';
|
||||
try {
|
||||
const data: any = await api(`/api/agents/${id}/docker/compose/projects/${encodeURIComponent(project)}/logs`);
|
||||
logs = data.stdout || data.stderr || '';
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function containerStatus(value: string) {
|
||||
if (!value) return '-';
|
||||
if (value.startsWith('Up')) return value.replace('Up', '运行中');
|
||||
if (value.startsWith('Exited')) return value.replace('Exited', '已退出');
|
||||
if (value.startsWith('Created')) return value.replace('Created', '已创建');
|
||||
if (value.startsWith('Restarting')) return value.replace('Restarting', '重启中');
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePorts(value: string) {
|
||||
return value
|
||||
.split(/[,\n]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => {
|
||||
const [host, containerProtocol] = item.split(':');
|
||||
const [container, protocol = 'tcp'] = (containerProtocol || '').split('/');
|
||||
return { host: Number(host), container: Number(container), protocol };
|
||||
})
|
||||
.filter((item) => item.host && item.container);
|
||||
}
|
||||
|
||||
function parseVolumes(value: string) {
|
||||
return value
|
||||
.split(/[,\n]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => {
|
||||
const parts = item.split(':');
|
||||
return { host: parts[0], container: parts[1], readonly: parts[2] === 'ro' };
|
||||
})
|
||||
.filter((item) => item.host && item.container);
|
||||
}
|
||||
|
||||
function parseEnv(value: string) {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of value.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !trimmed.includes('=')) continue;
|
||||
const index = trimmed.indexOf('=');
|
||||
env[trimmed.slice(0, index).trim()] = trimmed.slice(index + 1);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function openProxyForContainer(container: any) {
|
||||
proxyVisible = true;
|
||||
proxyTarget = container.names || container.id;
|
||||
proxySiteName = normalizeSiteName(container.names || container.id);
|
||||
proxyServerName = `${proxySiteName}.example.com`;
|
||||
proxyUpstream = `http://127.0.0.1:${extractHostPort(container.ports) || '3000'}`;
|
||||
}
|
||||
|
||||
function openProxyForCompose(project: any) {
|
||||
proxyVisible = true;
|
||||
proxyTarget = project.name;
|
||||
proxySiteName = normalizeSiteName(project.name);
|
||||
proxyServerName = `${proxySiteName}.example.com`;
|
||||
proxyUpstream = 'http://127.0.0.1:3000';
|
||||
}
|
||||
|
||||
async function createProxySite() {
|
||||
const site = proxySiteName.trim();
|
||||
const serverNameValue = proxyServerName.trim();
|
||||
const upstreamValue = proxyUpstream.trim();
|
||||
if (!site || !serverNameValue || !upstreamValue) {
|
||||
error = '请填写站点名、域名和上游地址';
|
||||
return;
|
||||
}
|
||||
const confirmInfo = requireDangerConfirm('创建并启用 Nginx 反代', site);
|
||||
if (!confirmInfo) return;
|
||||
error = '';
|
||||
logs = '';
|
||||
creatingProxy = true;
|
||||
try {
|
||||
const body = withConfirm(
|
||||
{
|
||||
name: site,
|
||||
server_name: serverNameValue,
|
||||
mode: 'proxy',
|
||||
upstream: upstreamValue,
|
||||
websocket: true,
|
||||
gzip: true,
|
||||
cache_static: true,
|
||||
client_max_body_size: '64m',
|
||||
source: 'docker',
|
||||
target: proxyTarget
|
||||
},
|
||||
confirmInfo
|
||||
);
|
||||
await post(`/api/agents/${id}/nginx/sites`, body);
|
||||
await post(`/api/agents/${id}/nginx/sites/${encodeURIComponent(site)}/enable`, withConfirm({}, confirmInfo));
|
||||
await post(`/api/agents/${id}/nginx/reload`, withConfirm({}, confirmInfo));
|
||||
if (proxyIssueSsl) {
|
||||
await post(`/api/agents/${id}/nginx/ssl/issue`, withConfirm({
|
||||
domains: serverNameValue.split(/[\s,]+/).filter(Boolean),
|
||||
email: proxySslEmail || null
|
||||
}, confirmInfo));
|
||||
}
|
||||
logs = `反代站点 ${site} 已创建,上游 ${upstreamValue}`;
|
||||
proxyVisible = false;
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
creatingProxy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function extractHostPort(ports: string) {
|
||||
const match = ports?.match(/(?:0\.0\.0\.0|127\.0\.0\.1|\[?::\]?|\*)?:(\d+)->/);
|
||||
return match?.[1] || '';
|
||||
}
|
||||
|
||||
function normalizeSiteName(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/^[\/]+/, '')
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64) || 'lightops-app';
|
||||
}
|
||||
|
||||
function detailPairs(detail: any) {
|
||||
if (!detail) return [];
|
||||
const config = detail.Config || {};
|
||||
const host = detail.HostConfig || {};
|
||||
const state = detail.State || {};
|
||||
return [
|
||||
['容器 ID', detail.Id || '-'],
|
||||
['镜像', config.Image || detail.Image || '-'],
|
||||
['状态', state.Status || '-'],
|
||||
['启动时间', state.StartedAt || '-'],
|
||||
['重启策略', host.RestartPolicy?.Name || '-'],
|
||||
['网络模式', host.NetworkMode || '-'],
|
||||
['工作目录', config.WorkingDir || '-'],
|
||||
['入口命令', [config.Entrypoint, config.Cmd].flat().filter(Boolean).join(' ') || '-']
|
||||
];
|
||||
}
|
||||
|
||||
function mountList(detail: any) {
|
||||
return (detail?.Mounts || []).map((item: any) => `${item.Source || '-'} -> ${item.Destination || '-'}${item.RW === false ? ' (只读)' : ''}`);
|
||||
}
|
||||
|
||||
function statValue(key: string) {
|
||||
return containerStats?.[key] || '-';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Docker</h1>
|
||||
<p class="muted">安装状态:{status.installed ? '已安装' : '未安装'} {status.version || ''}</p>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<h2>运行容器</h2>
|
||||
<div class="nginx-create">
|
||||
<div class="form-grid">
|
||||
<label>镜像<input bind:value={runImage} placeholder="nginx:latest" /></label>
|
||||
<label>容器名<input bind:value={runName} placeholder="可选,例如 my-nginx" /></label>
|
||||
<label>端口映射<input bind:value={runPorts} placeholder="8080:80,8443:443/tcp" /></label>
|
||||
<label>数据卷<input bind:value={runVolumes} placeholder="/host/data:/data,/host/conf:/conf:ro" /></label>
|
||||
<label>重启策略
|
||||
<select bind:value={runRestart}>
|
||||
<option value="no">不自动重启</option>
|
||||
<option value="always">always</option>
|
||||
<option value="unless-stopped">unless-stopped</option>
|
||||
<option value="on-failure">on-failure</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>启动命令<input bind:value={runCommand} placeholder="可选,例如 server --port 3000" /></label>
|
||||
</div>
|
||||
<label>环境变量<textarea bind:value={runEnv} rows="4" placeholder="TZ=Asia/Shanghai PUID=1000"></textarea></label>
|
||||
<button on:click={runContainer} disabled={running}>{running ? '创建中...' : '创建并启动容器'}</button>
|
||||
</div>
|
||||
|
||||
<h2>部署 Compose</h2>
|
||||
<div class="nginx-create">
|
||||
<div class="form-grid">
|
||||
<label>项目名<input bind:value={composeProject} placeholder="my-app" /></label>
|
||||
<label>工作目录<input bind:value={composeWorkDir} placeholder="/opt/lightops-compose/my-app" /></label>
|
||||
</div>
|
||||
<textarea bind:value={composeContent} rows="10" placeholder="docker compose yaml"></textarea>
|
||||
<button on:click={deployCompose} disabled={deployingCompose}>{deployingCompose ? '部署中...' : '保存并 up -d'}</button>
|
||||
</div>
|
||||
|
||||
<h2>Docker Compose 项目</h2>
|
||||
<div class="table">
|
||||
{#each composeProjects as project}
|
||||
<div class="row">
|
||||
<strong>{project.name}</strong>
|
||||
<span>{project.status === 'Running' ? '运行中' : '已停止'}</span>
|
||||
<span>服务:{(project.services || []).join(', ') || '-'}</span>
|
||||
<span>容器:{(project.containers || []).length}</span>
|
||||
<button on:click={() => composeAct(project.name, 'start')}>启动</button>
|
||||
<button on:click={() => composeAct(project.name, 'restart')}>重启</button>
|
||||
<button on:click={() => composeAct(project.name, 'stop')}>停止</button>
|
||||
<button class="ghost" on:click={() => showComposeLogs(project.name)}>日志</button>
|
||||
<button class="ghost" on:click={() => openProxyForCompose(project)}>创建反代</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if composeProjects.length === 0}
|
||||
<div class="empty-state"><p class="muted">未发现 Docker Compose 项目。</p></div>
|
||||
{/if}
|
||||
</div>
|
||||
<h2>容器</h2>
|
||||
<div class="table">
|
||||
{#each containers as c}
|
||||
<div class="row docker-row">
|
||||
<strong>{c.names}</strong><span>{c.image}</span><span>{containerStatus(c.status)}</span><span>{c.ports || '-'}</span>
|
||||
<button on:click={() => act(c.id, 'start')}>启动</button>
|
||||
<button on:click={() => act(c.id, 'restart')}>重启</button>
|
||||
<button on:click={() => act(c.id, 'stop')}>停止</button>
|
||||
<button class="ghost" on:click={() => openExec(c)}>终端</button>
|
||||
<button class="ghost" on:click={() => showContainerDetail(c.id)}>详情/资源</button>
|
||||
<button class="ghost" on:click={() => showLogs(c.id)}>日志</button>
|
||||
<button class="ghost" on:click={() => openProxyForContainer(c)}>创建反代</button>
|
||||
<button class="danger" on:click={() => rm(c.id)}>删除</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if execContainer}
|
||||
<h2>容器终端</h2>
|
||||
<div class="panel inner terminal-panel">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<strong>{execContainer.names || execContainer.id}</strong>
|
||||
<p class="muted">通过 docker exec -it 进入容器,只连接当前容器,不开放任意宿主机命令。</p>
|
||||
</div>
|
||||
<button class="ghost" on:click={closeExec}>关闭终端</button>
|
||||
</div>
|
||||
<div bind:this={execEl} class="terminal"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedContainer}
|
||||
<h2>容器详情</h2>
|
||||
<div class="panel inner">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<strong>{selectedContainer}</strong>
|
||||
<p class="muted">资源占用来自 docker stats --no-stream,配置来自 docker inspect。</p>
|
||||
</div>
|
||||
<button class="ghost" on:click={() => showContainerDetail(selectedContainer)}>刷新详情</button>
|
||||
</div>
|
||||
<div class="metric-grid">
|
||||
<div><b>{statValue('CPUPerc')}</b><span>CPU</span></div>
|
||||
<div><b>{statValue('MemUsage')}</b><span>内存</span></div>
|
||||
<div><b>{statValue('NetIO')}</b><span>网络</span></div>
|
||||
<div><b>{statValue('BlockIO')}</b><span>磁盘 IO</span></div>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
{#each detailPairs(containerDetail) as pair}
|
||||
<div><span>{pair[0]}</span><strong>{pair[1]}</strong></div>
|
||||
{/each}
|
||||
</div>
|
||||
<h3>挂载目录</h3>
|
||||
{#if mountList(containerDetail).length === 0}
|
||||
<p class="muted">无挂载目录。</p>
|
||||
{:else}
|
||||
<div class="permission-list">
|
||||
{#each mountList(containerDetail) as mount}
|
||||
<span>{mount}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if proxyVisible}
|
||||
<h2>一键创建 Nginx 反代</h2>
|
||||
<div class="nginx-create">
|
||||
<p class="muted">目标:{proxyTarget}。会创建站点、启用站点,并执行 nginx -t 后重载。</p>
|
||||
<div class="form-grid">
|
||||
<label>站点名<input bind:value={proxySiteName} placeholder="my-app" /></label>
|
||||
<label>域名<input bind:value={proxyServerName} placeholder="app.example.com" /></label>
|
||||
<label>上游地址<input bind:value={proxyUpstream} placeholder="http://127.0.0.1:3000" /></label>
|
||||
<label>证书邮箱<input bind:value={proxySslEmail} placeholder="可选,用于 certbot" /></label>
|
||||
<label class="check"><input type="checkbox" bind:checked={proxyIssueSsl} /> 同时申请免费 SSL 证书</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button on:click={createProxySite} disabled={creatingProxy}>{creatingProxy ? '创建中...' : '创建并启用反代'}</button>
|
||||
<button class="ghost" on:click={() => (proxyVisible = false)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if logs}
|
||||
<h2>容器日志</h2>
|
||||
<pre class="log-output">{logs}</pre>
|
||||
{/if}
|
||||
<h2>镜像</h2>
|
||||
<div class="inline-form">
|
||||
<input bind:value={imageName} placeholder="镜像名称,例如 nginx:latest、redis:7" />
|
||||
<button on:click={pullImage} disabled={pulling}>{pulling ? '拉取中...' : '拉取镜像'}</button>
|
||||
</div>
|
||||
<div class="table">
|
||||
{#each images as img}
|
||||
<div class="row">
|
||||
<strong>{img.repository}:{img.tag}</strong><span>{img.id}</span><span>{img.size}</span>
|
||||
<button class="danger" on:click={() => rmi(img.id)}>删除镜像</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h2>数据卷</h2>
|
||||
<p class="muted">删除数据卷会删除持久化数据。只建议删除确认不再被容器使用的卷。</p>
|
||||
<div class="table">
|
||||
{#each volumes as volume}
|
||||
<div class="row volume-row">
|
||||
<strong>{volume.Name || volume.name}</strong>
|
||||
<span>驱动:{volume.Driver || '-'}</span>
|
||||
<span>挂载点:{volume.Mountpoint || '-'}</span>
|
||||
<button class="danger" on:click={() => removeVolume(volume.Name || volume.name)}>删除数据卷</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if volumes.length === 0}
|
||||
<div class="empty-state"><p class="muted">未发现 Docker 数据卷。</p></div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
360
web/src/pages/Files.svelte
Normal file
360
web/src/pages/Files.svelte
Normal file
@@ -0,0 +1,360 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, post } from '../lib/api';
|
||||
import { requireDangerConfirm, withConfirm } from '../lib/confirm';
|
||||
|
||||
export let id: string;
|
||||
|
||||
let roots: any[] = [];
|
||||
let path = '/';
|
||||
let entries: any[] = [];
|
||||
let content = '';
|
||||
let editPath = '';
|
||||
let error = '';
|
||||
let transfer = '';
|
||||
let searchKeyword = '';
|
||||
let searchResults: any[] = [];
|
||||
let searching = false;
|
||||
let loading = true;
|
||||
const chunkSize = 512 * 1024;
|
||||
|
||||
onMount(async () => {
|
||||
const queryPath = new URLSearchParams(location.search).get('path');
|
||||
if (queryPath) path = queryPath;
|
||||
await loadRoots();
|
||||
if (queryPath) path = queryPath;
|
||||
await load();
|
||||
});
|
||||
|
||||
async function loadRoots() {
|
||||
try {
|
||||
const data: any = await api(`/api/agents/${id}/files/roots`);
|
||||
roots = data.roots || [];
|
||||
if (roots.length > 0) path = roots[0].path;
|
||||
} catch {
|
||||
roots = [{ name: '/', path: '/' }];
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const data: any = await api(`/api/agents/${id}/files?path=${encodeURIComponent(path)}`);
|
||||
entries = (data.entries || []).sort((a: any, b: any) => Number(b.is_dir) - Number(a.is_dir) || a.name.localeCompare(b.name));
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function open(entry: any) {
|
||||
if (entry.is_dir) {
|
||||
path = entry.path;
|
||||
content = '';
|
||||
editPath = '';
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
await readFile(entry.path);
|
||||
}
|
||||
|
||||
async function readFile(filePath: string) {
|
||||
error = '';
|
||||
try {
|
||||
const data: any = await post(`/api/agents/${id}/files/read`, { path: filePath });
|
||||
editPath = filePath;
|
||||
content = data.content;
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editPath) return;
|
||||
const confirmInfo = requireDangerConfirm('保存文件', editPath, 'normal');
|
||||
if (!confirmInfo) return;
|
||||
await post(`/api/agents/${id}/files/write`, withConfirm({ path: editPath, content }, confirmInfo));
|
||||
await load();
|
||||
}
|
||||
|
||||
async function createFile() {
|
||||
const name = prompt('请输入新文件名');
|
||||
if (!name) return;
|
||||
const filePath = joinPath(path, name);
|
||||
await post(`/api/agents/${id}/files/write`, { path: filePath, content: '' });
|
||||
await readFile(filePath);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function mkdir() {
|
||||
const name = prompt('请输入文件夹名称');
|
||||
if (!name) return;
|
||||
await post(`/api/agents/${id}/files/mkdir`, { path: joinPath(path, name) });
|
||||
await load();
|
||||
}
|
||||
|
||||
async function rename(entry: any) {
|
||||
const nextName = prompt('请输入新名称', entry.name);
|
||||
if (!nextName || nextName === entry.name) return;
|
||||
const confirmInfo = requireDangerConfirm('重命名文件', entry.path, 'normal');
|
||||
if (!confirmInfo) return;
|
||||
await post(`/api/agents/${id}/files/rename`, withConfirm({ from: entry.path, to: joinPath(path, nextName) }, confirmInfo));
|
||||
if (editPath === entry.path) editPath = '';
|
||||
await load();
|
||||
}
|
||||
|
||||
async function remove(entry: any) {
|
||||
const recursive = entry.is_dir;
|
||||
const message = recursive
|
||||
? `确认递归删除目录 ${entry.path}?目录内所有文件都会被删除。`
|
||||
: `确认删除 ${entry.path}?`;
|
||||
const confirmInfo = requireDangerConfirm(message, entry.path);
|
||||
if (!confirmInfo) return;
|
||||
await post(`/api/agents/${id}/files/delete`, withConfirm({ path: entry.path, recursive }, confirmInfo));
|
||||
if (editPath === entry.path) {
|
||||
editPath = '';
|
||||
content = '';
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
async function upload(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
const filePath = joinPath(path, file.name);
|
||||
const confirmInfo = requireDangerConfirm('上传并覆盖文件', filePath, 'normal');
|
||||
if (!confirmInfo) return;
|
||||
error = '';
|
||||
transfer = `上传中 0%`;
|
||||
try {
|
||||
let offset = 0;
|
||||
if (file.size === 0) {
|
||||
await post(`/api/agents/${id}/files/write`, withConfirm({ path: filePath, content: '' }, confirmInfo));
|
||||
}
|
||||
while (offset < file.size) {
|
||||
const chunk = file.slice(offset, offset + chunkSize);
|
||||
const data = await blobToBase64(chunk);
|
||||
await post(`/api/agents/${id}/files/upload-chunk`, withConfirm({
|
||||
path: filePath,
|
||||
offset,
|
||||
data
|
||||
}, confirmInfo));
|
||||
offset += chunk.size;
|
||||
transfer = `上传中 ${Math.min(100, Math.round((offset / file.size) * 100))}%`;
|
||||
}
|
||||
transfer = '上传完成';
|
||||
input.value = '';
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function download(entry: any) {
|
||||
error = '';
|
||||
transfer = '下载中 0%';
|
||||
const chunks: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
let total = entry.size || 0;
|
||||
try {
|
||||
while (true) {
|
||||
const data: any = await api(`/api/agents/${id}/files/download-chunk?path=${encodeURIComponent(entry.path)}&offset=${offset}&size=${chunkSize}`);
|
||||
total = data.size || total;
|
||||
const bytes = base64ToBytes(data.data || '');
|
||||
chunks.push(bytes);
|
||||
offset += data.read || bytes.length;
|
||||
transfer = total ? `下载中 ${Math.min(100, Math.round((offset / total) * 100))}%` : `下载中 ${offset} B`;
|
||||
if (data.eof) break;
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
return;
|
||||
}
|
||||
const blob = new Blob(chunks, { type: 'application/octet-stream' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = entry.name;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
transfer = '下载完成';
|
||||
}
|
||||
|
||||
async function chmod(entry: any) {
|
||||
const mode = prompt(`请输入 ${entry.name} 的权限模式,例如 755`, '755');
|
||||
if (!mode) return;
|
||||
const confirmInfo = requireDangerConfirm('修改文件权限', entry.path);
|
||||
if (!confirmInfo) return;
|
||||
await post(`/api/agents/${id}/files/chmod`, withConfirm({ path: entry.path, mode }, confirmInfo));
|
||||
await load();
|
||||
}
|
||||
|
||||
async function searchFiles() {
|
||||
if (!searchKeyword.trim()) {
|
||||
searchResults = [];
|
||||
return;
|
||||
}
|
||||
searching = true;
|
||||
error = '';
|
||||
try {
|
||||
const data: any = await post(`/api/agents/${id}/files/search`, {
|
||||
path,
|
||||
keyword: searchKeyword.trim(),
|
||||
max_depth: 6,
|
||||
limit: 200
|
||||
});
|
||||
searchResults = data.entries || [];
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function parent() {
|
||||
const normalized = path.replace(/[\\/]+$/, '');
|
||||
if (/^[A-Za-z]:$/.test(normalized) || normalized === '') return path;
|
||||
const index = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\'));
|
||||
if (index <= 0) return path.includes('\\') ? path.slice(0, 3) : '/';
|
||||
return normalized.slice(0, index);
|
||||
}
|
||||
|
||||
async function goParent() {
|
||||
const next = parent();
|
||||
if (next === path) return;
|
||||
path = next;
|
||||
await load();
|
||||
}
|
||||
|
||||
function joinPath(base: string, name: string) {
|
||||
const cleanName = name.replace(/[\\/]+/g, '');
|
||||
if (base.endsWith('/') || base.endsWith('\\')) return `${base}${cleanName}`;
|
||||
return `${base}${base.includes('\\') ? '\\' : '/'}${cleanName}`;
|
||||
}
|
||||
|
||||
function size(bytes = 0) {
|
||||
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function blobToBase64(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result || '');
|
||||
resolve(result.includes(',') ? result.split(',')[1] : result);
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string) {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="file-shell">
|
||||
<div class="panel file-browser">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<p class="eyebrow">远程文件系统</p>
|
||||
<h1>文件管理</h1>
|
||||
<p class="muted">通过 Agent 浏览当前主机文件,能访问的范围取决于 Agent 运行用户权限。</p>
|
||||
</div>
|
||||
<button on:click={load} disabled={loading}>{loading ? '读取中...' : '刷新'}</button>
|
||||
</div>
|
||||
|
||||
<div class="root-list">
|
||||
{#each roots as root}
|
||||
<button class:active={path === root.path} on:click={async () => { path = root.path; await load(); }}>{root.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="path-bar">
|
||||
<button class="ghost" on:click={goParent}>上级</button>
|
||||
<input bind:value={path} on:keydown={(e) => e.key === 'Enter' && load()} />
|
||||
<button on:click={load}>打开</button>
|
||||
</div>
|
||||
|
||||
<div class="path-bar">
|
||||
<input bind:value={searchKeyword} placeholder="在当前目录下搜索文件或目录名" on:keydown={(e) => e.key === 'Enter' && searchFiles()} />
|
||||
<button class="ghost" on:click={searchFiles} disabled={searching}>{searching ? '搜索中...' : '搜索'}</button>
|
||||
<button class="ghost" on:click={() => { searchKeyword = ''; searchResults = []; }}>清空</button>
|
||||
</div>
|
||||
|
||||
{#if searchResults.length > 0}
|
||||
<div class="panel inner">
|
||||
<h2>搜索结果</h2>
|
||||
<div class="file-table">
|
||||
{#each searchResults as result}
|
||||
<div class="file-row">
|
||||
<button class="file-name" on:click={() => open(result)}>
|
||||
<b>{result.is_dir ? '目录' : '文件'}</b>
|
||||
<span>{result.path}</span>
|
||||
</button>
|
||||
<span>{result.is_dir ? '-' : size(result.size)}</span>
|
||||
<span>-</span>
|
||||
<span>-</span>
|
||||
<span class="file-actions">{#if !result.is_dir}<button class="ghost" on:click={() => download(result)}>下载</button>{/if}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button on:click={createFile}>新建文件</button>
|
||||
<button on:click={mkdir}>新建文件夹</button>
|
||||
<label class="upload-button">
|
||||
上传文件
|
||||
<input type="file" on:change={upload} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
{#if transfer}<p class="muted">{transfer}</p>{/if}
|
||||
|
||||
<div class="file-table">
|
||||
<div class="file-row file-head">
|
||||
<span>名称</span><span>大小</span><span>修改时间</span><span>权限</span><span>操作</span>
|
||||
</div>
|
||||
{#each entries as entry}
|
||||
<div class="file-row">
|
||||
<button class="file-name" on:click={() => open(entry)}>
|
||||
<b>{entry.is_dir ? '目录' : '文件'}</b>
|
||||
<span>{entry.name}</span>
|
||||
</button>
|
||||
<span>{entry.is_dir ? '-' : size(entry.size)}</span>
|
||||
<span>{entry.modified || '-'}</span>
|
||||
<span>{entry.readonly ? '只读' : '可写'}</span>
|
||||
<span class="file-actions">
|
||||
{#if !entry.is_dir}
|
||||
<button class="ghost" on:click={() => download(entry)}>下载</button>
|
||||
{/if}
|
||||
<button class="ghost" on:click={() => chmod(entry)}>权限</button>
|
||||
<button class="ghost" on:click={() => rename(entry)}>重命名</button>
|
||||
<button class="danger" on:click={() => remove(entry)}>删除</button>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel file-editor">
|
||||
<h2>{editPath || '选择左侧文本文件'}</h2>
|
||||
<textarea bind:value={content} rows="26" placeholder="当前只支持 2MB 以内文本文件编辑"></textarea>
|
||||
<div class="actions">
|
||||
<button disabled={!editPath} on:click={save}>保存文件</button>
|
||||
<button class="ghost" disabled={!editPath} on:click={() => { editPath = ''; content = ''; }}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
35
web/src/pages/Logs.svelte
Normal file
35
web/src/pages/Logs.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { post } from '../lib/api';
|
||||
export let id: string;
|
||||
let path = '/var/log/syslog';
|
||||
let lines = 200;
|
||||
let output = '';
|
||||
let error = '';
|
||||
|
||||
onMount(() => {
|
||||
const queryPath = new URLSearchParams(location.search).get('path');
|
||||
if (queryPath) path = queryPath;
|
||||
});
|
||||
|
||||
async function tail() {
|
||||
error = '';
|
||||
try {
|
||||
const data: any = await post(`/api/agents/${id}/logs/tail`, { path, lines });
|
||||
output = data.stdout || data.content || '';
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>日志查看</h1>
|
||||
<div class="inline-form">
|
||||
<input bind:value={path} placeholder="/var/log/syslog" />
|
||||
<input bind:value={lines} type="number" min="1" max="2000" />
|
||||
<button on:click={tail}>读取尾部日志</button>
|
||||
</div>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<pre class="log-output">{output}</pre>
|
||||
</section>
|
||||
430
web/src/pages/Nginx.svelte
Normal file
430
web/src/pages/Nginx.svelte
Normal file
@@ -0,0 +1,430 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, post } from '../lib/api';
|
||||
import { requireConfirm, requireDangerConfirm, withConfirm } from '../lib/confirm';
|
||||
|
||||
export let id: string;
|
||||
|
||||
let status: any = {};
|
||||
let sslStatus: any = {};
|
||||
let sites: any[] = [];
|
||||
let selected = '';
|
||||
let content = '';
|
||||
let backups: any[] = [];
|
||||
let createMode = 'proxy';
|
||||
let name = 'example.com';
|
||||
let serverName = 'example.com';
|
||||
let upstream = 'http://127.0.0.1:3000';
|
||||
let upstreams = '127.0.0.1:3000\n127.0.0.1:3001';
|
||||
let root = '/var/www/html';
|
||||
let fastcgiPass = 'unix:/run/php/php8.2-fpm.sock';
|
||||
let proxyWebsocket = true;
|
||||
let proxyGzip = true;
|
||||
let proxyCacheStatic = false;
|
||||
let proxyForceHttps = false;
|
||||
let proxyClientMaxBodySize = '64m';
|
||||
let sslDomains = 'example.com';
|
||||
let sslEmail = '';
|
||||
let output = '';
|
||||
let error = '';
|
||||
let loading = true;
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
status = await api(`/api/agents/${id}/nginx/status`);
|
||||
const data: any = await api(`/api/agents/${id}/nginx/sites`);
|
||||
sites = data.sites || [];
|
||||
try {
|
||||
sslStatus = await api(`/api/agents/${id}/nginx/ssl/status`);
|
||||
} catch {
|
||||
sslStatus = {};
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSite() {
|
||||
error = '';
|
||||
output = '';
|
||||
const body = siteCreateBody();
|
||||
try {
|
||||
const result: any = await post(`/api/agents/${id}/nginx/sites`, body);
|
||||
output = result.stdout || '站点已创建,并已通过 nginx -t 测试';
|
||||
await load();
|
||||
await openSite(name);
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function openSite(siteName: string) {
|
||||
error = '';
|
||||
selected = siteName;
|
||||
try {
|
||||
const data: any = await api(`/api/agents/${id}/nginx/sites/${encodeURIComponent(siteName)}`);
|
||||
content = data.content || '';
|
||||
await loadBackups(siteName);
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackups(siteName = selected) {
|
||||
if (!siteName) {
|
||||
backups = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data: any = await api(`/api/agents/${id}/nginx/sites/${encodeURIComponent(siteName)}/backups`);
|
||||
backups = data.backups || [];
|
||||
} catch {
|
||||
backups = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup(backup: any) {
|
||||
if (!selected) return;
|
||||
const confirmInfo = requireDangerConfirm('恢复 Nginx 站点备份', selected);
|
||||
if (!confirmInfo) return;
|
||||
error = '';
|
||||
output = '';
|
||||
try {
|
||||
const result: any = await post(
|
||||
`/api/agents/${id}/nginx/sites/${encodeURIComponent(selected)}/backups/${encodeURIComponent(backup.name)}/restore`,
|
||||
withConfirm({}, confirmInfo)
|
||||
);
|
||||
output = result.stdout || '备份已恢复,并已通过 nginx -t 测试';
|
||||
await openSite(selected);
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSite() {
|
||||
if (!selected) return;
|
||||
const confirmInfo = requireDangerConfirm('保存站点配置', selected);
|
||||
if (!confirmInfo) return;
|
||||
error = '';
|
||||
output = '';
|
||||
try {
|
||||
const result: any = await api(`/api/agents/${id}/nginx/sites/${encodeURIComponent(selected)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(withConfirm({ content }, confirmInfo))
|
||||
});
|
||||
output = result.stdout || '配置已保存,并已通过 nginx -t 测试';
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(site: any, action: string) {
|
||||
const label = action === 'enable' ? '启用' : '禁用';
|
||||
const confirmInfo = requireDangerConfirm(`${label}站点`, site.name);
|
||||
if (!confirmInfo) return;
|
||||
await post(`/api/agents/${id}/nginx/sites/${encodeURIComponent(site.name)}/${action}`, withConfirm({}, confirmInfo));
|
||||
await load();
|
||||
}
|
||||
|
||||
async function testConfig() {
|
||||
error = '';
|
||||
output = '';
|
||||
try {
|
||||
const result: any = await post(`/api/agents/${id}/nginx/test`, {});
|
||||
output = `${result.stdout || ''}${result.stderr || ''}` || 'nginx -t 通过';
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
const confirmInfo = requireDangerConfirm('重载 Nginx', 'nginx');
|
||||
if (!confirmInfo) return;
|
||||
error = '';
|
||||
output = '';
|
||||
try {
|
||||
const result: any = await post(`/api/agents/${id}/nginx/reload`, withConfirm({}, confirmInfo));
|
||||
output = `${result.stdout || ''}${result.stderr || ''}` || 'Nginx 已重载';
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function issueSsl() {
|
||||
const domains = sslDomains
|
||||
.split(/[\s,]+/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
if (domains.length === 0) {
|
||||
error = '请输入至少一个域名';
|
||||
return;
|
||||
}
|
||||
const confirmInfo = requireDangerConfirm('申请 HTTPS 证书', domains.join(','));
|
||||
if (!confirmInfo) return;
|
||||
error = '';
|
||||
output = '';
|
||||
try {
|
||||
const result: any = await post(`/api/agents/${id}/nginx/ssl/issue`, withConfirm({
|
||||
domains,
|
||||
email: sslEmail || null
|
||||
}, confirmInfo));
|
||||
output = `${result.stdout || ''}${result.stderr || ''}` || '证书申请完成';
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function renewSsl() {
|
||||
if (!requireConfirm('确认执行 certbot renew?')) return;
|
||||
error = '';
|
||||
output = '';
|
||||
try {
|
||||
const result: any = await post(`/api/agents/${id}/nginx/ssl/renew`, {});
|
||||
output = `${result.stdout || ''}${result.stderr || ''}` || '证书续期命令已执行';
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableAutoRenew() {
|
||||
if (!requireConfirm('确认启用 SSL 自动续期?将优先启用 certbot.timer,缺失时创建 LightOps 续期定时器。')) return;
|
||||
error = '';
|
||||
output = '';
|
||||
try {
|
||||
const result: any = await post(`/api/agents/${id}/nginx/ssl/auto-renew`, {});
|
||||
output = `${result.stdout || ''}${result.stderr || ''}` || '自动续期已启用';
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function certStatusLabel(statusValue: string) {
|
||||
return ({
|
||||
valid: '有效',
|
||||
warning: '30 天内到期',
|
||||
critical: '7 天内到期',
|
||||
expired: '已过期',
|
||||
unknown: '未知'
|
||||
} as Record<string, string>)[statusValue] || '未知';
|
||||
}
|
||||
|
||||
function certStatusClass(statusValue: string) {
|
||||
if (statusValue === 'expired' || statusValue === 'critical') return 'danger';
|
||||
if (statusValue === 'warning') return 'warn';
|
||||
if (statusValue === 'valid') return 'good';
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
function formatExpiresAt(value: string | null | undefined) {
|
||||
if (!value) return '未知';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function autoRenewProviderLabel(value: string | null | undefined) {
|
||||
if (value === 'certbot.timer') return 'certbot.timer';
|
||||
if (value === 'lightops-certbot-renew.timer') return 'LightOps 定时器';
|
||||
return '未启用';
|
||||
}
|
||||
|
||||
function siteCreateBody() {
|
||||
const base = {
|
||||
name,
|
||||
server_name: serverName,
|
||||
mode: createMode,
|
||||
root,
|
||||
upstream,
|
||||
upstreams: upstreams.split('\n').map((value) => value.trim()).filter(Boolean),
|
||||
fastcgi_pass: fastcgiPass,
|
||||
websocket: proxyWebsocket,
|
||||
gzip: proxyGzip,
|
||||
cache_static: proxyCacheStatic,
|
||||
force_https: proxyForceHttps,
|
||||
client_max_body_size: proxyClientMaxBodySize
|
||||
};
|
||||
return base;
|
||||
}
|
||||
|
||||
function backupTime(value: string | null | undefined) {
|
||||
if (!value) return '-';
|
||||
const seconds = Number(value);
|
||||
if (Number.isFinite(seconds)) return new Date(seconds * 1000).toLocaleString('zh-CN');
|
||||
return value;
|
||||
}
|
||||
|
||||
function modeNeedsRoot(mode: string) {
|
||||
return ['static', 'spa', 'php'].includes(mode);
|
||||
}
|
||||
|
||||
function modeNeedsProxyOptions(mode: string) {
|
||||
return ['proxy', 'load_balance'].includes(mode);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="nginx-shell">
|
||||
<div class="panel">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<p class="eyebrow">网站与反向代理</p>
|
||||
<h1>Nginx 管理</h1>
|
||||
<p class="muted">安装状态:{status.installed ? '已安装' : '未安装'} {status.version || ''}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button on:click={load} disabled={loading}>{loading ? '刷新中...' : '刷新'}</button>
|
||||
<button class="ghost" on:click={testConfig}>配置测试</button>
|
||||
<button on:click={reload}>重载</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
{#if output}<pre class="log-output compact-log">{output}</pre>{/if}
|
||||
|
||||
<div class="nginx-create">
|
||||
<h2>新建站点</h2>
|
||||
<div class="form-grid">
|
||||
<label>类型
|
||||
<select bind:value={createMode}>
|
||||
<option value="proxy">反向代理</option>
|
||||
<option value="static">静态站点</option>
|
||||
<option value="spa">SPA 单页应用</option>
|
||||
<option value="load_balance">负载均衡</option>
|
||||
<option value="php">PHP / FastCGI</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>配置名<input bind:value={name} placeholder="example.com" /></label>
|
||||
<label>域名<input bind:value={serverName} placeholder="example.com www.example.com" /></label>
|
||||
{#if createMode === 'proxy'}
|
||||
<label>上游地址<input bind:value={upstream} placeholder="http://127.0.0.1:3000" /></label>
|
||||
<label>上传大小限制<input bind:value={proxyClientMaxBodySize} placeholder="64m" /></label>
|
||||
{:else if createMode === 'load_balance'}
|
||||
<label>上传大小限制<input bind:value={proxyClientMaxBodySize} placeholder="64m" /></label>
|
||||
<label class="wide">上游列表<textarea bind:value={upstreams} rows="4" placeholder="127.0.0.1:3000 127.0.0.1:3001"></textarea></label>
|
||||
{/if}
|
||||
{#if modeNeedsRoot(createMode)}
|
||||
<label>站点目录<input bind:value={root} placeholder="/var/www/html" /></label>
|
||||
{/if}
|
||||
{#if createMode === 'php'}
|
||||
<label>FastCGI 地址<input bind:value={fastcgiPass} placeholder="unix:/run/php/php8.2-fpm.sock 或 127.0.0.1:9000" /></label>
|
||||
{/if}
|
||||
</div>
|
||||
{#if modeNeedsProxyOptions(createMode)}
|
||||
<div class="settings-grid compact-settings">
|
||||
<label class="setting-card">
|
||||
<span>WebSocket 支持</span>
|
||||
<input type="checkbox" bind:checked={proxyWebsocket} />
|
||||
<small>适合面板、终端、实时推送类应用。</small>
|
||||
</label>
|
||||
<label class="setting-card">
|
||||
<span>开启 gzip</span>
|
||||
<input type="checkbox" bind:checked={proxyGzip} />
|
||||
<small>压缩文本资源,降低带宽占用。</small>
|
||||
</label>
|
||||
<label class="setting-card">
|
||||
<span>静态资源缓存</span>
|
||||
<input type="checkbox" bind:checked={proxyCacheStatic} />
|
||||
<small>对 css/js/图片等资源加 7 天缓存。</small>
|
||||
</label>
|
||||
<label class="setting-card">
|
||||
<span>强制 HTTPS</span>
|
||||
<input type="checkbox" bind:checked={proxyForceHttps} />
|
||||
<small>已有证书配置后再开启,避免 HTTP 首次访问异常。</small>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
<button on:click={createSite}>创建并测试配置</button>
|
||||
</div>
|
||||
|
||||
<h2>站点列表</h2>
|
||||
<div class="app-table">
|
||||
<div class="app-row app-head">
|
||||
<span>站点</span><span>状态</span><span>配置路径</span><span>操作</span><span></span><span></span><span></span>
|
||||
</div>
|
||||
{#each sites as site}
|
||||
<div class="app-row">
|
||||
<button class="file-name" on:click={() => openSite(site.name)}>
|
||||
<strong>{site.name}</strong>
|
||||
</button>
|
||||
<span class:good={site.enabled}>{site.enabled ? '已启用' : '已禁用'}</span>
|
||||
<span>{site.path}</span>
|
||||
<span>{selected === site.name ? '编辑中' : '点击编辑'}</span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span>
|
||||
<button on:click={() => toggle(site, site.enabled ? 'disable' : 'enable')}>
|
||||
{site.enabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel nginx-editor">
|
||||
<h2>{selected ? `编辑配置:${selected}` : '选择站点配置'}</h2>
|
||||
<textarea bind:value={content} rows="22" placeholder="选择左侧站点后编辑 Nginx 配置"></textarea>
|
||||
<div class="actions">
|
||||
<button disabled={!selected} on:click={saveSite}>保存并测试</button>
|
||||
<button class="ghost" disabled={!selected} on:click={() => loadBackups()}>刷新备份</button>
|
||||
<button class="ghost" disabled={!selected} on:click={() => { selected = ''; content = ''; }}>关闭</button>
|
||||
</div>
|
||||
|
||||
{#if selected}
|
||||
<h2>配置备份</h2>
|
||||
{#if backups.length === 0}
|
||||
<p class="muted">暂无备份。每次保存站点配置前会自动创建备份。</p>
|
||||
{:else}
|
||||
<div class="table">
|
||||
{#each backups as backup}
|
||||
<div class="row nginx-backup-row">
|
||||
<strong>{backup.name}</strong>
|
||||
<span>{backupTime(backup.modified)}</span>
|
||||
<span>{backup.size || 0} B</span>
|
||||
<button class="ghost" on:click={() => restoreBackup(backup)}>恢复</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h2>HTTPS 证书</h2>
|
||||
<p class="muted">
|
||||
Certbot:{sslStatus.installed ? '已安装' : '未安装'} {sslStatus.version || ''}
|
||||
自动续期:{sslStatus.auto_renew_enabled ? `已启用(${autoRenewProviderLabel(sslStatus.auto_renew_provider)})` : '未启用'}
|
||||
</p>
|
||||
<div class="inline-form">
|
||||
<input bind:value={sslDomains} placeholder="example.com www.example.com" />
|
||||
<input bind:value={sslEmail} placeholder="邮箱,可选" />
|
||||
<button on:click={issueSsl}>申请证书</button>
|
||||
<button class="ghost" on:click={renewSsl}>立即续期</button>
|
||||
<button class="ghost" on:click={enableAutoRenew}>启用自动续期</button>
|
||||
</div>
|
||||
<div class="cert-list">
|
||||
{#if (sslStatus.certs || []).length === 0}
|
||||
<p class="muted">暂未发现 Let’s Encrypt 证书。</p>
|
||||
{:else}
|
||||
{#each sslStatus.certs || [] as cert}
|
||||
<article class="cert-card">
|
||||
<div>
|
||||
<strong>{cert.name}</strong>
|
||||
<span class={`cert-pill ${certStatusClass(cert.status)}`}>{certStatusLabel(cert.status)}</span>
|
||||
</div>
|
||||
<p>到期时间:{formatExpiresAt(cert.expires_at)}</p>
|
||||
<p>剩余天数:{cert.days_remaining ?? '未知'}</p>
|
||||
<p class="muted">{cert.fullchain}</p>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
195
web/src/pages/NodeDetail.svelte
Normal file
195
web/src/pages/NodeDetail.svelte
Normal file
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../lib/api';
|
||||
export let id: string;
|
||||
let agent: any;
|
||||
let metrics: any[] = [];
|
||||
let snapshot: any = {};
|
||||
let snapshotError = '';
|
||||
onMount(async () => {
|
||||
agent = await api(`/api/agents/${id}`);
|
||||
metrics = await api(`/api/agents/${id}/metrics?limit=120`);
|
||||
loadSnapshot();
|
||||
});
|
||||
|
||||
async function loadSnapshot() {
|
||||
snapshotError = '';
|
||||
try {
|
||||
snapshot = await api(`/api/agents/${id}/system/snapshot`);
|
||||
} catch (err: any) {
|
||||
snapshotError = err.message;
|
||||
}
|
||||
}
|
||||
function go(path: string) {
|
||||
history.pushState({}, '', `/nodes/${id}/${path}`);
|
||||
dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
function back() {
|
||||
history.pushState({}, '', '/dashboard');
|
||||
dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
$: m = metrics[0] || {};
|
||||
$: historyMetrics = [...metrics].reverse();
|
||||
|
||||
function percent(used = 0, total = 0) {
|
||||
if (!total) return 0;
|
||||
return Math.min(100, Math.max(0, (used / total) * 100));
|
||||
}
|
||||
|
||||
function size(bytes = 0) {
|
||||
if (!bytes) return '-';
|
||||
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||
if (bytes >= 1024 * 1024) return `${Math.round(bytes / 1024 / 1024)} MB`;
|
||||
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function speed(bytesPerSecond = 0) {
|
||||
if (!bytesPerSecond) return '0 B/s';
|
||||
if (bytesPerSecond >= 1024 * 1024) return `${(bytesPerSecond / 1024 / 1024).toFixed(1)} MB/s`;
|
||||
if (bytesPerSecond >= 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`;
|
||||
return `${bytesPerSecond.toFixed(0)} B/s`;
|
||||
}
|
||||
|
||||
async function copyUpgradeCommand() {
|
||||
if (!agent?.upgrade_command) return;
|
||||
await navigator.clipboard.writeText(agent.upgrade_command);
|
||||
}
|
||||
|
||||
function valueFor(metric: any, key: string) {
|
||||
if (key === 'memory_usage') return percent(metric.memory_used, metric.memory_total);
|
||||
if (key === 'disk_usage') return percent(metric.disk_used, metric.disk_total);
|
||||
return Number(metric[key] || 0);
|
||||
}
|
||||
|
||||
function linePoints(items: any[], key: string, max = 100) {
|
||||
if (items.length === 0) return '';
|
||||
return items
|
||||
.map((item, index) => {
|
||||
const x = items.length === 1 ? 100 : (index / (items.length - 1)) * 100;
|
||||
const y = 42 - (Math.min(max, Math.max(0, valueFor(item, key))) / max) * 34;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function networkRates(key: 'network_rx' | 'network_tx') {
|
||||
const rates: any[] = [];
|
||||
for (let index = 1; index < historyMetrics.length; index += 1) {
|
||||
const prev = historyMetrics[index - 1];
|
||||
const current = historyMetrics[index];
|
||||
const seconds = (Date.parse(current.created_at) - Date.parse(prev.created_at)) / 1000;
|
||||
const delta = (current[key] || 0) - (prev[key] || 0);
|
||||
rates.push({ ...current, rate: seconds > 0 && delta > 0 ? delta / seconds : 0 });
|
||||
}
|
||||
return rates;
|
||||
}
|
||||
|
||||
function ratePoints(items: any[]) {
|
||||
const max = Math.max(1, ...items.map((item) => item.rate || 0));
|
||||
return items
|
||||
.map((item, index) => {
|
||||
const x = items.length === 1 ? 100 : (index / (items.length - 1)) * 100;
|
||||
const y = 42 - ((item.rate || 0) / max) * 34;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
$: rxRates = networkRates('network_rx');
|
||||
$: txRates = networkRates('network_tx');
|
||||
$: latestRx = rxRates.at(-1)?.rate || 0;
|
||||
$: latestTx = txRates.at(-1)?.rate || 0;
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<p class="eyebrow">单机运维</p>
|
||||
<h1>{agent?.hostname || id}</h1>
|
||||
<p class="muted">{agent?.ip || '-'} · {agent?.os} / {agent?.arch} / {agent?.status === 'online' ? '在线' : '离线'} · Agent {agent?.version || '-'}{agent?.version_status === 'outdated' ? `(最新 ${agent?.latest_version})` : ''}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if agent?.version_status === 'outdated'}<button on:click={copyUpgradeCommand}>复制升级命令</button>{/if}
|
||||
<button class="ghost" on:click={back}>返回总览</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-grid">
|
||||
<div><b>{(m.cpu_usage || 0).toFixed?.(1) || 0}%</b><span>CPU</span></div>
|
||||
<div><b>{Math.round((m.memory_used || 0) / 1024 / 1024)} MB</b><span>内存使用</span></div>
|
||||
<div><b>{Math.round((m.disk_used || 0) / 1024 / 1024 / 1024)} GB</b><span>磁盘使用</span></div>
|
||||
<div><b>{m.load_avg || 0}</b><span>负载</span></div>
|
||||
</div>
|
||||
<h2>监控趋势</h2>
|
||||
<div class="trend-grid">
|
||||
<article class="trend-card">
|
||||
<div><strong>CPU</strong><span>{(m.cpu_usage || 0).toFixed?.(1) || 0}%</span></div>
|
||||
<svg viewBox="0 0 100 48" preserveAspectRatio="none"><polyline points={linePoints(historyMetrics, 'cpu_usage')} /></svg>
|
||||
</article>
|
||||
<article class="trend-card">
|
||||
<div><strong>内存</strong><span>{percent(m.memory_used, m.memory_total).toFixed(1)}% · {size(m.memory_used)}</span></div>
|
||||
<svg viewBox="0 0 100 48" preserveAspectRatio="none"><polyline points={linePoints(historyMetrics, 'memory_usage')} /></svg>
|
||||
</article>
|
||||
<article class="trend-card">
|
||||
<div><strong>磁盘</strong><span>{percent(m.disk_used, m.disk_total).toFixed(1)}% · {size(m.disk_used)}</span></div>
|
||||
<svg viewBox="0 0 100 48" preserveAspectRatio="none"><polyline points={linePoints(historyMetrics, 'disk_usage')} /></svg>
|
||||
</article>
|
||||
<article class="trend-card">
|
||||
<div><strong>网络</strong><span>下行 {speed(latestRx)} / 上行 {speed(latestTx)}</span></div>
|
||||
<svg viewBox="0 0 100 48" preserveAspectRatio="none">
|
||||
<polyline class="rx" points={ratePoints(rxRates)} />
|
||||
<polyline class="tx" points={ratePoints(txRates)} />
|
||||
</svg>
|
||||
</article>
|
||||
</div>
|
||||
<h2>运维操作</h2>
|
||||
<div class="actions">
|
||||
<button on:click={() => go('files')}>文件</button>
|
||||
<button on:click={() => go('terminal')}>终端</button>
|
||||
<button on:click={() => go('logs')}>日志</button>
|
||||
<button on:click={() => go('services')}>服务</button>
|
||||
<button on:click={() => go('nginx')}>Nginx</button>
|
||||
<button on:click={() => go('docker')}>Docker</button>
|
||||
<button on:click={() => go('apps')}>应用</button>
|
||||
<button on:click={() => go('tasks')}>任务</button>
|
||||
</div>
|
||||
|
||||
<div class="title-row sub-title-row">
|
||||
<div>
|
||||
<h2>系统快照</h2>
|
||||
<p class="muted">查看进程、监听端口、磁盘分区和网络接口,便于不用 SSH 也能快速定位问题。</p>
|
||||
</div>
|
||||
<button class="ghost" on:click={loadSnapshot}>刷新快照</button>
|
||||
</div>
|
||||
{#if snapshotError}<p class="error">{snapshotError}</p>{/if}
|
||||
<div class="detail-grid">
|
||||
<article class="detail-card">
|
||||
<h3>高占用进程</h3>
|
||||
{#each (snapshot.processes || []).slice(0, 8) as item}
|
||||
<p><strong>{item.name}</strong> PID {item.pid} · CPU {item.cpu}% · 内存 {item.memory}% · {item.user}</p>
|
||||
{/each}
|
||||
{#if !snapshot.processes?.length}<p class="muted">暂无进程数据。</p>{/if}
|
||||
</article>
|
||||
<article class="detail-card">
|
||||
<h3>监听端口</h3>
|
||||
{#each (snapshot.ports || []).slice(0, 8) as item}
|
||||
<p class="mono-line">{item.raw}</p>
|
||||
{/each}
|
||||
{#if !snapshot.ports?.length}<p class="muted">暂无端口数据。</p>{/if}
|
||||
</article>
|
||||
<article class="detail-card">
|
||||
<h3>磁盘分区</h3>
|
||||
{#each snapshot.disks || [] as item}
|
||||
<p><strong>{item.mount}</strong> {item.used}/{item.size} · {item.usage} · {item.filesystem}</p>
|
||||
{/each}
|
||||
{#if !snapshot.disks?.length}<p class="muted">暂无磁盘数据。</p>{/if}
|
||||
</article>
|
||||
<article class="detail-card">
|
||||
<h3>网络接口</h3>
|
||||
{#each snapshot.networks || [] as item}
|
||||
<p class="mono-line">{item.raw}</p>
|
||||
{/each}
|
||||
{#if !snapshot.networks?.length}<p class="muted">暂无网络接口数据。</p>{/if}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
25
web/src/pages/Nodes.svelte
Normal file
25
web/src/pages/Nodes.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../lib/api';
|
||||
let agents: any[] = [];
|
||||
onMount(async () => (agents = await api('/api/agents')));
|
||||
function go(id: string) {
|
||||
history.pushState({}, '', `/nodes/${id}`);
|
||||
dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>主机列表</h1>
|
||||
<div class="table">
|
||||
{#each agents as a}
|
||||
<button class="row" on:click={() => go(a.id)}>
|
||||
<span class:online={a.status === 'online'} class="dot"></span>
|
||||
<strong>{a.hostname}</strong>
|
||||
<span>{a.ip || '-'}</span>
|
||||
<span>{a.os}/{a.arch}</span>
|
||||
<span>{a.version} {a.version_status === 'outdated' ? '· 可升级' : '· 最新'}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
49
web/src/pages/Services.svelte
Normal file
49
web/src/pages/Services.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, post } from '../lib/api';
|
||||
import { requireConfirm, requireDangerConfirm, withConfirm } from '../lib/confirm';
|
||||
export let id: string;
|
||||
let services: any[] = [];
|
||||
onMount(load);
|
||||
async function load() {
|
||||
const data: any = await api(`/api/agents/${id}/services`);
|
||||
services = data.services || [];
|
||||
}
|
||||
async function act(name: string, action: string) {
|
||||
const label = ({ start: '启动', stop: '停止', restart: '重启', enable: '启用自启', disable: '禁用自启' } as Record<string, string>)[action] || action;
|
||||
const confirmInfo = action === 'start' ? null : requireDangerConfirm(`${label}服务`, name);
|
||||
if (action === 'start' && !requireConfirm(`确认${label}服务 ${name}?`)) return;
|
||||
if (action !== 'start' && !confirmInfo) return;
|
||||
await post(`/api/agents/${id}/services/${name}/${action}`, withConfirm({}, confirmInfo));
|
||||
await load();
|
||||
}
|
||||
|
||||
function serviceStatus(value: string) {
|
||||
return ({
|
||||
active: '运行中',
|
||||
inactive: '未运行',
|
||||
failed: '失败',
|
||||
loaded: '已加载',
|
||||
'not-found': '未找到',
|
||||
running: '运行中',
|
||||
exited: '已退出',
|
||||
dead: '已停止'
|
||||
} as Record<string, string>)[value] || value || '-';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>服务管理</h1>
|
||||
<div class="table">
|
||||
{#each services as s}
|
||||
<div class="row">
|
||||
<strong>{s.name}</strong><span>{serviceStatus(s.active)}/{serviceStatus(s.sub)}</span><span>{s.description}</span>
|
||||
<button on:click={() => act(s.name, 'start')}>启动</button>
|
||||
<button on:click={() => act(s.name, 'restart')}>重启</button>
|
||||
<button on:click={() => act(s.name, 'enable')}>启用自启</button>
|
||||
<button on:click={() => act(s.name, 'disable')}>禁用自启</button>
|
||||
<button class="danger" on:click={() => act(s.name, 'stop')}>停止</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
332
web/src/pages/Settings.svelte
Normal file
332
web/src/pages/Settings.svelte
Normal file
@@ -0,0 +1,332 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, del, getToken, post } from '../lib/api';
|
||||
import { requireDangerConfirm, withConfirm } from '../lib/confirm';
|
||||
|
||||
let settings: Record<string, string> = {};
|
||||
let permissions: any[] = [];
|
||||
let backups: any[] = [];
|
||||
let updateInfo: any = null;
|
||||
let loading = true;
|
||||
let saving = false;
|
||||
let creatingBackup = false;
|
||||
let checkingUpdate = false;
|
||||
let runningUpdate = false;
|
||||
let error = '';
|
||||
let message = '';
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
settings = await api('/api/settings');
|
||||
permissions = await api('/api/permissions');
|
||||
backups = await api('/api/system/backups');
|
||||
await checkUpdate(false);
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const confirmInfo = requireDangerConfirm('保存系统设置', 'settings');
|
||||
if (!confirmInfo) return;
|
||||
saving = true;
|
||||
error = '';
|
||||
message = '';
|
||||
try {
|
||||
settings = await api('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(withConfirm(settings, confirmInfo))
|
||||
});
|
||||
message = '设置已保存';
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testWebhook() {
|
||||
error = '';
|
||||
message = '';
|
||||
try {
|
||||
const result: any = await api('/api/notifications/webhook/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
url: settings['notifications.webhook_url'],
|
||||
message: 'LightOps Webhook 测试'
|
||||
})
|
||||
});
|
||||
message = result.ok ? 'Webhook 测试发送成功' : 'Webhook 测试已执行';
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
const confirmInfo = requireDangerConfirm('创建主控备份', 'backup', 'normal');
|
||||
if (!confirmInfo) return;
|
||||
creatingBackup = true;
|
||||
error = '';
|
||||
message = '';
|
||||
try {
|
||||
const result: any = await post('/api/system/backups', withConfirm({}, confirmInfo));
|
||||
message = `备份已创建:${result.name}`;
|
||||
backups = await api('/api/system/backups');
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
creatingBackup = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBackup(name: string) {
|
||||
error = '';
|
||||
try {
|
||||
const headers = new Headers();
|
||||
const token = getToken();
|
||||
if (token) headers.set('authorization', `Bearer ${token}`);
|
||||
const res = await fetch(`/api/system/backups/${encodeURIComponent(name)}/download`, { headers });
|
||||
if (!res.ok) throw new Error(`下载失败:HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup(name: string) {
|
||||
const confirmInfo = requireDangerConfirm('恢复主控备份', name);
|
||||
if (!confirmInfo) return;
|
||||
error = '';
|
||||
message = '';
|
||||
try {
|
||||
const result: any = await post(`/api/system/backups/${encodeURIComponent(name)}/restore`, withConfirm({}, confirmInfo));
|
||||
message = result.message || '备份已恢复,服务即将重启';
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeBackup(name: string) {
|
||||
if (!requireDangerConfirm('删除主控备份', name)) return;
|
||||
error = '';
|
||||
try {
|
||||
await del(`/api/system/backups/${encodeURIComponent(name)}`);
|
||||
backups = await api('/api/system/backups');
|
||||
message = '备份已删除';
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdate(showMessage = true) {
|
||||
checkingUpdate = true;
|
||||
error = '';
|
||||
if (showMessage) message = '';
|
||||
try {
|
||||
updateInfo = await api('/api/system/update/check');
|
||||
if (showMessage) {
|
||||
message = updateInfo.update_available
|
||||
? `检测到 ${updateInfo.behind} 个远程提交可更新`
|
||||
: '当前已经是最新版本';
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (showMessage) error = err.message;
|
||||
} finally {
|
||||
checkingUpdate = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runUpdate() {
|
||||
const confirmInfo = requireDangerConfirm('执行系统更新并自动重启', 'system-update');
|
||||
if (!confirmInfo) return;
|
||||
runningUpdate = true;
|
||||
error = '';
|
||||
message = '';
|
||||
try {
|
||||
const result: any = await post('/api/system/update/run', withConfirm({}, confirmInfo));
|
||||
message = `更新任务已启动:${result.started_by},日志:${result.log_path}`;
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
runningUpdate = false;
|
||||
}
|
||||
}
|
||||
|
||||
function boolValue(key: string) {
|
||||
return settings[key] !== 'false';
|
||||
}
|
||||
|
||||
function setBool(key: string, value: boolean) {
|
||||
settings = { ...settings, [key]: String(value) };
|
||||
}
|
||||
|
||||
function checked(event: Event) {
|
||||
return (event.currentTarget as HTMLInputElement).checked;
|
||||
}
|
||||
|
||||
function size(bytes = 0) {
|
||||
if (!bytes) return '-';
|
||||
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function time(value: string | null | undefined) {
|
||||
if (!value) return '-';
|
||||
const seconds = Number(value);
|
||||
if (Number.isFinite(seconds)) return new Date(seconds * 1000).toLocaleString('zh-CN');
|
||||
return value;
|
||||
}
|
||||
|
||||
function shortCommit(value: string | null | undefined) {
|
||||
return value ? value.slice(0, 12) : '-';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<p class="eyebrow">生产配置</p>
|
||||
<h1>系统设置</h1>
|
||||
<p class="muted">这里控制高风险功能开关、Agent 判定策略和监控保留策略。生产环境建议只给可信用户开启终端和写文件权限。</p>
|
||||
</div>
|
||||
<button on:click={save} disabled={saving || loading}>{saving ? '保存中...' : '保存设置'}</button>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
{#if message}<p class="success">{message}</p>{/if}
|
||||
|
||||
<div class="settings-grid">
|
||||
<label class="setting-card">
|
||||
<span>允许 Web 终端</span>
|
||||
<input type="checkbox" checked={boolValue('security.terminal_enabled')} on:change={(e) => setBool('security.terminal_enabled', checked(e))} />
|
||||
<small>关闭后所有用户都无法打开远程终端。</small>
|
||||
</label>
|
||||
<label class="setting-card">
|
||||
<span>允许文件写入</span>
|
||||
<input type="checkbox" checked={boolValue('security.file_write_enabled')} on:change={(e) => setBool('security.file_write_enabled', checked(e))} />
|
||||
<small>关闭后文件写入、上传、删除、改名和 chmod 会被后端拒绝。</small>
|
||||
</label>
|
||||
<label class="setting-card">
|
||||
<span>强制危险操作确认</span>
|
||||
<input type="checkbox" checked={boolValue('security.require_danger_confirm')} on:change={(e) => setBool('security.require_danger_confirm', checked(e))} />
|
||||
<small>前端危险操作需要输入目标名,后端会校验确认目标。</small>
|
||||
</label>
|
||||
<label class="setting-card">
|
||||
<span>告警开关</span>
|
||||
<input type="checkbox" checked={boolValue('alerts.enabled')} on:change={(e) => setBool('alerts.enabled', checked(e))} />
|
||||
<small>关闭后心跳指标不会生成新的告警事件。</small>
|
||||
</label>
|
||||
<label class="setting-card">
|
||||
<span>应用自动健康检查</span>
|
||||
<input type="checkbox" checked={boolValue('apps.health_check_enabled')} on:change={(e) => setBool('apps.health_check_enabled', checked(e))} />
|
||||
<small>后台定期检查运行中应用的域名或端口,结果展示在应用列表和详情页。</small>
|
||||
</label>
|
||||
<label class="setting-card">
|
||||
<span>应用健康告警</span>
|
||||
<input type="checkbox" checked={boolValue('apps.health_alert_enabled')} on:change={(e) => setBool('apps.health_alert_enabled', checked(e))} />
|
||||
<small>健康检查失败时生成应用健康异常告警,恢复后自动关闭。</small>
|
||||
</label>
|
||||
<label class="setting-card">
|
||||
<span>Webhook 通知</span>
|
||||
<input type="checkbox" checked={boolValue('notifications.webhook_enabled')} on:change={(e) => setBool('notifications.webhook_enabled', checked(e))} />
|
||||
<small>告警首次触发时向外部 Webhook 推送 JSON。</small>
|
||||
</label>
|
||||
<label class="setting-card">
|
||||
<span>恢复通知</span>
|
||||
<input type="checkbox" checked={boolValue('notifications.recovery_enabled')} on:change={(e) => setBool('notifications.recovery_enabled', checked(e))} />
|
||||
<small>告警恢复时发送恢复事件,便于闭环跟踪。</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<label>Agent 离线判定秒数<input bind:value={settings['agent.offline_after_seconds']} type="number" min="30" /></label>
|
||||
<label>监控数据保留天数<input bind:value={settings['metrics.retention_days']} type="number" min="1" /></label>
|
||||
<label>应用健康检查间隔秒数<input bind:value={settings['apps.health_check_interval_seconds']} type="number" min="60" /></label>
|
||||
<label>单轮健康检查数量<input bind:value={settings['apps.health_check_batch_size']} type="number" min="1" max="100" /></label>
|
||||
<label>健康检查超时秒数<input bind:value={settings['apps.health_check_timeout_seconds']} type="number" min="1" max="30" /></label>
|
||||
<label>Webhook 地址<input bind:value={settings['notifications.webhook_url']} placeholder="https://example.com/lightops-webhook" /></label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="ghost" on:click={testWebhook} disabled={!settings['notifications.webhook_url']}>测试 Webhook</button>
|
||||
</div>
|
||||
|
||||
<div class="panel inner">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<h2>系统更新</h2>
|
||||
<p class="muted">从服务器上的 Git 仓库检查远程分支。Windows、macOS、Linux 都只负责提交代码;更新动作由面板触发,在部署服务器上拉取、构建、替换二进制并重启服务。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="ghost" on:click={() => checkUpdate(true)} disabled={checkingUpdate}>{checkingUpdate ? '检查中...' : '检查更新'}</button>
|
||||
<button on:click={runUpdate} disabled={runningUpdate || updateInfo?.dirty}>{runningUpdate ? '更新中...' : '立即更新并重启'}</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if updateInfo}
|
||||
<div class="table">
|
||||
<div class="row"><strong>仓库目录</strong><span>{updateInfo.repo_dir}</span></div>
|
||||
<div class="row"><strong>远程仓库</strong><span>{updateInfo.remote_url}</span></div>
|
||||
<div class="row"><strong>分支</strong><span>{updateInfo.branch}</span></div>
|
||||
<div class="row"><strong>当前提交</strong><span>{shortCommit(updateInfo.current_commit)} · {updateInfo.current_subject || '-'}</span></div>
|
||||
<div class="row"><strong>远程提交</strong><span>{shortCommit(updateInfo.remote_commit)}</span></div>
|
||||
<div class="row"><strong>状态</strong><span class:good={!updateInfo.update_available && !updateInfo.dirty} class:bad={updateInfo.dirty}>{updateInfo.dirty ? '本地有未提交改动,请先处理' : updateInfo.update_available ? `落后 ${updateInfo.behind} 个提交` : '已是最新'}</span></div>
|
||||
{#if updateInfo.fetch_error}<div class="row"><strong>拉取错误</strong><span>{updateInfo.fetch_error}</span></div>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">尚未检查更新。</p>
|
||||
{/if}
|
||||
<div class="form-grid">
|
||||
<label>仓库目录<input bind:value={settings['updates.repo_dir']} placeholder="/opt/lightops" /></label>
|
||||
<label>更新分支<input bind:value={settings['updates.branch']} placeholder="main" /></label>
|
||||
<label>更新脚本<input bind:value={settings['updates.script_path']} placeholder="scripts/update-from-git.sh" /></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel inner">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<h2>主控备份</h2>
|
||||
<p class="muted">在线使用 SQLite VACUUM INTO 创建一致性备份。恢复会替换主控数据库并让服务退出,生产环境由 systemd 自动重启。</p>
|
||||
</div>
|
||||
<button on:click={createBackup} disabled={creatingBackup}>{creatingBackup ? '备份中...' : '立即备份'}</button>
|
||||
</div>
|
||||
<div class="table">
|
||||
{#each backups as backup}
|
||||
<div class="row backup-row">
|
||||
<strong>{backup.name}</strong>
|
||||
<span>{size(backup.size)}</span>
|
||||
<span>{time(backup.modified_at || backup.created_at)}</span>
|
||||
<button class="ghost" on:click={() => downloadBackup(backup.name)}>下载</button>
|
||||
<button class="ghost" on:click={() => restoreBackup(backup.name)}>恢复</button>
|
||||
<button class="danger" on:click={() => removeBackup(backup.name)}>删除</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if backups.length === 0}
|
||||
<div class="empty-state"><p class="muted">暂无备份。</p></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel inner">
|
||||
<h2>权限目录</h2>
|
||||
<p class="muted">权限已由后端支持,后续用户管理页会基于这些权限给普通用户授权。</p>
|
||||
<div class="permission-list">
|
||||
{#each permissions as permission}
|
||||
<span>{permission.key} · {permission.label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
71
web/src/pages/StoreAppDetail.svelte
Normal file
71
web/src/pages/StoreAppDetail.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
export let slug: string;
|
||||
|
||||
let app: any = null;
|
||||
let error = '';
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
error = '';
|
||||
try {
|
||||
app = await api(`/api/app-store/${encodeURIComponent(slug)}`);
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function back() {
|
||||
history.pushState({}, '', '/store');
|
||||
dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<p class="eyebrow">软件商店详情</p>
|
||||
<h1>{app?.name || slug}</h1>
|
||||
<p class="muted">{app?.description || '正在加载应用说明...'}</p>
|
||||
</div>
|
||||
<button class="ghost" on:click={back}>返回软件商店</button>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
|
||||
{#if app}
|
||||
<div class="detail-grid">
|
||||
<div class="info-card"><span>分类</span><b>{app.category}</b></div>
|
||||
<div class="info-card"><span>镜像</span><b>{app.image}</b></div>
|
||||
<div class="info-card"><span>默认端口</span><b>{app.default_port}</b></div>
|
||||
<div class="info-card"><span>容器端口</span><b>{app.container_port}</b></div>
|
||||
</div>
|
||||
|
||||
<div class="panel inner">
|
||||
<h2>安装参数</h2>
|
||||
{#if app.fields?.length}
|
||||
<div class="table">
|
||||
{#each app.fields as field}
|
||||
<div class="row">
|
||||
<strong>{field.label}</strong>
|
||||
<span>{field.type}</span>
|
||||
<span>{field.required ? '必填' : '可选'}</span>
|
||||
<span>{field.sensitive ? '敏感字段,会脱敏保存' : field.help || '-'}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">该应用无需额外安装参数。</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="panel inner">
|
||||
<h2>运维说明</h2>
|
||||
<p>安装前会自动检测 Docker、Docker Compose、端口占用、项目名冲突和目录风险。</p>
|
||||
<p>安装后可以在软件商店安装记录中启动、停止、重启、查看日志、更新和卸载;也可以在应用管理中继续查看关联 Docker、Nginx、文件和日志。</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
233
web/src/pages/Tasks.svelte
Normal file
233
web/src/pages/Tasks.svelte
Normal file
@@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, post } from '../lib/api';
|
||||
import { requireDangerConfirm } from '../lib/confirm';
|
||||
|
||||
export let id: string | undefined = undefined;
|
||||
|
||||
let tasks: any[] = [];
|
||||
let loading = true;
|
||||
let error = '';
|
||||
let status = '';
|
||||
let action = '';
|
||||
let expanded: Record<string, boolean> = {};
|
||||
let events: Record<string, any[]> = {};
|
||||
let retrying = '';
|
||||
let summary: any = {};
|
||||
let autoRefresh = true;
|
||||
let timer: number | undefined;
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
timer = window.setInterval(() => {
|
||||
if (autoRefresh) load(false);
|
||||
}, 5000);
|
||||
return () => timer && clearInterval(timer);
|
||||
});
|
||||
|
||||
async function load(showLoading = true) {
|
||||
if (showLoading) loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', '200');
|
||||
if (status) params.set('status', status);
|
||||
if (action) params.set('action', action);
|
||||
const url = id ? `/api/agents/${id}/tasks?${params}` : `/api/tasks?${params}`;
|
||||
const summaryParams = new URLSearchParams(params);
|
||||
if (id) summaryParams.set('agent_id', id);
|
||||
const [taskData, summaryData] = await Promise.all([
|
||||
api(url),
|
||||
api(`/api/tasks/summary?${summaryParams}`)
|
||||
]);
|
||||
tasks = taskData as any[];
|
||||
summary = summaryData;
|
||||
await refreshExpandedEvents();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
if (showLoading) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(value: string) {
|
||||
return ({
|
||||
running: '运行中',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
timeout: '超时',
|
||||
pending: '等待中',
|
||||
cancelled: '已取消'
|
||||
} as Record<string, string>)[value] || value;
|
||||
}
|
||||
|
||||
function short(value: string | null | undefined) {
|
||||
if (!value) return '-';
|
||||
return value.length > 180 ? `${value.slice(0, 180)}...` : value;
|
||||
}
|
||||
|
||||
async function retry(task: any) {
|
||||
const confirmInfo = requireDangerConfirm('重试任务', task.id);
|
||||
if (!confirmInfo) return;
|
||||
retrying = task.id;
|
||||
error = '';
|
||||
try {
|
||||
await post(`/api/tasks/${encodeURIComponent(task.id)}/retry`, { confirm_target: confirmInfo.target });
|
||||
await load();
|
||||
expanded[task.id] = true;
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
retrying = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelTask(task: any) {
|
||||
const confirmInfo = requireDangerConfirm('取消任务', task.id, 'normal');
|
||||
if (!confirmInfo) return;
|
||||
error = '';
|
||||
try {
|
||||
await post(`/api/tasks/${encodeURIComponent(task.id)}/cancel`, { confirm_target: confirmInfo.target });
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDetail(task: any) {
|
||||
expanded[task.id] = !expanded[task.id];
|
||||
if (expanded[task.id]) await loadTaskEvents(task.id);
|
||||
}
|
||||
|
||||
async function loadTaskEvents(taskId: string) {
|
||||
try {
|
||||
events[taskId] = await api(`/api/tasks/${encodeURIComponent(taskId)}/events`);
|
||||
events = { ...events };
|
||||
} catch {
|
||||
events[taskId] = [];
|
||||
events = { ...events };
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshExpandedEvents() {
|
||||
const ids = Object.entries(expanded)
|
||||
.filter(([, value]) => value)
|
||||
.map(([key]) => key);
|
||||
await Promise.all(ids.map(loadTaskEvents));
|
||||
}
|
||||
|
||||
function canRetry(task: any) {
|
||||
return ['failed', 'timeout'].includes(task.status) && !['file.upload.chunk', 'file.download.chunk'].includes(task.action);
|
||||
}
|
||||
|
||||
function canCancel(task: any) {
|
||||
return ['pending', 'running'].includes(task.status);
|
||||
}
|
||||
|
||||
function prettyJson(value: string | null | undefined) {
|
||||
if (!value) return '-';
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function prettyEventData(value: any) {
|
||||
if (!value) return '';
|
||||
if (value.output) return value.output;
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<p class="eyebrow">任务执行记录</p>
|
||||
<h1>任务历史</h1>
|
||||
<p class="muted">记录 Server 下发给 Agent 的任务状态、参数摘要、结果和错误,便于排查运维操作。</p>
|
||||
</div>
|
||||
<div class="actions inline-actions">
|
||||
<label class="check"><input type="checkbox" bind:checked={autoRefresh} /> 自动刷新</label>
|
||||
<button on:click={() => load()} disabled={loading}>{loading ? '刷新中...' : '刷新'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid compact-metrics">
|
||||
<div><b>{summary.total || 0}</b><span>全部</span></div>
|
||||
<div><b>{summary.running || 0}</b><span>运行中</span></div>
|
||||
<div><b>{summary.success || 0}</b><span>成功</span></div>
|
||||
<div><b>{(summary.failed || 0) + (summary.timeout || 0)}</b><span>失败/超时</span></div>
|
||||
</div>
|
||||
|
||||
<div class="log-toolbar">
|
||||
<select bind:value={status} on:change={load}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="running">运行中</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="timeout">超时</option>
|
||||
</select>
|
||||
<input bind:value={action} placeholder="按动作过滤,例如 docker" on:keydown={(e) => e.key === 'Enter' && load()} />
|
||||
<button class="ghost" on:click={load}>筛选</button>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
|
||||
<div class="table">
|
||||
{#each tasks as task}
|
||||
<div class="row task-row">
|
||||
<strong>{task.action}</strong>
|
||||
<span>{statusLabel(task.status)}</span>
|
||||
<span>主机 {task.agent_id}</span>
|
||||
<span>{task.created_at}</span>
|
||||
<span>{task.error || '-'}</span>
|
||||
<button class="ghost" on:click={() => toggleDetail(task)}>{expanded[task.id] ? '收起' : '详情'}</button>
|
||||
{#if canRetry(task)}
|
||||
<button on:click={() => retry(task)} disabled={retrying === task.id}>{retrying === task.id ? '重试中...' : '重试'}</button>
|
||||
{/if}
|
||||
{#if canCancel(task)}
|
||||
<button class="danger" on:click={() => cancelTask(task)}>取消</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if expanded[task.id]}
|
||||
<div class="task-detail">
|
||||
<p class="muted">任务 ID:{task.id}</p>
|
||||
<p class="muted">开始:{task.started_at || '-'},结束:{task.finished_at || '-'}</p>
|
||||
<h3>参数</h3>
|
||||
<pre class="compact-log">{prettyJson(task.params_json)}</pre>
|
||||
<h3>结果</h3>
|
||||
<pre class="compact-log">{prettyJson(task.result_json)}</pre>
|
||||
<h3>错误</h3>
|
||||
<pre class="compact-log">{task.error || '-'}</pre>
|
||||
<h3>实时日志</h3>
|
||||
<div class="task-event-stream">
|
||||
{#each events[task.id] || [] as event}
|
||||
<article class:error-line={event.level === 'error'} class:risk-line={event.level === 'warn'} class="task-event-line">
|
||||
<span>{event.created_at}</span>
|
||||
<b>{event.level}</b>
|
||||
<strong>{event.message}</strong>
|
||||
{#if event.data}<pre>{prettyEventData(event.data)}</pre>{/if}
|
||||
</article>
|
||||
{/each}
|
||||
{#if !(events[task.id] || []).length}
|
||||
<p class="muted">暂无任务事件。</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<pre class="compact-log">{short(task.params_json)}</pre>
|
||||
{#if task.result_json}
|
||||
<pre class="compact-log">{short(task.result_json)}</pre>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !loading && tasks.length === 0}
|
||||
<div class="empty-state">
|
||||
<h2>暂无任务</h2>
|
||||
<p class="muted">还没有匹配的任务记录。</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
25
web/src/pages/Terminal.svelte
Normal file
25
web/src/pages/Terminal.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { wsUrl } from '../lib/api';
|
||||
export let id: string;
|
||||
let el: HTMLDivElement;
|
||||
onMount(() => {
|
||||
const term = new Terminal({ cursorBlink: true, fontFamily: 'JetBrains Mono, monospace', theme: { background: '#08110f' } });
|
||||
term.open(el);
|
||||
const ws = new WebSocket(wsUrl(`/api/agents/${id}/terminal`));
|
||||
ws.onopen = () => term.writeln('LightOps 终端已连接\r\n');
|
||||
ws.onmessage = (ev) => term.write(ev.data);
|
||||
term.onData((data) => ws.readyState === WebSocket.OPEN && ws.send(data));
|
||||
return () => {
|
||||
ws.close();
|
||||
term.dispose();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="panel terminal-panel">
|
||||
<h1>远程终端</h1>
|
||||
<div bind:this={el} class="terminal"></div>
|
||||
</section>
|
||||
191
web/src/pages/Users.svelte
Normal file
191
web/src/pages/Users.svelte
Normal file
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, post, del } from '../lib/api';
|
||||
import { requireDangerConfirm, withConfirm } from '../lib/confirm';
|
||||
|
||||
let users: any[] = [];
|
||||
let agents: any[] = [];
|
||||
let permissions: any[] = [];
|
||||
let selected: any = null;
|
||||
let password = '';
|
||||
let error = '';
|
||||
let message = '';
|
||||
let loading = true;
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
users = await api('/api/users');
|
||||
agents = await api('/api/agents');
|
||||
permissions = (await api('/api/permissions')).filter((item: any) => item.key !== '*');
|
||||
if (!selected) newUser();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function newUser() {
|
||||
selected = {
|
||||
id: null,
|
||||
username: '',
|
||||
role: 'operator',
|
||||
permissions: ['agents', 'tasks', 'logs'],
|
||||
agent_ids: []
|
||||
};
|
||||
password = '';
|
||||
}
|
||||
|
||||
function edit(user: any) {
|
||||
selected = structuredClone(user);
|
||||
password = '';
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error = '';
|
||||
message = '';
|
||||
if (!selected.username.trim()) {
|
||||
error = '请输入用户名';
|
||||
return;
|
||||
}
|
||||
const target = selected.id ? selected.username : `新用户 ${selected.username}`;
|
||||
const confirmInfo = requireDangerConfirm(selected.id ? '更新用户' : '创建用户', target, 'normal');
|
||||
if (!confirmInfo) return;
|
||||
try {
|
||||
const body = withConfirm(
|
||||
{
|
||||
username: selected.username.trim(),
|
||||
password: selected.id ? undefined : password,
|
||||
role: selected.role,
|
||||
permissions: selected.role === 'admin' ? [] : selected.permissions,
|
||||
agent_ids: selected.role === 'admin' ? [] : selected.agent_ids
|
||||
},
|
||||
confirmInfo
|
||||
);
|
||||
if (selected.id) {
|
||||
await api(`/api/users/${selected.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
} else {
|
||||
await post('/api/users', body);
|
||||
}
|
||||
message = '用户已保存';
|
||||
selected = null;
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword(user: any) {
|
||||
const next = prompt(`请输入 ${user.username} 的新密码,至少 8 位`);
|
||||
if (!next) return;
|
||||
const confirmInfo = requireDangerConfirm('重置用户密码', user.username);
|
||||
if (!confirmInfo) return;
|
||||
await post(`/api/users/${user.id}/password`, withConfirm({ password: next }, confirmInfo));
|
||||
message = '密码已重置';
|
||||
}
|
||||
|
||||
async function remove(user: any) {
|
||||
const confirmInfo = requireDangerConfirm('删除用户', user.username);
|
||||
if (!confirmInfo) return;
|
||||
await del(`/api/users/${user.id}`);
|
||||
if (selected?.id === user.id) newUser();
|
||||
await load();
|
||||
}
|
||||
|
||||
function togglePermission(key: string) {
|
||||
const set = new Set(selected.permissions || []);
|
||||
if (set.has(key)) set.delete(key);
|
||||
else set.add(key);
|
||||
selected.permissions = Array.from(set);
|
||||
}
|
||||
|
||||
function toggleAgent(agentId: string) {
|
||||
const set = new Set(selected.agent_ids || []);
|
||||
if (set.has(agentId)) set.delete(agentId);
|
||||
else set.add(agentId);
|
||||
selected.agent_ids = Array.from(set);
|
||||
}
|
||||
|
||||
function checked(list: string[], value: string) {
|
||||
return (list || []).includes(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<p class="eyebrow">访问控制</p>
|
||||
<h1>用户管理</h1>
|
||||
<p class="muted">创建普通运维用户,按功能权限和主机范围授权。管理员默认拥有全部权限。</p>
|
||||
</div>
|
||||
<button on:click={newUser}>新建用户</button>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
{#if message}<p class="success">{message}</p>{/if}
|
||||
|
||||
<div class="split">
|
||||
<div class="panel inner">
|
||||
<h2>用户列表</h2>
|
||||
<div class="table">
|
||||
{#each users as user}
|
||||
<div class="row user-row">
|
||||
<strong>{user.username}</strong>
|
||||
<span>{user.role === 'admin' ? '管理员' : '运维用户'}</span>
|
||||
<span>权限 {user.permissions?.length || (user.role === 'admin' ? '全部' : 0)}</span>
|
||||
<button class="ghost" on:click={() => edit(user)}>编辑</button>
|
||||
<button class="ghost" on:click={() => resetPassword(user)}>改密</button>
|
||||
<button class="danger" on:click={() => remove(user)}>删除</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selected}
|
||||
<div class="panel inner">
|
||||
<h2>{selected.id ? '编辑用户' : '新建用户'}</h2>
|
||||
<div class="form-grid">
|
||||
<label>用户名<input bind:value={selected.username} placeholder="operator" /></label>
|
||||
<label>角色
|
||||
<select bind:value={selected.role}>
|
||||
<option value="operator">运维用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if !selected.id}
|
||||
<label>初始密码<input bind:value={password} type="password" placeholder="至少 8 位" /></label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selected.role !== 'admin'}
|
||||
<h3>功能权限</h3>
|
||||
<div class="permission-list selectable-list">
|
||||
{#each permissions as permission}
|
||||
<button class:active={checked(selected.permissions, permission.key)} on:click={() => togglePermission(permission.key)}>
|
||||
{permission.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h3>可访问主机</h3>
|
||||
<div class="permission-list selectable-list">
|
||||
{#each agents as agent}
|
||||
<button class:active={checked(selected.agent_ids, agent.id)} on:click={() => toggleAgent(agent.id)}>
|
||||
{agent.hostname || agent.name || agent.id}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button on:click={save} disabled={loading}>保存用户</button>
|
||||
<button class="ghost" on:click={newUser}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
1131
web/src/styles.css
Normal file
1131
web/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user