实现 LightOps 运维面板基础功能

This commit is contained in:
2026-05-25 01:13:03 +08:00
commit d3bb9f45a6
84 changed files with 23505 additions and 0 deletions

161
web/src/App.svelte Normal file
View 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
View 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
View 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
View 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
View 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>

View 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}

View 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>

View 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="搜索软件,例如 alistredisgit" 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
View 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
View 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>

View 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
View 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&#10;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:latestredis: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
View 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
View 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
View 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&#10;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">暂未发现 Lets 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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
View 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

File diff suppressed because it is too large Load Diff