diff --git a/sop-admin/sop-admin-front/assets/js/lib.js b/sop-admin/sop-admin-front/assets/js/lib.js index 3dc971ca..c16fd746 100644 --- a/sop-admin/sop-admin-front/assets/js/lib.js +++ b/sop-admin/sop-admin-front/assets/js/lib.js @@ -13,12 +13,12 @@ var lib = (function () { '../../assets/lib/jquery/3.2.1/jquery.min.js' ,'../../assets/lib/layui/layui.js' ,'../../assets/lib/easyopen/sdk.js' + ,'../../assets/lib/layuiext/Form.js' ,'../../assets/js/ApiUtil.js' - ,'../../assets/js/Form.js' - ,'../../assets/js/profile.js' ] var jsArr = []; + var layuiConfig, layuiExtend; /** * 加载js @@ -26,7 +26,7 @@ var lib = (function () { * @param require layui依赖模块 * @param loadSuccess 加载成功后触发 */ - function layuiInit(jsArr, require, loadSuccess) { + function loadJs(jsArr, loadSuccess) { // 接着加载自定义的js jsArr = addVersion(jsArr); @@ -36,7 +36,7 @@ var lib = (function () { .script(jsArr) // 接着加载自定义的js // 全部加载成后执行 .wait(function () { - layui.use(require, loadSuccess); + loadSuccess && loadSuccess(); }); } @@ -62,16 +62,37 @@ var lib = (function () { * @returns {lib} */ importJs: function (jsPath) { - jsArr = jsPath || []; + if (jsPath) { + if (typeof jsPath === 'string') { + jsPath = [jsPath]; + } + jsArr = jsArr.concat(jsPath); + } + return this; + } + , config: function (cfg) { + layuiConfig = cfg; + return this; + } + , extend: function (ext) { + layuiExtend = ext; return this; } /** * layui初始化,同layui.use() * @param require - * @param loadSuccess + * @param layuiInitFun */ - , use: function (require, loadSuccess) { - layuiInit(jsArr, require, loadSuccess); + , use: function (require, layuiInitFun) { + loadJs(jsArr, function () { + if (layuiConfig) { + layui.config(layuiConfig); + } + if (layuiExtend) { + layui.extend(layuiExtend); + } + layui.use(require, layuiInitFun); + }); } } })(); diff --git a/sop-admin/sop-admin-front/assets/js/Form.js b/sop-admin/sop-admin-front/assets/lib/layuiext/Form.js similarity index 100% rename from sop-admin/sop-admin-front/assets/js/Form.js rename to sop-admin/sop-admin-front/assets/lib/layuiext/Form.js diff --git a/sop-admin/sop-admin-front/assets/lib/layuiext/module/treetable-lay/treetable.css b/sop-admin/sop-admin-front/assets/lib/layuiext/module/treetable-lay/treetable.css new file mode 100644 index 00000000..584b2f8c --- /dev/null +++ b/sop-admin/sop-admin-front/assets/lib/layuiext/module/treetable-lay/treetable.css @@ -0,0 +1,18 @@ +.treeTable-empty { + width: 20px; + display: inline-block; +} + +.treeTable-icon { + cursor: pointer; +} + +.treeTable-icon .layui-icon-triangle-d:before { + content: "\e623"; +} + +.treeTable-icon.open .layui-icon-triangle-d:before { + content: "\e625"; + background-color: transparent; +} + diff --git a/sop-admin/sop-admin-front/assets/lib/layuiext/module/treetable-lay/treetable.js b/sop-admin/sop-admin-front/assets/lib/layuiext/module/treetable-lay/treetable.js new file mode 100644 index 00000000..e38a5e1c --- /dev/null +++ b/sop-admin/sop-admin-front/assets/lib/layuiext/module/treetable-lay/treetable.js @@ -0,0 +1,206 @@ +layui.define(['layer', 'table'], function (exports) { + var $ = layui.jquery; + var layer = layui.layer; + var table = layui.table; + + var treetable = { + // 渲染树形表格 + render: function (param) { + // 检查参数 + if (!treetable.checkParam(param)) { + return; + } + // 获取数据 + if (param.data) { + treetable.init(param, param.data); + } else { + $.getJSON(param.url, param.where, function (res) { + treetable.init(param, res.data); + }); + } + }, + // 渲染表格 + init: function (param, data) { + var mData = []; + var doneCallback = param.done; + var tNodes = data; + // 补上id和pid字段 + for (var i = 0; i < tNodes.length; i++) { + var tt = tNodes[i]; + if (!tt.id) { + if (!param.treeIdName) { + layer.msg('参数treeIdName不能为空', {icon: 5}); + return; + } + tt.id = tt[param.treeIdName]; + } + if (!tt.pid) { + if (!param.treePidName) { + layer.msg('参数treePidName不能为空', {icon: 5}); + return; + } + tt.pid = tt[param.treePidName]; + } + } + + // 对数据进行排序 + var sort = function (s_pid, data) { + for (var i = 0; i < data.length; i++) { + if (data[i].pid == s_pid) { + var len = mData.length; + if (len > 0 && mData[len - 1].id == s_pid) { + mData[len - 1].isParent = true; + } + mData.push(data[i]); + sort(data[i].id, data); + } + } + }; + sort(param.treeSpid, tNodes); + + // 重写参数 + param.url = undefined; + param.data = mData; + param.page = { + count: param.data.length, + limit: param.data.length + }; + param.cols[0][param.treeColIndex].templet = function (d) { + var mId = d.id; + var mPid = d.pid; + var isDir = d.isParent; + var emptyNum = treetable.getEmptyNum(mPid, mData); + var iconHtml = ''; + for (var i = 0; i < emptyNum; i++) { + iconHtml += ''; + } + if (isDir) { + iconHtml += ' '; + } else { + iconHtml += ''; + } + iconHtml += '  '; + var ttype = isDir ? 'dir' : 'file'; + var vg = ''; + return vg + iconHtml + d[param.cols[0][param.treeColIndex].field] + '' + }; + + param.done = function (res, curr, count) { + $(param.elem).next().addClass('treeTable'); + $('.treeTable .layui-table-page').css('display', 'none'); + $(param.elem).next().attr('treeLinkage', param.treeLinkage); + // 绑定事件换成对body绑定 + /*$('.treeTable .treeTable-icon').click(function () { + treetable.toggleRows($(this), param.treeLinkage); + });*/ + if (param.treeDefaultClose) { + treetable.foldAll(param.elem); + } + if (doneCallback) { + doneCallback(res, curr, count); + } + }; + + // 渲染表格 + table.render(param); + }, + // 计算缩进的数量 + getEmptyNum: function (pid, data) { + var num = 0; + if (!pid) { + return num; + } + var tPid; + for (var i = 0; i < data.length; i++) { + if (pid == data[i].id) { + num += 1; + tPid = data[i].pid; + break; + } + } + return num + treetable.getEmptyNum(tPid, data); + }, + // 展开/折叠行 + toggleRows: function ($dom, linkage) { + var type = $dom.attr('lay-ttype'); + if ('file' == type) { + return; + } + var mId = $dom.attr('lay-tid'); + var isOpen = $dom.hasClass('open'); + if (isOpen) { + $dom.removeClass('open'); + } else { + $dom.addClass('open'); + } + $dom.closest('tbody').find('tr').each(function () { + var $ti = $(this).find('.treeTable-icon'); + var pid = $ti.attr('lay-tpid'); + var ttype = $ti.attr('lay-ttype'); + var tOpen = $ti.hasClass('open'); + if (mId == pid) { + if (isOpen) { + $(this).hide(); + if ('dir' == ttype && tOpen == isOpen) { + $ti.trigger('click'); + } + } else { + $(this).show(); + if (linkage && 'dir' == ttype && tOpen == isOpen) { + $ti.trigger('click'); + } + } + } + }); + }, + // 检查参数 + checkParam: function (param) { + if (!param.treeSpid && param.treeSpid != 0) { + layer.msg('参数treeSpid不能为空', {icon: 5}); + return false; + } + + if (!param.treeColIndex && param.treeColIndex != 0) { + layer.msg('参数treeColIndex不能为空', {icon: 5}); + return false; + } + return true; + }, + // 展开所有 + expandAll: function (dom) { + $(dom).next('.treeTable').find('.layui-table-body tbody tr').each(function () { + var $ti = $(this).find('.treeTable-icon'); + var ttype = $ti.attr('lay-ttype'); + var tOpen = $ti.hasClass('open'); + if ('dir' == ttype && !tOpen) { + $ti.trigger('click'); + } + }); + }, + // 折叠所有 + foldAll: function (dom) { + $(dom).next('.treeTable').find('.layui-table-body tbody tr').each(function () { + var $ti = $(this).find('.treeTable-icon'); + var ttype = $ti.attr('lay-ttype'); + var tOpen = $ti.hasClass('open'); + if ('dir' == ttype && tOpen) { + $ti.trigger('click'); + } + }); + } + }; + + layui.link(layui.cache.base + 'treetable-lay/treetable.css'); + + // 给图标列绑定事件 + $('body').on('click', '.treeTable .treeTable-icon', function () { + var treeLinkage = $(this).parents('.treeTable').attr('treeLinkage'); + if ('true' == treeLinkage) { + treetable.toggleRows($(this), true); + } else { + treetable.toggleRows($(this), false); + } + }); + + exports('treetable', treetable); +}); diff --git a/sop-admin/sop-admin-front/pages/service/routeManager.js b/sop-admin/sop-admin-front/pages/service/routeManager.js index 31125c79..353895c3 100644 --- a/sop-admin/sop-admin-front/pages/service/routeManager.js +++ b/sop-admin/sop-admin-front/pages/service/routeManager.js @@ -1,4 +1,4 @@ -lib.use(['element', 'table', 'tree', 'form'], function () { +lib.importJs('../../assets/js/profile.js').use(['element', 'table', 'tree', 'form'], function () { var ROUTE_STATUS = { '0': '待审核' ,'1': '已启用' diff --git a/sop-admin/sop-admin-front/pages/service/serviceList.html b/sop-admin/sop-admin-front/pages/service/serviceList.html index 1dc016c8..fc8ab65a 100644 --- a/sop-admin/sop-admin-front/pages/service/serviceList.html +++ b/sop-admin/sop-admin-front/pages/service/serviceList.html @@ -32,43 +32,17 @@ -
- + \ No newline at end of file diff --git a/sop-admin/sop-admin-front/pages/service/serviceList.js b/sop-admin/sop-admin-front/pages/service/serviceList.js index e69de29b..7dadee53 100644 --- a/sop-admin/sop-admin-front/pages/service/serviceList.js +++ b/sop-admin/sop-admin-front/pages/service/serviceList.js @@ -0,0 +1,53 @@ +lib.config({ + base: '../../assets/lib/layuiext/module/' +}).extend({ + treetable: 'treetable-lay/treetable' +}).use(['element', 'table', 'form', 'treetable'], function () { + var layer = layui.layer; + var form = layui.form; + var treetable = layui.treetable; + var serverTable; + + // 渲染表格 + var renderTable = function () { + layer.load(2); + serverTable = treetable.render({ + treeColIndex: 1, + treeSpid: 0, + treeIdName: 'id', + treePidName: 'parentId', + treeDefaultClose: true, + treeLinkage: false, + elem: '#treeTable', + url: ApiUtil.createUrl('service.instance.list'), + page: false, + cols: [[ + {type: 'numbers'}, + {field: 'name', title: '服务名称', width: 200}, + {field: 'instanceId', title: 'instanceId', width: 220}, + {field: 'ipAddr', title: 'IP地址', width: 150}, + {field: 'serverPort', title: '端口号', width: 100}, + {field: 'status', title: 'status', width: 100}, + {field: 'updateTime', title: '最后更新时间', width: 150}, + {fixed: 'right', templet: '#optCol', title: '操作', width: 200} + ]], + done: function () { + layer.closeAll('loading'); + } + }); + }; + + renderTable(); + + + form.on('submit(searchFilter)', function(data){ + var param = data.field; + serverTable.reload({ + where: { + data: JSON.stringify(param) + } + }) + return false; + }); + +}); \ No newline at end of file diff --git a/sop-admin/sop-admin-server/pom.xml b/sop-admin/sop-admin-server/pom.xml index 4c8566b0..183b75d7 100644 --- a/sop-admin/sop-admin-server/pom.xml +++ b/sop-admin/sop-admin-server/pom.xml @@ -23,6 +23,7 @@ 1.8 3.4.12 4.0.1 + 3.11.0 @@ -65,6 +66,12 @@ + + com.squareup.okhttp3 + okhttp + ${okhttp.version} + + org.springframework.boot spring-boot-starter-web diff --git a/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/api/service/ServiceApi.java b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/api/service/ServiceApi.java index c1f2cfea..9e7a5595 100644 --- a/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/api/service/ServiceApi.java +++ b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/api/service/ServiceApi.java @@ -5,16 +5,30 @@ import com.gitee.easyopen.annotation.Api; import com.gitee.easyopen.annotation.ApiService; import com.gitee.easyopen.doc.annotation.ApiDoc; import com.gitee.easyopen.doc.annotation.ApiDocMethod; +import com.gitee.easyopen.exception.ApiException; import com.gitee.sop.adminserver.api.service.param.ServiceSearchParam; import com.gitee.sop.adminserver.api.service.result.ServiceInfo; +import com.gitee.sop.adminserver.api.service.result.ServiceInfoVo; +import com.gitee.sop.adminserver.bean.EurekaApplication; +import com.gitee.sop.adminserver.bean.EurekaApps; +import com.gitee.sop.adminserver.bean.EurekaInstance; +import com.gitee.sop.adminserver.bean.EurekaUri; import com.gitee.sop.adminserver.bean.ZookeeperContext; import lombok.extern.slf4j.Slf4j; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; import org.apache.commons.lang.StringUtils; import org.apache.curator.framework.recipes.cache.ChildData; +import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.env.Environment; +import javax.annotation.PostConstruct; +import java.io.IOException; +import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; /** @@ -26,12 +40,22 @@ import java.util.stream.Collectors; public class ServiceApi { + OkHttpClient client = new OkHttpClient(); + @Autowired private Environment environment; + private String eurekaUrl; + + // eureka.client.serviceUrl.defaultZone + @Api(name = "service.list") @ApiDocMethod(description = "服务列表", elementClass = ServiceInfo.class) List listServiceInfo(ServiceSearchParam param) throws Exception { + String json = this.requestEurekaServer(EurekaUri.QUERY_APPS); + EurekaApps eurekaApps = JSON.parseObject(json, EurekaApps.class); + + String routeRootPath = ZookeeperContext.getSopRouteRootPath(param.getProfile()); List childDataList = ZookeeperContext.getChildrenData(routeRootPath); List serviceInfoList = childDataList.stream() @@ -53,4 +77,70 @@ public class ServiceApi { return serviceInfoList; } + @Api(name = "service.instance.list") + @ApiDocMethod(description = "服务列表", elementClass = ServiceInfo.class) + List listService(ServiceSearchParam param) throws Exception { + String json = this.requestEurekaServer(EurekaUri.QUERY_APPS); + EurekaApps eurekaApps = JSON.parseObject(json, EurekaApps.class); + + List serviceInfoVoList = new ArrayList<>(); + List applicationList = eurekaApps.getApplications().getApplication(); + AtomicInteger idGen = new AtomicInteger(1); + applicationList.stream() + .forEach(eurekaApplication -> { + int pid = idGen.getAndIncrement(); + String name = eurekaApplication.getName(); + ServiceInfoVo parent = new ServiceInfoVo(); + parent.setId(pid); + parent.setName(name); + parent.setParentId(0); + serviceInfoVoList.add(parent); + List instanceList = eurekaApplication.getInstance(); + for (EurekaInstance instance : instanceList) { + ServiceInfoVo vo = new ServiceInfoVo(); + BeanUtils.copyProperties(instance, vo); + int id = idGen.getAndIncrement(); + vo.setId(id); + vo.setName(name); + vo.setParentId(pid); + vo.setServerPort(instance.fetchPort()); + serviceInfoVoList.add(vo); + } + }); + return serviceInfoVoList; + } + + private String requestEurekaServer(EurekaUri uri) throws IOException { + Request request = this.buildRequest(EurekaUri.QUERY_APPS); + Response response = client.newCall(request).execute(); + if (response.isSuccessful()) { + return response.body().string(); + } else { + return "{}"; + } + } + + private Request buildRequest(EurekaUri uri) { + String apiUrl = this.eurekaUrl + uri.getUri(); + Request request = new Request.Builder() + .url(apiUrl) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .build(); + return request; + } + + @PostConstruct + protected void after() { + String eurekaUrls = environment.getProperty("eureka.client.serviceUrl.defaultZone"); + if (StringUtils.isBlank(eurekaUrls)) { + throw new ApiException("未指定eureka.client.serviceUrl.defaultZone参数"); + } + String url = eurekaUrls.split("\\,")[0]; + if (url.endsWith("/")) { + url = eurekaUrls.substring(0, eurekaUrls.length() - 1); + } + this.eurekaUrl = url; + } + } diff --git a/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/api/service/result/ServiceInfoVo.java b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/api/service/result/ServiceInfoVo.java new file mode 100644 index 00000000..68cf73a6 --- /dev/null +++ b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/api/service/result/ServiceInfoVo.java @@ -0,0 +1,52 @@ +package com.gitee.sop.adminserver.api.service.result; + +import com.alibaba.fastjson.annotation.JSONField; +import com.gitee.easyopen.doc.annotation.ApiDocField; +import lombok.Data; +import org.apache.commons.lang.StringUtils; + +import java.util.Date; + +/** + * @author tanghc + */ +@Data +public class ServiceInfoVo { + @ApiDocField(description = "id") + private Integer id; + + @ApiDocField(description = "服务名称") + private String name; + + @ApiDocField(description = "instanceId") + private String instanceId; + + @ApiDocField(description = "ip") + private String ipAddr; + + @ApiDocField(description = "端口") + private String serverPort; + + @ApiDocField(description = "status") + private String status; + + @ApiDocField(description = "statusPageUrl") + private String statusPageUrl; + + @ApiDocField(description = "healthCheckUrl") + private String healthCheckUrl; + + @ApiDocField(description = "最后更新时间") + private String lastUpdatedTimestamp; + + @ApiDocField(description = "parentId") + private Integer parentId; + + @JSONField(format = "yyyy-MM-dd HH:mm:ss") + public Date getUpdateTime() { + if (StringUtils.isBlank(lastUpdatedTimestamp)) { + return null; + } + return new Date(Long.valueOf(lastUpdatedTimestamp)); + } +} diff --git a/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/bean/EurekaApplication.java b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/bean/EurekaApplication.java new file mode 100644 index 00000000..91d73122 --- /dev/null +++ b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/bean/EurekaApplication.java @@ -0,0 +1,14 @@ +package com.gitee.sop.adminserver.bean; + +import lombok.Data; + +import java.util.List; + +/** + * @author tanghc + */ +@Data +public class EurekaApplication { + private String name; + private List instance; +} diff --git a/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/bean/EurekaApplications.java b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/bean/EurekaApplications.java new file mode 100644 index 00000000..7dc08e21 --- /dev/null +++ b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/bean/EurekaApplications.java @@ -0,0 +1,13 @@ +package com.gitee.sop.adminserver.bean; + +import lombok.Data; + +import java.util.List; + +/** + * @author tanghc + */ +@Data +public class EurekaApplications { + private List application; +} diff --git a/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/bean/EurekaApps.java b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/bean/EurekaApps.java new file mode 100644 index 00000000..f52440d2 --- /dev/null +++ b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/bean/EurekaApps.java @@ -0,0 +1,11 @@ +package com.gitee.sop.adminserver.bean; + +import lombok.Data; + +/** + * @author tanghc + */ +@Data +public class EurekaApps { + private EurekaApplications applications; +} diff --git a/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/bean/EurekaInstance.java b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/bean/EurekaInstance.java new file mode 100644 index 00000000..acc37d9c --- /dev/null +++ b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/bean/EurekaInstance.java @@ -0,0 +1,29 @@ +package com.gitee.sop.adminserver.bean; + +import lombok.Data; +import org.springframework.util.CollectionUtils; + +import java.util.Map; + +/** + * @author tanghc + */ +@Data +public class EurekaInstance { + private String instanceId; + private String ipAddr; + private Map port; + + private String status; + private String statusPageUrl; + private String healthCheckUrl; + private String lastUpdatedTimestamp; + + public String fetchPort() { + if (CollectionUtils.isEmpty(port)) { + return ""; + } + return String.valueOf(port.getOrDefault("$", "")); + } + +} diff --git a/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/bean/EurekaUri.java b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/bean/EurekaUri.java new file mode 100644 index 00000000..8f345a82 --- /dev/null +++ b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/bean/EurekaUri.java @@ -0,0 +1,20 @@ +package com.gitee.sop.adminserver.bean; + +/** + * https://github.com/Netflix/eureka/wiki/Eureka-REST-operations + * @author tanghc + */ +public enum EurekaUri { + + /** Query for all instances */ + QUERY_APPS("/apps"), + ; + String uri; + + EurekaUri(String uri) { + this.uri = uri; + } + + public String getUri() { + return uri; + }} diff --git a/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/config/WebConfig.java b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/config/WebConfig.java index e96f7e79..e473e473 100644 --- a/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/config/WebConfig.java +++ b/sop-admin/sop-admin-server/src/main/java/com/gitee/sop/adminserver/config/WebConfig.java @@ -19,6 +19,7 @@ public class WebConfig extends WebMvcConfigurationSupport { public void addResourceHandlers(ResourceHandlerRegistry registry) { //配置映射关系 registry.addResourceHandler("/conf/**").addResourceLocations("classpath:/META-INF/resources/webjars/sop-admin-front/1.0.0-SNAPSHOT/"); + registry.addResourceHandler("/opendoc/**").addResourceLocations("classpath:/META-INF/resources/opendoc/"); } @Controller diff --git a/sop-admin/sop-admin-server/src/main/resources/application-dev.yml b/sop-admin/sop-admin-server/src/main/resources/application-dev.yml index 69dd6fe9..1bb7c7f4 100644 --- a/sop-admin/sop-admin-server/src/main/resources/application-dev.yml +++ b/sop-admin/sop-admin-server/src/main/resources/application-dev.yml @@ -22,6 +22,13 @@ easyopen: sop-admin: profiles: default,prod,dev,test +# 注册中心地址,这里只是参数,并不会去注册 +eureka: + port: 1111 + host: localhost + client: + serviceUrl: + defaultZone: http://${eureka.host}:${eureka.port}/eureka/ logging: level: