Merge pull request #1 from openspug/3.0

sync from upstream
pull/509/head
sf 2022-06-23 14:03:30 +08:00 committed by GitHub
commit 0061fc3c62
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 118 additions and 30 deletions

View File

@ -10,11 +10,16 @@ Spug是面向中小型企业设计的轻量级无Agent的自动化运维平台
- 使用文档https://spug.cc/docs/about-spug/ - 使用文档https://spug.cc/docs/about-spug/
- 更新日志https://spug.cc/docs/change-log/ - 更新日志https://spug.cc/docs/change-log/
- 常见问题https://spug.cc/docs/faq/ - 常见问题https://spug.cc/docs/faq/
- 推送助手https://push.spug.cc
## 演示环境 ## 演示环境
演示地址https://demo.spug.cc 演示地址https://demo.spug.cc
## 🔥推送助手
推送助手是一个集成了电话、短信、邮件、飞书、钉钉、微信、企业微信等多通道的消息推送平台用户只需要调用一个简单的URL就可以完成多通道的消息推送点击体验[https://push.spug.cc](https://push.spug.cc)
## 特性 ## 特性
@ -39,9 +44,10 @@ Spug是面向中小型企业设计的轻量级无Agent的自动化运维平台
## 安装 ## 安装
[官方文档](https://spug.cc/docs/install/) [官方文档](https://spug.cc/docs/install-docker)
更多使用帮助请参考: [使用文档](https://spug.cc/docs/host-manage/)
更多使用帮助请参考 [使用文档](https://spug.cc/docs/host-manage/)。
## 推荐项目 ## 推荐项目
[Yearning — MYSQL 开源SQL语句审核平台](https://github.com/cookieY/Yearning) [Yearning — MYSQL 开源SQL语句审核平台](https://github.com/cookieY/Yearning)

View File

@ -52,10 +52,9 @@ class Command(BaseCommand):
self.echo_error('缺少参数') self.echo_error('缺少参数')
self.print_help() self.print_help()
user = User.objects.filter(username=options['u'], deleted_by_id__isnull=True).first() user = User.objects.filter(username=options['u'], deleted_by_id__isnull=True).first()
if not user: if user:
return self.echo_error(f'未找到登录名为【{options["u"]}】的账户') user.is_active = True
user.is_active = True user.save()
user.save()
cache.delete(user.username) cache.delete(user.username)
self.echo_success('账户已启用') self.echo_success('账户已启用')
elif action == 'reset': elif action == 'reset':

View File

@ -41,13 +41,12 @@ class UserView(AdminView):
return json_response(error=f'已存在登录名为【{form.username}】的用户') return json_response(error=f'已存在登录名为【{form.username}】的用户')
role_ids, password = form.pop('role_ids'), form.pop('password') role_ids, password = form.pop('role_ids'), form.pop('password')
if not verify_password(password):
return json_response(error='请设置至少8位包含数字、小写和大写字母的新密码')
if form.id: if form.id:
user = User.objects.get(pk=form.id) user = User.objects.get(pk=form.id)
user.update_by_dict(form) user.update_by_dict(form)
else: else:
if not verify_password(password):
return json_response(error='请设置至少8位包含数字、小写和大写字母的新密码')
user = User.objects.create( user = User.objects.create(
password_hash=User.make_password(password), password_hash=User.make_password(password),
created_by=request.user, created_by=request.user,

View File

@ -6,6 +6,7 @@ from apps.setting.utils import AppSetting
from apps.deploy.models import Deploy, DeployRequest from apps.deploy.models import Deploy, DeployRequest
from apps.repository.models import Repository from apps.repository.models import Repository
from apps.deploy.utils import dispatch as deploy_dispatch from apps.deploy.utils import dispatch as deploy_dispatch
from libs.utils import human_datetime
from threading import Thread from threading import Thread
import hashlib import hashlib
import hmac import hmac
@ -119,4 +120,7 @@ def _dispatch(deploy_id, ref, commit_id=None, message=None):
req.save() req.save()
if req.status == '2': if req.status == '2':
req.do_at = human_datetime()
req.do_by = deploy.created_by
req.save()
deploy_dispatch(req) deploy_dispatch(req)

View File

@ -130,6 +130,10 @@ class RequestDetailView(View):
outputs[item['key']]['status'] = item['status'] outputs[item['key']]['status'] = item['status']
data = rds.lrange(key, counter, counter + 9) data = rds.lrange(key, counter, counter + 9)
response['index'] = counter response['index'] = counter
if counter == 0:
for item in outputs:
outputs[item]['data'] += '\r\n\r\n未读取到数据Spug 仅保存最近2周的日志信息。'
if req.is_quick_deploy: if req.is_quick_deploy:
if outputs['local']['data']: if outputs['local']['data']:
outputs['local']['data'] = f'{human_time()} 读取数据... ' + outputs['local']['data'] outputs['local']['data'] = f'{human_time()} 读取数据... ' + outputs['local']['data']

View File

@ -84,7 +84,7 @@ class HostExtend(models.Model, ModelMixin):
class Group(models.Model, ModelMixin): class Group(models.Model, ModelMixin):
name = models.CharField(max_length=20) name = models.CharField(max_length=50)
parent_id = models.IntegerField(default=0) parent_id = models.IntegerField(default=0)
sort_id = models.IntegerField(default=0) sort_id = models.IntegerField(default=0)
hosts = models.ManyToManyField(Host, related_name='groups') hosts = models.ManyToManyField(Host, related_name='groups')

View File

@ -202,6 +202,8 @@ def fetch_host_extend(ssh):
code, out = ssh.exec_command_raw('hostname -I') code, out = ssh.exec_command_raw('hostname -I')
if code == 0: if code == 0:
for ip in out.strip().split(): for ip in out.strip().split():
if len(ip) > 15: # ignore ipv6
continue
if ipaddress.ip_address(ip).is_global: if ipaddress.ip_address(ip).is_global:
if len(public_ip_address) < 10: if len(public_ip_address) < 10:
public_ip_address.add(ip) public_ip_address.add(ip)

View File

@ -114,6 +114,7 @@ def get_overview(request):
for item in Detection.objects.all(): for item in Detection.objects.all():
data = {} data = {}
for key in json.loads(item.targets): for key in json.loads(item.targets):
key = str(key)
data[key] = { data[key] = {
'id': f'{item.id}_{key}', 'id': f'{item.id}_{key}',
'group': item.group, 'group': item.group,

View File

@ -6,6 +6,7 @@ from apps.account.models import History
from apps.alarm.models import Alarm from apps.alarm.models import Alarm
from apps.schedule.models import Task, History as TaskHistory from apps.schedule.models import Task, History as TaskHistory
from apps.deploy.models import DeployRequest from apps.deploy.models import DeployRequest
from apps.app.models import DeployExtend1
from apps.exec.models import ExecHistory from apps.exec.models import ExecHistory
from apps.notify.models import Notify from apps.notify.models import Notify
from apps.deploy.utils import dispatch from apps.deploy.utils import dispatch
@ -21,6 +22,12 @@ def auto_run_by_day():
History.objects.filter(created_at__lt=date_30).delete() History.objects.filter(created_at__lt=date_30).delete()
Notify.objects.filter(created_at__lt=date_7, unread=False).delete() Notify.objects.filter(created_at__lt=date_7, unread=False).delete()
Alarm.objects.filter(created_at__lt=date_30).delete() Alarm.objects.filter(created_at__lt=date_30).delete()
for item in DeployExtend1.objects.all():
index = 0
for req in DeployRequest.objects.filter(deploy_id=item.deploy_id, repository_id__isnull=False):
if index > item.versions and req.repository_id:
req.repository.delete()
index += 1
try: try:
record = ExecHistory.objects.all()[50] record = ExecHistory.objects.all()[50]
ExecHistory.objects.filter(id__lt=record.id).delete() ExecHistory.objects.filter(id__lt=record.id).delete()

View File

@ -131,7 +131,7 @@ AUTHENTICATION_EXCLUDES = (
re.compile('/apis/.*'), re.compile('/apis/.*'),
) )
SPUG_VERSION = 'v3.1.5' SPUG_VERSION = 'v3.1.7'
# override default config # override default config
try: try:

View File

@ -10,4 +10,4 @@ export * from './functools';
export * from './router'; export * from './router';
export const http = _http; export const http = _http;
export const history = _history; export const history = _history;
export const VERSION = 'v3.1.5'; export const VERSION = 'v3.1.7';

View File

@ -46,7 +46,7 @@ export default observer(function () {
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={10}> <Col span={10}>
<Form.Item required label="版本数量" tooltip="早于指定数量的历史版本会被删除,以释放磁盘空间。"> <Form.Item required label="版本数量" tooltip="早于指定数量的构建纪录及历史版本会被删除,以释放磁盘空间。">
<Input value={info['versions']} onChange={e => info['versions'] = e.target.value} placeholder="请输入保存的版本数量"/> <Input value={info['versions']} onChange={e => info['versions'] = e.target.value} placeholder="请输入保存的版本数量"/>
</Form.Item> </Form.Item>
</Col> </Col>

View File

@ -55,12 +55,12 @@ export default observer(function () {
<Form.Item required name="name" label="申请标题"> <Form.Item required name="name" label="申请标题">
<Input placeholder="请输入申请标题"/> <Input placeholder="请输入申请标题"/>
</Form.Item> </Form.Item>
<Form.Item required name="request_id" label="选择版本"> <Form.Item required name="request_id" label="选择版本" tooltip="可选择回滚版本与发布配置中的版本数量配置相关。">
<Select <Select
showSearch showSearch
placeholder="请选择回滚至哪个版本" placeholder="请选择回滚至哪个版本"
filterOption={(input, option) => includes(option.props.children, input)}> filterOption={(input, option) => includes(option.props.children, input)}>
{store.records.filter(x => x.deploy_id === deploy_id && ['3', '-3'].includes(x.status)).map((item, index) => ( {store.records.filter(x => x.repository_id && x.deploy_id === deploy_id && ['3', '-3'].includes(x.status)).map((item, index) => (
<Select.Option key={item.id} value={item.id} record={item} disabled={index === 0}> <Select.Option key={item.id} value={item.id} record={item} disabled={index === 0}>
<div style={{display: 'flex', justifyContent: 'space-between'}}> <div style={{display: 'flex', justifyContent: 'space-between'}}>
<span>{`${item.name} (${item.version})`}</span> <span>{`${item.name} (${item.version})`}</span>

View File

@ -11,6 +11,7 @@ import { http, hasPermission } from 'libs';
import { Action, AuthButton, TableCard } from 'components'; import { Action, AuthButton, TableCard } from 'components';
import S from './index.module.less'; import S from './index.module.less';
import store from './store'; import store from './store';
import moment from 'moment';
function DeployConfirm() { function DeployConfirm() {
return ( return (
@ -72,7 +73,7 @@ function ComTable() {
className: S.min120, className: S.min120,
dataIndex: 'created_at', dataIndex: 'created_at',
sorter: (a, b) => a['created_at'].localeCompare(b['created_at']), sorter: (a, b) => a['created_at'].localeCompare(b['created_at']),
render: v => <Tooltip title={v}>{v ? v.substring(0, 10) : null}</Tooltip>, render: v => <Tooltip title={v}>{v ? moment(v).fromNow() : null}</Tooltip>,
hide: true hide: true
}, { }, {
title: '审核人', title: '审核人',
@ -83,7 +84,7 @@ function ComTable() {
title: '审核时间', title: '审核时间',
className: S.min120, className: S.min120,
dataIndex: 'approve_at', dataIndex: 'approve_at',
render: v => <Tooltip title={v}>{v ? v.substring(0, 10) : null}</Tooltip>, render: v => <Tooltip title={v}>{v ? moment(v).fromNow() : null}</Tooltip>,
}, { }, {
title: '发布人', title: '发布人',
className: S.min120, className: S.min120,
@ -93,7 +94,7 @@ function ComTable() {
title: '发布时间', title: '发布时间',
className: S.min120, className: S.min120,
dataIndex: 'do_at', dataIndex: 'do_at',
render: v => <Tooltip title={v}>{v ? v.substring(0, 10) : null}</Tooltip>, render: v => <Tooltip title={v}>{v ? moment(v).fromNow() : null}</Tooltip>,
hide: true hide: true
}, { }, {
title: '备注', title: '备注',

View File

@ -4,14 +4,15 @@
* Released under the AGPL-3.0 License. * Released under the AGPL-3.0 License.
*/ */
import React from 'react'; import React from 'react';
import { Breadcrumb, Table, Switch, Progress, Modal, message } from 'antd'; import { Breadcrumb, Table, Switch, Progress, Modal, Input, message } from 'antd';
import { import {
DeleteOutlined, DeleteOutlined,
DownloadOutlined, DownloadOutlined,
FileOutlined, FileOutlined,
FolderOutlined, FolderOutlined,
HomeOutlined, HomeOutlined,
UploadOutlined UploadOutlined,
EditOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { AuthButton, Action } from 'components'; import { AuthButton, Action } from 'components';
import { http, uniqueId, X_TOKEN } from 'libs'; import { http, uniqueId, X_TOKEN } from 'libs';
@ -23,10 +24,12 @@ class FileManager extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.input = null; this.input = null;
this.input2 = null
this.state = { this.state = {
fetching: false, fetching: false,
showDot: false, showDot: false,
uploading: false, uploading: false,
inputPath: null,
uploadStatus: 'active', uploadStatus: 'active',
pwd: [], pwd: [],
objects: [], objects: [],
@ -97,10 +100,11 @@ class FileManager extends React.Component {
this.setState({fetching: true}); this.setState({fetching: true});
pwd = pwd || this.state.pwd; pwd = pwd || this.state.pwd;
const path = '/' + pwd.join('/'); const path = '/' + pwd.join('/');
http.get('/api/file/', {params: {id: this.props.id, path}}) return http.get('/api/file/', {params: {id: this.props.id, path}})
.then(res => { .then(res => {
const objects = lds.orderBy(res, [this._kindSort, 'name'], ['desc', 'asc']); const objects = lds.orderBy(res, [this._kindSort, 'name'], ['desc', 'asc']);
this.setState({objects, pwd}) this.setState({objects, pwd})
this.state.inputPath !== null && this.setState({inputPath: path})
}) })
.finally(() => this.setState({fetching: false})) .finally(() => this.setState({fetching: false}))
}; };
@ -109,6 +113,7 @@ class FileManager extends React.Component {
let pwd = this.state.pwd.map(x => x); let pwd = this.state.pwd.map(x => x);
if (action === '1') { if (action === '1') {
pwd.push(name) pwd.push(name)
this.setState({inputPath: null})
} else if (action === '2') { } else if (action === '2') {
const index = pwd.indexOf(name); const index = pwd.indexOf(name);
pwd = pwd.splice(0, index + 1) pwd = pwd.splice(0, index + 1)
@ -118,6 +123,28 @@ class FileManager extends React.Component {
this.fetchFiles(pwd) this.fetchFiles(pwd)
}; };
handlePathInput = (e) => {
const value = e.target.value;
const pwd = value.substring(1).split('/')
this.setState({pwd})
};
handleInputEnter = () => {
if (this.state.inputPath === null) {
if (this.state.pwd.length > 0) {
this.setState({inputPath: `/${this.state.pwd.join('/')}/`})
} else {
this.setState({inputPath: '/'})
}
setTimeout(() => this.input2.focus(), 100)
} else {
let pwdStr = this.state.inputPath.replace(/^\/+/, '')
pwdStr = pwdStr.replace(/\/+$/, '')
this.fetchFiles(pwdStr.split('/'))
.then(() => this.setState({inputPath: null}))
}
}
handleUpload = () => { handleUpload = () => {
this.input.click(); this.input.click();
this.input.onchange = e => { this.input.onchange = e => {
@ -198,16 +225,27 @@ class FileManager extends React.Component {
<React.Fragment> <React.Fragment>
<input style={{display: 'none'}} type="file" ref={ref => this.input = ref}/> <input style={{display: 'none'}} type="file" ref={ref => this.input = ref}/>
<div className={styles.drawerHeader}> <div className={styles.drawerHeader}>
<Breadcrumb> {this.state.inputPath !== null ? (
<Breadcrumb.Item href="#" onClick={() => this.handleChdir('', '0')}> <Input ref={ref => this.input2 = ref} size="small" className={styles.input}
<HomeOutlined/> suffix={<div style={{color: '#999', fontSize: 12}}>回车确认</div>}
</Breadcrumb.Item> value={this.state.inputPath} onChange={e => this.setState({inputPath: e.target.value})}
{this.state.pwd.map(item => ( onPressEnter={this.handleInputEnter}/>
<Breadcrumb.Item key={item} href="#" onClick={() => this.handleChdir(item, '2')}> ) : (
<span>{item}</span> <Breadcrumb className={styles.bread}>
<Breadcrumb.Item href="#" onClick={() => this.handleChdir('', '0')}>
<HomeOutlined style={{fontSize: 16}}/>
</Breadcrumb.Item> </Breadcrumb.Item>
))} {this.state.pwd.map(item => (
</Breadcrumb> <Breadcrumb.Item key={item} href="#" onClick={() => this.handleChdir(item, '2')}>
<span>{item}</span>
</Breadcrumb.Item>
))}
<Breadcrumb.Item onClick={this.handleInputEnter}>
<EditOutlined className={styles.edit}/>
</Breadcrumb.Item>
</Breadcrumb>
)}
<div className={styles.action}> <div className={styles.action}>
<span>显示隐藏文件</span> <span>显示隐藏文件</span>
<Switch <Switch

View File

@ -162,6 +162,24 @@
height: 24px; height: 24px;
} }
.bread:hover {
.edit {
display: inline-block;
}
}
.input {
width: 60%;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
}
.edit {
display: none;
color: #2563fcbb;
margin-left: 24px;
cursor: pointer;
}
.progress { .progress {
width: 94px; width: 94px;
margin-left: 12px; margin-left: 12px;
@ -174,6 +192,15 @@
white-space: nowrap; white-space: nowrap;
margin-right: 24px; margin-right: 24px;
} }
:global(.ant-breadcrumb-separator) {
margin: 0 4px;
color: rgba(0, 0, 0, 0.85);
}
:global(.ant-breadcrumb-link) {
color: rgba(0, 0, 0, 0.85);
}
} }
.drawerBtn { .drawerBtn {