Compare commits

...

45 Commits
2.3.5 ... 2.3.6

Author SHA1 Message Date
pycook
dc1a2a7632 fix(api): secrets 2023-10-30 17:23:42 +08:00
pycook
153fef4918 feat: add inner password storage and optimize flask command about inner cmdb (#248)
Co-authored-by: fxiang21 <fxiang21@126.com>
2023-10-30 16:48:53 +08:00
wang-liang0615
e218f8e065 Dev UI 231030 (#247)
* config(ui):useEncryption default false

* fix(cmdb-ui):ident 4

* fix(cmdb-ui):relation views
2023-10-30 12:38:05 +08:00
pycook
b7137b3975 Dev api password (#244)
* fix: delete CI password data

* fix(api): update CI password to flush cache
2023-10-29 11:42:07 +08:00
pycook
c6a9478dbb fix: delete CI password data (#243) 2023-10-29 10:53:29 +08:00
pycook
af40004fe9 fix(cmdb-ui): CI update password 2023-10-28 23:07:01 +08:00
pycook
d86eb1c5eb release: v2.3.6 2023-10-28 17:51:13 +08:00
pycook
fef3bfceaf feat(cmdb-api): ci password 2023-10-28 16:55:02 +08:00
pycook
3a4f0b248f feat(cmdb-api): CI password data store (#242)
* add secrets,for test

* feat: vault SDK (#238)

* feat: vault SDK

* docs: i18n

* perf(vault): format code

* feat(secrets): support vault

* feat: add inner password storage

* feat: secrets

* feat: add inner password storage

* feat: add secrets feature

* perf(secrets): review

---------

Co-authored-by: fxiang21 <fxiang21@126.com>
Co-authored-by: Mimo <osatmnzn@gmail.com>
2023-10-28 16:19:00 +08:00
wang-liang0615
ffa3d7cd43 feat:预定义值支持脚本&&密码存储&&一些bugfix (#239) 2023-10-27 11:10:43 +08:00
ivonGwy
6aefac98cd Doc (#235)
* change assignees
2023-10-25 16:00:04 +08:00
ivonGwy
22989f8d5a Doc (#234)
* final template
2023-10-25 15:52:12 +08:00
ivonGwy
ebe9d1e29f Doc (#232)
* fix bugs
2023-10-25 14:54:23 +08:00
ivonGwy
f1dd5ca074 Update issue templates 2023-10-25 14:46:44 +08:00
ivonGwy
4dd95f0d7e Doc (#231)
* add issue template
2023-10-25 14:36:36 +08:00
ivonGwy
7e3e248c2b Update issue templates 2023-10-25 14:06:24 +08:00
ivonGwy
a5ff1139f7 Update issue templates 2023-10-25 14:04:53 +08:00
pycook
00135f4644 fix(api): add ci (#230) 2023-10-25 13:51:29 +08:00
ivonGwy
8297d4c9b4 Doc (#229)
* change reandme
2023-10-25 13:19:30 +08:00
kdyq007
5143539593 关闭前端密码加密;加强 ldap 用户验证 (#216)
* [更新] python-ldap 更新到 ldap3

* [更新] 关闭前端密码加密;加强 ldap 用户验证

* Update app.js

---------

Co-authored-by: sherlock <sherlock@gmail.com>
Co-authored-by: pycook <pycook@126.com>
2023-10-24 19:47:46 +08:00
pycook
a6eb2f0d21 feat: Predefined values support executing scripts (#227) 2023-10-24 19:32:43 +08:00
simontigers
07a63bef6e fix: add_employee_from_acl (#225) 2023-10-24 14:20:40 +08:00
wang-liang0615
d69efeea25 fix:关系视图删除关系接口传参修改 (#224)
* fix:acl新增用户展示异常问题

* fix:关系视图删除关系接口传参修改
2023-10-24 06:04:21 +08:00
wang-liang0615
0ef67360ad fix:acl新增用户展示异常问题 (#223) 2023-10-24 05:59:08 +08:00
pycook
e2f993bc11 feat: add cryptography to requirements 2023-10-23 14:37:01 +08:00
pycook
05d2795e79 fix: acl cache 2023-10-23 13:57:06 +08:00
Evan Sung
6ff77a140c fix(common): fix 'ACLManager' object has no attribute 'create_app' (#217) 2023-10-21 11:38:19 +08:00
Evan Sung
6503d32e6e fix(ci_cache): ci cache async args (#215) 2023-10-20 12:05:19 +08:00
kdyq007
887a69c2bd feat: python-ldap 更新到 ldap3 (#214)
Co-authored-by: sherlock <sherlock@gmail.com>
2023-10-20 09:36:38 +08:00
pycook
6d052eaffc Dev api 20231019 (#210)
* fix(acl): get resources

* fix(celery worker): db server has gone away
2023-10-19 11:51:34 +08:00
wang-liang0615
d0f0bf84dd fix:ci relation add type filter (#208) 2023-10-18 14:06:28 +08:00
pycook
802fda66e7 fix: ci relation statistics 2023-10-18 13:35:01 +08:00
pycook
c95747c88a docs: api doc 2023-10-17 12:06:37 +08:00
wang-liang0615
ed49b238d8 feat:webhook body 支持非json (#203) 2023-10-17 10:44:38 +08:00
Evan Sung
375f0879fb Feature db migrate 20231013 (#202)
* feat(db): support flask migrate

* minor

---------

Co-authored-by: s01249 <songbing@smyfinancial.com>
2023-10-13 16:24:49 +08:00
Evan Sung
8bc1893ca9 feat(db): support flask migrate (#201)
Co-authored-by: s01249 <songbing@smyfinancial.com>
2023-10-13 15:55:26 +08:00
simontigers
53cd2342bf fix: common perms (#200) 2023-10-12 16:02:35 +08:00
wang-liang0615
eff6d974d4 pref:批量上传&资源管理小优化 (#199) 2023-10-12 15:06:39 +08:00
ivonGwy
8478d2f858 Merge pull request #197 from veops/doc
Doc
2023-10-11 14:34:05 +08:00
ivonGwy
80e99cc335 change wechat pic size 2023-10-11 14:33:19 +08:00
ivonGwy
8f64fc4aa0 change wechat pic 2023-10-11 14:28:49 +08:00
pycook
cfc345c993 Dev api (#196)
* docs: update

* docs: README & Makefile
2023-10-11 13:40:15 +08:00
wang-liang0615
928116d0b5 Dev UI 231009 (#195)
* pref:用户密钥非必填

* fix:chartColor undefined
2023-10-11 09:12:04 +08:00
pycook
4c2e6ae69f docs: update (#194) 2023-10-10 16:53:30 +08:00
wang-liang0615
a2c75fd34e pref:用户密钥非必填 (#193) 2023-10-10 09:25:24 +08:00
99 changed files with 9796 additions and 7184 deletions

60
.github/ISSUE_TEMPLATE/1bug.yaml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Bug Report
description: File a bug report
title: "[Bug]: "
labels: ["☢️ bug"]
assignees:
- Selina316
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: input
id: contact
attributes:
label: Contact Details
description: How can we get in touch with you if we need more info?
placeholder: ex. email@example.com
validations:
required: false
- type: dropdown
id: aspects
attributes:
label: This bug is related to UI or API?
multiple: true
options:
- UI
- API
- type: textarea
id: happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
value: "A bug happened!"
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: What version of our software are you running?
value: "newest"
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- Microsoft Edge
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell

44
.github/ISSUE_TEMPLATE/2feature.yaml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Feature wanted
description: A new feature would be good
title: "[Feature]: "
labels: ["✏️ feature"]
assignees:
- pycook
body:
- type: markdown
attributes:
value: |
Thank you for your feature suggestion; we will evaluate it carefully!
- type: input
id: contact
attributes:
label: Contact Details
description: How can we get in touch with you if we need more info?
placeholder: ex. email@example.com
validations:
required: false
- type: dropdown
id: aspects
attributes:
label: feature is related to UI or API aspects?
multiple: true
options:
- UI
- API
- type: textarea
id: feature
attributes:
label: What is your advice?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you want!
value: "everyone wants this feature!"
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: What version of our software are you running?
value: "newest"
validations:
required: true

View File

@@ -0,0 +1,36 @@
name: Help wanted
description: I have a question
title: "[help wanted]: "
labels: ["help wanted"]
assignees:
- ivonGwy
body:
- type: markdown
attributes:
value: |
Please tell us what's you need!
- type: input
id: contact
attributes:
label: Contact Details
description: How can we get in touch with you if we need more info?
placeholder: ex. email@example.com
validations:
required: false
- type: textarea
id: question
attributes:
label: What is your question?
description: Also tell us, how can we help?
placeholder: Tell us what you need!
value: "i have a question!"
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: What version of our software are you running?
value: "newest"
validations:
required: true

60
.github/ISSUE_TEMPLATE/bug.yaml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Bug Report
description: File a bug report
title: "[Bug]: "
labels: ["bug"]
assignees:
- pycook
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: input
id: contact
attributes:
label: Contact Details
description: How can we get in touch with you if we need more info?
placeholder: ex. email@example.com
validations:
required: false
- type: dropdown
id: type
attributes:
label: bug is related to UI or API aspects?
multiple: true
options:
- UI
- API
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
value: "A bug happened!"
validations:
required: true
- type: textarea
id: version
attributes:
label: Version
description: What version of our software are you running?
default: 2.3.5
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- Microsoft Edge
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell

6
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
blank_issues_enabled: false
contact_links:
- name: veops official website
url: https://veops.cn/#hero
about: you can contact us here.

View File

44
.github/ISSUE_TEMPLATE/feature.yaml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Feature wanted
description: A new feature would be good
title: "[Feature]: "
labels: ["feature"]
assignees:
- pycook
body:
- type: markdown
attributes:
value: |
Thank you for your feature suggestion; we will evaluate it carefully!
- type: input
id: contact
attributes:
label: Contact Details
description: How can we get in touch with you if we need more info?
placeholder: ex. email@example.com
validations:
required: false
- type: dropdown
id: type
attributes:
label: feature is related to UI or API aspects?
multiple: true
options:
- UI
- API
- type: textarea
id: describe the feature
attributes:
label: What is your advice?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you want!
value: "everyone wants this feature!"
validations:
required: true
- type: textarea
id: version
attributes:
label: Version
description: What version of our software are you running?
default: 2.3.5
validations:
required: true

0
.github/config.yml vendored Normal file
View File

1
.gitignore vendored
View File

@@ -40,6 +40,7 @@ nosetests.xml
.pytest_cache
cmdb-api/test-output
cmdb-api/api/uploaded_files
cmdb-api/migrations/versions
# Translations
*.mo

View File

@@ -9,7 +9,7 @@ help: ## display this help
env: ## create a development environment using pipenv
sudo easy_install pip && \
pip install pipenv -i https://pypi.douban.com/simple && \
pip install pipenv -i https://repo.huaweicloud.com/repository/pypi/simple && \
npm install yarn && \
make deps
.PHONY: env
@@ -36,7 +36,7 @@ api: ## start api server
.PHONY: api
worker: ## start async tasks worker
cd cmdb-api && pipenv run celery -A celery_worker.celery worker -E -Q one_cmdb_async --concurrency=1 -D && pipenv run celery -A celery_worker.celery worker -E -Q acl_async --concurrency=1 -D
cd cmdb-api && pipenv run celery -A celery_worker.celery worker -E -Q one_cmdb_async --autoscale=5,2 --logfile=one_cmdb_async.log -D && pipenv run celery -A celery_worker.celery worker -E -Q acl_async --autoscale=2,1 --logfile=one_acl_async.log -D
.PHONY: worker
ui: ## start ui server

View File

@@ -1,12 +1,20 @@
![维易开源CMDB](docs/images/logo.png)
[![License](https://img.shields.io/badge/License-AGPLv3-brightgreen)](https://github.com/veops/cmdb/blob/master/LICENSE)
[![UI](https://img.shields.io/badge/UI-Ant%20Design%20Pro%20Vue-brightgreen)](https://github.com/sendya/ant-design-pro-vue)
[![API](https://img.shields.io/badge/API-Flask-brightgreen)](https://github.com/pallets/flask)
<p align="center">
<a href="https://veops.cn"><img src="docs/images/logo.png" alt="维易CMDB" width="300"/></a>
</p>
<h3 align="center">简单、轻量、通用的运维配置管理数据库</h3>
<p align="center">
<a href="https://github.com/veops/cmdb/blob/master/LICENSE"><img src="https://img.shields.io/badge/License-AGPLv3-brightgreen" alt="License: GPLv3"></a>
<a href="https:https://github.com/sendya/ant-design-pro-vue"><img src="https://img.shields.io/badge/UI-Ant%20Design%20Pro%20Vue-brightgreen" alt="UI"></a>
<a href="https://github.com/pallets/flask"><img src="https://img.shields.io/badge/API-Flask-brightgreen" alt="API"></a>
</p>
------------------------------
[English](docs/README_en.md) / [中文](README.md)
- 产品文档https://veops.cn/docs/
- 在线体验: <a href="https://cmdb.veops.cn" target="_blank">CMDB</a>
- 在线体验<a href="https://cmdb.veops.cn" target="_blank">CMDB</a>
- username: demo 或者 admin
- password: 123456
@@ -15,45 +23,43 @@
## 系统介绍
### 整体架构
### 系统概览
<img src=docs/images/view.jpg />
<img src=docs/images/dashboard.png />
### 相关文档
[查看更多展示](docs/screenshot.md)
### 相关文章
- <a href="https://mp.weixin.qq.com/s/v3eANth64UBW5xdyOkK3tg" target="_blank">概要设计</a>
- <a href="https://github.com/veops/cmdb/tree/master/docs/cmdb_api.md" target="_blank">API 文档</a>
- <a href="https://mp.weixin.qq.com/s/rQaf4AES7YJsyNQG_MKOLg" target="_blank">自动发现</a>
- 更多文章可以在公众号 **维易科技OneOps** 里查看
### 特点
- 灵活性
1. 规范并统一纳管复杂数据资产
2. 自动发现、入库 IT 资产
1. 配置灵活,不设定任何运维场景,有内置模板
2. 自动发现、入库 IT 资产
- 安全性
1. 细粒度访问控制
1. 细粒度权限控制
2. 完备操作日志
- 多应用
1. 丰富视图展示维度
2. 提供 Restful API
2. API简单强大
3. 支持定义属性触发器、计算属性
### 主要功能
- 模型属性支持索引、多值、默认排序、字体颜色,支持计算属性
- 支持自动发现、定时巡检、文件导入
- 支持资源、树形、关系视图展示
- 支持资源、层级、关系视图展示
- 支持模型间关系配置和展示
- 细粒度访问控制,完备的操作日志
- 支持跨模型搜索
### 系统概览
- 服务树
![服务树](docs/images/0.png "首页展示")
[查看更多展示](docs/screenshot.md)
### 更多功能
@@ -67,7 +73,7 @@
## 安装
### Docker 一键快速构建
- 进入主目录(先安装 docker 环境)
- 进入主目录(先安装 docker 环境, 注意要clone整个项目
```
docker-compose up -d
@@ -83,6 +89,6 @@ docker-compose up -d
---
_**欢迎关注我们的公众号点击联系我们加入微信、QQ群(336164978),获得更多产品、行业相关资讯**_
_**欢迎关注公众号(维易科技OneOps),关注后可加入微信群,进行产品和技术交流。**_
![公众号: 维易科技OneOps](docs/images/qrcode_for_gzh.jpg)
![公众号: 维易科技OneOps](docs/images/wechat.png)

View File

@@ -24,18 +24,19 @@ supervisor = "==4.0.3"
Flask-Login = "==0.6.2"
Flask-Bcrypt = "==1.0.1"
Flask-Cors = ">=3.0.8"
python-ldap = "==3.4.0"
ldap3 = "==2.9.1"
pycryptodome = "==3.12.0"
cryptography = ">=41.0.2"
# Caching
Flask-Caching = ">=1.0.0"
# Environment variable parsing
environs = "==4.2.0"
marshmallow = "==2.20.2"
# async tasks
celery = "==5.3.1"
celery = ">=5.3.1"
celery_once = "==3.0.1"
more-itertools = "==5.0.0"
kombu = "==5.3.1"
kombu = ">=5.3.1"
# common setting
timeout-decorator = "==0.5.0"
WTForms = "==3.0.0"
@@ -58,6 +59,9 @@ Jinja2 = "==3.1.2"
jinja2schema = "==0.1.4"
msgpack-python = "==0.5.6"
alembic = "==1.7.7"
hvac = "==2.0.0"
colorama = ">=0.4.6"
pycryptodomex = ">=3.19.0"
[dev-packages]
# Testing
@@ -74,4 +78,3 @@ flake8-isort = "==2.7.0"
isort = "==4.3.21"
pep8-naming = "==0.8.2"
pydocstyle = "==3.0.0"

View File

@@ -7,6 +7,7 @@ import os
import sys
from inspect import getmembers
from logging.handlers import RotatingFileHandler
from pathlib import Path
from flask import Flask
from flask import jsonify
@@ -17,11 +18,14 @@ from flask.json.provider import DefaultJSONProvider
import api.views.entry
from api.extensions import (bcrypt, cache, celery, cors, db, es, login_manager, migrate, rd)
from api.extensions import inner_secrets
from api.flask_cas import CAS
from api.lib.secrets.secrets import InnerKVManger
from api.models.acl import User
HERE = os.path.abspath(os.path.dirname(__file__))
PROJECT_ROOT = os.path.join(HERE, os.pardir)
BASE_DIR = Path(__file__).resolve().parent.parent
@login_manager.user_loader
@@ -76,15 +80,6 @@ class MyJSONEncoder(DefaultJSONProvider):
return o
def create_acl_app(config_object="settings"):
app = Flask(__name__.split(".")[0])
app.config.from_object(config_object)
register_extensions(app)
return app
def create_app(config_object="settings"):
"""Create application factory, as explained here: http://flask.pocoo.org/docs/patterns/appfactories/.
@@ -125,7 +120,7 @@ def register_extensions(app):
db.init_app(app)
cors.init_app(app)
login_manager.init_app(app)
migrate.init_app(app, db)
migrate.init_app(app, db, directory=f"{BASE_DIR}/migrations")
rd.init_app(app)
if app.config.get('USE_ES'):
es.init_app(app)
@@ -133,6 +128,10 @@ def register_extensions(app):
app.config.update(app.config.get("CELERY"))
celery.conf.update(app.config)
if app.config.get('SECRETS_ENGINE') == 'inner':
with app.app_context():
inner_secrets.init_app(app, InnerKVManger())
def register_blueprints(app):
for item in getmembers(api.views.entry):

View File

@@ -7,6 +7,7 @@ import json
import time
import click
import requests
from flask import current_app
from flask.cli import with_appcontext
from flask_login import login_user
@@ -29,6 +30,9 @@ from api.lib.perm.acl.resource import ResourceCRUD
from api.lib.perm.acl.resource import ResourceTypeCRUD
from api.lib.perm.acl.role import RoleCRUD
from api.lib.perm.acl.user import UserCRUD
from api.lib.secrets.inner import KeyManage
from api.lib.secrets.inner import global_key_threshold
from api.lib.secrets.secrets import InnerKVManger
from api.models.acl import App
from api.models.acl import ResourceType
from api.models.cmdb import Attribute
@@ -53,6 +57,7 @@ def cmdb_init_cache():
if relations:
rd.create_or_update(relations, REDIS_PREFIX_CI_RELATION)
es = None
if current_app.config.get("USE_ES"):
from api.extensions import es
from api.models.cmdb import Attribute
@@ -311,3 +316,156 @@ def cmdb_index_table_upgrade():
CIIndexValueDateTime.create(ci_id=i.ci_id, attr_id=i.attr_id, value=i.value, commit=False)
i.delete(commit=False)
db.session.commit()
def valid_address(address):
if not address:
return False
if not address.startswith(("http://127.0.0.1", "https://127.0.0.1")):
response = {
"message": "Address should start with http://127.0.0.1 or https://127.0.0.1",
"status": "failed"
}
KeyManage.print_response(response)
return False
return True
@click.command()
@click.option(
'-a',
'--address',
help='inner cmdb api, http://127.0.0.1:8000',
)
@with_appcontext
def cmdb_inner_secrets_init(address):
"""
init inner secrets for password feature
"""
res, ok = KeyManage(backend=InnerKVManger).init()
if not ok:
if res.get("status") == "failed":
KeyManage.print_response(res)
return
token = res.get("details", {}).get("root_token", "")
if valid_address(address):
token = current_app.config.get("INNER_TRIGGER_TOKEN", "") if not token else token
if not token:
token = click.prompt(f'Enter root token', hide_input=True, confirmation_prompt=False)
assert token is not None
resp = requests.post("{}/api/v0.1/secrets/auto_seal".format(address.strip("/")),
headers={"Inner-Token": token})
if resp.status_code == 200:
KeyManage.print_response(resp.json())
else:
KeyManage.print_response({"message": resp.text or resp.status_code, "status": "failed"})
else:
KeyManage.print_response(res)
@click.command()
@click.option(
'-a',
'--address',
help='inner cmdb api, http://127.0.0.1:8000',
required=True,
)
@with_appcontext
def cmdb_inner_secrets_unseal(address):
"""
unseal the secrets feature
"""
if not valid_address(address):
return
address = "{}/api/v0.1/secrets/unseal".format(address.strip("/"))
for i in range(global_key_threshold):
token = click.prompt(f'Enter unseal token {i + 1}', hide_input=True, confirmation_prompt=False)
assert token is not None
resp = requests.post(address, headers={"Unseal-Token": token})
if resp.status_code == 200:
KeyManage.print_response(resp.json())
if resp.json().get("status") in ["success", "skip"]:
return
else:
KeyManage.print_response({"message": resp.status_code, "status": "failed"})
return
@click.command()
@click.option(
'-a',
'--address',
help='inner cmdb api, http://127.0.0.1:8000',
required=True,
)
@click.option(
'-k',
'--token',
help='root token',
prompt=True,
hide_input=True,
)
@with_appcontext
def cmdb_inner_secrets_seal(address, token):
"""
seal the secrets feature
"""
assert address is not None
assert token is not None
if not valid_address(address):
return
address = "{}/api/v0.1/secrets/seal".format(address.strip("/"))
resp = requests.post(address, headers={
"Inner-Token": token,
})
if resp.status_code == 200:
KeyManage.print_response(resp.json())
else:
KeyManage.print_response({"message": resp.status_code, "status": "failed"})
@click.command()
@with_appcontext
def cmdb_password_data_migrate():
"""
Migrate CI password data, version >= v2.3.6
"""
from api.models.cmdb import CIIndexValueText
from api.models.cmdb import CIValueText
from api.lib.secrets.inner import InnerCrypt
from api.lib.secrets.vault import VaultClient
attrs = Attribute.get_by(to_dict=False)
for attr in attrs:
if attr.is_password:
value_table = CIIndexValueText if attr.is_index else CIValueText
for i in value_table.get_by(attr_id=attr.id, to_dict=False):
if current_app.config.get("SECRETS_ENGINE", 'inner') == 'inner':
_, status = InnerCrypt().decrypt(i.value)
if status:
continue
encrypt_value, status = InnerCrypt().encrypt(i.value)
if status:
CIValueText.create(ci_id=i.ci_id, attr_id=attr.id, value=encrypt_value)
else:
continue
elif current_app.config.get("SECRETS_ENGINE") == 'vault':
if i.value == '******':
continue
vault = VaultClient(current_app.config.get('VAULT_URL'), current_app.config.get('VAULT_TOKEN'))
try:
vault.update("/{}/{}".format(i.ci_id, i.attr_id), dict(v=i.value))
except Exception as e:
print('save password to vault failed: {}'.format(e))
continue
else:
continue
i.delete()

View File

@@ -165,31 +165,48 @@ class InitDepartment(object):
acl = self.check_app('backend')
resources_types = acl.get_all_resources_types()
perms = ['read', 'grant', 'delete', 'update']
acl_rid = self.get_admin_user_rid()
results = list(filter(lambda t: t['name'] == '操作权限', resources_types['groups']))
if len(results) == 0:
payload = dict(
app_id=acl.app_name,
name='操作权限',
description='',
perms=['read', 'grant', 'delete', 'update']
perms=perms
)
resource_type = acl.create_resources_type(payload)
else:
resource_type = results[0]
resource_type_id = resource_type['id']
existed_perms = resources_types.get('id2perms', {}).get(resource_type_id, [])
existed_perms = [p['name'] for p in existed_perms]
new_perms = []
for perm in perms:
if perm not in existed_perms:
new_perms.append(perm)
if len(new_perms) > 0:
resource_type['perms'] = existed_perms + new_perms
acl.update_resources_type(resource_type_id, resource_type)
resource_list = acl.get_resource_by_type(None, None, resource_type['id'])
for name in ['公司信息', '公司架构', '通知设置']:
payload = dict(
type_id=resource_type['id'],
app_id=acl.app_name,
name=name,
)
try:
acl.create_resource(payload)
except Exception as e:
if '已经存在' in str(e):
pass
else:
raise Exception(e)
target = list(filter(lambda r: r['name'] == name, resource_list))
if len(target) == 0:
payload = dict(
type_id=resource_type['id'],
app_id=acl.app_name,
name=name,
)
resource = acl.create_resource(payload)
else:
resource = target[0]
if acl_rid > 0:
acl.grant_resource(acl_rid, resource['id'], perms)
def check_app(self, app_name):
acl = ACLManager(app_name)
@@ -199,10 +216,9 @@ class InitDepartment(object):
)
try:
app = acl.validate_app()
if app:
return acl
acl.create_app(payload)
if not app:
acl.create_app(payload)
return acl
except Exception as e:
current_app.logger.error(e)
if '不存在' in str(e):
@@ -210,6 +226,10 @@ class InitDepartment(object):
return acl
raise Exception(e)
def get_admin_user_rid(self):
admin = Employee.get_by(first=True, username='admin', to_dict=False)
return admin.acl_rid if admin else 0
@click.command()
@with_appcontext

View File

@@ -9,6 +9,7 @@ from flask_login import LoginManager
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from api.lib.secrets.inner import KeyManage
from api.lib.utils import ESHandler
from api.lib.utils import RedisHandler
@@ -21,3 +22,4 @@ celery = Celery()
cors = CORS(supports_credentials=True)
rd = RedisHandler()
es = ESHandler()
inner_secrets = KeyManage()

View File

@@ -60,22 +60,33 @@ class AttributeManager(object):
return []
@staticmethod
def _get_choice_values_from_other_ci(choice_other):
def _get_choice_values_from_other(choice_other):
from api.lib.cmdb.search import SearchError
from api.lib.cmdb.search.ci import search
type_ids = choice_other.get('type_ids')
attr_id = choice_other.get('attr_id')
other_filter = choice_other.get('filter') or ''
if choice_other.get('type_ids'):
type_ids = choice_other.get('type_ids')
attr_id = choice_other.get('attr_id')
other_filter = choice_other.get('filter') or ''
query = "_type:({}),{}".format(";".join(map(str, type_ids)), other_filter)
s = search(query, fl=[str(attr_id)], facet=[str(attr_id)], count=1)
try:
_, _, _, _, _, facet = s.search()
return [[i[0], {}] for i in (list(facet.values()) or [[]])[0]]
except SearchError as e:
current_app.logger.error("get choice values from other ci failed: {}".format(e))
return []
query = "_type:({}),{}".format(";".join(map(str, type_ids)), other_filter)
s = search(query, fl=[str(attr_id)], facet=[str(attr_id)], count=1)
try:
_, _, _, _, _, facet = s.search()
return [[i[0], {}] for i in (list(facet.values()) or [[]])[0]]
except SearchError as e:
current_app.logger.error("get choice values from other ci failed: {}".format(e))
return []
elif choice_other.get('script'):
try:
x = compile(choice_other['script'], '', "exec")
exec(x)
res = locals()['ChoiceValue']().values() or []
return [[i, {}] for i in res]
except Exception as e:
current_app.logger.error("get choice values from script: {}".format(e))
return []
@classmethod
def get_choice_values(cls, attr_id, value_type, choice_web_hook, choice_other,
@@ -87,7 +98,7 @@ class AttributeManager(object):
return []
elif choice_other:
if choice_other_parse and isinstance(choice_other, dict):
return cls._get_choice_values_from_other_ci(choice_other)
return cls._get_choice_values_from_other(choice_other)
else:
return []
@@ -96,7 +107,8 @@ class AttributeManager(object):
return []
choice_values = choice_table.get_by(fl=["value", "option"], attr_id=attr_id)
return [[choice_value['value'], choice_value['option']] for choice_value in choice_values]
return [[ValueTypeMap.serialize[value_type](choice_value['value']), choice_value['option']]
for choice_value in choice_values]
@staticmethod
def add_choice_values(_id, value_type, choice_values):
@@ -218,10 +230,15 @@ class AttributeManager(object):
if name in BUILTIN_KEYWORDS:
return abort(400, ErrFormat.attribute_name_cannot_be_builtin)
if kwargs.get('choice_other'):
if (not isinstance(kwargs['choice_other'], dict) or not kwargs['choice_other'].get('type_ids') or
not kwargs['choice_other'].get('attr_id')):
return abort(400, ErrFormat.attribute_choice_other_invalid)
while kwargs.get('choice_other'):
if isinstance(kwargs['choice_other'], dict):
if kwargs['choice_other'].get('script'):
break
if kwargs['choice_other'].get('type_ids') and kwargs['choice_other'].get('attr_id'):
break
return abort(400, ErrFormat.attribute_choice_other_invalid)
alias = kwargs.pop("alias", "")
alias = name if not alias else alias
@@ -232,6 +249,8 @@ class AttributeManager(object):
kwargs.get('is_computed') and cls.can_create_computed_attribute()
kwargs.get('choice_other') and kwargs['choice_other'].get('script') and cls.can_create_computed_attribute()
attr = Attribute.create(flush=True,
name=name,
alias=alias,
@@ -337,10 +356,15 @@ class AttributeManager(object):
self._change_index(attr, attr.is_index, kwargs['is_index'])
if kwargs.get('choice_other'):
if (not isinstance(kwargs['choice_other'], dict) or not kwargs['choice_other'].get('type_ids') or
not kwargs['choice_other'].get('attr_id')):
return abort(400, ErrFormat.attribute_choice_other_invalid)
while kwargs.get('choice_other'):
if isinstance(kwargs['choice_other'], dict):
if kwargs['choice_other'].get('script'):
break
if kwargs['choice_other'].get('type_ids') and kwargs['choice_other'].get('attr_id'):
break
return abort(400, ErrFormat.attribute_choice_other_invalid)
existed2 = attr.to_dict()
if not existed2['choice_web_hook'] and not existed2.get('choice_other') and existed2['is_choice']:

View File

@@ -29,6 +29,7 @@ from api.lib.cmdb.const import PermEnum
from api.lib.cmdb.const import REDIS_PREFIX_CI
from api.lib.cmdb.const import ResourceTypeEnum
from api.lib.cmdb.const import RetKey
from api.lib.cmdb.const import ValueTypeEnum
from api.lib.cmdb.history import AttributeHistoryManger
from api.lib.cmdb.history import CIRelationHistoryManager
from api.lib.cmdb.history import CITriggerHistoryManager
@@ -42,8 +43,10 @@ from api.lib.notify import notify_send
from api.lib.perm.acl.acl import ACLManager
from api.lib.perm.acl.acl import is_app_admin
from api.lib.perm.acl.acl import validate_permission
from api.lib.utils import Lock
from api.lib.secrets.inner import InnerCrypt
from api.lib.secrets.vault import VaultClient
from api.lib.utils import handle_arg_list
from api.lib.utils import Lock
from api.lib.webhook import webhook_request
from api.models.cmdb import AttributeHistory
from api.models.cmdb import AutoDiscoveryCI
@@ -60,6 +63,7 @@ from api.tasks.cmdb import ci_relation_cache
from api.tasks.cmdb import ci_relation_delete
PRIVILEGED_USERS = {"worker", "cmdb_agent", "agent"}
PASSWORD_DEFAULT_SHOW = "******"
class CIManager(object):
@@ -323,6 +327,8 @@ class CIManager(object):
ci_attr2type_attr = {type_attr.attr_id: type_attr for type_attr, _ in attrs}
ci = None
record_id = None
password_dict = {}
need_lock = current_user.username not in current_app.config.get('PRIVILEGED_USERS', PRIVILEGED_USERS)
with Lock(ci_type_name, need_lock=need_lock):
existed = cls.ci_is_exist(unique_key, unique_value, ci_type.id)
@@ -351,14 +357,23 @@ class CIManager(object):
ci_dict.get(attr.name) is None and ci_dict.get(attr.alias) is None)):
ci_dict[attr.name] = attr.default.get('default')
if type_attr.is_required and (attr.name not in ci_dict and attr.alias not in ci_dict):
if (type_attr.is_required and not attr.is_computed and
(attr.name not in ci_dict and attr.alias not in ci_dict)):
return abort(400, ErrFormat.attribute_value_required.format(attr.name))
else:
for type_attr, attr in attrs:
if attr.default and attr.default.get('default') == AttributeDefaultValueEnum.UPDATED_AT:
ci_dict[attr.name] = now
computed_attrs = [attr.to_dict() for _, attr in attrs if attr.is_computed] or None
computed_attrs = []
for _, attr in attrs:
if attr.is_computed:
computed_attrs.append(attr.to_dict())
elif attr.is_password:
if attr.name in ci_dict:
password_dict[attr.id] = ci_dict.pop(attr.name)
elif attr.alias in ci_dict:
password_dict[attr.id] = ci_dict.pop(attr.alias)
value_manager = AttributeValueManager()
@@ -395,6 +410,10 @@ class CIManager(object):
cls.delete(ci.id)
raise e
if password_dict:
for attr_id in password_dict:
record_id = cls.save_password(ci.id, attr_id, password_dict[attr_id], record_id, ci_type.id)
if record_id: # has change
ci_cache.apply_async(args=(ci.id, operate_type, record_id), queue=CMDB_QUEUE)
@@ -414,7 +433,16 @@ class CIManager(object):
if attr.default and attr.default.get('default') == AttributeDefaultValueEnum.UPDATED_AT:
ci_dict[attr.name] = now
computed_attrs = [attr.to_dict() for _, attr in attrs if attr.is_computed] or None
password_dict = dict()
computed_attrs = list()
for _, attr in attrs:
if attr.is_computed:
computed_attrs.append(attr.to_dict())
elif attr.is_password:
if attr.name in ci_dict:
password_dict[attr.id] = ci_dict.pop(attr.name)
elif attr.alias in ci_dict:
password_dict[attr.id] = ci_dict.pop(attr.alias)
value_manager = AttributeValueManager()
@@ -423,6 +451,7 @@ class CIManager(object):
limit_attrs = self._valid_ci_for_no_read(ci) if not _is_admin else {}
record_id = None
need_lock = current_user.username not in current_app.config.get('PRIVILEGED_USERS', PRIVILEGED_USERS)
with Lock(ci.ci_type.name, need_lock=need_lock):
self._valid_unique_constraint(ci.type_id, ci_dict, ci_id)
@@ -440,6 +469,10 @@ class CIManager(object):
except BadRequest as e:
raise e
if password_dict:
for attr_id in password_dict:
record_id = self.save_password(ci.id, attr_id, password_dict[attr_id], record_id, ci.type_id)
if record_id: # has change
ci_cache.apply_async(args=(ci_id, OperateType.UPDATE, record_id), queue=CMDB_QUEUE)
@@ -465,16 +498,17 @@ class CIManager(object):
ci_dict = cls.get_cis_by_ids([ci_id])
ci_dict = ci_dict and ci_dict[0]
triggers = CITriggerManager.get(ci_dict['_type'])
for trigger in triggers:
option = trigger['option']
if not option.get('enable') or option.get('action') != OperateType.DELETE:
continue
if ci_dict:
triggers = CITriggerManager.get(ci_dict['_type'])
for trigger in triggers:
option = trigger['option']
if not option.get('enable') or option.get('action') != OperateType.DELETE:
continue
if option.get('filter') and not CITriggerManager.ci_filter(ci_dict.get('_id'), option['filter']):
continue
if option.get('filter') and not CITriggerManager.ci_filter(ci_dict.get('_id'), option['filter']):
continue
ci_delete_trigger.apply_async(args=(trigger, OperateType.DELETE, ci_dict), queue=CMDB_QUEUE)
ci_delete_trigger.apply_async(args=(trigger, OperateType.DELETE, ci_dict), queue=CMDB_QUEUE)
attrs = CITypeAttribute.get_by(type_id=ci.type_id, to_dict=False)
attr_names = set([AttributeCache.get(attr.attr_id).name for attr in attrs])
@@ -498,7 +532,8 @@ class CIManager(object):
db.session.commit()
AttributeHistoryManger.add(None, ci_id, [(None, OperateType.DELETE, ci_dict, None)], ci.type_id)
if ci_dict:
AttributeHistoryManger.add(None, ci_id, [(None, OperateType.DELETE, ci_dict, None)], ci.type_id)
ci_delete.apply_async(args=(ci_id,), queue=CMDB_QUEUE)
@@ -600,7 +635,7 @@ class CIManager(object):
_fields = list()
for field in fields:
attr = AttributeCache.get(field)
if attr is not None:
if attr is not None and not attr.is_password:
_fields.append(str(attr.id))
filter_fields_sql = "WHERE A.attr_id in ({0})".format(",".join(_fields))
@@ -618,7 +653,7 @@ class CIManager(object):
ci_dict = dict()
unique_id2obj = dict()
excludes = excludes and set(excludes)
for ci_id, type_id, attr_id, attr_name, attr_alias, value, value_type, is_list in cis:
for ci_id, type_id, attr_id, attr_name, attr_alias, value, value_type, is_list, is_password in cis:
if not fields and excludes and (attr_name in excludes or attr_alias in excludes):
continue
@@ -645,11 +680,14 @@ class CIManager(object):
else:
return abort(400, ErrFormat.argument_invalid.format("ret_key"))
value = ValueTypeMap.serialize2[value_type](value)
if is_list:
ci_dict.setdefault(attr_key, []).append(value)
if is_password and value:
ci_dict[attr_key] = PASSWORD_DEFAULT_SHOW
else:
ci_dict[attr_key] = value
value = ValueTypeMap.serialize2[value_type](value)
if is_list:
ci_dict.setdefault(attr_key, []).append(value)
else:
ci_dict[attr_key] = value
return res
@@ -681,6 +719,84 @@ class CIManager(object):
return cls._get_cis_from_db(ci_ids, ret_key, fields, value_tables, excludes=excludes)
@classmethod
def save_password(cls, ci_id, attr_id, value, record_id, type_id):
changed = None
encrypt_value = None
value_table = ValueTypeMap.table[ValueTypeEnum.PASSWORD]
if current_app.config.get('SECRETS_ENGINE') == 'inner':
if value:
encrypt_value, status = InnerCrypt().encrypt(value)
if not status:
current_app.logger.error('save password failed: {}'.format(encrypt_value))
return abort(400, ErrFormat.password_save_failed.format(encrypt_value))
else:
encrypt_value = PASSWORD_DEFAULT_SHOW
existed = value_table.get_by(ci_id=ci_id, attr_id=attr_id, first=True, to_dict=False)
if existed is None:
if value:
value_table.create(ci_id=ci_id, attr_id=attr_id, value=encrypt_value)
changed = [(ci_id, attr_id, OperateType.ADD, '', PASSWORD_DEFAULT_SHOW, type_id)]
elif existed.value != encrypt_value:
if value:
existed.update(ci_id=ci_id, attr_id=attr_id, value=encrypt_value)
changed = [(ci_id, attr_id, OperateType.UPDATE, PASSWORD_DEFAULT_SHOW, PASSWORD_DEFAULT_SHOW, type_id)]
else:
existed.delete()
changed = [(ci_id, attr_id, OperateType.DELETE, PASSWORD_DEFAULT_SHOW, '', type_id)]
if current_app.config.get('SECRETS_ENGINE') == 'vault':
vault = VaultClient(current_app.config.get('VAULT_URL'), current_app.config.get('VAULT_TOKEN'))
if value:
try:
vault.update("/{}/{}".format(ci_id, attr_id), dict(v=value))
except Exception as e:
current_app.logger.error('save password to vault failed: {}'.format(e))
return abort(400, ErrFormat.password_save_failed.format('write vault failed'))
else:
try:
vault.delete("/{}/{}".format(ci_id, attr_id))
except Exception as e:
current_app.logger.warning('delete password to vault failed: {}'.format(e))
if changed is not None:
return AttributeValueManager.write_change2(changed, record_id)
@classmethod
def load_password(cls, ci_id, attr_id):
ci = CI.get_by_id(ci_id) or abort(404, ErrFormat.ci_not_found.format(ci_id))
limit_attrs = cls._valid_ci_for_no_read(ci, ci.ci_type)
if limit_attrs:
attr = AttributeCache.get(attr_id)
if attr and attr.name not in limit_attrs:
return abort(403, ErrFormat.no_permission2)
if current_app.config.get('SECRETS_ENGINE', 'inner') == 'inner':
value_table = ValueTypeMap.table[ValueTypeEnum.PASSWORD]
v = value_table.get_by(ci_id=ci_id, attr_id=attr_id, first=True, to_dict=False)
v = v and v.value
if not v:
return
decrypt_value, status = InnerCrypt().decrypt(v)
if not status:
current_app.logger.error('load password failed: {}'.format(decrypt_value))
return abort(400, ErrFormat.password_load_failed.format(decrypt_value))
return decrypt_value
elif current_app.config.get('SECRETS_ENGINE') == 'vault':
vault = VaultClient(current_app.config.get('VAULT_URL'), current_app.config.get('VAULT_TOKEN'))
data, status = vault.read("/{}/{}".format(ci_id, attr_id))
if not status:
current_app.logger.error('read password from vault failed: {}'.format(data))
return abort(400, ErrFormat.password_load_failed.format(data))
return data.get('v')
class CIRelationManager(object):
"""

View File

@@ -12,6 +12,8 @@ class ValueTypeEnum(BaseEnum):
DATE = "4"
TIME = "5"
JSON = "6"
PASSWORD = TEXT
LINK = TEXT
class ConstraintEnum(BaseEnum):

View File

@@ -95,3 +95,6 @@ class ErrFormat(CommonErrFormat):
ci_filter_perm_cannot_or_query = "CI过滤授权 暂时不支持 或 查询"
ci_filter_perm_attr_no_permission = "您没有属性 {} 的操作权限!"
ci_filter_perm_ci_no_permission = "您没有该CI的操作权限!"
password_save_failed = "保存密码失败: {}"
password_load_failed = "获取密码失败: {}"

View File

@@ -7,6 +7,7 @@ QUERY_CIS_BY_VALUE_TABLE = """
attr.alias AS attr_alias,
attr.value_type,
attr.is_list,
attr.is_password,
c_cis.type_id,
{0}.ci_id,
{0}.attr_id,
@@ -26,7 +27,8 @@ QUERY_CIS_BY_IDS = """
A.attr_alias,
A.value,
A.value_type,
A.is_list
A.is_list,
A.is_password
FROM
({1}) AS A {0}
ORDER BY A.ci_id;
@@ -43,7 +45,7 @@ FACET_QUERY1 = """
FACET_QUERY = """
SELECT {0}.value,
count({0}.ci_id)
count(distinct {0}.ci_id)
FROM {0}
INNER JOIN ({1}) AS F ON F.ci_id={0}.ci_id
WHERE {0}.attr_id={2:d}

View File

@@ -28,6 +28,7 @@ from api.lib.cmdb.search.ci.db.query_sql import QUERY_CI_BY_NO_ATTR
from api.lib.cmdb.search.ci.db.query_sql import QUERY_CI_BY_TYPE
from api.lib.cmdb.search.ci.db.query_sql import QUERY_UNION_CI_ATTRIBUTE_IS_NULL
from api.lib.cmdb.utils import TableMap
from api.lib.cmdb.utils import ValueTypeMap
from api.lib.perm.acl.acl import ACLManager
from api.lib.perm.acl.acl import is_app_admin
from api.lib.utils import handle_arg_list
@@ -524,15 +525,15 @@ class Search(object):
if k:
table_name = TableMap(attr=attr).table_name
query_sql = FACET_QUERY.format(table_name, self.query_sql, attr.id)
# current_app.logger.warning(query_sql)
result = db.session.execute(query_sql).fetchall()
facet[k] = result
facet_result = dict()
for k, v in facet.items():
if not k.startswith('_'):
a = getattr(AttributeCache.get(k), self.ret_key)
facet_result[a] = [(f[0], f[1], a) for f in v]
attr = AttributeCache.get(k)
a = getattr(attr, self.ret_key)
facet_result[a] = [(ValueTypeMap.serialize[attr.value_type](f[0]), f[1], a) for f in v]
return facet_result

View File

@@ -35,7 +35,7 @@ class Search(object):
self.sort = sort or ("ci_id" if current_app.config.get("USE_ES") else None)
self.root_id = root_id
self.level = level
self.level = level or 0
self.reverse = reverse
def _get_ids(self):
@@ -104,16 +104,22 @@ class Search(object):
ci_ids=merge_ids).search()
def statistics(self, type_ids):
self.level = int(self.level)
_tmp = []
ids = [self.root_id] if not isinstance(self.root_id, list) else self.root_id
for l in range(0, int(self.level)):
if not l:
_tmp = list(map(lambda x: list(json.loads(x).items()),
[i or '{}' for i in rd.get(ids, REDIS_PREFIX_CI_RELATION) or []]))
for lv in range(0, self.level):
if not lv:
if type_ids and lv == self.level - 1:
_tmp = list(map(lambda x: [i for i in x if i[1] in type_ids],
(map(lambda x: list(json.loads(x).items()),
[i or '{}' for i in rd.get(ids, REDIS_PREFIX_CI_RELATION) or []]))))
else:
_tmp = list(map(lambda x: list(json.loads(x).items()),
[i or '{}' for i in rd.get(ids, REDIS_PREFIX_CI_RELATION) or []]))
else:
for idx, item in enumerate(_tmp):
if item:
if type_ids and l == self.level - 1:
if type_ids and lv == self.level - 1:
__tmp = list(
map(lambda x: [(_id, type_id) for _id, type_id in json.loads(x).items()
if type_id in type_ids],

View File

@@ -12,7 +12,7 @@ import api.models.cmdb as model
from api.lib.cmdb.cache import AttributeCache
from api.lib.cmdb.const import ValueTypeEnum
TIME_RE = re.compile(r"^(20|21|22|23|[0-1]\d):[0-5]\d:[0-5]\d$")
TIME_RE = re.compile(r"^20|21|22|23|[0-1]\d:[0-5]\d:[0-5]\d$")
def string2int(x):
@@ -21,7 +21,7 @@ def string2int(x):
def str2datetime(x):
try:
return datetime.datetime.strptime(x, "%Y-%m-%d")
return datetime.datetime.strptime(x, "%Y-%m-%d").date()
except ValueError:
pass
@@ -44,8 +44,8 @@ class ValueTypeMap(object):
ValueTypeEnum.FLOAT: float,
ValueTypeEnum.TEXT: lambda x: x if isinstance(x, six.string_types) else str(x),
ValueTypeEnum.TIME: lambda x: x if isinstance(x, six.string_types) else str(x),
ValueTypeEnum.DATE: lambda x: x.strftime("%Y-%m-%d"),
ValueTypeEnum.DATETIME: lambda x: x.strftime("%Y-%m-%d %H:%M:%S"),
ValueTypeEnum.DATE: lambda x: x.strftime("%Y-%m-%d") if not isinstance(x, six.string_types) else x,
ValueTypeEnum.DATETIME: lambda x: x.strftime("%Y-%m-%d %H:%M:%S") if not isinstance(x, six.string_types) else x,
ValueTypeEnum.JSON: lambda x: json.loads(x) if isinstance(x, six.string_types) and x else x,
}
@@ -64,6 +64,8 @@ class ValueTypeMap(object):
ValueTypeEnum.FLOAT: model.FloatChoice,
ValueTypeEnum.TEXT: model.TextChoice,
ValueTypeEnum.TIME: model.TextChoice,
ValueTypeEnum.DATE: model.TextChoice,
ValueTypeEnum.DATETIME: model.TextChoice,
}
table = {
@@ -97,7 +99,7 @@ class ValueTypeMap(object):
ValueTypeEnum.DATE: 'text',
ValueTypeEnum.TIME: 'text',
ValueTypeEnum.FLOAT: 'float',
ValueTypeEnum.JSON: 'object'
ValueTypeEnum.JSON: 'object',
}
@@ -110,7 +112,9 @@ class TableMap(object):
@property
def table(self):
attr = AttributeCache.get(self.attr_name) if not self.attr else self.attr
if attr.value_type != ValueTypeEnum.TEXT and attr.value_type != ValueTypeEnum.JSON:
if attr.is_password or attr.is_link:
self.is_index = False
elif attr.value_type not in {ValueTypeEnum.TEXT, ValueTypeEnum.JSON}:
self.is_index = True
elif self.is_index is None:
self.is_index = attr.is_index
@@ -122,7 +126,9 @@ class TableMap(object):
@property
def table_name(self):
attr = AttributeCache.get(self.attr_name) if not self.attr else self.attr
if attr.value_type != ValueTypeEnum.TEXT and attr.value_type != ValueTypeEnum.JSON:
if attr.is_password or attr.is_link:
self.is_index = False
elif attr.value_type not in {ValueTypeEnum.TEXT, ValueTypeEnum.JSON}:
self.is_index = True
elif self.is_index is None:
self.is_index = attr.is_index

View File

@@ -66,9 +66,10 @@ class AttributeValueManager(object):
use_master=use_master,
to_dict=False)
field_name = getattr(attr, ret_key)
if attr.is_list:
res[field_name] = [ValueTypeMap.serialize[attr.value_type](i.value) for i in rs]
elif attr.is_password and rs:
res[field_name] = '******' if rs[0].value else ''
else:
res[field_name] = ValueTypeMap.serialize[attr.value_type](rs[0].value) if rs else None
@@ -131,8 +132,7 @@ class AttributeValueManager(object):
return AttributeHistoryManger.add(record_id, ci_id, [(attr_id, operate_type, old, new)], type_id)
@staticmethod
def _write_change2(changed):
record_id = None
def write_change2(changed, record_id=None):
for ci_id, attr_id, operate_type, old, new, type_id in changed:
record_id = AttributeHistoryManger.add(record_id, ci_id, [(attr_id, operate_type, old, new)], type_id,
commit=False, flush=False)
@@ -284,9 +284,9 @@ class AttributeValueManager(object):
except Exception as e:
db.session.rollback()
current_app.logger.warning(str(e))
return abort(400, ErrFormat.attribute_value_unknown_error.format(str(e)))
return abort(400, ErrFormat.attribute_value_unknown_error.format(e.args[0]))
return self._write_change2(changed)
return self.write_change2(changed)
@staticmethod
def delete_attr_value(attr_id, ci_id):

View File

@@ -1,12 +1,13 @@
# -*- coding:utf-8 -*-
from flask import abort
from flask import current_app
from api.lib.common_setting.resp_format import ErrFormat
from api.lib.perm.acl.app import AppCRUD
from api.lib.perm.acl.cache import RoleCache, AppCache
from api.lib.perm.acl.permission import PermissionCRUD
from api.lib.perm.acl.resource import ResourceTypeCRUD, ResourceCRUD
from api.lib.perm.acl.role import RoleCRUD, RoleRelationCRUD
from api.lib.perm.acl.user import UserCRUD
from api.lib.perm.acl.resource import ResourceTypeCRUD, ResourceCRUD
class ACLManager(object):
@@ -109,8 +110,32 @@ class ACLManager(object):
id2perms=id2perms
)
def create_resources_type(self, payload):
payload['app_id'] = self.validate_app().id
rt = ResourceTypeCRUD.add(**payload)
return rt.to_dict()
def update_resources_type(self, _id, payload):
rt = ResourceTypeCRUD.update(_id, **payload)
return rt.to_dict()
def create_resource(self, payload):
payload['app_id'] = self.validate_app().id
resource = ResourceCRUD.add(**payload)
return resource.to_dict()
def get_resource_by_type(self, q, u, rt_id, page=1, page_size=999999):
numfound, res = ResourceCRUD.search(q, u, self.validate_app().id, rt_id, page, page_size)
return res
def grant_resource(self, rid, resource_id, perms):
PermissionCRUD.grant(rid, perms, resource_id=resource_id, group_id=None)
@staticmethod
def create_app(payload):
rt = AppCRUD.add(**payload)
return rt.to_dict()

View File

@@ -121,6 +121,19 @@ class EmployeeCRUD(object):
employee = CreateEmployee().create_single(**data)
return employee.to_dict()
@staticmethod
def add_employee_from_acl_created(**kwargs):
try:
kwargs['acl_uid'] = kwargs.pop('uid')
kwargs['acl_rid'] = kwargs.pop('rid')
kwargs['department_id'] = 0
Employee.create(
**kwargs
)
except Exception as e:
abort(400, str(e))
@staticmethod
def add(**kwargs):
try:

View File

@@ -4,8 +4,14 @@
from functools import wraps
from flask import abort
from flask import current_app
from flask import request
from sqlalchemy.exc import InvalidRequestError
from sqlalchemy.exc import OperationalError
from sqlalchemy.exc import PendingRollbackError
from sqlalchemy.exc import StatementError
from api.extensions import db
from api.lib.resp_format import CommonErrFormat
@@ -70,3 +76,43 @@ def args_validate(model_cls, exclude_args=None):
return wrapper
return decorate
def reconnect_db(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except (StatementError, OperationalError, InvalidRequestError) as e:
error_msg = str(e)
if 'Lost connection' in error_msg or 'reconnect until invalid transaction' in error_msg or \
'can be emitted within this transaction' in error_msg:
current_app.logger.info('[reconnect_db] lost connect rollback then retry')
db.session.rollback()
return func(*args, **kwargs)
else:
raise e
except Exception as e:
raise e
return wrapper
def _flush_db():
try:
db.session.commit()
except (StatementError, OperationalError, InvalidRequestError, PendingRollbackError):
db.session.rollback()
def flush_db(func):
@wraps(func)
def wrapper(*args, **kwargs):
_flush_db()
return func(*args, **kwargs)
return wrapper
def run_flush_db():
_flush_db()

View File

@@ -4,7 +4,7 @@
import msgpack
from api.extensions import cache
from api.extensions import db
from api.lib.decorator import flush_db
from api.lib.utils import Lock
from api.models.acl import App
from api.models.acl import Permission
@@ -221,9 +221,9 @@ class RoleRelationCache(object):
return msgpack.loads(r_g, raw=False)
@classmethod
@flush_db
def rebuild(cls, rid, app_id):
cls.clean(rid, app_id)
db.session.remove()
cls.get_parent_ids(rid, app_id)
cls.get_child_ids(rid, app_id)
@@ -235,9 +235,9 @@ class RoleRelationCache(object):
cls.get_resources2(rid, app_id)
@classmethod
@flush_db
def rebuild2(cls, rid, app_id):
cache.delete(cls.PREFIX_RESOURCES2.format(rid, app_id))
db.session.remove()
cls.get_resources2(rid, app_id)
@classmethod

View File

@@ -260,7 +260,8 @@ class ResourceCRUD(object):
numfound = query.count()
res = [i.to_dict() for i in query.offset((page - 1) * page_size).limit(page_size)]
for i in res:
i['user'] = UserCache.get(i['uid']).nickname if i['uid'] else ''
user = UserCache.get(i['uid']) if i['uid'] else ''
i['user'] = user and user.nickname
return numfound, res

View File

@@ -58,10 +58,14 @@ class UserCRUD(object):
kwargs['employee_id'] = '{0:04d}'.format(biggest_employee_id + 1)
user = User.create(**kwargs)
RoleCRUD.add_role(user.username, uid=user.uid)
role = RoleCRUD.add_role(user.username, uid=user.uid)
AuditCRUD.add_role_log(None, AuditOperateType.create,
AuditScope.user, user.uid, {}, user.to_dict(), {}, {}
)
from api.lib.common_setting.employee import EmployeeCRUD
payload = {column: getattr(user, column) for column in ['uid', 'username', 'nickname', 'email', 'block']}
payload['rid'] = role.id
EmployeeCRUD.add_employee_from_acl_created(**payload)
return user

View File

@@ -0,0 +1 @@
# -*- coding:utf-8 -*-

View File

@@ -0,0 +1,429 @@
import os
import secrets
import sys
from base64 import b64decode, b64encode
from Cryptodome.Protocol.SecretSharing import Shamir
from colorama import Back
from colorama import Fore
from colorama import Style
from colorama import init as colorama_init
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers import algorithms
from cryptography.hazmat.primitives.ciphers import modes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from flask import current_app
global_iv_length = 16
global_key_shares = 5 # Number of generated key shares
global_key_threshold = 3 # Minimum number of shares required to rebuild the key
backend_root_key_name = "root_key"
backend_encrypt_key_name = "encrypt_key"
backend_root_key_salt_name = "root_key_salt"
backend_encrypt_key_salt_name = "encrypt_key_salt"
backend_seal_key = "seal_status"
success = "success"
seal_status = True
def string_to_bytes(value):
if isinstance(value, bytes):
return value
if sys.version_info.major == 2:
byte_string = value
else:
byte_string = value.encode("utf-8")
return byte_string
class Backend:
def __init__(self, backend=None):
self.backend = backend
def get(self, key):
return self.backend.get(key)
def add(self, key, value):
return self.backend.add(key, value)
def update(self, key, value):
return self.backend.update(key, value)
class KeyManage:
def __init__(self, trigger=None, backend=None):
self.trigger = trigger
self.backend = backend
if backend:
self.backend = Backend(backend)
def init_app(self, app, backend=None):
if sys.argv[0].endswith("gunicorn") or (len(sys.argv) > 1 and sys.argv[1] == "run"):
self.trigger = app.config.get("INNER_TRIGGER_TOKEN")
if not self.trigger:
return
self.backend = backend
resp = self.auto_unseal()
self.print_response(resp)
def hash_root_key(self, value):
algorithm = hashes.SHA256()
salt = self.backend.get(backend_root_key_salt_name)
if not salt:
salt = secrets.token_hex(16)
msg, ok = self.backend.add(backend_root_key_salt_name, salt)
if not ok:
return msg, ok
kdf = PBKDF2HMAC(
algorithm=algorithm,
length=32,
salt=string_to_bytes(salt),
iterations=100000,
)
key = kdf.derive(string_to_bytes(value))
return b64encode(key).decode('utf-8'), True
def generate_encrypt_key(self, key):
algorithm = hashes.SHA256()
salt = self.backend.get(backend_encrypt_key_salt_name)
if not salt:
salt = secrets.token_hex(32)
kdf = PBKDF2HMAC(
algorithm=algorithm,
length=32,
salt=string_to_bytes(salt),
iterations=100000,
backend=default_backend()
)
key = kdf.derive(string_to_bytes(key))
msg, ok = self.backend.add(backend_encrypt_key_salt_name, salt)
if ok:
return b64encode(key).decode('utf-8'), ok
else:
return msg, ok
@classmethod
def generate_keys(cls, secret):
shares = Shamir.split(global_key_threshold, global_key_shares, secret, False)
new_shares = []
for share in shares:
t = [i for i in share[1]] + [ord(i) for i in "{:0>2}".format(share[0])]
new_shares.append(b64encode(bytes(t)))
return new_shares
def is_valid_root_key(self, root_key):
root_key_hash, ok = self.hash_root_key(root_key)
if not ok:
return root_key_hash, ok
backend_root_key_hash = self.backend.get(backend_root_key_name)
if not backend_root_key_hash:
return "should init firstly", False
elif backend_root_key_hash != root_key_hash:
return "invalid root key", False
else:
return "", True
def auth_root_secret(self, root_key):
msg, ok = self.is_valid_root_key(root_key)
if not ok:
return {
"message": msg,
"status": "failed"
}
encrypt_key_aes = self.backend.get(backend_encrypt_key_name)
if not encrypt_key_aes:
return {
"message": "encrypt key is empty",
"status": "failed"
}
secrets_encrypt_key, ok = InnerCrypt.aes_decrypt(string_to_bytes(root_key), encrypt_key_aes)
if ok:
msg, ok = self.backend.update(backend_seal_key, "open")
if ok:
current_app.config["secrets_encrypt_key"] = secrets_encrypt_key
current_app.config["secrets_root_key"] = root_key
current_app.config["secrets_shares"] = []
return {"message": success, "status": success}
return {"message": msg, "status": "failed"}
else:
return {
"message": secrets_encrypt_key,
"status": "failed"
}
def unseal(self, key):
if not self.is_seal():
return {
"message": "current status is unseal, skip",
"status": "skip"
}
try:
t = [i for i in b64decode(key)]
v = (int("".join([chr(i) for i in t[-2:]])), bytes(t[:-2]))
shares = current_app.config.get("secrets_shares", [])
if v not in shares:
shares.append(v)
current_app.config["secrets_shares"] = shares
if len(shares) >= global_key_threshold:
recovered_secret = Shamir.combine(shares[:global_key_threshold], False)
return self.auth_root_secret(b64encode(recovered_secret))
else:
return {
"message": "waiting for inputting other unseal key {0}/{1}".format(len(shares),
global_key_threshold),
"status": "waiting"
}
except Exception as e:
return {
"message": "invalid token: " + str(e),
"status": "failed"
}
def generate_unseal_keys(self):
info = self.backend.get(backend_root_key_name)
if info:
return "already exist", [], False
secret = AESGCM.generate_key(128)
shares = self.generate_keys(secret)
return b64encode(secret), shares, True
def init(self):
"""
init the master key, unseal key and store in backend
:return:
"""
root_key = self.backend.get(backend_root_key_name)
if root_key:
return {"message": "already init, skip", "status": "skip"}, False
else:
root_key, shares, status = self.generate_unseal_keys()
if not status:
return {"message": root_key, "status": "failed"}, False
# hash root key and store in backend
root_key_hash, ok = self.hash_root_key(root_key)
if not ok:
return {"message": root_key_hash, "status": "failed"}, False
msg, ok = self.backend.add(backend_root_key_name, root_key_hash)
if not ok:
return {"message": msg, "status": "failed"}, False
# generate encrypt key from root_key and store in backend
encrypt_key, ok = self.generate_encrypt_key(root_key)
if not ok:
return {"message": encrypt_key, "status": "failed"}
encrypt_key_aes, status = InnerCrypt.aes_encrypt(root_key, encrypt_key)
if not status:
return {"message": encrypt_key_aes, "status": "failed"}
msg, ok = self.backend.add(backend_encrypt_key_name, encrypt_key_aes)
if not ok:
return {"message": msg, "status": "failed"}, False
msg, ok = self.backend.add(backend_seal_key, "open")
if not ok:
return {"message": msg, "status": "failed"}, False
current_app.config["secrets_root_key"] = root_key
current_app.config["secrets_encrypt_key"] = encrypt_key
self.print_token(shares, root_token=root_key)
return {"message": "OK",
"details": {
"root_token": root_key,
"seal_tokens": shares,
}}, True
def auto_unseal(self):
if not self.trigger:
return {
"message": "trigger config is empty, skip",
"status": "skip"
}
if self.trigger.startswith("http"):
return {
"message": "todo in next step, skip",
"status": "skip"
}
# TODO
elif len(self.trigger.strip()) == 24:
res = self.auth_root_secret(self.trigger.encode())
if res.get("status") == success:
return {
"message": success,
"status": success
}
else:
return {
"message": res.get("message"),
"status": "failed"
}
else:
return {
"message": "trigger config is invalid, skip",
"status": "skip"
}
def seal(self, root_key):
root_key = root_key.encode()
msg, ok = self.is_valid_root_key(root_key)
if not ok:
return {
"message": msg,
"status": "failed"
}
else:
msg, ok = self.backend.update(backend_seal_key, "block")
if not ok:
return {
"message": msg,
"status": "failed",
}
current_app.config["secrets_root_key"] = ''
current_app.config["secrets_encrypt_key"] = ''
return {
"message": success,
"status": success
}
def is_seal(self):
"""
If there is no initialization or the root key is inconsistent, it is considered to be in a sealed state.
:return:
"""
secrets_root_key = current_app.config.get("secrets_root_key")
msg, ok = self.is_valid_root_key(secrets_root_key)
if not ok:
return {"message": msg, "status": "failed"}
status = self.backend.get(backend_seal_key)
return status == "block"
@classmethod
def print_token(cls, shares, root_token):
"""
data: {"message": "OK",
"details": {
"root_token": root_key,
"seal_tokens": shares,
}}
"""
colorama_init()
print(Style.BRIGHT, "Please be sure to store the Unseal Key in a secure location and avoid losing it."
" The Unseal Key is required to unseal the system every time when it restarts."
" Successful unsealing is necessary to enable the password feature." + Style.RESET_ALL)
for i, v in enumerate(shares):
print(
"unseal token " + str(i + 1) + ": " + Fore.RED + Back.BLACK + v.decode("utf-8") + Style.RESET_ALL)
print()
print(Fore.GREEN + "root token: " + root_token.decode("utf-8") + Style.RESET_ALL)
@classmethod
def print_response(cls, data):
status = data.get("status", "")
message = data.get("message", "")
status_colors = {
"skip": Style.BRIGHT,
"failed": Fore.RED,
"waiting": Fore.YELLOW,
}
print(status_colors.get(status, Fore.GREEN), message, Style.RESET_ALL)
class InnerCrypt:
def __init__(self):
secrets_encrypt_key = current_app.config.get("secrets_encrypt_key", "")
self.encrypt_key = b64decode(secrets_encrypt_key.encode("utf-8"))
def encrypt(self, plaintext):
"""
encrypt method contain aes currently
"""
return self.aes_encrypt(self.encrypt_key, plaintext)
def decrypt(self, ciphertext):
"""
decrypt method contain aes currently
"""
return self.aes_decrypt(self.encrypt_key, ciphertext)
@classmethod
def aes_encrypt(cls, key, plaintext):
if isinstance(plaintext, str):
plaintext = string_to_bytes(plaintext)
iv = os.urandom(global_iv_length)
try:
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
encryptor = cipher.encryptor()
v_padder = padding.PKCS7(algorithms.AES.block_size).padder()
padded_plaintext = v_padder.update(plaintext) + v_padder.finalize()
ciphertext = encryptor.update(padded_plaintext) + encryptor.finalize()
return b64encode(iv + ciphertext).decode("utf-8"), True
except Exception as e:
return str(e), False
@classmethod
def aes_decrypt(cls, key, ciphertext):
try:
s = b64decode(ciphertext.encode("utf-8"))
iv = s[:global_iv_length]
ciphertext = s[global_iv_length:]
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
decrypter = cipher.decryptor()
decrypted_padded_plaintext = decrypter.update(ciphertext) + decrypter.finalize()
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
plaintext = unpadder.update(decrypted_padded_plaintext) + unpadder.finalize()
return plaintext.decode('utf-8'), True
except Exception as e:
return str(e), False
if __name__ == "__main__":
km = KeyManage()
# info, shares, status = km.generate_unseal_keys()
# print(info, shares, status)
# print("..................")
# for i in shares:
# print(b64encode(i[1]).decode())
res1, ok1 = km.init()
if not ok1:
print(res1)
# for j in res["details"]["seal_tokens"]:
# r = km.unseal(j)
# if r["status"] != "waiting":
# if r["status"] != "success":
# print("r........", r)
# else:
# print(r)
# break
t_plaintext = b"Hello, World!" # The plaintext to encrypt
c = InnerCrypt()
t_ciphertext, status1 = c.encrypt(t_plaintext)
print("Ciphertext:", t_ciphertext)
decrypted_plaintext, status2 = c.decrypt(t_ciphertext)
print("Decrypted plaintext:", decrypted_plaintext)

View File

@@ -0,0 +1,35 @@
from api.models.cmdb import InnerKV
class InnerKVManger(object):
def __init__(self):
pass
@classmethod
def add(cls, key, value):
data = {"key": key, "value": value}
res = InnerKV.create(**data)
if res.key == key:
return "success", True
return "add failed", False
@classmethod
def get(cls, key):
res = InnerKV.get_by(first=True, to_dict=False, key=key)
if not res:
return None
return res.value
@classmethod
def update(cls, key, value):
res = InnerKV.get_by(first=True, to_dict=False, key=key)
if not res:
return cls.add(key, value)
t = res.update(value=value)
if t.key == key:
return "success", True
return "update failed", True

View File

@@ -0,0 +1,141 @@
from base64 import b64decode
from base64 import b64encode
import hvac
class VaultClient:
def __init__(self, base_url, token, mount_path='cmdb'):
self.client = hvac.Client(url=base_url, token=token)
self.mount_path = mount_path
def create_app_role(self, role_name, policies):
resp = self.client.create_approle(role_name, policies=policies)
return resp == 200
def delete_app_role(self, role_name):
resp = self.client.delete_approle(role_name)
return resp == 204
def update_app_role_policies(self, role_name, policies):
resp = self.client.update_approle_role(role_name, policies=policies)
return resp == 204
def get_app_role(self, role_name):
resp = self.client.get_approle(role_name)
resp.json()
if resp.status_code == 200:
return resp.json
else:
return {}
def enable_secrets_engine(self):
resp = self.client.sys.enable_secrets_engine('kv', path=self.mount_path)
resp_01 = self.client.sys.enable_secrets_engine('transit')
if resp.status_code == 200 and resp_01.status_code == 200:
return resp.json
else:
return {}
def encrypt(self, plaintext):
response = self.client.secrets.transit.encrypt_data(name='transit-key', plaintext=plaintext)
ciphertext = response['data']['ciphertext']
return ciphertext
# decrypt data
def decrypt(self, ciphertext):
response = self.client.secrets.transit.decrypt_data(name='transit-key', ciphertext=ciphertext)
plaintext = response['data']['plaintext']
return plaintext
def write(self, path, data, encrypt=None):
if encrypt:
for k, v in data.items():
data[k] = self.encrypt(self.encode_base64(v))
response = self.client.secrets.kv.v2.create_or_update_secret(
path=path,
secret=data,
mount_point=self.mount_path
)
return response
# read data
def read(self, path, decrypt=True):
try:
response = self.client.secrets.kv.v2.read_secret_version(
path=path, raise_on_deleted_version=False, mount_point=self.mount_path
)
except Exception as e:
return str(e), False
data = response['data']['data']
if decrypt:
try:
for k, v in data.items():
data[k] = self.decode_base64(self.decrypt(v))
except:
return data, True
return data, True
# update data
def update(self, path, data, overwrite=True, encrypt=True):
if encrypt:
for k, v in data.items():
data[k] = self.encrypt(self.encode_base64(v))
if overwrite:
response = self.client.secrets.kv.v2.create_or_update_secret(
path=path,
secret=data,
mount_point=self.mount_path
)
else:
response = self.client.secrets.kv.v2.patch(path=path, secret=data, mount_point=self.mount_path)
return response
# delete data
def delete(self, path):
response = self.client.secrets.kv.v2.delete_metadata_and_all_versions(
path=path,
mount_point=self.mount_path
)
return response
# Base64 encode
@classmethod
def encode_base64(cls, data):
encoded_bytes = b64encode(data.encode())
encoded_string = encoded_bytes.decode()
return encoded_string
# Base64 decode
@classmethod
def decode_base64(cls, encoded_string):
decoded_bytes = b64decode(encoded_string)
decoded_string = decoded_bytes.decode()
return decoded_string
if __name__ == "__main__":
_base_url = "http://localhost:8200"
_token = "your token"
_path = "test001"
# Example
sdk = VaultClient(_base_url, _token)
# sdk.enable_secrets_engine()
_data = {"key1": "value1", "key2": "value2", "key3": "value3"}
_data = sdk.update(_path, _data, overwrite=True, encrypt=True)
print(_data)
_data = sdk.read(_path, decrypt=True)
print(_data)

View File

@@ -5,7 +5,8 @@ import copy
import hashlib
from datetime import datetime
import ldap
from ldap3 import Server, Connection, ALL
from ldap3.core.exceptions import LDAPBindError, LDAPCertificateError
from flask import current_app
from flask_sqlalchemy import BaseQuery
@@ -57,9 +58,7 @@ class UserQuery(BaseQuery):
return user, authenticated
def authenticate_with_ldap(self, username, password):
ldap_conn = ldap.initialize(current_app.config.get('LDAP_SERVER'))
ldap_conn.protocol_version = 3
ldap_conn.set_option(ldap.OPT_REFERRALS, 0)
server = Server(current_app.config.get('LDAP_SERVER'), get_info=ALL)
if '@' in username:
email = username
who = current_app.config.get('LDAP_USER_DN').format(username.split('@')[0])
@@ -70,11 +69,14 @@ class UserQuery(BaseQuery):
username = username.split('@')[0]
user = self.get_by_username(username)
try:
if not password:
raise ldap.INVALID_CREDENTIALS
raise LDAPCertificateError
ldap_conn.simple_bind_s(who, password)
conn = Connection(server, user=who, password=password)
conn.bind()
if conn.result['result'] != 0:
raise LDAPBindError
conn.unbind()
if not user:
from api.lib.perm.acl.user import UserCRUD
@@ -84,7 +86,7 @@ class UserQuery(BaseQuery):
op_record.apply_async(args=(None, username, OperateType.LOGIN, ["ACL"]), queue=ACL_QUEUE)
return user, True
except ldap.INVALID_CREDENTIALS:
except LDAPBindError:
return user, False
def search(self, key):

View File

@@ -504,3 +504,10 @@ class CIFilterPerms(Model):
attr_filter = db.Column(db.Text)
rid = db.Column(db.Integer, index=True)
class InnerKV(Model):
__tablename__ = "c_kv"
key = db.Column(db.String(128), index=True)
value = db.Column(db.Text)

View File

@@ -46,5 +46,4 @@ def register_resources(resource_path, rest_api):
resource_cls.url_prefix = ("",)
if isinstance(resource_cls.url_prefix, six.string_types):
resource_cls.url_prefix = (resource_cls.url_prefix,)
rest_api.add_resource(resource_cls, *resource_cls.url_prefix)

View File

@@ -9,7 +9,8 @@ from werkzeug.exceptions import BadRequest
from werkzeug.exceptions import NotFound
from api.extensions import celery
from api.extensions import db
from api.lib.decorator import flush_db
from api.lib.decorator import reconnect_db
from api.lib.perm.acl.audit import AuditCRUD
from api.lib.perm.acl.audit import AuditOperateSource
from api.lib.perm.acl.audit import AuditOperateType
@@ -28,6 +29,7 @@ from api.models.acl import Trigger
name="acl.role_rebuild",
queue=ACL_QUEUE,
once={"graceful": True, "unlock_before_run": True})
@reconnect_db
def role_rebuild(rids, app_id):
rids = rids if isinstance(rids, list) else [rids]
for rid in rids:
@@ -37,6 +39,7 @@ def role_rebuild(rids, app_id):
@celery.task(name="acl.update_resource_to_build_role", queue=ACL_QUEUE)
@reconnect_db
def update_resource_to_build_role(resource_id, app_id, group_id=None):
rids = [i.id for i in Role.get_by(__func_isnot__key_uid=None, fl='id', to_dict=False)]
rids += [i.id for i in Role.get_by(app_id=app_id, fl='id', to_dict=False)]
@@ -52,9 +55,9 @@ def update_resource_to_build_role(resource_id, app_id, group_id=None):
@celery.task(name="acl.apply_trigger", queue=ACL_QUEUE)
@flush_db
@reconnect_db
def apply_trigger(_id, resource_id=None, operator_uid=None):
db.session.remove()
from api.lib.perm.acl.permission import PermissionCRUD
trigger = Trigger.get_by_id(_id)
@@ -118,9 +121,9 @@ def apply_trigger(_id, resource_id=None, operator_uid=None):
@celery.task(name="acl.cancel_trigger", queue=ACL_QUEUE)
@flush_db
@reconnect_db
def cancel_trigger(_id, resource_id=None, operator_uid=None):
db.session.remove()
from api.lib.perm.acl.permission import PermissionCRUD
trigger = Trigger.get_by_id(_id)
@@ -186,6 +189,7 @@ def cancel_trigger(_id, resource_id=None, operator_uid=None):
@celery.task(name="acl.op_record", queue=ACL_QUEUE)
@reconnect_db
def op_record(app, rolename, operate_type, obj):
if isinstance(app, int):
app = AppCache.get(app)

View File

@@ -16,6 +16,8 @@ from api.lib.cmdb.cache import CITypeAttributesCache
from api.lib.cmdb.const import CMDB_QUEUE
from api.lib.cmdb.const import REDIS_PREFIX_CI
from api.lib.cmdb.const import REDIS_PREFIX_CI_RELATION
from api.lib.decorator import flush_db
from api.lib.decorator import reconnect_db
from api.lib.perm.acl.cache import UserCache
from api.lib.utils import Lock
from api.lib.utils import handle_arg_list
@@ -25,11 +27,12 @@ from api.models.cmdb import CITypeAttribute
@celery.task(name="cmdb.ci_cache", queue=CMDB_QUEUE)
@flush_db
@reconnect_db
def ci_cache(ci_id, operate_type, record_id):
from api.lib.cmdb.ci import CITriggerManager
time.sleep(0.01)
db.session.remove()
m = api.lib.cmdb.ci.CIManager()
ci_dict = m.get_ci_by_id_from_db(ci_id, need_children=False, use_master=False)
@@ -49,9 +52,10 @@ def ci_cache(ci_id, operate_type, record_id):
@celery.task(name="cmdb.batch_ci_cache", queue=CMDB_QUEUE)
@flush_db
@reconnect_db
def batch_ci_cache(ci_ids, ): # only for attribute change index
time.sleep(1)
db.session.remove()
for ci_id in ci_ids:
m = api.lib.cmdb.ci.CIManager()
@@ -66,6 +70,7 @@ def batch_ci_cache(ci_ids, ): # only for attribute change index
@celery.task(name="cmdb.ci_delete", queue=CMDB_QUEUE)
@reconnect_db
def ci_delete(ci_id):
current_app.logger.info(ci_id)
@@ -78,6 +83,7 @@ def ci_delete(ci_id):
@celery.task(name="cmdb.ci_delete_trigger", queue=CMDB_QUEUE)
@reconnect_db
def ci_delete_trigger(trigger, operate_type, ci_dict):
current_app.logger.info('delete ci {} trigger'.format(ci_dict['_id']))
from api.lib.cmdb.ci import CITriggerManager
@@ -89,9 +95,9 @@ def ci_delete_trigger(trigger, operate_type, ci_dict):
@celery.task(name="cmdb.ci_relation_cache", queue=CMDB_QUEUE)
@flush_db
@reconnect_db
def ci_relation_cache(parent_id, child_id):
db.session.remove()
with Lock("CIRelation_{}".format(parent_id)):
children = rd.get([parent_id], REDIS_PREFIX_CI_RELATION)[0]
children = json.loads(children) if children is not None else {}
@@ -106,6 +112,8 @@ def ci_relation_cache(parent_id, child_id):
@celery.task(name="cmdb.ci_relation_add", queue=CMDB_QUEUE)
@flush_db
@reconnect_db
def ci_relation_add(parent_dict, child_id, uid):
"""
:param parent_dict: key is '$parent_model.attr_name'
@@ -121,8 +129,6 @@ def ci_relation_add(parent_dict, child_id, uid):
current_app.test_request_context().push()
login_user(UserCache.get(uid))
db.session.remove()
for parent in parent_dict:
parent_ci_type_name, _attr_name = parent.strip()[1:].split('.', 1)
attr_name = CITypeAttributeManager.get_attr_name(parent_ci_type_name, _attr_name)
@@ -147,10 +153,14 @@ def ci_relation_add(parent_dict, child_id, uid):
except Exception as e:
current_app.logger.warning(e)
finally:
db.session.remove()
try:
db.session.commit()
except:
pass
@celery.task(name="cmdb.ci_relation_delete", queue=CMDB_QUEUE)
@reconnect_db
def ci_relation_delete(parent_id, child_id):
with Lock("CIRelation_{}".format(parent_id)):
children = rd.get([parent_id], REDIS_PREFIX_CI_RELATION)[0]
@@ -165,9 +175,10 @@ def ci_relation_delete(parent_id, child_id):
@celery.task(name="cmdb.ci_type_attribute_order_rebuild", queue=CMDB_QUEUE)
@flush_db
@reconnect_db
def ci_type_attribute_order_rebuild(type_id, uid):
current_app.logger.info('rebuild attribute order')
db.session.remove()
from api.lib.cmdb.ci_type import CITypeAttributeGroupManager
@@ -188,11 +199,11 @@ def ci_type_attribute_order_rebuild(type_id, uid):
@celery.task(name="cmdb.calc_computed_attribute", queue=CMDB_QUEUE)
@flush_db
@reconnect_db
def calc_computed_attribute(attr_id, uid):
from api.lib.cmdb.ci import CIManager
db.session.remove()
current_app.test_request_context().push()
login_user(UserCache.get(uid))

View File

@@ -84,11 +84,10 @@ class CIView(APIView):
ci_dict = self._wrap_ci_dict()
manager = CIManager()
current_app.logger.debug(ci_dict)
ci_id = manager.add(ci_type,
exist_policy=exist_policy or ExistPolicy.REJECT,
_no_attribute_policy=_no_attribute_policy,
_is_admin=request.values.pop('__is_admin', False),
_is_admin=request.values.pop('__is_admin', None) or False,
**ci_dict)
return self.jsonify(ci_id=ci_id)
@@ -96,7 +95,6 @@ class CIView(APIView):
@has_perm_for_ci("ci_id", ResourceTypeEnum.CI, PermEnum.UPDATE, CIManager.get_type)
def put(self, ci_id=None):
args = request.values
current_app.logger.info(args)
ci_type = args.get("ci_type")
_no_attribute_policy = args.get("no_attribute_policy", ExistPolicy.IGNORE)
@@ -104,14 +102,14 @@ class CIView(APIView):
manager = CIManager()
if ci_id is not None:
manager.update(ci_id,
_is_admin=request.values.pop('__is_admin', False),
_is_admin=request.values.pop('__is_admin', None) or False,
**ci_dict)
else:
request.values.pop('exist_policy', None)
ci_id = manager.add(ci_type,
exist_policy=ExistPolicy.REPLACE,
_no_attribute_policy=_no_attribute_policy,
_is_admin=request.values.pop('__is_admin', False),
_is_admin=request.values.pop('__is_admin', None) or False,
**ci_dict)
return self.jsonify(ci_id=ci_id)
@@ -228,11 +226,11 @@ class CIFlushView(APIView):
from api.tasks.cmdb import ci_cache
from api.lib.cmdb.const import CMDB_QUEUE
if ci_id is not None:
ci_cache.apply_async([ci_id], queue=CMDB_QUEUE)
ci_cache.apply_async(args=(ci_id, None, None), queue=CMDB_QUEUE)
else:
cis = CI.get_by(to_dict=False)
for ci in cis:
ci_cache.apply_async([ci.id], queue=CMDB_QUEUE)
ci_cache.apply_async(args=(ci.id, None, None), queue=CMDB_QUEUE)
return self.jsonify(code=200)
@@ -242,3 +240,13 @@ class CIAutoDiscoveryStatisticsView(APIView):
def get(self):
return self.jsonify(CIManager.get_ad_statistics())
class CIPasswordView(APIView):
url_prefix = "/ci/<int:ci_id>/attributes/<int:attr_id>/password"
def get(self, ci_id, attr_id):
return self.jsonify(ci_id=ci_id, attr_id=attr_id, value=CIManager.load_password(ci_id, attr_id))
def post(self, ci_id, attr_id):
return self.get(ci_id, attr_id)

View File

@@ -0,0 +1,37 @@
from flask import request
from api.lib.perm.auth import auth_abandoned
from api.lib.secrets.inner import KeyManage
from api.lib.secrets.secrets import InnerKVManger
from api.resource import APIView
class InnerSecretUnSealView(APIView):
url_prefix = "/secrets/unseal"
@auth_abandoned
def post(self):
unseal_key = request.headers.get("Unseal-Token")
res = KeyManage(backend=InnerKVManger()).unseal(unseal_key)
return self.jsonify(**res)
class InnerSecretSealView(APIView):
url_prefix = "/secrets/seal"
@auth_abandoned
def post(self):
unseal_key = request.headers.get("Inner-Token")
res = KeyManage(backend=InnerKVManger()).seal(unseal_key)
return self.jsonify(**res)
class InnerSecretAutoSealView(APIView):
url_prefix = "/secrets/auto_seal"
@auth_abandoned
def post(self):
root_key = request.headers.get("Inner-Token")
res = KeyManage(trigger=root_key,
backend=InnerKVManger()).auto_unseal()
return self.jsonify(**res)

View File

@@ -0,0 +1 @@
Generic single-database configuration.

View File

@@ -0,0 +1,45 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

110
cmdb-api/migrations/env.py Normal file
View File

@@ -0,0 +1,110 @@
from __future__ import with_statement
import logging
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option(
'sqlalchemy.url', current_app.config.get(
'SQLALCHEMY_DATABASE_URI').replace('%', '%%'))
target_metadata = current_app.extensions['migrate'].db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
# 添加要屏蔽的table列表
exclude_tables = ["c_cfp"]
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True,
include_name=include_name
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
include_name=include_name,
**current_app.extensions['migrate'].configure_args
)
with context.begin_transaction():
context.run_migrations()
def include_name(name, type_, parent_names):
if type_ == "table":
return name not in exclude_tables
elif parent_names.get("table_name") in exclude_tables:
return False
return True
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,360 @@
"""empty message
Revision ID: 6a4df2623057
Revises:
Create Date: 2023-10-13 15:17:00.066858
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '6a4df2623057'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('common_data',
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.Column('deleted', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('data_type', sa.VARCHAR(length=255), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_common_data_deleted'), 'common_data', ['deleted'], unique=False)
op.create_table('common_notice_config',
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.Column('deleted', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('platform', sa.VARCHAR(length=255), nullable=False),
sa.Column('info', sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_common_notice_config_deleted'), 'common_notice_config', ['deleted'], unique=False)
op.add_column('c_attributes', sa.Column('choice_other', sa.JSON(), nullable=True))
op.drop_index('idx_c_attributes_uid', table_name='c_attributes')
op.create_index(op.f('ix_c_attributes_uid'), 'c_attributes', ['uid'], unique=False)
op.drop_index('ix_c_custom_dashboard_deleted', table_name='c_c_d')
op.create_index(op.f('ix_c_c_d_deleted'), 'c_c_d', ['deleted'], unique=False)
op.drop_index('ix_c_ci_type_triggers_deleted', table_name='c_c_t_t')
op.create_index(op.f('ix_c_c_t_t_deleted'), 'c_c_t_t', ['deleted'], unique=False)
op.drop_index('ix_c_ci_type_unique_constraints_deleted', table_name='c_c_t_u_c')
op.create_index(op.f('ix_c_c_t_u_c_deleted'), 'c_c_t_u_c', ['deleted'], unique=False)
op.drop_index('c_ci_types_uid', table_name='c_ci_types')
op.create_index(op.f('ix_c_ci_types_uid'), 'c_ci_types', ['uid'], unique=False)
op.alter_column('c_prv', 'uid',
existing_type=mysql.INTEGER(),
nullable=False)
op.drop_index('ix_c_preference_relation_views_deleted', table_name='c_prv')
op.drop_index('ix_c_preference_relation_views_name', table_name='c_prv')
op.create_index(op.f('ix_c_prv_deleted'), 'c_prv', ['deleted'], unique=False)
op.create_index(op.f('ix_c_prv_name'), 'c_prv', ['name'], unique=False)
op.create_index(op.f('ix_c_prv_uid'), 'c_prv', ['uid'], unique=False)
op.drop_index('ix_c_preference_show_attributes_deleted', table_name='c_psa')
op.drop_index('ix_c_preference_show_attributes_uid', table_name='c_psa')
op.create_index(op.f('ix_c_psa_deleted'), 'c_psa', ['deleted'], unique=False)
op.create_index(op.f('ix_c_psa_uid'), 'c_psa', ['uid'], unique=False)
op.drop_index('ix_c_preference_tree_views_deleted', table_name='c_ptv')
op.drop_index('ix_c_preference_tree_views_uid', table_name='c_ptv')
op.create_index(op.f('ix_c_ptv_deleted'), 'c_ptv', ['deleted'], unique=False)
op.create_index(op.f('ix_c_ptv_uid'), 'c_ptv', ['uid'], unique=False)
op.alter_column('common_department', 'department_name',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment=None,
existing_comment='部门名称',
existing_nullable=True)
op.alter_column('common_department', 'department_director_id',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='部门负责人ID',
existing_nullable=True)
op.alter_column('common_department', 'department_parent_id',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='上级部门ID',
existing_nullable=True)
op.alter_column('common_department', 'sort_value',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='排序值',
existing_nullable=True)
op.alter_column('common_department', 'acl_rid',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='ACL中rid',
existing_nullable=True)
op.alter_column('common_employee', 'email',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment=None,
existing_comment='邮箱',
existing_nullable=True)
op.alter_column('common_employee', 'username',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment=None,
existing_comment='用户名',
existing_nullable=True)
op.alter_column('common_employee', 'nickname',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment=None,
existing_comment='姓名',
existing_nullable=True)
op.alter_column('common_employee', 'sex',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=64),
comment=None,
existing_comment='性别',
existing_nullable=True)
op.alter_column('common_employee', 'position_name',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment=None,
existing_comment='职位名称',
existing_nullable=True)
op.alter_column('common_employee', 'mobile',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment=None,
existing_comment='电话号码',
existing_nullable=True)
op.alter_column('common_employee', 'avatar',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment=None,
existing_comment='头像',
existing_nullable=True)
op.alter_column('common_employee', 'direct_supervisor_id',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='直接上级ID',
existing_nullable=True)
op.alter_column('common_employee', 'department_id',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='部门ID',
existing_nullable=True)
op.alter_column('common_employee', 'acl_uid',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='ACL中uid',
existing_nullable=True)
op.alter_column('common_employee', 'acl_rid',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='ACL中rid',
existing_nullable=True)
op.alter_column('common_employee', 'acl_virtual_rid',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='ACL中虚拟角色rid',
existing_nullable=True)
op.alter_column('common_employee', 'last_login',
existing_type=mysql.TIMESTAMP(),
comment=None,
existing_comment='上次登录时间',
existing_nullable=True)
op.alter_column('common_employee', 'block',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='锁定状态',
existing_nullable=True)
op.alter_column('common_employee_info', 'info',
existing_type=mysql.JSON(),
comment=None,
existing_comment='员工信息',
existing_nullable=True)
op.alter_column('common_employee_info', 'employee_id',
existing_type=mysql.INTEGER(),
comment=None,
existing_comment='员工ID',
existing_nullable=True)
op.alter_column('common_internal_message', 'title',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment=None,
existing_comment='标题',
existing_nullable=True)
op.alter_column('common_internal_message', 'content',
existing_type=mysql.TEXT(charset='utf8mb3', collation='utf8mb3_unicode_ci'),
comment=None,
existing_comment='内容',
existing_nullable=True)
op.alter_column('common_internal_message', 'path',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment=None,
existing_comment='跳转路径',
existing_nullable=True)
op.alter_column('common_internal_message', 'is_read',
existing_type=mysql.TINYINT(display_width=1),
comment=None,
existing_comment='是否已读',
existing_nullable=True)
op.alter_column('common_internal_message', 'app_name',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=128),
comment=None,
existing_comment='应用名称',
existing_nullable=False)
op.alter_column('common_internal_message', 'category',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=128),
comment=None,
existing_comment='分类',
existing_nullable=False)
op.alter_column('common_internal_message', 'message_data',
existing_type=mysql.JSON(),
comment=None,
existing_comment='数据',
existing_nullable=True)
op.drop_column('users', 'apps')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('apps', mysql.JSON(), nullable=True))
op.alter_column('common_internal_message', 'message_data',
existing_type=mysql.JSON(),
comment='数据',
existing_nullable=True)
op.alter_column('common_internal_message', 'category',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=128),
comment='分类',
existing_nullable=False)
op.alter_column('common_internal_message', 'app_name',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=128),
comment='应用名称',
existing_nullable=False)
op.alter_column('common_internal_message', 'is_read',
existing_type=mysql.TINYINT(display_width=1),
comment='是否已读',
existing_nullable=True)
op.alter_column('common_internal_message', 'path',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment='跳转路径',
existing_nullable=True)
op.alter_column('common_internal_message', 'content',
existing_type=mysql.TEXT(charset='utf8mb3', collation='utf8mb3_unicode_ci'),
comment='内容',
existing_nullable=True)
op.alter_column('common_internal_message', 'title',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment='标题',
existing_nullable=True)
op.alter_column('common_employee_info', 'employee_id',
existing_type=mysql.INTEGER(),
comment='员工ID',
existing_nullable=True)
op.alter_column('common_employee_info', 'info',
existing_type=mysql.JSON(),
comment='员工信息',
existing_nullable=True)
op.alter_column('common_employee', 'block',
existing_type=mysql.INTEGER(),
comment='锁定状态',
existing_nullable=True)
op.alter_column('common_employee', 'last_login',
existing_type=mysql.TIMESTAMP(),
comment='上次登录时间',
existing_nullable=True)
op.alter_column('common_employee', 'acl_virtual_rid',
existing_type=mysql.INTEGER(),
comment='ACL中虚拟角色rid',
existing_nullable=True)
op.alter_column('common_employee', 'acl_rid',
existing_type=mysql.INTEGER(),
comment='ACL中rid',
existing_nullable=True)
op.alter_column('common_employee', 'acl_uid',
existing_type=mysql.INTEGER(),
comment='ACL中uid',
existing_nullable=True)
op.alter_column('common_employee', 'department_id',
existing_type=mysql.INTEGER(),
comment='部门ID',
existing_nullable=True)
op.alter_column('common_employee', 'direct_supervisor_id',
existing_type=mysql.INTEGER(),
comment='直接上级ID',
existing_nullable=True)
op.alter_column('common_employee', 'avatar',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment='头像',
existing_nullable=True)
op.alter_column('common_employee', 'mobile',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment='电话号码',
existing_nullable=True)
op.alter_column('common_employee', 'position_name',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment='职位名称',
existing_nullable=True)
op.alter_column('common_employee', 'sex',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=64),
comment='性别',
existing_nullable=True)
op.alter_column('common_employee', 'nickname',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment='姓名',
existing_nullable=True)
op.alter_column('common_employee', 'username',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment='用户名',
existing_nullable=True)
op.alter_column('common_employee', 'email',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment='邮箱',
existing_nullable=True)
op.alter_column('common_department', 'acl_rid',
existing_type=mysql.INTEGER(),
comment='ACL中rid',
existing_nullable=True)
op.alter_column('common_department', 'sort_value',
existing_type=mysql.INTEGER(),
comment='排序值',
existing_nullable=True)
op.alter_column('common_department', 'department_parent_id',
existing_type=mysql.INTEGER(),
comment='上级部门ID',
existing_nullable=True)
op.alter_column('common_department', 'department_director_id',
existing_type=mysql.INTEGER(),
comment='部门负责人ID',
existing_nullable=True)
op.alter_column('common_department', 'department_name',
existing_type=mysql.VARCHAR(charset='utf8mb3', collation='utf8mb3_unicode_ci', length=255),
comment='部门名称',
existing_nullable=True)
op.drop_index(op.f('ix_c_ptv_uid'), table_name='c_ptv')
op.drop_index(op.f('ix_c_ptv_deleted'), table_name='c_ptv')
op.create_index('ix_c_preference_tree_views_uid', 'c_ptv', ['uid'], unique=False)
op.create_index('ix_c_preference_tree_views_deleted', 'c_ptv', ['deleted'], unique=False)
op.drop_index(op.f('ix_c_psa_uid'), table_name='c_psa')
op.drop_index(op.f('ix_c_psa_deleted'), table_name='c_psa')
op.create_index('ix_c_preference_show_attributes_uid', 'c_psa', ['uid'], unique=False)
op.create_index('ix_c_preference_show_attributes_deleted', 'c_psa', ['deleted'], unique=False)
op.drop_index(op.f('ix_c_prv_uid'), table_name='c_prv')
op.drop_index(op.f('ix_c_prv_name'), table_name='c_prv')
op.drop_index(op.f('ix_c_prv_deleted'), table_name='c_prv')
op.create_index('ix_c_preference_relation_views_name', 'c_prv', ['name'], unique=False)
op.create_index('ix_c_preference_relation_views_deleted', 'c_prv', ['deleted'], unique=False)
op.alter_column('c_prv', 'uid',
existing_type=mysql.INTEGER(),
nullable=True)
op.drop_index(op.f('ix_c_ci_types_uid'), table_name='c_ci_types')
op.create_index('c_ci_types_uid', 'c_ci_types', ['uid'], unique=False)
op.drop_index(op.f('ix_c_c_t_u_c_deleted'), table_name='c_c_t_u_c')
op.create_index('ix_c_ci_type_unique_constraints_deleted', 'c_c_t_u_c', ['deleted'], unique=False)
op.drop_index(op.f('ix_c_c_t_t_deleted'), table_name='c_c_t_t')
op.create_index('ix_c_ci_type_triggers_deleted', 'c_c_t_t', ['deleted'], unique=False)
op.drop_index(op.f('ix_c_c_d_deleted'), table_name='c_c_d')
op.create_index('ix_c_custom_dashboard_deleted', 'c_c_d', ['deleted'], unique=False)
op.drop_index(op.f('ix_c_attributes_uid'), table_name='c_attributes')
op.create_index('idx_c_attributes_uid', 'c_attributes', ['uid'], unique=False)
op.drop_column('c_attributes', 'choice_other')
op.drop_index(op.f('ix_common_notice_config_deleted'), table_name='common_notice_config')
op.drop_table('common_notice_config')
op.drop_index(op.f('ix_common_data_deleted'), table_name='common_data')
op.drop_table('common_data')
# ### end Alembic commands ###

View File

@@ -1,7 +1,7 @@
-i https://mirrors.aliyun.com/pypi/simple
alembic==1.7.7
bs4==0.0.1
celery==5.3.1
celery>=5.3.1
celery-once==3.0.1
click==8.1.3
elasticsearch==7.17.9
@@ -18,21 +18,22 @@ Flask-RESTful==0.3.10
Flask-SQLAlchemy==2.5.0
future==0.18.3
gunicorn==21.0.1
hvac==2.0.0
itsdangerous==2.1.2
Jinja2==3.1.2
jinja2schema==0.1.4
jsonschema==4.18.0
kombu==5.3.1
kombu>=5.3.1
Mako==1.2.4
MarkupSafe==2.1.3
marshmallow==2.20.2
more-itertools==5.0.0
msgpack-python==0.5.6
Pillow==9.3.0
pycryptodome==3.12.0
cryptography==41.0.2
PyJWT==2.4.0
PyMySQL==1.1.0
python-ldap==3.4.0
ldap3==2.9.1
PyYAML==6.0
redis==4.6.0
requests==2.31.0
@@ -46,3 +47,7 @@ toposort==1.10
treelib==1.6.1
Werkzeug==2.3.6
WTForms==3.0.0
shamir~=17.12.0
hvac~=2.0.0
pycryptodomex>=3.19.0
colorama>=0.4.6

View File

@@ -97,3 +97,9 @@ BOOL_TRUE = ['true', 'TRUE', 'True', True, '1', 1, "Yes", "YES", "yes", 'Y', 'y'
# # messenger
USE_MESSENGER = True
# # secrets
SECRETS_ENGINE = 'inner' # 'inner' or 'vault'
VAULT_URL = ''
VAULT_TOKEN = ''
INNER_TRIGGER_TOKEN = ''

View File

@@ -54,6 +54,48 @@
<div class="content unicode" style="display: block;">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont">&#xe894;</span>
<div class="name">icon-xianxing-password</div>
<div class="code-name">&amp;#xe894;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe895;</span>
<div class="name">icon-xianxing-link</div>
<div class="code-name">&amp;#xe895;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe892;</span>
<div class="name">itsm-oneclick download</div>
<div class="code-name">&amp;#xe892;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe893;</span>
<div class="name">itsm-package download</div>
<div class="code-name">&amp;#xe893;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe891;</span>
<div class="name">weixin</div>
<div class="code-name">&amp;#xe891;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe88f;</span>
<div class="name">itsm-again</div>
<div class="code-name">&amp;#xe88f;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe890;</span>
<div class="name">itsm-next</div>
<div class="code-name">&amp;#xe890;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe88e;</span>
<div class="name">wechatApp</div>
@@ -4002,9 +4044,9 @@
<pre><code class="language-css"
>@font-face {
font-family: 'iconfont';
src: url('iconfont.woff2?t=1696815443987') format('woff2'),
url('iconfont.woff?t=1696815443987') format('woff'),
url('iconfont.ttf?t=1696815443987') format('truetype');
src: url('iconfont.woff2?t=1698273699449') format('woff2'),
url('iconfont.woff?t=1698273699449') format('woff'),
url('iconfont.ttf?t=1698273699449') format('truetype');
}
</code></pre>
<h3 id="-iconfont-">第二步:定义使用 iconfont 的样式</h3>
@@ -4030,6 +4072,69 @@
<div class="content font-class">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont icon-xianxing-password"></span>
<div class="name">
icon-xianxing-password
</div>
<div class="code-name">.icon-xianxing-password
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-xianxing-link"></span>
<div class="name">
icon-xianxing-link
</div>
<div class="code-name">.icon-xianxing-link
</div>
</li>
<li class="dib">
<span class="icon iconfont a-itsm-oneclickdownload"></span>
<div class="name">
itsm-oneclick download
</div>
<div class="code-name">.a-itsm-oneclickdownload
</div>
</li>
<li class="dib">
<span class="icon iconfont a-itsm-packagedownload"></span>
<div class="name">
itsm-package download
</div>
<div class="code-name">.a-itsm-packagedownload
</div>
</li>
<li class="dib">
<span class="icon iconfont a-Frame4"></span>
<div class="name">
weixin
</div>
<div class="code-name">.a-Frame4
</div>
</li>
<li class="dib">
<span class="icon iconfont itsm-again"></span>
<div class="name">
itsm-again
</div>
<div class="code-name">.itsm-again
</div>
</li>
<li class="dib">
<span class="icon iconfont itsm-next"></span>
<div class="name">
itsm-next
</div>
<div class="code-name">.itsm-next
</div>
</li>
<li class="dib">
<span class="icon iconfont wechatApp"></span>
<div class="name">
@@ -9952,6 +10057,62 @@
<div class="content symbol">
<ul class="icon_lists dib-box">
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-xianxing-password"></use>
</svg>
<div class="name">icon-xianxing-password</div>
<div class="code-name">#icon-xianxing-password</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-xianxing-link"></use>
</svg>
<div class="name">icon-xianxing-link</div>
<div class="code-name">#icon-xianxing-link</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#a-itsm-oneclickdownload"></use>
</svg>
<div class="name">itsm-oneclick download</div>
<div class="code-name">#a-itsm-oneclickdownload</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#a-itsm-packagedownload"></use>
</svg>
<div class="name">itsm-package download</div>
<div class="code-name">#a-itsm-packagedownload</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#a-Frame4"></use>
</svg>
<div class="name">weixin</div>
<div class="code-name">#a-Frame4</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#itsm-again"></use>
</svg>
<div class="name">itsm-again</div>
<div class="code-name">#itsm-again</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#itsm-next"></use>
</svg>
<div class="name">itsm-next</div>
<div class="code-name">#itsm-next</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#wechatApp"></use>

View File

@@ -1,8 +1,8 @@
@font-face {
font-family: "iconfont"; /* Project id 3857903 */
src: url('iconfont.woff2?t=1696815443987') format('woff2'),
url('iconfont.woff?t=1696815443987') format('woff'),
url('iconfont.ttf?t=1696815443987') format('truetype');
src: url('iconfont.woff2?t=1698273699449') format('woff2'),
url('iconfont.woff?t=1698273699449') format('woff'),
url('iconfont.ttf?t=1698273699449') format('truetype');
}
.iconfont {
@@ -13,6 +13,34 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-xianxing-password:before {
content: "\e894";
}
.icon-xianxing-link:before {
content: "\e895";
}
.a-itsm-oneclickdownload:before {
content: "\e892";
}
.a-itsm-packagedownload:before {
content: "\e893";
}
.a-Frame4:before {
content: "\e891";
}
.itsm-again:before {
content: "\e88f";
}
.itsm-next:before {
content: "\e890";
}
.wechatApp:before {
content: "\e88e";
}

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,55 @@
"css_prefix_text": "",
"description": "",
"glyphs": [
{
"icon_id": "37830610",
"name": "icon-xianxing-password",
"font_class": "icon-xianxing-password",
"unicode": "e894",
"unicode_decimal": 59540
},
{
"icon_id": "37830609",
"name": "icon-xianxing-link",
"font_class": "icon-xianxing-link",
"unicode": "e895",
"unicode_decimal": 59541
},
{
"icon_id": "37822199",
"name": "itsm-oneclick download",
"font_class": "a-itsm-oneclickdownload",
"unicode": "e892",
"unicode_decimal": 59538
},
{
"icon_id": "37822198",
"name": "itsm-package download",
"font_class": "a-itsm-packagedownload",
"unicode": "e893",
"unicode_decimal": 59539
},
{
"icon_id": "37772067",
"name": "weixin",
"font_class": "a-Frame4",
"unicode": "e891",
"unicode_decimal": 59537
},
{
"icon_id": "37632784",
"name": "itsm-again",
"font_class": "itsm-again",
"unicode": "e88f",
"unicode_decimal": 59535
},
{
"icon_id": "37632783",
"name": "itsm-next",
"font_class": "itsm-next",
"unicode": "e890",
"unicode_decimal": 59536
},
{
"icon_id": "37590786",
"name": "wechatApp",

Binary file not shown.

View File

@@ -1,282 +1,290 @@
<template>
<div>
<a-popover
v-if="isDropdown"
v-model="visible"
trigger="click"
:placement="placement"
overlayClassName="table-filter"
@visibleChange="visibleChange"
>
<slot name="popover_item">
<a-button type="primary" ghost>条件过滤<a-icon type="filter"/></a-button>
</slot>
<template slot="content">
<Expression v-model="ruleList" :canSearchPreferenceAttrList="canSearchPreferenceAttrList" />
<a-divider :style="{ margin: '10px 0' }" />
<div style="width:534px">
<a-space :style="{ display: 'flex', justifyContent: 'flex-end' }">
<a-button type="primary" size="small" @click="handleSubmit">确定</a-button>
<a-button size="small" @click="handleClear">清空</a-button>
</a-space>
</div>
</template>
</a-popover>
<Expression v-else v-model="ruleList" :canSearchPreferenceAttrList="canSearchPreferenceAttrList" />
</div>
</template>
<script>
import { v4 as uuidv4 } from 'uuid'
import Expression from './expression.vue'
import { advancedExpList, compareTypeList } from './constants'
export default {
name: 'FilterComp',
components: { Expression },
props: {
canSearchPreferenceAttrList: {
type: Array,
required: true,
default: () => [],
},
expression: {
type: String,
default: '',
},
regQ: {
type: String,
default: '(?<=q=).+(?=&)|(?<=q=).+$',
},
placement: {
type: String,
default: 'bottomRight',
},
isDropdown: {
type: Boolean,
default: true,
},
},
data() {
return {
advancedExpList,
compareTypeList,
visible: false,
ruleList: [],
filterExp: '',
}
},
methods: {
visibleChange(open, isInitOne = true) {
// isInitOne 初始化exp为空时ruleList是否默认给一条
// const regQ = /(?<=q=).+(?=&)|(?<=q=).+$/g
const exp = this.expression.match(new RegExp(this.regQ, 'g'))
? this.expression.match(new RegExp(this.regQ, 'g'))[0]
: null
if (open && exp) {
const expArray = exp.split(',').map((item) => {
let has_not = ''
const key = item.split(':')[0]
const val = item
.split(':')
.slice(1)
.join(':')
let type, property, exp, value, min, max, compareType
if (key.includes('-')) {
type = 'or'
if (key.includes('~')) {
property = key.substring(2)
has_not = '~'
} else {
property = key.substring(1)
}
} else {
type = 'and'
if (key.includes('~')) {
property = key.substring(1)
has_not = '~'
} else {
property = key
}
}
const in_reg = /(?<=\().+(?=\))/g
const range_reg = /(?<=\[).+(?=\])/g
const compare_reg = /(?<=>=|<=|>(?!=)|<(?!=)).+/
if (val === '*') {
exp = has_not + 'value'
value = ''
} else if (in_reg.test(val)) {
exp = has_not + 'in'
value = val.match(in_reg)[0]
} else if (range_reg.test(val)) {
exp = has_not + 'range'
value = val.match(range_reg)[0]
min = value.split('_TO_')[0]
max = value.split('_TO_')[1]
} else if (compare_reg.test(val)) {
exp = has_not + 'compare'
value = val.match(compare_reg)[0]
const _compareType = val.substring(0, val.match(compare_reg)['index'])
const idx = compareTypeList.findIndex((item) => item.label === _compareType)
compareType = compareTypeList[idx].value
} else if (!val.includes('*')) {
exp = has_not + 'is'
value = val
} else {
const resList = [
['contain', /(?<=\*).*(?=\*)/g],
['end_with', /(?<=\*).+/g],
['start_with', /.+(?=\*)/g],
]
for (let i = 0; i < 3; i++) {
const reg = resList[i]
if (reg[1].test(val)) {
exp = has_not + reg[0]
value = val.match(reg[1])[0]
break
}
}
}
return {
id: uuidv4(),
type,
property,
exp,
value,
min,
max,
compareType,
}
})
this.ruleList = [...expArray]
} else if (open) {
this.ruleList = isInitOne
? [
{
id: uuidv4(),
type: 'and',
property:
this.canSearchPreferenceAttrList && this.canSearchPreferenceAttrList.length
? this.canSearchPreferenceAttrList[0].name
: undefined,
exp: 'is',
value: null,
},
]
: []
}
},
handleClear() {
this.ruleList = [
{
id: uuidv4(),
type: 'and',
property: this.canSearchPreferenceAttrList[0].name,
exp: 'is',
value: null,
},
]
this.filterExp = ''
this.visible = false
this.$emit('setExpFromFilter', this.filterExp)
},
handleSubmit() {
if (this.ruleList && this.ruleList.length) {
this.ruleList[0].type = 'and' // 增删后以防万一第一个不是and
this.filterExp = ''
const expList = this.ruleList.map((rule) => {
let singleRuleExp = ''
let _exp = rule.exp
if (rule.type === 'or') {
singleRuleExp += '-'
}
if (rule.exp.includes('~')) {
singleRuleExp += '~'
_exp = rule.exp.split('~')[1]
}
singleRuleExp += `${rule.property}:`
if (_exp === 'is') {
singleRuleExp += `${rule.value ?? ''}`
}
if (_exp === 'contain') {
singleRuleExp += `*${rule.value ?? ''}*`
}
if (_exp === 'start_with') {
singleRuleExp += `${rule.value ?? ''}*`
}
if (_exp === 'end_with') {
singleRuleExp += `*${rule.value ?? ''}`
}
if (_exp === 'value') {
singleRuleExp += `*`
}
if (_exp === 'in') {
singleRuleExp += `(${rule.value ?? ''})`
}
if (_exp === 'range') {
singleRuleExp += `[${rule.min}_TO_${rule.max}]`
}
if (_exp === 'compare') {
const idx = compareTypeList.findIndex((item) => item.value === rule.compareType)
singleRuleExp += `${compareTypeList[idx].label}${rule.value ?? ''}`
}
return singleRuleExp
})
this.filterExp = expList.join(',')
this.$emit('setExpFromFilter', this.filterExp)
} else {
this.$emit('setExpFromFilter', '')
}
this.visible = false
},
},
}
</script>
<style lang="less" scoped>
.table-filter {
.table-filter-add {
margin-top: 10px;
& > a {
padding: 2px 8px;
&:hover {
background-color: #f0faff;
border-radius: 5px;
}
}
}
.table-filter-extra-icon {
padding: 0px 2px;
&:hover {
display: inline-block;
border-radius: 5px;
background-color: #f0faff;
}
}
}
</style>
<style lang="less">
.table-filter-extra-operation {
.ant-popover-inner-content {
padding: 3px 4px;
.operation {
cursor: pointer;
width: 90px;
height: 30px;
line-height: 30px;
padding: 3px 4px;
border-radius: 5px;
transition: all 0.3s;
&:hover {
background-color: #f0faff;
}
> .anticon {
margin-right: 10px;
}
}
}
}
</style>
<template>
<div>
<a-popover
v-if="isDropdown"
v-model="visible"
trigger="click"
:placement="placement"
overlayClassName="table-filter"
@visibleChange="visibleChange"
>
<slot name="popover_item">
<a-button type="primary" ghost>条件过滤<a-icon type="filter"/></a-button>
</slot>
<template slot="content">
<Expression
v-model="ruleList"
:canSearchPreferenceAttrList="canSearchPreferenceAttrList.filter((attr) => !attr.is_password)"
/>
<a-divider :style="{ margin: '10px 0' }" />
<div style="width:534px">
<a-space :style="{ display: 'flex', justifyContent: 'flex-end' }">
<a-button type="primary" size="small" @click="handleSubmit">确定</a-button>
<a-button size="small" @click="handleClear">清空</a-button>
</a-space>
</div>
</template>
</a-popover>
<Expression
v-else
v-model="ruleList"
:canSearchPreferenceAttrList="canSearchPreferenceAttrList.filter((attr) => !attr.is_password)"
/>
</div>
</template>
<script>
import { v4 as uuidv4 } from 'uuid'
import Expression from './expression.vue'
import { advancedExpList, compareTypeList } from './constants'
export default {
name: 'FilterComp',
components: { Expression },
props: {
canSearchPreferenceAttrList: {
type: Array,
required: true,
default: () => [],
},
expression: {
type: String,
default: '',
},
regQ: {
type: String,
default: '(?<=q=).+(?=&)|(?<=q=).+$',
},
placement: {
type: String,
default: 'bottomRight',
},
isDropdown: {
type: Boolean,
default: true,
},
},
data() {
return {
advancedExpList,
compareTypeList,
visible: false,
ruleList: [],
filterExp: '',
}
},
methods: {
visibleChange(open, isInitOne = true) {
// isInitOne 初始化exp为空时ruleList是否默认给一条
// const regQ = /(?<=q=).+(?=&)|(?<=q=).+$/g
const exp = this.expression.match(new RegExp(this.regQ, 'g'))
? this.expression.match(new RegExp(this.regQ, 'g'))[0]
: null
if (open && exp) {
const expArray = exp.split(',').map((item) => {
let has_not = ''
const key = item.split(':')[0]
const val = item
.split(':')
.slice(1)
.join(':')
let type, property, exp, value, min, max, compareType
if (key.includes('-')) {
type = 'or'
if (key.includes('~')) {
property = key.substring(2)
has_not = '~'
} else {
property = key.substring(1)
}
} else {
type = 'and'
if (key.includes('~')) {
property = key.substring(1)
has_not = '~'
} else {
property = key
}
}
const in_reg = /(?<=\().+(?=\))/g
const range_reg = /(?<=\[).+(?=\])/g
const compare_reg = /(?<=>=|<=|>(?!=)|<(?!=)).+/
if (val === '*') {
exp = has_not + 'value'
value = ''
} else if (in_reg.test(val)) {
exp = has_not + 'in'
value = val.match(in_reg)[0]
} else if (range_reg.test(val)) {
exp = has_not + 'range'
value = val.match(range_reg)[0]
min = value.split('_TO_')[0]
max = value.split('_TO_')[1]
} else if (compare_reg.test(val)) {
exp = has_not + 'compare'
value = val.match(compare_reg)[0]
const _compareType = val.substring(0, val.match(compare_reg)['index'])
const idx = compareTypeList.findIndex((item) => item.label === _compareType)
compareType = compareTypeList[idx].value
} else if (!val.includes('*')) {
exp = has_not + 'is'
value = val
} else {
const resList = [
['contain', /(?<=\*).*(?=\*)/g],
['end_with', /(?<=\*).+/g],
['start_with', /.+(?=\*)/g],
]
for (let i = 0; i < 3; i++) {
const reg = resList[i]
if (reg[1].test(val)) {
exp = has_not + reg[0]
value = val.match(reg[1])[0]
break
}
}
}
return {
id: uuidv4(),
type,
property,
exp,
value,
min,
max,
compareType,
}
})
this.ruleList = [...expArray]
} else if (open) {
const _canSearchPreferenceAttrList = this.canSearchPreferenceAttrList.filter((attr) => !attr.is_password)
this.ruleList = isInitOne
? [
{
id: uuidv4(),
type: 'and',
property:
_canSearchPreferenceAttrList && _canSearchPreferenceAttrList.length
? _canSearchPreferenceAttrList[0].name
: undefined,
exp: 'is',
value: null,
},
]
: []
}
},
handleClear() {
this.ruleList = [
{
id: uuidv4(),
type: 'and',
property: this.canSearchPreferenceAttrList[0].name,
exp: 'is',
value: null,
},
]
this.filterExp = ''
this.visible = false
this.$emit('setExpFromFilter', this.filterExp)
},
handleSubmit() {
if (this.ruleList && this.ruleList.length) {
this.ruleList[0].type = 'and' // 增删后以防万一第一个不是and
this.filterExp = ''
const expList = this.ruleList.map((rule) => {
let singleRuleExp = ''
let _exp = rule.exp
if (rule.type === 'or') {
singleRuleExp += '-'
}
if (rule.exp.includes('~')) {
singleRuleExp += '~'
_exp = rule.exp.split('~')[1]
}
singleRuleExp += `${rule.property}:`
if (_exp === 'is') {
singleRuleExp += `${rule.value ?? ''}`
}
if (_exp === 'contain') {
singleRuleExp += `*${rule.value ?? ''}*`
}
if (_exp === 'start_with') {
singleRuleExp += `${rule.value ?? ''}*`
}
if (_exp === 'end_with') {
singleRuleExp += `*${rule.value ?? ''}`
}
if (_exp === 'value') {
singleRuleExp += `*`
}
if (_exp === 'in') {
singleRuleExp += `(${rule.value ?? ''})`
}
if (_exp === 'range') {
singleRuleExp += `[${rule.min}_TO_${rule.max}]`
}
if (_exp === 'compare') {
const idx = compareTypeList.findIndex((item) => item.value === rule.compareType)
singleRuleExp += `${compareTypeList[idx].label}${rule.value ?? ''}`
}
return singleRuleExp
})
this.filterExp = expList.join(',')
this.$emit('setExpFromFilter', this.filterExp)
} else {
this.$emit('setExpFromFilter', '')
}
this.visible = false
},
},
}
</script>
<style lang="less" scoped>
.table-filter {
.table-filter-add {
margin-top: 10px;
& > a {
padding: 2px 8px;
&:hover {
background-color: #f0faff;
border-radius: 5px;
}
}
}
.table-filter-extra-icon {
padding: 0px 2px;
&:hover {
display: inline-block;
border-radius: 5px;
background-color: #f0faff;
}
}
}
</style>
<style lang="less">
.table-filter-extra-operation {
.ant-popover-inner-content {
padding: 3px 4px;
.operation {
cursor: pointer;
width: 90px;
height: 30px;
line-height: 30px;
padding: 3px 4px;
border-radius: 5px;
transition: all 0.3s;
&:hover {
background-color: #f0faff;
}
> .anticon {
margin-right: 10px;
}
}
}
}
</style>

View File

@@ -1,57 +1,45 @@
<template>
<span>
<ops-icon :type="getPropertyIcon(attr)" />
</span>
</template>
<script>
export default {
name: 'ValueTypeIcon',
props: {
attr: {
type: Object,
default: () => {},
},
},
methods: {
getPropertyStyle(attr) {
switch (attr.value_type) {
case '0':
return { color: '#cf1322', backgroundColor: '#fff1f0' }
case '1':
return { color: '#d4b106', backgroundColor: '#feffe6' }
case '2':
return { color: '#d46b08', backgroundColor: '#fff7e6' }
case '3':
return { color: '#531dab', backgroundColor: '#f9f0ff' }
case '4':
return { color: '#389e0d', backgroundColor: '#f6ffed' }
case '5':
return { color: '#08979c', backgroundColor: '#e6fffb' }
case '6':
return { color: '#c41d7f', backgroundColor: '#fff0f6' }
}
},
getPropertyIcon(attr) {
switch (attr.value_type) {
case '0':
return 'icon-xianxing-shishu'
case '1':
return 'icon-xianxing-fudianshu'
case '2':
return 'icon-xianxing-wenben'
case '3':
return 'icon-xianxing-datetime'
case '4':
return 'icon-xianxing-date'
case '5':
return 'icon-xianxing-time'
case '6':
return 'icon-xianxing-json'
}
},
},
}
</script>
<style></style>
<template>
<span>
<ops-icon :type="getPropertyIcon(attr)" />
</span>
</template>
<script>
export default {
name: 'ValueTypeIcon',
props: {
attr: {
type: Object,
default: () => {},
},
},
methods: {
getPropertyIcon(attr) {
switch (attr.value_type) {
case '0':
return 'icon-xianxing-shishu'
case '1':
return 'icon-xianxing-fudianshu'
case '2':
if (attr.is_password) {
return 'icon-xianxing-password'
}
if (attr.is_link) {
return 'icon-xianxing-link'
}
return 'icon-xianxing-wenben'
case '3':
return 'icon-xianxing-datetime'
case '4':
return 'icon-xianxing-date'
case '5':
return 'icon-xianxing-time'
case '6':
return 'icon-xianxing-json'
}
},
},
}
</script>
<style></style>

View File

@@ -4,7 +4,7 @@ const appConfig = {
buildAclToModules: true, // 是否在各个应用下 内联权限管理
ssoLogoutURL: '/api/sso/logout',
showDocs: false,
useEncryption: true,
useEncryption: false,
}
export default appConfig

View File

@@ -99,7 +99,7 @@
align="center"
show-overflow>
<template #default="{ row }">
<span v-show="row.isGroup">
<span v-show="isGroup">
<a @click="handleDisplayMember(row)">成员</a>
<a-divider type="vertical" />
<a @click="handleGroupEdit(row)">编辑</a>

View File

@@ -35,8 +35,8 @@ export default {
secret: '',
},
rules: {
key: [{ required: true, message: 'key is required' }],
secret: [{ required: true, message: 'secret is required' }],
key: [{ required: false, message: 'key is required' }],
secret: [{ required: false, message: 'secret is required' }],
},
visible: false,
}

View File

@@ -1,194 +1,198 @@
<template>
<div class="acl-users">
<div class="acl-users-header">
<a-button v-if="isAclAdmin" @click="handleCreate" type="primary">{{ btnName }}</a-button>
<a-input-search
class="ops-input"
allowClear
:style="{ display: 'inline', marginLeft: '10px' }"
placeholder="搜索 | 用户名、中文名"
v-model="searchName"
></a-input-search>
</div>
<a-spin :spinning="loading">
<vxe-grid
stripe
class="ops-stripe-table"
:columns="tableColumns"
:data="tableData"
show-overflow
highlight-hover-row
:height="`${windowHeight - 165}px`"
size="small"
>
<template #block_default="{row}">
<a-icon type="lock" v-if="row.block" />
</template>
<template #action_default="{row}">
<a-space>
<a :disabled="isAclAdmin ? false : true" @click="handleEdit(row)">
<a-icon type="edit" />
</a>
<a-tooltip title="权限汇总">
<a @click="handlePermCollect(row)"><a-icon type="solution"/></a>
</a-tooltip>
<a-popconfirm :title="`确认删除【${row.nickname || row.username}】?`" @confirm="deleteUser(row.uid)">
<a :style="{ color: 'red' }"><ops-icon type="icon-xianxing-delete"/></a>
</a-popconfirm>
</a-space>
</template>
</vxe-grid>
</a-spin>
<userForm ref="userForm" :handleOk="handleOk"> </userForm>
<perm-collect-form ref="permCollectForm"></perm-collect-form>
</div>
</template>
<script>
import { mapState } from 'vuex'
import userForm from './module/userForm'
import PermCollectForm from './module/permCollectForm'
import { deleteUserById, searchUser, getOnDutyUser } from '@/modules/acl/api/user'
export default {
name: 'Users',
components: {
userForm,
PermCollectForm,
},
data() {
return {
loading: false,
tableColumns: [
{
title: '用户名',
field: 'username',
sortable: true,
minWidth: '100px',
fixed: 'left',
},
{
title: '中文名',
field: 'nickname',
minWidth: '100px',
},
{
title: '加入时间',
field: 'date_joined',
minWidth: '160px',
align: 'center',
sortable: true,
},
{
title: '锁定',
field: 'block',
width: '150px',
align: 'center',
slots: {
default: 'block_default',
},
},
{
title: '操作',
field: 'action',
width: '150px',
fixed: 'right',
align: 'center',
slots: {
default: 'action_default',
},
},
],
onDutuUids: [],
btnName: '新增用户',
allUsers: [],
tableData: [],
searchName: '',
}
},
beforeCreate() {
this.form = this.$form.createForm(this)
},
async beforeMount() {
this.loading = true
await getOnDutyUser().then((res) => {
this.onDutuUids = res.map((i) => i.uid)
this.search()
})
},
computed: {
...mapState({
windowHeight: (state) => state.windowHeight,
}),
isAclAdmin: function() {
if (this.$store.state.user.roles.permissions.filter((item) => item === 'acl_admin').length > 0) {
return true
} else {
return false
}
},
},
watch: {
searchName: {
immediate: true,
handler(newVal, oldVal) {
if (newVal) {
this.tableData = this.allUsers.filter(
(item) =>
item.username.toLowerCase().includes(newVal.toLowerCase()) ||
item.nickname.toLowerCase().includes(newVal.toLowerCase())
)
} else {
this.tableData = this.allUsers
}
},
},
},
mounted() {},
inject: ['reload'],
methods: {
search() {
searchUser({ page_size: 10000 }).then((res) => {
const ret = res.users.filter((u) => this.onDutuUids.includes(u.uid))
this.allUsers = ret
this.tableData = ret
this.loading = false
})
},
handlePermCollect(record) {
this.$refs['permCollectForm'].collect(record)
},
handleEdit(record) {
this.$refs.userForm.handleEdit(record)
},
handleOk() {
this.searchName = ''
this.search()
},
handleCreate() {
this.$refs.userForm.handleCreate()
},
deleteUser(uid) {
deleteUserById(uid).then((res) => {
this.$message.success(`删除成功`)
this.handleOk()
})
},
},
}
</script>
<style lang="less" scoped>
.acl-users {
border-radius: 15px;
background-color: #fff;
height: calc(100vh - 64px);
margin-bottom: -24px;
padding: 24px;
.acl-users-header {
display: inline-flex;
margin-bottom: 15px;
}
}
</style>
<template>
<div class="acl-users">
<div class="acl-users-header">
<a-button v-if="isAclAdmin" @click="handleCreate" type="primary">{{ btnName }}</a-button>
<a-input-search
class="ops-input"
allowClear
:style="{ display: 'inline', marginLeft: '10px' }"
placeholder="搜索 | 用户名、中文名"
v-model="searchName"
></a-input-search>
</div>
<a-spin :spinning="loading">
<vxe-grid
stripe
class="ops-stripe-table"
:columns="tableColumns"
:data="tableData"
show-overflow
highlight-hover-row
:height="`${windowHeight - 165}px`"
size="small"
>
<template #block_default="{row}">
<a-icon type="lock" v-if="row.block" />
</template>
<template #action_default="{row}">
<a-space>
<a :disabled="isAclAdmin ? false : true" @click="handleEdit(row)">
<a-icon type="edit" />
</a>
<a-tooltip title="权限汇总">
<a @click="handlePermCollect(row)"><a-icon type="solution"/></a>
</a-tooltip>
<a-popconfirm :title="`确认删除【${row.nickname || row.username}】?`" @confirm="deleteUser(row.uid)">
<a :style="{ color: 'red' }"><ops-icon type="icon-xianxing-delete"/></a>
</a-popconfirm>
</a-space>
</template>
</vxe-grid>
</a-spin>
<userForm ref="userForm" :handleOk="handleOk"> </userForm>
<perm-collect-form ref="permCollectForm"></perm-collect-form>
</div>
</template>
<script>
import { mapState } from 'vuex'
import userForm from './module/userForm'
import PermCollectForm from './module/permCollectForm'
import { deleteUserById, searchUser, getOnDutyUser } from '@/modules/acl/api/user'
export default {
name: 'Users',
components: {
userForm,
PermCollectForm,
},
data() {
return {
loading: false,
tableColumns: [
{
title: '用户名',
field: 'username',
sortable: true,
minWidth: '100px',
fixed: 'left',
},
{
title: '中文名',
field: 'nickname',
minWidth: '100px',
},
{
title: '加入时间',
field: 'date_joined',
minWidth: '160px',
align: 'center',
sortable: true,
},
{
title: '锁定',
field: 'block',
width: '150px',
align: 'center',
slots: {
default: 'block_default',
},
},
{
title: '操作',
field: 'action',
width: '150px',
fixed: 'right',
align: 'center',
slots: {
default: 'action_default',
},
},
],
onDutuUids: [],
btnName: '新增用户',
allUsers: [],
tableData: [],
searchName: '',
}
},
beforeCreate() {
this.form = this.$form.createForm(this)
},
async beforeMount() {
this.loading = true
await this.getOnDutyUser()
this.search()
},
computed: {
...mapState({
windowHeight: (state) => state.windowHeight,
}),
isAclAdmin: function() {
if (this.$store.state.user.roles.permissions.filter((item) => item === 'acl_admin').length > 0) {
return true
} else {
return false
}
},
},
watch: {
searchName: {
immediate: true,
handler(newVal, oldVal) {
if (newVal) {
this.tableData = this.allUsers.filter(
(item) =>
item.username.toLowerCase().includes(newVal.toLowerCase()) ||
item.nickname.toLowerCase().includes(newVal.toLowerCase())
)
} else {
this.tableData = this.allUsers
}
},
},
},
mounted() {},
inject: ['reload'],
methods: {
async getOnDutyUser() {
await getOnDutyUser().then((res) => {
this.onDutuUids = res.map((i) => i.uid)
})
},
search() {
searchUser({ page_size: 10000 }).then((res) => {
const ret = res.users.filter((u) => this.onDutuUids.includes(u.uid))
this.allUsers = ret
this.tableData = ret
this.loading = false
})
},
handlePermCollect(record) {
this.$refs['permCollectForm'].collect(record)
},
handleEdit(record) {
this.$refs.userForm.handleEdit(record)
},
async handleOk() {
this.searchName = ''
await this.getOnDutyUser()
this.search()
},
handleCreate() {
this.$refs.userForm.handleCreate()
},
deleteUser(uid) {
deleteUserById(uid).then((res) => {
this.$message.success(`删除成功`)
this.handleOk()
})
},
},
}
</script>
<style lang="less" scoped>
.acl-users {
border-radius: 15px;
background-color: #fff;
height: calc(100vh - 64px);
margin-bottom: -24px;
padding: 24px;
.acl-users-header {
display: inline-flex;
margin-bottom: 15px;
}
}
</style>

View File

@@ -1,170 +1,177 @@
import { axios } from '@/utils/request'
/**
* 获取 ci_type 的属性
* @param CITypeName
* @param parameter
* @returns {AxiosPromise}
*/
export function getCITypeAttributesByName(CITypeName, parameter) {
return axios({
url: `/v0.1/ci_types/${CITypeName}/attributes`,
method: 'get',
params: parameter
})
}
/**
* 获取 ci_type 的属性
* @param CITypeId
* @param parameter
* @returns {AxiosPromise}
*/
export function getCITypeAttributesById(CITypeId, parameter) {
return axios({
url: `/v0.1/ci_types/${CITypeId}/attributes`,
method: 'get',
params: parameter
})
}
/**
* 更新属性
* @param attrId
* @param data
* @returns {AxiosPromise}
*/
export function updateAttributeById(attrId, data) {
return axios({
url: `/v0.1/attributes/${attrId}`,
method: 'put',
data: data
})
}
/**
* 添加属性
* @param data
* @returns {AxiosPromise}
*/
export function createAttribute(data) {
return axios({
url: `/v0.1/attributes`,
method: 'post',
data: data
})
}
/**
* 搜索属性/ 获取所有的属性
* @param data
* @returns {AxiosPromise}
*/
export function searchAttributes(params) {
return axios({
url: `/v0.1/attributes/s`,
method: 'get',
params: params
})
}
export function getCITypeAttributesByTypeIds(params) {
return axios({
url: `/v0.1/ci_types/attributes`,
method: 'get',
params: params
})
}
export function getCITypeCommonAttributesByTypeIds(params) {
return axios({
url: `/v0.1/ci_types/common_attributes`,
method: 'get',
params: params
})
}
/**
* 删除属性
* @param attrId
* @returns {AxiosPromise}
*/
export function deleteAttributesById(attrId) {
return axios({
url: `/v0.1/attributes/${attrId}`,
method: 'delete'
})
}
/**
* 绑定ci_type 属性
* @param CITypeId
* @param data
* @returns {AxiosPromise}
*/
export function createCITypeAttributes(CITypeId, data) {
return axios({
url: `/v0.1/ci_types/${CITypeId}/attributes`,
method: 'post',
data: data
})
}
/**
* 更新ci_type 属性
* @param CITypeId
* @param data
* @returns {AxiosPromise}
*/
export function updateCITypeAttributesById(CITypeId, data) {
return axios({
url: `/v0.1/ci_types/${CITypeId}/attributes`,
method: 'put',
data: data
})
}
/**
* 删除ci_type 属性
* @param CITypeId
* @param data
* @returns {AxiosPromise}
*/
export function deleteCITypeAttributesById(CITypeId, data) {
return axios({
url: `/v0.1/ci_types/${CITypeId}/attributes`,
method: 'delete',
data: data
})
}
export function transferCITypeAttrIndex(CITypeId, data) {
return axios({
url: `/v0.1/ci_types/${CITypeId}/attributes/transfer`,
method: 'POST',
data: data
})
}
export function transferCITypeGroupIndex(CITypeId, data) {
return axios({
url: `/v0.1/ci_types/${CITypeId}/attribute_groups/transfer`,
method: 'POST',
data: data
})
}
export function canDefineComputed() {
return axios({
url: `/v0.1/ci_types/can_define_computed`,
method: 'HEAD',
})
}
export function calcComputedAttribute(attr_id) {
return axios({
url: `/v0.1/attributes/${attr_id}/calc_computed_attribute`,
method: 'PUT',
})
}
import { axios } from '@/utils/request'
/**
* 获取 ci_type 的属性
* @param CITypeName
* @param parameter
* @returns {AxiosPromise}
*/
export function getCITypeAttributesByName(CITypeName, parameter) {
return axios({
url: `/v0.1/ci_types/${CITypeName}/attributes`,
method: 'get',
params: parameter
})
}
/**
* 获取 ci_type 的属性
* @param CITypeId
* @param parameter
* @returns {AxiosPromise}
*/
export function getCITypeAttributesById(CITypeId, parameter) {
return axios({
url: `/v0.1/ci_types/${CITypeId}/attributes`,
method: 'get',
params: parameter
})
}
/**
* 更新属性
* @param attrId
* @param data
* @returns {AxiosPromise}
*/
export function updateAttributeById(attrId, data) {
return axios({
url: `/v0.1/attributes/${attrId}`,
method: 'put',
data: data
})
}
/**
* 添加属性
* @param data
* @returns {AxiosPromise}
*/
export function createAttribute(data) {
return axios({
url: `/v0.1/attributes`,
method: 'post',
data: data
})
}
/**
* 搜索属性/ 获取所有的属性
* @param data
* @returns {AxiosPromise}
*/
export function searchAttributes(params) {
return axios({
url: `/v0.1/attributes/s`,
method: 'get',
params: params
})
}
export function getCITypeAttributesByTypeIds(params) {
return axios({
url: `/v0.1/ci_types/attributes`,
method: 'get',
params: params
})
}
export function getCITypeCommonAttributesByTypeIds(params) {
return axios({
url: `/v0.1/ci_types/common_attributes`,
method: 'get',
params: params
})
}
/**
* 删除属性
* @param attrId
* @returns {AxiosPromise}
*/
export function deleteAttributesById(attrId) {
return axios({
url: `/v0.1/attributes/${attrId}`,
method: 'delete'
})
}
/**
* 绑定ci_type 属性
* @param CITypeId
* @param data
* @returns {AxiosPromise}
*/
export function createCITypeAttributes(CITypeId, data) {
return axios({
url: `/v0.1/ci_types/${CITypeId}/attributes`,
method: 'post',
data: data
})
}
/**
* 更新ci_type 属性
* @param CITypeId
* @param data
* @returns {AxiosPromise}
*/
export function updateCITypeAttributesById(CITypeId, data) {
return axios({
url: `/v0.1/ci_types/${CITypeId}/attributes`,
method: 'put',
data: data
})
}
/**
* 删除ci_type 属性
* @param CITypeId
* @param data
* @returns {AxiosPromise}
*/
export function deleteCITypeAttributesById(CITypeId, data) {
return axios({
url: `/v0.1/ci_types/${CITypeId}/attributes`,
method: 'delete',
data: data
})
}
export function transferCITypeAttrIndex(CITypeId, data) {
return axios({
url: `/v0.1/ci_types/${CITypeId}/attributes/transfer`,
method: 'POST',
data: data
})
}
export function transferCITypeGroupIndex(CITypeId, data) {
return axios({
url: `/v0.1/ci_types/${CITypeId}/attribute_groups/transfer`,
method: 'POST',
data: data
})
}
export function canDefineComputed() {
return axios({
url: `/v0.1/ci_types/can_define_computed`,
method: 'HEAD',
})
}
export function calcComputedAttribute(attr_id) {
return axios({
url: `/v0.1/attributes/${attr_id}/calc_computed_attribute`,
method: 'PUT',
})
}
export function getAttrPassword(ci_id, attr_id) {
return axios({
url: `/v0.1/ci/${ci_id}/attributes/${attr_id}/password`,
method: 'Get',
})
}

View File

@@ -1,42 +1,51 @@
<template>
<div>
<span v-if="!isShow && !isTableLoading">{{ showPassword }}</span>
<span v-else>{{ password }}</span>
<a
:style="{ marginLeft: '10px' }"
@click="
() => {
isShow = !isShow
}
"
><a-icon
:type="isShow ? 'eye-invisible' : 'eye'"
/></a>
</div>
</template>
<script>
import { mapState } from 'vuex'
export default {
name: 'PasswordField',
props: {
password: {
type: String,
default: '',
},
},
data() {
return {
isShow: false,
}
},
computed: {
showPassword() {
return '******'
},
...mapState('cmdbStore', ['isTableLoading']),
},
}
</script>
<style></style>
<template>
<div>
<span v-if="!isShow && !isTableLoading">{{ showPassword }}</span>
<span v-else>{{ password }}</span>
<a :style="{ marginLeft: '10px' }" @click="getPassword"><a-icon :type="isShow ? 'eye-invisible' : 'eye'"/></a>
</div>
</template>
<script>
import { mapState } from 'vuex'
import { getAttrPassword } from '../../api/CITypeAttr'
export default {
name: 'PasswordField',
props: {
ci_id: {
type: Number,
default: 0,
},
attr_id: {
type: Number,
default: 0,
},
},
data() {
return {
isShow: false,
password: '',
}
},
computed: {
showPassword() {
return '******'
},
...mapState('cmdbStore', ['isTableLoading']),
},
methods: {
getPassword() {
if (this.isShow) {
this.isShow = false
} else {
getAttrPassword(this.ci_id, this.attr_id).then((res) => {
this.password = res.value
this.isShow = true
})
}
},
},
}
</script>
<style></style>

View File

@@ -16,7 +16,7 @@
</a-space> -->
</div>
<div style="margin-top:10px">
<vue-json-editor v-model="jsonData" :showBtns="false" :mode="'text'" />
<codemirror style="z-index: 9999" :options="cmOptions" v-model="jsonData"></codemirror>
<!-- <a-empty
v-else
:image-style="{
@@ -31,11 +31,14 @@
</template>
<script>
import vueJsonEditor from 'vue-json-editor'
import { codemirror } from 'vue-codemirror'
import 'codemirror/lib/codemirror.css'
require('codemirror/mode/python/python.js')
export default {
name: 'Body',
components: { vueJsonEditor },
components: { codemirror },
data() {
const segmentedContentTypes = [
{
@@ -60,7 +63,14 @@ export default {
return {
segmentedContentTypes,
// contentType: 'none',
jsonData: {},
jsonData: '',
cmOptions: {
lineNumbers: true,
mode: 'python',
height: '200px',
tabSize: 4,
lineWrapping: true,
},
}
},
}

View File

@@ -81,7 +81,11 @@ export default {
this.$refs.Parameters.parameters.forEach((item) => {
parameters[item.key] = item.value
})
const body = this.$refs.Body.jsonData
let body = this.$refs.Body.jsonData
try {
JSON.parse(body)
body = JSON.parse(body)
} catch {}
const headers = {}
this.$refs.Header.headers.forEach((item) => {
headers[item.key] = item.value
@@ -99,7 +103,6 @@ export default {
return { method, url, parameters, body, headers, authorization }
},
setParams(params) {
console.log(2222, params)
const { method, url, parameters, body, headers, authorization = {} } = params ?? {}
this.method = method
this.url = url
@@ -111,7 +114,11 @@ export default {
value: parameters[key],
}
}) || []
this.$refs.Body.jsonData = body
if (body && Object.prototype.toString.call(body) === '[object Object]') {
this.$refs.Body.jsonData = JSON.stringify(body)
} else {
this.$refs.Body.jsonData = body
}
this.$refs.Header.headers =
Object.keys(headers).map((key) => {
return {

View File

@@ -61,18 +61,18 @@ const genCmdbRoutes = async () => {
name: 'cmdb_disabled2',
meta: { title: '配置', disabled: true, },
},
{
path: '/cmdb/batch',
component: () => import('../views/batch'),
name: 'cmdb_batch',
meta: { 'title': '批量导入', icon: 'ops-cmdb-batch', selectedIcon: 'ops-cmdb-batch-selected', keepAlive: false }
},
{
path: '/cmdb/preference',
component: () => import('../views/preference/index'),
name: 'cmdb_preference',
meta: { title: '我的订阅', icon: 'ops-cmdb-preference', selectedIcon: 'ops-cmdb-preference-selected', keepAlive: false }
},
{
path: '/cmdb/batch',
component: () => import('../views/batch'),
name: 'cmdb_batch',
meta: { 'title': '批量导入', icon: 'ops-cmdb-batch', selectedIcon: 'ops-cmdb-batch-selected', keepAlive: false }
},
{
path: '/cmdb/ci_types',
name: 'ci_type',

View File

@@ -1,26 +1,28 @@
export const valueTypeMap = {
'0': '整数',
'1': '浮点数',
'2': '文本',
'3': 'datetime',
'4': 'date',
'5': 'time',
'6': 'json'
}
export const defautValueColor = [
{ value: '#d9d9d9' },
{ value: '#ffccc7' },
{ value: '#ffd8bf' },
{ value: '#ffe7ba' },
{ value: '#fff1b8' },
{ value: '#f4ffb8' },
{ value: '#d9f7be' },
{ value: '#b5f5ec' },
{ value: '#bae7ff' },
{ value: '#d6e4ff' },
{ value: '#efdbff' },
{ value: '#ffd6e7' },
]
export const defaultBGColors = ['#ffccc7', '#ffd8bf', '#ffe7ba', '#fff1b8', '#d9f7be', '#b5f5ec', '#bae7ff', '#d6e4ff', '#efdbff', '#ffd6e7']
export const valueTypeMap = {
'0': '整数',
'1': '浮点数',
'2': '文本',
'3': '日期时间',
'4': '日期',
'5': '时间',
'6': 'JSON',
'7': '密码',
'8': '链接'
}
export const defautValueColor = [
{ value: '#d9d9d9' },
{ value: '#ffccc7' },
{ value: '#ffd8bf' },
{ value: '#ffe7ba' },
{ value: '#fff1b8' },
{ value: '#f4ffb8' },
{ value: '#d9f7be' },
{ value: '#b5f5ec' },
{ value: '#bae7ff' },
{ value: '#d6e4ff' },
{ value: '#efdbff' },
{ value: '#ffd6e7' },
]
export const defaultBGColors = ['#ffccc7', '#ffd8bf', '#ffe7ba', '#fff1b8', '#d9f7be', '#b5f5ec', '#bae7ff', '#d6e4ff', '#efdbff', '#ffd6e7']

View File

@@ -1,176 +1,181 @@
/* eslint-disable */
import _ from 'lodash'
import XLSX from 'xlsx'
import XLSXS from 'xlsx-js-style'
export function sum(arr) {
if (!arr.length) {
return 0
}
return arr.reduce(function (prev, curr, idx, arr) {
return prev + curr
})
}
const strLength = (fData) => {
if (!fData) {
return 0
}
if (fData.length && typeof fData === 'object') {
fData = fData.join(' ')
}
let intLength = 0
for (let i = 0; i < fData.length; i++) {
if ((fData.charCodeAt(i) < 0) || (fData.charCodeAt(i) > 255)) {
intLength = intLength + 2
}
else {
intLength = intLength + 1
}
}
return Math.floor(intLength * 7)
}
String.prototype.pxWidth = function (font) {
// re-use canvas object for better performance
const canvas = String.prototype.pxWidth.canvas || (String.prototype.pxWidth.canvas = document.createElement("canvas")),
context = canvas.getContext("2d");
font && (context.font = font);
const metrics = context.measureText(this);
return metrics.width;
}
export function getCITableColumns(data, attrList, width = 1600, height) {
// 计算出来 主table的列表 布局属性
const _attrList = _.orderBy(attrList, ['is_fixed'], ['desc'])
const columns = []
for (let attr of _attrList) {
const editRender = { name: 'input' }
switch (attr.value_type) {
case '0':
editRender['props'] = { 'type': 'float' }
break
case '1':
editRender['props'] = { 'type': 'float' }
break
case '2':
editRender['attrs'] = { 'type': 'text' }
break
case '3':
editRender['props'] = { 'type': 'datetime' }
break
case "4":
editRender['props'] = { 'type': 'date' }
break
case '5':
editRender['props'] = { 'type': 'time' }
break
case '6':
editRender['props'] = { 'type': 'text' }
break
default:
editRender['props'] = { 'type': 'text' }
break
}
if (attr.is_choice) {
editRender.name = '$select'
editRender.options = attr.choice_value ? attr.choice_value.map(item => { return { label: item, value: item } }) : []
delete editRender.props
}
columns.push({
editRender,
title: attr.alias || attr.name,
field: attr.name,
value_type: attr.value_type,
sortable: !!attr.is_sortable,
filters: attr.is_choice ? attr.choice_value : null,
width: Math.min(Math.max(100, ...data.map(item => strLength(item[attr.name]))), 350),
is_link: attr.is_link,
is_password: attr.is_password,
is_list: attr.is_list,
is_choice: attr.is_choice,
is_fixed: attr.is_fixed,
})
}
const totalWidth = sum(columns.map(col => col.width))
if (totalWidth < width) {
columns.map(item => {
// if (item.width === 100) {
delete item.width
// }
})
}
return columns
}
export const getPropertyStyle = (attr) => {
switch (attr.value_type) {
case '0':
return { color: '#cf1322', backgroundColor: '#fff1f0' }
case '1':
return { color: '#d4b106', backgroundColor: '#feffe6' }
case '2':
return { color: '#d46b08', backgroundColor: '#fff7e6' }
case '3':
return { color: '#531dab', backgroundColor: '#f9f0ff' }
case '4':
return { color: '#389e0d', backgroundColor: '#f6ffed' }
case '5':
return { color: '#08979c', backgroundColor: '#e6fffb' }
case '6':
return { color: '#c41d7f', backgroundColor: '#fff0f6' }
}
}
export const getLastLayout = (data, x1 = 0, y1 = 0, w1 = 0) => {
const _tempData = _.orderBy(data, ['y', 'x'], ['asc', 'asc'])
if (!_tempData.length) {
return { xLast: 0, yLast: 0, wLast: 0 }
}
const { x, y, w } = _tempData[_tempData.length - 1]
if (y < y1) {
return { xLast: x1, yLast: y1, wLast: w1 }
} else if (y > y1) {
return { xLast: x, yLast: y, wLast: w }
} else {
const xLast = _.max([x, x1])
return { xLast, yLast: y, wLast: xLast === x ? w : w1 }
}
}
// 数字加逗号
export const toThousands = (num = 0) => {
return num.toString().replace(/\d+/, function (n) {
return n.replace(/(\d)(?=(?:\d{3})+$)/g, '$1,')
})
}
export const downloadExcel = (data, fileName = `${moment().format('YYYY-MM-DD HH:mm:ss')}.xls`) => {
// STEP 1: Create a new workbook
const wb = XLSXS.utils.book_new()
// STEP 2: Create data rows and styles
const rowArray = data
// STEP 3: Create worksheet with rows; Add worksheet to workbook
const ws = XLSXS.utils.aoa_to_sheet(rowArray)
XLSXS.utils.book_append_sheet(wb, ws, fileName)
let maxColumnNumber = 1 // 默认最大列数
rowArray.forEach(item => { if (item.length > maxColumnNumber) { maxColumnNumber = item.length } })
// 添加列宽
ws['!cols'] = (rowArray[0].map(item => {
return { width: 22 }
}))
// // 添加行高
// ws['!rows'] = [{ 'hpt': 80 }]
// STEP 4: Write Excel file to browser #导出
XLSXS.writeFile(wb, fileName + '.xlsx')
}
/* eslint-disable */
import _ from 'lodash'
import XLSX from 'xlsx'
import XLSXS from 'xlsx-js-style'
export function sum(arr) {
if (!arr.length) {
return 0
}
return arr.reduce(function (prev, curr, idx, arr) {
return prev + curr
})
}
const strLength = (fData) => {
if (!fData) {
return 0
}
if (fData.length && typeof fData === 'object') {
fData = fData.join(' ')
}
let intLength = 0
for (let i = 0; i < fData.length; i++) {
if ((fData.charCodeAt(i) < 0) || (fData.charCodeAt(i) > 255)) {
intLength = intLength + 2
}
else {
intLength = intLength + 1
}
}
return Math.floor(intLength * 7)
}
String.prototype.pxWidth = function (font) {
// re-use canvas object for better performance
const canvas = String.prototype.pxWidth.canvas || (String.prototype.pxWidth.canvas = document.createElement("canvas")),
context = canvas.getContext("2d");
font && (context.font = font);
const metrics = context.measureText(this);
return metrics.width;
}
export function getCITableColumns(data, attrList, width = 1600, height) {
// 计算出来 主table的列表 布局属性
const _attrList = _.orderBy(attrList, ['is_fixed'], ['desc'])
const columns = []
for (let attr of _attrList) {
const editRender = { name: 'input' }
switch (attr.value_type) {
case '0':
editRender['props'] = { 'type': 'float' }
break
case '1':
editRender['props'] = { 'type': 'float' }
break
case '2':
editRender['attrs'] = { 'type': 'text' }
break
case '3':
editRender['props'] = { 'type': 'datetime' }
break
case "4":
editRender['props'] = { 'type': 'date' }
break
case '5':
editRender['props'] = { 'type': 'time' }
break
case '6':
editRender['props'] = { 'type': 'text' }
break
default:
editRender['props'] = { 'type': 'text' }
break
}
if (attr.is_choice) {
editRender.name = '$select'
editRender.options = attr.choice_value ? attr.choice_value.map(item => { return { label: item, value: item } }) : []
delete editRender.props
}
columns.push({
attr_id:attr.id,
editRender,
title: attr.alias || attr.name,
field: attr.name,
value_type: attr.value_type,
sortable: !!attr.is_sortable,
filters: attr.is_choice ? attr.choice_value : null,
width: Math.min(Math.max(100, ...data.map(item => strLength(item[attr.name]))), 350),
is_link: attr.is_link,
is_password: attr.is_password,
is_list: attr.is_list,
is_choice: attr.is_choice,
is_fixed: attr.is_fixed,
})
}
const totalWidth = sum(columns.map(col => col.width))
if (totalWidth < width) {
columns.map(item => {
// if (item.width === 100) {
delete item.width
// }
})
}
return columns
}
export const getPropertyStyle = (attr) => {
switch (attr.value_type) {
case '0':
return { color: '#cf1322', backgroundColor: '#fff1f0' }
case '1':
return { color: '#d4b106', backgroundColor: '#feffe6' }
case '2':
return { color: '#d46b08', backgroundColor: '#fff7e6' }
case '3':
return { color: '#531dab', backgroundColor: '#f9f0ff' }
case '4':
return { color: '#389e0d', backgroundColor: '#f6ffed' }
case '5':
return { color: '#08979c', backgroundColor: '#e6fffb' }
case '6':
return { color: '#c41d7f', backgroundColor: '#fff0f6' }
case '7':
return { color: '#0390CC', backgroundColor: '#e6fffb' }
case '8':
return { color: '#144BD9', backgroundColor: '#fff0f6' }
}
}
export const getLastLayout = (data, x1 = 0, y1 = 0, w1 = 0) => {
const _tempData = _.orderBy(data, ['y', 'x'], ['asc', 'asc'])
if (!_tempData.length) {
return { xLast: 0, yLast: 0, wLast: 0 }
}
const { x, y, w } = _tempData[_tempData.length - 1]
if (y < y1) {
return { xLast: x1, yLast: y1, wLast: w1 }
} else if (y > y1) {
return { xLast: x, yLast: y, wLast: w }
} else {
const xLast = _.max([x, x1])
return { xLast, yLast: y, wLast: xLast === x ? w : w1 }
}
}
// 数字加逗号
export const toThousands = (num = 0) => {
return num.toString().replace(/\d+/, function (n) {
return n.replace(/(\d)(?=(?:\d{3})+$)/g, '$1,')
})
}
export const downloadExcel = (data, fileName = `${moment().format('YYYY-MM-DD HH:mm:ss')}.xls`) => {
// STEP 1: Create a new workbook
const wb = XLSXS.utils.book_new()
// STEP 2: Create data rows and styles
const rowArray = data
// STEP 3: Create worksheet with rows; Add worksheet to workbook
const ws = XLSXS.utils.aoa_to_sheet(rowArray)
XLSXS.utils.book_append_sheet(wb, ws, fileName)
let maxColumnNumber = 1 // 默认最大列数
rowArray.forEach(item => { if (item.length > maxColumnNumber) { maxColumnNumber = item.length } })
// 添加列宽
ws['!cols'] = (rowArray[0].map(item => {
return { width: 22 }
}))
// // 添加行高
// ws['!rows'] = [{ 'hpt': 80 }]
// STEP 4: Write Excel file to browser #导出
XLSXS.writeFile(wb, fileName + '.xlsx')
}

View File

@@ -5,7 +5,7 @@
</div>
<a-row>
<a-col :span="12">
<upload-file-form ref="uploadFileForm" @uploadDone="uploadDone"></upload-file-form>
<upload-file-form :ciType="ciType" ref="uploadFileForm" @uploadDone="uploadDone"></upload-file-form>
</a-col>
<a-col :span="24" v-if="ciType && uploadData.length">
<CiUploadTable :ciTypeAttrs="ciTypeAttrs" ref="ciUploadTable" :uploadData="uploadData"></CiUploadTable>

View File

@@ -7,6 +7,7 @@
accept=".xls,.xlsx"
:showUploadList="false"
:fileList="fileList"
:disabled="!ciType"
>
<img :style="{ width: '80px', height: '80px' }" src="@/assets/file_upload.png" />
<p class="ant-upload-text">点击或拖拽文件至此上传</p>
@@ -24,6 +25,12 @@ import { processFile } from '@/modules/cmdb/api/batch'
export default {
name: 'UploadFileForm',
props: {
ciType: {
type: Number,
default: 0,
}
},
data() {
return {
ciItemNum: 0,

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@
:key="attr.name"
v-for="attr in group.attributes"
>
<CiDetailAttrContent :ci="ci" :attr="attr" @refresh="refresh" />
<CiDetailAttrContent :ci="ci" :attr="attr" @refresh="refresh" @updateCIByself="updateCIByself" />
</el-descriptions-item>
</el-descriptions>
</div>
@@ -97,6 +97,7 @@
</template>
<script>
import _ from 'lodash'
import { Descriptions, DescriptionsItem } from 'element-ui'
import { getCITypeGroupById, getCITypes } from '@/modules/cmdb/api/CIType'
import { getCIHistory } from '@/modules/cmdb/api/history'
@@ -292,6 +293,19 @@ export default {
}
}
},
updateCIByself(params, editAttrName) {
const _ci = { ..._.cloneDeep(this.ci), ...params }
this.ci = _ci
const _find = this.treeViewsLevels.find((level) => level.name === editAttrName)
// 修改的字段为树形视图订阅的字段 则全部reload
setTimeout(() => {
if (_find) {
this.reload()
} else {
this.handleSearch()
}
}, 500)
},
},
}
</script>

View File

@@ -1,359 +1,377 @@
<template>
<CustomDrawer
:title="title + CIType.alias"
width="800"
@close="handleClose"
:maskClosable="false"
:visible="visible"
wrapClassName="create-instance-form"
:bodyStyle="{ paddingTop: 0 }"
:headerStyle="{ borderBottom: 'none' }"
>
<div class="custom-drawer-bottom-action">
<a-button @click="handleClose">取消</a-button>
<a-button type="primary" @click="createInstance">提交</a-button>
</div>
<template v-if="action === 'create'">
<template v-for="group in attributesByGroup">
<CreateInstanceFormByGroup
:ref="`createInstanceFormByGroup_${group.id}`"
:key="group.id || group.name"
:group="group"
@handleFocusInput="handleFocusInput"
:attributeList="attributeList"
/>
</template>
<template v-if="parentsType && parentsType.length">
<a-divider style="font-size:14px;margin:14px 0;font-weight:700;">模型关系</a-divider>
<a-form>
<a-row :gutter="24" align="top" type="flex">
<a-col :span="12" v-for="item in parentsType" :key="item.id">
<a-form-item :label="item.alias || item.name" :colon="false">
<a-input-group compact style="width: 100%">
<a-select v-model="parentsForm[item.name].attr">
<a-select-option
:title="attr.alias || attr.name"
v-for="attr in item.attributes"
:key="attr.name"
:value="attr.name"
>
{{ attr.alias || attr.name }}
</a-select-option>
</a-select>
<a-input placeholder="多个值使用,分割" v-model="parentsForm[item.name].value" style="width: 50%" />
</a-input-group>
</a-form-item>
</a-col>
</a-row>
</a-form>
</template>
</template>
<template v-if="action === 'update'">
<a-form :form="form">
<p>可根据需要修改字段当值为<strong></strong>则该字段<strong>置空</strong></p>
<a-row :gutter="24" v-for="list in batchUpdateLists" :key="list.name">
<a-col :span="11">
<a-form-item>
<el-select showSearch size="small" filterable v-model="list.name" placeholder="请选择需要修改的字段">
<el-option
v-for="attr in attributeList"
:key="attr.name"
:value="attr.name"
:disabled="batchUpdateLists.findIndex((item) => item.name === attr.name) > -1"
:label="attr.alias || attr.name"
>
</el-option>
</el-select>
</a-form-item>
</a-col>
<a-col :span="11">
<a-form-item>
<a-select
:style="{ width: '100%' }"
v-decorator="[list.name, { rules: [{ required: false }] }]"
placeholder="请选择"
v-if="getFieldType(list.name).split('%%')[0] === 'select'"
:mode="getFieldType(list.name).split('%%')[1] === 'multiple' ? 'multiple' : 'default'"
showSearch
allowClear
>
<a-select-option
:value="choice[0]"
:key="'New_' + choice + choice_idx"
v-for="(choice, choice_idx) in getSelectFieldOptions(list.name)"
>
<span :style="choice[1] ? choice[1].style || {} : {}">
<ops-icon
:style="{ color: choice[1].icon.color }"
v-if="choice[1] && choice[1].icon && choice[1].icon.name"
:type="choice[1].icon.name"
/>
{{ choice[0] }}
</span>
</a-select-option>
</a-select>
<a-input-number
v-decorator="[list.name, { rules: [{ required: false }] }]"
style="width: 100%"
v-if="getFieldType(list.name) === 'input_number'"
/>
<a-date-picker
v-decorator="[list.name, { rules: [{ required: false }] }]"
style="width: 100%"
:format="getFieldType(list.name) == 'date' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss'"
v-if="getFieldType(list.name) === 'date' || getFieldType(list.name) === 'datetime'"
:showTime="getFieldType(list.name) === 'date' ? false : { format: 'HH:mm:ss' }"
/>
<a-input
v-if="getFieldType(list.name) === 'input'"
@focus="(e) => handleFocusInput(e, list)"
v-decorator="[list.name, { rules: [{ required: false }] }]"
/>
</a-form-item>
</a-col>
<a-col :span="2">
<a-form-item>
<a :style="{ color: 'red', marginTop: '2px' }" @click="handleDelete(list.name)">
<a-icon type="delete"/>
</a>
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" ghost icon="plus" @click="handleAdd">新增修改字段</a-button>
</a-form>
</template>
<JsonEditor ref="jsonEditor" @jsonEditorOk="jsonEditorOk" />
</CustomDrawer>
</template>
<script>
import moment from 'moment'
import { Select, Option } from 'element-ui'
import { getCIType, getCITypeGroupById } from '@/modules/cmdb/api/CIType'
import { addCI } from '@/modules/cmdb/api/ci'
import JsonEditor from '../../../components/JsonEditor/jsonEditor.vue'
import { valueTypeMap } from '../../../utils/const'
import CreateInstanceFormByGroup from './createInstanceFormByGroup.vue'
import { getCITypeParent } from '@/modules/cmdb/api/CITypeRelation'
export default {
name: 'CreateInstanceForm',
components: {
ElSelect: Select,
ElOption: Option,
JsonEditor,
CreateInstanceFormByGroup,
},
props: {
typeIdFromRelation: {
type: Number,
default: 0,
},
},
data() {
return {
valueTypeMap,
action: '',
form: this.$form.createForm(this),
visible: false,
attributeList: [],
CIType: {},
batchUpdateLists: [],
editAttr: null,
attributesByGroup: [],
parentsType: [],
parentsForm: {},
}
},
computed: {
title() {
return this.action === 'create' ? '创建 ' : '批量修改 '
},
typeId() {
if (this.typeIdFromRelation) {
return this.typeIdFromRelation
}
return this.$router.currentRoute.meta.typeId
},
},
provide() {
return {
getFieldType: this.getFieldType,
}
},
inject: ['attrList'],
methods: {
moment,
async getCIType() {
await getCIType(this.typeId).then((res) => {
this.CIType = res.ci_types[0]
})
},
async getAttributeList() {
const _attrList = this.attrList()
this.attributeList = _attrList.sort((x, y) => y.is_required - x.is_required)
await getCITypeGroupById(this.typeId).then((res1) => {
const _attributesByGroup = res1.map((g) => {
g.attributes = g.attributes.filter((attr) => !attr.is_computed)
return g
})
const attrHasGroupIds = []
res1.forEach((g) => {
const id = g.attributes.map((attr) => attr.id)
attrHasGroupIds.push(...id)
})
const otherGroupAttr = this.attributeList.filter(
(attr) => !attrHasGroupIds.includes(attr.id) && !attr.is_computed
)
if (otherGroupAttr.length) {
_attributesByGroup.push({ id: -1, name: '其他', attributes: otherGroupAttr })
}
this.attributesByGroup = _attributesByGroup
})
},
createInstance() {
const _this = this
if (_this.action === 'update') {
this.form.validateFields((err, values) => {
if (err) {
return
}
Object.keys(values).forEach((k) => {
const _tempFind = this.attributeList.find((item) => item.name === k)
if (_tempFind.value_type === '3' && values[k]) {
values[k] = values[k].format('YYYY-MM-DD HH:mm:ss')
}
if (_tempFind.value_type === '4' && values[k]) {
values[k] = values[k].format('YYYY-MM-DD')
}
if (_tempFind.value_type === '6') {
values[k] = values[k] ? JSON.parse(values[k]) : undefined
}
})
_this.$emit('submit', values)
})
} else {
let values = {}
for (let i = 0; i < this.attributesByGroup.length; i++) {
const data = this.$refs[`createInstanceFormByGroup_${this.attributesByGroup[i].id}`][0].getData()
if (data === 'error') {
return
}
values = { ...values, ...data }
}
Object.keys(values).forEach((k) => {
const _tempFind = this.attributeList.find((item) => item.name === k)
if (_tempFind.value_type === '3' && values[k]) {
values[k] = values[k].format('YYYY-MM-DD HH:mm:ss')
}
if (_tempFind.value_type === '4' && values[k]) {
values[k] = values[k].format('YYYY-MM-DD')
}
if (_tempFind.value_type === '6') {
values[k] = values[k] ? JSON.parse(values[k]) : undefined
}
})
values.ci_type = _this.typeId
Object.keys(this.parentsForm).forEach((type) => {
if (this.parentsForm[type].value) {
values[`$${type}.${this.parentsForm[type].attr}`] = this.parentsForm[type].value
}
})
addCI(values).then((res) => {
_this.$message.success('新增成功!')
_this.visible = false
_this.$emit('reload', { ci_id: res.ci_id })
})
}
},
handleClose() {
this.visible = false
},
handleOpen(visible, action) {
this.visible = visible
this.action = action
this.$nextTick(() => {
this.form.resetFields()
Promise.all([this.getCIType(), this.getAttributeList()]).then(() => {
this.batchUpdateLists = [{ name: this.attributeList[0].name }]
})
if (action === 'create') {
getCITypeParent(this.typeId).then((res) => {
this.parentsType = res.parents
const _parentsForm = {}
res.parents.forEach((item) => {
const _find = item.attributes.find((attr) => attr.id === item.unique_id)
_parentsForm[item.name] = { attr: _find.name, value: '' }
})
this.parentsForm = _parentsForm
})
}
})
},
getFieldType(name) {
const _find = this.attributeList.find((item) => item.name === name)
if (_find) {
if (_find.is_choice) {
if (_find.is_list) {
return 'select%%multiple'
}
return 'select'
} else if (_find.value_type === '0' || _find.value_type === '1') {
return 'input_number'
} else if (_find.value_type === '4' || _find.value_type === '3') {
return valueTypeMap[_find.value_type]
} else {
return 'input'
}
}
return 'input'
},
getSelectFieldOptions(name) {
const _find = this.attributeList.find((item) => item.name === name)
if (_find) {
return _find.choice_value
}
return []
},
handleAdd() {
this.batchUpdateLists.push({ name: undefined })
},
handleDelete(name) {
const _idx = this.batchUpdateLists.findIndex((item) => item.name === name)
if (_idx > -1) {
this.batchUpdateLists.splice(_idx, 1)
}
},
handleFocusInput(e, attr) {
console.log(attr)
const _tempFind = this.attributeList.find((item) => item.name === attr.name)
if (_tempFind.value_type === '6') {
this.editAttr = attr
e.srcElement.blur()
const jsonData = this.form.getFieldValue(attr.name)
this.$refs.jsonEditor.open(null, null, jsonData ? JSON.parse(jsonData) : {})
} else {
this.editAttr = null
}
},
jsonEditorOk(jsonData) {
this.form.setFieldsValue({ [this.editAttr.name]: JSON.stringify(jsonData) })
},
},
}
</script>
<style lang="less">
.create-instance-form {
.ant-form-item {
margin-bottom: 5px;
}
.ant-drawer-body {
overflow-y: auto;
max-height: calc(100vh - 110px);
}
}
</style>
<template>
<CustomDrawer
:title="title + CIType.alias"
width="800"
@close="handleClose"
:maskClosable="false"
:visible="visible"
wrapClassName="create-instance-form"
:bodyStyle="{ paddingTop: 0 }"
:headerStyle="{ borderBottom: 'none' }"
>
<div class="custom-drawer-bottom-action">
<a-button @click="handleClose">取消</a-button>
<a-button type="primary" @click="createInstance">提交</a-button>
</div>
<template v-if="action === 'create'">
<template v-for="group in attributesByGroup">
<CreateInstanceFormByGroup
:ref="`createInstanceFormByGroup_${group.id}`"
:key="group.id || group.name"
:group="group"
@handleFocusInput="handleFocusInput"
:attributeList="attributeList"
/>
</template>
<template v-if="parentsType && parentsType.length">
<a-divider style="font-size:14px;margin:14px 0;font-weight:700;">模型关系</a-divider>
<a-form>
<a-row :gutter="24" align="top" type="flex">
<a-col :span="12" v-for="item in parentsType" :key="item.id">
<a-form-item :label="item.alias || item.name" :colon="false">
<a-input-group compact style="width: 100%">
<a-select v-model="parentsForm[item.name].attr">
<a-select-option
:title="attr.alias || attr.name"
v-for="attr in item.attributes"
:key="attr.name"
:value="attr.name"
>
{{ attr.alias || attr.name }}
</a-select-option>
</a-select>
<a-input placeholder="多个值使用,分割" v-model="parentsForm[item.name].value" style="width: 50%" />
</a-input-group>
</a-form-item>
</a-col>
</a-row>
</a-form>
</template>
</template>
<template v-if="action === 'update'">
<a-form :form="form">
<p>可根据需要修改字段当值为<strong></strong>则该字段<strong>置空</strong></p>
<a-row :gutter="24" v-for="list in batchUpdateLists" :key="list.name">
<a-col :span="11">
<a-form-item>
<el-select showSearch size="small" filterable v-model="list.name" placeholder="请选择需要修改的字段">
<el-option
v-for="attr in attributeList"
:key="attr.name"
:value="attr.name"
:disabled="batchUpdateLists.findIndex((item) => item.name === attr.name) > -1"
:label="attr.alias || attr.name"
>
</el-option>
</el-select>
</a-form-item>
</a-col>
<a-col :span="11">
<a-form-item>
<a-select
:style="{ width: '100%' }"
v-decorator="[list.name, { rules: [{ required: false }] }]"
placeholder="请选择"
v-if="getFieldType(list.name).split('%%')[0] === 'select'"
:mode="getFieldType(list.name).split('%%')[1] === 'multiple' ? 'multiple' : 'default'"
showSearch
allowClear
>
<a-select-option
:value="choice[0]"
:key="'New_' + choice + choice_idx"
v-for="(choice, choice_idx) in getSelectFieldOptions(list.name)"
>
<span :style="choice[1] ? choice[1].style || {} : {}">
<ops-icon
:style="{ color: choice[1].icon.color }"
v-if="choice[1] && choice[1].icon && choice[1].icon.name"
:type="choice[1].icon.name"
/>
{{ choice[0] }}
</span>
</a-select-option>
</a-select>
<a-input-number
v-decorator="[list.name, { rules: [{ required: false }] }]"
style="width: 100%"
v-if="getFieldType(list.name) === 'input_number'"
/>
<a-date-picker
v-decorator="[list.name, { rules: [{ required: false }] }]"
style="width: 100%"
:format="getFieldType(list.name) == 'date' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss'"
v-if="getFieldType(list.name) === 'date' || getFieldType(list.name) === 'datetime'"
:showTime="getFieldType(list.name) === 'date' ? false : { format: 'HH:mm:ss' }"
/>
<a-input
v-if="getFieldType(list.name) === 'input'"
@focus="(e) => handleFocusInput(e, list)"
v-decorator="[list.name, { rules: [{ required: false }] }]"
/>
</a-form-item>
</a-col>
<a-col :span="2">
<a-form-item>
<a :style="{ color: 'red', marginTop: '2px' }" @click="handleDelete(list.name)">
<a-icon type="delete" />
</a>
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" ghost icon="plus" @click="handleAdd">新增修改字段</a-button>
</a-form>
</template>
<!-- </a-form> -->
<JsonEditor ref="jsonEditor" @jsonEditorOk="jsonEditorOk" />
</CustomDrawer>
</template>
<script>
import moment from 'moment'
import { Select, Option } from 'element-ui'
import { getCIType, getCITypeGroupById } from '@/modules/cmdb/api/CIType'
import { addCI } from '@/modules/cmdb/api/ci'
import JsonEditor from '../../../components/JsonEditor/jsonEditor.vue'
import { valueTypeMap } from '../../../utils/const'
import CreateInstanceFormByGroup from './createInstanceFormByGroup.vue'
import { getCITypeParent } from '@/modules/cmdb/api/CITypeRelation'
export default {
name: 'CreateInstanceForm',
components: {
ElSelect: Select,
ElOption: Option,
JsonEditor,
CreateInstanceFormByGroup,
},
props: {
typeIdFromRelation: {
type: Number,
default: 0,
},
},
data() {
return {
valueTypeMap,
action: '',
form: this.$form.createForm(this),
visible: false,
attributeList: [],
CIType: {},
batchUpdateLists: [],
editAttr: null,
attributesByGroup: [],
parentsType: [],
parentsForm: {},
}
},
computed: {
title() {
return this.action === 'create' ? '创建 ' : '批量修改 '
},
typeId() {
if (this.typeIdFromRelation) {
return this.typeIdFromRelation
}
return this.$router.currentRoute.meta.typeId
},
},
provide() {
return {
getFieldType: this.getFieldType,
}
},
inject: ['attrList'],
methods: {
moment,
async getCIType() {
await getCIType(this.typeId).then((res) => {
this.CIType = res.ci_types[0]
})
},
async getAttributeList() {
const _attrList = this.attrList()
this.attributeList = _attrList.sort((x, y) => y.is_required - x.is_required)
await getCITypeGroupById(this.typeId).then((res1) => {
const _attributesByGroup = res1.map((g) => {
g.attributes = g.attributes.filter((attr) => !attr.is_computed)
return g
})
const attrHasGroupIds = []
res1.forEach((g) => {
const id = g.attributes.map((attr) => attr.id)
attrHasGroupIds.push(...id)
})
const otherGroupAttr = this.attributeList.filter(
(attr) => !attrHasGroupIds.includes(attr.id) && !attr.is_computed
)
if (otherGroupAttr.length) {
_attributesByGroup.push({ id: -1, name: '其他', attributes: otherGroupAttr })
}
this.attributesByGroup = _attributesByGroup
})
},
createInstance() {
const _this = this
if (_this.action === 'update') {
this.form.validateFields((err, values) => {
if (err) {
return
}
Object.keys(values).forEach((k) => {
const _tempFind = this.attributeList.find((item) => item.name === k)
if (
_tempFind.value_type === '3' &&
values[k] &&
Object.prototype.toString.call(values[k]) === '[object Object]'
) {
values[k] = values[k].format('YYYY-MM-DD HH:mm:ss')
}
if (
_tempFind.value_type === '4' &&
values[k] &&
Object.prototype.toString.call(values[k]) === '[object Object]'
) {
values[k] = values[k].format('YYYY-MM-DD')
}
if (_tempFind.value_type === '6') {
values[k] = values[k] ? JSON.parse(values[k]) : undefined
}
})
_this.$emit('submit', values)
})
} else {
let values = {}
for (let i = 0; i < this.attributesByGroup.length; i++) {
const data = this.$refs[`createInstanceFormByGroup_${this.attributesByGroup[i].id}`][0].getData()
if (data === 'error') {
return
}
values = { ...values, ...data }
}
Object.keys(values).forEach((k) => {
const _tempFind = this.attributeList.find((item) => item.name === k)
if (
_tempFind.value_type === '3' &&
values[k] &&
Object.prototype.toString.call(values[k]) === '[object Object]'
) {
values[k] = values[k].format('YYYY-MM-DD HH:mm:ss')
}
if (
_tempFind.value_type === '4' &&
values[k] &&
Object.prototype.toString.call(values[k]) === '[object Object]'
) {
values[k] = values[k].format('YYYY-MM-DD')
}
if (_tempFind.value_type === '6') {
values[k] = values[k] ? JSON.parse(values[k]) : undefined
}
})
values.ci_type = _this.typeId
console.log(this.parentsForm)
Object.keys(this.parentsForm).forEach((type) => {
if (this.parentsForm[type].value) {
values[`$${type}.${this.parentsForm[type].attr}`] = this.parentsForm[type].value
}
})
addCI(values).then((res) => {
_this.$message.success('新增成功!')
_this.visible = false
_this.$emit('reload', { ci_id: res.ci_id })
})
}
},
handleClose() {
this.visible = false
},
handleOpen(visible, action) {
this.visible = visible
this.action = action
this.$nextTick(() => {
this.form.resetFields()
Promise.all([this.getCIType(), this.getAttributeList()]).then(() => {
this.batchUpdateLists = [{ name: this.attributeList[0].name }]
})
if (action === 'create') {
getCITypeParent(this.typeId).then((res) => {
this.parentsType = res.parents
const _parentsForm = {}
res.parents.forEach((item) => {
const _find = item.attributes.find((attr) => attr.id === item.unique_id)
_parentsForm[item.name] = { attr: _find.name, value: '' }
})
this.parentsForm = _parentsForm
})
}
})
},
getFieldType(name) {
const _find = this.attributeList.find((item) => item.name === name)
if (_find) {
if (_find.is_choice) {
if (_find.is_list) {
return 'select%%multiple'
}
return 'select'
} else if (_find.value_type === '0' || _find.value_type === '1') {
return 'input_number'
} else if (_find.value_type === '4' || _find.value_type === '3') {
return valueTypeMap[_find.value_type]
} else {
return 'input'
}
}
return 'input'
},
getSelectFieldOptions(name) {
const _find = this.attributeList.find((item) => item.name === name)
if (_find) {
return _find.choice_value
}
return []
},
handleAdd() {
this.batchUpdateLists.push({ name: undefined })
},
handleDelete(name) {
const _idx = this.batchUpdateLists.findIndex((item) => item.name === name)
if (_idx > -1) {
this.batchUpdateLists.splice(_idx, 1)
}
},
handleFocusInput(e, attr) {
console.log(attr)
const _tempFind = this.attributeList.find((item) => item.name === attr.name)
if (_tempFind.value_type === '6') {
this.editAttr = attr
e.srcElement.blur()
const jsonData = this.form.getFieldValue(attr.name)
this.$refs.jsonEditor.open(null, null, jsonData ? JSON.parse(jsonData) : {})
} else {
this.editAttr = null
}
},
jsonEditorOk(jsonData) {
this.form.setFieldsValue({ [this.editAttr.name]: JSON.stringify(jsonData) })
},
},
}
</script>
<style lang="less">
.create-instance-form {
.ant-form-item {
margin-bottom: 5px;
}
.ant-drawer-body {
overflow-y: auto;
max-height: calc(100vh - 110px);
}
}
</style>

View File

@@ -1,215 +1,225 @@
<template>
<CustomDrawer
:visible="visible"
:hasFooter="false"
@close="
() => {
visible = false
}
"
title="属性说明"
width="72%"
:bodyStyle="{ height: '100vh' }"
>
<vxe-toolbar>
<template #buttons>
<a-input
v-model="searchKey"
:style="{ display: 'inline-block', width: '244px' }"
class="ops-input ops-input-radius"
type="search"
placeholder="搜索 名称 | 别名"
@keyup="searchAttributes"
>
<a-icon type="search" slot="suffix" />
</a-input>
</template>
</vxe-toolbar>
<a-spin :spinning="loading">
<vxe-table
resizable
border
size="mini"
:height="windowHeight - 160"
:data="list"
:scroll-x="{ enabled: true, gt: 0 }"
show-overflow
show-header-overflow
align="center"
highlight-hover-row
class="ops-stripe-table"
>
<vxe-column
v-for="(column, index) in columns"
:field="column.field"
:title="column.title"
:min-width="column.width"
:align="column.align"
:key="column.field"
:fixed="index < 3 ? 'left' : ''"
:sortable="index < 3 ? true : false"
:title-help="column.help !== null ? { message: column.help } : null"
:filters="
index < 2 ? null: index === 2
? valueTypeFilters: [{ label: '', value: true }, { label: '', value: false },]"
type="html"
>
<template #default="{ row }">
<span v-if="column.field !== 'name' && column.field !== 'alias' && column.field !== 'value_type'">
<a-icon :style="{ color: '#1fb51f' }" type="check" v-if="row[column.field]" />
</span>
<span v-else-if="column.field === 'value_type'" v-html="valueTypeMap[row.value_type]"> </span>
<span v-else v-html="row[column.field]"> </span>
</template>
</vxe-column>
</vxe-table>
</a-spin>
</CustomDrawer>
</template>
<script>
import XEUtils from 'xe-utils'
import { getCITypeAttributesByName } from '@/modules/cmdb/api/CITypeAttr'
import { valueTypeMap } from '@/modules/cmdb/utils/const'
export default {
name: 'MetadataDrawer',
data() {
const columns = [
{
field: 'name',
title: '名称',
width: 150,
align: 'left',
help: null,
},
{
field: 'alias',
title: '',
width: 150,
align: 'left',
help: null,
},
{
field: 'value_type',
title: '类型',
width: 100,
align: 'left',
help: null,
},
{
field: 'is_index',
title: '是否索引',
width: 110,
help: '加快检索, 可以全文搜索, 无需使用条件过滤\n\n json目前不支持建索引 \n\n文本字符长度超过190不能建索引',
},
{
field: 'default_show',
title: '默认显示',
width: 110,
help: '订阅CI默认显示在table里的属性',
},
{
field: 'is_unique',
title: '是否唯一',
width: 110,
help: null,
},
{
field: 'is_choice',
title: '是否选择',
width: 110,
help: '表现形式是下拉框, 值必须在预定义值里',
},
{
field: 'is_list',
title: '是否列表',
width: 110,
help: '多值, 比如内网IP',
},
{
field: 'is_sortable',
title: '可排序',
width: 100,
help: '仅针对前端',
},
{
field: 'is_password',
title: '是否密码',
width: 100,
help: null,
},
{
field: 'is_link',
title: '是否链接',
width: 110,
help: null,
},
{
field: 'is_computed',
title: '计算属性',
width: 110,
help: '模型的其他属性通过表达式的方式计算出来\n\n一个代码片段计算返回的值',
},
]
return {
columns,
visible: false,
list: [],
tableData: [],
loading: false,
valueTypeMap,
valueTypeFilters: [],
searchKey: '',
}
},
computed: {
windowHeight() {
return this.$store.state.windowHeight
},
},
created: function () {
this.valueTypeFilters = Object.keys(this.valueTypeMap).map((key) => {
return { label: this.valueTypeMap[key], value: key }
})
},
methods: {
open(typeId) {
this.visible = true
this.typeId = typeId
this.getAttrs()
},
async getAttrs() {
this.loading = true
const { attributes = [] } = await getCITypeAttributesByName(this.typeId)
this.tableData = attributes
this.loading = false
this.searchAttributes()
},
searchAttributes() {
const filterName = XEUtils.toValueString(this.searchKey).trim().toLowerCase()
if (filterName) {
const filterRE = new RegExp(filterName, 'gi')
const searchProps = ['name', 'alias', 'value_type']
const rest = this.tableData.filter((item) =>
searchProps.some((key) => XEUtils.toValueString(item[key]).toLowerCase().indexOf(filterName) > -1)
)
this.list = rest.map((row) => {
const item = Object.assign({}, row)
searchProps.forEach((key) => {
item[key] = XEUtils.toValueString(item[key]).replace(
filterRE,
(match) => `<span style='background: yellow'>${match}</span>`
)
})
return item
})
} else {
this.list = this.tableData
}
},
},
}
</script>
<style></style>
<template>
<CustomDrawer
:visible="visible"
:hasFooter="false"
@close="
() => {
visible = false
}
"
title="属性说明"
width="72%"
:bodyStyle="{ height: '100vh' }"
>
<vxe-toolbar>
<template #buttons>
<a-input
v-model="searchKey"
:style="{ display: 'inline-block', width: '244px' }"
class="ops-input ops-input-radius"
type="search"
placeholder="搜索 名称 | 别名"
@keyup="searchAttributes"
>
<a-icon type="search" slot="suffix" />
</a-input>
</template>
</vxe-toolbar>
<a-spin :spinning="loading">
<vxe-table
resizable
border
size="mini"
:height="windowHeight - 160"
:data="list"
:scroll-x="{ enabled: true, gt: 0 }"
show-overflow
show-header-overflow
align="center"
highlight-hover-row
class="ops-stripe-table"
>
<vxe-column
v-for="(column, index) in columns"
:field="column.field"
:title="column.title"
:min-width="column.width"
:align="column.align"
:key="column.field"
:fixed="index < 3 ? 'left' : ''"
:sortable="index < 3 ? true : false"
:title-help="column.help !== null ? { message: column.help } : null"
:filters="
index < 2
? null
: index === 2
? valueTypeFilters
: [
{ label: '', value: true },
{ label: '', value: false },
]
"
type="html"
>
<template #default="{ row }">
<span v-if="column.field !== 'name' && column.field !== 'alias' && column.field !== 'value_type'">
<a-icon :style="{ color: '#1fb51f' }" type="check" v-if="row[column.field]" />
</span>
<span v-else-if="column.field === 'value_type'" v-html="valueTypeMap[row.value_type]"> </span>
<span v-else v-html="row[column.field]"> </span>
</template>
</vxe-column>
</vxe-table>
</a-spin>
</CustomDrawer>
</template>
<script>
import XEUtils from 'xe-utils'
import { getCITypeAttributesByName } from '@/modules/cmdb/api/CITypeAttr'
import { valueTypeMap } from '@/modules/cmdb/utils/const'
export default {
name: 'MetadataDrawer',
data() {
const columns = [
{
field: 'name',
title: '',
width: 150,
align: 'left',
help: null,
},
{
field: 'alias',
title: '别名',
width: 150,
align: 'left',
help: null,
},
{
field: 'value_type',
title: '类型',
width: 100,
align: 'left',
help: null,
},
{
field: 'is_index',
title: '是否索引',
width: 110,
help: '加快检索, 可以全文搜索, 无需使用条件过滤\n\n json目前不支持建索引 \n\n文本字符长度超过190不能建索引',
},
{
field: 'default_show',
title: '默认显示',
width: 110,
help: '订阅CI默认显示在table里的属性',
},
{
field: 'is_unique',
title: '是否唯一',
width: 110,
help: null,
},
{
field: 'is_choice',
title: '是否选择',
width: 110,
help: '表现形式是下拉框, 值必须在预定义值里',
},
{
field: 'is_list',
title: '是否列表',
width: 110,
help: '多值, 比如内网IP',
},
{
field: 'is_sortable',
title: '可排序',
width: 100,
help: '仅针对前端',
},
{
field: 'is_computed',
title: '计算属性',
width: 110,
help: '模型的其他属性通过表达式的方式计算出来\n\n一个代码片段计算返回的值',
},
]
return {
columns,
visible: false,
list: [],
tableData: [],
loading: false,
valueTypeMap,
valueTypeFilters: [],
searchKey: '',
}
},
computed: {
windowHeight() {
return this.$store.state.windowHeight
},
},
created: function() {
this.valueTypeFilters = Object.keys(this.valueTypeMap).map((key) => {
return { label: this.valueTypeMap[key], value: key }
})
},
methods: {
open(typeId) {
this.visible = true
this.typeId = typeId
this.getAttrs()
},
async getAttrs() {
this.loading = true
const { attributes = [] } = await getCITypeAttributesByName(this.typeId)
this.tableData = attributes.map((attr) => {
if (attr.is_password) {
attr.value_type = '7'
}
if (attr.is_link) {
attr.value_type = '8'
}
return attr
})
this.loading = false
this.searchAttributes()
},
searchAttributes() {
const filterName = XEUtils.toValueString(this.searchKey)
.trim()
.toLowerCase()
if (filterName) {
const filterRE = new RegExp(filterName, 'gi')
const searchProps = ['name', 'alias', 'value_type']
const rest = this.tableData.filter((item) =>
searchProps.some(
(key) =>
XEUtils.toValueString(item[key])
.toLowerCase()
.indexOf(filterName) > -1
)
)
this.list = rest.map((row) => {
const item = Object.assign({}, row)
searchProps.forEach((key) => {
item[key] = XEUtils.toValueString(item[key]).replace(
filterRE,
(match) => `<span style='background: yellow'>${match}</span>`
)
})
return item
})
} else {
this.list = this.tableData
}
},
},
}
</script>
<style></style>

View File

@@ -1,234 +1,299 @@
<template>
<span :id="`ci-detail-attr-${attr.name}`">
<span v-if="!isEdit || attr.value_type === '6'">
<template v-if="attr.value_type === '6'">{{ JSON.stringify(ci[attr.name] || {}) }}</template>
<template v-else-if="attr.is_choice">
<template v-if="attr.is_list">
<span
v-for="value in ci[attr.name]"
:key="value"
:style="{
borderRadius: '4px',
padding: '1px 5px',
margin: '2px',
...getChoiceValueStyle(attr, value),
}"
>
<ops-icon
:style="{ color: getChoiceValueIcon(attr, value).color }"
:type="getChoiceValueIcon(attr, value).name"
/>
{{ value }}</span
>
</template>
<span
v-else
:style="{
borderRadius: '4px',
padding: '1px 5px',
margin: '2px 0',
...getChoiceValueStyle(attr, ci[attr.name]),
}"
>
<ops-icon
:style="{ color: getChoiceValueIcon(attr, ci[attr.name]).color }"
:type="getChoiceValueIcon(attr, ci[attr.name]).name"
/>
{{ ci[attr.name] }}
</span>
</template>
<template v-else>{{ getName(ci[attr.name]) }}</template>
</span>
<template v-else>
<a-form :form="form">
<a-form-item label="" :colon="false">
<a-select
:style="{ width: '100%' }"
v-decorator="[
attr.name,
{
rules: [{ required: attr.is_required }],
},
]"
placeholder="请选择"
v-if="attr.is_choice"
:mode="attr.is_list ? 'multiple' : 'default'"
:multiple="attr.is_list"
showSearch
allowClear
size="small"
:getPopupContainer="(trigger) => trigger.parentElement"
>
<a-select-option
:value="choice[0]"
:key="'New_' + attr.name + choice_idx"
v-for="(choice, choice_idx) in attr.choice_value"
>
<span :style="choice[1] ? choice[1].style || {} : {}">
<ops-icon
:style="{ color: choice[1].icon.color }"
v-if="choice[1] && choice[1].icon && choice[1].icon.name"
:type="choice[1].icon.name"
/>
{{ choice[0] }}
</span>
</a-select-option>
</a-select>
<a-input-number
size="small"
v-decorator="[
attr.name,
{
rules: [{ required: attr.is_required }],
},
]"
style="width: 100%"
v-else-if="attr.value_type === '0' || attr.value_type === '1'"
/>
<a-date-picker
size="small"
v-decorator="[
attr.name,
{
rules: [{ required: attr.is_required }],
},
]"
style="width: 100%"
:format="attr.value_type === '4' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss'"
:valueFormat="attr.value_type === '4' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss'"
v-else-if="attr.value_type === '4' || attr.value_type === '3'"
:showTime="attr.value_type === '4' ? false : { format: 'HH:mm:ss' }"
/>
<a-input
size="small"
v-decorator="[
attr.name,
{
validateTrigger: ['submit'],
rules: [{ required: attr.is_required }],
},
]"
style="width: 100%"
v-else
/>
</a-form-item>
</a-form>
</template>
<a v-if="!isEdit && !attr.is_computed" @click="handleEdit" :style="{ opacity: 0 }"><a-icon type="edit"/></a>
<JsonEditor ref="jsonEditor" @jsonEditorOk="jsonEditorOk" />
</span>
</template>
<script>
import _ from 'lodash'
import moment from 'moment'
import { updateCI } from '@/modules/cmdb/api/ci'
import JsonEditor from '../../../components/JsonEditor/jsonEditor.vue'
export default {
name: 'CiDetailAttrContent',
components: { JsonEditor },
props: {
ci: {
type: Object,
default: () => {},
},
attr: {
type: Object,
default: () => {},
},
},
data() {
return {
isEdit: false,
form: this.$form.createForm(this, this.attr.name),
}
},
mounted() {
document.addEventListener('click', this.eventListener)
},
beforeDestroy() {
document.removeEventListener('click', this.eventListener)
},
methods: {
moment,
eventListener(e) {
const datePickerContainer = document.getElementsByClassName('ant-calendar-picker-container')
if (this.isEdit && !datePickerContainer.length) {
const dom = document.getElementById(`ci-detail-attr-${this.attr.name}`)
e.stopPropagation()
e.preventDefault()
if (dom) {
const isSelf = dom.contains(e.target)
if (!isSelf) {
this.handleCloseEdit()
}
}
}
},
handleEdit(e) {
e.stopPropagation()
e.preventDefault()
if (this.attr.value_type === '6') {
const jsonData = this.ci[this.attr.name]
this.$refs.jsonEditor.open(null, null, jsonData || {})
return
}
this.isEdit = true
this.$nextTick(() => {
if (this.attr.is_list && !this.attr.is_choice) {
this.form.setFieldsValue({
[`${this.attr.name}`]: this.ci[this.attr.name].join(',') || null,
})
return
}
this.form.setFieldsValue({
[`${this.attr.name}`]: this.ci[this.attr.name] ?? null,
})
})
},
async handleCloseEdit() {
const newData = this.form.getFieldValue(this.attr.name)
if (!_.isEqual(this.ci[this.attr.name], newData)) {
await updateCI(this.ci._id, { [`${this.attr.name}`]: newData })
.then(() => {
this.$message.success('更新成功!')
})
.finally(() => {
this.$emit('refresh', this.attr.name)
})
}
this.isEdit = false
},
jsonEditorOk(jsonData) {
if (!_.isEqual(this.ci[this.attr.name], jsonData)) {
updateCI(this.ci._id, { [`${this.attr.name}`]: jsonData })
.then(() => {
this.$message.success('更新成功!')
})
.finally(() => {
this.$emit('refresh', this.attr.name)
})
}
},
getChoiceValueStyle(col, colValue) {
const _find = col.choice_value.find((item) => String(item[0]) === String(colValue))
if (_find) {
return _find[1]?.style || {}
}
return {}
},
getChoiceValueIcon(col, colValue) {
const _find = col.choice_value.find((item) => String(item[0]) === String(colValue))
if (_find) {
return _find[1]?.icon || {}
}
return {}
},
getName(name) {
return name ?? ''
},
},
}
</script>
<style></style>
<template>
<span :id="`ci-detail-attr-${attr.name}`">
<span v-if="!isEdit || attr.value_type === '6'">
<PasswordField
:style="{ display: 'inline-block' }"
v-if="attr.is_password && ci[attr.name]"
:ci_id="ci._id"
:attr_id="attr.id"
></PasswordField>
<template v-else-if="attr.value_type === '6'">{{ JSON.stringify(ci[attr.name] || {}) }}</template>
<template v-else-if="attr.is_choice">
<template v-if="attr.is_list">
<span
v-for="value in ci[attr.name]"
:key="value"
:style="{
borderRadius: '4px',
padding: '1px 5px',
margin: '2px',
...getChoiceValueStyle(attr, value),
display: 'inline-flex',
alignItems: 'center',
}"
>
<img
v-if="getChoiceValueIcon(attr, value).id && getChoiceValueIcon(attr, value).url"
:src="`/api/common-setting/v1/file/${getChoiceValueIcon(attr, value).url}`"
:style="{ maxHeight: '13px', maxWidth: '13px', marginRight: '5px' }"
/>
<ops-icon
v-else
:style="{ color: getChoiceValueIcon(attr, value).color, marginRight: '5px' }"
:type="getChoiceValueIcon(attr, value).name"
/>
{{ value }}</span
>
</template>
<span
v-else
:style="{
borderRadius: '4px',
padding: '1px 5px',
margin: '2px 0',
...getChoiceValueStyle(attr, ci[attr.name]),
display: 'inline-flex',
alignItems: 'center',
}"
>
<img
v-if="getChoiceValueIcon(attr, ci[attr.name]).id && getChoiceValueIcon(attr, ci[attr.name]).url"
:src="`/api/common-setting/v1/file/${getChoiceValueIcon(attr, ci[attr.name]).url}`"
:style="{ maxHeight: '13px', maxWidth: '13px', marginRight: '5px' }"
/>
<ops-icon
v-else
:style="{ color: getChoiceValueIcon(attr, ci[attr.name]).color, marginRight: '5px' }"
:type="getChoiceValueIcon(attr, ci[attr.name]).name"
/>
{{ ci[attr.name] }}
</span>
</template>
<template v-else>{{ getName(ci[attr.name]) }}</template>
</span>
<template v-else>
<a-form :form="form">
<a-form-item label="" :colon="false">
<a-select
:style="{ width: '100%' }"
v-decorator="[
attr.name,
{
rules: [{ required: attr.is_required }],
},
]"
placeholder="请选择"
v-if="attr.is_choice"
:mode="attr.is_list ? 'multiple' : 'default'"
:multiple="attr.is_list"
showSearch
allowClear
size="small"
:getPopupContainer="(trigger) => trigger.parentElement"
>
<a-select-option
:value="choice[0]"
:key="'New_' + attr.name + choice_idx"
v-for="(choice, choice_idx) in attr.choice_value"
>
<span :style="{ ...(choice[1] ? choice[1].style : {}), display: 'inline-flex', alignItems: 'center' }">
<template v-if="choice[1] && choice[1].icon && choice[1].icon.name">
<img
v-if="choice[1].icon.id && choice[1].icon.url"
:src="`/api/common-setting/v1/file/${choice[1].icon.url}`"
:style="{ maxHeight: '13px', maxWidth: '13px', marginRight: '5px' }"
/>
<ops-icon
v-else
:style="{ color: choice[1].icon.color, marginRight: '5px' }"
:type="choice[1].icon.name"
/>
</template>
{{ choice[0] }}
</span>
</a-select-option>
</a-select>
<a-input-number
size="small"
v-decorator="[
attr.name,
{
rules: [{ required: attr.is_required }],
},
]"
style="width: 100%"
v-else-if="attr.value_type === '0' || attr.value_type === '1'"
/>
<a-date-picker
size="small"
v-decorator="[
attr.name,
{
rules: [{ required: attr.is_required }],
},
]"
style="width: 100%"
:format="attr.value_type === '4' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss'"
:valueFormat="attr.value_type === '4' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss'"
v-else-if="attr.value_type === '4' || attr.value_type === '3'"
:showTime="attr.value_type === '4' ? false : { format: 'HH:mm:ss' }"
/>
<!-- <a-input
size="small"
@focus="(e) => handleFocusInput(e, attr)"
v-decorator="[
attr.name,
{
validateTrigger: ['submit'],
rules: [{ required: attr.is_required }],
},
]"
style="width: 100%"
v-else-if="attr.value_type === '6'"
/> -->
<a-input
size="small"
v-decorator="[
attr.name,
{
validateTrigger: ['submit'],
rules: [{ required: attr.is_required }],
},
]"
style="width: 100%"
v-else
/>
</a-form-item>
</a-form>
</template>
<a v-if="!isEdit && !attr.is_computed" @click="handleEdit" :style="{ opacity: 0 }"><a-icon type="edit"/></a>
<JsonEditor ref="jsonEditor" @jsonEditorOk="jsonEditorOk" />
</span>
</template>
<script>
import _ from 'lodash'
import moment from 'moment'
import { updateCI } from '@/modules/cmdb/api/ci'
import JsonEditor from '../../../components/JsonEditor/jsonEditor.vue'
import PasswordField from '../../../components/passwordField/index.vue'
import { getAttrPassword } from '../../../api/CITypeAttr'
export default {
name: 'CiDetailAttrContent',
components: { JsonEditor, PasswordField },
props: {
ci: {
type: Object,
default: () => {},
},
attr: {
type: Object,
default: () => {},
},
},
data() {
return {
isEdit: false,
form: this.$form.createForm(this, this.attr.name),
}
},
mounted() {
document.addEventListener('click', this.eventListener)
},
beforeDestroy() {
document.removeEventListener('click', this.eventListener)
},
methods: {
moment,
eventListener(e) {
const datePickerContainer = document.getElementsByClassName('ant-calendar-picker-container')
if (this.isEdit && !datePickerContainer.length) {
const dom = document.getElementById(`ci-detail-attr-${this.attr.name}`)
e.stopPropagation()
e.preventDefault()
if (dom) {
const isSelf = dom.contains(e.target)
if (!isSelf) {
this.handleCloseEdit()
}
}
}
},
handleEdit(e) {
e.stopPropagation()
e.preventDefault()
if (this.attr.value_type === '6') {
const jsonData = this.ci[this.attr.name]
this.$refs.jsonEditor.open(null, null, jsonData || {})
return
}
this.isEdit = true
this.$nextTick(async () => {
if (this.attr.is_list && !this.attr.is_choice) {
this.form.setFieldsValue({
[`${this.attr.name}`]: this.ci[this.attr.name].join(',') || null,
})
return
}
if (this.attr.is_password) {
await getAttrPassword(this.ci._id, this.attr.id).then((res) => {
this.form.setFieldsValue({
[`${this.attr.name}`]: res.value ?? null,
})
})
return
}
this.form.setFieldsValue({
[`${this.attr.name}`]: this.ci[this.attr.name] ?? null,
})
})
},
async handleCloseEdit() {
const newData = this.form.getFieldValue(this.attr.name)
if (!_.isEqual(this.ci[this.attr.name], newData)) {
await updateCI(this.ci._id, { [`${this.attr.name}`]: newData })
.then(() => {
this.$message.success('更新成功!')
this.$emit('updateCIByself', { [`${this.attr.name}`]: newData }, this.attr.name)
})
.catch(() => {
this.$emit('refresh', this.attr.name)
})
}
this.isEdit = false
},
// handleFocusInput(e, attr) {
// console.log('focus')
// if (this.attr.value_type === '6') {
// e.preventDefault()
// e.stopPropagation()
// // e.srcElement.blur()
// const jsonData = this.form.getFieldValue(attr.name)
// this.$refs.jsonEditor.open(null, null, jsonData ? JSON.parse(jsonData) : {})
// }
// },
jsonEditorOk(jsonData) {
if (!_.isEqual(this.ci[this.attr.name], jsonData)) {
updateCI(this.ci._id, { [`${this.attr.name}`]: jsonData })
.then(() => {
this.$message.success('更新成功!')
this.$emit('updateCIByself', { [`${this.attr.name}`]: jsonData }, this.attr.name)
})
.catch(() => {
this.$emit('refresh', this.attr.name)
})
}
},
getChoiceValueStyle(col, colValue) {
const _find = col.choice_value.find((item) => String(item[0]) === String(colValue))
if (_find) {
return _find[1]?.style || {}
}
return {}
},
getChoiceValueIcon(col, colValue) {
const _find = col.choice_value.find((item) => String(item[0]) === String(colValue))
if (_find) {
return _find[1]?.icon || {}
}
return {}
},
getName(name) {
return name ?? ''
},
},
}
</script>
<style></style>

View File

@@ -1,280 +1,274 @@
<template>
<div class="attribute-card">
<div class="attribute-card-content">
<div class="attribute-card-value-type-icon handle" :style="{ ...getPropertyStyle(property) }">
<ValueTypeIcon :attr="property" />
</div>
<div :class="{ 'attribute-card-content-inner': true, 'attribute-card-name-required': property.is_required }">
<div :class="{ 'attribute-card-name': true, 'attribute-card-name-default-show': property.default_show }">
{{ property.alias || property.name }}
</div>
<div class="attribute-card_value-type">{{ valueTypeMap[property.value_type] }}</div>
</div>
<div
class="attribute-card-trigger"
v-if="(property.value_type === '3' || property.value_type === '4') && !isStore"
>
<a @click="openTrigger"><ops-icon type="ops-trigger"/></a>
</div>
</div>
<div class="attribute-card-footer">
<a-popover
trigger="click"
:arrowPointAtCenter="true"
placement="bottom"
overlayClassName="attribute-card-footer-popover"
>
<div slot="content">
<h3 :style="{ textAlign: 'center', paddingTop: '0.5em' }">
<span>{{ property.alias }}({{ property.name }})</span>
</h3>
<a-descriptions layout="horizontal" bordered size="small" :column="2">
<a-descriptions-item v-for="item in propertyList" :key="item.property" :label="item.label">
<components
:is="`ops_${item.property}`"
v-if="property[item.property]"
:style="{ width: '1em', height: '1em' }"
/>
<ops-icon v-else :type="`ops-${item.property}-disabled`" />
</a-descriptions-item>
<a-descriptions-item label></a-descriptions-item>
</a-descriptions>
</div>
<a-space :style="{ cursor: 'pointer' }">
<components
v-for="item in propertyList.filter((p) => property[p.property])"
:key="item.property"
:is="`ops_${item.property}`"
/>
</a-space>
</a-popover>
<a-space class="attribute-card-operation">
<a v-if="!isStore"><a-icon type="edit" @click="handleEdit"/></a>
<a-tooltip title="所有CI触发计算">
<a v-if="!isStore && property.is_computed"><a-icon type="redo" @click="handleCalcComputed"/></a>
</a-tooltip>
<a style="color:red;"><a-icon type="delete" @click="handleDelete"/></a>
</a-space>
</div>
<TriggerForm ref="triggerForm" :CITypeId="CITypeId" />
</div>
</template>
<script>
import { deleteCITypeAttributesById, deleteAttributesById, calcComputedAttribute } from '@/modules/cmdb/api/CITypeAttr'
import ValueTypeIcon from '@/components/CMDBValueTypeMapIcon'
import {
ops_default_show,
ops_is_choice,
ops_is_index,
ops_is_link,
ops_is_password,
ops_is_sortable,
ops_is_unique,
} from '@/core/icons'
import { valueTypeMap } from '../../utils/const'
import { getPropertyStyle } from '../../utils/helper'
import TriggerForm from './triggerForm.vue'
export default {
name: 'AttributeCard',
components: {
ValueTypeIcon,
TriggerForm,
ops_default_show,
ops_is_choice,
ops_is_index,
ops_is_link,
ops_is_password,
ops_is_sortable,
ops_is_unique,
},
props: {
property: {
type: Object,
default: () => {},
},
CITypeId: {
type: Number,
default: null,
},
isStore: {
type: Boolean,
default: false,
},
},
data() {
const propertyList = [
{
label: '是否唯一',
property: 'is_unique',
},
{
label: '是否选择',
property: 'is_choice',
},
{
label: '默认显示',
property: 'default_show',
},
{
label: '可排序',
property: 'is_sortable',
},
{
label: '是否索引',
property: 'is_index',
},
{
label: '是否密码',
property: 'is_password',
},
{
label: '是否链接',
property: 'is_link',
},
]
return {
valueTypeMap,
propertyList,
}
},
methods: {
getPropertyStyle,
handleEdit() {
this.$emit('edit')
},
handleDelete() {
const that = this
this.$confirm({
title: '警告',
content: `确认删除 ${that.property.alias || that.property.name}`,
onOk() {
if (that.isStore) {
deleteAttributesById(that.property.id).then(() => {
that.$message.success('删除成功!')
that.$emit('ok')
})
} else {
deleteCITypeAttributesById(that.CITypeId, { attr_id: [that.property.id] }).then(() => {
that.$message.success('删除成功!')
that.$emit('ok')
})
}
},
onCancel() {},
})
},
openTrigger() {
this.$refs.triggerForm.open(this.property)
},
handleCalcComputed() {
const that = this
this.$confirm({
title: '警告',
content: `确认触发所有CI的计算`,
onOk() {
calcComputedAttribute(that.property.id).then(() => {
that.$message.success('触发成功!')
})
},
})
},
},
}
</script>
<style lang="less" scoped>
.attribute-card {
width: 182px;
height: 80px;
background: #f8faff;
border-radius: 5px;
position: relative;
margin-bottom: 16px;
transition: all 0.3s;
&:hover {
box-shadow: 0 4px 12px #4e5ea066;
.attribute-card-operation {
visibility: visible !important;
}
}
.attribute-card-content {
height: 50px;
display: inline-flex;
align-items: center;
padding: 8px;
width: 100%;
.attribute-card-value-type-icon {
width: 32px;
height: 32px;
font-size: 12px;
cursor: move;
background: #ffffff !important;
box-shadow: 0px 1px 2px rgba(47, 84, 235, 0.2);
border-radius: 2px;
text-align: center;
line-height: 32px;
}
.attribute-card-content-inner {
padding-left: 12px;
font-weight: 400;
font-size: 12px;
width: 120px;
position: relative;
.attribute-card-name {
width: 100%;
color: rgba(0, 0, 0, 0.8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attribute-card-name-default-show {
color: #2f54eb;
}
.attribute-card_value-type {
font-size: 10px;
color: rgba(0, 0, 0, 0.35);
}
}
.attribute-card-name-required::before {
content: '*';
width: 5px;
color: red;
position: absolute;
left: 3px;
}
.attribute-card-trigger {
position: absolute;
right: 8px;
top: 8px;
}
}
.attribute-card-footer {
width: 182px;
height: 30px;
padding: 0 8px;
position: absolute;
bottom: 0;
left: 0;
background: linear-gradient(180deg, #96abd6 0%, #ecf2ff 0.01%, #ffffff 143.33%);
border-radius: 0px 0px 5px 5px;
display: inline-flex;
align-items: center;
justify-content: space-between;
.attribute-card-operation {
visibility: hidden;
}
}
}
</style>
<style lang="less">
.attribute-card-footer-popover {
.ant-popover-inner-content {
padding: 0;
}
.ant-descriptions-bordered .ant-descriptions-item-label {
background-color: #f8faff;
}
}
</style>
<template>
<div class="attribute-card">
<div class="attribute-card-content">
<div class="attribute-card-value-type-icon handle" :style="{ ...getPropertyStyle(property) }">
<ValueTypeIcon :attr="property" />
</div>
<div :class="{ 'attribute-card-content-inner': true, 'attribute-card-name-required': property.is_required }">
<div :class="{ 'attribute-card-name': true, 'attribute-card-name-default-show': property.default_show }">
{{ property.alias || property.name }}
</div>
<div v-if="property.is_password" class="attribute-card_value-type">密码</div>
<div v-else-if="property.is_link" class="attribute-card_value-type">链接</div>
<div v-else class="attribute-card_value-type">{{ valueTypeMap[property.value_type] }}</div>
</div>
<div
class="attribute-card-trigger"
v-if="(property.value_type === '3' || property.value_type === '4') && !isStore"
>
<a @click="openTrigger"><ops-icon type="ops-trigger"/></a>
</div>
</div>
<div class="attribute-card-footer">
<a-popover
trigger="click"
:arrowPointAtCenter="true"
placement="bottom"
overlayClassName="attribute-card-footer-popover"
>
<div slot="content">
<h3 :style="{ textAlign: 'center', paddingTop: '0.5em' }">
<span>{{ property.alias }}({{ property.name }})</span>
</h3>
<a-descriptions layout="horizontal" bordered size="small" :column="2">
<a-descriptions-item v-for="item in propertyList" :key="item.property" :label="item.label">
<components
:is="`ops_${item.property}`"
v-if="property[item.property]"
:style="{ width: '1em', height: '1em' }"
/>
<ops-icon v-else :type="`ops-${item.property}-disabled`" />
</a-descriptions-item>
<a-descriptions-item label></a-descriptions-item>
</a-descriptions>
</div>
<a-space :style="{ cursor: 'pointer' }">
<components
v-for="item in propertyList.filter((p) => property[p.property])"
:key="item.property"
:is="`ops_${item.property}`"
/>
</a-space>
</a-popover>
<a-space class="attribute-card-operation">
<a v-if="!isStore"><a-icon type="edit" @click="handleEdit"/></a>
<a-tooltip title="所有CI触发计算">
<a v-if="!isStore && property.is_computed"><a-icon type="redo" @click="handleCalcComputed"/></a>
</a-tooltip>
<a style="color:red;"><a-icon type="delete" @click="handleDelete"/></a>
</a-space>
</div>
<TriggerForm ref="triggerForm" :CITypeId="CITypeId" />
</div>
</template>
<script>
import { deleteCITypeAttributesById, deleteAttributesById, calcComputedAttribute } from '@/modules/cmdb/api/CITypeAttr'
import ValueTypeIcon from '@/components/CMDBValueTypeMapIcon'
import {
ops_default_show,
ops_is_choice,
ops_is_index,
ops_is_link,
ops_is_password,
ops_is_sortable,
ops_is_unique,
} from '@/core/icons'
import { valueTypeMap } from '../../utils/const'
import { getPropertyStyle } from '../../utils/helper'
import TriggerForm from './triggerForm.vue'
export default {
name: 'AttributeCard',
components: {
ValueTypeIcon,
TriggerForm,
ops_default_show,
ops_is_choice,
ops_is_index,
ops_is_link,
ops_is_password,
ops_is_sortable,
ops_is_unique,
},
props: {
property: {
type: Object,
default: () => {},
},
CITypeId: {
type: Number,
default: null,
},
isStore: {
type: Boolean,
default: false,
},
},
data() {
const propertyList = [
{
label: '是否唯一',
property: 'is_unique',
},
{
label: '是否选择',
property: 'is_choice',
},
{
label: '默认显示',
property: 'default_show',
},
{
label: '可排序',
property: 'is_sortable',
},
{
label: '是否索引',
property: 'is_index',
},
]
return {
valueTypeMap,
propertyList,
}
},
methods: {
getPropertyStyle,
handleEdit() {
this.$emit('edit')
},
handleDelete() {
const that = this
this.$confirm({
title: '警告',
content: `确认删除 ${that.property.alias || that.property.name}`,
onOk() {
if (that.isStore) {
deleteAttributesById(that.property.id).then(() => {
that.$message.success('删除成功!')
that.$emit('ok')
})
} else {
deleteCITypeAttributesById(that.CITypeId, { attr_id: [that.property.id] }).then(() => {
that.$message.success('删除成功!')
that.$emit('ok')
})
}
},
onCancel() {},
})
},
openTrigger() {
this.$refs.triggerForm.open(this.property)
},
handleCalcComputed() {
const that = this
this.$confirm({
title: '警告',
content: `确认触发所有CI的计算`,
onOk() {
calcComputedAttribute(that.property.id).then(() => {
that.$message.success('触发成功!')
})
},
})
},
},
}
</script>
<style lang="less" scoped>
.attribute-card {
width: 182px;
height: 80px;
background: #f8faff;
border-radius: 5px;
position: relative;
margin-bottom: 16px;
transition: all 0.3s;
&:hover {
box-shadow: 0 4px 12px #4e5ea066;
.attribute-card-operation {
visibility: visible !important;
}
}
.attribute-card-content {
height: 50px;
display: inline-flex;
align-items: center;
padding: 8px;
width: 100%;
.attribute-card-value-type-icon {
width: 32px;
height: 32px;
font-size: 12px;
cursor: move;
background: #ffffff !important;
box-shadow: 0px 1px 2px rgba(47, 84, 235, 0.2);
border-radius: 2px;
text-align: center;
line-height: 32px;
}
.attribute-card-content-inner {
padding-left: 12px;
font-weight: 400;
font-size: 12px;
width: 120px;
position: relative;
.attribute-card-name {
width: 100%;
color: rgba(0, 0, 0, 0.8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attribute-card-name-default-show {
color: #2f54eb;
}
.attribute-card_value-type {
font-size: 10px;
color: rgba(0, 0, 0, 0.35);
}
}
.attribute-card-name-required::before {
content: '*';
width: 5px;
color: red;
position: absolute;
left: 3px;
}
.attribute-card-trigger {
position: absolute;
right: 8px;
top: 8px;
}
}
.attribute-card-footer {
width: 182px;
height: 30px;
padding: 0 8px;
position: absolute;
bottom: 0;
left: 0;
background: linear-gradient(180deg, #96abd6 0%, #ecf2ff 0.01%, #ffffff 143.33%);
border-radius: 0px 0px 5px 5px;
display: inline-flex;
align-items: center;
justify-content: space-between;
.attribute-card-operation {
visibility: hidden;
}
}
}
</style>
<style lang="less">
.attribute-card-footer-popover {
.ant-popover-inner-content {
padding: 0;
}
.ant-descriptions-bordered .ant-descriptions-item-label {
background-color: #f8faff;
}
}
</style>

View File

@@ -84,7 +84,12 @@
</a-input-number>
<a-input
style="width: 100%"
v-else-if="currentValueType === '2' || currentValueType === '5'"
v-else-if="
currentValueType === '2' ||
currentValueType === '5' ||
currentValueType === '7' ||
currentValueType === '8'
"
v-decorator="['default_value', { rules: [{ required: false }] }]"
>
</a-input>
@@ -148,13 +153,13 @@
label="必须"
>
<a-switch
@change="onChange"
@change="(checked) => onChange(checked, 'is_required')"
name="is_required"
v-decorator="['is_required', { rules: [], valuePropName: 'checked' }]"
/>
</a-form-item>
</a-col>
<a-col :span="6" v-if="currentValueType !== '6'">
<a-col :span="6" v-if="currentValueType !== '6' && currentValueType !== '7'">
<a-form-item :label-col="{ span: 8 }" :wrapper-col="horizontalFormItemLayout.wrapperCol" label="唯一">
<a-switch
:disabled="isShowComputedArea"
@@ -228,7 +233,7 @@
/>
</a-form-item>
</a-col>
<a-col :span="6" v-if="currentValueType !== '6'">
<a-col :span="6" v-if="currentValueType !== '6' && currentValueType !== '7'">
<a-form-item
:label-col="currentValueType === '2' ? horizontalFormItemLayout.labelCol : { span: 8 }"
:wrapper-col="horizontalFormItemLayout.wrapperCol"
@@ -236,14 +241,14 @@
>
<a-switch
:disabled="isShowComputedArea"
@change="onChange"
@change="(checked) => onChange(checked, 'is_sortable')"
name="is_sortable"
v-decorator="['is_sortable', { rules: [], valuePropName: 'checked' }]"
/>
</a-form-item>
</a-col>
<a-col :span="6" v-if="currentValueType !== '6'">
<a-col :span="6" v-if="currentValueType !== '6' && currentValueType !== '7'">
<a-form-item
:label-col="currentValueType === '2' ? { span: 8 } : horizontalFormItemLayout.labelCol"
:wrapper-col="horizontalFormItemLayout.wrapperCol"
@@ -275,31 +280,6 @@
/>
</a-form-item>
</a-col>
<a-col :span="6" v-if="currentValueType === '2'">
<a-form-item
:label-col="horizontalFormItemLayout.labelCol"
:wrapper-col="horizontalFormItemLayout.wrapperCol"
label="密码"
>
<a-switch
:disabled="isShowComputedArea"
@change="(checked) => onChange(checked, 'is_password')"
name="is_password"
v-decorator="['is_password', { rules: [], valuePropName: 'checked' }]"
/>
</a-form-item>
</a-col>
<a-col :span="6" v-if="currentValueType === '2'">
<a-form-item :label-col="{ span: 8 }" :wrapper-col="horizontalFormItemLayout.wrapperCol" label="链接">
<a-switch
:disabled="isShowComputedArea"
@change="(checked) => onChange(checked, 'is_link')"
name="is_link"
v-decorator="['is_link', { rules: [], valuePropName: 'checked' }]"
/>
</a-form-item>
</a-col>
<a-divider style="font-size:14px;margin-top:6px;">高级设置</a-divider>
<a-row>
<a-col :span="24">
@@ -307,12 +287,17 @@
<FontArea ref="fontArea" />
</a-form-item>
</a-col>
<a-col :span="24" v-if="currentValueType !== '6'">
<a-col :span="24" v-if="!['6', '7'].includes(currentValueType)">
<a-form-item :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }" label="预定义值">
<PreValueArea v-if="drawerVisible" ref="preValueArea" :disabled="isShowComputedArea" />
<PreValueArea
v-if="drawerVisible"
:canDefineScript="canDefineScript"
ref="preValueArea"
:disabled="isShowComputedArea"
/>
</a-form-item>
</a-col>
<a-col :span="24" v-if="currentValueType !== '6'">
<a-col :span="24" v-if="!['6', '7'].includes(currentValueType)">
<a-form-item :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<template slot="label">
<span
@@ -340,7 +325,7 @@
<a-switch
:disabled="!canDefineComputed"
@change="(checked) => onChange(checked, 'is_computed')"
name="is_password"
name="is_computed"
v-decorator="['is_computed', { rules: [], valuePropName: 'checked' }]"
/>
<ComputedArea
@@ -366,6 +351,7 @@
</template>
<script>
import _ from 'lodash'
import moment from 'moment'
import vueJsonEditor from 'vue-json-editor'
import {
@@ -434,6 +420,9 @@ export default {
wrapperCol: { span: 4 },
}
},
canDefineScript() {
return this.canDefineComputed
},
},
mounted() {},
methods: {
@@ -463,28 +452,30 @@ export default {
})
if (this.currentValueType === '2') {
this.form.setFieldsValue({
is_password: false,
is_link: false,
is_index: true,
})
}
}
}
if (checked && property === 'is_password') {
this.form.setFieldsValue({
is_link: false,
})
}
if (checked && property === 'is_link') {
this.form.setFieldsValue({
is_password: false,
})
}
if (property === 'is_list') {
this.form.setFieldsValue({
default_value: checked ? [] : '',
})
}
if (checked && property === 'is_sortable') {
this.$message.warning('选中排序,则必须也要选中!')
this.form.setFieldsValue({
is_required: true,
})
}
if (!checked && property === 'is_required' && this.form.getFieldValue('is_sortable')) {
this.$message.warning('选中排序,则必须也要选中!')
this.$nextTick(() => {
this.form.setFieldsValue({
is_required: true,
})
})
}
},
async handleEdit(record, attributes) {
@@ -494,90 +485,91 @@ export default {
} catch {
this.canDefineComputed = false
}
const _record = _.cloneDeep(record)
if (_record.is_password) {
_record.value_type = '7'
}
if (_record.is_link) {
_record.value_type = '8'
}
this.drawerTitle = '编辑属性'
this.drawerVisible = true
this.record = record
this.currentValueType = record.value_type
this.record = _record
this.currentValueType = _record.value_type
this.$nextTick(() => {
this.form.setFieldsValue({
id: record.id,
alias: record.alias,
name: record.name,
value_type: record.value_type,
is_required: record.is_required,
default_show: record.default_show,
id: _record.id,
alias: _record.alias,
name: _record.name,
value_type: _record.value_type,
is_required: _record.is_required,
default_show: _record.default_show,
})
if (record.value_type !== '6') {
if (!['6', '7'].includes(_record.value_type)) {
this.form.setFieldsValue({
is_list: record.is_list,
is_unique: record.is_unique,
is_index: record.is_index,
is_sortable: record.is_sortable,
is_computed: record.is_computed,
is_list: _record.is_list,
is_unique: _record.is_unique,
is_index: _record.is_index,
is_sortable: _record.is_sortable,
is_computed: _record.is_computed,
})
}
if (record.value_type === '2') {
this.form.setFieldsValue({
is_password: record.is_password,
is_link: record.is_link,
})
}
console.log(record)
if (record.default) {
console.log(_record)
if (_record.default) {
this.$nextTick(() => {
if (record.value_type === '0') {
if (_record.value_type === '0') {
this.form.setFieldsValue({
default_value: record.default.default ? [record.default.default] : [],
default_value: _record.default.default ? [_record.default.default] : [],
})
} else if (record.value_type === '6') {
this.default_value_json = record?.default?.default || null
} else if (record.value_type === '3' || record.value_type === '4') {
if (record?.default?.default === '$created_at' || record?.default?.default === '$updated_at') {
this.defaultForDatetime = record.default.default
} else if (_record.value_type === '6') {
this.default_value_json = _record?.default?.default || null
} else if (_record.value_type === '3' || _record.value_type === '4') {
if (_record?.default?.default === '$created_at' || _record?.default?.default === '$updated_at') {
this.defaultForDatetime = _record.default.default
this.form.setFieldsValue({
default_value: record?.default?.default,
default_value: _record?.default?.default,
})
} else {
this.defaultForDatetime = '$custom_time'
this.form.setFieldsValue({
default_value: record.default && record.default.default ? moment(record.default.default) : null,
default_value: _record.default && _record.default.default ? moment(_record.default.default) : null,
})
}
} else {
this.$nextTick(() => {
this.form.setFieldsValue({
default_value: record.default && record.default.default ? record.default.default : null,
default_value: _record.default && _record.default.default ? _record.default.default : null,
})
})
}
})
} else {
this.default_value_json = {}
if (record.value_type === '0') {
if (_record.value_type === '0') {
this.form.setFieldsValue({
default_value: [],
})
} else if (record.value_type !== '6') {
} else if (_record.value_type !== '6') {
this.form.setFieldsValue({
default_value: null,
})
}
}
this.isShowComputedArea = record.is_computed
if (record.is_computed) {
this.isShowComputedArea = _record.is_computed
if (_record.is_computed) {
this.$nextTick(() => {
this.$refs.computedArea.setData({
compute_expr: record.compute_expr,
compute_script: record.compute_script,
compute_expr: _record.compute_expr,
compute_script: _record.compute_script,
})
})
}
const _find = attributes.find((item) => item.id === record.id)
if (record.value_type !== '6') {
const _find = attributes.find((item) => item.id === _record.id)
if (!['6', '7'].includes(_record.value_type)) {
this.$refs.preValueArea.setData({
choice_value: (_find || {}).choice_value || [],
choice_web_hook: record.choice_web_hook,
choice_other: record.choice_other || undefined,
choice_web_hook: _record.choice_web_hook,
choice_other: _record.choice_other || undefined,
})
}
this.$refs.fontArea.setData({
@@ -633,13 +625,20 @@ export default {
values = { ...values, ...computedAreaData }
} else {
// 如果是非计算属性就看看有没有预定义值
if (values.value_type !== '6') {
if (!['6', '7'].includes(values.value_type)) {
const preValueAreaData = this.$refs.preValueArea.getData()
values = { ...values, ...preValueAreaData }
}
}
const fontOptions = this.$refs.fontArea.getData()
if (values.value_type === '7') {
values.value_type = '2'
values.is_password = true
}
if (values.value_type === '8') {
values.value_type = '2'
values.is_link = true
}
if (values.id) {
await this.updateAttribute(values.id, { ...values, option: { fontOptions } }, isCalcComputed)
} else {
@@ -660,7 +659,6 @@ export default {
handleOk() {
this.$emit('ok')
},
handleChangeValueType(value) {
this.currentValueType = value
this.$nextTick(() => {

View File

@@ -1,9 +1,14 @@
<template>
<a-form :form="form" class="create-new-attribute">
<a-form
:form="form"
class="create-new-attribute"
:label-col="formItemLayout.labelCol"
:wrapper-col="formItemLayout.wrapperCol"
>
<a-divider style="font-size:14px;margin-top:6px;">基础设置</a-divider>
<a-row>
<a-col :span="12">
<a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="属性名(英文)">
<a-form-item label="属性名(英文)">
<a-input
name="name"
placeholder="英文"
@@ -24,28 +29,33 @@
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="别名">
<a-form-item label="别名">
<a-input name="alias" v-decorator="['alias', { rules: [] }]" />
</a-form-item>
</a-col>
</a-row>
<a-row>
<a-col :span="12">
<a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="数据类型">
<a-form-item label="数据类型">
<a-select
name="value_type"
style="width: 100%"
v-decorator="['value_type', { rules: [{ required: true }], initialValue: '2' }]"
@change="handleChangeValueType"
>
<a-select-option :value="key" :key="key" v-for="(value, key) in valueTypeMap">{{ value }}</a-select-option>
<a-select-option :value="key" :key="key" v-for="(value, key) in valueTypeMap">
{{ value }}
<span class="value-type-des" v-if="key === '3'">yyyy-mm-dd HH:MM:SS</span>
<span class="value-type-des" v-if="key === '4'">yyyy-mm-dd</span>
<span class="value-type-des" v-if="key === '5'">HH:MM:SS</span>
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="currentValueType === '6' ? 24 : 12">
<a-form-item
:label-col="{ span: currentValueType === '6' ? 4 : 8 }"
:wrapper-col="{ span: currentValueType === '6' ? 18 : 12 }"
:wrapper-col="{ span: currentValueType === '6' ? 18 : 15 }"
label="默认值"
>
<template>
@@ -74,7 +84,12 @@
</a-select>
<a-input
style="width: 100%"
v-else-if="currentValueType === '2' || currentValueType === '5'"
v-else-if="
currentValueType === '2' ||
currentValueType === '5' ||
currentValueType === '7' ||
currentValueType === '8'
"
v-decorator="['default_value', { rules: [{ required: false }] }]"
>
</a-input>
@@ -140,13 +155,13 @@
label="必须"
>
<a-switch
@change="onChange"
@change="(checked) => onChange(checked, 'is_required')"
name="is_required"
v-decorator="['is_required', { rules: [], valuePropName: 'checked' }]"
/>
</a-form-item>
</a-col>
<a-col :span="6" v-if="currentValueType !== '6'">
<a-col :span="6" v-if="currentValueType !== '6' && currentValueType !== '7'">
<a-form-item :label-col="{ span: 8 }" :wrapper-col="horizontalFormItemLayout.wrapperCol" label="唯一">
<a-switch
:disabled="isShowComputedArea"
@@ -216,7 +231,7 @@
/>
</a-form-item>
</a-col>
<a-col :span="6" v-if="currentValueType !== '6'">
<a-col :span="6" v-if="currentValueType !== '6' && currentValueType !== '7'">
<a-form-item
:label-col="currentValueType === '2' ? horizontalFormItemLayout.labelCol : { span: 8 }"
:wrapper-col="horizontalFormItemLayout.wrapperCol"
@@ -224,14 +239,14 @@
>
<a-switch
:disabled="isShowComputedArea"
@change="onChange"
@change="(checked) => onChange(checked, 'is_sortable')"
name="is_sortable"
v-decorator="['is_sortable', { rules: [], valuePropName: 'checked' }]"
/>
</a-form-item>
</a-col>
<a-col :span="6" v-if="currentValueType !== '6'">
<a-col :span="6" v-if="currentValueType !== '6' && currentValueType !== '7'">
<a-form-item
:label-col="currentValueType === '2' ? { span: 8 } : horizontalFormItemLayout.labelCol"
:wrapper-col="horizontalFormItemLayout.wrapperCol"
@@ -263,30 +278,6 @@
/>
</a-form-item>
</a-col>
<a-col :span="6" v-if="currentValueType === '2'">
<a-form-item
:label-col="horizontalFormItemLayout.labelCol"
:wrapper-col="horizontalFormItemLayout.wrapperCol"
label="密码"
>
<a-switch
:disabled="isShowComputedArea"
@change="(checked) => onChange(checked, 'is_password')"
name="is_password"
v-decorator="['is_password', { rules: [], valuePropName: 'checked' }]"
/>
</a-form-item>
</a-col>
<a-col :span="6" v-if="currentValueType === '2'">
<a-form-item :label-col="{ span: 8 }" :wrapper-col="horizontalFormItemLayout.wrapperCol" label="链接">
<a-switch
:disabled="isShowComputedArea"
@change="(checked) => onChange(checked, 'is_link')"
name="is_link"
v-decorator="['is_link', { rules: [], valuePropName: 'checked' }]"
/>
</a-form-item>
</a-col>
<a-divider style="font-size:14px;margin-top:6px;">高级设置</a-divider>
<a-row>
<a-col :span="24">
@@ -294,12 +285,12 @@
<FontArea ref="fontArea" />
</a-form-item>
</a-col>
<a-col :span="24" v-if="currentValueType !== '6'">
<a-col :span="24" v-if="!['6', '7'].includes(currentValueType)">
<a-form-item :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }" label="预定义值">
<PreValueArea ref="preValueArea" :disabled="isShowComputedArea" />
<PreValueArea ref="preValueArea" :canDefineScript="canDefineScript" :disabled="isShowComputedArea" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-col :span="24" v-if="!['6', '7'].includes(currentValueType)">
<a-form-item :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<template slot="label">
<span
@@ -327,7 +318,7 @@
<a-switch
:disabled="!canDefineComputed"
@change="(checked) => onChange(checked, 'is_computed')"
name="is_password"
name="is_computed"
v-decorator="['is_computed', { rules: [], valuePropName: 'checked' }]"
/>
<ComputedArea ref="computedArea" v-if="isShowComputedArea" :canDefineComputed="canDefineComputed" />
@@ -369,7 +360,7 @@ export default {
valueTypeMap,
formItemLayout: {
labelCol: { span: 8 },
wrapperCol: { span: 12 },
wrapperCol: { span: 15 },
},
horizontalFormItemLayout: {
labelCol: { span: 16 },
@@ -386,6 +377,11 @@ export default {
defaultForDatetime: '',
}
},
computed: {
canDefineScript() {
return this.canDefineComputed
},
},
methods: {
handleSubmit(isCloseModal = true) {
this.form.validateFields(async (err, values) => {
@@ -428,7 +424,7 @@ export default {
values = { ...values, ...computedAreaData }
} else {
// 如果是非计算属性就看看有没有预定义值
if (values.value_type !== '6') {
if (!['6', '7'].includes(values.value_type)) {
const preValueAreaData = this.$refs.preValueArea.getData()
values = { ...values, ...preValueAreaData }
}
@@ -437,17 +433,25 @@ export default {
// is_index进行操作除了文本 索引隐藏掉 文本 索引默认是true
// 框里的5种类型 is_index=true
// json类型 is_index=false
if (values.value_type === '6') {
// json类型密码链接 is_index=false
if (['6', '7', '8'].includes(values.value_type)) {
values.is_index = false
} else if (values.value_type !== '2') {
values.is_index = true
}
if (values.value_type === '7') {
values.value_type = '2'
values.is_password = true
}
if (values.value_type === '8') {
values.value_type = '2'
values.is_link = true
}
const { attr_id } = await createAttribute({ ...values, option: { fontOptions } })
this.form.resetFields()
this.currentValueType = '2'
if (values.value_type !== '6') {
if (!['6'].includes(values.value_type) && !values.is_password) {
this.$refs.preValueArea.valueList = []
}
this.$emit('done', attr_id, data, isCloseModal)
@@ -485,29 +489,27 @@ export default {
is_index: false,
is_sortable: false,
})
if (this.currentValueType === '2') {
this.form.setFieldsValue({
is_password: false,
is_link: false,
})
}
}
}
if (checked && property === 'is_password') {
this.form.setFieldsValue({
is_link: false,
})
}
if (checked && property === 'is_link') {
this.form.setFieldsValue({
is_password: false,
})
}
if (property === 'is_list') {
this.form.setFieldsValue({
default_value: checked ? [] : '',
})
}
if (checked && property === 'is_sortable') {
this.$message.warning('选中排序,则必须也要选中!')
this.form.setFieldsValue({
is_required: true,
})
}
if (!checked && property === 'is_required' && this.form.getFieldValue('is_sortable')) {
this.$message.warning('选中排序,则必须也要选中!')
this.$nextTick(() => {
this.form.setFieldsValue({
is_required: true,
})
})
}
},
onJsonChange(value) {
this.default_value_json_right = true
@@ -548,4 +550,8 @@ export default {
background-color: #2f54eb;
}
}
.value-type-des {
font-size: 10px;
color: #a9a9a9;
}
</style>

View File

@@ -1,91 +1,90 @@
<template>
<a-tabs v-model="activeKey" size="small" :tabBarStyle="{ borderBottom: 'none' }">
<a-tab-pane key="expr" :disabled="!canDefineComputed">
<span style="font-size:12px;" slot="tab">表达式</span>
<a-textarea v-model="compute_expr" :placeholder="`{{a}}+{{b}}`" :rows="2" :disabled="!canDefineComputed" />
</a-tab-pane>
<a-tab-pane key="script" :disabled="!canDefineComputed">
<span style="font-size:12px;" slot="tab">代码</span>
<codemirror style="z-index: 9999" :options="cmOptions" v-model="compute_script"></codemirror>
</a-tab-pane>
<template slot="tabBarExtraContent" v-if="showCalcComputed">
<a-button type="primary" size="small" @click="handleCalcComputed">
应用
</a-button>
<a-tooltip title="所有CI触发计算">
<a-icon type="question-circle" style="margin-left:5px" />
</a-tooltip>
</template>
</a-tabs>
</template>
<script>
import { codemirror } from 'vue-codemirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/theme/monokai.css'
require('codemirror/mode/python/python.js')
export default {
name: 'ComputedArea',
components: { codemirror },
props: {
canDefineComputed: {
type: Boolean,
default: true,
},
showCalcComputed: {
type: Boolean,
default: false,
}
},
data() {
return {
activeKey: 'expr', // expr script
compute_expr: '',
compute_script: 'def computed(): \n return',
cmOptions: {
lineNumbers: true,
mode: 'python',
height: '200px',
theme: 'monokai',
smartIndent: true,
tabSize: 4,
lineWrapping: true,
readOnly: !this.canDefineComputed,
},
}
},
methods: {
getData() {
const { activeKey, compute_expr, compute_script } = this
if (activeKey === 'expr') {
return { compute_expr, compute_script: null }
} else if (activeKey === 'script') {
return { compute_script, compute_expr: null }
}
},
setData(data) {
const { compute_expr, compute_script } = data
this.compute_expr = compute_expr
this.compute_script = compute_script || 'def computed(): \n return'
if (compute_script) {
this.activeKey = 'script'
} else {
this.activeKey = 'expr'
}
},
handleCalcComputed() {
const that = this
this.$confirm({
title: '警告',
content: `确认触发将保存当前配置及触发所有CI的计算`,
onOk() {
that.$emit('handleCalcComputed')
},
})
},
},
}
</script>
<style></style>
<template>
<a-tabs v-model="activeKey" size="small" :tabBarStyle="{ borderBottom: 'none' }">
<a-tab-pane key="expr" :disabled="!canDefineComputed">
<span style="font-size:12px;" slot="tab">表达式</span>
<a-textarea v-model="compute_expr" :placeholder="`{{a}}+{{b}}`" :rows="2" :disabled="!canDefineComputed" />
</a-tab-pane>
<a-tab-pane key="script" :disabled="!canDefineComputed">
<span style="font-size:12px;" slot="tab">代码</span>
<codemirror style="z-index: 9999" :options="cmOptions" v-model="compute_script"></codemirror>
</a-tab-pane>
<template slot="tabBarExtraContent" v-if="showCalcComputed">
<a-button type="primary" size="small" @click="handleCalcComputed">
应用
</a-button>
<a-tooltip title="所有CI触发计算">
<a-icon type="question-circle" style="margin-left:5px" />
</a-tooltip>
</template>
</a-tabs>
</template>
<script>
import { codemirror } from 'vue-codemirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/theme/monokai.css'
require('codemirror/mode/python/python.js')
export default {
name: 'ComputedArea',
components: { codemirror },
props: {
canDefineComputed: {
type: Boolean,
default: true,
},
showCalcComputed: {
type: Boolean,
default: false,
},
},
data() {
return {
activeKey: 'expr', // expr script
compute_expr: '',
compute_script: 'def computed(): \n return',
cmOptions: {
lineNumbers: true,
mode: 'python',
height: '200px',
theme: 'monokai',
tabSize: 4,
lineWrapping: true,
readOnly: !this.canDefineComputed,
},
}
},
methods: {
getData() {
const { activeKey, compute_expr, compute_script } = this
if (activeKey === 'expr') {
return { compute_expr, compute_script: null }
} else if (activeKey === 'script') {
return { compute_script, compute_expr: null }
}
},
setData(data) {
const { compute_expr, compute_script } = data
this.compute_expr = compute_expr
this.compute_script = compute_script || 'def computed(): \n return'
if (compute_script) {
this.activeKey = 'script'
} else {
this.activeKey = 'expr'
}
},
handleCalcComputed() {
const that = this
this.$confirm({
title: '警告',
content: `确认触发将保存当前配置及触发所有CI的计算`,
onOk() {
that.$emit('handleCalcComputed')
},
})
},
},
}
</script>
<style></style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,11 @@
<template>
<a-tabs id="preValueArea" v-model="activeKey" size="small" :tabBarStyle="{ borderBottom: 'none' }">
<a-tabs
id="preValueArea"
v-model="activeKey"
@change="changeActiveKey"
size="small"
:tabBarStyle="{ borderBottom: 'none' }"
>
<a-tab-pane key="define" :disabled="disabled">
<span style="font-size:12px;" slot="tab">定义</span>
<PreValueTag type="add" :item="[]" @add="addNewValue" :disabled="disabled">
@@ -170,6 +176,15 @@
</a-col>
</a-row>
</a-tab-pane>
<a-tab-pane key="script" :disabled="disabled || !canDefineScript">
<span style="font-size:12px;" slot="tab">脚本</span>
<CustomCodeMirror
codeMirrorId="cmdb-pre-value"
ref="codemirror"
@changeCodeContent="changeCodeContent"
></CustomCodeMirror>
<!-- <codemirror style="z-index: 9999" :options="cmOptions" v-model="script"></codemirror> -->
</a-tab-pane>
</a-tabs>
</template>
@@ -184,14 +199,22 @@ import { getCITypeGroups } from '../../api/ciTypeGroup'
import { getCITypeCommonAttributesByTypeIds } from '../../api/CITypeAttr'
import FilterComp from '@/components/CMDBFilterComp'
import CustomCodeMirror from '@/components/CustomCodeMirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/theme/monokai.css'
require('codemirror/mode/python/python.js')
export default {
name: 'PreValueArea',
components: { draggable, PreValueTag, ColorPicker, Webhook, FilterComp },
components: { draggable, PreValueTag, ColorPicker, Webhook, FilterComp, CustomCodeMirror },
props: {
disabled: {
type: Boolean,
default: true,
},
canDefineScript: {
type: Boolean,
default: false,
},
},
data() {
return {
@@ -208,6 +231,17 @@ export default {
ciTypeGroup: [],
typeAttrs: [],
filterExp: '',
script:
'class ChoiceValue(object):\n @staticmethod\n def values():\n """\n 执行入口, 返回预定义值\n :return: 返回一个列表, 值的类型同属性的类型\n 例如:\n return ["在线", "下线"]\n """\n return []',
cmOptions: {
lineNumbers: true,
mode: 'python',
height: '200px',
theme: 'monokai',
tabSize: 4,
lineWrapping: true,
readOnly: this.disabled || !this.canDefineScript,
},
}
},
watch: {
@@ -281,6 +315,14 @@ export default {
const choice_web_hook = this.$refs.webhook.getParams()
choice_web_hook.ret_key = this.form.ret_key
return { choice_value: [], choice_web_hook, choice_other: null }
} else if (this.activeKey === 'script') {
return {
choice_value: [],
choice_web_hook: null,
choice_other: {
script: this.script,
},
}
} else {
let choice_other = {}
if (this.choice_other.type_ids && this.choice_other.type_ids.length) {
@@ -302,14 +344,22 @@ export default {
this.form.ret_key = choice_web_hook.ret_key ?? ''
})
} else if (choice_other) {
this.activeKey = 'choice_other'
const { type_ids, attr_id, filter } = choice_other
this.choice_other = { type_ids, attr_id }
this.filterExp = filter
if (type_ids && type_ids.length) {
if (choice_other.script) {
this.activeKey = 'script'
this.script = choice_other.script
this.$nextTick(() => {
this.$refs.filterComp.visibleChange(true, false)
this.$refs.codemirror.initCodeMirror(choice_other.script)
})
} else {
this.activeKey = 'choice_other'
const { type_ids, attr_id, filter } = choice_other
this.choice_other = { type_ids, attr_id }
this.filterExp = filter
if (type_ids && type_ids.length) {
this.$nextTick(() => {
this.$refs.filterComp.visibleChange(true, false)
})
}
}
} else {
this.valueList = choice_value
@@ -330,6 +380,16 @@ export default {
this.filterExp = ''
}
},
changeCodeContent(value) {
this.script = value && value.replace('\t', ' ')
},
changeActiveKey(value) {
if (value === 'script') {
this.$nextTick(() => {
this.$refs.codemirror.initCodeMirror(this.script)
})
}
},
},
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@ export const category_1_bar_options = (data, options) => {
})
return {
color: options.chartColor.split(','),
color: (options?.chartColor ?? '#6592FD,#6EE3EB,#44C2FD,#5F59F7,#1A348F,#7D8FCF,#A6D1E5,#8E56DD').split(','),
grid: {
top: 15,
left: 'left',
@@ -83,7 +83,7 @@ export const category_1_bar_options = (data, options) => {
export const category_1_line_options = (data, options) => {
const xData = Object.keys(data)
return {
color: options.chartColor.split(','),
color: (options?.chartColor ?? '#6592FD,#6EE3EB,#44C2FD,#5F59F7,#1A348F,#7D8FCF,#A6D1E5,#8E56DD').split(','),
grid: {
top: 15,
left: 'left',
@@ -117,7 +117,7 @@ export const category_1_line_options = (data, options) => {
x2: 0,
y2: 1,
colorStops: [{
offset: 0, color: options.chartColor.split(',')[0] // 0% 处的颜色
offset: 0, color: (options?.chartColor ?? '#6592FD,#6EE3EB,#44C2FD,#5F59F7,#1A348F,#7D8FCF,#A6D1E5,#8E56DD').split(',')[0] // 0% 处的颜色
}, {
offset: 1, color: '#ffffff' // 100% 处的颜色
}],
@@ -131,7 +131,7 @@ export const category_1_line_options = (data, options) => {
export const category_1_pie_options = (data, options) => {
return {
color: options.chartColor.split(','),
color: (options?.chartColor ?? '#6592FD,#6EE3EB,#44C2FD,#5F59F7,#1A348F,#7D8FCF,#A6D1E5,#8E56DD').split(','),
grid: {
top: 10,
left: 'left',
@@ -181,7 +181,7 @@ export const category_2_bar_options = (data, options, chartType) => {
})
const legend = [...new Set(_legend)]
return {
color: options.chartColor.split(','),
color: (options?.chartColor ?? '#6592FD,#6EE3EB,#44C2FD,#5F59F7,#1A348F,#7D8FCF,#A6D1E5,#8E56DD').split(','),
grid: {
top: 15,
left: 'left',
@@ -249,7 +249,7 @@ export const category_2_bar_options = (data, options, chartType) => {
x2: 0,
y2: 1,
colorStops: [{
offset: 0, color: options.chartColor.split(',')[index % 8] // 0% 处的颜色
offset: 0, color: (options?.chartColor ?? '#6592FD,#6EE3EB,#44C2FD,#5F59F7,#1A348F,#7D8FCF,#A6D1E5,#8E56DD').split(',')[index % 8] // 0% 处的颜色
}, {
offset: 1, color: '#ffffff' // 100% 处的颜色
}],
@@ -269,7 +269,7 @@ export const category_2_pie_options = (data, options) => {
})
})
return {
color: options.chartColor.split(','),
color: (options?.chartColor ?? '#6592FD,#6EE3EB,#44C2FD,#5F59F7,#1A348F,#7D8FCF,#A6D1E5,#8E56DD').split(','),
grid: {
top: 15,
left: 'left',

View File

@@ -1,345 +1,344 @@
<template>
<CustomDrawer width="800px" :title="title" :visible="visible" @close="handleClose">
<template v-if="adType === 'agent'">
<a-form-model
ref="autoDiscoveryForm"
:model="form"
:rules="rules"
:label-col="{ span: 2 }"
:wrapper-col="{ span: 20 }"
>
<a-divider :style="{ margin: '5px 0' }">基础设置</a-divider>
<a-form-model-item label="名称" prop="name">
<a-input v-model="form.name" />
</a-form-model-item>
<a-form-model-item label="图标" v-if="is_inner">
<CustomIconSelect v-model="customIcon" :style="{ marginTop: '6px' }" />
</a-form-model-item>
<a-form-model-item label="模式" prop="is_plugin">
<a-radio-group v-model="form.is_plugin" @change="changeIsPlugin" :disabled="!is_inner">
<a-radio :value="false">默认</a-radio>
<a-radio :value="true">plugin</a-radio>
</a-radio-group>
</a-form-model-item>
</a-form-model>
<a-divider :style="{ margin: '5px 0' }">采集设置</a-divider>
<CustomCodeMirror
codeMirrorId="cmdb-adt"
v-if="form.is_plugin"
ref="codemirror"
@changeCodeContent="changeCodeContent"
></CustomCodeMirror>
<div style="margin:10px 0;text-align:right;">
<a-button
v-show="form.is_plugin"
size="small"
type="primary"
ghost
@click="handleSubmit(true)"
>更新字段</a-button
>
</div>
<a-button
v-show="!form.is_plugin"
size="small"
type="primary"
ghost
icon="plus"
:style="{ marginBottom: '10px' }"
@click="insertEvent(-1)"
>新增</a-button
>
<vxe-table
size="mini"
stripe
class="ops-stripe-table"
show-overflow
keep-source
ref="xTable"
max-height="400"
:data="tableData"
:edit-config="{ trigger: 'manual', mode: 'row' }"
>
<vxe-column field="name" title="名称" :edit-render="{ autofocus: '.vxe-input--inner' }">
<template #edit="{ row }">
<vxe-input v-model="row.name" type="text"></vxe-input>
</template>
</vxe-column>
<vxe-column field="type" title="类型" :edit-render="{}">
<template #edit="{ row }">
<vxe-select v-model="row.type" transfer>
<vxe-option v-for="item in typeList" :key="item" :value="item" :label="item"></vxe-option>
</vxe-select>
</template>
</vxe-column>
<vxe-column field="desc" title="描述" :edit-render="{ autofocus: '.vxe-input--inner' }">
<template #edit="{ row }">
<vxe-input v-model="row.desc" type="text"></vxe-input>
</template>
</vxe-column>
<vxe-column title="操作" width="60" v-if="!form.is_plugin">
<template #default="{ row }">
<a-space v-if="$refs.xTable.isActiveByRow(row)">
<a @click="saveRowEvent(row)"><a-icon type="save"/></a>
<a @click="cancelRowEvent(row)"><a-icon type="close"/></a>
</a-space>
<a-space v-else>
<a @click="editRowEvent(row)"><a-icon type="edit"/></a>
<a :style="{ color: 'red' }" @click="deleteRowEvent(row)"><a-icon type="delete"/></a>
</a-space>
</template>
</vxe-column>
</vxe-table>
<div class="custom-drawer-bottom-action">
<a-button @click="handleClose">取消</a-button>
<a-button @click="handleSubmit(false)" type="primary">保存</a-button>
</div>
</template>
<template v-else>
<HttpSnmpAD ref="httpSnmpAd" :ruleType="adType" :ruleName="ruleData.name" />
</template>
</CustomDrawer>
</template>
<script>
import CustomIconSelect from '@/components/CustomIconSelect'
import { postDiscovery, putDiscovery } from '../../api/discovery'
import HttpSnmpAD from '../../components/httpSnmpAD'
import CustomCodeMirror from '@/components/CustomCodeMirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/theme/monokai.css'
export default {
name: 'EditDrawer',
components: { CustomIconSelect, CustomCodeMirror, HttpSnmpAD },
props: {
is_inner: {
type: Boolean,
default: true,
},
},
data() {
const default_plugin_script = `# -*- coding:utf-8 -*-
import json
class AutoDiscovery(object):
@property
def unique_key(self):
"""
:return: 返回唯一属性的名字
"""
return
@staticmethod
def attributes():
"""
定义属性字段
:return: 返回属性字段列表, 列表项是(名称, 类型, 描述), 名称必须是英文
类型: String Integer Float Date DateTime Time JSON
例如:
return [
("ci_type", "String", "模型名称"),
("private_ip", "String", "内网IP, 多值逗号分隔")
]
"""
return []
@staticmethod
def run():
"""
执行入口, 返回采集的属性值
:return: 返回一个列表, 列表项是字典, 字典key是属性名称, value是属性值
例如:
return [dict(ci_type="server", private_ip="192.168.1.1")]
"""
return []
if __name__ == "__main__":
result = AutoDiscovery().run()
if isinstance(result, list):
print("AutoDiscovery::Result::{}".format(json.dumps(result)))
else:
print("ERROR: 采集返回必须是列表")
`
const typeList = ['String', 'Integer', 'Float', 'Date', 'DateTime', 'Time', 'JSON']
return {
default_plugin_script,
typeList,
visible: false,
ruleData: {},
type: 'add',
adType: '',
form: { name: '', is_plugin: false },
rules: {},
customIcon: { name: '', color: '' },
tableData: [],
editDefaultTableData: [],
plugin_script: '',
cmOptions: {
lineNumbers: true,
mode: 'python',
height: '200px',
theme: 'monokai',
smartIndent: true,
tabSize: 4,
lineWrapping: true,
},
}
},
computed: {
title() {
if (this.adType === 'http' || this.adType === 'snmp') {
return this.ruleData.name
}
if (this.type === 'edit') {
return `编辑${this.ruleData.name}`
}
return '新建'
},
},
inject: {
getDiscovery: {
from: 'getDiscovery',
default: () => {},
},
},
methods: {
open(data, type, adType) {
this.visible = true
this.type = type
this.ruleData = data
this.adType = adType
if (!this.is_inner) {
this.form = {
name: '',
is_plugin: true,
}
}
if (adType === 'http' || adType === 'snmp') {
return
}
this.$nextTick(() => {
if (this.type === 'edit') {
this.form = {
name: data.name,
is_plugin: data.is_plugin,
}
this.customIcon = data?.option?.icon ?? { name: 'caise-chajian', color: '' }
this.tableData = data?.attributes ?? []
this.editDefaultTableData = data?.attributes ?? []
this.plugin_script = data?.plugin_script ?? this.default_plugin_script
}
if (this.type === 'add') {
this.customIcon = { name: 'caise-chajian', color: '' }
// eslint-disable-next-line no-useless-escape
this.plugin_script = this.default_plugin_script
}
if (data?.is_plugin || !this.is_inner) {
this.$nextTick(() => {
this.$refs.codemirror.initCodeMirror(this.plugin_script)
})
}
})
},
handleClose() {
this.tableData = []
this.customIcon = { name: '', color: '' }
this.form = { name: '', is_plugin: false }
if (this.adType === 'agent') {
this.$refs.autoDiscoveryForm.clearValidate()
} else {
// this.$refs.httpSnmpAd.currentCate = ''
}
this.visible = false
},
async insertEvent(row) {
const $table = this.$refs.xTable
const record = {}
const { row: newRow } = await $table.insertAt(record, row)
await $table.setEditRow(newRow)
},
editRowEvent(row) {
const $table = this.$refs.xTable
$table.setActiveRow(row)
},
saveRowEvent() {
const $table = this.$refs.xTable
$table.clearActived().then(() => {
this.loading = true
setTimeout(() => {
this.loading = false
}, 300)
})
},
cancelRowEvent(row) {
const $table = this.$refs.xTable
$table.clearActived().then(() => {
// 还原行数据
$table.revertData(row)
})
},
deleteRowEvent(row) {
const $table = this.$refs.xTable
$table.remove(row)
},
async handleSubmit(isUpdateAttr = false) {
const $table = this.$refs.xTable
const { fullData: _tableData } = $table.getTableData()
console.log(_tableData)
const params = {
...this.form,
type: this.adType,
is_inner: this.is_inner,
option: { icon: this.customIcon },
attributes: this.form.is_plugin
? undefined
: _tableData.map(({ name, alias, desc, type }) => {
return { name, alias, desc, type }
}),
plugin_script: this.form.is_plugin ? this.plugin_script : undefined,
}
let res
if (this.type === 'add') {
res = await postDiscovery(params)
} else {
res = await putDiscovery(this.ruleData.id, params)
}
if (isUpdateAttr) {
this.tableData = res.attributes
this.type = 'edit'
this.ruleData = res
this.$message.success('更新成功!')
if (this.is_inner) {
this.getDiscovery()
}
return
}
this.handleClose()
console.log(this.is_inner)
if (this.is_inner) {
this.$message.success('保存成功!')
this.getDiscovery()
} else {
this.$emit('updateNotInner', res)
}
},
changeIsPlugin(e) {
if (e.target.value) {
this.$nextTick(() => {
this.$refs.codemirror.initCodeMirror(this.plugin_script)
})
}
},
changeCodeContent(value) {
this.plugin_script = value && value.replace('\t', ' ')
},
},
}
</script>
<style></style>
<template>
<CustomDrawer width="800px" :title="title" :visible="visible" @close="handleClose">
<template v-if="adType === 'agent'">
<a-form-model
ref="autoDiscoveryForm"
:model="form"
:rules="rules"
:label-col="{ span: 2 }"
:wrapper-col="{ span: 20 }"
>
<a-divider :style="{ margin: '5px 0' }">基础设置</a-divider>
<a-form-model-item label="名称" prop="name">
<a-input v-model="form.name" />
</a-form-model-item>
<a-form-model-item label="图标" v-if="is_inner">
<CustomIconSelect v-model="customIcon" :style="{ marginTop: '6px' }" />
</a-form-model-item>
<a-form-model-item label="模式" prop="is_plugin">
<a-radio-group v-model="form.is_plugin" @change="changeIsPlugin" :disabled="!is_inner">
<a-radio :value="false">默认</a-radio>
<a-radio :value="true">plugin</a-radio>
</a-radio-group>
</a-form-model-item>
</a-form-model>
<a-divider :style="{ margin: '5px 0' }">采集设置</a-divider>
<CustomCodeMirror
codeMirrorId="cmdb-adt"
v-if="form.is_plugin"
ref="codemirror"
@changeCodeContent="changeCodeContent"
></CustomCodeMirror>
<div style="margin:10px 0;text-align:right;">
<a-button
v-show="form.is_plugin"
size="small"
type="primary"
ghost
@click="handleSubmit(true)"
>更新字段</a-button
>
</div>
<a-button
v-show="!form.is_plugin"
size="small"
type="primary"
ghost
icon="plus"
:style="{ marginBottom: '10px' }"
@click="insertEvent(-1)"
>新增</a-button
>
<vxe-table
size="mini"
stripe
class="ops-stripe-table"
show-overflow
keep-source
ref="xTable"
max-height="400"
:data="tableData"
:edit-config="{ trigger: 'manual', mode: 'row' }"
>
<vxe-column field="name" title="名称" :edit-render="{ autofocus: '.vxe-input--inner' }">
<template #edit="{ row }">
<vxe-input v-model="row.name" type="text"></vxe-input>
</template>
</vxe-column>
<vxe-column field="type" title="类型" :edit-render="{}">
<template #edit="{ row }">
<vxe-select v-model="row.type" transfer>
<vxe-option v-for="item in typeList" :key="item" :value="item" :label="item"></vxe-option>
</vxe-select>
</template>
</vxe-column>
<vxe-column field="desc" title="描述" :edit-render="{ autofocus: '.vxe-input--inner' }">
<template #edit="{ row }">
<vxe-input v-model="row.desc" type="text"></vxe-input>
</template>
</vxe-column>
<vxe-column title="操作" width="60" v-if="!form.is_plugin">
<template #default="{ row }">
<a-space v-if="$refs.xTable.isActiveByRow(row)">
<a @click="saveRowEvent(row)"><a-icon type="save"/></a>
<a @click="cancelRowEvent(row)"><a-icon type="close"/></a>
</a-space>
<a-space v-else>
<a @click="editRowEvent(row)"><a-icon type="edit"/></a>
<a :style="{ color: 'red' }" @click="deleteRowEvent(row)"><a-icon type="delete"/></a>
</a-space>
</template>
</vxe-column>
</vxe-table>
<div class="custom-drawer-bottom-action">
<a-button @click="handleClose">取消</a-button>
<a-button @click="handleSubmit(false)" type="primary">保存</a-button>
</div>
</template>
<template v-else>
<HttpSnmpAD ref="httpSnmpAd" :ruleType="adType" :ruleName="ruleData.name" />
</template>
</CustomDrawer>
</template>
<script>
import CustomIconSelect from '@/components/CustomIconSelect'
import { postDiscovery, putDiscovery } from '../../api/discovery'
import HttpSnmpAD from '../../components/httpSnmpAD'
import CustomCodeMirror from '@/components/CustomCodeMirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/theme/monokai.css'
export default {
name: 'EditDrawer',
components: { CustomIconSelect, CustomCodeMirror, HttpSnmpAD },
props: {
is_inner: {
type: Boolean,
default: true,
},
},
data() {
const default_plugin_script = `# -*- coding:utf-8 -*-
import json
class AutoDiscovery(object):
@property
def unique_key(self):
"""
:return: 返回唯一属性的名字
"""
return
@staticmethod
def attributes():
"""
定义属性字段
:return: 返回属性字段列表, 列表项是(名称, 类型, 描述), 名称必须是英文
类型: String Integer Float Date DateTime Time JSON
例如:
return [
("ci_type", "String", "模型名称"),
("private_ip", "String", "内网IP, 多值逗号分隔")
]
"""
return []
@staticmethod
def run():
"""
执行入口, 返回采集的属性值
:return: 返回一个列表, 列表项是字典, 字典key是属性名称, value是属性值
例如:
return [dict(ci_type="server", private_ip="192.168.1.1")]
"""
return []
if __name__ == "__main__":
result = AutoDiscovery().run()
if isinstance(result, list):
print("AutoDiscovery::Result::{}".format(json.dumps(result)))
else:
print("ERROR: 采集返回必须是列表")
`
const typeList = ['String', 'Integer', 'Float', 'Date', 'DateTime', 'Time', 'JSON']
return {
default_plugin_script,
typeList,
visible: false,
ruleData: {},
type: 'add',
adType: '',
form: { name: '', is_plugin: false },
rules: {},
customIcon: { name: '', color: '' },
tableData: [],
editDefaultTableData: [],
plugin_script: '',
cmOptions: {
lineNumbers: true,
mode: 'python',
height: '200px',
theme: 'monokai',
tabSize: 4,
lineWrapping: true,
},
}
},
computed: {
title() {
if (this.adType === 'http' || this.adType === 'snmp') {
return this.ruleData.name
}
if (this.type === 'edit') {
return `编辑${this.ruleData.name}`
}
return '新建'
},
},
inject: {
getDiscovery: {
from: 'getDiscovery',
default: () => {},
},
},
methods: {
open(data, type, adType) {
this.visible = true
this.type = type
this.ruleData = data
this.adType = adType
if (!this.is_inner) {
this.form = {
name: '',
is_plugin: true,
}
}
if (adType === 'http' || adType === 'snmp') {
return
}
this.$nextTick(() => {
if (this.type === 'edit') {
this.form = {
name: data.name,
is_plugin: data.is_plugin,
}
this.customIcon = data?.option?.icon ?? { name: 'caise-chajian', color: '' }
this.tableData = data?.attributes ?? []
this.editDefaultTableData = data?.attributes ?? []
this.plugin_script = data?.plugin_script ?? this.default_plugin_script
}
if (this.type === 'add') {
this.customIcon = { name: 'caise-chajian', color: '' }
// eslint-disable-next-line no-useless-escape
this.plugin_script = this.default_plugin_script
}
if (data?.is_plugin || !this.is_inner) {
this.$nextTick(() => {
this.$refs.codemirror.initCodeMirror(this.plugin_script)
})
}
})
},
handleClose() {
this.tableData = []
this.customIcon = { name: '', color: '' }
this.form = { name: '', is_plugin: false }
if (this.adType === 'agent') {
this.$refs.autoDiscoveryForm.clearValidate()
} else {
// this.$refs.httpSnmpAd.currentCate = ''
}
this.visible = false
},
async insertEvent(row) {
const $table = this.$refs.xTable
const record = {}
const { row: newRow } = await $table.insertAt(record, row)
await $table.setEditRow(newRow)
},
editRowEvent(row) {
const $table = this.$refs.xTable
$table.setActiveRow(row)
},
saveRowEvent() {
const $table = this.$refs.xTable
$table.clearActived().then(() => {
this.loading = true
setTimeout(() => {
this.loading = false
}, 300)
})
},
cancelRowEvent(row) {
const $table = this.$refs.xTable
$table.clearActived().then(() => {
// 还原行数据
$table.revertData(row)
})
},
deleteRowEvent(row) {
const $table = this.$refs.xTable
$table.remove(row)
},
async handleSubmit(isUpdateAttr = false) {
const $table = this.$refs.xTable
const { fullData: _tableData } = $table.getTableData()
console.log(_tableData)
const params = {
...this.form,
type: this.adType,
is_inner: this.is_inner,
option: { icon: this.customIcon },
attributes: this.form.is_plugin
? undefined
: _tableData.map(({ name, alias, desc, type }) => {
return { name, alias, desc, type }
}),
plugin_script: this.form.is_plugin ? this.plugin_script : undefined,
}
let res
if (this.type === 'add') {
res = await postDiscovery(params)
} else {
res = await putDiscovery(this.ruleData.id, params)
}
if (isUpdateAttr) {
this.tableData = res.attributes
this.type = 'edit'
this.ruleData = res
this.$message.success('更新成功!')
if (this.is_inner) {
this.getDiscovery()
}
return
}
this.handleClose()
console.log(this.is_inner)
if (this.is_inner) {
this.$message.success('保存成功!')
this.getDiscovery()
} else {
this.$emit('updateNotInner', res)
}
},
changeIsPlugin(e) {
if (e.target.value) {
this.$nextTick(() => {
this.$refs.codemirror.initCodeMirror(this.plugin_script)
})
}
},
changeCodeContent(value) {
this.plugin_script = value && value.replace('\t', ' ')
},
},
}
</script>
<style></style>

View File

@@ -25,7 +25,7 @@
:expandedKeys="expandedKeys"
>
<a-icon slot="switcherIcon" type="down" />
<template #title="{ key: treeKey, title, isLeaf }">
<template #title="{ key: treeKey, title,isLeaf }">
<ContextMenu
:title="title"
:treeKey="treeKey"
@@ -135,7 +135,8 @@
{{ col.title }}</span
>
</template>
<template v-if="col.is_choice" #edit="{ row }">
<template v-if="col.is_choice || col.is_password" #edit="{ row }">
<vxe-input v-if="col.is_password" v-model="passwordValue[col.field]" />
<a-select
:getPopupContainer="(trigger) => trigger.parentElement"
:style="{ width: '100%', height: '32px' }"
@@ -177,10 +178,20 @@
#default="{row}"
>
<span v-if="col.value_type === '6' && row[col.field]">{{ row[col.field] }}</span>
<a v-else-if="col.is_link" :href="`${row[col.field]}`" target="_blank">{{ row[col.field] }}</a>
<a
v-else-if="col.is_link && row[col.field]"
:href="
row[col.field].startsWith('http') || row[col.field].startsWith('https')
? `${row[col.field]}`
: `http://${row[col.field]}`
"
target="_blank"
>{{ row[col.field] }}</a
>
<PasswordField
v-else-if="col.is_password && row[col.field]"
:password="row[col.field]"
:ci_id="row._id"
:attr_id="col.attr_id"
></PasswordField>
<template v-else-if="col.is_choice">
<template v-if="col.is_list">
@@ -333,7 +344,7 @@ import {
} from '@/modules/cmdb/api/CIRelation'
import { getCITypeAttributesById } from '@/modules/cmdb/api/CITypeAttr'
import { searchCI2, updateCI, deleteCI } from '@/modules/cmdb/api/ci'
import { searchCI2, updateCI, deleteCI, searchCI } from '@/modules/cmdb/api/ci'
import { getCITypes } from '../../api/CIType'
import { roleHasPermissionToGrant } from '@/modules/acl/api/permission'
import { searchResourceType } from '@/modules/acl/api/resource'
@@ -347,6 +358,7 @@ import PasswordField from '../../components/passwordField/index.vue'
import PreferenceSearch from '../../components/preferenceSearch/preferenceSearch.vue'
import CMDBGrant from '../../components/cmdbGrant'
import { ops_move_icon as OpsMoveIcon } from '@/core/icons'
import { getAttrPassword } from '../../api/CITypeAttr'
export default {
name: 'RelationViews',
@@ -407,6 +419,11 @@ export default {
tableDragClassName: [],
resource_type: {},
initialPasswordValue: {},
passwordValue: {},
lastEditCiId: null,
isContinueCloseEdit: true,
}
},
@@ -549,11 +566,11 @@ export default {
q = q.slice(1)
}
if (this.treeKeys.length === 0) {
await this.judgeCITypes(q)
if (!refreshType) {
this.loadRoot()
}
await this.judgeCITypes(q)
const fuzzySearch = (this.$refs['search'] || {}).fuzzySearch || ''
if (fuzzySearch) {
q = `q=_type:${this.currentTypeId[0]},*${fuzzySearch}*,` + q
@@ -635,6 +652,7 @@ export default {
statisticsCIRelation({
root_ids: key.split('%')[0],
level: this.treeKeys.length - index,
type_ids: this.showTypes.map((type) => type.id).join(','),
}).then((res) => {
let result
const getTreeItem = (data, id) => {
@@ -689,6 +707,7 @@ export default {
}
const promises = _showTypeIds.map((typeId) => {
const _q = (`q=_type:${typeId},` + q).replace(/count=\d*/, 'count=1')
console.log(_q)
if (this.treeKeys.length === 0) {
return searchCI2(_q).then((res) => {
if (res.numfound !== 0) {
@@ -739,7 +758,11 @@ export default {
level = idx + 1
}
})
return statisticsCIRelation({ root_ids: ciIds.join(','), level: level }).then((num) => {
return statisticsCIRelation({
root_ids: ciIds.join(','),
level: level,
type_ids: this.showTypes.map((type) => type.id).join(','),
}).then((num) => {
facet.forEach((item, idx) => {
item[1] += num[ciIds[idx] + '']
})
@@ -752,25 +775,35 @@ export default {
async loadNoRoot(rootIdAndTypeId, level) {
const rootId = rootIdAndTypeId.split('%')[0]
searchCIRelation(`root_id=${rootId}&level=1&count=10000`).then(async (res) => {
const facet = []
const ciIds = []
res.result.forEach((item) => {
facet.push([item[item.unique], 0, item._id, item._type, item.unique])
ciIds.push(item._id)
})
const promises = level.map((_level) => {
if (_level > 1) {
return statisticsCIRelation({ root_ids: ciIds.join(','), level: _level - 1 }).then((num) => {
facet.forEach((item, idx) => {
item[1] += num[ciIds[idx] + '']
const typeId = Number(rootIdAndTypeId.split('%')[1])
const topo_flatten = this.relationViews?.views[this.$route.meta.name]?.topo_flatten ?? []
const index = topo_flatten.findIndex((id) => id === typeId)
const _type = topo_flatten[index + 1]
if (_type) {
searchCIRelation(`q=_type:${_type}&root_id=${rootId}&level=1&count=10000`).then(async (res) => {
const facet = []
const ciIds = []
res.result.forEach((item) => {
facet.push([item[item.unique], 0, item._id, item._type, item.unique])
ciIds.push(item._id)
})
const promises = level.map((_level) => {
if (_level > 1) {
return statisticsCIRelation({
root_ids: ciIds.join(','),
level: _level - 1,
type_ids: this.showTypes.map((type) => type.id).join(','),
}).then((num) => {
facet.forEach((item, idx) => {
item[1] += num[ciIds[idx] + '']
})
})
})
}
}
})
await Promise.all(promises)
this.wrapTreeData(facet, 'loadNoRoot')
})
await Promise.all(promises)
this.wrapTreeData(facet, 'loadNoRoot')
})
}
},
onNodeClick(keys) {
@@ -874,6 +907,12 @@ export default {
calcColumns() {
const width = document.getElementById('relation-views-right').clientWidth
this.columns = getCITableColumns(this.instanceList, this.preferenceAttrList, width)
this.columns.forEach((col) => {
if (col.is_password) {
this.initialPasswordValue[col.field] = ''
this.passwordValue[col.field] = ''
}
})
this.$nextTick(() => {
this.$refs.xTable.refreshColumn()
})
@@ -924,10 +963,10 @@ export default {
),
onOk() {
const _tempTree = that.treeKeys[that.treeKeys.length - 1].split('%')
const firstCIObj = JSON.parse(_tempTree[2])
const first_ci_id = Number(_tempTree[0])
batchDeleteCIRelation(
that.selectedRowKeys.map((item) => item._id),
firstCIObj
[first_ci_id]
).then((res) => {
that.$refs.xTable.clearCheckboxRow()
that.$refs.xTable.clearCheckboxReserve()
@@ -1060,15 +1099,48 @@ export default {
}
return _editRender
},
handleEditActived() {},
handleEditActived() {
const passwordCol = this.columns.filter((col) => col.is_password)
this.$nextTick(() => {
const editRecord = this.$refs.xTable.getEditRecord()
const { row, column } = editRecord
if (passwordCol.length && this.lastEditCiId !== row._id) {
this.$nextTick(async () => {
for (let i = 0; i < passwordCol.length; i++) {
await getAttrPassword(row._id, passwordCol[i].attr_id).then((res) => {
this.initialPasswordValue[passwordCol[i].field] = res.value
this.passwordValue[passwordCol[i].field] = res.value
})
}
this.isContinueCloseEdit = false
await this.$refs.xTable.clearEdit()
this.isContinueCloseEdit = true
this.$nextTick(() => {
this.$refs.xTable.setEditCell(row, column.field)
})
})
}
this.lastEditCiId = row._id
})
},
handleEditClose({ row, rowIndex, column }) {
if (!this.isContinueCloseEdit) {
return
}
const $table = this.$refs['xTable']
const data = {}
this.columns.forEach((item) => {
if (!_.isEqual(row[item.field], this.initialInstanceList[rowIndex][item.field])) {
data[item.field] = row[item.field] || null
if (!(item.field in this.initialPasswordValue) && !_.isEqual(row[item.field], this.initialInstanceList[rowIndex][item.field])) {
data[item.field] = row[item.field] ?? null
}
})
Object.keys(this.initialPasswordValue).forEach((key) => {
if (this.initialPasswordValue[key] !== this.passwordValue[key]) {
data[key] = this.passwordValue[key]
row[key] = this.passwordValue[key]
}
})
this.lastEditCiId = null
if (JSON.stringify(data) !== '{}') {
updateCI(row.ci_id || row._id, data)
.then(() => {
@@ -1086,6 +1158,12 @@ export default {
$table.revertData(row)
})
}
this.columns.forEach((col) => {
if (col.is_password) {
this.initialPasswordValue[col.field] = ''
this.passwordValue[col.field] = ''
}
})
},
deleteCI(record) {
const that = this

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -17,9 +17,11 @@ const logo = {
getCompanyInfo({ commit }) {
return new Promise((resolve, reject) => {
getCompanyInfo().then(res => {
commit('SET_FILENAME', res.info.logoName)
commit('SET_SMALL_FILENAME', res.info.smallLogoName)
resolve(res.info)
if (res.info) {
commit('SET_FILENAME', res.info.logoName)
commit('SET_SMALL_FILENAME', res.info.smallLogoName)
resolve(res.info)
}
}).catch(err => {
console.log('获取失败', err)
reject(err)

View File

@@ -30,7 +30,7 @@ services:
- redis
cmdb-api:
image: registry.cn-hangzhou.aliyuncs.com/veops/cmdb-api:2.3.5
image: registry.cn-hangzhou.aliyuncs.com/veops/cmdb-api:2.3.6
# build:
# context: .
# target: cmdb-api
@@ -47,13 +47,15 @@ services:
flask db-setup
flask common-check-new-columns
gunicorn --workers=3 autoapp:app -b 0.0.0.0:5000 -D
celery -A celery_worker.celery worker -E -Q one_cmdb_async --autoscale=5,2 --logfile=one_cmdb_async.log -D
celery -A celery_worker.celery worker -E -Q acl_async --logfile=one_acl_async.log --concurrency=2 -D
nohup flask cmdb-trigger > trigger.log 2>&1 &
flask cmdb-init-cache
flask cmdb-init-acl
nohup flask cmdb-trigger > trigger.log 2>&1 &
nohup flask cmdb-counter > counter.log 2>&1 &
flask cmdb-counter > counter.log 2>&1
celery -A celery_worker.celery worker -E -Q one_cmdb_async --autoscale=5,2 --logfile=one_cmdb_async.log -D
celery -A celery_worker.celery worker -E -Q acl_async --logfile=one_acl_async.log --concurrency=2
depends_on:
- cmdb-db
- cmdb-cache
@@ -63,7 +65,7 @@ services:
- cmdb-api
cmdb-ui:
image: registry.cn-hangzhou.aliyuncs.com/veops/cmdb-ui:2.3.5
image: registry.cn-hangzhou.aliyuncs.com/veops/cmdb-ui:2.3.6
# build:
# context: .
# target: cmdb-ui

View File

@@ -1,8 +1,16 @@
![维易CMDB](images/logo.png)
[![License](https://img.shields.io/badge/License-AGPLv3-brightgreen)](https://github.com/veops/cmdb/blob/master/LICENSE)
[![UI](https://img.shields.io/badge/UI-Ant%20Design%20Pro%20Vue-brightgreen)](https://github.com/sendya/ant-design-pro-vue)
[![API](https://img.shields.io/badge/API-Flask-brightgreen)](https://github.com/pallets/flask)
<p align="center">
<a href="https://veops.cn"><img src="images/logo.png" alt="维易CMDB" width="300"/></a>
</p>
<h3 align="center">Simple, lightweight, and versatile operational CMDB</h3>
<p align="center">
<a href="https://github.com/veops/cmdb/blob/master/LICENSE"><img src="https://img.shields.io/badge/License-AGPLv3-brightgreen" alt="License: GPLv3"></a>
<a href="https:https://github.com/sendya/ant-design-pro-vue"><img src="https://img.shields.io/badge/UI-Ant%20Design%20Pro%20Vue-brightgreen" alt="UI"></a>
<a href="https://github.com/pallets/flask"><img src="https://img.shields.io/badge/API-Flask-brightgreen" alt="API"></a>
</p>
------------------------------
[English](README_en.md) / [中文](../README.md)
@@ -16,9 +24,11 @@
## Overview
### Technical Architecture
### System Overview
<img src=images/view.jpg />
<img src=images/dashboard.png />
[View more screenshots](screenshot.md)
### Document
@@ -48,12 +58,7 @@
- Fine-grained access control and comprehensive operation logs.
- Support cross-model search.
### System Overview
- Service Tree
![1](images/0.png "首页展示")
[View more screenshots](screenshot.md)
### More Features

View File

@@ -33,6 +33,7 @@
* **`IN`**查询: 例如: `hostname:(cmdb*;cmdb-web*)` 小括号, 分号分隔
* **`范围`**查询: 例如: `hostname:[cmdb* _TO_ cmdb-web*]` `_TO_`分隔
* **`比较`**查询: 例如: `cpu_count:>5` 支持`>, >=, <, <=`
* 多个条件可以用`小括号`进行组合
* 结果字段说明

View File

@@ -25,6 +25,8 @@
- `IN`查询. eg. `hostname:(cmdb*;cmdb-web*)` 小括号, 分号分隔
- `RANGE`查询. eg. `hostname:[cmdb* _TO_ cmdb-web*]` `_TO_`分隔
- `COMPARISON`查询. eg. `cpu_count:>5` 支持`>, >=, <, <=`
- 多个条件可以用`小括号`进行组合
- 返回结果
- 搜索表达式 `/api/v0.1/ci/s?q=_type:kvm,status:在线,idc:南汇,private_ip:10.1.1.1*&page=1&fl=hostname,private_ip&facet=private_ip&count=1`
- 返回数据(默认 json
@@ -91,6 +93,8 @@
- `IN`查询. eg. `hostname:(cmdb*;cmdb-web*)` 小括号, 分号分隔
- `RANGE`查询. eg. `hostname:[cmdb* _TO_ cmdb-web*]` `_TO_`分隔
- `COMPARISON`查询. eg. `cpu_count:>5` 支持`>, >=, <, <=`
- 多个条件可以用`小括号`进行组合
- 返回结果
- 搜索表达式 `/api/v0.1/ci_relations/s?root_id=53&level=3&q=_type:kvm,status:在线,idc:南汇,private_ip:10.1.1.1*&page=1&fl=hostname,private_ip&facet=private_ip&count=1`
- 返回数据(默认 json

15
docs/flask-migrate.md Normal file
View File

@@ -0,0 +1,15 @@
## 使用Flask-Migrate做数据库版本管理
- 首次可以删除cmdb-api/migrations/versions下的所有文件
-
### 进入cmdb-api完成下面步骤操作可能会删除数据库中不被代码管理的表如需保留请看文末中的tips
- 如果是首次使用需要先删除cmdb-api/migrations/versions下的所有文件非首次跳过
- 执行`flask db migrate` 生成对应版本数据库表的升级文件到versions文件夹下需要你的数据库是已经upgrade的
- 执行`flask db upgrade` 数据库表同步更新到mysql
### tips
- cmdb-api/migrations/env.py文件内的exclude_tables列表可以填写不想被flask-migrate管理的数据库表

BIN
docs/images/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

BIN
docs/images/wechat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -28,8 +28,8 @@ cp cmdb-api/settings.example.py cmdb-api/settings.py
- 后端: 进入**cmdb-api**目录执行 `pipenv run flask run -h 0.0.0.0`
- 前端: 进入**cmdb-ui**目录执行`yarn run serve`
- worker:
- 进入**cmdb-api**目录执行 `pipenv run celery -A celery_worker.celery worker -E -Q one_cmdb_async --concurrency=1 -D`
- 进入**cmdb-api**目录执行 `pipenv run celery -A celery_worker.celery worker -E -Q acl_async --concurrency=1 -D`
- 进入**cmdb-api**目录执行 `pipenv run celery -A celery_worker.celery worker -E -Q one_cmdb_async --autoscale=5,2 --logfile=one_cmdb_async.log -D`
- 进入**cmdb-api**目录执行 `pipenv run celery -A celery_worker.celery worker -E -Q acl_async --autoscale=2,1 --logfile=one_acl_async.log -D`
- 浏览器打开: [http://127.0.0.1:8000](http://127.0.0.1:8000)
- 如果是非本机访问, 要修改**cmdb-ui/.env**里**VUE_APP_API_BASE_URL**里的 IP 地址为后端服务的 ip 地址

View File

@@ -26,8 +26,8 @@
- backend: in **cmdb-api** directory: `pipenv run flask run -h 0.0.0.0`
- frontend: in **cmdb-ui** directory: `yarn run serve`
- worker:
- in **cmdb-api** directory: `pipenv run celery -A celery_worker.celery worker -E -Q one_cmdb_async --concurrency=1 -D`
- in **cmdb-api** directory: `pipenv run celery -A celery_worker.celery worker -E -Q acl_async --concurrency=1 -D`
- in **cmdb-api** directory: `pipenv run celery -A celery_worker.celery worker -E -Q one_cmdb_async --autoscale=5,2 --logfile=one_cmdb_async.log -D`
- in **cmdb-api** directory: `pipenv run celery -A celery_worker.celery worker -E -Q acl_async --autoscale=2,1 --logfile=one_acl_async.log -D`
- homepage: [http://127.0.0.1:8000](http://127.0.0.1:8000)
- if not run localhost: please change ip address(**VUE_APP_API_BASE_URL**) in config file **cmdb-ui/.env** into your backend ip address