mirror of
https://github.com/apache/skywalking-booster-ui.git
synced 2025-07-18 20:45:24 +00:00
draw anchor
This commit is contained in:
parent
57e701ee4e
commit
6a6cc7a29b
@ -96,7 +96,6 @@ export const networkProfilingStore = defineStore({
|
|||||||
delete d.targetObj;
|
delete d.targetObj;
|
||||||
return d;
|
return d;
|
||||||
});
|
});
|
||||||
console.log(calls);
|
|
||||||
this.calls = calls;
|
this.calls = calls;
|
||||||
this.nodes = data.nodes;
|
this.nodes = data.nodes;
|
||||||
},
|
},
|
||||||
|
@ -20,12 +20,12 @@ export const linkElement = (graph: any) => {
|
|||||||
.append("path")
|
.append("path")
|
||||||
.attr("class", "topo-call")
|
.attr("class", "topo-call")
|
||||||
.attr("marker-end", "url(#arrow)")
|
.attr("marker-end", "url(#arrow)")
|
||||||
.attr("stroke", "#afc4dd")
|
.attr("stroke", "#bbb")
|
||||||
.attr("d", (d: any) => {
|
.attr("d", (d: any) => {
|
||||||
const controlPos = computeControlPoint(
|
const controlPos = computeControlPoint(
|
||||||
[d.source.x, d.source.y - 5],
|
[d.source.x, d.source.y - 5],
|
||||||
[d.target.x, d.target.y - 5],
|
[d.target.x, d.target.y - 5],
|
||||||
1
|
0.5
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
"M" +
|
"M" +
|
||||||
@ -50,7 +50,21 @@ export const anchorElement = (graph: any, funcs: any, tip: any) => {
|
|||||||
.append("circle")
|
.append("circle")
|
||||||
.attr("class", "topo-line-anchor")
|
.attr("class", "topo-line-anchor")
|
||||||
.attr("r", 5)
|
.attr("r", 5)
|
||||||
.attr("fill", "#217EF25f")
|
.attr("fill", "#ccc")
|
||||||
|
.attr("transform", (d: any) => {
|
||||||
|
const controlPos = computeControlPoint(
|
||||||
|
[d.source.x, d.source.y - 5],
|
||||||
|
[d.target.x, d.target.y - 5],
|
||||||
|
0.5
|
||||||
|
);
|
||||||
|
const p = quadraticBezier(
|
||||||
|
0.5,
|
||||||
|
{ x: d.source.x, y: d.source.y - 5 },
|
||||||
|
{ x: controlPos[0], y: controlPos[1] },
|
||||||
|
{ x: d.target.x, y: d.target.y - 5 }
|
||||||
|
);
|
||||||
|
return `translate(${p[0]}, ${p[1]})`;
|
||||||
|
})
|
||||||
.on("mouseover", function (event: unknown, d: unknown) {
|
.on("mouseover", function (event: unknown, d: unknown) {
|
||||||
tip.html(funcs.tipHtml).show(d, this);
|
tip.html(funcs.tipHtml).show(d, this);
|
||||||
})
|
})
|
||||||
@ -69,17 +83,18 @@ export const arrowMarker = (graph: any) => {
|
|||||||
.attr("id", "arrow")
|
.attr("id", "arrow")
|
||||||
.attr("class", "topo-line-arrow")
|
.attr("class", "topo-line-arrow")
|
||||||
.attr("markerUnits", "strokeWidth")
|
.attr("markerUnits", "strokeWidth")
|
||||||
.attr("markerWidth", "6")
|
.attr("markerWidth", "8")
|
||||||
.attr("markerHeight", "6")
|
.attr("markerHeight", "8")
|
||||||
.attr("viewBox", "0 0 12 12")
|
.attr("viewBox", "0 0 12 12")
|
||||||
.attr("refX", "10")
|
.attr("refX", "10")
|
||||||
.attr("refY", "6")
|
.attr("refY", "6")
|
||||||
.attr("orient", "auto");
|
.attr("orient", "auto");
|
||||||
const arrowPath = "M2,2 L10,6 L2,10 L6,6 L2,2";
|
const arrowPath = "M2,2 L10,6 L2,10 L6,6 L2,2";
|
||||||
|
|
||||||
arrow.append("path").attr("d", arrowPath).attr("fill", "#afc4dd");
|
arrow.append("path").attr("d", arrowPath).attr("fill", "#bbb");
|
||||||
return arrow;
|
return arrow;
|
||||||
};
|
};
|
||||||
|
// Control Point coordinates of quadratic Bezier curve
|
||||||
function computeControlPoint(ps: number[], pe: number[], arc = 0.5) {
|
function computeControlPoint(ps: number[], pe: number[], arc = 0.5) {
|
||||||
const deltaX = pe[0] - ps[0];
|
const deltaX = pe[0] - ps[0];
|
||||||
const deltaY = pe[1] - ps[1];
|
const deltaY = pe[1] - ps[1];
|
||||||
@ -91,3 +106,14 @@ function computeControlPoint(ps: number[], pe: number[], arc = 0.5) {
|
|||||||
(ps[1] + pe[1]) / 2 + len * Math.sin(newTheta),
|
(ps[1] + pe[1]) / 2 + len * Math.sin(newTheta),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
// Point coordinates of quadratic Bezier curve
|
||||||
|
function quadraticBezier(
|
||||||
|
t: number,
|
||||||
|
ps: { x: number; y: number },
|
||||||
|
pc: { x: number; y: number },
|
||||||
|
pe: { x: number; y: number }
|
||||||
|
) {
|
||||||
|
const x = (1 - t) * (1 - t) * ps.x + 2 * t * (1 - t) * pc.x + t * t * pe.x;
|
||||||
|
const y = (1 - t) * (1 - t) * ps.y + 2 * t * (1 - t) * pc.y + t * t * pe.y;
|
||||||
|
return [x, y];
|
||||||
|
}
|
||||||
|
@ -87,7 +87,7 @@ async function init() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
drawGraph();
|
drawGraph();
|
||||||
update();
|
drawTopology();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawGraph() {
|
function drawGraph() {
|
||||||
@ -149,7 +149,7 @@ function createPolygon(radius: number, sides = 6, offset = 0) {
|
|||||||
return poly;
|
return poly;
|
||||||
}
|
}
|
||||||
|
|
||||||
function update() {
|
function drawTopology() {
|
||||||
if (!node.value || !link.value) {
|
if (!node.value || !link.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -254,30 +254,23 @@ function update() {
|
|||||||
link.value = link.value.data(calls, (d: Call) => d.id);
|
link.value = link.value.data(calls, (d: Call) => d.id);
|
||||||
link.value.exit().remove();
|
link.value.exit().remove();
|
||||||
link.value = linkElement(link.value.enter()).merge(link.value);
|
link.value = linkElement(link.value.enter()).merge(link.value);
|
||||||
// anchorElement
|
anchor.value = anchor.value.data(calls, (d: Call) => d.id);
|
||||||
// anchor.value = anchor.value.data(
|
anchor.value.exit().remove();
|
||||||
// networkProfilingStore.calls,
|
anchor.value = anchorElement(
|
||||||
// (d: Call) => d.id
|
anchor.value.enter(),
|
||||||
// );
|
{
|
||||||
// anchor.value.exit().remove();
|
handleLinkClick: handleLinkClick,
|
||||||
// anchor.value = anchorElement(
|
tipHtml: (data: Call) => {
|
||||||
// anchor.value.enter(),
|
const html = `<div><span class="grey">${t(
|
||||||
// {
|
"detectPoint"
|
||||||
// handleLinkClick: handleLinkClick,
|
)}:</span>${data.detectPoints.join(" | ")}</div>`;
|
||||||
// tipHtml: (data: Call) => {
|
return html;
|
||||||
// const html = `<div><span class="grey">${t(
|
},
|
||||||
// "detectPoint"
|
},
|
||||||
// )}:</span>${data.detectPoints.join(" | ")}</div>`;
|
tip.value
|
||||||
// return html;
|
).merge(anchor.value);
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// tip.value
|
|
||||||
// ).merge(anchor.value);
|
|
||||||
// arrow marker
|
// arrow marker
|
||||||
arrow.value = arrow.value.data(
|
arrow.value = arrow.value.data(calls, (d: Call) => d.id);
|
||||||
networkProfilingStore.calls,
|
|
||||||
(d: Call) => d.id
|
|
||||||
);
|
|
||||||
arrow.value.exit().remove();
|
arrow.value.exit().remove();
|
||||||
arrow.value = arrowMarker(arrow.value.enter()).merge(arrow.value);
|
arrow.value = arrowMarker(arrow.value.enter()).merge(arrow.value);
|
||||||
}
|
}
|
||||||
@ -351,7 +344,7 @@ async function freshNodes() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
drawGraph();
|
drawGraph();
|
||||||
update();
|
drawTopology();
|
||||||
}
|
}
|
||||||
watch(
|
watch(
|
||||||
() => networkProfilingStore.nodes,
|
() => networkProfilingStore.nodes,
|
||||||
|
@ -1,343 +0,0 @@
|
|||||||
<!-- Licensed to the Apache Software Foundation (ASF) under one or more
|
|
||||||
contributor license agreements. See the NOTICE file distributed with
|
|
||||||
this work for additional information regarding copyright ownership.
|
|
||||||
The ASF licenses this file to You under the Apache License, Version 2.0
|
|
||||||
(the "License"); you may not use this file except in compliance with
|
|
||||||
the License. You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License. -->
|
|
||||||
<template>
|
|
||||||
<div ref="chart" class="micro-topo-chart"></div>
|
|
||||||
<div
|
|
||||||
class="switch-icon ml-5"
|
|
||||||
title="Settings"
|
|
||||||
@click="setConfig"
|
|
||||||
v-if="dashboardStore.editMode"
|
|
||||||
>
|
|
||||||
<Icon size="middle" iconName="settings" />
|
|
||||||
</div>
|
|
||||||
<div class="setting" v-if="showSettings && dashboardStore.editMode">
|
|
||||||
<Settings @update="updateSettings" @updateNodes="freshNodes" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import type { PropType } from "vue";
|
|
||||||
import { ref, onMounted, watch } from "vue";
|
|
||||||
import * as d3 from "d3";
|
|
||||||
import { useI18n } from "vue-i18n";
|
|
||||||
import { ElMessage } from "element-plus";
|
|
||||||
import router from "@/router";
|
|
||||||
import { useNetworkProfilingStore } from "@/store/modules/network-profiling";
|
|
||||||
import { useDashboardStore } from "@/store/modules/dashboard";
|
|
||||||
import d3tip from "d3-tip";
|
|
||||||
import {
|
|
||||||
simulationInit,
|
|
||||||
simulationSkip,
|
|
||||||
} from "../../components/D3Graph/simulation";
|
|
||||||
import {
|
|
||||||
linkElement,
|
|
||||||
anchorElement,
|
|
||||||
arrowMarker,
|
|
||||||
} from "../../components/D3Graph/linkElement";
|
|
||||||
import nodeElement from "../../components/D3Graph/nodeElement";
|
|
||||||
import { Call } from "@/types/topology";
|
|
||||||
// import zoom from "../../components/D3Graph/zoom";
|
|
||||||
import { ProcessNode } from "@/types/ebpf";
|
|
||||||
import { useThrottleFn } from "@vueuse/core";
|
|
||||||
import Settings from "./Settings.vue";
|
|
||||||
import { EntityType } from "@/views/dashboard/data";
|
|
||||||
import getDashboard from "@/hooks/useDashboardsSession";
|
|
||||||
|
|
||||||
/*global Nullable, defineProps */
|
|
||||||
const props = defineProps({
|
|
||||||
config: {
|
|
||||||
type: Object as PropType<any>,
|
|
||||||
default: () => ({ graph: {} }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { t } = useI18n();
|
|
||||||
const dashboardStore = useDashboardStore();
|
|
||||||
const networkProfilingStore = useNetworkProfilingStore();
|
|
||||||
const height = ref<number>(100);
|
|
||||||
const width = ref<number>(100);
|
|
||||||
const simulation = ref<any>(null);
|
|
||||||
const svg = ref<Nullable<any>>(null);
|
|
||||||
const chart = ref<Nullable<HTMLDivElement>>(null);
|
|
||||||
const tip = ref<Nullable<HTMLDivElement>>(null);
|
|
||||||
const graph = ref<any>(null);
|
|
||||||
const node = ref<any>(null);
|
|
||||||
const link = ref<any>(null);
|
|
||||||
const anchor = ref<any>(null);
|
|
||||||
const arrow = ref<any>(null);
|
|
||||||
const oldVal = ref<{ width: number; height: number }>({ width: 0, height: 0 });
|
|
||||||
const showSettings = ref<boolean>(false);
|
|
||||||
const config = ref<any>({});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
init();
|
|
||||||
oldVal.value = (chart.value && chart.value.getBoundingClientRect()) || {
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
svg.value = d3.select(chart.value).append("svg").attr("class", "process-svg");
|
|
||||||
if (!networkProfilingStore.nodes.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
drawGraph();
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawGraph() {
|
|
||||||
const dom = chart.value?.getBoundingClientRect() || {
|
|
||||||
height: 20,
|
|
||||||
width: 0,
|
|
||||||
};
|
|
||||||
height.value = dom.height - 20;
|
|
||||||
width.value = dom.width;
|
|
||||||
svg.value.attr("height", height.value).attr("width", width.value);
|
|
||||||
tip.value = (d3tip as any)().attr("class", "d3-tip").offset([-8, 0]);
|
|
||||||
graph.value = svg.value
|
|
||||||
.append("g")
|
|
||||||
.attr("class", "svg-graph")
|
|
||||||
.attr("transform", `translate(-250, -220)`);
|
|
||||||
graph.value.call(tip.value);
|
|
||||||
simulation.value = simulationInit(
|
|
||||||
d3,
|
|
||||||
networkProfilingStore.nodes,
|
|
||||||
networkProfilingStore.calls,
|
|
||||||
ticked
|
|
||||||
);
|
|
||||||
node.value = graph.value.append("g").selectAll(".topo-node");
|
|
||||||
link.value = graph.value.append("g").selectAll(".topo-line");
|
|
||||||
anchor.value = graph.value.append("g").selectAll(".topo-line-anchor");
|
|
||||||
arrow.value = graph.value.append("g").selectAll(".topo-line-arrow");
|
|
||||||
// svg.value.call(zoom(d3, graph.value));
|
|
||||||
svg.value.on("click", (event: any) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
event.preventDefault();
|
|
||||||
networkProfilingStore.setNode(null);
|
|
||||||
networkProfilingStore.setLink(null);
|
|
||||||
dashboardStore.selectWidget(props.config);
|
|
||||||
});
|
|
||||||
useThrottleFn(resize, 500)();
|
|
||||||
}
|
|
||||||
|
|
||||||
function update() {
|
|
||||||
// node element
|
|
||||||
if (!node.value || !link.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
node.value = node.value.data(
|
|
||||||
networkProfilingStore.nodes,
|
|
||||||
(d: ProcessNode) => d.id
|
|
||||||
);
|
|
||||||
node.value.exit().remove();
|
|
||||||
node.value = nodeElement(
|
|
||||||
d3,
|
|
||||||
node.value.enter(),
|
|
||||||
{
|
|
||||||
tipHtml: (data: ProcessNode) => {
|
|
||||||
return ` <div class="mb-5"><span class="grey">name: </span>${data.name}</div>`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tip.value
|
|
||||||
).merge(node.value);
|
|
||||||
// line element
|
|
||||||
link.value = link.value.data(networkProfilingStore.calls, (d: Call) => d.id);
|
|
||||||
link.value.exit().remove();
|
|
||||||
link.value = linkElement(link.value.enter()).merge(link.value);
|
|
||||||
// anchorElement
|
|
||||||
anchor.value = anchor.value.data(
|
|
||||||
networkProfilingStore.calls,
|
|
||||||
(d: Call) => d.id
|
|
||||||
);
|
|
||||||
anchor.value.exit().remove();
|
|
||||||
anchor.value = anchorElement(
|
|
||||||
anchor.value.enter(),
|
|
||||||
{
|
|
||||||
handleLinkClick: handleLinkClick,
|
|
||||||
tipHtml: (data: Call) => {
|
|
||||||
const html = `<div><span class="grey">${t(
|
|
||||||
"detectPoint"
|
|
||||||
)}:</span>${data.detectPoints.join(" | ")}</div>`;
|
|
||||||
return html;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tip.value
|
|
||||||
).merge(anchor.value);
|
|
||||||
// arrow marker
|
|
||||||
arrow.value = arrow.value.data(
|
|
||||||
networkProfilingStore.calls,
|
|
||||||
(d: Call) => d.id
|
|
||||||
);
|
|
||||||
arrow.value.exit().remove();
|
|
||||||
arrow.value = arrowMarker(arrow.value.enter()).merge(arrow.value);
|
|
||||||
// force element
|
|
||||||
simulation.value.nodes(networkProfilingStore.nodes);
|
|
||||||
simulation.value
|
|
||||||
.force("link")
|
|
||||||
.links(networkProfilingStore.calls)
|
|
||||||
.id((d: Call) => d.id);
|
|
||||||
const loopMap: any = {};
|
|
||||||
for (let i = 0; i < networkProfilingStore.calls.length; i++) {
|
|
||||||
const link: any = networkProfilingStore.calls[i];
|
|
||||||
link.loopFactor = 1;
|
|
||||||
for (let j = 0; j < networkProfilingStore.calls.length; j++) {
|
|
||||||
if (i === j || loopMap[i]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const otherLink = networkProfilingStore.calls[j];
|
|
||||||
if (
|
|
||||||
link.source.id === otherLink.target.id &&
|
|
||||||
link.target.id === otherLink.source.id
|
|
||||||
) {
|
|
||||||
link.loopFactor = -1;
|
|
||||||
loopMap[j] = 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLinkClick(event: any, d: Call) {
|
|
||||||
if (
|
|
||||||
d.source.layer !== dashboardStore.layerId ||
|
|
||||||
d.target.layer !== dashboardStore.layerId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.stopPropagation();
|
|
||||||
networkProfilingStore.setNode(null);
|
|
||||||
networkProfilingStore.setLink(d);
|
|
||||||
if (!config.value.linkDashboard) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { dashboard } = getDashboard({
|
|
||||||
name: config.value.linkDashboard,
|
|
||||||
layer: dashboardStore.layerId,
|
|
||||||
entity: EntityType[7].value,
|
|
||||||
});
|
|
||||||
if (!dashboard) {
|
|
||||||
ElMessage.error(
|
|
||||||
`The dashboard named ${config.value.linkDashboard} doesn't exist`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const path = `/dashboard/related/${dashboard.layer}/${EntityType[7].value}/${d.source.id}/${d.target.id}/${dashboard.name}`;
|
|
||||||
const routeUrl = router.resolve({ path });
|
|
||||||
window.open(routeUrl.href, "_blank");
|
|
||||||
}
|
|
||||||
|
|
||||||
function ticked() {
|
|
||||||
link.value.attr(
|
|
||||||
"d",
|
|
||||||
(d: Call | any) =>
|
|
||||||
`M${d.source.x} ${d.source.y} Q ${(d.source.x + d.target.x) / 2} ${
|
|
||||||
(d.target.y + d.source.y) / 2 - d.loopFactor * 90
|
|
||||||
} ${d.target.x} ${d.target.y}`
|
|
||||||
);
|
|
||||||
anchor.value.attr(
|
|
||||||
"transform",
|
|
||||||
(d: Call | any) =>
|
|
||||||
`translate(${(d.source.x + d.target.x) / 2}, ${
|
|
||||||
(d.target.y + d.source.y) / 2 - d.loopFactor * 45
|
|
||||||
})`
|
|
||||||
);
|
|
||||||
node.value.attr(
|
|
||||||
"transform",
|
|
||||||
(d: Node | any) => `translate(${d.x - 22},${d.y - 22})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSettings(config: any) {
|
|
||||||
config.value = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setConfig() {
|
|
||||||
showSettings.value = !showSettings.value;
|
|
||||||
dashboardStore.selectWidget(props.config);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resize() {
|
|
||||||
const observer = new ResizeObserver((entries) => {
|
|
||||||
const entry = entries[0];
|
|
||||||
const cr = entry.contentRect;
|
|
||||||
if (
|
|
||||||
Math.abs(cr.width - oldVal.value.width) < 5 &&
|
|
||||||
Math.abs(cr.height - oldVal.value.height) < 5
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
freshNodes();
|
|
||||||
oldVal.value = { width: cr.width, height: cr.height };
|
|
||||||
});
|
|
||||||
if (chart.value) {
|
|
||||||
observer.observe(chart.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function freshNodes() {
|
|
||||||
svg.value.selectAll(".svg-graph").remove();
|
|
||||||
if (!networkProfilingStore.nodes.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
drawGraph();
|
|
||||||
update();
|
|
||||||
}
|
|
||||||
watch(
|
|
||||||
() => networkProfilingStore.nodes,
|
|
||||||
() => {
|
|
||||||
freshNodes();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.micro-topo-chart {
|
|
||||||
width: calc(100% - 10px);
|
|
||||||
margin: 0 5px 5px 0;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.process-svg {
|
|
||||||
width: 100%;
|
|
||||||
height: calc(100% - 10px);
|
|
||||||
cursor: move;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch-icon {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.5ms linear;
|
|
||||||
background-color: #252a2f99;
|
|
||||||
color: #ddd;
|
|
||||||
display: inline-block;
|
|
||||||
padding: 5px 8px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting {
|
|
||||||
position: absolute;
|
|
||||||
top: 65px;
|
|
||||||
right: 10px;
|
|
||||||
width: 300px;
|
|
||||||
height: 160px;
|
|
||||||
background-color: #2b3037;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 3px;
|
|
||||||
color: #ccc;
|
|
||||||
transition: all 0.5ms linear;
|
|
||||||
}
|
|
||||||
</style>
|
|
Loading…
Reference in New Issue
Block a user