Merge pull request #636 from veops/dev_ui_ipam

feat(ui): add ipam
This commit is contained in:
Leo Song 2024-11-11 16:50:35 +08:00 committed by GitHub
commit 54d645b711
47 changed files with 6250 additions and 66 deletions

View File

@ -54,6 +54,108 @@
<div class="content unicode" style="display: block;">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont">&#xe9fa;</span>
<div class="name">ops-setting-holiday_management-copy</div>
<div class="code-name">&amp;#xe9fa;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9f8;</span>
<div class="name">itsm-system_log</div>
<div class="code-name">&amp;#xe9f8;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9f6;</span>
<div class="name">ops-setting-adjustday</div>
<div class="code-name">&amp;#xe9f6;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9f7;</span>
<div class="name">ops-setting-holiday</div>
<div class="code-name">&amp;#xe9f7;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9f5;</span>
<div class="name">ops-setting-festival</div>
<div class="code-name">&amp;#xe9f5;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9f4;</span>
<div class="name">itsm-count</div>
<div class="code-name">&amp;#xe9f4;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9f3;</span>
<div class="name">itsm-satisfaction</div>
<div class="code-name">&amp;#xe9f3;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9f2;</span>
<div class="name">veops-folder</div>
<div class="code-name">&amp;#xe9f2;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9f1;</span>
<div class="name">veops-entire_network_</div>
<div class="code-name">&amp;#xe9f1;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9f0;</span>
<div class="name">veops-subnet</div>
<div class="code-name">&amp;#xe9f0;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9ef;</span>
<div class="name">veops-map_view</div>
<div class="code-name">&amp;#xe9ef;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9ee;</span>
<div class="name">veops-recycle</div>
<div class="code-name">&amp;#xe9ee;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9ed;</span>
<div class="name">veops-catalog</div>
<div class="code-name">&amp;#xe9ed;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9ec;</span>
<div class="name">veops-ipam</div>
<div class="code-name">&amp;#xe9ec;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9eb;</span>
<div class="name">cmdb-calc</div>
<div class="code-name">&amp;#xe9eb;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9ea;</span>
<div class="name">ai-users</div>
<div class="code-name">&amp;#xe9ea;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9e9;</span>
<div class="name">ai-tokens</div>
<div class="code-name">&amp;#xe9e9;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe9e8;</span>
<div class="name">oneterm-mysql</div>
@ -6000,9 +6102,9 @@
<pre><code class="language-css"
>@font-face {
font-family: 'iconfont';
src: url('iconfont.woff2?t=1729157759723') format('woff2'),
url('iconfont.woff?t=1729157759723') format('woff'),
url('iconfont.ttf?t=1729157759723') format('truetype');
src: url('iconfont.woff2?t=1731312848138') format('woff2'),
url('iconfont.woff?t=1731312848138') format('woff'),
url('iconfont.ttf?t=1731312848138') format('truetype');
}
</code></pre>
<h3 id="-iconfont-">第二步:定义使用 iconfont 的样式</h3>
@ -6028,6 +6130,159 @@
<div class="content font-class">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont ops-setting-holidays"></span>
<div class="name">
ops-setting-holiday_management-copy
</div>
<div class="code-name">.ops-setting-holidays
</div>
</li>
<li class="dib">
<span class="icon iconfont ops-itsm-logs"></span>
<div class="name">
itsm-system_log
</div>
<div class="code-name">.ops-itsm-logs
</div>
</li>
<li class="dib">
<span class="icon iconfont ops-setting-workday"></span>
<div class="name">
ops-setting-adjustday
</div>
<div class="code-name">.ops-setting-workday
</div>
</li>
<li class="dib">
<span class="icon iconfont ops-setting-holiday"></span>
<div class="name">
ops-setting-holiday
</div>
<div class="code-name">.ops-setting-holiday
</div>
</li>
<li class="dib">
<span class="icon iconfont ops-setting-festival"></span>
<div class="name">
ops-setting-festival
</div>
<div class="code-name">.ops-setting-festival
</div>
</li>
<li class="dib">
<span class="icon iconfont itsm-calc"></span>
<div class="name">
itsm-count
</div>
<div class="code-name">.itsm-calc
</div>
</li>
<li class="dib">
<span class="icon iconfont itsm-reports_4"></span>
<div class="name">
itsm-satisfaction
</div>
<div class="code-name">.itsm-reports_4
</div>
</li>
<li class="dib">
<span class="icon iconfont veops-folder"></span>
<div class="name">
veops-folder
</div>
<div class="code-name">.veops-folder
</div>
</li>
<li class="dib">
<span class="icon iconfont veops-entire_network_"></span>
<div class="name">
veops-entire_network_
</div>
<div class="code-name">.veops-entire_network_
</div>
</li>
<li class="dib">
<span class="icon iconfont veops-subnet"></span>
<div class="name">
veops-subnet
</div>
<div class="code-name">.veops-subnet
</div>
</li>
<li class="dib">
<span class="icon iconfont veops-map_view"></span>
<div class="name">
veops-map_view
</div>
<div class="code-name">.veops-map_view
</div>
</li>
<li class="dib">
<span class="icon iconfont veops-recycle"></span>
<div class="name">
veops-recycle
</div>
<div class="code-name">.veops-recycle
</div>
</li>
<li class="dib">
<span class="icon iconfont veops-catalog"></span>
<div class="name">
veops-catalog
</div>
<div class="code-name">.veops-catalog
</div>
</li>
<li class="dib">
<span class="icon iconfont veops-ipam"></span>
<div class="name">
veops-ipam
</div>
<div class="code-name">.veops-ipam
</div>
</li>
<li class="dib">
<span class="icon iconfont cmdb-calc"></span>
<div class="name">
cmdb-calc
</div>
<div class="code-name">.cmdb-calc
</div>
</li>
<li class="dib">
<span class="icon iconfont ai-users"></span>
<div class="name">
ai-users
</div>
<div class="code-name">.ai-users
</div>
</li>
<li class="dib">
<span class="icon iconfont ai-tokens"></span>
<div class="name">
ai-tokens
</div>
<div class="code-name">.ai-tokens
</div>
</li>
<li class="dib">
<span class="icon iconfont oneterm-mysql"></span>
<div class="name">
@ -8162,20 +8417,20 @@
</li>
<li class="dib">
<span class="icon iconfont itsm-duration"></span>
<span class="icon iconfont itsm-reports_3"></span>
<div class="name">
itsm-duration
</div>
<div class="code-name">.itsm-duration
<div class="code-name">.itsm-reports_3
</div>
</li>
<li class="dib">
<span class="icon iconfont itsm-workload"></span>
<span class="icon iconfont itsm-reports_2"></span>
<div class="name">
itsm-workload (1)
</div>
<div class="code-name">.itsm-workload
<div class="code-name">.itsm-reports_2
</div>
</li>
@ -14947,6 +15202,142 @@
<div class="content symbol">
<ul class="icon_lists dib-box">
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#ops-setting-holidays"></use>
</svg>
<div class="name">ops-setting-holiday_management-copy</div>
<div class="code-name">#ops-setting-holidays</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#ops-itsm-logs"></use>
</svg>
<div class="name">itsm-system_log</div>
<div class="code-name">#ops-itsm-logs</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#ops-setting-workday"></use>
</svg>
<div class="name">ops-setting-adjustday</div>
<div class="code-name">#ops-setting-workday</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#ops-setting-holiday"></use>
</svg>
<div class="name">ops-setting-holiday</div>
<div class="code-name">#ops-setting-holiday</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#ops-setting-festival"></use>
</svg>
<div class="name">ops-setting-festival</div>
<div class="code-name">#ops-setting-festival</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#itsm-calc"></use>
</svg>
<div class="name">itsm-count</div>
<div class="code-name">#itsm-calc</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#itsm-reports_4"></use>
</svg>
<div class="name">itsm-satisfaction</div>
<div class="code-name">#itsm-reports_4</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#veops-folder"></use>
</svg>
<div class="name">veops-folder</div>
<div class="code-name">#veops-folder</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#veops-entire_network_"></use>
</svg>
<div class="name">veops-entire_network_</div>
<div class="code-name">#veops-entire_network_</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#veops-subnet"></use>
</svg>
<div class="name">veops-subnet</div>
<div class="code-name">#veops-subnet</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#veops-map_view"></use>
</svg>
<div class="name">veops-map_view</div>
<div class="code-name">#veops-map_view</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#veops-recycle"></use>
</svg>
<div class="name">veops-recycle</div>
<div class="code-name">#veops-recycle</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#veops-catalog"></use>
</svg>
<div class="name">veops-catalog</div>
<div class="code-name">#veops-catalog</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#veops-ipam"></use>
</svg>
<div class="name">veops-ipam</div>
<div class="code-name">#veops-ipam</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#cmdb-calc"></use>
</svg>
<div class="name">cmdb-calc</div>
<div class="code-name">#cmdb-calc</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#ai-users"></use>
</svg>
<div class="name">ai-users</div>
<div class="code-name">#ai-users</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#ai-tokens"></use>
</svg>
<div class="name">ai-tokens</div>
<div class="code-name">#ai-tokens</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#oneterm-mysql"></use>
@ -16845,18 +17236,18 @@
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#itsm-duration"></use>
<use xlink:href="#itsm-reports_3"></use>
</svg>
<div class="name">itsm-duration</div>
<div class="code-name">#itsm-duration</div>
<div class="code-name">#itsm-reports_3</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#itsm-workload"></use>
<use xlink:href="#itsm-reports_2"></use>
</svg>
<div class="name">itsm-workload (1)</div>
<div class="code-name">#itsm-workload</div>
<div class="code-name">#itsm-reports_2</div>
</li>
<li class="dib">

View File

