mirror of
https://github.com/WGDashboard/WGDashboard.git
synced 2026-08-03 22:42:59 +00:00
b2a82dcfe5
- Fixed issue in #312: The dashboard will automatically get the actual Dashboard version number. - Fixed issue in #311: WGDashboard was not treating restricted peers correctly.
625 lines
18 KiB
Vue
625 lines
18 KiB
Vue
<script>
|
|
import PeerSearch from "@/components/configurationComponents/peerSearch.vue";
|
|
import {DashboardConfigurationStore} from "@/stores/DashboardConfigurationStore.js";
|
|
import {WireguardConfigurationsStore} from "@/stores/WireguardConfigurationsStore.js";
|
|
import {fetchGet} from "@/utilities/fetch.js";
|
|
import Peer from "@/components/configurationComponents/peer.vue";
|
|
import { Line, Bar } from 'vue-chartjs'
|
|
import Fuse from "fuse.js";
|
|
import {
|
|
Chart,
|
|
ArcElement,
|
|
LineElement,
|
|
BarElement,
|
|
PointElement,
|
|
BarController,
|
|
BubbleController,
|
|
DoughnutController,
|
|
LineController,
|
|
PieController,
|
|
PolarAreaController,
|
|
RadarController,
|
|
ScatterController,
|
|
CategoryScale,
|
|
LinearScale,
|
|
LogarithmicScale,
|
|
RadialLinearScale,
|
|
TimeScale,
|
|
TimeSeriesScale,
|
|
Decimation,
|
|
Filler,
|
|
Legend,
|
|
Title,
|
|
Tooltip
|
|
} from 'chart.js';
|
|
import dayjs from "dayjs";
|
|
import PeerSettings from "@/components/configurationComponents/peerSettings.vue";
|
|
import PeerQRCode from "@/components/configurationComponents/peerQRCode.vue";
|
|
import PeerCreate from "@/components/configurationComponents/peerCreate.vue";
|
|
import PeerJobs from "@/components/configurationComponents/peerJobs.vue";
|
|
import PeerJobsAllModal from "@/components/configurationComponents/peerJobsAllModal.vue";
|
|
import PeerJobsLogsModal from "@/components/configurationComponents/peerJobsLogsModal.vue";
|
|
import {ref} from "vue";
|
|
import PeerShareLinkModal from "@/components/configurationComponents/peerShareLinkModal.vue";
|
|
|
|
Chart.register(
|
|
ArcElement,
|
|
LineElement,
|
|
BarElement,
|
|
PointElement,
|
|
BarController,
|
|
BubbleController,
|
|
DoughnutController,
|
|
LineController,
|
|
PieController,
|
|
PolarAreaController,
|
|
RadarController,
|
|
ScatterController,
|
|
CategoryScale,
|
|
LinearScale,
|
|
LogarithmicScale,
|
|
RadialLinearScale,
|
|
TimeScale,
|
|
TimeSeriesScale,
|
|
Decimation,
|
|
Filler,
|
|
Legend,
|
|
Title,
|
|
Tooltip
|
|
);
|
|
|
|
export default {
|
|
name: "peerList",
|
|
components: {
|
|
PeerShareLinkModal,
|
|
PeerJobsLogsModal,
|
|
PeerJobsAllModal, PeerJobs, PeerCreate, PeerQRCode, PeerSettings, PeerSearch, Peer, Line, Bar},
|
|
setup(){
|
|
const dashboardConfigurationStore = DashboardConfigurationStore();
|
|
const wireguardConfigurationStore = WireguardConfigurationsStore();
|
|
const interval = ref(undefined)
|
|
return {dashboardConfigurationStore, wireguardConfigurationStore, interval}
|
|
},
|
|
data(){
|
|
return {
|
|
configurationToggling: false,
|
|
loading: false,
|
|
error: null,
|
|
configurationInfo: [],
|
|
configurationPeers: [],
|
|
historyDataSentDifference: [],
|
|
historyDataReceivedDifference: [],
|
|
historySentData: {
|
|
labels: [],
|
|
datasets: [
|
|
{
|
|
label: 'Data Sent',
|
|
data: [],
|
|
fill: false,
|
|
borderColor: '#198754',
|
|
tension: 0
|
|
},
|
|
],
|
|
},
|
|
historyReceiveData: {
|
|
labels: [],
|
|
datasets: [
|
|
{
|
|
label: 'Data Received',
|
|
data: [],
|
|
fill: false,
|
|
borderColor: '#0d6efd',
|
|
tension: 0
|
|
},
|
|
],
|
|
},
|
|
peerSetting: {
|
|
modalOpen: false,
|
|
selectedPeer: undefined
|
|
},
|
|
peerScheduleJobs:{
|
|
modalOpen: false,
|
|
selectedPeer: undefined
|
|
},
|
|
peerQRCode: {
|
|
modalOpen: false,
|
|
peerConfigData: undefined
|
|
},
|
|
peerCreate: {
|
|
modalOpen: false
|
|
},
|
|
peerScheduleJobsAll: {
|
|
modalOpen: false
|
|
},
|
|
peerScheduleJobsLogs: {
|
|
modalOpen: false
|
|
},
|
|
peerShare:{
|
|
modalOpen: false,
|
|
selectedPeer: undefined
|
|
}
|
|
}
|
|
},
|
|
mounted() {
|
|
|
|
},
|
|
watch: {
|
|
'$route': {
|
|
immediate: true,
|
|
handler(){
|
|
clearInterval(this.dashboardConfigurationStore.Peers.RefreshInterval);
|
|
this.loading = true;
|
|
let id = this.$route.params.id;
|
|
this.configurationInfo = [];
|
|
this.configurationPeers = [];
|
|
if (id){
|
|
this.getPeers(id)
|
|
this.setPeerInterval();
|
|
}
|
|
}
|
|
},
|
|
'dashboardConfigurationStore.Configuration.Server.dashboard_refresh_interval'(){
|
|
clearInterval(this.dashboardConfigurationStore.Peers.RefreshInterval);
|
|
this.setPeerInterval();
|
|
}
|
|
},
|
|
beforeRouteLeave(){
|
|
clearInterval(this.dashboardConfigurationStore.Peers.RefreshInterval);
|
|
},
|
|
methods:{
|
|
toggle(){
|
|
this.configurationToggling = true;
|
|
fetchGet("/api/toggleWireguardConfiguration/", {
|
|
configurationName: this.configurationInfo.Name
|
|
}, (res) => {
|
|
if (res.status){
|
|
this.dashboardConfigurationStore.newMessage("Server",
|
|
`${this.configurationInfo.Name} is
|
|
${res.data ? 'is on':'is off'}`, "Success")
|
|
}else{
|
|
this.dashboardConfigurationStore.newMessage("Server",
|
|
res.message, 'danger')
|
|
}
|
|
this.configurationInfo.Status = res.data
|
|
this.configurationToggling = false;
|
|
})
|
|
},
|
|
getPeers(id = this.$route.params.id){
|
|
fetchGet("/api/getWireguardConfigurationInfo",
|
|
{
|
|
configurationName: id
|
|
}, (res) => {
|
|
this.configurationInfo = res.data.configurationInfo;
|
|
this.configurationPeers = res.data.configurationPeers;
|
|
this.configurationPeers.forEach(x => {
|
|
x.restricted = false;
|
|
})
|
|
res.data.configurationRestrictedPeers.forEach(x => {
|
|
x.restricted = true;
|
|
this.configurationPeers.push(x)
|
|
})
|
|
this.loading = false;
|
|
if (this.configurationPeers.length > 0){
|
|
const sent = this.configurationPeers.map(x => x.total_sent + x.cumu_sent)
|
|
.reduce((x,y) => x + y).toFixed(4);
|
|
const receive = this.configurationPeers.map(x => x.total_receive + x.cumu_receive).reduce((x,y) => x + y).toFixed(4);
|
|
if (
|
|
this.historyDataSentDifference[this.historyDataSentDifference.length - 1] !== sent
|
|
){
|
|
if (this.historyDataSentDifference.length > 0){
|
|
this.historySentData = {
|
|
labels: [...this.historySentData.labels, dayjs().format("HH:mm:ss A")],
|
|
datasets: [
|
|
{
|
|
label: 'Data Sent',
|
|
data: [...this.historySentData.datasets[0].data,
|
|
((sent - this.historyDataSentDifference[this.historyDataSentDifference.length - 1])*1000)
|
|
.toFixed(4)],
|
|
fill: false,
|
|
borderColor: '#198754',
|
|
tension: 0
|
|
}
|
|
],
|
|
}
|
|
}
|
|
this.historyDataSentDifference.push(sent)
|
|
}
|
|
if (
|
|
this.historyDataReceivedDifference[this.historyDataReceivedDifference.length - 1] !== receive
|
|
){
|
|
if (this.historyDataReceivedDifference.length > 0){
|
|
this.historyReceiveData = {
|
|
labels: [...this.historyReceiveData.labels, dayjs().format("HH:mm:ss A")],
|
|
datasets: [
|
|
{
|
|
label: 'Data Received',
|
|
data: [...this.historyReceiveData.datasets[0].data,
|
|
((receive - this.historyDataReceivedDifference[this.historyDataReceivedDifference.length - 1])*1000)
|
|
.toFixed(4)],
|
|
fill: false,
|
|
borderColor: '#0d6efd',
|
|
tension: 0
|
|
}
|
|
],
|
|
}
|
|
}
|
|
this.historyDataReceivedDifference.push(receive)
|
|
}
|
|
}
|
|
});
|
|
},
|
|
setPeerInterval(){
|
|
this.dashboardConfigurationStore.Peers.RefreshInterval = setInterval(() => {
|
|
this.getPeers()
|
|
}, parseInt(this.dashboardConfigurationStore.Configuration.Server.dashboard_refresh_interval))
|
|
},
|
|
},
|
|
computed: {
|
|
configurationSummary(){
|
|
const k = {
|
|
connectedPeers: this.configurationPeers.filter(x => x.status === "running").length,
|
|
totalUsage: this.configurationPeers.length > 0 ?
|
|
this.configurationPeers.filter(x => !x.restricted)
|
|
.map(x => x.total_data + x.cumu_data).reduce((a, b) => a + b, 0).toFixed(4) : 0,
|
|
totalReceive: this.configurationPeers.length > 0 ?
|
|
this.configurationPeers.filter(x => !x.restricted)
|
|
.map(x => x.total_receive + x.cumu_receive).reduce((a, b) => a + b, 0).toFixed(4) : 0,
|
|
totalSent: this.configurationPeers.length > 0 ?
|
|
this.configurationPeers.filter(x => !x.restricted)
|
|
.map(x => x.total_sent + x.cumu_sent).reduce((a, b) => a + b, 0).toFixed(4) : 0
|
|
}
|
|
|
|
return k
|
|
},
|
|
receiveData(){
|
|
return this.historyReceiveData
|
|
},
|
|
sentData(){
|
|
return this.historySentData
|
|
},
|
|
individualDataUsage(){
|
|
return {
|
|
labels: this.configurationPeers.map(x => {
|
|
if (x.name) return x.name
|
|
return `Untitled Peer - ${x.id}`
|
|
}),
|
|
datasets: [{
|
|
label: 'Total Data Usage',
|
|
data: this.configurationPeers.map(x => x.cumu_data + x.total_data),
|
|
backgroundColor: this.configurationPeers.map(x => `#0dcaf0`),
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (tooltipItem) => {
|
|
return `${tooltipItem.formattedValue} GB`
|
|
}
|
|
}
|
|
}
|
|
}]
|
|
}
|
|
},
|
|
individualDataUsageChartOption(){
|
|
return {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
ticks: {
|
|
display: false,
|
|
},
|
|
grid: {
|
|
display: false
|
|
},
|
|
},
|
|
y:{
|
|
ticks: {
|
|
callback: (val, index) => {
|
|
return `${val} GB`
|
|
}
|
|
},
|
|
grid: {
|
|
display: false
|
|
},
|
|
}
|
|
}
|
|
}
|
|
},
|
|
chartOptions() {
|
|
return {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (tooltipItem) => {
|
|
return `${tooltipItem.formattedValue} MB/s`
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
ticks: {
|
|
display: false,
|
|
},
|
|
grid: {
|
|
display: false
|
|
},
|
|
},
|
|
y:{
|
|
ticks: {
|
|
callback: (val, index) => {
|
|
return `${val} MB/s`
|
|
}
|
|
},
|
|
grid: {
|
|
display: false
|
|
},
|
|
}
|
|
}
|
|
}
|
|
},
|
|
searchPeers(){
|
|
const fuse = new Fuse(this.configurationPeers, {
|
|
keys: ["name", "id", "allowed_ip"]
|
|
});
|
|
|
|
const result = this.wireguardConfigurationStore.searchString ?
|
|
fuse.search(this.wireguardConfigurationStore.searchString).map(x => x.item) : this.configurationPeers;
|
|
|
|
if (this.dashboardConfigurationStore.Configuration.Server.dashboard_sort === "restricted"){
|
|
return result.slice().sort((a, b) => {
|
|
if ( a[this.dashboardConfigurationStore.Configuration.Server.dashboard_sort]
|
|
< b[this.dashboardConfigurationStore.Configuration.Server.dashboard_sort] ){
|
|
return 1;
|
|
}
|
|
if ( a[this.dashboardConfigurationStore.Configuration.Server.dashboard_sort]
|
|
> b[this.dashboardConfigurationStore.Configuration.Server.dashboard_sort]){
|
|
return -1;
|
|
}
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
return result.slice().sort((a, b) => {
|
|
if ( a[this.dashboardConfigurationStore.Configuration.Server.dashboard_sort]
|
|
< b[this.dashboardConfigurationStore.Configuration.Server.dashboard_sort] ){
|
|
return -1;
|
|
}
|
|
if ( a[this.dashboardConfigurationStore.Configuration.Server.dashboard_sort]
|
|
> b[this.dashboardConfigurationStore.Configuration.Server.dashboard_sort]){
|
|
return 1;
|
|
}
|
|
return 0;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="!this.loading" class="container-md">
|
|
<div class="d-flex align-items-center">
|
|
<div>
|
|
<small CLASS="text-muted">CONFIGURATION</small>
|
|
<div class="d-flex align-items-center gap-3">
|
|
<h1 class="mb-0"><samp>{{this.configurationInfo.Name}}</samp></h1>
|
|
</div>
|
|
</div>
|
|
<div class="card rounded-3 bg-transparent shadow-sm ms-auto">
|
|
<div class="card-body py-2 d-flex align-items-center">
|
|
<div>
|
|
<p class="mb-0 text-muted"><small>Status</small></p>
|
|
<div class="form-check form-switch ms-auto">
|
|
<label class="form-check-label" style="cursor: pointer" :for="'switch' + this.configurationInfo.id">
|
|
{{this.configurationToggling ? 'Turning ':''}}
|
|
{{this.configurationInfo.Status ? "On":"Off"}}
|
|
<span v-if="this.configurationToggling"
|
|
class="spinner-border spinner-border-sm" aria-hidden="true"></span>
|
|
</label>
|
|
<input class="form-check-input"
|
|
style="cursor: pointer"
|
|
:disabled="this.configurationToggling"
|
|
type="checkbox" role="switch" :id="'switch' + this.configurationInfo.id"
|
|
@change="this.toggle()"
|
|
v-model="this.configurationInfo.Status"
|
|
>
|
|
</div>
|
|
</div>
|
|
<div class="dot ms-5" :class="{active: this.configurationInfo.Status}"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row mt-3 gy-2 gx-2 mb-2">
|
|
|
|
<div class="col-6 col-lg-3">
|
|
<div class="card rounded-3 bg-transparent shadow-sm">
|
|
<div class="card-body py-2">
|
|
<p class="mb-0 text-muted"><small>Address</small></p>
|
|
{{this.configurationInfo.Address}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<div class="card rounded-3 bg-transparent shadow-sm">
|
|
<div class="card-body py-2">
|
|
<p class="mb-0 text-muted"><small>Listen Port</small></p>
|
|
{{this.configurationInfo.ListenPort}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="word-break: break-all" class="col-12 col-lg-6">
|
|
<div class="card rounded-3 bg-transparent shadow-sm">
|
|
<div class="card-body py-2">
|
|
<p class="mb-0 text-muted"><small>Public Key</small></p>
|
|
<samp>{{this.configurationInfo.PublicKey}}</samp>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row gx-2 gy-2 mb-2">
|
|
<div class="col-6 col-lg-3">
|
|
<div class="card rounded-3 bg-transparent shadow-sm">
|
|
<div class="card-body d-flex">
|
|
<div>
|
|
<p class="mb-0 text-muted"><small>Connected Peers</small></p>
|
|
<strong class="h4">{{configurationSummary.connectedPeers}}</strong>
|
|
</div>
|
|
<i class="bi bi-ethernet ms-auto h2 text-muted"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<div class="card rounded-3 bg-transparent shadow-sm">
|
|
<div class="card-body d-flex">
|
|
<div>
|
|
<p class="mb-0 text-muted"><small>Total Usage</small></p>
|
|
<strong class="h4">{{configurationSummary.totalUsage}} GB</strong>
|
|
</div>
|
|
<i class="bi bi-arrow-down-up ms-auto h2 text-muted"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<div class="card rounded-3 bg-transparent shadow-sm">
|
|
<div class="card-body d-flex">
|
|
<div>
|
|
<p class="mb-0 text-muted"><small>Total Received</small></p>
|
|
<strong class="h4 text-primary">{{configurationSummary.totalReceive}} GB</strong>
|
|
</div>
|
|
<i class="bi bi-arrow-down ms-auto h2 text-muted"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<div class="card rounded-3 bg-transparent shadow-sm">
|
|
<div class="card-body d-flex">
|
|
<div>
|
|
<p class="mb-0 text-muted"><small>Total Sent</small></p>
|
|
<strong class="h4 text-success">{{configurationSummary.totalSent}} GB</strong>
|
|
</div>
|
|
<i class="bi bi-arrow-up ms-auto h2 text-muted"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row gx-2 gy-2 mb-3">
|
|
<div class="col-12 col-lg-6">
|
|
<div class="card rounded-3 bg-transparent shadow-sm" style="height: 270px">
|
|
<div class="card-header bg-transparent border-0">
|
|
<small class="text-muted">Peers Total Data Usage</small></div>
|
|
<div class="card-body pt-1">
|
|
<Bar
|
|
:data="individualDataUsage"
|
|
:options="individualDataUsageChartOption"
|
|
style="width: 100%; height: 200px; max-height: 200px"></Bar>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-sm col-lg-3">
|
|
<div class="card rounded-3 bg-transparent shadow-sm" style="height: 270px">
|
|
<div class="card-header bg-transparent border-0"><small class="text-muted">Real Time Received Data Usage</small></div>
|
|
<div class="card-body pt-1">
|
|
<Line
|
|
:options="chartOptions"
|
|
:data="receiveData"
|
|
style="width: 100%; height: 200px; max-height: 200px"
|
|
></Line>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-sm col-lg-3">
|
|
<div class="card rounded-3 bg-transparent shadow-sm" style="height: 270px">
|
|
<div class="card-header bg-transparent border-0"><small class="text-muted">Real Time Sent Data Usage</small></div>
|
|
<div class="card-body pt-1">
|
|
<Line
|
|
:options="chartOptions"
|
|
:data="sentData"
|
|
style="width: 100%; height: 200px; max-height: 200px"
|
|
></Line>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<PeerSearch
|
|
@jobsAll="this.peerScheduleJobsAll.modalOpen = true"
|
|
@jobLogs="this.peerScheduleJobsLogs.modalOpen = true"
|
|
:configuration="this.configurationInfo"></PeerSearch>
|
|
<TransitionGroup name="list" tag="div" class="row gx-2 gy-2 z-0">
|
|
<div class="col-12 col-lg-6 col-xl-4"
|
|
:key="peer.id"
|
|
v-for="peer in this.searchPeers">
|
|
<Peer :Peer="peer"
|
|
@share="this.peerShare.selectedPeer = peer.id; this.peerShare.modalOpen = true;"
|
|
@refresh="this.getPeers()"
|
|
@jobs="peerScheduleJobs.modalOpen = true; peerScheduleJobs.selectedPeer = this.configurationPeers.find(x => x.id === peer.id)"
|
|
@setting="peerSetting.modalOpen = true; peerSetting.selectedPeer = this.configurationPeers.find(x => x.id === peer.id)"
|
|
@qrcode="(file) => {this.peerQRCode.peerConfigData = file; this.peerQRCode.modalOpen = true;}"
|
|
></Peer>
|
|
</div>
|
|
</TransitionGroup>
|
|
</div>
|
|
<Transition name="zoom">
|
|
<PeerSettings v-if="this.peerSetting.modalOpen"
|
|
key="settings"
|
|
:selectedPeer="this.peerSetting.selectedPeer"
|
|
@refresh="this.getPeers()"
|
|
@close="this.peerSetting.modalOpen = false">
|
|
</PeerSettings>
|
|
</Transition>
|
|
<Transition name="zoom">
|
|
<PeerQRCode :peerConfigData="this.peerQRCode.peerConfigData"
|
|
key="qrcode"
|
|
@close="this.peerQRCode.modalOpen = false"
|
|
v-if="peerQRCode.modalOpen"></PeerQRCode>
|
|
</Transition>
|
|
<Transition name="zoom">
|
|
<PeerJobs
|
|
@refresh="this.getPeers()"
|
|
v-if="this.peerScheduleJobs.modalOpen"
|
|
:selectedPeer="this.peerScheduleJobs.selectedPeer"
|
|
@close="this.peerScheduleJobs.modalOpen = false">
|
|
</PeerJobs>
|
|
</Transition>
|
|
<Transition name="zoom">
|
|
<PeerJobsAllModal
|
|
v-if="this.peerScheduleJobsAll.modalOpen"
|
|
@refresh="this.getPeers()"
|
|
@close="this.peerScheduleJobsAll.modalOpen = false"
|
|
:configurationPeers="this.configurationPeers"
|
|
>
|
|
</PeerJobsAllModal>
|
|
</Transition>
|
|
<Transition name="zoom">
|
|
<PeerJobsLogsModal v-if="this.peerScheduleJobsLogs.modalOpen"
|
|
@close="this.peerScheduleJobsLogs.modalOpen = false"
|
|
:configurationInfo="this.configurationInfo"
|
|
>
|
|
</PeerJobsLogsModal>
|
|
</Transition>
|
|
<Transition name="zoom">
|
|
<PeerShareLinkModal
|
|
v-if="this.peerShare.modalOpen"
|
|
@close="this.peerShare.modalOpen = false; this.peerShare.selectedPeer = undefined;"
|
|
:peer="this.configurationPeers.find(x => x.id === this.peerShare.selectedPeer)"></PeerShareLinkModal>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.peerNav .nav-link{
|
|
&.active{
|
|
//background: linear-gradient(var(--degree), var(--brandColor1) var(--distance2), var(--brandColor2) 100%);
|
|
//color: white;
|
|
background-color: #efefef;
|
|
}
|
|
}
|
|
|
|
|
|
</style> |