mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 14:26:36 +08:00
feat: add OperationSetting component and integrate into settings page
- Introduced OperationSetting component for managing agent configurations. - Updated settings page to include a new tab for operation settings. - Implemented functionality to fetch and update agent parameters such as heartbeat interval, sync interval, and auto-update settings. docs: enhance deployment documentation for agent installation - Added detailed instructions for agent installation using a script. - Included examples for using discovery and agent tokens. - Updated sections on global discovery tokens and agent auto-update features. docs: revise design and development guidelines for V3 - Updated design document to reflect the current state and goals for V3. - Clarified development guidelines to focus on operational experience improvements. ci: add GitHub Actions workflow for agent releases - Created a new workflow to automate the release of agent binaries on GitHub. - Configured the workflow to build binaries for multiple platforms and publish them as releases. feat: implement self-update mechanism for agent - Added updater module to handle checking for and applying updates from GitHub releases. - Implemented logic to restart the agent after a successful update. chore: create install script for agent deployment - Developed a bash script to facilitate the installation of the ATSFlare agent. - The script supports automatic configuration and systemd service creation.
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Form,
|
||||
Grid,
|
||||
Header,
|
||||
Icon,
|
||||
Label,
|
||||
Message,
|
||||
Segment,
|
||||
} from 'semantic-ui-react';
|
||||
import { API, copy, showError, showSuccess } from '../helpers';
|
||||
|
||||
const OperationSetting = () => {
|
||||
const [inputs, setInputs] = useState({
|
||||
AgentHeartbeatInterval: '30000',
|
||||
AgentSyncInterval: '30000',
|
||||
NodeOfflineThreshold: '120000',
|
||||
AgentAutoUpdate: 'false',
|
||||
AgentUpdateRepo: 'Rain-kl/ATSFlare',
|
||||
ServerAddress: '',
|
||||
AgentDiscoveryToken: '',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const getOptions = async () => {
|
||||
const res = await API.get('/api/option/');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
if (item.key in inputs) {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
});
|
||||
setInputs((prev) => ({ ...prev, ...newInputs }));
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getOptions();
|
||||
getDiscoveryToken();
|
||||
}, []);
|
||||
|
||||
const getDiscoveryToken = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/nodes/bootstrap-token');
|
||||
const { success, data } = res.data;
|
||||
if (success && data) {
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
AgentDiscoveryToken: data.discovery_token,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const updateOption = async (key, value) => {
|
||||
setLoading(true);
|
||||
const res = await API.put('/api/option', { key, value });
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('设置已保存');
|
||||
setInputs((prev) => ({ ...prev, [key]: value }));
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleInputChange = (e, { name, value }) => {
|
||||
setInputs((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (e, { name, checked }) => {
|
||||
setInputs((prev) => ({ ...prev, [name]: checked ? 'true' : 'false' }));
|
||||
};
|
||||
|
||||
const submitAgentIntervals = async () => {
|
||||
const hb = parseInt(inputs.AgentHeartbeatInterval, 10);
|
||||
const sync = parseInt(inputs.AgentSyncInterval, 10);
|
||||
const offline = parseInt(inputs.NodeOfflineThreshold, 10);
|
||||
if (isNaN(hb) || hb < 5000) {
|
||||
showError('心跳间隔不能小于 5000 毫秒');
|
||||
return;
|
||||
}
|
||||
if (isNaN(sync) || sync < 5000) {
|
||||
showError('同步间隔不能小于 5000 毫秒');
|
||||
return;
|
||||
}
|
||||
if (isNaN(offline) || offline < 10000) {
|
||||
showError('离线阈值不能小于 10000 毫秒');
|
||||
return;
|
||||
}
|
||||
await updateOption('AgentHeartbeatInterval', String(hb));
|
||||
await updateOption('AgentSyncInterval', String(sync));
|
||||
await updateOption('NodeOfflineThreshold', String(offline));
|
||||
};
|
||||
|
||||
const submitAutoUpdate = async () => {
|
||||
await updateOption('AgentAutoUpdate', inputs.AgentAutoUpdate);
|
||||
await updateOption('AgentUpdateRepo', inputs.AgentUpdateRepo);
|
||||
};
|
||||
|
||||
const rotateDiscoveryToken = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/nodes/bootstrap-token/rotate');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
showSuccess('Discovery Token 已重新生成');
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
AgentDiscoveryToken: data.discovery_token,
|
||||
}));
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (e) {
|
||||
showError('操作失败');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const formatMs = (ms) => {
|
||||
const val = parseInt(ms, 10);
|
||||
if (isNaN(val)) return ms;
|
||||
if (val >= 60000) return `${val / 60000} 分钟`;
|
||||
return `${val / 1000} 秒`;
|
||||
};
|
||||
|
||||
const serverAddr = inputs.ServerAddress || window.location.origin;
|
||||
const curlCommand = inputs.AgentDiscoveryToken
|
||||
? `curl -fsSL https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh | bash -s -- --server-url ${serverAddr} --discovery-token ${inputs.AgentDiscoveryToken}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Grid columns={1}>
|
||||
<Grid.Column>
|
||||
<Form loading={loading}>
|
||||
<Header as='h3'>Agent 运行参数</Header>
|
||||
<Message info size='small'>
|
||||
<p>这些参数通过心跳响应下发到所有 Agent,修改后下次心跳即生效。</p>
|
||||
</Message>
|
||||
<Form.Group widths='equal'>
|
||||
<Form.Input
|
||||
label={`心跳间隔 (${formatMs(inputs.AgentHeartbeatInterval)})`}
|
||||
placeholder='30000'
|
||||
value={inputs.AgentHeartbeatInterval}
|
||||
name='AgentHeartbeatInterval'
|
||||
onChange={handleInputChange}
|
||||
type='number'
|
||||
/>
|
||||
<Form.Input
|
||||
label={`同步间隔 (${formatMs(inputs.AgentSyncInterval)})`}
|
||||
placeholder='30000'
|
||||
value={inputs.AgentSyncInterval}
|
||||
name='AgentSyncInterval'
|
||||
onChange={handleInputChange}
|
||||
type='number'
|
||||
/>
|
||||
<Form.Input
|
||||
label={`离线阈值 (${formatMs(inputs.NodeOfflineThreshold)})`}
|
||||
placeholder='120000'
|
||||
value={inputs.NodeOfflineThreshold}
|
||||
name='NodeOfflineThreshold'
|
||||
onChange={handleInputChange}
|
||||
type='number'
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Button onClick={submitAgentIntervals} primary>
|
||||
保存运行参数
|
||||
</Form.Button>
|
||||
|
||||
<Divider />
|
||||
<Header as='h3'>Agent 自动更新</Header>
|
||||
<Form.Group>
|
||||
<Form.Checkbox
|
||||
label='启用 Agent 自动更新'
|
||||
checked={inputs.AgentAutoUpdate === 'true'}
|
||||
name='AgentAutoUpdate'
|
||||
onChange={handleCheckboxChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group widths='equal'>
|
||||
<Form.Input
|
||||
label='更新仓库'
|
||||
placeholder='Rain-kl/ATSFlare'
|
||||
value={inputs.AgentUpdateRepo}
|
||||
name='AgentUpdateRepo'
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Button onClick={submitAutoUpdate} primary>
|
||||
保存更新设置
|
||||
</Form.Button>
|
||||
|
||||
<Divider />
|
||||
<Header as='h3'>节点接入</Header>
|
||||
<Form.Group widths='equal'>
|
||||
<Form.Field>
|
||||
<label>全局 Discovery Token</label>
|
||||
<Segment>
|
||||
<Label basic>{inputs.AgentDiscoveryToken || '(未设置)'}</Label>
|
||||
<Button
|
||||
size='mini'
|
||||
icon
|
||||
style={{ marginLeft: '8px' }}
|
||||
onClick={() =>
|
||||
copy(inputs.AgentDiscoveryToken, 'Discovery Token')
|
||||
}
|
||||
title='复制'
|
||||
>
|
||||
<Icon name='copy' />
|
||||
</Button>
|
||||
<Button
|
||||
size='mini'
|
||||
color='orange'
|
||||
style={{ marginLeft: '4px' }}
|
||||
onClick={rotateDiscoveryToken}
|
||||
>
|
||||
重新生成
|
||||
</Button>
|
||||
</Segment>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
|
||||
{curlCommand && (
|
||||
<>
|
||||
<Header as='h4'>Agent 一键部署命令</Header>
|
||||
<Message>
|
||||
<pre
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
fontFamily: 'JetBrains Mono, Consolas, monospace',
|
||||
fontSize: '12px',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{curlCommand}
|
||||
</pre>
|
||||
</Message>
|
||||
<Button
|
||||
size='small'
|
||||
icon
|
||||
labelPosition='left'
|
||||
onClick={() => copy(curlCommand, '部署命令')}
|
||||
>
|
||||
<Icon name='copy' />
|
||||
复制部署命令
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Grid.Column>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default OperationSetting;
|
||||
@@ -25,7 +25,7 @@ export function getFooterHTML() {
|
||||
return localStorage.getItem('footer_html');
|
||||
}
|
||||
|
||||
export async function copy(text) {
|
||||
export async function copy(text, label) {
|
||||
let okay = true;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
@@ -33,6 +33,9 @@ export async function copy(text) {
|
||||
okay = false;
|
||||
console.error(e);
|
||||
}
|
||||
if (okay && label) {
|
||||
showSuccess(`${label} 已复制`);
|
||||
}
|
||||
return okay;
|
||||
}
|
||||
|
||||
@@ -151,3 +154,15 @@ export function formatDateTime(value) {
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
export function timeAgo(value) {
|
||||
if (!value) return '暂无';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
const diff = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (diff < 0) return '刚刚';
|
||||
if (diff < 60) return `${diff} 秒前`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`;
|
||||
return `${Math.floor(diff / 86400)} 天前`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Form, Header, Label, Segment, Table } from 'semantic-ui-react';
|
||||
import { API, formatDateTime, showError, showSuccess } from '../../helpers';
|
||||
import { API, showError, showSuccess, timeAgo } from '../../helpers';
|
||||
|
||||
const initialForm = {
|
||||
name: '',
|
||||
@@ -33,9 +33,11 @@ const Node = () => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form, setForm] = useState(initialForm);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [, setTick] = useState(0);
|
||||
const refreshTimer = useRef(null);
|
||||
|
||||
const loadNodes = async () => {
|
||||
setLoading(true);
|
||||
const loadNodes = useCallback(async (silent) => {
|
||||
if (!silent) setLoading(true);
|
||||
const res = await API.get('/api/nodes/');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
@@ -43,8 +45,8 @@ const Node = () => {
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
if (!silent) setLoading(false);
|
||||
}, []);
|
||||
|
||||
const loadBootstrapToken = async () => {
|
||||
const res = await API.get('/api/nodes/bootstrap-token');
|
||||
@@ -71,8 +73,14 @@ const Node = () => {
|
||||
|
||||
useEffect(() => {
|
||||
loadBootstrapToken().then();
|
||||
loadNodes().then();
|
||||
}, []);
|
||||
loadNodes(false).then();
|
||||
// Refresh node list and relative time every 30s
|
||||
refreshTimer.current = setInterval(() => {
|
||||
loadNodes(true);
|
||||
setTick((t) => t + 1);
|
||||
}, 30000);
|
||||
return () => clearInterval(refreshTimer.current);
|
||||
}, [loadNodes]);
|
||||
|
||||
const resetForm = () => {
|
||||
setForm(initialForm);
|
||||
@@ -91,7 +99,7 @@ const Node = () => {
|
||||
if (success) {
|
||||
showSuccess(editingId ? '节点已更新' : '节点已创建');
|
||||
resetForm();
|
||||
await loadNodes();
|
||||
await loadNodes(false);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -106,7 +114,11 @@ const Node = () => {
|
||||
};
|
||||
|
||||
const deleteNode = async (node) => {
|
||||
if (!window.confirm(`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`)) {
|
||||
if (
|
||||
!window.confirm(
|
||||
`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const res = await API.delete(`/api/nodes/${node.id}`);
|
||||
@@ -116,7 +128,7 @@ const Node = () => {
|
||||
if (editingId === node.id) {
|
||||
resetForm();
|
||||
}
|
||||
await loadNodes();
|
||||
await loadNodes(false);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -125,7 +137,10 @@ const Node = () => {
|
||||
return (
|
||||
<Segment loading={loading}>
|
||||
<Header as='h3'>节点管理</Header>
|
||||
<p className='page-subtitle'>创建节点后会直接生成节点专属 auth token;批量部署时可复用全局 discovery token 自动注册。</p>
|
||||
<p className='page-subtitle'>
|
||||
创建节点后会直接生成节点专属 auth token;批量部署时可复用全局 discovery
|
||||
token 自动注册。
|
||||
</p>
|
||||
|
||||
<Form>
|
||||
<Form.Group widths='equal'>
|
||||
@@ -183,20 +198,37 @@ const Node = () => {
|
||||
<Table.Cell>
|
||||
{node.agent_token ? (
|
||||
<>
|
||||
{node.pending ? <Label color='orange'>未占用</Label> : <Label color='green'>已绑定</Label>}
|
||||
<div className='table-meta' style={{ wordBreak: 'break-all' }}>{node.agent_token}</div>
|
||||
{node.pending ? (
|
||||
<Label color='orange'>未占用</Label>
|
||||
) : (
|
||||
<Label color='green'>已绑定</Label>
|
||||
)}
|
||||
<div
|
||||
className='table-meta'
|
||||
style={{ wordBreak: 'break-all' }}
|
||||
>
|
||||
{node.agent_token}
|
||||
</div>
|
||||
</>
|
||||
) : '暂无'}
|
||||
) : (
|
||||
'暂无'
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{node.ip}</Table.Cell>
|
||||
<Table.Cell>{renderStatus(node.status)}</Table.Cell>
|
||||
<Table.Cell>{node.agent_version} / {node.nginx_version || 'unknown'}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{node.agent_version} / {node.nginx_version || 'unknown'}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{node.current_version || '未应用'}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{renderApply(node.latest_apply_result)}
|
||||
<div className='table-meta'>{node.latest_apply_message || '暂无记录'}</div>
|
||||
<div className='table-meta'>
|
||||
{node.latest_apply_message || '暂无记录'}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell title={node.last_seen_at}>
|
||||
{node.last_seen_at ? timeAgo(node.last_seen_at) : '暂无'}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{node.last_seen_at ? formatDateTime(node.last_seen_at) : '暂无'}</Table.Cell>
|
||||
<Table.Cell>{node.last_error || '无'}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Button size='small' onClick={() => beginEdit(node)}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import SystemSetting from '../../components/SystemSetting';
|
||||
import { isRoot } from '../../helpers';
|
||||
import OtherSetting from '../../components/OtherSetting';
|
||||
import PersonalSetting from '../../components/PersonalSetting';
|
||||
import OperationSetting from '../../components/OperationSetting';
|
||||
|
||||
const Setting = () => {
|
||||
let panes = [
|
||||
@@ -13,18 +14,26 @@ const Setting = () => {
|
||||
<Tab.Pane attached={false}>
|
||||
<PersonalSetting />
|
||||
</Tab.Pane>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isRoot()) {
|
||||
panes.push({
|
||||
menuItem: '运维设置',
|
||||
render: () => (
|
||||
<Tab.Pane attached={false}>
|
||||
<OperationSetting />
|
||||
</Tab.Pane>
|
||||
),
|
||||
});
|
||||
panes.push({
|
||||
menuItem: '系统设置',
|
||||
render: () => (
|
||||
<Tab.Pane attached={false}>
|
||||
<SystemSetting />
|
||||
</Tab.Pane>
|
||||
)
|
||||
),
|
||||
});
|
||||
panes.push({
|
||||
menuItem: '其他设置',
|
||||
@@ -32,7 +41,7 @@ const Setting = () => {
|
||||
<Tab.Pane attached={false}>
|
||||
<OtherSetting />
|
||||
</Tab.Pane>
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user