Remove legacy dashboard
This commit is contained in:
@@ -1,48 +0,0 @@
|
|||||||
#!/bin/groovy
|
|
||||||
/*
|
|
||||||
* Licensed to the OpenAirInterface (OAI) Software Alliance under one or more
|
|
||||||
* contributor license agreements. See the NOTICE file distributed with
|
|
||||||
* this work for additional information regarding copyright ownership.
|
|
||||||
* The OpenAirInterface Software Alliance licenses this file to You under
|
|
||||||
* the OAI Public License, Version 1.1 (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.openairinterface.org/?page_id=698
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*-------------------------------------------------------------------------------
|
|
||||||
* For more information about the OpenAirInterface (OAI) Software Alliance:
|
|
||||||
* contact@openairinterface.org
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Template Jenkins Declarative Pipeline script to run Test w/ RF HW
|
|
||||||
|
|
||||||
// Location of the python executor node shall be in the same subnet as the others servers
|
|
||||||
def pythonExecutor = params.pythonExecutor
|
|
||||||
|
|
||||||
|
|
||||||
pipeline {
|
|
||||||
agent {
|
|
||||||
label pythonExecutor
|
|
||||||
}
|
|
||||||
stages {
|
|
||||||
stage ("gDashboard") {
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
dir ("ci-scripts/ran_dashboard") {
|
|
||||||
//retrieve MR data from gitlab / mySQL db, build HTML pages and load them to AWS S3 bucket (configured as static web page hosting)
|
|
||||||
//deprecated method : sh returnStdout: true, script: 'python3 ran_dashboard.py'
|
|
||||||
sh returnStdout: true, script: 'python3 Hdashboard.py'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -376,19 +376,6 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stage ("Result Update"){
|
|
||||||
when {
|
|
||||||
expression { params.DataBaseHost != "none" }
|
|
||||||
}
|
|
||||||
agent {label DataBaseHost}
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
dir ('ci-scripts/ran_dashboard') {
|
|
||||||
sh "python3 Hdashboard.py testevent ${params.eNB_MR} "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,488 +32,34 @@
|
|||||||
|
|
||||||
|
|
||||||
#Author Remi
|
#Author Remi
|
||||||
import boto3
|
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import json #json structures
|
import json #json structures
|
||||||
import datetime #now() and date formating
|
|
||||||
from datetime import datetime
|
|
||||||
import re
|
|
||||||
import gitlab
|
import gitlab
|
||||||
import yaml
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from sqlconnect import SQLConnect
|
|
||||||
|
|
||||||
#-----------------------------------------------------------
|
#-----------------------------------------------------------
|
||||||
# Class Declaration
|
# Class Declaration
|
||||||
#-----------------------------------------------------------
|
#-----------------------------------------------------------
|
||||||
|
|
||||||
class Dashboard:
|
class Dashboard:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
#init with data sources : git, test results databases
|
||||||
|
|
||||||
#init with data sources : git, yaml config file, test results databases
|
|
||||||
print("Collecting Data")
|
print("Collecting Data")
|
||||||
cmd="""curl --silent "https://gitlab.eurecom.fr/api/v4/projects/oai%2Fopenairinterface5g/merge_requests?state=opened&per_page=100" """
|
cmd="""curl --silent "https://gitlab.eurecom.fr/api/v4/projects/oai%2Fopenairinterface5g/merge_requests?state=opened&per_page=100" """
|
||||||
self.git = self.__getGitData(cmd) #git data from Gitlab
|
self.git = self.__getGitData(cmd) #git data from Gitlab
|
||||||
self.tests = self.__loadCfg('ran_dashboard_cfg.yaml') #tests table setup from yaml
|
|
||||||
self.db = self.__loadFromDB() #test results from database
|
|
||||||
self.mr_list=[] #mr list in string format
|
self.mr_list=[] #mr list in string format
|
||||||
for x in range(len(self.git)):
|
for x in range(len(self.git)):
|
||||||
self.mr_list.append(str(self.git[x]['iid']))
|
self.mr_list.append(str(self.git[x]['iid']))
|
||||||
|
|
||||||
def __loadCfg(self,yaml_file):
|
|
||||||
with open(yaml_file,'r') as f:
|
|
||||||
tests = yaml.load(f)
|
|
||||||
return tests
|
|
||||||
|
|
||||||
def __getGitData(self,cmd):
|
def __getGitData(self,cmd):
|
||||||
#cmd="""curl --silent "https://gitlab.eurecom.fr/api/v4/projects/oai%2Fopenairinterface5g/merge_requests?state=opened&per_page=100" """
|
|
||||||
process = subprocess.Popen(shlex.split(cmd), stdout=subprocess.PIPE)
|
process = subprocess.Popen(shlex.split(cmd), stdout=subprocess.PIPE)
|
||||||
output = process.stdout.readline()
|
output = process.stdout.readline()
|
||||||
tmp=output.decode("utf-8")
|
tmp=output.decode("utf-8")
|
||||||
d = json.loads(tmp)
|
d = json.loads(tmp)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def __loadFromDB(self):
|
|
||||||
mr_list=[]
|
|
||||||
for x in range(len(self.git)):
|
|
||||||
mr_list.append(str(self.git[x]['iid']))
|
|
||||||
mydb=SQLConnect()
|
|
||||||
for MR in mr_list:
|
|
||||||
mydb.get(MR)
|
|
||||||
mydb.close_connection()
|
|
||||||
return mydb.data
|
|
||||||
|
|
||||||
def singleMR_initHTML(self, date):
|
|
||||||
self.f_html.write('<!DOCTYPE html>\n')
|
|
||||||
self.f_html.write('<head>\n')
|
|
||||||
self.f_html.write('<link rel="stylesheet" href="../test_styles.css">\n')
|
|
||||||
self.f_html.write('<title>Test Dashboard</title>\n')
|
|
||||||
self.f_html.write('</head>\n')
|
|
||||||
self.f_html.write('<br>\n')
|
|
||||||
self.f_html.write('<br>\n')
|
|
||||||
self.f_html.write('<table>\n')
|
|
||||||
self.f_html.write('<tr>\n')
|
|
||||||
self.f_html.write('<td class="Main">OAI RAN TEST Status Dashboard</td>\n')
|
|
||||||
self.f_html.write('</td>\n')
|
|
||||||
self.f_html.write('<tr>\n')
|
|
||||||
self.f_html.write('<tr></tr>\n')
|
|
||||||
self.f_html.write('<tr>\n')
|
|
||||||
self.f_html.write('<td class="Date">Update : '+date+'</td>\n')
|
|
||||||
self.f_html.write('</td>\n')
|
|
||||||
self.f_html.write('</tr>\n')
|
|
||||||
self.f_html.write('</table>\n')
|
|
||||||
self.f_html.write('<br>\n')
|
|
||||||
self.f_html.write('<br>\n')
|
|
||||||
|
|
||||||
|
|
||||||
def Test_initHTML(self, date):
|
|
||||||
self.f_html.write('<!DOCTYPE html>\n')
|
|
||||||
self.f_html.write('<head>\n')
|
|
||||||
self.f_html.write('<link rel="stylesheet" href="test_styles.css">\n')
|
|
||||||
self.f_html.write('<title>Test Dashboard</title>\n')
|
|
||||||
self.f_html.write('</head>\n')
|
|
||||||
self.f_html.write('<br>\n')
|
|
||||||
self.f_html.write('<br>\n')
|
|
||||||
self.f_html.write('<table>\n')
|
|
||||||
self.f_html.write('<tr>\n')
|
|
||||||
self.f_html.write('<td class="Main">OAI RAN TEST Status Dashboard</td>\n')
|
|
||||||
self.f_html.write('</td>\n')
|
|
||||||
self.f_html.write('<tr>\n')
|
|
||||||
self.f_html.write('<td class="DashLink"> <a href="https://oairandashboard.s3.eu-west-1.amazonaws.com/index.html">Merge Requests Dashboard</a></td>\n')
|
|
||||||
self.f_html.write('</td>\n')
|
|
||||||
self.f_html.write('<tr></tr>\n')
|
|
||||||
self.f_html.write('<tr>\n')
|
|
||||||
self.f_html.write('<td class="Date">Update : '+date+'</td>\n')
|
|
||||||
self.f_html.write('</td>\n')
|
|
||||||
self.f_html.write('</tr>\n')
|
|
||||||
self.f_html.write('</table>\n')
|
|
||||||
self.f_html.write('<br>\n')
|
|
||||||
self.f_html.write('<br>\n')
|
|
||||||
|
|
||||||
def Test_terminateHTML(self):
|
|
||||||
self.f_html.write('</body>\n')
|
|
||||||
self.f_html.write('</html>\n')
|
|
||||||
self.f_html.close()
|
|
||||||
|
|
||||||
|
|
||||||
def MR_initHTML(self,date):
|
|
||||||
self.f_html.write('<!DOCTYPE html>\n')
|
|
||||||
self.f_html.write('<head>\n')
|
|
||||||
self.f_html.write('<link rel="stylesheet" href="mr_styles.css">\n')
|
|
||||||
self.f_html.write('<title>MR Dashboard</title>\n')
|
|
||||||
self.f_html.write('</head>\n')
|
|
||||||
self.f_html.write('<br>\n')
|
|
||||||
self.f_html.write('<br>\n')
|
|
||||||
self.f_html.write('<table>\n')
|
|
||||||
self.f_html.write('<tr>\n')
|
|
||||||
self.f_html.write('<td class="Main">OAI RAN MR Status Dashboard</td>\n')
|
|
||||||
self.f_html.write('</td>\n')
|
|
||||||
self.f_html.write('<tr>\n')
|
|
||||||
self.f_html.write('<td class="DashLink"> <a href="https://oaitestdashboard.s3.eu-west-1.amazonaws.com/index.html">Tests Dashboard</a></td>\n')
|
|
||||||
self.f_html.write('</td>\n')
|
|
||||||
self.f_html.write('<tr></tr>\n')
|
|
||||||
self.f_html.write('<tr>\n')
|
|
||||||
self.f_html.write('<td class="Date">Update : '+date+'</td>\n')
|
|
||||||
self.f_html.write('</td>\n')
|
|
||||||
self.f_html.write('</tr>\n')
|
|
||||||
self.f_html.write('</table>\n')
|
|
||||||
self.f_html.write('<br>\n')
|
|
||||||
self.f_html.write('<br>\n')
|
|
||||||
self.f_html.write('<table class="MR_Table">\n')
|
|
||||||
self.f_html.write('<tr>\n')
|
|
||||||
self.f_html.write('<th class="MR">MR</th>\n')
|
|
||||||
self.f_html.write('<th class="CREATED_AT">Created_At</th>\n')
|
|
||||||
self.f_html.write('<th class="AUTHOR">Author</th>\n')
|
|
||||||
self.f_html.write('<th class="TITLE">Title</th>\n')
|
|
||||||
self.f_html.write('<th class="ASSIGNEE">Assignee</th>\n')
|
|
||||||
self.f_html.write('<th class="REVIEWER">Reviewer</th>\n')
|
|
||||||
self.f_html.write('<th class="CAN_START">CAN START</th>\n')
|
|
||||||
self.f_html.write('<th class="IN_PROGRESS">IN PROGRESS</th>\n')
|
|
||||||
self.f_html.write('<th class="COMPLETED">COMPLETED</th>\n')
|
|
||||||
self.f_html.write('<th class="REVIEW_FORM">Review Form</th>\n')
|
|
||||||
self.f_html.write('<th class="OK_MERGE">OK Merge</th>\n')
|
|
||||||
self.f_html.write('<th class="MERGE_CONFLICTS">Merge Conflicts</th>\n')
|
|
||||||
self.f_html.write('</tr>\n')
|
|
||||||
|
|
||||||
def MR_terminateHTML(self):
|
|
||||||
self.f_html.write('</table> \n')
|
|
||||||
self.f_html.write('</body>\n')
|
|
||||||
self.f_html.write('</html>\n')
|
|
||||||
self.f_html.close()
|
|
||||||
|
|
||||||
|
|
||||||
def MR_rowHTML(self,row):
|
|
||||||
self.f_html.write('<tr>\n')
|
|
||||||
self.f_html.write('<td><a href=\"'+row[0]+'\">'+row[1]+'</a></td>\n')
|
|
||||||
self.f_html.write('<td>'+row[2]+'</td>\n')
|
|
||||||
self.f_html.write('<td>'+row[3]+'</td>\n')
|
|
||||||
self.f_html.write('<td class="title_cell">'+row[4]+'</td>\n')
|
|
||||||
self.f_html.write('<td>'+row[5]+'</td>\n')
|
|
||||||
self.f_html.write('<td>'+row[6]+'</td>\n')
|
|
||||||
if row[7]=='X':
|
|
||||||
self.f_html.write('<td style="background-color: orange;">'+row[7]+'</td>\n')
|
|
||||||
else:
|
|
||||||
self.f_html.write('<td></td>\n')
|
|
||||||
if row[8]=='X':
|
|
||||||
self.f_html.write('<td style="background-color: yellow;">'+row[8]+'</td>\n')
|
|
||||||
else:
|
|
||||||
self.f_html.write('<td></td>\n')
|
|
||||||
if row[9]=='X':
|
|
||||||
self.f_html.write('<td style="background-color: rgb(144, 221, 231);">'+row[9]+'</td>\n')
|
|
||||||
else:
|
|
||||||
self.f_html.write('<td></td>\n')
|
|
||||||
if row[10]=='X':
|
|
||||||
self.f_html.write('<td style="background-color: rgb(58, 236, 58);">'+row[10]+'</td>\n')
|
|
||||||
else:
|
|
||||||
self.f_html.write('<td></td>\n')
|
|
||||||
if row[11]=='X':
|
|
||||||
self.f_html.write('<td style="background-color: rgb(58, 236, 58);">'+row[11]+'</td>\n')
|
|
||||||
else:
|
|
||||||
self.f_html.write('<td></td>\n')
|
|
||||||
if row[12]=='YES':
|
|
||||||
self.f_html.write('<td style="background-color: red;">'+row[12]+'</td>\n')
|
|
||||||
else:
|
|
||||||
self.f_html.write('<td></td>\n')
|
|
||||||
self.f_html.write('</tr>\n')
|
|
||||||
|
|
||||||
|
|
||||||
def Build(self, type, mr, htmlfilename):
|
|
||||||
if type=='MR':
|
|
||||||
self.Build_MR_Table(htmlfilename)
|
|
||||||
elif type=='Tests':
|
|
||||||
self.Build_Test_Table(htmlfilename)
|
|
||||||
elif type=='singleMR':
|
|
||||||
self.Build_singleMR_Table(mr,htmlfilename)
|
|
||||||
else :
|
|
||||||
print("Undefined Dashboard Type, options : MR or Tests")
|
|
||||||
|
|
||||||
|
|
||||||
def Build_Test_Table(self,htmlfilename):
|
|
||||||
print("Building Tests Dashboard...")
|
|
||||||
|
|
||||||
self.f_html=open(htmlfilename,'w')
|
|
||||||
|
|
||||||
###update date/time, format dd/mm/YY H:M:S
|
|
||||||
now = datetime.now()
|
|
||||||
dt_string = now.strftime("%d/%m/%Y %H:%M")
|
|
||||||
#HTML table header
|
|
||||||
self.Test_initHTML(dt_string)
|
|
||||||
|
|
||||||
|
|
||||||
#1 table per MR if test results exist
|
|
||||||
for x in range(len(self.git)):
|
|
||||||
mr=str(self.git[x]['iid'])
|
|
||||||
if 'PASS' not in self.db[mr]:
|
|
||||||
self.f_html.write('<h3><a href="https://gitlab.eurecom.fr/oai/openairinterface5g/-/merge_requests/'+mr+'">'+mr+'</a>'+' '+self.git[x]['title'] + '</h3>\n')
|
|
||||||
self.f_html.write('<table class="Test_Table">\n')
|
|
||||||
self.f_html.write('<tr>\n')
|
|
||||||
self.f_html.write('<th class="Test_Name">Test Name</th>\n')
|
|
||||||
self.f_html.write('<th class="Test_Descr">Bench</th> \n')
|
|
||||||
self.f_html.write('<th class="Test_Descr">Test</th> \n')
|
|
||||||
self.f_html.write('<th class="Pass"># Pass</th>\n')
|
|
||||||
self.f_html.write('<th class="Fail"># Fail</th>\n')
|
|
||||||
self.f_html.write('<th class="Last_Pass">Last Pass</th>\n')
|
|
||||||
self.f_html.write('<th class="Last_Fail">Last Fail</th>\n')
|
|
||||||
self.f_html.write('</tr>\n')
|
|
||||||
|
|
||||||
#parsing the tests
|
|
||||||
for t in self.tests:
|
|
||||||
|
|
||||||
row=[]
|
|
||||||
short_name= t
|
|
||||||
hyperlink= self.tests[t]['link']
|
|
||||||
job=self.tests[t]['job']
|
|
||||||
|
|
||||||
|
|
||||||
if job in self.db[mr]:
|
|
||||||
if 'PASS' in self.db[mr][job]:
|
|
||||||
row.append(self.db[mr][job]['PASS'])
|
|
||||||
else:
|
|
||||||
row.append('')
|
|
||||||
if 'FAIL' in self.db[mr][job]:
|
|
||||||
row.append(self.db[mr][job]['FAIL'])
|
|
||||||
else:
|
|
||||||
row.append('')
|
|
||||||
#2 columns for last_pass and last_fail links
|
|
||||||
if 'last_pass' in self.db[mr][job]:
|
|
||||||
lastpasshyperlink= self.db[mr][job]['last_pass'][1]
|
|
||||||
lastpasstext= self.db[mr][job]['last_pass'][0]
|
|
||||||
else:
|
|
||||||
lastpasshyperlink=''
|
|
||||||
lastpasstext=''
|
|
||||||
|
|
||||||
if 'last_fail' in self.db[mr][job]:
|
|
||||||
lastfailhyperlink= self.db[mr][job]['last_fail'][1]
|
|
||||||
lastfailtext= self.db[mr][job]['last_fail'][0]
|
|
||||||
else:
|
|
||||||
lastfailhyperlink=''
|
|
||||||
lastfailtext=''
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
self.f_html.write('<tr>\n')
|
|
||||||
self.f_html.write('<td><a href='+hyperlink+'>'+short_name+'</a></td>\n')
|
|
||||||
self.f_html.write('<td>'+self.tests[t]['bench']+'</td>\n')
|
|
||||||
self.f_html.write('<td>'+self.tests[t]['test']+'</td>\n')
|
|
||||||
if row[0]!='':
|
|
||||||
self.f_html.write('<td style="background-color: rgb(58, 236, 58);">'+str(row[0])+'</td>\n')
|
|
||||||
else:
|
|
||||||
self.f_html.write('<td></td>\n')
|
|
||||||
if row[1]!='':
|
|
||||||
self.f_html.write('<td style="background-color: red;">'+str(row[1])+'</td>\n')
|
|
||||||
else:
|
|
||||||
self.f_html.write('<td></td>\n')
|
|
||||||
self.f_html.write('<td><a href='+lastpasshyperlink+'>'+lastpasstext+'</a></td>\n')
|
|
||||||
self.f_html.write('<td><a href='+lastfailhyperlink+'>'+lastfailtext+'</a></td>\n')
|
|
||||||
self.f_html.write('</tr>\n')
|
|
||||||
|
|
||||||
self.f_html.write('</table>\n')
|
|
||||||
|
|
||||||
#terminate HTML table and close file
|
|
||||||
self.Test_terminateHTML()
|
|
||||||
|
|
||||||
|
|
||||||
def Build_singleMR_Table(self,singlemr,htmlfilename):
|
|
||||||
print("Building single MR Tests Results...")
|
|
||||||
|
|
||||||
self.f_html=open(htmlfilename,'w')
|
|
||||||
|
|
||||||
###update date/time, format dd/mm/YY H:M:S
|
|
||||||
now = datetime.now()
|
|
||||||
dt_string = now.strftime("%d/%m/%Y %H:%M")
|
|
||||||
#HTML table header
|
|
||||||
self.singleMR_initHTML(dt_string)
|
|
||||||
|
|
||||||
|
|
||||||
#1 table per MR if test results exist => 1 table for matching mr
|
|
||||||
for x in range(len(self.git)):
|
|
||||||
mr=str(self.git[x]['iid'])
|
|
||||||
if mr==singlemr:
|
|
||||||
#if 'PASS' not in self.db[mr]:
|
|
||||||
self.f_html.write('<h3><a href="https://gitlab.eurecom.fr/oai/openairinterface5g/-/merge_requests/'+mr+'">'+mr+'</a>'+' '+self.git[x]['title'] + '</h3>\n')
|
|
||||||
self.f_html.write('<table class="Test_Table">\n')
|
|
||||||
self.f_html.write('<tr>\n')
|
|
||||||
self.f_html.write('<th class="Test_Name">Test Name</th>\n')
|
|
||||||
self.f_html.write('<th class="Test_Descr">Bench</th> \n')
|
|
||||||
self.f_html.write('<th class="Test_Descr">Test</th> \n')
|
|
||||||
self.f_html.write('<th class="Pass"># Pass</th>\n')
|
|
||||||
self.f_html.write('<th class="Fail"># Fail</th>\n')
|
|
||||||
self.f_html.write('<th class="Last_Pass">Last Pass</th>\n')
|
|
||||||
self.f_html.write('<th class="Last_Fail">Last Fail</th>\n')
|
|
||||||
self.f_html.write('</tr>\n')
|
|
||||||
|
|
||||||
#parsing the tests
|
|
||||||
for t in self.tests:
|
|
||||||
|
|
||||||
row=[]
|
|
||||||
short_name= t
|
|
||||||
hyperlink= self.tests[t]['link']
|
|
||||||
job=self.tests[t]['job']
|
|
||||||
|
|
||||||
|
|
||||||
if job in self.db[mr]:
|
|
||||||
if 'PASS' in self.db[mr][job]:
|
|
||||||
row.append(self.db[mr][job]['PASS'])
|
|
||||||
else:
|
|
||||||
row.append('')
|
|
||||||
if 'FAIL' in self.db[mr][job]:
|
|
||||||
row.append(self.db[mr][job]['FAIL'])
|
|
||||||
else:
|
|
||||||
row.append('')
|
|
||||||
#2 columns for last_pass and last_fail links
|
|
||||||
if 'last_pass' in self.db[mr][job]:
|
|
||||||
lastpasshyperlink= self.db[mr][job]['last_pass'][1]
|
|
||||||
lastpasstext= self.db[mr][job]['last_pass'][0]
|
|
||||||
else:
|
|
||||||
lastpasshyperlink=''
|
|
||||||
lastpasstext=''
|
|
||||||
|
|
||||||
if 'last_fail' in self.db[mr][job]:
|
|
||||||
lastfailhyperlink= self.db[mr][job]['last_fail'][1]
|
|
||||||
lastfailtext= self.db[mr][job]['last_fail'][0]
|
|
||||||
else:
|
|
||||||
lastfailhyperlink=''
|
|
||||||
lastfailtext=''
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
self.f_html.write('<tr>\n')
|
|
||||||
self.f_html.write('<td><a href='+hyperlink+'>'+short_name+'</a></td>\n')
|
|
||||||
self.f_html.write('<td>'+self.tests[t]['bench']+'</td>\n')
|
|
||||||
self.f_html.write('<td>'+self.tests[t]['test']+'</td>\n')
|
|
||||||
if row[0]!='':
|
|
||||||
self.f_html.write('<td style="background-color: rgb(58, 236, 58);">'+str(row[0])+'</td>\n')
|
|
||||||
else:
|
|
||||||
self.f_html.write('<td></td>\n')
|
|
||||||
if row[1]!='':
|
|
||||||
self.f_html.write('<td style="background-color: red;">'+str(row[1])+'</td>\n')
|
|
||||||
else:
|
|
||||||
self.f_html.write('<td></td>\n')
|
|
||||||
self.f_html.write('<td><a href='+lastpasshyperlink+'>'+lastpasstext+'</a></td>\n')
|
|
||||||
self.f_html.write('<td><a href='+lastfailhyperlink+'>'+lastfailtext+'</a></td>\n')
|
|
||||||
self.f_html.write('</tr>\n')
|
|
||||||
|
|
||||||
self.f_html.write('</table>\n')
|
|
||||||
|
|
||||||
#terminate HTML table and close file
|
|
||||||
self.Test_terminateHTML()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def Build_MR_Table(self,htmlfilename):
|
|
||||||
|
|
||||||
print("Building Merge Requests Dashboard...")
|
|
||||||
|
|
||||||
self.f_html=open(htmlfilename,'w')
|
|
||||||
|
|
||||||
###update date/time, format dd/mm/YY H:M:S
|
|
||||||
now = datetime.now()
|
|
||||||
dt_string = now.strftime("%d/%m/%Y %H:%M")
|
|
||||||
|
|
||||||
#HTML table header
|
|
||||||
self.MR_initHTML(dt_string)
|
|
||||||
|
|
||||||
|
|
||||||
###MR data lines
|
|
||||||
for x in range(len(self.git)):
|
|
||||||
|
|
||||||
|
|
||||||
hyperlink= 'https://gitlab.eurecom.fr/oai/openairinterface5g/-/merge_requests/'+ str(self.git[x]['iid'])
|
|
||||||
text= str(self.git[x]['iid'])
|
|
||||||
|
|
||||||
|
|
||||||
date_time_str = self.git[x]['created_at']
|
|
||||||
date_time_obj = datetime.strptime(date_time_str, '%Y-%m-%dT%H:%M:%S.%fZ')
|
|
||||||
|
|
||||||
milestone1=milestone2=milestone3=milestone4=""
|
|
||||||
if self.git[x]['milestone']!=None:
|
|
||||||
if self.git[x]['milestone']['title']=="REVIEW_CAN_START":
|
|
||||||
milestone1="X"
|
|
||||||
elif self.git[x]['milestone']['title']=="REVIEW_IN_PROGRESS":
|
|
||||||
milestone2="X"
|
|
||||||
elif self.git[x]['milestone']['title']=="REVIEW_COMPLETED_AND_APPROVED":
|
|
||||||
milestone3="X"
|
|
||||||
elif self.git[x]['milestone']['title']=="OK_TO_BE_MERGED":
|
|
||||||
milestone4="X"
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
#check if empty or not
|
|
||||||
if self.git[x]['assignee']!=None:
|
|
||||||
assignee = str(self.git[x]['assignee']['name'])
|
|
||||||
else:
|
|
||||||
assignee = ""
|
|
||||||
|
|
||||||
#check if empty or not
|
|
||||||
if len(self.git[x]['reviewers'])!=0:
|
|
||||||
reviewer = str(self.git[x]['reviewers'][0]['name'])
|
|
||||||
else:
|
|
||||||
reviewer = ""
|
|
||||||
|
|
||||||
if self.git[x]['has_conflicts']==True:
|
|
||||||
conflicts = "YES"
|
|
||||||
else:
|
|
||||||
conflicts = ""
|
|
||||||
|
|
||||||
|
|
||||||
#add a column flagging that the review form is present
|
|
||||||
#we use gitlab API to parse the MR notes
|
|
||||||
gl = gitlab.Gitlab.from_config('OAI')
|
|
||||||
project_id = 223
|
|
||||||
project = gl.projects.get(project_id)
|
|
||||||
#get the opened MR in the project
|
|
||||||
mrs = project.mergerequests.list(state='opened',per_page=100)
|
|
||||||
review_form=''
|
|
||||||
for m in range (0,len(mrs)):
|
|
||||||
if mrs[m].iid==self.git[x]['iid']:#check the iid is the one we are on
|
|
||||||
mr_notes = mrs[m].notes.list(all=True)
|
|
||||||
n=0
|
|
||||||
found=False
|
|
||||||
while found==False and n<len(mr_notes):
|
|
||||||
res=re.search('Code Review by',mr_notes[n].body)#this is the marker we are looking for in all notes
|
|
||||||
if res!=None:
|
|
||||||
review_form = "X"
|
|
||||||
found=True
|
|
||||||
n+=1
|
|
||||||
|
|
||||||
#build final row to be inserted
|
|
||||||
row =[hyperlink, text, str(date_time_obj.date()),str(self.git[x]['author']['name']), str(self.git[x]['title']),\
|
|
||||||
assignee, reviewer,\
|
|
||||||
milestone1,milestone2,milestone3,review_form,milestone4,conflicts]
|
|
||||||
|
|
||||||
self.MR_rowHTML(row)
|
|
||||||
|
|
||||||
#terminate HTML table and close file
|
|
||||||
self.MR_terminateHTML()
|
|
||||||
|
|
||||||
|
|
||||||
def CopyToS3(self,htmlfilename,bucket,key):
|
|
||||||
print("Uploading to S3 bucket")
|
|
||||||
#Creating Session With Boto3.
|
|
||||||
s3 = boto3.client('s3')
|
|
||||||
|
|
||||||
#Creating S3 Resource From the Session.
|
|
||||||
result = s3.upload_file(htmlfilename, bucket,key, ExtraArgs={'ACL':'public-read','ContentType': 'text/html'})
|
|
||||||
|
|
||||||
#unused
|
|
||||||
def CopyCSS(self,path):
|
|
||||||
s3 = boto3.resource('s3')
|
|
||||||
copy_source = {'Bucket': 'oaitestdashboard','Key':'test_styles.css'}
|
|
||||||
s3.meta.client.copy(copy_source, 'oaitestdashboard', path+'/'+ 'test_styles.css')
|
|
||||||
|
|
||||||
|
|
||||||
def PostGitNote(self,mr,commit,args):
|
def PostGitNote(self,mr,commit,args):
|
||||||
#current date and time to be posted with test results
|
|
||||||
#now = datetime.now()
|
|
||||||
#dt_string = now.strftime("%d/%m/%Y %H:%M")
|
|
||||||
|
|
||||||
if len(args)%4 != 0:
|
if len(args)%4 != 0:
|
||||||
print("Wrong Number of Arguments")
|
print("Wrong Number of Arguments")
|
||||||
return
|
return
|
||||||
@@ -528,7 +74,7 @@ class Dashboard:
|
|||||||
editable_mr = project.mergerequests.get(int(mr))
|
editable_mr = project.mergerequests.get(int(mr))
|
||||||
mr_notes = editable_mr.notes.list(all=True)
|
mr_notes = editable_mr.notes.list(all=True)
|
||||||
|
|
||||||
body = '[Consolidated Test Results](https://oaitestdashboard.s3.eu-west-1.amazonaws.com/MR'+mr+'/index.html)\\\n'
|
body = '[Consolidated Test Results]\\\n'
|
||||||
body += 'Tested CommitID: ' + commit
|
body += 'Tested CommitID: ' + commit
|
||||||
|
|
||||||
for i in range(0,n_tests):
|
for i in range(0,n_tests):
|
||||||
@@ -544,54 +90,16 @@ class Dashboard:
|
|||||||
})
|
})
|
||||||
editable_mr.save()
|
editable_mr.save()
|
||||||
|
|
||||||
def AWSCleanup(self,mode):
|
|
||||||
#first build MR list from aws S3 bucket
|
|
||||||
if mode != 'report' and mode !='delete':
|
|
||||||
print("incorrect mode for awsclean")
|
|
||||||
return
|
|
||||||
aws_mr_list=[]
|
|
||||||
s3 = boto3.resource('s3')
|
|
||||||
my_bucket = s3.Bucket('oaitestdashboard')
|
|
||||||
for my_bucket_object in my_bucket.objects.all():
|
|
||||||
#MR objects are like MR1407/index.html
|
|
||||||
res=re.search(r'^MR([0-9]+)',my_bucket_object.key)
|
|
||||||
if res!=None:
|
|
||||||
aws_mr_list.append(res.group(1))#store MR number as a string
|
|
||||||
#open MR list from GIt already exists as an attribute of this class self.mr_list
|
|
||||||
#parse aws MR list and delete those MR that are no longer open
|
|
||||||
for aws_mr in aws_mr_list:
|
|
||||||
if aws_mr not in self.mr_list:
|
|
||||||
if mode=="report":
|
|
||||||
print(aws_mr+' can be deleted from AWS S3')
|
|
||||||
else :
|
|
||||||
awspath="MR"+aws_mr+"/"
|
|
||||||
print('deleting ' + aws_mr)
|
|
||||||
my_bucket.objects.filter(Prefix=awspath).delete()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
||||||
#call from slave Jenkinsfile : sh "python3 Hdashboard.py testevent ${params.eNB_MR} "
|
|
||||||
#call from master Jenkinsfile : sh "python3 Hdashboard.py gitpost ${GitPostArgs}"
|
#call from master Jenkinsfile : sh "python3 Hdashboard.py gitpost ${GitPostArgs}"
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
|
||||||
if len(sys.argv)>1:
|
|
||||||
|
|
||||||
#individual MR test results + test dashboard, event based (end of slave jenkins pipeline)
|
#individual MR test results + test dashboard, event based (end of slave jenkins pipeline)
|
||||||
if sys.argv[1]=="testevent" :
|
if sys.argv[1] != "gitpost":
|
||||||
mr=sys.argv[2]
|
print("error: only gitpost subcommand is supported")
|
||||||
htmlDash=Dashboard()
|
exit(1)
|
||||||
if mr in htmlDash.mr_list:
|
|
||||||
#single MR test results
|
|
||||||
htmlDash.Build('singleMR',mr,'/tmp/MR'+mr+'_index.html')
|
|
||||||
htmlDash.CopyToS3('/tmp/MR'+mr+'_index.html','oaitestdashboard','MR'+mr+'/index.html')
|
|
||||||
#all MR test results
|
|
||||||
htmlDash.Build('Tests','0000','/tmp/Tests_index.html')
|
|
||||||
htmlDash.CopyToS3('/tmp/Tests_index.html','oaitestdashboard','index.html')
|
|
||||||
|
|
||||||
#git post with MR test results, event based (end of master jenkins pipeline)
|
|
||||||
elif sys.argv[1]=="gitpost":
|
elif sys.argv[1]=="gitpost":
|
||||||
mr=sys.argv[2]
|
mr=sys.argv[2]
|
||||||
commit=sys.argv[3]
|
commit=sys.argv[3]
|
||||||
@@ -603,25 +111,15 @@ def main():
|
|||||||
htmlDash.PostGitNote(mr,commit, args)
|
htmlDash.PostGitNote(mr,commit, args)
|
||||||
else:
|
else:
|
||||||
print("Not a Merge Request => this build is for testing/debug purpose, no report to git")
|
print("Not a Merge Request => this build is for testing/debug purpose, no report to git")
|
||||||
elif sys.argv[1]=="awsclean":
|
|
||||||
mode=sys.argv[2]#report or delete
|
|
||||||
htmlDash=Dashboard()
|
|
||||||
htmlDash.AWSCleanup(mode)
|
|
||||||
else:
|
else:
|
||||||
print("Wrong argument at position 1")
|
print("Wrong argument at position 1")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
|
||||||
#test and MR status dashboards, cron based
|
#test and MR status dashboards, cron based
|
||||||
else:
|
else:
|
||||||
htmlDash=Dashboard()
|
print("error: only gitpost subcommand is supported")
|
||||||
#all MR status dashboard
|
exit(1)
|
||||||
htmlDash.Build('MR','0000','/tmp/MR_index.html')
|
|
||||||
htmlDash.CopyToS3('/tmp/MR_index.html','oairandashboard','index.html')
|
|
||||||
#all MR test results
|
|
||||||
htmlDash.Build('Tests','0000','/tmp/Tests_index.html')
|
|
||||||
htmlDash.CopyToS3('/tmp/Tests_index.html','oaitestdashboard','index.html')
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# execute only if run as a script
|
# execute only if run as a script
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
body {
|
|
||||||
font-family: 'lato', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.Main {
|
|
||||||
text-align:left;
|
|
||||||
font-size: 30px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.DashLink {
|
|
||||||
text-align:left;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.DashLink:hover {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Date {
|
|
||||||
text-align:left;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
a { text-decoration: none; }
|
|
||||||
a:visited { text-decoration: none; color:blue}
|
|
||||||
a:hover { text-decoration: none; font-weight: bold; color:blue}
|
|
||||||
|
|
||||||
|
|
||||||
.MR_Table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.MR_Table tr {
|
|
||||||
font-size: 12px;
|
|
||||||
text-align:center;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.MR_Table tr:hover {
|
|
||||||
background-color:lightgray;
|
|
||||||
}
|
|
||||||
|
|
||||||
.MR_Table td {
|
|
||||||
border: 1px solid black;
|
|
||||||
padding : 5px;
|
|
||||||
}
|
|
||||||
.MR_Table td:hover {
|
|
||||||
font-weight : bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.MR_Table th {
|
|
||||||
font-size: 14px;
|
|
||||||
text-align:center;
|
|
||||||
padding : 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.title_cell {
|
|
||||||
text-align:left;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.MR {
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 50px;
|
|
||||||
background-color: rgb(143, 154, 216);
|
|
||||||
}
|
|
||||||
|
|
||||||
.CREATED_AT {
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 100px;
|
|
||||||
background-color: rgb(143, 154, 216);
|
|
||||||
}
|
|
||||||
|
|
||||||
.AUTHOR {
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 150px;
|
|
||||||
background-color: rgb(143, 154, 216);
|
|
||||||
}
|
|
||||||
|
|
||||||
.TITLE {
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 500px;
|
|
||||||
background-color: rgb(143, 154, 216);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ASSIGNEE {
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 150px;
|
|
||||||
background-color: rgb(143, 154, 216);
|
|
||||||
}
|
|
||||||
|
|
||||||
.REVIEWER {
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 150px;
|
|
||||||
background-color: rgb(143, 154, 216);
|
|
||||||
}
|
|
||||||
|
|
||||||
.CAN_START {
|
|
||||||
background-color: orange;
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 150px;
|
|
||||||
}
|
|
||||||
.IN_PROGRESS {
|
|
||||||
background-color: yellow;
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 150px;
|
|
||||||
}
|
|
||||||
.COMPLETED {
|
|
||||||
background-color: rgb(144, 221, 231);
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.REVIEW_FORM {
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 100px;
|
|
||||||
background-color: rgb(143, 154, 216);
|
|
||||||
}
|
|
||||||
|
|
||||||
.OK_MERGE {
|
|
||||||
background-color: rgb(58, 236, 58);
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.MERGE_CONFLICTS {
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 100px;
|
|
||||||
background-color: rgb(143, 154, 216);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
#/*
|
|
||||||
# * Licensed to the OpenAirInterface (OAI) Software Alliance under one or more
|
|
||||||
# * contributor license agreements. See the NOTICE file distributed with
|
|
||||||
# * this work for additional information regarding copyright ownership.
|
|
||||||
# * The OpenAirInterface Software Alliance licenses this file to You under
|
|
||||||
# * the OAI Public License, Version 1.1 (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.openairinterface.org/?page_id=698
|
|
||||||
# *
|
|
||||||
# * 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.
|
|
||||||
# *-------------------------------------------------------------------------------
|
|
||||||
# * For more information about the OpenAirInterface (OAI) Software Alliance:
|
|
||||||
# * contact@openairinterface.org
|
|
||||||
# */
|
|
||||||
#---------------------------------------------------------------------
|
|
||||||
# Merge Requests Dashboard for RAN on googleSheet
|
|
||||||
#
|
|
||||||
# Required Python Version
|
|
||||||
# Python 3.x
|
|
||||||
#
|
|
||||||
#---------------------------------------------------------------------
|
|
||||||
|
|
||||||
#-----------------------------------------------------------
|
|
||||||
# Import
|
|
||||||
#-----------------------------------------------------------
|
|
||||||
|
|
||||||
#author Remi
|
|
||||||
|
|
||||||
#import google spreadsheet API
|
|
||||||
import gspread
|
|
||||||
from oauth2client.service_account import ServiceAccountCredentials
|
|
||||||
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import shlex #lexical analysis
|
|
||||||
import json #json structures
|
|
||||||
import datetime #now() and date formating
|
|
||||||
from datetime import datetime
|
|
||||||
import re
|
|
||||||
import gitlab
|
|
||||||
import yaml
|
|
||||||
import os
|
|
||||||
import pickle
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
from sqlconnect import SQLConnect
|
|
||||||
|
|
||||||
#-----------------------------------------------------------
|
|
||||||
# Class Declaration
|
|
||||||
#-----------------------------------------------------------
|
|
||||||
|
|
||||||
class gDashboard:
|
|
||||||
def __init__(self, creds_file, spreadsheet, worksheet): #"creds.json", 'OAI RAN Dashboard', 'MR Status'
|
|
||||||
self.scope = ["https://spreadsheets.google.com/feeds",'https://www.googleapis.com/auth/spreadsheets',"https://www.googleapis.com/auth/drive.file","https://www.googleapis.com/auth/drive"]
|
|
||||||
self.creds = ServiceAccountCredentials.from_json_keyfile_name(creds_file, self.scope)
|
|
||||||
self.client = gspread.authorize(self.creds)
|
|
||||||
#spreadsheet
|
|
||||||
self.ss = self.client.open(spreadsheet)
|
|
||||||
#worksheet
|
|
||||||
self.sheet = self.ss.worksheet(worksheet)
|
|
||||||
self.ss.del_worksheet(self.sheet) #start by deleting the old sheet
|
|
||||||
self.sheet = self.ss.add_worksheet(title=worksheet, rows="100", cols="30") #create a new one
|
|
||||||
|
|
||||||
#init with data sources : git, yaml config file, test results databases
|
|
||||||
cmd="""curl --silent "https://gitlab.eurecom.fr/api/v4/projects/oai%2Fopenairinterface5g/merge_requests?state=opened&per_page=100" """
|
|
||||||
self.git = self.__getGitData(cmd) #git data from Gitlab
|
|
||||||
self.tests = self.__loadCfg('ran_dashboard_cfg.yaml') #tests table setup from yaml
|
|
||||||
self.db = self.__loadFromDB() #test results from database
|
|
||||||
|
|
||||||
|
|
||||||
def __loadCfg(self,yaml_file):
|
|
||||||
with open(yaml_file,'r') as f:
|
|
||||||
tests = yaml.load(f)
|
|
||||||
return tests
|
|
||||||
|
|
||||||
def __getGitData(self,cmd):
|
|
||||||
#cmd="""curl --silent "https://gitlab.eurecom.fr/api/v4/projects/oai%2Fopenairinterface5g/merge_requests?state=opened&per_page=100" """
|
|
||||||
process = subprocess.Popen(shlex.split(cmd), stdout=subprocess.PIPE)
|
|
||||||
output = process.stdout.readline()
|
|
||||||
tmp=output.decode("utf-8")
|
|
||||||
d = json.loads(tmp)
|
|
||||||
return d
|
|
||||||
|
|
||||||
def __loadFromDB(self):
|
|
||||||
mr_list=[]
|
|
||||||
for x in range(len(self.git)):
|
|
||||||
mr_list.append(str(self.git[x]['iid']))
|
|
||||||
mydb=SQLConnect()
|
|
||||||
for MR in mr_list:
|
|
||||||
mydb.get(MR)
|
|
||||||
mydb.close_connection()
|
|
||||||
return mydb.data
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def gBuild(self, destinationSheetName):
|
|
||||||
|
|
||||||
###line 1 : update date/time, format dd/mm/YY H:M:S
|
|
||||||
now = datetime.now()
|
|
||||||
dt_string = "Update : " + now.strftime("%d/%m/%Y %H:%M")
|
|
||||||
row =[dt_string]
|
|
||||||
self.sheet.insert_row(row, index=1, value_input_option='RAW')
|
|
||||||
|
|
||||||
###line 2 is for the test short names (links to jenkins pipeline), updated at the end
|
|
||||||
|
|
||||||
###line 3 is for the column names
|
|
||||||
i=3
|
|
||||||
row =["MR","Created_at","Author","Title","Assignee", "Reviewer", "CAN START","IN PROGRESS","COMPLETED","Review Form","OK MERGE","Merge conflicts",""]
|
|
||||||
|
|
||||||
#tests
|
|
||||||
for t in range(0,len(self.tests)):
|
|
||||||
row.append("# PASS")
|
|
||||||
row.append("# FAIL")
|
|
||||||
row.append("Last Pass")
|
|
||||||
row.append("Last Fail")
|
|
||||||
|
|
||||||
self.sheet.insert_row(row, index=i, value_input_option='RAW')
|
|
||||||
|
|
||||||
###line 4 onward, MR data lines
|
|
||||||
for x in range(len(self.git)):
|
|
||||||
i=i+1
|
|
||||||
date_time_str = self.git[x]['created_at']
|
|
||||||
date_time_obj = datetime.strptime(date_time_str, '%Y-%m-%dT%H:%M:%S.%fZ')
|
|
||||||
|
|
||||||
milestone1=milestone2=milestone3=milestone4=""
|
|
||||||
if self.git[x]['milestone']!=None:
|
|
||||||
if self.git[x]['milestone']['title']=="REVIEW_CAN_START":
|
|
||||||
milestone1="X"
|
|
||||||
elif self.git[x]['milestone']['title']=="REVIEW_IN_PROGRESS":
|
|
||||||
milestone2="X"
|
|
||||||
elif self.git[x]['milestone']['title']=="REVIEW_COMPLETED_AND_APPROVED":
|
|
||||||
milestone3="X"
|
|
||||||
elif self.git[x]['milestone']['title']=="OK_TO_BE_MERGED":
|
|
||||||
milestone4="X"
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
#check if empty or not
|
|
||||||
if self.git[x]['assignee']!=None:
|
|
||||||
assignee = str(self.git[x]['assignee']['name'])
|
|
||||||
else:
|
|
||||||
assignee = ""
|
|
||||||
|
|
||||||
#check if empty or not
|
|
||||||
if len(self.git[x]['reviewers'])!=0:
|
|
||||||
reviewer = str(self.git[x]['reviewers'][0]['name'])
|
|
||||||
else:
|
|
||||||
reviewer = ""
|
|
||||||
|
|
||||||
if self.git[x]['has_conflicts']==True:
|
|
||||||
conflicts = "YES"
|
|
||||||
else:
|
|
||||||
conflicts = ""
|
|
||||||
|
|
||||||
|
|
||||||
#add a column flagging that the review form is present
|
|
||||||
#we use gitlab API to parse the MR notes
|
|
||||||
gl = gitlab.Gitlab.from_config('OAI')
|
|
||||||
project_id = 223
|
|
||||||
project = gl.projects.get(project_id)
|
|
||||||
#get the opened MR in the project
|
|
||||||
mrs = project.mergerequests.list(state='opened',per_page=100)
|
|
||||||
review_form=''
|
|
||||||
for m in range (0,len(mrs)):
|
|
||||||
if mrs[m].iid==self.git[x]['iid']:#check the iid is the one we are on
|
|
||||||
mr_notes = mrs[m].notes.list(all=True)
|
|
||||||
n=0
|
|
||||||
found=False
|
|
||||||
while found==False and n<len(mr_notes):
|
|
||||||
res=re.search('Code Review by',mr_notes[n].body)#this is the marker we are looking for in all notes
|
|
||||||
if res!=None:
|
|
||||||
review_form = "X"
|
|
||||||
found=True
|
|
||||||
n+=1
|
|
||||||
|
|
||||||
#build final row to be inserted, the first column is left empty for now, will be filled afterward with hyperlinks to gitlab MR
|
|
||||||
row =["", str(date_time_obj.date()),str(self.git[x]['author']['name']), str(self.git[x]['title']),\
|
|
||||||
assignee, reviewer,\
|
|
||||||
milestone1,milestone2,milestone3,review_form,milestone4,conflicts,""]
|
|
||||||
#and append the test results coming from self.db
|
|
||||||
mr=str(self.git[x]['iid'])
|
|
||||||
for t in self.tests:
|
|
||||||
if mr in self.db:
|
|
||||||
job=self.tests[t]['job']
|
|
||||||
if job in self.db[mr]:
|
|
||||||
if 'PASS' in self.db[mr][job]:
|
|
||||||
row.append(self.db[mr][job]['PASS'])
|
|
||||||
else:
|
|
||||||
row.append('')
|
|
||||||
if 'FAIL' in self.db[mr][job]:
|
|
||||||
row.append(self.db[mr][job]['FAIL'])
|
|
||||||
else:
|
|
||||||
row.append('')
|
|
||||||
#leave 2 columns for last_pass and last_fail links
|
|
||||||
row.append('')
|
|
||||||
row.append('')
|
|
||||||
else:
|
|
||||||
#4 columns are empty
|
|
||||||
row.append('')
|
|
||||||
row.append('')
|
|
||||||
row.append('')
|
|
||||||
row.append('')
|
|
||||||
|
|
||||||
#insert the final row to worksheet
|
|
||||||
self.sheet.insert_row(row, index=i, value_input_option='RAW')
|
|
||||||
time.sleep(10)
|
|
||||||
|
|
||||||
|
|
||||||
#add MR hyperlinks in a list of requests to be sent as one update batch; this to save API calls (quotas)
|
|
||||||
i=3
|
|
||||||
requests=[]
|
|
||||||
for x in range(len(self.git)):
|
|
||||||
rowIndex=i
|
|
||||||
colIndex=0
|
|
||||||
hyperlink= '\"'+"https://gitlab.eurecom.fr/oai/openairinterface5g/-/merge_requests/"+ str(self.git[x]['iid']) +'\"'
|
|
||||||
text= '\"'+str(self.git[x]['iid'])+'"'
|
|
||||||
requests.append(self.addHyperlink(hyperlink, text, destinationSheetName, rowIndex, colIndex))
|
|
||||||
|
|
||||||
mr=str(self.git[x]['iid'])
|
|
||||||
colIndex=15
|
|
||||||
for t in self.tests:
|
|
||||||
job=self.tests[t]['job']
|
|
||||||
if job in self.db[mr]:
|
|
||||||
if 'last_pass' in self.db[mr][job]:
|
|
||||||
hyperlink= '\"'+ self.db[mr][job]['last_pass'][1] +'\"'
|
|
||||||
text= '\"'+self.db[mr][job]['last_pass'][0]+'"'
|
|
||||||
requests.append(self.addHyperlink(hyperlink, text, destinationSheetName, rowIndex, colIndex))
|
|
||||||
if 'last_fail' in self.db[mr][job]:
|
|
||||||
hyperlink= '\"'+ self.db[mr][job]['last_fail'][1] +'\"'
|
|
||||||
text= '\"'+self.db[mr][job]['last_fail'][0]+'"'
|
|
||||||
requests.append(self.addHyperlink(hyperlink, text, destinationSheetName, rowIndex, colIndex+1))
|
|
||||||
colIndex+=4 #move to next test
|
|
||||||
i=i+1 #increment row index for next MR
|
|
||||||
|
|
||||||
body = {"requests": requests}
|
|
||||||
self.ss.batch_update(body)
|
|
||||||
|
|
||||||
###line 2 is for the test names
|
|
||||||
#add MR hyperlinks in a list of requests to be sent as one update batch; this to save API calls (quotas)
|
|
||||||
requests=[]
|
|
||||||
rowIndex=1
|
|
||||||
colIndex=13
|
|
||||||
for t in self.tests :
|
|
||||||
hyperlink= '\"'+self.tests[t]['link']+'\"'
|
|
||||||
short_name= '\"'+ t +'\"'
|
|
||||||
requests.append(self.addHyperlink(hyperlink, short_name, destinationSheetName, rowIndex, colIndex))
|
|
||||||
colIndex+=4
|
|
||||||
|
|
||||||
body = {"requests": requests}
|
|
||||||
self.ss.batch_update(body)
|
|
||||||
|
|
||||||
|
|
||||||
def addHyperlink(self, hyperlink, text, destinationSheetName, rowIndex, colIndex):
|
|
||||||
sheetId = self.ss.worksheet(destinationSheetName)._properties['sheetId']
|
|
||||||
request =\
|
|
||||||
{
|
|
||||||
"updateCells": {
|
|
||||||
"rows": [
|
|
||||||
{
|
|
||||||
"values": [
|
|
||||||
{
|
|
||||||
"userEnteredValue": {
|
|
||||||
"formulaValue":"=HYPERLINK({},{})".format(hyperlink, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"fields": "userEnteredValue",
|
|
||||||
"start": {
|
|
||||||
"sheetId": sheetId,
|
|
||||||
"rowIndex": rowIndex,
|
|
||||||
"columnIndex": colIndex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return request
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def gFormat(self,sourceSheetName,destinationSheetName): # "Formating" , "MR Status"
|
|
||||||
#the requests are appended in a list of requests to be sent as one update batch; this to save API calls (quotas)
|
|
||||||
#copy formating template
|
|
||||||
sourceSheetId = self.ss.worksheet(sourceSheetName)._properties['sheetId']
|
|
||||||
destinationSheetId = self.ss.worksheet(destinationSheetName)._properties['sheetId']
|
|
||||||
requests=[]
|
|
||||||
requests.append(
|
|
||||||
{
|
|
||||||
"copyPaste": {
|
|
||||||
"source": {
|
|
||||||
"sheetId": sourceSheetId,
|
|
||||||
"startRowIndex": 1,
|
|
||||||
"endRowIndex": 40,
|
|
||||||
"startColumnIndex": 0,
|
|
||||||
"endColumnIndex": 30
|
|
||||||
},
|
|
||||||
"destination": {
|
|
||||||
"sheetId": destinationSheetId,
|
|
||||||
"startRowIndex": 1,
|
|
||||||
"endRowIndex": 40,
|
|
||||||
"startColumnIndex": 0,
|
|
||||||
"endColumnIndex": 30
|
|
||||||
},
|
|
||||||
"pasteType": "PASTE_FORMAT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
#resize columns fit to data, except col 0
|
|
||||||
sheetId = self.ss.worksheet(destinationSheetName)._properties['sheetId']
|
|
||||||
requests.append(
|
|
||||||
{
|
|
||||||
'autoResizeDimensions': {
|
|
||||||
'dimensions': {
|
|
||||||
'sheetId': sheetId,
|
|
||||||
'dimension': 'COLUMNS',
|
|
||||||
'startIndex': 1,
|
|
||||||
'endIndex': 12
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
#resize col 0
|
|
||||||
sheetId = self.ss.worksheet(destinationSheetName)._properties['sheetId']
|
|
||||||
requests.append(
|
|
||||||
{
|
|
||||||
"updateDimensionProperties": {
|
|
||||||
"range": {
|
|
||||||
"sheetId": sheetId,
|
|
||||||
"dimension": "COLUMNS",
|
|
||||||
"startIndex": 0,
|
|
||||||
"endIndex": 1
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"pixelSize": 100
|
|
||||||
},
|
|
||||||
"fields": "pixelSize"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
#resize milestones to be cleaner
|
|
||||||
sheetId = self.ss.worksheet(destinationSheetName)._properties['sheetId']
|
|
||||||
requests.append(
|
|
||||||
{
|
|
||||||
"updateDimensionProperties": {
|
|
||||||
"range": {
|
|
||||||
"sheetId": sheetId,
|
|
||||||
"dimension": "COLUMNS",
|
|
||||||
"startIndex": 6,
|
|
||||||
"endIndex": 11
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"pixelSize": 120
|
|
||||||
},
|
|
||||||
"fields": "pixelSize"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
#group MR related columns
|
|
||||||
# sheetId = self.ss.worksheet(destinationSheetName)._properties['sheetId']
|
|
||||||
# requests.append(
|
|
||||||
# {
|
|
||||||
# "addDimensionGroup": {
|
|
||||||
# "range": {
|
|
||||||
# "dimension": "COLUMNS",
|
|
||||||
# "sheetId": sheetId,
|
|
||||||
# "startIndex": 3,
|
|
||||||
# "endIndex": 12
|
|
||||||
# },
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
# )
|
|
||||||
#
|
|
||||||
body = {"requests": requests}
|
|
||||||
self.ss.batch_update(body)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
my_gDashboard=gDashboard("/opt/dashboard/g_creds.json", 'OAI RAN Dashboard', 'MR Status')
|
|
||||||
my_gDashboard.gBuild("MR Status")
|
|
||||||
my_gDashboard.gFormat("Formating" , "MR Status")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# execute only if run as a script
|
|
||||||
main()
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
LTE-2x2 : #short name used in the dashboard
|
|
||||||
job : 'RAN-LTE-2x2-Module-OAIEPC' #job name from Jenkins, used in the database
|
|
||||||
link : 'https://jenkins-oai.eurecom.fr/view/RAN/job/RAN-LTE-2x2-Module-OAIEPC'
|
|
||||||
bench : 'Obelix-N310-OAIEPC-Quectel(nrmodule2)'
|
|
||||||
test : 'TM1 + TM2, TDD, 40MHz, MCS9, 26Mb DL, 7Mb UL'
|
|
||||||
NSA-B200 :
|
|
||||||
job : 'RAN-NSA-B200-Module-LTEBOX'
|
|
||||||
link : 'https://jenkins-oai.eurecom.fr/view/RAN/job/RAN-NSA-B200-Module-LTEBOX'
|
|
||||||
bench : 'Nepes-B200-Obelix-B200-LTEBOX-Quectel(idefix)'
|
|
||||||
test : '20MHz, 60Mb DL, 3Mb UL'
|
|
||||||
NSA-2x2 :
|
|
||||||
job : 'RAN-NSA-2x2-Module-OAIEPC'
|
|
||||||
link : 'https://jenkins-oai.eurecom.fr/view/RAN/job/RAN-NSA-2x2-Module-OAIEPC'
|
|
||||||
bench : 'Asterix-N310-Obelix-N310-OAIEPC-Quectel(nrmodule2)'
|
|
||||||
test : 'TDD, 40MHz, 60Mb DL, 3Mb UL'
|
|
||||||
SA-N310 :
|
|
||||||
job : 'RAN-SA-Module-CN5G'
|
|
||||||
link : 'https://jenkins-oai.eurecom.fr/view/RAN/job/RAN-SA-Module-CN5G'
|
|
||||||
bench : 'Asterix-N310-OAICN5G-Quectel(nrmodule2)'
|
|
||||||
test : 'TDD, 40MHz, 60Mb DL, 3Mb UL'
|
|
||||||
SA-OAIUE-N310-X300 :
|
|
||||||
job : 'RAN-SA-OAIUE-N310-X300-CN5G'
|
|
||||||
link : 'https://jenkins-oai.eurecom.fr/view/RAN/job/RAN-SA-OAIUE-N310-X300-CN5G/'
|
|
||||||
bench : 'Asterix-N310-OAICN5G-OAIUE-N310'
|
|
||||||
test : 'TDD, 40MHz, Ping, (to be implemented : iperf)'
|
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
The code ran_dashboard.py was initially developped to bring MR status and test resuts to a google sheet, using google sheets API.
|
|
||||||
This method is now deprecated, and replaced by Hdashboard.py that builds the results as HTML page and loads it to AWS S3.
|
|
||||||
ran_dashboard_cfg.yaml is still used by Hdashboard.py as tests config file.
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
#/*
|
|
||||||
# * Licensed to the OpenAirInterface (OAI) Software Alliance under one or more
|
|
||||||
# * contributor license agreements. See the NOTICE file distributed with
|
|
||||||
# * this work for additional information regarding copyright ownership.
|
|
||||||
# * The OpenAirInterface Software Alliance licenses this file to You under
|
|
||||||
# * the OAI Public License, Version 1.1 (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.openairinterface.org/?page_id=698
|
|
||||||
# *
|
|
||||||
# * 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.
|
|
||||||
# *-------------------------------------------------------------------------------
|
|
||||||
# * For more information about the OpenAirInterface (OAI) Software Alliance:
|
|
||||||
# * contact@openairinterface.org
|
|
||||||
# */
|
|
||||||
#---------------------------------------------------------------------
|
|
||||||
# Merge Requests Dashboard for RAN on googleSheet
|
|
||||||
#
|
|
||||||
# Required Python Version
|
|
||||||
# Python 3.x
|
|
||||||
#
|
|
||||||
#---------------------------------------------------------------------
|
|
||||||
|
|
||||||
#author Remi
|
|
||||||
|
|
||||||
|
|
||||||
import pymysql
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
import pickle
|
|
||||||
|
|
||||||
#This is the script/package used by the dashboard to retrieve the MR test results from the database
|
|
||||||
|
|
||||||
class SQLConnect:
|
|
||||||
def __init__(self):
|
|
||||||
self.connection = pymysql.connect(
|
|
||||||
host='172.22.0.2',
|
|
||||||
user='root',
|
|
||||||
password = 'ucZBc2XRYdvEm59F',
|
|
||||||
db='oaicicd_tests',
|
|
||||||
port=3306
|
|
||||||
)
|
|
||||||
self.data={}
|
|
||||||
|
|
||||||
|
|
||||||
#retrieve data from mysql database and organize it in a dictionary (per MR passed as argument)
|
|
||||||
def get(self,MR):
|
|
||||||
self.data[MR]={}
|
|
||||||
cur=self.connection.cursor()
|
|
||||||
|
|
||||||
#get counters per test
|
|
||||||
sql = "select TEST,STATUS, count(*) AS COUNT from test_results where MR=(%s) group by TEST, STATUS;"
|
|
||||||
cur.execute(sql,MR)
|
|
||||||
response=cur.fetchall()
|
|
||||||
if len(response)==0:#no test results yet
|
|
||||||
self.data[MR]['PASS']=''
|
|
||||||
self.data[MR]['FAIL']=''
|
|
||||||
else:
|
|
||||||
for i in range(0,len(response)):
|
|
||||||
test=response[i][0]
|
|
||||||
status=response[i][1]
|
|
||||||
count=response[i][2]
|
|
||||||
if test in self.data[MR]:
|
|
||||||
self.data[MR][test][status]=count
|
|
||||||
else:
|
|
||||||
self.data[MR][test]={}
|
|
||||||
self.data[MR][test][status]=count
|
|
||||||
|
|
||||||
#get last failing build and link
|
|
||||||
sql = "select TEST,BUILD, BUILD_LINK from test_results where MR=(%s) and STATUS='FAIL' order by DATE DESC;"
|
|
||||||
cur.execute(sql,MR)
|
|
||||||
response=cur.fetchall()
|
|
||||||
if len(response)!=0:
|
|
||||||
for i in range(0,len(response)):
|
|
||||||
test=response[i][0]
|
|
||||||
build=response[i][1]
|
|
||||||
link=response[i][2]
|
|
||||||
if 'last_fail' not in self.data[MR][test]:
|
|
||||||
self.data[MR][test]['last_fail']=[]
|
|
||||||
self.data[MR][test]['last_fail'].append(build)
|
|
||||||
self.data[MR][test]['last_fail'].append(link)
|
|
||||||
|
|
||||||
#get last passing build and link
|
|
||||||
sql = "select TEST,BUILD, BUILD_LINK from test_results where MR=(%s) and STATUS='PASS' order by DATE DESC;"
|
|
||||||
cur.execute(sql,MR)
|
|
||||||
response=cur.fetchall()
|
|
||||||
if len(response)!=0:
|
|
||||||
for i in range(0,len(response)):
|
|
||||||
test=response[i][0]
|
|
||||||
build=response[i][1]
|
|
||||||
link=response[i][2]
|
|
||||||
if 'last_pass' not in self.data[MR][test]:
|
|
||||||
self.data[MR][test]['last_pass']=[]
|
|
||||||
self.data[MR][test]['last_pass'].append(build)
|
|
||||||
self.data[MR][test]['last_pass'].append(link)
|
|
||||||
|
|
||||||
|
|
||||||
#close database connection
|
|
||||||
def close_connection(self):
|
|
||||||
self.connection.close()
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
body {
|
|
||||||
font-family: 'lato', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Main {
|
|
||||||
text-align: left;
|
|
||||||
font-size: 30px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.DashLink {
|
|
||||||
text-align:left;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.DashLink:hover {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.Date {
|
|
||||||
text-align: left;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a { text-decoration: none; }
|
|
||||||
a:visited { text-decoration: none; color:blue}
|
|
||||||
a:hover { text-decoration: none; font-weight: bold; color:blue}
|
|
||||||
|
|
||||||
|
|
||||||
.Test_Table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Test_Table tr {
|
|
||||||
font-size: 12px;
|
|
||||||
text-align:center;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.Test_Table tr:hover {
|
|
||||||
background-color:lightgray;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Test_Table td {
|
|
||||||
border: 1px solid black;
|
|
||||||
padding : 5px;
|
|
||||||
}
|
|
||||||
.Test_Table td:hover {
|
|
||||||
font-weight : bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Test_Table th {
|
|
||||||
font-size: 14px;
|
|
||||||
text-align:center;
|
|
||||||
padding : 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.Test_Name {
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 100px;
|
|
||||||
background-color: rgb(143, 154, 216);
|
|
||||||
}
|
|
||||||
|
|
||||||
.Test_Descr {
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 400px;
|
|
||||||
background-color: rgb(143, 154, 216);
|
|
||||||
}
|
|
||||||
|
|
||||||
.Pass {
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 100px;
|
|
||||||
background-color: rgb(143, 154, 216);
|
|
||||||
}
|
|
||||||
|
|
||||||
.Fail {
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 100px;
|
|
||||||
background-color: rgb(143, 154, 216);
|
|
||||||
}
|
|
||||||
|
|
||||||
.Last_Pass {
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 100px;
|
|
||||||
background-color: rgb(143, 154, 216);
|
|
||||||
}
|
|
||||||
|
|
||||||
.Last_Fail {
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 100px;
|
|
||||||
background-color: rgb(143, 154, 216);
|
|
||||||
}
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user