@ -1,8 +1,8 @@
@font-face {
font-family: "iconfont"; /* Project id 3857903 */
src: url('iconfont.woff2?t=1729157759723') format('woff2'),
url('iconfont.woff?t=1729157759723') format('woff'),
url('iconfont.ttf?t=1729157759723') format('truetype');
src: url('iconfont.woff2?t=1731312848138') format('woff2'),
url('iconfont.woff?t=1731312848138') format('woff'),
url('iconfont.ttf?t=1731312848138') format('truetype');
}
.iconfont {
@ -13,6 +13,74 @@
-moz-osx-font-smoothing: grayscale;
}
.ops-setting-holidays:before {
content: "\e9fa";
}
.ops-itsm-logs:before {
content: "\e9f8";
}
.ops-setting-workday:before {
content: "\e9f6";
}
.ops-setting-holiday:before {
content: "\e9f7";
}
.ops-setting-festival:before {
content: "\e9f5";
}
.itsm-calc:before {
content: "\e9f4";
}
.itsm-reports_4:before {
content: "\e9f3";
}
.veops-folder:before {
content: "\e9f2";
}
.veops-entire_network_:before {
content: "\e9f1";
}
.veops-subnet:before {
content: "\e9f0";
}
.veops-map_view:before {
content: "\e9ef";
}
.veops-recycle:before {
content: "\e9ee";
}
.veops-catalog:before {
content: "\e9ed";
}
.veops-ipam:before {
content: "\e9ec";
}
.cmdb-calc:before {
content: "\e9eb";
}
.ai-users:before {
content: "\e9ea";
}
.ai-tokens:before {
content: "\e9e9";
}
.oneterm-mysql:before {
content: "\e9e8";
}
@ -961,11 +1029,11 @@
content: "\e914";
}
.itsm-duration:before {
.itsm-reports_3:before {
content: "\e913";
}
.itsm-workload:before {
.itsm-reports_2:before {
content: "\e912";
}

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,125 @@
"css_prefix_text": "",
"description": "",
"glyphs": [
{
"icon_id": "42337844",
"name": "ops-setting-holiday_management-copy",
"font_class": "ops-setting-holidays",
"unicode": "e9fa",
"unicode_decimal": 59898
},
{
"icon_id": "42335414",
"name": "itsm-system_log",
"font_class": "ops-itsm-logs",
"unicode": "e9f8",
"unicode_decimal": 59896
},
{
"icon_id": "42334782",
"name": "ops-setting-adjustday",
"font_class": "ops-setting-workday",
"unicode": "e9f6",
"unicode_decimal": 59894
},
{
"icon_id": "42334768",
"name": "ops-setting-holiday",
"font_class": "ops-setting-holiday",
"unicode": "e9f7",
"unicode_decimal": 59895
},
{
"icon_id": "42334734",
"name": "ops-setting-festival",
"font_class": "ops-setting-festival",
"unicode": "e9f5",
"unicode_decimal": 59893
},
{
"icon_id": "42281202",
"name": "itsm-count",
"font_class": "itsm-calc",
"unicode": "e9f4",
"unicode_decimal": 59892
},
{
"icon_id": "42270632",
"name": "itsm-satisfaction",
"font_class": "itsm-reports_4",
"unicode": "e9f3",
"unicode_decimal": 59891
},
{
"icon_id": "42205149",
"name": "veops-folder",
"font_class": "veops-folder",
"unicode": "e9f2",
"unicode_decimal": 59890
},
{
"icon_id": "42205128",
"name": "veops-entire_network_",
"font_class": "veops-entire_network_",
"unicode": "e9f1",
"unicode_decimal": 59889
},
{
"icon_id": "42205094",
"name": "veops-subnet",
"font_class": "veops-subnet",
"unicode": "e9f0",
"unicode_decimal": 59888
},
{
"icon_id": "42201912",
"name": "veops-map_view",
"font_class": "veops-map_view",
"unicode": "e9ef",
"unicode_decimal": 59887
},
{
"icon_id": "42201676",
"name": "veops-recycle",
"font_class": "veops-recycle",
"unicode": "e9ee",
"unicode_decimal": 59886
},
{
"icon_id": "42201586",
"name": "veops-catalog",
"font_class": "veops-catalog",
"unicode": "e9ed",
"unicode_decimal": 59885
},
{
"icon_id": "42201534",
"name": "veops-ipam",
"font_class": "veops-ipam",
"unicode": "e9ec",
"unicode_decimal": 59884
},
{
"icon_id": "42179262",
"name": "cmdb-calc",
"font_class": "cmdb-calc",
"unicode": "e9eb",
"unicode_decimal": 59883
},
{
"icon_id": "42161413",
"name": "ai-users",
"font_class": "ai-users",
"unicode": "e9ea",
"unicode_decimal": 59882
},
{
"icon_id": "42161417",
"name": "ai-tokens",
"font_class": "ai-tokens",
"unicode": "e9e9",
"unicode_decimal": 59881
},
{
"icon_id": "42155223",
"name": "oneterm-mysql",
@ -1667,14 +1786,14 @@
{
"icon_id": "39926816",
"name": "itsm-duration",
"font_class": "itsm-duration",
"font_class": "itsm-reports_3",
"unicode": "e913",
"unicode_decimal": 59667
},
{
"icon_id": "39926833",
"name": "itsm-workload (1)",
"font_class": "itsm-workload",
"font_class": "itsm-reports_2",
"unicode": "e912",
"unicode_decimal": 59666
},

Binary file not shown.

View File

@ -0,0 +1,109 @@
import { axios } from '@/utils/request'
export function getIPAMSubnet() {
return axios({
url: '/v0.1/ipam/subnet',
method: 'GET'
})
}
export function postIPAMSubnet(data) {
return axios({
url: '/v0.1/ipam/subnet',
method: 'POST',
data
})
}
export function getIPAMSubnetById(id) {
return axios({
url: `/v0.1/ipam/subnet/${id}`,
method: 'GET'
})
}
export function putIPAMSubnet(id, data) {
return axios({
url: `/v0.1/ipam/subnet/${id}`,
method: 'PUT',
data
})
}
export function deleteIPAMSubnet(id) {
return axios({
url: `/v0.1/ipam/subnet/${id}`,
method: 'DELETE'
})
}
export function postIPAMScope(data) {
return axios({
url: '/v0.1/ipam/scope',
method: 'POST',
data
})
}
export function putIPAMScope(id, data) {
return axios({
url: `/v0.1/ipam/scope/${id}`,
method: 'PUT',
data
})
}
export function deleteIPAMScope(id) {
return axios({
url: `/v0.1/ipam/scope/${id}`,
method: 'DELETE'
})
}
export function getIPAMAddress(params) {
return axios({
url: '/v0.1/ipam/address',
method: 'GET',
params
})
}
export function getIPAMHosts(params) {
return axios({
url: '/v0.1/ipam/subnet/hosts',
method: 'GET',
params
})
}
export function postIPAMAddress(data) {
return axios({
url: '/v0.1/ipam/address',
method: 'POST',
data
})
}
export function getIPAMHistoryOperate(params) {
return axios({
url: '/v0.1/ipam/history/operate',
method: 'GET',
params
})
}
export function getIPAMHistoryScan(params) {
return axios({
url: '/v0.1/ipam/history/scan',
method: 'GET',
params
})
}
export function getIPAMStats(params) {
return axios({
url: '/v0.1/ipam/stats',
method: 'GET',
params
})
}

View File

@ -1,4 +1,6 @@
import { axios } from '@/utils/request'
import { CI_DEFAULT_ATTR } from '@/modules/cmdb/utils/const.js'
import i18n from '@/lang'
export function getPreference(instance = true, tree = null) {
return axios({
@ -16,10 +18,34 @@ export function getPreference2(instance = true, tree = null) {
})
}
export function getSubscribeAttributes(ciTypeId) {
return axios({
url: `/v0.1/preference/ci_types/${ciTypeId}/attributes`,
method: 'GET'
export function getSubscribeAttributes(ciTypeId, formatDefaultAttr = true) {
return new Promise(async (resolve) => {
const res = await axios({
url: `/v0.1/preference/ci_types/${ciTypeId}/attributes`,
method: 'GET'
})
if (
formatDefaultAttr &&
res?.attributes?.length
) {
res.attributes.forEach((item) => {
switch (item.name) {
case CI_DEFAULT_ATTR.UPDATE_USER:
item.id = item.name
item.alias = i18n.t('cmdb.components.updater')
break
case CI_DEFAULT_ATTR.UPDATE_TIME:
item.id = item.name
item.alias = i18n.t('cmdb.components.updateTime')
break
default:
break
}
})
}
resolve(res)
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -22,7 +22,7 @@
<draggable :value="targetKeys" animation="300" @end="dragEnd" :disabled="!isSortable">
<div
@dblclick="changeSingleItem(item)"
v-for="item in filteredItems"
v-for="item in filterDefaultAttr(filteredItems)"
:key="item.key"
:style="{ height: '38px' }"
>
@ -54,11 +54,44 @@
</li>
</div>
</draggable>
<div
v-if="rightDefaultAttrList.length"
class="default-attr"
>
<a-divider>
<span class="default-attr-divider">
{{ $t('cmdb.components.default') }}
</span>
</a-divider>
<div
v-for="(item) in rightDefaultAttrList"
:key="item.key"
:class="['default-attr-item', selectedKeys.includes(item.key) ? 'default-attr-item-selected' : '']"
@click="setSelectedKeys(item)"
@dblclick="changeSingleItem(item)"
>
<div
class="default-attr-arrow"
style="left: 17px"
@click.stop="changeSingleItem(item)"
>
<a-icon type="left" />
</div>
<div class="default-attr-title">
{{ $t(item.title) }}
</div>
<div class="default-attr-name">
{{ item.name }}
</div>
</div>
</div>
</div>
<div v-if="direction === 'left'" class="ant-transfer-list-content">
<div
@dblclick="changeSingleItem(item)"
v-for="item in filteredItems"
v-for="item in filterDefaultAttr(filteredItems)"
:key="item.key"
:style="{ height: '38px' }"
>
@ -82,6 +115,39 @@
</div>
</li>
</div>
<div
v-if="leftDefaultAttrList.length"
class="default-attr"
>
<a-divider>
<span class="default-attr-divider">
{{ $t('cmdb.components.default') }}
</span>
</a-divider>
<div
v-for="(item) in leftDefaultAttrList"
:key="item.key"
:class="['default-attr-item', selectedKeys.includes(item.key) ? 'default-attr-item-selected' : '']"
@click="setSelectedKeys(item)"
@dblclick="changeSingleItem(item)"
>
<div
class="default-attr-arrow"
style="left: 2px"
@click.stop="changeSingleItem(item)"
>
<a-icon type="right" />
</div>
<div class="default-attr-title">
{{ $t(item.title) }}
</div>
<div class="default-attr-name">
{{ item.name }}
</div>
</div>
</div>
</div>
</template>
</a-transfer>
@ -95,6 +161,7 @@
import _ from 'lodash'
import draggable from 'vuedraggable'
import { ops_move_icon as OpsMoveIcon } from '@/core/icons'
import { CI_DEFAULT_ATTR } from '@/modules/cmdb/utils/const.js'
export default {
name: 'AttributesTransfer',
@ -130,10 +197,41 @@ export default {
type: Number,
default: 400,
},
showDefaultAttr: {
type: Boolean,
default: false,
}
},
data() {
return {
selectedKeys: [],
defaultAttrList: [
{
title: 'cmdb.components.updater',
name: 'updater',
key: CI_DEFAULT_ATTR.UPDATE_USER
},
{
title: 'cmdb.components.updateTime',
name: 'update time',
key: CI_DEFAULT_ATTR.UPDATE_TIME
}
]
}
},
computed: {
rightDefaultAttrList() {
if (!this.showDefaultAttr) {
return []
}
return this.defaultAttrList.filter((item) => this.targetKeys.includes(item.key))
},
leftDefaultAttrList() {
if (!this.showDefaultAttr) {
return []
}
return this.defaultAttrList.filter((item) => !this.targetKeys.includes(item.key))
}
},
methods: {
@ -216,6 +314,10 @@ export default {
}
this.$emit('setFixedList', _fixedList)
},
filterDefaultAttr(list) {
return this.showDefaultAttr ? list.filter((item) => ![CI_DEFAULT_ATTR.UPDATE_USER, CI_DEFAULT_ATTR.UPDATE_TIME].includes(item.key)) : list
}
},
}
</script>
@ -296,5 +398,67 @@ export default {
}
}
}
.default-attr {
.ant-divider {
margin: 7px 0;
padding: 0 15px;
.ant-divider-inner-text {
padding: 0 6px;
}
}
&-divider {
font-size: 12px;
color: #86909C;
}
&-title {
font-size: 14px;
line-height: 14px;
font-weight: 400;
}
&-name {
font-size: 11px;
line-height: 12px;
color: rgb(163, 163, 163);
}
&-arrow {
position: absolute;
top: 9px;
display: none;
cursor: pointer;
font-size: 12px;
background-color: #fff;
color: @primary-color;
border-radius: 4px;
width: 12px;
}
&-item {
padding-left: 34px;
padding-top: 4px;
padding-bottom: 4px;
position: relative;
border-left: solid 2px transparent;
margin-bottom: 6px;
&-selected {
background-color: #f0f5ff;
border-color: #2f54eb;
}
&:hover {
background-color: #f0f5ff;
.default-attr-arrow {
display: inline;
}
}
}
}
}
</style>

View File

@ -129,6 +129,8 @@ import Treeselect from '@riophae/vue-treeselect'
import MetadataDrawer from '../../views/ci/modules/MetadataDrawer.vue'
import FilterComp from '@/components/CMDBFilterComp'
import { getCITypeGroups } from '../../api/ciTypeGroup'
import { CI_DEFAULT_ATTR } from '@/modules/cmdb/utils/const.js'
export default {
name: 'SearchForm',
components: { MetadataDrawer, FilterComp, Treeselect },
@ -176,7 +178,9 @@ export default {
return '200px'
},
canSearchPreferenceAttrList() {
return this.preferenceAttrList.filter((item) => item.value_type !== '6')
return this.preferenceAttrList.filter((item) => {
return item.value_type !== '6' && ![CI_DEFAULT_ATTR.UPDATE_USER, CI_DEFAULT_ATTR.UPDATE_TIME].includes(item.name)
})
},
},
watch: {

View File

@ -29,6 +29,7 @@
:fixedList="fixedList"
@setFixedList="setFixedList"
:height="windowHeight - 170"
:showDefaultAttr="true"
/>
<div class="custom-drawer-bottom-action">
<a-button @click="subInstanceSubmit" type="primary">{{ $t('cmdb.preference.sub') }}</a-button>
@ -64,7 +65,7 @@
</div>
</div>
<div class="cmdb-subscribe-drawer-tree-main" :style="{ maxHeight: `${((windowHeight - 170) * 2) / 3}px` }">
<div @click="changeTreeViews(attr)" v-for="attr in attrList" :key="attr.name">
<div @click="changeTreeViews(attr)" v-for="attr in treeViewAttrList" :key="attr.name">
<a-checkbox :checked="treeViews.includes(attr.name)" />
{{ attr.title }}
</div>
@ -90,6 +91,8 @@ import {
} from '@/modules/cmdb/api/preference'
import { getCITypeAttributesByName } from '@/modules/cmdb/api/CITypeAttr'
import AttributesTransfer from '../attributesTransfer'
import { CI_DEFAULT_ATTR } from '@/modules/cmdb/utils/const.js'
export default {
name: 'SubscribeSetting',
components: { AttributesTransfer },
@ -110,16 +113,32 @@ export default {
...mapState({
windowHeight: (state) => state.windowHeight,
}),
treeViewAttrList() {
return this.attrList.filter((item) => ![CI_DEFAULT_ATTR.UPDATE_USER, CI_DEFAULT_ATTR.UPDATE_TIME].includes(item.name))
}
},
methods: {
open(ciType = {}, activeKey = '1') {
this.ciType = ciType
this.activeKey = activeKey
const updatedByKey = CI_DEFAULT_ATTR.UPDATE_USER
const updatedAtKey = CI_DEFAULT_ATTR.UPDATE_TIME
getCITypeAttributesByName(ciType.type_id).then((res) => {
const attributes = res.attributes
const attributes = res.attributes.filter((item) => ![updatedByKey, updatedAtKey].includes(item.name))
;[updatedByKey, updatedAtKey].map((key) => {
attributes.push({
alias: key,
name: key,
id: key
})
})
getSubscribeAttributes(ciType.type_id).then((_res) => {
this.instanceSubscribed = _res.is_subscribed
const selectedAttrList = _res.attributes.map((item) => item.id.toString())
const attrList = attributes.map((item) => {
return {
key: item.id.toString(),
@ -188,9 +207,20 @@ export default {
})
},
subInstanceSubmit() {
const customAttr = []
const defaultAttr = []
this.selectedAttrList.map((attr) => {
if ([CI_DEFAULT_ATTR.UPDATE_USER, CI_DEFAULT_ATTR.UPDATE_TIME].includes(attr)) {
defaultAttr.push(attr)
} else {
customAttr.push(attr)
}
})
const selectedAttrList = [...customAttr, ...defaultAttr]
subscribeCIType(
this.ciType.type_id,
this.selectedAttrList.map((item) => {
selectedAttrList.map((item) => {
return [item, !!this.fixedList.includes(item)]
})
).then((res) => {

View File

@ -24,7 +24,8 @@ const cmdb_en = {
operationHistory: 'Operation Audit',
relationType: 'Relation Type',
ad: 'AutoDiscovery',
cidetail: 'CI Detail'
cidetail: 'CI Detail',
scene: 'Scene'
},
ciType: {
ciType: 'CIType',
@ -377,6 +378,9 @@ const cmdb_en = {
param: 'Parameter{param}',
value: 'Value{value}',
clear: 'Clear',
updater: 'Update User',
updateTime: 'Update Time',
default: 'Default'
},
batch: {
downloadFailed: 'Download failed',
@ -757,6 +761,83 @@ if __name__ == "__main__":
conditionName: 'Condition Name',
path: 'Path',
expandCondition: 'Expand Condition',
},
ipam: {
overview: 'Overview',
addressAssign: 'Address Assign',
ipSearch: 'IP Search',
subnetList: 'Subnet List',
history: 'History Log',
ticket: 'Related Tickets',
addSubnet: 'Add Subnet',
editSubnet: 'Edit Subnet',
addCatalog: 'Add Catalog',
editCatalog: 'Edit Catalog',
catalogName: 'Catalog Name',
editName: 'Edit Name',
editNode: 'Edit Node',
deleteNode: 'Delete Node',
basicInfo: 'Basic Info',
scanRule: 'Scan Rule',
adExecTarget: 'Execute targets',
masterMachineTip: 'The machine where OneMaster is installed',
oneagentIdTips: 'Please enter the hexadecimal OneAgent ID starting with 0x ID',
selectFromCMDBTips: 'Select from CMDB',
adInterval: 'Collection frequency',
cronTips: 'The format is the same as crontab, for example: 0 15 * * 1-5',
masterMachine: 'Master machine',
specifyMachine: 'Specify machine',
specifyMachineTips: 'Please fill in the specify machine!',
cronRequiredTip: 'Acquisition frequency cannot be null',
addressNullTip: ' Please select the leaf node of the left subnet tree first',
addressNullTip2: 'Subnet prefix length must be >= 16',
assignedOnline: 'Assigned Online',
assignedOffline: 'Assigned Offline',
unassignedOnline: 'Unassigned Online',
unused: 'Unused',
allStatus: 'All Status',
editAssignAddress: 'Edit Assign Address',
assign: 'Assign',
recycle: 'Recycle',
assignStatus: 'Assign Status',
reserved: 'Reserved',
assigned: 'Assigned',
recycleTip: 'Confirmed for recycle? After recycling, the status of the segment will be changed to unassigned.',
recycleSuccess: '{ip} Recycled successfully, status changed to: unassigned.',
operationLog: 'Operation Log',
scanLog: 'Scan Log',
updateCatalog: 'Update Catalog',
deleteCatalog: 'Delete Catalog',
updateSubnet: 'Update Subnet',
deleteSubnet: 'Delete Subnet',
revokeAddress: 'Revoke Address',
operateTime: 'Operate Time',
operateUser: 'Operate User',
operateType: 'Operate Type',
subnet: 'Subnet',
description: 'Description',
ipNumber: 'Number of online IP',
startTime: 'Start Time',
endTime: 'End Time',
scanningTime: 'Scanning Time',
viewResult: 'View Result',
scannedIP: 'Scanned IP',
subnetStats: 'Subnet Stats',
addressStats: 'Address Stats',
onlineStats: 'Online Stats',
assignStats: 'Assign Stats',
total: 'Total',
free: 'Free',
unassigned: 'Unassigned',
online: 'Online',
offline: 'Offline',
onlineUsageStats: 'Subnet Online Stats',
subnetName: 'Subnet Name',
addressCount: 'Address Count',
onlineRatio: 'Online Ratio',
scanEnable: 'Scan Enable',
lastScanTime: 'Last Scan Time',
isSuccess: 'Is Success'
}
}
export default cmdb_en

View File

@ -24,7 +24,8 @@ const cmdb_zh = {
operationHistory: '操作审计',
relationType: '关系类型',
ad: '自动发现',
cidetail: 'CI 详情'
cidetail: 'CI 详情',
scene: '场景'
},
ciType: {
ciType: '模型',
@ -377,6 +378,9 @@ const cmdb_zh = {
param: '参数{param}',
value: '值{value}',
clear: '清空',
updater: '更新人',
updateTime: '更新时间',
default: '默认'
},
batch: {
downloadFailed: '失败下载',
@ -756,6 +760,83 @@ if __name__ == "__main__":
conditionName: '条件命名',
path: '路径',
expandCondition: '展开条件',
},
ipam: {
overview: '概览',
addressAssign: '地址分配',
ipSearch: 'IP查询',
subnetList: '子网列表',
history: '历史记录',
ticket: '关联工单',
addSubnet: '新增子网',
editSubnet: '编辑子网',
addCatalog: '新增目录',
editCatalog: '修改目录',
catalogName: '目录名称',
editName: '修改名称',
editNode: '修改节点',
deleteNode: '删除节点',
basicInfo: '基本信息',
scanRule: '扫描规则',
adExecTarget: '执行机器',
masterMachineTip: '安装OneMaster的所在机器',
oneagentIdTips: '请输入以0x开头的16进制OneAgent ID',
selectFromCMDBTips: '从CMDB中选择',
adInterval: '采集频率',
cronTips: '格式同crontab, 例如0 15 * * 1-5',
masterMachine: 'Master机器',
specifyMachine: '指定机器',
specifyMachineTips: '请填写指定机器!',
cronRequiredTip: '采集频率不能为空',
addressNullTip: '请先选择左侧子网树的叶子节点',
addressNullTip2: '子网前缀长度必须 >= 16',
assignedOnline: '已分配在线',
assignedOffline: '已分配离线',
unassignedOnline: '未分配在线',
unused: '空闲',
allStatus: '全部状态',
editAssignAddress: '编辑分配地址',
assign: '分配',
recycle: '回收',
assignStatus: '分配状态',
reserved: '预留',
assigned: '已分配',
recycleTip: '确认要回收吗?回收后该网段状态变更为:未分配',
recycleSuccess: '{ip} 回收成功,状态变更为: 未分配',
operationLog: '操作记录',
scanLog: '扫描记录',
updateCatalog: '更新目录',
deleteCatalog: '删除目录',
updateSubnet: '修改子网',
deleteSubnet: '删除子网',
revokeAddress: '地址回收',
operateTime: '操作时间',
operateUser: '操作人',
operateType: '操作类型',
subnet: '子网',
description: '描述',
ipNumber: '在线IP地址数',
startTime: '开始时间',
endTime: '结束时间',
scanningTime: '扫描耗时',
viewResult: '查看结果',
scannedIP: '已扫描的IP',
subnetStats: '子网统计',
addressStats: '地址数统计',
onlineStats: '在线统计',
assignStats: '分配统计',
total: '总数',
free: '空闲',
unassigned: '未分配',
online: '在线',
offline: '离线',
onlineUsageStats: '子网在线统计',
subnetName: '子网名称',
addressCount: '地址数',
onlineRatio: '在线率',
scanEnable: '是否扫描',
lastScanTime: '最后扫描时间',
isSuccess: '是否成功'
}
}
export default cmdb_zh

View File

@ -70,6 +70,17 @@ const genCmdbRoutes = async () => {
meta: { title: 'cmdb.menu.cidetail', keepAlive: false },
component: () => import('../views/ci/ciDetailPage.vue')
},
{
path: '/cmdb/disabled4',
name: 'cmdb_disabled4',
meta: { title: 'cmdb.menu.scene', appName: 'cmdb', disabled: true, permission: ['admin', 'cmdb_admin'] },
},
{
path: '/cmdb/ipam',
component: () => import('../views/ipam'),
name: 'cmdb_ipam',
meta: { title: 'IPAM', appName: 'cmdb', icon: 'veops-ipam', selectedIcon: 'veops-ipam', keepAlive: false, permission: ['admin', 'cmdb_admin'] }
},
{
path: '/cmdb/disabled2',
name: 'cmdb_disabled2',

View File

@ -33,3 +33,8 @@ export const defautValueColor = [
]
export const defaultBGColors = ['#ffccc7', '#ffd8bf', '#ffe7ba', '#fff1b8', '#d9f7be', '#b5f5ec', '#bae7ff', '#d6e4ff', '#efdbff', '#ffd6e7']
export const CI_DEFAULT_ATTR = {
UPDATE_USER: '_updated_by',
UPDATE_TIME: '_updated_at'
}

View File

@ -2,6 +2,9 @@
import _ from 'lodash'
import XLSX from 'xlsx'
import XLSXS from 'xlsx-js-style'
import { CI_DEFAULT_ATTR } from './const.js'
import i18n from '@/lang'
export function sum(arr) {
if (!arr.length) {
return 0
@ -49,7 +52,10 @@ export function getCITableColumns(data, attrList, width = 1600, height) {
const _attrList = _.orderBy(attrList, ['is_fixed'], ['desc'])
const columns = []
for (let attr of _attrList) {
const editRender = { name: 'input' }
const editRender = {
name: 'input',
enabled: !attr.is_computed && !attr.sys_computed
}
switch (attr.value_type) {
case '0':
editRender['props'] = { 'type': 'float' }
@ -83,13 +89,35 @@ export function getCITableColumns(data, attrList, width = 1600, height) {
delete editRender.props
}
let title = attr.alias || attr.name
let sortable = !!attr.is_sortable
let attr_id = attr.id
if ([CI_DEFAULT_ATTR.UPDATE_TIME, CI_DEFAULT_ATTR.UPDATE_USER].includes(attr.name)) {
editRender.enabled = false
attr_id = attr.name
switch (attr.name) {
case CI_DEFAULT_ATTR.UPDATE_USER:
title = i18n.t('cmdb.components.updater')
break;
case CI_DEFAULT_ATTR.UPDATE_TIME:
title = i18n.t('cmdb.components.updateTime')
sortable = true
break;
default:
break;
}
}
columns.push({
attr_id: attr.id,
attr_id,
editRender,
title: attr.alias || attr.name,
title,
field: attr.name,
value_type: attr.value_type,
sortable: !!attr.is_sortable,
sortable,
filters: attr.is_choice ? attr.choice_value : null,
choice_builtin: null,
width: Math.min(Math.max(100, ...data.map(item => strLength(item[attr.name]))), 350),

View File

@ -259,7 +259,7 @@ export default {
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)
g.attributes = g.attributes.filter((attr) => !attr.is_computed && !attr.sys_computed)
return g
})
const attrHasGroupIds = []
@ -268,7 +268,7 @@ export default {
attrHasGroupIds.push(...id)
})
const otherGroupAttr = this.attributeList.filter(
(attr) => !attrHasGroupIds.includes(attr.id) && !attr.is_computed
(attr) => !attrHasGroupIds.includes(attr.id) && !attr.is_computed && !attr.sys_computed
)
if (otherGroupAttr.length) {
_attributesByGroup.push({ id: -1, name: this.$t('other'), attributes: otherGroupAttr })

View File

@ -178,7 +178,7 @@
</a-form-item>
</a-form>
</template>
<a v-if="!isEdit && !attr.is_computed" @click="handleEdit" :style="{ opacity: 0 }"><a-icon type="edit"/></a>
<a v-if="!isEdit && !attr.is_computed && !attr.sys_computed" @click="handleEdit" :style="{ opacity: 0 }"><a-icon type="edit"/></a>
<JsonEditor ref="jsonEditor" @jsonEditorOk="jsonEditorOk" />
</span>
</template>
@ -267,7 +267,7 @@ export default {
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 })
await updateCI(this.ci._id, { [`${this.attr.name}`]: newData ?? null })
.then(() => {
this.$message.success(this.$t('updateSuccess'))
this.$emit('updateCIByself', { [`${this.attr.name}`]: newData }, this.attr.name)

View File

@ -18,7 +18,7 @@
:disabled="!canEdit[parent.id]"
@click="
() => {
$refs.addTableModal.openModal({ [`${ci.unique}`]: ci[ci.unique] }, ci._id, parent.id, 'parents')
$refs.addTableModal.openModal({ [`${ci.unique}`]: ci[ci.unique] }, ci._id, parent, 'parents')
}
"
><a-icon
@ -76,7 +76,7 @@
:disabled="!canEdit[child.id]"
@click="
() => {
$refs.addTableModal.openModal({ [`${ci.unique}`]: ci[ci.unique] }, ci._id, child.id, 'children')
$refs.addTableModal.openModal({ [`${ci.unique}`]: ci[ci.unique] }, ci._id, child, 'children')
}
"
><a-icon

View File

@ -78,20 +78,24 @@ export default {
},
})
this.canvas.setZoomable(true, true)
this.canvas.on('events', ({ type, data }) => {
const sourceNode = data?.id || null
if (type === 'custom:clickLeft') {
searchCIRelation(`root_id=${Number(sourceNode)}&level=1&reverse=1&count=10000`).then((res) => {
this.redrawData(res, sourceNode, 'left')
})
this.debounceClick(sourceNode, 1)
}
if (type === 'custom:clickRight') {
searchCIRelation(`root_id=${Number(sourceNode)}&level=1&reverse=0&count=10000`).then((res) => {
this.redrawData(res, sourceNode, 'right')
})
this.debounceClick(sourceNode, 0)
}
})
},
debounceClick: _.debounce(function(sourceNode, reverse) {
searchCIRelation(`root_id=${Number(sourceNode)}&level=1&reverse=${reverse}&count=10000`).then((res) => {
this.redrawData(res, sourceNode, reverse === 1 ? 'left' : 'right')
})
}, 300),
setTopoData(data) {
const root = document.getElementById('ci-detail-relation-topo')
if (root && root?.innerHTML) {

View File

@ -4,6 +4,7 @@
<AttributesTransfer
:dataSource="attrList"
:targetKeys="selectedAttrList"
:showDefaultAttr="true"
@setTargetKeys="setTargetKeys"
@changeSingleItem="changeSingleItem"
@handleSubmit="handleSubmit"
@ -23,6 +24,8 @@
import AttributesTransfer from '../../../components/attributesTransfer'
import { subscribeCIType, getSubscribeAttributes } from '@/modules/cmdb/api/preference'
import { getCITypeAttributesByName } from '@/modules/cmdb/api/CITypeAttr'
import { CI_DEFAULT_ATTR } from '@/modules/cmdb/utils/const.js'
export default {
name: 'EditAttrsPopover',
components: { AttributesTransfer },
@ -48,8 +51,19 @@ export default {
}
},
getAttrs() {
const updatedByKey = CI_DEFAULT_ATTR.UPDATE_USER
const updatedAtKey = CI_DEFAULT_ATTR.UPDATE_TIME
getCITypeAttributesByName(this.typeId).then((res) => {
const attributes = res.attributes
const attributes = res.attributes.filter((item) => ![updatedByKey, updatedAtKey].includes(item.name))
;[updatedByKey, updatedAtKey].map((key) => {
attributes.push({
alias: key,
name: key,
id: key
})
})
getSubscribeAttributes(this.typeId).then((_res) => {
const selectedAttrList = _res.attributes.map((item) => item.id.toString())
@ -71,12 +85,23 @@ export default {
handleSubmit() {
if (this.selectedAttrList.length) {
const customAttr = []
const defaultAttr = []
this.selectedAttrList.map((attr) => {
if ([CI_DEFAULT_ATTR.UPDATE_USER, CI_DEFAULT_ATTR.UPDATE_TIME].includes(attr)) {
defaultAttr.push(attr)
} else {
customAttr.push(attr)
}
})
const selectedAttrList = [...customAttr, ...defaultAttr]
subscribeCIType(
this.typeId,
this.selectedAttrList.map((item) => {
selectedAttrList.map((item) => {
return [item, !!this.fixedList.includes(item)]
})
).then((res) => {
).then(() => {
this.$message.success(this.$t('cmdb.components.subSuccess'))
this.visible = false
this.$emit('refresh')

View File

@ -0,0 +1,99 @@
<template>
<a-modal
:visible="visible"
:title="$t(actionType === 'edit' ? 'cmdb.ipam.editCatalog' : 'cmdb.ipam.addCatalog')"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form-model
ref="catelogFormRef"
:model="form"
:rules="formRules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 19 }"
>
<a-form-model-item
:label="$t('cmdb.ipam.catalogName')"
prop="name"
>
<a-input
:placeholder="$t('placeholder1')"
v-model="form.name"
/>
</a-form-model-item>
</a-form-model>
</a-modal>
</template>
<script>
import { postIPAMScope, putIPAMScope } from '@/modules/cmdb/api/ipam.js'
export default {
name: 'CatalogForm',
data() {
return {
visible: false,
nodeId: null,
actionType: 'create',
form: {
name: ''
},
formRules: {
name: [
{
required: true, message: this.$t('placeholder1')
}
]
}
}
},
methods: {
open({
nodeId,
type,
name
}) {
this.nodeId = nodeId || null
this.actionType = type || 'create'
this.form.name = name || ''
this.visible = true
},
handleCancel() {
this.visible = false
this.form.name = ''
this.actionType = 'create'
this.nodeId = null
this.$refs.catelogFormRef.clearValidate()
},
handleOk() {
this.$refs.catelogFormRef.validate(async (valid) => {
if (!valid) {
return
}
if (this.actionType === 'edit') {
await putIPAMScope(this.nodeId, {
name: this.form.name
})
this.$message.success(this.$t('editSuccess'))
} else {
await postIPAMScope({
parent_id: this.nodeId,
name: this.form.name
})
this.$message.success(this.$t('addSuccess'))
}
this.$emit('ok')
this.handleCancel()
})
}
}
}
</script>
<style lang="less" scoped>
</style>

View File

@ -0,0 +1,317 @@
<template>
<div class="ipam-tree">
<a-input
v-model="searchValue"
class="ipam-tree-search"
:placeholder="$t('placeholder1')"
/>
<div class="ipam-tree-main">
<a-tree
v-if="treeData.length"
:treeData="filterTreeData"
:selectedKeys="treeKey ? [treeKey] : []"
:defaultExpandedKeys="treeKey ? [treeKey] : []"
>
<template #title="treeNodeData">
<div
class="ipam-tree-node"
@click="clickTreeNode(treeNodeData)"
>
<ops-icon
:type="treeNodeData.icon"
class="ipam-tree-node-icon"
:style="{ color: treeNodeData.iconColor }"
/>
<a-tooltip :title="treeNodeData.title">
<span
class="ipam-tree-node-title"
:style="{
color: treeKey === treeNodeData.key ? '#2F54EB' : ''
}"
>
{{ treeNodeData.title }}
</span>
</a-tooltip>
<div class="ipam-tree-node-right">
<span
v-if="(treeNodeData.key === 'all' && treeNodeData.count) || (treeNodeData.key !== 'all' && treeNodeData.children && treeNodeData.children.length && treeNodeData.count)"
class="ipam-tree-node-count"
>
{{ treeNodeData.count }}
</span>
<a-dropdown :getPopupContainer="(trigger) => trigger">
<a class="ipam-tree-node-action">
<ops-icon type="veops-more" />
</a>
<a-menu slot="overlay">
<a-menu-item
v-if="treeNodeData.showCatalogBtn"
@click="openCatalogForm(treeNodeData, 'create')"
>
<ops-icon type="veops-catalog" />
{{ $t('cmdb.ipam.addCatalog') }}
</a-menu-item>
<a-menu-item
v-if="treeNodeData.showSubnetBtn"
@click="openSubnetForm(treeNodeData, 'create')"
>
<ops-icon type="veops-increase" />
{{ $t('cmdb.ipam.addSubnet') }}
</a-menu-item>
<template v-if="treeNodeData.key !== 'all'">
<a-menu-item
v-if="!treeNodeData.isSubnet"
@click="openCatalogForm(treeNodeData, 'edit')"
>
<ops-icon type="veops-edit" />
{{ $t('cmdb.ipam.editName') }}
</a-menu-item>
<a-menu-item
v-if="treeNodeData.isSubnet"
@click="openSubnetForm(treeNodeData, 'edit')"
>
<ops-icon type="veops-edit" />
{{ $t('cmdb.ipam.editNode') }}
</a-menu-item>
<a-menu-item @click="deleteNode(treeNodeData)">
<ops-icon type="veops-delete" />
{{ $t('cmdb.ipam.deleteNode') }}
</a-menu-item>
</template>
</a-menu>
</a-dropdown>
</div>
</div>
</template>
</a-tree>
</div>
<SubnetForm
ref="subnetFormRef"
:subnetCIType="subnetCIType"
@ok="refreshData"
/>
<CatalogForm
ref="catalogFormRef"
@ok="refreshData"
/>
</div>
</template>
<script>
import _ from 'lodash'
import { deleteIPAMSubnet, deleteIPAMScope } from '@/modules/cmdb/api/ipam.js'
import SubnetForm from './subnetForm.vue'
import CatalogForm from './catalogForm.vue'
export default {
name: 'IPAMTree',
components: {
SubnetForm,
CatalogForm
},
props: {
treeData: {
type: Array,
default: () => []
},
treeKey: {
type: [String, Number],
default: () => ''
},
subnetCIType: {
type: Object,
default: () => {}
}
},
data() {
return {
searchValue: ''
}
},
computed: {
filterTreeData() {
if (this.searchValue) {
let treeData = _.cloneDeep(this.treeData)
treeData = treeData.filter((data) => {
return this.handleTreeDataBySearch(data)
})
return treeData
}
return this.treeData
}
},
methods: {
handleTreeDataBySearch(data) {
const isMatch = data?.title?.indexOf?.(this.searchValue) !== -1
if (!data?.children?.length) {
return isMatch ? data : null
}
data.children = data.children.filter((data) => {
return this.handleTreeDataBySearch(data)
})
return isMatch || data.children.length ? data : null
},
openCatalogForm(node, type) {
const nodeId = node?.key && node?.key !== 'all' ? node.key : null
const name = type === 'edit' ? (node?.title || '') : ''
this.$refs.catalogFormRef.open({
nodeId,
type,
name
})
},
openSubnetForm(node, type) {
const nodeId = node?.key && node?.key !== 'all' ? node.key : null
const parentId = node?.parentId || null
this.$refs.subnetFormRef.open(nodeId, type, parentId)
},
deleteNode(node) {
this.$confirm({
title: this.$t('warning'),
content: this.$t('confirmDelete'),
onOk: async () => {
if (node.isSubnet) {
await deleteIPAMSubnet(node.key)
} else {
await deleteIPAMScope(node.key)
}
if (node.key === this.treeKey) {
this.$emit('updateTreeKey', 'all')
}
this.$nextTick(() => {
this.refreshData()
})
},
})
},
refreshData() {
this.$emit('refreshData')
},
clickTreeNode(node) {
this.$emit('updateTreeKey', node.key)
}
}
}
</script>
<style lang="less" scoped>
.ipam-tree {
width: 100%;
&-search {
width: 100%;
height: 26px;
line-height: 26px;
}
&-main {
width: 100%;
height: 100%;
/deep/ .ant-tree {
.ant-tree-node-content-wrapper {
width: calc(100% - 24px);
padding: 0px;
display: inline-block;
height: fit-content;
.ant-tree-title {
display: inline-block;
width: 100%;
padding: 0 6px;
}
}
& > li:first-child {
.ant-tree-switcher {
display: none;
}
.ant-tree-node-content-wrapper {
width: 100%;
}
}
.ant-tree-switcher-icon {
color: #CACDD9;
}
}
}
&-node {
display: flex;
align-items: center;
height: 32px;
cursor: pointer;
&-icon {
font-size: 12px;
flex-shrink: 0;
}
&-title {
margin-left: 6px;
font-size: 14px;
font-weight: 400;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
}
&-right {
margin-left: auto;
display: flex;
align-items: center;
flex-shrink: 0;
}
&-count {
font-size: 10px;
font-weight: 400;
color: #A5A9BC;
}
&-action {
display: none;
margin-left: 3px;
font-size: 12px;
&:hover {
color: #2F54EB;
}
/deep/ .ant-dropdown-menu {
padding: 4px 0;
}
/deep/ .ant-dropdown-menu-item {
padding: 5px 12px;
}
}
&:hover {
.ipam-tree-node-action {
display: inline-block;
}
}
}
}
</style>

View File

@ -0,0 +1,445 @@
<template>
<CustomDrawer
width="800px"
:title="$t(actionType === 'edit' ? 'cmdb.ipam.editSubnet' : 'cmdb.ipam.addSubnet')"
:visible="visible"
:bodyStyle="{ height: 'calc(-108px + 100vh)' }"
@close="handleClose"
>
<a-form-model
ref="subnetFormRef"
:model="form"
:rules="formRules"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 17 }"
>
<div
v-for="(group, groupIndex) in basicFormGroup"
:key="groupIndex"
>
<div class="subnet-form-title">
{{ group.name }}
</div>
<a-form-model-item
v-for="(attr) in group.attributes"
:key="attr.name"
:label="attr.alias || attr.name"
:prop="attr.name"
>
<CIReferenceAttr
v-if="attr.is_reference"
:referenceTypeId="attr.reference_type_id"
:isList="attr.is_list"
:referenceShowAttrName="attr.showAttrName"
:initSelectOption="getInitReferenceSelectOption(attr)"
v-model="form[attr.name]"
/>
<a-select
v-else-if="attr.is_choice"
v-model="form[attr.name]"
:mode="attr.is_list ? 'multiple' : 'default'"
showSearch
allowClear
>
<a-icon slot="suffixIcon" type="caret-down" />
<a-select-option
v-for="(choiceItem, choiceIndex) in attr.selectOption"
:key="choiceIndex"
:value="choiceItem.value"
>
{{ choiceItem.label }}
</a-select-option>
</a-select>
<a-switch
v-else-if="attr.is_bool"
v-model="form[attr.name]"
/>
<a-input
v-else
:placeholder="$t('placeholder1')"
v-model="form[attr.name]"
/>
</a-form-model-item>
</div>
<div class="subnet-form-title">
<a-row>
<a-col :span="4">
{{ $t('cmdb.ipam.scanRule') }}
</a-col>
<a-switch v-model="form.scan_enabled" />
</a-row>
</div>
<template v-if="form.scan_enabled">
<a-form-model-item
:label="$t('cmdb.ipam.adExecTarget')"
>
<CustomRadio
v-model="agentType"
:radioList="agentTypeRadioList"
>
<span
v-show="agentType === 'master'"
slot="extra_master"
class="subnet-form-agent-tip"
>
{{ $t('cmdb.ipam.masterMachineTip') }}
</span>
<a-input
v-show="agentType === 'agent_id'"
slot="extra_agent_id"
:style="{ width: '300px' }"
:placeholder="$t('cmdb.ipam.oneagentIdTips')"
v-model="agentId"
/>
</CustomRadio>
</a-form-model-item>
<a-form-model-item
:label="$t('cmdb.ipam.adInterval')"
>
<el-popover
v-model="cronVisible"
trigger="click"
>
<template slot>
<Vcrontab
ref="cronTab"
:hideComponent="['second', 'year']"
:expression="form.cron"
:hasFooter="true"
@fill="crontabFill"
@hide="hideCron"
></Vcrontab>
</template>
<a-input
v-model="form.cron"
slot="reference"
:placeholder="$t('cmdb.ipam.cronTips')"
/>
</el-popover>
</a-form-model-item>
</template>
</a-form-model>
<div class="custom-drawer-bottom-action">
<a-button @click="handleClose">{{ $t('cancel') }}</a-button>
<a-button @click="handleSubmit" type="primary">{{ $t('save') }}</a-button>
</div>
</CustomDrawer>
</template>
<script>
import {
postIPAMSubnet,
getIPAMSubnetById,
putIPAMSubnet
} from '@/modules/cmdb/api/ipam.js'
import { getCITypeGroupById, getCITypes } from '@/modules/cmdb/api/CIType'
import { searchCI } from '@/modules/cmdb/api/ci'
import Vcrontab from '@/components/Crontab'
import CIReferenceAttr from '@/components/ciReferenceAttr/index.vue'
export default {
name: 'SubnetForm',
components: {
Vcrontab,
CIReferenceAttr
},
props: {
subnetCIType: {
type: Object,
default: () => {}
}
},
data() {
return {
nodeId: null,
parentId: null,
actionType: 'create',
visible: false,
form: {
scan_enabled: true,
cron: ''
},
basicFormGroup: [],
formRules: {},
agentTypeRadioList: [
{ value: 'master', label: this.$t('cmdb.ipam.masterMachine') },
{ value: 'agent_id', label: this.$t('cmdb.ipam.specifyMachine') }
],
agentType: 'master',
agentId: '',
cronVisible: false
}
},
methods: {
async open(nodeId, type, parentId) {
this.visible = true
this.actionType = type
this.nodeId = nodeId
this.parentId = parentId || null
let nodeData = {}
if (type === 'edit') {
nodeData = await getIPAMSubnetById(nodeId)
this.form.scan_enabled = !!nodeData.scan_enabled
if (nodeData?.scan_enabled) {
this.form.cron = nodeData.cron
if (nodeData.agent_id) {
if (nodeData.agent_id === '0x0000') {
this.agentType = 'master'
} else {
this.agentType = 'agent_id'
this.agentId = nodeData.agent_id
}
}
}
}
// const res = await getCITypeAttributesById(SUB_NET_CITYPE_NAME)
// const attributes = res?.attributes || []
const groupAttr = await getCITypeGroupById(this.subnetCIType.id)
const form = {
...this.form
}
const formRules = {}
let basicFormGroup = []
groupAttr.map((group) => {
group.attributes = group?.attributes?.filter?.((attr) => !attr.sys_computed && !attr.is_computed) || []
if (group.attributes.length) {
group.attributes.forEach((attr) => {
form[attr.name] = nodeData?.[attr.name] ?? undefined
if (attr?.is_choice) {
let choice_value = attr?.choice_value || []
if (attr.name === 'assign_status') {
choice_value = choice_value.filter((item) => item?.[0] !== 1)
}
attr.selectOption = choice_value.map((item) => {
return {
label: item?.[1]?.label || item?.[0] || '',
value: item?.[0]
}
})
}
if (attr.is_required) {
formRules[attr.name] = [
{
required: true, message: this.$t('placeholder1')
}
]
}
})
basicFormGroup.push({
name: group.name,
attributes: group.attributes
})
}
})
basicFormGroup = await this.handleReferenceAttr(basicFormGroup, form)
this.form = form
this.$set(this, 'basicFormGroup', basicFormGroup)
this.formRules = formRules
},
async handleReferenceAttr(basicFormGroup, ci) {
const map = {}
basicFormGroup.forEach((group) => {
group.attributes.forEach((attr) => {
if (attr?.is_reference && attr?.reference_type_id && ci[attr.name]) {
const ids = Array.isArray(ci[attr.name]) ? ci[attr.name] : ci[attr.name] ? [ci[attr.name]] : []
if (ids.length) {
if (!map?.[attr.reference_type_id]) {
map[attr.reference_type_id] = {}
}
ids.forEach((id) => {
map[attr.reference_type_id][id] = {}
})
}
}
})
})
if (!Object.keys(map).length) {
return basicFormGroup
}
const ciTypesRes = await getCITypes({
type_ids: Object.keys(map).join(',')
})
const showAttrNameMap = {}
ciTypesRes.ci_types.forEach((ciType) => {
showAttrNameMap[ciType.id] = ciType?.show_name || ciType?.unique_name || ''
})
const allRes = await Promise.all(
Object.keys(map).map((key) => {
return searchCI({
q: `_type:${key},_id:(${Object.keys(map[key]).join(';')})`,
count: 9999
})
})
)
const ciNameMap = {}
allRes.forEach((res) => {
res.result.forEach((item) => {
ciNameMap[item._id] = item
})
})
basicFormGroup.forEach((group) => {
group.attributes.forEach((attr) => {
if (attr?.is_reference && attr?.reference_type_id) {
attr.showAttrName = showAttrNameMap?.[attr?.reference_type_id] || ''
const referenceShowAttrNameMap = {}
const referenceCIIds = ci[attr.name];
(Array.isArray(referenceCIIds) ? referenceCIIds : referenceCIIds ? [referenceCIIds] : []).forEach((id) => {
referenceShowAttrNameMap[id] = ciNameMap?.[id]?.[attr.showAttrName] ?? id
})
attr.referenceShowAttrNameMap = referenceShowAttrNameMap
}
})
})
return basicFormGroup
},
handleClose() {
this.form = {
scan_enabled: true,
cron: ''
}
this.basicFormGroup = []
this.formRules = {}
this.agentType = 'master'
this.agentId = ''
this.cronVisible = false
this.nodeId = null
this.parentId = null
this.actionType = 'create'
this.$refs.subnetFormRef.clearValidate()
this.visible = false
},
handleSubmit() {
this.$refs.subnetFormRef.validate(async (valid) => {
if (!valid || !this.validateScan()) {
return
}
const { cron, ...otherParams } = this.form
const params = {
...otherParams
}
if (this.form.scan_enabled) {
params.cron = cron
switch (this.agentType) {
case 'master':
params.agent_id = '0x0000'
break
case 'agent_id':
params.agent_id = this.agentId
break
default:
break
}
}
if (this.actionType === 'edit') {
if (this.parentId) {
params.parent_id = this.parentId
}
await putIPAMSubnet(this.nodeId, params)
this.$message.success(this.$t('editSuccess'))
} else {
params.parent_id = this.nodeId
await postIPAMSubnet(params)
this.$message.success(this.$t('addSuccess'))
}
this.$emit('ok')
this.handleClose()
})
},
validateScan() {
if (this.form.scan_enabled) {
switch (this.agentType) {
case 'agent_id':
if (!this.agentId) {
this.$message.error(this.$t('cmdb.ipam.specifyMachineTips'))
return false
}
break
default:
break
}
if (!this.form.cron) {
this.$message.error(this.$t('cmdb.ipam.cronRequiredTip'))
return false
}
}
return true
},
handleOpenCmdb() {
this.$refs.cmdbDrawer.open()
},
crontabFill(cron) {
this.form.cron = cron
},
hideCron() {
this.cronVisible = false
},
getInitReferenceSelectOption(attr) {
const option = Object.keys(attr?.referenceShowAttrNameMap || {}).map((key) => {
return {
key: Number(key),
title: attr?.referenceShowAttrNameMap?.[Number(key)] ?? ''
}
})
return option
}
}
}
</script>
<style lang="less" scoped>
.subnet-form-title {
font-size: 14px;
font-weight: 700;
color: #000000;
margin-bottom: 20px;
}
.subnet-form-agent-tip {
font-size: 12px;
color: #86909c;
line-height: 14px;
}
</style>

View File

@ -0,0 +1,3 @@
export const SUB_NET_CITYPE_NAME = 'ipam_subnet'
export const SCOPE_CITYPE_NAME = 'ipam_scope'
export const ADDRESS_CITYPE_NAME = 'ipam_address'

View File

@ -0,0 +1,296 @@
<template>
<TwoColumnLayout
class="ipam"
appName="cmdb-ipam"
calcBasedParent
>
<template #one>
<IPAMTree
v-if="subnetCIType"
:treeData="treeData"
:treeKey="treeKey"
:subnetCIType="subnetCIType"
@refreshData="refreshData"
@updateTreeKey="updateTreeKey"
/>
</template>
<template #two>
<a-tabs
:activeKey="tabKey"
@change="handleTabChange"
>
<a-tab-pane
v-for="(item) in tabs"
:key="item.key"
:tab="$t(item.title)"
>
</a-tab-pane>
</a-tabs>
<Overview
v-if="tabKey === 'overview'"
ref="overviewRef"
:nodeId="treeKey"
/>
<template v-if="addressCIType">
<Address
v-if="tabKey === 'address'"
:nodeData="nodeData"
:addressCIType="addressCIType"
/>
<IPSearch
v-if="tabKey === 'ipSearch'"
:addressCIType="addressCIType"
/>
</template>
<template v-if="subnetCIType">
<SubnetList
v-if="tabKey === 'subnet'"
ref="subnetListRef"
:subnetCIType="subnetCIType"
@delete="getTreeData"
/>
</template>
<HistoryLog
v-if="tabKey === 'history'"
ref="historyRef"
/>
</template>
</TwoColumnLayout>
</template>
<script>
import { getIPAMSubnet } from '@/modules/cmdb/api/ipam.js'
import { getCIType } from '@/modules/cmdb/api/CIType.js'
import { SUB_NET_CITYPE_NAME, ADDRESS_CITYPE_NAME, SCOPE_CITYPE_NAME } from './constants.js'
import TwoColumnLayout from '@/components/TwoColumnLayout'
import IPAMTree from './components/ipamTree.vue'
import Overview from './modules/overview/index.vue'
import Address from './modules/address/index.vue'
import IPSearch from './modules/ipSearch/index.vue'
import SubnetList from './modules/subnetList/index.vue'
import HistoryLog from './modules/history/index.vue'
const TAB_STORAGE_KEY = 'ops_ipam_tab_active'
const TREE_STORAGE_KEY = 'ops_ipam_tree_active'
export default {
name: 'IPAM',
components: {
TwoColumnLayout,
IPAMTree,
IPSearch,
SubnetList,
Address,
HistoryLog,
Overview
},
data() {
return {
tabKey: localStorage.getItem(TAB_STORAGE_KEY) || 'overview',
treeKey: localStorage.getItem(TREE_STORAGE_KEY) || 'all',
tabs: [
{
key: 'overview',
title: 'cmdb.ipam.overview'
},
{
key: 'address',
title: 'cmdb.ipam.addressAssign'
},
{
key: 'ipSearch',
title: 'cmdb.ipam.ipSearch'
},
{
key: 'subnet',
title: 'cmdb.ipam.subnetList'
},
{
key: 'history',
title: 'cmdb.ipam.history'
}
],
treeData: [],
subnetCIType: null,
addressCIType: null,
}
},
computed: {
nodeData() {
return this.findNodeById(this.treeData, this.treeKey)
}
},
watch: {
tabKey: {
deep: true,
immediate: true,
handler(key) {
switch (key) {
case 'subnet':
if (!this.subnetCITYpe) {
this.getSubnetCIType()
}
break
case 'address':
case 'ipSearch':
if (!this.addressCIType) {
this.getAddressCIType()
}
break
default:
break
}
}
}
},
mounted() {
this.getSubnetCIType()
this.getTreeData()
},
methods: {
async getSubnetCIType() {
const res = await getCIType(SUB_NET_CITYPE_NAME)
this.subnetCIType = res?.ci_types?.[0] || {}
},
async getAddressCIType() {
const res = await getCIType(ADDRESS_CITYPE_NAME)
this.addressCIType = res?.ci_types?.[0] || {}
},
async getTreeData() {
const res = await getIPAMSubnet()
let treeData = []
if (res?.result?.length) {
treeData = res.result.map((data) => {
return this.handleTreeData(data, res.type2name)
})
}
const allCount = treeData.reduce((acc, cur) => acc + cur.count, 0)
const rootShowSubnetBtn = treeData.every((item) => item.ci_type === SUB_NET_CITYPE_NAME)
const rootShowCatalogBtn = treeData.every((item) => item.ci_type === SCOPE_CITYPE_NAME)
treeData.unshift({
key: 'all',
title: this.$t('all'),
count: allCount,
icon: 'veops-entire_network_',
iconColor: '#2F54EB',
showCatalogBtn: rootShowCatalogBtn,
showSubnetBtn: rootShowSubnetBtn,
parentId: '',
})
this.treeData = treeData
},
handleTreeData(data, type2name, parentId = '') {
const title = data?.[type2name?.[data?._type]] || ''
const isSubnet = data?.ci_type === SUB_NET_CITYPE_NAME
const icon = isSubnet ? 'veops-subnet' : 'veops-folder'
const iconColor = isSubnet ? '#CACDD9' : ''
const key = String(data._id)
if (!data?.children?.length) {
return {
key,
title,
count: isSubnet ? 1 : 0,
icon,
iconColor,
showCatalogBtn: !isSubnet,
showSubnetBtn: true,
isSubnet,
parentId,
...data
}
}
const children = data.children.map((item) => {
return this.handleTreeData(item, type2name, key)
})
const showSubnetBtn = children.every((item) => item.ci_type === SUB_NET_CITYPE_NAME)
return {
key,
title,
icon,
iconColor,
showCatalogBtn: !isSubnet && !showSubnetBtn,
showSubnetBtn: showSubnetBtn,
isSubnet,
parentId,
...data,
children,
count: children.reduce((acc, item) => {
return acc + item.count
}, 0)
}
},
handleTabChange(key) {
if (key !== this.tabKey) {
this.tabKey = key
localStorage.setItem(TAB_STORAGE_KEY, key)
}
},
updateTreeKey(key) {
this.treeKey = key
localStorage.setItem(TREE_STORAGE_KEY, key)
},
findNodeById(nodes, key) {
for (const node of nodes) {
if (node.key === key) {
return node
}
if (node.children) {
const foundNode = this.findNodeById(node.children, key)
if (foundNode) {
return foundNode
}
}
}
return null
},
refreshData() {
this.getTreeData()
switch (this.tabKey) {
case 'overview':
if (this.$refs.overviewRef) {
this.$refs.overviewRef.initData()
}
break
case 'subnet':
if (this.$refs.subnetListRef) {
this.$refs.subnetListRef.getTableData()
}
break
case 'history':
if (this.$refs.historyRef) {
this.$refs.historyRef.refreshData()
}
break
default:
break
}
}
}
}
</script>
<style lang="less" scoped>
.ipam {
/deep/ .ant-tabs {
display: inline-block;
}
}
</style>

View File

@ -0,0 +1,284 @@
<template>
<a-modal
:visible="visible"
:width="700"
:title="$t('cmdb.ipam.addressAssign')"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form-model
ref="assignFormRef"
:model="form"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
class="assign-form"
>
<a-form-model-item
label="IP"
>
{{ ipData.ip }}
</a-form-model-item>
<a-form-model-item
v-for="(item) in formList"
:key="item.name"
:label="item.alias || item.name"
:prop="item.name"
>
<CIReferenceAttr
v-if="item.is_reference"
:referenceTypeId="item.reference_type_id"
:isList="item.is_list"
:referenceShowAttrName="item.showAttrName"
:initSelectOption="getInitReferenceSelectOption(item)"
v-model="form[item.name]"
/>
<a-select
v-else-if="item.is_choice"
:mode="item.is_list ? 'multiple' : 'default'"
showSearch
allowClear
v-model="form[item.name]"
>
<a-icon slot="suffixIcon" type="caret-down" />
<a-select-option
v-for="(choiceItem, choiceIndex) in item.selectOption"
:key="choiceIndex"
:value="choiceItem.value"
>
{{ choiceItem.label }}
</a-select-option>
</a-select>
<a-switch
v-else-if="item.is_bool"
v-model="form[item.name]"
/>
<a-input
v-else
:placeholder="$t('placeholder1')"
v-model="form[item.name]"
/>
</a-form-model-item>
</a-form-model>
</a-modal>
</template>
<script>
import _ from 'lodash'
import { postIPAMAddress } from '@/modules/cmdb/api/ipam.js'
import { getCITypes } from '@/modules/cmdb/api/CIType'
import { searchCI } from '@/modules/cmdb/api/ci'
import CIReferenceAttr from '@/components/ciReferenceAttr/index.vue'
export default {
name: 'AssignForm',
components: {
CIReferenceAttr
},
props: {
attrList: {
type: Array,
default: () => []
},
subnetData: {
type: Object,
default: () => {}
}
},
data() {
return {
visible: false,
ipData: {},
nodeId: -1,
formList: [],
form: {},
formRules: {},
statusSelectOption: [
{
value: 0,
label: 'cmdb.ipam.assigned'
},
{
value: 2,
label: 'cmdb.ipam.reserved'
}
]
}
},
methods: {
async open({
ipData,
nodeId,
}) {
this.ipData = ipData || {}
this.nodeId = nodeId || -1
this.visible = true
const form = {}
const formRules = {}
let formList = []
let attrList = _.cloneDeep(this.attrList)
attrList = attrList?.filter?.((attr) => !attr.sys_computed && !attr.is_computed) || []
if (ipData?.assign_status === 1) {
ipData.assign_status = undefined
}
if (attrList.length) {
_.remove(attrList, (item) => {
return ['subnet_mask', 'gateway', 'name', 'mac_address', 'is_used', 'ip'].includes(item.name)
})
const assingStatusIndex = attrList.findIndex((attr) => attr.name === 'assign_status')
if (assingStatusIndex > 0) {
const assign_status = attrList.splice(assingStatusIndex, 1)
attrList.unshift(...assign_status)
}
attrList.forEach((attr) => {
form[attr.name] = ipData?.[attr.name] ?? undefined
if (attr?.is_choice) {
let choice_value = attr?.choice_value || []
if (attr.name === 'assign_status') {
choice_value = choice_value.filter((item) => item?.[0] !== 1)
}
attr.selectOption = choice_value.map((item) => {
return {
label: item?.[1]?.label || item?.[0] || '',
value: item?.[0]
}
})
}
formList.push(attr)
if (attr.is_required) {
formRules[attr.name] = [
{
required: true, message: attr?.is_choice ? this.$t('placeholder2') : this.$t('placeholder1')
}
]
}
})
}
formList = await this.handleReferenceAttr(formList, form)
this.form = form
this.formList = formList
this.formRules = formRules
},
async handleReferenceAttr(formList, ci) {
const map = {}
formList.forEach((attr) => {
if (attr?.is_reference && attr?.reference_type_id && ci[attr.name]) {
const ids = Array.isArray(ci[attr.name]) ? ci[attr.name] : ci[attr.name] ? [ci[attr.name]] : []
if (ids.length) {
if (!map?.[attr.reference_type_id]) {
map[attr.reference_type_id] = {}
}
ids.forEach((id) => {
map[attr.reference_type_id][id] = {}
})
}
}
})
if (!Object.keys(map).length) {
return formList
}
const ciTypesRes = await getCITypes({
type_ids: Object.keys(map).join(',')
})
const showAttrNameMap = {}
ciTypesRes.ci_types.forEach((ciType) => {
showAttrNameMap[ciType.id] = ciType?.show_name || ciType?.unique_name || ''
})
const allRes = await Promise.all(
Object.keys(map).map((key) => {
return searchCI({
q: `_type:${key},_id:(${Object.keys(map[key]).join(';')})`,
count: 9999
})
})
)
const ciNameMap = {}
allRes.forEach((res) => {
res.result.forEach((item) => {
ciNameMap[item._id] = item
})
})
formList.forEach((attr) => {
if (attr?.is_reference && attr?.reference_type_id) {
attr.showAttrName = showAttrNameMap?.[attr?.reference_type_id] || ''
const referenceShowAttrNameMap = {}
const referenceCIIds = ci[attr.name];
(Array.isArray(referenceCIIds) ? referenceCIIds : referenceCIIds ? [referenceCIIds] : []).forEach((id) => {
referenceShowAttrNameMap[id] = ciNameMap?.[id]?.[attr.showAttrName] ?? id
})
attr.referenceShowAttrNameMap = referenceShowAttrNameMap
}
})
return formList
},
handleCancel() {
this.visible = false
this.ipData = {}
this.nodeId = -1
this.form = {}
this.formRules = {}
this.formList = []
this.visible = false
this.$refs.assignFormRef.clearValidate()
},
handleOk() {
this.$refs.assignFormRef.validate(async (valid) => {
if (!valid) {
return
}
await postIPAMAddress({
ips: [this.ipData.ip],
parent_id: this.nodeId,
...this.form,
subnet_mask: this?.ipData?.subnet_mask ?? undefined,
gateway: this?.ipData?.gateway ?? undefined
})
this.$emit('ok')
this.handleCancel()
})
},
getInitReferenceSelectOption(attr) {
const option = Object.keys(attr?.referenceShowAttrNameMap || {}).map((key) => {
return {
key: Number(key),
title: attr?.referenceShowAttrNameMap?.[Number(key)] ?? ''
}
})
return option
}
}
}
</script>
<style lang="less" scoped>
.assign-form {
padding-right: 12px;
max-height: 400px;
overflow-y: auto;
overflow-x: hidden;
}
</style>

View File

@ -0,0 +1,39 @@
export const ADDRESS_STATUS = {
ONLINE_ASSIGNED: '0',
ONLINE_UNASSIGNED: '1',
OFFLINE_ASSIGNED: '2',
OFFLINE_UNASSIGNED: '3',
}
export const STATUS_COLOR = {
[ADDRESS_STATUS.ONLINE_ASSIGNED]: '#00B42A',
[ADDRESS_STATUS.ONLINE_UNASSIGNED]: '#FF7D00',
[ADDRESS_STATUS.OFFLINE_ASSIGNED]: '#2F54EB',
[ADDRESS_STATUS.OFFLINE_UNASSIGNED]: '#A5A9BC'
}
export const STATUS_LABEL = {
[ADDRESS_STATUS.ONLINE_ASSIGNED]: 'cmdb.ipam.assignedOnline',
[ADDRESS_STATUS.ONLINE_UNASSIGNED]: 'cmdb.ipam.unassignedOnline',
[ADDRESS_STATUS.OFFLINE_ASSIGNED]: 'cmdb.ipam.assignedOffline',
[ADDRESS_STATUS.OFFLINE_UNASSIGNED]: 'cmdb.ipam.unused'
}
export const STATUS_OPTION = [
{
value: ADDRESS_STATUS.ONLINE_ASSIGNED,
label: STATUS_LABEL[ADDRESS_STATUS.ONLINE_ASSIGNED],
},
{
value: ADDRESS_STATUS.ONLINE_UNASSIGNED,
label: STATUS_LABEL[ADDRESS_STATUS.ONLINE_UNASSIGNED],
},
{
value: ADDRESS_STATUS.OFFLINE_ASSIGNED,
label: STATUS_LABEL[ADDRESS_STATUS.OFFLINE_ASSIGNED],
},
{
value: ADDRESS_STATUS.OFFLINE_UNASSIGNED,
label: STATUS_LABEL[ADDRESS_STATUS.OFFLINE_UNASSIGNED],
}
]

View File

@ -0,0 +1,399 @@
<template>
<div
class="ip-grid"
:style="{
gap: gridGap + 'px'
}"
>
<div
v-for="(item) in gridList"
:key="item.ip"
class="ip-grid-item"
:style="{
width: gridItemSize + 'px',
height: gridItemSize + 'px',
backgroundColor: `${STATUS_COLOR[item._ip_status]}22`,
color: STATUS_COLOR[item._ip_status],
borderColor: STATUS_COLOR[item._ip_status]
}"
@click="clickGridItem(item, $event)"
>
{{ item.gridTitle }}
</div>
<div
v-show="infoCardVisible"
class="info-card"
:style="{
top: infoCardY + 'px',
left: infoCardX + 'px',
width: infoCardWidth + 'px',
height: infoCardHeight + 'px',
}"
>
<div class="info-card-header">
<div class="info-card-ip">
{{ infoCardData.ip }}
</div>
<div
class="info-card-status-dot"
:style="{
backgroundColor: `${STATUS_COLOR[infoCardData._ip_status]}22`
}"
>
<div
class="info-card-status-dot-content"
:style="{
backgroundColor: STATUS_COLOR[infoCardData._ip_status]
}"
></div>
</div>
<div class="info-card-status-text">
{{ $t(STATUS_LABEL[infoCardData._ip_status]) }}
</div>
<a-button
type="primary"
class="ops-button-ghost info-card-recycle"
ghost
@click="clickRecycle(infoCardData)"
>
<ops-icon type="veops-recycle" />
{{ $t('cmdb.ipam.recycle') }}
</a-button>
</div>
<div class="info-card-main">
<div
v-for="(col) in filterColumns"
:key="col.field"
class="info-card-main-row"
>
<div class="info-card-main-title">
<a-tooltip :title="col.title">
{{ col.title }}
</a-tooltip>
</div>
<div class="info-card-main-value">
<a-tooltip :title="infoCardTip[col.field]" placement="topLeft" >
<template v-if="col.is_reference && infoCardData[col.field]" >
<a
v-for="(ciId) in (col.is_list ? infoCardData[col.field] : [infoCardData[col.field]])"
:key="ciId"
:href="`/cmdb/cidetail/${col.reference_type_id}/${ciId}`"
target="_blank"
>
{{ getReferenceAttrValue(ciId, col) }}
</a>
</template>
<template v-else-if="col.is_link && infoCardData[col.field]">
<a
v-for="(linkItem, linkIndex) in (col.is_list ? infoCardData[col.field] : [infoCardData[col.field]])"
:key="linkIndex"
:href="
linkItem.startsWith('http') || linkItem.startsWith('https')
? `${linkItem}`
: `http://${linkItem}`
"
target="_blank"
>
{{ getChoiceValueLabel(col, linkItem) || linkItem }}
</a>
</template>
<template v-else-if="col.is_choice && infoCardData[col.field]">
<span
v-for="value in (col.is_list ? infoCardData[col.field] : [infoCardData[col.field]])"
:key="value"
class="column-default-choice"
>
{{ getChoiceValueLabel(col, value) || value }}
</span>
</template>
<template v-else>
{{ infoCardData[col.field] !== undefined ? Array.isArray(infoCardData[col.field]) ? infoCardData[col.field].join(', ') : infoCardData[col.field] : '' }}
</template>
</a-tooltip>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { STATUS_COLOR, STATUS_LABEL, ADDRESS_STATUS } from './constants.js'
export default {
name: 'GridIP',
props: {
ipList: {
type: Array,
default: () => []
},
columns: {
type: Array,
default: () => []
},
referenceShowAttrNameMap: {
type: Object,
default: () => {}
},
referenceCIIdMap: {
type: Object,
default: () => {}
}
},
data() {
return {
STATUS_COLOR,
STATUS_LABEL,
gridItemSize: 52, //
gridGap: 8,
infoCardX: 0,
infoCardY: 0,
infoCardWidth: 375,
infoCardVisible: false,
infoCardData: {},
infoCardTip: {}
}
},
computed: {
gridList() {
const list = this.ipList.map((item) => {
const ipSplit = item?.ip?.split('.') || []
const gridTitle = ipSplit?.[ipSplit.length - 1] || ''
return {
...item,
gridTitle
}
})
return list
},
filterColumns() {
return this.columns.filter((col) => col.field !== '_ip_status') || []
},
infoCardHeight() {
let infoCardHeight = 311
if (this.filterColumns.length < 6) {
infoCardHeight -= ((6 - this.filterColumns.length) * 36)
}
return infoCardHeight
}
},
mounted() {
window.addEventListener('click', this.handleClick)
},
beforeDestroy() {
window.removeEventListener('click', this.handleClick)
},
methods: {
handleClick(event) {
const classStr = event?.target?.classList?.value
if (classStr.indexOf('info-card') === -1 && classStr.indexOf('ip-grid-item') === -1) {
this.infoCardVisible = false
}
},
clickGridItem(item, event) {
if ([ADDRESS_STATUS.OFFLINE_UNASSIGNED, ADDRESS_STATUS.ONLINE_UNASSIGNED].includes(item?._ip_status)) {
this.$emit('openAssign', item)
} else {
this.showInfoCard(item, event)
}
},
showInfoCard(item, event) {
let infoCardX = event.clientX - event.offsetX
let infoCardY = event.clientY - event.offsetY + this.gridItemSize + this.gridGap
// 右侧是否超出视口边界
if (infoCardX + this.infoCardWidth > window.innerWidth) {
infoCardX = infoCardX + this.gridItemSize - this.infoCardWidth
}
// 底部是否超出视口边界
if (infoCardY + this.infoCardHeight > window.innerHeight) {
infoCardY = infoCardY - this.gridItemSize - this.gridGap * 2 - this.infoCardHeight
}
const infoCardTip = {}
this.filterColumns.forEach((col) => {
const arrayValue = Array.isArray(item[col.field]) ? item[col.field] : [item[col.field]]
infoCardTip[col.field] = arrayValue.map((value) => {
if (value === undefined || value === null) {
return value
}
if (col.is_reference) {
return this.getReferenceAttrValue(value, col) || value
}
if (col.is_link || col.is_choice) {
return this.getChoiceValueLabel(col, value) || value
}
return value
}).join(', ')
})
this.infoCardX = infoCardX
this.infoCardY = infoCardY
this.infoCardVisible = true
this.infoCardData = item
this.infoCardTip = infoCardTip
},
clickRecycle(data) {
this.$emit('recycle', data.ip)
},
getReferenceAttrValue(id, col) {
const ci = this?.referenceCIIdMap?.[col?.reference_type_id]?.[id]
if (!ci) {
return id
}
const attrName = this.referenceShowAttrNameMap?.[col.reference_type_id]
return ci?.[attrName] || id
},
getChoiceValueLabel(col, colValue) {
const _find = col?.choice_value?.find((item) => String(item[0]) === String(colValue))
if (_find) {
return _find?.[1]?.label || ''
}
return ''
},
}
}
</script>
<style lang="less" scoped>
.ip-grid {
display: flex;
flex-wrap: wrap;
max-height: calc(100vh - 230px);
overflow-y: auto;
overflow-x: hidden;
&-item {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 400;
cursor: pointer;
&:hover {
border-style: solid;
border-width: 1px;
}
}
.info-card {
position: fixed;
top: 0;
left: 0;
transition: top 0.2s, left 0.2s;
padding: 23px 18px;
border-radius: 2px;
background-color: #FFFFFF;
box-shadow: -2px 4px 12px 0px rgba(168, 191, 211, 0.25);
&-header {
display: flex;
align-items: center;
}
&-ip {
font-size: 18px;
font-weight: 700;
color: #2F54EB;
}
&-status-dot {
width: 12px;
height: 12px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-left: 14px;
&-content {
width: 6px;
height: 6px;
border-radius: 6px;
}
}
&-status-text {
font-size: 12px;
font-weight: 400;
color: #4E5969;
margin-left: 4px;
}
&-recycle {
margin-left: auto;
}
&-main {
margin-top: 15px;
width: 100%;
border: solid 1px #F0F1F5;
border-bottom-style: none;
max-height: calc(100% - 47px);
overflow-y: auto;
overflow-x: hidden;
&-row {
height: 36px;
line-height: 36px;
display: flex;
align-items: center;
}
&-title {
border-right: solid 1px #F0F1F5;
background-color: #F7F8FA;
padding-left: 17px;
padding-right: 10px;
width: 32%;
height: 100%;
flex-shrink: 0;
font-size: 14px;
font-weight: 400;
color: #4E5969;
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
border-bottom: solid 1px #E4E7ED;
}
&-value {
width: 68%;
flex-shrink: 0;
padding-left: 18px;
padding-right: 10px;
height: 100%;
font-size: 14px;
font-weight: 400;
color: #4E5969;
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
border-bottom: solid 1px #F0F1F5;
}
}
}
}
</style>

View File

@ -0,0 +1,721 @@
<template>
<div
ref="addressRef"
class="address"
>
<div v-if="addressNullTip" class="address-null">
<img class="address-null-img" :src="require(`@/modules/cmdb/assets/ipam_address_null.png`)"></img>
<div class="address-null-tip">{{ $t('noData') }}</div>
<div class="address-null-tip2">{{ $t(addressNullTip) }}</div>
</div>
<div v-else-if="loading" class="address-loading">
<a-icon type="loading" class="address-loading-icon" />
<span class="address-loading-text">{{ $t('loading') }}</span>
</div>
<template v-else>
<div class="address-header">
<div class="address-header-left">
<a-input-search
v-model="searchValue"
:placeholder="$t('placeholderSearch')"
class="address-header-search"
/>
<a-select
class="address-header-filter"
v-model="currentStatus"
>
<a-icon slot="suffixIcon" type="caret-down" />
<a-select-option
v-for="(item) in filterOption"
:key="item.value"
:value="item.value"
>
{{ $t(item.label) }}
</a-select-option>
</a-select>
<a-select
v-if="scopeSelectOption.length > 1"
class="address-header-filter"
v-model="currentSelectScope"
showSearch
>
<a-icon slot="suffixIcon" type="caret-down" />
<a-select-option
v-for="(key) in scopeSelectOption"
:key="key"
:value="key"
>
{{ key }}
</a-select-option>
</a-select>
<div
v-if="currentLayout === 'grid'"
class="address-header-status"
>
<div
v-for="(item) in statusOption"
:key="item.value"
class="address-header-status-item"
>
<div
class="address-header-status-dot"
:style="{
backgroundColor: `${STATUS_COLOR[item.value]}22`
}"
>
<div
class="address-header-status-dot-content"
:style="{
backgroundColor: STATUS_COLOR[item.value]
}"
></div>
</div>
<div
class="address-header-status-text"
>
{{ $t(item.label) }}: {{ item.count }}
</div>
</div>
</div>
</div>
<div class="address-header-right">
<a-button
type="primary"
class="ops-button-ghost"
ghost
@click="handleExport"
>
<ops-icon type="veops-export" />
{{ $t('export') }}
</a-button>
<div class="address-header-layout">
<div
v-for="(item) in layoutList"
:key="item.value"
:class="['address-header-layout-item', currentLayout === item.value ?'address-header-layout-item-active' : '']"
@click="currentLayout = item.value"
>
<ops-icon :type="item.icon" />
</div>
</div>
</div>
</div>
<div class="address-main">
<TableIP
v-if="currentLayout === 'table'"
ref="tableIPRef"
:columns="columns"
:allTableData="filterIPList"
:referenceShowAttrNameMap="referenceShowAttrNameMap"
:referenceCIIdMap="referenceCIIdMap"
:columnWidth="columnWidth"
@openAssign="openAssign"
@recycle="handleRecycle"
/>
<GridIP
v-if="currentLayout === 'grid'"
:ipList="filterIPList"
:columns="columns"
:referenceShowAttrNameMap="referenceShowAttrNameMap"
:referenceCIIdMap="referenceCIIdMap"
@openAssign="openAssign"
@recycle="handleRecycle"
/>
</div>
</template>
<AssignForm
ref="assignFormRef"
:attrList="attrList"
:subnetData="subnetData"
@ok="getIPList"
/>
</div>
</template>
<script>
import moment from 'moment'
import _ from 'lodash'
import ExcelJS from 'exceljs'
import FileSaver from 'file-saver'
import { getIPAMAddress, getIPAMHosts, postIPAMAddress, getIPAMSubnetById } from '@/modules/cmdb/api/ipam.js'
import { getCITypeAttributesById } from '@/modules/cmdb/api/CITypeAttr'
import { ADDRESS_STATUS, STATUS_COLOR, STATUS_OPTION, STATUS_LABEL } from './constants.js'
import { getCITypes } from '@/modules/cmdb/api/CIType'
import { searchCI } from '@/modules/cmdb/api/ci'
import { strLength } from '@/modules/cmdb/utils/helper.js'
import TableIP from './tableIP.vue'
import GridIP from './gridIP.vue'
import AssignForm from './assignForm.vue'
export default {
name: 'Address',
components: {
TableIP,
GridIP,
AssignForm
},
props: {
nodeData: {
type: [Object, null],
default: null
},
addressCIType: {
type: Object,
default: () => {}
}
},
data() {
return {
STATUS_COLOR,
searchValue: '',
ipList: {},
currentSelectScope: '',
columns: [],
attrList: [],
subnetData: {},
referenceShowAttrNameMap: {},
referenceCIIdMap: {},
columnWidth: {},
loading: false,
currentStatus: 'all',
filterOption: [
{
value: 'all',
label: 'cmdb.ipam.allStatus',
},
...STATUS_OPTION,
],
currentLayout: 'table',
layoutList: [
{
value: 'table',
icon: 'monitor-list_view'
},
{
value: 'grid',
icon: 'veops-map_view'
}
],
}
},
computed: {
addressNullTip() {
if (
this?.nodeData?.isSubnet &&
this?.nodeData?.cidr &&
this?.nodeData?.children?.length === 0
) {
const cidrSplit = this.nodeData?.cidr?.split?.('/')
const cidrNumber = cidrSplit[cidrSplit.length - 1]
if (Number(cidrNumber) >= 16) {
return ''
} else {
return 'cmdb.ipam.addressNullTip2'
}
}
return 'cmdb.ipam.addressNullTip'
},
addressCITypeId() {
return this.addressCIType?.id || null
},
filterIPList() {
let ipList = this.ipList?.[this.currentSelectScope]
if (!ipList?.length) {
return []
}
if (this.searchValue) {
ipList = ipList.filter((item) => item.ip.indexOf(this.searchValue) !== -1)
}
if (this.currentStatus !== 'all') {
ipList = ipList.filter((item) => item._ip_status === this.currentStatus)
}
return ipList
},
scopeSelectOption() {
if (typeof this.ipList === 'object') {
return Object.keys(this.ipList)
}
return []
},
statusOption() {
const ipList = this.ipList?.[this.currentSelectScope] || []
const statusCount = {
[ADDRESS_STATUS.OFFLINE_ASSIGNED]: 0,
[ADDRESS_STATUS.OFFLINE_UNASSIGNED]: 0,
[ADDRESS_STATUS.ONLINE_ASSIGNED]: 0,
[ADDRESS_STATUS.ONLINE_UNASSIGNED]: 0,
}
ipList.forEach((item) => {
if (item._ip_status) {
statusCount[item._ip_status]++
}
})
return STATUS_OPTION.map((option) => ({
...option,
count: statusCount[option.value]
}))
}
},
watch: {
nodeData: {
deep: true,
immediate: true,
handler(node, oldNode) {
if (
node &&
node?.isSubnet &&
node?.cidr &&
node?.children?.length === 0 &&
node?.key !== oldNode?.key
) {
const cidrSplit = node?.cidr?.split?.('/')
const cidrNumber = cidrSplit[cidrSplit.length - 1]
if (Number(cidrNumber) >= 16) {
this.initData()
}
}
}
}
},
methods: {
async initData() {
this.loading = true
try {
await this.getColumns()
await this.handleReferenceShowAttrName()
await this.getIPList(true)
this.calcColumnWidth()
} catch (error) {
console.log('initData fail', error)
}
this.loading = false
},
async getColumns() {
const getAttrRes = await getCITypeAttributesById(this.addressCITypeId)
const attrList = getAttrRes.attributes
this.attrList = _.cloneDeep(attrList)
const filterAttrList = _.remove(attrList, (item) => {
return ['ip', 'subnet_mask', 'assign_status', 'is_used', '_updated_by', '_updated_at'].includes(item.name)
})
const columns = []
;['ip', 'subnet_mask'].forEach((key) => {
const attr = filterAttrList.find((item) => item.name === key)
if (attr) {
columns.push({
field: attr.name,
title: attr.alias || attr.name || ''
})
}
})
columns.push({
field: '_ip_status',
title: this.$t('status')
})
attrList.map((attr) => {
columns.push({
field: attr.name,
title: attr.alias || attr.name || '',
...attr
})
})
this.columns = columns
},
async getIPList(isInit = false) {
const hostsList = await getIPAMHosts({
cidr: this.nodeData.cidr
})
const res = await getIPAMAddress({
parent_id: this.nodeData.key
})
const subnetData = await getIPAMSubnetById(this.nodeData.key)
this.subnetData = subnetData
const addressMap = {}
if (res?.result?.length) {
res.result.forEach((item) => {
addressMap[item.ip] = item
})
}
const ipList = {}
let currentSelectScope = ''
hostsList.map((ip) => {
let colData = {
ip,
_ip_status: ADDRESS_STATUS.OFFLINE_UNASSIGNED
}
if (addressMap[ip]) {
const data = addressMap[ip]
const assigned = data.assign_status === 0 || data.assign_status === 2
if (data.is_used) {
colData._ip_status = assigned ? ADDRESS_STATUS.ONLINE_ASSIGNED : ADDRESS_STATUS.ONLINE_UNASSIGNED
} else if (assigned) {
colData._ip_status = ADDRESS_STATUS.OFFLINE_ASSIGNED
}
colData = {
...colData,
...data
}
}
const itemData = {
...colData,
subnet_mask: colData?.subnet_mask ?? subnetData?.subnet_mask ?? undefined,
gateway: colData?.gateway ?? subnetData?.gateway ?? undefined
}
const key = ip.split(/\.(?=[^.]*$)/)?.[0]
if (ipList[key]) {
ipList[key].push(itemData)
} else {
if (!currentSelectScope) {
currentSelectScope = key
}
ipList[key] = [itemData]
}
})
this.ipList = ipList
if (isInit) {
this.currentSelectScope = currentSelectScope
}
this.handleReferenceCIIdMap()
},
async handleReferenceShowAttrName() {
const needRequiredCITypeIds = this.columns?.filter((col) => col?.is_reference && col?.reference_type_id).map((col) => col.reference_type_id) || []
if (!needRequiredCITypeIds.length) {
this.referenceShowAttrNameMap = {}
return
}
const res = await getCITypes({
type_ids: needRequiredCITypeIds.join(',')
})
const map = {}
res.ci_types.forEach((ciType) => {
map[ciType.id] = ciType?.show_name || ciType?.unique_name || ''
})
this.referenceShowAttrNameMap = map
},
async handleReferenceCIIdMap() {
const referenceTypeCol = this.columns.filter((col) => col?.is_reference && col?.reference_type_id) || []
if (!this.ipList?.[this.currentSelectScope]?.length || !referenceTypeCol?.length) {
this.referenceCIIdMap = {}
return
}
const map = {}
this.ipList[this.currentSelectScope].forEach((row) => {
referenceTypeCol.forEach((col) => {
const ids = Array.isArray(row[col.field]) ? row[col.field] : row[col.field] ? [row[col.field]] : []
if (ids.length) {
if (!map?.[col.reference_type_id]) {
map[col.reference_type_id] = {}
}
ids.forEach((id) => {
map[col.reference_type_id][id] = {}
})
}
})
})
if (!Object.keys(map).length) {
this.referenceCIIdMap = {}
return
}
const allRes = await Promise.all(
Object.keys(map).map((key) => {
return searchCI({
q: `_type:${key},_id:(${Object.keys(map[key]).join(';')})`,
count: 9999
})
})
)
allRes.forEach((res) => {
res.result.forEach((item) => {
if (map?.[item._type]?.[item._id]) {
map[item._type][item._id] = item
}
})
})
this.referenceCIIdMap = map
},
calcColumnWidth() {
const columnWidth = {}
this.columns.forEach((col) => {
columnWidth[col.field] = Math.min(Math.max(100, ...this.ipList[this.currentSelectScope].map(item => strLength(item[col.field]))), 350)
})
const wrapWidth = this.$refs.addressRef?.clientWidth
const totalWidth = Object.values(columnWidth).reduce((acc, cur) => acc + cur, 0)
if (totalWidth < wrapWidth) {
this.columnWidth = {}
} else {
this.columnWidth = columnWidth
}
},
handleExport() {
let tableData = []
if (this.currentLayout === 'table') {
tableData = this.$refs.tableIPRef.getCheckedTableData()
} else {
tableData = this.filterIPList
}
if (!tableData.length) {
return
}
const wb = new ExcelJS.Workbook()
const ws = wb.addWorksheet(this.tabActive)
const columns = this.columns.map((col) => {
return {
header: col.title,
key: col.field,
width: 20
}
})
ws.columns = columns
tableData.forEach((data) => {
const row = {}
columns.forEach(({ key }) => {
let value = data?.[key] ?? null
if (key === '_ip_status') {
const text = STATUS_LABEL?.[data?.[key]]
value = text ? this.$t(text) : null
}
row[key] = value
})
ws.addRow(row)
})
wb.xlsx.writeBuffer().then((buffer) => {
const fileName = `cmdb-${this.$t('cmdb.ipam.addressAssign')}-${moment().format('YYYYMMDDHHmmss')}.xlsx`
const file = new Blob([buffer], {
type: 'application/octet-stream',
})
FileSaver.saveAs(file, fileName)
})
},
openAssign(data) {
this.$refs.assignFormRef.open({
nodeId: this?.nodeData?._id,
ipData: _.cloneDeep(data)
})
},
handleRecycle(ip) {
this.$confirm({
title: this.$t('warning'),
content: this.$t('cmdb.ipam.recycleTip'),
onOk: () => {
postIPAMAddress({
ips: [ip],
parent_id: this.nodeData._id,
assign_status: 1
}).then(() => {
this.$message.success(this.$t('cmdb.ipam.recycleSuccess', { ip }))
this.getIPList()
})
},
})
}
}
}
</script>
<style lang="less" scoped>
.address {
width: 100%;
height: fit-content;
position: relative;
&-header {
width: 100%;
display: flex;
align-items: baseline;
justify-content: space-between;
&-left {
display: flex;
align-items: center;
flex-wrap: wrap;
row-gap: 12px
}
&-search {
height: 32px;
width: 246px;
flex-shrink: 0;
margin-right: 16px
}
&-filter {
width: 150px;
margin-right: 16px;
flex-shrink: 0;
}
&-status {
display: flex;
align-items: center;
flex-shrink: 0;
column-gap: 20px;
&-item {
display: flex;
align-items: center;
}
&-dot {
width: 12px;
height: 12px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
&-content {
width: 6px;
height: 6px;
border-radius: 6px;
}
}
&-text {
margin-left: 4px;
font-size: 14px;
font-weight: 400;
color: #4E5969;
}
}
&-right {
display: flex;
align-items: center;
flex-shrink: 0;
column-gap: 24px;
}
&-layout {
display: flex;
align-items: center;
height: 32px;
border: solid 1px #E4E7ED;
&-item {
height: 100%;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
&:not(:last-child) {
border-right: solid 1px #E4E7ED;
}
&-active {
color: #2F54EB;
background-color: #F0F5FF;
}
&:hover {
color: #2F54EB;
}
}
}
}
&-main {
margin-top: 22px;
}
&-null {
width: 100%;
padding-top: 130px;
text-align: center;
&-img {
height: 200px;
}
&-tip {
font-size: 14px;
font-weight: 400;
color: #86909C;
}
&-tip2 {
font-size: 14px;
font-weight: 400;
color: #2F54EB;
}
}
&-loading {
width: 100%;
height: 300px;
position: absolute;
top: 0;
left: 0;
color: #000000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
&-icon {
font-size: 28px;
}
&-text {
margin-top: 12px;
}
}
}
</style>

View File

@ -0,0 +1,346 @@
<template>
<div class="ip-table">
<ops-table
ref="xTable"
size="small"
show-overflow
show-header-overflow
highlight-hover-row
:data="tableData"
:row-config="{ useKey: true, keyField: 'ip' }"
:column-config="{ resizable: true }"
:checkbox-config="{ highlight: true, reserve: true, range: true }"
:height="tableHeight"
class="ops-unstripe-table checkbox-hover-table"
@checkbox-change="onSelectChange"
@checkbox-all="onSelectChange"
@checkbox-range-end="onSelectChange"
>
<vxe-table-column
align="center"
type="checkbox"
:width="60"
>
<template #default="{row}">
{{ getRowSeq(row) }}
</template>
</vxe-table-column>
<vxe-table-column
v-for="(col) in columns"
:key="col.field"
:title="col.title"
:field="col.field"
:width="columnWidth[col.field] || undefined"
>
<template
v-if="col.field === '_ip_status' || col.is_link || col.is_reference || col.is_choice"
#default="{ row }"
>
<div v-if="col.field === '_ip_status'" class="ip-table-status">
<div
class="ip-table-status-dot"
:style="{
backgroundColor: `${STATUS_COLOR[row._ip_status]}22`
}"
>
<div
class="ip-table-status-dot-content"
:style="{
backgroundColor: STATUS_COLOR[row._ip_status]
}"
></div>
</div>
<div
class="ip-table-status-text"
>
{{ $t(STATUS_LABEL[row._ip_status]) }}
</div>
</div>
<template v-if="col.is_reference && row[col.field]" >
<a
v-for="(ciId) in (col.is_list ? row[col.field] : [row[col.field]])"
:key="ciId"
:href="`/cmdb/cidetail/${col.reference_type_id}/${ciId}`"
target="_blank"
>
{{ getReferenceAttrValue(ciId, col) }}
</a>
</template>
<template v-else-if="col.is_link && row[col.field]">
<a
v-for="(linkItem, linkIndex) in (col.is_list ? row[col.field] : [row[col.field]])"
:key="linkIndex"
:href="
linkItem.startsWith('http') || linkItem.startsWith('https')
? `${linkItem}`
: `http://${linkItem}`
"
target="_blank"
>
{{ getChoiceValueLabel(col, linkItem) || linkItem }}
</a>
</template>
<template v-else-if="col.is_choice && row[col.field]">
<span
v-for="value in (col.is_list ? row[col.field] : [row[col.field]])"
:key="value"
class="column-default-choice"
>
{{ getChoiceValueLabel(col, value) || value }}
</span>
</template>
</template>
</vxe-table-column>
<vxe-table-column
:title="$t('operation')"
:width="80"
fixed="right"
>
<template #default="{ row }">
<div class="ip-table-operation">
<template v-if="[ADDRESS_STATUS.ONLINE_ASSIGNED, ADDRESS_STATUS.OFFLINE_ASSIGNED].includes(row._ip_status)">
<a-tooltip :title="$t('cmdb.ipam.editAssignAddress')">
<a @click="assignAddress(row)"><ops-icon type="veops-edit" /></a>
</a-tooltip>
<a-tooltip :title="$t('cmdb.ipam.recycle')">
<a @click="clickRecycle(row)"><ops-icon type="veops-recycle" /></a>
</a-tooltip>
</template>
<a-tooltip v-else :title="$t('cmdb.ipam.assign')">
<a @click="assignAddress(row)"><ops-icon type="monitor-add2" /></a>
</a-tooltip>
</div>
</template>
</vxe-table-column>
</ops-table>
<div class="ip-table-pagination">
<a-pagination
:showSizeChanger="true"
:current="page"
size="small"
:total="allTableData.length"
show-quick-jumper
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:show-total="
(total, range) =>
$t('pagination.total', {
range0: range[0],
range1: range[1],
total,
})
"
@showSizeChange="handlePageSizeChange"
@change="changePage"
>
<template slot="buildOptionText" slot-scope="props">
<span v-if="props.value !== '100000'">{{ props.value }}{{ $t('itemsPerPage') }}</span>
<span v-if="props.value === '100000'">{{ $t('all') }}</span>
</template>
</a-pagination>
</div>
</div>
</template>
<script>
import _ from 'lodash'
import { mapState } from 'vuex'
import { STATUS_COLOR, STATUS_LABEL, ADDRESS_STATUS } from './constants.js'
export default {
name: 'TableIP',
props: {
columns: {
type: Array,
default: () => []
},
allTableData: {
type: Array,
default: () => []
},
referenceShowAttrNameMap: {
type: Object,
default: () => {}
},
referenceCIIdMap: {
type: Object,
default: () => {}
},
columnWidth: {
type: Object,
default: () => {}
}
},
data() {
return {
STATUS_COLOR,
STATUS_LABEL,
ADDRESS_STATUS,
page: 1,
pageSize: 50,
pageSizeOptions: ['50', '100', '200'],
}
},
computed: {
...mapState({
windowHeight: (state) => state.windowHeight,
}),
tableHeight() {
return `${this.windowHeight - 270}px`
},
tableData() {
const start = (this.page - 1) * this.pageSize
const end = start + this.pageSize
const tableData = this.allTableData.slice(start, end)
return _.cloneDeep(tableData)
}
},
watch: {
allTableData: {
immediate: true,
handler() {
this.page = 1
}
}
},
methods: {
getRowSeq(row) {
const table = this.$refs?.['xTable']?.getVxetableRef?.() || null
return table?.getRowSeq?.(row)
},
handlePageSizeChange(_, pageSize) {
this.pageSize = pageSize
this.page = 1
},
changePage(page) {
this.page = page
},
assignAddress(data) {
this.$emit('openAssign', data)
},
clickRecycle(data) {
this.$emit('recycle', data.ip)
},
getCheckedTableData(clearCheckbox = true) {
const tableRef = this.$refs.xTable.getVxetableRef()
let tableData = _.cloneDeep([
...tableRef.getCheckboxReserveRecords(),
...tableRef.getCheckboxRecords(true),
])
if (!tableData.length) {
const { fullData } = tableRef.getTableData()
tableData = _.cloneDeep(fullData)
}
if (clearCheckbox) {
tableRef.clearCheckboxRow()
tableRef.clearCheckboxReserve()
}
return tableData
},
getReferenceAttrValue(id, col) {
const ci = this?.referenceCIIdMap?.[col?.reference_type_id]?.[id]
if (!ci) {
return id
}
const attrName = this.referenceShowAttrNameMap?.[col.reference_type_id]
return ci?.[attrName] || id
},
getChoiceValueLabel(col, colValue) {
const _find = col?.choice_value?.find((item) => String(item[0]) === String(colValue))
if (_find) {
return _find?.[1]?.label || ''
}
return ''
},
onSelectChange() {
console.log('onSelectChange')
},
}
}
</script>
<style lang="less" scoped>
.ip-table {
&-status {
display: flex;
align-items: center;
&-dot {
width: 12px;
height: 12px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
&-content {
width: 6px;
height: 6px;
border-radius: 6px;
}
}
&-text {
margin-left: 4px;
font-size: 12px;
font-weight: 400;
color: #4E5969;
}
}
&-operation {
display: flex;
align-items: center;
column-gap: 12px;
}
&-pagination {
text-align: right;
margin-top: 12px;
}
}
.checkbox-hover-table {
/deep/ .vxe-table--body-wrapper {
.vxe-checkbox--label {
display: inline;
padding-left: 0px !important;
color: #bfbfbf;
}
.vxe-icon-checkbox-unchecked {
display: none;
}
.vxe-icon-checkbox-checked ~ .vxe-checkbox--label {
display: none;
}
.vxe-cell--checkbox {
&:hover {
.vxe-icon-checkbox-unchecked {
display: inline;
}
.vxe-checkbox--label {
display: none;
}
}
}
}
}
</style>

View File

@ -0,0 +1,96 @@
<template>
<div class="history">
<div class="history-tab">
<div
v-for="(item) in tabs"
:key="item.key"
:class="['history-tab-item', activeKey === item.key ? 'history-tab-item-active' : '']"
@click="activeKey = item.key"
>
{{ $t(item.title) }}
</div>
</div>
<div class="history-main">
<Operation
v-if="activeKey === 'operation'"
ref="operationRef"
/>
<Scan v-if="activeKey === 'scan'" />
</div>
</div>
</template>
<script>
import Operation from './operation/index.vue'
import Scan from './scan/index.vue'
export default {
name: 'HistoryLog',
components: {
Operation,
Scan
},
data() {
return {
activeKey: 'operation',
tabs: [
{
key: 'operation',
title: 'cmdb.ipam.operationLog'
},
{
key: 'scan',
title: 'cmdb.ipam.scanLog'
}
]
}
},
methods: {
refreshData() {
if (this.activeKey === 'operation' && this.$refs.operationRef) {
this.$refs.operationRef.getTableData()
}
}
}
}
</script>
<style lang="less" scoped>
.history {
width: 100%;
&-tab {
display: inline-flex;
align-items: center;
border: solid 1px #E4E7ED;
&-item {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 20px;
background-color: #FFFFFF;
font-size: 14px;
font-weight: 400;
color: #4E5969;
cursor: pointer;
&:hover {
color: #2F54EB;
}
&-active {
background-color: #2F54EB;
color: #FFFFFF !important;
}
}
}
&-main {
width: 100%;
margin-top: 16px;
}
}
</style>

View File

@ -0,0 +1,56 @@
export const OPERATE_TYPE = {
ADD_SCOPE: '0',
UPDATE_SCOPE: '1',
DELETE_SCOPE: '2',
ADD_SUBNET: '3',
UPDATE_SUBNET: '4',
DELETE_SUBNET: '5',
ASSIGN_ADDRESS: '6',
REVOKE_ADDRESS: '7',
}
export const OPERATE_TYPE_TEXT = {
[OPERATE_TYPE.ADD_SCOPE]: 'cmdb.ipam.addCatalog',
[OPERATE_TYPE.UPDATE_SCOPE]: 'cmdb.ipam.updateCatalog',
[OPERATE_TYPE.DELETE_SCOPE]: 'cmdb.ipam.deleteCatalog',
[OPERATE_TYPE.ADD_SUBNET]: 'cmdb.ipam.addSubnet',
[OPERATE_TYPE.UPDATE_SUBNET]: 'cmdb.ipam.updateSubnet',
[OPERATE_TYPE.DELETE_SUBNET]: 'cmdb.ipam.deleteSubnet',
[OPERATE_TYPE.ASSIGN_ADDRESS]: 'cmdb.ipam.addressAssign',
[OPERATE_TYPE.REVOKE_ADDRESS]: 'cmdb.ipam.revokeAddress',
}
export const OPERATE_TYPE_COLOR = {
[OPERATE_TYPE.ADD_SCOPE]: {
color: '#2F54EB',
backgroundColor: '#DCF5FF'
},
[OPERATE_TYPE.UPDATE_SCOPE]: {
color: '#FF7D00',
backgroundColor: '#FFECCF'
},
[OPERATE_TYPE.DELETE_SCOPE]: {
color: '#FD4C6A',
backgroundColor: '#FFECE8'
},
[OPERATE_TYPE.ADD_SUBNET]: {
color: '#2F54EB',
backgroundColor: '#DCF5FF'
},
[OPERATE_TYPE.UPDATE_SUBNET]: {
color: '#FF7D00',
backgroundColor: '#FFECCF'
},
[OPERATE_TYPE.DELETE_SUBNET]: {
color: '#FD4C6A',
backgroundColor: '#FFECE8'
},
[OPERATE_TYPE.ASSIGN_ADDRESS]: {
color: '#00B42A',
backgroundColor: '#F6FFED'
},
[OPERATE_TYPE.REVOKE_ADDRESS]: {
color: '#0AA5A8',
backgroundColor: '#E8FFFB'
},
}

View File

@ -0,0 +1,240 @@
<template>
<div class="operate">
<a-input-search
class="operate-search"
@search="handleSearch"
/>
<ops-table
ref="xTable"
size="small"
show-overflow
show-header-overflow
highlight-hover-row
:data="tableData"
:height="tableHeight"
class="ops-unstripe-table operate-table"
:filter-config="{ remote: true }"
:sort-config="{ remote: true, trigger: 'cell' }"
:column-config="{ resizable: true }"
@filter-change="handlefilterChange"
@sort-change="handleSortChange"
>
<vxe-table-column
:title="$t('cmdb.ipam.operateTime')"
sortable
field="created_at"
:width="150"
></vxe-table-column>
<vxe-table-column
:title="$t('cmdb.ipam.operateUser')"
field="uid"
:filters="userFilters"
:width="130"
>
<template #default="{row}">
{{ row.nickname }}
</template>
</vxe-table-column>
<vxe-table-column
:title="$t('cmdb.ipam.operateType')"
field="operate_type"
:filters="operateTypeFilters"
:width="150"
>
<template #default="{row}">
<div
v-if="row.operate_type"
class="operate-table-type"
:style="{
backgroundColor: OPERATE_TYPE_COLOR[row.operate_type].backgroundColor,
color: OPERATE_TYPE_COLOR[row.operate_type].color
}"
>
{{ $t(OPERATE_TYPE_TEXT[row.operate_type]) }}
</div>
</template>
</vxe-table-column>
<vxe-table-column
title="CIDR"
field="cidr"
:width="150"
></vxe-table-column>
<vxe-table-column
:title="$t('cmdb.ipam.description')"
field="description"
></vxe-table-column>
</ops-table>
<div class="operate-pagination">
<a-pagination
:showSizeChanger="true"
:current="page"
size="small"
:total="totalNumber"
show-quick-jumper
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:show-total="
(total, range) =>
$t('pagination.total', {
range0: range[0],
range1: range[1],
total,
})
"
@change="handleChangePage"
@showSizeChange="onShowSizeChange"
>
<template slot="buildOptionText" slot-scope="props">
<span v-if="props.value !== '100000'">{{ props.value }}{{ $t('itemsPerPage') }}</span>
<span v-if="props.value === '100000'">{{ $t('cmdb.ci.all') }}</span>
</template>
</a-pagination>
</div>
</div>
</template>
<script>
import _ from 'lodash'
import { mapState } from 'vuex'
import { OPERATE_TYPE_TEXT, OPERATE_TYPE_COLOR, OPERATE_TYPE } from './constants.js'
import { getIPAMHistoryOperate } from '@/modules/cmdb/api/ipam.js'
export default {
name: 'Operate',
data() {
return {
OPERATE_TYPE_TEXT,
OPERATE_TYPE_COLOR,
searchValue: '',
page: 1,
pageSize: 50,
pageSizeOptions: ['50', '100', '200'],
tableData: [],
totalNumber: 0,
getTableDataParams: {
reverse: 1
},
userFilters: []
}
},
computed: {
...mapState({
allEmployees: (state) => state.user.allEmployees,
windowHeight: (state) => state.windowHeight,
}),
tableHeight() {
return `${this.windowHeight - 308}px`
},
operateTypeFilters() {
const filters = Object.values(OPERATE_TYPE).map((key) => {
return {
value: key,
label: this.$t(OPERATE_TYPE_TEXT[key])
}
})
return filters
}
},
mounted() {
this.getTableData()
},
methods: {
async getTableData() {
const res = await getIPAMHistoryOperate({
page: this.page,
page_size: this.pageSize,
...this.getTableDataParams
})
const tableData = res?.result || []
const userFilters = []
const defaultUserChecked = this.getTableDataParams.uid ? this.getTableDataParams.uid.split(',') : []
tableData.forEach((item) => {
const nickname = this.allEmployees?.find?.((user) => user?.acl_uid === item?.uid)?.nickname
item.nickname = nickname
userFilters.push({
label: nickname,
value: item.uid,
checked: defaultUserChecked.includes(String(item.uid))
})
})
this.totalNumber = res?.numfound || 0
this.tableData = tableData
this.userFilters = _.uniqBy(userFilters, 'value')
},
handleSearch(v) {
if (v) {
this.getTableDataParams.cidr = `*${v}*`
} else if (this.getTableDataParams.cidr) {
delete this.getTableDataParams.cidr
}
this.page = 1
this.getTableData()
},
handleChangePage(page) {
this.page = page
this.getTableData()
},
onShowSizeChange(_, pageSize) {
this.page = 1
this.pageSize = pageSize
this.getTableData()
},
handlefilterChange({ field, values }) {
this.page = 1
const value = values.join(',')
if (!value && this.getTableDataParams[field]) {
delete this.getTableDataParams[field]
} else {
this.getTableDataParams[field] = values.join(',')
}
this.getTableData()
},
handleSortChange(data) {
if (data?.order === 'asc') {
this.getTableDataParams.reverse = 0
} else {
this.getTableDataParams.reverse = 1
}
this.page = 1
this.getTableData()
}
}
}
</script>
<style lang="less" scoped>
.operate {
width: 100%;
&-search {
width: 244px;
margin-bottom: 22px;
}
&-table {
&-type {
display: inline-block;
font-size: 12px;
font-weight: 400;
padding: 0 9px;
height: 22px;
line-height: 22px;
border-radius: 1px;
}
}
&-pagination {
text-align: right;
margin-top: 4px;
}
}
</style>

View File

@ -0,0 +1,286 @@
<template>
<div class="scan">
<a-input-search
class="scan-search"
@search="handleSearch"
/>
<ops-table
ref="xTable"
size="small"
show-overflow
show-header-overflow
highlight-hover-row
:data="tableData"
:height="tableHeight"
:column-config="{ resizable: true }"
class="ops-unstripe-table scan-table"
>
<vxe-table-column
title="CIDR"
field="cidr"
></vxe-table-column>
<vxe-table-column
:title="$t('cmdb.ipam.ipNumber')"
field="ip_num"
></vxe-table-column>
<vxe-table-column
:title="$t('cmdb.ipam.startTime')"
field="start_at"
></vxe-table-column>
<vxe-table-column
:title="$t('cmdb.ipam.endTime')"
field="end_at"
></vxe-table-column>
<vxe-table-column
:title="$t('cmdb.ipam.scanningTime')"
field="scanning_time"
></vxe-table-column>
<vxe-table-column
:title="$t('cmdb.ipam.isSuccess')"
field="status"
>
<template #default="{ row }">
<div class="scan-table-success" v-if="row.status === 0">
<a-icon class="scan-table-success-icon" type="check-circle" theme="filled" />
<div class="scan-table-success-text">{{ $t('success') }}</div>
</div>
<div class="scan-table-fail" v-else>
<a-icon class="scan-table-fail-icon" type="close-circle" theme="filled" />
<div class="scan-table-fail-text">{{ $t('fail') }}</div>
</div>
</template>
</vxe-table-column>
<vxe-table-column
:title="$t('cmdb.ipam.viewResult')"
field="operation"
:show-overflow="false"
>
<template #default="{ row }">
<a-popover placement="left">
<span class="scan-table-operation">
{{ row.status === 0 ? row.ips ? row.ips.join(', ') : '' : row.stdout }}
</span>
<template slot="content">
<div
v-if="row.status === 0"
class="scan-table-ip"
>
<div
v-for="(ip, index) in row.ips"
:key="index"
class="scan-table-ip-item"
>
{{ ip }}
</div>
</div>
<div
v-else
class="scan-table-error-log"
>
{{ row.stdout }}
</div>
</template>
</a-popover>
</template>
</vxe-table-column>
</ops-table>
<div class="scan-pagination">
<a-pagination
:showSizeChanger="true"
:current="page"
size="small"
:total="totalNumber"
show-quick-jumper
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:show-total="
(total, range) =>
$t('pagination.total', {
range0: range[0],
range1: range[1],
total,
})
"
@change="handleChangePage"
@showSizeChange="onShowSizeChange"
>
<template slot="buildOptionText" slot-scope="props">
<span v-if="props.value !== '100000'">{{ props.value }}{{ $t('itemsPerPage') }}</span>
<span v-if="props.value === '100000'">{{ $t('cmdb.ci.all') }}</span>
</template>
</a-pagination>
</div>
</div>
</template>
<script>
import moment from 'moment'
import { mapState } from 'vuex'
import { getIPAMHistoryScan } from '@/modules/cmdb/api/ipam.js'
export default {
name: 'Scan',
data() {
return {
page: 1,
pageSize: 50,
pageSizeOptions: ['50', '100', '200'],
tableData: [],
totalNumber: 0,
getTableDataParams: {},
}
},
computed: {
...mapState({
windowHeight: (state) => state.windowHeight,
}),
tableHeight() {
return `${this.windowHeight - 308}px`
},
},
mounted() {
this.getTableData()
},
methods: {
async getTableData() {
const res = await getIPAMHistoryScan({
page: this.page,
page_size: this.pageSize,
reverse: 1,
...this.getTableDataParams
})
const tableData = res?.result || []
tableData.forEach((item) => {
if (item.start_at && item.end_at) {
const startAt = moment(item.start_at)
const endAt = moment(item.end_at)
item.scanning_time = `${endAt.diff(startAt, 'seconds')}s`
}
})
this.tableData = tableData
this.totalNumber = res?.numfound || 0
},
handleChangePage(page) {
this.page = page
this.getTableData()
},
onShowSizeChange(_, pageSize) {
this.page = 1
this.pageSize = pageSize
this.getTableData()
},
handleSearch(v) {
if (v) {
this.getTableDataParams.cidr = `*${v}*`
} else if (this.getTableDataParams.cidr) {
delete this.getTableDataParams.cidr
}
this.page = 1
this.getTableData()
}
}
}
</script>
<style lang="less" scoped>
.scan {
width: 100%;
&-search {
width: 244px;
margin-bottom: 22px;
}
&-table {
&-success {
padding: 4px 7px;
border-radius: 1px;
background-color: #DCF3E3;
display: inline-flex;
align-items: center;
justify-content: center;
&-icon {
font-size: 12px;
color: #00B42A;
}
&-text {
font-size: 12px;
font-weight: 400;
color: #30AD2D;
margin-left: 4px;
}
}
&-fail {
padding: 0px 7px;
border-radius: 1px;
background-color: #FFECE8;
display: inline-flex;
align-items: center;
justify-content: center;
&-icon {
font-size: 12px;
color: #FD4C6A;
}
&-text {
font-size: 12px;
font-weight: 400;
color: #FD4C6A;
margin-left: 4px;
}
}
&-operation {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
}
&-ip {
width: 100%;
max-height: 216px;
overflow-y: auto;
overflow-x: hidden;
border: solid 1px #F0F1F5;
&-item {
height: 36px;
line-height: 36px;
padding: 0 12px;
font-size: 14px;
font-weight: 400;
color: #1D2129;
&:not(:last-child) {
border-bottom: solid 1px #F0F1F5;
}
}
}
&-error-log {
max-width: 200px;
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
}
}
&-pagination {
text-align: right;
margin-top: 4px;
}
}
</style>

View File

@ -0,0 +1,385 @@
<template>
<div ref="wrapRef">
<div class="table-header">
<SearchForm
ref="search"
:preferenceAttrList="preferenceAttrList"
:typeId="addressCITypeId"
@copyExpression="copyExpression"
@refresh="handleSearch"
/>
<div class="table-header-right">
<EditAttrsPopover
:typeId="addressCITypeId"
@refresh="refreshAfterEditAttrs"
>
<a-button
type="primary"
ghost
class="ops-button-ghost"
>
<ops-icon type="veops-configuration_table" />
{{ $t('cmdb.configTable') }}
</a-button>
</EditAttrsPopover>
<a-button
v-if="instanceList && instanceList.length"
type="primary"
class="ops-button-ghost"
ghost
@click="handleExport"
>
<ops-icon type="veops-export" />
{{ $t('export') }}
</a-button>
</div>
</div>
<CITable
ref="xTable"
:loading="loading"
:attrList="preferenceAttrList"
:columns="columns"
:data="instanceList"
:height="tableHeight"
@sort-change="handleSortCol"
@openDetail="openDetail"
@deleteCI="deleteCI"
/>
<div class="table-pagination">
<a-pagination
:showSizeChanger="true"
:current="page"
size="small"
:total="totalNumber"
show-quick-jumper
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:show-total="
(total, range) =>
$t('pagination.total', {
range0: range[0],
range1: range[1],
total,
})
"
@change="handleChangePage"
@showSizeChange="onShowSizeChange"
>
<template slot="buildOptionText" slot-scope="props">
<span v-if="props.value !== '100000'">{{ props.value }}{{ $t('itemsPerPage') }}</span>
<span v-if="props.value === '100000'">{{ $t('cmdb.ci.all') }}</span>
</template>
</a-pagination>
</div>
<BatchDownload
ref="batchDownload"
:showFileTypeSelect="false"
@batchDownload="batchDownload"
/>
<CIDetailDrawer ref="detail" :typeId="addressCITypeId" />
</div>
</template>
<script>
import _ from 'lodash'
import { mapState } from 'vuex'
import ExcelJS from 'exceljs'
import FileSaver from 'file-saver'
import { searchCI, deleteCI } from '@/modules/cmdb/api/ci'
import { getSubscribeAttributes } from '@/modules/cmdb/api/preference'
import { getCITypeAttributesById } from '@/modules/cmdb/api/CITypeAttr'
import { getCITableColumns } from '@/modules/cmdb/utils/helper'
import SearchForm from '@/modules/cmdb/components/searchForm/SearchForm.vue'
import CITable from '@/modules/cmdb/components/ciTable/index.vue'
import BatchDownload from '@/modules/cmdb/components/batchDownload/batchDownload.vue'
import CIDetailDrawer from '@/modules/cmdb/views/ci/modules/ciDetailDrawer.vue'
import EditAttrsPopover from '@/modules/cmdb/views/ci/modules/editAttrsPopover.vue'
export default {
name: 'IPSearch',
components: {
SearchForm,
CITable,
BatchDownload,
CIDetailDrawer,
EditAttrsPopover
},
props: {
addressCIType: {
type: Object,
default: () => {}
}
},
data() {
return {
page: 1,
pageSize: 50,
pageSizeOptions: ['50', '100', '200'],
loading: false,
sortByTable: undefined,
instanceList: [],
totalNumber: 0,
columns: [],
preferenceAttrList: [],
attrList: [],
attributes: {},
}
},
computed: {
...mapState({
windowHeight: (state) => state.windowHeight,
}),
tableHeight() {
return this.windowHeight - 260
},
addressCITypeId() {
return this.addressCIType?.id || null
}
},
provide() {
return {
handleSearch: this.getTableData,
attrList: () => {
return this.attrList
},
attributes: () => {
return this.attributes
}
}
},
async mounted() {
this.$nextTick(async () => {
if (this.addressCITypeId) {
await this.getAttributeList()
await this.getPreferenceAttrList()
this.getTableData()
}
})
},
methods: {
async getAttributeList() {
await getCITypeAttributesById(this.addressCITypeId).then((res) => {
this.attrList = res.attributes
this.attributes = res
})
},
async getPreferenceAttrList() {
const subscribed = await getSubscribeAttributes(this.addressCITypeId)
this.preferenceAttrList = subscribed.attributes
},
async getTableData() {
try {
this.loading = true
const fuzzySearch = this.$refs['search'].fuzzySearch
const expression = this.$refs['search'].expression || ''
const regQ = /(?<=q=).+(?=&)|(?<=q=).+$/g
const regSort = /(?<=sort=).+/g
const exp = expression.match(regQ) ? expression.match(regQ)[0] : null
let sort
if (this.sortByTable) {
sort = this.sortByTable
} else {
sort = expression.match(regSort) ? expression.match(regSort)[0] : undefined
}
const res = await searchCI({
q: `_type:${this.addressCITypeId}${exp ? `,${exp}` : ''}${fuzzySearch ? `,*${fuzzySearch}*` : ''}`,
count: this.pageSize,
page: this.page,
sort,
})
this.totalNumber = res?.numfound
const instanceList = res.result
const jsonAttrList = this.preferenceAttrList.filter((attr) => attr.value_type === '6')
instanceList.forEach((item) => {
jsonAttrList.forEach(
(jsonAttr) => (item[jsonAttr.name] = item[jsonAttr.name] ? JSON.stringify(item[jsonAttr.name]) : '')
)
})
this.getColumns(instanceList)
this.instanceList = instanceList
} finally {
this.loading = false
}
},
getColumns(data) {
const width = this.$refs.wrapRef.clientWidth - 50
const columns = getCITableColumns(data, this.preferenceAttrList, width)
columns.forEach((item) => {
if (item.editRender) {
item.editRender.enabled = false
}
})
this.columns = columns
},
copyExpression() {
const expression = this.$refs['search'].expression || ''
const fuzzySearch = this.$refs['search'].fuzzySearch
const regQ = /(?<=q=).+(?=&)|(?<=q=).+$/g
const exp = expression.match(regQ) ? expression.match(regQ)[0] : null
const text = `q=_type:${this.addressCITypeId}${exp ? `,${exp}` : ''}${fuzzySearch ? `,*${fuzzySearch}*` : ''}`
this.$copyText(text)
.then(() => {
this.$message.success(this.$t('copySuccess'))
})
},
handleSearch() {
this.$refs.xTable.getVxetableRef().clearSort()
this.$nextTick(() => {
this.page = 1
this.getTableData()
})
},
handleChangePage(page) {
this.page = page
this.getTableData()
},
onShowSizeChange(_, pageSize) {
this.page = 1
this.pageSize = pageSize
this.getTableData()
},
handleSortCol({ property, order }) {
let sortByTable
if (order === 'asc') {
sortByTable = property
} else if (order === 'desc') {
sortByTable = `-${property}`
}
this.sortByTable = sortByTable
this.$nextTick(() => {
this.page = 1
this.getTableData()
})
},
handleExport() {
this.$refs.batchDownload.open({
preferenceAttrList: this.preferenceAttrList,
ciTypeName: this.$t('cmdb.ipam.ipSearch') || '',
})
},
batchDownload({ checkedKeys, filename }) {
const wb = new ExcelJS.Workbook()
const tableRef = this.$refs.xTable.getVxetableRef()
let tableData = _.cloneDeep([
...tableRef.getCheckboxReserveRecords(),
...tableRef.getCheckboxRecords(true),
])
if (!tableData.length) {
const { fullData } = tableRef.getTableData()
tableData = _.cloneDeep(fullData)
}
const ws = wb.addWorksheet(this.tabActive)
const columns = []
const attrMap = new Map()
this.columns.filter((col) => checkedKeys.includes(col.field)).map((col) => {
attrMap.set(col.field, col)
columns.push({
header: col.title || '',
key: col.field,
width: 20,
})
})
ws.columns = columns
tableData.forEach((item) => {
const row = {}
columns.forEach(({ key }) => {
const value = item?.[key] ?? null
const attr = attrMap.get(key)
if (attr.valueType === '6') {
row[key] = value ? JSON.stringify(value) : value
} else if (attr.is_list && Array.isArray(value)) {
row[key] = value.join(',')
} else {
row[key] = value
}
})
ws.addRow(row)
})
wb.xlsx.writeBuffer().then((buffer) => {
const file = new Blob([buffer], {
type: 'application/octet-stream',
})
FileSaver.saveAs(file, `${filename}.xlsx`)
})
this.$refs.xTable.getVxetableRef().clearCheckboxRow()
this.$refs.xTable.getVxetableRef().clearCheckboxReserve()
},
openDetail(id, activeTabKey, ciDetailRelationKey) {
this.$refs.detail.create(id, activeTabKey, ciDetailRelationKey)
},
async refreshAfterEditAttrs() {
await this.getPreferenceAttrList()
this.getTableData()
},
deleteCI(record) {
this.$confirm({
title: this.$t('warning'),
content: this.$t('confirmDelete'),
onOk: () => {
deleteCI(record.ci_id || record._id).then(() => {
this.$message.success(this.$t('deleteSuccess'))
this.getTableData()
})
},
})
},
}
}
</script>
<style lang="less" scoped>
.table-header {
display: flex;
align-items: baseline;
width: 100%;
justify-content: space-between;
&-right {
display: flex;
align-items: center;
column-gap: 12px;
}
}
.table-pagination {
text-align: right;
margin-top: 4px;
}
</style>

View File

@ -0,0 +1,65 @@
<template>
<div class="overview">
<Stats :statsData="statsData" />
<SubnetTable :tableData="tableData" />
</div>
</template>
<script>
import { getIPAMStats } from '@/modules/cmdb/api/ipam.js'
import Stats from './stats.vue'
import SubnetTable from './subnetTable.vue'
export default {
name: 'Overview',
components: {
Stats,
SubnetTable
},
props: {
nodeId: {
type: String,
default: ''
}
},
data() {
return {
statsData: {},
tableData: []
}
},
watch: {
nodeId: {
deep: true,
immediate: true,
handler(newValue, oldValue) {
if (newValue !== oldValue) {
this.initData(newValue)
}
}
}
},
methods: {
async initData() {
const res = await getIPAMStats({
parent_id: this.nodeId === 'all' ? 0 : this.nodeId
})
const tableData = res?.subnets || []
tableData.forEach((item) => {
item.hosts_count = item?.hosts_count || 0
item.used_ratio = item?.used_count && item?.hosts_count ? Math.round((item.used_count / item.hosts_count) * 100) : 0
})
this.statsData = res
this.tableData = tableData
}
}
}
</script>
<style lang="less" scoped>
.overview {
width: 100%;
}
</style>

View File

@ -0,0 +1,212 @@
<template>
<div class="stats">
<div
class="stats-card"
v-for="(item, index) in statsListData"
:key="index"
>
<div class="stats-card-left">
<div class="stats-card-title">{{ $t(item.title) }}</div>
<div class="stats-card-row">
<div
v-for="(dataItem, dataIndex) in item.data"
:key="dataIndex"
class="stats-card-data"
>
<span class="stats-card-data-label">{{ $t(dataItem.label) }}</span>
<span class="stats-card-data-value">{{ dataItem.value }}</span>
</div>
</div>
</div>
<div
v-if="item.logo"
class="stats-card-logo"
>
<ops-icon
:type="item.logo"
class="stats-card-logo-icon"
/>
</div>
<StatsChart
v-else
:statsData="item"
/>
</div>
</div>
</template>
<script>
import StatsChart from './statsChart.vue'
export default {
name: 'Statistics',
components: {
StatsChart
},
props: {
statsData: {
type: Object,
default: () => {}
}
},
computed: {
statsListData() {
const {
subnet_num = 0,
address_num = 0,
address_free_num = 0,
address_assign_num = 0,
address_unassign_num = 0,
address_used_num = 0,
address_used_free_num = 0
} = this.statsData || {}
return [
{
title: 'cmdb.ipam.subnetStats',
logo: 'caise-IPAM',
data: [
{
label: 'cmdb.ipam.total',
value: subnet_num
}
]
},
{
title: 'cmdb.ipam.addressStats',
data: [
{
label: 'cmdb.ipam.total',
value: address_num,
chartValue: address_num - address_free_num,
},
{
label: 'cmdb.ipam.free',
value: address_free_num
}
],
ratio: address_num && address_free_num ? Math.round((address_free_num / address_num) * 100) : 0,
chartColor: ['#6EE3EB', '#6592FD']
},
{
title: 'cmdb.ipam.assignStats',
data: [
{
label: 'cmdb.ipam.assigned',
value: address_assign_num
},
{
label: 'cmdb.ipam.unassigned',
value: address_unassign_num
}
],
ratio: address_num && address_assign_num ? Math.round((address_assign_num / address_num) * 100) : 0,
chartColor: ['#8C85ED', '#387BFD']
},
{
title: 'cmdb.ipam.onlineStats',
data: [
{
label: 'cmdb.ipam.online',
value: address_used_num
},
{
label: 'cmdb.ipam.offline',
value: address_used_free_num
}
],
ratio: address_num && address_used_num ? Math.round((address_used_num / address_num) * 100) : 0,
chartColor: ['#009FA9', '#17D4B0']
},
]
}
},
watch: {
statsData: {
deep: true,
immediate: true,
handler(data) {
this.initData(data)
}
}
},
methods: {
initData() {
}
}
}
</script>
<style lang="less" scoped>
.stats {
width: 100%;
display: flex;
column-gap: 24px;
row-gap: 12px;
&-card {
padding: 14px 17px;
background-color: #F7F8FA;
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
&-left {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
}
&-title {
font-size: 14px;
font-weight: 400;
color: #4E5969;
}
&-row {
display: flex;
align-items: baseline;
margin-top: 12px;
flex-wrap: wrap;
column-gap: 25px;
}
&-data {
display: flex;
&-label {
font-size: 14px;
font-weight: 400;
color: #1D2129;
}
&-value {
font-size: 14px;
font-weight: 700;
color: #1D2129;
margin-left: 6px;
}
}
&-logo {
width: 52px;
height: 52px;
border-radius: 52px;
background-color: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
&-icon {
font-size: 25px;
}
}
}
}
</style>

View File

@ -0,0 +1,105 @@
<template>
<div class="stats-chart">
<div
class="stats-chart-pie"
ref="statsChartRef"
></div>
<div class="stats-chart-ratio">
{{ statsData.ratio }}%
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
export default {
name: 'StatsChart',
props: {
statsData: {
type: Object,
default: () => {}
}
},
watch: {
statsData: {
deep: true,
immediate: true,
handler(data) {
this.updateChart(data)
}
}
},
beforeDestroy() {
if (this.chart) {
this.chart.dispose()
this.chart = null
}
},
methods: {
updateChart(data) {
const option = {
color: data?.chartColor || [],
tooltip: {
show: false
},
legend: {
show: false,
},
series: [
{
type: 'pie',
radius: ['60%', '85%'],
data: data?.data?.map((item) => {
return {
name: this.$t(item?.label),
value: item?.chartValue ?? item.value
}
}) || [],
itemStyle: {
borderColor: '#fff',
borderWidth: 1
},
label: {
show: false,
},
}
]
}
this.$nextTick(() => {
if (!this.chart) {
const el = this.$refs.statsChartRef
this.chart = echarts.init(el)
}
this.chart.setOption(option)
})
}
}
}
</script>
<style lang="less" scoped>
.stats-chart {
width: 60px;
height: 60px;
position: relative;
&-pie {
width: 100%;
height: 100%;
}
&-ratio {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
font-size: 14px;
font-weight: 700;
color: #1D2129;
}
}
</style>

View File

@ -0,0 +1,207 @@
<template>
<div class="subnet-table">
<div class="subnet-table-title">
{{ $t('cmdb.ipam.onlineUsageStats') }}
</div>
<ops-table
ref="xTable"
show-overflow
show-header-overflow
highlight-hover-row
:data="tableData"
size="small"
:height="tableHeight"
:column-config="{ resizable: true }"
class="ops-unstripe-table"
>
<vxe-table-column
:title="$t('cmdb.ipam.subnetName')"
:min-width="130"
field="name"
></vxe-table-column>
<vxe-table-column
title="CIDR"
field="cidr"
:min-width="130"
></vxe-table-column>
<vxe-table-column
:title="$t('cmdb.ipam.addressCount')"
field="hosts_count"
:min-width="70"
></vxe-table-column>
<vxe-table-column
:title="$t('cmdb.ipam.onlineRatio')"
field="onlineRatio"
:min-width="180"
>
<template #default="{ row }">
<div class="subnet-table-ratio">
<div class="subnet-table-ratio-value">
{{ row.used_ratio }}%
</div>
<div class="subnet-table-ratio-progress">
<div
class="subnet-table-ratio-progress-content"
:style="{
width: row.used_ratio + '%'
}"
></div>
</div>
<div class="subnet-table-ratio-count">
{{ row.used_count }}/{{ row.hosts_count }}
</div>
</div>
</template>
</vxe-table-column>
<vxe-table-column
:title="$t('cmdb.ipam.assigned')"
field="assign_count"
:min-width="70"
></vxe-table-column>
<vxe-table-column
:title="$t('cmdb.ipam.free')"
field="free_count"
:min-width="50"
></vxe-table-column>
<vxe-table-column
:title="$t('cmdb.ipam.scanEnable')"
field="scan_enabled"
:min-width="100"
>
<template #default="{ row }">
<div class="subnet-table-scan-yes" v-if="row.scan_enabled">
<a-icon class="subnet-table-scan-yes-icon" type="check-circle" theme="filled" />
<div class="subnet-table-scan-yes-text">{{ $t('yes') }}</div>
</div>
<div class="subnet-table-scan-no" v-else>
<a-icon class="subnet-table-scan-no-icon" type="close-circle" theme="filled" />
<div class="subnet-table-scan-no-text">{{ $t('no') }}</div>
</div>
</template>
</vxe-table-column>
<vxe-table-column
:title="$t('cmdb.ipam.lastScanTime')"
field="last_scan_time"
:min-width="100"
></vxe-table-column>
</ops-table>
</div>
</template>
<script>
import { mapState } from 'vuex'
export default {
name: 'SubnetTable',
props: {
tableData: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {
...mapState({
windowHeight: (state) => state.windowHeight,
}),
tableHeight() {
return `${this.windowHeight - 337}px`
},
},
methods: {}
}
</script>
<style lang="less" scoped>
.subnet-table {
width: 100%;
margin-top: 20px;
&-title {
font-size: 15px;
font-weight: 700;
margin-bottom: 16px;
}
&-ratio {
display: flex;
align-items: center;
&-value {
font-size: 14px;
font-weight: 400;
color: #4E5969;
}
&-progress {
width: 84px;
height: 6px;
border-radius: 6px;
background-color: #EBEFF8;
margin-left: 12px;
&-content {
height: 6px;
border-radius: 6px;
background-color: #7F97FA;
}
}
&-count {
margin-left: 5px;
font-size: 10px;
font-weight: 400;
color: #86909C;
}
}
&-scan-yes {
padding: 4px 7px;
border-radius: 1px;
background-color: #DCF3E3;
display: inline-flex;
align-items: center;
justify-content: center;
&-icon {
font-size: 12px;
color: #00B42A;
}
&-text {
font-size: 12px;
font-weight: 400;
color: #30AD2D;
margin-left: 4px;
}
}
&-scan-no {
padding: 0px 7px;
border-radius: 1px;
background-color: #E4E7ED;
display: inline-flex;
align-items: center;
justify-content: center;
&-icon {
font-size: 12px;
color: #A5A9BC;
}
&-text {
font-size: 12px;
font-weight: 400;
color: #4E5969;
margin-left: 4px;
}
}
}
</style>

View File

@ -0,0 +1,387 @@
<template>
<div ref="wrapRef">
<div class="table-header">
<SearchForm
ref="search"
:preferenceAttrList="preferenceAttrList"
:typeId="subnetCITypeId"
@copyExpression="copyExpression"
@refresh="handleSearch"
/>
<div class="table-header-right">
<EditAttrsPopover
:typeId="subnetCITypeId"
@refresh="refreshAfterEditAttrs"
>
<a-button
type="primary"
ghost
class="ops-button-ghost"
>
<ops-icon type="veops-configuration_table" />
{{ $t('cmdb.configTable') }}
</a-button>
</EditAttrsPopover>
<a-button
v-if="instanceList && instanceList.length"
type="primary"
class="ops-button-ghost"
ghost
@click="handleExport"
>
<ops-icon type="veops-export" />
{{ $t('export') }}
</a-button>
</div>
</div>
<CITable
ref="xTable"
:loading="loading"
:attrList="preferenceAttrList"
:columns="columns"
:data="instanceList"
:height="tableHeight"
@sort-change="handleSortCol"
@openDetail="openDetail"
@deleteCI="deleteCI"
/>
<div class="table-pagination">
<a-pagination
:showSizeChanger="true"
:current="page"
size="small"
:total="totalNumber"
show-quick-jumper
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:show-total="
(total, range) =>
$t('pagination.total', {
range0: range[0],
range1: range[1],
total,
})
"
@change="handleChangePage"
@showSizeChange="onShowSizeChange"
>
<template slot="buildOptionText" slot-scope="props">
<span v-if="props.value !== '100000'">{{ props.value }}{{ $t('itemsPerPage') }}</span>
<span v-if="props.value === '100000'">{{ $t('cmdb.ci.all') }}</span>
</template>
</a-pagination>
</div>
<BatchDownload
ref="batchDownload"
:showFileTypeSelect="false"
@batchDownload="batchDownload"
/>
<CIDetailDrawer ref="detail" :typeId="subnetCITypeId" />
</div>
</template>
<script>
import _ from 'lodash'
import { mapState } from 'vuex'
import ExcelJS from 'exceljs'
import FileSaver from 'file-saver'
import { searchCI, deleteCI } from '@/modules/cmdb/api/ci'
import { getSubscribeAttributes } from '@/modules/cmdb/api/preference'
import { getCITypeAttributesById } from '@/modules/cmdb/api/CITypeAttr'
import { getCITableColumns } from '@/modules/cmdb/utils/helper'
import SearchForm from '@/modules/cmdb/components/searchForm/SearchForm.vue'
import CITable from '@/modules/cmdb/components/ciTable/index.vue'
import BatchDownload from '@/modules/cmdb/components/batchDownload/batchDownload.vue'
import CIDetailDrawer from '@/modules/cmdb/views/ci/modules/ciDetailDrawer.vue'
import EditAttrsPopover from '@/modules/cmdb/views/ci/modules/editAttrsPopover.vue'
export default {
name: 'SubnetList',
components: {
SearchForm,
CITable,
BatchDownload,
CIDetailDrawer,
EditAttrsPopover
},
props: {
subnetCIType: {
type: Object,
default: () => {}
}
},
data() {
return {
page: 1,
pageSize: 50,
pageSizeOptions: ['50', '100', '200'],
loading: false,
sortByTable: undefined,
instanceList: [],
totalNumber: 0,
columns: [],
preferenceAttrList: [],
attrList: [],
attributes: {},
}
},
computed: {
...mapState({
windowHeight: (state) => state.windowHeight,
}),
tableHeight() {
return this.windowHeight - 260
},
subnetCITypeId() {
return this?.subnetCIType?.id || null
}
},
provide() {
return {
handleSearch: this.getTableData,
attrList: () => {
return this.attrList
},
attributes: () => {
return this.attributes
}
}
},
async mounted() {
this.$nextTick(async () => {
if (this.subnetCITypeId) {
await this.getAttributeList()
await this.getPreferenceAttrList()
this.getTableData()
}
})
},
methods: {
async getAttributeList() {
await getCITypeAttributesById(this.subnetCITypeId).then((res) => {
this.attrList = res.attributes
this.attributes = res
})
},
async getPreferenceAttrList() {
const subscribed = await getSubscribeAttributes(this.subnetCITypeId)
this.preferenceAttrList = subscribed.attributes
},
async getTableData() {
try {
this.loading = true
const fuzzySearch = this.$refs['search'].fuzzySearch
const expression = this.$refs['search'].expression || ''
const regQ = /(?<=q=).+(?=&)|(?<=q=).+$/g
const regSort = /(?<=sort=).+/g
const exp = expression.match(regQ) ? expression.match(regQ)[0] : null
let sort
if (this.sortByTable) {
sort = this.sortByTable
} else {
sort = expression.match(regSort) ? expression.match(regSort)[0] : undefined
}
const res = await searchCI({
q: `_type:${this.subnetCITypeId}${exp ? `,${exp}` : ''}${fuzzySearch ? `,*${fuzzySearch}*` : ''}`,
count: this.pageSize,
page: this.page,
sort,
})
this.totalNumber = res?.numfound
const instanceList = res.result
const jsonAttrList = this.preferenceAttrList.filter((attr) => attr.value_type === '6')
instanceList.forEach((item) => {
jsonAttrList.forEach(
(jsonAttr) => (item[jsonAttr.name] = item[jsonAttr.name] ? JSON.stringify(item[jsonAttr.name]) : '')
)
})
this.getColumns(instanceList)
this.instanceList = instanceList
} finally {
this.loading = false
}
},
getColumns(data) {
const width = this.$refs.wrapRef.clientWidth - 50
const columns = getCITableColumns(data, this.preferenceAttrList, width)
columns.forEach((item) => {
if (item.editRender) {
item.editRender.enabled = false
}
})
this.columns = columns
},
copyExpression() {
const expression = this.$refs['search'].expression || ''
const fuzzySearch = this.$refs['search'].fuzzySearch
const regQ = /(?<=q=).+(?=&)|(?<=q=).+$/g
const exp = expression.match(regQ) ? expression.match(regQ)[0] : null
const text = `q=_type:${this.subnetCITypeId}${exp ? `,${exp}` : ''}${fuzzySearch ? `,*${fuzzySearch}*` : ''}`
this.$copyText(text)
.then(() => {
this.$message.success(this.$t('copySuccess'))
})
},
handleSearch() {
this.$refs.xTable.getVxetableRef().clearSort()
this.$nextTick(() => {
this.page = 1
this.getTableData()
})
},
handleChangePage(page) {
this.page = page
this.getTableData()
},
onShowSizeChange(_, pageSize) {
this.page = 1
this.pageSize = pageSize
this.getTableData()
},
handleSortCol({ property, order }) {
let sortByTable
if (order === 'asc') {
sortByTable = property
} else if (order === 'desc') {
sortByTable = `-${property}`
}
this.sortByTable = sortByTable
this.$nextTick(() => {
this.page = 1
this.getTableData()
})
},
handleExport() {
this.$refs.batchDownload.open({
preferenceAttrList: this.preferenceAttrList,
ciTypeName: this.$t('cmdb.ipam.subnetList') || '',
})
},
batchDownload({ checkedKeys, filename }) {
const wb = new ExcelJS.Workbook()
const tableRef = this.$refs.xTable.getVxetableRef()
let tableData = _.cloneDeep([
...tableRef.getCheckboxReserveRecords(),
...tableRef.getCheckboxRecords(true),
])
if (!tableData.length) {
const { fullData } = tableRef.getTableData()
tableData = _.cloneDeep(fullData)
}
const ws = wb.addWorksheet(this.tabActive)
const columns = []
const attrMap = new Map()
this.columns.filter((col) => checkedKeys.includes(col.field)).map((col) => {
attrMap.set(col.field, col)
columns.push({
header: col.title || '',
key: col.field,
width: 20,
})
})
ws.columns = columns
tableData.forEach((item) => {
const row = {}
columns.forEach(({ key }) => {
const value = item?.[key] ?? null
const attr = attrMap.get(key)
if (attr.valueType === '6') {
row[key] = value ? JSON.stringify(value) : value
} else if (attr.is_list && Array.isArray(value)) {
row[key] = value.join(',')
} else {
row[key] = value
}
})
ws.addRow(row)
})
wb.xlsx.writeBuffer().then((buffer) => {
const file = new Blob([buffer], {
type: 'application/octet-stream',
})
FileSaver.saveAs(file, `${filename}.xlsx`)
})
this.$refs.xTable.getVxetableRef().clearCheckboxRow()
this.$refs.xTable.getVxetableRef().clearCheckboxReserve()
},
openDetail(id, activeTabKey, ciDetailRelationKey) {
this.$refs.detail.create(id, activeTabKey, ciDetailRelationKey)
},
async refreshAfterEditAttrs() {
await this.getPreferenceAttrList()
this.getTableData()
},
deleteCI(record) {
this.$confirm({
title: this.$t('warning'),
content: this.$t('confirmDelete'),
onOk: () => {
deleteCI(record.ci_id || record._id).then(() => {
this.$message.success(this.$t('deleteSuccess'))
this.getTableData()
this.$emit('delete')
})
},
})
},
}
}
</script>
<style lang="less" scoped>
.table-header {
display: flex;
align-items: baseline;
width: 100%;
justify-content: space-between;
&-right {
display: flex;
align-items: center;
column-gap: 12px;
}
}
.table-pagination {
text-align: right;
margin-top: 4px;
}
</style>

View File

@ -233,6 +233,7 @@ import CollapseTransition from '@/components/CollapseTransition'
import SubscribeSetting from '../../components/subscribeSetting/subscribeSetting'
import { getCIAdcStatistics } from '../../api/ci'
import { ops_move_icon as OpsMoveIcon } from '@/core/icons'
import { SUB_NET_CITYPE_NAME, SCOPE_CITYPE_NAME, ADDRESS_CITYPE_NAME } from '../ipam/constants.js'
export default {
name: 'Preference',
@ -282,8 +283,16 @@ export default {
getPreference2(true, true),
getCIAdcStatistics(),
])
const IPAM_CI = [
SUB_NET_CITYPE_NAME,
SCOPE_CITYPE_NAME,
ADDRESS_CITYPE_NAME
]
ciTypeGroup.forEach((group) => {
if (group.ci_types && group.ci_types.length) {
group.ci_types = group.ci_types.filter((type) => !IPAM_CI.includes(type.name))
group.ci_types.forEach((type) => {
const idx = pref.type_ids.findIndex((p) => p === type.id)
if (idx > -1) {
@ -304,10 +313,17 @@ export default {
const { self, type_id2users } = pref2
this.self = self
this.type_id2users = type_id2users
const prefGroupTypes = pref.group_types.filter((group) => {
group.ci_types = group?.ci_types?.filter((type) => !IPAM_CI.includes(type?.name)) || []
return group?.ci_types?.length
})
const prefTreeTypes = pref?.tree_types?.filter((type) => !IPAM_CI.includes(type?.name)) || []
const _myPreferences = [
{
name: this.$t('cmdb.menu.ciTable'),
groups: pref.group_types,
groups: prefGroupTypes,
icon: 'cmdb-ci',
type: 'ci',
},
@ -315,7 +331,7 @@ export default {
name: this.$t('cmdb.menu.ciTree'),
groups: [
{
ci_types: pref.tree_types,
ci_types: prefTreeTypes,
name: null,
}
],
@ -382,11 +398,13 @@ export default {
})
},
resetRoute() {
resetRouter()
const roles = store.getters.roles
store.dispatch('GenerateRoutes', { roles }, { root: true }).then(() => {
router.addRoutes(store.getters.appRoutes)
this.getCITypes()
resetRouter()
this.$nextTick(() => {
router.addRoutes(store.getters.appRoutes)
this.getCITypes()
})
})
},

View File

@ -984,7 +984,28 @@ export default {
this.batchTreeKey = []
} else {
const childTypeId = menuKey
this.$refs.addTableModal.openModal(firstCIObj, firstCIId, childTypeId, 'children', ancestor_ids)
let typeName = ''
if (this?.relationViews?.id2type?.[childTypeId]) {
typeName = this.relationViews.id2type[childTypeId]?.name || ''
} else {
const node2show_types = this?.relationViews?.views?.[this.viewName]?.node2show_types
const typeId = _tempTree?.[1]
if (node2show_types?.[typeId]?.length) {
const findType = node2show_types[typeId].find((item) => item.id === childTypeId)
typeName = findType?.name || ''
}
}
this.$refs.addTableModal.openModal(
firstCIObj,
firstCIId,
{
id: childTypeId,
name: typeName
},
'children',
ancestor_ids
)
}
}
},

View File

@ -18,6 +18,7 @@
@refresh="handleSearch"
>
<a-button
v-if="showCreateBtn"
@click="
() => {
$refs.createInstanceForm.handleOpen(true, 'create')
@ -116,6 +117,7 @@ import { getCITableColumns } from '../../../utils/helper'
import SearchForm from '../../../components/searchForm/SearchForm.vue'
import CreateInstanceForm from '../../ci/modules/CreateInstanceForm.vue'
import { getCITypeAttributesById } from '@/modules/cmdb/api/CITypeAttr'
import { SUB_NET_CITYPE_NAME, SCOPE_CITYPE_NAME, ADDRESS_CITYPE_NAME } from '@/modules/cmdb/views/ipam/constants.js'
export default {
name: 'AddTableModal',
@ -137,6 +139,7 @@ export default {
preferenceAttrList: [],
ancestor_ids: undefined,
attrList1: [],
showCreateBtn: true, // 是否展示新增按钮
}
},
computed: {
@ -159,18 +162,20 @@ export default {
},
watch: {},
methods: {
async openModal(ciObj, ciId, addTypeId, type, ancestor_ids = undefined) {
console.log(ciObj, ciId, addTypeId, type)
async openModal(ciObj, ciId, addType, type, ancestor_ids = undefined) {
console.log(ciObj, ciId, addType, type)
this.visible = true
this.ciObj = ciObj
this.ciId = ciId
this.addTypeId = addTypeId
this.addTypeId = addType.id
this.type = type
this.ancestor_ids = ancestor_ids
await getSubscribeAttributes(addTypeId).then((res) => {
this.showCreateBtn = ![SUB_NET_CITYPE_NAME, SCOPE_CITYPE_NAME, ADDRESS_CITYPE_NAME].includes(addType.name)
await getSubscribeAttributes(this.addTypeId).then((res) => {
this.preferenceAttrList = res.attributes // 已经订阅的全部列
})
getCITypeAttributesById(addTypeId).then((res) => {
getCITypeAttributesById(this.addTypeId).then((res) => {
this.attrList = res.attributes
})
this.getTableData(true)
@ -232,6 +237,7 @@ export default {
this.expression = ''
this.isFocusExpression = false
this.visible = false
this.showCreateBtn = true
},
async handleOk() {
const selectRecordsCurrent = this.$refs.xTable.getCheckboxRecords()

View File

@ -38,6 +38,9 @@
:column-config="{ resizable: true }"
:resizable-config="{ minWidth: 60 }"
class="checkbox-hover-table"
@checkbox-change="onSelectChange"
@checkbox-all="onSelectChange"
@checkbox-range-end="onSelectChange"
>
<vxe-table-column
v-if="tableData.ciList && tableData.ciList.length"
@ -137,7 +140,6 @@
<script>
import _ from 'lodash'
import moment from 'moment'
import { mapState } from 'vuex'
import ExcelJS from 'exceljs'
import FileSaver from 'file-saver'
@ -266,8 +268,7 @@ export default {
ciTypeName: this.tabActive || '',
})
},
batchDownload({ checkedKeys }) {
const excel_name = `cmdb-${this.tabActive}-${moment().format('YYYYMMDDHHmmss')}.xlsx`
batchDownload({ checkedKeys, filename }) {
const wb = new ExcelJS.Workbook()
const tableRef = this.$refs.xTable.getVxetableRef()
@ -341,12 +342,16 @@ export default {
const file = new Blob([buffer], {
type: 'application/octet-stream',
})
FileSaver.saveAs(file, excel_name)
FileSaver.saveAs(file, `${filename}.xlsx`)
})
this.$refs.xTable.getVxetableRef().clearCheckboxRow()
this.$refs.xTable.getVxetableRef().clearCheckboxReserve()
}
},
onSelectChange() {
console.log('onSelectChange')
},
}
}
</script>